@namzu/sdk 6.0.0 → 6.1.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 +133 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +13 -0
- package/dist/agents/SupervisorAgent.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 +258 -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 +211 -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__/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.map +1 -1
- 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 +35 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +222 -16
- package/dist/tools/coordinator/index.js.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 +1 -1
- package/src/agents/SupervisorAgent.ts +14 -0
- package/src/gateway/__tests__/completion-inbox.test.ts +320 -0
- package/src/gateway/completion-inbox.ts +227 -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__/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 +10 -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 +250 -20
- package/src/types/tool/index.ts +13 -0
|
@@ -0,0 +1,196 @@
|
|
|
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 { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
import { CompletionInbox } from '../../../gateway/completion-inbox.js'
|
|
8
|
+
import { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
9
|
+
import { defineTool } from '../../../tools/defineTool.js'
|
|
10
|
+
import type { TaskHandle } from '../../../types/agent/gateway.js'
|
|
11
|
+
import type { SessionId, TaskId, TenantId } from '../../../types/ids/index.js'
|
|
12
|
+
import { createUserMessage } from '../../../types/message/index.js'
|
|
13
|
+
import type { LLMProvider, StreamChunk } from '../../../types/provider/index.js'
|
|
14
|
+
import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
|
|
15
|
+
import { drainQuery } from '../index.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The last link, and the one most likely to be built and never wired.
|
|
19
|
+
*
|
|
20
|
+
* `CompletionInbox` can queue an unclaimed completion and format it, and the
|
|
21
|
+
* coordinator tools can decline to claim one — but none of that reaches the
|
|
22
|
+
* model unless the iteration loop actually drains the inbox into the
|
|
23
|
+
* transcript. A mechanism that is declared, threaded through types, and
|
|
24
|
+
* driven by nothing is precisely the shape of the defect this whole change
|
|
25
|
+
* exists to fix, so the drain gets its own test at the loop level rather
|
|
26
|
+
* than being assumed from its parts.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const ZERO_USAGE = {
|
|
30
|
+
promptTokens: 0,
|
|
31
|
+
completionTokens: 0,
|
|
32
|
+
totalTokens: 0,
|
|
33
|
+
cachedTokens: 0,
|
|
34
|
+
cacheWriteTokens: 0,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Calls a tool once, then answers. Two turns is all the drain needs. */
|
|
38
|
+
class ToolThenAnswerProvider implements LLMProvider {
|
|
39
|
+
readonly id = 'tool-then-answer'
|
|
40
|
+
readonly name = 'Tool Then Answer Provider'
|
|
41
|
+
calls = 0
|
|
42
|
+
|
|
43
|
+
async *chatStream(): AsyncIterable<StreamChunk> {
|
|
44
|
+
this.calls += 1
|
|
45
|
+
|
|
46
|
+
if (this.calls === 1) {
|
|
47
|
+
yield {
|
|
48
|
+
id: 'msg_1',
|
|
49
|
+
delta: {
|
|
50
|
+
toolCalls: [
|
|
51
|
+
{
|
|
52
|
+
index: 0,
|
|
53
|
+
id: 'toolu_noop_1',
|
|
54
|
+
type: 'function',
|
|
55
|
+
function: { name: 'noop', arguments: '{}' },
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
yield {
|
|
61
|
+
id: 'msg_1',
|
|
62
|
+
delta: {},
|
|
63
|
+
finishReason: 'tool_calls',
|
|
64
|
+
usage: ZERO_USAGE,
|
|
65
|
+
}
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
yield { id: 'msg_2', delta: { content: 'Done.' } }
|
|
70
|
+
yield { id: 'msg_2', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const noop = defineTool({
|
|
75
|
+
name: 'noop',
|
|
76
|
+
description: 'does nothing',
|
|
77
|
+
inputSchema: z.object({}),
|
|
78
|
+
category: 'analysis',
|
|
79
|
+
permissions: [],
|
|
80
|
+
readOnly: true,
|
|
81
|
+
destructive: false,
|
|
82
|
+
concurrencySafe: true,
|
|
83
|
+
async execute() {
|
|
84
|
+
return { success: true, output: 'ok' }
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
function completed(taskId: string, result: string): TaskHandle {
|
|
89
|
+
return {
|
|
90
|
+
taskId: taskId as TaskId,
|
|
91
|
+
agentId: 'reviewer',
|
|
92
|
+
state: 'completed',
|
|
93
|
+
createdAt: 1_000,
|
|
94
|
+
completedAt: 4_000,
|
|
95
|
+
result: { status: 'completed', result } as TaskHandle['result'],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const workdirs: string[] = []
|
|
100
|
+
afterEach(async () => {
|
|
101
|
+
await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
|
102
|
+
workdirs.length = 0
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
async function runWith(inbox: CompletionInbox | undefined): Promise<string[]> {
|
|
106
|
+
const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-completion-'))
|
|
107
|
+
workdirs.push(workingDirectory)
|
|
108
|
+
|
|
109
|
+
const tools = new ToolRegistry()
|
|
110
|
+
tools.register(noop)
|
|
111
|
+
|
|
112
|
+
const run = await drainQuery({
|
|
113
|
+
provider: new ToolThenAnswerProvider(),
|
|
114
|
+
tools,
|
|
115
|
+
...(inbox ? { completionInbox: inbox } : {}),
|
|
116
|
+
agentId: 'agent_test',
|
|
117
|
+
agentName: 'Test Agent',
|
|
118
|
+
messages: [createUserMessage('delegate and report')],
|
|
119
|
+
workingDirectory,
|
|
120
|
+
runConfig: {
|
|
121
|
+
model: 'mock-model',
|
|
122
|
+
timeoutMs: 10_000,
|
|
123
|
+
tokenBudget: 100_000,
|
|
124
|
+
maxIterations: 4,
|
|
125
|
+
maxResponseTokens: 256,
|
|
126
|
+
},
|
|
127
|
+
sessionId: 'ses_completion' as SessionId,
|
|
128
|
+
threadId: 'thd_completion' as ThreadId,
|
|
129
|
+
projectId: 'prj_completion' as ProjectId,
|
|
130
|
+
tenantId: 'tnt_completion' as TenantId,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return run.messages
|
|
134
|
+
.filter((m) => m.role === 'user')
|
|
135
|
+
.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe('an unclaimed completion reaches the transcript', () => {
|
|
139
|
+
it('injects the notification as a user message the next turn can read', async () => {
|
|
140
|
+
const inbox = new CompletionInbox()
|
|
141
|
+
// Settled by a gateway before the turn ended, with nothing waiting on
|
|
142
|
+
// it — the abandoned-launch case.
|
|
143
|
+
inbox.attach({
|
|
144
|
+
onTaskCompleted: (cb: (h: TaskHandle) => void) => {
|
|
145
|
+
cb(completed('tsk_late', 'the worker finished after the wait was abandoned'))
|
|
146
|
+
return () => {}
|
|
147
|
+
},
|
|
148
|
+
} as never)
|
|
149
|
+
|
|
150
|
+
const userMessages = await runWith(inbox)
|
|
151
|
+
const notification = userMessages.find((m) => m.includes('task-notification'))
|
|
152
|
+
|
|
153
|
+
expect(notification).toBeDefined()
|
|
154
|
+
expect(notification).toContain('tsk_late')
|
|
155
|
+
expect(notification).toContain('the worker finished after the wait was abandoned')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('drains it exactly once, however many turns follow', async () => {
|
|
159
|
+
const inbox = new CompletionInbox()
|
|
160
|
+
inbox.attach({
|
|
161
|
+
onTaskCompleted: (cb: (h: TaskHandle) => void) => {
|
|
162
|
+
cb(completed('tsk_late', 'only once'))
|
|
163
|
+
return () => {}
|
|
164
|
+
},
|
|
165
|
+
} as never)
|
|
166
|
+
|
|
167
|
+
const userMessages = await runWith(inbox)
|
|
168
|
+
|
|
169
|
+
expect(userMessages.filter((m) => m.includes('task-notification'))).toHaveLength(1)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('says nothing when every completion was already delivered', async () => {
|
|
173
|
+
// The `dc16d58` regression at the loop level: a blocking `create_task`
|
|
174
|
+
// claims its own completion, so the transcript must stay clean.
|
|
175
|
+
const inbox = new CompletionInbox()
|
|
176
|
+
inbox.attach({
|
|
177
|
+
onTaskCompleted: (cb: (h: TaskHandle) => void) => {
|
|
178
|
+
cb(completed('tsk_awaited', 'delivered as a tool_result'))
|
|
179
|
+
return () => {}
|
|
180
|
+
},
|
|
181
|
+
} as never)
|
|
182
|
+
inbox.claim('tsk_awaited' as TaskId)
|
|
183
|
+
|
|
184
|
+
const userMessages = await runWith(inbox)
|
|
185
|
+
|
|
186
|
+
expect(userMessages.some((m) => m.includes('task-notification'))).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('runs unchanged with no inbox at all', async () => {
|
|
190
|
+
// The kernel must not require one: a host on the old wiring keeps
|
|
191
|
+
// working, it just never hears about abandoned completions.
|
|
192
|
+
const userMessages = await runWith(undefined)
|
|
193
|
+
|
|
194
|
+
expect(userMessages.some((m) => m.includes('task-notification'))).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -0,0 +1,205 @@
|
|
|
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 { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
import { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
8
|
+
import { defineTool } from '../../../tools/defineTool.js'
|
|
9
|
+
import type { SessionId, TenantId } from '../../../types/ids/index.js'
|
|
10
|
+
import { createUserMessage } from '../../../types/message/index.js'
|
|
11
|
+
import type { LLMProvider, StreamChunk } from '../../../types/provider/index.js'
|
|
12
|
+
import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
|
|
13
|
+
import { drainQuery } from '../index.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Narrowing a step has to narrow what RUNS, not only what is offered.
|
|
17
|
+
*
|
|
18
|
+
* `prepareStep.activeTools` says "restrict which tools the model may call
|
|
19
|
+
* this step, by name", and for a while it did nothing of the sort: it chose
|
|
20
|
+
* which schemas went into the request, was copied into the tool context, and
|
|
21
|
+
* was read by nobody on the execution path. A model that named a withheld
|
|
22
|
+
* tool had it run — and a model does name one, from a repeated call earlier
|
|
23
|
+
* in the context, from a gateway carrying its own tool list, or from a
|
|
24
|
+
* replayed cache prefix.
|
|
25
|
+
*
|
|
26
|
+
* These tests call the withheld tool deliberately, which is the only way to
|
|
27
|
+
* tell an enforced restriction from a presentational one. Every existing test
|
|
28
|
+
* asked the model nicely and so could not have caught this.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const ZERO_USAGE = {
|
|
32
|
+
promptTokens: 0,
|
|
33
|
+
completionTokens: 0,
|
|
34
|
+
totalTokens: 0,
|
|
35
|
+
cachedTokens: 0,
|
|
36
|
+
cacheWriteTokens: 0,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let danger = 0
|
|
40
|
+
|
|
41
|
+
const readOnly = defineTool({
|
|
42
|
+
name: 'read_only',
|
|
43
|
+
description: 'observes',
|
|
44
|
+
inputSchema: z.object({}),
|
|
45
|
+
category: 'analysis',
|
|
46
|
+
permissions: [],
|
|
47
|
+
readOnly: true,
|
|
48
|
+
destructive: false,
|
|
49
|
+
concurrencySafe: true,
|
|
50
|
+
async execute() {
|
|
51
|
+
return { success: true, output: 'observed' }
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const dangerous = defineTool({
|
|
56
|
+
name: 'danger',
|
|
57
|
+
description: 'mutates',
|
|
58
|
+
inputSchema: z.object({}),
|
|
59
|
+
category: 'custom',
|
|
60
|
+
permissions: [],
|
|
61
|
+
readOnly: false,
|
|
62
|
+
destructive: true,
|
|
63
|
+
concurrencySafe: false,
|
|
64
|
+
async execute() {
|
|
65
|
+
danger += 1
|
|
66
|
+
return { success: true, output: 'the withheld tool ran' }
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/** Calls whichever tool it is told to, then answers. */
|
|
71
|
+
class NamesTool implements LLMProvider {
|
|
72
|
+
readonly id = 'names-tool'
|
|
73
|
+
readonly name = 'Names Tool Provider'
|
|
74
|
+
calls = 0
|
|
75
|
+
offered: string[][] = []
|
|
76
|
+
|
|
77
|
+
constructor(private readonly toolName: string) {}
|
|
78
|
+
|
|
79
|
+
async *chatStream(params: {
|
|
80
|
+
tools?: { function: { name: string } }[]
|
|
81
|
+
}): AsyncIterable<StreamChunk> {
|
|
82
|
+
this.calls += 1
|
|
83
|
+
this.offered.push((params.tools ?? []).map((t) => t.function.name))
|
|
84
|
+
|
|
85
|
+
if (this.calls === 1) {
|
|
86
|
+
yield {
|
|
87
|
+
id: 'msg_1',
|
|
88
|
+
delta: {
|
|
89
|
+
toolCalls: [
|
|
90
|
+
{
|
|
91
|
+
index: 0,
|
|
92
|
+
id: 'toolu_1',
|
|
93
|
+
type: 'function',
|
|
94
|
+
function: { name: this.toolName, arguments: '{}' },
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
yield { id: 'msg_1', delta: {}, finishReason: 'tool_calls', usage: ZERO_USAGE }
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
yield { id: 'msg_2', delta: { content: 'done' } }
|
|
104
|
+
yield { id: 'msg_2', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const workdirs: string[] = []
|
|
109
|
+
afterEach(async () => {
|
|
110
|
+
await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
|
111
|
+
workdirs.length = 0
|
|
112
|
+
danger = 0
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
async function run(opts: {
|
|
116
|
+
names: string
|
|
117
|
+
allowedTools?: string[]
|
|
118
|
+
activeTools?: string[]
|
|
119
|
+
}): Promise<{ offered: string[][]; output: string }> {
|
|
120
|
+
const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-allow-'))
|
|
121
|
+
workdirs.push(workingDirectory)
|
|
122
|
+
|
|
123
|
+
const tools = new ToolRegistry()
|
|
124
|
+
tools.register(readOnly)
|
|
125
|
+
tools.register(dangerous)
|
|
126
|
+
|
|
127
|
+
const provider = new NamesTool(opts.names)
|
|
128
|
+
let output = ''
|
|
129
|
+
|
|
130
|
+
await drainQuery(
|
|
131
|
+
{
|
|
132
|
+
provider,
|
|
133
|
+
tools,
|
|
134
|
+
...(opts.allowedTools ? { allowedTools: opts.allowedTools } : {}),
|
|
135
|
+
...(opts.activeTools
|
|
136
|
+
? { prepareStep: () => ({ activeTools: opts.activeTools as string[] }) }
|
|
137
|
+
: {}),
|
|
138
|
+
agentId: 'agent_test',
|
|
139
|
+
agentName: 'Test Agent',
|
|
140
|
+
messages: [createUserMessage('go')],
|
|
141
|
+
workingDirectory,
|
|
142
|
+
runConfig: {
|
|
143
|
+
model: 'mock-model',
|
|
144
|
+
timeoutMs: 10_000,
|
|
145
|
+
tokenBudget: 100_000,
|
|
146
|
+
maxIterations: 3,
|
|
147
|
+
maxResponseTokens: 256,
|
|
148
|
+
},
|
|
149
|
+
sessionId: 'ses_allow' as SessionId,
|
|
150
|
+
threadId: 'thd_allow' as ThreadId,
|
|
151
|
+
projectId: 'prj_allow' as ProjectId,
|
|
152
|
+
tenantId: 'tnt_allow' as TenantId,
|
|
153
|
+
},
|
|
154
|
+
(event) => {
|
|
155
|
+
if (event.type === 'tool_completed') output += event.result ?? ''
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return { offered: provider.offered, output }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
describe('a narrowed step narrows what can run, not just what is shown', () => {
|
|
163
|
+
it('refuses a tool the step withheld, even when the model names it', async () => {
|
|
164
|
+
const { offered, output } = await run({ names: 'danger', activeTools: ['read_only'] })
|
|
165
|
+
|
|
166
|
+
// The request really was narrowed — otherwise this test proves nothing
|
|
167
|
+
// about enforcement, only about the model's manners.
|
|
168
|
+
expect(offered[0]).toEqual(['read_only'])
|
|
169
|
+
// And the call was refused rather than executed.
|
|
170
|
+
expect(danger, 'the withheld tool executed').toBe(0)
|
|
171
|
+
expect(output).toContain('not available on this step')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('refuses a tool outside the run-level list too', async () => {
|
|
175
|
+
const { output } = await run({ names: 'danger', allowedTools: ['read_only'] })
|
|
176
|
+
|
|
177
|
+
expect(danger).toBe(0)
|
|
178
|
+
expect(output).toContain('not available on this step')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('still runs a tool that is on the list', async () => {
|
|
182
|
+
const { output } = await run({ names: 'read_only', activeTools: ['read_only'] })
|
|
183
|
+
|
|
184
|
+
expect(output).toContain('observed')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('leaves an unnarrowed run alone', async () => {
|
|
188
|
+
// Absent means unrestricted. A run that never narrows anything must not
|
|
189
|
+
// suddenly start refusing its own tools.
|
|
190
|
+
const { output } = await run({ names: 'danger' })
|
|
191
|
+
|
|
192
|
+
expect(danger).toBe(1)
|
|
193
|
+
expect(output).toContain('the withheld tool ran')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('treats an empty list as "nothing", not as "no restriction"', async () => {
|
|
197
|
+
// The fail-open reading this codebase has already been bitten by once,
|
|
198
|
+
// in the delegate roster: an empty allow-list IS the answer, and
|
|
199
|
+
// degrading it to "unrestricted" is how a closed list becomes open.
|
|
200
|
+
const { output } = await run({ names: 'danger', activeTools: [] })
|
|
201
|
+
|
|
202
|
+
expect(danger).toBe(0)
|
|
203
|
+
expect(output).toContain('(none)')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -205,6 +205,8 @@ export class ToolExecutor {
|
|
|
205
205
|
private workingStateManager?: WorkingStateManager
|
|
206
206
|
private probes: ProbeRegistry
|
|
207
207
|
private parentSpan?: Span
|
|
208
|
+
/** Set per turn by the orchestrator; see {@link setStepAllowedTools}. */
|
|
209
|
+
private stepAllowedTools?: readonly string[]
|
|
208
210
|
private readonly readPaths: Set<string> = new Set()
|
|
209
211
|
private readonly readFingerprints: Map<string, string> = new Map()
|
|
210
212
|
private readonly fileReadTracker: FileReadTracker = {
|
|
@@ -251,6 +253,21 @@ export class ToolExecutor {
|
|
|
251
253
|
this.parentSpan = span
|
|
252
254
|
}
|
|
253
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Narrow what this turn may call, or clear the narrowing.
|
|
258
|
+
*
|
|
259
|
+
* Re-set each turn by the orchestrator for the same reason the parent span
|
|
260
|
+
* is: `prepareStep` can hand a different list to every step, and the run's
|
|
261
|
+
* own `allowedTools` is only the default when a step names none.
|
|
262
|
+
*
|
|
263
|
+
* Without this the executor could only ever see the RUN-level list, so a
|
|
264
|
+
* per-step narrowing reached the request that was sent and nothing else —
|
|
265
|
+
* the model was shown fewer tools and could still call all of them.
|
|
266
|
+
*/
|
|
267
|
+
setStepAllowedTools(names: readonly string[] | undefined): void {
|
|
268
|
+
this.stepAllowedTools = names
|
|
269
|
+
}
|
|
270
|
+
|
|
254
271
|
/**
|
|
255
272
|
* Answer every `tool_use` block in `response` with exactly one
|
|
256
273
|
* `tool_result`.
|
|
@@ -424,7 +441,10 @@ export class ToolExecutor {
|
|
|
424
441
|
},
|
|
425
442
|
invocationState: this.config.invocationState,
|
|
426
443
|
toolRegistry: this.config.tools,
|
|
427
|
-
|
|
444
|
+
// The step's list wins where it has one; the run's is the default.
|
|
445
|
+
// Same precedence the request already uses when it decides which
|
|
446
|
+
// schemas to send, so the menu and the kitchen agree.
|
|
447
|
+
allowedTools: this.stepAllowedTools ?? this.config.allowedTools,
|
|
428
448
|
sandbox: this.config.sandbox,
|
|
429
449
|
fileReadTracker: this.fileReadTracker,
|
|
430
450
|
...(this.parentSpan ? { parentSpan: this.parentSpan } : {}),
|
|
@@ -410,6 +410,16 @@ export interface QueryParams {
|
|
|
410
410
|
|
|
411
411
|
taskGateway?: import('../../types/agent/gateway.js').TaskGateway
|
|
412
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Where a worker completion goes when no tool call is waiting for it.
|
|
415
|
+
*
|
|
416
|
+
* Supplied by whoever built the coordinator tools, because the tools and
|
|
417
|
+
* this loop have to share one inbox: the tools claim what they deliver,
|
|
418
|
+
* and the loop delivers what is left. Omitted, the loop drains nothing and
|
|
419
|
+
* the behaviour is exactly what it was before the inbox existed.
|
|
420
|
+
*/
|
|
421
|
+
completionInbox?: import('../../gateway/completion-inbox.js').CompletionInbox
|
|
422
|
+
|
|
413
423
|
launchedTasks?: Map<
|
|
414
424
|
import('../../types/ids/index.js').TaskId,
|
|
415
425
|
import('./iteration/phases/context.js').LaunchedTaskMeta
|
|
@@ -842,6 +852,7 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
|
|
|
842
852
|
checkpointMgr,
|
|
843
853
|
planManager: ctx.planManager,
|
|
844
854
|
taskGateway: params.taskGateway,
|
|
855
|
+
completionInbox: params.completionInbox,
|
|
845
856
|
taskStore: params.taskStore,
|
|
846
857
|
launchedTasks: params.launchedTasks ?? new Map(),
|
|
847
858
|
// Run-scoped. An approval is a statement about this run's work;
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEFAULT_STRUCTURED_OUTPUT_RETRIES,
|
|
6
6
|
STRUCTURED_OUTPUT_REPROMPT,
|
|
7
7
|
} from '../../../constants/tools/index.js'
|
|
8
|
+
import { formatCompletionNotification } from '../../../gateway/completion-inbox.js'
|
|
8
9
|
import { renderSkillsSection } from '../../../persona/assembler.js'
|
|
9
10
|
import { collect } from '../../../provider/collect.js'
|
|
10
11
|
import {
|
|
@@ -61,6 +62,16 @@ export type { ToolReviewOutcome } from './phases/index.js'
|
|
|
61
62
|
*/
|
|
62
63
|
const DEFAULT_ANSWER_REVIEW_LIMIT = 3
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* How long a finishing run waits for a background worker it launched.
|
|
67
|
+
*
|
|
68
|
+
* Long enough to be worth having — a delegated worker doing real work takes
|
|
69
|
+
* minutes — and bounded because a worker that never finishes must not hold
|
|
70
|
+
* the run open forever. `maxIterations` bounds how many times this can
|
|
71
|
+
* happen, and the run's own timeout bounds the whole thing regardless.
|
|
72
|
+
*/
|
|
73
|
+
const BACKGROUND_TASK_GRACE_MS = 120_000
|
|
74
|
+
|
|
64
75
|
export class IterationOrchestrator {
|
|
65
76
|
private ctx: IterationContext
|
|
66
77
|
/** Rejections so far. See {@link DEFAULT_ANSWER_REVIEW_LIMIT}. */
|
|
@@ -207,7 +218,13 @@ export class IterationOrchestrator {
|
|
|
207
218
|
// supplied no hook.
|
|
208
219
|
const step = await this.prepareStep(iterationNum)
|
|
209
220
|
|
|
210
|
-
const
|
|
221
|
+
const stepAllowedTools = step.allowedTools ?? this.ctx.allowedTools
|
|
222
|
+
const llmTools = this.ctx.tools.toLLMTools(stepAllowedTools)
|
|
223
|
+
// The same list the request was built from now also bounds what
|
|
224
|
+
// may run. Narrowing only the request left the restriction
|
|
225
|
+
// presentational — the model was shown fewer tools and could
|
|
226
|
+
// still call any of them by name.
|
|
227
|
+
this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools)
|
|
211
228
|
const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools)
|
|
212
229
|
const stepModel = step.model ?? model
|
|
213
230
|
|
|
@@ -551,6 +568,36 @@ export class IterationOrchestrator {
|
|
|
551
568
|
}
|
|
552
569
|
}
|
|
553
570
|
|
|
571
|
+
// A background worker is still out there, and this turn was
|
|
572
|
+
// about to end the run.
|
|
573
|
+
//
|
|
574
|
+
// Settling here would throw away the very thing the launch
|
|
575
|
+
// existed to produce: the supervisor said "launched", the
|
|
576
|
+
// worker had not finished, and the run closed over it. So
|
|
577
|
+
// the run is held open — bounded by the deadline below and
|
|
578
|
+
// by `maxIterations` above, so a worker that never finishes
|
|
579
|
+
// cannot keep it open forever — and the completion arrives
|
|
580
|
+
// as a notification the next turn reads.
|
|
581
|
+
if (!forceFinalize && this.ctx.completionInbox?.hasPendingWork) {
|
|
582
|
+
this.ctx.log.info('Holding the run open for a background task', {
|
|
583
|
+
runId: runMgr.id,
|
|
584
|
+
iteration: iterationNum,
|
|
585
|
+
})
|
|
586
|
+
await this.ctx.completionInbox.waitForArrival(BACKGROUND_TASK_GRACE_MS)
|
|
587
|
+
const arrived = this.ctx.completionInbox.drain()
|
|
588
|
+
if (arrived.length > 0) {
|
|
589
|
+
runMgr.pushMessage(createUserMessage(formatCompletionNotification(arrived)))
|
|
590
|
+
await this.ctx.emitEvent({
|
|
591
|
+
type: 'iteration_completed',
|
|
592
|
+
runId: runMgr.id,
|
|
593
|
+
iteration: iterationNum,
|
|
594
|
+
hasToolCalls: false,
|
|
595
|
+
})
|
|
596
|
+
yield* this.ctx.drainPending()
|
|
597
|
+
continue
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
554
601
|
if (!hasContent && !forceFinalize) {
|
|
555
602
|
this.ctx.log.warn('Empty completion detected — requesting final summary', {
|
|
556
603
|
iteration: iterationNum,
|
|
@@ -674,6 +721,35 @@ export class IterationOrchestrator {
|
|
|
674
721
|
return
|
|
675
722
|
}
|
|
676
723
|
|
|
724
|
+
// Workers that finished with nobody listening.
|
|
725
|
+
//
|
|
726
|
+
// A completion normally reaches the supervisor as the
|
|
727
|
+
// `tool_result` of the `create_task` that launched it. Two
|
|
728
|
+
// cases have no such call: a launch made in the background on
|
|
729
|
+
// purpose, and a blocking launch whose deadline passed — the
|
|
730
|
+
// model was told "timed out, it may still be running" and the
|
|
731
|
+
// worker then finished, holding a result nothing would read.
|
|
732
|
+
//
|
|
733
|
+
// This is the channel that was removed in `dc16d58` because it
|
|
734
|
+
// double-delivered: it fired for completions the blocking tool
|
|
735
|
+
// had already handed over, so the supervisor saw each result
|
|
736
|
+
// twice. The inbox restores it with the distinction that was
|
|
737
|
+
// missing — a tool that delivers a completion claims it, and
|
|
738
|
+
// only unclaimed ones arrive here.
|
|
739
|
+
//
|
|
740
|
+
// Placed beside the advisory phase deliberately: that is the
|
|
741
|
+
// established seam for putting a user message in after tool
|
|
742
|
+
// results and before the next turn.
|
|
743
|
+
const unheard = this.ctx.completionInbox?.drain() ?? []
|
|
744
|
+
if (unheard.length > 0) {
|
|
745
|
+
this.ctx.log.info('Delivering unawaited task completions', {
|
|
746
|
+
runId: runMgr.id,
|
|
747
|
+
iteration: iterationNum,
|
|
748
|
+
tasks: unheard.map((h) => h.taskId),
|
|
749
|
+
})
|
|
750
|
+
runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)))
|
|
751
|
+
}
|
|
752
|
+
|
|
677
753
|
await runAdvisoryPhase(this.ctx, iterationNum, response)
|
|
678
754
|
|
|
679
755
|
if (this.ctx.pluginManager) {
|
|
@@ -3,6 +3,7 @@ import type { AgentBus } from '../../../../bus/index.js'
|
|
|
3
3
|
import type { WorkingStateManager } from '../../../../compaction/manager.js'
|
|
4
4
|
import type { ContextReducer } from '../../../../compaction/reducer.js'
|
|
5
5
|
import type { CompactionConfig } from '../../../../config/runtime.js'
|
|
6
|
+
import type { CompletionInbox } from '../../../../gateway/completion-inbox.js'
|
|
6
7
|
import type { PlanManager } from '../../../../manager/plan/lifecycle.js'
|
|
7
8
|
import type { RunPersistence } from '../../../../manager/run/persistence.js'
|
|
8
9
|
import type { ActivityStore } from '../../../../store/activity/memory.js'
|
|
@@ -103,6 +104,15 @@ export interface IterationContext {
|
|
|
103
104
|
|
|
104
105
|
readonly taskGateway?: TaskGateway
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Completions no call is waiting for, on their way to the transcript.
|
|
109
|
+
*
|
|
110
|
+
* Absent means the loop behaves exactly as it did before this existed:
|
|
111
|
+
* a blocking `create_task` still delivers its own result, and a
|
|
112
|
+
* completion nobody awaited is simply never mentioned.
|
|
113
|
+
*/
|
|
114
|
+
readonly completionInbox?: CompletionInbox
|
|
115
|
+
|
|
106
116
|
readonly taskStore?: TaskStore
|
|
107
117
|
|
|
108
118
|
readonly launchedTasks: Map<TaskId, LaunchedTaskMeta>
|