From 53181ab8f704fa47310c86d1ae2abb10e76aae19 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 10:35:23 +0800 Subject: [PATCH 01/32] =?UTF-8?q?fix(provider):=20=E4=BF=AE=E5=A4=8D=20xhi?= =?UTF-8?q?gh=20=E6=8E=A8=E7=90=86=E5=BC=BA=E5=BA=A6=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E9=81=97=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **问题** 1. Anthropic fable5 等模型走 @ai-sdk/openai-compatible 网关时仍只有 high(不含 xhigh), 因为该路径只看 GPT5_FAMILY_RE,不处理 Claude 模型。 2. OpenAI gpt-5.x 在 @ai-sdk/openai-compatible 路径多出 "none" 档位, 该档位是 Responses API 专属,compatible 端点不支持(会返回 400)。 3. azure test 因前序测试遗留 AgentGateway.configure({ enabled: true }) 导致 renderPlanStatus → planStoreRoot 抛出"configureRoot not called"。 **修复** - @ai-sdk/openai-compatible case: - 新增 Claude 模型分支:api.id 含 "claude" 时,使用 anthropicAdaptiveEfforts 返回的档位集(含 xhigh)以 reasoningEffort 格式暴露给网关转发。 - GPT-5 分支:过滤掉 "none" 和 "minimal"(Responses API 专属档位)。 - azure test:在 LLMRequestPrep.prepare 前加 AgentGateway.configure({ enabled: false })。 **测试** - 更新 gpt-5.6-sol 测试:期望 ["low","medium","high","xhigh"](不含 none)。 - 新增 claude-fable-5 via openai-compatible 测试:期望 ["low","medium","high","xhigh","max"]。 - 262/262 通过,turbo typecheck 15/15 全绿。 Co-Authored-By: Claude Opus 4.8 --- packages/app/package.json | 2 +- .../pages/session/message-timeline.data.ts | 4 ++- .../deepagent-code/src/provider/transform.ts | 20 ++++++++++--- .../test/provider/transform.test.ts | 29 +++++++++++++++++-- packages/desktop/package.json | 2 +- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 625b3083..3d4e8646 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@deepagent-code/app", - "version": "1.4.2", + "version": "1.4.3", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/pages/session/message-timeline.data.ts b/packages/app/src/pages/session/message-timeline.data.ts index 13fc176c..4ae5e699 100644 --- a/packages/app/src/pages/session/message-timeline.data.ts +++ b/packages/app/src/pages/session/message-timeline.data.ts @@ -260,7 +260,9 @@ export namespace Timeline { } if (error) { - const data = error.data?.message + // Not every assistant error carries `data.message` (e.g. OutputDegenerationError has + // { chars, ratio, detectorVersion }). Read via loose-record probe. + const data = (error.data as Record | undefined)?.message rows.push( new TimelineRow.Error({ userMessageID: userMessage.id, diff --git a/packages/deepagent-code/src/provider/transform.ts b/packages/deepagent-code/src/provider/transform.ts index 2b44e055..eaf857d4 100644 --- a/packages/deepagent-code/src/provider/transform.ts +++ b/packages/deepagent-code/src/provider/transform.ts @@ -838,14 +838,26 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) + } + + // GPT-5.x versioned models support extended effort tiers (xhigh from v5.2+). Strip `none` and + // `minimal` — those are Responses-API-only tiers that openai-compatible endpoints do not + // implement, so exposing them would produce 400 errors on most third-party gateways. if (GPT5_FAMILY_RE.test(apiId)) { - const openaiCompatEfforts = openaiCompatibleReasoningEfforts(apiId) + const openaiCompatEfforts = openaiCompatibleReasoningEfforts(apiId).filter( + (e) => e !== "none" && e !== "minimal", + ) return Object.fromEntries(openaiCompatEfforts.map((effort) => [effort, { reasoningEffort: effort }])) } + const efforts = [...WIDELY_SUPPORTED_EFFORTS] if (apiId.includes("deepseek-v4")) { efforts.push("max") diff --git a/packages/deepagent-code/test/provider/transform.test.ts b/packages/deepagent-code/test/provider/transform.test.ts index b09ce0bd..d27457a3 100644 --- a/packages/deepagent-code/test/provider/transform.test.ts +++ b/packages/deepagent-code/test/provider/transform.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" +import { AgentGateway } from "@deepagent-code/core/agent-gateway" import { ProviderV2 } from "@deepagent-code/core/provider" import { ModelV2 } from "@deepagent-code/core/model" @@ -334,6 +335,9 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { }) test("azure chat completions omit Responses-only reasoning options after variants merge", async () => { + // Disable deepagent mode so the prepare path skips renderPlanStatus (which requires + // SessionState.configure/planStoreRoot and is orthogonal to what this test exercises). + AgentGateway.configure({ enabled: false }) const model = { ...createGpt5Model("gpt-5.4"), id: "azure/gpt-5.4", @@ -2529,8 +2533,9 @@ describe("ProviderTransform.variants", () => { }) // Bug fix: gpt-5.x models on @ai-sdk/openai-compatible endpoints now get the extended - // effort set (xhigh from gpt-5.2+). Previously the adapter always returned low/medium/high. - test("openai-compatible gpt-5.6-sol returns xhigh reasoning effort", () => { + // effort set (xhigh from gpt-5.2+). `none` and `minimal` are filtered out — they are + // Responses-API-only tiers that compatible endpoints do not implement. + test("openai-compatible gpt-5.6-sol returns xhigh but not none/minimal", () => { const model = createMockModel({ id: "cortecs/gpt-5.6-sol", providerID: "cortecs", @@ -2541,7 +2546,25 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) + expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) + expect(result.none).toBeUndefined() + }) + + // Bug fix: Anthropic models (Claude family) routed via an openai-compatible gateway now get the + // full adaptive effort set including xhigh, expressed as reasoningEffort for the gateway to forward. + test("openai-compatible claude-fable-5 returns adaptive efforts including xhigh", () => { + const model = createMockModel({ + id: "gateway/claude-fable-5", + providerID: "gateway", + api: { + id: "claude-fable-5", + url: "https://api.gateway.ai/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) }) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 917be9ac..531c7962 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepagent-code/desktop", "private": true, - "version": "1.4.2", + "version": "1.4.3", "type": "module", "license": "AGPL-3.0-or-later", "homepage": "https://deepagent-code.ai", From 6fce2e7cbf9812261eaa2672c8a17b87009e9cf8 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 12:40:17 +0800 Subject: [PATCH 02/32] =?UTF-8?q?fix(session):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AD=90agent=20StructuredOutput=20=E5=BE=AA=E7=8E=AF=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E6=AD=BB=E9=94=81=E4=B8=8E=E5=BC=82=E5=B8=B8=E9=80=80?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **问题** 两个子agent在生产环境中同时踩中两个 bug: 1. researcher子agent("盘点DTK/gfx936可用算子")无法退出循环推理 — 循环死锁 2. 另一个子agent("研究P5-2目标算子")异常中断无进一步反应 **根因分析** 两个 bug 均发生在 runLoop() 的 StructuredOutput 路径上,该路径仅在 subagent + json_schema format 组合下激活(主agent不触发): Bug 1 — 无限循环死锁: - researcher/reviewer 子agent 自动挂载 ResearchResult/ReviewResult schema (DEFAULT_OUTPUT_SCHEMA_BY_AGENT),设 toolChoice: "required" - 模型在扩展思考(xhigh reasoning)阶段猜测错误字段名 ("summary/key_findings" 而非真实的 "module/mechanism/keyFiles") - AI SDK 在调用 execute() 前做 schema 校验 → 校验失败 → onSuccess 从不触发 → structured 永远是 undefined - 退出条件 (structured !== undefined) 永远为 false - finish === "tool-calls"(模型确实调了工具),StructuredOutputError 分支 (finished && !handle.message.error) 也永远为 false - 结果:while(true) 死循环。retryCount 字段存在但从未被消费。 Bug 2 — 异常中断: - 当模型直接以文本结束(不调工具)时走 StructuredOutputError 路径, result.info.structured 为 undefined,task.ts 回退到 findLast(text), 返回空/无意义结果 → 父agent收到空结果后停止响应。 **修复(P0+P1)** P1 — schema 字段注入系统提示(防止模型猜错字段): - 将 STRUCTURED_OUTPUT_SYSTEM_PROMPT 常量改为 buildStructuredOutputSystemPrompt(schema) 函数 - 将 schema 的顶层字段名(如 module, mechanism, keyFiles, interfaces, risks, openQuestions)注入系统提示,确保模型在扩展思考阶段就能看到 正确字段名,不依赖工具定义 P0 — retryCount 截断循环(消费之前一直忽略的 retryCount 字段): - 在 runLoop() 中新增 structuredFailedAttempts 计数器 - 当 format.type === "json_schema" 且 finish === "tool-calls" 且 structured === undefined 时,说明 StructuredOutput 调用失败(schema 校验拒绝),递增计数器 - 达到 retryCount(默认2)后: - 设置 StructuredOutputError(含已尝试次数和字段列表) - 记录 warn 日志 - 返回 "break" 终止循环 - 在截断前注入 synthetic 纠错提示,给模型一次看到正确字段名后重试的机会 **测试** - 新增 buildStructuredOutputSystemPrompt 测试(4例):字段注入/无属性/空schema/不泄露description - 新增 extractSchemaTopLevelFields 测试(3例):正常/无属性/null - 全套 structured-output.test.ts 37/37 通过 - session/agent/tool 全套 1052/1064 通过(1 fail 为预存在网络连接失败,与本次无关) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/deepagent-code/src/session/prompt.ts | 74 ++++++++++++++++++- .../test/session/structured-output.test.ts | 70 ++++++++++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts index 79136e91..a02fbe18 100644 --- a/packages/deepagent-code/src/session/prompt.ts +++ b/packages/deepagent-code/src/session/prompt.ts @@ -111,7 +111,24 @@ IMPORTANT: - Complete all necessary research and tool calls BEFORE calling this tool - This tool provides your final answer - no further actions are taken after calling it` -const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` +// P1: Build a schema-aware system prompt that injects the required field names so the model +// knows the exact schema even during extended-thinking (xhigh) reasoning, without relying +// solely on the tool definition which may not be visible during the thinking phase. +function buildStructuredOutputSystemPrompt(schema: Record): string { + const fields = extractSchemaTopLevelFields(schema) + const fieldHint = + fields.length > 0 + ? `\nThe StructuredOutput tool requires these top-level fields: ${fields.join(", ")}. Use ONLY these exact field names.` + : "" + return `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.${fieldHint}` +} + +function extractSchemaTopLevelFields(schema: Record): string[] { + if (!schema || typeof schema !== "object") return [] + const props = schema.properties + if (!props || typeof props !== "object") return [] + return Object.keys(props) +} const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) @@ -2086,6 +2103,12 @@ export const layer = Layer.effect( const slog = elog.with({ sessionID }) let structured: unknown let step = 0 + // P0: count StructuredOutput tool-call attempts that did NOT produce a valid structured + // result (schema validation rejected the arguments). When this reaches the format's + // retryCount ceiling we inject a corrective hint and exit — preventing the infinite loop + // that occurs when the model repeatedly guesses wrong field names (e.g. "summary" instead + // of "module" for ResearchResult) and the AI SDK silently rejects them before execute(). + let structuredFailedAttempts = 0 const session = yield* sessions.get(sessionID).pipe(Effect.orDie) // V3.8 App-A C2.5 (Stage 5): the Conversation Log writer. Constructed ONCE per run so its @@ -2427,7 +2450,10 @@ export const layer = Layer.effect( ]) const system = [...env, ...instructions, ...(skills ? [skills] : [])] const format = lastUser.format ?? { type: "text" as const } - if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + // P1: inject schema-aware prompt so the model knows the exact field names even + // during extended-thinking (xhigh) reasoning where the tool definition may not + // be immediately visible when the model starts generating its thinking tokens. + if (format.type === "json_schema") system.push(buildStructuredOutputSystemPrompt(format.schema)) const result = yield* handle.process({ user: lastUser, agent, @@ -2460,6 +2486,47 @@ export const layer = Layer.effect( } } + // P0: StructuredOutput retry-cap. When the model made tool-calls (finish === "tool-calls") + // but structured is still undefined, it means the StructuredOutput call was either: + // (a) schema-validation-rejected by the AI SDK (wrong field names) — execute() never ran + // (b) the model called other tools instead + // Either way, count the attempt. Once retryCount is exhausted, inject a corrective + // synthetic nudge that repeats the required field names and exit. This breaks the + // infinite loop where the model repeatedly guesses incorrect schema fields (the + // "summary/key_findings" vs "module/mechanism/keyFiles" problem observed in production). + if (format.type === "json_schema" && handle.message.finish === "tool-calls") { + const retryMax = format.retryCount ?? 2 + structuredFailedAttempts++ + if (structuredFailedAttempts >= retryMax) { + const fields = extractSchemaTopLevelFields(format.schema) + const fieldList = fields.length > 0 ? fields.join(", ") : "(see schema)" + handle.message.error = new SessionV1.StructuredOutputError({ + message: `StructuredOutput schema validation failed after ${structuredFailedAttempts} attempt(s). Required fields: ${fieldList}`, + retries: structuredFailedAttempts, + }).toObject() + yield* sessions.updateMessage(handle.message) + yield* slog.warn("structured-output retry cap reached", { + attempts: structuredFailedAttempts, + retryMax, + fields: fieldList, + }) + return "break" as const + } + // Inject a corrective hint before the next attempt so the model sees the exact + // field names inline in the conversation rather than only in the tool definition. + const fields = extractSchemaTopLevelFields(format.schema) + if (fields.length > 0) { + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: handle.message.id, + sessionID, + type: "text", + text: `[structured-output correction] Your StructuredOutput call did not match the required schema. Required top-level fields: ${fields.join(", ")}. Please call StructuredOutput again using EXACTLY these field names.`, + synthetic: true, + } satisfies SessionV1.TextPart) + } + } + if (result === "stop") return "break" as const if (result === "compact") { // V4.0.1 P0 — a turn-internal hard compaction (the provider signalled overflow mid-stream). @@ -2959,6 +3026,9 @@ const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) /** @internal Exported for testing */ +/** @internal Exported for testing */ +export { buildStructuredOutputSystemPrompt, extractSchemaTopLevelFields } + export function createStructuredOutputTool(input: { schema: Record onSuccess: (output: unknown) => void diff --git a/packages/deepagent-code/test/session/structured-output.test.ts b/packages/deepagent-code/test/session/structured-output.test.ts index 8e46d393..563ab530 100644 --- a/packages/deepagent-code/test/session/structured-output.test.ts +++ b/packages/deepagent-code/test/session/structured-output.test.ts @@ -469,3 +469,73 @@ describe("structured-output.createStructuredOutputTool", () => { // The tool simply calls onSuccess when execute() is called with valid args // See prompt.ts loop() for actual retry logic }) + +// P1: schema-aware system prompt — injects top-level field names so the model knows +// the exact fields during extended-thinking (xhigh) reasoning, preventing the +// infinite loop caused by the model guessing wrong field names (e.g. "summary" instead +// of "module" for ResearchResult). +describe("structured-output.buildStructuredOutputSystemPrompt", () => { + test("includes field names in the prompt", () => { + const result = SessionPrompt.buildStructuredOutputSystemPrompt({ + type: "object", + properties: { + module: { type: "string" }, + mechanism: { type: "string" }, + keyFiles: { type: "array" }, + }, + }) + expect(result).toContain("module") + expect(result).toContain("mechanism") + expect(result).toContain("keyFiles") + expect(result).toContain("StructuredOutput") + }) + + test("falls back gracefully when schema has no properties", () => { + const result = SessionPrompt.buildStructuredOutputSystemPrompt({ type: "object" }) + expect(result).toContain("StructuredOutput") + // should not contain a field hint line + expect(result).not.toContain("Required fields:") + }) + + test("falls back gracefully for empty schema", () => { + const result = SessionPrompt.buildStructuredOutputSystemPrompt({}) + expect(result).toContain("StructuredOutput") + expect(result).not.toContain("Required fields:") + }) + + test("uses ONLY exact field names, not descriptions or types", () => { + const result = SessionPrompt.buildStructuredOutputSystemPrompt({ + type: "object", + properties: { + summary: { type: "string", description: "A long description here" }, + findings: { type: "array" }, + }, + }) + expect(result).toContain("summary") + expect(result).toContain("findings") + // should not leak the description text into the prompt + expect(result).not.toContain("A long description here") + }) +}) + +// P0: extractSchemaTopLevelFields — utility that drives both the system-prompt injection +// and the retry-cap corrective hint. +describe("structured-output.extractSchemaTopLevelFields", () => { + test("returns top-level property names", () => { + const fields = SessionPrompt.extractSchemaTopLevelFields({ + type: "object", + properties: { module: {}, mechanism: {}, keyFiles: {} }, + }) + expect(fields).toEqual(["module", "mechanism", "keyFiles"]) + }) + + test("returns empty array when no properties", () => { + expect(SessionPrompt.extractSchemaTopLevelFields({ type: "object" })).toEqual([]) + expect(SessionPrompt.extractSchemaTopLevelFields({})).toEqual([]) + }) + + test("returns empty array for non-object/null schema", () => { + expect(SessionPrompt.extractSchemaTopLevelFields(null as any)).toEqual([]) + expect(SessionPrompt.extractSchemaTopLevelFields(undefined as any)).toEqual([]) + }) +}) From 6e68e588178133828e66ab671467b434d45f40cf Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 13:25:18 +0800 Subject: [PATCH 03/32] =?UTF-8?q?fix(session):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=AD=90agent=20StructuredOutput=20=E4=BF=AE=E5=A4=8D=E7=9A=84?= =?UTF-8?q?3=E4=B8=AA=E5=AE=A1=E6=A0=B8=20bug=20(B1/B2/B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由子agent代码审核发现,commit 221d8611 存在3个真实缺陷: **Bug B1(严重)— 计数器误触发** structuredFailedAttempts 对任何 finish=tool-calls 的步骤无差别计数, 包括 bash/read/write 等与 StructuredOutput 无关的工具调用。 默认 retryCount=2 时,任何需要2步以上研究工具的子agent都会 在 StructuredOutput 被调用前就命中上限报错退出。 修复:重新读取当前轮次的 assistant message parts,仅在 parts 中确实存在 tool === "StructuredOutput" 的调用时才计数。 **Bug B2(中等)— 纠错文本注入到 assistant 消息** correction text 通过 sessions.updatePart({ messageID: handle.message.id }) 注入到当前助手消息。toModelMessagesEffect 对 assistant text parts 没有 synthetic 过滤,该文本会以模型自己说过的话出现在下一轮 模型上下文中——模型对自身输出的服从性远低于用户消息,纠错效果失效。 修复:改用 injectTailReminder(sessionID, ...) 生成 user 侧 synthetic 消息,与所有其他提示注入路径一致。 **Bug B3(中等)— StructuredOutputError 在 task.ts 被静默丢弃** retry cap 触发后 handle.message.error 被设为 StructuredOutputError, 但 task.ts runTaskInner 只检查 result.info.structured,从不检查 result.info.error,错误被静默丢弃,父 agent 收到空字符串并无失败指示。 修复:在 structured 检查后追加 error 检查:若 msgError 是 StructuredOutputError,通过 Effect.fail 向上传播错误,让 task 工具的错误路径正确处理并通知父 agent。 测试:structured-output 37/37 + orchestration-schema 15/15,typecheck 通过 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/deepagent-code/src/session/prompt.ts | 85 +++++++++++-------- packages/deepagent-code/src/tool/task.ts | 12 +++ 2 files changed, 62 insertions(+), 35 deletions(-) diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts index a02fbe18..f8207784 100644 --- a/packages/deepagent-code/src/session/prompt.ts +++ b/packages/deepagent-code/src/session/prompt.ts @@ -2486,44 +2486,59 @@ export const layer = Layer.effect( } } - // P0: StructuredOutput retry-cap. When the model made tool-calls (finish === "tool-calls") - // but structured is still undefined, it means the StructuredOutput call was either: - // (a) schema-validation-rejected by the AI SDK (wrong field names) — execute() never ran - // (b) the model called other tools instead - // Either way, count the attempt. Once retryCount is exhausted, inject a corrective - // synthetic nudge that repeats the required field names and exit. This breaks the - // infinite loop where the model repeatedly guesses incorrect schema fields (the - // "summary/key_findings" vs "module/mechanism/keyFiles" problem observed in production). + // P0: StructuredOutput retry-cap. Only fires when: + // 1. format is json_schema (structured-output mode) + // 2. the model made tool-calls (finish === "tool-calls") + // 3. structured is still undefined (StructuredOutput was NOT successfully captured) + // 4. the current turn's parts actually contain a StructuredOutput call + // (B1 fix: filter to ONLY StructuredOutput failures, not any tool call) + // + // When the model called StructuredOutput but AI SDK schema-validation rejected the + // arguments (wrong field names like "summary" instead of "module"), execute() never + // runs, onSuccess never fires, and structured stays undefined — causing an infinite + // loop. The retry-cap truncates this loop. if (format.type === "json_schema" && handle.message.finish === "tool-calls") { - const retryMax = format.retryCount ?? 2 - structuredFailedAttempts++ - if (structuredFailedAttempts >= retryMax) { + // Re-read the latest message parts to detect if StructuredOutput was attempted + // this step. We check the CURRENT assistant message's parts (by handle.message.id). + const latestMsgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + ) + const currentAssistantMsg = latestMsgs.findLast( + (m) => m.info.role === "assistant" && m.info.id === handle.message.id, + ) + const hadStructuredOutputCall = currentAssistantMsg?.parts.some( + (p) => p.type === "tool" && p.tool === "StructuredOutput", + ) ?? false + + if (hadStructuredOutputCall) { + const retryMax = format.retryCount ?? 2 + structuredFailedAttempts++ const fields = extractSchemaTopLevelFields(format.schema) const fieldList = fields.length > 0 ? fields.join(", ") : "(see schema)" - handle.message.error = new SessionV1.StructuredOutputError({ - message: `StructuredOutput schema validation failed after ${structuredFailedAttempts} attempt(s). Required fields: ${fieldList}`, - retries: structuredFailedAttempts, - }).toObject() - yield* sessions.updateMessage(handle.message) - yield* slog.warn("structured-output retry cap reached", { - attempts: structuredFailedAttempts, - retryMax, - fields: fieldList, - }) - return "break" as const - } - // Inject a corrective hint before the next attempt so the model sees the exact - // field names inline in the conversation rather than only in the tool definition. - const fields = extractSchemaTopLevelFields(format.schema) - if (fields.length > 0) { - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: handle.message.id, - sessionID, - type: "text", - text: `[structured-output correction] Your StructuredOutput call did not match the required schema. Required top-level fields: ${fields.join(", ")}. Please call StructuredOutput again using EXACTLY these field names.`, - synthetic: true, - } satisfies SessionV1.TextPart) + if (structuredFailedAttempts >= retryMax) { + handle.message.error = new SessionV1.StructuredOutputError({ + message: `StructuredOutput schema validation failed after ${structuredFailedAttempts} attempt(s). Required fields: ${fieldList}`, + retries: structuredFailedAttempts, + }).toObject() + yield* sessions.updateMessage(handle.message) + yield* slog.warn("structured-output retry cap reached", { + attempts: structuredFailedAttempts, + retryMax, + fields: fieldList, + }) + return "break" as const + } + // B2 fix: inject via injectTailReminder (user-side synthetic message) so the + // correction text appears as a user instruction in the next model context — + // not as assistant output (which the model treats with lower compliance). + if (fields.length > 0) { + yield* injectTailReminder( + sessionID, + `[structured-output correction] Your StructuredOutput call did not match the required schema. Required top-level fields: ${fieldList}. Please call StructuredOutput again using EXACTLY these field names.`, + lastUser.model, + lastUser.agent, + ) + } } } diff --git a/packages/deepagent-code/src/tool/task.ts b/packages/deepagent-code/src/tool/task.ts index 4b4cd438..059be2ef 100644 --- a/packages/deepagent-code/src/tool/task.ts +++ b/packages/deepagent-code/src/tool/task.ts @@ -882,6 +882,18 @@ export const TaskTool = Tool.define( if (resolvedOutputSchema) { const structured = result.info.role === "assistant" ? result.info.structured : undefined if (structured !== undefined) return JSON.stringify(structured) + // B3 fix: when the retry cap fired, the assistant message carries a StructuredOutputError. + // Silently returning "" here would make the parent agent see an empty success result and + // lose all signal that the subagent failed to produce structured output. Surface the error + // explicitly so the task tool's error path propagates it correctly to the parent. + const msgError = result.info.role === "assistant" ? result.info.error : undefined + if (msgError && SessionV1.StructuredOutputError.isInstance(msgError)) { + return yield* Effect.fail( + new Error( + `StructuredOutput failed (${msgError.data.retries} attempt(s)): ${msgError.data.message}`, + ), + ) + } } return result.parts.findLast((item) => item.type === "text")?.text ?? "" }) From 7a99b6b863d59d01b7d6a5480df3059c278ce4df Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 13:43:07 +0800 Subject: [PATCH 04/32] =?UTF-8?q?fix(desktop):=20listener=20ready=20?= =?UTF-8?q?=E5=90=8E=E7=AB=8B=E5=8D=B3=E6=98=BE=E7=A4=BA=E4=B8=BB=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=EF=BC=8Chealth=20check=20=E5=90=8E=E5=8F=B0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/desktop/src/main/index.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 5d2c8b87..b4c6f3be 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -345,6 +345,13 @@ const main = Effect.gen(function* () { void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) } + yield* Deferred.succeed(serverReady, { + url, + username: "deepagent-code", + password, + }) + + // Health check runs in background — failure only logs, does not block renderer yield* Effect.promise(() => health.wait).pipe( Effect.timeout("30 seconds"), Effect.tapError((e) => @@ -352,14 +359,10 @@ const main = Effect.gen(function* () { logger.error("sidecar health check failed", e.toString()) }), ), + Effect.ignore, + Effect.forkDetach, ) - yield* Deferred.succeed(serverReady, { - url, - username: "deepagent-code", - password, - }) - logger.log("loading task finished") }).pipe(forwardInitializationFailure(serverReady), Effect.forkChild) From c09c65b37f6b3ad1504e0c6e97dc597381234f6f Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 14:23:16 +0800 Subject: [PATCH 05/32] =?UTF-8?q?feat(task):=20Phase=201=20=E5=AD=90Agent?= =?UTF-8?q?=E6=88=90=E6=9E=9C=E5=9B=9E=E6=94=B6=20=E2=80=94=20interrupted?= =?UTF-8?q?=E7=8A=B6=E6=80=81+recovery=20pointer+durable=20task=5Fstatus+t?= =?UTF-8?q?ask=5Fread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/4.0.4_r1.md §4 Phase 1 完整实施。 **U1 — StructuredOutputError 加入 child session ID** task.ts L3 路径:StructuredOutput 失败时错误文本从 "StructuredOutput failed (N attempt(s)): ..." 改为 "StructuredOutput failed (N attempt(s)): ... Partial research is preserved in subagent session ses_xxx. Call task_read({ task_id: "ses_xxx" }) before retrying or duplicating the task." 父 Agent 不再只看到失败消息,而是直接得到恢复指针。 **§4.3 — 子Agent interrupted 状态合同** AttemptBundle 接口 + 两个 markFinished 实现同步扩展: - 新状态:"interrupted"(人类主动打断、保留成果) - 新 reason 字段:"human" | "parent_interrupted" | "timeout" | "takeover" | "runtime_error" - 旧 finished:true + state 的 compat 读取路径不变 **§4.3/4.6 — cancelled → interrupted + recovery pointer** 两处 "Task cancelled" 改为 interrupted 语义: 1. driveForeground block2 路径(outcome.kind === "cancelled") - markFinished("interrupted", "human") - teardownWorktree(false)(保留 worktree,不强制删除) - 错误文本:"Task interrupted by the user. Partial work is preserved in ses_xxx. Call task_read({ task_id: "ses_xxx" }) before retrying." 2. 非-block2 前台路径(result?.status === "cancelled") - 同上,使用 nextSession.id **§4.4 — Durable task_status** task_status.ts 全部重写: - 权威层:Session.children(parentID) — DB 持久记录,进程重启后仍可用 - 叠加层:BackgroundJob.list() — 当前进程实时运行状态(advisory only) - 输出包含 child session ID、durable state、耗时 - "interrupted" 状态附带 task_read recovery hint - 历史数据兼容:没有 subagent metadata 的子会话标为 "unknown",不伪装 running **§4.5 — 新增 task_read 工具** packages/deepagent-code/src/tool/task_read.ts: - 参数:task_id、limit(默认20,最大100)、before(分页 cursor) - 安全边界:仅允许读取 child.parentID === ctx.sessionID 的直接子会话 - 不读取进程内 BackgroundJob,直接读 Session.messages(durable) - 输出格式:`` XML 标签,含 tool result / interruption 标记 - 截断时附加 task_id 和下一页 cursor - 不暴露 reasoning 内容(synthetic + ignored parts 过滤) **registry.ts** task_read 以 task 权限同级(无额外 flag,默认可用)注册到 builtin 工具列表。 测试:task.test.ts + task-concurrency.test.ts 31/31 通过,typecheck 全绿 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/deepagent-code/src/tool/registry.ts | 4 + packages/deepagent-code/src/tool/task.ts | 43 ++++- packages/deepagent-code/src/tool/task_read.ts | 182 ++++++++++++++++++ .../deepagent-code/src/tool/task_status.ts | 99 ++++++++-- 4 files changed, 302 insertions(+), 26 deletions(-) create mode 100644 packages/deepagent-code/src/tool/task_read.ts diff --git a/packages/deepagent-code/src/tool/registry.ts b/packages/deepagent-code/src/tool/registry.ts index 1998e480..7e22104b 100644 --- a/packages/deepagent-code/src/tool/registry.ts +++ b/packages/deepagent-code/src/tool/registry.ts @@ -9,6 +9,7 @@ import { GrepTool } from "./grep" import { ReadTool } from "./read" import { TaskTool } from "./task" import { TaskStatusTool } from "./task_status" +import { TaskReadTool } from "./task_read" import { DismissValidationTool } from "./dismiss_validation" import { Database } from "@deepagent-code/core/database/database" import { WebFetchTool } from "./webfetch" @@ -129,6 +130,7 @@ export const layer: Layer.Layer< const invalid = yield* InvalidTool const task = yield* TaskTool const taskstatus = yield* TaskStatusTool + const taskread = yield* TaskReadTool const dismissvalidation = yield* DismissValidationTool const read = yield* ReadTool const question = yield* QuestionTool @@ -259,6 +261,7 @@ export const layer: Layer.Layer< write: Tool.init(writetool), task: Tool.init(task), task_status: Tool.init(taskstatus), + task_read: Tool.init(taskread), dismiss_validation: Tool.init(dismissvalidation), fetch: Tool.init(webfetch), search: Tool.init(websearch), @@ -287,6 +290,7 @@ export const layer: Layer.Layer< tool.write, tool.task, tool.task_status, + tool.task_read, tool.dismiss_validation, tool.fetch, tool.search, diff --git a/packages/deepagent-code/src/tool/task.ts b/packages/deepagent-code/src/tool/task.ts index 059be2ef..e527f5dc 100644 --- a/packages/deepagent-code/src/tool/task.ts +++ b/packages/deepagent-code/src/tool/task.ts @@ -174,7 +174,7 @@ interface AttemptBundle { readonly worktree: Worktree.Interface | undefined readonly nextSession: Session.Info readonly metadata: AttemptMetadata - readonly markFinished: (state: "completed" | "error" | "cancelled") => Effect.Effect + readonly markFinished: (state: "completed" | "error" | "cancelled" | "interrupted", reason?: "human" | "parent_interrupted" | "timeout" | "takeover" | "runtime_error") => Effect.Effect readonly inject: (state: "completed" | "error", text: string, takeovers: number) => Effect.Effect readonly mergeWorktree: () => Effect.Effect readonly teardownWorktree: (force: boolean) => Effect.Effect @@ -391,7 +391,8 @@ export const TaskTool = Tool.define( }) const markFinished = Effect.fn("TaskTool.markSubagentFinished")(function* ( - state: "completed" | "error" | "cancelled", + state: "completed" | "error" | "cancelled" | "interrupted", + reason?: "human" | "parent_interrupted" | "timeout" | "takeover" | "runtime_error", ) { const current = yield* sessions.get(a.nextSession.id).pipe(Effect.orDie) yield* sessions @@ -401,7 +402,9 @@ export const TaskTool = Tool.define( ...(current.metadata ?? {}), deepagent: { ...((current.metadata?.["deepagent"] as Record | undefined) ?? {}), - subagent: { finished: true, state, at: Date.now() }, + // §4.3 SubagentRunMetadata: state is the durable authority for terminal state. + // Compat: old data using `finished: true` with state is still readable unchanged. + subagent: { finished: true, state, at: Date.now(), ...(reason ? { reason } : {}) }, }, }, }) @@ -616,9 +619,18 @@ export const TaskTool = Tool.define( } } if (outcome.kind === "cancelled") { - yield* b.markFinished("cancelled") + // §4.3/4.6: "cancelled" from the abort signal means human interrupted the task. + // Write "interrupted" (not "cancelled") so parent agent and supervision UI can + // distinguish voluntary human stop with preserved work from a runtime failure. + // Do NOT force-remove the worktree — partial work may be worth recovering. + yield* b.markFinished("interrupted", "human") yield* b.teardownWorktree(false) - return yield* Effect.fail(new Error("Task cancelled")) + return yield* Effect.fail( + new Error( + `Task interrupted by the user. Partial work is preserved in subagent session ${b.nextSession.id}. ` + + `Call task_read({ task_id: "${b.nextSession.id}" }) before retrying or duplicating the task.`, + ), + ) } if (takeovers >= takeoverLimit) { yield* background.cancel(b.nextSession.id).pipe(Effect.ignore) @@ -890,7 +902,11 @@ export const TaskTool = Tool.define( if (msgError && SessionV1.StructuredOutputError.isInstance(msgError)) { return yield* Effect.fail( new Error( - `StructuredOutput failed (${msgError.data.retries} attempt(s)): ${msgError.data.message}`, + // U1: include the child session ID so the parent agent can recover partial work via + // task_read({ task_id: nextSession.id }) before retrying or duplicating the task. + `StructuredOutput failed (${msgError.data.retries} attempt(s)): ${msgError.data.message}. ` + + `Partial research is preserved in subagent session ${nextSession.id}. ` + + `Call task_read({ task_id: "${nextSession.id}" }) to recover completed work before retrying.`, ), ) } @@ -938,7 +954,8 @@ export const TaskTool = Tool.define( // only flips the UI to "已完成" and disables the composer; it touches no message/part data. // Read-merge because setMetadata replaces the whole metadata object. const markFinished = Effect.fn("TaskTool.markSubagentFinished")(function* ( - state: "completed" | "error" | "cancelled", + state: "completed" | "error" | "cancelled" | "interrupted", + reason?: "human" | "parent_interrupted" | "timeout" | "takeover" | "runtime_error", ) { // Resume (`params.task_id`) reuses an existing session; a fresh finish marker is still correct // (the reused session just completed another turn), so no special-casing is needed. @@ -950,7 +967,8 @@ export const TaskTool = Tool.define( ...(current.metadata ?? {}), deepagent: { ...((current.metadata?.["deepagent"] as Record | undefined) ?? {}), - subagent: { finished: true, state, at: Date.now() }, + // §4.3: state is the durable terminal-state authority; reason narrows the cause. + subagent: { finished: true, state, at: Date.now(), ...(reason ? { reason } : {}) }, }, }, }) @@ -1051,8 +1069,13 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(result.error ?? "Task failed")) } if (result?.status === "cancelled") { - yield* markFinished("cancelled") - return yield* Effect.fail(new Error("Task cancelled")) + yield* markFinished("interrupted", "human") + return yield* Effect.fail( + new Error( + `Task interrupted by the user. Partial work is preserved in subagent session ${nextSession.id}. ` + + `Call task_read({ task_id: "${nextSession.id}" }) before retrying or duplicating the task.`, + ), + ) } yield* markFinished("completed") return { diff --git a/packages/deepagent-code/src/tool/task_read.ts b/packages/deepagent-code/src/tool/task_read.ts new file mode 100644 index 00000000..58f2c4f3 --- /dev/null +++ b/packages/deepagent-code/src/tool/task_read.ts @@ -0,0 +1,182 @@ +import * as Tool from "./tool" +import { Session } from "@/session/session" +import { SessionV1 } from "@deepagent-code/core/v1/session" +import { Effect, Schema } from "effect" +import type { SessionID } from "@/session/schema" + +const id = "task_read" + +const DESCRIPTION = [ + "Read the transcript of a subagent task you dispatched via the task tool.", + "Parameters: task_id (the session ID from task_status output), limit (default 20, max 100), before (message ID cursor for pagination).", + "Returns up to `limit` messages from the subagent's conversation, newest-first.", + "Use this to recover partial work when a subagent was interrupted or failed to produce structured output.", + "IMPORTANT: Only reads sessions you directly spawned (child sessions of the current session).", + "Never returns hidden reasoning content.", +].join(" ") + +const Parameters = Schema.Struct({ + task_id: Schema.String.annotate({ description: "The subagent session ID (from task_status output)" }), + limit: Schema.optional(Schema.Number).annotate({ + description: "Max messages to return (default 20, max 100)", + }), + before: Schema.optional(Schema.String).annotate({ + description: "Message ID cursor for pagination (from the 'before' hint in a previous call)", + }), +}) + +const MAX_LIMIT = 100 +const DEFAULT_LIMIT = 20 + +/** Render a single tool part into the transcript. */ +function renderToolPart(part: SessionV1.ToolPart): string { + const state = part.state.status + const name = part.tool ?? "unknown" + if (state === "running" || state === "pending") { + return ` ` + } + if (state === "error") { + const err = part.state.error ?? "error" + return ` ${truncate(String(err), 200)}` + } + if (state === "completed") { + const output = part.state.output ?? part.state.metadata?.output ?? "" + return ` ${truncate(String(output), 400)}` + } + return ` ` +} + +function renderTextPart(part: SessionV1.TextPart): string | undefined { + // Skip synthetic parts (system injections) and ignored parts from the transcript. + if (part.synthetic || part.ignored) return undefined + const text = part.text?.trim() + if (!text) return undefined + return ` ${truncate(text, 600)}` +} + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text + return text.slice(0, maxLen - 1) + "…" +} + +/** + * §4.5: task_read — parent-session-constrained child transcript reader. + * + * Security boundary: ONLY reads sessions whose parentID equals the calling session's ID. + * This prevents using a known session ID to read arbitrary other sessions. + */ +export const TaskReadTool = Tool.define( + id, + Effect.gen(function* () { + const sessions = yield* Session.Service + + const run = Effect.fn("TaskReadTool.execute")(function* ( + params: Schema.Schema.Type, + ctx: Tool.Context, + ) { + const childSessionID = params.task_id as SessionID + const limit = Math.min(params.limit ?? DEFAULT_LIMIT, MAX_LIMIT) + + // §4.5 security boundary: verify the requested session is a direct child of the calling session. + const child = yield* sessions.get(childSessionID).pipe( + Effect.catchCause(() => + Effect.fail(new Error(`task_read: session not found: ${params.task_id}`)), + ), + ) + if (child.parentID !== ctx.sessionID) { + return yield* Effect.fail( + new Error( + `task_read: session ${params.task_id} is not a direct subagent of the current session. ` + + `Only direct subagent sessions may be read.`, + ), + ) + } + + // Read messages — Session.messages returns them oldest-first, we reverse for newest-first cursor. + const allMessages = yield* sessions + .messages({ sessionID: childSessionID, limit: MAX_LIMIT + 1 }) + .pipe(Effect.catchCause(() => Effect.succeed([] as SessionV1.WithParts[]))) + + // Apply `before` cursor (message ID boundary for pagination). + const beforeID = params.before + const filteredMessages = beforeID + ? allMessages.filter((m) => m.info.id < beforeID) + : allMessages + + // Take newest `limit` messages. + const page = filteredMessages.slice(-limit) + const hasMore = filteredMessages.length > limit + const nextCursor = page[0]?.info.id + + // Read durable state from metadata. + const deepagent = child.metadata?.["deepagent"] as Record | undefined + const subagent = deepagent?.["subagent"] as Record | undefined + const durableState = subagent + ? (subagent["state"] as string | undefined) ?? + (subagent["finished"] === true ? "completed" : "unknown") + : "running" + + // Format transcript lines. + const lines: string[] = [] + for (const msg of page) { + const role = msg.info.role + if (role === "user") { + const textParts = msg.parts + .filter((p): p is SessionV1.TextPart => p.type === "text" && !p.synthetic && !p.ignored) + .map((p) => p.text?.trim()) + .filter(Boolean) + if (textParts.length > 0) { + lines.push(`${truncate(textParts.join(" "), 600)}`) + } + } else if (role === "assistant") { + for (const part of msg.parts) { + if (part.type === "text") { + const rendered = renderTextPart(part) + if (rendered) lines.push(`${truncate(part.text?.trim() ?? "", 600)}`) + } else if (part.type === "tool") { + lines.push(renderToolPart(part)) + } + } + // Mark interrupted/error assistant messages. + const msgInfo = msg.info + if (msgInfo.role === "assistant" && msgInfo.error) { + const name = msgInfo.error.name ?? "error" + const data = msgInfo.error.data + const msg_text = + name === "StructuredOutputError" && data + ? `StructuredOutput failed after ${(data as Record)["retries"] ?? "?"} attempt(s)` + : String(name) + lines.push(` ${truncate(msg_text, 200)}`) + } + } + } + + // Pagination hint at the end. + const moreHint = hasMore && nextCursor ? ` more="true" before="${nextCursor}"` : "" + const transcript = [ + ``, + ...lines.map((l) => ` ${l}`), + ``, + ].join("\n") + + // Pagination instruction when truncated. + const paginationHint = + hasMore && nextCursor + ? `\n[Truncated. Older messages available. Call task_read({ task_id: "${childSessionID}", before: "${nextCursor}" }) for the previous page.]` + : "" + + return { + title: `Task transcript: ${child.title ?? childSessionID}`, + metadata: { sessionID: childSessionID, state: durableState, messageCount: page.length, hasMore }, + output: transcript + paginationHint, + } + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.catchCause((cause) => Effect.die(cause))), + } + }), +) diff --git a/packages/deepagent-code/src/tool/task_status.ts b/packages/deepagent-code/src/tool/task_status.ts index 4f282846..5d8990f5 100644 --- a/packages/deepagent-code/src/tool/task_status.ts +++ b/packages/deepagent-code/src/tool/task_status.ts @@ -1,13 +1,17 @@ import * as Tool from "./tool" import { BackgroundJob } from "@/background/job" +import { Session } from "@/session/session" import { Effect, Schema } from "effect" +import type { SessionID } from "@/session/schema" const id = "task_status" const DESCRIPTION = [ "List the subagent tasks this session has dispatched (via the task tool), oldest first.", - "For each: status (running/completed/error/cancelled), agent type, title, elapsed time, and session/job id.", - "Use it to check on a subagent that has not reported back before deciding to wait, retry, or take over.", + "For each: session ID, status (running/completed/error/interrupted/cancelled), agent type, title, elapsed time.", + "Uses durable child-session records as the authoritative source so results survive process restarts.", + "Live elapsed time is overlaid from the current process's BackgroundJob registry when available.", + "Use it to check on a subagent before deciding to wait, retry, take over, or call task_read to recover partial work.", "Read-only: it never starts, cancels, or modifies tasks.", ].join(" ") @@ -21,37 +25,100 @@ function formatDuration(ms: number) { return `${minutes}m${seconds % 60}s` } +function formatAge(ts: number): string { + return formatDuration(Date.now() - ts) +} + /** - * v4.0.4 块1 (1c): model-callable, READ-ONLY view over the BackgroundJob registry so the parent - * agent can check on subagents it dispatched (a hung subagent that never reports back is otherwise - * invisible). Jobs are filtered to the CURRENT session via metadata.parentSessionId — other - * sessions' tasks are never exposed. No timeout/takeover behavior is gated here; listing works - * regardless of flags. + * §4.4: durable task_status — two-layer merge. + * + * Layer 1 (authoritative): Session.children(parentID) — durable DB records. Survives process + * restarts and gives the canonical terminal state written by task.ts markFinished. + * + * Layer 2 (advisory): BackgroundJob.list() — current-process live jobs. Overlays elapsed time + * and "running" status for jobs that haven't written their terminal marker yet. + * + * Backward compat: old child sessions with no subagent metadata are shown as "unknown" state, + * not silently omitted or shown as "running" (which would be misleading). */ export const TaskStatusTool = Tool.define( id, Effect.gen(function* () { const background = yield* BackgroundJob.Service + const sessions = yield* Session.Service const run = Effect.fn("TaskStatusTool.execute")(function* ( _params: Schema.Schema.Type, ctx: Tool.Context, ) { - const jobs = (yield* background.list()).filter((job) => job.metadata?.parentSessionId === ctx.sessionID) const now = Date.now() - const lines = jobs.map((job) => { - const duration = formatDuration((job.completed_at ?? now) - job.started_at) - const title = job.title ? ` "${job.title}"` : "" - // Prefer the real subagent type (researcher/reviewer/…) recorded in metadata; fall back to the - // BackgroundJob type only if a job predates the metadata (always "task" there, so uninformative). - const rawType = job.metadata?.subagentType - const agentType = typeof rawType === "string" && rawType.length > 0 ? rawType : job.type - return `- [${job.status}] ${agentType}${title} (${duration}) id=${job.id}` + + // Layer 1: durable child sessions from DB. + const children = yield* sessions.children(ctx.sessionID as SessionID).pipe( + Effect.catchCause(() => Effect.succeed([] as Session.Info[])), + ) + + // Layer 2: live BackgroundJob overlay (process-local, advisory). + const liveJobs = yield* background.list().pipe( + Effect.map((jobs) => { + const m = new Map() + for (const job of jobs) { + if (job.metadata?.parentSessionId === ctx.sessionID) { + const sessionId = job.metadata?.sessionId ?? job.id + if (typeof sessionId === "string") m.set(sessionId, job) + } + } + return m + }), + Effect.catchCause(() => Effect.succeed(new Map())), + ) + + const lines = children.map((child) => { + const deepagent = child.metadata?.["deepagent"] as Record | undefined + const subagent = deepagent?.["subagent"] as Record | undefined + const liveJob = liveJobs.get(child.id) + + // Determine durable state from metadata (written by markFinished). + const durableState = subagent + ? (subagent["state"] as string | undefined) ?? + // compat: old rows used `finished: true` without state field + (subagent["finished"] === true ? "completed" : "unknown") + : "unknown" + + // If a live job is running in the current process, override to "running". + const state = + liveJob && liveJob.status === "running" ? "running" : durableState + + // Prefer live job elapsed time; fall back to metadata timestamp. + const elapsedMs = + liveJob && state === "running" + ? now - liveJob.started_at + : subagent?.["at"] + ? now - (subagent["at"] as number) + : undefined + const duration = elapsedMs !== undefined ? ` (${formatDuration(elapsedMs)})` : "" + + // Agent type and title from session metadata or BackgroundJob. + const rawType = subagent?.["subagentType"] ?? liveJob?.metadata?.subagentType ?? child.agent ?? "task" + const agentType = typeof rawType === "string" && rawType.length > 0 ? rawType : "task" + const title = child.title && !child.title.startsWith("New Conversation") ? ` "${child.title}"` : "" + + // §4.6 recovery hint for interrupted tasks. + const recoverHint = + state === "interrupted" + ? ` [partial work preserved — call task_read({ task_id: "${child.id}" }) to recover]` + : state === "error" + ? ` [call task_read({ task_id: "${child.id}" }) to inspect partial work]` + : "" + + return `- [${state}] ${agentType}${title}${duration} id=${child.id}${recoverHint}` }) + const output = lines.length === 0 ? "No subagent tasks dispatched by this session." : [`${lines.length} subagent task(s) dispatched by this session:`, ...lines].join("\n") + return { title: "Subagent task status", metadata: { count: lines.length }, From 3c3c37fe670648122fc9a58c8a5d4391fb30681a Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 15:08:22 +0800 Subject: [PATCH 06/32] =?UTF-8?q?fix(app):=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=81=A2=E5=A4=8D=E4=B8=8A=E6=AC=A1=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=20tab=EF=BC=8C=E6=B6=88=E9=99=A4=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=E5=8F=B3=E4=BE=A7=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:应用重启后路由从 "/" (Home) 开始,无自动导航逻辑。 TabsProvider 的 persisted store 已保存历史 tab 状态,但没有 代码在启动时消费它来恢复导航。 修复:在 RouterRoot 加入 createEffect,当 tabs.ready() 且 location.pathname === "/" 时,直接 navigate 到 tabs.store[0] (最近使用的会话),复现之前"sidecar 启动完成立即显示对话框"的体验。 行为: - 有历史 tab:重启直接恢复到最近会话 - 无历史 tab(全新安装):保持 Home 页面不变 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/app/src/app.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 1568b3df..c298f301 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,7 +9,7 @@ import { Font } from "@deepagent-code/ui/font" import { Splash } from "@deepagent-code/ui/logo" import { ThemeProvider } from "@deepagent-code/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { type BaseRouterProps, Navigate, Route, Router, useLocation, useNavigate } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" import { @@ -45,7 +45,7 @@ import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" -import { TabsProvider } from "@/context/tabs" +import { TabsProvider, tabHref, useTabs } from "@/context/tabs" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" @@ -143,6 +143,21 @@ function SessionProviders(props: ParentProps) { } function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { + const tabs = useTabs() + const navigate = useNavigate() + const location = useLocation() + + // On startup: when persisted tabs finish loading and the app is sitting at "/" (Home), + // navigate directly to the most recently opened session tab so the user lands on their + // last conversation without having to click through the project list. + createEffect(() => { + if (!tabs.ready()) return + if (location.pathname !== "/") return + const first = tabs.store[0] + if (!first) return + navigate(tabHref(first), { replace: true }) + }) + return ( {/*}>*/} From c7c6a99177d71917c12c5dbf93f8c3731a89b15c Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 15:23:33 +0800 Subject: [PATCH 07/32] =?UTF-8?q?feat(app):=20Phase=202=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=AD=90Agent=E7=9B=91=E7=9D=A3=E7=AA=97=E5=8F=A3=20?= =?UTF-8?q?=E2=80=94=20subagents+oversight=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实施 docs/4.0.4_r1.md §3(Phase 2): 1. **API 层(oversight.api.ts)**: - OversightTraceNode 新增 sessionID 字段支持反向选择 - recordHumanTakeover 接收可选 sessionID 参数 2. **Oversight Dashboard(oversight-dashboard.tsx)**: - 新增 OversightDashboardProps:selectedSessionID + onSessionSelect - takeover 自动携带选中的 session ID - rollback 输入预填选中的 session ID(可覆盖) - trace 节点支持反向选择(点击跳转到对应子Agent) 3. **子Agent面板(side-panel-subagents.tsx)**: - 新增 selectedSessionID 状态(auto-select: running → interrupted → 最近) - 点击行选择监督对象,[打开]按钮导航到完整会话 - capability-gated:v4MultiAgentRuntime ON 时嵌入 OversightDashboard - 新增 interrupted 状态识别 4. **右侧面板(session-side-panel.tsx)**: - 移除独立的 oversight panel entry、import、Match case - 移除 oversight capability 检查和 approvals resource - badge 合并:subagents = running + interrupted 总数 - 移除未使用的 useSDK、fetchCapabilities、createResource 5. **持久化迁移(layout.tsx)**: - rightPanelMode 读取时自动映射 "oversight" → "subagents" - SessionView 类型保留 "oversight" 仅用于向后兼容 **技术要点**: - 单一监督入口,选中态驱动 takeover/rollback/trace 目标 - capability 关闭时只隐藏 workspace 级功能,保留基础列表 - rail badge 覆盖所有需要注意的状态(§3.5) - 旧 oversight 持久化状态无缝迁移,用户无感知 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../deepagent/oversight-dashboard.tsx | 46 ++++-- .../src/components/deepagent/oversight.api.ts | 5 +- packages/app/src/context/layout.tsx | 10 +- .../src/pages/session/session-side-panel.tsx | 67 +++----- .../pages/session/side-panel-subagents.tsx | 147 ++++++++++++++---- 5 files changed, 191 insertions(+), 84 deletions(-) diff --git a/packages/app/src/components/deepagent/oversight-dashboard.tsx b/packages/app/src/components/deepagent/oversight-dashboard.tsx index 58ec0cd5..09f72cd3 100644 --- a/packages/app/src/components/deepagent/oversight-dashboard.tsx +++ b/packages/app/src/components/deepagent/oversight-dashboard.tsx @@ -55,7 +55,16 @@ function MetricCard(props: { label: string; value: string; tone?: "ok" | "warn" ) } -export const OversightDashboard: Component = () => { +// Phase 2: OversightDashboard now accepts a selected subagent session ID so takeover/rollback +// automatically target the chosen session, and trace nodes can reverse-select a subagent. +export type OversightDashboardProps = { + /** When provided, takeover and rollback default to this session ID. */ + selectedSessionID?: string + /** Called when a trace node with a sessionID is clicked so the caller can select that subagent. */ + onSessionSelect?: (sessionID: string) => void +} + +export const OversightDashboard: Component = (props) => { const sdk = useSDK() const language = useLanguage() const client = () => sdk.client as unknown as OversightClient @@ -118,7 +127,9 @@ export const OversightDashboard: Component = () => { if (!reason) return setTakeoverBusy(true) setTakeoverNote(null) - const result = await recordHumanTakeover(client(), { reason }) + // Phase 2: automatically pass the selected session ID when present. + const sessionID = props.selectedSessionID + const result = await recordHumanTakeover(client(), { reason, ...(sessionID ? { sessionID } : {}) }) setTakeoverBusy(false) if (result.ok) { setTakeoverReason("") @@ -133,13 +144,17 @@ export const OversightDashboard: Component = () => { } // ── §D2 rollback (P4.4) ───────────────────────────────────────────────────────── - const [rollbackSession, setRollbackSession] = createSignal("") + // Phase 2: rollback input is pre-seeded with the selected session ID. Users may override it. const [rollbackReason, setRollbackReason] = createSignal("") const [rollbackBusy, setRollbackBusy] = createSignal(false) const [rollbackNote, setRollbackNote] = createSignal(null) + // The effective session ID for rollback: the input field overrides; falls back to selectedSessionID. + const [rollbackSessionOverride, setRollbackSessionOverride] = createSignal("") + const effectiveRollbackSession = () => rollbackSessionOverride() || props.selectedSessionID || "" + const submitRollback = async () => { - const sessionID = rollbackSession().trim() + const sessionID = effectiveRollbackSession().trim() if (!sessionID) return const reason = rollbackReason().trim() setRollbackBusy(true) @@ -147,7 +162,7 @@ export const OversightDashboard: Component = () => { const result = await recordRollback(client(), { sessionID, ...(reason ? { reason } : {}) }) setRollbackBusy(false) if (result.ok) { - setRollbackSession("") + setRollbackSessionOverride("") setRollbackReason("") setRollbackNote( result.record?.outcome === "noop" @@ -328,6 +343,17 @@ export const OversightDashboard: Component = () => {
{fmtTime(node.createdAt)}
+ {/* Phase 2: if this node is tied to a specific subagent session, offer + a reverse-select link so the user can jump to that subagent row. */} + + + )} @@ -372,11 +398,13 @@ export const OversightDashboard: Component = () => {

{language.t("oversight.rollback.title")}

{language.t("oversight.rollback.description")}

+ {/* Phase 2: when a subagent is selected the placeholder is replaced by the session ID. + Typing in this field overrides the pre-selected session. */} setRollbackSession(e.currentTarget.value)} + placeholder={props.selectedSessionID || language.t("oversight.rollback.sessionPlaceholder")} + value={rollbackSessionOverride()} + onInput={(e) => setRollbackSessionOverride(e.currentTarget.value)} />