@namzu/sdk 25.0.0 → 26.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 +182 -0
- package/README.md +1 -1
- package/dist/connector/mcp/index.d.ts +1 -0
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +3 -0
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/server-stdio.d.ts +62 -0
- package/dist/connector/mcp/server-stdio.d.ts.map +1 -0
- package/dist/connector/mcp/server-stdio.js +121 -0
- package/dist/connector/mcp/server-stdio.js.map +1 -0
- package/dist/connector/mcp/stdio.d.ts +11 -0
- package/dist/connector/mcp/stdio.d.ts.map +1 -1
- package/dist/connector/mcp/stdio.js +106 -1
- package/dist/connector/mcp/stdio.js.map +1 -1
- package/dist/constants/telemetry/index.d.ts +15 -1
- package/dist/constants/telemetry/index.d.ts.map +1 -1
- package/dist/constants/telemetry/index.js +15 -1
- package/dist/constants/telemetry/index.js.map +1 -1
- package/dist/provider/fallback.d.ts.map +1 -1
- package/dist/provider/fallback.js +8 -2
- package/dist/provider/fallback.js.map +1 -1
- package/dist/provider/retry.d.ts.map +1 -1
- package/dist/provider/retry.js +10 -2
- package/dist/provider/retry.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +12 -0
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/session/workspace/git-worktree.d.ts +15 -0
- package/dist/session/workspace/git-worktree.d.ts.map +1 -1
- package/dist/session/workspace/git-worktree.js +55 -1
- package/dist/session/workspace/git-worktree.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +35 -0
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +12 -0
- 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 +134 -6
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/connector/mcp.d.ts +17 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/provider/interface.d.ts +23 -2
- package/dist/types/provider/interface.d.ts.map +1 -1
- package/dist/types/sandbox/index.d.ts +23 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/package.json +1 -1
- package/src/connector/mcp/index.ts +3 -0
- package/src/connector/mcp/server-stdio.ts +137 -0
- package/src/connector/mcp/stdio.ts +106 -1
- package/src/constants/telemetry/index.ts +16 -1
- package/src/provider/fallback.ts +8 -2
- package/src/provider/retry.ts +10 -2
- package/src/registry/tool/execute.ts +12 -0
- package/src/session/workspace/git-worktree.ts +55 -1
- package/src/tools/builtins/bash.ts +34 -0
- package/src/tools/coordinator/agent.ts +12 -0
- package/src/tools/coordinator/index.ts +144 -8
- package/src/types/connector/mcp.ts +17 -0
- package/src/types/provider/interface.ts +23 -2
- package/src/types/sandbox/index.ts +23 -0
|
@@ -13,6 +13,111 @@ import { type Logger, getRootLogger } from '../../utils/logger.js'
|
|
|
13
13
|
*/
|
|
14
14
|
const TERMINATE_GRACE_MS = 2_000
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The variables a child needs in order to be a working process at all.
|
|
18
|
+
*
|
|
19
|
+
* This spawn used to pass `{ ...process.env, ...config.env }`, so a connected
|
|
20
|
+
* server received every credential the host happened to hold — measured at 119
|
|
21
|
+
* variables on a developer machine, including a planted secret the server had
|
|
22
|
+
* no reason to see. A server that needs one token was handed all of them, and
|
|
23
|
+
* nothing in the config said so.
|
|
24
|
+
*
|
|
25
|
+
* The list below is process plumbing, not secrets: where to find executables,
|
|
26
|
+
* where the home and temp directories are, what the locale is. Dropping any of
|
|
27
|
+
* it does not harden anything and does break servers — a child with no `PATH`
|
|
28
|
+
* cannot resolve its own interpreter.
|
|
29
|
+
*
|
|
30
|
+
* Anything else a server needs is now named: `env` gives it a literal value,
|
|
31
|
+
* and `inheritEnv` names a parent variable to pass through. Naming is the
|
|
32
|
+
* point — a grant that has to be written down is a grant somebody can review.
|
|
33
|
+
*
|
|
34
|
+
* Windows spellings are matched case-insensitively, because its environment is
|
|
35
|
+
* case-insensitive and a lookup for `Path` against a key stored as `PATH` would
|
|
36
|
+
* silently drop it — which would present as "the server does not start on
|
|
37
|
+
* Windows" rather than as anything to do with this list.
|
|
38
|
+
*/
|
|
39
|
+
const BASE_ENV_KEYS: readonly string[] = [
|
|
40
|
+
// Everywhere.
|
|
41
|
+
'PATH',
|
|
42
|
+
'LANG',
|
|
43
|
+
'LC_ALL',
|
|
44
|
+
'LC_CTYPE',
|
|
45
|
+
'TZ',
|
|
46
|
+
// POSIX.
|
|
47
|
+
'HOME',
|
|
48
|
+
'SHELL',
|
|
49
|
+
'TMPDIR',
|
|
50
|
+
'USER',
|
|
51
|
+
'LOGNAME',
|
|
52
|
+
// Windows. `SystemRoot` and `ComSpec` are load-bearing: without them a
|
|
53
|
+
// child cannot resolve system DLLs or the command interpreter.
|
|
54
|
+
'PATHEXT',
|
|
55
|
+
'SystemRoot',
|
|
56
|
+
'SystemDrive',
|
|
57
|
+
'ComSpec',
|
|
58
|
+
'WINDIR',
|
|
59
|
+
'TEMP',
|
|
60
|
+
'TMP',
|
|
61
|
+
'USERPROFILE',
|
|
62
|
+
'HOMEDRIVE',
|
|
63
|
+
'HOMEPATH',
|
|
64
|
+
'APPDATA',
|
|
65
|
+
'LOCALAPPDATA',
|
|
66
|
+
'PROGRAMDATA',
|
|
67
|
+
'PROGRAMFILES',
|
|
68
|
+
'NUMBER_OF_PROCESSORS',
|
|
69
|
+
'PROCESSOR_ARCHITECTURE',
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read one variable from the parent, honouring the platform's own casing rules.
|
|
74
|
+
*
|
|
75
|
+
* Node exposes `process.env` on Windows through a case-insensitive proxy, so a
|
|
76
|
+
* direct lookup already works there — but the KEY this returns has to be the
|
|
77
|
+
* one the parent actually uses, or a child comparing key names sees a spelling
|
|
78
|
+
* the host never set.
|
|
79
|
+
*/
|
|
80
|
+
function readParentVar(source: NodeJS.ProcessEnv, name: string): [string, string] | undefined {
|
|
81
|
+
const direct = source[name]
|
|
82
|
+
if (direct !== undefined) return [name, direct]
|
|
83
|
+
if (process.platform !== 'win32') return undefined
|
|
84
|
+
const lowered = name.toLowerCase()
|
|
85
|
+
for (const [key, value] of Object.entries(source)) {
|
|
86
|
+
if (key.toLowerCase() === lowered && value !== undefined) return [key, value]
|
|
87
|
+
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What the child is handed: plumbing, then the named inheritances, then the
|
|
93
|
+
* literal values.
|
|
94
|
+
*
|
|
95
|
+
* Later wins, and the order is the precedence an operator would guess: a
|
|
96
|
+
* literal `env` entry overrides an inherited one, and both override the base.
|
|
97
|
+
* Exported for the tests, which assert on the ENV rather than on the spawn —
|
|
98
|
+
* a test that only checked the config was accepted would have passed against
|
|
99
|
+
* the version this replaces.
|
|
100
|
+
*/
|
|
101
|
+
export function buildChildEnv(
|
|
102
|
+
config: Pick<MCPStdioTransportConfig, 'env' | 'inheritEnv'>,
|
|
103
|
+
source: NodeJS.ProcessEnv = process.env,
|
|
104
|
+
): Record<string, string> {
|
|
105
|
+
const env: Record<string, string> = {}
|
|
106
|
+
for (const name of BASE_ENV_KEYS) {
|
|
107
|
+
const found = readParentVar(source, name)
|
|
108
|
+
if (found) env[found[0]] = found[1]
|
|
109
|
+
}
|
|
110
|
+
for (const name of config.inheritEnv ?? []) {
|
|
111
|
+
const found = readParentVar(source, name)
|
|
112
|
+
// A named variable the parent does not hold is simply absent. Refusing
|
|
113
|
+
// the spawn would turn an optional credential into a startup failure,
|
|
114
|
+
// and inventing an empty string would tell the server it has one.
|
|
115
|
+
if (found) env[found[0]] = found[1]
|
|
116
|
+
}
|
|
117
|
+
for (const [name, value] of Object.entries(config.env ?? {})) env[name] = value
|
|
118
|
+
return env
|
|
119
|
+
}
|
|
120
|
+
|
|
16
121
|
export class StdioTransport implements MCPTransport {
|
|
17
122
|
private process: ChildProcess | null = null
|
|
18
123
|
private messageHandlers: Array<(message: MCPJsonRpcMessage) => void> = []
|
|
@@ -32,7 +137,7 @@ export class StdioTransport implements MCPTransport {
|
|
|
32
137
|
if (this.connected) return
|
|
33
138
|
|
|
34
139
|
this.process = spawn(this.config.command, this.config.args ?? [], {
|
|
35
|
-
env:
|
|
140
|
+
env: buildChildEnv(this.config),
|
|
36
141
|
cwd: this.config.cwd,
|
|
37
142
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
143
|
})
|
|
@@ -23,7 +23,22 @@ export const GENAI = {
|
|
|
23
23
|
|
|
24
24
|
TOOL_NAME: 'gen_ai.tool.name',
|
|
25
25
|
TOOL_TYPE: 'gen_ai.tool.type',
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The id of the tool call this span is about.
|
|
29
|
+
*
|
|
30
|
+
* Spelled `call.id`, matching the registry and matching the two
|
|
31
|
+
* neighbours above — this read `gen_ai.tool.call_id`, one underscore
|
|
32
|
+
* where the convention has a dot, so a consumer grouping by the
|
|
33
|
+
* conventional name found nothing under it. Nothing errored, because a
|
|
34
|
+
* span attribute is a free-form key and a wrong one is simply a key
|
|
35
|
+
* nobody asked for.
|
|
36
|
+
*
|
|
37
|
+
* Pinned by `telemetry/__tests__/tool-call-id-attribute.test.ts`, which
|
|
38
|
+
* also drives the emitter: the spelling was only half the defect, and
|
|
39
|
+
* the constant had no writer at all.
|
|
40
|
+
*/
|
|
41
|
+
TOOL_CALL_ID: 'gen_ai.tool.call.id',
|
|
27
42
|
|
|
28
43
|
AGENT_NAME: 'gen_ai.agent.name',
|
|
29
44
|
AGENT_ID: 'gen_ai.agent.id',
|
package/src/provider/fallback.ts
CHANGED
|
@@ -382,7 +382,13 @@ export function withProviderFallback(
|
|
|
382
382
|
},
|
|
383
383
|
chatStream,
|
|
384
384
|
...(first.provider.listModels ? { listModels: () => first.provider.listModels?.() } : {}),
|
|
385
|
-
|
|
386
|
-
|
|
385
|
+
// Forwarded for the reason `retry.ts` forwards it: a wrapper that drops
|
|
386
|
+
// the model turns a probe the caller could answer into one it cannot.
|
|
387
|
+
...(first.provider.healthCheck
|
|
388
|
+
? { healthCheck: (model?: string) => first.provider.healthCheck?.(model) }
|
|
389
|
+
: {}),
|
|
390
|
+
...(first.provider.doctorCheck
|
|
391
|
+
? { doctorCheck: (model?: string) => first.provider.doctorCheck?.(model) }
|
|
392
|
+
: {}),
|
|
387
393
|
} as LLMProvider
|
|
388
394
|
}
|
package/src/provider/retry.ts
CHANGED
|
@@ -231,7 +231,15 @@ export function withProviderRetry(
|
|
|
231
231
|
},
|
|
232
232
|
chatStream,
|
|
233
233
|
...(provider.listModels ? { listModels: () => provider.listModels?.() } : {}),
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
// The model is forwarded, not dropped. A wrapper that swallowed it would
|
|
235
|
+
// leave the wrapped driver probing whatever it probes with no argument
|
|
236
|
+
// — which for at least one driver is "nothing", so the check would come
|
|
237
|
+
// back unanswerable purely because it was wrapped.
|
|
238
|
+
...(provider.healthCheck
|
|
239
|
+
? { healthCheck: (model?: string) => provider.healthCheck?.(model) }
|
|
240
|
+
: {}),
|
|
241
|
+
...(provider.doctorCheck
|
|
242
|
+
? { doctorCheck: (model?: string) => provider.doctorCheck?.(model) }
|
|
243
|
+
: {}),
|
|
236
244
|
} as LLMProvider
|
|
237
245
|
}
|
|
@@ -415,9 +415,21 @@ Executable tool names, descriptions, and JSON input schemas are attached through
|
|
|
415
415
|
|
|
416
416
|
return tracer.startActiveSpan(toolSpanName(toolName), {}, parentCtx, async (span) => {
|
|
417
417
|
try {
|
|
418
|
+
// The call id joins this span to the assistant block that asked
|
|
419
|
+
// for it. Without it a trace shows that `Bash` ran four times
|
|
420
|
+
// this turn and cannot say which span answers which
|
|
421
|
+
// `tool_use` — and the id was already in hand here, threaded
|
|
422
|
+
// through `ToolContext` for the tools that reply
|
|
423
|
+
// asynchronously.
|
|
424
|
+
//
|
|
425
|
+
// Conditional because `toolUseId` is optional: a host calling
|
|
426
|
+
// a tool directly, outside a run, has no call to correlate to,
|
|
427
|
+
// and an attribute set to `undefined` is worse than an absent
|
|
428
|
+
// one — it reaches the exporter as a key with no value.
|
|
418
429
|
span.setAttributes({
|
|
419
430
|
[GENAI.TOOL_NAME]: toolName,
|
|
420
431
|
[GENAI.TOOL_TYPE]: 'function',
|
|
432
|
+
...(context.toolUseId !== undefined ? { [GENAI.TOOL_CALL_ID]: context.toolUseId } : {}),
|
|
421
433
|
})
|
|
422
434
|
|
|
423
435
|
const tool = this.getOrThrow(toolName)
|
|
@@ -89,7 +89,26 @@ export class GitWorktreeDriver implements WorkspaceBackendDriver {
|
|
|
89
89
|
try {
|
|
90
90
|
await this.exec('git', argv)
|
|
91
91
|
} catch (cause) {
|
|
92
|
-
|
|
92
|
+
// A non-zero exit here does not mean the worktree was not created.
|
|
93
|
+
// `git worktree add` runs the repository's post-checkout hook AFTER
|
|
94
|
+
// the checkout has completed, so a hook that fails — or that a
|
|
95
|
+
// timeout kills — reports failure over a worktree that is finished
|
|
96
|
+
// and usable. Treating the status as the answer throws away a good
|
|
97
|
+
// checkout AND leaks it: the path stays registered, and the next
|
|
98
|
+
// attempt fails differently, with "already exists".
|
|
99
|
+
//
|
|
100
|
+
// So the exit code is a hint and the repository is the evidence.
|
|
101
|
+
// The bar is deliberately high: registered under this exact path
|
|
102
|
+
// AND carrying the branch this call asked for. A registered path
|
|
103
|
+
// alone can be a half-finished checkout, or somebody else's.
|
|
104
|
+
if (!(await this.createdDespite(worktreePath, branch))) {
|
|
105
|
+
throw new WorkspaceBackendError({ op: 'create', kind: this.kind, cause })
|
|
106
|
+
}
|
|
107
|
+
this.log.warn('git-worktree add reported failure but the worktree is present', {
|
|
108
|
+
branch,
|
|
109
|
+
worktreePath,
|
|
110
|
+
cause: cause instanceof Error ? cause.message : String(cause),
|
|
111
|
+
})
|
|
93
112
|
}
|
|
94
113
|
|
|
95
114
|
const meta: GitWorktreeBackendMeta = {
|
|
@@ -142,6 +161,41 @@ export class GitWorktreeDriver implements WorkspaceBackendDriver {
|
|
|
142
161
|
}
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Did the worktree arrive despite the command reporting failure?
|
|
166
|
+
*
|
|
167
|
+
* Answers only for the branch this call created. A path registered
|
|
168
|
+
* without that branch is not this call's worktree — it is a leftover
|
|
169
|
+
* from a killed attempt, or a checkout somebody else owns, and the two
|
|
170
|
+
* are indistinguishable from here. Claiming either would mean handing
|
|
171
|
+
* a caller a workspace whose contents nobody vouched for, so both are
|
|
172
|
+
* left to surface as the failure they are.
|
|
173
|
+
*
|
|
174
|
+
* Any error while checking is itself a "no". This runs on a path that
|
|
175
|
+
* has already gone wrong once, and guessing optimistically there is how
|
|
176
|
+
* a recovery turns a bad situation into a wrong one.
|
|
177
|
+
*/
|
|
178
|
+
private async createdDespite(worktreePath: string, branch: string): Promise<boolean> {
|
|
179
|
+
try {
|
|
180
|
+
const { stdout } = await this.exec('git', [
|
|
181
|
+
'-C',
|
|
182
|
+
this.repoRoot,
|
|
183
|
+
'worktree',
|
|
184
|
+
'list',
|
|
185
|
+
'--porcelain',
|
|
186
|
+
])
|
|
187
|
+
const entry = parseWorktreeList(stdout, worktreePath)
|
|
188
|
+
// `--porcelain` writes the branch as a full ref (`refs/heads/x`),
|
|
189
|
+
// and `branch` here is the short name this call passed to `-b`.
|
|
190
|
+
// Comparing them directly is a check that can never pass, which
|
|
191
|
+
// would make this whole recovery path silently dead — the exact
|
|
192
|
+
// shape it exists to catch.
|
|
193
|
+
return entry?.branch === `refs/heads/${branch}`
|
|
194
|
+
} catch {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
145
199
|
async inspect(ref: WorkspaceRef): Promise<WorkspaceInspection> {
|
|
146
200
|
let listStdout: string
|
|
147
201
|
try {
|
|
@@ -60,6 +60,27 @@ const inputSchema = z.object({
|
|
|
60
60
|
|
|
61
61
|
type BashInput = z.infer<typeof inputSchema>
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The last line worth showing from one chunk of streamed output.
|
|
65
|
+
*
|
|
66
|
+
* A progress line is a status, not a log: the host renders one line and
|
|
67
|
+
* replaces it as the next arrives, so sending a whole chunk sends a wall
|
|
68
|
+
* of text into a slot that shows one line of it. A chunk usually ends
|
|
69
|
+
* mid-line and usually ends with a newline, so the last NON-EMPTY line is
|
|
70
|
+
* the most recent complete thing the command actually said.
|
|
71
|
+
*
|
|
72
|
+
* Progress is capped rather than truncated with an ellipsis: this is
|
|
73
|
+
* glanced at, and a marker in a line nobody reads to the end is noise.
|
|
74
|
+
*/
|
|
75
|
+
function lastNonEmptyLine(chunk: string): string | undefined {
|
|
76
|
+
const lines = chunk.split('\n')
|
|
77
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
78
|
+
const line = lines[i]?.trim()
|
|
79
|
+
if (line) return line.length > 160 ? line.slice(0, 160) : line
|
|
80
|
+
}
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
function isDangerousCommand(command: string): boolean {
|
|
64
85
|
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command))
|
|
65
86
|
}
|
|
@@ -114,6 +135,19 @@ export const BashTool = defineTool({
|
|
|
114
135
|
// Same reason as the host path below: a Stop must reach the
|
|
115
136
|
// process, not just the promise waiting on it.
|
|
116
137
|
signal: context.abortSignal,
|
|
138
|
+
// The worker has always streamed its output; nothing asked for
|
|
139
|
+
// it, so a command that ran for minutes said nothing until it
|
|
140
|
+
// exited. `report` is ephemeral by design — it answers "is it
|
|
141
|
+
// still working?" for a live view and is excluded from the
|
|
142
|
+
// durable transcript — so this is a progress signal, not a
|
|
143
|
+
// second copy of the output. `result.stdout` remains the
|
|
144
|
+
// answer the model is given.
|
|
145
|
+
onOutput: context.report
|
|
146
|
+
? ({ data }) => {
|
|
147
|
+
const line = lastNonEmptyLine(data)
|
|
148
|
+
if (line) context.report?.(line)
|
|
149
|
+
}
|
|
150
|
+
: undefined,
|
|
117
151
|
})
|
|
118
152
|
|
|
119
153
|
if (result.timedOut) {
|
|
@@ -7,6 +7,7 @@ import { defineTool } from '../defineTool.js'
|
|
|
7
7
|
import { wrapUntrusted } from '../untrusted-envelope.js'
|
|
8
8
|
import { failureLabel, taskSucceeded } from './outcome.js'
|
|
9
9
|
|
|
10
|
+
import { DELEGATION_TIMEOUT_MS } from './index.js'
|
|
10
11
|
import type { TaskLaunchedCallback } from './index.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -107,6 +108,17 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
|
|
|
107
108
|
readOnly: false,
|
|
108
109
|
destructive: false,
|
|
109
110
|
concurrencySafe: true,
|
|
111
|
+
// Declaring nothing here does not mean "no deadline"; it means the
|
|
112
|
+
// executor's 120-second default, which is a bound for a tool call and
|
|
113
|
+
// absurd for a whole agent run. `create_task` in the sibling module
|
|
114
|
+
// carries the same reasoning and the same hour, and the measurement
|
|
115
|
+
// behind that number is in its docblock: three delegated children took
|
|
116
|
+
// 4m21s, 5m58s and 8m04s, and all three parents gave up at 120s.
|
|
117
|
+
//
|
|
118
|
+
// This surface did not get that fix when its twin did, and the file's
|
|
119
|
+
// own note above records the pair doing exactly this before. The two
|
|
120
|
+
// tools are twins; a bound applied to one of them is not applied.
|
|
121
|
+
timeoutMs: DELEGATION_TIMEOUT_MS,
|
|
110
122
|
...(opts.terminal !== undefined ? { terminal: opts.terminal } : {}),
|
|
111
123
|
async execute({ description, prompt, subagent_type }, context) {
|
|
112
124
|
// With a single registered subagent the type is optional — default to
|
|
@@ -157,6 +157,73 @@ const askUserQuestionModelInputSchema: Record<string, unknown> = {
|
|
|
157
157
|
additionalProperties: false,
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/** One well-formed tag token: `<step>`, `</step>`, `<a href="…">`, `<br/>`. */
|
|
161
|
+
const TAG_TOKEN = /<\/?[A-Za-z][\w-]*(?:\s[^<>]*)?\/?>/g
|
|
162
|
+
const DESCRIPTION_BLOCK = /<description>([\s\S]*?)<\/description>/gi
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Remove every tag token, including the ones removing a tag creates.
|
|
166
|
+
*
|
|
167
|
+
* One pass is not enough and the reason is not obvious: deleting an inner
|
|
168
|
+
* tag can splice its neighbours into a new one. `<<step>step>` loses the
|
|
169
|
+
* inner `<step>` and the halves close up into `<step>` again, so a line
|
|
170
|
+
* that is nothing but markup comes back non-empty and is offered to a
|
|
171
|
+
* human as a step to approve — which is the exact outcome this whole path
|
|
172
|
+
* exists to prevent.
|
|
173
|
+
*
|
|
174
|
+
* Repeating to a fixed point terminates: every pass that changes the
|
|
175
|
+
* string removes at least one token and so strictly shortens it.
|
|
176
|
+
*
|
|
177
|
+
* Only ever used to ANSWER "is there anything here besides markup". The
|
|
178
|
+
* result is never shown to anyone, so this is a test rather than a
|
|
179
|
+
* sanitiser, and it does not have to defend against every way a tag can
|
|
180
|
+
* be spelled.
|
|
181
|
+
*/
|
|
182
|
+
function withoutTags(text: string): string {
|
|
183
|
+
let current = text
|
|
184
|
+
for (;;) {
|
|
185
|
+
const next = current.replace(TAG_TOKEN, '')
|
|
186
|
+
if (next === current) return current
|
|
187
|
+
current = next
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Peel tag wrappers off the ENDS of one line, and nowhere else — a step
|
|
193
|
+
* that legitimately says "wrap it in a <div>" keeps its sentence.
|
|
194
|
+
*/
|
|
195
|
+
function unwrapStepLine(line: string): string {
|
|
196
|
+
let text = line.trim()
|
|
197
|
+
for (;;) {
|
|
198
|
+
const next = text
|
|
199
|
+
.replace(/^<[A-Za-z][\w-]*(?:\s[^<>]*)?>\s*/, '')
|
|
200
|
+
.replace(/\s*<\/[A-Za-z][\w-]*>$/, '')
|
|
201
|
+
.trim()
|
|
202
|
+
if (next === text) break
|
|
203
|
+
text = next
|
|
204
|
+
}
|
|
205
|
+
return text
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A step list the model serialized instead of building.
|
|
210
|
+
*
|
|
211
|
+
* The line-splitting fallback below is the general case, and it had one
|
|
212
|
+
* shape badly wrong. A model that serializes this array tends to reach for
|
|
213
|
+
* MARKUP, not for prose:
|
|
214
|
+
*
|
|
215
|
+
* <steps>
|
|
216
|
+
* <step>
|
|
217
|
+
* <description>Convert the document to Word</description>
|
|
218
|
+
* </step>
|
|
219
|
+
* </steps>
|
|
220
|
+
*
|
|
221
|
+
* Split on newlines, that is seven "steps", five of which are tags. A host
|
|
222
|
+
* then numbered them in an approval card and asked a person to approve
|
|
223
|
+
* `</steps>` — reported from a real run. The descriptions the model named
|
|
224
|
+
* are right there, so read them; fall back to lines only when there are
|
|
225
|
+
* none, and drop the lines that carry no words at all.
|
|
226
|
+
*/
|
|
160
227
|
function normalizeApprovePlanSteps(value: unknown): unknown {
|
|
161
228
|
if (typeof value !== 'string') return value
|
|
162
229
|
|
|
@@ -171,19 +238,84 @@ function normalizeApprovePlanSteps(value: unknown): unknown {
|
|
|
171
238
|
}
|
|
172
239
|
}
|
|
173
240
|
|
|
241
|
+
const described = [...trimmed.matchAll(DESCRIPTION_BLOCK)]
|
|
242
|
+
.map((match) => (match[1] ?? '').trim())
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
if (described.length > 0) {
|
|
245
|
+
return described.map((description) => ({ description }))
|
|
246
|
+
}
|
|
247
|
+
|
|
174
248
|
const lines = trimmed
|
|
175
249
|
.split(/\r?\n+/)
|
|
176
250
|
.map((line) =>
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
251
|
+
unwrapStepLine(
|
|
252
|
+
line
|
|
253
|
+
.trim()
|
|
254
|
+
.replace(/^(?:[-*•]|\d+[.)])\s*/, '')
|
|
255
|
+
.trim(),
|
|
256
|
+
),
|
|
181
257
|
)
|
|
182
|
-
.filter(
|
|
258
|
+
.filter((line) => line.length > 0 && withoutTags(line).trim().length > 0)
|
|
259
|
+
|
|
260
|
+
// Every line was markup: there is no plan in this string, and inventing
|
|
261
|
+
// one step reading `<steps>` is worse than saying so.
|
|
262
|
+
if (lines.length === 0) {
|
|
263
|
+
return withoutTags(unwrapStepLine(trimmed)).trim()
|
|
264
|
+
? [{ description: unwrapStepLine(trimmed) }]
|
|
265
|
+
: []
|
|
266
|
+
}
|
|
183
267
|
|
|
184
|
-
return
|
|
185
|
-
|
|
186
|
-
|
|
268
|
+
return lines.map((description) => ({ description }))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The single closed shape a capable provider constrains this call to —
|
|
273
|
+
* the same instrument `ask_user_question` carries, for the same failure.
|
|
274
|
+
*
|
|
275
|
+
* `steps` arriving as a STRING is what everything above exists to survive,
|
|
276
|
+
* and surviving it is not the same as preventing it: the normalizer can
|
|
277
|
+
* only guess at a structure the model already threw away. Advertising the
|
|
278
|
+
* closed shape turns the guess into a refusal at generation time.
|
|
279
|
+
*/
|
|
280
|
+
const approvePlanModelInputSchema: Record<string, unknown> = {
|
|
281
|
+
type: 'object',
|
|
282
|
+
properties: {
|
|
283
|
+
title: {
|
|
284
|
+
type: 'string',
|
|
285
|
+
description: 'Short title for the plan (e.g. "TypeScript Security & Performance Review").',
|
|
286
|
+
},
|
|
287
|
+
summary: {
|
|
288
|
+
type: 'string',
|
|
289
|
+
description: '1-3 sentence summary of what you plan to do.',
|
|
290
|
+
},
|
|
291
|
+
steps: {
|
|
292
|
+
type: 'array',
|
|
293
|
+
description:
|
|
294
|
+
'A JSON array of ordered step objects. Never a string, and never markup — no <step> or <description> tags.',
|
|
295
|
+
items: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
properties: {
|
|
298
|
+
description: {
|
|
299
|
+
type: 'string',
|
|
300
|
+
description: 'What this step does, as one plain sentence a person can read.',
|
|
301
|
+
},
|
|
302
|
+
agent_id: {
|
|
303
|
+
type: 'string',
|
|
304
|
+
description: 'Which agent handles this; omit for steps you carry out yourself.',
|
|
305
|
+
},
|
|
306
|
+
depends_on: {
|
|
307
|
+
type: 'array',
|
|
308
|
+
items: { type: 'string' },
|
|
309
|
+
description: 'Descriptions of the steps that must finish before this one.',
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
required: ['description'],
|
|
313
|
+
additionalProperties: false,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
required: ['title', 'summary', 'steps'],
|
|
318
|
+
additionalProperties: false,
|
|
187
319
|
}
|
|
188
320
|
|
|
189
321
|
/**
|
|
@@ -1013,6 +1145,10 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
1013
1145
|
.preprocess(normalizeApprovePlanSteps, z.array(approvePlanStepSchema))
|
|
1014
1146
|
.describe('Ordered list of planned steps'),
|
|
1015
1147
|
}),
|
|
1148
|
+
modelInputSchema: structuredClone(approvePlanModelInputSchema),
|
|
1149
|
+
enforceModelInput: true,
|
|
1150
|
+
validationErrorHint:
|
|
1151
|
+
'Required shape: {"title":"…","summary":"…","steps":[{"description":"One plain sentence"}]}. "steps" must be a JSON array of objects — never a string, and never markup such as <step> or <description>.',
|
|
1016
1152
|
category: 'custom',
|
|
1017
1153
|
permissions: [],
|
|
1018
1154
|
readOnly: true,
|
|
@@ -18,7 +18,24 @@ export interface MCPStdioTransportConfig extends MCPTransportConfigBase {
|
|
|
18
18
|
type: 'stdio'
|
|
19
19
|
command: string
|
|
20
20
|
args?: string[]
|
|
21
|
+
/** Literal values for the child. Highest precedence. */
|
|
21
22
|
env?: Record<string, string>
|
|
23
|
+
/**
|
|
24
|
+
* Parent variables the child may have, named one at a time.
|
|
25
|
+
*
|
|
26
|
+
* The spawn used to pass the whole parent environment, so a server that
|
|
27
|
+
* needed one token received every credential the host held. It now gets
|
|
28
|
+
* process plumbing plus what is named here and in `env`, which is what
|
|
29
|
+
* makes the grant reviewable: the config says which secrets cross the
|
|
30
|
+
* boundary instead of the answer being "all of them".
|
|
31
|
+
*
|
|
32
|
+
* Use this rather than `env` for a credential — `env` puts the value in the
|
|
33
|
+
* config file, and this keeps it in the environment where it already lives.
|
|
34
|
+
*
|
|
35
|
+
* A name the parent does not hold is absent from the child rather than
|
|
36
|
+
* empty, and does not fail the spawn.
|
|
37
|
+
*/
|
|
38
|
+
inheritEnv?: readonly string[]
|
|
22
39
|
cwd?: string
|
|
23
40
|
}
|
|
24
41
|
|
|
@@ -73,7 +73,23 @@ export interface LLMProvider {
|
|
|
73
73
|
*/
|
|
74
74
|
probeCredential?(): Promise<void>
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Is this driver able to serve traffic? A summary bit; `doctorCheck` is
|
|
78
|
+
* the same probe with its reasoning intact.
|
|
79
|
+
*
|
|
80
|
+
* `model` is the model the CALLER intends to run, and it is a parameter
|
|
81
|
+
* rather than something the driver reads off its own config because at
|
|
82
|
+
* least one driver's config does not carry a model at all. That driver
|
|
83
|
+
* hardcoded an id instead, which is how its check came to probe a model
|
|
84
|
+
* nobody used — and, once the id went stale, could not pass at any
|
|
85
|
+
* credential, region or service state. A health check against a model the
|
|
86
|
+
* operator does not run tests the wrong thing even while the id is valid.
|
|
87
|
+
*
|
|
88
|
+
* Optional, and a driver may ignore it: one that probes an endpoint rather
|
|
89
|
+
* than a model has nothing to do with the argument. Passing it is always
|
|
90
|
+
* safe.
|
|
91
|
+
*/
|
|
92
|
+
healthCheck?(model?: string): Promise<boolean>
|
|
77
93
|
|
|
78
94
|
/**
|
|
79
95
|
* Optional structured health probe used by `runDoctor()`.
|
|
@@ -82,8 +98,13 @@ export interface LLMProvider {
|
|
|
82
98
|
* (latency, model availability, auth status, …). Providers that
|
|
83
99
|
* cannot be cheaply probed should return `{ status: 'inconclusive' }`
|
|
84
100
|
* so the doctor doesn't mark them as failing — see ses_007 Q6.4.
|
|
101
|
+
*
|
|
102
|
+
* Takes `model` for the reason `healthCheck` does, and a driver is free to
|
|
103
|
+
* return a SUBTYPE of `DoctorCheckResult` carrying its own machine-readable
|
|
104
|
+
* detail. `status` is what `runDoctor()` reads; a caller holding the
|
|
105
|
+
* concrete driver can read more.
|
|
85
106
|
*/
|
|
86
|
-
doctorCheck?(): Promise<DoctorCheckResult>
|
|
107
|
+
doctorCheck?(model?: string): Promise<DoctorCheckResult>
|
|
87
108
|
|
|
88
109
|
/**
|
|
89
110
|
* Which {@link ChatCompletionParams.effort} levels this model accepts,
|
|
@@ -103,6 +103,29 @@ export interface SandboxExecOptions {
|
|
|
103
103
|
readonly timeout?: number
|
|
104
104
|
readonly env?: Record<string, string>
|
|
105
105
|
readonly cwd?: string
|
|
106
|
+
/**
|
|
107
|
+
* Called as output arrives, before the command has finished.
|
|
108
|
+
*
|
|
109
|
+
* Every container-tier worker already streams its output a chunk at a
|
|
110
|
+
* time — the wire carries `stdout_delta` and `stderr_delta` events —
|
|
111
|
+
* and every backend concatenated them into a string and returned that
|
|
112
|
+
* when the process exited. So a command that takes eight minutes said
|
|
113
|
+
* nothing for eight minutes, on a transport that had been reporting
|
|
114
|
+
* the whole time.
|
|
115
|
+
*
|
|
116
|
+
* Additive and optional: a backend that cannot stream simply never
|
|
117
|
+
* calls it, and `SandboxExecResult.stdout` still carries the complete
|
|
118
|
+
* output either way. A caller that wants only the result ignores this
|
|
119
|
+
* and behaves exactly as before.
|
|
120
|
+
*
|
|
121
|
+
* The callback must not throw and must not be awaited — it is on the
|
|
122
|
+
* read path of a running process, so a slow or failing consumer would
|
|
123
|
+
* otherwise become a slow or failing command.
|
|
124
|
+
*/
|
|
125
|
+
readonly onOutput?: (chunk: {
|
|
126
|
+
readonly stream: 'stdout' | 'stderr'
|
|
127
|
+
readonly data: string
|
|
128
|
+
}) => void
|
|
106
129
|
/**
|
|
107
130
|
* Cancellation for the command. A backend that honours it kills the
|
|
108
131
|
* process; one that does not simply ignores it, so this is additive.
|