diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 18f4348ff7..83db3408e6 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -2,6 +2,22 @@ import { describe, expect, it } from "vitest"; import { createCli } from "./cli.js"; describe("canonical CLI surface", () => { + it.each(["send", "agent send"])("%s accepts an explicit behavior override", async (path) => { + const cli = createCli(); + const owner = + path === "send" ? cli : cli.commands.find((command) => command.name() === "agent")!; + const send = owner.commands.find((command) => command.name() === "send")!; + send.action(() => {}); + await send.parseAsync(["target", "message", "--behavior", "interrupt"], { from: "user" }); + expect(send.opts().behavior).toBe("interrupt"); + await send.parseAsync(["target", "message", "--behavior", "steer"], { from: "user" }); + expect(send.opts().behavior).toBe("steer"); + send.exitOverride().configureOutput({ writeErr: () => {} }); + await expect( + send.parseAsync(["target", "message", "--behavior", "queue"], { from: "user" }), + ).rejects.toThrow(); + }); + it("offers daemon host selection as a global option", () => { expect(createCli().helpInformation()).toContain("--host "); }); diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index 69e0c36d9d..1f288d4ca2 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -1,4 +1,5 @@ -import { type Command } from "commander"; +import { type Command, Option } from "commander"; +import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import { collectMultiple } from "../../utils/command-options.js"; import { connectToDaemon } from "../../utils/client.js"; import type { @@ -28,6 +29,7 @@ export const agentSendSchema: OutputSchema = { }; export interface AgentSendOptions extends CommandOptions { + behavior?: ActiveTurnBehavior; wait?: boolean; image?: string[]; prompt?: string; @@ -42,6 +44,12 @@ export function addSendOptions(cmd: Command): Command { .option("--prompt ", "Provide the message inline as a flag") .option("--prompt-file ", "Read the message from a UTF-8 text file") .option("--image ", "Attach image(s) to the message", collectMultiple, []) + .addOption( + new Option( + "--behavior ", + "Active-turn behavior (agent default: steer; otherwise: interrupt)", + ).choices(["interrupt", "steer"]), + ) .option("--no-wait", "Return immediately without waiting for completion"); } @@ -191,10 +199,12 @@ export async function runSendCommand( const images = options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined; + const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined; // Send the message await client.sendAgentMessage(agentIdArg, promptInput, { images, - sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined, + sourceAgentId, + activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"), }); // If --no-wait, return immediately diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 4044fd84bc..bf6d50480b 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -402,6 +402,8 @@ export interface SetupFinishNotificationParams { childAgentId: string; callerAgentId: string; requireParentOwnership?: boolean; + /** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */ + dispatchResult?: Promise; /** A replacement must observe its new start, not the displaced running snapshot. */ waitForTurnStart?: boolean; logger: Logger; @@ -466,10 +468,17 @@ interface NotifySafelyOptions { permissionRequest?: AgentPermissionRequest; } -// A caller waits on a child through one armed notification. Arming again, such as a -// follow-up prompt while the child still runs, replaces the earlier one so the child's -// next finish reaches the caller once. -const armedFinishNotifications = new WeakMap void>>(); +// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces +// every earlier observation for the caller/child pair; a failure removes only itself. +interface ArmedFinishNotification { + stop(): void; + supersedeWhen(accepted: Promise): void; +} + +const armedFinishNotifications = new WeakMap< + AgentManager, + Map> +>(); export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { const { @@ -491,15 +500,49 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map(); armedFinishNotifications.set(agentManager, armedByManager); const armedKey = JSON.stringify([childAgentId, callerAgentId]); - armedByManager.get(armedKey)?.(); - armedByManager.set(armedKey, stop); + const registrations = armedByManager.get(armedKey) ?? new Set(); + armedByManager.set(armedKey, registrations); + const previous = [...registrations]; + let superseded = Promise.resolve(false); + let dispatchPending = Boolean(params.dispatchResult); + const registration: ArmedFinishNotification = { + stop, + supersedeWhen(accepted) { + superseded = superseded.then( + async (alreadySuperseded) => alreadySuperseded || (await accepted), + ); + }, + }; + registrations.add(registration); + if (params.dispatchResult) { + const dispatchAccepted = params.dispatchResult.then((result) => result !== false); + for (const observer of previous) observer.supersedeWhen(dispatchAccepted); + void params.dispatchResult.then((accepted) => { + dispatchPending = false; + if (accepted) { + for (const observer of previous) observer.stop(); + if (stopped || accepted === "turn_started") stop(); + } else { + stop(); + } + return undefined; + }); + } else { + for (const observer of previous) { + observer.supersedeWhen(Promise.resolve(true)); + observer.stop(); + } + } function stop(): void { - if (stopped) return; stopped = true; unsubscribe?.(); - if (armedByManager.get(armedKey) === stop) { - armedByManager.delete(armedKey); + // Pending observations remain visible to overlapping sends until acknowledgement. + if (!dispatchPending) { + registrations.delete(registration); + if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) { + armedByManager.delete(armedKey); + } } } @@ -507,6 +550,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): reason: FinishNotificationReason, permissionRequest?: AgentPermissionRequest, ): Promise { + if (params.dispatchResult) { + const result = await params.dispatchResult; + if (result === false || result === "turn_started") return; + } + if (await superseded) return; const callerRecord = await agentStorage.get(callerAgentId); if (callerRecord?.archivedAt) { return; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 33467b22f6..9246f463d0 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,3 +1,4 @@ +import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js"; import { parseAgentMessage } from "./agent-messages/index.js"; import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -242,6 +243,7 @@ function buildAgentManagerSpies() { appendTimelineItem: vi.fn().mockResolvedValue(undefined), emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined), hasInFlightRun: vi.fn().mockReturnValue(false), + steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }), tryRunOutOfBand: vi.fn().mockReturnValue(false), subscribe: vi.fn().mockReturnValue(() => {}), streamAgent: vi.fn(() => (async function* noop() {})()), @@ -3860,6 +3862,9 @@ const HELD_TURN_CAPABILITIES = { } as const; interface HeldTurnOptions { + finishDuringSteer?: boolean; + rejectSteer?: boolean; + steerResult?: "accepted" | "unavailable"; /** Run the whole turn before the turn-start request returns. */ finishTurnsDuringStart?: boolean; } @@ -3872,6 +3877,7 @@ class HeldTurnAgentSession implements AgentSession { readonly capabilities = HELD_TURN_CAPABILITIES; readonly id = randomUUID(); readonly prompts: string[] = []; + readonly steeredPrompts: AgentPromptInput[] = []; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private activeTurnId: string | null = null; private turnStartGate: Promise | null = null; @@ -3904,6 +3910,14 @@ class HeldTurnAgentSession implements AgentSession { return prompt === "/side-command" ? { run: async () => {} } : null; } + async steerActiveTurn(prompt: AgentPromptInput) { + const status = this.options.steerResult ?? "unavailable"; + if (status === "accepted") this.steeredPrompts.push(prompt); + if (status === "accepted" && this.options.finishDuringSteer) this.finishTurn(); + if (this.options.rejectSteer) throw new Error("Steer rejected"); + return { status }; + } + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); await this.turnStartGate; @@ -4050,6 +4064,295 @@ class HeldTurnAgentClient implements AgentClient { } describe("send_agent_prompt MCP tool", () => { + it.each([ + { agentScoped: true, behavior: undefined, available: true, steered: true }, + { agentScoped: true, behavior: "interrupt", available: true, steered: false }, + { agentScoped: true, behavior: "steer", available: true, steered: true }, + { agentScoped: false, behavior: undefined, available: true, steered: false }, + { agentScoped: false, behavior: "steer", available: true, steered: true }, + { agentScoped: true, behavior: undefined, available: false, steered: false }, + ] as const)( + "dispatches behavior $behavior for agent-scoped=$agentScoped, steering available=$available", + async ({ agentScoped, behavior, available, steered }) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true, { + steerResult: available ? "accepted" : "unavailable", + }); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: agentScoped ? parent.id : undefined, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Original work", + notifyOnFinish: false, + }); + const originalTurn = manager.getAgent(child.id)!.activeTurnId; + const response = await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "New message", + notifyOnFinish: false, + ...(behavior ? { activeTurnBehavior: behavior } : {}), + }); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: steered ? "steered" : "turn_started", + }); + expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2); + expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0); + expect(manager.getAgent(child.id)!.activeTurnId === originalTurn).toBe(steered); + } finally { + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + + it.each([ + { rejected: false, priorNotification: false }, + { rejected: true, priorNotification: false }, + { rejected: false, priorNotification: true }, + ])( + "buffers completion until steer acknowledgement (rejected: $rejected, prior notification: $priorNotification)", + async ({ rejected, priorNotification }) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true, { + steerResult: "accepted", + finishDuringSteer: true, + rejectSteer: rejected, + }); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let mcpClient: Client | undefined; + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + mcpClient = await connectInMemoryMcpClient(server); + const send = (input: Record) => + mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + await send({ + agentId: child.id, + prompt: "Original work", + notifyOnFinish: priorNotification, + }); + const sending = send({ + agentId: child.id, + prompt: "New message", + notifyOnFinish: true, + }); + if (rejected) { + expect(await sending).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Steer rejected") }], + }); + // Flush asynchronous notification work before checking that a rejected + // dispatch did not notify. Then prove a later successful send still can. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(client.sessions[0]!.prompts).toHaveLength(0); + await send({ + agentId: child.id, + prompt: "Retry after rejection", + }); + client.sessions[1]!.finishTurn(); + } else { + const response = await sending; + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "steered", + }); + } + await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1)); + expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: child.id, event: "finished" }, + }); + } finally { + await mcpClient?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + + it.each([false, true])( + "preserves the original notification after rejection (completion during send: %s)", + async (finishDuringSteer) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true, { + steerResult: "accepted", + finishDuringSteer, + rejectSteer: true, + }); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let mcpClient: Client | undefined; + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { + workspaceId: "wks_parent", + }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { + workspaceId: "wks_parent", + }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + mcpClient = await connectInMemoryMcpClient(server); + const send = (input: Record) => + mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + await send({ + agentId: child.id, + prompt: "Original work", + notifyOnFinish: true, + }); + const sending = send({ + agentId: child.id, + prompt: "New message", + notifyOnFinish: true, + }); + expect(await sending).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Steer rejected") }], + }); + if (!finishDuringSteer) { + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(client.sessions[0]!.prompts).toHaveLength(0); + client.sessions[1]!.finishTurn(); + } + await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1)); + expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: child.id, event: "finished" }, + }); + } finally { + await mcpClient?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + + it.each([ + [false, true], + [true, false], + [false, false], + [true, true], + ])( + "overlapping follow-ups notify once (first accepted: %s, second accepted: %s)", + async (firstAccepted, secondAccepted) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true, { + steerResult: "accepted", + finishDuringSteer: false, + rejectSteer: true, + }); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let mcpClient: Client | undefined; + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { + workspaceId: "wks_parent", + }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { + workspaceId: "wks_parent", + }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + mcpClient = await connectInMemoryMcpClient(server); + const send = (input: Record) => + mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + await send({ + agentId: child.id, + prompt: "Original work", + notifyOnFinish: true, + }); + let firstResolve!: (result: PromptDispatchDisposition | false) => void; + let secondResolve!: (result: PromptDispatchDisposition | false) => void; + const first = new Promise((resolve) => { + firstResolve = resolve; + }); + const second = new Promise((resolve) => { + secondResolve = resolve; + }); + for (const dispatchResult of [first, second]) { + setupFinishNotification({ + agentManager: manager, + agentStorage: storage, + childAgentId: child.id, + callerAgentId: parent.id, + dispatchResult, + logger, + }); + } + firstResolve(firstAccepted ? "steered" : false); + await first; + secondResolve(secondAccepted ? "steered" : false); + await second; + client.sessions[1]!.finishTurn(); + await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1)); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(client.sessions[0]!.prompts).toHaveLength(1); + expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: child.id, event: "finished" }, + }); + } finally { + await mcpClient?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + const logger = createTestLogger(); const existingCwd = process.cwd(); @@ -4100,7 +4403,7 @@ describe("send_agent_prompt MCP tool", () => { const response = await tool.handler(parsed.data as Record); - expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1); + expect(spies.agentManager.subscribe).toHaveBeenCalled(); expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); expect(response.structuredContent.guidance).toBe( "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", @@ -4270,7 +4573,7 @@ describe("send_agent_prompt MCP tool", () => { }, ); - it.each(["out_of_band", "startup_failure"] as const)( + it.each(["out_of_band", "out_of_band_interrupt", "startup_failure"] as const)( "preserves %s dispatch notification semantics", async (scenario) => { const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-")); @@ -4306,10 +4609,11 @@ describe("send_agent_prompt MCP tool", () => { prompt: "Ongoing task", notifyOnFinish: false, }); - if (scenario === "out_of_band") { + if (scenario !== "startup_failure") { const response = await invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "/side-command", + activeTurnBehavior: scenario === "out_of_band_interrupt" ? "interrupt" : "steer", }); expect(response.structuredContent).toMatchObject({ success: true, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 68e9d99a40..64d36d3b52 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -6,7 +6,7 @@ import type { Logger } from "pino"; import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentManager } from "../agent-manager.js"; -import { AgentProfileSchema } from "@getpaseo/protocol/messages"; +import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages"; import type { DaemonConfigStore } from "../../daemon-config-store.js"; import { AgentFeatureSchema, @@ -1209,6 +1209,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .object(legacyTopLevelCreateAgentInputSchema) .strict(); const commonSendAgentPromptInputSchema = { + activeTurnBehavior: ActiveTurnBehaviorSchema.optional().describe( + 'How to send into a running turn: "steer" joins it, falling back to interrupt when unavailable; "interrupt" replaces it. Defaults to steer for agent callers and interrupt otherwise.', + ), agentId: z.string(), prompt: z.string(), sessionMode: z.string().optional().describe("Optional mode to set before running the prompt."), @@ -1955,28 +1958,55 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase inputSchema: sendAgentPromptInputSchema, outputSchema: { success: z.boolean(), - disposition: z.enum(["turn_started", "out_of_band"]).optional(), + disposition: z.enum(["turn_started", "out_of_band", "steered"]).optional(), status: AgentStatusEnum, guidance: z.string().optional(), }, }, - async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => { + async ({ + agentId, + prompt, + sessionMode, + notifyOnFinish = Boolean(callerAgentId), + activeTurnBehavior = callerAgentId ? "steer" : "interrupt", + }) => { let stopNotification: (() => void) | undefined; - function armFinishNotification(disposition: PromptDispatchDisposition): void { - if (!callerAgentId || !notifyOnFinish) { - return; - } + let acceptDispatch!: (result: PromptDispatchDisposition | false) => void; + const dispatchResult = new Promise((resolve) => { + acceptDispatch = resolve; + }); + const notifying = Boolean(callerAgentId && notifyOnFinish); + if (callerAgentId && notifyOnFinish) { stopNotification = setupFinishNotification({ agentManager, agentStorage, childAgentId: agentId, callerAgentId, - waitForTurnStart: disposition === "turn_started", + waitForTurnStart: false, + dispatchResult, + logger: childLogger, + }); + } + function confirmDispatch(disposition: PromptDispatchDisposition): void { + // A new turn must ignore outcomes from the displaced turn. Its iterator + // has not started yet, so rearm here; steering keeps the early observer. + if (disposition !== "turn_started") { + acceptDispatch(disposition); + return; + } + acceptDispatch(disposition); + if (!callerAgentId || !notifyOnFinish) return; + stopNotification?.(); + stopNotification = setupFinishNotification({ + agentManager, + agentStorage, + childAgentId: agentId, + callerAgentId, + waitForTurnStart: true, logger: childLogger, }); } - const notifying = Boolean(callerAgentId && notifyOnFinish); const { disposition } = await sendPromptToAgent({ agentManager, agentStorage, @@ -1984,9 +2014,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase prompt, source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined, sessionMode, - onDispatch: armFinishNotification, + activeTurnBehavior, + onDispatch: confirmDispatch, logger: childLogger, }).catch((error) => { + acceptDispatch(false); stopNotification?.(); if (error instanceof AgentNotFoundError) { throw new PaseoToolInputError(error.message); diff --git a/public-docs/cli.md b/public-docs/cli.md index c8f3cc0e1a..f118d35894 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -289,9 +289,12 @@ Use the recipient's agent ID from `paseo ls`, or [copy it from the agent's tab]( ```bash paseo send "now run the tests" paseo send --image screenshot.png "what's wrong here?" -paseo send --no-wait "queue this task" +paseo send --no-wait "run this task" +paseo send --behavior interrupt "stop and do this instead" ``` +Agent-originated sends (`PASEO_AGENT_ID` set) default to steering the running turn. Other sends default to interrupting it. Use `--behavior steer` or `--behavior interrupt` to override the default. Steering uses the same fallback as the client: if steering is unavailable, the running turn is interrupted and replaced. + ## Viewing logs ```bash diff --git a/public-docs/orchestration-workflows.md b/public-docs/orchestration-workflows.md index e7e5f271c3..61343e5cff 100644 --- a/public-docs/orchestration-workflows.md +++ b/public-docs/orchestration-workflows.md @@ -51,7 +51,7 @@ Agents can prompt each other by **agent ID**, the identifier Paseo uses to addre > Use Paseo to send this prompt to agent [paste agent ID here]: “Review the parser changes in your workspace and report any missing test cases.” -The receiving agent gets the prompt in its existing conversation. To have it send a separate message back, give it the sender's agent ID too. +The receiving agent gets the prompt in its existing conversation. Agent-originated sends default to steering a running turn, with interruption fallback when steering is unavailable. Set MCP `activeTurnBehavior` or CLI `--behavior` to `steer` or `interrupt` to override that default. To have it send a separate message back, give it the sender's agent ID too. Underneath, the agent calls `send_agent_prompt` with the recipient's `agentId` and a `prompt`. Agents can also discover IDs with `list_agents`; the CLI uses [`paseo send `](/docs/cli#sending-messages). For another host, use the [remote CLI workflow](#work-on-another-machine). diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index b7fe95811d..83d329f948 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -63,7 +63,7 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents. -**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback. +**`send_agent_prompt`** — `{ agentId, prompt, activeTurnBehavior? }`. Use for follow-ups to an existing agent. Agent-scoped calls default to `steer`; top-level calls default to `interrupt`. Set `activeTurnBehavior` to `steer` or `interrupt` to override the default. Steering falls back to interruption when unavailable. CLI agent sends use the same defaults and accept `--behavior steer|interrupt`. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback. **`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.