@springbrand/agent-runtime 0.3.0-alpha.16 → 0.3.0-alpha.18

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.
@@ -0,0 +1,29 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { EntryRecord } from "@earendil-works/pi-durable";
3
+ import type { Usage } from "@earendil-works/pi-ai";
4
+
5
+ /** Browser/Host projection only; Pi's EntryRecord remains the canonical record. */
6
+ export type ProjectedSessionEntry = {
7
+ id: string;
8
+ identity: string;
9
+ timestamp: number;
10
+ submissionId?: string;
11
+ } & ({ type: "message"; message: AgentMessage } | {
12
+ type: "compaction" | "branch_summary";
13
+ summary: string;
14
+ usage?: Usage;
15
+ });
16
+
17
+ export function projectSessionEntry(entry: EntryRecord): ProjectedSessionEntry {
18
+ const data = entry.data as { createdAt?: number; displayId?: string; submissionId?: string; message?: AgentMessage } | undefined;
19
+ const message = entry.model?.[0];
20
+ const base = { id: String(entry.id), identity: data?.displayId ?? String(entry.id), timestamp: data?.createdAt ?? message?.timestamp ?? 0, ...(data?.submissionId ? { submissionId: data.submissionId } : {}) };
21
+ if (entry.kind === "pi.compaction") return {
22
+ ...base, type: "compaction",
23
+ summary: message?.role === "user" ? typeof message.content === "string" ? message.content : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("") : "",
24
+ };
25
+ return {
26
+ ...base, type: "message",
27
+ message: entry.kind === "springbrand.retained-context" ? { role: "custom", customType: entry.kind, content: [], timestamp: base.timestamp } : data?.message ?? message ?? { role: "custom", customType: entry.kind, content: [], timestamp: base.timestamp },
28
+ };
29
+ }
@@ -0,0 +1,206 @@
1
+ import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import { isRetryableAssistantError } from "@earendil-works/pi-ai/utils/retry";
3
+ import type { EntryRecord } from "@earendil-works/pi-durable";
4
+ import type { ArtifactRef } from "../lib/artifacts";
5
+ import {
6
+ isModelStreamStallMessage,
7
+ isOpenRouterServerToolTimeout,
8
+ isRecoverableAssistantError,
9
+ } from "../models";
10
+
11
+ export const MAX_TOOL_RETRIES = 3;
12
+ export const TURN_FAILURE_CEILING = 8;
13
+ export const DEFAULT_MAX_WRAP_UP_MODEL_TURNS = 2;
14
+ export const TOOL_FAILURE_WRAP_UP_INSTRUCTION =
15
+ "This turn reached its tool failure limit. Stop calling tools now. Reply with a final message that explains what failed and the exact next step the user should take. Do not start new work.";
16
+ const RECOVERY_MAX_FAILURES = 5;
17
+ const STALL_MAX_FAILURES = 3;
18
+ const SERVER_TOOL_TIMEOUT_MAX_FAILURES = 2;
19
+ const ARTIFACT_MANIFEST_MAX_CHARS = 12 * 1024;
20
+ /** Harness diagnostics for calls that never crossed the Tool effect boundary. */
21
+ const UNEXECUTED_CODES = new Set(["blocked", "tool_unavailable", "invalid_arguments", "interrupted", "aborted"]);
22
+
23
+ function canonicalJson(value: unknown): string {
24
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
25
+ if (value !== null && typeof value === "object") {
26
+ return `{${Object.entries(value as Record<string, unknown>)
27
+ .sort(([left], [right]) => left.localeCompare(right))
28
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
29
+ .join(",")}}`;
30
+ }
31
+ return JSON.stringify(value) ?? "null";
32
+ }
33
+
34
+ export function toolCallKey(toolName: string, args: unknown): string {
35
+ return `${toolName}\0${canonicalJson(args)}`;
36
+ }
37
+
38
+ function resultCodes(entry: EntryRecord): string[] {
39
+ const diagnostics = (entry.data as { diagnostics?: { code?: unknown }[] } | undefined)?.diagnostics;
40
+ return Array.isArray(diagnostics)
41
+ ? diagnostics.flatMap((item) => typeof item?.code === "string" ? [item.code] : [])
42
+ : [];
43
+ }
44
+
45
+ /**
46
+ * Per-Submission Tool failure budget: an exact call that keeps failing is blocked, and too many failures in one
47
+ * Submission switch it to a text-only wrap-up. Counts are rebuilt from canonical entries after a restart.
48
+ */
49
+ export class ToolGovernance {
50
+ private readonly failures = new Map<string, number>();
51
+ private total = 0;
52
+ wrapUp = false;
53
+ /** Completed model turns when the wrap-up began; the wrap-up then has a bounded number of text turns. */
54
+ wrapUpAt: number | undefined;
55
+
56
+ restore(entries: readonly EntryRecord[]): void {
57
+ this.failures.clear();
58
+ this.total = 0;
59
+ this.wrapUp = false;
60
+ this.wrapUpAt = undefined;
61
+ const calls = new Map<string, ToolCall>();
62
+ let turns = 0;
63
+ for (const entry of entries) {
64
+ const message = entry.model?.[0];
65
+ if (message?.role === "assistant") {
66
+ turns += 1;
67
+ for (const part of message.content) if (part.type === "toolCall") calls.set(part.id, part);
68
+ continue;
69
+ }
70
+ if (message?.role !== "toolResult") continue;
71
+ const call = calls.get(message.toolCallId);
72
+ if (!call) continue;
73
+ const codes = resultCodes(entry);
74
+ if (codes.some((code) => UNEXECUTED_CODES.has(code))) {
75
+ if (codes.includes("blocked") && this.exhausted(call)) this.startWrapUp(turns);
76
+ continue;
77
+ }
78
+ this.record(call, message.isError, turns);
79
+ }
80
+ }
81
+
82
+ exhausted(call: Pick<ToolCall, "name" | "arguments">): boolean {
83
+ return (this.failures.get(toolCallKey(call.name, call.arguments)) ?? 0) >= MAX_TOOL_RETRIES;
84
+ }
85
+
86
+ /** Returns a block reason, or undefined when the call may run. */
87
+ admit(call: Pick<ToolCall, "name" | "arguments">, turns: number): string | undefined {
88
+ if (this.wrapUp) return "This turn reached its tool failure limit. Stop calling tools now.";
89
+ if (!this.exhausted(call)) return undefined;
90
+ this.startWrapUp(turns);
91
+ return `This exact ${call.name} call has already failed ${MAX_TOOL_RETRIES} times — change the input or approach instead of retrying it verbatim.`;
92
+ }
93
+
94
+ record(call: Pick<ToolCall, "name" | "arguments">, isError: boolean, turns: number): void {
95
+ const key = toolCallKey(call.name, call.arguments);
96
+ if (!isError) {
97
+ this.failures.delete(key);
98
+ return;
99
+ }
100
+ this.failures.set(key, (this.failures.get(key) ?? 0) + 1);
101
+ this.total += 1;
102
+ if (this.total >= TURN_FAILURE_CEILING) this.startWrapUp(turns);
103
+ }
104
+
105
+ private startWrapUp(turns: number): void {
106
+ this.wrapUp = true;
107
+ this.wrapUpAt ??= turns;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Provider-error recovery the durable Harness cannot express by itself: per-class budgets, refusing deterministic
113
+ * failures, and a fresh provider Session after an OpenRouter server Tool timeout so retries leave a failing route.
114
+ * Pi only retries messages its own classifier accepts, so recoverable failures it does not recognize are labelled.
115
+ */
116
+ export class ModelRecovery {
117
+ private failures = 0;
118
+ private stalls = 0;
119
+ private serverToolTimeouts = 0;
120
+ private allowed = true;
121
+
122
+ constructor(private readonly baseSessionId: string) {}
123
+
124
+ /** Provider Session identity for the next request. */
125
+ sessionId(): string {
126
+ return this.serverToolTimeouts === 0
127
+ ? this.baseSessionId
128
+ : `${this.baseSessionId}:server-tool-recovery:${this.serverToolTimeouts}`;
129
+ }
130
+
131
+ /** Whether the Harness may retry the response last passed to {@link classify}. */
132
+ get retryAllowed(): boolean {
133
+ return this.allowed;
134
+ }
135
+
136
+ /** Classify one terminal provider response before the Harness decides whether to retry it. */
137
+ classify(message: AssistantMessage): void {
138
+ if (message.stopReason !== "error") {
139
+ this.allowed = true;
140
+ return;
141
+ }
142
+ this.failures += 1;
143
+ const stalled = isModelStreamStallMessage(message.errorMessage);
144
+ const serverToolTimeout = isOpenRouterServerToolTimeout(message);
145
+ if (stalled) this.stalls += 1;
146
+ if (serverToolTimeout) this.serverToolTimeouts += 1;
147
+ const recoverable = stalled || serverToolTimeout || isRecoverableAssistantError(message);
148
+ this.allowed = recoverable && this.failures < RECOVERY_MAX_FAILURES &&
149
+ (!stalled || this.stalls < STALL_MAX_FAILURES) &&
150
+ (!serverToolTimeout || this.serverToolTimeouts < SERVER_TOOL_TIMEOUT_MAX_FAILURES);
151
+ if (this.allowed && !isRetryableAssistantError(message)) {
152
+ // Pi's classifier does not know our stall watchdog or OpenRouter's HTTP/2 upstream failure. Only a retried
153
+ // attempt carries the label; a final failure keeps the provider text.
154
+ message.errorMessage = `network error: ${message.errorMessage ?? "recoverable provider failure"}`;
155
+ }
156
+ }
157
+ }
158
+
159
+ export function artifactRefFromMessage(message: Message | Pick<ToolResultMessage, "role" | "details">): ArtifactRef | undefined {
160
+ if (message.role !== "toolResult" || message.details === null ||
161
+ typeof message.details !== "object" ||
162
+ (message.details as { kind?: unknown }).kind !== "artifact_ref") {
163
+ return undefined;
164
+ }
165
+ const ref = message.details as Partial<ArtifactRef>;
166
+ if (typeof ref.path !== "string" || typeof ref.hash !== "string" ||
167
+ typeof ref.bytes !== "number" || typeof ref.preview !== "string" ||
168
+ typeof ref.note !== "string") return undefined;
169
+ return {
170
+ kind: "artifact_ref",
171
+ artifactId: typeof ref.artifactId === "string" ? ref.artifactId : `artifact_${ref.hash.slice(0, 24)}`,
172
+ path: ref.path,
173
+ bytes: ref.bytes,
174
+ hash: ref.hash,
175
+ workspaceRevision: typeof ref.workspaceRevision === "string" ? ref.workspaceRevision : "unknown",
176
+ availability: ref.availability === "unavailable" ? "unavailable" : "available",
177
+ preview: ref.preview,
178
+ note: ref.note,
179
+ };
180
+ }
181
+
182
+ /** Lightweight pointers to spilled Tool output, so the model can reread evidence after compaction. */
183
+ export function artifactManifestMessage(refs: ReadonlyMap<string, ArtifactRef>): Message | undefined {
184
+ if (refs.size === 0) return undefined;
185
+ const lines = [
186
+ "<workspace_artifact_manifest>",
187
+ "These are lightweight references to prior Tool output. Do not treat previews as complete. Read an available artifact with the Workspace readFile/readFilePage tool only when the current request needs its evidence.",
188
+ ];
189
+ let omitted = 0;
190
+ for (const ref of refs.values()) {
191
+ const preview = ref.preview.replace(/[\r\n]+/g, " ").slice(0, 160);
192
+ const line = [
193
+ `path=${ref.path}`,
194
+ ...(ref.availability === "unavailable" ? ["unavailable"] : []),
195
+ `preview=${preview}`,
196
+ ].join(" ");
197
+ if ([...lines, line, "</workspace_artifact_manifest>"].join("\n").length > ARTIFACT_MANIFEST_MAX_CHARS) {
198
+ omitted += 1;
199
+ continue;
200
+ }
201
+ lines.push(line);
202
+ }
203
+ if (omitted > 0) lines.push(`omitted=${omitted} additional artifact references due to manifest budget`);
204
+ lines.push("</workspace_artifact_manifest>");
205
+ return { role: "user", content: lines.join("\n"), timestamp: 1 };
206
+ }
@@ -3,5 +3,8 @@ export type {
3
3
  SessionHarnessLimits,
4
4
  SessionHarnessOptions,
5
5
  SessionHarnessRunResult,
6
+ SessionRunOutcome,
7
+ SessionRunRequest,
6
8
  } from "./session-harness";
7
9
  export { prepareSubmissionOperation } from "./submission-operation";
10
+ export { ModelRecovery } from "./governance";