@namzu/sdk 6.0.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +254 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +18 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +31 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -1
- package/dist/gateway/__tests__/completion-inbox.test.d.ts +2 -0
- package/dist/gateway/__tests__/completion-inbox.test.d.ts.map +1 -0
- package/dist/gateway/__tests__/completion-inbox.test.js +275 -0
- package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -0
- package/dist/gateway/completion-inbox.d.ts +113 -0
- package/dist/gateway/completion-inbox.d.ts.map +1 -0
- package/dist/gateway/completion-inbox.js +225 -0
- package/dist/gateway/completion-inbox.js.map +1 -0
- package/dist/public-runtime.d.ts +1 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +4 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/__tests__/dialect.test.js +8 -5
- package/dist/registry/tool/__tests__/dialect.test.js.map +1 -1
- package/dist/registry/tool/dialect.js +11 -4
- package/dist/registry/tool/dialect.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +34 -0
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/query/__tests__/completion-notification.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/completion-notification.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/completion-notification.test.js +168 -0
- package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts +2 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js +122 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js.map +1 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.js +171 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +14 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +20 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +9 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +74 -1
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js +43 -13
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/tools/builtins/__tests__/bash.proc-test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.js +115 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.js.map +1 -0
- package/dist/tools/builtins/__tests__/bash.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/bash.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/bash.test.js +65 -0
- package/dist/tools/builtins/__tests__/bash.test.js.map +1 -0
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +89 -17
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.js +94 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js +255 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/task-list.test.js +13 -7
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts +44 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +239 -16
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/agent/supervisor.d.ts +27 -0
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/tool/index.d.ts +13 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +3 -2
- package/src/agents/SupervisorAgent.ts +19 -0
- package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +37 -0
- package/src/gateway/__tests__/completion-inbox.test.ts +343 -0
- package/src/gateway/completion-inbox.ts +241 -0
- package/src/public-runtime.ts +4 -0
- package/src/registry/tool/__tests__/dialect.test.ts +8 -5
- package/src/registry/tool/dialect.ts +11 -4
- package/src/registry/tool/execute.ts +35 -0
- package/src/runtime/query/__tests__/completion-notification.test.ts +196 -0
- package/src/runtime/query/__tests__/run-survives-its-own-park.proc-test.ts +127 -0
- package/src/runtime/query/__tests__/step-allow-list.test.ts +205 -0
- package/src/runtime/query/executor.ts +21 -1
- package/src/runtime/query/index.ts +11 -0
- package/src/runtime/query/iteration/index.ts +77 -1
- package/src/runtime/query/iteration/phases/context.ts +51 -15
- package/src/tools/builtins/__tests__/bash.proc-test.ts +142 -0
- package/src/tools/builtins/__tests__/bash.test.ts +79 -0
- package/src/tools/builtins/bash.ts +100 -18
- package/src/tools/coordinator/__tests__/allow-delegation.test.ts +120 -0
- package/src/tools/coordinator/__tests__/completion-delivery.test.ts +347 -0
- package/src/tools/coordinator/__tests__/task-list.test.ts +13 -7
- package/src/tools/coordinator/index.ts +278 -21
- package/src/types/agent/supervisor.ts +28 -0
- package/src/types/tool/index.ts +13 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { TaskGateway } from '../../../types/agent/gateway.js'
|
|
4
|
+
import { buildCoordinatorTools } from '../index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a run may delegate is not the same question as who it may delegate
|
|
8
|
+
* to, and only the caller can answer the first.
|
|
9
|
+
*
|
|
10
|
+
* The roster answers WHO. It cannot answer WHETHER, because two runs are
|
|
11
|
+
* indistinguishable in it: a supervisor whose roster happens to hold one
|
|
12
|
+
* specialist, where delegating is the point; and a run whose own persona IS
|
|
13
|
+
* that specialist, where delegating is delegating to itself. A host builds the
|
|
14
|
+
* second by putting a specialist's persona into the supervisor shell and its
|
|
15
|
+
* id into the roster — so a predicate comparing the roster against the
|
|
16
|
+
* executing agent sees two different ids and cheerfully says "can delegate".
|
|
17
|
+
*
|
|
18
|
+
* Measured before this existed: such a run carried `create_task`,
|
|
19
|
+
* `wait_for_task`, `cancel_task` and `agent_task_list`, byte-identical to a
|
|
20
|
+
* run that could actually delegate, and the model could only discover the
|
|
21
|
+
* refusal by spending a turn on it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const gateway = {
|
|
25
|
+
listTasks: () => [],
|
|
26
|
+
onTaskCompleted: () => () => {},
|
|
27
|
+
} as unknown as TaskGateway
|
|
28
|
+
|
|
29
|
+
function namesFor(opts: {
|
|
30
|
+
agentIds: string[]
|
|
31
|
+
allowDelegation?: boolean
|
|
32
|
+
withHitl?: boolean
|
|
33
|
+
}): string[] {
|
|
34
|
+
return buildCoordinatorTools({
|
|
35
|
+
gateway,
|
|
36
|
+
workingDirectory: '/tmp/test',
|
|
37
|
+
allowedAgentIds: opts.agentIds,
|
|
38
|
+
...(opts.allowDelegation !== undefined ? { allowDelegation: opts.allowDelegation } : {}),
|
|
39
|
+
...(opts.withHitl
|
|
40
|
+
? { resumeHandler: (async () => ({ action: 'continue' })) as never, runId: 'run_1' as never }
|
|
41
|
+
: {}),
|
|
42
|
+
}).map((t) => t.name)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('a run can decline to delegate while still naming who it would have called', () => {
|
|
46
|
+
it('withholds the delegation tools when delegation is off', () => {
|
|
47
|
+
const names = namesFor({ agentIds: ['specialist'], allowDelegation: false })
|
|
48
|
+
|
|
49
|
+
expect(names).not.toContain('create_task')
|
|
50
|
+
expect(names).not.toContain('wait_for_task')
|
|
51
|
+
expect(names).not.toContain('cancel_task')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('produces exactly the empty-roster surface', () => {
|
|
55
|
+
// The two reasons differ but the outcome is the same one tool, so a
|
|
56
|
+
// reader does not have to hold two shapes in their head.
|
|
57
|
+
expect(namesFor({ agentIds: ['specialist'], allowDelegation: false })).toEqual(
|
|
58
|
+
namesFor({ agentIds: [] }),
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('keeps the listing, because a run may still want to see what is running', () => {
|
|
63
|
+
expect(namesFor({ agentIds: ['specialist'], allowDelegation: false })).toContain(
|
|
64
|
+
'agent_task_list',
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('leaves the human channel alone — that is not delegation', () => {
|
|
69
|
+
// `approve_plan` and `ask_user_question` are the HITL park surface. A
|
|
70
|
+
// run that must not delegate needs them as much as any other.
|
|
71
|
+
const names = namesFor({ agentIds: ['specialist'], allowDelegation: false, withHitl: true })
|
|
72
|
+
|
|
73
|
+
expect(names).toContain('ask_user_question')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('an absent flag changes nothing', () => {
|
|
78
|
+
it('mounts the full surface, as it always did', () => {
|
|
79
|
+
const names = namesFor({ agentIds: ['specialist'] })
|
|
80
|
+
|
|
81
|
+
expect(names).toContain('create_task')
|
|
82
|
+
expect(names).toContain('wait_for_task')
|
|
83
|
+
expect(names).toContain('cancel_task')
|
|
84
|
+
expect(names).toContain('agent_task_list')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('is identical to opting in explicitly', () => {
|
|
88
|
+
// So adopting the flag cannot change a caller that says yes out loud.
|
|
89
|
+
expect(namesFor({ agentIds: ['a', 'b'], allowDelegation: true })).toEqual(
|
|
90
|
+
namesFor({ agentIds: ['a', 'b'] }),
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('still withholds everything on an empty roster, flag or no flag', () => {
|
|
95
|
+
expect(namesFor({ agentIds: [], allowDelegation: true })).toEqual(['agent_task_list'])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('the flag is absolute', () => {
|
|
100
|
+
it('cannot be overridden back on', () => {
|
|
101
|
+
// Worth a test precisely because the opposite is the intuitive guess:
|
|
102
|
+
// "explicit beats implicit" would say a runtime override should win.
|
|
103
|
+
// It cannot, mechanically — the override pass in SupervisorAgent runs
|
|
104
|
+
// over the array this builder returns, and there is no entry for it to
|
|
105
|
+
// act on. And it should not: both values come from the same caller in
|
|
106
|
+
// the same call, so "this run must not delegate" plus "give it
|
|
107
|
+
// create_task" is a caller contradicting itself, not one who knows
|
|
108
|
+
// something extra.
|
|
109
|
+
//
|
|
110
|
+
// Same rule the empty roster has always had.
|
|
111
|
+
const tools = buildCoordinatorTools({
|
|
112
|
+
gateway,
|
|
113
|
+
workingDirectory: '/tmp/test',
|
|
114
|
+
allowedAgentIds: ['specialist'],
|
|
115
|
+
allowDelegation: false,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(tools.find((t) => t.name === 'create_task')).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { CompletionInbox } from '../../../gateway/completion-inbox.js'
|
|
4
|
+
import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
|
|
5
|
+
import type { TaskId } from '../../../types/ids/index.js'
|
|
6
|
+
import type { ToolDefinition } from '../../../types/tool/index.js'
|
|
7
|
+
import { DELEGATION_TIMEOUT_MS, buildCoordinatorTools } from '../index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Who claims a completion, and who is left to announce it.
|
|
11
|
+
*
|
|
12
|
+
* A worker's output normally rides back as the `tool_result` of the
|
|
13
|
+
* `create_task` that launched it. Two situations have no such call:
|
|
14
|
+
*
|
|
15
|
+
* - the launch was made in the background on purpose;
|
|
16
|
+
* - the launch blocked, the executor's deadline passed, and the model was
|
|
17
|
+
* told *"timed out… it may still be running"*. The worker then finished
|
|
18
|
+
* normally and its result had no reader at all.
|
|
19
|
+
*
|
|
20
|
+
* The second one was the live defect: a supervisor whose `create_task` timed
|
|
21
|
+
* out lost both the task id and the output, and `agent_task_list` — the only
|
|
22
|
+
* tool it had left — reported id, state and duration while withholding the
|
|
23
|
+
* one field it was looking for. `agent_task_list` in a sleep loop was not the
|
|
24
|
+
* model misbehaving; it was the only move on the board.
|
|
25
|
+
*
|
|
26
|
+
* These tests drive the claim/announce split from the tool side, because the
|
|
27
|
+
* inbox can only tell a delivered completion from an abandoned one if the
|
|
28
|
+
* tools tell it the truth.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface Harness {
|
|
32
|
+
tools: ToolDefinition[]
|
|
33
|
+
inbox: CompletionInbox
|
|
34
|
+
settle: (handle: TaskHandle) => void
|
|
35
|
+
/** Resolve the pending `waitForTask` for this task. */
|
|
36
|
+
finish: (taskId: string, result: string) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function harness(opts: { autoFinish?: boolean } = {}): Harness {
|
|
40
|
+
const listeners = new Set<(h: TaskHandle) => void>()
|
|
41
|
+
const waiters = new Map<string, (h: TaskHandle) => void>()
|
|
42
|
+
const handles = new Map<string, TaskHandle>()
|
|
43
|
+
let seq = 0
|
|
44
|
+
|
|
45
|
+
const makeHandle = (taskId: string, result?: string): TaskHandle => ({
|
|
46
|
+
taskId: taskId as TaskId,
|
|
47
|
+
agentId: 'reviewer',
|
|
48
|
+
state: 'completed',
|
|
49
|
+
createdAt: 1_000,
|
|
50
|
+
completedAt: 2_000,
|
|
51
|
+
...(result !== undefined
|
|
52
|
+
? { result: { status: 'completed', result } as TaskHandle['result'] }
|
|
53
|
+
: {}),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const gateway = {
|
|
57
|
+
createTask: async () => {
|
|
58
|
+
seq += 1
|
|
59
|
+
const taskId = `tsk_${seq}`
|
|
60
|
+
const handle: TaskHandle = { ...makeHandle(taskId), state: 'running' }
|
|
61
|
+
handles.set(taskId, handle)
|
|
62
|
+
return handle
|
|
63
|
+
},
|
|
64
|
+
waitForTask: (taskId: TaskId) =>
|
|
65
|
+
new Promise<TaskHandle>((resolve) => {
|
|
66
|
+
if (opts.autoFinish) {
|
|
67
|
+
resolve(makeHandle(taskId, 'the worker output'))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
waiters.set(taskId, resolve)
|
|
71
|
+
}),
|
|
72
|
+
getTask: (taskId: TaskId) => handles.get(taskId),
|
|
73
|
+
listTasks: () => [...handles.values()],
|
|
74
|
+
cancelTask: () => undefined,
|
|
75
|
+
continueTask: async () => undefined,
|
|
76
|
+
onTaskCompleted: (cb: (h: TaskHandle) => void) => {
|
|
77
|
+
listeners.add(cb)
|
|
78
|
+
return () => listeners.delete(cb)
|
|
79
|
+
},
|
|
80
|
+
} as unknown as TaskGateway
|
|
81
|
+
|
|
82
|
+
const inbox = new CompletionInbox()
|
|
83
|
+
inbox.attach(gateway)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
inbox,
|
|
87
|
+
tools: buildCoordinatorTools({
|
|
88
|
+
gateway,
|
|
89
|
+
completionInbox: inbox,
|
|
90
|
+
workingDirectory: '/tmp/test',
|
|
91
|
+
allowedAgentIds: ['reviewer'],
|
|
92
|
+
}),
|
|
93
|
+
settle: (handle) => {
|
|
94
|
+
handles.set(handle.taskId, handle)
|
|
95
|
+
for (const cb of listeners) cb(handle)
|
|
96
|
+
},
|
|
97
|
+
finish: (taskId, result) => {
|
|
98
|
+
const handle = makeHandle(taskId, result)
|
|
99
|
+
handles.set(taskId, handle)
|
|
100
|
+
waiters.get(taskId)?.(handle)
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toolNamed(tools: ToolDefinition[], name: string): ToolDefinition {
|
|
106
|
+
const tool = tools.find((t) => t.name === name)
|
|
107
|
+
if (!tool) throw new Error(`${name} is not registered`)
|
|
108
|
+
return tool
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('the supervisor has a tool for every move it needs', () => {
|
|
112
|
+
it('registers a way to wait, so polling is no longer the only option', () => {
|
|
113
|
+
// Before this, waiting meant `continue_task` — which blocks only as a
|
|
114
|
+
// side effect of sending a message, so a supervisor that just wanted
|
|
115
|
+
// to wait had to invent something to say.
|
|
116
|
+
const names = harness().tools.map((t) => t.name)
|
|
117
|
+
|
|
118
|
+
expect(names).toContain('wait_for_task')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('registers a way to cancel, which background launching makes necessary', () => {
|
|
122
|
+
// `cancel_task` was defined in this file and never returned from it.
|
|
123
|
+
// It matters now: a background launch can leave a worker running with
|
|
124
|
+
// nothing waiting on it, and without this the supervisor could start
|
|
125
|
+
// one it had no way to stop.
|
|
126
|
+
expect(harness().tools.map((t) => t.name)).toContain('cancel_task')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('gives the launching tool a deadline a real worker can meet', () => {
|
|
130
|
+
// The run default is two minutes; a delegated worker doing real work
|
|
131
|
+
// takes longer, and every expiry past that point produced the state
|
|
132
|
+
// this whole change exists to repair. Not firing at all beats
|
|
133
|
+
// recovering well.
|
|
134
|
+
expect(toolNamed(harness().tools, 'create_task').timeoutMs).toBe(DELEGATION_TIMEOUT_MS)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('gives the waiting tool a deadline longer than the executor default', () => {
|
|
138
|
+
// A tool whose entire job is to wait must not be killed for waiting.
|
|
139
|
+
// `ToolExecutor` reads `timeoutMs` before falling back to the run
|
|
140
|
+
// default, so declaring one here is the supported way to say so.
|
|
141
|
+
expect(toolNamed(harness().tools, 'wait_for_task').timeoutMs).toBe(DELEGATION_TIMEOUT_MS)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('mounts none of them when there is no roster to delegate to', () => {
|
|
145
|
+
const tools = buildCoordinatorTools({
|
|
146
|
+
gateway: { onTaskCompleted: () => () => {} } as unknown as TaskGateway,
|
|
147
|
+
workingDirectory: '/tmp/test',
|
|
148
|
+
allowedAgentIds: [],
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
expect(tools.map((t) => t.name)).toEqual(['agent_task_list'])
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('a blocking launch claims its own completion', () => {
|
|
156
|
+
it('delivers the output inline and leaves nothing to announce', () => {
|
|
157
|
+
// The `dc16d58` case: this result reached the model as a tool_result,
|
|
158
|
+
// so an envelope carrying it again would be the duplicate delivery
|
|
159
|
+
// that removal fixed.
|
|
160
|
+
const h = harness({ autoFinish: true })
|
|
161
|
+
|
|
162
|
+
return toolNamed(h.tools, 'create_task')
|
|
163
|
+
.execute({ agent_id: 'reviewer', prompt: 'go', description: 'review' }, {} as never)
|
|
164
|
+
.then((result) => {
|
|
165
|
+
expect(result.success).toBe(true)
|
|
166
|
+
expect(result.output).toContain('the worker output')
|
|
167
|
+
expect(h.inbox.drain()).toEqual([])
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('an abandoned launch leaves its completion to be announced', () => {
|
|
173
|
+
it('does not claim when the executor already gave up on the call', async () => {
|
|
174
|
+
// This is the whole fix. The executor's deadline passed, the model was
|
|
175
|
+
// told the tool timed out, and whatever this call returns now is
|
|
176
|
+
// discarded — so claiming here would delete the worker's output for
|
|
177
|
+
// good.
|
|
178
|
+
const h = harness({ autoFinish: true })
|
|
179
|
+
const aborted = AbortSignal.abort()
|
|
180
|
+
|
|
181
|
+
const result = await toolNamed(h.tools, 'create_task').execute(
|
|
182
|
+
{ agent_id: 'reviewer', prompt: 'go', description: 'review' },
|
|
183
|
+
{ abortSignal: aborted } as never,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
expect(result.success).toBe(false)
|
|
187
|
+
// It also says where the result WILL turn up, rather than leaving the
|
|
188
|
+
// model to conclude the work was lost.
|
|
189
|
+
expect(result.output).toContain('task notification')
|
|
190
|
+
|
|
191
|
+
h.settle({
|
|
192
|
+
taskId: 'tsk_1' as TaskId,
|
|
193
|
+
agentId: 'reviewer',
|
|
194
|
+
state: 'completed',
|
|
195
|
+
createdAt: 1_000,
|
|
196
|
+
completedAt: 2_000,
|
|
197
|
+
result: {
|
|
198
|
+
status: 'completed',
|
|
199
|
+
result: 'the worker output',
|
|
200
|
+
} as TaskHandle['result'],
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
expect(h.inbox.drain().map((x) => x.taskId)).toEqual(['tsk_1'])
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('a background launch returns a handle instead of a result', () => {
|
|
208
|
+
it('hands back the task id straight away and does not wait', async () => {
|
|
209
|
+
// `autoFinish` is off, so `waitForTask` never resolves here. If the
|
|
210
|
+
// background path awaited it, this test would hang — which is the
|
|
211
|
+
// point: returning is what lets the supervisor keep working.
|
|
212
|
+
const h = harness()
|
|
213
|
+
|
|
214
|
+
const result = await toolNamed(h.tools, 'create_task').execute(
|
|
215
|
+
{
|
|
216
|
+
agent_id: 'reviewer',
|
|
217
|
+
prompt: 'go',
|
|
218
|
+
description: 'review',
|
|
219
|
+
background: true,
|
|
220
|
+
},
|
|
221
|
+
{} as never,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
expect(result.success).toBe(true)
|
|
225
|
+
expect((result.data as { task_id?: string } | undefined)?.task_id).toBe('tsk_1')
|
|
226
|
+
// In `output` too, not only in `data` — the executor builds the
|
|
227
|
+
// tool_result from `output` alone, so an id that lives only in `data`
|
|
228
|
+
// is an id the model never receives.
|
|
229
|
+
expect(result.output).toContain('tsk_1')
|
|
230
|
+
// And it tells the model what happens next, so "launched" does not
|
|
231
|
+
// read as "finished".
|
|
232
|
+
expect(result.output).toContain('task notification')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('leaves the completion for the transcript', async () => {
|
|
236
|
+
const h = harness()
|
|
237
|
+
await toolNamed(h.tools, 'create_task').execute(
|
|
238
|
+
{
|
|
239
|
+
agent_id: 'reviewer',
|
|
240
|
+
prompt: 'go',
|
|
241
|
+
description: 'review',
|
|
242
|
+
background: true,
|
|
243
|
+
},
|
|
244
|
+
{} as never,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
h.settle({
|
|
248
|
+
taskId: 'tsk_1' as TaskId,
|
|
249
|
+
agentId: 'reviewer',
|
|
250
|
+
state: 'completed',
|
|
251
|
+
createdAt: 1_000,
|
|
252
|
+
completedAt: 2_000,
|
|
253
|
+
result: { status: 'completed', result: 'done' } as TaskHandle['result'],
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
expect(h.inbox.drain()).toHaveLength(1)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('waiting explicitly beats listing in a loop', () => {
|
|
261
|
+
it('returns the output and claims it', async () => {
|
|
262
|
+
const h = harness({ autoFinish: true })
|
|
263
|
+
await toolNamed(h.tools, 'create_task').execute(
|
|
264
|
+
{
|
|
265
|
+
agent_id: 'reviewer',
|
|
266
|
+
prompt: 'go',
|
|
267
|
+
description: 'review',
|
|
268
|
+
background: true,
|
|
269
|
+
},
|
|
270
|
+
{} as never,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
const result = await toolNamed(h.tools, 'wait_for_task').execute(
|
|
274
|
+
{ task_id: 'tsk_1' },
|
|
275
|
+
{} as never,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
expect(result.success).toBe(true)
|
|
279
|
+
expect(result.output).toContain('the worker output')
|
|
280
|
+
// Claimed, so it is not also announced.
|
|
281
|
+
expect(h.inbox.drain()).toEqual([])
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('says so plainly rather than hanging on an id that does not exist', async () => {
|
|
285
|
+
const h = harness()
|
|
286
|
+
|
|
287
|
+
const result = await toolNamed(h.tools, 'wait_for_task').execute(
|
|
288
|
+
{ task_id: 'tsk_nope' },
|
|
289
|
+
{} as never,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
expect(result.success).toBe(false)
|
|
293
|
+
expect(result.output).toContain('No task')
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('the task listing carries the output it always had', () => {
|
|
298
|
+
async function listWith(result: string): Promise<string> {
|
|
299
|
+
const h = harness()
|
|
300
|
+
h.settle({
|
|
301
|
+
taskId: 'tsk_1' as TaskId,
|
|
302
|
+
agentId: 'reviewer',
|
|
303
|
+
state: 'completed',
|
|
304
|
+
createdAt: 1_000,
|
|
305
|
+
completedAt: 2_000,
|
|
306
|
+
result: { status: 'completed', result } as TaskHandle['result'],
|
|
307
|
+
})
|
|
308
|
+
const listed = await toolNamed(h.tools, 'agent_task_list').execute({}, {} as never)
|
|
309
|
+
return listed.output
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
it('puts the worker result where the model can actually read it', async () => {
|
|
313
|
+
// Two failures stacked here. The projection read `h.result` for the
|
|
314
|
+
// status and the error and stopped one property short of the thing the
|
|
315
|
+
// task was launched to produce — and the obvious repair, adding it to
|
|
316
|
+
// `data`, would have been invisible: the executor builds the
|
|
317
|
+
// tool_result from `output` alone and never reads `data`. So the fix
|
|
318
|
+
// has to land in the RENDERED text, and this asserts on `output` for
|
|
319
|
+
// that reason.
|
|
320
|
+
expect(await listWith('the findings')).toContain('the findings')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('names the tool that fetches the rest when it truncates', async () => {
|
|
324
|
+
const output = await listWith('x'.repeat(6_000))
|
|
325
|
+
|
|
326
|
+
expect(output).toContain('truncated')
|
|
327
|
+
expect(output).toContain('wait_for_task with "tsk_1"')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('still lists a task that has produced nothing yet', async () => {
|
|
331
|
+
const h = harness()
|
|
332
|
+
await toolNamed(h.tools, 'create_task').execute(
|
|
333
|
+
{
|
|
334
|
+
agent_id: 'reviewer',
|
|
335
|
+
prompt: 'go',
|
|
336
|
+
description: 'review',
|
|
337
|
+
background: true,
|
|
338
|
+
},
|
|
339
|
+
{} as never,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const listed = await toolNamed(h.tools, 'agent_task_list').execute({}, {} as never)
|
|
343
|
+
|
|
344
|
+
expect(listed.output).toContain('tsk_1')
|
|
345
|
+
expect(listed.output).toContain('running')
|
|
346
|
+
})
|
|
347
|
+
})
|
|
@@ -180,7 +180,7 @@ describe('coordinator agent_task_list tool', () => {
|
|
|
180
180
|
expect(names).not.toContain('task_list')
|
|
181
181
|
})
|
|
182
182
|
|
|
183
|
-
it('
|
|
183
|
+
it('advertises per-task cancellation now that a task can outlive its launch', () => {
|
|
184
184
|
const coordinatorTools = buildCoordinatorTools({
|
|
185
185
|
gateway: gatewayWith([]),
|
|
186
186
|
workingDirectory: '/tmp/test',
|
|
@@ -188,11 +188,17 @@ describe('coordinator agent_task_list tool', () => {
|
|
|
188
188
|
})
|
|
189
189
|
const names = coordinatorTools.map((tool) => tool.name)
|
|
190
190
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
191
|
+
// This assertion used to be its own inverse, and the reasoning was
|
|
192
|
+
// correct at the time: create_task returned only after the worker was
|
|
193
|
+
// terminal, so the supervisor could never hold a LIVE task id in a
|
|
194
|
+
// later turn, and cancel_task could only manufacture success for
|
|
195
|
+
// something already finished.
|
|
196
|
+
//
|
|
197
|
+
// `background: true` reinstates the precondition that argument rested
|
|
198
|
+
// on. A background launch hands back an id while the worker is still
|
|
199
|
+
// running, and a supervisor able to start one it cannot stop is a
|
|
200
|
+
// hole — so the tool comes back with the capability that makes it
|
|
201
|
+
// mean something.
|
|
202
|
+
expect(names).toContain('cancel_task')
|
|
197
203
|
})
|
|
198
204
|
})
|