@namzu/sdk 6.2.0 → 7.0.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 +240 -0
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +5 -0
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +4 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/runAgent.d.ts +19 -1
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js +2 -0
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +123 -8
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/constants/agent/index.d.ts +5 -0
- package/dist/constants/agent/index.d.ts.map +1 -1
- package/dist/constants/agent/index.js +5 -0
- package/dist/constants/agent/index.js.map +1 -1
- package/dist/constants/plugin/index.d.ts +15 -0
- package/dist/constants/plugin/index.d.ts.map +1 -1
- package/dist/constants/plugin/index.js +15 -0
- package/dist/constants/plugin/index.js.map +1 -1
- package/dist/gateway/local.d.ts +11 -0
- package/dist/gateway/local.d.ts.map +1 -1
- package/dist/gateway/local.js +27 -1
- package/dist/gateway/local.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +6 -0
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/provider/thinking-support.d.ts +2 -1
- package/dist/provider/thinking-support.d.ts.map +1 -1
- package/dist/provider/thinking-support.js +14 -0
- package/dist/provider/thinking-support.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +9 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/__tests__/context.test.js +18 -0
- package/dist/runtime/query/__tests__/context.test.js.map +1 -1
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
- package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
- package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
- package/dist/runtime/query/context.d.ts.map +1 -1
- package/dist/runtime/query/context.js +16 -1
- package/dist/runtime/query/context.js.map +1 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +11 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +5 -0
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
- package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
- package/dist/telemetry/__tests__/span-closure.test.js +12 -5
- package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts +19 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +82 -60
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
- package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
- package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
- package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
- package/dist/types/agent/base.d.ts +16 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/gateway.d.ts +23 -0
- package/dist/types/agent/gateway.d.ts.map +1 -1
- package/dist/types/agent/lifecycle-event.d.ts +9 -1
- package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +5 -0
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/hitl/index.d.ts +10 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/probe/registry.d.ts +6 -0
- package/dist/types/probe/registry.d.ts.map +1 -1
- package/dist/types/run/config.d.ts +25 -0
- package/dist/types/run/config.d.ts.map +1 -1
- package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
- package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
- package/dist/utils/__tests__/abort-reason.test.js +48 -0
- package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
- package/dist/utils/abort.d.ts +26 -0
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +34 -0
- package/dist/utils/abort.js.map +1 -1
- package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
- package/dist/verification/gate.d.ts +17 -1
- package/dist/verification/gate.d.ts.map +1 -1
- package/dist/verification/gate.js +58 -2
- package/dist/verification/gate.js.map +1 -1
- package/dist/verification/index.d.ts +1 -1
- package/dist/verification/index.d.ts.map +1 -1
- package/dist/verification/index.js +1 -1
- package/dist/verification/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/ReactiveAgent.ts +5 -0
- package/src/agents/SupervisorAgent.ts +4 -0
- package/src/agents/runAgent.ts +22 -1
- package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
- package/src/connector/mcp/adapter.ts +131 -7
- package/src/constants/agent/index.ts +5 -0
- package/src/constants/plugin/index.ts +15 -0
- package/src/gateway/local.ts +26 -1
- package/src/manager/agent/lifecycle.ts +6 -0
- package/src/provider/thinking-support.ts +19 -2
- package/src/public-runtime.ts +9 -0
- package/src/runtime/query/__tests__/context.test.ts +24 -0
- package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
- package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
- package/src/runtime/query/context.ts +16 -1
- package/src/runtime/query/executor.ts +11 -1
- package/src/runtime/query/iteration/index.ts +5 -0
- package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
- package/src/telemetry/__tests__/span-closure.test.ts +12 -5
- package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
- package/src/tools/coordinator/index.ts +83 -63
- package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
- package/src/types/agent/base.ts +17 -0
- package/src/types/agent/gateway.ts +24 -0
- package/src/types/agent/lifecycle-event.ts +7 -0
- package/src/types/agent/task.ts +5 -0
- package/src/types/hitl/index.ts +10 -0
- package/src/types/probe/registry.ts +6 -0
- package/src/types/run/config.ts +26 -0
- package/src/utils/__tests__/abort-reason.test.ts +56 -0
- package/src/utils/abort.ts +34 -0
- package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
- package/src/verification/gate.ts +61 -3
- package/src/verification/index.ts +1 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
|
|
4
|
+
import type { TaskId } from '../../../types/ids/index.js'
|
|
5
|
+
import { describeWaitTimeout, waitForTaskWithBounds } from '../wait-with-idle-bound.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A wall clock cannot tell a slow worker from a stuck one.
|
|
9
|
+
*
|
|
10
|
+
* The bound before this was an hour of elapsed time, and an hour has to do
|
|
11
|
+
* two incompatible jobs: be long enough for a child doing real work, and
|
|
12
|
+
* short enough to notice one that wedged. It cannot be both, so a worker
|
|
13
|
+
* stuck in minute two held the supervisor for another fifty-eight, and one
|
|
14
|
+
* making steady progress at minute fifty-nine was killed for being slow.
|
|
15
|
+
*
|
|
16
|
+
* These tests drive both clocks with a fake one, because the real bounds are
|
|
17
|
+
* measured in minutes and a test that actually waits them out is a test
|
|
18
|
+
* nobody runs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function gatewayFor(opts: {
|
|
22
|
+
/** Resolve the wait when this is called. */
|
|
23
|
+
settle?: (resolve: (h: TaskHandle) => void) => void
|
|
24
|
+
withProgress?: boolean
|
|
25
|
+
}): { gateway: TaskGateway; progress: () => void; finish: () => void } {
|
|
26
|
+
let resolveWait: ((h: TaskHandle) => void) | undefined
|
|
27
|
+
const listeners = new Set<(id: TaskId) => void>()
|
|
28
|
+
|
|
29
|
+
const handle: TaskHandle = {
|
|
30
|
+
taskId: 'tsk_1' as TaskId,
|
|
31
|
+
agentId: 'worker',
|
|
32
|
+
state: 'completed',
|
|
33
|
+
createdAt: 0,
|
|
34
|
+
completedAt: 1,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const gateway = {
|
|
38
|
+
waitForTask: () =>
|
|
39
|
+
new Promise<TaskHandle>((resolve) => {
|
|
40
|
+
resolveWait = resolve
|
|
41
|
+
opts.settle?.(resolve)
|
|
42
|
+
}),
|
|
43
|
+
...(opts.withProgress === false
|
|
44
|
+
? {}
|
|
45
|
+
: {
|
|
46
|
+
onTaskProgress: (cb: (id: TaskId) => void) => {
|
|
47
|
+
listeners.add(cb)
|
|
48
|
+
return () => listeners.delete(cb)
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
} as unknown as TaskGateway
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
gateway,
|
|
55
|
+
progress: () => {
|
|
56
|
+
for (const cb of listeners) cb('tsk_1' as TaskId)
|
|
57
|
+
},
|
|
58
|
+
finish: () => resolveWait?.(handle),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A clock the test moves by hand. */
|
|
63
|
+
function fakeClock(): { now: () => number; advance: (ms: number) => void } {
|
|
64
|
+
let t = 1_000_000
|
|
65
|
+
return {
|
|
66
|
+
now: () => t,
|
|
67
|
+
advance: (ms) => {
|
|
68
|
+
t += ms
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('a worker that is still working is not killed for being slow', () => {
|
|
74
|
+
it('keeps waiting past the idle bound while progress keeps arriving', async () => {
|
|
75
|
+
vi.useFakeTimers()
|
|
76
|
+
try {
|
|
77
|
+
const clock = fakeClock()
|
|
78
|
+
const { gateway, progress, finish } = gatewayFor({})
|
|
79
|
+
|
|
80
|
+
const waiting = waitForTaskWithBounds(
|
|
81
|
+
gateway,
|
|
82
|
+
'tsk_1' as TaskId,
|
|
83
|
+
{ runMs: 60_000, idleMs: 5_000 },
|
|
84
|
+
clock.now,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
// Four times the idle bound in elapsed time, but never quiet for
|
|
88
|
+
// more than half of it.
|
|
89
|
+
for (let i = 0; i < 8; i += 1) {
|
|
90
|
+
clock.advance(2_500)
|
|
91
|
+
progress()
|
|
92
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
finish()
|
|
96
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
97
|
+
|
|
98
|
+
expect((await waiting).kind, 'a working worker was cut off').toBe('completed')
|
|
99
|
+
} finally {
|
|
100
|
+
vi.useRealTimers()
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('a worker that has gone quiet is reported as quiet', () => {
|
|
106
|
+
it('fires the idle bound and says which clock it was', async () => {
|
|
107
|
+
vi.useFakeTimers()
|
|
108
|
+
try {
|
|
109
|
+
const clock = fakeClock()
|
|
110
|
+
const { gateway } = gatewayFor({})
|
|
111
|
+
|
|
112
|
+
const waiting = waitForTaskWithBounds(
|
|
113
|
+
gateway,
|
|
114
|
+
'tsk_1' as TaskId,
|
|
115
|
+
{ runMs: 600_000, idleMs: 5_000 },
|
|
116
|
+
clock.now,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
clock.advance(6_000)
|
|
120
|
+
await vi.advanceTimersByTimeAsync(1_100)
|
|
121
|
+
|
|
122
|
+
const outcome = await waiting
|
|
123
|
+
expect(outcome.kind).toBe('timeout')
|
|
124
|
+
if (outcome.kind !== 'timeout') return
|
|
125
|
+
// The distinction is the point: "went quiet" and "ran too long"
|
|
126
|
+
// are different diagnoses and the caller acts on the message.
|
|
127
|
+
expect(outcome.cause).toBe('idle')
|
|
128
|
+
expect(outcome.idleBoundArmed).toBe(true)
|
|
129
|
+
} finally {
|
|
130
|
+
vi.useRealTimers()
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('says it went quiet, and that the worker was not cancelled', () => {
|
|
135
|
+
const text = describeWaitTimeout({
|
|
136
|
+
kind: 'timeout',
|
|
137
|
+
cause: 'idle',
|
|
138
|
+
elapsedMs: 30_000,
|
|
139
|
+
idleBoundArmed: true,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(text).toContain('went quiet')
|
|
143
|
+
// A wait that ran out is a statement about the WAITER. Losing an
|
|
144
|
+
// eight-minute worker's output because a short clock expired is the
|
|
145
|
+
// bug this whole area has been unpicking.
|
|
146
|
+
expect(text).toContain('not been cancelled')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('the run bound still catches a worker that never stops', () => {
|
|
151
|
+
it('fires on elapsed time even while progress keeps arriving', async () => {
|
|
152
|
+
vi.useFakeTimers()
|
|
153
|
+
try {
|
|
154
|
+
const clock = fakeClock()
|
|
155
|
+
const { gateway, progress } = gatewayFor({})
|
|
156
|
+
|
|
157
|
+
const waiting = waitForTaskWithBounds(
|
|
158
|
+
gateway,
|
|
159
|
+
'tsk_1' as TaskId,
|
|
160
|
+
{ runMs: 10_000, idleMs: 5_000 },
|
|
161
|
+
clock.now,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
for (let i = 0; i < 6; i += 1) {
|
|
165
|
+
clock.advance(2_000)
|
|
166
|
+
progress()
|
|
167
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const outcome = await waiting
|
|
171
|
+
expect(outcome.kind).toBe('timeout')
|
|
172
|
+
if (outcome.kind !== 'timeout') return
|
|
173
|
+
expect(outcome.cause).toBe('run')
|
|
174
|
+
} finally {
|
|
175
|
+
vi.useRealTimers()
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('a gateway that cannot see its children', () => {
|
|
181
|
+
it('is bounded by the wall clock alone, as it was before', async () => {
|
|
182
|
+
vi.useFakeTimers()
|
|
183
|
+
try {
|
|
184
|
+
const clock = fakeClock()
|
|
185
|
+
const { gateway } = gatewayFor({ withProgress: false })
|
|
186
|
+
|
|
187
|
+
const waiting = waitForTaskWithBounds(
|
|
188
|
+
gateway,
|
|
189
|
+
'tsk_1' as TaskId,
|
|
190
|
+
{ runMs: 10_000, idleMs: 1_000 },
|
|
191
|
+
clock.now,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// Far past the idle bound, and it must NOT fire — there is no
|
|
195
|
+
// signal, so silence carries no information.
|
|
196
|
+
clock.advance(5_000)
|
|
197
|
+
await vi.advanceTimersByTimeAsync(1_100)
|
|
198
|
+
clock.advance(6_000)
|
|
199
|
+
await vi.advanceTimersByTimeAsync(1_100)
|
|
200
|
+
|
|
201
|
+
const outcome = await waiting
|
|
202
|
+
expect(outcome.kind).toBe('timeout')
|
|
203
|
+
if (outcome.kind !== 'timeout') return
|
|
204
|
+
expect(outcome.cause).toBe('run')
|
|
205
|
+
// And the degradation is visible rather than silent.
|
|
206
|
+
expect(outcome.idleBoundArmed).toBe(false)
|
|
207
|
+
} finally {
|
|
208
|
+
vi.useRealTimers()
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('says so, instead of implying the worker was stuck', () => {
|
|
213
|
+
const text = describeWaitTimeout({
|
|
214
|
+
kind: 'timeout',
|
|
215
|
+
cause: 'run',
|
|
216
|
+
elapsedMs: 3_600_000,
|
|
217
|
+
idleBoundArmed: false,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(text).toContain('cannot report progress')
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('a completion always wins', () => {
|
|
225
|
+
it('returns the handle rather than a timeout when the task finishes first', async () => {
|
|
226
|
+
vi.useFakeTimers()
|
|
227
|
+
try {
|
|
228
|
+
const clock = fakeClock()
|
|
229
|
+
const { gateway, finish } = gatewayFor({})
|
|
230
|
+
|
|
231
|
+
const waiting = waitForTaskWithBounds(
|
|
232
|
+
gateway,
|
|
233
|
+
'tsk_1' as TaskId,
|
|
234
|
+
{ runMs: 10_000, idleMs: 5_000 },
|
|
235
|
+
clock.now,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
finish()
|
|
239
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
240
|
+
|
|
241
|
+
const outcome = await waiting
|
|
242
|
+
expect(outcome.kind).toBe('completed')
|
|
243
|
+
} finally {
|
|
244
|
+
vi.useRealTimers()
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
})
|
|
@@ -11,6 +11,7 @@ import type { ToolDefinition } from '../../types/tool/index.js'
|
|
|
11
11
|
import { defineTool } from '../defineTool.js'
|
|
12
12
|
import { wrapUntrusted } from '../untrusted-envelope.js'
|
|
13
13
|
import { resolvePlanDependencies } from './plan-dependencies.js'
|
|
14
|
+
import { describeWaitTimeout, waitForTaskWithBounds } from './wait-with-idle-bound.js'
|
|
14
15
|
|
|
15
16
|
export type TaskLaunchedCallback = (
|
|
16
17
|
agentTaskId: TaskId,
|
|
@@ -259,6 +260,33 @@ const LISTED_RESULT_LIMIT = 2_000
|
|
|
259
260
|
*/
|
|
260
261
|
export const DELEGATION_TIMEOUT_MS = 60 * 60 * 1000
|
|
261
262
|
|
|
263
|
+
/**
|
|
264
|
+
* How long a delegated worker may say nothing before the wait gives up.
|
|
265
|
+
*
|
|
266
|
+
* The hour above answers "how long is too long". It cannot also answer
|
|
267
|
+
* "how quiet is too quiet", because it has to be generous enough for a
|
|
268
|
+
* child doing real work — which makes it useless as a stall detector. A
|
|
269
|
+
* worker wedged in its second minute held the supervisor for another
|
|
270
|
+
* fifty-eight under that number alone.
|
|
271
|
+
*
|
|
272
|
+
* Five minutes of silence, because a worker between tool calls can be
|
|
273
|
+
* quiet for a while legitimately — a long model turn emits nothing until
|
|
274
|
+
* it starts streaming — and the cost of guessing low is killing a wait on
|
|
275
|
+
* a worker that was fine. Guessing high only delays a diagnosis. Set
|
|
276
|
+
* `NAMZU_DELEGATION_IDLE_MS` to change it.
|
|
277
|
+
*
|
|
278
|
+
* Only armed when the gateway can report progress at all; see
|
|
279
|
+
* `TaskGateway.onTaskProgress`.
|
|
280
|
+
*/
|
|
281
|
+
export const DELEGATION_IDLE_MS = readPositiveIntEnv('NAMZU_DELEGATION_IDLE_MS', 5 * 60 * 1000)
|
|
282
|
+
|
|
283
|
+
function readPositiveIntEnv(key: string, fallback: number): number {
|
|
284
|
+
const value = process.env[key]?.trim()
|
|
285
|
+
if (!value) return fallback
|
|
286
|
+
const parsed = Number(value)
|
|
287
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
|
|
288
|
+
}
|
|
289
|
+
|
|
262
290
|
export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefinition[] {
|
|
263
291
|
const {
|
|
264
292
|
gateway,
|
|
@@ -379,7 +407,23 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
379
407
|
// assistant turn, the runtime runs them together and delivers all
|
|
380
408
|
// N `tool_result`s at once. No second `tool_result` for the
|
|
381
409
|
// same `tool_use_id` — providers reject a duplicated id outright.
|
|
382
|
-
|
|
410
|
+
// Bounded by two clocks rather than one. The wait gives up on a
|
|
411
|
+
// worker that has gone quiet long before the hour is out, and says
|
|
412
|
+
// which of the two ran out — the caller acts on that difference.
|
|
413
|
+
// Giving up does NOT cancel the child: it keeps going, and its
|
|
414
|
+
// result still reaches the supervisor as a notification.
|
|
415
|
+
const outcome = await waitForTaskWithBounds(gateway, handle.taskId, {
|
|
416
|
+
runMs: DELEGATION_TIMEOUT_MS,
|
|
417
|
+
idleMs: DELEGATION_IDLE_MS,
|
|
418
|
+
})
|
|
419
|
+
if (outcome.kind === 'timeout') {
|
|
420
|
+
return {
|
|
421
|
+
success: false,
|
|
422
|
+
output: describeWaitTimeout(outcome),
|
|
423
|
+
data: { task_id: handle.taskId, agent_id, timed_out: outcome.cause },
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const completed = outcome.handle
|
|
383
427
|
|
|
384
428
|
// Whether this call is still the live path decides who delivers the
|
|
385
429
|
// result. If the executor already gave up on us — its deadline
|
|
@@ -450,55 +494,6 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
450
494
|
},
|
|
451
495
|
})
|
|
452
496
|
|
|
453
|
-
const continueTask = defineTool({
|
|
454
|
-
name: 'continue_task',
|
|
455
|
-
description:
|
|
456
|
-
"Send a follow-up message to a previously completed task and await the agent's next reply. BLOCKING: returns the agent's new output as this call's tool_result, the same shape as create_task. Only use this with a task_id from a previous create_task. To run multiple follow-ups in parallel, call this tool multiple times in a single assistant turn.",
|
|
457
|
-
inputSchema: z.object({
|
|
458
|
-
task_id: z.string().describe('Agent task ID from a previous create_task'),
|
|
459
|
-
message: z.string().describe('Follow-up instruction for the agent'),
|
|
460
|
-
}),
|
|
461
|
-
category: 'custom',
|
|
462
|
-
permissions: [],
|
|
463
|
-
readOnly: false,
|
|
464
|
-
destructive: false,
|
|
465
|
-
concurrencySafe: true,
|
|
466
|
-
// It waits on a child exactly as create_task does, so it inherits
|
|
467
|
-
// the same bound rather than the file-read default.
|
|
468
|
-
timeoutMs: DELEGATION_TIMEOUT_MS,
|
|
469
|
-
async execute({ task_id, message }, _context) {
|
|
470
|
-
await gateway.continueTask(task_id as TaskId, message)
|
|
471
|
-
// Mirror create_task's blocking pattern: await the new
|
|
472
|
-
// completion and return the agent's output inline. The
|
|
473
|
-
// previous non-blocking shape ('You will receive a
|
|
474
|
-
// task-notification…') relied on a global
|
|
475
|
-
// onTaskCompleted listener that the iteration loop
|
|
476
|
-
// no longer registers (envelope path is dead).
|
|
477
|
-
const completed = await gateway.waitForTask(task_id as TaskId)
|
|
478
|
-
// Same reasoning as create_task: the model already has a timeout
|
|
479
|
-
// for this call, so leaving the completion unclaimed is what sends
|
|
480
|
-
// it to the transcript as a notification.
|
|
481
|
-
if (_context.abortSignal?.aborted) {
|
|
482
|
-
return {
|
|
483
|
-
success: false,
|
|
484
|
-
output: `This wait was abandoned before task ${task_id} finished; its result will arrive separately as a task notification.`,
|
|
485
|
-
data: { task_id, abandoned: true },
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
completionInbox?.claim(task_id as TaskId)
|
|
489
|
-
const success = completed.state === 'completed'
|
|
490
|
-
const resultText =
|
|
491
|
-
completed.result?.result ??
|
|
492
|
-
completed.result?.lastError ??
|
|
493
|
-
`Task finished with state: ${completed.state}`
|
|
494
|
-
return {
|
|
495
|
-
success,
|
|
496
|
-
output: resultText,
|
|
497
|
-
data: { task_id, state: completed.state },
|
|
498
|
-
}
|
|
499
|
-
},
|
|
500
|
-
})
|
|
501
|
-
|
|
502
497
|
/**
|
|
503
498
|
* Join a task already running, without sending it anything.
|
|
504
499
|
*
|
|
@@ -533,7 +528,18 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
533
528
|
}
|
|
534
529
|
}
|
|
535
530
|
|
|
536
|
-
const
|
|
531
|
+
const outcome = await waitForTaskWithBounds(gateway, task_id as TaskId, {
|
|
532
|
+
runMs: DELEGATION_TIMEOUT_MS,
|
|
533
|
+
idleMs: DELEGATION_IDLE_MS,
|
|
534
|
+
})
|
|
535
|
+
if (outcome.kind === 'timeout') {
|
|
536
|
+
return {
|
|
537
|
+
success: false,
|
|
538
|
+
output: describeWaitTimeout(outcome),
|
|
539
|
+
data: { task_id, timed_out: outcome.cause },
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const completed = outcome.handle
|
|
537
543
|
if (_context.abortSignal?.aborted) {
|
|
538
544
|
return {
|
|
539
545
|
success: false,
|
|
@@ -675,18 +681,32 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
675
681
|
},
|
|
676
682
|
})
|
|
677
683
|
|
|
678
|
-
// `continue_task`
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
684
|
+
// `continue_task` is gone, and the reasoning is worth keeping because
|
|
685
|
+
// the obvious argument for bringing it back does not survive contact.
|
|
686
|
+
//
|
|
687
|
+
// It was dropped on the grounds that a blocking `create_task` leaves every
|
|
688
|
+
// worker terminal before a later turn learns its id, and the manager
|
|
689
|
+
// refuses `continue` on a terminal task. `background: true` reinstated that
|
|
690
|
+
// precondition — a live id is reachable now — so the question was reopened.
|
|
691
|
+
//
|
|
692
|
+
// Measured rather than assumed, and it fails on the other side. On a LIVE
|
|
693
|
+
// task the manager accepts the call and pushes onto `pendingMessages`,
|
|
694
|
+
// and NOTHING drains that queue during a run. This codebase already
|
|
695
|
+
// knows: `runtime/query/steering.ts` says in as many words that
|
|
696
|
+
// `queueMessage`/`drainMessages` were never read by the iteration loop,
|
|
697
|
+
// and `SteeringChannel` exists BECAUSE of that — it delivers guidance on a
|
|
698
|
+
// tool result instead, since a `tool_use` must be answered by a
|
|
699
|
+
// `tool_result` with the same id and there is no legal slot for a user
|
|
700
|
+
// message mid-batch.
|
|
701
|
+
//
|
|
702
|
+
// So the tool had no state it worked in: terminal tasks refuse it, live
|
|
703
|
+
// tasks accept it into a queue nobody reads. Registering it would have
|
|
704
|
+
// handed the model a call that silently does nothing — worse than the
|
|
705
|
+
// unregistered definition it replaced, because a defined-but-unreachable
|
|
706
|
+
// tool at least cannot be called.
|
|
707
|
+
//
|
|
708
|
+
// If follow-ups on a live worker are wanted, the work is a consumer for
|
|
709
|
+
// the queue or a steering channel that reaches a child, not this tool.
|
|
690
710
|
// `cancel_task` is registered again, and the reasoning that dropped it is
|
|
691
711
|
// worth keeping because it was sound at the time and is not any more.
|
|
692
712
|
//
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { TaskGateway, TaskHandle } from '../../types/agent/gateway.js'
|
|
2
|
+
import type { TaskId } from '../../types/ids/index.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Waiting on a delegated worker, bounded by two different questions.
|
|
6
|
+
*
|
|
7
|
+
* A wall clock alone is the wrong instrument. It has to be long enough to
|
|
8
|
+
* serve as the outer bound for a child doing real work — an hour, here — and
|
|
9
|
+
* that is far too long to notice a child that wedged in its second minute.
|
|
10
|
+
* The same number cannot be both "how long is too long" and "how quiet is too
|
|
11
|
+
* quiet", so this keeps them apart:
|
|
12
|
+
*
|
|
13
|
+
* - the **run bound** counts elapsed time and is never refreshed. It exists
|
|
14
|
+
* for a worker that stays busy forever.
|
|
15
|
+
* - the **idle bound** counts time since the worker last did anything, and
|
|
16
|
+
* resets whenever it does. It exists for a worker that stopped.
|
|
17
|
+
*
|
|
18
|
+
* Whichever fires first ends the wait, and the result says WHICH — because
|
|
19
|
+
* "it went quiet" and "it ran too long" are different diagnoses and lead to
|
|
20
|
+
* different next moves. Telling a caller its worker timed out when the worker
|
|
21
|
+
* was making steady progress is the failure this replaces.
|
|
22
|
+
*
|
|
23
|
+
* The idle bound is only armed when the gateway can report progress.
|
|
24
|
+
* `onTaskProgress` is optional on the contract, because hosts implement
|
|
25
|
+
* `TaskGateway` and not all of them can observe their children — so a gateway
|
|
26
|
+
* without it is bounded by the wall clock alone, exactly as before. That is a
|
|
27
|
+
* real degradation and it is deliberately visible in the result rather than
|
|
28
|
+
* silent: `idleBoundArmed` says whether the quieter half was ever watching.
|
|
29
|
+
*/
|
|
30
|
+
export type WaitOutcome =
|
|
31
|
+
| { readonly kind: 'completed'; readonly handle: TaskHandle }
|
|
32
|
+
| {
|
|
33
|
+
readonly kind: 'timeout'
|
|
34
|
+
/** Which clock ran out. */
|
|
35
|
+
readonly cause: 'idle' | 'run'
|
|
36
|
+
readonly elapsedMs: number
|
|
37
|
+
/** False when the gateway cannot report progress, so only the wall clock applied. */
|
|
38
|
+
readonly idleBoundArmed: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WaitBounds {
|
|
42
|
+
/** Elapsed-time ceiling, never refreshed. */
|
|
43
|
+
readonly runMs: number
|
|
44
|
+
/**
|
|
45
|
+
* Time-without-progress ceiling, refreshed on every progress signal.
|
|
46
|
+
*
|
|
47
|
+
* Omit to bound by the run clock alone.
|
|
48
|
+
*/
|
|
49
|
+
readonly idleMs?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Await a task under both bounds.
|
|
54
|
+
*
|
|
55
|
+
* Note what this does NOT do: it does not cancel the worker. A wait that ran
|
|
56
|
+
* out is a statement about the waiter, not about the work — the child keeps
|
|
57
|
+
* going, its completion still reaches the inbox, and the supervisor is still
|
|
58
|
+
* told what it produced. Killing a child because a parent stopped waiting was
|
|
59
|
+
* never asked for, and losing an eight-minute worker's output because a
|
|
60
|
+
* two-minute clock expired is the exact shape of the bug this whole area has
|
|
61
|
+
* been unpicking.
|
|
62
|
+
*/
|
|
63
|
+
export async function waitForTaskWithBounds(
|
|
64
|
+
gateway: TaskGateway,
|
|
65
|
+
taskId: TaskId,
|
|
66
|
+
bounds: WaitBounds,
|
|
67
|
+
now: () => number = Date.now,
|
|
68
|
+
): Promise<WaitOutcome> {
|
|
69
|
+
const startedAt = now()
|
|
70
|
+
let lastProgressAt = startedAt
|
|
71
|
+
let settled = false
|
|
72
|
+
|
|
73
|
+
const detach = gateway.onTaskProgress?.((id) => {
|
|
74
|
+
if (id === taskId) lastProgressAt = now()
|
|
75
|
+
})
|
|
76
|
+
const idleBoundArmed = detach !== undefined && bounds.idleMs !== undefined
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const completion = gateway.waitForTask(taskId).then(
|
|
80
|
+
(handle): WaitOutcome => ({ kind: 'completed', handle }),
|
|
81
|
+
// A gateway that rejects has answered the question; let it through
|
|
82
|
+
// rather than reporting a timeout that did not happen.
|
|
83
|
+
(err) => {
|
|
84
|
+
throw err
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const expiry = new Promise<WaitOutcome>((resolve) => {
|
|
89
|
+
// Polled rather than scheduled, because the idle deadline MOVES: a
|
|
90
|
+
// timer armed for it would have to be cleared and rearmed on every
|
|
91
|
+
// tick of progress, and the one that slipped through would be the
|
|
92
|
+
// one that mattered. A coarse tick is enough — these bounds are
|
|
93
|
+
// minutes, and being a second late to notice silence costs nothing.
|
|
94
|
+
const tick = setInterval(() => {
|
|
95
|
+
if (settled) return
|
|
96
|
+
const elapsed = now() - startedAt
|
|
97
|
+
if (elapsed >= bounds.runMs) {
|
|
98
|
+
clearInterval(tick)
|
|
99
|
+
resolve({ kind: 'timeout', cause: 'run', elapsedMs: elapsed, idleBoundArmed })
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (idleBoundArmed && bounds.idleMs !== undefined) {
|
|
103
|
+
const quietFor = now() - lastProgressAt
|
|
104
|
+
if (quietFor >= bounds.idleMs) {
|
|
105
|
+
clearInterval(tick)
|
|
106
|
+
resolve({ kind: 'timeout', cause: 'idle', elapsedMs: elapsed, idleBoundArmed })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}, POLL_INTERVAL_MS)
|
|
110
|
+
// Never the reason a process stays alive. This one is safe to unref
|
|
111
|
+
// where the park recorder was not, because nothing AWAITS it alone:
|
|
112
|
+
// it races a real completion promise, so the wait is held open by
|
|
113
|
+
// work that is genuinely outstanding rather than by this timer.
|
|
114
|
+
;(tick as { unref?: () => void }).unref?.()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return await Promise.race([completion, expiry])
|
|
118
|
+
} finally {
|
|
119
|
+
settled = true
|
|
120
|
+
detach?.()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* How often the bounds are checked.
|
|
126
|
+
*
|
|
127
|
+
* Coarse on purpose: both bounds are measured in minutes, so a second of
|
|
128
|
+
* latency in noticing is irrelevant, and a tight interval would spend a timer
|
|
129
|
+
* wakeup per second per in-flight worker for nothing.
|
|
130
|
+
*/
|
|
131
|
+
const POLL_INTERVAL_MS = 1_000
|
|
132
|
+
|
|
133
|
+
/** What to tell the model, in the words that fit what actually happened. */
|
|
134
|
+
export function describeWaitTimeout(outcome: Extract<WaitOutcome, { kind: 'timeout' }>): string {
|
|
135
|
+
const seconds = Math.round(outcome.elapsedMs / 1000)
|
|
136
|
+
if (outcome.cause === 'idle') {
|
|
137
|
+
return `This worker went quiet: nothing has come from it for a while, after ${seconds}s. It has not been cancelled and may still finish — its result will arrive as a task notification if it does. Check agent_task_list, or start a different approach.`
|
|
138
|
+
}
|
|
139
|
+
return outcome.idleBoundArmed
|
|
140
|
+
? `This worker has been running for ${seconds}s without finishing, though it was still doing something. It has not been cancelled — its result will arrive as a task notification if it finishes.`
|
|
141
|
+
: `This worker has been running for ${seconds}s without finishing. This gateway cannot report progress, so there is no way to tell a busy worker from a stuck one here. It has not been cancelled — its result will arrive as a task notification if it finishes.`
|
|
142
|
+
}
|
package/src/types/agent/base.ts
CHANGED
|
@@ -24,6 +24,23 @@ export interface BaseAgentConfig {
|
|
|
24
24
|
permissionMode?: PermissionMode
|
|
25
25
|
env?: Record<string, string>
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Thinking mode and response-effort level for every model call this agent
|
|
29
|
+
* makes. See {@link import('../run/config.js').AgentRunConfig} for what
|
|
30
|
+
* each one controls and why they are siblings.
|
|
31
|
+
*
|
|
32
|
+
* They are declared HERE, on the shared base, rather than on each agent
|
|
33
|
+
* config that happens to want them. Every agent builds its `AgentRunConfig`
|
|
34
|
+
* by hand-listing fields, and a field absent from a hand-listed literal is
|
|
35
|
+
* dropped in silence — which is exactly how `thinking` came to be settable
|
|
36
|
+
* only through the raw kernel entry point while every ergonomic one quietly
|
|
37
|
+
* ignored it. Putting them on the base is what makes "did you forget to
|
|
38
|
+
* forward it" a type error in the places that matter rather than a support
|
|
39
|
+
* question.
|
|
40
|
+
*/
|
|
41
|
+
thinking?: import('../provider/index.js').ThinkingConfig
|
|
42
|
+
effort?: import('../provider/index.js').ReasoningEffort
|
|
43
|
+
|
|
27
44
|
/**
|
|
28
45
|
* Deduplicate a retried invocation instead of running it twice.
|
|
29
46
|
*
|
|
@@ -57,4 +57,28 @@ export interface TaskGateway {
|
|
|
57
57
|
listTasks(): TaskHandle[]
|
|
58
58
|
|
|
59
59
|
onTaskCompleted(callback: (handle: TaskHandle) => void): () => void
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Tell me when a task does something, not just when it finishes.
|
|
63
|
+
*
|
|
64
|
+
* This is what an idle bound is measured against. A wall clock says
|
|
65
|
+
* nothing about whether a worker is working: an hour is long enough to
|
|
66
|
+
* be useless as a stall detector, and short enough to kill a child that
|
|
67
|
+
* is making steady progress at minute fifty-nine. Time-without-progress
|
|
68
|
+
* is the quantity that separates "stuck" from "slow", and only the
|
|
69
|
+
* gateway can see it.
|
|
70
|
+
*
|
|
71
|
+
* OPTIONAL, and the absence is meaningful rather than an oversight: a
|
|
72
|
+
* gateway that cannot observe its children still works, and its waits
|
|
73
|
+
* are bounded by the wall clock alone — which is exactly the behaviour
|
|
74
|
+
* before this existed. It is optional because `TaskGateway` is
|
|
75
|
+
* implemented by hosts, and a required method would break every one of
|
|
76
|
+
* them for a capability not all of them can provide.
|
|
77
|
+
*
|
|
78
|
+
* Anything the worker did counts: a tool call, an emitted token, a state
|
|
79
|
+
* change. What must NOT count is the supervisor's own activity — the
|
|
80
|
+
* point is to notice a child that has gone quiet, and a parent polling
|
|
81
|
+
* about it is not the child speaking.
|
|
82
|
+
*/
|
|
83
|
+
onTaskProgress?(callback: (taskId: TaskId) => void): () => void
|
|
60
84
|
}
|
|
@@ -11,6 +11,13 @@ export type AgentLifecycleEvent =
|
|
|
11
11
|
depth: number
|
|
12
12
|
}
|
|
13
13
|
| { type: 'running'; taskId: TaskId }
|
|
14
|
+
/**
|
|
15
|
+
* **Never emitted.** Nothing constructs this variant, so a host that
|
|
16
|
+
* switches on it has written a branch that cannot run — and a host that
|
|
17
|
+
* relies on progress arriving will wait for an event that never comes.
|
|
18
|
+
*
|
|
19
|
+
* @deprecated No producer. Removed in the next major.
|
|
20
|
+
*/
|
|
14
21
|
| { type: 'progress_updated'; taskId: TaskId; progress: AgentTaskProgress }
|
|
15
22
|
| { type: 'completed'; taskId: TaskId; result: BaseAgentResult }
|
|
16
23
|
| { type: 'failed'; taskId: TaskId; error: string }
|
package/src/types/agent/task.ts
CHANGED
|
@@ -106,6 +106,11 @@ export interface AgentTask {
|
|
|
106
106
|
context: AgentTaskContext
|
|
107
107
|
state: AgentTaskState
|
|
108
108
|
result?: BaseAgentResult
|
|
109
|
+
/**
|
|
110
|
+
* **Never populated.** Nothing in the SDK writes task progress.
|
|
111
|
+
*
|
|
112
|
+
* @deprecated No producer. Removed in the next major.
|
|
113
|
+
*/
|
|
109
114
|
progress?: AgentTaskProgress
|
|
110
115
|
|
|
111
116
|
/**
|
package/src/types/hitl/index.ts
CHANGED
|
@@ -180,6 +180,16 @@ export interface IterationCheckpoint {
|
|
|
180
180
|
messages: Message[]
|
|
181
181
|
tokenUsage: TokenUsage
|
|
182
182
|
costInfo: CostInfo
|
|
183
|
+
/**
|
|
184
|
+
* **Never set.** No checkpoint is written with a plan status.
|
|
185
|
+
*
|
|
186
|
+
* It matters more than an unused field usually would: a host restoring a
|
|
187
|
+
* checkpoint and reading this to decide whether the plan was approved
|
|
188
|
+
* gets `undefined` for every run, approved or not, and cannot tell the
|
|
189
|
+
* two apart. Ask the plan manager instead.
|
|
190
|
+
*
|
|
191
|
+
* @deprecated No producer. Removed in the next major.
|
|
192
|
+
*/
|
|
183
193
|
planStatus?: PlanStatus
|
|
184
194
|
|
|
185
195
|
/**
|