From 0a6462ef90bf62ebbd03c17d5425a0b740bb2c00 Mon Sep 17 00:00:00 2001 From: zhanba Date: Thu, 6 Aug 2026 17:44:54 +0800 Subject: [PATCH] feat(agents): support background subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow subagents to run concurrently while surfacing their progress and results in CLI and VS Code conversations. 🤖 Generated with [Pochi](https://getpochi.com) | [Task](https://app.getpochi.com/share/p-9a7583204ff24d8eac5f035f14d7c80b) Co-Authored-By: Pochi --- packages/cli/src/cli.ts | 1 + packages/cli/src/running-task-adaptor.ts | 35 +- packages/cli/src/task-runner.ts | 148 ++++++- packages/cli/src/tools/new-task.ts | 27 +- packages/cli/src/types.ts | 10 + packages/common/src/base/formatters.ts | 1 + packages/common/src/base/index.ts | 17 +- packages/common/src/base/subagent.ts | 53 +++ .../task-executor/task-executor.ts | 90 +++- .../src/chat/flexible-chat-transport.ts | 7 + packages/livekit/src/chat/live-chat-kit.ts | 76 +++- .../chat/middlewares/new-task-middleware.ts | 1 + packages/livekit/src/index.ts | 2 + .../livekit/src/livestore/default-schema.ts | 15 + packages/livekit/src/task-utils.ts | 77 +++- packages/livekit/src/types.ts | 4 + packages/tools/src/new-task.ts | 13 + .../src/components/message/message-list.tsx | 5 + .../components/message/subagent-results.tsx | 56 +++ .../src/components/task-thread.tsx | 21 +- .../background-task-debug-panel.tsx | 343 --------------- ...nel.test.tsx => background-tasks.test.tsx} | 65 ++- .../chat/components/background-tasks.tsx | 413 ++++++++++++++++++ .../chat/components/chat-toolbar.test.tsx | 6 +- .../features/chat/components/chat-toolbar.tsx | 41 +- .../hooks/use-background-subtask-results.ts | 60 +++ .../features/chat/hooks/use-chat-submit.ts | 11 +- .../lib/fixed-state-tool-call-life-cycle.ts | 6 + .../features/chat/lib/tool-call-life-cycle.ts | 83 +++- .../vscode-webui/src/features/chat/page.tsx | 9 +- .../tools/components/new-task/index.tsx | 116 ++++- .../tools/hooks/use-live-sub-task.tsx | 9 + .../vscode-webui/src/i18n/locales/en.json | 13 +- .../vscode-webui/src/i18n/locales/jp.json | 15 +- .../vscode-webui/src/i18n/locales/ko.json | 13 +- .../vscode-webui/src/i18n/locales/zh.json | 14 +- .../src/lib/vscode-running-task-adaptor.ts | 39 ++ 37 files changed, 1504 insertions(+), 411 deletions(-) create mode 100644 packages/common/src/base/subagent.ts create mode 100644 packages/vscode-webui/src/components/message/subagent-results.tsx delete mode 100644 packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx rename packages/vscode-webui/src/features/chat/components/{background-task-debug-panel.test.tsx => background-tasks.test.tsx} (59%) create mode 100644 packages/vscode-webui/src/features/chat/components/background-tasks.tsx create mode 100644 packages/vscode-webui/src/features/chat/hooks/use-background-subtask-results.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index bfeb2e5b4f..078697cde4 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -389,6 +389,7 @@ const program = new Command() parentFileStateCache, autoMemoryManager, projectMemoryEnabled, + resolveSubTaskLLM, }); const taskMemory = autoCompactEnabled ? {} : undefined; const projectMemory = projectMemoryEnabled diff --git a/packages/cli/src/running-task-adaptor.ts b/packages/cli/src/running-task-adaptor.ts index 282a1a3be1..3b3255414b 100644 --- a/packages/cli/src/running-task-adaptor.ts +++ b/packages/cli/src/running-task-adaptor.ts @@ -40,6 +40,9 @@ interface CliRunningTaskAdaptorOptions { parentFileStateCache?: FileStateCache; autoMemoryManager?: AutoMemoryManager; projectMemoryEnabled?: boolean; + resolveSubTaskLLM?: ( + customAgent: ValidCustomAgentFile, + ) => Promise; } export class CliRunningTaskAdaptor implements RunningTaskAdaptor { @@ -56,6 +59,8 @@ export class CliRunningTaskAdaptor implements RunningTaskAdaptor { private readonly fileStateCaches = new Map(); private readonly autoMemoryManager: AutoMemoryManager; private readonly projectMemoryEnabled: boolean; + private readonly resolveSubTaskLLM: CliRunningTaskAdaptorOptions["resolveSubTaskLLM"]; + private readonly taskLLMs = new Map(); private readonly backgroundJobManagers = new Map< string, BackgroundJobManager @@ -75,6 +80,7 @@ export class CliRunningTaskAdaptor implements RunningTaskAdaptor { this.autoMemoryManager = options.autoMemoryManager ?? new AutoMemoryManager(); this.projectMemoryEnabled = options.projectMemoryEnabled ?? true; + this.resolveSubTaskLLM = options.resolveSubTaskLLM; } dispose() { @@ -113,6 +119,33 @@ export class CliRunningTaskAdaptor implements RunningTaskAdaptor { }; } + async resolveTaskLLM( + context: Parameters>[0], + ): Promise { + const { taskState } = context; + if (taskState.useCase !== "subagent" || !taskState.agentType) { + return undefined; + } + const agent = this.customAgents?.find( + (a) => a.name === taskState.agentType, + ); + if (!agent?.model) return undefined; + + try { + const llm = await this.resolveSubTaskLLM?.(agent); + if (llm) { + this.taskLLMs.set(context.taskId, llm); + } + return llm; + } catch (error) { + logger.warn( + `Failed to resolve model "${agent.model}" for agent ${agent.name}; falling back to the default model`, + error, + ); + return undefined; + } + } + async executeToolCall( args: Parameters[0], ) { @@ -134,7 +167,7 @@ export class CliRunningTaskAdaptor implements RunningTaskAdaptor { this.createToolCallOptions(args.taskId), this.cwd, args.abortSignal, - this.llm.contentType, + (this.taskLLMs.get(args.taskId) ?? this.llm).contentType, ), ); diff --git a/packages/cli/src/task-runner.ts b/packages/cli/src/task-runner.ts index 373cfb95a6..4958226c94 100644 --- a/packages/cli/src/task-runner.ts +++ b/packages/cli/src/task-runner.ts @@ -34,6 +34,8 @@ import { type LiveKitStore, type Message, type Task, + catalog, + createSubAgentResultNotification, processContentOutput, } from "@getpochi/livekit"; import { LiveChatKit } from "@getpochi/livekit/node"; @@ -214,6 +216,7 @@ export class TaskRunner { private asyncWaitTimeoutInMs: number; private abortSignal?: AbortSignal; + private notifiedBackgroundSubTaskIds?: Set; readonly taskId: string; @@ -289,6 +292,15 @@ export class TaskRunner { options.onSubTaskCreated?.(runner); return runner; }, + backgroundSubTask: async (args) => { + const backgroundSubTask = this.chatKit.backgroundSubTask; + if (!backgroundSubTask) { + throw new Error( + "Background subagent execution is not available in this context.", + ); + } + await backgroundSubTask(args); + }, }; this.stepCount = new StepCount(options.maxSteps, options.maxRetries); this.chatKit = new LiveChatKit({ @@ -474,6 +486,117 @@ export class TaskRunner { : undefined; } + private readBackgroundSubTasks(): Task[] { + return this.store + .query(catalog.queries.makeSubTaskQuery(this.taskId)) + .filter((task) => task.background); + } + + private hasRunningBackgroundSubTasks(): boolean { + return this.readBackgroundSubTasks().some( + (task) => + task.status === "pending-model" || task.status === "pending-tool", + ); + } + + /** + * Lazily seeded from the conversation so subagents already notified in a + * previous run of a resumed task are not delivered twice. Keyed by + * taskId:status so a retried task's new outcome notifies again. + */ + private getNotifiedBackgroundSubTaskIds(): Set { + if (!this.notifiedBackgroundSubTaskIds) { + const ids = new Set(); + for (const message of this.chat.messages) { + for (const part of message.parts) { + if (part.type === "data-subagent-results") { + for (const result of part.data.results) { + ids.add(`${result.taskId}:${result.status}`); + } + } + } + } + this.notifiedBackgroundSubTaskIds = ids; + } + return this.notifiedBackgroundSubTaskIds; + } + + /** + * Appends the results of finished background subagents to the conversation + * as a user message. + * @returns true if a message was injected + */ + private injectCompletedSubAgentResults(): boolean { + const notified = this.getNotifiedBackgroundSubTaskIds(); + const done = this.readBackgroundSubTasks().filter( + (task) => + (task.status === "completed" || task.status === "failed") && + !notified.has(`${task.id}:${task.status}`), + ); + if (done.length === 0) { + return false; + } + const results = done.map((task) => { + notified.add(`${task.id}:${task.status}`); + return createSubAgentResultNotification(this.store, task); + }); + this.chat.appendOrReplaceMessage({ + id: crypto.randomUUID(), + role: "user", + parts: [{ type: "data-subagent-results", data: { results } }], + }); + return true; + } + + private async waitForBackgroundSubAgents(): Promise { + const ids = this.readBackgroundSubTasks() + .filter( + (task) => + task.status === "pending-model" || task.status === "pending-tool", + ) + .map((task) => task.id); + if (ids.length === 0) return; + + const spinner = createSpinner( + `Waiting for ${ids.length} background subagent(s) to complete (timeout: ${this.asyncWaitTimeoutInMs}ms)...`, + ).start(); + + let timeoutId: ReturnType | undefined; + const timeout = new Promise<"timeout">((resolve) => { + timeoutId = setTimeout( + () => resolve("timeout"), + this.asyncWaitTimeoutInMs, + ); + }); + const aborted = new Promise<"aborted">((resolve) => { + if (this.abortSignal?.aborted) { + resolve("aborted"); + return; + } + this.abortSignal?.addEventListener("abort", () => resolve("aborted"), { + once: true, + }); + }); + const done = Promise.all( + ids.map((id) => this.chatKit.waitForBackgroundTaskDone(id)), + ).then(() => "done" as const); + + try { + const result = await Promise.race([done, timeout, aborted]); + if (result === "done") { + spinner.succeed("All background subagents completed."); + } else if (result === "timeout") { + spinner.fail( + "Async wait timeout reached; background subagents still running.", + ); + } else { + spinner.fail("Background subagent wait was aborted."); + } + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + } + /** * Drains pending monitor events and appends them to the conversation as * a user message. @@ -515,6 +638,10 @@ export class TaskRunner { return "next"; } + if (this.injectCompletedSubAgentResults()) { + return "next"; + } + // Check for pending background jobs const hasPendingJobs = this.backgroundJobManager.hasPendingJobs(); @@ -532,6 +659,19 @@ export class TaskRunner { } } + // Running background subagents get the same grace period before the + // task is allowed to complete; their results are fed back like + // background job results. + if ( + this.asyncWaitTimeoutInMs > 0 && + this.hasRunningBackgroundSubTasks() + ) { + await this.waitForBackgroundSubAgents(); + if (this.injectCompletedSubAgentResults()) { + return "next"; + } + } + if (this.attemptCompletionHook && isResultMessage(lastMessage)) { const attemptCompletionPart = lastMessage.parts?.find( (p) => @@ -567,10 +707,12 @@ export class TaskRunner { if (result === "next") { this.stepCount.throwIfReachedMaxSteps(); - // Deliver monitor events between rounds so the model sees them in - // the upcoming inference. Retry rounds are skipped: they resend a - // prepared message and an interleaved user message would break that. + // Deliver monitor events and background subagent results between + // rounds so the model sees them in the upcoming inference. Retry + // rounds are skipped: they resend a prepared message and an + // interleaved user message would break that. this.injectPendingMonitorEvents(); + this.injectCompletedSubAgentResults(); } if (result === "retry") { this.stepCount.throwIfReachedMaxRetries(); diff --git a/packages/cli/src/tools/new-task.ts b/packages/cli/src/tools/new-task.ts index f357e03131..eebd88b280 100644 --- a/packages/cli/src/tools/new-task.ts +++ b/packages/cli/src/tools/new-task.ts @@ -1,7 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { getLogger } from "@getpochi/common"; +import { + constants, + createBackgroundSubAgentStartedResult, + getLogger, +} from "@getpochi/common"; import type { ValidCustomAgentFile } from "@getpochi/common/vscode-webui-bridge"; import { formatFollowupQuestions } from "@getpochi/livekit"; import type { ClientTools, ToolFunctionType } from "@getpochi/tools"; @@ -27,7 +31,7 @@ const SubTaskBrowserAgentMaxSteps = 65535; */ export const newTask = (options: ToolCallOptions): ToolFunctionType => - async ({ _meta, agentType }, { toolCallId }) => { + async ({ _meta, agentType, runInBackground }, { toolCallId }) => { const taskId = _meta?.uid || crypto.randomUUID(); if (!options.createSubTaskRunner) { @@ -49,6 +53,25 @@ export const newTask = } } + // The browser agent needs a per-task browser session and recording, + // which are only wired up in the foreground path. The todo-completion + // agent resolves todos through the foreground result flow. + const supportsBackground = + customAgent?.name !== "browser" && + agentType !== constants.AttemptTodoCompletionAgentName; + if (runInBackground && supportsBackground) { + if (!options.backgroundSubTask) { + throw new Error( + "Background subagent execution is not available in this context.", + ); + } + await options.backgroundSubTask({ taskId, agentType }); + return { + result: createBackgroundSubAgentStartedResult(taskId), + backgroundTaskId: taskId, + }; + } + const subTaskLLM = customAgent?.model ? await options.resolveSubTaskLLM?.(customAgent) : undefined; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 6d21a811bc..3ec15a08cd 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -56,6 +56,16 @@ export interface ToolCallOptions { overrideOptions?: CreateSubTaskRunnerOverrideOptions, ) => TaskRunner; + /** + * Converts an already-inited subtask into a background subagent task + * executed by the TaskExecutor (optional, used by newTask tool with + * runInBackground). + */ + backgroundSubTask?: (options: { + taskId: string; + agentType?: string; + }) => Promise; + /** * MCP Hub instance for accessing MCP server tools */ diff --git a/packages/common/src/base/formatters.ts b/packages/common/src/base/formatters.ts index 3f2fd2a49a..d9daba6e0c 100644 --- a/packages/common/src/base/formatters.ts +++ b/packages/common/src/base/formatters.ts @@ -103,6 +103,7 @@ function removeSystemReminder(messages: UIMessage[]): UIMessage[] { x.type === "data-reviews" || x.type === "data-bash-outputs" || x.type === "data-monitor-events" || + x.type === "data-subagent-results" || isStaticToolUIPart(x), ) ) { diff --git a/packages/common/src/base/index.ts b/packages/common/src/base/index.ts index 06910eaa5c..fee54b7d94 100644 --- a/packages/common/src/base/index.ts +++ b/packages/common/src/base/index.ts @@ -26,6 +26,12 @@ export { MonitorRateLimitedReason, } from "./monitor"; +export { + type SubAgentResultNotification, + createBackgroundSubAgentStartedResult, + formatSubAgentNotifications, +} from "./subagent"; + export { SocialLinks } from "./social"; export * as constants from "./constants"; @@ -113,10 +119,19 @@ export type ContextWindowUsage = { projectMemory: number; }; +export const BackgroundTaskUseCase = z.enum([ + ...ForkAgentUseCase.options, + "subagent", +]); + +export type BackgroundTaskUseCase = z.infer; + export interface BackgroundTaskState { tools?: readonly ToolSpecInput[]; parentTaskId?: string; - useCase?: ForkAgentUseCase; + useCase?: BackgroundTaskUseCase; + /** Custom agent name for `subagent` tasks; undefined runs the generic agent. */ + agentType?: string; /** Step-start count inherited from the parent, excluded from the max-step guard. */ baselineStepCount?: number; } diff --git a/packages/common/src/base/subagent.ts b/packages/common/src/base/subagent.ts new file mode 100644 index 0000000000..ed629f4eb7 --- /dev/null +++ b/packages/common/src/base/subagent.ts @@ -0,0 +1,53 @@ +/** + * Host-agnostic protocol for background subagent (newTask with + * runInBackground) result delivery. When a background subagent task + * completes, its result is injected into the parent conversation as a + * `data-subagent-results` part, rendered for the LLM with + * formatSubAgentNotifications. Shared by the CLI task runner and the + * VSCode webview so both hosts speak the same protocol. + */ + +import { prompts } from "./prompts"; + +/** One completed (or failed) background subagent, ready for injection. */ +export interface SubAgentResultNotification { + taskId: string; + agentType?: string; + /** The subtask title (the newTask description). */ + title?: string; + status: "completed" | "failed"; + result: string; +} + +/** Tool result returned by newTask when the subagent starts in the background. */ +export function createBackgroundSubAgentStartedResult(taskId: string): string { + return `Subagent started in the background (backgroundTaskId: ${taskId}). Its result will arrive later as a system notification; do not assume or fabricate its outcome before that notification arrives.`; +} + +function renderSubAgentResult( + notification: SubAgentResultNotification, +): string { + const title = notification.title ? ` "${notification.title}"` : ""; + const agent = notification.agentType + ? ` (agentType: ${notification.agentType})` + : ""; + const header = + notification.status === "completed" + ? `Subagent ${notification.taskId}${title}${agent} completed with result:` + : `Subagent ${notification.taskId}${title}${agent} failed:`; + return `${header}\n${notification.result}`; +} + +/** + * Renders one delivery of background subagent results as the system-reminder + * user message injected into the parent conversation. System reminders are + * kept on the LLM path but hidden from the chat UI. + */ +export function formatSubAgentNotifications( + results: SubAgentResultNotification[], +): string { + const body = results.map(renderSubAgentResult).join("\n\n"); + return prompts.createSystemReminder( + `The following background subagents started with the newTask tool have finished. This is an automated notification, not user input. Review the results and take appropriate action:\n${body}`, + ); +} diff --git a/packages/livekit/src/background-task/task-executor/task-executor.ts b/packages/livekit/src/background-task/task-executor/task-executor.ts index 4aba34fe98..44841384ec 100644 --- a/packages/livekit/src/background-task/task-executor/task-executor.ts +++ b/packages/livekit/src/background-task/task-executor/task-executor.ts @@ -31,11 +31,13 @@ import { import type { BlobStore } from "../../blob-store"; import type { PrepareRequestGetters } from "../../chat/flexible-chat-transport"; import { defaultCatalog as catalog } from "../../livestore"; -import type { LiveKitStore, Message, Task } from "../../types"; +import type { LiveKitStore, Message, RequestData, Task } from "../../types"; const logger = getLogger("TaskExecutor"); const TaskExecutorMaxStep = 50; +/** Generic subagents run arbitrary work, so they get a higher step budget than memory extraction. */ +const TaskExecutorSubagentMaxStep = 256; const TaskExecutorMaxRetry = 8; const TaskExecutorMaxToolRejections = 5; const TaskExecutorMaxConcurrency = 10; @@ -57,6 +59,15 @@ export interface RunningTaskAdaptor { taskId: string; cwd: string | undefined; }): PrepareRequestGetters; + /** + * Resolves a per-task model override (e.g. a subagent's `model` field). + * Returning undefined keeps the adaptor's default model. + */ + resolveTaskLLM?(context: { + taskId: string; + cwd: string | undefined; + taskState: BackgroundTaskState; + }): Promise; executeToolCall(args: TaskExecutorToolCallExecution): Promise; onTaskError?(taskId: string, error: Error): MaybePromise; } @@ -97,7 +108,7 @@ type CreateRunningTaskChatKit = (options: { store: LiveKitStore; blobStore: BlobStore; abortSignal: AbortSignal; - requestUseCase: BackgroundTaskState["useCase"]; + taskState: BackgroundTaskState; getters: PrepareRequestGetters; }) => RunningTaskChatKit; @@ -178,6 +189,33 @@ export class TaskExecutor { } } + /** + * Stops one background task: aborts its running loop and marks it failed + * with an AbortError so `runnableTasks$` stops matching it. Without the + * failed status, the next reconcile would pick the task up again. + */ + async stopTask(taskId: string) { + const runningTask = this.runningTasks.get(taskId); + if (runningTask) { + await runningTask.dispose(); + await runningTask.done.catch(() => undefined); + } + + const task = this.store.query(catalog.queries.makeTaskQuery(taskId)); + if (task && isRunnableTaskStatus(task.status)) { + this.store.commit( + catalog.events.taskFailed({ + id: taskId, + error: { + kind: "AbortError", + message: "Stopped by user.", + }, + updatedAt: new Date(), + }), + ); + } + } + waitForTaskDone(taskId: string): Promise { if (this.disposed) return Promise.resolve(); this.start(); @@ -319,7 +357,7 @@ class RunningTask { try { await this.adaptor.waitUntilReady?.(); this.taskState = (await this.readTaskState(this.taskId)) ?? {}; - this.chatKit = this.createChatKit(this.task); + this.chatKit = await this.createChatKit(this.task); while (!this.abortController.signal.aborted) { const stepResult = await this.step(); @@ -365,16 +403,46 @@ class RunningTask { return this.chatKit.chat; } - private createChatKit(currentTask: Task | undefined) { + private async createChatKit(currentTask: Task | undefined) { + const context = this.createTaskContext(currentTask); + let getters = this.adaptor.getRequestGetters(context); + + const llmOverride = await this.adaptor.resolveTaskLLM?.({ + ...context, + taskState: this.taskState, + }); + if (llmOverride) { + getters = { ...getters, getLLM: () => llmOverride }; + } + + // Subagent tasks store only the agent name; the tool whitelist is + // resolved here so both the request-side tool selection and the + // execution-side validation derive from the same agent definition. + if ( + this.taskState.useCase === "subagent" && + this.taskState.agentType && + !this.taskState.tools + ) { + const agent = getters + .getCustomAgents?.() + ?.find((a) => a.name === this.taskState.agentType); + if (!agent) { + throw new Error( + `Custom agent "${this.taskState.agentType}" not found for background subagent task.`, + ); + } + if (agent.tools) { + this.taskState = { ...this.taskState, tools: agent.tools }; + } + } + return this.createRunningTaskChatKit({ taskId: this.taskId, store: this.store, blobStore: this.blobStore, abortSignal: this.abortController.signal, - requestUseCase: this.taskState.useCase, - getters: this.adaptor.getRequestGetters( - this.createTaskContext(currentTask), - ), + taskState: this.taskState, + getters, }); } @@ -605,7 +673,11 @@ class RunningTask { stepCount - (this.taskState.baselineStepCount ?? 0), ); - if (effectiveStepCount > TaskExecutorMaxStep) { + const maxStep = + this.taskState.useCase === "subagent" + ? TaskExecutorSubagentMaxStep + : TaskExecutorMaxStep; + if (effectiveStepCount > maxStep) { throw new Error("The task failed to complete, max step count reached."); } } diff --git a/packages/livekit/src/chat/flexible-chat-transport.ts b/packages/livekit/src/chat/flexible-chat-transport.ts index 9de2847be9..f69bac1d4b 100644 --- a/packages/livekit/src/chat/flexible-chat-transport.ts +++ b/packages/livekit/src/chat/flexible-chat-transport.ts @@ -9,6 +9,7 @@ import type { } from "@getpochi/common"; import { formatMonitorNotifications, + formatSubAgentNotifications, formatters, prompts, } from "@getpochi/common"; @@ -548,6 +549,12 @@ export function convertDataPartToText( text: formatMonitorNotifications(part.data.batches), }; } + if (part.type === "data-subagent-results") { + return { + type: "text" as const, + text: formatSubAgentNotifications(part.data.results), + }; + } return part; } diff --git a/packages/livekit/src/chat/live-chat-kit.ts b/packages/livekit/src/chat/live-chat-kit.ts index 12635a8623..4b2bcb56fa 100644 --- a/packages/livekit/src/chat/live-chat-kit.ts +++ b/packages/livekit/src/chat/live-chat-kit.ts @@ -18,6 +18,7 @@ import { getToolCallCancelErrorMessage, isReadonlyToolCall, isUserInputToolPart, + parseOutputSchema, } from "@getpochi/tools"; import { Duration } from "@livestore/utils/effect"; import { @@ -407,6 +408,16 @@ export class LiveChatKit< readonly repairMermaid: (chart: string, error: string) => Promise; private consecutiveAutoCompactFailures = 0; + /** + * Converts an existing subtask (created by the newTask middleware) into a + * background subagent task: records its state and flips `background` so the + * TaskExecutor picks it up. Undefined when background tasks are not enabled. + */ + readonly backgroundSubTask?: (options: { + taskId: string; + agentType?: string; + }) => Promise; + constructor({ taskId, abortSignal, @@ -467,22 +478,67 @@ export class LiveChatKit< store, blobStore, abortSignal, - requestUseCase, + taskState, getters, - }) => - new LiveChatKit({ + }) => { + if (taskState.useCase === "subagent") { + // A background subagent behaves like a foreground subtask: + // its own system prompt from the custom agent, not a fork of + // the parent conversation. + const subagentCustomAgent = taskState.agentType + ? getters + .getCustomAgents?.() + ?.find((a) => a.name === taskState.agentType) + : undefined; + const resultSchema = + subagentCustomAgent?._internal?.resultSchema; + return new LiveChatKit({ + taskId, + store, + blobStore, + chatClass: InMemoryChat, + abortSignal, + isSubTask: true, + requestUseCase: "agent", + getters, + customAgent: subagentCustomAgent, + attemptCompletionSchema: resultSchema + ? parseOutputSchema(resultSchema) + : undefined, + }); + } + return new LiveChatKit({ taskId, store, blobStore, chatClass: InMemoryChat, abortSignal, isSubTask: false, - requestUseCase, + requestUseCase: taskState.useCase, getters, systemPromptOverride: this.latestRequestSnapshot?.systemPrompt, - }), + }); + }, }) : undefined; + this.backgroundSubTask = + backgroundTaskStateStore && this.backgroundTaskExecutor + ? async ({ taskId: subTaskId, agentType }) => { + await backgroundTaskStateStore.set(subTaskId, { + parentTaskId: this.taskId, + useCase: "subagent", + agentType, + }); + store.commit( + events.taskBackgrounded({ + id: subTaskId, + updatedAt: new Date(), + }), + ); + this.startBackgroundTasks(); + } + : undefined; + const defaultMemoryParentCwd = () => this.task?.cwd ?? undefined; this.taskMemoryAdaptor = taskMemory && startForkAgent @@ -1063,6 +1119,16 @@ export class LiveChatKit< this.backgroundTaskAdaptor?.dispose?.(); } + waitForBackgroundTaskDone(taskId: string): Promise { + return ( + this.backgroundTaskExecutor?.waitForTaskDone(taskId) ?? Promise.resolve() + ); + } + + stopBackgroundTask(taskId: string): Promise { + return this.backgroundTaskExecutor?.stopTask(taskId) ?? Promise.resolve(); + } + private startBackgroundTasks(): void { if (this.backgroundTasksStarted) return; this.backgroundTasksStarted = true; diff --git a/packages/livekit/src/chat/middlewares/new-task-middleware.ts b/packages/livekit/src/chat/middlewares/new-task-middleware.ts index cff8fbae1e..381d5330df 100644 --- a/packages/livekit/src/chat/middlewares/new-task-middleware.ts +++ b/packages/livekit/src/chat/middlewares/new-task-middleware.ts @@ -100,6 +100,7 @@ export function createNewTaskMiddleware( id: uid, cwd, parentId: parentTaskId, + initTitle: args.description, createdAt: new Date(), initMessages: [ { diff --git a/packages/livekit/src/index.ts b/packages/livekit/src/index.ts index 8354f42c3b..35ebe29018 100644 --- a/packages/livekit/src/index.ts +++ b/packages/livekit/src/index.ts @@ -24,11 +24,13 @@ export type { BlobStore } from "./blob-store"; export { processContentOutput, fileToUri, findBlob } from "./store-blob"; export { + createSubAgentResultNotification, extractAttemptCompletionResult, extractTaskResult, formatFollowupQuestions, getTaskErrorMessage, mapTaskStatusToBackgroundStatus, + restartBackgroundTask, } from "./task-utils"; export type { BackgroundJobStatus, diff --git a/packages/livekit/src/livestore/default-schema.ts b/packages/livekit/src/livestore/default-schema.ts index acb3e6e224..160f60f1fb 100644 --- a/packages/livekit/src/livestore/default-schema.ts +++ b/packages/livekit/src/livestore/default-schema.ts @@ -141,6 +141,13 @@ export const events = { updatedAt: Schema.Date, }), }), + taskBackgrounded: Events.synced({ + name: "v1.TaskBackgrounded", + schema: Schema.Struct({ + id: Schema.String, + updatedAt: Schema.Date, + }), + }), chatStreamStarted: Events.synced({ name: "v1.ChatStreamStarted", schema: Schema.Struct({ @@ -402,6 +409,14 @@ const materializers = State.SQLite.materializers(events, { }) .where({ id }), ], + "v1.TaskBackgrounded": ({ id, updatedAt }) => [ + tables.tasks + .update({ + background: true, + updatedAt, + }) + .where({ id }), + ], "v1.ChatStreamStarted": ({ id, data, diff --git a/packages/livekit/src/task-utils.ts b/packages/livekit/src/task-utils.ts index 0b5c21d827..13cb348aa9 100644 --- a/packages/livekit/src/task-utils.ts +++ b/packages/livekit/src/task-utils.ts @@ -1,7 +1,8 @@ +import type { SubAgentResultNotification } from "@getpochi/common"; import type { AskFollowupQuestionInput, Question } from "@getpochi/tools"; import type { z } from "zod"; import { defaultCatalog as catalog } from "./livestore"; -import type { LiveKitStore, Message } from "./types"; +import type { LiveKitStore, Message, Task } from "./types"; export type TaskStatusLike = | "completed" @@ -86,6 +87,80 @@ export function extractTaskResult(store: LiveKitStore, uid: string): unknown { } } +/** + * Builds the notification for a finished background subagent task, injected + * into the parent conversation as a `data-subagent-results` part. + */ +export function createSubAgentResultNotification( + store: LiveKitStore, + task: Pick, + agentType?: string, +): SubAgentResultNotification { + const title = task.title ?? undefined; + if (task.status === "failed") { + return { + taskId: task.id, + agentType, + title, + status: "failed", + result: getTaskErrorMessage(task.error) ?? "Subagent failed.", + }; + } + + let result: unknown; + try { + result = extractTaskResult(store, task.id); + } catch { + result = undefined; + } + return { + taskId: task.id, + agentType, + title, + status: "completed", + result: + result === undefined + ? "Subagent finished without an explicit result." + : typeof result === "string" + ? result + : JSON.stringify(result), + }; +} + +/** + * Flips a failed background task back to pending-model by re-committing its + * last message, so the TaskExecutor picks it up again. Only failed tasks are + * restarted: reviving a completed task would leave it stuck in pending-model + * (the executor finishes without another status commit) and the reconcile + * loop would pick it up forever. + */ +export function restartBackgroundTask( + store: LiveKitStore, + taskId: string, +): boolean { + const task = store.query(catalog.queries.makeTaskQuery(taskId)); + if (!task?.background) return false; + if (task.status === "pending-model" || task.status === "pending-tool") { + return true; + } + if (task.status !== "failed") return false; + const lastMessage = store + .query(catalog.queries.makeMessagesQuery(taskId)) + .map((x) => x.data as Message) + .at(-1); + if (!lastMessage) return false; + store.commit( + catalog.events.chatStreamStarted({ + id: taskId, + data: lastMessage, + todos: task.todos ? [...task.todos] : [], + updatedAt: new Date(), + modelId: task.modelId ?? undefined, + }), + ); + return true; +} + export function extractAttemptCompletionResult( store: LiveKitStore, uid: string, diff --git a/packages/livekit/src/types.ts b/packages/livekit/src/types.ts index 1c1c875265..684cc97475 100644 --- a/packages/livekit/src/types.ts +++ b/packages/livekit/src/types.ts @@ -5,6 +5,7 @@ import type { MessageMetadata, MonitorEventBatch, Review, + SubAgentResultNotification, TerminalTextSelection, UserEdits, } from "@getpochi/common"; @@ -39,6 +40,9 @@ export type DataParts = { "monitor-events": { batches: MonitorEventBatch[]; }; + "subagent-results": { + results: SubAgentResultNotification[]; + }; }; export type UITools = InferUITools; diff --git a/packages/tools/src/new-task.ts b/packages/tools/src/new-task.ts index ca00bd47b6..44a42093d7 100644 --- a/packages/tools/src/new-task.ts +++ b/packages/tools/src/new-task.ts @@ -72,6 +72,12 @@ export const inputSchema = z.object({ .describe( "Optional. The type of the specialized agent to use for the task. Leave empty (None) to launch a generic sub agent for the task.", ), + runInBackground: z + .boolean() + .optional() + .describe( + "Optional. Run the subagent in the background. The tool returns immediately with a backgroundTaskId; the subagent's result arrives later as a system notification. Never assume or fabricate the result before that notification arrives.", + ), _meta: z .object({ uid: z.string().describe("A unique identifier for the task."), @@ -114,9 +120,16 @@ Usage notes: 4. The agent's outputs should generally be trusted 5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent 6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +7. Set runInBackground to true to keep working while the subagent runs. The call returns immediately with a backgroundTaskId, and the subagent's result is delivered later as a system notification. Until that notification arrives, never report, guess, or fabricate the subagent's result; if asked about it, say the subagent is still running. `.trim(), inputSchema, outputSchema: z.object({ result: z.string().describe("The task completion result."), + backgroundTaskId: z + .string() + .optional() + .describe( + "Present when the subagent was started in the background; identifies the background task whose result will arrive later.", + ), }), }); diff --git a/packages/vscode-webui/src/components/message/message-list.tsx b/packages/vscode-webui/src/components/message/message-list.tsx index b616877450..ad45018e70 100644 --- a/packages/vscode-webui/src/components/message/message-list.tsx +++ b/packages/vscode-webui/src/components/message/message-list.tsx @@ -31,6 +31,7 @@ import type { MermaidContext } from "./mermaid-context"; import { MermaidContextProvider } from "./mermaid-context"; import { MonitorEventsPart } from "./monitor-events"; import { Reviews } from "./reviews"; +import { SubagentResultsPart } from "./subagent-results"; import { UserEditsPart } from "./user-edits"; interface UserEditsCheckpoint { @@ -398,6 +399,10 @@ function Part({ return ; } + if (part.type === "data-subagent-results") { + return ; + } + if (part.type === "data-terminal-context") { return null; } diff --git a/packages/vscode-webui/src/components/message/subagent-results.tsx b/packages/vscode-webui/src/components/message/subagent-results.tsx new file mode 100644 index 0000000000..56157b3102 --- /dev/null +++ b/packages/vscode-webui/src/components/message/subagent-results.tsx @@ -0,0 +1,56 @@ +import { CollapsibleSection } from "@/components/ui/collapsible-section"; +import { cn } from "@/lib/utils"; +import type { SubAgentResultNotification } from "@getpochi/common"; +import { Bot } from "lucide-react"; + +/** + * Visible record of background subagent results (newTask with + * runInBackground) delivered to the model. The LLM receives the same content + * as a system-reminder text; this component keeps the notification visible + * in the chat history. Each subagent renders as its own collapsible section + * so results never blend together. + */ +export const SubagentResultsPart: React.FC<{ + results: SubAgentResultNotification[]; +}> = ({ results }) => { + if (results.length === 0) return null; + + return ( +
+ {results.map((result) => ( + + + + {result.title || result.agentType || "Subagent"} + + {result.title && result.agentType && ( + + {result.agentType} + + )} + + } + actions={ + + {result.status} + + } + > +
+ {result.result} +
+
+ ))} +
+ ); +}; diff --git a/packages/vscode-webui/src/components/task-thread.tsx b/packages/vscode-webui/src/components/task-thread.tsx index 6b36639e79..112fec10b0 100644 --- a/packages/vscode-webui/src/components/task-thread.tsx +++ b/packages/vscode-webui/src/components/task-thread.tsx @@ -27,6 +27,11 @@ export const TaskThread: React.FC<{ messageListClassName?: string; scrollAreaClassName?: string; instantAutoScroll?: boolean; + /** + * Keep user messages in the thread. The default hides them because the + * inline newTask card already shows the prompt as its description. + */ + showUserMessages?: boolean; }> = ({ source, user, @@ -36,6 +41,7 @@ export const TaskThread: React.FC<{ messageListClassName, scrollAreaClassName, instantAutoScroll = false, + showUserMessages = false, }) => { const [isLoading, setIsLoading] = useState(false); const [messages, setMessages] = useState([]); @@ -49,7 +55,10 @@ export const TaskThread: React.FC<{ setMessages(source.messages); }, [source]); - const renderMessages = useMemo(() => prepareForRender(messages), [messages]); + const renderMessages = useMemo( + () => prepareForRender(messages, showUserMessages), + [messages, showUserMessages], + ); const newTaskContainer = useRef(null); const { isAtBottom, scrollToBottom } = useIsAtBottom(newTaskContainer); const isAtBottomRef = useRef(isAtBottom); @@ -161,9 +170,13 @@ function filterTrailingAskFollowupQuestion(messages: Message[]): Message[] { return messages; } -function prepareForRender(messages: Message[]): Message[] { - // Remove user messages. - const filteredMessages = messages.filter((x) => x.role !== "user"); +function prepareForRender( + messages: Message[], + showUserMessages: boolean, +): Message[] { + const filteredMessages = showUserMessages + ? messages + : messages.filter((x) => x.role !== "user"); // Filter out trailing askFollowupQuestion tool calls const withoutTrailingAskFollowup = filterTrailingAskFollowupQuestion(filteredMessages); diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx deleted file mode 100644 index 44033a4dae..0000000000 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx +++ /dev/null @@ -1,343 +0,0 @@ -/** - * BackgroundTaskDebugPanel — dev-mode-only floating debug UI for background tasks. - * - * Renders a thin vertical handle on the right edge of the chat page. Hovering - * the handle opens an overview list of all background tasks (any status). - * Clicking a task opens a slide-out panel that shows the task's messages and - * todos via the reusable component. - * - * Mounted from `features/chat/page.tsx` (only renders when `isDevMode` is true). - * - * This is a developer-only surface, so the user-facing strings here are not - * translated. - */ -/* eslint-disable i18next/no-literal-string */ - -import { TaskThread, type TaskThreadSource } from "@/components/task-thread"; -import { Button } from "@/components/ui/button"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { useIsDevMode } from "@/features/settings"; -import { useBackgroundTaskState } from "@/lib/hooks/use-background-task-state"; -import { useDefaultStore } from "@/lib/use-default-store"; -import { cn } from "@/lib/utils"; -import { type Message, type Task, catalog } from "@getpochi/livekit"; -import { - AlertCircle, - CheckCircle2, - Loader2, - PauseCircle, - X, -} from "lucide-react"; -import { useMemo, useState } from "react"; - -export function BackgroundTaskDebugPanel() { - const [isDevMode] = useIsDevMode(); - - if (isDevMode !== true) return null; - - return ; -} - -function BackgroundTaskDebugPanelInner() { - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [isListOpen, setIsListOpen] = useState(false); - - const handleSelectTask = (taskId: string) => { - setSelectedTaskId(taskId); - // Auto-hide the overview list as soon as a task is selected — the - // slide-out detail panel becomes the focus. - setIsListOpen(false); - }; - - return ( - <> - - - {/* - The visible gray bar stays small (`w-1.5 h-16`) so the UI is - unobtrusive, but the hit area is a much larger transparent - column (`w-5 h-40`) anchored to the right edge — hovering - anywhere within that column instantly opens the list. - */} - - - - - - - {selectedTaskId && ( - setSelectedTaskId(null)} - /> - )} - - ); -} - -function BackgroundTaskList({ - selectedTaskId, - onSelect, -}: { - selectedTaskId: string | null; - onSelect: (taskId: string) => void; -}) { - const store = useDefaultStore(); - const backgroundTasks = store.useQuery(catalog.queries.backgroundTasks$); - - return ( -
-
- - Background Tasks - - - {backgroundTasks.length} - -
- {backgroundTasks.length === 0 ? ( -
- No background tasks -
- ) : ( -
    - {backgroundTasks.map((task) => ( - onSelect(task.id)} - /> - ))} -
- )} -
- ); -} - -function BackgroundTaskListItem({ - task, - isSelected, - onSelect, -}: { - task: Task; - isSelected: boolean; - onSelect: () => void; -}) { - return ( -
  • - -
  • - ); -} - -function BackgroundTaskStatusIcon({ task }: { task: Task }) { - switch (task.status) { - case "pending-model": - case "pending-tool": - return ( - - ); - case "pending-input": - return ; - case "completed": - return ; - case "failed": - return ; - default: - return ; - } -} - -function BackgroundTaskDetail({ - taskId, - onClose, -}: { - taskId: string; - onClose: () => void; -}) { - const store = useDefaultStore(); - const task = store.useQuery(catalog.queries.makeTaskQuery(taskId)); - const messageRows = store.useQuery(catalog.queries.makeMessagesQuery(taskId)); - const { backgroundTaskState } = useBackgroundTaskState(taskId); - - const source = useMemo( - () => ({ - messages: messageRows.map((row) => row.data as Message), - todos: task?.todos ? [...task.todos] : [], - isLoading: - task?.status === "pending-model" || task?.status === "pending-tool", - }), - [messageRows, task?.todos, task?.status], - ); - - return ( -
    -
    -
    - {task && } -
    - - {task?.title || "(Untitled)"} - - - {taskId} - -
    -
    - -
    -
    - - - {backgroundTaskState?.useCase && ( - - )} - {backgroundTaskState?.parentTaskId && ( - - )} - {backgroundTaskState?.tools?.length !== undefined && ( - - )} - {task?.error?.message && ( - - )} -
    -
    - -
    -
    - ); -} - -function DetailRow({ - label, - value, - mono, - fullWidth, -}: { - label: string; - value: string | undefined; - mono?: boolean; - fullWidth?: boolean; -}) { - if (!value) return null; - return ( -
    - - {label} - - - {value} - -
    - ); -} - -function formatRelative(date: Date | string | number): string { - const updated = new Date(date).getTime(); - const diffMs = Date.now() - updated; - if (diffMs < 0) return "now"; - - const diffSeconds = Math.floor(diffMs / 1000); - if (diffSeconds < 5) return "now"; - if (diffSeconds < 60) return `${diffSeconds}s`; - const diffMinutes = Math.floor(diffSeconds / 60); - if (diffMinutes < 60) return `${diffMinutes}m`; - const diffHours = Math.floor(diffMinutes / 60); - if (diffHours < 24) return `${diffHours}h`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d`; -} diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/background-tasks.test.tsx similarity index 59% rename from packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx rename to packages/vscode-webui/src/features/chat/components/background-tasks.test.tsx index 234eea7844..db4044aa61 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-tasks.test.tsx @@ -2,17 +2,22 @@ import { fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; -import { BackgroundTaskDebugPanel } from "./background-task-debug-panel"; +import { BackgroundTasksChip } from "./background-tasks"; const task = { id: "task-1", title: "Background task", - status: "failed", + status: "pending-tool", + parentId: "parent-1", updatedAt: new Date(), todos: [], - error: { message: "A detailed failure message" }, + error: null, }; +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + vi.mock("@/components/task-thread", () => ({ TaskThread: ({ className, @@ -43,23 +48,33 @@ vi.mock("@/components/ui/button", () => ({ ), })); -vi.mock("@/components/ui/hover-card", () => ({ - HoverCard: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}, +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/lib/hooks/use-navigate", () => ({ + useNavigate: () => vi.fn(), })); vi.mock("@/features/settings", () => ({ - useIsDevMode: () => [true], + useIsDevMode: () => [false], })); -vi.mock("@/lib/hooks/use-background-task-state", () => ({ - useBackgroundTaskState: () => ({ - backgroundTaskState: { - useCase: "explore", - parentTaskId: "parent-task-id", - tools: ["readFile"], - }, +vi.mock("@/lib/vscode", () => ({ + isVSCodeEnvironment: () => true, + vscodeHost: { + readBackgroundTaskState: async () => ({ + value: "serialized-signal", + setBackgroundTaskState: async () => {}, + }), + }, +})); + +vi.mock("@quilted/threads/signals", () => ({ + threadSignal: () => ({ + value: { useCase: "subagent", agentType: "researcher" }, }), })); @@ -75,6 +90,7 @@ vi.mock("@getpochi/livekit", () => ({ vi.mock("@/lib/use-default-store", () => ({ useDefaultStore: () => ({ + storeId: "store-1", useQuery: (query: string) => { if (query === "backgroundTasks") return [task]; if (query === "task") return task; @@ -83,11 +99,19 @@ vi.mock("@/lib/use-default-store", () => ({ }), })); -describe("BackgroundTaskDebugPanel", () => { - it("uses a single borderless scroll area that fills the remaining height", () => { - render(); +describe("BackgroundTasksChip", () => { + it("lists subagent tasks with a stop action and opens the detail thread", async () => { + const stopBackgroundTask = vi.fn().mockResolvedValue(undefined); + render(); + + // The row appears once the task's background state resolves to subagent. + const row = await screen.findByText("Background task"); + + const stopButton = screen.getAllByTitle("backgroundTasks.stop")[0]; + fireEvent.click(stopButton); + expect(stopBackgroundTask).toHaveBeenCalledWith("task-1"); - fireEvent.click(screen.getByText("Background task")); + fireEvent.click(row); const taskThread = screen.getByTestId("task-thread"); expect(taskThread.classList.contains("min-h-0")).toBe(true); @@ -101,11 +125,8 @@ describe("BackgroundTaskDebugPanel", () => { expect(taskThread.dataset.instantAutoScroll).toBe("true"); const detailBodyClasses = taskThread.parentElement?.classList; - expect(detailBodyClasses?.contains("flex")).toBe(true); expect(detailBodyClasses?.contains("min-h-0")).toBe(true); expect(detailBodyClasses?.contains("flex-1")).toBe(true); - expect(detailBodyClasses?.contains("flex-col")).toBe(true); expect(detailBodyClasses?.contains("overflow-hidden")).toBe(true); - expect(taskThread.dataset.scrollAreaClassName).not.toContain("100vh"); }); }); diff --git a/packages/vscode-webui/src/features/chat/components/background-tasks.tsx b/packages/vscode-webui/src/features/chat/components/background-tasks.tsx new file mode 100644 index 0000000000..089fb15776 --- /dev/null +++ b/packages/vscode-webui/src/features/chat/components/background-tasks.tsx @@ -0,0 +1,413 @@ +/** + * Background agents UI — view and manage background subagent tasks + * (newTask with runInBackground) and, optionally, system background tasks + * such as memory extraction. + * + * Composition: + * - — toolbar entry; shows a spinner with the running + * count while agents are active. Opens the list popover. + * - List — status, agent badge, title, relative time, stop/retry actions. + * Shows subagent tasks; system background tasks (memory extraction etc.) + * appear only in dev mode. + * - Detail — slide-out panel rendering the task thread reactively from the + * store (works for TaskExecutor-driven tasks, updating at step boundaries). + */ + +import { TaskThread, type TaskThreadSource } from "@/components/task-thread"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useIsDevMode } from "@/features/settings"; +import { useDefaultStore } from "@/lib/use-default-store"; +import { cn } from "@/lib/utils"; +import { isVSCodeEnvironment, vscodeHost } from "@/lib/vscode"; +import type { BackgroundTaskState } from "@getpochi/common"; +import { type Message, type Task, catalog } from "@getpochi/livekit"; +import { threadSignal } from "@quilted/threads/signals"; +import { + AlertCircle, + Bot, + CheckCircle2, + Loader2, + PauseCircle, + Square, + X, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface BackgroundTasksActions { + stopBackgroundTask?: (taskId: string) => Promise; +} + +export function BackgroundTasksChip({ + className, + stopBackgroundTask, +}: BackgroundTasksActions & { className?: string }) { + const { t } = useTranslation(); + const [isDevMode] = useIsDevMode(); + const [isListOpen, setIsListOpen] = useState(false); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [showSystem, setShowSystem] = useState(false); + + const { tasks, totalCount, states } = useVisibleBackgroundTasks( + isDevMode === true && showSystem, + ); + const runningCount = tasks.filter(isRunningTask).length; + + // In dev mode the chip stays reachable while any background task exists, + // so the system toggle is accessible even without subagents. + if (tasks.length === 0 && !(isDevMode === true && totalCount > 0)) { + return null; + } + + return ( + <> + + + + + +
    + + {t("backgroundTasks.title")} + +
    + {isDevMode === true && ( + + )} + + {tasks.length} + +
    +
    + {tasks.length === 0 ? ( +
    + {t("backgroundTasks.empty")} +
    + ) : ( +
      + {tasks.map((task) => ( + { + setSelectedTaskId(task.id); + setIsListOpen(false); + }} + /> + ))} +
    + )} +
    +
    + {selectedTaskId && ( + setSelectedTaskId(null)} + /> + )} + + ); +} + +function BackgroundTaskRow({ + task, + agentType, + useCase, + stopBackgroundTask, + onSelect, +}: { + task: Task; + agentType: string | undefined; + useCase: string | undefined; + onSelect: () => void; +} & BackgroundTasksActions) { + const { t } = useTranslation(); + const badge = agentType ?? (useCase !== "subagent" ? useCase : undefined); + + return ( +
  • +
    + + +
    +
  • + ); +} + +function BackgroundTaskActions({ + task, + stopBackgroundTask, + className, +}: { + task: Task; + className?: string; +} & BackgroundTasksActions) { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + + if (isRunningTask(task) && stopBackgroundTask) { + return ( + + ); + } + + return null; +} + +function BackgroundTaskDetail({ + taskId, + agentType, + useCase, + stopBackgroundTask, + onClose, +}: { + taskId: string; + agentType: string | undefined; + useCase: string | undefined; + onClose: () => void; +} & BackgroundTasksActions) { + const { t } = useTranslation(); + const store = useDefaultStore(); + const task = store.useQuery(catalog.queries.makeTaskQuery(taskId)); + const messageRows = store.useQuery(catalog.queries.makeMessagesQuery(taskId)); + + const source = useMemo( + () => ({ + messages: messageRows.map((row) => row.data as Message), + todos: task?.todos ? [...task.todos] : [], + isLoading: + task?.status === "pending-model" || task?.status === "pending-tool", + }), + [messageRows, task?.todos, task?.status], + ); + + const panelRef = useRef(null); + useEffect(() => { + const onMouseDown = (event: MouseEvent) => { + if ( + panelRef.current && + !panelRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [onClose]); + + return ( +
    +
    +
    + {task && } +
    + + {task?.title || t("backgroundTasks.untitled")} + + + {agentType ?? useCase ?? ""} + {task?.error?.message ? ` · ${task.error.message}` : ""} + +
    +
    +
    + {task && ( + + )} + +
    +
    +
    + +
    +
    + ); +} + +function BackgroundTaskStatusIcon({ task }: { task: Task }) { + switch (task.status) { + case "pending-model": + case "pending-tool": + return ( + + ); + case "pending-input": + return ; + case "completed": + return ; + case "failed": + return ; + default: + return ; + } +} + +function isRunningTask(task: Task) { + return task.status === "pending-model" || task.status === "pending-tool"; +} + +/** + * Background tasks joined with their BackgroundTaskState (agentType/useCase) + * from the extension host. Only subagent tasks are visible unless `showAll` + * reveals system background tasks (memory extraction etc.). Tasks whose + * state is still loading are hidden so system agents never flash into the + * list. + */ +function useVisibleBackgroundTasks(showAll: boolean) { + const store = useDefaultStore(); + const all = store.useQuery(catalog.queries.backgroundTasks$); + const [states, setStates] = useState< + Record + >({}); + const fetchingRef = useRef(new Set()); + + useEffect(() => { + if (!isVSCodeEnvironment()) return; + for (const task of all) { + if (fetchingRef.current.has(task.id)) continue; + fetchingRef.current.add(task.id); + vscodeHost + .readBackgroundTaskState(task.id) + .then((result) => { + const value = threadSignal(result.value).value; + setStates((prev) => ({ ...prev, [task.id]: value })); + }) + .catch(() => { + // State unavailable; the task stays out of the subagent list. + }); + } + }, [all]); + + const tasks = useMemo( + () => + all.filter((task) => showAll || states[task.id]?.useCase === "subagent"), + [all, showAll, states], + ); + + return { tasks, totalCount: all.length, states }; +} + +function formatRelative(date: Date | string | number): string { + const updated = new Date(date).getTime(); + const diffMs = Date.now() - updated; + if (diffMs < 0) return "now"; + + const diffSeconds = Math.floor(diffMs / 1000); + if (diffSeconds < 5) return "now"; + if (diffSeconds < 60) return `${diffSeconds}s`; + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes}m`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d`; +} diff --git a/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx b/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx index 6cd56e2ebc..8d15492260 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx @@ -115,7 +115,11 @@ vi.mock("@/lib/hooks/use-task-changed-files", () => ({ }), })); vi.mock("@/lib/use-default-store", () => ({ - useDefaultStore: () => ({ commit: vi.fn() }), + useDefaultStore: () => ({ + commit: vi.fn(), + storeId: "store-1", + useQuery: () => [], + }), })); vi.mock("@/lib/vscode", () => ({ vscodeHost: {}, diff --git a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx index 685d7974d5..30efef491a 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx @@ -27,7 +27,10 @@ import { useUserEdits } from "@/lib/hooks/use-user-edits"; import { cn, tw } from "@/lib/utils"; import type { UseChatHelpers } from "@ai-sdk/react"; import { constants } from "@getpochi/common"; -import type { MonitorEventEnvelope } from "@getpochi/common"; +import type { + MonitorEventEnvelope, + SubAgentResultNotification, +} from "@getpochi/common"; import { hasActiveTodos } from "@getpochi/common/message-utils"; import type { DisplayModel, @@ -44,6 +47,7 @@ import { import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useBackgroundSubtaskResults } from "../hooks/use-background-subtask-results"; import { type BlockingOperation, useBlockingOperations, @@ -62,6 +66,10 @@ import { ErrorMessageView } from "./error-message-view"; import { SubmitReviewsButton } from "./submit-review-button"; import { CompleteSubtaskButton } from "./subtask"; +function subagentLabel(result: SubAgentResultNotification) { + return result.title || result.agentType || "subagent"; +} + const PopupContainerClassName = tw`-translate-y-full -top-2 absolute left-0 w-full px-4 pt-1`; const PopupContentClassName = tw`flex w-full flex-col bg-background`; const FooterContainerClassName = tw`my-2 flex shrink-0 justify-between gap-5 overflow-x-hidden`; @@ -169,6 +177,37 @@ export const ChatToolbar: React.FC = ({ }); }, []); useMonitorEvents(taskId, onMonitorEvents); + + // Finished background subagents (newTask with runInBackground) enter the + // conversation through the same queued-messages pipeline as monitor + // events; results arriving while a draft is still queued merge into it. + const onSubagentResults = useCallback( + (results: SubAgentResultNotification[]) => { + setQueuedMessages((prev) => { + const last = prev.at(-1); + const queuedResults = last?.raw.subagentResults; + const merged = queuedResults ? [...queuedResults, ...results] : results; + + const draft: DraftMessage = { + // Rendered to a system-reminder text for the LLM by the chat + // transport; kept as a data part so the chat UI can display it. + parts: [{ type: "data-subagent-results", data: { results: merged } }], + raw: { + text: + merged.length === 1 + ? `Subagent ${merged[0].status}: ${subagentLabel(merged[0])}` + : `${merged.length} subagents finished: ${merged + .map(subagentLabel) + .join(", ")}`, + subagentResults: merged, + }, + }; + return queuedResults ? [...prev.slice(0, -1), draft] : [...prev, draft]; + }); + }, + [], + ); + useBackgroundSubtaskResults(taskId, messages, onSubagentResults); const [excludedUserEditsContext, setExcludedUserEditsContext] = useState(); const lastCheckpointHash = task?.lastCheckpointHash ?? undefined; diff --git a/packages/vscode-webui/src/features/chat/hooks/use-background-subtask-results.ts b/packages/vscode-webui/src/features/chat/hooks/use-background-subtask-results.ts new file mode 100644 index 0000000000..03002ae6b4 --- /dev/null +++ b/packages/vscode-webui/src/features/chat/hooks/use-background-subtask-results.ts @@ -0,0 +1,60 @@ +import { useDefaultStore } from "@/lib/use-default-store"; +import type { SubAgentResultNotification } from "@getpochi/common"; +import { + type Message, + catalog, + createSubAgentResultNotification, +} from "@getpochi/livekit"; +import { useEffect, useRef } from "react"; + +/** + * Watches background subagent tasks (newTask with runInBackground) of the + * given parent task and hands each finished one to `onResults` exactly once. + * Delivery is deduplicated against `data-subagent-results` parts already in + * the conversation, so notifications survive webview reloads without being + * delivered twice. + */ +export function useBackgroundSubtaskResults( + taskId: string, + messages: Message[], + onResults: (results: SubAgentResultNotification[]) => void, +) { + const store = useDefaultStore(); + const subTasks = store.useQuery(catalog.queries.makeSubTaskQuery(taskId)); + + const onResultsRef = useRef(onResults); + onResultsRef.current = onResults; + + // Guards against redelivery while a notification is still queued (not yet + // part of the conversation). + const deliveredRef = useRef(new Set()); + + useEffect(() => { + // Keyed by taskId:status so a retried task's new outcome notifies again. + const notified = new Set(); + for (const message of messages) { + for (const part of message.parts) { + if (part.type === "data-subagent-results") { + for (const result of part.data.results) { + notified.add(`${result.taskId}:${result.status}`); + } + } + } + } + + const fresh = subTasks.filter( + (task) => + task.background && + (task.status === "completed" || task.status === "failed") && + !notified.has(`${task.id}:${task.status}`) && + !deliveredRef.current.has(`${task.id}:${task.status}`), + ); + if (fresh.length === 0) return; + + const results = fresh.map((task) => { + deliveredRef.current.add(`${task.id}:${task.status}`); + return createSubAgentResultNotification(store, task); + }); + onResultsRef.current(results); + }, [subTasks, messages, store]); +} diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts index 5252a92bb3..0c3d4870ef 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts @@ -4,7 +4,10 @@ import { prepareMessageParts } from "@/lib/message-utils"; import { vscodeHost } from "@/lib/vscode"; import type { UseChatHelpers } from "@ai-sdk/react"; import { getLogger } from "@getpochi/common"; -import type { MonitorEventEnvelope } from "@getpochi/common"; +import type { + MonitorEventEnvelope, + SubAgentResultNotification, +} from "@getpochi/common"; import type { Message } from "@getpochi/livekit"; import { useActiveSelection } from "@/lib/hooks/use-active-selection"; @@ -51,6 +54,12 @@ export interface DraftMessage { */ envelopes: MonitorEventEnvelope[]; }; + /** + * Present when this draft was generated from finished background + * subagents. Kept so results arriving while the draft is still queued + * are merged into one message. + */ + subagentResults?: SubAgentResultNotification[]; }; } diff --git a/packages/vscode-webui/src/features/chat/lib/fixed-state-tool-call-life-cycle.ts b/packages/vscode-webui/src/features/chat/lib/fixed-state-tool-call-life-cycle.ts index 3d558e532d..cb162d0a32 100644 --- a/packages/vscode-webui/src/features/chat/lib/fixed-state-tool-call-life-cycle.ts +++ b/packages/vscode-webui/src/features/chat/lib/fixed-state-tool-call-life-cycle.ts @@ -41,6 +41,12 @@ export class FixedStateToolCallLifeCycle implements ToolCallLifeCycle { ); } + detach(_result: unknown): void { + throw new Error( + "Method 'detach()' should not be called on FixedStateToolCallLifeCycle.", + ); + } + addResult(_result: unknown): void { throw new Error( "Method 'addResult()' should not be called on FixedStateToolCallLifeCycle.", diff --git a/packages/vscode-webui/src/features/chat/lib/tool-call-life-cycle.ts b/packages/vscode-webui/src/features/chat/lib/tool-call-life-cycle.ts index c8753fa88b..981b121c0f 100644 --- a/packages/vscode-webui/src/features/chat/lib/tool-call-life-cycle.ts +++ b/packages/vscode-webui/src/features/chat/lib/tool-call-life-cycle.ts @@ -1,6 +1,11 @@ import { blobStore } from "@/lib/remote-blob-store"; import { vscodeHost } from "@/lib/vscode"; -import { constants, getLogger, toErrorMessage } from "@getpochi/common"; +import { + constants, + createBackgroundSubAgentStartedResult, + getLogger, + toErrorMessage, +} from "@getpochi/common"; import type { BuiltinSubAgentInfo, ExecuteCommandResult, @@ -38,6 +43,8 @@ type NewTaskReturnType = { result: string; agentType?: string; todos?: readonly Todo[]; + /** The subtask was converted to a background subagent task. */ + runInBackground?: boolean; }; type ExecuteReturnType = ExecuteCommandReturnType | NewTaskReturnType | unknown; @@ -139,6 +146,13 @@ export interface ToolCallLifeCycle { */ abort(reason?: AbortReason, result?: unknown): void; + /** + * Settle the tool call as finished with the given result while aborting + * the in-flight execution — used when the work is handed off elsewhere + * (e.g. a foreground subtask moved to the background). + */ + detach(result: unknown): void; + /** * Reject the tool call, preventing execution. */ @@ -218,6 +232,7 @@ export class ManagedToolCallLifeCycle if (this.toolName === "newTask") { executePromise = this.runNewTask(args as NewTaskParameterType, { toolPolicies: options?.toolPolicies, + taskId: options?.taskId, }); } else { executePromise = vscodeHost.executeToolCall(this.toolName, args, { @@ -249,10 +264,11 @@ export class ManagedToolCallLifeCycle }); } - private runNewTask( + private async runNewTask( args: NewTaskParameterType, options?: { toolPolicies?: CompiledToolPolicies; + taskId?: string; }, ): Promise { // Validate the agent type pattern policy, throw if failed @@ -266,11 +282,36 @@ export class ManagedToolCallLifeCycle throw new Error("Missing uid in newTask arguments"); } - return Promise.resolve({ + // The browser agent needs a per-task browser session that only the + // foreground path sets up; the todo-completion agent resolves todos + // through the foreground result flow. + const runInBackground = + !!args.runInBackground && + args.agentType !== "browser" && + args.agentType !== constants.AttemptTodoCompletionAgentName; + if (runInBackground) { + const { setBackgroundTaskState } = + await vscodeHost.readBackgroundTaskState(uid); + await setBackgroundTaskState({ + parentTaskId: options?.taskId, + useCase: "subagent", + agentType: args.agentType, + }); + this.store.commit( + catalog.events.taskBackgrounded({ id: uid, updatedAt: new Date() }), + ); + return { + result: uid, + agentType: args.agentType, + runInBackground: true, + }; + } + + return { result: uid, agentType: args.agentType, todos: args._meta?.todos, - }); + }; } addResult(result: unknown): void { @@ -292,6 +333,24 @@ export class ManagedToolCallLifeCycle this.settleAbort(reason, result); } + detach(result: unknown) { + if ( + this.state.type !== "execute" && + this.state.type !== "execute:streaming" + ) { + return; + } + const { abort } = this.state; + // Settle as execute-finish first: the abort listeners' settleAbort then + // no-ops instead of overwriting the result with an abort error. + this.transitTo(this.state.type, { + type: "complete", + result, + reason: "execute-finish", + }); + abort("detached"); + } + reject() { this.transitTo("init", { type: "complete", @@ -368,11 +427,27 @@ export class ManagedToolCallLifeCycle result: uid, agentType, todos, + runInBackground, }: NewTaskReturnType) { if (!uid) { throw new Error("Missing uid in newTask result"); } + if (runInBackground) { + // The TaskExecutor picks the backgrounded task up reactively; the tool + // call completes immediately and the result arrives later as a + // subagent-results notification. + this.transitTo("execute", { + type: "complete", + result: { + result: createBackgroundSubAgentStartedResult(uid), + backgroundTaskId: uid, + }, + reason: "execute-finish", + }); + return; + } + const cleanupFns: (() => void)[] = []; const cleanup = () => { for (const fn of cleanupFns) { diff --git a/packages/vscode-webui/src/features/chat/page.tsx b/packages/vscode-webui/src/features/chat/page.tsx index 6970ba0af0..99b7c8c06c 100644 --- a/packages/vscode-webui/src/features/chat/page.tsx +++ b/packages/vscode-webui/src/features/chat/page.tsx @@ -33,7 +33,7 @@ import { useSelectedModels, useSettingsStore, } from "../settings"; -import { BackgroundTaskDebugPanel } from "./components/background-task-debug-panel"; +import { BackgroundTasksChip } from "./components/background-tasks"; import { ChatArea } from "./components/chat-area"; import { ChatSkeleton } from "./components/chat-skeleton"; import { ChatToolbar } from "./components/chat-toolbar"; @@ -455,6 +455,12 @@ function Chat({ user, uid, info }: ChatProps) { className="absolute top-1 right-2 z-10" /> )} + {!isSubTask && ( + chatKit.stopBackgroundTask(taskId)} + /> + )} - ); } diff --git a/packages/vscode-webui/src/features/tools/components/new-task/index.tsx b/packages/vscode-webui/src/features/tools/components/new-task/index.tsx index b39ac15d0a..1f054292ff 100644 --- a/packages/vscode-webui/src/features/tools/components/new-task/index.tsx +++ b/packages/vscode-webui/src/features/tools/components/new-task/index.tsx @@ -1,15 +1,30 @@ import { TaskThread, type TaskThreadSource } from "@/components/task-thread"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { FixedStateChatContextProvider, ToolCallStatusRegistry, + useToolCallLifeCycle, } from "@/features/chat"; import { useDebounceState } from "@/lib/hooks/use-debounce-state"; import { useNavigate } from "@/lib/hooks/use-navigate"; import { useDefaultStore } from "@/lib/use-default-store"; import { cn } from "@/lib/utils"; -import { isVSCodeEnvironment } from "@/lib/vscode"; -import { type RefObject, useEffect, useMemo, useRef } from "react"; +import { isVSCodeEnvironment, vscodeHost } from "@/lib/vscode"; +import { + constants, + createBackgroundSubAgentStartedResult, +} from "@getpochi/common"; +import { catalog, restartBackgroundTask } from "@getpochi/livekit"; +import { getStaticToolName } from "ai"; +import { PictureInPicture2 } from "lucide-react"; +import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { useThrottle } from "react-use"; import { useInlinedSubTask } from "../../hooks/use-inlined-sub-task"; import { useLiveSubTask } from "../../hooks/use-live-sub-task"; @@ -58,25 +73,93 @@ function LiveSubTaskToolView(props: NewTaskToolProps & { uid: string }) { subTaskToolCallStatusRegistry.current, ); + const store = useDefaultStore(); + const lifecycle = useToolCallLifeCycle().getToolCallLifeCycle({ + toolName: getStaticToolName(tool), + toolCallId: tool.toolCallId, + }); + const agentType = + tool.state !== "input-streaming" ? tool.input?.agentType : undefined; + const parentId = taskSource?.parentId; + const canMoveToBackground = + isExecuting && + lifecycle.status === "execute:streaming" && + !!parentId && + !tool.input?.runInBackground && + agentType !== "browser" && + agentType !== constants.AttemptTodoCompletionAgentName; + + const onMoveToBackground = useCallback(async () => { + if (!parentId) return; + // Record the subagent state before handing off, so the TaskExecutor can + // resolve the agent when it picks the task up. + const { setBackgroundTaskState } = + await vscodeHost.readBackgroundTaskState(uid); + await setBackgroundTaskState({ + parentTaskId: parentId, + useCase: "subagent", + agentType, + }); + // Settle the parent tool call as finished; this aborts the foreground + // loop driving the subtask. + lifecycle.detach({ + result: createBackgroundSubAgentStartedResult(uid), + backgroundTaskId: uid, + }); + // The aborted loop marks the task failed asynchronously; wait for it to + // settle before flipping it to a runnable background task. + await waitForTaskSettled(store, uid); + store.commit( + catalog.events.taskBackgrounded({ id: uid, updatedAt: new Date() }), + ); + restartBackgroundTask(store, uid); + }, [parentId, uid, agentType, lifecycle, store]); + return ( ); } +async function waitForTaskSettled( + store: ReturnType, + taskId: string, +) { + for (let i = 0; i < 50; i++) { + const task = store.query(catalog.queries.makeTaskQuery(taskId)); + if ( + task && + task.status !== "pending-model" && + task.status !== "pending-tool" + ) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + export interface NewTaskToolViewProps extends ToolProps<"newTask"> { taskSource?: (TaskThreadSource & { parentId?: string }) | undefined; uid: string | undefined; toolCallStatusRegistryRef?: RefObject; + onMoveToBackground?: () => void; } function NewTaskToolView(props: NewTaskToolViewProps) { - const { tool, isExecuting, taskSource, uid, toolCallStatusRegistryRef } = - props; + const { + tool, + isExecuting, + taskSource, + uid, + toolCallStatusRegistryRef, + onMoveToBackground, + } = props; + const { t } = useTranslation(); const store = useDefaultStore(); const navigate = useNavigate(); const agent = tool.input?.agentType; @@ -169,6 +252,31 @@ function NewTaskToolView(props: NewTaskToolViewProps) { {description} )} + {onMoveToBackground && ( + + + + + +

    + {t("backgroundTasks.moveToBackground")} +

    +

    + {t("backgroundTasks.moveToBackgroundHint")} +

    +
    +
    + )} ); diff --git a/packages/vscode-webui/src/features/tools/hooks/use-live-sub-task.tsx b/packages/vscode-webui/src/features/tools/hooks/use-live-sub-task.tsx index f4216b03cf..a0824b9e40 100644 --- a/packages/vscode-webui/src/features/tools/hooks/use-live-sub-task.tsx +++ b/packages/vscode-webui/src/features/tools/hooks/use-live-sub-task.tsx @@ -49,6 +49,14 @@ export function useLiveSubTask( const agentType = tool.state !== "input-streaming" ? tool.input?.agentType : undefined; + // Background subtasks are driven by the TaskExecutor, not by this hook. + // Mirrors the lifecycle's forced-foreground exceptions so both sides make + // the same call from the tool input alone (no race on task state). + const runInBackground = + tool.state !== "input-streaming" && + !!tool.input?.runInBackground && + agentType !== "browser" && + agentType !== constants.AttemptTodoCompletionAgentName; const { customAgent, customAgentModel, @@ -305,6 +313,7 @@ export function useLiveSubTask( useInitAutoStart({ start: retry, enabled: + !runInBackground && tool.state === "input-available" && isExecuting && !(agentType && isCustomAgentLoading) && diff --git a/packages/vscode-webui/src/i18n/locales/en.json b/packages/vscode-webui/src/i18n/locales/en.json index 52697ccabb..159a768b75 100644 --- a/packages/vscode-webui/src/i18n/locales/en.json +++ b/packages/vscode-webui/src/i18n/locales/en.json @@ -5,7 +5,8 @@ "account": "Account", "help": "Help", "reset": "Reset", - "clear": "Clear" + "clear": "Clear", + "close": "Close" }, "error": { "somethingWentWrong": "Something went wrong", @@ -646,5 +647,15 @@ "builtInAgentsSettings": { "title": "Built-in Agents", "browser": "browser" + }, + "backgroundTasks": { + "title": "Background Agents", + "empty": "No background agents", + "untitled": "Untitled", + "stop": "Stop", + "moveToBackground": "Move to background", + "all": "All", + "showAll": "Show all background tasks", + "moveToBackgroundHint": "Continues in the background and notifies you when done." } } diff --git a/packages/vscode-webui/src/i18n/locales/jp.json b/packages/vscode-webui/src/i18n/locales/jp.json index 841239106c..08be58c5e1 100644 --- a/packages/vscode-webui/src/i18n/locales/jp.json +++ b/packages/vscode-webui/src/i18n/locales/jp.json @@ -3,7 +3,8 @@ "disable": "無効化", "openFolder": "フォルダーを開く", "account": "アカウント", - "help": "ヘルプ" + "help": "ヘルプ", + "close": "閉じる" }, "error": { "somethingWentWrong": "問題が発生しました", @@ -16,7 +17,6 @@ "attachments": "ファイルを送信", "context": "コンテキストを添付" }, - "autoSave": { "title": "自動保存が有効な場合、Pochi は正しく動作しません。", "description": "Pochi は保留中のファイル変更に依存して編集の差分を表示するため、自動保存を無効にする必要があります。" @@ -316,7 +316,6 @@ "requiresApproval": "承認待ち", "taskCompleted": "タスク完了", "reading": "読み込み中 ", - "writing": "書き込み中 ", "continue": "続行", "retry": "再試行", @@ -643,5 +642,15 @@ "builtInAgentsSettings": { "title": "組み込みエージェント", "browser": "ブラウザー" + }, + "backgroundTasks": { + "title": "バックグラウンドエージェント", + "empty": "バックグラウンドエージェントはありません", + "untitled": "無題", + "stop": "停止", + "moveToBackground": "バックグラウンドへ移動", + "all": "すべて", + "showAll": "すべてのバックグラウンドタスクを表示", + "moveToBackgroundHint": "バックグラウンドで実行を続け、完了したら通知します。" } } diff --git a/packages/vscode-webui/src/i18n/locales/ko.json b/packages/vscode-webui/src/i18n/locales/ko.json index 815418cc5d..1852a581ab 100644 --- a/packages/vscode-webui/src/i18n/locales/ko.json +++ b/packages/vscode-webui/src/i18n/locales/ko.json @@ -3,7 +3,8 @@ "disable": "비활성화", "openFolder": "폴더 열기", "account": "계정", - "help": "도움말" + "help": "도움말", + "close": "닫기" }, "error": { "somethingWentWrong": "문제가 발생했습니다", @@ -636,5 +637,15 @@ "builtInAgentsSettings": { "title": "내장 에이전트", "browser": "브라우저" + }, + "backgroundTasks": { + "title": "백그라운드 에이전트", + "empty": "백그라운드 에이전트가 없습니다", + "untitled": "제목 없음", + "stop": "중지", + "moveToBackground": "백그라운드로 이동", + "all": "전체", + "showAll": "모든 백그라운드 작업 표시", + "moveToBackgroundHint": "백그라운드에서 계속 실행되며 완료되면 알려드립니다." } } diff --git a/packages/vscode-webui/src/i18n/locales/zh.json b/packages/vscode-webui/src/i18n/locales/zh.json index 50148fd469..1a7594ca71 100644 --- a/packages/vscode-webui/src/i18n/locales/zh.json +++ b/packages/vscode-webui/src/i18n/locales/zh.json @@ -3,7 +3,8 @@ "disable": "禁用", "openFolder": "打开文件夹", "account": "账户", - "help": "帮助" + "help": "帮助", + "close": "关闭" }, "error": { "somethingWentWrong": "出错了", @@ -314,7 +315,6 @@ "requiresApproval": "需要批准", "taskCompleted": "任务完成", "reading": "正在读取 ", - "writing": "正在写入 ", "continue": "继续", "retry": "重试", @@ -641,5 +641,15 @@ "builtInAgentsSettings": { "title": "内置代理", "browser": "浏览器" + }, + "backgroundTasks": { + "title": "后台代理", + "empty": "没有后台代理", + "untitled": "未命名", + "stop": "停止", + "moveToBackground": "移到后台", + "all": "全部", + "showAll": "显示所有后台任务", + "moveToBackgroundHint": "在后台继续运行,完成后通知你。" } } diff --git a/packages/vscode-webui/src/lib/vscode-running-task-adaptor.ts b/packages/vscode-webui/src/lib/vscode-running-task-adaptor.ts index 069208e978..caeb0e86fc 100644 --- a/packages/vscode-webui/src/lib/vscode-running-task-adaptor.ts +++ b/packages/vscode-webui/src/lib/vscode-running-task-adaptor.ts @@ -80,6 +80,45 @@ export class VscodeRunningTaskAdaptor implements RunningTaskAdaptor { }; } + async resolveTaskLLM( + context: Parameters>[0], + ) { + const { taskState } = context; + if (taskState.useCase !== "subagent" || !taskState.agentType) { + return undefined; + } + const agent = this.customAgents + .filter(isValidCustomAgentFile) + .find((a) => a.name === taskState.agentType); + if (!agent?.model) return undefined; + + const resolvedModel = resolveModelFromId(agent.model, this.modelList); + if (resolvedModel) return displayModelToLLM(resolvedModel); + + if (agent.isBuiltIn) { + // Built-in special models are currently served by the Pochi vendor. + const credentialSource = this.modelList.find( + (model) => model.type === "vendor" && model.vendorId === "pochi", + ); + if (credentialSource?.type === "vendor") { + return displayModelToLLM({ + type: "vendor", + id: agent.model, + name: agent.model, + vendorId: "pochi", + modelId: agent.model, + options: {}, + getCredentials: credentialSource.getCredentials, + }); + } + } + + logger.warn( + `Model "${agent.model}" for agent ${agent.name} not found; falling back to the selected model.`, + ); + return undefined; + } + async executeToolCall( args: Parameters[0], ) {