@agenticprimitives/orchestration 0.0.0-alpha.27 → 0.0.0-alpha.29
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/dist/admission.d.ts +11 -2
- package/dist/admission.d.ts.map +1 -1
- package/dist/admission.js.map +1 -1
- package/dist/artifacts.d.ts +9 -1
- package/dist/artifacts.d.ts.map +1 -1
- package/dist/artifacts.js +119 -2
- package/dist/artifacts.js.map +1 -1
- package/dist/conformance.d.ts +14 -0
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +154 -30
- package/dist/conformance.js.map +1 -1
- package/dist/derivation.d.ts +23 -4
- package/dist/derivation.d.ts.map +1 -1
- package/dist/derivation.js +105 -6
- package/dist/derivation.js.map +1 -1
- package/dist/digest.d.ts +10 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +45 -0
- package/dist/digest.js.map +1 -0
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/loop.d.ts +4 -1
- package/dist/loop.d.ts.map +1 -1
- package/dist/loop.js +275 -75
- package/dist/loop.js.map +1 -1
- package/dist/outcome.d.ts.map +1 -1
- package/dist/outcome.js +6 -5
- package/dist/outcome.js.map +1 -1
- package/dist/plan-digest.d.ts.map +1 -1
- package/dist/plan-digest.js +5 -13
- package/dist/plan-digest.js.map +1 -1
- package/dist/planner.d.ts +25 -0
- package/dist/planner.d.ts.map +1 -1
- package/dist/planner.js +57 -0
- package/dist/planner.js.map +1 -1
- package/dist/record.d.ts +14 -1
- package/dist/record.d.ts.map +1 -1
- package/dist/record.js +6 -0
- package/dist/record.js.map +1 -1
- package/dist/regression.d.ts +23 -2
- package/dist/regression.d.ts.map +1 -1
- package/dist/regression.js +22 -6
- package/dist/regression.js.map +1 -1
- package/dist/selection.js +1 -1
- package/dist/selection.js.map +1 -1
- package/dist/spans.d.ts +15 -2
- package/dist/spans.d.ts.map +1 -1
- package/dist/spans.js +20 -14
- package/dist/spans.js.map +1 -1
- package/dist/tool-invoker.d.ts +4 -0
- package/dist/tool-invoker.d.ts.map +1 -1
- package/dist/tool-invoker.js.map +1 -1
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/loop.js
CHANGED
|
@@ -14,9 +14,15 @@
|
|
|
14
14
|
//
|
|
15
15
|
// A step with no capability skips the path and still emits a receipt: the trace is the product, and
|
|
16
16
|
// "nothing was verified" is itself a fact worth recording.
|
|
17
|
-
import { compactObservations } from './artifacts.js';
|
|
17
|
+
import { canonicalJson, compactObservations } from './artifacts.js';
|
|
18
|
+
import { VALUE_DIGEST_SCHEME, isValueDigest, sameValue, valueDigest } from './digest.js';
|
|
18
19
|
import { planDigest } from './plan-digest.js';
|
|
19
|
-
import { CONTINUE_STEP_ID, OrchestrationError } from './types.js';
|
|
20
|
+
import { CONTINUE_STEP_ID, OrchestrationError, isEvaluationIntent } from './types.js';
|
|
21
|
+
import { frameUntrusted } from './planner.js';
|
|
22
|
+
import { statedOutcomeOf } from './conformance.js';
|
|
23
|
+
/** The stated-outcome rule's codes — the only violations the point-of-use re-judgment acts on (other admission rules
|
|
24
|
+
* read the plan as the planner WORDED it and are not re-run against resolved values). */
|
|
25
|
+
const POINT_OF_USE_CODES = new Set(['OUTCOME_NOT_ENTAILED', 'OUTCOME_BOUND_EXCEEDED', 'OUTCOME_BOUND_UNREAD', 'OUTCOME_BOUND_PENDING', 'OUTCOME_DISCLOSURE']);
|
|
20
26
|
import { isToolInvocationResult } from './tool-invoker.js';
|
|
21
27
|
import { inputsFor, isInputRequired, receiptPending } from './input.js';
|
|
22
28
|
import { deriveArgs } from './derivation.js';
|
|
@@ -24,7 +30,8 @@ import { deriveArgs } from './derivation.js';
|
|
|
24
30
|
* dots is a plain top-level lookup. A missing segment short-circuits to `undefined` (the tool then validates
|
|
25
31
|
* its own args — fail-closed, no path traversal / eval). */
|
|
26
32
|
function getPath(root, path) {
|
|
27
|
-
|
|
33
|
+
// OWN properties only (07 §6 lead 5 / RUNC-14): `x.constructor`, `x.__proto__`, `x.toString` are not results a step returned.
|
|
34
|
+
return path.split('.').reduce((acc, seg) => (acc == null || !Object.prototype.hasOwnProperty.call(Object(acc), seg) ? undefined : acc[seg]), root);
|
|
28
35
|
}
|
|
29
36
|
/** Recursively resolve `{ $ref: 'name.path' }` placeholders (anywhere — nested objects/arrays included)
|
|
30
37
|
* against prior step results. Non-placeholder values pass through unchanged. */
|
|
@@ -115,8 +122,6 @@ function resolveStep(runRef, index, step, tool, args) {
|
|
|
115
122
|
/** Spec 413 — the stepRef of the declared pre-plan retrieval step. */
|
|
116
123
|
export const RETRIEVAL_STEP_REF = 'retrieve';
|
|
117
124
|
const randomRef = () => `run-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
|
|
118
|
-
/** A stable digest is the sink's job (it has the hashing deps); here we record a cheap structural fingerprint
|
|
119
|
-
* so two receipts for the same input compare equal. Never treated as a cryptographic commitment. */
|
|
120
125
|
/**
|
|
121
126
|
* Spec 410 §2 — READ WHAT THE ADAPTER SAW, and bound it by what the tool can establish. A result contract is
|
|
122
127
|
* taken at its word within that bound; a raw value is `attempted` for an act (the adapter showed nothing but
|
|
@@ -144,20 +149,12 @@ function observeInvocation(resolved, tool, now) {
|
|
|
144
149
|
}
|
|
145
150
|
return { output: resolved.output, observation: { ...o, outcome, evidence } };
|
|
146
151
|
}
|
|
147
|
-
function fingerprint(v) {
|
|
148
|
-
try {
|
|
149
|
-
return `json:${JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? x.toString() : x))}`.slice(0, 2048);
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
return 'json:<unserializable>';
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
152
|
/** Does a declared branch hold against the results so far? Absent/empty is false for `exists`; `equals`
|
|
156
|
-
* compares
|
|
153
|
+
* compares the FULL canonical JSON (the same JSON is the same value) — never a prefix (RUN-8). */
|
|
157
154
|
function holds(when, refs) {
|
|
158
155
|
const v = getPath(refs, when.ref);
|
|
159
156
|
if (when.equals !== undefined)
|
|
160
|
-
return
|
|
157
|
+
return sameValue(v, when.equals);
|
|
161
158
|
if (when.exists === false)
|
|
162
159
|
return v === undefined || v === null || (Array.isArray(v) && v.length === 0);
|
|
163
160
|
return !(v === undefined || v === null || (Array.isArray(v) && v.length === 0));
|
|
@@ -233,6 +230,11 @@ function independentBatch(plan, i, toolsById, completed) {
|
|
|
233
230
|
*/
|
|
234
231
|
/** The planner's own words for a step's arguments (before resolution turned "nathan.me" into an address). */
|
|
235
232
|
const rawArgsOf = (rs) => rs.step.args ?? rs.args;
|
|
233
|
+
/** Spec 445 decision 8 — the digest an outbound confirmation is bound to: the tool and EXACTLY the arguments it would
|
|
234
|
+
* send (`sha256-jcs-v1`). A `yes` to one disclosure is not a `yes` to another. */
|
|
235
|
+
export async function outboundDigest(toolId, sends) {
|
|
236
|
+
return valueDigest({ toolId, sends });
|
|
237
|
+
}
|
|
236
238
|
export async function runIntent(intent, opts) {
|
|
237
239
|
// THE STEP BUDGET bounds what a PLANNER may keep proposing (a re-plan loop that never ends), not what a
|
|
238
240
|
// declared fan-out enumerates: that is capped by the ontology's binding (spec 358 W4 `maxItems`) at the
|
|
@@ -251,6 +253,8 @@ export async function runIntent(intent, opts) {
|
|
|
251
253
|
const plannerTools = opts.continuation && opts.continuation.max > 0 ? [...opts.tools, CONTINUE_TOOL] : opts.tools;
|
|
252
254
|
const toolsById = new Map(plannerTools.map((t) => [t.id, t]));
|
|
253
255
|
const toolIds = new Set(toolsById.keys());
|
|
256
|
+
/** The tools the AGENT exposes (never the reserved continuation step) — what a compensation may name (RUN-7). */
|
|
257
|
+
const exposedById = new Map(opts.tools.map((t) => [t.id, t]));
|
|
254
258
|
const allObservations = [];
|
|
255
259
|
const receipts = [];
|
|
256
260
|
/** Executed steps whose tool declares a compensation, newest first — unwound on failure. */
|
|
@@ -282,18 +286,19 @@ export async function runIntent(intent, opts) {
|
|
|
282
286
|
if (ports.receiptSink)
|
|
283
287
|
await ports.receiptSink.record(r);
|
|
284
288
|
};
|
|
285
|
-
|
|
289
|
+
// RUN-8 (spec 445) — the digests are SHA-256 over the full canonical value (`digest.ts`), so `base` awaits.
|
|
290
|
+
const base = async (rs, startedAt) => ({
|
|
286
291
|
runRef, stepRef: rs.stepRef, index: rs.index, toolId: rs.tool.id, risk: rs.risk, startedAt,
|
|
287
292
|
...(rs.risk !== 'informational' ? { capability: rs.capability } : {}),
|
|
288
293
|
...(rs.idempotencyKey ? { idempotencyKey: rs.idempotencyKey } : {}),
|
|
289
|
-
inputDigest:
|
|
294
|
+
inputDigest: await valueDigest(rs.args),
|
|
290
295
|
// What was PLANNED for this step (resolved against earlier results only) — what a resume's replay can reproduce.
|
|
291
|
-
...(() => { try {
|
|
292
|
-
return { planArgsDigest:
|
|
296
|
+
...(await (async () => { try {
|
|
297
|
+
return { planArgsDigest: await valueDigest(resolveArgs(rs.step.args, refs)) };
|
|
293
298
|
}
|
|
294
299
|
catch {
|
|
295
300
|
return {};
|
|
296
|
-
} })(),
|
|
301
|
+
} })()),
|
|
297
302
|
// Spec 421 W2 — whose words each argument of an ACT came from (raw planner values against her goal and what was read).
|
|
298
303
|
...(rs.risk !== 'informational' ? (() => { const d = deriveArgs(rawArgsOf(rs), intent.goal, allObservations); return Object.keys(d).length ? { derivation: d } : {}; })() : {}),
|
|
299
304
|
// Spec 367 §5 — the binding the step was admitted under, pinned by the harness that knows the
|
|
@@ -325,7 +330,7 @@ export async function runIntent(intent, opts) {
|
|
|
325
330
|
// 401, not 403. Nothing was verified and nothing ran; the receipt records that the plan reached a
|
|
326
331
|
// step it had no authority for, and the caller is told WHICH step so it can go and get one.
|
|
327
332
|
emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
|
|
328
|
-
await record({ ...base(rs, startedAt), status: 'authority-required', completedAt: now() });
|
|
333
|
+
await record({ ...(await base(rs, startedAt)), status: 'authority-required', completedAt: now() });
|
|
329
334
|
return {
|
|
330
335
|
outcome: 'authority-required', plan, steps: allObservations, receipts, runRef,
|
|
331
336
|
required: { stepRef: rs.stepRef, toolId: rs.tool.id, capability: rs.capability, risk: rs.risk, args: rs.args, ...(() => { const d = deriveArgs(rawArgsOf(rs), intent.goal, allObservations); return Object.keys(d).length ? { derivation: d } : {}; })(), ...(rs.tool.carries ? { carries: rs.tool.carries } : {}) },
|
|
@@ -336,7 +341,7 @@ export async function runIntent(intent, opts) {
|
|
|
336
341
|
emit({ type: 'MandateChecked', runRef, stepRef: rs.stepRef, decision: decision.decision, afterApproval: false });
|
|
337
342
|
if (decision.decision === 'deny') {
|
|
338
343
|
emit({ type: 'MandateDenied', runRef, stepRef: rs.stepRef, reasons: decision.reasons });
|
|
339
|
-
await record({ ...base(rs, startedAt), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: false }, completedAt: now(), error: decision.reasons.map((r) => `${r.code}: ${r.message}`).join('; ') });
|
|
344
|
+
await record({ ...(await base(rs, startedAt)), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: false }, completedAt: now(), error: decision.reasons.map((r) => `${r.code}: ${r.message}`).join('; ') });
|
|
340
345
|
return { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error: decision.reasons.map((r) => `${r.code}: ${r.message}`).join('; ') };
|
|
341
346
|
}
|
|
342
347
|
// Obligations: the verifier's, plus whatever policy adds for this risk class. Never fewer.
|
|
@@ -355,7 +360,7 @@ export async function runIntent(intent, opts) {
|
|
|
355
360
|
emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: outcome.resumeToken });
|
|
356
361
|
const prompt = outcome.prompt ? { ...outcome.prompt, stepRef: rs.stepRef, toolId: rs.tool.id } : undefined;
|
|
357
362
|
await record({
|
|
358
|
-
...base(rs, startedAt), status: 'suspended',
|
|
363
|
+
...(await base(rs, startedAt)), status: 'suspended',
|
|
359
364
|
authority: { presentedRef: presented.ref, decision, afterApproval: false },
|
|
360
365
|
...(prompt ? { pendingInput: receiptPending(prompt) } : {}),
|
|
361
366
|
completedAt: now(),
|
|
@@ -365,7 +370,7 @@ export async function runIntent(intent, opts) {
|
|
|
365
370
|
if (outcome.status === 'refused') {
|
|
366
371
|
emit({ type: 'ApprovalRefused', runRef, stepRef: rs.stepRef, ...(outcome.reason ? { reason: outcome.reason } : {}) });
|
|
367
372
|
const error = `approval refused${outcome.by ? ` by ${outcome.by}` : ''}${outcome.reason ? `: ${outcome.reason}` : ''}`;
|
|
368
|
-
await record({ ...base(rs, startedAt), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: false }, completedAt: now(), error });
|
|
373
|
+
await record({ ...(await base(rs, startedAt)), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: false }, completedAt: now(), error });
|
|
369
374
|
return { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error };
|
|
370
375
|
}
|
|
371
376
|
approvalRecords = outcome.records;
|
|
@@ -378,44 +383,91 @@ export async function runIntent(intent, opts) {
|
|
|
378
383
|
const reasons = decision.decision === 'deny' ? decision.reasons : [{ code: 'still-obligated', message: 'obligations remain after approval' }];
|
|
379
384
|
emit({ type: 'MandateDenied', runRef, stepRef: rs.stepRef, reasons });
|
|
380
385
|
const error = reasons.map((r) => `${r.code}: ${r.message}`).join('; ');
|
|
381
|
-
await record({ ...base(rs, startedAt), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: true }, approvalRecords, completedAt: now(), error });
|
|
386
|
+
await record({ ...(await base(rs, startedAt)), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: true }, approvalRecords, completedAt: now(), error });
|
|
382
387
|
return { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error };
|
|
383
388
|
}
|
|
384
389
|
}
|
|
385
390
|
return { decision, approvalRecords, used: presented };
|
|
386
391
|
}
|
|
387
|
-
/**
|
|
388
|
-
*
|
|
389
|
-
*
|
|
392
|
+
/**
|
|
393
|
+
* Unwind executed steps that declared compensation, newest first. Best effort: a compensation that fails is
|
|
394
|
+
* recorded, not retried — the receipt is the honest record.
|
|
395
|
+
*
|
|
396
|
+
* RUN-7 (spec 445 W0) — A COMPENSATION IS A STEP. It runs only if it is a tool this run EXPOSES, and, when that tool
|
|
397
|
+
* exercises a capability, only under a verdict: the verifier judged it `allow` against a presented mandate that fits
|
|
398
|
+
* it, with no obligation outstanding (or it is one of the loop's declared non-mandate paths — self-acting, or judged
|
|
399
|
+
* at the subject's agent — exactly as in `attempt`). Its arguments come from the original tool's RESULT, so they are
|
|
400
|
+
* the last thing that may run unjudged. Anything else is NOT RUN: it is recorded on its own receipt and returned as
|
|
401
|
+
* `compensationsOwed`, naming the step, so the caller can obtain the authority and perform it — owed, never done
|
|
402
|
+
* unverified, never dropped.
|
|
403
|
+
*/
|
|
404
|
+
const compensationsOwed = [];
|
|
405
|
+
const withOwed = (r) => (compensationsOwed.length ? { ...r, compensationsOwed: [...compensationsOwed] } : r);
|
|
390
406
|
async function compensate() {
|
|
391
407
|
for (const { resolved, result } of compensable.reverse()) {
|
|
392
408
|
const comp = resolved.tool.compensation;
|
|
393
|
-
const compTool =
|
|
409
|
+
const compTool = exposedById.get(comp.toolId);
|
|
394
410
|
const startedAt = now();
|
|
395
|
-
const args = resolveValue(comp.args, { ...refs, $result: result });
|
|
411
|
+
const args = (resolveValue(comp.args, { ...refs, $result: result }) ?? {});
|
|
396
412
|
const crs = compTool
|
|
397
413
|
? resolveStep(runRef, resolved.index, { toolId: comp.toolId, args, id: `${resolved.stepRef}:compensate` }, compTool, args)
|
|
398
|
-
: { ...resolved, stepRef: `${resolved.stepRef}:compensate`, tool: { id: comp.toolId, description: '' }, args, risk: 'informational', capability: { id: '', action: '' } };
|
|
399
|
-
let
|
|
400
|
-
|
|
414
|
+
: { ...resolved, stepRef: `${resolved.stepRef}:compensate`, step: { toolId: comp.toolId, args, id: `${resolved.stepRef}:compensate` }, tool: { id: comp.toolId, description: '' }, args, risk: 'informational', capability: { id: '', action: '' } };
|
|
415
|
+
let authority;
|
|
416
|
+
const notRun = async (reason, status, detail) => {
|
|
417
|
+
compensationsOwed.push({ stepRef: crs.stepRef, forStepRef: resolved.stepRef, toolId: comp.toolId, ...(crs.risk !== 'informational' ? { capability: crs.capability } : {}), risk: crs.risk, args, reason, detail });
|
|
418
|
+
emit({ type: 'StepCompensated', runRef, stepRef: resolved.stepRef, by: comp.toolId, ok: false });
|
|
419
|
+
await record({ ...(await base(crs, startedAt)), status, ...(authority ? { authority } : {}), completedAt: now(), error: `compensation owed (${reason}): ${detail}` });
|
|
420
|
+
};
|
|
421
|
+
if (!compTool) {
|
|
422
|
+
await notRun('tool-not-exposed', 'failed', `${resolved.tool.id} declares ${comp.toolId} as its compensation, which this run does not expose — refused, not run`);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
401
425
|
try {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
426
|
+
if (crs.risk !== 'informational') {
|
|
427
|
+
const judgedAt = opts.authorityJudgedElsewhere ? opts.authorityJudgedElsewhere(crs) : null;
|
|
428
|
+
if (judgedAt) {
|
|
429
|
+
authority = { presentedRef: 'routed', decision: { decision: 'allow', obligations: [], evidence: { mandateRef: `routed:${judgedAt}`, intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'routed', allowed: true, reason: `the compensation's subject is ${judgedAt}; its authority is judged at that agent` }] } }, afterApproval: false };
|
|
430
|
+
}
|
|
431
|
+
else if (compTool.selfAuthorized) {
|
|
432
|
+
if (holdSelfActs) {
|
|
433
|
+
await record({ ...(await base(crs, startedAt)), status: 'skipped', completedAt: now(), error: holdReason });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
authority = { presentedRef: 'self', decision: { decision: 'allow', obligations: [], evidence: { mandateRef: 'self', intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'self', allowed: true, reason: 'the run\'s own principal owns what this touches; the session is the authority' }] } }, afterApproval: false };
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
if (!ports.mandateVerifier) {
|
|
440
|
+
await notRun('no-mandate', 'authority-required', `${comp.toolId} exercises ${crs.capability.id} and no MandateVerifier is configured — nothing could judge it`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const compPresented = presentedFor(crs);
|
|
444
|
+
if (!compPresented) {
|
|
445
|
+
await notRun('no-mandate', 'authority-required', `${comp.toolId} exercises ${crs.capability.id} and no presented mandate covers it`);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const req = { intent, presented: compPresented, step: crs, afterApproval: false, planDigest: currentPlanDigest };
|
|
449
|
+
const d = await ports.mandateVerifier.verify(req);
|
|
450
|
+
emit({ type: 'MandateChecked', runRef, stepRef: crs.stepRef, decision: d.decision, afterApproval: false });
|
|
451
|
+
authority = { presentedRef: compPresented.ref, decision: d, afterApproval: false };
|
|
452
|
+
if (d.decision === 'deny') {
|
|
453
|
+
await notRun('not-authorized', 'denied', d.reasons.map((r) => `${r.code}: ${r.message}`).join('; '));
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const extra = ports.policyEvaluator ? (await ports.policyEvaluator.evaluate({ ...req, decision: d })).obligations : [];
|
|
457
|
+
if (d.decision !== 'allow' || d.obligations.length + extra.length > 0) {
|
|
458
|
+
await notRun('approval-required', 'authority-required', `${comp.toolId} carries obligation(s) that an unwinding run does not discharge — it needs its own approval`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
407
461
|
}
|
|
408
462
|
}
|
|
409
|
-
|
|
410
|
-
await opts.invoke(comp.toolId, args, { intent, step: crs.step, index: crs.index, ...(crs.idempotencyKey ? { idempotencyKey: crs.idempotencyKey } : {}), operationId: `${crs.idempotencyKey ?? `${runRef}:${crs.stepRef}`}:compensate`, planDigest: currentPlanDigest });
|
|
411
|
-
ok = true;
|
|
412
|
-
}
|
|
463
|
+
await opts.invoke(comp.toolId, args, { intent, step: crs.step, index: crs.index, ...(crs.idempotencyKey ? { idempotencyKey: crs.idempotencyKey } : {}), operationId: `${crs.idempotencyKey ?? `${runRef}:${crs.stepRef}`}:compensate`, planDigest: currentPlanDigest, untrustedSeen });
|
|
413
464
|
}
|
|
414
465
|
catch (e) {
|
|
415
|
-
|
|
466
|
+
await notRun('failed', 'failed', e instanceof Error ? e.message : String(e));
|
|
467
|
+
continue;
|
|
416
468
|
}
|
|
417
|
-
emit({ type: 'StepCompensated', runRef, stepRef: resolved.stepRef, by: comp.toolId, ok });
|
|
418
|
-
await record({ ...base(crs, startedAt), status:
|
|
469
|
+
emit({ type: 'StepCompensated', runRef, stepRef: resolved.stepRef, by: comp.toolId, ok: true });
|
|
470
|
+
await record({ ...(await base(crs, startedAt)), status: 'compensated', ...(authority ? { authority } : {}), completedAt: now() });
|
|
419
471
|
}
|
|
420
472
|
compensable.length = 0;
|
|
421
473
|
}
|
|
@@ -428,10 +480,20 @@ export async function runIntent(intent, opts) {
|
|
|
428
480
|
// placeholder drop, a re-plan): what the verifier compares a plan-bound mandate against. A re-planned run
|
|
429
481
|
// is a different plan and a plan-bound mandate refuses it, which is the point.
|
|
430
482
|
let currentPlanDigest = '';
|
|
483
|
+
// Spec 445 decision 8 (EVAL-13) — an EVALUATION's intent holds self-acting writes exactly as a comparison's dry run does.
|
|
484
|
+
const holdSelfActs = opts.dryRunSelfActs === true || isEvaluationIntent(intent);
|
|
485
|
+
const holdReason = isEvaluationIntent(intent) ? 'held: an evaluation run does not commit a self-acting write' : 'dry run: a comparison run does not commit a self-acting write';
|
|
431
486
|
// Spec 409 §4 — whether any observation so far carried untrusted content (see `InvokeContext.untrustedSeen`).
|
|
432
487
|
let untrustedSeen = false;
|
|
433
488
|
const isUntrusted = (result) => !!result && typeof result === 'object' && (result.untrusted === true || result['x-ap-untrusted'] === true);
|
|
434
489
|
const completedByRef = new Map((opts.resume?.completed ?? []).map((c) => [c.stepRef, c]));
|
|
490
|
+
// Spec 445 decision 8 — A REPLAYED UNTRUSTED OBSERVATION STILL MARKS THE RUN. The checkpoint carries what this run
|
|
491
|
+
// read before it parked; a resume is the same run, so what it read then counts from the first step now — not only
|
|
492
|
+
// once its replay is reached (and whether or not a host relays it on `ctx.untrustedSeen`).
|
|
493
|
+
if ((opts.resume?.completed ?? []).some((c) => isUntrusted(c.result)))
|
|
494
|
+
untrustedSeen = true;
|
|
495
|
+
/** Spec 445 decision 8 — the `ref`s whose results are untrusted, or were threaded from one (transitively). */
|
|
496
|
+
const taintedRefs = new Set();
|
|
435
497
|
// Spec 413 — the declared retrieval step (see `RunIntentOpts.retrieval`), before the plan and outside its budget —
|
|
436
498
|
// or, with `onlyIf`, after the plan and only when the plan needs it (416 §4g).
|
|
437
499
|
const runRetrieval = async () => {
|
|
@@ -458,26 +520,42 @@ export async function runIntent(intent, opts) {
|
|
|
458
520
|
obs = { step, ok: false, error: e instanceof Error ? e.message : String(e), stepRef: rs.stepRef };
|
|
459
521
|
}
|
|
460
522
|
emit({ type: 'ToolInvoked', runRef, stepRef: rs.stepRef, toolId: tool.id, ok: obs.ok });
|
|
461
|
-
await record({ ...base(rs, startedAt), status: obs.ok ? 'executed' : 'failed', ...(obs.ok ? { outputDigest:
|
|
523
|
+
await record({ ...(await base(rs, startedAt)), status: obs.ok ? 'executed' : 'failed', ...(obs.ok ? { outputDigest: await valueDigest(obs.result) } : { error: obs.error }), ...(observation ? { observation, attests: observation.outcome } : {}), completedAt: now() });
|
|
462
524
|
allObservations.push(obs);
|
|
463
525
|
opts.onObserve?.(obs);
|
|
464
526
|
};
|
|
465
527
|
if (opts.retrieval && !opts.resume && !opts.retrieval.onlyIf)
|
|
466
528
|
await runRetrieval();
|
|
467
|
-
|
|
529
|
+
// RUN-3 — what the planner is shown of a FAILED step is its error framed as untrusted data (`frameUntrusted`: the
|
|
530
|
+
// same `<data>` delimiter a continuation's results use, the delimiter neutralised inside, bounded): an error string
|
|
531
|
+
// can be text a remote service chose. The run's own observations and receipts keep the error as it was.
|
|
532
|
+
const forPlanner = (list) => list.filter((o) => o.stepRef !== RETRIEVAL_STEP_REF)
|
|
533
|
+
.map((o) => (!o.ok && typeof o.error === 'string' ? { ...o, error: frameUntrusted(o.error) } : o));
|
|
468
534
|
// ── PLAN ADMISSION (spec 367 W1), as ONE function — the first plan, a failure re-plan and a continuation plan
|
|
469
535
|
// (spec 421) all pass the same gate. Returns the admitted plan, or the run's terminal result.
|
|
470
536
|
const admitPlan = async (p) => {
|
|
471
537
|
if (!ports.planAdmission)
|
|
472
538
|
return { plan: p };
|
|
473
|
-
|
|
539
|
+
// Spec 445 W0 — A DEFERRED JUDGMENT IS NOT A PASS, AND THIS LOOP IS WHAT MAKES IT SAFE TO PROCEED. A rule that cannot
|
|
540
|
+
// judge a value that does not exist yet (an amount to be asked for, or threaded by `$ref`) says `defer`; to any other
|
|
541
|
+
// caller of `admit` that is a refusal. Here it is set aside, because every step that exercises a capability under a
|
|
542
|
+
// stated outcome is judged AGAIN on its concrete arguments at the point of use (`judgeAtPointOfUse`, below) before
|
|
543
|
+
// the verifier, a signature request or the tool sees it.
|
|
544
|
+
const admit = async (candidate) => {
|
|
545
|
+
const v = await ports.planAdmission.admit({ intent, plan: candidate, tools: plannerTools });
|
|
546
|
+
if (v.admitted)
|
|
547
|
+
return v;
|
|
548
|
+
const rest = v.violations.filter((x) => x.recovery !== 'defer');
|
|
549
|
+
return rest.length ? { admitted: false, violations: rest } : { admitted: true };
|
|
550
|
+
};
|
|
551
|
+
let verdict = await admit(p);
|
|
474
552
|
// Spec 421 W1 — A PLAN THAT READ WHAT ITS ACT NEEDS AND STOPPED continues: the loop appends `plan.continue` and admits
|
|
475
553
|
// again. Only when every violation says so; the continuation plan that follows is admitted by these same rules.
|
|
476
554
|
if (!verdict.admitted && verdict.violations.every((v) => v.recovery === 'continue')) {
|
|
477
555
|
emit({ type: 'PlanRefused', runRef, violations: verdict.violations, replanning: false });
|
|
478
556
|
owed = verdict.violations.map((v) => v.message);
|
|
479
557
|
p = { ...p, steps: [...p.steps, { toolId: CONTINUE_STEP_ID, args: {} }] };
|
|
480
|
-
verdict = await
|
|
558
|
+
verdict = await admit(p);
|
|
481
559
|
if (verdict.admitted)
|
|
482
560
|
emit({ type: 'PlanCreated', runRef, steps: p.steps.length, rationale: 'the plan read what the act needs — continuing from what it returns' });
|
|
483
561
|
}
|
|
@@ -498,7 +576,7 @@ export async function runIntent(intent, opts) {
|
|
|
498
576
|
return { ...st, args };
|
|
499
577
|
}) };
|
|
500
578
|
emit({ type: 'PlanRefused', runRef, violations: verdict.violations, replanning: false });
|
|
501
|
-
verdict = await
|
|
579
|
+
verdict = await admit(p);
|
|
502
580
|
if (verdict.admitted)
|
|
503
581
|
emit({ type: 'PlanCreated', runRef, steps: p.steps.length, rationale: `placeholders dropped to be asked for: ${asked.join(', ')}` });
|
|
504
582
|
}
|
|
@@ -514,7 +592,7 @@ export async function runIntent(intent, opts) {
|
|
|
514
592
|
emit({ type: 'PlanRefused', runRef, violations: verdict.violations, replanning: true });
|
|
515
593
|
const told = { ...intent, context: { ...(intent.context ?? {}), admission: verdict.violations.map((v) => v.message) } };
|
|
516
594
|
p = await callPlanner(opts.planner, { intent: told, tools: plannerTools });
|
|
517
|
-
verdict = await
|
|
595
|
+
verdict = await admit(p);
|
|
518
596
|
if (!verdict.admitted) {
|
|
519
597
|
emit({ type: 'PlanRefused', runRef, violations: verdict.violations, replanning: false });
|
|
520
598
|
const error = `plan_refused: ${verdict.violations.map((v) => v.message).join(' | ')}`;
|
|
@@ -543,6 +621,16 @@ export async function runIntent(intent, opts) {
|
|
|
543
621
|
// 416 §4g — a conditional retrieval runs now, once the plan is known, and only if the plan needs it.
|
|
544
622
|
if (opts.retrieval?.onlyIf && !opts.resume && opts.retrieval.onlyIf(plan))
|
|
545
623
|
await runRetrieval();
|
|
624
|
+
// The plan loop: walk the current plan; on a step failure, re-plan (bounded) and restart with a fresh plan.
|
|
625
|
+
//
|
|
626
|
+
// ── ONE ATTEMPT OF ONE STEP (spec 370 P3) ────────────────────────────────────────────────────────
|
|
627
|
+
// Everything a step goes through — replay, a declared branch, the answers already given, normalisation,
|
|
628
|
+
// authority, execution, the receipt, declared effects — in one function, so the scheduler below can run
|
|
629
|
+
// it for one step or for a batch of independent ones. It reports what happened; the scheduler decides
|
|
630
|
+
// what it means for the plan.
|
|
631
|
+
/** Spec 445 W0 — consequential steps that have run in this run, with the arguments they ran with (see the point of use). */
|
|
632
|
+
const spent = [];
|
|
633
|
+
const concreteArgs = new Map();
|
|
546
634
|
const attempt = async (i, step) => {
|
|
547
635
|
const tool = toolsById.get(step.toolId);
|
|
548
636
|
// ── ALREADY RAN (spec 370 P1) — replayed as evidence, never as permission. ────────────────
|
|
@@ -568,9 +656,18 @@ export async function runIntent(intent, opts) {
|
|
|
568
656
|
refuse(`the receipt records ${r.toolId}, the plan says ${step.toolId}`);
|
|
569
657
|
if (r.status !== 'executed')
|
|
570
658
|
refuse(`the receipt records '${r.status}', not an execution`);
|
|
571
|
-
|
|
659
|
+
// RUN-8 (spec 445 W0) — THE DIGESTS BIND THE WHOLE VALUE, AND ONLY UNDER THE CURRENT SCHEME. They were a
|
|
660
|
+
// 2,048-character JSON prefix: a result altered past it replayed as recorded. A receipt written under that
|
|
661
|
+
// retired form (or under no form) cannot vouch for anything, and there is exactly one comparison — it is
|
|
662
|
+
// never compared by prefix "for compatibility", and the step is never quietly re-executed either (an act
|
|
663
|
+
// that already happened would happen twice). The run is refused, by name; start it again.
|
|
664
|
+
const retired = (d) => !isValueDigest(d);
|
|
665
|
+
if (retired(r.outputDigest) || retired(r.inputDigest) || (r.planArgsDigest !== undefined && retired(r.planArgsDigest))) {
|
|
666
|
+
refuse(`its receipt carries no ${VALUE_DIGEST_SCHEME} digest of its arguments and result — it was recorded under the retired prefix scheme (or was not written by this loop), which cannot vouch for a replayed value; this run cannot be resumed with integrity and must be started again`);
|
|
667
|
+
}
|
|
668
|
+
if (r.outputDigest !== await valueDigest(done.result))
|
|
572
669
|
refuse('the supplied result is not the one the receipt recorded');
|
|
573
|
-
|
|
670
|
+
{
|
|
574
671
|
let resolvedArgs = {};
|
|
575
672
|
try {
|
|
576
673
|
resolvedArgs = resolveArgs(step.args, refs);
|
|
@@ -583,7 +680,7 @@ export async function runIntent(intent, opts) {
|
|
|
583
680
|
// resume past goal regression's inserted roster read of "missio nexus" was refused). As strict as before:
|
|
584
681
|
// any change to the plan's arguments or to an earlier result still refuses.
|
|
585
682
|
const recorded = r.planArgsDigest ?? r.inputDigest;
|
|
586
|
-
if (recorded !==
|
|
683
|
+
if (recorded !== await valueDigest(resolvedArgs))
|
|
587
684
|
refuse('the receipt recorded different arguments than this plan and the results before it produce');
|
|
588
685
|
}
|
|
589
686
|
const obs = { step, ok: true, result: done.result, stepRef, replayed: true };
|
|
@@ -597,9 +694,9 @@ export async function runIntent(intent, opts) {
|
|
|
597
694
|
if (step.when && !holds(step.when, refs)) {
|
|
598
695
|
const stepRef = step.id ?? `s${i}`;
|
|
599
696
|
const rs = resolveStep(runRef, i, step, tool, resolveArgs(step.args, refs));
|
|
600
|
-
const because = `${step.when.ref} ${step.when.equals !== undefined ? `≠ ${
|
|
697
|
+
const because = `${step.when.ref} ${step.when.equals !== undefined ? `≠ ${canonicalJson(step.when.equals).slice(0, 120)}` : 'is absent'}`;
|
|
601
698
|
emit({ type: 'StepSkipped', runRef, stepRef, toolId: tool.id, because });
|
|
602
|
-
await record({ ...base(rs, now()), status: 'skipped', completedAt: now(), error: `not taken: ${because}` });
|
|
699
|
+
await record({ ...(await base(rs, now())), status: 'skipped', completedAt: now(), error: `not taken: ${because}` });
|
|
603
700
|
return { kind: 'done', obs: { step, ok: true, stepRef, skipped: true } };
|
|
604
701
|
}
|
|
605
702
|
let args = resolveArgs(step.args, refs);
|
|
@@ -613,6 +710,9 @@ export async function runIntent(intent, opts) {
|
|
|
613
710
|
// asked is not a licence to set arguments we did not ask about.
|
|
614
711
|
const declaredArgs = tool.inputSchema?.properties;
|
|
615
712
|
for (const answer of inputsFor(opts.supplied, step.id ?? `s${i}`)) {
|
|
713
|
+
// A CONFIRMATION answers yes/no; its `data` (an outbound `disclosure` digest) binds the answer, never an argument.
|
|
714
|
+
if (answer.confirmed !== undefined)
|
|
715
|
+
continue;
|
|
616
716
|
for (const [name, value] of Object.entries(answer.data ?? {})) {
|
|
617
717
|
if (value === undefined || value === '')
|
|
618
718
|
continue;
|
|
@@ -641,15 +741,43 @@ export async function runIntent(intent, opts) {
|
|
|
641
741
|
const pending = resolveStep(runRef, i, step, tool, args);
|
|
642
742
|
const request = { ...e.request, stepRef: pending.stepRef, toolId: tool.id };
|
|
643
743
|
emit({ type: 'RunSuspended', runRef, stepRef: pending.stepRef, resumeToken: pending.stepRef });
|
|
644
|
-
await record({ ...base(pending, startedAt), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
|
|
744
|
+
await record({ ...(await base(pending, startedAt)), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
|
|
645
745
|
return { kind: 'exit', result: { outcome: 'suspended', plan, steps: allObservations, receipts, runRef, resumeToken: pending.stepRef, prompt: request }, compensate: false };
|
|
646
746
|
}
|
|
647
747
|
}
|
|
648
748
|
const rs = resolveStep(runRef, i, step, tool, args);
|
|
649
749
|
emit({ type: 'StepProposed', runRef, stepRef: rs.stepRef, toolId: tool.id, risk: rs.risk });
|
|
750
|
+
// ── THE STATED OUTCOME, JUDGED ON THE ARGUMENTS THE STEP RUNS WITH (spec 445 W0) ────────────────────────
|
|
751
|
+
// Plan admission judged the plan as written. An amount that was absent then (asked for since) or threaded from an
|
|
752
|
+
// earlier result (resolved since) is a figure only now — and the bound is over the run, so it is summed with the
|
|
753
|
+
// amounts that have already moved. Judged here, before the verifier, before any signature is asked for and
|
|
754
|
+
// before the tool: over the bound ⇒ denied and unwound; still not stated ⇒ the PERSON is asked, by the loop, so
|
|
755
|
+
// no tool is left to choose an amount nobody compared with what she said.
|
|
756
|
+
if (ports.planAdmission && tool.capability && statedOutcomeOf(intent)) {
|
|
757
|
+
const at = spent.length;
|
|
758
|
+
const verdict = await ports.planAdmission.admit({ intent, plan: { steps: [...spent, { toolId: step.toolId, args }] }, tools: plannerTools, concrete: true });
|
|
759
|
+
const mine = verdict.admitted ? [] : verdict.violations.filter((v) => v.stepIndex === at && POINT_OF_USE_CODES.has(v.code));
|
|
760
|
+
const refused = mine.filter((v) => v.recovery !== 'ask');
|
|
761
|
+
if (refused.length) {
|
|
762
|
+
const error = `plan_refused: ${refused.map((v) => v.message).join(' | ')}`;
|
|
763
|
+
emit({ type: 'PlanRefused', runRef, violations: refused.map((v) => ({ ...v, stepIndex: i })), replanning: false });
|
|
764
|
+
await record({ ...(await base(rs, startedAt)), status: 'denied', completedAt: now(), error });
|
|
765
|
+
return { kind: 'exit', result: { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error }, compensate: true };
|
|
766
|
+
}
|
|
767
|
+
const asked = mine.filter((v) => v.field);
|
|
768
|
+
if (asked.length) {
|
|
769
|
+
const request = { kind: 'data', stepRef: rs.stepRef, toolId: tool.id, prompt: asked.map((v) => v.message).join(' '), fields: asked.map((v) => ({ name: v.field, label: v.field, type: 'number', required: true })) };
|
|
770
|
+
emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
|
|
771
|
+
await record({ ...(await base(rs, startedAt)), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
|
|
772
|
+
return { kind: 'exit', result: { outcome: 'suspended', plan, steps: allObservations, receipts, runRef, resumeToken: rs.stepRef, prompt: request }, compensate: false };
|
|
773
|
+
}
|
|
774
|
+
concreteArgs.set(rs.stepRef, args);
|
|
775
|
+
}
|
|
650
776
|
// ── authority, for every step that exercises a capability ──
|
|
651
777
|
let authority;
|
|
652
778
|
let approvalRecords = [];
|
|
779
|
+
/** Spec 445 decision 8 — this step runs under a VERIFIED mandate (the effectful-step authority path). */
|
|
780
|
+
let mandated = false;
|
|
653
781
|
// Spec 374 §1 — THE ASK IS ROUTED; THE MANDATE IS NOT. A step whose subject is another agent is
|
|
654
782
|
// sent there (spec 366 R1), and THAT agent judges its authority under its own playbook, asks its own
|
|
655
783
|
// steward, and answers. Judging it here would ask the asker for a mandate the subject never
|
|
@@ -662,12 +790,12 @@ export async function runIntent(intent, opts) {
|
|
|
662
790
|
presentedRef: 'routed',
|
|
663
791
|
decision: {
|
|
664
792
|
decision: 'allow', obligations: [],
|
|
665
|
-
evidence: { mandateRef: `routed:${judgedAt}`, intentDigest:
|
|
793
|
+
evidence: { mandateRef: `routed:${judgedAt}`, intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'routed', allowed: true, reason: `the step's subject is ${judgedAt}; its authority is judged at that agent, under its own playbook and its own steward — this run holds only what it answers` }] },
|
|
666
794
|
},
|
|
667
795
|
afterApproval: false,
|
|
668
796
|
};
|
|
669
797
|
}
|
|
670
|
-
else if (rs.risk !== 'informational' && tool.selfAuthorized &&
|
|
798
|
+
else if (rs.risk !== 'informational' && tool.selfAuthorized && holdSelfActs && tool.dryRun === 'invoke') {
|
|
671
799
|
// A tool that honours a dry run is INVOKED with it: its preconditions run, nothing is written. A refusal is recorded as
|
|
672
800
|
// executed (it ran, and declined); anything else as skipped — the write it describes did not happen.
|
|
673
801
|
let result;
|
|
@@ -681,19 +809,19 @@ export async function runIntent(intent, opts) {
|
|
|
681
809
|
if (isInputRequired(e)) {
|
|
682
810
|
const request = { ...e.request, stepRef: rs.stepRef, toolId: tool.id };
|
|
683
811
|
emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
|
|
684
|
-
await record({ ...base(rs, startedAt), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
|
|
812
|
+
await record({ ...(await base(rs, startedAt)), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
|
|
685
813
|
return { kind: 'exit', result: { outcome: 'suspended', plan, steps: allObservations, receipts, runRef, resumeToken: rs.stepRef, prompt: request }, compensate: false };
|
|
686
814
|
}
|
|
687
|
-
await record({ ...base(rs, startedAt), status: 'failed', completedAt: now(), error: e instanceof Error ? e.message : String(e) });
|
|
815
|
+
await record({ ...(await base(rs, startedAt)), status: 'failed', completedAt: now(), error: e instanceof Error ? e.message : String(e) });
|
|
688
816
|
return { kind: 'done', obs: { step, ok: false, stepRef: rs.stepRef, error: e instanceof Error ? e.message : String(e) } };
|
|
689
817
|
}
|
|
690
818
|
const declined = !!result && typeof result === 'object' && typeof result.refused === 'string';
|
|
691
|
-
await record({ ...base(rs, startedAt), status: declined ? 'executed' : 'skipped', completedAt: now(), ...(declined ? {} : { error: 'dry run: the invoker checked and wrote nothing' }),
|
|
692
|
-
authority: { presentedRef: 'self', decision: { decision: 'allow', obligations: [], evidence: { mandateRef: 'self', intentDigest:
|
|
819
|
+
await record({ ...(await base(rs, startedAt)), status: declined ? 'executed' : 'skipped', completedAt: now(), ...(declined ? {} : { error: 'dry run: the invoker checked and wrote nothing' }),
|
|
820
|
+
authority: { presentedRef: 'self', decision: { decision: 'allow', obligations: [], evidence: { mandateRef: 'self', intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'self', allowed: true, reason: 'dry run' }] } }, afterApproval: false } });
|
|
693
821
|
return { kind: 'done', obs: { step, ok: true, stepRef: rs.stepRef, ...(declined ? {} : { skipped: true }), result } };
|
|
694
822
|
}
|
|
695
|
-
else if (rs.risk !== 'informational' && tool.selfAuthorized &&
|
|
696
|
-
await record({ ...base(rs, startedAt), status: 'skipped', completedAt: now(), error:
|
|
823
|
+
else if (rs.risk !== 'informational' && tool.selfAuthorized && holdSelfActs) {
|
|
824
|
+
await record({ ...(await base(rs, startedAt)), status: 'skipped', completedAt: now(), error: holdReason });
|
|
697
825
|
return { kind: 'done', obs: { step, ok: true, stepRef: rs.stepRef, skipped: true, result: { dryRun: true, toolId: tool.id } } };
|
|
698
826
|
}
|
|
699
827
|
else if (rs.risk !== 'informational' && tool.selfAuthorized) {
|
|
@@ -708,7 +836,7 @@ export async function runIntent(intent, opts) {
|
|
|
708
836
|
decision: 'allow', obligations: [],
|
|
709
837
|
// The evidence names the basis honestly — there is no mandate to reference, and inventing a
|
|
710
838
|
// mandateRef would make a receipt claim a signature that never happened.
|
|
711
|
-
evidence: { mandateRef: 'self', intentDigest:
|
|
839
|
+
evidence: { mandateRef: 'self', intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'self', allowed: true, reason: 'the run\'s own principal owns what this touches; the session is the authority' }] },
|
|
712
840
|
},
|
|
713
841
|
afterApproval: false,
|
|
714
842
|
};
|
|
@@ -719,12 +847,43 @@ export async function runIntent(intent, opts) {
|
|
|
719
847
|
return { kind: 'exit', result: a, compensate: true };
|
|
720
848
|
authority = { presentedRef: a.used.ref, decision: a.decision, afterApproval: a.approvalRecords.length > 0 };
|
|
721
849
|
approvalRecords = a.approvalRecords;
|
|
850
|
+
mandated = true;
|
|
851
|
+
}
|
|
852
|
+
// ── OUTBOUND (spec 445 decision 8) — a tool that sends data to another party is not run silently once this run
|
|
853
|
+
// has read untrusted content, nor when an argument is threaded from an untrusted result. A verified mandate is
|
|
854
|
+
// the authority path; without one the step PARKS for the asker's confirmation, bound to `outboundDigest` over
|
|
855
|
+
// exactly what would leave. Declined ⇒ denied, nothing sent. Batched steps cannot `$ref` their own batch's
|
|
856
|
+
// results (`independentBatch`), so a batched outbound step sends the plan's literal arguments only.
|
|
857
|
+
let outboundConfirmed;
|
|
858
|
+
if (tool.outbound && !mandated) {
|
|
859
|
+
const threaded = [...refsUsed(step.args)].some((r) => taintedRefs.has(r));
|
|
860
|
+
if (untrustedSeen || threaded) {
|
|
861
|
+
const disclosure = await outboundDigest(tool.id, args);
|
|
862
|
+
const answers = inputsFor(opts.supplied, rs.stepRef).filter((s) => s.confirmed !== undefined);
|
|
863
|
+
const party = typeof tool.outbound === 'object' ? String(args[tool.outbound.partyArg] ?? 'another party') : 'another party';
|
|
864
|
+
if (answers.some((s) => s.confirmed === false)) {
|
|
865
|
+
const error = `outbound_declined: nothing was sent to ${party} — the person declined`;
|
|
866
|
+
await record({ ...(await base(rs, startedAt)), status: 'denied', completedAt: now(), error });
|
|
867
|
+
return { kind: 'exit', result: { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error }, compensate: true };
|
|
868
|
+
}
|
|
869
|
+
if (!answers.some((s) => s.confirmed === true && s.data?.disclosure === disclosure)) {
|
|
870
|
+
const request = {
|
|
871
|
+
kind: 'confirmation', stepRef: rs.stepRef, toolId: tool.id,
|
|
872
|
+
prompt: `This run has read content written by others. Send this to ${party}? Nothing leaves until you confirm exactly this.`,
|
|
873
|
+
summary: { party, sends: args, disclosure, why: threaded ? 'threaded-from-untrusted' : 'untrusted-content-seen', confirmWith: { confirmed: true, data: { disclosure } } },
|
|
874
|
+
};
|
|
875
|
+
emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
|
|
876
|
+
await record({ ...(await base(rs, startedAt)), status: 'suspended', ...(authority ? { authority } : {}), pendingInput: receiptPending(request), completedAt: now() });
|
|
877
|
+
return { kind: 'exit', result: { outcome: 'suspended', plan, steps: allObservations, receipts, runRef, resumeToken: rs.stepRef, prompt: request }, compensate: false };
|
|
878
|
+
}
|
|
879
|
+
outboundConfirmed = disclosure;
|
|
880
|
+
}
|
|
722
881
|
}
|
|
723
882
|
// ── execute ──
|
|
724
883
|
const suppliedHere = inputsFor(opts.supplied, rs.stepRef);
|
|
725
884
|
const binding = opts.bindingFor?.(rs);
|
|
726
885
|
const operationId = binding?.operationId ?? rs.idempotencyKey ?? `${runRef}:${rs.stepRef}`;
|
|
727
|
-
const ctx = { intent, step, index: i, ...(rs.idempotencyKey ? { idempotencyKey: rs.idempotencyKey } : {}), operationId, ...(suppliedHere.length ? { supplied: suppliedHere } : {}), planDigest: currentPlanDigest, untrustedSeen };
|
|
886
|
+
const ctx = { intent, step, index: i, ...(rs.idempotencyKey ? { idempotencyKey: rs.idempotencyKey } : {}), operationId, ...(suppliedHere.length ? { supplied: suppliedHere } : {}), planDigest: currentPlanDigest, untrustedSeen, ...(outboundConfirmed ? { outboundConfirmed } : {}) };
|
|
728
887
|
executed++;
|
|
729
888
|
let obs;
|
|
730
889
|
let observation;
|
|
@@ -764,7 +923,7 @@ export async function runIntent(intent, opts) {
|
|
|
764
923
|
const request = { ...e.request, stepRef: rs.stepRef, toolId: tool.id };
|
|
765
924
|
emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
|
|
766
925
|
await record({
|
|
767
|
-
...base(rs, startedAt),
|
|
926
|
+
...(await base(rs, startedAt)),
|
|
768
927
|
status: 'suspended',
|
|
769
928
|
...(authority ? { authority } : {}),
|
|
770
929
|
...(approvalRecords.length ? { approvalRecords } : {}),
|
|
@@ -778,13 +937,13 @@ export async function runIntent(intent, opts) {
|
|
|
778
937
|
emit({ type: 'ToolInvoked', runRef, stepRef: rs.stepRef, toolId: tool.id, ok: obs.ok });
|
|
779
938
|
const decisions = opts.decisionsFor?.(tool.id, args) ?? [];
|
|
780
939
|
const stepReceipt = {
|
|
781
|
-
...base(rs, startedAt),
|
|
940
|
+
...(await base(rs, startedAt)),
|
|
782
941
|
status: obs.ok ? 'executed' : 'failed',
|
|
783
942
|
// What was decided FOR the person on the way here, and on what basis (spec 363 W6).
|
|
784
943
|
...(decisions.length ? { decisions: [...decisions] } : {}),
|
|
785
944
|
...(authority ? { authority } : {}),
|
|
786
945
|
...(approvalRecords.length ? { approvalRecords } : {}),
|
|
787
|
-
...(obs.ok ? { outputDigest:
|
|
946
|
+
...(obs.ok ? { outputDigest: await valueDigest(obs.result) } : { error: obs.error }),
|
|
788
947
|
// Spec 410 §2 — what the adapter saw, and the one word the receipt attests.
|
|
789
948
|
...(observation ? { observation, attests: observation.outcome } : {}),
|
|
790
949
|
...(reconciled ? { reconciled: true } : {}),
|
|
@@ -916,6 +1075,20 @@ export async function runIntent(intent, opts) {
|
|
|
916
1075
|
emit({ type: 'PlanCreated', runRef, steps: expanded.length, rationale: `fan-out of ${baseId} over <${relation}> into ${expanded.length} steps` });
|
|
917
1076
|
maxSteps += Math.max(0, expanded.length - 1);
|
|
918
1077
|
plan = { ...plan, steps: [...plan.steps.slice(0, i), ...expanded, ...plan.steps.slice(i + 1)] };
|
|
1078
|
+
// RUNC-4 (spec 445 W0) — A BOUND STATED OVER THE PLAN IS JUDGED OVER THE EXPANDED PLAN. Admission saw ONE step
|
|
1079
|
+
// where there are now N; "up to 20 in total" over three payees is only decidable here. The expanded plan goes
|
|
1080
|
+
// back through the same admission port, and a violation no re-plan may cure (`refuse`) ends the run before
|
|
1081
|
+
// any item runs. Other recoveries are not acted on here: the plan as written was already admitted, and an
|
|
1082
|
+
// expansion is not a new plan to re-word.
|
|
1083
|
+
if (ports.planAdmission && expanded.length > 0) {
|
|
1084
|
+
const verdict = await ports.planAdmission.admit({ intent, plan, tools: plannerTools });
|
|
1085
|
+
const refused = verdict.admitted ? [] : verdict.violations.filter((v) => v.recovery === 'refuse');
|
|
1086
|
+
if (refused.length) {
|
|
1087
|
+
emit({ type: 'PlanRefused', runRef, violations: refused, replanning: false });
|
|
1088
|
+
await compensate();
|
|
1089
|
+
return withOwed({ outcome: 'denied', plan, steps: allObservations, receipts, runRef, error: `plan_refused: ${refused.map((v) => v.message).join(' | ')}` });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
919
1092
|
if (expanded.length === 0)
|
|
920
1093
|
continue; // an empty set fans out to nothing, which is a fine answer (the step is gone; `i` now names the next)
|
|
921
1094
|
step = plan.steps[i];
|
|
@@ -940,8 +1113,24 @@ export async function runIntent(intent, opts) {
|
|
|
940
1113
|
for (const o of outcomes) {
|
|
941
1114
|
if (o.kind === 'done') {
|
|
942
1115
|
allObservations.push(o.obs);
|
|
1116
|
+
// What has MOVED in this run, with the arguments it moved with — the running total a bound over the run is
|
|
1117
|
+
// judged against at the next step's point of use (a replayed step counts: it ran, in the run this resumes).
|
|
1118
|
+
if (o.obs.ok && !o.obs.skipped && toolsById.get(o.obs.step.toolId)?.capability) {
|
|
1119
|
+
let ran = o.obs.stepRef ? concreteArgs.get(o.obs.stepRef) : undefined;
|
|
1120
|
+
if (!ran) {
|
|
1121
|
+
try {
|
|
1122
|
+
ran = resolveArgs(o.obs.step.args, refs);
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
ran = o.obs.step.args;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
spent.push({ toolId: o.obs.step.toolId, args: ran });
|
|
1129
|
+
}
|
|
943
1130
|
if (o.obs.ok && isUntrusted(o.obs.result))
|
|
944
1131
|
untrustedSeen = true;
|
|
1132
|
+
if (o.obs.ok && o.obs.step.ref && (isUntrusted(o.obs.result) || [...refsUsed(o.obs.step.args)].some((r) => taintedRefs.has(r))))
|
|
1133
|
+
taintedRefs.add(o.obs.step.ref);
|
|
945
1134
|
opts.onObserve?.(o.obs);
|
|
946
1135
|
if (o.obs.ok && !o.obs.skipped)
|
|
947
1136
|
lastResult = o.obs.result;
|
|
@@ -954,7 +1143,7 @@ export async function runIntent(intent, opts) {
|
|
|
954
1143
|
if (exit) {
|
|
955
1144
|
if (exit.compensate)
|
|
956
1145
|
await compensate();
|
|
957
|
-
return exit.result;
|
|
1146
|
+
return withOwed(exit.result);
|
|
958
1147
|
}
|
|
959
1148
|
if (failedMidPlan)
|
|
960
1149
|
break; // stop this plan; decide whether to re-plan below
|
|
@@ -969,12 +1158,23 @@ export async function runIntent(intent, opts) {
|
|
|
969
1158
|
await compensate();
|
|
970
1159
|
const error = last?.error ?? 'step failed';
|
|
971
1160
|
emit({ type: 'RunFailed', runRef, error });
|
|
972
|
-
return { outcome: 'failed', plan, steps: allObservations, receipts, runRef, error };
|
|
1161
|
+
return withOwed({ outcome: 'failed', plan, steps: allObservations, receipts, runRef, error });
|
|
973
1162
|
}
|
|
974
1163
|
replans++;
|
|
975
1164
|
// Spec 391 W2 — the planner sees what each prior step WAS and how big, not a 40 KB body in its prompt.
|
|
976
|
-
|
|
977
|
-
|
|
1165
|
+
// RUN-3 (spec 445 W0) — and what a failed step SAID is framed as data, bounded (`forPlanner`): it can be a remote
|
|
1166
|
+
// service's text. The plan that comes back is a plan like any other: it passes `admitPlan` — the one gate the
|
|
1167
|
+
// first plan and a continuation pass — BEFORE a step of it runs. It used to go straight to the known-tools
|
|
1168
|
+
// guard, so every admission rule (a `refuse` rule included) was skipped on exactly the planner turn that had
|
|
1169
|
+
// just read an error string. Refused ⇒ the run ends there, and what already ran is unwound.
|
|
1170
|
+
const replanned = await callPlanner(opts.planner, { intent, tools: plannerTools, observations: compactObservations(forPlanner(allObservations)) });
|
|
1171
|
+
const readmitted = await admitPlan(replanned);
|
|
1172
|
+
if ('result' in readmitted) {
|
|
1173
|
+
await compensate();
|
|
1174
|
+
return withOwed(readmitted.result);
|
|
1175
|
+
}
|
|
1176
|
+
plan = readmitted.plan;
|
|
1177
|
+
assertKnownTools(plan, toolIds); // fail-closed after admission (ADR-0013).
|
|
978
1178
|
currentPlanDigest = await planDigest(plan);
|
|
979
1179
|
emit({ type: 'PlanCreated', runRef, steps: plan.steps.length, ...(plan.rationale ? { rationale: plan.rationale } : {}) });
|
|
980
1180
|
}
|
|
@@ -983,7 +1183,7 @@ export async function runIntent(intent, opts) {
|
|
|
983
1183
|
const error = e instanceof OrchestrationError ? `${e.code}: ${e.message}` : e instanceof Error ? e.message : String(e);
|
|
984
1184
|
await compensate();
|
|
985
1185
|
emit({ type: 'RunFailed', runRef, error });
|
|
986
|
-
return { outcome: 'failed', plan, steps: allObservations, receipts, runRef, error };
|
|
1186
|
+
return withOwed({ outcome: 'failed', plan, steps: allObservations, receipts, runRef, error });
|
|
987
1187
|
}
|
|
988
1188
|
}
|
|
989
1189
|
//# sourceMappingURL=loop.js.map
|