feat(subagent): add current-turn interrupt RPC

ctx.subagents.interrupt() stops one live continuable child's current turn
via Agent.cancel(cause, { keepInbox: true }) under either a human durable
parent address or an exact live ancestor Agent. Fire-and-return: admission
is synchronous, quiescence is not awaited. Pending inbox work, the
Activation, and published descendants are preserved; only a later waking
send resumes the parked FIFO queue. Absent, one-shot, and disposing
targets are accepted no-ops.

The new Host RPC subagent.interrupt calls only that primitive with user
authority — no catalog, history, persistence, or parent-registry lookup —
so a live child stays stoppable while its parent Agent is offline.

Refs #1535
This commit is contained in:
Hypatia May
2026-08-06 11:59:19 +08:00
committed by Tianyi Cui
parent 5f9456c24f
commit 66a21a38b1
27 changed files with 613 additions and 17 deletions

View File

@@ -2203,6 +2203,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
prompt: request => Promise.resolve(ok(request, {
messageId: `fixture-message-${request.payload.childSessionId}` as never,
})),
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
@@ -2748,6 +2749,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'subagent.list': return this.api.subagents.list(request)
case 'subagent.history': return this.api.subagents.history(request)
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)

View File

@@ -126,6 +126,9 @@ export class FakeApiClient implements IApiClient {
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
messageId: 'fake-message' as never,
}))),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
accepted: true as const,
}))),
}
readonly host: IApiClient['host'] = {

View File

@@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient {
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
= () => Promise.resolve(ok({ accepted: true as const }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
}
readonly host: IApiClient['host'] = {