@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.
Files changed (141) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +3 -0
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +11 -0
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/runAgent.d.ts +14 -0
  9. package/dist/agents/runAgent.d.ts.map +1 -1
  10. package/dist/agents/runAgent.js +3 -0
  11. package/dist/agents/runAgent.js.map +1 -1
  12. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  13. package/dist/manager/agent/lifecycle.js +20 -0
  14. package/dist/manager/agent/lifecycle.js.map +1 -1
  15. package/dist/manager/resident/outbox.d.ts +8 -8
  16. package/dist/manager/resident/store.d.ts +4 -4
  17. package/dist/public-runtime.d.ts +4 -1
  18. package/dist/public-runtime.d.ts.map +1 -1
  19. package/dist/public-runtime.js +13 -1
  20. package/dist/public-runtime.js.map +1 -1
  21. package/dist/public-tools.d.ts +1 -1
  22. package/dist/public-tools.d.ts.map +1 -1
  23. package/dist/public-tools.js +4 -2
  24. package/dist/public-tools.js.map +1 -1
  25. package/dist/registry/tool/execute.d.ts.map +1 -1
  26. package/dist/registry/tool/execute.js +10 -1
  27. package/dist/registry/tool/execute.js.map +1 -1
  28. package/dist/runtime/bidi/session.d.ts +11 -0
  29. package/dist/runtime/bidi/session.d.ts.map +1 -1
  30. package/dist/runtime/bidi/session.js +2 -0
  31. package/dist/runtime/bidi/session.js.map +1 -1
  32. package/dist/runtime/query/cancelled-before-start.d.ts +34 -0
  33. package/dist/runtime/query/cancelled-before-start.d.ts.map +1 -0
  34. package/dist/runtime/query/cancelled-before-start.js +152 -0
  35. package/dist/runtime/query/cancelled-before-start.js.map +1 -0
  36. package/dist/runtime/query/checkpoint.d.ts +21 -0
  37. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  38. package/dist/runtime/query/checkpoint.js +23 -0
  39. package/dist/runtime/query/checkpoint.js.map +1 -1
  40. package/dist/runtime/query/executor/tool-call-admission.d.ts +57 -0
  41. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -0
  42. package/dist/runtime/query/executor/tool-call-admission.js +373 -0
  43. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -0
  44. package/dist/runtime/query/executor.d.ts +76 -35
  45. package/dist/runtime/query/executor.d.ts.map +1 -1
  46. package/dist/runtime/query/executor.js +52 -380
  47. package/dist/runtime/query/executor.js.map +1 -1
  48. package/dist/runtime/query/finalize-run.d.ts +55 -0
  49. package/dist/runtime/query/finalize-run.d.ts.map +1 -0
  50. package/dist/runtime/query/finalize-run.js +113 -0
  51. package/dist/runtime/query/finalize-run.js.map +1 -0
  52. package/dist/runtime/query/guardrail-presets.d.ts +187 -1
  53. package/dist/runtime/query/guardrail-presets.d.ts.map +1 -1
  54. package/dist/runtime/query/guardrail-presets.js +298 -0
  55. package/dist/runtime/query/guardrail-presets.js.map +1 -1
  56. package/dist/runtime/query/index.d.ts +18 -9
  57. package/dist/runtime/query/index.d.ts.map +1 -1
  58. package/dist/runtime/query/index.js +241 -893
  59. package/dist/runtime/query/index.js.map +1 -1
  60. package/dist/runtime/query/iteration/index.d.ts +6 -161
  61. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  62. package/dist/runtime/query/iteration/index.js +23 -523
  63. package/dist/runtime/query/iteration/index.js.map +1 -1
  64. package/dist/runtime/query/iteration/outstanding-work.d.ts +158 -0
  65. package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -0
  66. package/dist/runtime/query/iteration/outstanding-work.js +365 -0
  67. package/dist/runtime/query/iteration/outstanding-work.js.map +1 -0
  68. package/dist/runtime/query/iteration/phases/plan.d.ts.map +1 -1
  69. package/dist/runtime/query/iteration/phases/plan.js +13 -2
  70. package/dist/runtime/query/iteration/phases/plan.js.map +1 -1
  71. package/dist/runtime/query/iteration/step-shaping.d.ts +41 -0
  72. package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -0
  73. package/dist/runtime/query/iteration/step-shaping.js +184 -0
  74. package/dist/runtime/query/iteration/step-shaping.js.map +1 -0
  75. package/dist/runtime/query/prepare-run.d.ts +94 -0
  76. package/dist/runtime/query/prepare-run.d.ts.map +1 -0
  77. package/dist/runtime/query/prepare-run.js +589 -0
  78. package/dist/runtime/query/prepare-run.js.map +1 -0
  79. package/dist/runtime/query/release-run.d.ts +56 -0
  80. package/dist/runtime/query/release-run.d.ts.map +1 -0
  81. package/dist/runtime/query/release-run.js +101 -0
  82. package/dist/runtime/query/release-run.js.map +1 -0
  83. package/dist/runtime/query/resume-pending.d.ts +112 -1
  84. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  85. package/dist/runtime/query/resume-pending.js +133 -0
  86. package/dist/runtime/query/resume-pending.js.map +1 -1
  87. package/dist/runtime/query/tooling.d.ts +2 -0
  88. package/dist/runtime/query/tooling.d.ts.map +1 -1
  89. package/dist/runtime/query/tooling.js +3 -0
  90. package/dist/runtime/query/tooling.js.map +1 -1
  91. package/dist/store/evidence/compaction-archive.d.ts +2 -2
  92. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  93. package/dist/tools/coordinator/agent.js +17 -2
  94. package/dist/tools/coordinator/agent.js.map +1 -1
  95. package/dist/tools/coordinator/index.d.ts.map +1 -1
  96. package/dist/tools/coordinator/index.js +17 -3
  97. package/dist/tools/coordinator/index.js.map +1 -1
  98. package/dist/tools/untrusted-envelope.d.ts +35 -0
  99. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  100. package/dist/tools/untrusted-envelope.js +91 -3
  101. package/dist/tools/untrusted-envelope.js.map +1 -1
  102. package/dist/types/agent/base.d.ts +23 -0
  103. package/dist/types/agent/base.d.ts.map +1 -1
  104. package/dist/types/agent/task.d.ts +19 -0
  105. package/dist/types/agent/task.d.ts.map +1 -1
  106. package/dist/types/run/config.d.ts +12 -5
  107. package/dist/types/run/config.d.ts.map +1 -1
  108. package/dist/types/tool/index.d.ts +19 -0
  109. package/dist/types/tool/index.d.ts.map +1 -1
  110. package/dist/types/tool/index.js.map +1 -1
  111. package/package.json +1 -1
  112. package/src/agents/ReactiveAgent.ts +3 -0
  113. package/src/agents/SupervisorAgent.ts +11 -0
  114. package/src/agents/runAgent.ts +18 -0
  115. package/src/manager/agent/lifecycle.ts +22 -0
  116. package/src/public-runtime.ts +14 -0
  117. package/src/public-tools.ts +8 -2
  118. package/src/registry/tool/execute.ts +9 -1
  119. package/src/runtime/bidi/session.ts +13 -0
  120. package/src/runtime/query/cancelled-before-start.ts +189 -0
  121. package/src/runtime/query/checkpoint.ts +22 -0
  122. package/src/runtime/query/executor/tool-call-admission.ts +473 -0
  123. package/src/runtime/query/executor.ts +76 -442
  124. package/src/runtime/query/finalize-run.ts +192 -0
  125. package/src/runtime/query/guardrail-presets.ts +356 -0
  126. package/src/runtime/query/index.ts +287 -1011
  127. package/src/runtime/query/iteration/index.ts +40 -586
  128. package/src/runtime/query/iteration/outstanding-work.ts +386 -0
  129. package/src/runtime/query/iteration/phases/plan.ts +18 -2
  130. package/src/runtime/query/iteration/step-shaping.ts +271 -0
  131. package/src/runtime/query/prepare-run.ts +718 -0
  132. package/src/runtime/query/release-run.ts +168 -0
  133. package/src/runtime/query/resume-pending.ts +158 -0
  134. package/src/runtime/query/tooling.ts +5 -0
  135. package/src/tools/coordinator/agent.ts +17 -2
  136. package/src/tools/coordinator/index.ts +17 -3
  137. package/src/tools/untrusted-envelope.ts +94 -3
  138. package/src/types/agent/base.ts +24 -0
  139. package/src/types/agent/task.ts +20 -0
  140. package/src/types/run/config.ts +12 -5
  141. 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
+ ])