Skip to content
Open
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
64 changes: 64 additions & 0 deletions docs/agent-delegate.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions src/acp/client.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});
});
});
93 changes: 92 additions & 1 deletion src/acp/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ function installFakePeer(agent: AgentImpl): {
prompt: ReturnType<typeof vi.fn>;
cancel: ReturnType<typeof vi.fn>;
};
sessions: Map<
string,
{ accumulator: { text: string[]; toolCalls: unknown[] } | null }
>;
} {
if (!agent.acpClient) {
throw new Error('ACP client was not initialized');
Expand Down Expand Up @@ -162,7 +166,7 @@ function installFakePeer(agent: AgentImpl): {
};
});

return { promptDeferred, fakeConnection };
return { promptDeferred, fakeConnection, sessions: acpClient.sessions };
}

function extractArtifactPath(notice: string): string {
Expand Down Expand Up @@ -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'),
});
});
});
});
112 changes: 110 additions & 2 deletions src/acp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -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.
*/
Expand Down Expand 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".',
Expand Down Expand Up @@ -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<never>((_, 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,
Expand Down
Loading