mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add detached agents and heartbeat scheduling (#1266)
This commit is contained in:
@@ -93,6 +93,7 @@ export interface CreateAgentFromMcpInput {
|
||||
mode?: string;
|
||||
background: boolean;
|
||||
notifyOnFinish: boolean;
|
||||
detached?: boolean;
|
||||
callerAgentId?: string;
|
||||
callerContext?: {
|
||||
lockedCwd?: string;
|
||||
@@ -256,11 +257,12 @@ async function resolveMcpCreateAgent(
|
||||
parent: parentAgent,
|
||||
});
|
||||
|
||||
const labels = mergeLabels(
|
||||
input.callerAgentId,
|
||||
input.callerContext?.childAgentDefaultLabels,
|
||||
input.labels,
|
||||
);
|
||||
const labels = mergeLabels({
|
||||
callerAgentId: input.callerAgentId,
|
||||
detached: input.detached ?? false,
|
||||
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
|
||||
labels: input.labels,
|
||||
});
|
||||
|
||||
const trimmedPrompt = input.initialPrompt.trim();
|
||||
return {
|
||||
@@ -469,15 +471,21 @@ async function createMcpWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLabels(
|
||||
callerAgentId: string | undefined,
|
||||
childAgentDefaultLabels: Record<string, string> | undefined,
|
||||
labels: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
function mergeLabels(params: {
|
||||
callerAgentId: string | undefined;
|
||||
detached: boolean;
|
||||
childAgentDefaultLabels: Record<string, string> | undefined;
|
||||
labels: Record<string, string> | undefined;
|
||||
}): Record<string, string> | undefined {
|
||||
const mergedLabels = {
|
||||
...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}),
|
||||
...childAgentDefaultLabels,
|
||||
...labels,
|
||||
...(!params.detached && params.callerAgentId
|
||||
? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId }
|
||||
: {}),
|
||||
...params.childAgentDefaultLabels,
|
||||
...params.labels,
|
||||
};
|
||||
if (params.detached) {
|
||||
delete mergedLabels[PARENT_AGENT_ID_LABEL];
|
||||
}
|
||||
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ async function createChildAgent(args?: Partial<StructuredContent>): Promise<stri
|
||||
title: "Parity child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "say done and stop",
|
||||
background: true,
|
||||
notifyOnFinish: false,
|
||||
...args,
|
||||
});
|
||||
return str(payload.agentId);
|
||||
@@ -286,6 +286,17 @@ describe("Suite A: Core Fixes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent with detached true omits the parent agent label", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createChildAgent({ detached: true });
|
||||
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(snapshot?.labels?.[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
@@ -565,7 +576,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity schedule list",
|
||||
provider: "claude",
|
||||
});
|
||||
@@ -591,7 +602,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity provider schedule",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
@@ -613,7 +624,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity inspect schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
@@ -638,7 +649,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity pause schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
@@ -665,7 +676,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity delete schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
@@ -682,14 +693,13 @@ describe("Suite C: Schedule Tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self with callerAgentId", async () => {
|
||||
test("create_heartbeat targets the scoped agent", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_schedule", {
|
||||
const created = await callToolStructured(agentScopedClient, "create_heartbeat", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity self schedule",
|
||||
target: "self",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Parity heartbeat",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
expect(created.target).toMatchObject({
|
||||
@@ -706,7 +716,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
@@ -722,16 +732,15 @@ describe("Suite C: Schedule Tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self without callerAgentId throws", async () => {
|
||||
test("create_heartbeat without callerAgentId throws", async () => {
|
||||
await expectToolError(
|
||||
topLevelClient,
|
||||
"create_schedule",
|
||||
"create_heartbeat",
|
||||
{
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
target: "self",
|
||||
cron: "*/5 * * * *",
|
||||
},
|
||||
/requires a caller agent/i,
|
||||
/requires an agent-scoped session/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1692,6 +1692,141 @@ describe("create_agent MCP tool", () => {
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
cwd: existingCwd,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await expect(
|
||||
tool.handler({
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: false,
|
||||
}),
|
||||
).rejects.toThrow(/Unrecognized key/);
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (!parsed.success) {
|
||||
throw new Error("Expected caller create_agent input to parse");
|
||||
}
|
||||
expect(parsed.data).toMatchObject({
|
||||
detached: false,
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns notify-on-finish guidance for caller-created agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const parentAgent = {
|
||||
id: "parent-agent",
|
||||
cwd: existingCwd,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
} as ManagedAgent;
|
||||
const childAgent = {
|
||||
id: "child-agent",
|
||||
cwd: existingCwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Child" },
|
||||
} as ManagedAgent;
|
||||
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
|
||||
if (agentId === "parent-agent") return parentAgent;
|
||||
if (agentId === "child-agent") return childAgent;
|
||||
return null;
|
||||
});
|
||||
spies.agentManager.createAgent.mockResolvedValue(childAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const response = await tool.handler({
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
|
||||
expect(response.structuredContent.guidance).toBe(
|
||||
"You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates detached caller agents without a parent label", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
cwd: existingCwd,
|
||||
provider: "codex",
|
||||
currentModeId: "full-access",
|
||||
} as ManagedAgent);
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "detached-agent",
|
||||
cwd: existingCwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Detached" },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
title: "Detached",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Take over",
|
||||
detached: true,
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: "spoofed-parent",
|
||||
source: "handoff",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: existingCwd,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
labels: {
|
||||
source: "handoff",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts provider features from caller agents and passes them through createAgent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
@@ -1721,7 +1856,6 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
settings: { features: { fast_mode: true } },
|
||||
};
|
||||
|
||||
@@ -2139,7 +2273,7 @@ describe("update_agent MCP tool", () => {
|
||||
describe("create_schedule MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("requires provider for new-agent schedules", async () => {
|
||||
it("requires provider for schedules", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2154,7 +2288,7 @@ describe("create_schedule MCP tool", () => {
|
||||
await expect(
|
||||
tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
name: "Default schedule",
|
||||
}),
|
||||
).rejects.toThrow("provider is required when target is new-agent");
|
||||
@@ -2175,12 +2309,12 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
await tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
cron: "*/5 * * * *",
|
||||
provider: "codex",
|
||||
});
|
||||
await tool.handler({
|
||||
prompt: "say hello again",
|
||||
every: "10m",
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
|
||||
@@ -2239,8 +2373,7 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
const response = await tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
target: "new-agent",
|
||||
cron: "*/5 * * * *",
|
||||
provider: "opencode/openai/gpt-5.5",
|
||||
});
|
||||
|
||||
@@ -2251,83 +2384,6 @@ describe("create_schedule MCP tool", () => {
|
||||
expectOutputSchemaAccepts(tool, response.structuredContent);
|
||||
});
|
||||
|
||||
it("accepts a blank cron field when every is provided", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
createStoredSchedule(scheduleInput),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
cron: "",
|
||||
provider: "codex",
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "whitespace cron field",
|
||||
input: { prompt: "say hello", every: "10m", cron: " ", provider: "codex" },
|
||||
cadence: { type: "every", everyMs: 600000 },
|
||||
},
|
||||
{
|
||||
label: "blank every field for cron cadence",
|
||||
input: {
|
||||
prompt: "say hello",
|
||||
every: "",
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex",
|
||||
},
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
},
|
||||
{
|
||||
label: "whitespace every field for cron cadence",
|
||||
input: {
|
||||
prompt: "say hello",
|
||||
every: " ",
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex",
|
||||
},
|
||||
cadence: { type: "cron", expression: "*/10 * * * *" },
|
||||
},
|
||||
])("normalizes create_schedule blank cadence input for $label", async ({ input, cadence }) => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
createStoredSchedule(scheduleInput),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await invokeToolWithParsedInput(tool, input);
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cadence,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes timezone through cron create_schedule input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
@@ -2360,7 +2416,7 @@ describe("create_schedule MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("still rejects both real every and cron inputs", async () => {
|
||||
it("rejects removed create_schedule every input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2372,19 +2428,17 @@ describe("create_schedule MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await expect(
|
||||
invokeToolWithParsedInput(tool, {
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
cron: "*/10 * * * *",
|
||||
provider: "codex",
|
||||
}),
|
||||
).rejects.toThrow("Specify exactly one of every or cron");
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
provider: "codex",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects create_schedule timezone without cron", async () => {
|
||||
it("rejects create_schedule without cron", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2397,13 +2451,11 @@ describe("create_schedule MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await expect(
|
||||
invokeToolWithParsedInput(tool, {
|
||||
tool.handler({
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
timezone: "America/New_York",
|
||||
provider: "codex",
|
||||
}),
|
||||
).rejects.toThrow("timezone can only be used with cron");
|
||||
).rejects.toThrow(/cron/);
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2431,17 +2483,55 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "missing both cadence fields",
|
||||
input: { prompt: "say hello", provider: "codex" },
|
||||
},
|
||||
{
|
||||
label: "blank cadence fields",
|
||||
input: { prompt: "say hello", every: " ", cron: "", provider: "codex" },
|
||||
},
|
||||
])("still rejects create_schedule when $label", async ({ input }) => {
|
||||
describe("create_heartbeat MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("creates a self-targeted cron heartbeat", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
provider: "codex",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: "build",
|
||||
availableModes: [],
|
||||
config: { title: "Parent agent" },
|
||||
} as ManagedAgent);
|
||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_heartbeat");
|
||||
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
prompt: "check status",
|
||||
cron: "*/15 * * * *",
|
||||
timezone: "America/New_York",
|
||||
name: "status heartbeat",
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "check status",
|
||||
cadence: {
|
||||
type: "cron",
|
||||
expression: "*/15 * * * *",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
target: { type: "agent", agentId: "parent-agent" },
|
||||
name: "status heartbeat",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an agent-scoped session", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2451,11 +2541,14 @@ describe("create_schedule MCP tool", () => {
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
const tool = registeredTool(server, "create_heartbeat");
|
||||
|
||||
await expect(invokeToolWithParsedInput(tool, input)).rejects.toThrow(
|
||||
"Specify exactly one of every or cron",
|
||||
);
|
||||
await expect(
|
||||
tool.handler({
|
||||
prompt: "check status",
|
||||
cron: "*/15 * * * *",
|
||||
}),
|
||||
).rejects.toThrow("create_heartbeat requires an agent-scoped session");
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3131,7 +3224,7 @@ describe("speak MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "speak");
|
||||
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
|
||||
"No speak handler registered for caller agent",
|
||||
"No speak handler registered for your session",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -514,6 +514,28 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
registerRawTool(name, relaxMcpToolOutputSchema(config), (async (args: never, extra: never) =>
|
||||
addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler);
|
||||
|
||||
const buildCronScheduleCadence = (input: {
|
||||
cron: string | undefined;
|
||||
timezone?: string;
|
||||
}): ScheduleCadence => {
|
||||
const expression = input.cron?.trim() ?? "";
|
||||
if (!expression) {
|
||||
throw new Error("cron is required");
|
||||
}
|
||||
const timezone = normalizeScheduleTimeZoneArg(input.timezone);
|
||||
return {
|
||||
type: "cron",
|
||||
expression,
|
||||
...(timezone !== undefined ? { timezone } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildScheduleExpiry = (expiresIn: string | undefined): string | undefined => {
|
||||
return expiresIn === undefined
|
||||
? undefined
|
||||
: new Date(Date.now() + parseDurationString(expiresIn)).toISOString();
|
||||
};
|
||||
|
||||
const resolveCallerAgent = () => {
|
||||
if (!callerAgentId) {
|
||||
return null;
|
||||
@@ -541,7 +563,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (opts?.required) {
|
||||
throw new Error("cwd is required");
|
||||
}
|
||||
throw new Error("cwd is required when no caller agent is available");
|
||||
throw new Error("cwd is required outside an agent-scoped session");
|
||||
}
|
||||
|
||||
return expandUserPath(trimmedCwd);
|
||||
@@ -699,7 +721,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional working directory. Defaults to the caller agent working directory."),
|
||||
.describe("Optional working directory. Defaults to your current working directory."),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -718,19 +740,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.trim()
|
||||
.min(1, "initialPrompt is required")
|
||||
.describe("Required first task to run immediately after creation."),
|
||||
background: z
|
||||
detached: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
|
||||
"If true, the created agent stands on its own: it does not appear in your subagent track and is not archived with you.",
|
||||
),
|
||||
notifyOnFinish: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.default(true)
|
||||
.describe(
|
||||
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.",
|
||||
"Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -787,7 +809,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.",
|
||||
"Agent-scoped only: get notified when the created agent finishes, errors, or needs permission.",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -806,6 +828,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"Draft provider settings used to compute available features.",
|
||||
),
|
||||
};
|
||||
type AgentToAgentCreateAgentArgs = z.infer<typeof agentToAgentCreateAgentArgsSchema>;
|
||||
type TopLevelCreateAgentArgs = z.infer<typeof topLevelCreateAgentArgsSchema>;
|
||||
|
||||
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||
@@ -832,7 +855,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
}
|
||||
const handler = resolveSpeakHandler?.(callerAgentId) ?? null;
|
||||
if (!handler) {
|
||||
throw new Error(`No speak handler registered for caller agent '${callerAgentId}'`);
|
||||
throw new Error(`No speak handler registered for your session '${callerAgentId}'`);
|
||||
}
|
||||
await handler({
|
||||
text: args.text,
|
||||
@@ -867,11 +890,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
availableModes: z.array(ProviderModeSchema),
|
||||
lastMessage: z.string().nullable().optional(),
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||
guidance: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (args: unknown) => {
|
||||
const { parsedArgs, worktree } = resolveCreateAgentToolArgs(args);
|
||||
const { snapshot, background, initialPromptStarted } = await createAgentCommand(
|
||||
const resolvedArgs = resolveCreateAgentToolArgs(args);
|
||||
const { parsedArgs, worktree } = resolvedArgs;
|
||||
let requestedBackground: boolean;
|
||||
let notifyOnFinish: boolean;
|
||||
let detached: boolean;
|
||||
if (resolvedArgs.kind === "agent-scoped") {
|
||||
requestedBackground = true;
|
||||
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish;
|
||||
detached = resolvedArgs.parsedArgs.detached;
|
||||
} else {
|
||||
requestedBackground = resolvedArgs.parsedArgs.background;
|
||||
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false;
|
||||
detached = false;
|
||||
}
|
||||
const {
|
||||
snapshot,
|
||||
background: createdInBackground,
|
||||
initialPromptStarted,
|
||||
} = await createAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage,
|
||||
@@ -892,8 +933,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
features: parsedArgs.settings?.features,
|
||||
labels: parsedArgs.labels,
|
||||
mode: parsedArgs.settings?.modeId,
|
||||
background: parsedArgs.background ?? false,
|
||||
notifyOnFinish: parsedArgs.notifyOnFinish ?? false,
|
||||
background: requestedBackground,
|
||||
notifyOnFinish,
|
||||
detached,
|
||||
callerAgentId,
|
||||
callerContext,
|
||||
worktree,
|
||||
@@ -901,7 +943,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
);
|
||||
|
||||
try {
|
||||
if (!background && initialPromptStarted) {
|
||||
if (!createdInBackground && initialPromptStarted) {
|
||||
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
|
||||
waitForActive: true,
|
||||
});
|
||||
@@ -930,8 +972,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Return immediately if background=true
|
||||
// Return immediately for async creation.
|
||||
const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot;
|
||||
const guidance =
|
||||
callerAgentId && notifyOnFinish && initialPromptStarted
|
||||
? "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives."
|
||||
: undefined;
|
||||
const response = {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
@@ -943,26 +989,36 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
availableModes: currentSnapshot.availableModes,
|
||||
lastMessage: null,
|
||||
permission: null,
|
||||
...(guidance ? { guidance } : {}),
|
||||
}),
|
||||
};
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
function resolveCreateAgentToolArgs(args: unknown): {
|
||||
parsedArgs:
|
||||
| z.infer<typeof agentToAgentCreateAgentArgsSchema>
|
||||
| z.infer<typeof topLevelCreateAgentArgsSchema>;
|
||||
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
|
||||
} {
|
||||
type ResolvedCreateAgentToolArgs =
|
||||
| {
|
||||
kind: "agent-scoped";
|
||||
parsedArgs: AgentToAgentCreateAgentArgs;
|
||||
worktree: undefined;
|
||||
}
|
||||
| {
|
||||
kind: "top-level";
|
||||
parsedArgs: TopLevelCreateAgentArgs;
|
||||
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
|
||||
};
|
||||
|
||||
function resolveCreateAgentToolArgs(args: unknown): ResolvedCreateAgentToolArgs {
|
||||
if (callerAgentId) {
|
||||
return {
|
||||
kind: "agent-scoped",
|
||||
parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args),
|
||||
worktree: undefined,
|
||||
};
|
||||
}
|
||||
const parsedArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||
return {
|
||||
kind: "top-level",
|
||||
parsedArgs,
|
||||
worktree: resolveTopLevelCreateAgentWorktree(parsedArgs),
|
||||
};
|
||||
@@ -1088,7 +1144,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission.",
|
||||
"Agent-scoped only: get notified when this run finishes, errors, or needs permission.",
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -1402,7 +1458,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional working directory. Defaults to the caller agent cwd."),
|
||||
.describe("Optional working directory. Defaults to your current working directory."),
|
||||
all: z.boolean().optional().describe("List terminals across all working directories."),
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -1450,7 +1506,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional working directory. Defaults to the caller agent cwd."),
|
||||
.describe("Optional working directory. Defaults to your current working directory."),
|
||||
name: z.string().optional().describe("Optional terminal name."),
|
||||
},
|
||||
outputSchema: TerminalSummarySchema.shape,
|
||||
@@ -1591,22 +1647,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"create_schedule",
|
||||
{
|
||||
title: "Create schedule",
|
||||
description: "Create a recurring schedule that runs on an agent or a new agent.",
|
||||
description: "Create a recurring schedule that starts a new agent on a cron cadence.",
|
||||
inputSchema: {
|
||||
prompt: z.string().trim().min(1, "prompt is required"),
|
||||
every: z.string().optional(),
|
||||
cron: z.string().optional(),
|
||||
cron: z.string().trim().min(1, "cron is required"),
|
||||
timezone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"IANA time zone for cron cadence; requires cron. For example: America/New_York.",
|
||||
),
|
||||
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
provider: AgentProviderEnum.describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
|
||||
),
|
||||
cwd: z.string().optional(),
|
||||
@@ -1615,70 +1667,71 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn }) => {
|
||||
async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
const normalizedEvery = normalizeScheduleCadenceArg(every);
|
||||
const normalizedCron = normalizeScheduleCadenceArg(cron);
|
||||
const normalizedTimeZone = normalizeScheduleTimeZoneArg(timezone);
|
||||
const cadenceCount =
|
||||
Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined);
|
||||
if (cadenceCount !== 1) {
|
||||
throw new Error("Specify exactly one of every or cron");
|
||||
}
|
||||
if (normalizedTimeZone !== undefined && normalizedCron === undefined) {
|
||||
throw new Error("timezone can only be used with cron");
|
||||
}
|
||||
|
||||
const scheduleTarget =
|
||||
target === "self"
|
||||
? (() => {
|
||||
const callerAgent = resolveCallerAgent();
|
||||
if (!callerAgentId || !callerAgent) {
|
||||
throw new Error("target=self requires a caller agent");
|
||||
}
|
||||
const trimmedCwd = cwd?.trim();
|
||||
if (trimmedCwd && expandUserPath(trimmedCwd) !== callerAgent.cwd) {
|
||||
throw new Error("cwd can only differ from the caller agent when target=new-agent");
|
||||
}
|
||||
if (provider !== undefined) {
|
||||
const resolved = resolveScheduleProviderAndModel({
|
||||
provider,
|
||||
defaultProvider: callerAgent.provider,
|
||||
});
|
||||
if (
|
||||
resolved.provider !== callerAgent.provider ||
|
||||
(resolved.model !== undefined && resolved.model !== callerAgent.config.model)
|
||||
) {
|
||||
throw new Error(
|
||||
"provider can only differ from the caller agent when target=new-agent",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { type: "agent" as const, agentId: callerAgentId };
|
||||
})()
|
||||
: (() => {
|
||||
return resolveNewAgentScheduleTarget({ provider, cwd });
|
||||
})();
|
||||
|
||||
const expiresAt = buildScheduleExpiry(expiresIn);
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
cadence:
|
||||
normalizedEvery !== undefined
|
||||
? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) }
|
||||
: {
|
||||
type: "cron" as const,
|
||||
expression: normalizedCron!,
|
||||
...(normalizedTimeZone !== undefined ? { timezone: normalizedTimeZone } : {}),
|
||||
},
|
||||
target: scheduleTarget,
|
||||
cadence: buildCronScheduleCadence({
|
||||
cron,
|
||||
...(timezone !== undefined ? { timezone } : {}),
|
||||
}),
|
||||
target: resolveNewAgentScheduleTarget({ provider, cwd }),
|
||||
...(name?.trim() ? { name: name.trim() } : {}),
|
||||
...(maxRuns === undefined ? {} : { maxRuns }),
|
||||
...(expiresIn === undefined
|
||||
? {}
|
||||
: { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }),
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(toScheduleSummary(schedule)),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
"create_heartbeat",
|
||||
{
|
||||
title: "Create heartbeat",
|
||||
description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.",
|
||||
inputSchema: {
|
||||
prompt: z.string().trim().min(1, "prompt is required"),
|
||||
cron: z.string().trim().min(1, "cron is required"),
|
||||
timezone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
|
||||
name: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
if (!callerAgentId) {
|
||||
throw new Error("create_heartbeat requires an agent-scoped session");
|
||||
}
|
||||
resolveCallerAgent();
|
||||
|
||||
const expiresAt = buildScheduleExpiry(expiresIn);
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
cadence: buildCronScheduleCadence({
|
||||
cron,
|
||||
...(timezone !== undefined ? { timezone } : {}),
|
||||
}),
|
||||
target: { type: "agent", agentId: callerAgentId },
|
||||
...(name?.trim() ? { name: name.trim() } : {}),
|
||||
...(maxRuns === undefined ? {} : { maxRuns }),
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -2027,7 +2080,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
|
||||
.describe("Optional repository cwd. Defaults to your current working directory."),
|
||||
},
|
||||
outputSchema: {
|
||||
worktrees: z.array(WorktreeSummarySchema),
|
||||
@@ -2131,7 +2184,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
|
||||
.describe("Optional repository cwd. Defaults to your current working directory."),
|
||||
worktreePath: z.string().optional(),
|
||||
worktreeSlug: z.string().optional(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user