@akagilnc/pi-workflow-roles 0.1.1815 → 0.1.1831

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.
@@ -101,7 +101,7 @@ export async function disposeComplianceDecision(decision, handlers, deliveredOut
101
101
  if (handlers.noReceipt === undefined) {
102
102
  throw new Error("Compliance no-receipt projection handler is unavailable");
103
103
  }
104
- return await handlers.noReceipt(decision);
104
+ return await handlers.noReceipt(decision, decision.usage === undefined ? {} : { usage: decision.usage });
105
105
  case "revise":
106
106
  return await handlers.revise(decision.violations);
107
107
  case "escalate":
@@ -1,7 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { executeAuditorChild, } from "./evidence-child-executor.js";
3
3
  import { createAuditorDossierTool } from "./auditor-dossier-tool.js";
4
- import { parseNoReceiptLifecycleFacts } from "./receipt-delivery-policy.js";
5
4
  /** Zero-projection kickoff — soul already carries dossier-fetch duty; no hand-delivered materials. */
6
5
  export const AUDITOR_DOSSIER_PROMPT = "Audit the current run dossier.";
7
6
  const nonblank = Type.String({ minLength: 1, pattern: "\\S" });
@@ -74,10 +73,12 @@ export async function runComplianceAudit(options) {
74
73
  ...(options.runCompletion === undefined ? {} : { runCompletion: options.runCompletion }),
75
74
  ...(options.signal === undefined ? {} : { signal: options.signal }),
76
75
  });
77
- try {
78
- return { status: "no-receipt", ...parseNoReceiptLifecycleFacts(receipt.decision) };
79
- }
80
- catch {
81
- return readComplianceCandidate(receipt.decision, receipt.response.usage);
76
+ if (receipt.noReceiptLifecycle !== undefined) {
77
+ return {
78
+ status: "no-receipt",
79
+ ...receipt.noReceiptLifecycle,
80
+ ...(receipt.response.usage === undefined ? {} : { usage: receipt.response.usage }),
81
+ };
82
82
  }
83
+ return readComplianceCandidate(receipt.decision, receipt.response.usage);
83
84
  }
@@ -428,10 +428,12 @@ export async function executeAuditorChild(options) {
428
428
  });
429
429
  const cwd = options.context.cwd ?? process.cwd();
430
430
  let decision;
431
+ let noReceiptLifecycle;
431
432
  let decisionSubmitted = false;
432
433
  let decisionCallId;
433
434
  let decisionToolFailure;
434
435
  const decisionToolFailures = new Map();
