@springbrand/agent-runtime 0.3.0-alpha.2 → 0.3.0-alpha.21

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 (131) hide show
  1. package/package.json +11 -9
  2. package/src/adapter/cloudflare/agent.ts +233 -179
  3. package/src/adapter/cloudflare/define-agent.ts +252 -104
  4. package/src/adapter/cloudflare/index.ts +18 -0
  5. package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
  6. package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
  7. package/src/adapter/cloudflare/schema.ts +21 -2
  8. package/src/adapter/cloudflare/subagent/definition.ts +25 -13
  9. package/src/adapter/cloudflare/subagent/runner.ts +43 -50
  10. package/src/adapter/cloudflare/subagent/tools.ts +4 -4
  11. package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
  12. package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
  13. package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
  14. package/src/config/activation.ts +13 -15
  15. package/src/config/agent-context.ts +1 -1
  16. package/src/config/assembler.ts +48 -31
  17. package/src/config/assembly-snapshot.ts +1 -1
  18. package/src/config/definition.ts +1 -1
  19. package/src/config/harness-runtime.ts +77 -49
  20. package/src/config/index.ts +1 -1
  21. package/src/config/model-guidance.ts +20 -0
  22. package/src/config/ports.ts +57 -7
  23. package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
  24. package/src/config/public-contracts.ts +2 -2
  25. package/src/config/runtime-config.ts +2 -1
  26. package/src/context/assembly.ts +1 -1
  27. package/src/context/extension/assembly.ts +2 -2
  28. package/src/context/memory/budget/gate.ts +19 -3
  29. package/src/conversation/harness-resume.ts +89 -238
  30. package/src/conversation/harness-stream.ts +87 -71
  31. package/src/conversation/history.ts +154 -152
  32. package/src/conversation/message/contract.ts +28 -4
  33. package/src/conversation/message/conversion.ts +46 -5
  34. package/src/conversation/message/projection.ts +61 -21
  35. package/src/conversation/session-entry.ts +29 -0
  36. package/src/conversation/state.ts +23 -2
  37. package/src/harness/governance.ts +206 -0
  38. package/src/harness/index.ts +3 -0
  39. package/src/harness/session-harness.ts +269 -445
  40. package/src/harness/submission-operation.ts +15 -40
  41. package/src/index.ts +18 -8
  42. package/src/lib/artifacts.ts +18 -0
  43. package/src/lib/execution-level.ts +13 -0
  44. package/src/lib/prompt.ts +9 -10
  45. package/src/lib/token-estimate.ts +8 -0
  46. package/src/model-protocol/openrouter.ts +41 -0
  47. package/src/models/index.ts +200 -35
  48. package/src/models/openrouter/messages.ts +13 -8
  49. package/src/models/openrouter/openai.ts +200 -0
  50. package/src/models/openrouter/protocol-response.ts +85 -0
  51. package/src/models/provider-streams.ts +45 -0
  52. package/src/session/cloudflare/migration.ts +382 -864
  53. package/src/session/cloudflare/storage.ts +13 -536
  54. package/src/session/index.ts +22 -35
  55. package/src/skills/springbrand-worker-website/index.ts +1 -1
  56. package/src/submission/admission.ts +20 -30
  57. package/src/submission/contracts.ts +42 -2
  58. package/src/submission/delivery.ts +1 -1
  59. package/src/submission/finalization.ts +36 -15
  60. package/src/submission/lifecycle.ts +28 -15
  61. package/src/submission/repository.ts +97 -49
  62. package/src/submission/steer-delivery.ts +32 -76
  63. package/src/submission/unattended.ts +40 -0
  64. package/src/telemetry/contract.ts +0 -1
  65. package/src/telemetry/coordinator.ts +1 -1
  66. package/src/telemetry/runtime-telemetry.ts +0 -4
  67. package/src/telemetry/subagent-lifecycle.ts +1 -1
  68. package/src/telemetry/turn-events.ts +35 -25
  69. package/src/tools/README.md +44 -0
  70. package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
  71. package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
  72. package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
  73. package/src/tools/assistant/ask-user.ts +319 -0
  74. package/src/tools/assistant/candidate.ts +15 -0
  75. package/src/tools/assistant/followups.ts +34 -0
  76. package/src/tools/assistant/index.ts +19 -0
  77. package/src/tools/assistant/memory.ts +73 -0
  78. package/src/tools/assistant/plan.ts +98 -0
  79. package/src/tools/{time.ts → assistant/time.ts} +1 -1
  80. package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
  81. package/src/tools/{core.ts → codemode/index.ts} +36 -92
  82. package/src/tools/codemode/workspace-connector.ts +198 -0
  83. package/src/tools/extension/index.ts +42 -0
  84. package/src/tools/index.ts +29 -26
  85. package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
  86. package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
  87. package/src/tools/runtime/activation.ts +40 -0
  88. package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
  89. package/src/tools/runtime/compiler.ts +246 -0
  90. package/src/tools/runtime/contracts.ts +140 -0
  91. package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
  92. package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
  93. package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
  94. package/src/tools/runtime/result.ts +22 -0
  95. package/src/tools/runtime/unattended-error.ts +4 -0
  96. package/src/tools/sandbox/index.ts +228 -0
  97. package/src/tools/schedule/index.ts +301 -0
  98. package/src/tools/skill/contracts.ts +20 -0
  99. package/src/tools/skill/index.ts +99 -0
  100. package/src/tools/skill/resources.ts +322 -0
  101. package/src/tools/skill/scripts.ts +168 -0
  102. package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
  103. package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
  104. package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
  105. package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
  106. package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
  107. package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
  108. package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
  109. package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
  110. package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
  111. package/src/tools/web/index.ts +2 -0
  112. package/src/tools/web/search/api.ts +1322 -0
  113. package/src/tools/web/search/index.ts +2 -0
  114. package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
  115. package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
  116. package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
  117. package/src/tools/base.ts +0 -534
  118. package/src/tools/compiler.ts +0 -327
  119. package/src/tools/schedule.ts +0 -261
  120. package/src/tools/skill.ts +0 -565
  121. package/src/tools/web-search/api.ts +0 -1318
  122. package/src/tools/web-search/index.ts +0 -2
  123. package/src/tools/workspace-state-connector.ts +0 -146
  124. package/src/tools/{approval → runtime/approval}/index.ts +0 -0
  125. package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
  126. package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
  127. package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
  128. package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
  129. package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
  130. package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
  131. /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
