fix(subagent): preserve follow-up provenance

This commit is contained in:
Dudu-0223
2026-07-24 13:18:35 +08:00
committed by imccyu
parent 1ab3cbf673
commit 189502e4ac
20 changed files with 202 additions and 87 deletions

View File

@@ -2,7 +2,7 @@
The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls.
The tool performs no lifecycle routing. The control service decides between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child; the tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered.
The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered.
## Model Experience

View File

@@ -67,7 +67,12 @@ export function apply(ctx: Context): void {
throw new Error('send_message requires a calling agent (exec.agent was undefined)')
}
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
const result = ctx.subagentControl.sendMessage(parent, SessionId(args.subagent_id), message)
const result = ctx.subagentControl.sendMessage(
parent,
SessionId(args.subagent_id),
message,
{ kind: 'coordinator', senderSessionId: parent.id },
)
return Promise.resolve(result)
},
}))

View File

@@ -83,17 +83,27 @@ describe('dsh-tool-subagent-control', () => {
expect(text(result)).toBe(`message started task subagent-2 continuing subagent ${started.childId}`)
const collected = await callTool(ctx, 'task_output', { task_id: 'subagent-2', wait: true }, parent)
expect(text(collected)).toBe('second answer\n[status: completed]')
const loaded = await ctx.sessionPersistence.load(started.childId)
const followUp = loaded.events.findLast(event =>
event.type === 'user/message',
)
expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
kind: 'coordinator',
senderSessionId: parent.id,
})
})
it('renders the steered route when the child is still running', async () => {
// Script the child's single turn as two steps: the steer joins mid-turn.
const { ctx, parent } = await setup([])
let steered: string | undefined
let source: unknown
// Reach past the tool into the control service to fake a running route
// deterministically: the tool is a thin adapter, so its steered wording is
// what this test pins.
ctx.subagentControl.sendMessage = (agent, _childId, message) => {
ctx.subagentControl.sendMessage = (agent, _childId, message, messageSource) => {
steered = (message[0] as { text: string }).text
source = messageSource
return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }
}
const result = await callTool(ctx, 'send_message', {
@@ -102,6 +112,7 @@ describe('dsh-tool-subagent-control', () => {
}, parent)
expect(result.isError).toBe(false)
expect(steered).toBe('also consider Y')
expect(source).toEqual({ kind: 'coordinator', senderSessionId: parent.id })
expect(text(result)).toBe('message delivered to running task subagent-9')
})