436
+ const delivery = createReceiptDeliveryPolicy();
435
437
  const tool = wrapPackageOwnedToolDefinition({
436
438
  ...options.tool,
437
439
  label: options.roleLabel,
@@ -439,8 +441,12 @@ export async function executeAuditorChild(options) {
439
441
  if (decisionSubmitted && decisionCallId !== args[0]) {
440
442
  throw new Error("Auditor decision was submitted more than once");
441
443
  }
444
+ // Pi may already have issued several decision calls in one assistant
445
+ // response. Execute every issued call: the budget limits future
446
+ // solicitations, not terminal calls already in flight.
442
447
  try {
443
448
  const result = await options.tool.execute(...args);
449
+ delivery.recordAccepted();
444
450
  decision = args[1];
445
451
  decisionCallId = args[0];
446
452
  decisionToolFailure = undefined;
@@ -490,11 +496,13 @@ export async function executeAuditorChild(options) {
490
496
  // response could not later be tied to the current parent attempt.
491
497
  auditorSessionManager.appendCustomEntry(AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE, binding);
492
498
  let turns = 0;
499
+ const sessionUsage = emptyUsage();
493
500
  let boundaryResponse;
494
501
  let retentionFailure;
495
502
  let retainedResponse;
496
503
  let rejectedDecisionResponse;
497
504
  let promptNeighboringFailure;
505
+ let promptDecisionFailures = [];
498
506
  const registeredToolNames = new Set(session.getAllTools().map((entry) => entry.name));
499
507
  const evidenceToolFailures = new Map();
500
508
  for (const name of registeredToolNames) {
@@ -523,9 +531,19 @@ export async function executeAuditorChild(options) {
523
531
  const callIdSet = new Set(callIds);
524
532
  return [...session.messages].reverse().find((message) => message.role === "toolResult" && callIdSet.has(message.toolCallId) && message.isError);
525
533
  };
534
+ const drainRejectedDecisionFailures = (response) => {
535
+ for (const part of response.content) {
536
+ if (part.type !== "toolCall" || part.name !== tool.name || !decisionToolFailures.has(part.id))
537
+ continue;
538
+ decisionToolFailure = decisionToolFailures.get(part.id);
539
+ promptDecisionFailures.push(decisionToolFailure);
540
+ decisionToolFailures.delete(part.id);
541
+ }
542
+ };
526
543
  const unsubscribe = session.subscribe((event) => {
527
544
  if (event.type === "message_end" && event.message.role === "assistant" && boundaryResponse === undefined) {
528
545
  turns += 1;
546
+ addUsage(sessionUsage, event.message.usage);
529
547
  retainedResponse = event.message;
530
548
  try {
531
549
  options.retainResponse?.(event.message);
@@ -556,14 +574,11 @@ export async function executeAuditorChild(options) {
556
574
  if (event.type === "turn_end") {
557
575
  if (rejectedDecisionResponse !== undefined) {
558
576
  promptNeighboringFailure = findToolFailure(rejectedDecisionResponse);
559
- const rejectedCall = rejectedDecisionResponse.content.find((part) => part.type === "toolCall" && part.name === tool.name && decisionToolFailures.has(part.id));
560
- if (rejectedCall?.type === "toolCall") {
561
- decisionToolFailure = decisionToolFailures.get(rejectedCall.id);
562
- decisionToolFailures.delete(rejectedCall.id);
563
- }
577
+ drainRejectedDecisionFailures(rejectedDecisionResponse);
564
578
  }
565
579
  if (decisionSubmitted || promptNeighboringFailure !== undefined
566
- || boundaryResponse !== undefined || retentionFailure !== undefined) {
580
+ || (boundaryResponse !== undefined && rejectedDecisionResponse === undefined)
581
+ || retentionFailure !== undefined) {
567
582
  void session.abort();
568
583
  }
569
584
  }
@@ -575,11 +590,11 @@ export async function executeAuditorChild(options) {
575
590
  options.signal?.addEventListener("abort", abort, { once: true });
576
591
  try {
577
592
  try {
578
- const delivery = createReceiptDeliveryPolicy();
579
593
  const promptAllowingRejectedDecision = async (prompt) => {
580
594
  rejectedDecisionResponse = undefined;
581
595
  promptNeighboringFailure = undefined;
582
596
  decisionToolFailure = undefined;
597
+ promptDecisionFailures = [];
583
598
  let promptFailure;
584
599
  try {
585
600
  await session.prompt(prompt);
@@ -593,28 +608,47 @@ export async function executeAuditorChild(options) {
593
608
  const correlatedResponse = rejectedDecisionResponse;
594
609
  if (correlatedResponse !== undefined) {
595
610
  promptNeighboringFailure ??= findToolFailure(correlatedResponse);
596
- const rejectedCall = correlatedResponse.content.find((part) => part.type === "toolCall" && part.name === tool.name && decisionToolFailures.has(part.id));
597
- if (rejectedCall?.type === "toolCall") {
598
- decisionToolFailure = decisionToolFailures.get(rejectedCall.id);
599
- decisionToolFailures.delete(rejectedCall.id);
600
- }
611
+ drainRejectedDecisionFailures(correlatedResponse);
601
612
  }
602
613
  // An adjacent failure outranks correctable decision feedback.
603
614
  if (promptNeighboringFailure !== undefined)
604
615
  throw promptNeighboringFailure;
616
+ // An accepted correction in the same response owns the terminal
617
+ // outcome; correlated rejected siblings remain observations, not a
618
+ // stale failure capable of replacing that accepted receipt.
619
+ if (decisionSubmitted) {
620
+ decisionToolFailure = undefined;
621
+ return;
622
+ }
605
623
  if (decisionToolFailure !== undefined)
606
624
  return;
607
625
  if (promptFailure !== undefined)
608
626
  throw promptFailure;
609
627
  };
628
+ const chargeAndClearRejectedDecisionFailures = (failures) => {
629
+ for (const failure of failures) {
630
+ delivery.recordRejected(failure instanceof Error ? failure.message : String(failure));
631
+ }
632
+ decisionToolFailure = undefined;
633
+ promptDecisionFailures = [];
634
+ };
610
635
  await promptAllowingRejectedDecision(options.prompt);
611
- while (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
612
- && delivery.nextAction() === "request-delivery") {
636
+ while (!decisionSubmitted && (boundaryResponse === undefined || decisionToolFailure !== undefined)
637
+ && inherited.streamFailure === undefined && delivery.nextAction() === "request-delivery") {
613
638
  if (decisionToolFailure !== undefined) {
614
- delivery.recordRejected(decisionToolFailure instanceof Error ? decisionToolFailure.message : String(decisionToolFailure));
615
- decisionToolFailure = undefined;
639
+ const failures = promptDecisionFailures.length === 0
640
+ ? [decisionToolFailure]
641
+ : promptDecisionFailures;
642
+ chargeAndClearRejectedDecisionFailures(failures);
643
+ if (delivery.nextAction() === "no-receipt")
644
+ boundaryResponse = undefined;
616
645
  if (delivery.nextAction() === "request-delivery") {
617
- await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
646
+ // A rejection and its correction solicitation are one budget unit;
647
+ // recordRejected already charged it.
648
+ if (retainedResponse === rejectedDecisionResponse) {
649
+ await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
650
+ chargeAndClearRejectedDecisionFailures(promptDecisionFailures);
651
+ }
618
652
  }
619
653
  }
620
654
  else {
@@ -622,15 +656,19 @@ export async function executeAuditorChild(options) {
622
656
  await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
623
657
  }
624
658
  }
625
- if (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
659
+ if (!decisionSubmitted && inherited.streamFailure === undefined
626
660
  && delivery.nextAction() === "no-receipt") {
627
661
  const runPointer = options.context.sessionManager.getSessionFile() ?? options.context.cwd ?? process.cwd();
628
662
  const attemptPointer = binding.parent.attemptEntryId ?? binding.parent.sessionId ?? `current:${runPointer}`;
629
- decision = delivery.facts({ runPointer, attemptPointer });
663
+ const facts = delivery.facts({ runPointer, attemptPointer });
664
+ decision = facts;
630
665
  // Late turn_end feedback cannot overturn a lifecycle that has already
631
666
  // charged this prompt to the exhausted shared budget.
632
667
  decisionToolFailure = undefined;
633
- auditorSessionManager.appendCustomEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, decision);
668
+ auditorSessionManager.appendCustomEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
669
+ // Provenance is granted only after the lifecycle owner persisted the
670
+ // current child record; accepted model arguments can never set it.
671
+ noReceiptLifecycle = facts;
634
672
  }
635
673
  }
636
674
  catch (error) {
@@ -644,7 +682,7 @@ export async function executeAuditorChild(options) {
644
682
  throw options.signal.reason;
645
683
  if (inherited.streamFailure !== undefined)
646
684
  throw inherited.streamFailure;
647
- if (decisionToolFailure !== undefined)
685
+ if (!decisionSubmitted && decisionToolFailure !== undefined)
648
686
  throw decisionToolFailure;
649
687
  const relevantResponse = !decisionSubmitted
650
688
  ? boundaryResponse
@@ -656,7 +694,7 @@ export async function executeAuditorChild(options) {
656
694
  }
657
695
  if (retentionFailure !== undefined && retainedResponse?.stopReason !== "error")
658
696
  throw retentionFailure;
659
- if (boundaryResponse !== undefined && !decisionSubmitted) {
697
+ if (boundaryResponse !== undefined && !decisionSubmitted && noReceiptLifecycle === undefined) {
660
698
  const toolNames = boundaryResponse.content.flatMap((part) => part.type === "toolCall" ? [part.name] : []);
661
699
  throw new AuditorTurnLimitError(AUDITOR_TURN_LIMIT, turns, {
662
700
  stopReason: boundaryResponse.stopReason,
@@ -728,7 +766,11 @@ export async function executeAuditorChild(options) {
728
766
  || (!decisionSubmitted && decision === undefined)) {
729
767
  throw new Error(`${options.roleLabel} exited without a readable decision receipt`);
730
768
  }
731
- return { decision, response };
769
+ return {
770
+ decision,
771
+ response: { ...response, usage: sessionUsage },
772
+ ...(noReceiptLifecycle === undefined ? {} : { noReceiptLifecycle }),
773
+ };
732
774
  }
733
775
  finally {
734
776
  options.signal?.removeEventListener("abort", abort);
@@ -438,8 +438,13 @@ function createNavigatorAttendance(options) {
438
438
  ${text}
439
439
  </role_help>`).join("\n");
440
440
  let output;
441
+ let prepareBatchRejected = false;
441
442
  outputSink = (value) => {
442
- if (output !== void 0) throw new Error("Navigator preparation must submit exactly one typed candidate batch");
443
+ if (prepareBatchRejected || output !== void 0) {
444
+ output = void 0;
445
+ prepareBatchRejected = true;
446
+ throw new Error("Navigator preparation must submit exactly one typed candidate batch");
447
+ }
443
448
  output = value;
444
449
  };
445
450
  const tool = createNavigatorPrepareTool((value) => {
@@ -553,6 +558,7 @@ ${helpContext}
553
558
  const delivery = createReceiptDeliveryPolicy();
554
559
  const promptAllowingRejectedPrepare = async (text, deliveryRequest) => {
555
560
  const entryStart = activeSession.entries().length;
561
+ prepareBatchRejected = false;
556
562
  let promptFailure;
557
563
  try {
558
564
  await activeSession.prompt(text);
@@ -565,6 +571,8 @@ ${helpContext}
565
571
  }
566
572
  const rejectedReason = rejectedPrepareReason(activeSession.entries(), entryStart);
567
573
  if (rejectedReason !== void 0) {
574
+ output = void 0;
575
+ prepareBatchRejected = true;
568
576
  delivery.recordRejected(rejectedReason);
569
577
  return;
570
578
  }
@@ -17909,12 +17909,15 @@ function isRecord4(value) {
17909
17909
  return typeof value === "object" && value !== null && !Array.isArray(value);
17910
17910
  }
17911
17911
  function parseNoReceiptLifecycleFacts(input) {
17912
- if (!isRecord4(input) || typeof input.terminalToolCalled !== "boolean" || input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT || input.sessionCompletion !== "settled-without-accepted-receipt" || input.acceptedReceipt !== false || typeof input.runPointer !== "string" || input.runPointer.trim() === "" || typeof input.attemptPointer !== "string" || input.attemptPointer.trim() === "" || !Array.isArray(input.rejectedReceipts) || !input.rejectedReceipts.every((item) => isRecord4(item) && typeof item.reason === "string" && item.reason.trim() !== "")) {
17912
+ if (!isRecord4(input) || typeof input.terminalToolCalled !== "boolean" || input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT || input.sessionCompletion !== "settled-without-accepted-receipt" || input.acceptedReceipt !== false || typeof input.runPointer !== "string" || input.runPointer.trim() === "" || typeof input.attemptPointer !== "string" || input.attemptPointer.trim() === "" || !Array.isArray(input.rejectedReceipts) || !input.rejectedReceipts.every((item) => isRecord4(item) && typeof item.reason === "string")) {
17913
17913
  throw new TypeError("malformed no-receipt lifecycle facts");
17914
17914
  }
17915
17915
  return {
17916
17916
  terminalToolCalled: input.terminalToolCalled,
17917
- rejectedReceipts: input.rejectedReceipts.map((item) => ({ reason: item.reason })),
17917
+ rejectedReceipts: input.rejectedReceipts.map((item) => ({
17918
+ reason: item.reason,
17919
+ diagnosticAvailable: item.reason.trim() !== ""
17920
+ })),
17918
17921
  deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
17919
17922
  sessionCompletion: "settled-without-accepted-receipt",
17920
17923
  runPointer: input.runPointer,
@@ -17967,7 +17970,6 @@ var init_compliance_transport = __esm({
17967
17970
  init_build();
17968
17971
  init_evidence_child_executor();
17969
17972
  init_auditor_dossier_tool();
17970
- init_receipt_delivery_policy();
17971
17973
  nonblank2 = typebox_exports.String({ minLength: 1, pattern: "\\S" });
17972
17974
  decisionGateSchema = typebox_exports.Object({ question: nonblank2, options: typebox_exports.Array(nonblank2, { minItems: 1 }) }, { additionalProperties: false });
17973
17975
  complianceDecisionSchema = typebox_exports.Object({ status: typebox_exports.Unknown({ description: "Auditor decision status." }), violations: typebox_exports.Array(nonblank2, { description: "Observed compliance violations." }), conflicts: typebox_exports.Array(nonblank2, { description: "Unresolved authority or execution conflicts." }), decisionGate: typebox_exports.Union([decisionGateSchema, typebox_exports.Null()], { description: "Escalation question and available options." }) }, { additionalProperties: true, required: [] });
@@ -16,12 +16,15 @@ export function parseNoReceiptLifecycleFacts(input) {
16
16
  || typeof input.attemptPointer !== "string" || input.attemptPointer.trim() === ""
17
17
  || !Array.isArray(input.rejectedReceipts)
18
18
  || !input.rejectedReceipts.every((item) => isRecord(item)
19
- && typeof item.reason === "string" && item.reason.trim() !== "")) {
19
+ && typeof item.reason === "string")) {
20
20
  throw new TypeError("malformed no-receipt lifecycle facts");
21
21
  }
22
22
  return {
23
23
  terminalToolCalled: input.terminalToolCalled,
24
- rejectedReceipts: input.rejectedReceipts.map((item) => ({ reason: item.reason })),
24
+ rejectedReceipts: input.rejectedReceipts.map((item) => ({
25
+ reason: item.reason,
26
+ diagnosticAvailable: item.reason.trim() !== "",
27
+ })),
25
28
  deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
26
29
  sessionCompletion: "settled-without-accepted-receipt",
27
30
  runPointer: input.runPointer,
@@ -35,7 +38,10 @@ export function noReceiptLifecycleFacts(input) {
35
38
  }
36
39
  return {
37
40
  terminalToolCalled: input.terminalToolCalled,
38
- rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({ reason })),
41
+ rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({
42
+ reason,
43
+ diagnosticAvailable: reason.trim() !== "",
44
+ })),
39
45
  deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
40
46
  sessionCompletion: "settled-without-accepted-receipt",
41
47
  runPointer: input.runPointer,
@@ -49,12 +55,15 @@ export function createReceiptDeliveryPolicy() {
49
55
  let deliveryTurns = 0;
50
56
  const rejectedReceipts = [];
51
57
  return {
52
- recordAccepted() { accepted = true; terminalToolCalled = true; },
58
+ recordAccepted() {
59
+ accepted = true;
60
+ terminalToolCalled = true;
61
+ },
53
62
  /** Infrastructure owns terminality and must never trigger receipt催交. */
54
63
  stopForInfrastructure() { accepted = true; },
55
64
  recordRejected(reason) {
56
65
  terminalToolCalled = true;
57
- rejectedReceipts.push({ reason });
66
+ rejectedReceipts.push({ reason, diagnosticAvailable: reason.trim() !== "" });
58
67
  deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
59
68
  },
60
69
  recordDeliveryRequest() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.1815",
3
+ "version": "0.1.1831",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -148,6 +148,7 @@ export type ComplianceDecisionHandlers<T> = {
148
148
  /** Project the accepted parent candidate beside the typed audit-leg facts. */
149
149
  noReceipt?: (
150
150
  facts: Extract<ComplianceDecision, { status: "no-receipt" }>,
151
+ usageProjection: { usage?: Usage },
151
152
  ) => T | PromiseLike<T>;
152
153
  revise: (violations: readonly unknown[]) => T | PromiseLike<T>;
153
154
  escalate: (result: AuditEscalationToolResult) => T | PromiseLike<T>;
@@ -172,7 +173,10 @@ export async function disposeComplianceDecision<T>(
172
173
  if (handlers.noReceipt === undefined) {
173
174
  throw new Error("Compliance no-receipt projection handler is unavailable");
174
175
  }
175
- return await handlers.noReceipt(decision);
176
+ return await handlers.noReceipt(
177
+ decision,
178
+ decision.usage === undefined ? {} : { usage: decision.usage },
179
+ );
176
180
  case "revise":
177
181
  return await handlers.revise(decision.violations);
178
182
  case "escalate":
@@ -7,7 +7,7 @@ import {
7
7
  } from "./evidence-child-executor.ts";
8
8
  import { createAuditorDossierTool } from "./auditor-dossier-tool.ts";
9
9
  import type { DossierObservation } from "./dossier-resolution.ts";
10
- import { parseNoReceiptLifecycleFacts, type NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
10
+ import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
11
11
 
12
12
  export type ComplianceCompletion = AuditorCompletion;
13
13
  export type ComplianceArgumentRootType = "null" | "array" | "undefined" | "string" | "number" | "boolean" | "bigint" | "symbol" | "function";
@@ -16,7 +16,7 @@ export type ComplianceAuditObservation =
16
16
  | { kind: "object-status-unreadable"; status: "missing" | "unknown" }
17
17
  | DossierObservation;
18
18
  export type ComplianceAuditIncomplete = { status: "audit-incomplete"; observation: ComplianceAuditObservation; candidate: unknown; usage?: Usage };
19
- export type ComplianceNoReceipt = NoReceiptLifecycleFacts & { status: "no-receipt" };
19
+ export type ComplianceNoReceipt = NoReceiptLifecycleFacts & { status: "no-receipt"; usage?: Usage };
20
20
  export type ComplianceDecision = { status: "pass"; usage?: Usage } | { status: "revise"; violations: readonly unknown[]; usage?: Usage } | { status: "escalate"; conflicts?: unknown; decisionGate?: unknown; usage?: Usage } | ComplianceNoReceipt | ComplianceAuditIncomplete;
21
21
  export type ComplianceDispatch = { model: Model<Api>; auth: { apiKey?: string; headers?: Record<string, string | null>; env?: Record<string, string> } };
22
22
 
@@ -128,9 +128,12 @@ export async function runComplianceAudit(options: RunComplianceAuditOptions): Pr
128
128
  ...(options.runCompletion === undefined ? {} : { runCompletion: options.runCompletion }),
129
129
  ...(options.signal === undefined ? {} : { signal: options.signal }),
130
130
  });
131
- try {
132
- return { status: "no-receipt", ...parseNoReceiptLifecycleFacts(receipt.decision) };
133
- } catch {
134
- return readComplianceCandidate(receipt.decision, receipt.response.usage);
131
+ if (receipt.noReceiptLifecycle !== undefined) {
132
+ return {
133
+ status: "no-receipt",
134
+ ...receipt.noReceiptLifecycle,
135
+ ...(receipt.response.usage === undefined ? {} : { usage: receipt.response.usage }),
136
+ };
135
137
  }
138
+ return readComplianceCandidate(receipt.decision, receipt.response.usage);
136
139
  }
@@ -12,7 +12,7 @@ export function createDoctorRoleRuntime(pi: ExtensionAPI, dependencies: DoctorRo
12
12
  let activation: { soul: string; patient: DoctorCase; store: DoctorEvidenceStore } | undefined; let registered = false; pi.registerFlag(DOCTOR_CASE_FLAG.name, DOCTOR_CASE_FLAG.definition);
13
13
  return { async activate() { const path = pi.getFlag(DOCTOR_CASE_FLAG.name); if (typeof path !== "string" || !path.trim()) throw new Error("Doctor requires --ak-doctor-case"); const soul = (await dependencies.loadSoul()).trim(); if (!soul) throw new Error("Doctor soul is empty"); const patient = await dependencies.loadCase(path); activation = { soul, patient, store: new DoctorEvidenceStore(patient) };
14
14
  if (!registered) { registered = true; pi.registerTool({ name: DOCTOR_EVIDENCE_TOOL_NAME, label: "Doctor Evidence", description: "Read retained Pi session bytes with bounded pagination.", parameters: doctorEvidenceReadSchema, async execute(_id, params: { evidenceId: string; offset?: number; limit?: number }) { if (!activation) throw new Error("Doctor is not activated"); const details = activation.store.read(params.evidenceId, params.offset, params.limit); return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details }; } });
15
- pi.registerTool({ name: DOCTOR_OUTPUT_TOOL_NAME, label: "Doctor Output", description: DOCTOR_OUTPUT_TOOL_DESCRIPTION, parameters: doctorSubmissionSchema, async execute(id, params, signal, _update, ctx): Promise<AgentToolResult<unknown>> { if (!activation) throw new Error("Doctor is not activated"); singleton(id, ctx); const testimony = validateDoctorOutput(params, activation.patient, activation.store); try { appendActiveSessionCustomEntry(ctx, DOCTOR_CANDIDATE_ENTRY_TYPE, { version: 1, testimony, readRecord: activation.store.readRecord(), patientIdentity: activation.patient.identity }, { unavailable: "doctor candidate retention is unavailable", failed: "doctor candidate retention failed" }); } catch (error) { host.failInfrastructure(error, ctx, id); } let audit: ComplianceDecision; try { audit = await dependencies.auditCompliance(signal === undefined ? { context: ctx } : { context: ctx, signal }); } catch (error) { host.failInfrastructure(error, ctx, id); } const details = testimony.status === "completed" ? { ...testimony, cost: activation.patient.cost } : testimony; return disposeComplianceDecision<AgentToolResult<unknown>>(audit, { pass: (usage) => ({ content: [{ type: "text" as const, text: "Doctor output accepted" }], details, terminate: true as const, ...(usage === undefined ? {} : { usage }) }), noReceipt: (auditNoReceipt) => ({ content: [{ type: "text" as const, text: "Doctor output accepted; compliance audit produced no receipt" }], details: { ...details, auditNoReceipt }, terminate: true as const }), revise: (violations) => { throw new Error(`Doctor output violates its soul: ${violations.join("; ")}`); }, escalate: (result) => result, auditIncomplete: (result) => result }, details); } });
15
+ pi.registerTool({ name: DOCTOR_OUTPUT_TOOL_NAME, label: "Doctor Output", description: DOCTOR_OUTPUT_TOOL_DESCRIPTION, parameters: doctorSubmissionSchema, async execute(id, params, signal, _update, ctx): Promise<AgentToolResult<unknown>> { if (!activation) throw new Error("Doctor is not activated"); singleton(id, ctx); const testimony = validateDoctorOutput(params, activation.patient, activation.store); try { appendActiveSessionCustomEntry(ctx, DOCTOR_CANDIDATE_ENTRY_TYPE, { version: 1, testimony, readRecord: activation.store.readRecord(), patientIdentity: activation.patient.identity }, { unavailable: "doctor candidate retention is unavailable", failed: "doctor candidate retention failed" }); } catch (error) { host.failInfrastructure(error, ctx, id); } let audit: ComplianceDecision; try { audit = await dependencies.auditCompliance(signal === undefined ? { context: ctx } : { context: ctx, signal }); } catch (error) { host.failInfrastructure(error, ctx, id); } const details = testimony.status === "completed" ? { ...testimony, cost: activation.patient.cost } : testimony; return disposeComplianceDecision<AgentToolResult<unknown>>(audit, { pass: (usage) => ({ content: [{ type: "text" as const, text: "Doctor output accepted" }], details, terminate: true as const, ...(usage === undefined ? {} : { usage }) }), noReceipt: (auditNoReceipt, usageProjection) => ({ content: [{ type: "text" as const, text: "Doctor output accepted; compliance audit produced no receipt" }], details: { ...details, auditNoReceipt }, terminate: true as const, ...usageProjection }), revise: (violations) => { throw new Error(`Doctor output violates its soul: ${violations.join("; ")}`); }, escalate: (result) => result, auditIncomplete: (result) => result }, details); } });
16
16
  pi.on("before_agent_start", (event) => { if (!activation) throw new Error("Doctor is not activated"); const catalog = { version: activation.patient.version, identity: activation.patient.identity, admittedMetrics: { provenance: "runtime-derived from retained session bytes and sealed into the accepted receipt", cost: activation.patient.cost }, lawfulTargetKeys: ["case", ...activation.patient.cost.invocations.sources], evidence: activation.patient.evidence.map(({ id, kind, sha256, byteLength, contentLength }) => ({ id, kind, sha256, byteLength, contentLength })) }; return { systemPrompt: `${event.systemPrompt}\n\n<doctor_soul>\n${activation.soul}\n</doctor_soul>\n\n<doctor_case>\n${JSON.stringify(catalog)}\n</doctor_case>` }; }); }
17
17
  const required = [DOCTOR_EVIDENCE_TOOL_NAME, DOCTOR_OUTPUT_TOOL_NAME]; const names = pi.getAllTools().map((tool) => tool.name); for (const name of required) if (names.filter((item) => item === name).length !== 1) throw new Error(`Doctor required tool collision or missing: ${name}`); pi.setActiveTools(required); const active = pi.getActiveTools?.() ?? required; if (active.length !== 2 || !required.every((name) => active.includes(name))) throw new Error("Doctor active tool narrowing failed"); } };
18
18
  }
@@ -32,7 +32,7 @@ import {
32
32
  import { wrapPackageOwnedToolDefinition } from "./package-owned-tool-idle.ts";
33
33
  import type { ReviewerPromptText } from "./reviewer-prompt-identity.ts";
34
34
  import { createStreamIdleGuard, isStreamIdleTimeoutError } from "./stream-idle-guard.ts";
35
- import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
35
+ import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT, type NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
36
36
 
37
37
  // ── shared constants / types ──────────────────────────────────────────────
38
38
 
@@ -532,7 +532,7 @@ export type AuditorRoleOptions = {
532
532
  */
533
533
  export async function executeAuditorChild(
534
534
  options: AuditorRoleOptions,
535
- ): Promise<{ decision: unknown; response: AssistantMessage }> {
535
+ ): Promise<{ decision: unknown; response: AssistantMessage; noReceiptLifecycle?: NoReceiptLifecycleFacts }> {
536
536
  const { createRecordSession } = await import("./sitian-record-entry.ts");
537
537
 
538
538
  return withInProcessScratch({ prefix: "ak-auditor-role-" }, async (scratch) => {
@@ -549,10 +549,12 @@ export async function executeAuditorChild(
549
549
  const cwd = options.context.cwd ?? process.cwd();
550
550
 
551
551
  let decision: unknown;
552
+ let noReceiptLifecycle: NoReceiptLifecycleFacts | undefined;
552
553
  let decisionSubmitted = false;
553
554
  let decisionCallId: string | undefined;
554
555
  let decisionToolFailure: unknown;
555
556
  const decisionToolFailures = new Map<string, unknown>();
557
+ const delivery = createReceiptDeliveryPolicy();
556
558
  const tool = wrapPackageOwnedToolDefinition({
557
559
  ...options.tool,
558
560
  label: options.roleLabel,
@@ -560,8 +562,12 @@ export async function executeAuditorChild(
560
562
  if (decisionSubmitted && decisionCallId !== args[0]) {
561
563
  throw new Error("Auditor decision was submitted more than once");
562
564
  }
565
+ // Pi may already have issued several decision calls in one assistant
566
+ // response. Execute every issued call: the budget limits future
567
+ // solicitations, not terminal calls already in flight.
563
568
  try {
564
569
  const result = await options.tool.execute(...args);
570
+ delivery.recordAccepted();
565
571
  decision = args[1];
566
572
  decisionCallId = args[0];
567
573
  decisionToolFailure = undefined;
@@ -614,11 +620,13 @@ export async function executeAuditorChild(
614
620
  auditorSessionManager.appendCustomEntry(AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE, binding);
615
621
 
616
622
  let turns = 0;
623
+ const sessionUsage = emptyUsage();
617
624
  let boundaryResponse: AssistantMessage | undefined;
618
625
  let retentionFailure: unknown;
619
626
  let retainedResponse: AssistantMessage | undefined;
620
627
  let rejectedDecisionResponse: AssistantMessage | undefined;
621
628
  let promptNeighboringFailure: unknown;
629
+ let promptDecisionFailures: unknown[] = [];
622
630
  const registeredToolNames = new Set(session.getAllTools().map((entry) => entry.name));
623
631
  const evidenceToolFailures = new Map<string, unknown>();
624
632
  for (const name of registeredToolNames) {
@@ -645,9 +653,18 @@ export async function executeAuditorChild(
645
653
  return [...session.messages].reverse().find((message) =>
646
654
  message.role === "toolResult" && callIdSet.has(message.toolCallId) && message.isError);
647
655
  };
656
+ const drainRejectedDecisionFailures = (response: AssistantMessage) => {
657
+ for (const part of response.content) {
658
+ if (part.type !== "toolCall" || part.name !== tool.name || !decisionToolFailures.has(part.id)) continue;
659
+ decisionToolFailure = decisionToolFailures.get(part.id);
660
+ promptDecisionFailures.push(decisionToolFailure);
661
+ decisionToolFailures.delete(part.id);
662
+ }
663
+ };
648
664
  const unsubscribe = session.subscribe((event) => {
649
665
  if (event.type === "message_end" && event.message.role === "assistant" && boundaryResponse === undefined) {
650
666
  turns += 1;
667
+ addUsage(sessionUsage, event.message.usage);
651
668
  retainedResponse = event.message;
652
669
  try { options.retainResponse?.(event.message); } catch (error) { retentionFailure = error; }
653
670
  // A tool call in assistant output is only an observation. Preserve its
@@ -671,15 +688,11 @@ export async function executeAuditorChild(
671
688
  if (event.type === "turn_end") {
672
689
  if (rejectedDecisionResponse !== undefined) {
673
690
  promptNeighboringFailure = findToolFailure(rejectedDecisionResponse);
674
- const rejectedCall = rejectedDecisionResponse.content.find((part) =>
675
- part.type === "toolCall" && part.name === tool.name && decisionToolFailures.has(part.id));
676
- if (rejectedCall?.type === "toolCall") {
677
- decisionToolFailure = decisionToolFailures.get(rejectedCall.id);
678
- decisionToolFailures.delete(rejectedCall.id);
679
- }
691
+ drainRejectedDecisionFailures(rejectedDecisionResponse);
680
692
  }
681
693
  if (decisionSubmitted || promptNeighboringFailure !== undefined
682
- || boundaryResponse !== undefined || retentionFailure !== undefined) {
694
+ || (boundaryResponse !== undefined && rejectedDecisionResponse === undefined)
695
+ || retentionFailure !== undefined) {
683
696
  void session.abort();
684
697
  }
685
698
  }
@@ -690,11 +703,11 @@ export async function executeAuditorChild(
690
703
 
691
704
  try {
692
705
  try {
693
- const delivery = createReceiptDeliveryPolicy();
694
706
  const promptAllowingRejectedDecision = async (prompt: string) => {
695
707
  rejectedDecisionResponse = undefined;
696
708
  promptNeighboringFailure = undefined;
697
709
  decisionToolFailure = undefined;
710
+ promptDecisionFailures = [];
698
711
  let promptFailure: unknown;
699
712
  try {
700
713
  await session.prompt(prompt);
@@ -707,41 +720,62 @@ export async function executeAuditorChild(
707
720
  const correlatedResponse = rejectedDecisionResponse as AssistantMessage | undefined;
708
721
  if (correlatedResponse !== undefined) {
709
722
  promptNeighboringFailure ??= findToolFailure(correlatedResponse);
710
- const rejectedCall = correlatedResponse.content.find((part) =>
711
- part.type === "toolCall" && part.name === tool.name && decisionToolFailures.has(part.id));
712
- if (rejectedCall?.type === "toolCall") {
713
- decisionToolFailure = decisionToolFailures.get(rejectedCall.id);
714
- decisionToolFailures.delete(rejectedCall.id);
715
- }
723
+ drainRejectedDecisionFailures(correlatedResponse);
716
724
  }
717
725
  // An adjacent failure outranks correctable decision feedback.
718
726
  if (promptNeighboringFailure !== undefined) throw promptNeighboringFailure;
727
+ // An accepted correction in the same response owns the terminal
728
+ // outcome; correlated rejected siblings remain observations, not a
729
+ // stale failure capable of replacing that accepted receipt.
730
+ if (decisionSubmitted) {
731
+ decisionToolFailure = undefined;
732
+ return;
733
+ }
719
734
  if (decisionToolFailure !== undefined) return;
720
735
  if (promptFailure !== undefined) throw promptFailure;
721
736
  };
737
+ const chargeAndClearRejectedDecisionFailures = (failures: unknown[]) => {
738
+ for (const failure of failures) {
739
+ delivery.recordRejected(failure instanceof Error ? failure.message : String(failure));
740
+ }
741
+ decisionToolFailure = undefined;
742
+ promptDecisionFailures = [];
743
+ };
722
744
  await promptAllowingRejectedDecision(options.prompt);
723
- while (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
724
- && delivery.nextAction() === "request-delivery") {
745
+ while (!decisionSubmitted && (boundaryResponse === undefined || decisionToolFailure !== undefined)
746
+ && inherited.streamFailure === undefined && delivery.nextAction() === "request-delivery") {
725
747
  if (decisionToolFailure !== undefined) {
726
- delivery.recordRejected(decisionToolFailure instanceof Error ? decisionToolFailure.message : String(decisionToolFailure));
727
- decisionToolFailure = undefined;
748
+ const failures = promptDecisionFailures.length === 0
749
+ ? [decisionToolFailure]
750
+ : promptDecisionFailures;
751
+ chargeAndClearRejectedDecisionFailures(failures);
752
+ if (delivery.nextAction() === "no-receipt") boundaryResponse = undefined;
728
753
  if (delivery.nextAction() === "request-delivery") {
729
- await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
754
+ // A rejection and its correction solicitation are one budget unit;
755
+ // recordRejected already charged it.
756
+ if (retainedResponse === rejectedDecisionResponse) {
757
+ await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
758
+ chargeAndClearRejectedDecisionFailures(promptDecisionFailures);
759
+ }
730
760
  }
731
761
  } else {
732
762
  delivery.recordDeliveryRequest();
733
763
  await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
734
764
  }
735
765
  }
736
- if (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
766
+ if (!decisionSubmitted && inherited.streamFailure === undefined
737
767
  && delivery.nextAction() === "no-receipt") {
738
768
  const runPointer = options.context.sessionManager.getSessionFile() ?? options.context.cwd ?? process.cwd();
739
769
  const attemptPointer = binding.parent.attemptEntryId ?? binding.parent.sessionId ?? `current:${runPointer}`;
740
- decision = delivery.facts({ runPointer, attemptPointer });
770
+ const facts = delivery.facts({ runPointer, attemptPointer });
771
+ decision = facts;
741
772
  // Late turn_end feedback cannot overturn a lifecycle that has already
742
773
  // charged this prompt to the exhausted shared budget.
743
774
  decisionToolFailure = undefined;
744
- auditorSessionManager.appendCustomEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, decision);
775
+ auditorSessionManager.appendCustomEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
776
+ // Provenance is granted only after the lifecycle owner persisted the
777
+ // current child record; accepted model arguments can never set it.
778
+ noReceiptLifecycle = facts;
745
779
  }
746
780
  } catch (error) {
747
781
  if (options.signal?.aborted) throw options.signal.reason;
@@ -750,7 +784,7 @@ export async function executeAuditorChild(
750
784
  }
751
785
  if (options.signal?.aborted) throw options.signal.reason;
752
786
  if (inherited.streamFailure !== undefined) throw inherited.streamFailure;
753
- if (decisionToolFailure !== undefined) throw decisionToolFailure;
787
+ if (!decisionSubmitted && decisionToolFailure !== undefined) throw decisionToolFailure;
754
788
  const relevantResponse = !decisionSubmitted
755
789
  ? boundaryResponse
756
790
  : [...session.messages].reverse().find((message): message is AssistantMessage =>
@@ -760,7 +794,7 @@ export async function executeAuditorChild(
760
794
  if (toolFailure !== undefined) throw toolFailure;
761
795
  }
762
796
  if (retentionFailure !== undefined && retainedResponse?.stopReason !== "error") throw retentionFailure;
763
- if (boundaryResponse !== undefined && !decisionSubmitted) {
797
+ if (boundaryResponse !== undefined && !decisionSubmitted && noReceiptLifecycle === undefined) {
764
798
  const toolNames = boundaryResponse.content.flatMap((part) => part.type === "toolCall" ? [part.name] : []);
765
799
  throw new AuditorTurnLimitError(AUDITOR_TURN_LIMIT, turns, {
766
800
  stopReason: boundaryResponse.stopReason,
@@ -841,7 +875,11 @@ export async function executeAuditorChild(
841
875
  ) {
842
876
  throw new Error(`${options.roleLabel} exited without a readable decision receipt`);
843
877
  }
844
- return { decision, response };
878
+ return {
879
+ decision,
880
+ response: { ...response, usage: sessionUsage },
881
+ ...(noReceiptLifecycle === undefined ? {} : { noReceiptLifecycle }),
882
+ };
845
883
  } finally {
846
884
  options.signal?.removeEventListener("abort", abort);
847
885
  unsubscribe();
package/src/judge-role.ts CHANGED
@@ -135,10 +135,11 @@ export function createJudgeRoleRuntime(
135
135
  terminate: true as const,
136
136
  ...(usage === undefined ? {} : { usage }),
137
137
  }),
138
- noReceipt: (auditNoReceipt) => ({
138
+ noReceipt: (auditNoReceipt, usageProjection) => ({
139
139
  content: [{ type: "text" as const, text: "Judge verdict accepted; compliance audit produced no receipt" }],
140
140
  details: { ...verdict, auditNoReceipt },
141
141
  terminate: true as const,
142
+ ...usageProjection,
142
143
  }),
143
144
  revise: (violations) => {
144
145
  throw new Error(
@@ -704,8 +704,16 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
704
704
  }
705
705
  const helpContext = help.map(({ role, help: text }) => `<role_help role="${role}">\n${text}\n</role_help>`).join("\n");
706
706
  let output: PrepareOutput | undefined;
707
+ let prepareBatchRejected = false;
707
708
  outputSink = (value) => {
708
- if (output !== undefined) throw new Error("Navigator preparation must submit exactly one typed candidate batch");
709
+ if (prepareBatchRejected || output !== undefined) {
710
+ // Tool executions in one assistant response are provisional until the
711
+ // whole response is known to contain exactly one submission. A
712
+ // duplicate invalidates the batch, including its first call.
713
+ output = undefined;
714
+ prepareBatchRejected = true;
715
+ throw new Error("Navigator preparation must submit exactly one typed candidate batch");
716
+ }
709
717
  output = value;
710
718
  };
711
719
  const tool = createNavigatorPrepareTool((value) => { outputSink?.(value); });
@@ -805,6 +813,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
805
813
  const delivery = createReceiptDeliveryPolicy();
806
814
  const promptAllowingRejectedPrepare = async (text: string, deliveryRequest: boolean) => {
807
815
  const entryStart = activeSession.entries().length;
816
+ prepareBatchRejected = false;
808
817
  let promptFailure: unknown;
809
818
  try {
810
819
  await activeSession.prompt(text);
@@ -817,6 +826,10 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
817
826
  }
818
827
  const rejectedReason = rejectedPrepareReason(activeSession.entries(), entryStart);
819
828
  if (rejectedReason !== undefined) {
829
+ // A rejected call makes every provisional output from this prompt
830
+ // ineligible for publication before the correction turn starts.
831
+ output = undefined;
832
+ prepareBatchRejected = true;
820
833
  delivery.recordRejected(rejectedReason);
821
834
  return;
822
835
  }
@@ -7,7 +7,7 @@ export const NO_RECEIPT_LIFECYCLE_ENTRY_TYPE = "ak-no-receipt-lifecycle" as cons
7
7
  /** The sole schema shared by lifecycle owners and Terminal projections. */
8
8
  export type NoReceiptLifecycleFacts = {
9
9
  terminalToolCalled: boolean;
10
- rejectedReceipts: readonly { reason: string }[];
10
+ rejectedReceipts: readonly { reason: string; diagnosticAvailable: boolean }[];
11
11
  deliveryTurns: typeof RECEIPT_DELIVERY_TURN_LIMIT;
12
12
  sessionCompletion: "settled-without-accepted-receipt";
13
13
  runPointer: string;
@@ -30,12 +30,15 @@ export function parseNoReceiptLifecycleFacts(input: unknown): NoReceiptLifecycle
30
30
  || typeof input.attemptPointer !== "string" || input.attemptPointer.trim() === ""
31
31
  || !Array.isArray(input.rejectedReceipts)
32
32
  || !input.rejectedReceipts.every((item) => isRecord(item)
33
- && typeof item.reason === "string" && item.reason.trim() !== "")) {
33
+ && typeof item.reason === "string")) {
34
34
  throw new TypeError("malformed no-receipt lifecycle facts");
35
35
  }
36
36
  return {
37
37
  terminalToolCalled: input.terminalToolCalled,
38
- rejectedReceipts: input.rejectedReceipts.map((item) => ({ reason: item.reason as string })),
38
+ rejectedReceipts: input.rejectedReceipts.map((item) => ({
39
+ reason: item.reason as string,
40
+ diagnosticAvailable: (item.reason as string).trim() !== "",
41
+ })),
39
42
  deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
40
43
  sessionCompletion: "settled-without-accepted-receipt",
41
44
  runPointer: input.runPointer,
@@ -45,14 +48,20 @@ export function parseNoReceiptLifecycleFacts(input: unknown): NoReceiptLifecycle
45
48
  }
46
49
 
47
50
  export function noReceiptLifecycleFacts(
48
- input: Omit<NoReceiptLifecycleFacts, "deliveryTurns" | "sessionCompletion" | "acceptedReceipt"> & { deliveryTurns: number },
51
+ input: Omit<NoReceiptLifecycleFacts, "rejectedReceipts" | "deliveryTurns" | "sessionCompletion" | "acceptedReceipt"> & {
52
+ rejectedReceipts: readonly { reason: string }[];
53
+ deliveryTurns: number;
54
+ },
49
55
  ): NoReceiptLifecycleFacts {
50
56
  if (input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT) {
51
57
  throw new TypeError("no-receipt lifecycle requires an exhausted delivery budget");
52
58
  }
53
59
  return {
54
60
  terminalToolCalled: input.terminalToolCalled,
55
- rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({ reason })),
61
+ rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({
62
+ reason,
63
+ diagnosticAvailable: reason.trim() !== "",
64
+ })),
56
65
  deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
57
66
  sessionCompletion: "settled-without-accepted-receipt",
58
67
  runPointer: input.runPointer,
@@ -65,14 +74,17 @@ export function createReceiptDeliveryPolicy() {
65
74
  let accepted = false;
66
75
  let terminalToolCalled = false;
67
76
  let deliveryTurns = 0;
68
- const rejectedReceipts: { reason: string }[] = [];
77
+ const rejectedReceipts: { reason: string; diagnosticAvailable: boolean }[] = [];
69
78
  return {
70
- recordAccepted() { accepted = true; terminalToolCalled = true; },
79
+ recordAccepted() {
80
+ accepted = true;
81
+ terminalToolCalled = true;
82
+ },
71
83
  /** Infrastructure owns terminality and must never trigger receipt催交. */
72
84
  stopForInfrastructure() { accepted = true; },
73
85
  recordRejected(reason: string) {
74
86
  terminalToolCalled = true;
75
- rejectedReceipts.push({ reason });
87
+ rejectedReceipts.push({ reason, diagnosticAvailable: reason.trim() !== "" });
76
88
  deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
77
89
  },
78
90
  recordDeliveryRequest() {
@@ -163,9 +163,9 @@ export function createReviewerRoleRuntime(pi: ExtensionAPI, dependencies: Review
163
163
  try { await dependencies.shutdownAgent?.(); } catch (error) { hostActions.failInfrastructure(ledger.recordInfrastructureFailure(error), toolCtx, id); }
164
164
  return { content: [{ type: "text" as const, text: "Reviewer report accepted" }], details: candidate, terminate: true as const, ...(usage === undefined ? {} : { usage }) };
165
165
  },
166
- noReceipt: async (auditNoReceipt) => {
166
+ noReceipt: async (auditNoReceipt, usageProjection) => {
167
167
  try { await dependencies.shutdownAgent?.(); } catch (error) { hostActions.failInfrastructure(ledger.recordInfrastructureFailure(error), toolCtx, id); }
168
- return { content: [{ type: "text" as const, text: "Reviewer report accepted; compliance audit produced no receipt" }], details: { ...candidate, auditNoReceipt }, terminate: true as const };
168
+ return { content: [{ type: "text" as const, text: "Reviewer report accepted; compliance audit produced no receipt" }], details: { ...candidate, auditNoReceipt }, terminate: true as const, ...usageProjection };
169
169
  },
170
170
  revise: (violations) => {
171
171
  throw new AggregateError([], `Reviewer receipt rejected:\n${violations.join("\n")}`, { cause: Object.freeze([...violations]) });
@@ -567,8 +567,9 @@ export function createRoleRuntimeExtension(
567
567
  // whose queued next turn is consumed before settlement.
568
568
  pi.on("agent_end", (event) => {
569
569
  const lastMessage = event.messages.at(-1);
570
- if (lastMessage?.role === "assistant" && lastMessage.stopReason === "error") {
571
- // Provider failure has no tool_result event; classify it here so the
570
+ if (lastMessage?.role === "assistant"
571
+ && (lastMessage.stopReason === "error" || lastMessage.stopReason === "aborted")) {
572
+ // Provider failure/abort has no tool_result event; classify it here so the
572
573
  // receipt policy cannot turn infrastructure death into an exit-0 lifecycle.
573
574
  receiptDelivery.stopForInfrastructure();
574
575
  return;