@piagent/platform 1.6.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +17 -14
  3. package/README.vi.md +26 -1
  4. package/benchmarks/production-v1/spend-control.v1.json +27 -3
  5. package/benchmarks/production-v1/suite.json +5 -1
  6. package/catalog/capabilities.json +16 -16
  7. package/docs/authority-manifest.md +1 -1
  8. package/docs/auto-delegation-policy.md +38 -73
  9. package/docs/command-reference-vietnamese.md +23 -31
  10. package/docs/context-engine.md +5 -3
  11. package/docs/context-window-policy.md +37 -1
  12. package/docs/distribution-standard.md +7 -7
  13. package/docs/en/architecture.md +26 -13
  14. package/docs/languages.json +4 -4
  15. package/docs/operator-manual-vietnamese.md +21 -41
  16. package/docs/publishing-for-teams.md +5 -0
  17. package/docs/quality-benchmark.md +179 -23
  18. package/docs/release-install-policy.md +6 -6
  19. package/docs/runtime-harness-standard.md +3 -3
  20. package/docs/security-threat-model.md +1 -1
  21. package/docs/subagent-orchestration-capabilities.md +19 -51
  22. package/docs/subagents-and-multiagent.md +21 -47
  23. package/docs/task-implementation-contract.md +3 -3
  24. package/docs/task-lifecycle-vietnamese.md +1 -1
  25. package/docs/team-onboarding.md +2 -2
  26. package/docs/vi/architecture.md +26 -13
  27. package/docs-site/architecture.html +1 -1
  28. package/docs-site/benchmark.html +89 -95
  29. package/docs-site/commands.html +1 -1
  30. package/docs-site/content/benchmark.html +88 -94
  31. package/docs-site/content/en/benchmark.html +134 -90
  32. package/docs-site/content/en/index.html +19 -19
  33. package/docs-site/content/en/runtime.html +4 -4
  34. package/docs-site/content/en/whats-new.html +12 -1
  35. package/docs-site/content/en/workflows.html +2 -2
  36. package/docs-site/content/index.html +19 -19
  37. package/docs-site/content/runtime.html +4 -4
  38. package/docs-site/content/whats-new.html +12 -1
  39. package/docs-site/content/workflows.html +2 -2
  40. package/docs-site/context-engine.html +1 -1
  41. package/docs-site/en/architecture.html +1 -1
  42. package/docs-site/en/benchmark.html +135 -91
  43. package/docs-site/en/commands.html +1 -1
  44. package/docs-site/en/context-engine.html +1 -1
  45. package/docs-site/en/index.html +20 -20
  46. package/docs-site/en/mcp-approval.html +1 -1
  47. package/docs-site/en/mcp-auth.html +1 -1
  48. package/docs-site/en/mcp-servers.html +1 -1
  49. package/docs-site/en/mcp.html +1 -1
  50. package/docs-site/en/permissions.html +1 -1
  51. package/docs-site/en/profiles.html +1 -1
  52. package/docs-site/en/quickstart.html +1 -1
  53. package/docs-site/en/runtime.html +5 -5
  54. package/docs-site/en/scope.html +1 -1
  55. package/docs-site/en/security.html +1 -1
  56. package/docs-site/en/team.html +1 -1
  57. package/docs-site/en/whats-new.html +13 -2
  58. package/docs-site/en/workflows.html +3 -3
  59. package/docs-site/index.html +20 -20
  60. package/docs-site/mcp-approval.html +1 -1
  61. package/docs-site/mcp-auth.html +1 -1
  62. package/docs-site/mcp-servers.html +1 -1
  63. package/docs-site/mcp.html +1 -1
  64. package/docs-site/permissions.html +1 -1
  65. package/docs-site/profiles.html +1 -1
  66. package/docs-site/quickstart.html +1 -1
  67. package/docs-site/runtime.html +5 -5
  68. package/docs-site/scope.html +1 -1
  69. package/docs-site/security.html +1 -1
  70. package/docs-site/team.html +1 -1
  71. package/docs-site/whats-new.html +13 -2
  72. package/docs-site/workflows.html +3 -3
  73. package/evals/fixtures/benchmark-assurance-evidence.invalid.json +1 -1
  74. package/evals/fixtures/benchmark-assurance-evidence.valid.json +1 -1
  75. package/evals/fixtures/codex-relative-efficiency-v1.invalid.json +4 -0
  76. package/evals/fixtures/codex-relative-efficiency-v1.valid.json +94 -0
  77. package/evals/fixtures/helper-request.invalid.json +1 -1
  78. package/evals/fixtures/helper-request.valid.json +1 -1
  79. package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +7 -0
  80. package/evals/fixtures/task-authority-snapshot.valid.json +4 -4
  81. package/evals/fs4-readiness-matrix.v1.json +2 -2
  82. package/evals/fs5-causal-arm.v1.json +2 -2
  83. package/evals/fs5-pilot-protocol.v1.json +3 -3
  84. package/evals/fs5-pilot-protocol.v2.json +8 -8
  85. package/evals/fs5-pilot-protocol.v3.json +8 -8
  86. package/evals/fs5-pilot-protocol.v4.json +6 -6
  87. package/evals/fs5-pilot-protocol.v5.json +7 -6
  88. package/evals/private-holdout-v1/public-exposure.v1.json +1 -1
  89. package/evals/runtime-conformance-v1/README.md +43 -0
  90. package/evals/runtime-conformance-v1/evaluate-paid-canary.mjs +198 -0
  91. package/evals/runtime-conformance-v1/lane.json +57 -0
  92. package/evals/runtime-conformance-v1/paid-canary.json +31 -0
  93. package/evals/runtime-conformance-v1/runner.mjs +456 -0
  94. package/package.json +1 -1
  95. package/packages/piagent-core/README.md +13 -4
  96. package/packages/piagent-core/benchmark/benchmark-codex-relative-efficiency.js +698 -0
  97. package/packages/piagent-core/benchmark/benchmark-core.js +4 -3
  98. package/packages/piagent-core/benchmark/benchmark-forensics.js +4 -2
  99. package/packages/piagent-core/benchmark/benchmark-preflight.js +3 -1
  100. package/packages/piagent-core/benchmark/benchmark-provider-free-evidence.js +345 -0
  101. package/packages/piagent-core/benchmark/benchmark-record-validation.js +74 -5
  102. package/packages/piagent-core/benchmark/benchmark-report.js +15 -1
  103. package/packages/piagent-core/benchmark/benchmark-runtime-causal.js +244 -0
  104. package/packages/piagent-core/benchmark/benchmark-stage-diagnostic.js +201 -4
  105. package/packages/piagent-core/benchmark/benchmark-suite.js +22 -1
  106. package/packages/piagent-core/benchmark/benchmark-summary-support.js +4 -0
  107. package/packages/piagent-core/benchmark/benchmark-usage.js +15 -4
  108. package/packages/piagent-core/capabilities/runtime-integrity.js +3 -0
  109. package/packages/piagent-core/extensions/acceptance-behavior-proof.js +806 -0
  110. package/packages/piagent-core/extensions/acceptance-receipt.js +89 -48
  111. package/packages/piagent-core/extensions/context-engine.js +1 -11
  112. package/packages/piagent-core/extensions/context-index-policy.js +4 -0
  113. package/packages/piagent-core/extensions/context-query-paths.js +57 -0
  114. package/packages/piagent-core/extensions/criterion-context-pack.js +16 -3
  115. package/packages/piagent-core/extensions/criterion-graph.js +4 -1
  116. package/packages/piagent-core/extensions/fallback-base-policy.ts +101 -0
  117. package/packages/piagent-core/extensions/guard-types.ts +4 -0
  118. package/packages/piagent-core/extensions/piagent-guard.ts +194 -226
  119. package/packages/piagent-core/extensions/task-lifecycle.js +32 -0
  120. package/packages/piagent-core/extensions/task-state.js +69 -8
  121. package/packages/piagent-core/extensions/verification-intelligence.js +49 -12
  122. package/packages/piagent-core/extensions/workspace-evidence-roots.js +52 -16
  123. package/packages/piagent-core/package.json +1 -1
  124. package/packages/piagent-core/policies/base-policy.json +42 -2
  125. package/packages/piagent-core/policy/authority-manifest.v1.json +3 -3
  126. package/packages/piagent-core/prompts/be-to-fe.md +2 -8
  127. package/packages/piagent-core/prompts/plan.md +1 -1
  128. package/packages/piagent-core/prompts/platform-improve.md +2 -6
  129. package/packages/piagent-core/prompts/review.md +2 -7
  130. package/packages/piagent-core/prompts/scout.md +1 -1
  131. package/packages/piagent-core/prompts/task.md +4 -3
  132. package/packages/piagent-core/runtime/hooks/completion-hook.ts +7 -6
  133. package/packages/piagent-core/runtime/hooks/input-hook.ts +6 -0
  134. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +100 -1
  135. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +58 -72
  136. package/packages/piagent-core/runtime/hooks/tool-result-value-helpers.ts +66 -0
  137. package/packages/piagent-core/runtime/inspection/bounded-emission-guard.ts +286 -0
  138. package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +37 -7
  139. package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +27 -4
  140. package/packages/piagent-core/runtime/inspection/tool-failure-classification.ts +20 -3
  141. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +47 -11
  142. package/packages/piagent-core/runtime/orchestration/helper-lifecycle.ts +16 -12
  143. package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +71 -0
  144. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +28 -20
  145. package/packages/piagent-core/runtime/orchestration/role-policy.ts +27 -9
  146. package/packages/piagent-core/runtime/orchestration/subagent-tool-policy.ts +329 -0
  147. package/packages/piagent-core/runtime/policy/block-remedy.ts +0 -4
  148. package/packages/piagent-core/runtime/product/efficiency-metrics.ts +3 -0
  149. package/packages/piagent-core/runtime/quality/edit-freshness-guard.ts +102 -0
  150. package/packages/piagent-core/runtime/quality/exact-output-contract.ts +20 -10
  151. package/packages/piagent-core/runtime/quality/performance-assurance.ts +3 -3
  152. package/packages/piagent-core/runtime/recovery/continuation-budget.ts +1 -1
  153. package/packages/piagent-core/runtime/recovery/handoff-projection.ts +4 -1
  154. package/packages/piagent-core/runtime/recovery/pristine-task-policy-refresh.ts +91 -0
  155. package/packages/piagent-core/runtime/recovery/recovery-policy.ts +7 -4
  156. package/packages/piagent-core/runtime/recovery/resume-state.ts +112 -14
  157. package/packages/piagent-core/runtime/recovery/semantic-repair-handshake.ts +7 -13
  158. package/packages/piagent-core/runtime/recovery/task-evidence-coverage-refresh.ts +217 -0
  159. package/packages/piagent-core/runtime/registration/context-commands.ts +16 -2
  160. package/packages/piagent-core/runtime/registration/knowledge-tools.ts +9 -5
  161. package/packages/piagent-core/runtime/registration/operator-request-intake.ts +49 -0
  162. package/packages/piagent-core/runtime/registration/policy-tools.ts +4 -4
  163. package/packages/piagent-core/runtime/registration/session-commands.ts +1 -1
  164. package/packages/piagent-core/runtime/registration/task-completion-tools.ts +12 -9
  165. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +21 -12
  166. package/packages/piagent-core/runtime/registration/task-start-guidance.ts +19 -13
  167. package/packages/piagent-core/runtime/registration/task-start-manifest.ts +54 -0
  168. package/packages/piagent-core/runtime/registration/task-start-tool.ts +57 -57
  169. package/packages/piagent-core/runtime/runtime-limits.ts +16 -2
  170. package/packages/piagent-core/runtime/session/adaptive-context-analysis.ts +461 -0
  171. package/packages/piagent-core/runtime/session/adaptive-context-governor.ts +464 -0
  172. package/packages/piagent-core/runtime/session/adaptive-context-ledger.ts +265 -0
  173. package/packages/piagent-core/runtime/session/message-signals.ts +7 -3
  174. package/packages/piagent-core/runtime/session/operator-request-carry.ts +44 -0
  175. package/packages/piagent-core/runtime/session/runtime-state.ts +11 -7
  176. package/packages/piagent-core/runtime/session/source-checkout-read-grants.ts +35 -0
  177. package/packages/piagent-core/runtime/session/system-prompt.ts +76 -14
  178. package/packages/piagent-core/runtime/session/task-contract-presentation.ts +39 -0
  179. package/packages/piagent-core/runtime/tools/phase-tools.ts +8 -8
  180. package/packages/piagent-core/runtime/tools/tool-groups.ts +12 -2
  181. package/packages/piagent-core/runtime/verification/exact-verifier-reuse.ts +101 -0
  182. package/packages/piagent-core/runtime/workflows/runtime-intake-compaction.ts +164 -0
  183. package/packages/piagent-core/runtime/workflows/task-intake.ts +167 -39
  184. package/packages/piagent-core/subagents/piagent-oracle.md +5 -3
  185. package/packages/piagent-core/subagents/piagent-planner.md +6 -4
  186. package/packages/piagent-core/subagents/piagent-reviewer.md +5 -3
  187. package/packages/piagent-core/subagents/piagent-scout.md +4 -2
  188. package/packages/piagent-core/subagents/piagent-worker.md +6 -4
  189. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CxeZqTte.js → MarkdownMessage-B4XehTCU.js} +1 -1
  190. package/packages/piagent-webui/dist/client/assets/{index-DynIMe8I.js → index-CgOEyRPG.js} +4 -4
  191. package/packages/piagent-webui/dist/client/index.html +1 -1
  192. package/packages/piagent-webui/extension/piagent-webui.ts +3 -1
  193. package/packages/piagent-webui/extension/same-session-bridge.ts +1 -1
  194. package/packages/piagent-webui/extension/session-stream-adapter.ts +33 -10
  195. package/packages/piagent-webui/gateway/gateway-session-stream.ts +37 -21
  196. package/packages/piagent-webui/gateway/session-operation-lifecycle.ts +237 -0
  197. package/packages/piagent-webui/gateway/session-operation-retry-control.ts +38 -0
  198. package/packages/piagent-webui/gateway/session-operation-watchdog.ts +9 -2
  199. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -7
  200. package/packages/piagent-webui/ownership/session-lease-store.ts +5 -2
  201. package/packages/piagent-webui/server/core-inspection-provider.ts +2 -1
  202. package/packages/piagent-webui/server/transcript-projection.ts +91 -5
  203. package/schemas/codex-relative-efficiency-v1.schema.json +155 -0
  204. package/schemas/piagent-webui/runtime-event-v2.schema.json +15 -5
  205. package/schemas/piagent-webui/subagent-tree-v1.schema.json +11 -1
  206. package/schemas/project-profile.schema.json +1 -1
  207. package/schemas/task-contract.schema.json +15 -0
  208. package/scripts/benchmark-runner-core.mjs +14 -6
  209. package/scripts/benchmark-runner-finalization.mjs +4 -0
  210. package/scripts/configure-subagents.sh +42 -34
  211. package/scripts/helper-evaluation.mjs +1 -1
  212. package/scripts/team-doctor.sh +1 -1
  213. package/scripts/verify-local.sh +5 -0
