Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ const program = new Command()
parentFileStateCache,
autoMemoryManager,
projectMemoryEnabled,
resolveSubTaskLLM,
});
const taskMemory = autoCompactEnabled ? {} : undefined;
const projectMemory = projectMemoryEnabled
Expand Down
35 changes: 34 additions & 1 deletion packages/cli/src/running-task-adaptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ interface CliRunningTaskAdaptorOptions {
parentFileStateCache?: FileStateCache;
autoMemoryManager?: AutoMemoryManager;
projectMemoryEnabled?: boolean;
resolveSubTaskLLM?: (
customAgent: ValidCustomAgentFile,
) => Promise<LLMRequestData | undefined>;
}

export class CliRunningTaskAdaptor implements RunningTaskAdaptor {
Expand All @@ -56,6 +59,8 @@ export class CliRunningTaskAdaptor implements RunningTaskAdaptor {
private readonly fileStateCaches = new Map<string, FileStateCache>();
private readonly autoMemoryManager: AutoMemoryManager;
private readonly projectMemoryEnabled: boolean;
private readonly resolveSubTaskLLM: CliRunningTaskAdaptorOptions["resolveSubTaskLLM"];
private readonly taskLLMs = new Map<string, LLMRequestData>();
private readonly backgroundJobManagers = new Map<
string,
BackgroundJobManager
Expand All @@ -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() {
Expand Down Expand Up @@ -113,6 +119,33 @@ export class CliRunningTaskAdaptor implements RunningTaskAdaptor {
};
}

async resolveTaskLLM(
context: Parameters<NonNullable<RunningTaskAdaptor["resolveTaskLLM"]>>[0],
): Promise<LLMRequestData | undefined> {
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<RunningTaskAdaptor["executeToolCall"]>[0],
) {
Expand All @@ -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,
),
);

Expand Down
148 changes: 145 additions & 3 deletions packages/cli/src/task-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
type LiveKitStore,
type Message,
type Task,
catalog,
createSubAgentResultNotification,
processContentOutput,
} from "@getpochi/livekit";
import { LiveChatKit } from "@getpochi/livekit/node";
Expand Down Expand Up @@ -214,6 +216,7 @@ export class TaskRunner {
private asyncWaitTimeoutInMs: number;

private abortSignal?: AbortSignal;
private notifiedBackgroundSubTaskIds?: Set<string>;

readonly taskId: string;

Expand Down Expand Up @@ -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<Chat>({
Expand Down Expand Up @@ -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<string> {
if (!this.notifiedBackgroundSubTaskIds) {
const ids = new Set<string>();
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<void> {
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<typeof setTimeout> | 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.
Expand Down Expand Up @@ -515,6 +638,10 @@ export class TaskRunner {
return "next";
}

if (this.injectCompletedSubAgentResults()) {
return "next";
}

// Check for pending background jobs
const hasPendingJobs = this.backgroundJobManager.hasPendingJobs();

Expand All @@ -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) =>
Expand Down Expand Up @@ -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();
Expand Down
27 changes: 25 additions & 2 deletions packages/cli/src/tools/new-task.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -27,7 +31,7 @@ const SubTaskBrowserAgentMaxSteps = 65535;
*/
export const newTask =
(options: ToolCallOptions): ToolFunctionType<ClientTools["newTask"]> =>
async ({ _meta, agentType }, { toolCallId }) => {
async ({ _meta, agentType, runInBackground }, { toolCallId }) => {
const taskId = _meta?.uid || crypto.randomUUID();

if (!options.createSubTaskRunner) {
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

/**
* MCP Hub instance for accessing MCP server tools
*/
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/base/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
) {
Expand Down
17 changes: 16 additions & 1 deletion packages/common/src/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export {
MonitorRateLimitedReason,
} from "./monitor";

export {
type SubAgentResultNotification,
createBackgroundSubAgentStartedResult,
formatSubAgentNotifications,
} from "./subagent";

export { SocialLinks } from "./social";
export * as constants from "./constants";

Expand Down Expand Up @@ -113,10 +119,19 @@ export type ContextWindowUsage = {
projectMemory: number;
};

export const BackgroundTaskUseCase = z.enum([
...ForkAgentUseCase.options,
"subagent",
]);

export type BackgroundTaskUseCase = z.infer<typeof BackgroundTaskUseCase>;

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;
}
Loading
Loading