chore: remove local planning docs from pr

This commit is contained in:
fanghanjun
2026-04-27 23:27:40 -07:00
parent 30f15a73ac
commit 39f20a005b
4 changed files with 0 additions and 2557 deletions
-52
View File
@@ -1,52 +0,0 @@
# AGENTS.md
InkOS 专业 agent 设计采用渐进式披露:先读 `docs/agents/agent-extraction-guide.md`,再按需深入 `docs/agents/<AgentName>.md`
默认使用中文回答问题,并使用中文撰写计划和 SPEC 文件。
InkOS pi-agent 持久化模块设计在 `docs/superpowers/specs/2026-04-27-pi-agent-jsonl-persistence-design.md`,需要了解 JSONL transcript、requestId、toolResult、thinking、cache 和 legacy migration 时先读该文件。
## 回答结构规范
回答技术问题时要体现清晰的思考过程,而非堆叠语义相近的描述。
1. **先抽象分类**:先把问题归纳成少数几个大类,说明每一类解决什么核心矛盾。
2. **再建立推演链路**:按“问题背景 -> 关键数据结构 -> 执行步骤 -> 结果影响”的顺序解释机制。
3. **用证据支撑判断**:涉及源码时给出关键函数、字段、调用链或文件位置,让结论可以被验证。
4. **解释因果关系**:说明某个设计为什么能产生对应结果,以及缺少这个设计会触发什么失败场景。
5. **避免空泛对比**:不要用简单二分对比替代解释;对比只能作为结论,不能作为证明过程。
6. **控制信息密度**:每一层只放支撑当前结论所需的信息,细节服务于推理,不做无目的罗列。
## TypeScript 代码实践
回答或改写 TypeScript 代码时,先说明数据如何从“不可信输入”变成“可信领域对象”。每个判断都要回答四件事:它消除什么非法状态;由哪个类型、schema 或函数保证;下游因此获得什么前置条件;缺少它会触发什么失败场景。
### 1. 核心分类
1. **边界收敛**:外部输入不可信。文件、网络、JSON、数据库、第三方库返回值可以先是 `unknown`,但必须在模块边界通过 schema、parser 或 type guard 收窄。
2. **领域建模**:业务状态要可区分。字段集合随 `role``type``kind` 改变时,用 discriminated union,不用 optional 字段堆出一个宽接口。
3. **合法状态约束落地**:系统声称某份数据合法时必须满足的规则要有归属。单个对象自身就能判断的规则,放进类型或 schema;必须结合事件顺序、上下文或外部状态才能判断的规则,放进命名清楚的校验、转换或清理函数。
4. **演进验证**:新增分支不能静默漏处理。union 要穷尽处理,非法状态要有测试覆盖。
### 2. 推演链路
解释 TypeScript 代码时按这条链路展开:
```text
问题背景:哪些输入不可信,或哪些业务状态容易混在一起。
关键数据结构:raw input、validated event、domain message、cleaned message 分别是什么。
执行步骤:哪个函数负责读取,哪个函数负责收窄,哪个函数负责合法状态校验,哪个函数返回可信结果。
结果影响:下游少做哪些重复判断;缺少该设计时,非法状态会在哪里爆炸。
```
例如 transcript 恢复:`readTranscriptEvents()` 读取并解析事件,`committedMessageEvents()` 排除未提交请求,`cleanRestoredAgentMessages()` 清理孤立 `toolResult`、空 assistant 和非法 trailing thinking。推理重点不是“调用了这些函数”,而是这些函数分别阻断了哪些非法历史进入模型上下文。
### 3. 代码准则
1. 宽类型止于边界:`unknown``any``Record<string, unknown>` 不进入核心业务流程。
2. 互斥状态用 union:不要用大量 optional 字段模拟状态机。
3. 合法状态约束集中实现:不要把“必须 committed”“必须有对应 toolCall”“assistant 不能为空”散落成临时 `if`
4. 类型断言要有证据:`as SomeType` 前面必须有 schema、parser 或 guard 支撑。
5. 分支处理要穷尽:用 `switch`、明确窄化和 `assertNever` 暴露新增分支。
6. 函数名表达可信度:`parse*` 表示可能失败,`normalize*` 表示转换形状,`clean*` 表示执行合法性修复。
7. 测试覆盖非法状态:恢复、迁移、IO、LLM message、tool loop 必须测坏数据、缺字段、未提交请求、孤立 `toolResult`、空 assistant、trailing thinking。
@@ -1,630 +0,0 @@
# Agent Native Google 与模型切换上下文修复计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
## 目标
修复 InkOS pi-agent 在 Google Gemini / DeepSeek / MiniMax 等模型之间切换时的上下文污染、工具调用失败和错误信息失真问题。
## 执行记录
2026-04-27 已执行:
- Google service 默认调用协议已切到 pi-ai native `google-generative-ai`
- Agent cache identity 已从 `modelId` 升级为 `api + provider + baseUrl + modelId`
- JSONL transcript 继续保存 raw `AgentMessage`;恢复时按目标模型协议投影上下文。
- 同模型同协议保留 native thinking/tool history;跨模型/跨协议把旧工具结果文本化为普通 user 语义上下文。
- Studio `/api/v1/agent` 已优先透传 final assistant error,避免被 fallback probe 改写成 `LLM returned empty response`
验证结果:
- `pnpm --filter @actalk/inkos-core exec vitest run src/__tests__/service-resolver.test.ts src/__tests__/provider.test.ts src/__tests__/effective-llm-config.test.ts src/__tests__/config-loader.test.ts` 通过。
- `pnpm --filter @actalk/inkos-core exec vitest run src/__tests__/session-transcript-restore.test.ts src/__tests__/agent-session.test.ts` 通过。
- `pnpm --filter @actalk/inkos-studio exec vitest run src/api/server.test.ts` 通过。
- `pnpm --filter @actalk/inkos-core typecheck` 通过。
- `pnpm --filter @actalk/inkos-studio typecheck` 通过。
- `git diff --check` 通过。
- 真实 `createLLMClient + chatCompletion``google/gemini-pro-latest` 三次,3/3 HTTP success3/3 包含 `【完整结束】`
- 真实 Studio agent + Gemini 工具调用普通提示三次,3/3 HTTP 200,未出现 XML `<function-calls>` 泄漏,JSONL 中 assistant tool call 为 `api: "google-generative-ai"` / `provider: "google"`
- 严格“最终回复必须完全等于固定字符串”的工具提示下,观测到 1 次 native Google provider 返回 `An unknown error occurred`transcript 显示这是 pi-ai Google provider 在 `stopReason: "error"` 且无内容时给出的 generic upstream error,不是 JSONL 恢复污染,也不是 OpenAI-compatible `MALFORMED_FUNCTION_CALL`
根因分两层:
1. **Google 传输协议选错**pi-ai 已有 native `google-generative-ai` provider,但 InkOS 当前 `google` service 走 `openai-completions + https://generativelanguage.googleapis.com/v1beta/openai`。这会让 Gemini 工具调用落在 OpenAI-compatible 适配层,现场已复现 `function_call_filter: MALFORMED_FUNCTION_CALL`、XML `<function-calls>` 文本泄漏和空 assistant。
2. **Agent cache identity 太窄**:当前 cache 只比较 `modelId`。同一个 `gemini-pro-latest` 从 OpenAI-compatible 切到 native Google 时,模型 id 不变,但 `api/provider/baseUrl` 已经变了,继续复用旧 Agent 会保留错误协议的内存态。
最终状态:
- Google service 默认走 pi-ai native `google-generative-ai`
- Agent cache 使用完整模型协议身份:`api + provider + baseUrl + modelId`
- JSONL transcript 继续保存 raw `AgentMessage`
- 恢复上下文时,同一完整模型协议身份保留原生 history;跨协议/跨模型只保留语义文本。
- OpenAI-compatible Gemini 只作为 legacy/custom fallback,不再作为 InkOS Google 默认路径。
- Studio API 透传 pi-agent-core / pi-ai 的真实上游错误,不再全部折叠成 `LLM returned empty response`
## 架构边界
本计划只处理 Studio agent chat 这条链路:
`Studio /api/v1/agent -> resolveServiceModel/createLLMClient -> runAgentSession -> pi-agent-core Agent -> pi-ai streamSimple -> JSONL transcript`
不处理专业 agent pipeline 的写作质量策略,不调整章节生成的业务 prompt。
## 关键设计
### 1. Google Provider 默认 native
当前事实:
- pi-ai `@mariozechner/pi-ai` 已注册 `api: "google-generative-ai"`
- `getModel("google", "gemini-2.5-flash")` 返回:
- `api: "google-generative-ai"`
- `provider: "google"`
- `baseUrl: "https://generativelanguage.googleapis.com/v1beta"`
- InkOS 当前 `packages/core/src/llm/providers/endpoints/google.ts` 仍配置为 `openai-completions``/v1beta/openai`
修改原则:
- `google` endpoint 的主调用协议改为 native `google-generative-ai`
- `modelsBaseUrl` 可以继续指向 OpenAI-compatible `/v1beta/openai`,只服务模型列表探测;模型调用必须走 native baseUrl。
- 不新增 `maxTokensField` 之类 OpenAI-compatible 参数。`maxTokens` 仍由 model card 的 `maxOutput` 决定。
### 2. Cache Identity 从 modelId 升级为模型协议身份
旧规则:
```ts
cached.modelId !== requestedModelId
```
新规则:
```ts
agentModelIdentity(model) =
`${model.api}::${model.provider}::${model.baseUrl ?? ""}::${model.id}`
```
推导:
1. `modelId` 只描述模型名称。
2. agent 的 LLM 调用行为由 `api/provider/baseUrl/modelId` 共同决定。
3. Google 从 OpenAI-compatible 切到 native 后,`modelId` 不变,`api/baseUrl/provider` 改变。
4. cache 比较必须覆盖所有影响 LLM message schema 的字段。
### 3. Transcript 恢复只在同协议身份下保留原生状态
保留规则:
- `assistant.api === target.api`
- `assistant.provider === target.provider`
- `assistant.model === target.id`
跨协议/跨模型规则:
- `user` 文本保留。
- assistant 可见 `text` 保留。
- `thinking` 丢弃,尤其是 DeepSeek `reasoning_content` 和 Gemini thought signature。
- `toolCall + toolResult` 合并为普通 `user` 上下文文本,例如 `[Tool results] read(tool-1): ...`
- synthetic bridge 文本不进入最终 LLM context。
原因:
1. thinking / reasoning signature 是 provider 协议状态,不是可跨 provider 携带的语义。
2. tool call id 只在对应 assistant tool call 的协议回合里有意义。
3. 跨模型恢复的目标是保存“模型已经看过哪些工具结果”的语义,不恢复旧协议的 pending state。
### 4. OpenAI-compatible Gemini 只保留 legacy 防线
`convertAgentMessagesForModel()``baseUrl.includes("generativelanguage.googleapis.com") && api === "openai-completions"` 的文本化逻辑可以保留,但它是 fallback:
- 用于旧 transcript、custom provider 或临时回滚。
- native Google 不应该触发这层文本化。
- 真实工具调用由 pi-ai Google provider 的 `part.functionCall -> toolCall` 处理。
## 文件结构
- Modify: `packages/core/src/llm/providers/types.ts`
- `ApiProtocol` 增加 `google-generative-ai`
- Modify: `packages/core/src/llm/providers/endpoints/google.ts`
- Google 主协议切到 native。
- 保留 `modelsBaseUrl` 或测试中明确 fallback 行为。
- Modify: `packages/core/src/llm/service-presets.ts`
- `resolveServicePiProvider("google")` 返回 `google`
- Modify: `packages/core/src/llm/service-resolver.ts`
- `resolveServiceModel("google", ...)` 产出 native `Model<Api>`
- OpenAI-compatible compat 只在 `api === "openai-completions"` 时注入。
- Modify: `packages/core/src/llm/provider.ts`
- `createLLMClient()` 构造的 Google `_piModel` 使用 native `api/provider/baseUrl`
- Modify: `packages/core/src/agent/agent-session.ts`
- cache identity 使用完整模型协议身份。
- legacy Gemini OpenAI-compatible `convertToLlm` 只作为 fallback。
- final assistant error 继续透传。
- Modify: `packages/core/src/interaction/session-transcript-restore.ts`
- 按目标模型身份恢复 raw transcript。
- Modify: `packages/studio/src/api/server.ts`
- `/api/v1/agent` 使用 resolved native Google model。
- 保留真实错误返回。
- Tests:
- `packages/core/src/__tests__/service-resolver.test.ts`
- `packages/core/src/__tests__/provider.test.ts`
- `packages/core/src/__tests__/effective-llm-config.test.ts`
- `packages/core/src/__tests__/config-loader.test.ts`
- `packages/core/src/__tests__/agent-session.test.ts`
- `packages/core/src/__tests__/session-transcript-restore.test.ts`
- `packages/studio/src/api/server.test.ts`
---
## Task 1: 用测试锁定 Google native provider
**Files:**
- Modify: `packages/core/src/__tests__/service-resolver.test.ts`
- Modify: `packages/core/src/__tests__/provider.test.ts`
- Modify: `packages/core/src/__tests__/effective-llm-config.test.ts`
- Modify: `packages/core/src/__tests__/config-loader.test.ts`
- [ ] **Step 1: service resolver 测试 Google native identity**
更新 `resolveServiceModel("google", "gemini-pro-latest", root)` 断言:
```ts
expect(result.model.api).toBe("google-generative-ai");
expect(result.model.provider).toBe("google");
expect(result.model.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
```
同时断言不再带 OpenAI-compatible compat
```ts
expect(result.model.compat).toBeUndefined();
```
- [ ] **Step 2: provider client 测试 Google native `_piModel`**
`provider.test.ts` 覆盖:
```ts
const client = createLLMClient({
service: "google",
provider: "openai",
model: "gemini-pro-latest",
apiKey: "test",
});
expect(client._piModel?.api).toBe("google-generative-ai");
expect(client._piModel?.provider).toBe("google");
expect(client._piModel?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
```
- [ ] **Step 3: 更新配置类测试的 baseUrl 期望**
`effective-llm-config.test.ts``config-loader.test.ts` 里 Google service 的默认 baseUrl 期望从:
```ts
https://generativelanguage.googleapis.com/v1beta/openai
```
改为:
```ts
https://generativelanguage.googleapis.com/v1beta
```
- [ ] **Step 4: 运行失败测试**
```bash
pnpm --filter @actalk/inkos-core exec vitest run \
src/__tests__/service-resolver.test.ts \
src/__tests__/provider.test.ts \
src/__tests__/effective-llm-config.test.ts \
src/__tests__/config-loader.test.ts
```
Expected before implementation: Google 相关断言失败。
---
## Task 2: 实现 Google native provider
**Files:**
- Modify: `packages/core/src/llm/providers/types.ts`
- Modify: `packages/core/src/llm/providers/endpoints/google.ts`
- Modify: `packages/core/src/llm/service-presets.ts`
- Modify: `packages/core/src/llm/service-resolver.ts`
- Modify: `packages/core/src/llm/provider.ts`
- [ ] **Step 1: 扩展 InkOS provider schema**
`ApiProtocol` 增加:
```ts
| "google-generative-ai"
```
- [ ] **Step 2: 修改 Google endpoint**
`GOOGLE` endpoint 改为:
```ts
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
modelsBaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
```
删除 Google endpoint 上 OpenAI-compatible 专用的:
```ts
compat: { supportsStore: false, requiresAssistantAfterToolResult: true }
```
该 compat 只属于 `/v1beta/openai`,不属于 native Google。
- [ ] **Step 3: 修正 service -> pi provider 映射**
`resolveServicePiProvider("google")` 必须返回:
```ts
"google"
```
`SERVICE_TO_PI_PROVIDER` 也要包含:
```ts
google: "google"
```
- [ ] **Step 4: 修正 `createLLMClient()` 的 pi provider 选择**
`packages/core/src/llm/provider.ts` 当前默认会把非特殊 provider 映射成 `openai`。增加 Google 分支:
```ts
if (inkosProvider?.id === "google") piProvider = "google";
```
该分支必须早于默认 `else piProvider = provider`
- [ ] **Step 5: 修正 `resolveServiceModel()` 的 model 合成**
`resolveServiceModel()` 对 Google 应优先使用:
```ts
getModel("google", modelId)
```
如果 pi-ai 内置 model 能命中,保留它的 `reasoning/input/contextWindow/maxTokens`,再覆盖 `id/name/baseUrl` 以匹配 InkOS provider bank。
- [ ] **Step 6: 运行 Task 1 测试**
```bash
pnpm --filter @actalk/inkos-core exec vitest run \
src/__tests__/service-resolver.test.ts \
src/__tests__/provider.test.ts \
src/__tests__/effective-llm-config.test.ts \
src/__tests__/config-loader.test.ts
```
Expected: pass。
- [ ] **Step 7: Commit**
```bash
git add \
packages/core/src/llm/providers/types.ts \
packages/core/src/llm/providers/endpoints/google.ts \
packages/core/src/llm/service-presets.ts \
packages/core/src/llm/service-resolver.ts \
packages/core/src/llm/provider.ts \
packages/core/src/__tests__/service-resolver.test.ts \
packages/core/src/__tests__/provider.test.ts \
packages/core/src/__tests__/effective-llm-config.test.ts \
packages/core/src/__tests__/config-loader.test.ts
git commit -m "fix: use native google provider"
```
---
## Task 3: 升级 Agent cache identity
**Files:**
- Modify: `packages/core/src/agent/agent-session.ts`
- Modify: `packages/core/src/__tests__/agent-session.test.ts`
- [ ] **Step 1: 写 cache identity 回归测试**
覆盖三种情况:
1.`sessionId + bookId + readPermission + api/provider/baseUrl/modelId`:复用 Agent。
2.`modelId``api``openai-completions` 变为 `google-generative-ai`:重建 Agent。
3.`modelId/api``baseUrl` 改变:重建 Agent。
断言方式:
- 用 mock `streamSimple` 记录 `context.model` 或构造时状态。
- 或检查 `streamCalls` 中第二次请求没有继承第一次 Agent 的旧 messages。
- [ ] **Step 2: 实现 `agentModelIdentity()`**
`agent-session.ts` 中增加局部函数:
```ts
function agentModelIdentity(model: Model<Api>): string {
return [
model.api,
model.provider,
model.baseUrl ?? "",
model.id,
].join("::");
}
```
- [ ] **Step 3: cache value 保存完整 identity**
`CachedAgent` 中的:
```ts
modelId: string | undefined;
```
替换为:
```ts
modelIdentity: string;
```
创建 Agent 前先解析本次 `model`,用 `agentModelIdentity(model)` 比较 cache。
- [ ] **Step 4: 保留原有 book/readPermission 失效规则**
cache 失效条件为:
```ts
modelIdentityChanged || bookChanged || readPermissionChanged
```
- [ ] **Step 5: 运行 agent-session 测试**
```bash
pnpm --filter @actalk/inkos-core exec vitest run src/__tests__/agent-session.test.ts
```
- [ ] **Step 6: Commit**
```bash
git add packages/core/src/agent/agent-session.ts packages/core/src/__tests__/agent-session.test.ts
git commit -m "fix: key agent cache by model protocol"
```
---
## Task 4: 保留 raw JSONL,并按目标协议恢复上下文
**Files:**
- Modify: `packages/core/src/interaction/session-transcript-restore.ts`
- Modify: `packages/core/src/__tests__/session-transcript-restore.test.ts`
- Modify: `packages/core/src/agent/agent-session.ts`
- Modify: `packages/core/src/__tests__/agent-session.test.ts`
- [ ] **Step 1: 测同协议保留 native Google tool/thought**
构造 assistant
```ts
{
role: "assistant",
api: "google-generative-ai",
provider: "google",
model: "gemini-pro-latest",
content: [
{ type: "thinking", thinking: "plan", thinkingSignature: "google-signature" },
{ type: "toolCall", id: "tool-1", name: "ls", arguments: { subdir: "story/roles" } },
],
}
```
目标模型同为 `api/provider/id` 时,恢复结果必须保留原始 blocks。
- [ ] **Step 2: 测跨协议文本化**
用旧 OpenAI-compatible Gemini assistant
```ts
api: "openai-completions",
provider: "openai",
model: "gemini-pro-latest",
content: [{ type: "toolCall", id: "tool-1", name: "ls", arguments: {} }]
```
目标模型是 native Google 或 DeepSeek 时:
- 不出现 `toolCall`
- 不出现 `toolResult` role。
- 出现 `[Tool results]` user 文本。
- [ ] **Step 3: 测 DeepSeek reasoning 不跨到 Google**
DeepSeek 历史中 `thinkingSignature: "reasoning_content"`,目标模型为 native Google 时:
- `reasoning_content` 不进入目标 context。
- assistant 可见 text 保留。
- 工具结果文本化。
- [ ] **Step 4: 实现恢复投影**
恢复规则:
- `isSameAssistantModel(message, target)` 只比较 `api/provider/model`
- 同模型:保留 raw assistant/toolResult。
- 异模型:丢弃 thinking,保留 text,工具结果合并为 user 文本。
- synthetic bridge 文本过滤。
注意:不要把只服务单一线性流程的步骤拆成多个无复用价值的顶层 helper;保持函数内局部 helper。
- [ ] **Step 5: legacy OpenAI-compatible Gemini runtime projection 只作为 fallback**
`agent-session.ts` 中如果保留 `convertAgentMessagesForModel()`,触发条件必须严格为:
```ts
model.api === "openai-completions" &&
model.baseUrl?.includes("generativelanguage.googleapis.com")
```
native Google 不触发该分支。
- [ ] **Step 6: 运行恢复与 agent 测试**
```bash
pnpm --filter @actalk/inkos-core exec vitest run \
src/__tests__/session-transcript-restore.test.ts \
src/__tests__/agent-session.test.ts
```
- [ ] **Step 7: Commit**
```bash
git add \
packages/core/src/interaction/session-transcript-restore.ts \
packages/core/src/__tests__/session-transcript-restore.test.ts \
packages/core/src/agent/agent-session.ts \
packages/core/src/__tests__/agent-session.test.ts
git commit -m "fix: project restored history by model protocol"
```
---
## Task 5: 真实错误透传
**Files:**
- Modify: `packages/core/src/agent/agent-session.ts`
- Modify: `packages/core/src/__tests__/agent-session.test.ts`
- Modify: `packages/studio/src/api/server.ts`
- Modify: `packages/studio/src/api/server.test.ts`
- [ ] **Step 1: `AgentSessionResult` 保留 final assistant error**
如果最终 assistant
```ts
stopReason === "error" || stopReason === "aborted"
```
且带 `errorMessage``runAgentSession()` 返回:
```ts
errorMessage
```
- [ ] **Step 2: Studio API 优先返回真实错误**
`/api/v1/agent` 中,如果:
```ts
!result.responseText && result.errorMessage
```
返回:
```ts
status 502
error.code = "AGENT_LLM_ERROR"
response = result.errorMessage
```
该分支必须早于 fallback probe 和 `LLM returned empty response`
- [ ] **Step 3: 覆盖真实错误测试**
测试至少覆盖:
- `400 The reasoning_content ... must be passed back`
- `Provider finish_reason: function_call_filter: MALFORMED_FUNCTION_CALL`
- [ ] **Step 4: Commit**
```bash
git add \
packages/core/src/agent/agent-session.ts \
packages/core/src/__tests__/agent-session.test.ts \
packages/studio/src/api/server.ts \
packages/studio/src/api/server.test.ts
git commit -m "fix: surface agent upstream errors"
```
---
## Task 6: 验证真实 Google 场景
**Files:**
- No code changes expected.
- [ ] **Step 1: 单纯 Google 文本流三次**
使用 InkOS `createLLMClient + chatCompletion``google/gemini-pro-latest` 三次,提示词要求输出结束标记:
```text
【完整结束】
```
Expected:
- 3/3 HTTP success。
- 3/3 包含结束标记。
- 不出现 `LLM returned empty response from stream`
- [ ] **Step 2: Studio agent + Gemini 工具调用三次**
对新 session 发送三次:
```text
请调用工具查看当前书的 story/roles 目录。工具调用完成后,只回复:Google agent 测试通过。【完整结束】
```
Expected:
- 3/3 HTTP 200。
- 3/3 包含结束标记。
- 不出现 XML `<function-calls>`
- 不出现 `MALFORMED_FUNCTION_CALL`
- JSONL 中 assistant tool call 的 `api``google-generative-ai``provider``google`
- [ ] **Step 3: 污染 transcript 恢复验证**
用旧 session `1777003372863-8uq0sb` 或构造等价 fixture,包含:
- 旧 Gemini OpenAI-compatible `toolCall/toolResult`
- DeepSeek `reasoning_content`
- 空 error assistant
- XML `<function-calls>` 文本
分别切到:
- native Google
- DeepSeek
Expected:
- 请求不因为旧 thinking/tool protocol 400。
- 恢复 context 中跨协议工具结果是普通 user 文本。
- 旧 XML 文本不被当成可执行 tool call。
- [ ] **Step 4: 类型检查和 diff 检查**
```bash
pnpm --filter @actalk/inkos-core typecheck
pnpm --filter @actalk/inkos-studio typecheck
git diff --check
```
---
## Self-Review Checklist
- [ ] `google` service 默认 `api``google-generative-ai`
- [ ] `google` service 默认 `provider``google`
- [ ] `google` service 默认调用 baseUrl 是 `https://generativelanguage.googleapis.com/v1beta`
- [ ] OpenAI-compatible `/v1beta/openai` 不再用于默认 Gemini agent 调用。
- [ ] Agent cache identity 覆盖 `api/provider/baseUrl/modelId`
- [ ] 同协议同模型恢复保留 native thinking/tool history。
- [ ] 跨协议恢复不传递 DeepSeek `reasoning_content` 或 Gemini thought signature。
- [ ] 跨协议工具结果降级为普通 user 语义文本。
- [ ] `LLM returned empty response` 不再吞掉真实上游错误。
- [ ] 真实 Gemini 工具调用三次稳定通过。
File diff suppressed because it is too large Load Diff
@@ -1,377 +0,0 @@
# InkOS Pi-Agent JSONL 持久化设计
状态:已确认进入 SPEC
日期:2026-04-27
## 1. 目标
InkOS pi-agent session 持久化收敛为一层:追加式 JSONL transcript,路径为 `.inkos/sessions/{sessionId}.jsonl`
transcript 是 agent 历史的唯一持久事实源。UI session 对象、聊天消息、thinking 展示、工具执行面板、session 列表摘要、恢复后的 `Agent.state.messages` 都从 transcript 派生。
这个设计替换当前两层行为:`BookSession` JSON 持久化 UI 形状的消息,`agentCache` 在短时间内保存更完整的内存态 `AgentMessage` 历史。
## 2. 源码对齐
### 2.1 Claude Code Main
Claude Code 用 JSONL transcript entry 存储对话历史。恢复主链围绕 `uuid``parentUuid``sessionId` 展开,没有把 transcript 级 `turnId` 作为恢复主结构。
关键源码位置:
- `/Users/fanghanjun/claude-code-main/src/types/logs.ts``SerializedMessage` 携带 `sessionId``timestamp``version``cwd` 等元数据。
- `/Users/fanghanjun/claude-code-main/src/types/logs.ts``TranscriptMessage` 增加 `parentUuid``isSidechain` 等图结构字段。
- `/Users/fanghanjun/claude-code-main/src/utils/sessionStorage.ts``insertMessageChain()` 写入消息并分配 `parentUuid`
- `/Users/fanghanjun/claude-code-main/src/utils/sessionStorage.ts`tool result 通过 `sourceToolAssistantUUID` 绑定到发出 tool use 的 assistant message。
- `/Users/fanghanjun/claude-code-main/src/utils/sessionStorage.ts``buildConversationChain()` 从 leaf message 沿 `parentUuid` 回溯恢复主链。
- `/Users/fanghanjun/claude-code-main/src/utils/sessionStorage.ts``recoverOrphanedParallelToolResults()` 修复并行工具调用导致的 sibling assistant/tool-result 分支遗漏。
Claude Code 在 query 和 compact tracking 中有 `turnId`,但 transcript 恢复不依赖它。InkOS 应采用同类结构:用图字段和顺序字段支撑持久恢复,用 request 级标识做分组和诊断。
### 2.2 pi-agent-core
pi-agent-core 有内部 `turn_start` / `turn_end` 事件。这个 turn 边界表示一次 assistant response cycle,粒度小于一次用户请求。
一次 `agent.prompt()` 可以包含多个 pi-agent turn
1. user prompt 进入 `currentContext.messages`
2. assistant 生成 `toolCall`
3. 工具执行返回 `toolResult` message。
4. `toolResult` 被 push 进 `currentContext.messages`
5. 下一次 assistant response 带着 tool result 继续运行。
关键源码位置:
- `node_modules/.../@mariozechner/pi-agent-core/dist/agent-loop.js``runAgentLoop()` 构造 `currentContext.messages = [...context.messages, ...prompts]`
- `node_modules/.../@mariozechner/pi-agent-core/dist/agent-loop.js``runLoop()` 围绕每次 assistant response cycle 发出 `turn_start``turn_end`
- `node_modules/.../@mariozechner/pi-agent-core/dist/agent-loop.js``emitToolCallOutcome()` 构造 `role: "toolResult"` message。
- `node_modules/.../@mariozechner/pi-agent-core/dist/agent.js``processEvents()` 在每个 `message_end` 时把 message push 到 `Agent.state.messages`
InkOS 外层用户请求字段命名为 `requestId`。这样可以让 pi-agent-core 的内部 turn 语义继续保留给 `piTurnIndex`
## 3. 核心决策
1. 使用 JSONL,一行一个事件。
2. 使用 `.inkos/sessions/{sessionId}.jsonl` 作为 canonical path。
3. 保留 `.inkos/sessions/{sessionId}.json` 的 legacy 读取能力。
4. 新 session 和已迁移 session 停止写完整 `BookSession` JSON。
5. 持久化原始 `AgentMessage`,避免写入压平后的 UI message。
6. 使用 `requestId` 表示一次 InkOS 用户请求。
7. 使用可选 `piTurnIndex` 表示 pi-agent-core 内部 turn 分组。
8. 使用 `uuid``parentUuid``seq` 支撑持久排序和未来图恢复。
9. 使用 `request_committed` 作为恢复栅栏。
10. UI 状态从 transcript event 派生,不单独持久化 `UiMessageEvent`
## 4. 事件模型
### 4.1 事件联合类型
```ts
type TranscriptEvent =
| SessionCreatedEvent
| SessionMetadataUpdatedEvent
| RequestStartedEvent
| MessageEvent
| RequestCommittedEvent
| RequestFailedEvent
```
### 4.2 消息事件
```ts
type MessageEvent = {
type: "message"
version: 1
sessionId: string
requestId: string
uuid: string
parentUuid: string | null
seq: number
role: "user" | "assistant" | "toolResult" | "system"
timestamp: number
piTurnIndex?: number
toolCallId?: string
sourceToolAssistantUuid?: string
legacyDisplay?: {
thinking?: string
toolExecutions?: unknown[]
}
message: AgentMessage
}
```
`message` 必须保留 pi-agent-core 原始 `AgentMessage`。assistant 的 `thinking``text``toolCall` content block,以及 `toolResult.content``toolResult.details``toolResult.isError` 都必须完整写入。
`legacyDisplay` 只服务于旧 JSON 迁移后的 UI 派生,例如旧 `InteractionMessage.thinking`。它不进入模型恢复路径,避免把没有 provider signature 的旧 thinking 伪造成可回放给模型的 signed thinking block。
### 4.3 请求事件
```ts
type RequestStartedEvent = {
type: "request_started"
version: 1
sessionId: string
requestId: string
seq: number
timestamp: number
input: string
}
type RequestCommittedEvent = {
type: "request_committed"
version: 1
sessionId: string
requestId: string
seq: number
timestamp: number
}
type RequestFailedEvent = {
type: "request_failed"
version: 1
sessionId: string
requestId: string
seq: number
timestamp: number
error: string
}
```
`request_committed` 是恢复时使用的 durable boundary。`request_started` 后面的 message event 只有在匹配的 `request_committed` 存在时才进入模型恢复。
## 5. 写入路径
写入路径在 `runAgentSession()` 内订阅 pi-agent-core event。
每次 `/api/v1/agent` 请求执行以下步骤:
1. 分配 `requestId`
2. append `request_started`
3. 执行 `agent.prompt(instruction)`
4. 每次收到 pi-agent-core `message_end` 时 append 一个 `message` event。
5. 根据 pi-agent-core `turn_start` / `turn_end` 在内存中维护 `piTurnIndex`
6. `agent.prompt()` 正常结束后 append `request_committed`
7. 执行失败或中断时 append `request_failed`
带工具调用的请求会形成这类事件序列:
```text
request_started
message(user)
message(assistant: thinking + toolCall)
message(toolResult)
message(assistant: thinking + text)
request_committed
```
writer 必须按 session 串行化 append。第一版使用进程内 per-session queue 即可满足 Studio 单 Node 进程模型。每次 append 写入一个 JSON object,再写入 `\n`
## 6. 恢复路径
恢复路径从 JSONL transcript 构造 `Agent.state.messages`
算法:
1. 读取 `.inkos/sessions/{sessionId}.jsonl`
2. 按文件顺序解析合法 JSONL event。
3. 构造已 committed 的 `requestId` 集合。
4. 收集 `requestId` 已 committed 的 `message` event。
5.`seq` 排序。
6. 执行模型合法性清理。
7. 返回 `messageEvent.message[]`
8.`agent.prompt()` 前赋值给 `agent.state.messages`
第一版使用 committed `seq` 顺序恢复。schema 同时写入 `uuid``parentUuid``toolCallId``sourceToolAssistantUuid`,后续可以增加 Claude Code 风格 leaf recovery,文件格式无需迁移。
## 7. thinking 持久化
thinking 必须作为 assistant 原始 content 持久化,不能只存 UI 字符串。
正确 transcript 数据形状:
```ts
{
role: "assistant",
content: [
{ type: "thinking", thinking: "...", signature: "..." },
{ type: "text", text: "..." }
]
}
```
UI thinking 面板从 assistant thinking block 派生。
模型恢复使用合法性清理后的原始 assistant message。清理需要覆盖:
1. 丢弃没有有效 sibling assistant content 的孤立 thinking-only assistant message。
2. 当 provider 拒绝以 thinking 结尾的 history 时,从最后一条 assistant message 移除 trailing thinking block。
3. 当 model/provider/auth fallback 使 signature 失效时,移除带 signature 的 thinking block。
legacy `.json` session 可能有 `InteractionMessage.thinking`,但该字符串没有 provider signature。迁移可以保留它用于 UI 展示,不能把它伪造成可回放给模型的 signed thinking block。
## 8. tool result 持久化
tool result 必须作为一等 `message` event 进入 transcript。
当前 InkOS 信息损耗点在 `agentMessagesToPlain()`:它跳过 `ToolResult` message。这个行为导致两种上下文结果:
1. cache 存活时,模型能看到之前的 tool result,因为 `Agent.state.messages` 仍在内存中。
2. cache 过期或进程重启后,模型只能看到压平后的 user/assistant 文本。
JSONL transcript 通过持久化原始 message 消除这种差异。恢复后的 `Agent.state.messages` 包含 committed request 结束时内存中存在的 user、assistant、toolResult message。
`restoreAgentMessagesFromTranscript()` 只恢复 committed 且清理后的 raw `AgentMessage`
provider-specific 兼容投影发生在 `adaptRestoredAgentMessagesForModel()`
跨模型会把外来 tool call/result 文本化;只有目标 provider 的 `compat.requiresAssistantAfterToolResult`
为 true 时,才在 restored `toolResult` 后补 synthetic assistant bridge。
`sourceToolAssistantUuid` 在可获得时指向发出对应 tool call 的 assistant message。后续图恢复可以借此把 tool result 绑定到来源 tool use,降低对顺序恢复的依赖。
## 9. 缓存角色
`agentCache` 保留为加速层,不承担持久化职责。
缓存值调整为:
```ts
type CachedAgent = {
agent: Agent
bookId: string | null
modelId: string | null
lastCommittedSeq: number
lastActive: number
}
```
复用条件:
1. `sessionId` 匹配。
2. `bookId` 匹配。
3. `modelId` 匹配。
4. transcript 最新 committed seq 等于 `lastCommittedSeq`
任一条件不满足时,从 JSONL 重建 Agent。cache eviction 不会丢上下文,因为 JSONL 是持久事实源。
## 10. UI 派生
不引入独立的 `AgentMessageEvent | UiMessageEvent` 双事件线。
UI 状态从 transcript event 派生:
- user chat bubble`message.role === "user"`
- assistant chat bubbleassistant text block。
- thinking displayassistant thinking block。
- tool execution panelassistant toolCall block 按 `toolCallId` 关联 `toolResult`
- title:第一条 user message 或 `session_metadata_updated`
- book binding`session_created``session_metadata_updated`
- session list summary:最后一条可见 user/assistant 文本与 metadata。
`toolResult` message 不作为独立 user message 展示。它服务于工具执行面板和模型恢复。
## 11. 旧数据兼容
现有 `.inkos/sessions/{sessionId}.json` 文件作为 legacy session 读取。
迁移行为:
1. JSONL 存在时优先读取 JSONL。
2. 只有 JSON 存在时,读取 legacy `BookSession`
3. 把 legacy user/assistant message 转成 transcript `message` event。
4. 把生成的 request 标记为 committed。
5. 保留 `sessionId``bookId``title``createdAt``updatedAt` 等 metadata。
6. 保留 legacy assistant `thinking` 给 UI 展示。
7. 不生成 legacy 数据里不存在的 tool result、tool call、signed thinking。
8. 迁移成功后,运行时只写 JSONL。
旧 JSON 文件可以继续留在磁盘上。迁移后它不再被更新。
如果 Studio plain chat fallback 需要补写 assistant 文本,使用 synthetic committed request 写入 transcript。该补写仍然产生 `request_started -> message -> request_committed`,不恢复 legacy JSON 写入路径。
## 12. 错误处理
格式错误的 JSONL 行不能导致 session 加载失败。reader 记录 warning 并跳过不可解析行。
缺少 `request_committed` 的 request 被视为 interrupted tail
- 不进入模型恢复。
- 保留给诊断。
- 后续可以在 UI 中展示为 interrupted request。
如果最后一条 committed message 让模型 history 处于非法状态,恢复前执行清理。第一版清理 unresolved tool use、孤立 tool result、孤立 thinking-only message、空 assistant message、trailing thinking。
## 13. 测试策略
这个改动必须用 TDD。核心风险是重启、cache 过期、工具调用循环后发生静默上下文丢失。
测试分层:
1. JSONL codec 单测
- 一行 append 一个 event。
- 保留原始 `AgentMessage` 内的未知字段。
- 拒绝非法 schema 形状。
- 分配单调递增 `seq`
2. restore 单测
- 只恢复 committed request。
- 忽略 interrupted tail。
- 保留 user、assistant、toolResult 顺序。
- 返回原始 `AgentMessage[]`
3. thinking 单测
- 保留 assistant thinking text。
- 保留 provider signature 字段。
- 不把 legacy UI thinking 转成 signed model thinking。
- 过滤非法 trailing thinking 或孤立 thinking。
4. tool loop 单测
- 持久化 `assistant(toolCall) -> toolResult -> assistant(text)`
- 恢复 toolResult 到 `Agent.state.messages`
- 通过 `toolCallId` 绑定 toolResult 和 toolCall。
5. legacy migration 单测
- 读取旧 BookSession JSON。
- 派生 UI 兼容 message。
- 迁移后写入 JSONL。
- 停止写 legacy JSON。
6. cache 单测
- 最新 committed seq 匹配时复用 cache。
- cache 落后时从 JSONL 重建。
- book 或 model 变化时重建。
7. API 集成测试
- `/api/v1/agent` 写 transcript event。
- session list/detail API 从 JSONL 派生。
- 前端响应形状保持兼容。
## 14. 实现边界
预期 core 模块:
- `packages/core/src/interaction/session-transcript.ts`
- `packages/core/src/interaction/session-transcript-schema.ts`
- `packages/core/src/interaction/session-transcript-restore.ts`
- `packages/core/src/interaction/session-transcript-legacy.ts`
预期集成点:
- `packages/core/src/agent/agent-session.ts`
- `packages/core/src/interaction/book-session-store.ts`
- `packages/studio/src/api/server.ts`
第一版不实现 compaction、branching UI、remote sync、完整 Claude Code graph recovery。schema 预留后续能力所需字段。
## 15. 验收标准
1. 新 session 写入 `.inkos/sessions/{sessionId}.jsonl`
2. 已迁移或新建 session 不再写 `.inkos/sessions/{sessionId}.json`
3. Studio 重启后能恢复包含 tool result 的原始 `AgentMessage[]`
4. assistant thinking block 经过 JSONL 写入和恢复后仍然存在。
5. legacy JSON session 仍然可以加载和展示。
6. cache 过期不会改变模型可见的 conversation context。
7. 测试覆盖 committed restore、interrupted tail 排除、tool result 恢复、thinking 持久化、legacy migration、cache invalidation。