@namzu/sdk 41.0.0 → 42.0.1
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 +233 -0
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +3 -0
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +11 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/runAgent.d.ts +14 -0
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js +3 -0
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +20 -0
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/resident/outbox.d.ts +8 -8
- package/dist/manager/resident/store.d.ts +4 -4
- package/dist/public-runtime.d.ts +4 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +13 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +1 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +4 -2
- package/dist/public-tools.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +10 -1
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/bidi/session.d.ts +11 -0
- package/dist/runtime/bidi/session.d.ts.map +1 -1
- package/dist/runtime/bidi/session.js +2 -0
- package/dist/runtime/bidi/session.js.map +1 -1
- package/dist/runtime/query/cancelled-before-start.d.ts +34 -0
- package/dist/runtime/query/cancelled-before-start.d.ts.map +1 -0
- package/dist/runtime/query/cancelled-before-start.js +152 -0
- package/dist/runtime/query/cancelled-before-start.js.map +1 -0
- package/dist/runtime/query/checkpoint.d.ts +21 -0
- package/dist/runtime/query/checkpoint.d.ts.map +1 -1
- package/dist/runtime/query/checkpoint.js +23 -0
- package/dist/runtime/query/checkpoint.js.map +1 -1
- package/dist/runtime/query/executor/tool-call-admission.d.ts +57 -0
- package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -0
- package/dist/runtime/query/executor/tool-call-admission.js +373 -0
- package/dist/runtime/query/executor/tool-call-admission.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +76 -35
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +52 -380
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/finalize-run.d.ts +55 -0
- package/dist/runtime/query/finalize-run.d.ts.map +1 -0
- package/dist/runtime/query/finalize-run.js +113 -0
- package/dist/runtime/query/finalize-run.js.map +1 -0
- package/dist/runtime/query/guardrail-presets.d.ts +187 -1
- package/dist/runtime/query/guardrail-presets.d.ts.map +1 -1
- package/dist/runtime/query/guardrail-presets.js +298 -0
- package/dist/runtime/query/guardrail-presets.js.map +1 -1
- package/dist/runtime/query/index.d.ts +18 -9
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +241 -893
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts +6 -161
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +23 -523
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.d.ts +158 -0
- package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -0
- package/dist/runtime/query/iteration/outstanding-work.js +365 -0
- package/dist/runtime/query/iteration/outstanding-work.js.map +1 -0
- package/dist/runtime/query/iteration/phases/plan.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/plan.js +13 -2
- package/dist/runtime/query/iteration/phases/plan.js.map +1 -1
- package/dist/runtime/query/iteration/step-shaping.d.ts +41 -0
- package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -0
- package/dist/runtime/query/iteration/step-shaping.js +184 -0
- package/dist/runtime/query/iteration/step-shaping.js.map +1 -0
- package/dist/runtime/query/prepare-run.d.ts +94 -0
- package/dist/runtime/query/prepare-run.d.ts.map +1 -0
- package/dist/runtime/query/prepare-run.js +589 -0
- package/dist/runtime/query/prepare-run.js.map +1 -0
- package/dist/runtime/query/release-run.d.ts +56 -0
- package/dist/runtime/query/release-run.d.ts.map +1 -0
- package/dist/runtime/query/release-run.js +101 -0
- package/dist/runtime/query/release-run.js.map +1 -0
- package/dist/runtime/query/resume-pending.d.ts +112 -1
- package/dist/runtime/query/resume-pending.d.ts.map +1 -1
- package/dist/runtime/query/resume-pending.js +133 -0
- package/dist/runtime/query/resume-pending.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +2 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +3 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/store/evidence/compaction-archive.d.ts +2 -2
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +17 -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 +17 -3
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/untrusted-envelope.d.ts +35 -0
- package/dist/tools/untrusted-envelope.d.ts.map +1 -1
- package/dist/tools/untrusted-envelope.js +91 -3
- package/dist/tools/untrusted-envelope.js.map +1 -1
- package/dist/types/agent/base.d.ts +23 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +19 -0
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/run/config.d.ts +12 -5
- package/dist/types/run/config.d.ts.map +1 -1
- package/dist/types/tool/index.d.ts +19 -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/ReactiveAgent.ts +3 -0
- package/src/agents/SupervisorAgent.ts +11 -0
- package/src/agents/runAgent.ts +18 -0
- package/src/manager/agent/lifecycle.ts +22 -0
- package/src/public-runtime.ts +14 -0
- package/src/public-tools.ts +8 -2
- package/src/registry/tool/execute.ts +9 -1
- package/src/runtime/bidi/session.ts +13 -0
- package/src/runtime/query/cancelled-before-start.ts +189 -0
- package/src/runtime/query/checkpoint.ts +22 -0
- package/src/runtime/query/executor/tool-call-admission.ts +473 -0
- package/src/runtime/query/executor.ts +76 -442
- package/src/runtime/query/finalize-run.ts +192 -0
- package/src/runtime/query/guardrail-presets.ts +356 -0
- package/src/runtime/query/index.ts +287 -1011
- package/src/runtime/query/iteration/index.ts +40 -586
- package/src/runtime/query/iteration/outstanding-work.ts +386 -0
- package/src/runtime/query/iteration/phases/plan.ts +18 -2
- package/src/runtime/query/iteration/step-shaping.ts +271 -0
- package/src/runtime/query/prepare-run.ts +718 -0
- package/src/runtime/query/release-run.ts +168 -0
- package/src/runtime/query/resume-pending.ts +158 -0
- package/src/runtime/query/tooling.ts +5 -0
- package/src/tools/coordinator/agent.ts +17 -2
- package/src/tools/coordinator/index.ts +17 -3
- package/src/tools/untrusted-envelope.ts +94 -3
- package/src/types/agent/base.ts +24 -0
- package/src/types/agent/task.ts +20 -0
- package/src/types/run/config.ts +12 -5
- package/src/types/tool/index.ts +20 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Span } from '@opentelemetry/api'
|
|
2
|
+
import { consolidationEntry } from '../../compaction/consolidation.js'
|
|
3
|
+
import type { WorkingStateManager } from '../../compaction/manager.js'
|
|
4
|
+
import { NAMZU } from '../../constants/telemetry/index.js'
|
|
5
|
+
import type { RunEvent, StepResult } from '../../types/run/index.js'
|
|
6
|
+
import { toErrorMessage } from '../../utils/error.js'
|
|
7
|
+
import type { RunContext } from './context.js'
|
|
8
|
+
import type { EventTranslator } from './events.js'
|
|
9
|
+
import { runOutputGuardrails } from './guardrails.js'
|
|
10
|
+
import type { QueryParams } from './index.js'
|
|
11
|
+
import { applyLifecycleHookResults } from './plugin-hooks.js'
|
|
12
|
+
import type { ResultAssembler } from './result.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a run does on its way out, once the loop has stopped.
|
|
16
|
+
*
|
|
17
|
+
* The loop returns for any of a dozen reasons — an answer, a budget, a stop
|
|
18
|
+
* condition, a cancelled signal — and this is the one place all of them pass
|
|
19
|
+
* through: the `run_end`/`subagent_stop` hooks, the step record, the output
|
|
20
|
+
* guardrails, consolidation into a memory store, and the terminal events.
|
|
21
|
+
*
|
|
22
|
+
* It is a generator because it emits, and it is reached with `yield*` so
|
|
23
|
+
* every one of those emits suspends the caller at exactly the point it did
|
|
24
|
+
* when the code lived inline.
|
|
25
|
+
*
|
|
26
|
+
* Two positions here are load-bearing, and they are stated where they happen.
|
|
27
|
+
* `markCancelled` runs before the assembler, because `completeRun` marks a
|
|
28
|
+
* `running` run `completed` — the reverse order would overwrite the
|
|
29
|
+
* cancellation the abort signal had already declared. And
|
|
30
|
+
* `memory_consolidated` precedes `run_completed`, so a host folding the
|
|
31
|
+
* stream in order has the memory before the run that produced it.
|
|
32
|
+
*
|
|
33
|
+
* `setSteps` keeps its position too, but on the move's terms rather than on
|
|
34
|
+
* its own. This file used to claim the assembler needed it first "or the
|
|
35
|
+
* returned `Run` loses the final turn's steps" — that is not true, and a
|
|
36
|
+
* mutation proves it: `completeRun` reads `result`, `stopReason` and the
|
|
37
|
+
* budget and never `steps`, the returned `Run` is built by `finalize()`
|
|
38
|
+
* (which runs after the whole `try`/`catch`/`finally`), and moving
|
|
39
|
+
* `setSteps` below the assembler leaves the suite green, including the test
|
|
40
|
+
* that asserts `run.steps` on a returned run. What holds `setSteps` where it
|
|
41
|
+
* is, is the byte-identity of this move: every position was preserved, not
|
|
42
|
+
* just the consequential ones. Its read is still deferred to the same
|
|
43
|
+
* moment — after the `run_end` hooks, immediately before the record is
|
|
44
|
+
* written — which is why the caller passes `takeSteps` rather than an array.
|
|
45
|
+
*/
|
|
46
|
+
export interface RunFinalization {
|
|
47
|
+
readonly ctx: RunContext
|
|
48
|
+
readonly params: QueryParams
|
|
49
|
+
readonly eventTranslator: EventTranslator
|
|
50
|
+
/**
|
|
51
|
+
* The steps the loop recorded, read HERE rather than handed over as an
|
|
52
|
+
* array: it is read where it always was, after the `run_end` hooks and
|
|
53
|
+
* immediately before the record is written.
|
|
54
|
+
*/
|
|
55
|
+
readonly takeSteps: () => readonly StepResult[]
|
|
56
|
+
readonly workingStateManager: WorkingStateManager | undefined
|
|
57
|
+
readonly resultAssembler: ResultAssembler
|
|
58
|
+
readonly rootSpan: Span
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function* finalizeRun(finalization: RunFinalization): AsyncGenerator<RunEvent, void> {
|
|
62
|
+
const {
|
|
63
|
+
ctx,
|
|
64
|
+
params,
|
|
65
|
+
eventTranslator,
|
|
66
|
+
takeSteps,
|
|
67
|
+
workingStateManager,
|
|
68
|
+
resultAssembler,
|
|
69
|
+
rootSpan,
|
|
70
|
+
} = finalization
|
|
71
|
+
|
|
72
|
+
if (params.pluginManager) {
|
|
73
|
+
const hookResults = await params.pluginManager.executeHooks(
|
|
74
|
+
'run_end',
|
|
75
|
+
{ runId: ctx.runId, signal: ctx.abortController.signal },
|
|
76
|
+
eventTranslator.emitEvent,
|
|
77
|
+
)
|
|
78
|
+
applyLifecycleHookResults('run_end', hookResults)
|
|
79
|
+
yield* eventTranslator.drainPending()
|
|
80
|
+
// A delegated run says so once more, by name, so a hook that
|
|
81
|
+
// only cares when a subagent finishes need not read parent ids
|
|
82
|
+
// off every run_end.
|
|
83
|
+
if (params.parentRunId !== undefined) {
|
|
84
|
+
const stopResults = await params.pluginManager.executeHooks(
|
|
85
|
+
'subagent_stop',
|
|
86
|
+
{
|
|
87
|
+
runId: ctx.runId,
|
|
88
|
+
parentRunId: params.parentRunId,
|
|
89
|
+
signal: ctx.abortController.signal,
|
|
90
|
+
},
|
|
91
|
+
eventTranslator.emitEvent,
|
|
92
|
+
)
|
|
93
|
+
applyLifecycleHookResults('subagent_stop', stopResults)
|
|
94
|
+
yield* eventTranslator.drainPending()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Hand the step record to the run before it settles, so the
|
|
99
|
+
// returned `Run` carries it.
|
|
100
|
+
ctx.runMgr.setSteps(takeSteps())
|
|
101
|
+
|
|
102
|
+
// Gates the FINAL result, not the stream — `text_delta` already
|
|
103
|
+
// reached the host as the model produced it. A rewrite is
|
|
104
|
+
// therefore a correction, and the event says so; buffering every
|
|
105
|
+
// token to gate the stream itself would trade the streaming UX
|
|
106
|
+
// for the guarantee, which is the host's call, not the SDK's.
|
|
107
|
+
if (params.outputGuardrails && params.outputGuardrails.length > 0) {
|
|
108
|
+
// Read what the run produced WITHOUT settling it. This used to
|
|
109
|
+
// call `markCompleted()` just to materialize the text, which
|
|
110
|
+
// force-marked a cancelled or paused run `completed` merely
|
|
111
|
+
// because a guardrail was configured — the presence of a
|
|
112
|
+
// safety check silently rewrote the run's own outcome.
|
|
113
|
+
const produced = ctx.runMgr.materializeResult()
|
|
114
|
+
const outputVerdict = await runOutputGuardrails(
|
|
115
|
+
params.outputGuardrails,
|
|
116
|
+
{ runId: ctx.runId, output: produced, messages: ctx.runMgr.messages },
|
|
117
|
+
ctx.log,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if (outputVerdict.blocked || outputVerdict.rewritten !== undefined) {
|
|
121
|
+
ctx.runMgr.clearStructuredOutput()
|
|
122
|
+
if (
|
|
123
|
+
params.structuredOutput &&
|
|
124
|
+
outputVerdict.rewritten !== undefined &&
|
|
125
|
+
ctx.runMgr.stopReason === 'end_turn'
|
|
126
|
+
)
|
|
127
|
+
ctx.runMgr.setStopReason('output_guardrail')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (outputVerdict.blocked) {
|
|
131
|
+
await eventTranslator.emitEvent({
|
|
132
|
+
type: 'guardrail_triggered',
|
|
133
|
+
runId: ctx.runId,
|
|
134
|
+
stage: 'output',
|
|
135
|
+
action: 'block',
|
|
136
|
+
...(outputVerdict.name ? { guardrail: outputVerdict.name } : {}),
|
|
137
|
+
...(outputVerdict.reason ? { reason: outputVerdict.reason } : {}),
|
|
138
|
+
})
|
|
139
|
+
yield* eventTranslator.drainPending()
|
|
140
|
+
// Same reasoning as the input-guardrail branch above.
|
|
141
|
+
await ctx.runMgr.recordAudit({
|
|
142
|
+
what: { action: 'guardrail:output', resource: outputVerdict.name },
|
|
143
|
+
outcome: 'refused',
|
|
144
|
+
reason: outputVerdict.reason ?? 'blocked by an output guardrail',
|
|
145
|
+
...(params.persona?.identity.role ? { persona: params.persona.identity.role } : {}),
|
|
146
|
+
})
|
|
147
|
+
ctx.runMgr.setStopReason('output_guardrail')
|
|
148
|
+
ctx.runMgr.setLastError(outputVerdict.reason ?? 'blocked by an output guardrail')
|
|
149
|
+
ctx.runMgr.setResult('')
|
|
150
|
+
} else if (outputVerdict.rewritten !== undefined) {
|
|
151
|
+
await eventTranslator.emitEvent({
|
|
152
|
+
type: 'guardrail_triggered',
|
|
153
|
+
runId: ctx.runId,
|
|
154
|
+
stage: 'output',
|
|
155
|
+
action: 'rewrite',
|
|
156
|
+
...(outputVerdict.name ? { guardrail: outputVerdict.name } : {}),
|
|
157
|
+
...(outputVerdict.reason ? { reason: outputVerdict.reason } : {}),
|
|
158
|
+
})
|
|
159
|
+
yield* eventTranslator.drainPending()
|
|
160
|
+
ctx.runMgr.setResult(outputVerdict.rewritten)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (params.consolidateInto && workingStateManager) {
|
|
165
|
+
const entry = consolidationEntry(workingStateManager.getState(), {
|
|
166
|
+
runId: ctx.runId,
|
|
167
|
+
at: Date.now(),
|
|
168
|
+
})
|
|
169
|
+
if (entry) {
|
|
170
|
+
try {
|
|
171
|
+
const { entry: saved } = await params.consolidateInto.create(entry)
|
|
172
|
+
await eventTranslator.emitEvent({
|
|
173
|
+
type: 'memory_consolidated',
|
|
174
|
+
runId: ctx.runId,
|
|
175
|
+
memoryId: saved.id,
|
|
176
|
+
title: entry.title,
|
|
177
|
+
decisions: workingStateManager.getState().decisions.length,
|
|
178
|
+
discoveries: workingStateManager.getState().discoveries.length,
|
|
179
|
+
failures: workingStateManager.getState().failures.length,
|
|
180
|
+
})
|
|
181
|
+
yield* eventTranslator.drainPending()
|
|
182
|
+
} catch (error) {
|
|
183
|
+
ctx.log.warn('consolidation into the memory store failed', {
|
|
184
|
+
[NAMZU.RUN_ID]: ctx.runId,
|
|
185
|
+
'namzu.memory.error': toErrorMessage(error),
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (ctx.abortController.signal.aborted) ctx.runMgr.markCancelled()
|
|
191
|
+
yield* resultAssembler.completeRun(rootSpan)
|
|
192
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { PLUGIN_NAMESPACE_SEPARATOR } from '../../constants/plugin/index.js'
|
|
1
2
|
import { OUTPUT_SECRET_PATTERNS } from '../../constants/secret-patterns.js'
|
|
3
|
+
import { untrustedEnvelopeBody } from '../../tools/untrusted-envelope.js'
|
|
2
4
|
import type {
|
|
3
5
|
GuardrailVerdict,
|
|
4
6
|
NamedGuardrail,
|
|
5
7
|
OutputGuardrail,
|
|
6
8
|
ToolResultGuardrail,
|
|
9
|
+
ToolResultGuardrailSpec,
|
|
7
10
|
ToolResultVerdict,
|
|
8
11
|
} from '../../types/guardrail/index.js'
|
|
9
12
|
|
|
@@ -169,3 +172,356 @@ export function toolResultInjectionGuardrail(): NamedGuardrail<ToolResultGuardra
|
|
|
169
172
|
},
|
|
170
173
|
}
|
|
171
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Requests shorter than this are not compared against the result.
|
|
178
|
+
*
|
|
179
|
+
* A one-word echo cannot be told from a one-word answer, and the shorter the
|
|
180
|
+
* value the likelier it is to recur in a legitimate result by coincidence:
|
|
181
|
+
* `ls` of a directory holding one entry called `src`, called with
|
|
182
|
+
* `{ path: "src" }`, returns `src`. The value of catching a five-character
|
|
183
|
+
* restatement is nil — nothing rides on it — so the comparison starts where
|
|
184
|
+
* a restatement actually means something.
|
|
185
|
+
*
|
|
186
|
+
* Counted in CODE POINTS, not UTF-16 units. Eight emoji are eight characters
|
|
187
|
+
* to every reader of this file and sixteen to `String.length`, and a floor
|
|
188
|
+
* that lets them through while the docblock says it excludes short values is
|
|
189
|
+
* a floor that is not doing what it says.
|
|
190
|
+
*/
|
|
191
|
+
const MIN_RESTATEMENT_LENGTH = 16
|
|
192
|
+
|
|
193
|
+
/** Whether `text` is at least `minimum` code points long, without counting all of them. */
|
|
194
|
+
function isAtLeastCodePoints(text: string, minimum: number): boolean {
|
|
195
|
+
let count = 0
|
|
196
|
+
for (const _character of text) {
|
|
197
|
+
count += 1
|
|
198
|
+
if (count >= minimum) return true
|
|
199
|
+
}
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ToolResultCorrespondenceOptions {
|
|
204
|
+
/**
|
|
205
|
+
* Which results this screen judges.
|
|
206
|
+
*
|
|
207
|
+
* `'framed'` — the default — is the results that carry the untrusted
|
|
208
|
+
* envelope: content this process did not author, marked as such by
|
|
209
|
+
* `wrapUntrusted`. For a connected server's tool that is every result the
|
|
210
|
+
* adapter returns (see `frameServerResult`), and it is also a host tool
|
|
211
|
+
* that framed its own result the same way — which is the point, and what
|
|
212
|
+
* an earlier `provenance`-based predicate got wrong: the CLI's own remote
|
|
213
|
+
* Exa search frames its answer and was left unscreened by accident of
|
|
214
|
+
* registration, while a fetch was screened by the same accident.
|
|
215
|
+
*
|
|
216
|
+
* `'all'` adds this process's own unframed tools. That is the host's call
|
|
217
|
+
* and not a safe default: a host that asks for it owns the consequences
|
|
218
|
+
* and names its own exceptions in {@link passthroughTools}.
|
|
219
|
+
*/
|
|
220
|
+
readonly scope?: 'framed' | 'all'
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Tools whose answer IS the request, and must not be refused for saying
|
|
224
|
+
* so: a validator returning what it validated, a normaliser returning the
|
|
225
|
+
* normalised form, a search that repeats its query when it found nothing,
|
|
226
|
+
* a fetch whose page body is its own URL.
|
|
227
|
+
*
|
|
228
|
+
* This is the escape hatch the check needs rather than a tuning knob. The
|
|
229
|
+
* screen is right about a tool that was asked a question and handed the
|
|
230
|
+
* question back; it is wrong about a tool whose purpose is to hand
|
|
231
|
+
* something back, and nothing on the context distinguishes the two. The
|
|
232
|
+
* false positive is the way this control dies — the host switches the
|
|
233
|
+
* screen off and it then protects nothing — so the exemption has to be
|
|
234
|
+
* cheap enough to be the first thing reached for.
|
|
235
|
+
*
|
|
236
|
+
* A tool answers to several names and each REGISTRATION SHAPE yields its
|
|
237
|
+
* own set; {@link passthroughToolNames} is the one implementation and
|
|
238
|
+
* spells them out, rather than this option and the check each carrying
|
|
239
|
+
* half of the rule.
|
|
240
|
+
*/
|
|
241
|
+
readonly passthroughTools?: readonly string[]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface RequestText {
|
|
245
|
+
/** Where in the request the text sits, e.g. `city` or `filters.city`. */
|
|
246
|
+
readonly path: string
|
|
247
|
+
readonly text: string
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizeText(text: string): string {
|
|
251
|
+
return text.trim().replace(/\s+/g, ' ')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Every string the call was made with, and where it sat.
|
|
256
|
+
*
|
|
257
|
+
* Strings only. A number is a plausible answer — a count of 10, a port of
|
|
258
|
+
* 8080 — and comparing one would fire on `{ maxResults: 10 }` answered with
|
|
259
|
+
* `10`, which is a legitimate result from a legitimate tool.
|
|
260
|
+
*/
|
|
261
|
+
function collectRequestText(value: unknown, path: string, into: RequestText[]): void {
|
|
262
|
+
if (typeof value === 'string') {
|
|
263
|
+
const text = normalizeText(value)
|
|
264
|
+
if (isAtLeastCodePoints(text, MIN_RESTATEMENT_LENGTH)) into.push({ path, text })
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
if (Array.isArray(value)) {
|
|
268
|
+
for (const [index, item] of value.entries()) {
|
|
269
|
+
collectRequestText(item, `${path}[${index}]`, into)
|
|
270
|
+
}
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (value === null || typeof value !== 'object') return
|
|
274
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
275
|
+
collectRequestText(nested, path === '' ? key : `${path}.${key}`, into)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** How the refusal names the argument that came back, for a caller that cannot see the path syntax. */
|
|
280
|
+
function describeSubject(path: string): string {
|
|
281
|
+
if (path === '') return 'what this call sent'
|
|
282
|
+
const item = /^\[(\d+)\]$/.exec(path)
|
|
283
|
+
if (item) return `item ${item[1]} of the request`
|
|
284
|
+
return `the "${path}" argument this call sent`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Every name a tool answers to, so `passthroughTools` does not require the
|
|
289
|
+
* host to write the connector's own internal spelling.
|
|
290
|
+
*
|
|
291
|
+
* Each registration shape yields its own set, and the sets are NOT
|
|
292
|
+
* interchangeable — an exemption written for one shape does not exempt
|
|
293
|
+
* another:
|
|
294
|
+
*
|
|
295
|
+
* - A server connected directly registers `mcp_<server>_<tool>` (the
|
|
296
|
+
* adapter concatenates `mcp_${serverName}_${toolName}` verbatim). For
|
|
297
|
+
* `mcp_weather-co_lookup` with `server: 'weather-co'`, the accepted names
|
|
298
|
+
* are `mcp_weather-co_lookup`, `lookup`, and `weather-co:lookup`.
|
|
299
|
+
* - A server a plugin contributes registers
|
|
300
|
+
* `<plugin>__mcp__<server>__<tool>`. For
|
|
301
|
+
* `myplugin__mcp__weather__lookup` the accepted names are
|
|
302
|
+
* `myplugin__mcp__weather__lookup`, the bare tail after the last `__`
|
|
303
|
+
* (`lookup`), and `weather:lookup` — NOT `mcp_weather_lookup`, which is
|
|
304
|
+
* the direct shape's spelling of a name this tool does not have.
|
|
305
|
+
* - A tool of this process's own has no server and no namespace, so the
|
|
306
|
+
* registered name is the whole set.
|
|
307
|
+
*
|
|
308
|
+
* The bare name is what the server's manifest calls the tool — the name the
|
|
309
|
+
* operator has in front of them when they read its manifest — and
|
|
310
|
+
* `server:tool` is what a human writes. Both are derived from the registered
|
|
311
|
+
* name and the tool's provenance, so a caller that has only one of the two
|
|
312
|
+
* gets the registered name and nothing else.
|
|
313
|
+
*/
|
|
314
|
+
export function passthroughToolNames(toolName: string, server?: string): readonly string[] {
|
|
315
|
+
const names = new Set<string>([toolName])
|
|
316
|
+
|
|
317
|
+
const separator = toolName.lastIndexOf(PLUGIN_NAMESPACE_SEPARATOR)
|
|
318
|
+
const pluginBare =
|
|
319
|
+
separator < 0 ? undefined : toolName.slice(separator + PLUGIN_NAMESPACE_SEPARATOR.length)
|
|
320
|
+
if (pluginBare !== undefined) names.add(pluginBare)
|
|
321
|
+
|
|
322
|
+
const directPrefix = server === undefined ? undefined : `mcp_${server}_`
|
|
323
|
+
const connectedBare =
|
|
324
|
+
directPrefix !== undefined && toolName.startsWith(directPrefix)
|
|
325
|
+
? toolName.slice(directPrefix.length)
|
|
326
|
+
: undefined
|
|
327
|
+
if (connectedBare !== undefined) names.add(connectedBare)
|
|
328
|
+
|
|
329
|
+
const bare = connectedBare ?? pluginBare
|
|
330
|
+
if (bare !== undefined && server !== undefined) names.add(`${server}:${bare}`)
|
|
331
|
+
|
|
332
|
+
return [...names]
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Refuse a result that is the request rather than an answer to it.
|
|
337
|
+
*
|
|
338
|
+
* #399 recorded that a screen at this boundary cannot know what a tool SHOULD
|
|
339
|
+
* have answered, and the issue that asked for this screen named three
|
|
340
|
+
* mismatches — a weather lookup for one city answering about another, a read
|
|
341
|
+
* of one path returning another's contents, a search returning instructions
|
|
342
|
+
* rather than results. **This screen decides none of the three.** The first
|
|
343
|
+
* two need a declaration of which argument names the subject, and the
|
|
344
|
+
* framework cannot infer one: for a file read the answer is the file's
|
|
345
|
+
* contents, which do not name the path, so the natural rule — an answer
|
|
346
|
+
* mentions its subject — would refuse every ordinary read. The third is what
|
|
347
|
+
* {@link toolResultInjectionGuardrail} already screens for at this same
|
|
348
|
+
* boundary, and re-implementing it here would give the two the same blind spot
|
|
349
|
+
* rather than the different one this adds.
|
|
350
|
+
*
|
|
351
|
+
* What is left is the one correspondence failure that needs no domain
|
|
352
|
+
* knowledge and cannot be an answer: **the result restates the request**. A
|
|
353
|
+
* tool handed a question and returning that question has answered nothing,
|
|
354
|
+
* whatever it is a tool for. The comparison is exact equality after
|
|
355
|
+
* whitespace normalisation against each string the call carried, so a result
|
|
356
|
+
* that says anything extra — the answer plus anything at all — passes.
|
|
357
|
+
*
|
|
358
|
+
* ## Why it judges framed results by default
|
|
359
|
+
*
|
|
360
|
+
* Because the envelope is the only marker that means *this process did not
|
|
361
|
+
* write this*, and the judgment it supports is the one this screen can make.
|
|
362
|
+
* Content arrives framed — by `wrapUntrusted`, through `frameServerResult`
|
|
363
|
+
* for a connector's tool result — when it came from outside: a connected
|
|
364
|
+
* server answered, or a host tool decided its own answer was not this
|
|
365
|
+
* process's to vouch for. A result that IS the request is a signal there.
|
|
366
|
+
*
|
|
367
|
+
* An earlier version judged results whose tool definition carried
|
|
368
|
+
* `provenance`, which is set by the MCP adapter and by nothing else. That
|
|
369
|
+
* predicate and this one agree on every connector result the comparison can
|
|
370
|
+
* act on, and they differ exactly where registration is an accident of the
|
|
371
|
+
* code rather than a fact about the content: the CLI's own remote Exa search
|
|
372
|
+
* frames its answer with this envelope and registers as a host tool, so a
|
|
373
|
+
* search that restated its query was out of scope while a fetch that did was
|
|
374
|
+
* in it. `scope` is a statement about what the screen is willing to judge, so
|
|
375
|
+
* it is expressed in terms of the content rather than of who mounted the
|
|
376
|
+
* tool.
|
|
377
|
+
*
|
|
378
|
+
* What the default leaves alone is this process's own UNFRAMED tools.
|
|
379
|
+
* `web_fetch` returns the page body, and a page whose body IS the URL it was
|
|
380
|
+
* fetched from — a redirect stub, a "you are here" placeholder, an echo
|
|
381
|
+
* endpoint — is a true result from a working tool that frames nothing.
|
|
382
|
+
* `test/…/a-result-that-does-not-answer-its-request` runs the shipped tool
|
|
383
|
+
* set through this screen with real calls and asserts none is refused, which
|
|
384
|
+
* is where that claim is checked rather than argued.
|
|
385
|
+
*
|
|
386
|
+
* `scope: 'all'` adds those too. That is the host's call, and it is the scope
|
|
387
|
+
* that costs something: under it, a fetch-like tool whose answer is its own
|
|
388
|
+
* argument is refused until the host names it in
|
|
389
|
+
* {@link ToolResultCorrespondenceOptions.passthroughTools}.
|
|
390
|
+
*
|
|
391
|
+
* The two other ways out were both worse. An exemption list of builtin NAMES
|
|
392
|
+
* inside this preset is a list that drifts — the next fetch-like tool has to
|
|
393
|
+
* remember to add itself, and a host tool that happens to be called
|
|
394
|
+
* `web_fetch` would be exempted by accident — and a subject declaration on
|
|
395
|
+
* every tool would be a new field on `ToolDefinition` that exists for this one
|
|
396
|
+
* screen.
|
|
397
|
+
*
|
|
398
|
+
* ## Three things it deliberately does not touch
|
|
399
|
+
*
|
|
400
|
+
* - **An empty result for a non-empty request.** Real, and not a signal:
|
|
401
|
+
* a search that matched nothing and a file with nothing in it both return
|
|
402
|
+
* nothing, and refusing either tells the model to stop looking when there
|
|
403
|
+
* was nothing to find.
|
|
404
|
+
* - **A shape contradicting `ToolDefinition.outputSchema`.** The schema is
|
|
405
|
+
* not on {@link ToolResultGuardrailContext} and is documented as shown to
|
|
406
|
+
* the model, never validated. Carrying it and enforcing it are changes to
|
|
407
|
+
* that contract, not this screen's to make.
|
|
408
|
+
* - **A failed call.** On `success: false` the text is a diagnostic, and
|
|
409
|
+
* `screenToolResult` replaces the output when it refuses — so refusing
|
|
410
|
+
* here would trade an echo nobody needs to catch for the error message
|
|
411
|
+
* the model needs to read.
|
|
412
|
+
*
|
|
413
|
+
* The comparison runs on the frame as well as on the text: a connector's
|
|
414
|
+
* result reaches a screen already wrapped by `wrapUntrusted`, so comparing
|
|
415
|
+
* the raw output would never match the one case this is most worth having —
|
|
416
|
+
* a connected server returning the request. Both the whole output and, when
|
|
417
|
+
* it is one wrapped block, the body inside it are compared.
|
|
418
|
+
*
|
|
419
|
+
* Detection is partial and this says so rather than implying coverage: an
|
|
420
|
+
* answer about the wrong subject, an answer that is plausible prose, and a
|
|
421
|
+
* restatement shorter than {@link MIN_RESTATEMENT_LENGTH} all pass.
|
|
422
|
+
*/
|
|
423
|
+
export function toolResultCorrespondenceGuardrail(
|
|
424
|
+
options: ToolResultCorrespondenceOptions = {},
|
|
425
|
+
): NamedGuardrail<ToolResultGuardrail> {
|
|
426
|
+
const passthrough = new Set(options.passthroughTools ?? [])
|
|
427
|
+
const scope = options.scope ?? 'framed'
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
name: 'tool-result-correspondence',
|
|
431
|
+
check: ({ toolName, input, output, success, provenance }): ToolResultVerdict => {
|
|
432
|
+
if (!success) return { action: 'pass' }
|
|
433
|
+
// `output` is typed as a string and a tool that ignores its own
|
|
434
|
+
// contract can hand back something else. Nothing here can compare
|
|
435
|
+
// a number against a request, and a screen that throws fails
|
|
436
|
+
// CLOSED — so the one thing this must not do is refuse a result
|
|
437
|
+
// because it could not read it.
|
|
438
|
+
if (typeof output !== 'string') return { action: 'pass' }
|
|
439
|
+
|
|
440
|
+
// The scope, and the same reader the comparison below uses rather
|
|
441
|
+
// than a second "looks framed" test that could disagree with it.
|
|
442
|
+
//
|
|
443
|
+
// This is a superset of the `provenance`-based predicate it
|
|
444
|
+
// replaced, for every result the comparison can act on. The only
|
|
445
|
+
// tool definition carrying `provenance` is the MCP adapter's, and
|
|
446
|
+
// `frameServerResult` frames every non-empty text it returns — so
|
|
447
|
+
// a connected result that reaches the comparison at all carries a
|
|
448
|
+
// legible frame. The one case the new predicate drops is a
|
|
449
|
+
// connected result with an EMPTY output, which the comparison
|
|
450
|
+
// skipped anyway (`normalizeText('')` is `''`, and an empty string
|
|
451
|
+
// is never a request of the minimum length).
|
|
452
|
+
const body = untrustedEnvelopeBody(output)
|
|
453
|
+
if (scope !== 'all' && body === undefined) return { action: 'pass' }
|
|
454
|
+
|
|
455
|
+
if (
|
|
456
|
+
passthrough.size > 0 &&
|
|
457
|
+
passthroughToolNames(toolName, provenance?.server).some((name) => passthrough.has(name))
|
|
458
|
+
) {
|
|
459
|
+
return { action: 'pass' }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const request: RequestText[] = []
|
|
463
|
+
collectRequestText(input, '', request)
|
|
464
|
+
if (request.length === 0) return { action: 'pass' }
|
|
465
|
+
|
|
466
|
+
// The whole output, and the body when the output is one wrapped
|
|
467
|
+
// block. The two are never the same string — the frame carries the
|
|
468
|
+
// tags — so this is two comparisons, not the same one twice, and
|
|
469
|
+
// the refusal says which one matched.
|
|
470
|
+
for (const [text, insideFrame] of [
|
|
471
|
+
[normalizeText(output), false],
|
|
472
|
+
[normalizeText(body ?? ''), true],
|
|
473
|
+
] as const) {
|
|
474
|
+
if (text === '') continue
|
|
475
|
+
const restated = request.find((entry) => entry.text === text)
|
|
476
|
+
if (!restated) continue
|
|
477
|
+
// The reason names the tool, and says what the comparison
|
|
478
|
+
// actually did. Both were wrong before: a host reading the
|
|
479
|
+
// transcript is the person who can exempt this tool, and
|
|
480
|
+
// "verbatim" was a false claim about a comparison that ran
|
|
481
|
+
// after whitespace normalisation.
|
|
482
|
+
const matched = insideFrame
|
|
483
|
+
? `the text inside the untrusted frame from "${toolName}"`
|
|
484
|
+
: `the result from "${toolName}"`
|
|
485
|
+
return {
|
|
486
|
+
action: 'refuse',
|
|
487
|
+
reason: `${matched} is ${describeSubject(restated.path)}, whitespace-normalised, so it is the request rather than an answer to it`,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return { action: 'pass' }
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The screens a run installs on itself when its host configured none.
|
|
498
|
+
*
|
|
499
|
+
* A DEFAULT, and the reason it is here rather than on
|
|
500
|
+
* {@link ToolRegistryConfig.resultGuardrails}: a host assembles a registry
|
|
501
|
+
* and hands it to `runAgent`, so a registry-constructor default would be the
|
|
502
|
+
* host's to write and this repository's default would reach nobody. The run
|
|
503
|
+
* is the thing that has to carry it, and a run that wants none says so with
|
|
504
|
+
* an empty array — which is the escape hatch, and it exists precisely because
|
|
505
|
+
* the screen below can refuse a result.
|
|
506
|
+
*
|
|
507
|
+
* One screen, scoped to results framed as untrusted. See the preset for why
|
|
508
|
+
* the scope is what it is: an unframed host tool whose answer IS the request —
|
|
509
|
+
* `web_fetch` returning a page whose body is its own URL — is an ordinary
|
|
510
|
+
* result, and a default that refuses those is a default that breaks a working
|
|
511
|
+
* tool.
|
|
512
|
+
*
|
|
513
|
+
* **A default that refuses a legitimate result is a default that gets turned
|
|
514
|
+
* off, so the exemption has to be reachable from wherever the screen is
|
|
515
|
+
* installed.** `toolResultCorrespondenceGuardrail({ passthroughTools })` is
|
|
516
|
+
* how a host writing SDK code says so; a run's `toolResultGuardrails` array
|
|
517
|
+
* is where it substitutes its own configured instance. An application that
|
|
518
|
+
* ships the default without also shipping a way to name an exception has
|
|
519
|
+
* shipped a switch with one position.
|
|
520
|
+
*
|
|
521
|
+
* Frozen, because it is handed to callers who may want to extend it:
|
|
522
|
+
* `[...DEFAULT_TOOL_RESULT_GUARDRAILS, myScreen()]`. A caller that could
|
|
523
|
+
* mutate it would be mutating the default for every other run in the process.
|
|
524
|
+
*/
|
|
525
|
+
export const DEFAULT_TOOL_RESULT_GUARDRAILS: readonly ToolResultGuardrailSpec[] = Object.freeze([
|
|
526
|
+
toolResultCorrespondenceGuardrail(),
|
|
527
|
+
])
|