@namzu/sdk 3.3.0 → 4.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 +250 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +52 -2
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.d.ts +2 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.d.ts.map +1 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +122 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -0
- package/dist/connector/mcp/__tests__/prompt-as-tool.test.js +1 -1
- package/dist/connector/mcp/__tests__/prompt-as-tool.test.js.map +1 -1
- package/dist/connector/mcp/prompt-adapter.d.ts.map +1 -1
- package/dist/connector/mcp/prompt-adapter.js +16 -9
- package/dist/connector/mcp/prompt-adapter.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 +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +2 -0
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +3 -0
- package/dist/public-tools.js.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/registry/tool/execute.d.ts +15 -0
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +19 -0
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/sandbox/__tests__/exec-cancellation.test.d.ts +2 -0
- package/dist/sandbox/__tests__/exec-cancellation.test.d.ts.map +1 -0
- package/dist/sandbox/__tests__/exec-cancellation.test.js +59 -0
- package/dist/sandbox/__tests__/exec-cancellation.test.js.map +1 -0
- package/dist/sandbox/provider/local.d.ts.map +1 -1
- package/dist/sandbox/provider/local.js +30 -5
- package/dist/sandbox/provider/local.js.map +1 -1
- package/dist/session/workspace/shared-run.d.ts +31 -0
- package/dist/session/workspace/shared-run.d.ts.map +1 -1
- package/dist/session/workspace/shared-run.js +31 -0
- package/dist/session/workspace/shared-run.js.map +1 -1
- package/dist/tools/__tests__/file-tool-containment.test.d.ts +2 -0
- package/dist/tools/__tests__/file-tool-containment.test.d.ts.map +1 -0
- package/dist/tools/__tests__/file-tool-containment.test.js +134 -0
- package/dist/tools/__tests__/file-tool-containment.test.js.map +1 -0
- package/dist/tools/__tests__/sandboxed-search.test.js +51 -0
- package/dist/tools/__tests__/sandboxed-search.test.js.map +1 -1
- package/dist/tools/__tests__/untrusted-envelope.test.d.ts +2 -0
- package/dist/tools/__tests__/untrusted-envelope.test.d.ts.map +1 -0
- package/dist/tools/__tests__/untrusted-envelope.test.js +55 -0
- package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -0
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +114 -0
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -0
- package/dist/tools/builtins/__tests__/edit.test.js +9 -2
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/write-file.test.js +59 -0
- package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +66 -14
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/ls.d.ts +1 -1
- package/dist/tools/builtins/ls.d.ts.map +1 -1
- package/dist/tools/builtins/ls.js +95 -0
- package/dist/tools/builtins/ls.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +3 -2
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/write-file.d.ts.map +1 -1
- package/dist/tools/builtins/write-file.js +26 -12
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/coordinator/__tests__/agent.test.js +6 -1
- package/dist/tools/coordinator/__tests__/agent.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js +47 -0
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/empty-roster.test.js +97 -0
- package/dist/tools/coordinator/__tests__/empty-roster.test.js.map +1 -0
- package/dist/tools/coordinator/agent.d.ts +22 -8
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +45 -2
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +80 -3
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/paths.d.ts +40 -0
- package/dist/tools/paths.d.ts.map +1 -1
- package/dist/tools/paths.js +70 -1
- package/dist/tools/paths.js.map +1 -1
- package/dist/tools/untrusted-envelope.d.ts +55 -0
- package/dist/tools/untrusted-envelope.d.ts.map +1 -0
- package/dist/tools/untrusted-envelope.js +65 -0
- package/dist/tools/untrusted-envelope.js.map +1 -0
- package/dist/types/sandbox/index.d.ts +10 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/toolset/index.d.ts +0 -24
- package/dist/types/toolset/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agents/SupervisorAgent.ts +51 -2
- package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +148 -0
- package/src/connector/mcp/__tests__/prompt-as-tool.test.ts +1 -1
- package/src/connector/mcp/prompt-adapter.ts +19 -9
- package/src/public-runtime.ts +1 -0
- package/src/public-tools.ts +4 -0
- package/src/registry/index.ts +1 -1
- package/src/registry/tool/execute.ts +23 -0
- package/src/sandbox/__tests__/exec-cancellation.test.ts +72 -0
- package/src/sandbox/provider/local.ts +30 -4
- package/src/session/workspace/shared-run.ts +31 -0
- package/src/tools/__tests__/file-tool-containment.test.ts +190 -0
- package/src/tools/__tests__/sandboxed-search.test.ts +66 -0
- package/src/tools/__tests__/untrusted-envelope.test.ts +82 -0
- package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +166 -0
- package/src/tools/builtins/__tests__/edit.test.ts +10 -2
- package/src/tools/builtins/__tests__/write-file.test.ts +80 -0
- package/src/tools/builtins/edit.ts +69 -14
- package/src/tools/builtins/ls.ts +102 -0
- package/src/tools/builtins/read-file.ts +3 -2
- package/src/tools/builtins/write-file.ts +26 -12
- package/src/tools/coordinator/__tests__/agent.test.ts +6 -1
- package/src/tools/coordinator/__tests__/ask-user-question.test.ts +58 -0
- package/src/tools/coordinator/__tests__/empty-roster.test.ts +118 -0
- package/src/tools/coordinator/agent.ts +72 -11
- package/src/tools/coordinator/index.ts +86 -3
- package/src/tools/paths.ts +83 -1
- package/src/tools/untrusted-envelope.ts +78 -0
- package/src/types/sandbox/index.ts +10 -0
- package/src/types/toolset/index.ts +0 -25
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EMPTY_TOKEN_USAGE } from '../constants/limits.js'
|
|
2
2
|
import { LocalTaskGateway } from '../gateway/local.js'
|
|
3
|
-
import { ToolRegistry } from '../registry/tool/execute.js'
|
|
3
|
+
import { ToolNameCollisionError, ToolRegistry } from '../registry/tool/execute.js'
|
|
4
4
|
import { drainQuery } from '../runtime/query/index.js'
|
|
5
5
|
import type { LaunchedTaskMeta } from '../runtime/query/iteration/phases/context.js'
|
|
6
6
|
import { PendingAnswers, QuestionParkBinding } from '../runtime/query/question-park.js'
|
|
@@ -198,8 +198,57 @@ export class SupervisorAgent extends AbstractAgent<SupervisorAgentConfig, Superv
|
|
|
198
198
|
tools.register(tool, config.tools.getAvailability(tool.name))
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
+
// Registered the way every other kernel-mounted tool in this SDK is
|
|
202
|
+
// registered: honouring `runtimeToolOverrides`, and refusing to take a
|
|
203
|
+
// name the host already used.
|
|
204
|
+
//
|
|
205
|
+
// Both halves were missing here and nowhere else. `runtimeToolOverrides`
|
|
206
|
+
// is declared on `AgentInput`, is forwarded into this very `drainQuery`
|
|
207
|
+
// call below, and is consulted for the task tools and for the advisory
|
|
208
|
+
// tools — but the coordinator tools were registered before that and
|
|
209
|
+
// unconditionally, so `{ create_task: 'disabled' }` was honoured
|
|
210
|
+
// everywhere except the one surface a host would most want to decline.
|
|
211
|
+
// A run that must not delegate had prompt text and a gateway refusal as
|
|
212
|
+
// its only defences.
|
|
213
|
+
//
|
|
214
|
+
// Collision REFUSES rather than overwrites, and the principle is
|
|
215
|
+
// complete mediation rather than fail-safe defaults: "proposals to gain
|
|
216
|
+
// performance by remembering the result of an authority check [must] be
|
|
217
|
+
// examined skeptically. If a change in authority occurs, such remembered
|
|
218
|
+
// results must be systematically updated" (Saltzer & Schroeder 1975,
|
|
219
|
+
// §I.A.3(c)). A registry entry is a remembered binding of a name to an
|
|
220
|
+
// authority, and a later write that rebinds the name leaves every
|
|
221
|
+
// decision made about the old binding stale.
|
|
222
|
+
//
|
|
223
|
+
// The counter-argument is that today the host's tool merely loses
|
|
224
|
+
// quietly and the run still works, so six reserved names is a real cost
|
|
225
|
+
// on a name a consumer may have chosen long ago. It does not hold,
|
|
226
|
+
// because "loses quietly" is not what happens. `registerOne` ends with
|
|
227
|
+
// `availability.set(id, state)` and this call passes no state, so a tool
|
|
228
|
+
// the host registered `deferred` or `suspended` is silently PROMOTED to
|
|
229
|
+
// active under someone else's implementation; and because the store is a
|
|
230
|
+
// Map, the replacement inherits the host's insertion position in the
|
|
231
|
+
// prompt-cache prefix. That is a different authorization surface, not a
|
|
232
|
+
// lost registration. CWE-390 is the shape `ManagedRegistry` has here —
|
|
233
|
+
// detection of an error condition without action — and CWE-694's own
|
|
234
|
+
// mitigation is nearly this fix: do not operate any resource with a
|
|
235
|
+
// non-unique identifier, and report the error.
|
|
236
|
+
//
|
|
237
|
+
// Refusing is also what the peer set does. One runtime's registry
|
|
238
|
+
// primitive throws on both duplicate and reserved names; another refuses
|
|
239
|
+
// its injected delegation name in a pre-flight that tells the author to
|
|
240
|
+
// rename. Closer to home, `ProviderRegistry.register` already throws
|
|
241
|
+
// unless the caller passes `{ replace: true }` — declared intent is what
|
|
242
|
+
// separates a legitimate replacement from an accidental one, and no such
|
|
243
|
+
// intent is expressible here.
|
|
244
|
+
const overrides = input.runtimeToolOverrides
|
|
201
245
|
for (const tool of coordinatorToolDefs) {
|
|
202
|
-
|
|
246
|
+
const override = overrides?.[tool.name]
|
|
247
|
+
if (override === 'disabled') continue
|
|
248
|
+
if (config.tools?.has(tool.name)) {
|
|
249
|
+
throw new ToolNameCollisionError(tool.name, 'the supervisor coordinator surface')
|
|
250
|
+
}
|
|
251
|
+
tools.register(tool, override ?? 'active')
|
|
203
252
|
}
|
|
204
253
|
|
|
205
254
|
const childInvocationState = deriveChildState(
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdtemp } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
import { MockLLMProvider } from '../../provider/mock.js'
|
|
8
|
+
import { ToolNameCollisionError, ToolRegistry } from '../../registry/tool/execute.js'
|
|
9
|
+
import { defineTool } from '../../tools/defineTool.js'
|
|
10
|
+
import { SupervisorAgent } from '../SupervisorAgent.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How the supervisor mounts its own coordinator tools.
|
|
14
|
+
*
|
|
15
|
+
* `runtimeToolOverrides` is this SDK's declared way for a host to decline a
|
|
16
|
+
* kernel-mounted tool. It is honoured for the task tools and the advisory
|
|
17
|
+
* tools inside `drainQuery`, and the supervisor forwards it there — but the
|
|
18
|
+
* supervisor registered the coordinator tools BEFORE that call and
|
|
19
|
+
* unconditionally, so `{ create_task: 'disabled' }` was obeyed everywhere
|
|
20
|
+
* except the one surface a host would most want to decline.
|
|
21
|
+
*
|
|
22
|
+
* The second case is the collision: `ManagedRegistry` warns and overwrites, so
|
|
23
|
+
* a host tool sharing a coordinator name vanished into a log line and the
|
|
24
|
+
* model kept a `create_task` whose behaviour depended on registration order.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const HOST_TOOL_DESCRIPTION = 'a tool this host registered deliberately'
|
|
28
|
+
|
|
29
|
+
function stubManager() {
|
|
30
|
+
return {
|
|
31
|
+
sendMessage: vi.fn(async () => ({ taskId: 'task_1', status: 'completed' })),
|
|
32
|
+
await: vi.fn(async () => undefined),
|
|
33
|
+
cancel: vi.fn(),
|
|
34
|
+
dispose: vi.fn(),
|
|
35
|
+
on: vi.fn(),
|
|
36
|
+
off: vi.fn(),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hostTool = (name: string) =>
|
|
41
|
+
defineTool({
|
|
42
|
+
name,
|
|
43
|
+
description: HOST_TOOL_DESCRIPTION,
|
|
44
|
+
inputSchema: z.object({}),
|
|
45
|
+
category: 'custom',
|
|
46
|
+
permissions: [],
|
|
47
|
+
readOnly: true,
|
|
48
|
+
destructive: false,
|
|
49
|
+
concurrencySafe: true,
|
|
50
|
+
async execute() {
|
|
51
|
+
return { success: true as const, output: 'host tool ran' }
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
async function runWith(options: {
|
|
56
|
+
hostTools?: string[]
|
|
57
|
+
runtimeToolOverrides?: Record<string, 'active' | 'deferred' | 'disabled'>
|
|
58
|
+
}) {
|
|
59
|
+
const agent = new SupervisorAgent({
|
|
60
|
+
id: 'supervisor',
|
|
61
|
+
name: 'Supervisor',
|
|
62
|
+
version: '1',
|
|
63
|
+
category: 'test',
|
|
64
|
+
description: 'coordinates workers',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const provider = new MockLLMProvider({ turns: [{ text: 'nothing to delegate' }] })
|
|
68
|
+
|
|
69
|
+
const tools = new ToolRegistry()
|
|
70
|
+
for (const name of options.hostTools ?? []) tools.register(hostTool(name))
|
|
71
|
+
|
|
72
|
+
await agent.run(
|
|
73
|
+
{
|
|
74
|
+
messages: [{ role: 'user', content: 'go', timestamp: 1 }],
|
|
75
|
+
workingDirectory: await mkdtemp(join(tmpdir(), 'namzu-sup-reg-')),
|
|
76
|
+
...(options.runtimeToolOverrides
|
|
77
|
+
? { runtimeToolOverrides: options.runtimeToolOverrides }
|
|
78
|
+
: {}),
|
|
79
|
+
} as never,
|
|
80
|
+
{
|
|
81
|
+
provider,
|
|
82
|
+
agentIds: ['worker'],
|
|
83
|
+
agentManager: stubManager(),
|
|
84
|
+
tools,
|
|
85
|
+
systemPrompt: 'You coordinate.',
|
|
86
|
+
model: 'mock-model',
|
|
87
|
+
tokenBudget: 100_000,
|
|
88
|
+
timeoutMs: 30_000,
|
|
89
|
+
maxIterations: 2,
|
|
90
|
+
sessionId: 'ses_sup',
|
|
91
|
+
threadId: 'thd_sup',
|
|
92
|
+
projectId: 'prj_sup',
|
|
93
|
+
tenantId: 'tnt_sup',
|
|
94
|
+
} as never,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const advertised = provider.requests[0]?.tools ?? []
|
|
98
|
+
return {
|
|
99
|
+
names: new Set(advertised.map((t) => t.function.name)),
|
|
100
|
+
describedAs: (name: string) =>
|
|
101
|
+
advertised.find((t) => t.function.name === name)?.function.description ?? '',
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('supervisor coordinator-tool registration', () => {
|
|
106
|
+
it('advertises create_task by default', async () => {
|
|
107
|
+
expect((await runWith({})).names).toContain('create_task')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not advertise a coordinator tool the host disabled', async () => {
|
|
111
|
+
const { names } = await runWith({ runtimeToolOverrides: { create_task: 'disabled' } })
|
|
112
|
+
|
|
113
|
+
expect(names).not.toContain('create_task')
|
|
114
|
+
// Declining one coordinator tool must not decline the rest.
|
|
115
|
+
expect(names).toContain('agent_task_list')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('leaves a host tool that shares no coordinator name alone', async () => {
|
|
119
|
+
const { names } = await runWith({ hostTools: ['host_only'] })
|
|
120
|
+
|
|
121
|
+
expect(names).toContain('host_only')
|
|
122
|
+
expect(names).toContain('create_task')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('refuses to take a name the host already registered', async () => {
|
|
126
|
+
// Named and carrying the name, so a host can catch it narrowly rather
|
|
127
|
+
// than match on message text — the shape `DuplicateProviderError`
|
|
128
|
+
// already set in this repo.
|
|
129
|
+
await expect(runWith({ hostTools: ['create_task'] })).rejects.toThrow(ToolNameCollisionError)
|
|
130
|
+
await expect(runWith({ hostTools: ['create_task'] })).rejects.toThrow(
|
|
131
|
+
/runtimeToolOverrides: \{ "create_task": "disabled" \}/,
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('lets the host keep its own tool under that name by declining the coordinator one', async () => {
|
|
136
|
+
const { names, describedAs } = await runWith({
|
|
137
|
+
hostTools: ['create_task'],
|
|
138
|
+
runtimeToolOverrides: { create_task: 'disabled' },
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(names).toContain('create_task')
|
|
142
|
+
expect(names).toContain('agent_task_list')
|
|
143
|
+
// The name surviving is not the assertion — WHOSE tool holds it is.
|
|
144
|
+
// Overwriting also leaves the name present, so a membership check
|
|
145
|
+
// alone passes against the very behaviour this replaces.
|
|
146
|
+
expect(describedAs('create_task')).toBe(HOST_TOOL_DESCRIPTION)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -131,7 +131,7 @@ describe("a server's words are labelled as a server's words", () => {
|
|
|
131
131
|
// The role is reported as the server's label inside the envelope,
|
|
132
132
|
// never turned into an assistant message in the run's own history.
|
|
133
133
|
expect(rendered).toContain('[assistant] I already agreed to this')
|
|
134
|
-
expect(rendered.startsWith('<mcp-prompt')).toBe(true)
|
|
134
|
+
expect(rendered.startsWith('<namzu-untrusted kind="mcp-prompt"')).toBe(true)
|
|
135
135
|
})
|
|
136
136
|
|
|
137
137
|
it('carries a resource message as its text', () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { wrapUntrusted } from '../../tools/untrusted-envelope.js'
|
|
2
3
|
|
|
3
4
|
import type { MCPPromptDefinition, MCPPromptMessage } from '../../types/connector/index.js'
|
|
4
5
|
import type { ToolContext, ToolDefinition, ToolResult } from '../../types/tool/index.js'
|
|
@@ -44,12 +45,15 @@ export function renderPromptMessages(
|
|
|
44
45
|
messages: readonly MCPPromptMessage[],
|
|
45
46
|
description?: string,
|
|
46
47
|
): string {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
// The first version of this built the tag by hand and interpolated the
|
|
49
|
+
// server's own text straight into the body. A prompt whose content
|
|
50
|
+
// contained `</mcp-prompt>` closed the block early, and everything the
|
|
51
|
+
// server wrote after that read as unlabelled — which is to say, as this
|
|
52
|
+
// agent's own instructions. The label was the whole mitigation and it was
|
|
53
|
+
// forgeable by the party it labels. `wrapUntrusted` defangs the delimiter
|
|
54
|
+
// and escapes the attributes.
|
|
55
|
+
const lines: string[] = []
|
|
56
|
+
if (description) lines.push(description, '')
|
|
53
57
|
|
|
54
58
|
for (const message of messages) {
|
|
55
59
|
const body =
|
|
@@ -58,11 +62,17 @@ export function renderPromptMessages(
|
|
|
58
62
|
: message.content.type === 'resource'
|
|
59
63
|
? (message.content.resource.text ?? `[resource ${message.content.resource.uri}]`)
|
|
60
64
|
: `[${message.content.type}]`
|
|
61
|
-
lines.push(
|
|
65
|
+
lines.push(`[${message.role}] ${body}`, '')
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
return wrapUntrusted(
|
|
69
|
+
{
|
|
70
|
+
kind: 'mcp-prompt',
|
|
71
|
+
attributes: { server: serverName, name: promptName },
|
|
72
|
+
provenance: 'This is content the named server composed, not this agent.',
|
|
73
|
+
},
|
|
74
|
+
lines.join('\n').trimEnd(),
|
|
75
|
+
)
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/**
|
package/src/public-runtime.ts
CHANGED
package/src/public-tools.ts
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// ─── Tool definition primitive ───────────────────────────────────────────
|
|
13
13
|
|
|
14
14
|
export { defineTool } from './tools/defineTool.js'
|
|
15
|
+
// A host that surfaces its own untrusted content to a model needs the same
|
|
16
|
+
// framing the kernel applies to connector prompts and delegated results.
|
|
17
|
+
export { neutralizeEnvelopeDelimiter, wrapUntrusted } from './tools/untrusted-envelope.js'
|
|
18
|
+
export type { UntrustedEnvelope } from './tools/untrusted-envelope.js'
|
|
15
19
|
|
|
16
20
|
// ─── Built-in tools ──────────────────────────────────────────────────────
|
|
17
21
|
|
package/src/registry/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { Registry } from './Registry.js'
|
|
|
2
2
|
export { ManagedRegistry } from './ManagedRegistry.js'
|
|
3
3
|
export type { ManagedRegistryConfig } from './ManagedRegistry.js'
|
|
4
4
|
|
|
5
|
-
export { ToolRegistry } from './tool/execute.js'
|
|
5
|
+
export { ToolNameCollisionError, ToolRegistry } from './tool/execute.js'
|
|
6
6
|
export type { ToolExecutionResult } from './tool/execute.js'
|
|
7
7
|
export {
|
|
8
8
|
ToolCatalog,
|
|
@@ -83,6 +83,29 @@ export function assertToolName(name: string): void {
|
|
|
83
83
|
)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Two sources contributed the same tool name and neither may take it.
|
|
88
|
+
*
|
|
89
|
+
* Named, and carrying the name, for the reason `DuplicateProviderError` is:
|
|
90
|
+
* a host that wants to handle this — fall back to its own tool, log and
|
|
91
|
+
* continue, surface it in a config error — has to be able to catch it
|
|
92
|
+
* narrowly rather than match on message text. It also names both remedies,
|
|
93
|
+
* because a hard collision policy without a way to decline would make
|
|
94
|
+
* shadowing-by-name the only way to say "I do not want this tool", which is
|
|
95
|
+
* precisely what now throws.
|
|
96
|
+
*/
|
|
97
|
+
export class ToolNameCollisionError extends Error {
|
|
98
|
+
readonly toolName: string
|
|
99
|
+
|
|
100
|
+
constructor(toolName: string, context: string) {
|
|
101
|
+
super(
|
|
102
|
+
`Tool name "${toolName}" is already registered by this host, and ${context} will not replace it. Rename the host tool, or decline the one being mounted with runtimeToolOverrides: { "${toolName}": "disabled" }.`,
|
|
103
|
+
)
|
|
104
|
+
this.name = 'ToolNameCollisionError'
|
|
105
|
+
this.toolName = toolName
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
/**
|
|
87
110
|
* Append a tool's declared return shape to its description.
|
|
88
111
|
*
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { getRootLogger } from '../../utils/logger.js'
|
|
4
|
+
import { LocalSandboxProvider } from '../provider/local.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `SandboxExecOptions.signal` was declared, documented, exported — and
|
|
8
|
+
* dropped by every backend. The local one built a fresh `AbortController`
|
|
9
|
+
* from the call's own timeout and never linked the caller's signal to it, so
|
|
10
|
+
* cancelling a run abandoned the *wait* while the sandboxed process kept
|
|
11
|
+
* running. That is verbatim the failure the option's docstring says it exists
|
|
12
|
+
* to prevent.
|
|
13
|
+
*
|
|
14
|
+
* These drive the real provider rather than a stub, because the defect was
|
|
15
|
+
* precisely in the wiring between the option and `spawn` — a stub asserting
|
|
16
|
+
* "the signal was passed along" would have passed against the broken code.
|
|
17
|
+
*/
|
|
18
|
+
describe('a sandboxed command honours the caller cancellation', () => {
|
|
19
|
+
it('kills a long-running process when the caller aborts', async () => {
|
|
20
|
+
const provider = new LocalSandboxProvider(getRootLogger())
|
|
21
|
+
const sandbox = await provider.create()
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
|
|
24
|
+
// Long enough that only the abort can end it: the deadline is 60s and
|
|
25
|
+
// the sleep is 30s, so a pass cannot come from either firing.
|
|
26
|
+
const running = sandbox.exec('node', ['-e', 'setTimeout(() => {}, 30000)'], {
|
|
27
|
+
timeout: 60_000,
|
|
28
|
+
signal: controller.signal,
|
|
29
|
+
})
|
|
30
|
+
setTimeout(() => controller.abort(), 50)
|
|
31
|
+
|
|
32
|
+
const result = await running
|
|
33
|
+
|
|
34
|
+
expect(result.exitCode).not.toBe(0)
|
|
35
|
+
// Cancelled is not late. Reporting a timeout here would tell the model
|
|
36
|
+
// to retry with a longer budget for something a human just stopped.
|
|
37
|
+
expect(result.timedOut).toBe(false)
|
|
38
|
+
expect(result.durationMs).toBeLessThan(20_000)
|
|
39
|
+
|
|
40
|
+
await sandbox.destroy()
|
|
41
|
+
}, 30_000)
|
|
42
|
+
|
|
43
|
+
it('still reports a deadline as a timeout when no caller signal is passed', async () => {
|
|
44
|
+
const provider = new LocalSandboxProvider(getRootLogger())
|
|
45
|
+
const sandbox = await provider.create()
|
|
46
|
+
|
|
47
|
+
const result = await sandbox.exec('node', ['-e', 'setTimeout(() => {}, 30000)'], {
|
|
48
|
+
timeout: 300,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(result.timedOut).toBe(true)
|
|
52
|
+
|
|
53
|
+
await sandbox.destroy()
|
|
54
|
+
}, 30_000)
|
|
55
|
+
|
|
56
|
+
it('leaves an uncancelled command alone', async () => {
|
|
57
|
+
const provider = new LocalSandboxProvider(getRootLogger())
|
|
58
|
+
const sandbox = await provider.create()
|
|
59
|
+
const controller = new AbortController()
|
|
60
|
+
|
|
61
|
+
const result = await sandbox.exec('node', ['-e', 'console.log("done")'], {
|
|
62
|
+
timeout: 30_000,
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result.exitCode).toBe(0)
|
|
67
|
+
expect(result.stdout).toContain('done')
|
|
68
|
+
expect(result.timedOut).toBe(false)
|
|
69
|
+
|
|
70
|
+
await sandbox.destroy()
|
|
71
|
+
}, 30_000)
|
|
72
|
+
})
|
|
@@ -384,11 +384,23 @@ class LocalSandbox implements Sandbox {
|
|
|
384
384
|
|
|
385
385
|
this.log.debug('Executing command', { command, args, timeout, environment: this.environment })
|
|
386
386
|
|
|
387
|
+
// The caller's cancellation and this call's own deadline both have to
|
|
388
|
+
// reach `spawn`, and `spawn` takes exactly one signal.
|
|
389
|
+
//
|
|
390
|
+
// Only the deadline used to. `SandboxExecOptions.signal` is declared,
|
|
391
|
+
// documented, and exported, and every backend dropped it — so a Stop
|
|
392
|
+
// abandoned the *wait* and left the sandboxed process running, which is
|
|
393
|
+
// verbatim the failure the option's own docstring says it exists to
|
|
394
|
+
// prevent. `AbortSignal.any` is what makes both reach the child: it
|
|
395
|
+
// aborts as soon as either does, and — unlike an `addEventListener`
|
|
396
|
+
// bridge — it does not retain a listener on the caller's long-lived
|
|
397
|
+
// signal after this call settles.
|
|
387
398
|
const ac = new AbortController()
|
|
388
399
|
const timeoutId = setTimeout(() => ac.abort(), timeout)
|
|
400
|
+
const spawnSignal = opts?.signal ? AbortSignal.any([ac.signal, opts.signal]) : ac.signal
|
|
389
401
|
|
|
390
402
|
try {
|
|
391
|
-
const result = await this.spawnProcess(spawnCommand, spawnArgs, cwd, env, ac)
|
|
403
|
+
const result = await this.spawnProcess(spawnCommand, spawnArgs, cwd, env, ac, spawnSignal)
|
|
392
404
|
return { ...result, durationMs: Date.now() - startTime }
|
|
393
405
|
} finally {
|
|
394
406
|
clearTimeout(timeoutId)
|
|
@@ -484,12 +496,22 @@ class LocalSandbox implements Sandbox {
|
|
|
484
496
|
})
|
|
485
497
|
}
|
|
486
498
|
|
|
499
|
+
/**
|
|
500
|
+
* @param ac this call's own deadline — still the thing that decides
|
|
501
|
+
* whether a termination is reported as `timedOut`.
|
|
502
|
+
* @param signal what actually reaches `spawn`: the deadline, merged with
|
|
503
|
+
* the caller's cancellation when one was passed. Two
|
|
504
|
+
* parameters because they answer different questions —
|
|
505
|
+
* "should this process die" and "did it die because it ran
|
|
506
|
+
* too long" — and a cancelled run did not time out.
|
|
507
|
+
*/
|
|
487
508
|
private spawnProcess(
|
|
488
509
|
command: string,
|
|
489
510
|
args: string[],
|
|
490
511
|
cwd: string,
|
|
491
512
|
env: Record<string, string>,
|
|
492
513
|
ac: AbortController,
|
|
514
|
+
signal: AbortSignal = ac.signal,
|
|
493
515
|
): Promise<Omit<SandboxExecResult, 'durationMs'>> {
|
|
494
516
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
495
517
|
let child: ReturnType<typeof spawn>
|
|
@@ -498,7 +520,7 @@ class LocalSandbox implements Sandbox {
|
|
|
498
520
|
cwd,
|
|
499
521
|
env,
|
|
500
522
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
501
|
-
signal
|
|
523
|
+
signal,
|
|
502
524
|
})
|
|
503
525
|
} catch (err) {
|
|
504
526
|
rejectPromise(err)
|
|
@@ -513,8 +535,12 @@ class LocalSandbox implements Sandbox {
|
|
|
513
535
|
child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
514
536
|
|
|
515
537
|
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
516
|
-
if (err.code === 'ABORT_ERR' ||
|
|
517
|
-
timedOut
|
|
538
|
+
if (err.code === 'ABORT_ERR' || signal.aborted) {
|
|
539
|
+
// `timedOut` means the DEADLINE fired. A caller-cancelled
|
|
540
|
+
// run is aborted but not late, and reporting it as a
|
|
541
|
+
// timeout would tell the model to retry with a longer
|
|
542
|
+
// budget for something a human just stopped.
|
|
543
|
+
timedOut = ac.signal.aborted
|
|
518
544
|
// Give process a grace period, then SIGKILL
|
|
519
545
|
if (child.pid) {
|
|
520
546
|
setTimeout(() => {
|
|
@@ -31,6 +31,37 @@ export interface RegisterSharedRunPlanInput {
|
|
|
31
31
|
status?: SharedRunWorkspacePlan['status']
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A `_work` directory a run's agents share — files, a manifest, a plan and
|
|
36
|
+
* per-agent records — offered to hosts and applied by none of them.
|
|
37
|
+
*
|
|
38
|
+
* **Nothing in this SDK calls it, and that is the design, not an omission.**
|
|
39
|
+
* It was flagged as a primitive exported without a driver, which is a real
|
|
40
|
+
* defect class and the reason most of this module's siblings were wired up.
|
|
41
|
+
* This one is the exception, and the distinguishing question is whose decision
|
|
42
|
+
* the thing encodes.
|
|
43
|
+
*
|
|
44
|
+
* Look at what {@link SharedRunWorkspaceConfig} asks for: `hostRoot`, where
|
|
45
|
+
* the directory lives on the machine running the kernel, and `runtimeRoot`,
|
|
46
|
+
* the path an agent will see — `/mnt/user-data/outputs/_work` under one
|
|
47
|
+
* container layout, the same directory as `hostRoot` in a local run, a bind
|
|
48
|
+
* target somewhere else entirely under a third. Those two roots are a
|
|
49
|
+
* deployment shape. A kernel that picked them would be choosing a filesystem
|
|
50
|
+
* layout for a host that has already chosen one, and would then have to be
|
|
51
|
+
* argued back out of it — the same reason `ToolCatalogSurface` was removed
|
|
52
|
+
* rather than made to work: a host's deployment surfaces are the host's to
|
|
53
|
+
* name.
|
|
54
|
+
*
|
|
55
|
+
* So the contract here is `runtimeRoot` and the paths {@link refs} derives
|
|
56
|
+
* from it. Those strings are what a host puts in a prompt and what an agent's
|
|
57
|
+
* `read` and `write` calls resolve against, which makes them an interface
|
|
58
|
+
* between three parties — and stable for that reason, not incidentally.
|
|
59
|
+
*
|
|
60
|
+
* A host wires it by calling {@link create} with both roots, passing
|
|
61
|
+
* `refs()` into its agents' runtime notes, and reading the manifest back when
|
|
62
|
+
* the run ends. If a future default gateway grows an opinion about where
|
|
63
|
+
* `_work` belongs, this is the thing it should call rather than reimplement.
|
|
64
|
+
*/
|
|
34
65
|
export class SharedRunWorkspace {
|
|
35
66
|
readonly hostRoot: string
|
|
36
67
|
readonly runtimeRoot: string
|