@jini-ai/daemon 0.5.3 → 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.
- package/CHANGELOG.md +4 -0
- package/dist/agent-executor/contracts.d.ts +12 -10
- package/dist/agent-executor/contracts.js +0 -9
- package/dist/agent-executor/event-translation.d.ts +2 -2
- package/dist/agent-executor/event-translation.js +0 -2
- package/dist/agent-executor/events.d.ts +9 -4
- package/dist/agent-executor/events.js +21 -5
- package/dist/agent-executor/handoff.d.ts +1 -1
- package/dist/agent-executor/handoff.js +0 -1
- package/dist/agent-executor/launch.d.ts +1 -1
- package/dist/agent-executor/launch.js +22 -1
- package/dist/agent-executor/mcp-bridge.d.ts +2 -2
- package/dist/agent-executor/mcp-bridge.js +0 -2
- package/dist/agent-executor/model-receipts.d.ts +6 -0
- package/dist/agent-executor/model-receipts.js +26 -0
- package/dist/agent-executor.d.ts +7 -1665
- package/dist/agent-executor.js +7 -3007
- package/dist/core/read-only-tools.d.ts +130 -0
- package/dist/core/read-only-tools.js +115 -0
- package/dist/core/tool-audit.d.ts +131 -0
- package/dist/core/tool-audit.js +113 -0
- package/dist/http/active-context.d.ts +2 -2
- package/dist/http/agents.d.ts +8 -2
- package/dist/http/agents.js +6 -2
- package/dist/http/component-catalog.d.ts +2 -2
- package/dist/http/connectors.d.ts +17 -17
- package/dist/http/daemon-status.d.ts +2 -2
- package/dist/http/db-ops.d.ts +3 -3
- package/dist/http/delegated-tools.d.ts +1 -1
- package/dist/http/delegated-tools.js +1 -1
- package/dist/http/frontend-sessions.d.ts +1 -1
- package/dist/http/health.d.ts +6 -6
- package/dist/http/host-tools.d.ts +2 -2
- package/dist/http/media.d.ts +4 -4
- package/dist/http/memory.d.ts +15 -15
- package/dist/http/remote-run-events.d.ts +2 -2
- package/dist/http/research.d.ts +1 -1
- package/dist/http/routines.d.ts +7 -7
- package/dist/http/run-credentials.d.ts +2 -2
- package/dist/http/run-credentials.js +2 -2
- package/dist/http/runs.d.ts +4 -4
- package/dist/http/terminals.d.ts +6 -6
- package/dist/http/tool-catalog.d.ts +2 -2
- package/dist/http/xai.d.ts +6 -6
- package/dist/index.d.ts +75 -571
- package/dist/index.js +75 -571
- package/dist/read-only-tools.d.ts +1 -129
- package/dist/read-only-tools.js +2 -114
- package/dist/run-credentials.d.ts +47 -23
- package/dist/run-credentials.js +24 -8
- package/dist/scheduler.d.ts +5 -0
- package/dist/scheduler.js +13 -1
- package/dist/session-coordination.d.ts +58 -24
- package/dist/session-coordination.js +40 -12
- package/dist/store/event-log/sqlite.js +3 -3
- package/dist/surface-exchanges.d.ts +207 -61
- package/dist/surface-exchanges.js +167 -20
- package/dist/tool-audit.d.ts +1 -113
- package/dist/tool-audit.js +2 -94
- package/dist/tool-recovery/diagnostics.d.ts +132 -0
- package/dist/tool-recovery/diagnostics.js +110 -0
- package/dist/tool-recovery/federated-refusal.d.ts +92 -0
- package/dist/tool-recovery/federated-refusal.js +152 -0
- package/dist/tool-recovery/index.d.ts +6 -0
- package/dist/tool-recovery/index.js +6 -0
- package/dist/tool-recovery/recovery.d.ts +49 -0
- package/dist/tool-recovery/recovery.js +419 -0
- package/dist/tool-recovery/redaction.d.ts +166 -0
- package/dist/tool-recovery/redaction.js +192 -0
- package/dist/tool-recovery/stack.d.ts +69 -0
- package/dist/tool-recovery/stack.js +62 -0
- 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
|