diff --git a/bun.lock b/bun.lock index 4585ed4d5..3e31e89d1 100644 --- a/bun.lock +++ b/bun.lock @@ -174,7 +174,7 @@ }, "packages/miaopan-code": { "name": "miaopan-code", - "version": "0.1.3", + "version": "0.2.3", "bin": { "miaopan-code": "./bin/miaopan-code", }, diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts index a46e4f935..c4d11c405 100644 --- a/packages/core/src/i18n.ts +++ b/packages/core/src/i18n.ts @@ -1271,6 +1271,11 @@ export const messages = { }, "tool.error.skill": { "zh-CN": "无法加载技能 {{name}}", en: "Unable to load skill {{name}}" }, "tool.error.task_cancelled": { "zh-CN": "任务已取消", en: "Task cancelled" }, + "tool.error.subagent_interrupted": { + "zh-CN": + "子代理任务被中断。要继续此任务,请使用相同的 subagent_type 并设置 task_id 为 {{sessionId}} 重新调用 task 工具,子代理将从上次中断处继续执行{{detail}}", + en: "The subagent task was interrupted. To resume, call the task tool again with the same subagent_type and task_id set to {{sessionId}}; the subagent will continue from where it was interrupted{{detail}}", + }, "tool.error.unknown_agent": { "zh-CN": "未知代理类型:{{type}} 不是有效的代理类型", en: "Unknown agent type: {{type}} is not a valid agent type", @@ -2472,8 +2477,9 @@ export const messages = { en: 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.', }, "tool.param.task_resume": { - "zh-CN": "仅在要恢复之前的任务时设置;传入之前的 task_id 可继续同一代理会话,而不是创建新会话", - en: "Set this only when resuming a previous task; pass a prior task_id to continue the same subagent session instead of creating a fresh one", + "zh-CN": + "仅在要恢复之前的任务时设置;传入之前的 task_id 可继续同一代理会话,而不是创建新会话。如果子代理被中断,传入此参数可从上次中断处继续执行", + en: "Set this only when resuming a previous task; pass a prior task_id to continue the same subagent session instead of creating a fresh one. If the subagent was interrupted, passing this will resume from where it left off", }, "tool.param.task_background": { "zh-CN": "在后台运行代理;完成后会收到通知。不要休眠、轮询或主动检查进度", diff --git a/packages/miaopan-code/src/session/prompt.ts b/packages/miaopan-code/src/session/prompt.ts index 20a050c07..d9ac8e90a 100644 --- a/packages/miaopan-code/src/session/prompt.ts +++ b/packages/miaopan-code/src/session/prompt.ts @@ -143,6 +143,7 @@ const layer = Layer.effect( cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)), + continue: (input: LoopInput) => continueSession(input).pipe(Effect.catch(Effect.die)), } satisfies TaskPromptOps }) @@ -371,13 +372,19 @@ const layer = Layer.effect( assistantMessage.time.completed = Date.now() yield* sessions.updateMessage(assistantMessage) if (part.state.status === "running") { + const cfg = yield* config.get() + const sessionId = part.state.metadata?.sessionId as string | undefined yield* sessions.updatePart({ ...part, state: { status: "error", - error: t((yield* config.get()).language, "error.tool_cancelled"), + error: t(cfg.language, "error.tool_cancelled"), time: { start: part.state.time.start, end: Date.now() }, - metadata: part.state.metadata, + metadata: { + ...(part.state.metadata ?? {}), + interrupted: true, + ...(sessionId ? { sessionId } : {}), + }, input: part.state.input, }, } satisfies SessionV1.ToolPart) @@ -419,13 +426,24 @@ const layer = Layer.effect( } if (!result) { + const stateMetadata = part.state.status === "pending" ? undefined : part.state.metadata + const wasInterrupted = stateMetadata?.interrupted === true + const sessionId = stateMetadata?.sessionId as string | undefined + const cfg = yield* config.get() yield* sessions.updatePart({ ...part, state: { status: "error", - error: t((yield* config.get()).language, "error.tool_execution_failed", { - detail: error ? `: ${error.message}` : "", - }), + error: wasInterrupted + ? sessionId + ? t(cfg.language, "tool.error.subagent_interrupted", { + detail: error ? `: ${error.message}` : "", + sessionId, + }) + : t(cfg.language, "error.tool_cancelled") + : t(cfg.language, "error.tool_execution_failed", { + detail: error ? `: ${error.message}` : "", + }), time: { start: part.state.status === "running" ? part.state.time.start : Date.now(), end: Date.now(), diff --git a/packages/miaopan-code/src/tool/task.ts b/packages/miaopan-code/src/tool/task.ts index 2117d92dd..95596973f 100644 --- a/packages/miaopan-code/src/tool/task.ts +++ b/packages/miaopan-code/src/tool/task.ts @@ -11,7 +11,7 @@ import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" -import { Effect, Exit, Schema, Scope } from "effect" +import { Effect, Exit, Option, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import { Database } from "@miaopan-code/core/database/database" @@ -25,6 +25,7 @@ export interface TaskPromptOps { cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect + continue(input: SessionPrompt.LoopInput): Effect.Effect } const id = "task" @@ -227,6 +228,23 @@ export const TaskTool = Tool.define( const oai = parentMessage.info.role === "user" ? parentMessage.info.oai : undefined const runTask = Effect.fn("TaskTool.runTask")(function* () { + if (session) { + const lastAssistant = yield* sessions + .findMessage(nextSession.id, (message) => message.info.role === "assistant") + .pipe(Effect.orDie) + const wasInterrupted = + Option.isSome(lastAssistant) && + lastAssistant.value.info.role === "assistant" && + lastAssistant.value.info.error?.name === "MessageAbortedError" + if (wasInterrupted) { + const result = yield* ops.continue({ + sessionID: nextSession.id, + }) + const text = result.parts.findLast((item) => item.type === "text")?.text ?? "" + if (!builtinReview) return text + return Review.renderOutput(Review.parseOutput(text), language) + } + } const parts = yield* ops.resolvePromptParts(params.prompt) const result = yield* ops.prompt({ messageID: MessageID.ascending(), diff --git a/packages/miaopan-code/test/tool/task.test.ts b/packages/miaopan-code/test/tool/task.test.ts index 198ef712a..4eaa076e8 100644 --- a/packages/miaopan-code/test/tool/task.test.ts +++ b/packages/miaopan-code/test/tool/task.test.ts @@ -127,6 +127,13 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; opts?.onPrompt?.(input) return reply(input, opts?.text ?? "done") }), + continue: (input) => + Effect.sync(() => + reply( + { ...input, messageID: MessageID.ascending(), parts: [{ type: "text" as const, text: "continued" }] }, + opts?.text ?? "continued", + ), + ), } } @@ -317,6 +324,251 @@ describe("tool.task", () => { }), ) + it.instance("execute continues an interrupted subagent via task_id", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Interrupted child" }) + // Seed the child with a user message and an interrupted assistant message + const childUser = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: child.id, + agent: "general", + model: ref, + time: { created: Date.now() }, + }) + const childAssistant: SessionV1.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: childUser.id, + sessionID: child.id, + mode: "general", + agent: "general", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + } + yield* sessions.updateMessage(childAssistant) + + const tool = yield* TaskTool + const def = yield* tool.init() + let continued = false + let prompted = false + const promptOps: TaskPromptOps = { + ...stubOps({ text: "resumed" }), + continue: () => + Effect.sync(() => { + continued = true + return reply( + { + sessionID: child.id, + parts: [{ type: "text" as const, text: "continued" }], + } as SessionPrompt.PromptInput, + "continued", + ) + }), + prompt: () => + Effect.sync(() => { + prompted = true + return reply( + { + sessionID: child.id, + parts: [{ type: "text" as const, text: "prompted" }], + } as SessionPrompt.PromptInput, + "prompted", + ) + }), + } + + const result = yield* def.execute( + { + description: "resume work", + prompt: "continue the previous task", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(continued).toBe(true) + expect(prompted).toBe(false) + expect(result.output).toContain("continued") + }), + ) + + it.instance("execute sends fresh prompt when resuming a non-interrupted session", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Completed child" }) + // Seed the child with a user message and a completed (no error) assistant message + const childUser = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: child.id, + agent: "general", + model: ref, + time: { created: Date.now() }, + }) + const childAssistant: SessionV1.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: childUser.id, + sessionID: child.id, + mode: "general", + agent: "general", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now(), completed: Date.now() }, + finish: "stop", + } + yield* sessions.updateMessage(childAssistant) + + const tool = yield* TaskTool + const def = yield* tool.init() + let continued = false + let prompted = false + const promptOps: TaskPromptOps = { + ...stubOps({ text: "fresh" }), + continue: () => + Effect.sync(() => { + continued = true + return reply( + { + sessionID: child.id, + parts: [{ type: "text" as const, text: "continued" }], + } as SessionPrompt.PromptInput, + "continued", + ) + }), + prompt: (input) => + Effect.sync(() => { + prompted = true + return reply(input, "fresh") + }), + } + + const result = yield* def.execute( + { + description: "new task", + prompt: "do something different", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(continued).toBe(false) + expect(prompted).toBe(true) + expect(result.output).toContain("fresh") + }), + ) + + it.instance("execute sends fresh prompt when the previous subagent failed without interruption", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Failed child" }) + const childUser = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: child.id, + agent: "general", + model: ref, + time: { created: Date.now() }, + }) + yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: childUser.id, + sessionID: child.id, + mode: "general", + agent: "general", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now(), completed: Date.now() }, + error: { name: "ProviderAuthError", data: { providerID: ref.providerID, message: "Failed" } }, + } satisfies SessionV1.Assistant) + + const tool = yield* TaskTool + const def = yield* tool.init() + let continued = false + let prompted = false + const promptOps: TaskPromptOps = { + ...stubOps({ text: "fresh" }), + continue: (input) => { + continued = true + return Effect.succeed( + reply( + { + ...input, + messageID: MessageID.ascending(), + parts: [{ type: "text", text: "continued" }], + }, + "continued", + ), + ) + }, + prompt: (input) => { + prompted = true + return Effect.succeed(reply(input, "fresh")) + }, + } + + const result = yield* def.execute( + { + description: "retry failed work", + prompt: "try a different approach", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(continued).toBe(false) + expect(prompted).toBe(true) + expect(result.output).toContain("fresh") + }), + ) + it.instance("plan mode persists only mode metadata when resuming a task session", () => Effect.gen(function* () { const sessions = yield* Session.Service @@ -883,6 +1135,26 @@ describe("tool.task", () => { ready.resolve(input) return cancelled.promise }).pipe(Effect.as(reply(input, "cancelled"))), + continue: (input) => + Effect.promise(() => { + ready.resolve({ + ...input, + messageID: MessageID.ascending(), + parts: [{ type: "text" as const, text: "continued" }], + } as SessionPrompt.PromptInput) + return cancelled.promise + }).pipe( + Effect.as( + reply( + { + ...input, + messageID: MessageID.ascending(), + parts: [{ type: "text" as const, text: "continued" }], + } as SessionPrompt.PromptInput, + "cancelled", + ), + ), + ), } const fiber = yield* def @@ -1074,6 +1346,20 @@ describe("tool.task", () => { return reply(input, "background done") }) }, + continue: (input) => + Effect.gen(function* () { + runs += 1 + yield* Deferred.succeed(ready, undefined) + yield* Deferred.await(done) + return reply( + { + ...input, + messageID: MessageID.ascending(), + parts: [{ type: "text" as const, text: "continued" }], + } as SessionPrompt.PromptInput, + "background continued", + ) + }), } const fiber = yield* def