@akagilnc/pi-workflow-roles 0.1.1794 → 0.1.1813
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/audit-escalation.js +7 -0
- package/dist/compliance-transport.js +7 -1
- package/dist/evidence-child-executor.js +92 -14
- package/dist/navigator-attendance.js +75 -16
- package/dist/public-cli/main.js +115 -46
- package/dist/receipt-delivery-policy.js +72 -0
- package/extensions/role-runtime.ts +5 -2
- package/package.json +1 -1
- package/src/audit-escalation.ts +11 -0
- package/src/compliance-transport.ts +8 -2
- package/src/doctor-role.ts +1 -1
- package/src/evidence-child-executor.ts +88 -12
- package/src/judge-role.ts +5 -0
- package/src/navigator-attendance.ts +72 -4
- package/src/public-cli/settlement.ts +47 -8
- package/src/public-cli/terminal.ts +10 -1
- package/src/receipt-delivery-policy.ts +89 -0
- package/src/reviewer-role.ts +4 -0
- package/src/role-runtime.ts +60 -5
|
@@ -32,6 +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
36
|
|
|
36
37
|
// ── shared constants / types ──────────────────────────────────────────────
|
|
37
38
|
|
|
@@ -551,6 +552,7 @@ export async function executeAuditorChild(
|
|
|
551
552
|
let decisionSubmitted = false;
|
|
552
553
|
let decisionCallId: string | undefined;
|
|
553
554
|
let decisionToolFailure: unknown;
|
|
555
|
+
const decisionToolFailures = new Map<string, unknown>();
|
|
554
556
|
const tool = wrapPackageOwnedToolDefinition({
|
|
555
557
|
...options.tool,
|
|
556
558
|
label: options.roleLabel,
|
|
@@ -562,10 +564,13 @@ export async function executeAuditorChild(
|
|
|
562
564
|
const result = await options.tool.execute(...args);
|
|
563
565
|
decision = args[1];
|
|
564
566
|
decisionCallId = args[0];
|
|
567
|
+
decisionToolFailure = undefined;
|
|
568
|
+
decisionToolFailures.delete(args[0]);
|
|
565
569
|
decisionSubmitted = true;
|
|
566
570
|
return result;
|
|
567
571
|
} catch (error) {
|
|
568
572
|
decisionToolFailure = error;
|
|
573
|
+
decisionToolFailures.set(args[0], error);
|
|
569
574
|
throw error;
|
|
570
575
|
}
|
|
571
576
|
},
|
|
@@ -612,6 +617,8 @@ export async function executeAuditorChild(
|
|
|
612
617
|
let boundaryResponse: AssistantMessage | undefined;
|
|
613
618
|
let retentionFailure: unknown;
|
|
614
619
|
let retainedResponse: AssistantMessage | undefined;
|
|
620
|
+
let rejectedDecisionResponse: AssistantMessage | undefined;
|
|
621
|
+
let promptNeighboringFailure: unknown;
|
|
615
622
|
const registeredToolNames = new Set(session.getAllTools().map((entry) => entry.name));
|
|
616
623
|
const evidenceToolFailures = new Map<string, unknown>();
|
|
617
624
|
for (const name of registeredToolNames) {
|
|
@@ -643,21 +650,38 @@ export async function executeAuditorChild(
|
|
|
643
650
|
turns += 1;
|
|
644
651
|
retainedResponse = event.message;
|
|
645
652
|
try { options.retainResponse?.(event.message); } catch (error) { retentionFailure = error; }
|
|
653
|
+
// A tool call in assistant output is only an observation. Preserve its
|
|
654
|
+
// candidate for typed malformed-decision settlement, but the wrapped
|
|
655
|
+
// execute path above is the sole owner of accepted-receipt state; a
|
|
656
|
+
// rejected execution must remain retryable in this same session.
|
|
646
657
|
for (const part of event.message.content) {
|
|
647
|
-
if (part.type
|
|
648
|
-
|
|
649
|
-
decision
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
658
|
+
if (part.type === "toolCall" && part.name === tool.name) {
|
|
659
|
+
rejectedDecisionResponse = event.message;
|
|
660
|
+
if (decision === undefined) {
|
|
661
|
+
decision = part.arguments;
|
|
662
|
+
decisionCallId = part.id;
|
|
663
|
+
// Pi can reject malformed root arguments before invoking execute;
|
|
664
|
+
// that remains the existing typed audit-incomplete candidate path.
|
|
665
|
+
if (part.arguments === undefined) decisionSubmitted = true;
|
|
666
|
+
}
|
|
654
667
|
}
|
|
655
668
|
}
|
|
656
669
|
if (turns >= AUDITOR_TURN_LIMIT) boundaryResponse = event.message;
|
|
657
670
|
}
|
|
658
|
-
if (event.type === "turn_end"
|
|
659
|
-
|
|
660
|
-
|
|
671
|
+
if (event.type === "turn_end") {
|
|
672
|
+
if (rejectedDecisionResponse !== undefined) {
|
|
673
|
+
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
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (decisionSubmitted || promptNeighboringFailure !== undefined
|
|
682
|
+
|| boundaryResponse !== undefined || retentionFailure !== undefined) {
|
|
683
|
+
void session.abort();
|
|
684
|
+
}
|
|
661
685
|
}
|
|
662
686
|
});
|
|
663
687
|
const abort = () => { void session.abort(); };
|
|
@@ -666,7 +690,59 @@ export async function executeAuditorChild(
|
|
|
666
690
|
|
|
667
691
|
try {
|
|
668
692
|
try {
|
|
669
|
-
|
|
693
|
+
const delivery = createReceiptDeliveryPolicy();
|
|
694
|
+
const promptAllowingRejectedDecision = async (prompt: string) => {
|
|
695
|
+
rejectedDecisionResponse = undefined;
|
|
696
|
+
promptNeighboringFailure = undefined;
|
|
697
|
+
decisionToolFailure = undefined;
|
|
698
|
+
let promptFailure: unknown;
|
|
699
|
+
try {
|
|
700
|
+
await session.prompt(prompt);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
promptFailure = error;
|
|
703
|
+
}
|
|
704
|
+
// Prefer turn_end correlation, but Pi may reject prompt() before that
|
|
705
|
+
// event. In that case correlate against this prompt's captured decision
|
|
706
|
+
// response and call-id maps at the catch boundary.
|
|
707
|
+
const correlatedResponse = rejectedDecisionResponse as AssistantMessage | undefined;
|
|
708
|
+
if (correlatedResponse !== undefined) {
|
|
709
|
+
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
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// An adjacent failure outranks correctable decision feedback.
|
|
718
|
+
if (promptNeighboringFailure !== undefined) throw promptNeighboringFailure;
|
|
719
|
+
if (decisionToolFailure !== undefined) return;
|
|
720
|
+
if (promptFailure !== undefined) throw promptFailure;
|
|
721
|
+
};
|
|
722
|
+
await promptAllowingRejectedDecision(options.prompt);
|
|
723
|
+
while (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
|
|
724
|
+
&& delivery.nextAction() === "request-delivery") {
|
|
725
|
+
if (decisionToolFailure !== undefined) {
|
|
726
|
+
delivery.recordRejected(decisionToolFailure instanceof Error ? decisionToolFailure.message : String(decisionToolFailure));
|
|
727
|
+
decisionToolFailure = undefined;
|
|
728
|
+
if (delivery.nextAction() === "request-delivery") {
|
|
729
|
+
await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
|
|
730
|
+
}
|
|
731
|
+
} else {
|
|
732
|
+
delivery.recordDeliveryRequest();
|
|
733
|
+
await promptAllowingRejectedDecision(RECEIPT_DELIVERY_PROMPT);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (!decisionSubmitted && boundaryResponse === undefined && inherited.streamFailure === undefined
|
|
737
|
+
&& delivery.nextAction() === "no-receipt") {
|
|
738
|
+
const runPointer = options.context.sessionManager.getSessionFile() ?? options.context.cwd ?? process.cwd();
|
|
739
|
+
const attemptPointer = binding.parent.attemptEntryId ?? binding.parent.sessionId ?? `current:${runPointer}`;
|
|
740
|
+
decision = delivery.facts({ runPointer, attemptPointer });
|
|
741
|
+
// Late turn_end feedback cannot overturn a lifecycle that has already
|
|
742
|
+
// charged this prompt to the exhausted shared budget.
|
|
743
|
+
decisionToolFailure = undefined;
|
|
744
|
+
auditorSessionManager.appendCustomEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, decision);
|
|
745
|
+
}
|
|
670
746
|
} catch (error) {
|
|
671
747
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
672
748
|
if (inherited.streamFailure !== undefined) throw inherited.streamFailure;
|
|
@@ -761,7 +837,7 @@ export async function executeAuditorChild(
|
|
|
761
837
|
response === undefined
|
|
762
838
|
|| response.stopReason === "error"
|
|
763
839
|
|| response.stopReason === "aborted"
|
|
764
|
-
|| !decisionSubmitted
|
|
840
|
+
|| (!decisionSubmitted && decision === undefined)
|
|
765
841
|
) {
|
|
766
842
|
throw new Error(`${options.roleLabel} exited without a readable decision receipt`);
|
|
767
843
|
}
|
package/src/judge-role.ts
CHANGED
|
@@ -135,6 +135,11 @@ export function createJudgeRoleRuntime(
|
|
|
135
135
|
terminate: true as const,
|
|
136
136
|
...(usage === undefined ? {} : { usage }),
|
|
137
137
|
}),
|
|
138
|
+
noReceipt: (auditNoReceipt) => ({
|
|
139
|
+
content: [{ type: "text" as const, text: "Judge verdict accepted; compliance audit produced no receipt" }],
|
|
140
|
+
details: { ...verdict, auditNoReceipt },
|
|
141
|
+
terminate: true as const,
|
|
142
|
+
}),
|
|
138
143
|
revise: (violations) => {
|
|
139
144
|
throw new Error(
|
|
140
145
|
`Judge verdict violates its soul: ${violations.join("; ")}`,
|
|
@@ -19,6 +19,7 @@ import { openInProcessAgentSession } from "./in-process-session.ts";
|
|
|
19
19
|
import { renderPublicAkRoleCommand } from "./public-command-renderer.ts";
|
|
20
20
|
import { issueRoot, subjectPath } from "./work-subject-identity.ts";
|
|
21
21
|
import { wrapPackageOwnedToolDefinition } from "./package-owned-tool-idle.ts";
|
|
22
|
+
import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
|
|
22
23
|
|
|
23
24
|
export const NAVIGATOR_EVENT_TYPE = "ak-navigator-attendance" as const;
|
|
24
25
|
export const NAVIGATOR_PREPARE_TOOL_NAME = "ak_navigator_prepare" as const;
|
|
@@ -278,6 +279,36 @@ function unavailableKey(value: unknown): NavigatorUnavailableKey | undefined {
|
|
|
278
279
|
function exactRecord(value: unknown): value is Record<string, unknown> {
|
|
279
280
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
280
281
|
}
|
|
282
|
+
|
|
283
|
+
/** Correlate one rejected prepare call/result inside the just-finished prompt. */
|
|
284
|
+
function rejectedPrepareReason(entries: readonly unknown[], start: number): string | undefined {
|
|
285
|
+
const recent = entries.slice(start);
|
|
286
|
+
const prepareCalls = new Set<string>();
|
|
287
|
+
for (const entry of recent) {
|
|
288
|
+
if (!exactRecord(entry) || entry.type !== "message" || !exactRecord(entry.message)
|
|
289
|
+
|| entry.message.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
|
|
290
|
+
for (const part of entry.message.content) {
|
|
291
|
+
if (exactRecord(part) && part.type === "toolCall" && part.name === NAVIGATOR_PREPARE_TOOL_NAME
|
|
292
|
+
&& typeof part.id === "string") prepareCalls.add(part.id);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
let reason: string | undefined;
|
|
296
|
+
for (const entry of recent) {
|
|
297
|
+
if (!exactRecord(entry) || entry.type !== "message" || !exactRecord(entry.message)
|
|
298
|
+
|| entry.message.role !== "toolResult" || entry.message.isError !== true) continue;
|
|
299
|
+
const callId = entry.message.toolCallId;
|
|
300
|
+
if (entry.message.toolName !== NAVIGATOR_PREPARE_TOOL_NAME
|
|
301
|
+
|| typeof callId !== "string" || !prepareCalls.has(callId)) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
const content = entry.message.content;
|
|
305
|
+
const text = Array.isArray(content)
|
|
306
|
+
? content.flatMap((part) => exactRecord(part) && typeof part.text === "string" ? [part.text] : []).join("")
|
|
307
|
+
: typeof content === "string" ? content : "";
|
|
308
|
+
if (text.trim() !== "") reason = text.trim();
|
|
309
|
+
}
|
|
310
|
+
return reason;
|
|
311
|
+
}
|
|
281
312
|
function targetIsValid(value: unknown): value is NavigatorRouteTarget {
|
|
282
313
|
if (!exactRecord(value) || !targetRoles.has(String(value.role))) return false;
|
|
283
314
|
const metadata = packagedRoleMetadata(String(value.role));
|
|
@@ -567,6 +598,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
|
|
|
567
598
|
let settlementTail: Promise<void> = Promise.resolve();
|
|
568
599
|
let settlementFailure: unknown;
|
|
569
600
|
let preparationFailure: unknown;
|
|
601
|
+
let preparationNoReceipt = false;
|
|
570
602
|
let routePlaybookReadFailure: string | undefined;
|
|
571
603
|
let disposed = false;
|
|
572
604
|
/** One-shot live-help warm; consumed by the next prepare so later prepares reread live help. */
|
|
@@ -770,7 +802,38 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
|
|
|
770
802
|
try {
|
|
771
803
|
try {
|
|
772
804
|
if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
|
|
773
|
-
|
|
805
|
+
const delivery = createReceiptDeliveryPolicy();
|
|
806
|
+
const promptAllowingRejectedPrepare = async (text: string, deliveryRequest: boolean) => {
|
|
807
|
+
const entryStart = activeSession.entries().length;
|
|
808
|
+
let promptFailure: unknown;
|
|
809
|
+
try {
|
|
810
|
+
await activeSession.prompt(text);
|
|
811
|
+
} catch (error) {
|
|
812
|
+
promptFailure = error;
|
|
813
|
+
}
|
|
814
|
+
const providerFailure = activeSession.providerFailure?.();
|
|
815
|
+
if (providerFailure !== undefined) {
|
|
816
|
+
throw navigatorUnavailableError(providerFailure.source, promptFailure ?? "Navigator provider failure", providerFailure.cause);
|
|
817
|
+
}
|
|
818
|
+
const rejectedReason = rejectedPrepareReason(activeSession.entries(), entryStart);
|
|
819
|
+
if (rejectedReason !== undefined) {
|
|
820
|
+
delivery.recordRejected(rejectedReason);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (promptFailure !== undefined) throw promptFailure;
|
|
824
|
+
if (deliveryRequest && output === undefined) delivery.recordDeliveryRequest();
|
|
825
|
+
};
|
|
826
|
+
await promptAllowingRejectedPrepare(request, false);
|
|
827
|
+
while (output === undefined && delivery.nextAction() === "request-delivery") {
|
|
828
|
+
await promptAllowingRejectedPrepare(RECEIPT_DELIVERY_PROMPT, true);
|
|
829
|
+
}
|
|
830
|
+
if (output === undefined && delivery.nextAction() === "no-receipt" && activeSession.providerFailure?.() === undefined) {
|
|
831
|
+
const facts = delivery.facts({ runPointer: sessionDir, attemptPointer: invocationId });
|
|
832
|
+
activeSession.appendEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
|
|
833
|
+
preparationNoReceipt = true;
|
|
834
|
+
candidates = [];
|
|
835
|
+
return candidates;
|
|
836
|
+
}
|
|
774
837
|
} catch (error) {
|
|
775
838
|
throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("transport", error);
|
|
776
839
|
}
|
|
@@ -907,10 +970,13 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
|
|
|
907
970
|
throw new Error("Navigator advice contradicts the accepted settlement");
|
|
908
971
|
}
|
|
909
972
|
}
|
|
910
|
-
//
|
|
911
|
-
|
|
973
|
+
// Budget exhaustion is affirmative typed no-advice; malformed submitted
|
|
974
|
+
// advice remains the existing unavailable path.
|
|
975
|
+
if (selected?.next === undefined && preparationNoReceipt) {
|
|
976
|
+
report = { disposition: "no-advice" };
|
|
977
|
+
} else if (selected?.next === undefined) {
|
|
912
978
|
throw new Error("Navigator prepared no machine-usable next direction");
|
|
913
|
-
}
|
|
979
|
+
} else {
|
|
914
980
|
const selectedRoute = selected.route;
|
|
915
981
|
const routeChanged = selectedRoute !== undefined && !routeEqual(previousRoute, selectedRoute);
|
|
916
982
|
// Single owner: public registry renderer (ADR 0052). Model command prose is never authority.
|
|
@@ -926,6 +992,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
|
|
|
926
992
|
previousRoute = selectedRoute;
|
|
927
993
|
session?.appendEntry(ROUTE_ENTRY, { invocationId, subjectKey, route: selectedRoute });
|
|
928
994
|
}
|
|
995
|
+
}
|
|
929
996
|
// Contract: README.md#Navigator-attendance — Navigator failures become typed unavailable without invalidating the role Receipt; retain the original cause in the unavailable report.
|
|
930
997
|
} catch (error) {
|
|
931
998
|
report = unavailable(invocationId, error);
|
|
@@ -964,6 +1031,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
|
|
|
964
1031
|
sessionReady = undefined;
|
|
965
1032
|
candidates = undefined;
|
|
966
1033
|
preparationFailure = undefined;
|
|
1034
|
+
preparationNoReceipt = false;
|
|
967
1035
|
routePlaybookSettlement = undefined;
|
|
968
1036
|
routePlaybookReadFailure = undefined;
|
|
969
1037
|
}
|
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
type InvocationMarkerIdentity,
|
|
75
75
|
} from "../navigator-invocation-identity.ts";
|
|
76
76
|
import type { NavigatorPhase } from "../navigator-attendance.ts";
|
|
77
|
+
import { NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, parseNoReceiptLifecycleFacts, type NoReceiptLifecycleFacts } from "../receipt-delivery-policy.ts";
|
|
77
78
|
import { packagedRoleMetadata } from "../packaged-role-registry.ts";
|
|
78
79
|
import {
|
|
79
80
|
workSubjectKeyFromProjectRoot,
|
|
@@ -856,11 +857,21 @@ function safelyRead(object: object, key: string): { readable: true; value: unkno
|
|
|
856
857
|
}
|
|
857
858
|
}
|
|
858
859
|
|
|
860
|
+
function auditNoReceiptDecisiveFact(candidate: object): Record<string, unknown> {
|
|
861
|
+
const projected = safelyRead(candidate, "auditNoReceipt");
|
|
862
|
+
if (!projected.readable || projected.value === undefined) return {};
|
|
863
|
+
try {
|
|
864
|
+
return { auditNoReceipt: parseNoReceiptLifecycleFacts(projected.value) };
|
|
865
|
+
} catch {
|
|
866
|
+
return {};
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
859
870
|
function judgeDecisiveFacts(
|
|
860
871
|
verdict: object,
|
|
861
872
|
judgeStatus: JudgeVerdict["judgeStatus"],
|
|
862
873
|
): Record<string, unknown> {
|
|
863
|
-
const facts: Record<string, unknown> = { judgeStatus };
|
|
874
|
+
const facts: Record<string, unknown> = { judgeStatus, ...auditNoReceiptDecisiveFact(verdict) };
|
|
864
875
|
if (judgeStatus === "continue") {
|
|
865
876
|
const fix = safelyRead(verdict, "fix");
|
|
866
877
|
if (fix.readable && isRecord(fix.value)) {
|
|
@@ -1015,7 +1026,7 @@ function collectorDecisiveFacts(
|
|
|
1015
1026
|
function doctorDecisiveFacts(output: DoctorOutput): Record<string, unknown> {
|
|
1016
1027
|
const candidate = output as unknown as object;
|
|
1017
1028
|
const status = safelyRead(candidate, "status");
|
|
1018
|
-
const facts: Record<string, unknown> = {};
|
|
1029
|
+
const facts: Record<string, unknown> = { ...auditNoReceiptDecisiveFact(candidate) };
|
|
1019
1030
|
if (status.readable && typeof status.value === "string") facts.doctorStatus = status.value;
|
|
1020
1031
|
if (status.readable && status.value === "refused") {
|
|
1021
1032
|
const reason = safelyRead(candidate, "reason");
|
|
@@ -1058,6 +1069,7 @@ function reviewerDecisiveFacts(
|
|
|
1058
1069
|
axes,
|
|
1059
1070
|
reportAxes,
|
|
1060
1071
|
acceptedBatchPresent: acceptedBatch.readable && acceptedBatch.value !== undefined,
|
|
1072
|
+
...auditNoReceiptDecisiveFact(candidate),
|
|
1061
1073
|
};
|
|
1062
1074
|
if (status.readable && typeof status.value === "string") facts.reviewerStatus = status.value;
|
|
1063
1075
|
const diagnostic = safelyRead(candidate, "diagnostic");
|
|
@@ -3668,6 +3680,33 @@ export async function settleFailureTerminalResult(
|
|
|
3668
3680
|
failure: ControlledFailure,
|
|
3669
3681
|
options: { readonly resume?: TerminalResume } = {},
|
|
3670
3682
|
): Promise<TerminalResult> {
|
|
3683
|
+
// #288 is lawful only when the lifecycle owner persisted an exhausted,
|
|
3684
|
+
// current-attempt fact. Transcript reconstruction must not turn arbitrary output
|
|
3685
|
+
// failures (or bytes retained from a prior resume attempt) into exit zero.
|
|
3686
|
+
if (failure.cause === "output") {
|
|
3687
|
+
const entries = await readBoundSessionEntries(admitted.sessionFile).catch(() => undefined);
|
|
3688
|
+
if (entries !== undefined) {
|
|
3689
|
+
let attemptStart = 0;
|
|
3690
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
3691
|
+
if (entries[index]?.type === "message" && entries[index]?.message?.role === "user") { attemptStart = index; break; }
|
|
3692
|
+
}
|
|
3693
|
+
const lifecycleEntry = entries.slice(attemptStart).reverse().find((entry: SessionEntry) =>
|
|
3694
|
+
entry.customType === NO_RECEIPT_LIFECYCLE_ENTRY_TYPE || entry.message?.customType === NO_RECEIPT_LIFECYCLE_ENTRY_TYPE);
|
|
3695
|
+
const raw = lifecycleEntry?.data ?? lifecycleEntry?.message?.details;
|
|
3696
|
+
if (raw !== undefined) {
|
|
3697
|
+
try {
|
|
3698
|
+
const facts = parseNoReceiptLifecycleFacts(raw);
|
|
3699
|
+
if (facts.runPointer === admitted.runDirectory && facts.attemptPointer === `current:${admitted.runDirectory}`) {
|
|
3700
|
+
const decisiveFacts: NoReceiptLifecycleFacts = facts;
|
|
3701
|
+
return {
|
|
3702
|
+
roleOutcome: { kind: "no_receipt", role: admitted.role, status: "no-accepted-receipt", ...facts, decisiveFacts },
|
|
3703
|
+
navigator: await extractNavigatorFactFromAdmittedSession(admitted), artifacts: [], runId: admitted.runId,
|
|
3704
|
+
};
|
|
3705
|
+
}
|
|
3706
|
+
} catch { /* malformed lifecycle bytes remain the existing nonzero output failure */ }
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3671
3710
|
// Exact-session attendance only — never infer no-advice from caller omission.
|
|
3672
3711
|
const navigator = await extractNavigatorFactFromAdmittedSession(admitted);
|
|
3673
3712
|
// Private durable artifacts retain the original diagnostic identity (including run ID).
|
|
@@ -3741,16 +3780,16 @@ export function presentFailureTerminal(
|
|
|
3741
3780
|
terminal: TerminalResult,
|
|
3742
3781
|
io: { stdout: (text: string) => void; stderr: (text: string) => void },
|
|
3743
3782
|
): void {
|
|
3744
|
-
if (terminal.roleOutcome.kind !== "failure") {
|
|
3745
|
-
throw new TypeError("presentFailureTerminal requires a failure role outcome");
|
|
3783
|
+
if (terminal.roleOutcome.kind !== "failure" && terminal.roleOutcome.kind !== "no_receipt") {
|
|
3784
|
+
throw new TypeError("presentFailureTerminal requires a failure or no-receipt role outcome");
|
|
3746
3785
|
}
|
|
3747
3786
|
io.stdout(formatTerminalResult(terminal));
|
|
3748
|
-
|
|
3749
|
-
formatFailureStderrDiagnostic({
|
|
3787
|
+
if (terminal.roleOutcome.kind === "failure") {
|
|
3788
|
+
io.stderr(formatFailureStderrDiagnostic({
|
|
3750
3789
|
cause: terminal.roleOutcome.cause,
|
|
3751
3790
|
diagnostic: terminal.roleOutcome.diagnostic,
|
|
3752
|
-
})
|
|
3753
|
-
|
|
3791
|
+
}));
|
|
3792
|
+
}
|
|
3754
3793
|
}
|
|
3755
3794
|
|
|
3756
3795
|
/**
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { renderPublicAkRoleCommand } from "./command-renderer.ts";
|
|
9
9
|
import type { ComplianceAuditIncomplete } from "../compliance-transport.ts";
|
|
10
10
|
import type { NavigatorPhase } from "../navigator-attendance.ts";
|
|
11
|
+
import type { NoReceiptLifecycleFacts } from "../receipt-delivery-policy.ts";
|
|
11
12
|
|
|
12
13
|
/** Encode one free-text Terminal cell. JSON string form cannot embed raw tab/newline. */
|
|
13
14
|
export function encodeTerminalField(value: string): string {
|
|
@@ -79,6 +80,13 @@ export function jsonSafeComplianceCandidate(value: unknown): unknown {
|
|
|
79
80
|
return value === undefined ? JSON_SAFE_UNDEFINED_ARGUMENT : value;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
export type NoReceiptTerminalOutcome = NoReceiptLifecycleFacts & {
|
|
84
|
+
kind: "no_receipt";
|
|
85
|
+
role: TerminalRoleName;
|
|
86
|
+
status: "no-accepted-receipt";
|
|
87
|
+
decisiveFacts: NoReceiptLifecycleFacts & Readonly<Record<string, unknown>>;
|
|
88
|
+
};
|
|
89
|
+
|
|
82
90
|
export type TerminalRoleOutcome =
|
|
83
91
|
| {
|
|
84
92
|
kind: "accepted";
|
|
@@ -95,6 +103,7 @@ export type TerminalRoleOutcome =
|
|
|
95
103
|
}
|
|
96
104
|
| AuditIncompleteTerminalOutcome
|
|
97
105
|
| ResidualIncompleteTerminalOutcome
|
|
106
|
+
| NoReceiptTerminalOutcome
|
|
98
107
|
| {
|
|
99
108
|
kind: "failure";
|
|
100
109
|
role: TerminalRoleName;
|
|
@@ -111,7 +120,7 @@ export type TerminalRoleOutcome =
|
|
|
111
120
|
export function isLawfulTypedTerminalOutcome(
|
|
112
121
|
outcome: TerminalRoleOutcome,
|
|
113
122
|
): boolean {
|
|
114
|
-
return outcome.kind === "accepted" || outcome.kind === "audit_escalation";
|
|
123
|
+
return outcome.kind === "accepted" || outcome.kind === "audit_escalation" || outcome.kind === "no_receipt";
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
export function exitCodeForTerminalOutcome(
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Shared accepted-receipt delivery budget for role, auditor, and Navigator sessions (#288). */
|
|
2
|
+
export const RECEIPT_DELIVERY_TURN_LIMIT = 2 as const;
|
|
3
|
+
export const RECEIPT_DELIVERY_PROMPT = "本 session 尚无已接受的 typed 回执。请现在调用具名终局工具交卷;若先前被打回,请按拒因修正后重交。";
|
|
4
|
+
|
|
5
|
+
export const NO_RECEIPT_LIFECYCLE_ENTRY_TYPE = "ak-no-receipt-lifecycle" as const;
|
|
6
|
+
|
|
7
|
+
/** The sole schema shared by lifecycle owners and Terminal projections. */
|
|
8
|
+
export type NoReceiptLifecycleFacts = {
|
|
9
|
+
terminalToolCalled: boolean;
|
|
10
|
+
rejectedReceipts: readonly { reason: string }[];
|
|
11
|
+
deliveryTurns: typeof RECEIPT_DELIVERY_TURN_LIMIT;
|
|
12
|
+
sessionCompletion: "settled-without-accepted-receipt";
|
|
13
|
+
runPointer: string;
|
|
14
|
+
attemptPointer: string;
|
|
15
|
+
acceptedReceipt: false;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read only the facts required by Terminal consumers; persisted extensions are ignored. */
|
|
23
|
+
export function parseNoReceiptLifecycleFacts(input: unknown): NoReceiptLifecycleFacts {
|
|
24
|
+
if (!isRecord(input)
|
|
25
|
+
|| typeof input.terminalToolCalled !== "boolean"
|
|
26
|
+
|| input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT
|
|
27
|
+
|| input.sessionCompletion !== "settled-without-accepted-receipt"
|
|
28
|
+
|| input.acceptedReceipt !== false
|
|
29
|
+
|| typeof input.runPointer !== "string" || input.runPointer.trim() === ""
|
|
30
|
+
|| typeof input.attemptPointer !== "string" || input.attemptPointer.trim() === ""
|
|
31
|
+
|| !Array.isArray(input.rejectedReceipts)
|
|
32
|
+
|| !input.rejectedReceipts.every((item) => isRecord(item)
|
|
33
|
+
&& typeof item.reason === "string" && item.reason.trim() !== "")) {
|
|
34
|
+
throw new TypeError("malformed no-receipt lifecycle facts");
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
terminalToolCalled: input.terminalToolCalled,
|
|
38
|
+
rejectedReceipts: input.rejectedReceipts.map((item) => ({ reason: item.reason as string })),
|
|
39
|
+
deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
|
|
40
|
+
sessionCompletion: "settled-without-accepted-receipt",
|
|
41
|
+
runPointer: input.runPointer,
|
|
42
|
+
attemptPointer: input.attemptPointer,
|
|
43
|
+
acceptedReceipt: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function noReceiptLifecycleFacts(
|
|
48
|
+
input: Omit<NoReceiptLifecycleFacts, "deliveryTurns" | "sessionCompletion" | "acceptedReceipt"> & { deliveryTurns: number },
|
|
49
|
+
): NoReceiptLifecycleFacts {
|
|
50
|
+
if (input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT) {
|
|
51
|
+
throw new TypeError("no-receipt lifecycle requires an exhausted delivery budget");
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
terminalToolCalled: input.terminalToolCalled,
|
|
55
|
+
rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({ reason })),
|
|
56
|
+
deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
|
|
57
|
+
sessionCompletion: "settled-without-accepted-receipt",
|
|
58
|
+
runPointer: input.runPointer,
|
|
59
|
+
attemptPointer: input.attemptPointer,
|
|
60
|
+
acceptedReceipt: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createReceiptDeliveryPolicy() {
|
|
65
|
+
let accepted = false;
|
|
66
|
+
let terminalToolCalled = false;
|
|
67
|
+
let deliveryTurns = 0;
|
|
68
|
+
const rejectedReceipts: { reason: string }[] = [];
|
|
69
|
+
return {
|
|
70
|
+
recordAccepted() { accepted = true; terminalToolCalled = true; },
|
|
71
|
+
/** Infrastructure owns terminality and must never trigger receipt催交. */
|
|
72
|
+
stopForInfrastructure() { accepted = true; },
|
|
73
|
+
recordRejected(reason: string) {
|
|
74
|
+
terminalToolCalled = true;
|
|
75
|
+
rejectedReceipts.push({ reason });
|
|
76
|
+
deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
|
|
77
|
+
},
|
|
78
|
+
recordDeliveryRequest() {
|
|
79
|
+
deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
|
|
80
|
+
},
|
|
81
|
+
nextAction(): "accepted" | "request-delivery" | "no-receipt" {
|
|
82
|
+
if (accepted) return "accepted";
|
|
83
|
+
return deliveryTurns < RECEIPT_DELIVERY_TURN_LIMIT ? "request-delivery" : "no-receipt";
|
|
84
|
+
},
|
|
85
|
+
facts(binding: { runPointer: string; attemptPointer: string }): NoReceiptLifecycleFacts {
|
|
86
|
+
return noReceiptLifecycleFacts({ terminalToolCalled, rejectedReceipts: [...rejectedReceipts], deliveryTurns, ...binding });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/reviewer-role.ts
CHANGED
|
@@ -163,6 +163,10 @@ 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) => {
|
|
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 };
|
|
169
|
+
},
|
|
166
170
|
revise: (violations) => {
|
|
167
171
|
throw new AggregateError([], `Reviewer receipt rejected:\n${violations.join("\n")}`, { cause: Object.freeze([...violations]) });
|
|
168
172
|
},
|
package/src/role-runtime.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "./tool-execution-observation.ts";
|
|
24
24
|
import { installPackageOwnedToolRegistration } from "./package-owned-tool-idle.ts";
|
|
25
25
|
import { installWorkerGitHooks } from "./worker-submission-gates.ts";
|
|
26
|
+
import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
|
|
26
27
|
|
|
27
28
|
import type { AnyCanonicalSkillBinding } from "./canonical-skill-binding.ts";
|
|
28
29
|
import type { CollectorClock } from "./collector-evidence.ts";
|
|
@@ -426,6 +427,10 @@ export function createRoleRuntimeExtension(
|
|
|
426
427
|
let pendingNavigatorSettlement: Promise<void> | undefined;
|
|
427
428
|
let navigatorWorkContext: NavigatorWorkContext | undefined;
|
|
428
429
|
const pendingInfrastructureToolCallIds = new Set<string>();
|
|
430
|
+
// #288 primary-session thin adapter. The policy is the sole budget owner;
|
|
431
|
+
// terminating-tool rejections and mechanical delivery requests share two turns.
|
|
432
|
+
let receiptDelivery = createReceiptDeliveryPolicy();
|
|
433
|
+
let noReceiptRecorded = false;
|
|
429
434
|
pi.on("input", () => {
|
|
430
435
|
const role = pi.getFlag(ROLE_FLAG.name);
|
|
431
436
|
if (role !== undefined && !admitted) return { action: "handled" as const };
|
|
@@ -467,7 +472,7 @@ export function createRoleRuntimeExtension(
|
|
|
467
472
|
});
|
|
468
473
|
pi.on("tool_result", async (event) => {
|
|
469
474
|
const role = selectedRole;
|
|
470
|
-
if (role === undefined
|
|
475
|
+
if (role === undefined) return;
|
|
471
476
|
const isRoleInfrastructureFailure = pendingInfrastructureToolCallIds.delete(event.toolCallId);
|
|
472
477
|
// Overlay typed infra fact so live settlement and durable session entry agree.
|
|
473
478
|
const infrastructureDetails = isRoleInfrastructureFailure
|
|
@@ -476,6 +481,19 @@ export function createRoleRuntimeExtension(
|
|
|
476
481
|
const classified = infrastructureDetails === undefined
|
|
477
482
|
? event
|
|
478
483
|
: { ...event, details: infrastructureDetails };
|
|
484
|
+
const isOutputTool = event.toolName === navigatorOutputTool(role);
|
|
485
|
+
const outputClassification = isOutputTool ? classifyPackagedRoleTerminalResult(classified) : undefined;
|
|
486
|
+
if (isRoleInfrastructureFailure || outputClassification?.kind === "infrastructure") {
|
|
487
|
+
receiptDelivery.stopForInfrastructure();
|
|
488
|
+
} else if (outputClassification?.kind === "accepted") {
|
|
489
|
+
receiptDelivery.recordAccepted();
|
|
490
|
+
} else if (isOutputTool && outputClassification?.kind === "nonterminal" && event.isError) {
|
|
491
|
+
const reason = (event.content ?? [])
|
|
492
|
+
.map((part) => part.type === "text" ? part.text : "")
|
|
493
|
+
.join("")
|
|
494
|
+
.trim() || "terminating tool rejected";
|
|
495
|
+
receiptDelivery.recordRejected(reason);
|
|
496
|
+
}
|
|
479
497
|
const settlement = publicNavigatorSettlement(
|
|
480
498
|
role,
|
|
481
499
|
navigatorPhase(pi, role),
|
|
@@ -483,8 +501,9 @@ export function createRoleRuntimeExtension(
|
|
|
483
501
|
);
|
|
484
502
|
if (settlement !== undefined) {
|
|
485
503
|
const attendance = navigatorAttendance;
|
|
486
|
-
|
|
487
|
-
|
|
504
|
+
if (attendance !== undefined) {
|
|
505
|
+
const workContext = navigatorWorkContext;
|
|
506
|
+
const pending = (async () => {
|
|
488
507
|
// Accepted role terminal starts the post-role Navigator grace (#101/#106).
|
|
489
508
|
if (settlement.kind !== "accepted") {
|
|
490
509
|
await attendance.settle(settlement);
|
|
@@ -522,8 +541,9 @@ export function createRoleRuntimeExtension(
|
|
|
522
541
|
void settlePromise.catch(() => undefined);
|
|
523
542
|
}
|
|
524
543
|
})();
|
|
525
|
-
|
|
526
|
-
|
|
544
|
+
pendingNavigatorSettlement = pending;
|
|
545
|
+
await pending;
|
|
546
|
+
}
|
|
527
547
|
}
|
|
528
548
|
// Persist typed infrastructure-failure fact onto the role session toolResult so
|
|
529
549
|
// exact-session restart shares the same durable completion classification.
|
|
@@ -541,6 +561,39 @@ export function createRoleRuntimeExtension(
|
|
|
541
561
|
content: decorated.content as typeof event.content,
|
|
542
562
|
};
|
|
543
563
|
});
|
|
564
|
+
// Queue receipt delivery before `agent_settled`: that event means Pi has
|
|
565
|
+
// already decided no queued continuation will run, so a triggerTurn there is
|
|
566
|
+
// too late for print/json sessions. `agent_end` is the last production seam
|
|
567
|
+
// whose queued next turn is consumed before settlement.
|
|
568
|
+
pi.on("agent_end", (event) => {
|
|
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
|
|
572
|
+
// receipt policy cannot turn infrastructure death into an exit-0 lifecycle.
|
|
573
|
+
receiptDelivery.stopForInfrastructure();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (receiptDelivery.nextAction() === "request-delivery") {
|
|
577
|
+
receiptDelivery.recordDeliveryRequest();
|
|
578
|
+
// Keep the package-owned continuation off the public input lifecycle:
|
|
579
|
+
// one-shot roles must not mistake this delivery request for later caller input.
|
|
580
|
+
pi.appendEntry("ak-receipt-delivery-request");
|
|
581
|
+
pi.sendMessage({
|
|
582
|
+
customType: "ak-receipt-delivery-prompt",
|
|
583
|
+
content: RECEIPT_DELIVERY_PROMPT,
|
|
584
|
+
display: false,
|
|
585
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
586
|
+
} else if (receiptDelivery.nextAction() === "no-receipt" && !noReceiptRecorded) {
|
|
587
|
+
const runPointer = process.env.AK_ROLE_RUN_DIR;
|
|
588
|
+
if (runPointer !== undefined) {
|
|
589
|
+
noReceiptRecorded = true;
|
|
590
|
+
pi.appendEntry(
|
|
591
|
+
NO_RECEIPT_LIFECYCLE_ENTRY_TYPE,
|
|
592
|
+
receiptDelivery.facts({ runPointer, attemptPointer: `current:${runPointer}` }),
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
});
|
|
544
597
|
pi.on("agent_settled", async () => {
|
|
545
598
|
if (pendingNavigatorSettlement !== undefined) {
|
|
546
599
|
await pendingNavigatorSettlement;
|
|
@@ -774,6 +827,8 @@ export function createRoleRuntimeExtension(
|
|
|
774
827
|
pi.on("session_start", async (event, ctx) => {
|
|
775
828
|
admitted = false;
|
|
776
829
|
selectedRole = undefined;
|
|
830
|
+
receiptDelivery = createReceiptDeliveryPolicy();
|
|
831
|
+
noReceiptRecorded = false;
|
|
777
832
|
observationFace.reset();
|
|
778
833
|
pendingNavigatorPresentation = undefined;
|
|
779
834
|
pendingNavigatorSettlement = undefined;
|