@jini-ai/daemon 0.5.2 → 0.5.4

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 (78) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/agent-executor/contracts.d.ts +26 -20
  3. package/dist/agent-executor/contracts.js +0 -9
  4. package/dist/agent-executor/event-translation.d.ts +2 -2
  5. package/dist/agent-executor/event-translation.js +1 -2
  6. package/dist/agent-executor/events.d.ts +9 -4
  7. package/dist/agent-executor/events.js +29 -7
  8. package/dist/agent-executor/handoff.d.ts +1 -1
  9. package/dist/agent-executor/handoff.js +0 -1
  10. package/dist/agent-executor/launch.d.ts +1 -1
  11. package/dist/agent-executor/launch.js +24 -1
  12. package/dist/agent-executor/mcp-bridge.d.ts +2 -2
  13. package/dist/agent-executor/mcp-bridge.js +0 -2
  14. package/dist/agent-executor/model-receipts.d.ts +6 -0
  15. package/dist/agent-executor/model-receipts.js +26 -0
  16. package/dist/agent-executor.d.ts +8 -1663
  17. package/dist/agent-executor.js +8 -3005
  18. package/dist/attachment-content.d.ts +45 -0
  19. package/dist/attachment-content.js +40 -0
  20. package/dist/core/read-only-tools.d.ts +130 -0
  21. package/dist/core/read-only-tools.js +115 -0
  22. package/dist/core/tool-audit.d.ts +131 -0
  23. package/dist/core/tool-audit.js +113 -0
  24. package/dist/http/active-context.d.ts +2 -2
  25. package/dist/http/agents.d.ts +8 -2
  26. package/dist/http/agents.js +6 -2
  27. package/dist/http/attachments.d.ts +16 -7
  28. package/dist/http/attachments.js +3 -2
  29. package/dist/http/component-catalog.d.ts +2 -2
  30. package/dist/http/connectors.d.ts +17 -17
  31. package/dist/http/daemon-status.d.ts +2 -2
  32. package/dist/http/db-ops.d.ts +3 -3
  33. package/dist/http/delegated-tools.d.ts +1 -1
  34. package/dist/http/delegated-tools.js +1 -1
  35. package/dist/http/frontend-sessions.d.ts +1 -1
  36. package/dist/http/health.d.ts +6 -6
  37. package/dist/http/host-tools.d.ts +2 -2
  38. package/dist/http/media.d.ts +4 -4
  39. package/dist/http/memory.d.ts +15 -15
  40. package/dist/http/remote-run-events.d.ts +2 -2
  41. package/dist/http/research.d.ts +1 -1
  42. package/dist/http/routines.d.ts +7 -7
  43. package/dist/http/run-credentials.d.ts +2 -2
  44. package/dist/http/run-credentials.js +2 -2
  45. package/dist/http/runs.d.ts +4 -4
  46. package/dist/http/terminals.d.ts +6 -6
  47. package/dist/http/tool-catalog.d.ts +2 -2
  48. package/dist/http/xai.d.ts +6 -6
  49. package/dist/index.d.ts +76 -571
  50. package/dist/index.js +76 -571
  51. package/dist/read-only-tools.d.ts +1 -129
  52. package/dist/read-only-tools.js +2 -114
  53. package/dist/run-credentials.d.ts +47 -23
  54. package/dist/run-credentials.js +24 -8
  55. package/dist/run-lifecycle.d.ts +1 -1
  56. package/dist/run-lifecycle.js +6 -5
  57. package/dist/scheduler.d.ts +5 -0
  58. package/dist/scheduler.js +13 -1
  59. package/dist/session-coordination.d.ts +58 -24
  60. package/dist/session-coordination.js +40 -12
  61. package/dist/store/event-log/sqlite.js +3 -3
  62. package/dist/surface-exchanges.d.ts +207 -61
  63. package/dist/surface-exchanges.js +167 -20
  64. package/dist/tool-audit.d.ts +1 -113
  65. package/dist/tool-audit.js +2 -94
  66. package/dist/tool-recovery/diagnostics.d.ts +132 -0
  67. package/dist/tool-recovery/diagnostics.js +110 -0
  68. package/dist/tool-recovery/federated-refusal.d.ts +92 -0
  69. package/dist/tool-recovery/federated-refusal.js +152 -0
  70. package/dist/tool-recovery/index.d.ts +6 -0
  71. package/dist/tool-recovery/index.js +6 -0
  72. package/dist/tool-recovery/recovery.d.ts +49 -0
  73. package/dist/tool-recovery/recovery.js +419 -0
  74. package/dist/tool-recovery/redaction.d.ts +166 -0
  75. package/dist/tool-recovery/redaction.js +192 -0
  76. package/dist/tool-recovery/stack.d.ts +69 -0
  77. package/dist/tool-recovery/stack.js +62 -0
  78. package/package.json +35 -12
