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
46 changes: 46 additions & 0 deletions packages/common/src/base/__tests__/fork-messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { buildForkDirective, buildForkMessages } from "../agents/fork-messages";

describe("buildForkDirective", () => {
it("wraps the task in a fork directive block", () => {
const directive = buildForkDirective("Summarize the current diff.");

expect(directive).toContain("<fork-directive>");
expect(directive).toContain("Summarize the current diff.");
expect(directive).toContain("Do NOT spawn sub-agents");
});
});

describe("buildForkMessages", () => {
it("clones parent messages and regenerates message ids", () => {
const parentMessages = [
{
id: "message-1",
role: "user" as const,
parts: [{ type: "text", text: "First" }],
},
{
id: "message-2",
role: "assistant" as const,
parts: [{ type: "text", text: "Second" }],
},
];

const messages = buildForkMessages(parentMessages, "Do the next step.");

expect(messages).toHaveLength(3);
expect(messages[0]?.role).toBe("user");
expect(messages[0]?.parts).toEqual(parentMessages[0]?.parts);
expect(messages[0]?.id).not.toBe(parentMessages[0]?.id);
expect(messages[1]?.role).toBe("assistant");
expect(messages[1]?.parts).toEqual(parentMessages[1]?.parts);
expect(messages[1]?.id).not.toBe(parentMessages[1]?.id);
expect(messages[2]?.role).toBe("user");
expect(messages[2]?.parts).toEqual([
{
type: "text",
text: buildForkDirective("Do the next step."),
},
]);
});
});
13 changes: 12 additions & 1 deletion packages/common/src/base/__tests__/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock('@getpochi/tools', async (importOriginal) => {
vi.mock('../prompts', () => ({
prompts: {
isSystemReminder: (text: string) => text.includes('<system-reminder>'),
isForkDirective: (text: string) => text.includes('<fork-directive>'),
isCompact: (text: string) => text.includes('<compact>'),
},
}));
Expand Down Expand Up @@ -67,6 +68,11 @@ const baseMessages: UIMessage[] = [
{
id: 'user-3',
role: 'user',
parts: [{ type: 'text', text: '<fork-directive>hidden</fork-directive>' }],
},
{
id: 'user-4',
role: 'user',
parts: [], // Empty message
},
];
Expand All @@ -85,6 +91,11 @@ describe('formatters', () => {
expect(formatted.find((m) => m.id === 'user-2')).toBeUndefined();
});

it('should remove fork directive messages', () => {
const formatted = formatters.ui(clone(baseMessages));
expect(formatted.find((m) => m.id === 'user-3')).toBeUndefined();
});

it('should resolve pending tool calls and combine messages', () => {
const messages: UIMessage[] = [
{
Expand Down Expand Up @@ -187,7 +198,7 @@ describe('formatters', () => {
describe('formatters.storage', () => {
it('should remove empty messages', () => {
const formatted = formatters.storage(clone(baseMessages));
expect(formatted.find((m) => m.id === 'user-3')).toBeUndefined();
expect(formatted.find((m) => m.id === 'user-4')).toBeUndefined();
});

it('should remove invalid characters from executeCommand output', () => {
Expand Down
96 changes: 96 additions & 0 deletions packages/common/src/base/agents/fork-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Utilities for building fork agent messages.
*
* A fork agent inherits the parent conversation's full message history
* so it can understand the ongoing context. The fork directive is appended
* as a final user message.
*/

interface ForkMessage {
id: string;
role: "user" | "assistant" | "system";
parts: Array<{ type: string; [key: string]: unknown }>;
}

/**
* Build the fork directive prompt that is appended to the parent's messages.
*/
export function buildForkDirective(directive: string): string {
return `<fork-directive>
You are a forked agent spawned from the parent conversation above. You have full context of the ongoing work.

## Behavioral Rules

1. **Execute directly.** Do NOT spawn sub-agents or new tasks. You have all the tools you need.
2. **No user interaction.** Do NOT use askFollowupQuestion. Work autonomously with the information available.
3. **Stay focused.** Complete ONLY the specific task described below. Do not address other issues you may notice.
4. **Be concise.** When done, call attemptCompletion with a structured report.

## Report Format

When calling attemptCompletion, structure your result as:

Scope: [1 sentence describing what you were asked to do]
Result: [1-2 sentences on outcome - success, partial, or blocked]
Key files: [bullet list of files read or modified, with brief notes]
Files changed: [bullet list of files you wrote to, or "None"]
Issues: [any blockers, warnings, or follow-ups for the parent, or "None"]

## Your Task

${directive}
</fork-directive>`;
}

/**
* Build the initial message array for a fork agent.
*
* The fork agent inherits the parent's full conversation history
* to maximize context understanding (and enable prompt cache reuse
* when supported by the provider).
*
* Structure:
* [parent message 1, ..., parent message N, fork directive user message]
*/
export function buildForkMessages<T extends ForkMessage>(
parentMessages: T[],
directive: string,
): ForkMessage[] {
// Deep clone to avoid mutating parent state, and rewrite message ids because
// forked subtasks live in the same store as the parent.
const messages: ForkMessage[] = structuredClone(parentMessages).map(
(message) => ({
...message,
id: crypto.randomUUID(),
}),
);

// Append fork directive as a new user message
messages.push({
id: crypto.randomUUID(),
role: "user",
parts: [
{
type: "text",
text: buildForkDirective(directive),
},
],
});

return messages;
}

/**
* Detect whether the current conversation is inside a fork child.
* Used to prevent recursive forking.
*/
export function isInForkChild<T extends ForkMessage>(messages: T[]): boolean {
return messages.some((m) =>
m.parts.some(
(p) =>
p.type === "text" &&
typeof p.text === "string" &&
p.text.includes("<fork-directive>"),
),
);
}
22 changes: 22 additions & 0 deletions packages/common/src/base/agents/fork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { CustomAgent } from "@getpochi/tools";

export const fork: CustomAgent = {
name: "fork",
description: `
Fork agent that inherits the parent conversation's full context. Use this when the child task needs to understand the ongoing conversation to complete its work effectively. Ideal for parallel, independent sub-tasks that share the parent's understanding of the problem.

**When to use fork vs other agents:**
- Use \`fork\` when the task requires understanding the current conversation context
(e.g., "fix the other lint error I mentioned", "also update the tests for the change we just discussed")
- Use \`explore\` for pure codebase research that doesn't need conversation context
- Use \`planner\` for architectural planning

**Fork usage notes:**
- Fork agents always run asynchronously - you can continue working while they execute
- Fork agents cannot spawn sub-agents (no recursion)
- Fork agents cannot ask the user questions - they work autonomously
- Launch multiple forks in a single message for maximum parallelism
`.trim(),
// No system prompt - the forked agent should inherit the parent's full context; append additional instructions in the user prompt if needed
systemPrompt: "",
};
4 changes: 4 additions & 0 deletions packages/common/src/base/agents/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import type { CustomAgent } from "@getpochi/tools";
import { browser } from "./browser";
import { explore } from "./explore";
import { fork } from "./fork";
import { guide } from "./guide/agent";
import { planner } from "./planner";
import { reviewer } from "./reviewer";
import { walkthrough } from "./walkthrough";

export * from "./fork-messages";

export const builtInAgents: CustomAgent[] = [
planner,
browser,
reviewer,
walkthrough,
explore,
guide,
fork,
];
5 changes: 4 additions & 1 deletion packages/common/src/base/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ function removeSystemReminder(messages: UIMessage[]): UIMessage[] {
if (message.role !== "user") return true;
const parts = message.parts.filter((part) => {
if (part.type !== "text") return true;
return !prompts.isSystemReminder(part.text);
return (
!prompts.isSystemReminder(part.text) &&
!prompts.isForkDirective(part.text)
);
});
message.parts = parts;
if (
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export { toErrorMessage } from "./error";

export { withTimeout } from "./async-utils";

export { builtInAgents } from "./agents";
export { builtInAgents, buildForkMessages, isInForkChild } from "./agents";

export { builtInSkills, BuiltInSkillPath } from "./skills";

Expand Down
8 changes: 8 additions & 0 deletions packages/common/src/base/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const prompts = {
createSystemReminder,
isSystemReminder,
isEnvironmentSystemReminder,
isForkDirective,
isCompact,
compact: createCompactPrompt,
inlineCompact,
Expand Down Expand Up @@ -54,6 +55,13 @@ function isEnvironmentSystemReminder(content: string) {
return isSystemReminder(content) && content.includes("# TODOs");
}

function isForkDirective(content: string) {
return (
content.startsWith("<fork-directive>") &&
content.endsWith("</fork-directive>")
);
}

function isCompact(content: string) {
return content.startsWith("<compact>") && content.endsWith("</compact>");
}
Expand Down
5 changes: 4 additions & 1 deletion packages/common/src/vscode-webui-bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ export type {
ReviewComment,
ReviewCodeSnippet,
} from "./types/review";
export type { BuiltinSubAgentInfo } from "./types/sub-agent";
export {
type BuiltinSubAgentInfo,
getBuiltinSubAgentInfo,
} from "./types/sub-agent";
export { isValidCustomAgentFile } from "./types/custom-agent";
export { isValidSkillFile } from "./types/skill";
export {
Expand Down
19 changes: 19 additions & 0 deletions packages/common/src/vscode-webui-bridge/types/sub-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,23 @@ export type BuiltinSubAgentInfo =
}
| {
type: "explore";
}
| {
type: "fork";
};

export const getBuiltinSubAgentInfo = (
agentType: string | undefined,
sessionId?: string,
): BuiltinSubAgentInfo | undefined => {
switch (agentType) {
case "browser":
return sessionId ? { type: agentType, sessionId } : undefined;
case "planner":
case "explore":
case "fork":
return { type: agentType };
default:
return undefined;
}
};
1 change: 1 addition & 0 deletions packages/livekit/src/chat/flexible-chat-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export class FlexibleChatTransport implements ChatTransport<Message> {
environment?.info.cwd,
chatId,
customAgents,
messages,
),
);
}
Expand Down
37 changes: 23 additions & 14 deletions packages/livekit/src/chat/middlewares/new-task-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ import type {
LanguageModelV3StreamPart,
} from "@ai-sdk/provider";
import { safeParseJSON } from "@ai-sdk/provider-utils";
import { constants } from "@getpochi/common";
import { constants, buildForkMessages } from "@getpochi/common";
import { type CustomAgent, newTaskInputSchema } from "@getpochi/tools";
import { InvalidToolInputError } from "ai";
import { events } from "../../livestore/default-schema";
import type { LiveKitStore } from "../../types";
import type { LiveKitStore, Message } from "../../types";

export function createNewTaskMiddleware(
store: LiveKitStore,
cwd: string | undefined,
parentTaskId: string,
customAgents?: CustomAgent[],
parentMessages?: Message[],
): LanguageModelV3Middleware {
return {
specificationVersion: "v3",
Expand Down Expand Up @@ -93,31 +94,39 @@ export function createNewTaskMiddleware(
}

const uid = crypto.randomUUID();
const isFork = args.agentType === "fork";
const runAsync =
(args.runAsync ?? false) && constants.EnableAsyncNewTask;
args.runAsync = runAsync;
args._meta = {
uid,
};

// For fork agents, inherit parent conversation history
const initMessages =
isFork && parentMessages
? buildForkMessages(parentMessages, args.prompt)
: [
{
id: crypto.randomUUID(),
role: "user" as const,
parts: [
{
type: "text" as const,
text: args.prompt,
},
],
},
];

store.commit(
events.taskInited({
id: uid,
cwd,
parentId: parentTaskId,
runAsync,
createdAt: new Date(),
initMessages: [
{
id: crypto.randomUUID(),
role: "user",
parts: [
{
type: "text",
text: args.prompt,
},
],
},
],
initMessages,
}),
);

Expand Down
Loading
Loading