@@ -0,0 +1,286 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export type EmissionSuppressionReason =
4
+ | "empty"
5
+ | "filler"
6
+ | "duplicate-exact"
7
+ | "duplicate-normalized"
8
+ | "update-budget";
9
+
10
+ export type BoundedEmissionCandidate = {
11
+ channel: string;
12
+ identityKey: string;
13
+ updateKey: string;
14
+ text: string;
15
+ actionable: boolean;
16
+ reference: string;
17
+ };
18
+
19
+ export type EmissionDecision = {
20
+ accepted: boolean;
21
+ reason: EmissionSuppressionReason | null;
22
+ priorReference: string | null;
23
+ };
24
+
25
+ export type BoundedEmissionTelemetry = {
26
+ schemaVersion: 1;
27
+ capacity: number;
28
+ retained: number;
29
+ primed: number;
30
+ accepted: number;
31
+ suppressed: number;
32
+ suppressedByReason: Record<EmissionSuppressionReason, number>;
33
+ };
34
+
35
+ type RetainedEmission = {
36
+ exactKey: string;
37
+ normalizedKey: string;
38
+ updateKey: string | null;
39
+ reference: string;
40
+ };
41
+
42
+ const ANSI = /\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g;
43
+ const DEFAULT_CAPACITY = 512;
44
+ const MIN_CAPACITY = 1;
45
+ const MAX_CAPACITY = 16_384;
46
+
47
+ const FILLER = new Set([
48
+ "abort", "all good", "carry on", "complete", "continue", "done", "finished", "halt", "lgtm",
49
+ "looks good", "no action", "no action needed", "no concern", "no concerns", "no further advice",
50
+ "no further advice needed", "no further input", "no further input needed", "no issue", "no issues",
51
+ "no issue continue", "no notes", "nothing to add", "nothing to flag", "nothing to report", "ok", "okay",
52
+ "stop", "stop here", "stop now", "task complete", "task done",
53
+ "da xong", "đã xong", "khong can lam gi", "không cần làm gì", "khong co gi de bao cao", "không có gì để báo cáo",
54
+ "khong co loi", "không có lỗi", "khong co van de", "không có vấn đề", "on", "ổn", "tiep tuc", "tiếp tục", "xong"
55
+ ]);
56
+
57
+ function digest(value: string): string {
58
+ return createHash("sha256").update(value).digest("hex");
59
+ }
60
+
61
+ function exactEmissionText(value: string): string {
62
+ return String(value ?? "")
63
+ .replace(ANSI, "")
64
+ .normalize("NFKC")
65
+ .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
66
+ .replace(/\s+/g, " ")
67
+ .trim();
68
+ }
69
+
70
+ /** Stable comparison form for reviewer notes and compact runtime event summaries. */
71
+ export function normalizeEmissionText(value: string): string {
72
+ return exactEmissionText(value)
73
+ .toLowerCase()
74
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
75
+ .trim();
76
+ }
77
+
78
+ function canonical(value: unknown): unknown {
79
+ if (Array.isArray(value)) return value.map(canonical);
80
+ if (!value || typeof value !== "object") return value;
81
+ const prototype = Object.getPrototypeOf(value);
82
+ if (prototype !== Object.prototype && prototype !== null) return String(value);
83
+ const record = value as Record<string, unknown>;
84
+ return Object.fromEntries(Object.keys(record).sort().map((key) => [key, canonical(record[key])]));
85
+ }
86
+
87
+ function canonicalText(value: unknown): string {
88
+ return JSON.stringify(canonical(value));
89
+ }
90
+
91
+ function textField(payload: Record<string, unknown>): string | null {
92
+ for (const field of ["note", "advice", "finding", "message", "summary"]) {
93
+ if (typeof payload[field] === "string" && payload[field].trim()) return payload[field];
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function reviewerRole(payload: Record<string, unknown>): string | null {
99
+ for (const field of ["role", "agentRole", "actorRole", "sourceRole"]) {
100
+ const value = String(payload[field] ?? "").toLowerCase();
101
+ if (value === "reviewer" || value === "piagent-reviewer") return value;
102
+ }
103
+ const emissionType = String(payload.emissionType ?? "").toLowerCase();
104
+ return emissionType === "reviewer-note" || emissionType === "review-note" ? "reviewer" : null;
105
+ }
106
+
107
+ type RuntimeEmissionLike = {
108
+ kind: string;
109
+ sessionRef: string;
110
+ taskRunId: string | null;
111
+ agentOperationId: string | null;
112
+ turnIndex: number | null;
113
+ toolCallId: string | null;
114
+ revision: object;
115
+ payload: Record<string, unknown>;
116
+ };
117
+
118
+ /**
119
+ * Select only bounded activity, reviewer-note and operation emissions. Message
120
+ * deltas/completions are deliberately outside this boundary, so a final user-
121
+ * visible response can never be suppressed by this guard.
122
+ */
123
+ export function runtimeEventEmissionCandidate(event: RuntimeEmissionLike, reference: string): BoundedEmissionCandidate | null {
124
+ const payload = event.payload ?? {};
125
+ const revision = canonicalText(event.revision);
126
+ // Lifecycle state is authoritative. Classify it before reviewer metadata so
127
+ // a note attached to a started/finished event can never hide that state.
128
+ if (event.kind.startsWith("activity.")) {
129
+ const state = payload.state ?? event.kind.slice("activity.".length);
130
+ return {
131
+ channel: "activity",
132
+ identityKey: canonicalText([event.agentOperationId, event.toolCallId, payload.activityRef, event.kind, state]),
133
+ updateKey: canonicalText([revision, event.agentOperationId, event.toolCallId, event.kind, state]),
134
+ text: canonicalText(payload),
135
+ actionable: false,
136
+ reference
137
+ };
138
+ }
139
+ if (event.kind.startsWith("agent-operation.")) {
140
+ const state = payload.state ?? payload.settlement ?? payload.status ?? event.kind.slice("agent-operation.".length);
141
+ return {
142
+ channel: "operation",
143
+ identityKey: canonicalText([event.agentOperationId, event.kind, state]),
144
+ updateKey: canonicalText([revision, event.agentOperationId, event.kind, state]),
145
+ text: canonicalText(payload),
146
+ actionable: false,
147
+ reference
148
+ };
149
+ }
150
+ const role = reviewerRole(payload), note = role ? textField(payload) : null;
151
+ if (role && note) {
152
+ const explicitUpdate = payload.updateRef ?? payload.reviewerRevision ?? payload.revision;
153
+ return {
154
+ channel: "reviewer",
155
+ // A session may host many unrelated tasks. Dedupe reviewer prose across
156
+ // retries/operations of one durable task, but never across task runs.
157
+ identityKey: canonicalText([event.sessionRef, event.taskRunId, role]),
158
+ updateKey: canonicalText([event.sessionRef, event.agentOperationId, event.turnIndex, explicitUpdate ?? revision]),
159
+ text: note,
160
+ actionable: true,
161
+ reference
162
+ };
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /** A small in-memory policy gate. It stores only hashes, never emitted prose. */
168
+ export class BoundedEmissionGuard {
169
+ readonly capacity: number;
170
+ readonly #retained: RetainedEmission[] = [];
171
+ readonly #exact = new Map<string, string>();
172
+ readonly #normalized = new Map<string, string>();
173
+ readonly #updates = new Map<string, string>();
174
+ #primed = 0;
175
+ #accepted = 0;
176
+ readonly #suppressed: Record<EmissionSuppressionReason, number> = {
177
+ empty: 0,
178
+ filler: 0,
179
+ "duplicate-exact": 0,
180
+ "duplicate-normalized": 0,
181
+ "update-budget": 0
182
+ };
183
+
184
+ constructor(options: { capacity?: number } = {}) {
185
+ const candidate = Number.isFinite(options.capacity) ? Math.floor(Number(options.capacity)) : DEFAULT_CAPACITY;
186
+ this.capacity = Math.max(MIN_CAPACITY, Math.min(MAX_CAPACITY, candidate));
187
+ }
188
+
189
+ #keys(candidate: BoundedEmissionCandidate): { exactKey: string; normalizedKey: string; updateKey: string | null; exact: string; normalized: string } {
190
+ const exact = exactEmissionText(candidate.text), normalized = normalizeEmissionText(exact);
191
+ const scope = `${candidate.channel}\0${candidate.identityKey}`;
192
+ return {
193
+ exact,
194
+ normalized,
195
+ exactKey: digest(`${scope}\0exact\0${exact}`),
196
+ normalizedKey: digest(`${scope}\0normalized\0${normalized}`),
197
+ updateKey: candidate.actionable ? digest(`${candidate.channel}\0update\0${candidate.updateKey}`) : null
198
+ };
199
+ }
200
+
201
+ #reject(reason: EmissionSuppressionReason, priorReference: string | null = null, count = true): EmissionDecision {
202
+ if (count) this.#suppressed[reason] += 1;
203
+ return { accepted: false, reason, priorReference };
204
+ }
205
+
206
+ #inspect(candidate: BoundedEmissionCandidate, countSuppression: boolean): EmissionDecision {
207
+ const keys = this.#keys(candidate);
208
+ if (candidate.actionable && !keys.exact) return this.#reject("empty", null, countSuppression);
209
+ if (candidate.actionable && FILLER.has(keys.normalized)) return this.#reject("filler", null, countSuppression);
210
+ const exact = this.#exact.get(keys.exactKey);
211
+ if (exact) return this.#reject("duplicate-exact", exact, countSuppression);
212
+ const normalized = this.#normalized.get(keys.normalizedKey);
213
+ if (normalized) return this.#reject("duplicate-normalized", normalized, countSuppression);
214
+ const update = keys.updateKey ? this.#updates.get(keys.updateKey) : undefined;
215
+ if (update) return this.#reject("update-budget", update, countSuppression);
216
+ return { accepted: true, reason: null, priorReference: null };
217
+ }
218
+
219
+ inspect(candidate: BoundedEmissionCandidate): EmissionDecision {
220
+ return this.#inspect(candidate, true);
221
+ }
222
+
223
+ /** Seed retained history without counting old suppression as live telemetry. */
224
+ prime(candidate: BoundedEmissionCandidate): boolean {
225
+ if (!this.#inspect(candidate, false).accepted) return false;
226
+ this.recordAccepted(candidate, { primed: true });
227
+ return true;
228
+ }
229
+
230
+ recordAccepted(candidate: BoundedEmissionCandidate, options: { primed?: boolean } = {}): void {
231
+ const keys = this.#keys(candidate);
232
+ const record = { exactKey: keys.exactKey, normalizedKey: keys.normalizedKey, updateKey: keys.updateKey, reference: candidate.reference };
233
+ this.#retained.push(record);
234
+ this.#exact.set(record.exactKey, record.reference);
235
+ this.#normalized.set(record.normalizedKey, record.reference);
236
+ if (record.updateKey) this.#updates.set(record.updateKey, record.reference);
237
+ if (options.primed) this.#primed += 1;
238
+ else this.#accepted += 1;
239
+ while (this.#retained.length > this.capacity) {
240
+ const stale = this.#retained.shift() as RetainedEmission;
241
+ if (this.#exact.get(stale.exactKey) === stale.reference) this.#exact.delete(stale.exactKey);
242
+ if (this.#normalized.get(stale.normalizedKey) === stale.reference) this.#normalized.delete(stale.normalizedKey);
243
+ if (stale.updateKey && this.#updates.get(stale.updateKey) === stale.reference) this.#updates.delete(stale.updateKey);
244
+ }
245
+ }
246
+
247
+ /** Remove fingerprints when their durable event leaves the retention window. */
248
+ forget(reference: string): boolean {
249
+ const kept = this.#retained.filter((record) => record.reference !== reference);
250
+ if (kept.length === this.#retained.length) return false;
251
+ this.#retained.length = 0;
252
+ this.#retained.push(...kept);
253
+ this.#exact.clear();
254
+ this.#normalized.clear();
255
+ this.#updates.clear();
256
+ for (const record of this.#retained) {
257
+ this.#exact.set(record.exactKey, record.reference);
258
+ this.#normalized.set(record.normalizedKey, record.reference);
259
+ if (record.updateKey) this.#updates.set(record.updateKey, record.reference);
260
+ }
261
+ return true;
262
+ }
263
+
264
+ reset(): void {
265
+ this.#retained.length = 0;
266
+ this.#exact.clear();
267
+ this.#normalized.clear();
268
+ this.#updates.clear();
269
+ this.#primed = 0;
270
+ this.#accepted = 0;
271
+ for (const reason of Object.keys(this.#suppressed) as EmissionSuppressionReason[]) this.#suppressed[reason] = 0;
272
+ }
273
+
274
+ telemetry(): BoundedEmissionTelemetry {
275
+ const suppressedByReason = { ...this.#suppressed };
276
+ return {
277
+ schemaVersion: 1,
278
+ capacity: this.capacity,
279
+ retained: this.#retained.length,
280
+ primed: this.#primed,
281
+ accepted: this.#accepted,
282
+ suppressed: Object.values(suppressedByReason).reduce((sum, value) => sum + value, 0),
283
+ suppressedByReason
284
+ };
285
+ }
286
+ }
@@ -3,6 +3,12 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
5
  import { ensurePrivateStateDirectory, resolveLocalStatePath } from "../../extensions/local-state-path.js";
6
+ import {
7
+ BoundedEmissionGuard,
8
+ type BoundedEmissionTelemetry,
9
+ type EmissionSuppressionReason,
10
+ runtimeEventEmissionCandidate
11
+ } from "./bounded-emission-guard.ts";
6
12
 
7
13
  export const RUNTIME_EVENT_SCHEMA_VERSION = 2 as const;
8
14
  export type RuntimeEventRevision = {
@@ -57,6 +63,9 @@ export type RuntimeEventReplay = {
57
63
  lastAvailableSequence: number | null;
58
64
  reasonCode: string | null;
59
65
  };
66
+ export type RuntimeEventAppendResult =
67
+ | { event: RuntimeEventV2; appended: true; suppressionReason: null }
68
+ | { event: RuntimeEventV2 | null; appended: false; suppressionReason: EmissionSuppressionReason | "event-id-duplicate" };
60
69
 
61
70
  const EVENT_KINDS = new Set([
62
71
  "runtime.started", "runtime.health-changed", "runtime.disconnected", "runtime.resync-required", "runtime.resynced", "runtime.phase-changed",
@@ -158,15 +167,17 @@ function readSegment(root: string, file: string): RuntimeEventV2[] {
158
167
  export class RuntimeEventStore {
159
168
  readonly directory: string;
160
169
  readonly corruptions: string[] = [];
161
- readonly options: { projectRoot: string; projectRef: string; runtimeInstanceId: string; sessionRef: string; maxEventsPerSegment?: number; maxSegments?: number };
170
+ readonly options: { projectRoot: string; projectRef: string; runtimeInstanceId: string; sessionRef: string; maxEventsPerSegment?: number; maxSegments?: number; emissionGuardCapacity?: number };
162
171
  private events: RuntimeEventV2[] = [];
163
172
  private segments: Array<{ file: string; start: number; count: number }> = [];
164
173
  private eventById = new Map<string, RuntimeEventV2>();
174
+ private readonly emissionGuard: BoundedEmissionGuard;
165
175
  private readonly maxEventsPerSegment: number;
166
176
  private readonly maxSegments: number;
167
177
 
168
- constructor(options: { projectRoot: string; projectRef: string; runtimeInstanceId: string; sessionRef: string; maxEventsPerSegment?: number; maxSegments?: number }) {
178
+ constructor(options: { projectRoot: string; projectRef: string; runtimeInstanceId: string; sessionRef: string; maxEventsPerSegment?: number; maxSegments?: number; emissionGuardCapacity?: number }) {
169
179
  this.options = options;
180
+ this.emissionGuard = new BoundedEmissionGuard({ capacity: options.emissionGuardCapacity });
170
181
  this.maxEventsPerSegment = Math.max(2, Math.min(1_000, options.maxEventsPerSegment ?? 500));
171
182
  this.maxSegments = Math.max(2, Math.min(20, options.maxSegments ?? 10));
172
183
  this.directory = storeDirectory(options.projectRoot, options.runtimeInstanceId, options.sessionRef);
@@ -195,11 +206,13 @@ export class RuntimeEventStore {
195
206
  if (previous && record.writerSequence !== previous + 1) throw new Error("event sequence gap");
196
207
  if (this.eventById.has(record.eventId)) throw new Error("duplicate event identity");
197
208
  previous = record.writerSequence; this.events.push(record); this.eventById.set(record.eventId, record);
209
+ const candidate = runtimeEventEmissionCandidate(record, record.eventId);
210
+ if (candidate) this.emissionGuard.prime(candidate);
198
211
  }
199
212
  this.segments.push({ file: name, start, count: records.length });
200
213
  }
201
214
  } catch {
202
- this.events = []; this.segments = []; this.eventById.clear(); this.corruptions.push("event-store-corrupt");
215
+ this.events = []; this.segments = []; this.eventById.clear(); this.emissionGuard.reset(); this.corruptions.push("event-store-corrupt");
203
216
  }
204
217
  }
205
218
 
@@ -208,14 +221,27 @@ export class RuntimeEventStore {
208
221
  retention(): { eventRetentionCount: number; eventRetentionSeconds: number } {
209
222
  return { eventRetentionCount: this.maxEventsPerSegment * this.maxSegments, eventRetentionSeconds: 0 };
210
223
  }
224
+ emissionTelemetry(): BoundedEmissionTelemetry { return this.emissionGuard.telemetry(); }
211
225
 
212
- append(draft: RuntimeEventDraft, recordedAt = new Date().toISOString()): { event: RuntimeEventV2; appended: boolean } {
226
+ append(draft: RuntimeEventDraft, recordedAt = new Date().toISOString()): RuntimeEventAppendResult {
213
227
  if (this.resyncRequired()) throw new Error("WebUI event store requires resync");
214
228
  if (draft.projectRef !== this.options.projectRef || draft.runtimeInstanceId !== this.options.runtimeInstanceId || draft.sessionRef !== this.options.sessionRef) throw new Error("Runtime event identity does not match its store");
215
229
  const id = eventId(draft), duplicate = this.eventById.get(id);
216
- if (duplicate) return { event: structuredClone(duplicate), appended: false };
230
+ const candidate = runtimeEventEmissionCandidate(draft, id);
231
+ if (duplicate) {
232
+ const decision = candidate ? this.emissionGuard.inspect(candidate) : null;
233
+ return { event: structuredClone(duplicate), appended: false,
234
+ suppressionReason: decision?.reason ?? "event-id-duplicate" };
235
+ }
217
236
  const sequence = (this.events.at(-1)?.writerSequence ?? 0) + 1;
218
237
  const event = validateRuntimeEventEnvelope({ schemaVersion: 2, eventId: id, eventCursor: eventCursor(sequence, id), writerSequence: sequence, recordedAt, ...structuredClone(draft) });
238
+ if (candidate) {
239
+ const decision = this.emissionGuard.inspect(candidate);
240
+ if (!decision.accepted) {
241
+ const prior = decision.priorReference ? this.eventById.get(decision.priorReference) ?? null : null;
242
+ return { event: prior ? structuredClone(prior) : null, appended: false, suppressionReason: decision.reason as EmissionSuppressionReason };
243
+ }
244
+ }
219
245
  let segment = this.segments.at(-1);
220
246
  if (!segment || segment.count >= this.maxEventsPerSegment) {
221
247
  segment = { file: segmentName(sequence), start: sequence, count: 0 }; this.segments.push(segment);
@@ -228,8 +254,9 @@ export class RuntimeEventStore {
228
254
  const descriptor = fs.openSync(target, flags, 0o600);
229
255
  try { fs.writeFileSync(descriptor, `${JSON.stringify(event)}\n`); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, 0o600); } finally { fs.closeSync(descriptor); }
230
256
  segment.count += 1; this.events.push(event); this.eventById.set(event.eventId, event);
257
+ if (candidate) this.emissionGuard.recordAccepted(candidate);
231
258
  try { this.enforceRetention(); } catch { this.corruptions.push("event-retention-failed"); }
232
- return { event: structuredClone(event), appended: true };
259
+ return { event: structuredClone(event), appended: true, suppressionReason: null };
233
260
  }
234
261
 
235
262
  private enforceRetention(): void {
@@ -240,7 +267,10 @@ export class RuntimeEventStore {
240
267
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("event retention target is unsafe");
241
268
  fs.unlinkSync(target);
242
269
  const discarded = this.events.splice(0, removed.count);
243
- for (const event of discarded) this.eventById.delete(event.eventId);
270
+ for (const event of discarded) {
271
+ this.eventById.delete(event.eventId);
272
+ this.emissionGuard.forget(event.eventId);
273
+ }
244
274
  }
245
275
  }
246
276
 
@@ -5,7 +5,7 @@ import { inspectOwnedWorkBudget, type OwnedWorkReservation } from "../orchestrat
5
5
  import { taskRunOpaqueRef } from "./task-run-index.ts";
6
6
 
7
7
  type Task = { taskId: string; taskRunId: string; trace?: { outcome?: unknown }; orchestration?: Record<string, unknown>;
8
- acceptanceReceipt?: { helperUsage?: { used?: unknown; helpers?: unknown[] } }; [key: string]: any };
8
+ acceptanceReceipt?: { helperUsage?: { used?: unknown; decision?: unknown; projectedSavingsRatio?: unknown; reasonCodes?: unknown; helpers?: unknown[] } }; [key: string]: any };
9
9
  type Identity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string; taskRunId: string;
10
10
  agentOperationId: null; toolCallId: null };
11
11
 
@@ -53,9 +53,32 @@ function orchestration(task: Task) {
53
53
  };
54
54
  }
55
55
 
56
+ function delegation(task: Task) {
57
+ const usage = task.acceptanceReceipt?.helperUsage;
58
+ const explicitDecision = usage?.decision;
59
+ const action = explicitDecision === "dispatch" || explicitDecision === "skip"
60
+ ? explicitDecision
61
+ : usage?.used === true ? "dispatch" : "skip";
62
+ const reasonCodes = Array.isArray(usage?.reasonCodes)
63
+ ? [...new Set(usage.reasonCodes.map((item) => String(item ?? "").trim()).filter((item) => /^[a-z0-9_.:-]+$/.test(item)).slice(0, 16))]
64
+ : action === "dispatch" ? ["helper-dispatched"] : ["parent-direct-default", "delegation-evidence-missing"];
65
+ const projected = usage?.projectedSavingsRatio;
66
+ return {
67
+ action,
68
+ reasonCodes,
69
+ projectedNetSavingsRatio: typeof projected === "number" && Number.isFinite(projected)
70
+ ? Math.max(-10, Math.min(1, projected))
71
+ : null,
72
+ minimumRequiredNetSavingsRatio: 0.3,
73
+ budget: { maxConcurrent: 1, maxTotal: 1, maxRetries: 0, maxWriters: 0 }
74
+ };
75
+ }
76
+
56
77
  function unavailable(identity: Identity, runRef: string, generatedAt: string, reasonCode: string) {
57
78
  return { schemaVersion: 1, version: "piagent-webui-subagent-tree-v1", generatedAt, identity: structuredClone(identity), runRef,
58
79
  state: "unavailable", treeRevision: null, evidenceState: "unknown", orchestration: { mode: "unknown", subagents: "unknown" },
80
+ delegation: { action: "unknown", reasonCodes: [reasonCode], projectedNetSavingsRatio: null,
81
+ minimumRequiredNetSavingsRatio: 0.3, budget: { maxConcurrent: 1, maxTotal: 1, maxRetries: 0, maxWriters: 0 } },
59
82
  parent: null, children: [], writer: { state: "unknown", ownerNodeRef: null },
60
83
  nestedLineage: { state: "unavailable", reasonCode: "no-durable-nested-lineage" },
61
84
  summary: { total: 0, active: 0, completed: 0, staleResults: 0, readOnly: 0, singleWriter: 0 }, warnings: [],
@@ -66,7 +89,7 @@ export function projectTaskSubagentTree(input: { cwd: string; task: Task; identi
66
89
  const generatedAt = input.generatedAt ?? new Date().toISOString(), runRef = taskRunOpaqueRef(input.task.taskRunId);
67
90
  const inspected = inspectOwnedWorkBudget(input.cwd, input.task.taskId, input.task.taskRunId, generatedAt);
68
91
  if (inspected.state === "corrupt") return unavailable(input.identity, runRef, generatedAt, inspected.reasonCode ?? "helper-budget-corrupt");
69
- const policy = orchestration(input.task), parentRef = opaque("parent", input.task.taskRunId), receipts = helperReceipts(input.task);
92
+ const policy = orchestration(input.task), delegationDecision = delegation(input.task), parentRef = opaque("parent", input.task.taskRunId), receipts = helperReceipts(input.task);
70
93
  const children = inspected.reservations.map((item) => child(input.task.taskRunId, parentRef, item, receipts.get(item.deduplicationKey)));
71
94
  const matchedReceipts = new Set(inspected.reservations.map((item) => item.deduplicationKey).filter((key) => receipts.has(key)));
72
95
  const unmatchedReceipts = Math.max(0, receipts.size - matchedReceipts.size);
@@ -90,10 +113,10 @@ export function projectTaskSubagentTree(input: { cwd: string; task: Task; identi
90
113
  staleResults: children.filter((item) => item.result.state === "stale-result").length,
91
114
  readOnly: children.filter((item) => item.authority === "read-only").length,
92
115
  singleWriter: children.filter((item) => item.authority === "single-writer").length };
93
- const treeRevision = `subagent-tree.${createHash("sha256").update(JSON.stringify({ policy, parentState, terminal: inspected.terminal,
116
+ const treeRevision = `subagent-tree.${createHash("sha256").update(JSON.stringify({ policy, delegationDecision, parentState, terminal: inspected.terminal,
94
117
  children, writer, evidenceState })).digest("hex")}`;
95
118
  return { schemaVersion: 1, version: "piagent-webui-subagent-tree-v1", generatedAt, identity: structuredClone(input.identity), runRef,
96
- state: "ready", treeRevision, evidenceState, orchestration: policy,
119
+ state: "ready", treeRevision, evidenceState, orchestration: policy, delegation: delegationDecision,
97
120
  parent: { nodeRef: parentRef, lifecycleState: parentState, budgetTerminal: inspected.terminal, mergeOwner: "parent" }, children, writer,
98
121
  nestedLineage: { state: "unavailable", reasonCode: "no-durable-nested-lineage" }, summary, warnings,
99
122
  health: warnings.length ? { state: "degraded", reasonCode: "subagent-tree-incomplete", message: "Some helper/subagent evidence is incomplete." }
@@ -3,13 +3,19 @@ export type ToolFailureReasonCode =
3
3
  | "search-target-missing"
4
4
  | "edit-anchor-not-unique"
5
5
  | "edit-anchor-stale"
6
+ | "helper-dispatch-rejected"
7
+ | "helper-insufficient-evidence"
6
8
  | "tool-result-failed";
7
9
 
8
- function boundedText(content: unknown): string {
10
+ function boundedText(content: unknown, depth = 0): string {
11
+ if (depth > 2) return "";
12
+ const nested = content && typeof content === "object" && !Array.isArray(content)
13
+ ? (content as { content?: unknown }).content
14
+ : undefined;
9
15
  const text = typeof content === "string" ? content : Array.isArray(content)
10
16
  ? content.filter((item) => item && typeof item === "object" && (item as { type?: unknown }).type === "text")
11
17
  .map((item) => String((item as { text?: unknown }).text ?? "")).join("\n")
12
- : "";
18
+ : nested !== undefined ? boundedText(nested, depth + 1) : "";
13
19
  return text.slice(0, 8_192);
14
20
  }
15
21
 
@@ -23,8 +29,19 @@ function commandFromInput(input: unknown): string {
23
29
  }
24
30
 
25
31
  export function classifyToolFailure(toolName: string, isError: boolean, content: unknown, input?: unknown): ToolFailureReasonCode | null {
26
- if (!isError) return null;
27
32
  const text = boundedText(content);
33
+ const subagent = /(?:^|[._-])subagents?(?:$|[._-])/i.test(toolName);
34
+ // Some subagent providers return a successful tool envelope even though no
35
+ // child was launched, or a zero-exit child explicitly reports that it could
36
+ // inspect no evidence. Canonical replay must not turn either outcome into a
37
+ // green "passed" Activity row merely because `isError` is false.
38
+ if (subagent && /spawn limit reached|no children were started|declared run cannot fit/i.test(text)) {
39
+ return "helper-dispatch-rejected";
40
+ }
41
+ if (subagent && /scope inspected:[^\n]{0,16}\bnone\b|insufficient-evidence rule|no source (?:content|evidence)[^\n]{0,80}(?:accessible|collected)/i.test(text)) {
42
+ return "helper-insufficient-evidence";
43
+ }
44
+ if (!isError) return null;
28
45
  // Only the registered, policy-governed `edit` tool may receive an automatic
29
46
  // current-file recovery snapshot. A similarly named third-party `replace`
30
47
  // tool is not necessarily covered by Piagent's mutation guard.
@@ -84,8 +84,13 @@ function scopedEvents(events: ActivityInspectorEvent[], sessionId: string, task?
84
84
  return [...deduped.values()].sort((left, right) => String(left.recordedAt).localeCompare(String(right.recordedAt))).slice(-2_000);
85
85
  }
86
86
 
87
- function enrichedFailureReasons(events: ActivityInspectorEvent[], sessionEntries: unknown[]): ActivityInspectorEvent[] {
88
- const inputs = new Map<string, unknown>(), results = new Map<string, { content: unknown; isError: boolean; toolName: string }>();
87
+ type CanonicalToolResult = { content: unknown; isError: boolean; toolName: string; recordedAt?: string };
88
+
89
+ function canonicalToolEvidence(sessionEntries: unknown[]): {
90
+ inputs: Map<string, unknown>;
91
+ results: Map<string, CanonicalToolResult>;
92
+ } {
93
+ const inputs = new Map<string, unknown>(), results = new Map<string, CanonicalToolResult>();
89
94
  for (const entry of sessionEntries) {
90
95
  if (!entry || typeof entry !== "object" || (entry as any).type !== "message") continue;
91
96
  const message = (entry as any).message;
@@ -93,16 +98,44 @@ function enrichedFailureReasons(events: ActivityInspectorEvent[], sessionEntries
93
98
  for (const block of message.content) if (block?.type === "toolCall" && typeof block.id === "string") inputs.set(block.id, block.arguments);
94
99
  }
95
100
  if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
96
- results.set(message.toolCallId, { content: message.content, isError: message.isError === true, toolName: String(message.toolName ?? "unknown") });
101
+ const recordedAt = TIMESTAMP.test(String((entry as any).timestamp ?? message.timestamp ?? ""))
102
+ ? String((entry as any).timestamp ?? message.timestamp) : undefined;
103
+ results.set(message.toolCallId, { content: message.content, isError: message.isError === true,
104
+ toolName: typeof message.toolName === "string" ? message.toolName : "", recordedAt });
97
105
  }
98
106
  }
99
- return events.map((event) => {
100
- if (event.event !== "tool_result" || !event.toolCallId || !event.isError) return event;
101
- const raw = results.get(event.toolCallId);
107
+ return { inputs, results };
108
+ }
109
+
110
+ function reconciledToolResults(events: ActivityInspectorEvent[], evidence: ReturnType<typeof canonicalToolEvidence>): ActivityInspectorEvent[] {
111
+ const resultIds = new Set(events.filter((event) => event.event === "tool_result" && event.toolCallId).map((event) => event.toolCallId as string));
112
+ const reconciled = events.map((event) => {
113
+ if (event.event !== "tool_result" || !event.toolCallId) return event;
114
+ const raw = evidence.results.get(event.toolCallId);
102
115
  if (!raw) return event;
103
- const reasonCode = classifyToolFailure(raw.toolName, raw.isError, raw.content, inputs.get(event.toolCallId));
116
+ const reasonCode = classifyToolFailure(raw.toolName || event.toolName, raw.isError, raw.content, evidence.inputs.get(event.toolCallId));
117
+ if (!event.isError) {
118
+ // A newer explicit success remains authoritative over an older canonical
119
+ // hard error. The exception is a provider-level helper soft failure:
120
+ // legacy telemetry could only see `isError=false`, while the canonical
121
+ // result proves that no useful child actually ran.
122
+ if (raw.isError || !["helper-dispatch-rejected", "helper-insufficient-evidence"].includes(String(reasonCode))) return event;
123
+ return { ...event, isError: true, reasonCode };
124
+ }
104
125
  return reasonCode ? { ...event, reasonCode } : event;
105
126
  });
127
+ for (const call of events) {
128
+ if (call.event !== "tool_call" || !call.toolCallId || resultIds.has(call.toolCallId)) continue;
129
+ const raw = evidence.results.get(call.toolCallId);
130
+ if (!raw) continue;
131
+ const reasonCode = classifyToolFailure(raw.toolName || call.toolName, raw.isError, raw.content, evidence.inputs.get(call.toolCallId));
132
+ const effectiveError = raw.isError || Boolean(reasonCode && !handledToolFailure(reasonCode));
133
+ reconciled.push({ activityId: `canonical-result:${call.toolCallId}`, event: "tool_result",
134
+ recordedAt: raw.recordedAt ?? call.recordedAt, sessionId: call.sessionId, taskRunId: call.taskRunId,
135
+ toolCallId: call.toolCallId, toolName: raw.toolName || call.toolName, isError: effectiveError,
136
+ ...(reasonCode ? { reasonCode } : {}) });
137
+ }
138
+ return reconciled.sort((left, right) => String(left.recordedAt ?? "").localeCompare(String(right.recordedAt ?? "")));
106
139
  }
107
140
 
108
141
  function sourceSummary(document: SourceChangeDocument | null, view: "task" | "working-tree" | "staged") {
@@ -302,12 +335,15 @@ export async function buildWebUiInspectionProjection(input: {
302
335
  const taskRevision = input.task ? webUiTaskRevision(input.task) : null;
303
336
  const views = await collectSourceChangeViews({ cwd: input.cwd, identity, generatedAt, taskRevision,
304
337
  isProtectedPath: (_root, repoPath) => Boolean(matchesProtectedPath(repoPath, effectiveProtectedPaths)) });
305
- const events = enrichedFailureReasons(scopedEvents(input.events ?? [], input.sessionId, input.task), input.sessionEntries ?? []);
338
+ const canonicalEvidence = canonicalToolEvidence(input.sessionEntries ?? []);
339
+ const events = reconciledToolResults(scopedEvents(input.events ?? [], input.sessionId, input.task), canonicalEvidence);
306
340
  const linked = projectCriteriaFileVerifier({ cwd: input.cwd, task: input.task, sourceViews: views, currentSnapshot,
307
341
  protectedPaths: input.protectedPaths, events, at: new Date(generatedAt) });
308
342
  const sourceChanges = { task: sourceSummary(linked.sourceViews.task, "task"), workingTree: sourceSummary(linked.sourceViews.workingTree, "working-tree"), staged: sourceSummary(linked.sourceViews.staged, "staged") };
309
343
  const currentDigest = workingTreeSnapshotUsesCurrentAlgorithm(currentSnapshot) ? workingTreeEvidenceDigest(currentSnapshot) : null;
310
- const current = input.current ?? [], controlState = input.task?.trace.outcome === "pending" ? "active" as const : "terminal" as const;
344
+ const current = (input.current ?? []).filter((item) => (item.status ?? "running") !== "running" || !canonicalEvidence.results.has(item.toolCallId));
345
+ const runningCurrent = current.find((item) => (item.status ?? "running") === "running");
346
+ const controlState = input.task?.trace.outcome === "pending" ? "active" as const : "terminal" as const;
311
347
  const context = contextProjection(input.contextUsage, generatedAt), usageFacts = usage(input.sessionEntries ?? []);
312
348
  const eventCursor = input.eventCursor ?? token("event-cursor", events.map((event) => event.activityId ?? [event.event, event.toolCallId, event.recordedAt]));
313
349
  const runtimeRevision = token("runtime-rev", [taskRevision, sourceChanges.workingTree.revision, sourceChanges.staged.revision, eventCursor, current]);
@@ -328,8 +364,8 @@ export async function buildWebUiInspectionProjection(input: {
328
364
  capabilities: capabilities(identity, generatedAt, input.resyncRequired === true, input.eventReplay),
329
365
  session: {
330
366
  connectionState: input.resyncRequired ? "resync-required" : "connected", connectionReason: input.resyncRequired ? "event-replay-gap" : null, displayName: input.task?.sessionName ?? null,
331
- operation: current.some((item) => (item.status ?? "running") === "running")
332
- ? { liveness: "running", operationRef: null, hostPhase: { state: "known", value: "tool", evidence: "derived", reasonCode: null }, startedAt: timestamp(current[0]?.startedAt, generatedAt), settledAt: null, reasonCode: null }
367
+ operation: runningCurrent
368
+ ? { liveness: "running", operationRef: null, hostPhase: { state: "known", value: "tool", evidence: "derived", reasonCode: null }, startedAt: timestamp(runningCurrent.startedAt, generatedAt), settledAt: null, reasonCode: null }
333
369
  : { liveness: "idle", operationRef: null, hostPhase: { state: "known", value: "idle", evidence: "derived", reasonCode: null }, startedAt: null, settledAt: null, reasonCode: null },
334
370
  controlState: input.task ? controlState : "active", taskOutcome: input.task?.trace.outcome ?? null, approvalState: approvals.state,
335
371
  verificationState: linked.verification.state, permissionProfile: { state: "unavailable", value: null, evidence: null, reasonCode: "permission-profile-not-projected" },