@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
package/src/gateway/local.ts
CHANGED
|
@@ -39,6 +39,8 @@ export class LocalTaskGateway implements TaskGateway {
|
|
|
39
39
|
private settledHandles: Map<TaskId, TaskHandle> = new Map()
|
|
40
40
|
|
|
41
41
|
private siblingFailurePolicy: SiblingFailurePolicy = 'continue'
|
|
42
|
+
/** See {@link onTaskProgress}. */
|
|
43
|
+
private readonly progressListeners = new Set<(taskId: TaskId) => void>()
|
|
42
44
|
|
|
43
45
|
constructor(
|
|
44
46
|
agentManager: AgentManagerContract,
|
|
@@ -86,7 +88,15 @@ export class LocalTaskGateway implements TaskGateway {
|
|
|
86
88
|
// allocated `maxBudgetFraction` of the SAME number — N x 50% of a
|
|
87
89
|
// budget that only had 100% in it.
|
|
88
90
|
this.taskContext,
|
|
89
|
-
this
|
|
91
|
+
// The host's listener still sees everything it always did; this
|
|
92
|
+
// only tees off the fact that SOMETHING happened, which is what an
|
|
93
|
+
// idle bound measures. The event itself is not forwarded — a
|
|
94
|
+
// progress signal that carried the child's output would be a
|
|
95
|
+
// second, undocumented way to read a worker's work.
|
|
96
|
+
(event) => {
|
|
97
|
+
this.listener?.(event)
|
|
98
|
+
for (const notify of this.progressListeners) notify(task.taskId)
|
|
99
|
+
},
|
|
90
100
|
)
|
|
91
101
|
|
|
92
102
|
this.trackedTaskIds.add(task.taskId)
|
|
@@ -208,6 +218,21 @@ export class LocalTaskGateway implements TaskGateway {
|
|
|
208
218
|
return handles
|
|
209
219
|
}
|
|
210
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Every event a child emits, reduced to "this one is still alive".
|
|
223
|
+
*
|
|
224
|
+
* Deliberately just the id. A caller that wanted the event itself has
|
|
225
|
+
* the run listener; what an idle clock needs is the fact, and passing
|
|
226
|
+
* the payload here would make this a second way to read a worker's
|
|
227
|
+
* output — one nobody documented and nothing frames as untrusted.
|
|
228
|
+
*/
|
|
229
|
+
onTaskProgress(callback: (taskId: TaskId) => void): () => void {
|
|
230
|
+
this.progressListeners.add(callback)
|
|
231
|
+
return () => {
|
|
232
|
+
this.progressListeners.delete(callback)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
onTaskCompleted(callback: (handle: TaskHandle) => void): () => void {
|
|
212
237
|
this.completionListeners.add(callback)
|
|
213
238
|
return () => {
|
|
@@ -296,6 +296,12 @@ export class AgentManager {
|
|
|
296
296
|
parentSpan: options.configOverrides?.parentSpan,
|
|
297
297
|
maxIterations: options.configOverrides?.maxIterations,
|
|
298
298
|
maxResponseTokens: options.configOverrides?.maxResponseTokens,
|
|
299
|
+
// A delegate spawned without a configBuilder lands here, and this
|
|
300
|
+
// list is the only thing it inherits. Omitting these meant a child
|
|
301
|
+
// silently ran at the default depth and effort its parent had
|
|
302
|
+
// deliberately moved off.
|
|
303
|
+
thinking: options.configOverrides?.thinking,
|
|
304
|
+
effort: options.configOverrides?.effort,
|
|
299
305
|
env: options.configOverrides?.env,
|
|
300
306
|
sessionId: spawnRecord.childSessionId,
|
|
301
307
|
threadId: context.threadId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ThinkingConfig } from '../types/provider/index.js'
|
|
1
|
+
import type { ReasoningEffort, ThinkingConfig } from '../types/provider/index.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Refuse a thinking request a driver does not implement.
|
|
@@ -27,8 +27,25 @@ import type { ThinkingConfig } from '../types/provider/index.js'
|
|
|
27
27
|
*/
|
|
28
28
|
export function assertThinkingUnsupported(
|
|
29
29
|
driverName: string,
|
|
30
|
-
params: { thinking?: ThinkingConfig },
|
|
30
|
+
params: { thinking?: ThinkingConfig; effort?: ReasoningEffort },
|
|
31
31
|
): void {
|
|
32
|
+
// `effort` is refused on exactly the same reasoning, and it is the worse
|
|
33
|
+
// silence of the two. A dropped `thinking` at least leaves an empty
|
|
34
|
+
// reasoning list a caller could notice; a dropped `effort` leaves a
|
|
35
|
+
// perfectly ordinary answer, so a run someone believes they paid for at
|
|
36
|
+
// `max` is indistinguishable from one at the model's default — including
|
|
37
|
+
// on the bill.
|
|
38
|
+
//
|
|
39
|
+
// Checked before thinking because it is the cheaper mistake to make: a
|
|
40
|
+
// caller reaching for effort on a driver without it has usually pointed a
|
|
41
|
+
// working config at a new provider, and naming the field they set beats
|
|
42
|
+
// naming the neighbouring one.
|
|
43
|
+
if (params.effort !== undefined) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${driverName} does not implement effort. Silently ignoring it would return an ordinary completion, so a run requested at "${params.effort}" would be indistinguishable from one at the model's default — including in what it cost. Drop \`effort\`, or use a driver that implements it.`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
const type = params.thinking?.type
|
|
33
50
|
if (type !== 'enabled' && type !== 'adaptive') return
|
|
34
51
|
throw new Error(
|
package/src/public-runtime.ts
CHANGED
|
@@ -369,9 +369,18 @@ export {
|
|
|
369
369
|
FileLockManager,
|
|
370
370
|
} from './bus/index.js'
|
|
371
371
|
|
|
372
|
+
// `describeRule` travels with `evaluateRule` deliberately. `evaluateRule`
|
|
373
|
+
// answers WHETHER a rule matched; on its own it leaves a caller holding a
|
|
374
|
+
// decision with no words for it, and the only way to say anything about the
|
|
375
|
+
// refusal is to switch on the rule's TYPE — which names the kind of rule and
|
|
376
|
+
// nothing about what it said. That is precisely the defect the gate itself
|
|
377
|
+
// carried until its reason stopped being `Matched rule: <type>`, and shipping
|
|
378
|
+
// the verdict without the sentence would have left the same hole one layer up
|
|
379
|
+
// for anyone driving the rules directly instead of through the gate.
|
|
372
380
|
export {
|
|
373
381
|
defaultSandboxedGateConfig,
|
|
374
382
|
defaultSandboxedShellGateConfig,
|
|
383
|
+
describeRule,
|
|
375
384
|
evaluateRule,
|
|
376
385
|
VerificationGate,
|
|
377
386
|
} from './verification/index.js'
|
|
@@ -100,4 +100,28 @@ describe('RunContextFactory.build', () => {
|
|
|
100
100
|
const runDir = builder.runDir('prj_x' as ProjectId, 'ses_y' as SessionId, 'run_z' as RunId)
|
|
101
101
|
expect(posix(runDir)).toBe('/base/.namzu/projects/prj_x/sessions/ses_y/runs/run_z')
|
|
102
102
|
})
|
|
103
|
+
|
|
104
|
+
it("carries the caller's stop reason across into the run", () => {
|
|
105
|
+
// This is the frame that was losing it. The host aborts with a
|
|
106
|
+
// sentence, the run re-aborts with nothing, and every layer below —
|
|
107
|
+
// the tool executor, the tool itself, the result the model reads —
|
|
108
|
+
// can only report that something stopped. The words do not survive
|
|
109
|
+
// the hop unless this site forwards them.
|
|
110
|
+
const host = new AbortController()
|
|
111
|
+
const ctx = RunContextFactory.build(buildConfig({ signal: host.signal }))
|
|
112
|
+
|
|
113
|
+
host.abort(new Error('nightly window closed'))
|
|
114
|
+
|
|
115
|
+
expect(ctx.abortController.signal.aborted).toBe(true)
|
|
116
|
+
expect((ctx.abortController.signal.reason as Error)?.message).toBe('nightly window closed')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('still aborts when the caller gave no reason', () => {
|
|
120
|
+
const host = new AbortController()
|
|
121
|
+
const ctx = RunContextFactory.build(buildConfig({ signal: host.signal }))
|
|
122
|
+
|
|
123
|
+
host.abort()
|
|
124
|
+
|
|
125
|
+
expect(ctx.abortController.signal.aborted).toBe(true)
|
|
126
|
+
})
|
|
103
127
|
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { MockLLMProvider } from '../../../provider/mock.js'
|
|
6
|
+
import { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
7
|
+
import { createUserMessage } from '../../../types/message/index.js'
|
|
8
|
+
import type { AgentRunConfig } from '../../../types/run/index.js'
|
|
9
|
+
import { drainQuery } from '../index.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `effort` was declared on the provider params, exported, and read by a driver
|
|
13
|
+
* that wrote it straight to the wire — and nothing in the kernel ever set it.
|
|
14
|
+
* No caller could reach it, and the symptom (every request going out at the
|
|
15
|
+
* model's default) reads as "this model ignores effort" rather than "nobody
|
|
16
|
+
* plumbed it through".
|
|
17
|
+
*
|
|
18
|
+
* These drive a real run and read what the provider was actually handed, which
|
|
19
|
+
* is the only thing that distinguishes a wired field from a declared one. A
|
|
20
|
+
* test asserting the field exists on the config type would have passed against
|
|
21
|
+
* the broken version.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
let workdirs: string[] = []
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await Promise.all(workdirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
28
|
+
workdirs = []
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
async function run(overrides: Partial<AgentRunConfig>, turns: unknown[]): Promise<MockLLMProvider> {
|
|
32
|
+
const provider = new MockLLMProvider({ turns: turns as never })
|
|
33
|
+
const dir = await mkdtemp(join(tmpdir(), 'namzu-effort-'))
|
|
34
|
+
workdirs.push(dir)
|
|
35
|
+
|
|
36
|
+
await drainQuery({
|
|
37
|
+
provider,
|
|
38
|
+
tools: new ToolRegistry(),
|
|
39
|
+
runConfig: {
|
|
40
|
+
model: 'mock-model',
|
|
41
|
+
timeoutMs: 30_000,
|
|
42
|
+
tokenBudget: 100_000,
|
|
43
|
+
maxIterations: 2,
|
|
44
|
+
...overrides,
|
|
45
|
+
},
|
|
46
|
+
agentId: 'agent_effort',
|
|
47
|
+
agentName: 'Effort Agent',
|
|
48
|
+
workingDirectory: dir,
|
|
49
|
+
sessionId: 'ses_effort',
|
|
50
|
+
threadId: 'thd_effort',
|
|
51
|
+
projectId: 'prj_effort',
|
|
52
|
+
tenantId: 'tnt_effort',
|
|
53
|
+
messages: [createUserMessage('go')],
|
|
54
|
+
} as never)
|
|
55
|
+
|
|
56
|
+
return provider
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('an effort level set on the run reaches the provider', () => {
|
|
60
|
+
it('arrives on the request', async () => {
|
|
61
|
+
const provider = await run({ effort: 'max' }, [{ text: 'done' }])
|
|
62
|
+
|
|
63
|
+
expect(provider.requests.length).toBeGreaterThan(0)
|
|
64
|
+
expect(provider.requests[0]?.effort).toBe('max')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('is absent when nobody asked for one', async () => {
|
|
68
|
+
// Not `undefined`-valued but genuinely absent: a present key carrying
|
|
69
|
+
// undefined is the kind of thing that survives a spread into a request
|
|
70
|
+
// body and reaches a wire that did not expect the field.
|
|
71
|
+
const provider = await run({}, [{ text: 'done' }])
|
|
72
|
+
|
|
73
|
+
expect(provider.requests[0] && 'effort' in provider.requests[0]).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rides every turn of the run, not only the first', async () => {
|
|
77
|
+
// The value is run-level because the provider documents that changing
|
|
78
|
+
// it between requests invalidates the cached prefix. A run that
|
|
79
|
+
// forwarded it once and then stopped would pay that cost silently.
|
|
80
|
+
const provider = await run({ effort: 'low' }, [{ text: 'one' }, { text: 'two' }])
|
|
81
|
+
|
|
82
|
+
for (const request of provider.requests) {
|
|
83
|
+
expect(request.effort).toBe('low')
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('travels alongside thinking rather than inside it', async () => {
|
|
88
|
+
const provider = await run({ effort: 'high', thinking: { type: 'adaptive' } }, [
|
|
89
|
+
{ text: 'done' },
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
expect(provider.requests[0]?.effort).toBe('high')
|
|
93
|
+
expect(provider.requests[0]?.thinking?.type).toBe('adaptive')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('the front door forwards it too, not only the kernel', () => {
|
|
98
|
+
/**
|
|
99
|
+
* These exist because everything above passed while a real run put NOTHING
|
|
100
|
+
* on the wire.
|
|
101
|
+
*
|
|
102
|
+
* `drainQuery` takes the run config a caller hands it, so testing through
|
|
103
|
+
* it proves the loop forwards the field and nothing about whether a caller
|
|
104
|
+
* can set it. Every ergonomic entry point — this one, `ReactiveAgent`,
|
|
105
|
+
* `SupervisorAgent`, and the manager's bare-config branch — builds its
|
|
106
|
+
* `AgentRunConfig` by HAND-LISTING fields, so a field nobody remembered to
|
|
107
|
+
* add is dropped in silence, with no cast to blame and no error to see.
|
|
108
|
+
* `thinking` had been in that state since it shipped.
|
|
109
|
+
*
|
|
110
|
+
* It was found by watching an actual HTTP body, which is the only place the
|
|
111
|
+
* gap is visible. So the regression test drives the front door.
|
|
112
|
+
*/
|
|
113
|
+
it('reaches the provider through runAgent', async () => {
|
|
114
|
+
const { runAgent } = await import('../../../agents/runAgent.js')
|
|
115
|
+
const provider = new MockLLMProvider({ turns: [{ text: 'done' }] as never })
|
|
116
|
+
const dir = await mkdtemp(join(tmpdir(), 'namzu-effort-door-'))
|
|
117
|
+
workdirs.push(dir)
|
|
118
|
+
|
|
119
|
+
await runAgent({
|
|
120
|
+
provider,
|
|
121
|
+
model: 'mock-model',
|
|
122
|
+
prompt: 'go',
|
|
123
|
+
workingDirectory: dir,
|
|
124
|
+
effort: 'xhigh',
|
|
125
|
+
thinking: { type: 'adaptive' },
|
|
126
|
+
timeoutMs: 30_000,
|
|
127
|
+
tokenBudget: 100_000,
|
|
128
|
+
maxIterations: 2,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(provider.requests.length).toBeGreaterThan(0)
|
|
132
|
+
expect(provider.requests[0]?.effort, 'the front door dropped effort').toBe('xhigh')
|
|
133
|
+
expect(provider.requests[0]?.thinking?.type, 'the front door dropped thinking').toBe('adaptive')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -177,6 +177,44 @@ describe('ToolExecutor — per-tool deadline', () => {
|
|
|
177
177
|
expect(batch.results[0]?.output).toContain('was cancelled')
|
|
178
178
|
})
|
|
179
179
|
|
|
180
|
+
it('says WHICH stop it was, when the caller named one', async () => {
|
|
181
|
+
// The reason was on this signal all along — it is forwarded into the
|
|
182
|
+
// per-tool controller a few lines above the message — and the message
|
|
183
|
+
// threw it away. So a deadline, a budget and an operator pressing stop
|
|
184
|
+
// all reached the model as the same four words, and they want
|
|
185
|
+
// different next moves: one is worth waiting out, one is worth
|
|
186
|
+
// narrowing the input for, and one is worth stopping over.
|
|
187
|
+
const controller = new AbortController()
|
|
188
|
+
const h = harness({
|
|
189
|
+
toolTimeoutMs: 60_000,
|
|
190
|
+
abortSignal: controller.signal,
|
|
191
|
+
run: never,
|
|
192
|
+
})
|
|
193
|
+
const pending = h.exec.executeBatch(response('hang'))
|
|
194
|
+
controller.abort(new Error('deployment window closed'))
|
|
195
|
+
const batch = await pending
|
|
196
|
+
|
|
197
|
+
expect(batch.results[0]?.output).toContain('deployment window closed')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('does not invent a reason when the caller gave none', async () => {
|
|
201
|
+
// `abort()` with no argument fills `reason` with a DOMException named
|
|
202
|
+
// AbortError. Rendering it would turn an honest silence into something
|
|
203
|
+
// that reads like an explanation, which is the worse failure.
|
|
204
|
+
const controller = new AbortController()
|
|
205
|
+
const h = harness({
|
|
206
|
+
toolTimeoutMs: 60_000,
|
|
207
|
+
abortSignal: controller.signal,
|
|
208
|
+
run: never,
|
|
209
|
+
})
|
|
210
|
+
const pending = h.exec.executeBatch(response('hang'))
|
|
211
|
+
controller.abort()
|
|
212
|
+
const batch = await pending
|
|
213
|
+
|
|
214
|
+
expect(batch.results[0]?.output).toContain('was cancelled.')
|
|
215
|
+
expect(batch.results[0]?.output).not.toContain('AbortError')
|
|
216
|
+
})
|
|
217
|
+
|
|
180
218
|
it('exposes a sane default deadline', () => {
|
|
181
219
|
// Documented so a change is a deliberate decision, not a drift.
|
|
182
220
|
expect(DEFAULT_TOOL_TIMEOUT_MS).toBe(120_000)
|
|
@@ -134,7 +134,22 @@ export class RunContextFactory {
|
|
|
134
134
|
static build(config: RunContextConfig): RunContext {
|
|
135
135
|
const abortController = new AbortController()
|
|
136
136
|
if (config.signal) {
|
|
137
|
-
|
|
137
|
+
// Forward the caller's REASON, not just the fact of the abort.
|
|
138
|
+
//
|
|
139
|
+
// This used to be a bare `abort()`. Every word a host attached to
|
|
140
|
+
// its stop — a deadline name, a budget, an operator's message —
|
|
141
|
+
// died one frame above the executor, so the most a tool result
|
|
142
|
+
// could say was "was cancelled". A run that ends for a nameable
|
|
143
|
+
// reason is a run someone can debug; this is the frame where the
|
|
144
|
+
// name was being thrown away.
|
|
145
|
+
//
|
|
146
|
+
// `createChildAbortController` already does exactly this, but it
|
|
147
|
+
// takes an AbortController and what arrives here is a bare
|
|
148
|
+
// AbortSignal, so the reason is forwarded by hand rather than by
|
|
149
|
+
// reaching for a helper that does not fit.
|
|
150
|
+
config.signal.addEventListener('abort', () => abortController.abort(config.signal?.reason), {
|
|
151
|
+
once: true,
|
|
152
|
+
})
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
const cwd = config.workingDirectory ?? process.cwd()
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
ToolCallRepair,
|
|
34
34
|
ToolCallRepairReason,
|
|
35
35
|
} from '../../types/tool/repair.js'
|
|
36
|
+
import { abortReasonText } from '../../utils/abort.js'
|
|
36
37
|
import { toErrorMessage } from '../../utils/error.js'
|
|
37
38
|
import type { Logger } from '../../utils/logger.js'
|
|
38
39
|
import { compressShellOutput } from '../../utils/shell-compress.js'
|
|
@@ -859,10 +860,19 @@ export class ToolExecutor {
|
|
|
859
860
|
}
|
|
860
861
|
|
|
861
862
|
if (outcome === 'aborted') {
|
|
863
|
+
// Say WHY, when the caller said why. The reason has been
|
|
864
|
+
// available on this signal all along — it is forwarded into
|
|
865
|
+
// `controller` a few lines above — and the message threw it
|
|
866
|
+
// away, so a deadline, a budget and an operator pressing stop
|
|
867
|
+
// were all reported to the model with the same four words.
|
|
868
|
+
// Those want different next moves.
|
|
869
|
+
const why = abortReasonText(controller.signal.reason)
|
|
862
870
|
return {
|
|
863
871
|
success: false,
|
|
864
872
|
output: '',
|
|
865
|
-
error:
|
|
873
|
+
error: why
|
|
874
|
+
? `Tool "${toolName}" was cancelled: ${why}`
|
|
875
|
+
: `Tool "${toolName}" was cancelled.`,
|
|
866
876
|
}
|
|
867
877
|
}
|
|
868
878
|
|
|
@@ -317,6 +317,7 @@ export class IterationOrchestrator {
|
|
|
317
317
|
maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
|
|
318
318
|
cacheControl: { type: 'auto' },
|
|
319
319
|
...(runConfig.thinking ? { thinking: runConfig.thinking } : {}),
|
|
320
|
+
...(runConfig.effort ? { effort: runConfig.effort } : {}),
|
|
320
321
|
// Thread the run abort into the model call so a Stop tears the
|
|
321
322
|
// in-flight turn down (provider passes it to fetch; the consumer
|
|
322
323
|
// also races it). Inert when never aborted.
|
|
@@ -1154,6 +1155,10 @@ export class IterationOrchestrator {
|
|
|
1154
1155
|
maxTokens: this.ctx.runConfig.maxResponseTokens,
|
|
1155
1156
|
cacheControl: { type: 'auto' },
|
|
1156
1157
|
...(this.ctx.runConfig.thinking ? { thinking: this.ctx.runConfig.thinking } : {}),
|
|
1158
|
+
// This turn is a hand-maintained duplicate of the one above, which
|
|
1159
|
+
// is exactly the shape a field goes missing from — so it is tested
|
|
1160
|
+
// separately rather than assumed to have been kept in step.
|
|
1161
|
+
...(this.ctx.runConfig.effort ? { effort: this.ctx.runConfig.effort } : {}),
|
|
1157
1162
|
// Cancellable too: a Stop during the closing summary must not
|
|
1158
1163
|
// stream to completion.
|
|
1159
1164
|
signal: this.ctx.abortController.signal,
|
|
@@ -2,6 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { MockLLMProvider } from '../../provider/mock.js'
|
|
6
|
+
import { ToolRegistry } from '../../registry/tool/execute.js'
|
|
7
|
+
import { drainQuery } from '../../runtime/query/index.js'
|
|
8
|
+
import { createUserMessage } from '../../types/message/index.js'
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* There was no span around the model call at all.
|
|
@@ -72,12 +76,25 @@ afterEach(async () => {
|
|
|
72
76
|
workdirs = []
|
|
73
77
|
})
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* These four are imported at module scope on purpose, and must stay there.
|
|
81
|
+
*
|
|
82
|
+
* They used to be `await import(...)` inside this function, which meant the
|
|
83
|
+
* query runtime's module graph — 75 imports deep — was loaded on the clock of
|
|
84
|
+
* whichever test called it first. Measured: that test took 1349ms on an idle
|
|
85
|
+
* machine while its eight siblings took 13-16ms, and under CPU load it hit
|
|
86
|
+
* vitest's 5000ms default and went red. Nothing about it was racy and nothing
|
|
87
|
+
* about it was slow; a load cost was simply billed to the wrong clock.
|
|
88
|
+
*
|
|
89
|
+
* At module scope the same work happens during collection, which carries no
|
|
90
|
+
* per-test deadline, so the wall time is unchanged and the deadline is not.
|
|
91
|
+
*
|
|
92
|
+
* Safe because `vi.mock` is hoisted above every import in this file, so a
|
|
93
|
+
* static import still receives the mocked `runtime-accessors`. That is a
|
|
94
|
+
* property of the transform rather than of import order — verified by running,
|
|
95
|
+
* not assumed, since no other file in this package had done it this way.
|
|
96
|
+
*/
|
|
75
97
|
async function runOnce(turns: { text?: string }[]): Promise<void> {
|
|
76
|
-
const { MockLLMProvider } = await import('../../provider/mock.js')
|
|
77
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
78
|
-
const { drainQuery } = await import('../../runtime/query/index.js')
|
|
79
|
-
const { createUserMessage } = await import('../../types/message/index.js')
|
|
80
|
-
|
|
81
98
|
const dir = await mkdtemp(join(tmpdir(), 'namzu-chatspan-'))
|
|
82
99
|
workdirs.push(dir)
|
|
83
100
|
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
+
// Imported at module scope on purpose, and must stay there.
|
|
4
|
+
//
|
|
5
|
+
// This was `await import(...)` repeated inside all five test bodies. That
|
|
6
|
+
// billed the tool-registry module graph to whichever test happened to run
|
|
7
|
+
// first, out of that test's own 5000ms deadline — and because every body
|
|
8
|
+
// re-entered the same pending import, a stall did not fail one test, it took
|
|
9
|
+
// the whole file down. At module scope the load happens during collection,
|
|
10
|
+
// which has no per-test deadline.
|
|
11
|
+
//
|
|
12
|
+
// `vi.mock` is hoisted above every import here, so the static form still
|
|
13
|
+
// receives the mocked `runtime-accessors`.
|
|
14
|
+
import { ToolRegistry } from '../../registry/tool/execute.js'
|
|
3
15
|
|
|
4
16
|
/**
|
|
5
17
|
* A span that never ends is a trace that never closes, and the export is
|
|
@@ -73,7 +85,6 @@ afterEach(() => {
|
|
|
73
85
|
|
|
74
86
|
describe('a tool span closes however the call leaves', () => {
|
|
75
87
|
it('closes on the ordinary path', async () => {
|
|
76
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
77
88
|
const tools = new ToolRegistry()
|
|
78
89
|
tools.register({
|
|
79
90
|
name: 'echo',
|
|
@@ -89,7 +100,6 @@ describe('a tool span closes however the call leaves', () => {
|
|
|
89
100
|
})
|
|
90
101
|
|
|
91
102
|
it('closes when the tool throws', async () => {
|
|
92
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
93
103
|
const tools = new ToolRegistry()
|
|
94
104
|
tools.register({
|
|
95
105
|
name: 'boom',
|
|
@@ -106,7 +116,6 @@ describe('a tool span closes however the call leaves', () => {
|
|
|
106
116
|
})
|
|
107
117
|
|
|
108
118
|
it('closes when input validation refuses the call', async () => {
|
|
109
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
110
119
|
const tools = new ToolRegistry()
|
|
111
120
|
tools.register({
|
|
112
121
|
name: 'strict',
|
|
@@ -121,7 +130,6 @@ describe('a tool span closes however the call leaves', () => {
|
|
|
121
130
|
})
|
|
122
131
|
|
|
123
132
|
it('closes when the tool is not active', async () => {
|
|
124
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
125
133
|
const tools = new ToolRegistry()
|
|
126
134
|
tools.register(
|
|
127
135
|
{
|
|
@@ -139,7 +147,6 @@ describe('a tool span closes however the call leaves', () => {
|
|
|
139
147
|
})
|
|
140
148
|
|
|
141
149
|
it('closes when the registry does not hold the name at all', async () => {
|
|
142
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
143
150
|
const tools = new ToolRegistry()
|
|
144
151
|
|
|
145
152
|
// `getOrThrow` sat OUTSIDE the try that owned the finally, so this
|