From 834332cbd2b7963f957cd4a2aa6fd012534a97ce Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sun, 19 Apr 2026 18:22:14 +0800 Subject: [PATCH] feat(acp): add agent_delegate synchronous delegation action --- docs/agent-delegate.md | 64 +++++++++++++++++++++ src/acp/client.e2e.test.ts | 39 +++++++++++++ src/acp/client.test.ts | 93 +++++++++++++++++++++++++++++- src/acp/client.ts | 112 ++++++++++++++++++++++++++++++++++++- 4 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 docs/agent-delegate.md diff --git a/docs/agent-delegate.md b/docs/agent-delegate.md new file mode 100644 index 00000000000..70cf3d4341e --- /dev/null +++ b/docs/agent-delegate.md @@ -0,0 +1,64 @@ +# `agent_delegate` + +## Overview + +`agent_delegate` sends a plain-text prompt to a configured ACP peer and waits for the peer to finish before returning a result to the caller. + +Use `agent_delegate` when the caller needs the sub-agent's full answer inline. + +Use `acp_prompt` when the caller wants fire-and-forget behavior, with completion delivered later through an ACP result artifact and injected notice. + +## Parameters + +`agent_delegate` accepts: + +- `target_group_jid`: peer name returned by `acp_list_remote_agents` +- `prompt`: plain-text prompt sent to the target peer +- `timeout_ms`: optional timeout in milliseconds; defaults to `300000` + +## Return Value + +`agent_delegate` returns: + +```json +{ + "text": "concatenated text chunks from the sub-agent", + "status": "completed", + "stop_reason": "end_turn" +} +``` + +Possible fields: + +- `text`: concatenated text chunks received from the peer during the run +- `status`: one of `completed`, `failed`, or `cancelled` +- `stop_reason`: peer stop reason when available, such as `end_turn` or `cancelled` +- `error`: set when the delegation fails or times out + +## Usage Example + +```json +{ + "name": "agent_delegate", + "payload": { + "target_group_jid": "test-peer", + "prompt": "Summarize the repository status in one paragraph.", + "timeout_ms": 300000 + } +} +``` + +## Isolation + +Each `agent_delegate` call opens a fresh ACP session for the target peer and removes that session when the call finishes. + +The delegated session is isolated from the caller. No prior conversation state is shared automatically. + +## Timeout Notes + +If `timeout_ms` expires before the peer finishes, `agent_delegate` returns: + +- `status: "failed"` +- `error` containing the timeout message + +The host also sends a best-effort ACP cancel request to the peer after the timeout fires. diff --git a/src/acp/client.e2e.test.ts b/src/acp/client.e2e.test.ts index a1d21b16dc3..87c76a79aca 100644 --- a/src/acp/client.e2e.test.ts +++ b/src/acp/client.e2e.test.ts @@ -197,4 +197,43 @@ describe('ACP background prompt e2e', () => { }), ]); }); + + it('returns the real peer response synchronously via agent_delegate', async () => { + const response = await callAction( + agent, + 'team', + 'agent_delegate', + { + target_group_jid: 'test-peer', + prompt: 'delegate this prompt', + }, + 'team@g.us', + ); + + expect(response.status).toBe(200); + expect(response.json.result).toMatchObject({ + status: 'completed', + text: expect.stringContaining('real peer says hello'), + }); + }); + + it('returns a failed result when agent_delegate times out', async () => { + const response = await callAction( + agent, + 'team', + 'agent_delegate', + { + target_group_jid: 'test-peer', + prompt: 'delegate this prompt', + timeout_ms: 1, + }, + 'team@g.us', + ); + + expect(response.status).toBe(200); + expect(response.json.result).toMatchObject({ + status: 'failed', + error: expect.stringContaining('timed out'), + }); + }); }); diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index f0288cf7865..d9ed1f26170 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -115,6 +115,10 @@ function installFakePeer(agent: AgentImpl): { prompt: ReturnType; cancel: ReturnType; }; + sessions: Map< + string, + { accumulator: { text: string[]; toolCalls: unknown[] } | null } + >; } { if (!agent.acpClient) { throw new Error('ACP client was not initialized'); @@ -162,7 +166,7 @@ function installFakePeer(agent: AgentImpl): { }; }); - return { promptDeferred, fakeConnection }; + return { promptDeferred, fakeConnection, sessions: acpClient.sessions }; } function extractArtifactPath(notice: string): string { @@ -505,4 +509,91 @@ describe('AcpOutboundClient integration', () => { }); expect(fs.existsSync(expiredPath)).toBe(false); }); + + describe('agent_delegate', () => { + it('returns the delegated peer response synchronously on success', async () => { + const agent = createAgent('acp-delegate-success', tmpDir); + agents.push(agent); + await agent.start(); + await agent.registerGroup('team@g.us', TEAM_GROUP); + + const { fakeConnection, sessions } = installFakePeer(agent); + fakeConnection.prompt.mockImplementationOnce( + async ({ sessionId }: { sessionId: string }) => { + const session = sessions.get(sessionId); + session?.accumulator?.text.push('delegate says hello'); + return { stopReason: 'end_turn' }; + }, + ); + + const response = await callAction( + agent, + 'team', + 'agent_delegate', + { + target_group_jid: 'fake-peer', + prompt: 'say hello', + }, + 'team@g.us', + ); + + expect(response.status).toBe(200); + expect(response.json.result).toMatchObject({ + status: 'completed', + stop_reason: 'end_turn', + text: 'delegate says hello', + }); + }); + + it('fails when the target peer is unknown', async () => { + const agent = createAgent('acp-delegate-missing-peer', tmpDir); + agents.push(agent); + await agent.start(); + await agent.registerGroup('team@g.us', TEAM_GROUP); + + const response = await callAction( + agent, + 'team', + 'agent_delegate', + { + target_group_jid: 'missing-peer', + prompt: 'hello', + }, + 'team@g.us', + ); + + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.json.error).toContain('unknown acp peer'); + }); + + it('returns a failed result when agent_delegate times out', async () => { + const agent = createAgent('acp-delegate-timeout', tmpDir); + agents.push(agent); + await agent.start(); + await agent.registerGroup('team@g.us', TEAM_GROUP); + + const { fakeConnection } = installFakePeer(agent); + fakeConnection.prompt.mockImplementationOnce( + async () => await new Promise<{ stopReason: string }>(() => {}), + ); + + const response = await callAction( + agent, + 'team', + 'agent_delegate', + { + target_group_jid: 'fake-peer', + prompt: 'hang forever', + timeout_ms: 100, + }, + 'team@g.us', + ); + + expect(response.status).toBe(200); + expect(response.json.result).toMatchObject({ + status: 'failed', + error: expect.stringContaining('timed out'), + }); + }); + }); }); diff --git a/src/acp/client.ts b/src/acp/client.ts index 42380d6393b..3e09c0ea9b1 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -6,11 +6,12 @@ // `search_actions({query: "acp"})` to discover, then `call_action(...)` to // invoke, using the existing PR #44 http-actions infrastructure. // -// Five actions get registered per agent (when options.acp.peers is set): +// Six actions get registered per agent (when options.acp.peers is set): // // acp_list_remote_agents — directory snapshot // acp_new_session — create a session on a peer // acp_prompt — send PromptRequest in the background +// agent_delegate — send a synchronous prompt in an isolated session // acp_cancel — session/cancel notification // acp_close_session — drop local session tracking // @@ -180,7 +181,7 @@ export class AcpOutboundClient { } /** - * Register the five core ACP conversation primitives as HTTP actions on + * Register the core ACP conversation primitives as HTTP actions on * the given agent. Called during `AgentImpl.startSubsystems()` after the * existing `actionsHttp` is wired up. */ @@ -222,6 +223,28 @@ export class AcpOutboundClient { async (args, ctx) => this.handlePrompt(args, ctx), ); + agent.action( + 'agent_delegate', + 'Delegate a prompt to another agent synchronously via ACP. Opens an isolated session to the target peer, sends the prompt, waits for the full response, and returns the result directly. Sub-agent sessions are isolated — no caller context is shared. The action blocks until the sub-agent finishes or the timeout expires.', + { + target_group_jid: z + .string() + .describe( + 'Peer name from acp_list_remote_agents — the target agent to delegate to', + ), + prompt: z.string().describe('The prompt text to send to the sub-agent'), + timeout_ms: z + .number() + .int() + .positive() + .optional() + .describe( + 'Response timeout in milliseconds. Default: 300000 (5 minutes)', + ), + }, + async (args, ctx) => this.handleDelegate(args, ctx), + ); + agent.action( 'acp_cancel', 'Cancel the active background ACP prompt for a session_id. Sends a session/cancel notification to the peer. AgentLite still writes a terminal artifact and injects a completion notice with stop_reason "cancelled".', @@ -314,6 +337,91 @@ export class AcpOutboundClient { return { ok: true }; } + private async handleDelegate( + args: { target_group_jid: string; prompt: string; timeout_ms?: number }, + ctx: ActionContext, + ): Promise<{ + text: string; + status: 'completed' | 'failed' | 'cancelled'; + stop_reason?: string; + error?: string; + }> { + const peer = this.requirePeer(args.target_group_jid); + await this.ensurePeerReady(peer); + + const callerChatJid = this.deps.resolveCallerChatJid(ctx); + const cwd = resolveGroupFolderPath(ctx.sourceGroup, this.deps.groupsDir); + fs.mkdirSync(cwd, { recursive: true }); + + const { sessionId } = await peer.connection!.newSession({ + cwd, + mcpServers: [], + }); + + const timeoutMs = args.timeout_ms ?? 5 * 60 * 1000; + const acc: SessionAccumulator = { text: [], toolCalls: [] }; + this.sessions.set(sessionId, { + peer: peer.config.name, + callerGroupFolder: ctx.sourceGroup, + callerChatJid, + createdAt: Date.now(), + accumulator: acc, + activeRunId: null, + }); + + let status: 'completed' | 'failed' | 'cancelled' = 'failed'; + let stopReason: string | undefined; + let error: string | undefined; + + try { + let timeoutHandle: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => + reject(new Error(`agent_delegate timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + + try { + const response = await Promise.race([ + peer.connection!.prompt({ + sessionId, + prompt: [{ type: 'text', text: args.prompt }], + }), + timeoutPromise, + ]); + stopReason = response.stopReason; + status = + response.stopReason === 'cancelled' ? 'cancelled' : 'completed'; + } finally { + clearTimeout(timeoutHandle); + } + } catch (err) { + error = errMsg(err); + if (peer.connection) { + try { + await peer.connection.cancel({ sessionId }); + } catch { + // best-effort + } + } + logger.warn( + { session_id: sessionId, peer: peer.config.name, err: error }, + 'acp: agent_delegate failed', + ); + } finally { + this.sessions.delete(sessionId); + } + + return { + text: acc.text.join(''), + status, + ...(stopReason !== undefined ? { stop_reason: stopReason } : {}), + ...(error !== undefined ? { error } : {}), + }; + } + private async handleCancel( args: { session_id: string }, ctx: ActionContext,