@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.
- package/package.json +11 -9
- package/src/adapter/cloudflare/agent.ts +233 -179
- package/src/adapter/cloudflare/define-agent.ts +252 -104
- package/src/adapter/cloudflare/index.ts +18 -0
- package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
- package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
- package/src/adapter/cloudflare/schema.ts +21 -2
- package/src/adapter/cloudflare/subagent/definition.ts +25 -13
- package/src/adapter/cloudflare/subagent/runner.ts +43 -50
- package/src/adapter/cloudflare/subagent/tools.ts +4 -4
- package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
- package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
- package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
- package/src/config/activation.ts +13 -15
- package/src/config/agent-context.ts +1 -1
- package/src/config/assembler.ts +48 -31
- package/src/config/assembly-snapshot.ts +1 -1
- package/src/config/definition.ts +1 -1
- package/src/config/harness-runtime.ts +77 -49
- package/src/config/index.ts +1 -1
- package/src/config/model-guidance.ts +20 -0
- package/src/config/ports.ts +57 -7
- package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
- package/src/config/public-contracts.ts +2 -2
- package/src/config/runtime-config.ts +2 -1
- package/src/context/assembly.ts +1 -1
- package/src/context/extension/assembly.ts +2 -2
- package/src/context/memory/budget/gate.ts +19 -3
- package/src/conversation/harness-resume.ts +89 -238
- package/src/conversation/harness-stream.ts +87 -71
- package/src/conversation/history.ts +154 -152
- package/src/conversation/message/contract.ts +28 -4
- package/src/conversation/message/conversion.ts +46 -5
- package/src/conversation/message/projection.ts +61 -21
- package/src/conversation/session-entry.ts +29 -0
- package/src/conversation/state.ts +23 -2
- package/src/harness/governance.ts +206 -0
- package/src/harness/index.ts +3 -0
- package/src/harness/session-harness.ts +269 -445
- package/src/harness/submission-operation.ts +15 -40
- package/src/index.ts +18 -8
- package/src/lib/artifacts.ts +18 -0
- package/src/lib/execution-level.ts +13 -0
- package/src/lib/prompt.ts +9 -10
- package/src/lib/token-estimate.ts +8 -0
- package/src/model-protocol/openrouter.ts +41 -0
- package/src/models/index.ts +200 -35
- package/src/models/openrouter/messages.ts +13 -8
- package/src/models/openrouter/openai.ts +200 -0
- package/src/models/openrouter/protocol-response.ts +85 -0
- package/src/models/provider-streams.ts +45 -0
- package/src/session/cloudflare/migration.ts +382 -864
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/skills/springbrand-worker-website/index.ts +1 -1
- package/src/submission/admission.ts +20 -30
- package/src/submission/contracts.ts +42 -2
- package/src/submission/delivery.ts +1 -1
- package/src/submission/finalization.ts +36 -15
- package/src/submission/lifecycle.ts +28 -15
- package/src/submission/repository.ts +97 -49
- package/src/submission/steer-delivery.ts +32 -76
- package/src/submission/unattended.ts +40 -0
- package/src/telemetry/contract.ts +0 -1
- package/src/telemetry/coordinator.ts +1 -1
- package/src/telemetry/runtime-telemetry.ts +0 -4
- package/src/telemetry/subagent-lifecycle.ts +1 -1
- package/src/telemetry/turn-events.ts +35 -25
- package/src/tools/README.md +44 -0
- package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
- package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
- package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
- package/src/tools/assistant/ask-user.ts +319 -0
- package/src/tools/assistant/candidate.ts +15 -0
- package/src/tools/assistant/followups.ts +34 -0
- package/src/tools/assistant/index.ts +19 -0
- package/src/tools/assistant/memory.ts +73 -0
- package/src/tools/assistant/plan.ts +98 -0
- package/src/tools/{time.ts → assistant/time.ts} +1 -1
- package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
- package/src/tools/{core.ts → codemode/index.ts} +36 -92
- package/src/tools/codemode/workspace-connector.ts +198 -0
- package/src/tools/extension/index.ts +42 -0
- package/src/tools/index.ts +29 -26
- package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
- package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
- package/src/tools/runtime/activation.ts +40 -0
- package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
- package/src/tools/runtime/compiler.ts +246 -0
- package/src/tools/runtime/contracts.ts +140 -0
- package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
- package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
- package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
- package/src/tools/runtime/result.ts +22 -0
- package/src/tools/runtime/unattended-error.ts +4 -0
- package/src/tools/sandbox/index.ts +228 -0
- package/src/tools/schedule/index.ts +301 -0
- package/src/tools/skill/contracts.ts +20 -0
- package/src/tools/skill/index.ts +99 -0
- package/src/tools/skill/resources.ts +322 -0
- package/src/tools/skill/scripts.ts +168 -0
- package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
- package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
- package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
- package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
- package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
- package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
- package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
- package/src/tools/web/index.ts +2 -0
- package/src/tools/web/search/api.ts +1322 -0
- package/src/tools/web/search/index.ts +2 -0
- package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
- package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
- package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
- package/src/tools/base.ts +0 -534
- package/src/tools/compiler.ts +0 -327
- package/src/tools/schedule.ts +0 -261
- package/src/tools/skill.ts +0 -565
- package/src/tools/web-search/api.ts +0 -1318
- package/src/tools/web-search/index.ts +0 -2
- package/src/tools/workspace-state-connector.ts +0 -146
- package/src/tools/{approval → runtime/approval}/index.ts +0 -0
- package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
- package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
- /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 =
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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 =
|
|
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
|
|
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
|
+
}
|
package/src/harness/index.ts
CHANGED
|
@@ -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";
|