@@ -75,6 +75,35 @@ function safeInput(value: unknown): Record<string, unknown> {
75
75
  return isRecord(safe) ? safe : {};
76
76
  }
77
77
 
78
+ export type ToolInputPreparer = (
79
+ toolName: string,
80
+ input: unknown,
81
+ ) => unknown;
82
+
83
+ function safeToolInput(
84
+ toolName: string,
85
+ value: unknown,
86
+ prepare?: ToolInputPreparer,
87
+ ): Record<string, unknown> {
88
+ if (prepare) {
89
+ try {
90
+ value = prepare(toolName, value);
91
+ } catch {
92
+ // Preserve invalid provider output for the normal Tool validation path.
93
+ }
94
+ }
95
+ const input = safeInput(value);
96
+ if (toolName === "ask_user" && typeof input.questions === "string") {
97
+ try {
98
+ const questions: unknown = JSON.parse(input.questions);
99
+ if (Array.isArray(questions)) input.questions = questions;
100
+ } catch {
101
+ // Preserve malformed provider output for the normal unavailable state.
102
+ }
103
+ }
104
+ return input;
105
+ }
106
+
78
107
  function contentText(result: unknown): string | undefined {
79
108
  if (!isRecord(result) || !Array.isArray(result.content)) return undefined;
80
109
  const text = result.content.flatMap((part) =>
@@ -175,6 +204,7 @@ export class PiChunkEncoder {
175
204
  private readonly startedAt?: number;
176
205
  private readonly turnId?: string;
177
206
  private readonly userMessageId?: string;
207
+ private readonly prepareToolInput?: ToolInputPreparer;
178
208
  private started = false;
179
209
  private finished = false;
180
210
  private firstTurnStarted = false;
@@ -194,11 +224,13 @@ export class PiChunkEncoder {
194
224
  startedAt?: number;
195
225
  turnId?: string;
196
226
  userMessageId?: string | null;
227
+ prepareToolInput?: ToolInputPreparer;
197
228
  }) {
198
229
  this.messageId = options.messageId;
199
230
  this.startedAt = options.startedAt;
200
231
  this.turnId = options.turnId;
201
232
  this.userMessageId = options.userMessageId ?? undefined;
233
+ this.prepareToolInput = options.prepareToolInput;
202
234
  }
203
235
 
204
236
  start(): UIMessageChunk[] {
@@ -277,7 +309,7 @@ export class PiChunkEncoder {
277
309
  toolName: string,
278
310
  input: unknown,
279
311
  ): UIMessageChunk[] {
280
- const safe = safeInput(input);
312
+ const safe = safeToolInput(toolName, input, this.prepareToolInput);
281
313
  this.toolInputs.set(toolCallId, { toolName, input: safe });
282
314
  return [{
283
315
  type: "tool-input-available",
@@ -341,6 +373,23 @@ export class PiChunkEncoder {
341
373
  const chunks = this.toolInputs.has(event.toolCallId)
342
374
  ? []
343
375
  : this.toolInput(event.toolCallId, event.toolName, event.args);
376
+ const partialResult = isRecord(event.partialResult)
377
+ ? event.partialResult
378
+ : undefined;
379
+ const details = isRecord(partialResult?.details)
380
+ ? partialResult.details
381
+ : undefined;
382
+ const actionToolMeta = isRecord(details?.actionToolMeta)
383
+ ? details.actionToolMeta
384
+ : undefined;
385
+ if (actionToolMeta) {
386
+ chunks.push({
387
+ type: "data-action-tool-meta",
388
+ id: event.toolCallId,
389
+ data: { toolCallId: event.toolCallId, ...actionToolMeta },
390
+ });
391
+ return chunks;
392
+ }
344
393
  chunks.push({
345
394
  type: "tool-output-available",
346
395
  toolCallId: event.toolCallId,
@@ -505,18 +554,11 @@ export class PiChunkEncoder {
505
554
  streaming.deltas.length = 0;
506
555
  }
507
556
  if (update.type === "toolcall_end") {
508
- const input = safeInput(update.toolCall.arguments);
509
- this.toolInputs.set(toolCallId, {
510
- toolName: update.toolCall.name,
511
- input,
512
- });
513
- chunks.push({
514
- type: "tool-input-available",
557
+ chunks.push(...this.toolInput(
515
558
  toolCallId,
516
- toolName: update.toolCall.name,
517
- input,
518
- dynamic: true,
519
- });
559
+ update.toolCall.name,
560
+ update.toolCall.arguments,
561
+ ));
520
562
  this.pendingToolByIndex.delete(index);
521
563
  this.toolCallIdByIndex.delete(index);
522
564
  }
@@ -553,6 +595,12 @@ export class PiChunkEncoder {
553
595
  turnDurationMs: Math.max(0, message.timestamp - this.startedAt),
554
596
  turnStatus: terminal.turnStatus,
555
597
  ...(this.turnId ? { turnId: this.turnId } : {}),
598
+ // The browser keeps this stream copy over a history snapshot that
599
+ // arrives while it still observes the stream, so it must carry the
600
+ // user-stop marker the snapshot projects from `turn-aborted`.
601
+ ...(terminal.outcome === "aborted" && message.errorMessage === USER_STOP_REASON
602
+ ? { interruptedByUser: true }
603
+ : {}),
556
604
  ...((publicError ?? terminal.message) &&
557
605
  (terminal.outcome !== "aborted" ||
558
606
  message.errorMessage !== USER_STOP_REASON)
@@ -613,15 +661,7 @@ function projectedTool(
613
661
  call: ToolCall,
614
662
  approval?: PiToolApprovalView,
615
663
  ): DynamicToolUIPart {
616
- const input = safeInput(call.arguments);
617
- if (call.name === "ask_user" && typeof input.questions === "string") {
618
- try {
619
- const questions: unknown = JSON.parse(input.questions);
620
- if (Array.isArray(questions)) input.questions = questions;
621
- } catch {
622
- // Preserve malformed provider output for the normal unavailable state.
623
- }
624
- }
664
+ const input = safeToolInput(call.name, call.arguments);
625
665
  const base = {
626
666
  type: "dynamic-tool" as const,
627
667
  toolCallId: call.id,
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ApprovalReceipt } from "../submission/contracts";
2
2
  import type { StoredSubmission } from "../submission/repository";
3
- import type { PendingApprovalSummary } from "../tools/approval/repository";
3
+ import type { PendingApprovalSummary } from "../tools/runtime/approval/repository";
4
4
  import type { PiCanonicalUserInput } from "./message/contract";
5
5
 
6
6
  export type RuntimeLoadPhase = "config" | "assembly" | "mcp" | "pi";
@@ -68,6 +68,17 @@ export interface RuntimeTurnState {
68
68
  queued: RuntimeQueuedSubmission[];
69
69
  }
70
70
 
71
+ /**
72
+ * 本 Session 当前是否拒绝普通用户输入。
73
+ *
74
+ * 定时 Run 未结束时它独占输入:宿主据此禁用输入框并显示原因。
75
+ * 服务端准入用同一事实拒绝消息,所以客户端状态与后端判定不会漂移。
76
+ */
77
+ export interface RuntimeInputLock {
78
+ /** 稳定原因码,由宿主/UI 决定文案。 */
79
+ reason: string;
80
+ }
81
+
71
82
  /**
72
83
  * 保存 Kernel 在当前会话里自己维护的审批状态。
73
84
  *
@@ -85,6 +96,8 @@ export interface RuntimeState {
85
96
  activity?: RuntimeActivityProjection;
86
97
  approvals?: ApprovalReceipt[];
87
98
  turn?: RuntimeTurnState;
99
+ /** 有值时普通输入被锁定,直到锁定的 Submission 进入终态。 */
100
+ inputLock?: RuntimeInputLock;
88
101
  }
89
102
 
90
103
  type SubmissionProjection = Pick<
@@ -103,6 +116,8 @@ export function projectRuntimeState(input: {
103
116
  readonly hasPendingInteraction: (submissionId: string) => boolean;
104
117
  readonly hasPendingSteer: (submissionId: string) => boolean;
105
118
  readonly hasRunningAgentTools: boolean;
119
+ /** A non-terminal scheduled Submission owns ordinary input until it ends. */
120
+ readonly hasUnfinishedScheduled?: boolean;
106
121
  readonly previousState: RuntimeState;
107
122
  }): { state: RuntimeState; changed: boolean } {
108
123
  const approvals: ApprovalReceipt[] = [
@@ -164,12 +179,18 @@ export function projectRuntimeState(input: {
164
179
  backgroundWork: input.hasRunningAgentTools,
165
180
  revision: (previousActivity?.revision ?? 0) + 1,
166
181
  };
182
+ const inputLock: RuntimeInputLock | undefined = input.hasUnfinishedScheduled
183
+ ? { reason: "scheduled-run" }
184
+ : undefined;
167
185
  const changed = input.previousState.approvals === undefined ||
168
186
  JSON.stringify(input.previousState.approvals) !== JSON.stringify(approvals) ||
169
187
  JSON.stringify(input.previousState.turn) !== JSON.stringify(turn) ||
188
+ JSON.stringify(input.previousState.inputLock) !== JSON.stringify(inputLock) ||
170
189
  nextActivity !== previousActivity;
171
190
  return {
172
- state: changed ? { ...input.previousState, activity: nextActivity, approvals, turn } : input.previousState,
191
+ state: changed
192
+ ? { ...input.previousState, activity: nextActivity, approvals, turn, inputLock }
193
+ : input.previousState,
173
194
  changed,
174
195
  };
175
196
  }
@@ -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";