@@ -0,0 +1,419 @@
1
+ import { isIssuedToolFailureDiagnostic } from "./diagnostics.js";
2
+ import { askOnce, SURFACE_DISMISSED_PARAM, SURFACE_EXCHANGE_ID_PARAM, } from "../surface-exchanges.js";
3
+ import { refuseNonReadOnlyDispatch } from "../core/read-only-tools.js";
4
+ /**
5
+ * @file The CONSUMER of `contracts/core/tool-failure-diagnostics.ts`'s `ToolFailureDiagnostic`
6
+ * contract — the generic ask -> apply -> retry-once loop that contract's own header describes as the
7
+ * payoff for adopting it ("one generic loop ... can handle a failure mode nobody wrote code for, using
8
+ * the identical mechanism that already handles one somebody did"). Before this file, `hint`/
9
+ * `remedyToolId` were read by nobody but the MODEL, acting on prose in a tool's own catalog
10
+ * description (`custom_credential_verify`/`custom_credential_make_request`'s "ask the human ...
11
+ * retry ONCE" wording). This file makes the same recovery a real mechanism that runs whether or not
12
+ * the model happens to follow that prose.
13
+ *
14
+ * ## The seam: a `ToolExecutor` decorator, not a tool
15
+ *
16
+ * `withToolFailureRecovery` wraps `@jini-ai/daemon`'s `ToolExecutor` — the SAME kind of drop-in
17
+ * `tool-executor-audit.ts`'s `withToolAttemptAudit` already is, for the same reason: this is the one
18
+ * layer that (a) sees every completed tool result BEFORE it reaches the model, (b) is handed the
19
+ * live `emitSurface` a handler would use to raise a human-facing surface, and (c) can re-invoke a
20
+ * tool — the remedy, then the original again — with the SAME `principal`/`run`/`signal` the first
21
+ * call had. No domain module (a `tool-registrations.ts`) has all three: a domain handler sees only
22
+ * its OWN result, not another domain's `remedyToolId`. The tool-registration layer was considered and
23
+ * rejected for exactly that reason — `custom_credential_set_username` is a different domain from
24
+ * whatever OTHER tool a future diagnostic might name, and this loop has to work for a diagnostic
25
+ * nobody has written yet, not just this one.
26
+ *
27
+ * Composed OUTSIDE `withToolAttemptAudit` in `agent-daemon-server.ts` (audit wraps the bare executor;
28
+ * this wraps the audited one) so the remedy call and the retry are each their own audited attempt,
29
+ * not lost inside one outer "completed" row — see that file's own wiring comment.
30
+ *
31
+ * ## What triggers it
32
+ *
33
+ * Only a `status: 'completed'` execution (a handler that returned normally) is even inspected — a
34
+ * thrown error has no structured output to carry a diagnostic in, and `ToolFailureDiagnostic` was
35
+ * never meant to model that case (see that file's own header, facet 1). The completed output is
36
+ * walked (bounded, see {@link findActionableDiagnostic}) for the first plain object the tool ISSUED
37
+ * (`issueToolFailureDiagnostic` — so stored content shaped like a diagnostic never counts) carrying BOTH a
38
+ * string `hint` AND a string `remedyToolId`. `hint` with no `remedyToolId` is deliberately NOT
39
+ * actionable here — the general contract's own header says as much ("no registered tool can supply
40
+ * the fix yet ... still worth surfacing to a human who can act on it by hand"): a human reading the
41
+ * model's own report of the failure is that "by hand" path; this loop only exists for the case where
42
+ * a tool id names something IT can do about it.
43
+ *
44
+ * ## What it asks for, and why that can never be a secret
45
+ *
46
+ * The loop does not know what a `hint` means — it is prose. What it CAN know, structurally, is the
47
+ * remedy tool's own published {@link ToolDescriptor.inputSchema}: which fields it requires, and which
48
+ * of those the ORIGINAL failing call's input already supplies (by matching key name — e.g. both
49
+ * `custom_credential_make_request` and `custom_credential_set_username` name a `label`). Whatever is
50
+ * required but not already known is the ONE thing worth asking a human for — see
51
+ * {@link planRemedyCall}. This is deliberately narrow: more than one unknown field, or a field whose
52
+ * schema type isn't plain text, means this loop has no safe, generic way to ask for it, and it backs
53
+ * off entirely rather than invent a shape.
54
+ *
55
+ * That narrowness is what keeps a secret out of this loop's own text field, BY CONSTRUCTION, not by a
56
+ * rule someone has to remember: `custom_credential_set_token`'s entire schema is `{label}`, and
57
+ * `label` is already known from the original call — so `planRemedyCall` computes ZERO fields to ask
58
+ * for, and this loop calls it with nothing but the carried-forward `label`. The token itself, if that
59
+ * tool is ever named as a `remedyToolId`, would still only ever reach the server the way that tool's
60
+ * OWN handler already collects it (its own masked form, opened when THIS loop invokes it) — never
61
+ * through a field this loop rendered, and never through a model-issued call's params. See
62
+ * `credentialed-request.ts`'s header for the incident this reasoning was written against.
63
+ *
64
+ * ## The one-cycle guard — structural, not a counter
65
+ *
66
+ * `execute` below calls `inner.execute` up to three times (the original call, the remedy call, the
67
+ * retry) and NEVER calls itself or the wrapper it returns. There is therefore no code path by which a
68
+ * hint surfacing on the remedy call's own result, or on the retry's result, can trigger a second ask
69
+ * -> apply -> retry cycle: the scan-for-a-diagnostic step only ever runs once per external
70
+ * `execute()` invocation, because it is written exactly once, outside any loop or recursion. A future
71
+ * edit would have to ADD a call from inside this function back into itself to reintroduce a second
72
+ * cycle — it cannot happen by a counter being reset, a flag being missed, or any other stateful
73
+ * mistake, because no state is involved at all.
74
+ *
75
+ * ## What it never does
76
+ *
77
+ * - Never suppresses the original failure: every early return hands back `result` — the exact object
78
+ * `inner.execute` produced for the original call, provider body and status included — untouched.
79
+ * The one return that is not byte-identical still carries that exact `output`: a remedy refused
80
+ * because this execution is read-only-constrained adds an `error` explaining that the recovery was
81
+ * not attempted (see `read-only-tool-constraint.ts`), because returning the original silently would
82
+ * be its own lie — "nothing was written" and "nothing was ever going to be tried" are different
83
+ * facts, and the caller can only act on the second one if it is told.
84
+ * - Never runs a remedy that writes on behalf of a caller that asked for reads only. The dispatch
85
+ * below would be refused by `withReadOnlyToolConstraint` in any case — that gate is the
86
+ * enforcement, and it sits innermost precisely so it does not depend on this file remembering —
87
+ * but this loop asks the same single question first so no human is ever prompted for an answer
88
+ * that would then be thrown away.
89
+ * - Never invents an answer: a declined, dismissed, expired, or blank response to the recovery surface
90
+ * is treated as "do not proceed," not as licence to guess.
91
+ * - Never adds cost to the overwhelmingly common no-hint case: the scan is one bounded, pure walk of
92
+ * an already-in-memory value, no I/O, no surface, no second call.
93
+ *
94
+ * Architectural role: `src/assistant` composition-layer adapter, mirroring `tool-executor-audit.ts`'s
95
+ * own role — depends on the domain-agnostic surface-exchange contract and the kernel's own
96
+ * `ToolExecutor`/`ToolRegistry` shapes, never on any domain feature.
97
+ */
98
+ /** Bounds {@link findActionableDiagnostic}'s walk of a handler's output — generous for any real tool
99
+ * result in this codebase (a handful of fields, one or two levels of nesting) while keeping an
100
+ * adversarially large or deep result cheap to give up on rather than a source of unbounded work. */
101
+ const MAX_DIAGNOSTIC_SCAN_DEPTH = 6;
102
+ const MAX_DIAGNOSTIC_SCAN_NODES = 500;
103
+ function isPlainObject(value) {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value);
105
+ }
106
+ function isActionableDiagnostic(value) {
107
+ return typeof value.hint === "string" && value.hint.length > 0 && typeof value.remedyToolId === "string" && value.remedyToolId.length > 0;
108
+ }
109
+ /**
110
+ * Whether a remedy tool's own COMPLETED result reports that it did not actually apply the fix. A
111
+ * `completed` execution status means only that the remedy tool ran without being denied or throwing —
112
+ * several human-gated write tools in this codebase report a decline, an expiry, or a validation
113
+ * failure as a normal `{saved: false, reason, ...}` RESULT rather than a non-completed status (see
114
+ * `custom_credential_set_token`, `external_mcp_*_save`, and the S3-compatible publish-credential save,
115
+ * each returning `{saved: true}` on success or `{saved: false, ...}` for every other outcome). This
116
+ * loop must not read `completed` as `succeeded` for any of them, or it would retry the original call
117
+ * as if the fix had been applied when it had not.
118
+ *
119
+ * @complexity O(1).
120
+ */
121
+ function remedyReportedFailure(output) {
122
+ return isPlainObject(output) && (output["saved"] === false || output["created"] === false || output["cancelled"] === true || output["connected"] === false);
123
+ }
124
+ /** `true` once the bounded walk should stop descending — depth past {@link MAX_DIAGNOSTIC_SCAN_DEPTH}
125
+ * or the node budget exhausted. Pulled out of {@link walkForDiagnostic} so that function's own guard
126
+ * is a single call instead of a compound `||` condition. */
127
+ function diagnosticScanLimitReached(depth, scanState) {
128
+ return depth > MAX_DIAGNOSTIC_SCAN_DEPTH || scanState.visited >= MAX_DIAGNOSTIC_SCAN_NODES;
129
+ }
130
+ /** The children {@link walkForDiagnostic} should recurse into for `value` — an object's own values, an
131
+ * array's own elements, or `undefined` for anything else (a leaf, nothing to descend into). */
132
+ function diagnosticScanChildren(value) {
133
+ if (isPlainObject(value))
134
+ return Object.values(value);
135
+ if (Array.isArray(value))
136
+ return value;
137
+ return undefined;
138
+ }
139
+ /**
140
+ * One step of the bounded walk for {@link findActionableDiagnostic} — checks `value` itself, then
141
+ * recurses into its children (via {@link diagnosticScanChildren}) until a match is found or the walk
142
+ * is exhausted.
143
+ *
144
+ * @complexity O(min(n, {@link MAX_DIAGNOSTIC_SCAN_NODES})) where n is the subtree's own node count.
145
+ */
146
+ function walkForDiagnostic(value, depth, scanState) {
147
+ if (diagnosticScanLimitReached(depth, scanState))
148
+ return undefined;
149
+ scanState.visited += 1;
150
+ // Issued, not merely shaped like one: stored content a tool returns (a post's `bodyJson`, a
151
+ // widget's props) is plain JSON and can carry `{hint, remedyToolId}` too. Only a diagnostic the
152
+ // tool itself issued for a failure may raise the recovery dialog — see `issueToolFailureDiagnostic`.
153
+ if (isPlainObject(value) && isIssuedToolFailureDiagnostic({ value }, {}) && isActionableDiagnostic(value)) {
154
+ return value;
155
+ }
156
+ const children = diagnosticScanChildren(value);
157
+ if (!children)
158
+ return undefined;
159
+ for (const child of children) {
160
+ const found = walkForDiagnostic(child, depth + 1, scanState);
161
+ if (found)
162
+ return found;
163
+ }
164
+ return undefined;
165
+ }
166
+ /**
167
+ * Walks a completed tool call's raw output for the first nested object carrying an actionable
168
+ * diagnostic (see {@link isActionableDiagnostic}) — domain-agnostic by construction: it knows nothing
169
+ * about `authDiagnostic`, credentials, or any other field name a concrete diagnostic type happens to
170
+ * nest itself under, only the two-field SHAPE the general contract defines.
171
+ *
172
+ * @param output - `ToolExecutionResult.output` from a `'completed'` execution.
173
+ * @returns The diagnostic, or `undefined` if none is found within the bounded walk.
174
+ * @complexity O(min(n, {@link MAX_DIAGNOSTIC_SCAN_NODES})) where n is the output's own node count.
175
+ */
176
+ function findActionableDiagnostic(output) {
177
+ return walkForDiagnostic(output, 0, { visited: 0 });
178
+ }
179
+ function isJsonPrimitive(value) {
180
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
181
+ }
182
+ /** The remedy tool's own required-field list and property schemas, pulled out of its `inputSchema` —
183
+ * or `undefined` if the tool isn't registered or its schema isn't object-shaped at all. Split out of
184
+ * {@link planRemedyCall} so that function's own guard clauses read as one step per concern. */
185
+ function resolveRemedySchemaShape(descriptor) {
186
+ if (!descriptor || !isPlainObject(descriptor.inputSchema))
187
+ return undefined;
188
+ const schema = descriptor.inputSchema;
189
+ const required = Array.isArray(schema["required"]) ? schema["required"].filter((k) => typeof k === "string") : [];
190
+ const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
191
+ return { required, properties };
192
+ }
193
+ function resolveAskKey(required, knownInput) {
194
+ const missing = required.filter((key) => !(key in knownInput));
195
+ if (missing.length > 1)
196
+ return { ok: false };
197
+ return { ok: true, askKey: missing[0] };
198
+ }
199
+ /** Every required field except `askKey`, carried forward verbatim from `knownInput` — or `undefined`
200
+ * if one of them isn't a JSON primitive (nothing safe to re-forward without guessing at its shape). */
201
+ function buildCarryForward(input) {
202
+ const { required, askKey, knownInput } = input;
203
+ const carryForward = {};
204
+ for (const key of required) {
205
+ if (key === askKey)
206
+ continue;
207
+ const value = knownInput[key];
208
+ if (!isJsonPrimitive(value))
209
+ return undefined;
210
+ carryForward[key] = value;
211
+ }
212
+ return carryForward;
213
+ }
214
+ function isTextLikeSchemaType(type) {
215
+ return type === "string" || (Array.isArray(type) && type.every((t) => t === "string" || t === "null"));
216
+ }
217
+ /** Builds the one field this loop asks a human for, from `askKey`'s own schema entry — or `undefined`
218
+ * if that schema isn't plain-text-shaped (this loop never invents how to render a number, boolean,
219
+ * enum, or nested-object field). */
220
+ function resolveAskFor(input) {
221
+ const { askKey, properties } = input;
222
+ const propSchema = properties[askKey];
223
+ if (!isPlainObject(propSchema) || !isTextLikeSchemaType(propSchema["type"]))
224
+ return undefined;
225
+ const description = propSchema["description"];
226
+ const prompt = typeof description === "string" && description.trim() !== "" ? description : askKey;
227
+ return { key: askKey, prompt };
228
+ }
229
+ /**
230
+ * Works out whether — and how — this loop can safely build a call to `remedyToolId`, from nothing but
231
+ * that tool's own published `inputSchema` and the ORIGINAL failing call's input. Returns `undefined`
232
+ * whenever the shape does not fit the one pattern this loop knows how to act on safely: this is the
233
+ * function that decides "not cleanly possible" for one diagnostic, without ever guessing past it.
234
+ *
235
+ * @param input.descriptor - The remedy tool's registry descriptor, or `undefined` if `remedyToolId`
236
+ * is not actually registered (a diagnostic naming a tool that does not exist).
237
+ * @param input.originalInput - The input the ORIGINAL (failing) call was made with.
238
+ * @returns `undefined` if: the remedy tool isn't registered or has no object-shaped `inputSchema`;
239
+ * more than one of its required fields is unknown (no single honest question covers all of them);
240
+ * a required field already known is not a JSON primitive (nothing to safely re-forward); or the one
241
+ * unknown field's own schema type is not plain-text-shaped (this loop never invents how to render an
242
+ * unfamiliar field kind).
243
+ * @complexity O(k) in the remedy schema's own required-field count (small, fixed per tool).
244
+ */
245
+ function planRemedyCall(input) {
246
+ const { remedyToolId, descriptor, originalInput } = input;
247
+ const shape = resolveRemedySchemaShape(descriptor);
248
+ if (!shape)
249
+ return undefined;
250
+ const knownInput = isPlainObject(originalInput) ? originalInput : {};
251
+ const missingField = resolveAskKey(shape.required, knownInput);
252
+ if (!missingField.ok)
253
+ return undefined;
254
+ const carryForward = buildCarryForward({ required: shape.required, askKey: missingField.askKey, knownInput });
255
+ if (!carryForward)
256
+ return undefined;
257
+ if (missingField.askKey === undefined)
258
+ return { remedyToolId, carryForward };
259
+ const askFor = resolveAskFor({ askKey: missingField.askKey, properties: shape.properties });
260
+ if (!askFor)
261
+ return undefined;
262
+ return { remedyToolId, carryForward, askFor };
263
+ }
264
+ /** Card schemas accept metadata only. Reject secret-shaped prefills before even asking. */
265
+ function planCredentialSetup({ diagnostic, descriptor, deps }, _optional = {}) {
266
+ const shape = resolveRemedySchemaShape(descriptor);
267
+ if (!shape || diagnostic.setupToolId !== diagnostic.remedyToolId || !diagnostic.prefill)
268
+ return undefined;
269
+ const prefill = diagnostic.prefill;
270
+ for (const [key, value] of Object.entries(prefill)) {
271
+ if (!(key in shape.properties) || /token|secret|password|authorization|env|headers|body|key/i.test(key))
272
+ return undefined;
273
+ if (!isJsonPrimitive(value) || (typeof value === "string" && deps.containsSecret({ text: value })))
274
+ return undefined;
275
+ if (typeof value === "string") {
276
+ try {
277
+ deps.assertCredentialFreeField({ value, field: key }, {});
278
+ }
279
+ catch {
280
+ return undefined;
281
+ }
282
+ }
283
+ }
284
+ if (shape.required.some(key => !(key in prefill)))
285
+ return undefined;
286
+ return { remedyToolId: diagnostic.remedyToolId, carryForward: prefill };
287
+ }
288
+ /**
289
+ * Sends the recovery surface and waits for the human's answer — plain `askOnce`, matching
290
+ * `custom_credential_make_request`'s own DELETE-gate precedent (`resolveMakeRequestDeleteDecision`)
291
+ * rather than `askThenReport`: this loop's surface is an ask ABOUT what to try next, not a rendering
292
+ * of a secret's own submission state, so there is nothing here `askThenReport` was built to correct.
293
+ *
294
+ * @complexity O(1) plus whatever `askOnce` itself costs.
295
+ */
296
+ async function resolveRecoveryDecision(exchange, ui, askForKey) {
297
+ const answer = await askOnce({ exchange, emission: { channel: "mcp-ui", payload: { resource: ui } } });
298
+ if (answer.status !== "received")
299
+ return { proceed: false };
300
+ if (answer.params[SURFACE_DISMISSED_PARAM] === true)
301
+ return { proceed: false };
302
+ if (askForKey === undefined)
303
+ return { proceed: true };
304
+ const raw = answer.params[askForKey];
305
+ const answerValue = typeof raw === "string" ? raw.trim() : "";
306
+ // Never invent an answer: a blank submission is treated as "do not proceed," not as a value to try.
307
+ if (answerValue === "")
308
+ return { proceed: false };
309
+ return { proceed: true, answerValue };
310
+ }
311
+ function resolveRecoveryGate(input) {
312
+ const { diagnostic, principal, originalInput, registry, result, deps } = input;
313
+ const readOnlyRefusal = refuseNonReadOnlyDispatch({ principal, toolId: diagnostic.remedyToolId, registry, messages: deps.readOnlyMessages }, {});
314
+ if (readOnlyRefusal !== null)
315
+ return { attempt: false, outcome: { ...result, error: deps.formatReadOnlyRemedyRefusal({ refusal: readOnlyRefusal }) } };
316
+ const descriptor = registry.list({}).find((d) => d.id === diagnostic.remedyToolId);
317
+ const plan = diagnostic.setupToolId
318
+ ? planCredentialSetup({ diagnostic, descriptor, deps }, {})
319
+ : planRemedyCall({ remedyToolId: diagnostic.remedyToolId, descriptor, originalInput });
320
+ if (!plan)
321
+ return { attempt: false, outcome: result };
322
+ return { attempt: true, plan };
323
+ }
324
+ /** Wires the recovery surface's exchange to close if the caller aborts mid-ask, delegates to
325
+ * {@link resolveRecoveryDecision} for the actual answer, and always tears the listener back down —
326
+ * pulled out of `execute` so the abort-wiring's own optional chaining does not count toward that
327
+ * method's own complexity. */
328
+ async function collectRecoveryDecision(input) {
329
+ const { exchange, ui, askForKey, signal } = input;
330
+ const closeOnAbort = () => exchange.close({});
331
+ signal?.addEventListener("abort", closeOnAbort, { once: true });
332
+ try {
333
+ return await resolveRecoveryDecision(exchange, ui, askForKey);
334
+ }
335
+ finally {
336
+ signal?.removeEventListener("abort", closeOnAbort);
337
+ }
338
+ }
339
+ /** The remedy tool's own input, built from the accepted {@link RemedyPlan}: the planned carry-forward
340
+ * fields plus, when one was asked for and answered, the human's own value under that field's key —
341
+ * never anything else from the delivered surface response (see this file's header, "What it never
342
+ * does"). */
343
+ function buildRemedyInput(plan, decision) {
344
+ const remedyInput = { ...plan.carryForward };
345
+ if (plan.askFor && decision.answerValue !== undefined)
346
+ remedyInput[plan.askFor.key] = decision.answerValue;
347
+ return remedyInput;
348
+ }
349
+ /**
350
+ * Wraps `inner` with the generic ask -> apply -> retry-once recovery loop described in this file's
351
+ * header. A drop-in `ToolExecutor` — every method other than `execute` delegates straight through,
352
+ * matching `tool-executor-audit.ts`'s own `withToolAttemptAudit` shape.
353
+ *
354
+ * @complexity The no-hint path (the overwhelming majority) is one `inner.execute` call plus one bounded
355
+ * output scan — O(1) beyond `inner`'s own cost. The recovery path adds at most two further
356
+ * `inner.execute` calls (the remedy, the retry) and one surface round trip.
357
+ */
358
+ export function withToolFailureRecovery({ inner, ...deps }, _optional = {}) {
359
+ return {
360
+ execute: async (required, optional = {}) => {
361
+ const { principal, toolId, input } = required;
362
+ const { signal, emitSurface } = optional;
363
+ const result = await inner.execute(required, optional);
364
+ // SILENCE STAYS FREE: everything but a completed call whose output carries an actionable
365
+ // diagnostic returns `result` exactly as `inner` produced it — no extra work, no surface, no
366
+ // added latency. This is the overwhelmingly common case.
367
+ if (result.status !== "completed")
368
+ return result;
369
+ const diagnostic = findActionableDiagnostic(result.output);
370
+ if (!diagnostic)
371
+ return result;
372
+ // No channel to ask through (a headless/synthetic caller) — never guess, never suppress.
373
+ if (!emitSurface)
374
+ return result;
375
+ // A read-only execution may not be recovered by a remedy that writes, and this diagnostic's
376
+ // shape must fit the one pattern this loop can safely act on — see `resolveRecoveryGate` and
377
+ // `planRemedyCall`'s own docs for every reason either can decide not to proceed.
378
+ // `withReadOnlyToolConstraint` would refuse the dispatch below regardless — it is the
379
+ // enforcement, and it does not depend on this check — but a refusal discovered THERE arrives
380
+ // only after a human has already been asked to fill in a form whose answer is then thrown away.
381
+ const gate = resolveRecoveryGate({ diagnostic, principal, originalInput: input, registry: deps.registry, result, deps });
382
+ if (!gate.attempt)
383
+ return gate.outcome;
384
+ let remedyInput = { ...gate.plan.carryForward };
385
+ if (!diagnostic.setupToolId) {
386
+ const exchange = deps.surfaceExchanges.open({ binding: { toolId: deps.recoveryToolId, principalId: principal.id }, emit: emitSurface });
387
+ const ui = deps.buildRecoveryForm({ exchangeId: exchange.id, hint: diagnostic.hint, remedyToolId: diagnostic.remedyToolId, ...(gate.plan.askFor ? { askFor: gate.plan.askFor } : {}) }, {});
388
+ const decision = await collectRecoveryDecision({ exchange, ui, askForKey: gate.plan.askFor?.key, signal });
389
+ // Declined, dismissed, expired, or a blank answer — the ORIGINAL failure is still the truth.
390
+ if (!decision.proceed)
391
+ return result;
392
+ remedyInput = buildRemedyInput(gate.plan, decision);
393
+ }
394
+ // Credential setup goes directly to the masked card; it supplies its own human decision.
395
+ // ---- STRUCTURAL ONE-CYCLE GUARD ----
396
+ // Both calls below go straight to `inner` — never back through this function or the object it
397
+ // returns. Whatever either one returns, including a fresh hint+remedyToolId of its own, is never
398
+ // fed back into the scan above: that scan is written exactly once, above this comment, and
399
+ // nothing here loops or recurses into it again. See this file's header.
400
+ const remedyResult = await inner.execute({ ...required, toolId: diagnostic.remedyToolId, input: remedyInput }, optional);
401
+ // The fix itself did not complete (denied, threw, timed out, ...) — nothing changed, so retrying
402
+ // the original would only reproduce the same failure. Return the ORIGINAL result, untouched.
403
+ if (remedyResult.status !== "completed")
404
+ return result;
405
+ // The remedy RAN, but its own result says the fix was never actually applied (a declined,
406
+ // expired, or invalid human-gated write) — see {@link remedyReportedFailure}. "Completed" is not
407
+ // "succeeded"; retrying here would only reproduce the same failure.
408
+ if (remedyReportedFailure(remedyResult.output))
409
+ return result;
410
+ // Retry the ORIGINAL call exactly once, with its exact original input. Whatever this returns —
411
+ // success or a fresh failure — is this wrapper's final answer.
412
+ return inner.execute(required, optional);
413
+ },
414
+ resumeConfirmation: (required) => inner.resumeConfirmation(required),
415
+ cancel: (required) => inner.cancel(required),
416
+ getAuditRecord: (required) => inner.getAuditRecord(required),
417
+ };
418
+ }
419
+ //# sourceMappingURL=recovery.js.map
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @file Blanks secret-shaped values out of a FAILED tool's error text and mints a durable, copyable
3
+ * error ID for the ones that need server-side follow-up — the 2026-09-16 owner decision ("hide
4
+ * secrets only"): a failed tool's error stays visible everywhere it already reaches (the model, the
5
+ * chat transcript, the tool card, admin BYOK, the daemon's delegated-tool route, the MCP-UI
6
+ * redemption endpoint, http-kit's server log), only a secret-shaped VALUE inside it never does.
7
+ *
8
+ * ## Position — see `tool-executor-stack.ts`'s own header
9
+ * Sits between `withReadOnlyToolConstraint` and `withToolAttemptAudit`, and INSIDE
10
+ * `withToolFailureRecovery` (which calls `inner` for the original call, the remedy, and the retry —
11
+ * each one redacted here). A read-only refusal is set by `withToolFailureRecovery` on the OUTER
12
+ * result, after this layer has already run, so it is never touched by it.
13
+ *
14
+ * ## Behavior by result
15
+ * | Result | Action |
16
+ * |---|---|
17
+ * | `failed` with `errorKind` `'internal'` or missing | Redact, mint an ID, prefix `Error <ID>: `, add `errorId`, report one {@link ToolFailureRecord} |
18
+ * | `failed` with `errorKind: 'validation'` | Redact only — no ID, no record. Byte-identical when nothing secret-shaped is present |
19
+ * | any other status | Returned as the SAME object, untouched |
20
+ * | inner throws | Rethrown untouched |
21
+ *
22
+ * A validation failure is the caller's own input problem: its message is already the full, safe
23
+ * reason, and nothing about it needs a server-side lookup — see `contracts/core/secret-redaction.ts`
24
+ * for why only the VALUE is ever blanked, never the surrounding text.
25
+ *
26
+ * Architectural role: `src/assistant` composition-layer adapter, same layer as
27
+ * `tool-executor-audit.ts` and `tool-failure-recovery.ts`. Depends only on the Tovu policy
28
+ * adapter over `@jini-ai/diagnostics/redaction/secrets-only` and `@jini-ai/daemon`'s `ToolExecutor` shape.
29
+ */
30
+ import type { ToolExecutionResult, ToolExecutor } from "../tool-executor.js";
31
+ /** Matches a minted error ID anywhere in a string — exported for tests and for a later chat-side
32
+ * copy button (`ToolCard` matching this against `result.content`, tracked separately). */
33
+ export declare const TOOL_ERROR_ID_PATTERN: RegExp;
34
+ /**
35
+ * Mints one error ID: 64 bits of `crypto.randomBytes`, formatted `ERR-XXXX-XXXX-XXXX-XXXX`.
36
+ *
37
+ * @returns A string always matching {@link TOOL_ERROR_ID_PATTERN}.
38
+ * @complexity O(1).
39
+ */
40
+ export declare function mintToolErrorId({ randomHex }: {
41
+ randomHex: (required: {
42
+ bytes: number;
43
+ }) => string;
44
+ }, _optional?: {}): string;
45
+ /** A `ToolExecutionResult` that may additionally carry the minted error ID — the ONLY field this
46
+ * decorator ever adds. Every other field is `inner`'s own, untouched. */
47
+ export type RedactedToolExecutionResult = ToolExecutionResult & {
48
+ readonly errorId?: string;
49
+ };
50
+ /**
51
+ * Reads the error ID this decorator attached, if any.
52
+ *
53
+ * @param result - Any `ToolExecutionResult`, redacted or not.
54
+ * @returns The ID, or `undefined` for a result this decorator never touched (not `failed`,
55
+ * `errorKind: 'validation'`, or produced upstream of this layer).
56
+ * @complexity O(1).
57
+ */
58
+ export declare function readToolErrorId({ result }: {
59
+ result: ToolExecutionResult;
60
+ }, _optional?: {}): string | undefined;
61
+ /**
62
+ * `@jini-ai/http-kit`'s `isModelSafeToolFailure` predicate for the delegated-tool route: `true` only
63
+ * for a failure this decorator already redacted and minted an ID for, so the model gets
64
+ * `Error <ID>: <reason>` instead of an opaque `INTERNAL_ERROR`. A failure without the ID never
65
+ * passed through this layer and keeps http-kit's SEC-005 redaction.
66
+ *
67
+ * @param result - The settled result the route is about to map.
68
+ * @returns Whether the route may show `result.error` to the model verbatim.
69
+ * @complexity O(1).
70
+ */
71
+ export declare function isRedactedToolFailure({ result }: {
72
+ result: ToolExecutionResult;
73
+ }, _optional?: {}): boolean;
74
+ /**
75
+ * What `@jini-ai/http-kit`'s `describeInternalError` hands its describer — declared here,
76
+ * structurally, rather than imported as `DelegatedToolsInternalErrorContext`, so this module still
77
+ * compiles against a published http-kit that predates `status`.
78
+ */
79
+ export interface DelegatedInternalErrorContext {
80
+ readonly source: string;
81
+ readonly runId: string;
82
+ readonly toolId: string;
83
+ /** http-kit's own id for this failure — the one its `[@jini-ai/http-kit] internal error` log line carries. */
84
+ readonly correlationId: string;
85
+ readonly error: unknown;
86
+ /** Set for a settled `timed-out`/`cancelled`/`failed` result; absent for a throw. */
87
+ readonly status?: string;
88
+ }
89
+ /**
90
+ * `@jini-ai/http-kit`'s `describeInternalError` for the agent daemon's delegated-tool route: the
91
+ * text a model gets in place of the bare `INTERNAL_ERROR: an internal error occurred` for every
92
+ * failure that route would otherwise redact whole — an unknown tool id (the executor throws), a
93
+ * throwing `resolvePrincipal`, a `timed-out` or `cancelled` result, and a `failed` result that never
94
+ * passed through {@link withRedactedToolFailures}. Same "hide secrets only" rule and the same
95
+ * `Error <ID>: <reason>` shape as that decorator, with one {@link ToolFailureRecord} per call.
96
+ *
97
+ * There is no execution or resolved principal to record for a throw, so the record carries
98
+ * http-kit's `correlationId` in `executionId` instead — the id that joins it to http-kit's own log
99
+ * line for the same failure.
100
+ *
101
+ * @param context - The failure as http-kit reports it.
102
+ * @param deps - Test seams; production passes nothing.
103
+ * @returns `Error <ID>: <redacted reason>`.
104
+ * @complexity O(reason length) for the redaction pass.
105
+ */
106
+ export declare function describeDelegatedInternalError({ context, ...deps }: RedactedToolFailuresDeps & {
107
+ context: DelegatedInternalErrorContext;
108
+ }, _optional?: {}): string;
109
+ /**
110
+ * The two error-disclosure options the agent daemon spreads into `registerDelegatedToolRoutes`, as
111
+ * one value so the daemon and its tests wire the identical pair: a failure this layer already
112
+ * ID-tagged answers `422 TOOL_EXECUTION_FAILED`, and every other internal error answers `500` with
113
+ * {@link describeDelegatedInternalError}'s text.
114
+ *
115
+ * @param deps - Test seams forwarded to the describer; production passes nothing.
116
+ * @complexity O(1).
117
+ */
118
+ export declare function delegatedToolErrorDisclosure(deps: RedactedToolFailuresDeps, _optional?: {}): {
119
+ readonly isModelSafeToolFailure: (required: {
120
+ result: ToolExecutionResult;
121
+ }) => boolean;
122
+ readonly describeInternalError: (context: DelegatedInternalErrorContext) => string;
123
+ };
124
+ /** The value-free server-side record for one internal failure — everything needed to find the
125
+ * tool, run, principal, execution and full (already redacted) message under its ID, with no secret
126
+ * value ever entering it. `message` is the text AFTER {@link redactSecretShapes}. */
127
+ export interface ToolFailureRecord {
128
+ readonly errorId: string;
129
+ readonly toolId: string;
130
+ readonly runId: string;
131
+ readonly principalId: string;
132
+ readonly executionId: string;
133
+ readonly redactions: number;
134
+ readonly message: string;
135
+ }
136
+ export interface RedactedToolFailuresDeps {
137
+ /** Host ID source; inject a deterministic source in tests. */
138
+ readonly mintErrorId: () => string;
139
+ readonly redactSecretShapes: (required: {
140
+ text: string;
141
+ }) => {
142
+ text: string;
143
+ redactions: number;
144
+ };
145
+ /** Where a {@link ToolFailureRecord} is reported for every internal failure — the §3(b)
146
+ * server-side record. Must not throw; a throwing sink is contained without logging its own
147
+ * exception, never allowed to turn a redacted result into a thrown
148
+ * error. */
149
+ readonly onFailure: (record: ToolFailureRecord) => void;
150
+ }
151
+ /**
152
+ * Wraps `inner` so every `failed` result it produces is redacted per this file's Behavior table
153
+ * before any caller — recovery, the BYOK map, the delegated-tool bridge, the MCP-UI route, or
154
+ * http-kit's log — ever sees it. Every other status, and every throw, passes through unchanged.
155
+ *
156
+ * @param inner - The executor to wrap. Its own decisions (status, `errorKind`, throwing) are never
157
+ * second-guessed here — this layer only ever transforms an already-`failed` result's `error` text.
158
+ * @param deps - Test seams; production callers pass nothing and get the real minter and logger.
159
+ * @returns A drop-in `ToolExecutor`. `resumeConfirmation`, `cancel`, `getAuditRecord` delegate
160
+ * straight through, matching `tool-failure-recovery.ts`'s and `tool-executor-audit.ts`'s own shape.
161
+ * @complexity O(1) beyond `inner`'s own cost and {@link redactFailedResult}'s.
162
+ */
163
+ export declare function withRedactedToolFailures({ inner, ...deps }: RedactedToolFailuresDeps & {
164
+ inner: ToolExecutor;
165
+ }, _optional?: {}): ToolExecutor;
166
+ //# sourceMappingURL=redaction.d.ts.map