@springbrand/agent-runtime 0.3.0-alpha.15 → 0.3.0-alpha.17
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 +6 -4
- package/src/adapter/cloudflare/agent.ts +66 -122
- package/src/adapter/cloudflare/subagent/definition.ts +11 -9
- package/src/adapter/cloudflare/subagent/runner.ts +20 -47
- package/src/adapter/cloudflare/subagent/tools.ts +2 -2
- package/src/config/harness-runtime.ts +27 -14
- package/src/config/model-guidance.ts +16 -17
- package/src/config/ports.ts +2 -0
- package/src/conversation/harness-resume.ts +69 -234
- package/src/conversation/harness-stream.ts +69 -70
- package/src/conversation/history.ts +118 -153
- package/src/conversation/message/contract.ts +12 -0
- package/src/conversation/session-entry.ts +29 -0
- package/src/harness/index.ts +2 -0
- package/src/harness/session-harness.ts +178 -606
- package/src/harness/submission-operation.ts +15 -40
- package/src/models/index.ts +3 -1
- package/src/models/openrouter/protocol-response.ts +36 -112
- package/src/session/cloudflare/migration.ts +221 -939
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/submission/finalization.ts +2 -13
- package/src/submission/repository.ts +1 -22
- package/src/submission/steer-delivery.ts +32 -76
- package/src/telemetry/turn-events.ts +34 -24
- package/src/tools/runtime/compiler.ts +25 -50
- package/src/tools/runtime/decision-gate.ts +5 -5
|
@@ -1,635 +1,207 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
type AgentHarness as PiAgentHarness,
|
|
5
|
-
type AgentHarnessOptions,
|
|
6
|
-
type AgentLane,
|
|
7
|
-
type AgentToolResult,
|
|
8
|
-
type DriveOutcome,
|
|
9
|
-
type DriveOptions,
|
|
10
|
-
type OpenOperation,
|
|
11
|
-
type OperationRequest,
|
|
12
|
-
} from "@earendil-works/pi-agent-core";
|
|
13
|
-
import type { Context } from "@earendil-works/pi-agent-core/harness/context";
|
|
14
|
-
import type { Entry, SettledAssistantMessage } from "@earendil-works/pi-agent-core/harness/session";
|
|
2
|
+
Harness, GenerationTask, ToolTask, AgentDoc,
|
|
3
|
+
} from "@earendil-works/pi-durable";
|
|
15
4
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
5
|
+
createRegistry, defineExtension, hook, section, watchEvents,
|
|
6
|
+
type ConversationRetryPolicy, type AgentEventStream, type CompactionPolicy, type Conversation,
|
|
7
|
+
type EntryId, type EntryRecord, type GenerationHooks, type Harness as NativeHarness,
|
|
8
|
+
type SettledSubmissionRecord, type Storage, type Submission,
|
|
9
|
+
type ToolHooks, type ToolRegistration,
|
|
10
|
+
} from "@earendil-works/pi-durable";
|
|
11
|
+
import type { Context } from "@earendil-works/chord";
|
|
12
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
13
|
+
import type { Api, Message, Model, Models, ModelThinkingLevel, UserMessage } from "@earendil-works/pi-ai";
|
|
14
|
+
import { RuntimeConversation } from "../session";
|
|
20
15
|
import { projectToolOutputForModel } from "../context/memory/budget/gate";
|
|
21
|
-
import type { ArtifactRef } from "../lib/artifacts";
|
|
22
16
|
|
|
23
17
|
export interface SessionHarnessLimits {
|
|
24
|
-
/** Complete model turns committed before one Worker instance releases. */
|
|
25
18
|
modelTurnsPerSlice?: number;
|
|
26
|
-
/** Complete working turns allowed before the no-tool final response. */
|
|
27
19
|
maxModelTurns?: number;
|
|
28
|
-
/** Maximum text turns allowed after the working or Tool failure budget. */
|
|
29
|
-
maxWrapUpModelTurns?: number;
|
|
30
20
|
wrapUpInstruction?: string;
|
|
31
21
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* Compiled tools omitted from the initial active set. A later successful
|
|
41
|
-
* result may add them through {@link SessionHarnessOptions.toolsToActivate}.
|
|
42
|
-
*/
|
|
22
|
+
export interface SessionHarnessOptions {
|
|
23
|
+
storage: Storage | Promise<Storage>;
|
|
24
|
+
models: Models;
|
|
25
|
+
model: Model<Api>;
|
|
26
|
+
thinkingLevel?: ModelThinkingLevel;
|
|
27
|
+
systemPrompt?: string;
|
|
28
|
+
tools?: readonly ToolRegistration<any, any>[];
|
|
29
|
+
activeToolNames?: readonly string[];
|
|
43
30
|
onDemandToolNames?: readonly string[];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
isError: boolean,
|
|
54
|
-
) => readonly string[];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export type SessionHarnessRunResult = DriveOutcome | {
|
|
58
|
-
kind: "released";
|
|
59
|
-
operationId: string;
|
|
60
|
-
reason: "slice" | "budget" | "tool-failure" | "approval" | "interaction";
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const DEFAULT_MODEL_TURNS_PER_SLICE = 30;
|
|
64
|
-
const DEFAULT_MAX_MODEL_TURNS = 300;
|
|
65
|
-
const DEFAULT_MAX_WRAP_UP_MODEL_TURNS = 2;
|
|
66
|
-
const RECOVERY_MAX_FAILURES = 5;
|
|
67
|
-
const STALL_MAX_FAILURES = 3;
|
|
68
|
-
const SERVER_TOOL_TIMEOUT_MAX_FAILURES = 2;
|
|
69
|
-
const DEFAULT_WRAP_UP_INSTRUCTION =
|
|
70
|
-
"Finish this request now without using tools. Summarize the completed work and any remaining limitation.";
|
|
71
|
-
const TOOL_FAILURE_WRAP_UP_INSTRUCTION =
|
|
72
|
-
"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.";
|
|
73
|
-
const MAX_TOOL_RETRIES = 3;
|
|
74
|
-
const TURN_FAILURE_CEILING = 8;
|
|
75
|
-
const ARTIFACT_MANIFEST_MAX_CHARS = 12 * 1024;
|
|
76
|
-
|
|
77
|
-
function canonicalJson(value: unknown): string {
|
|
78
|
-
if (Array.isArray(value)) {
|
|
79
|
-
return `[${value.map(canonicalJson).join(",")}]`;
|
|
80
|
-
}
|
|
81
|
-
if (value !== null && typeof value === "object") {
|
|
82
|
-
return `{${Object.entries(value)
|
|
83
|
-
.filter(([, item]) => item !== undefined)
|
|
84
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
85
|
-
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
|
|
86
|
-
.join(",")}}`;
|
|
87
|
-
}
|
|
88
|
-
return JSON.stringify(value) ?? "null";
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function toolCallKey(toolName: string, args: unknown): string {
|
|
92
|
-
return `${toolName}\0${canonicalJson(args)}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function uniqueToolNames(names: readonly string[]): string[] {
|
|
96
|
-
return [...new Set(names)];
|
|
31
|
+
modelToolResultExemptions?: readonly string[];
|
|
32
|
+
toolsToActivate?: (name: string, result: import("@earendil-works/pi-agent-core").AgentToolResult<unknown>, isError: boolean) => readonly string[];
|
|
33
|
+
compaction?: Partial<CompactionPolicy>;
|
|
34
|
+
streamOptions?: { maxRetries?: number };
|
|
35
|
+
retry?: Partial<ConversationRetryPolicy>;
|
|
36
|
+
toolExecution?: "parallel" | "sequential";
|
|
37
|
+
limits?: SessionHarnessLimits;
|
|
38
|
+
beforeTool?: ToolHooks["beforeTool"];
|
|
39
|
+
afterResponse?: GenerationHooks["afterResponse"];
|
|
97
40
|
}
|
|
98
41
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const projected = messages.map((message) => {
|
|
105
|
-
if (
|
|
106
|
-
message.role !== "toolResult" ||
|
|
107
|
-
exemptions.has(message.toolName)
|
|
108
|
-
) return message;
|
|
109
|
-
const payload = { content: message.content, details: message.details };
|
|
110
|
-
const next = projectToolOutputForModel(payload) as typeof payload;
|
|
111
|
-
if (next === payload) return message;
|
|
112
|
-
changed = true;
|
|
113
|
-
return { ...message, content: next.content, details: next.details };
|
|
114
|
-
});
|
|
115
|
-
return changed ? projected : [...messages];
|
|
42
|
+
/** Host terminal projection; native Submission records remain authoritative. */
|
|
43
|
+
export interface SessionRunOutcome {
|
|
44
|
+
status: "completed" | "failed" | "aborted" | "declined";
|
|
45
|
+
endedAt: number;
|
|
46
|
+
error?: { code: string; message: string };
|
|
116
47
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return undefined;
|
|
123
|
-
}
|
|
124
|
-
const ref = message.details as Partial<ArtifactRef>;
|
|
125
|
-
if (typeof ref.path !== "string" || typeof ref.hash !== "string" ||
|
|
126
|
-
typeof ref.bytes !== "number" || typeof ref.preview !== "string" ||
|
|
127
|
-
typeof ref.note !== "string") return undefined;
|
|
48
|
+
export type SessionHarnessRunResult =
|
|
49
|
+
| { kind: "settled"; outcome: SessionRunOutcome }
|
|
50
|
+
| { kind: "released"; operationId: string; reason: "slice" | "approval" | "interaction" };
|
|
51
|
+
export interface SessionRunRequest { operationId: string; prompt?: UserMessage | string; }
|
|
52
|
+
export function submissionOutcome(record: SettledSubmissionRecord): SessionRunOutcome {
|
|
128
53
|
return {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
: `artifact_${ref.hash.slice(0, 24)}`,
|
|
133
|
-
path: ref.path,
|
|
134
|
-
bytes: ref.bytes,
|
|
135
|
-
hash: ref.hash,
|
|
136
|
-
workspaceRevision: typeof ref.workspaceRevision === "string"
|
|
137
|
-
? ref.workspaceRevision
|
|
138
|
-
: "unknown",
|
|
139
|
-
availability: ref.availability === "unavailable"
|
|
140
|
-
? "unavailable"
|
|
141
|
-
: "available",
|
|
142
|
-
preview: ref.preview,
|
|
143
|
-
note: ref.note,
|
|
54
|
+
status: record.status === "done" ? "completed" : record.reason === "aborted" ? "aborted" : "failed",
|
|
55
|
+
endedAt: Date.now(),
|
|
56
|
+
...(record.status === "unanswered" ? { error: { code: record.reason, message: typeof record.detail === "string" ? record.detail : record.reason } } : {}),
|
|
144
57
|
};
|
|
145
58
|
}
|
|
146
59
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"<workspace_artifact_manifest>",
|
|
153
|
-
"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.",
|
|
154
|
-
];
|
|
155
|
-
let omitted = 0;
|
|
156
|
-
for (const ref of refs.values()) {
|
|
157
|
-
const preview = ref.preview.replace(/[\r\n]+/g, " ").slice(0, 160);
|
|
158
|
-
const line = [
|
|
159
|
-
`path=${ref.path}`,
|
|
160
|
-
...(ref.availability === "unavailable" ? ["unavailable"] : []),
|
|
161
|
-
`preview=${preview}`,
|
|
162
|
-
].join(" ");
|
|
163
|
-
if ([...lines, line, "</workspace_artifact_manifest>"].join("\n").length > ARTIFACT_MANIFEST_MAX_CHARS) {
|
|
164
|
-
omitted += 1;
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
lines.push(line);
|
|
168
|
-
}
|
|
169
|
-
if (omitted > 0) lines.push(`omitted=${omitted} additional artifact references due to manifest budget`);
|
|
170
|
-
lines.push("</workspace_artifact_manifest>");
|
|
171
|
-
return {
|
|
172
|
-
role: "user",
|
|
173
|
-
content: lines.join("\n"),
|
|
174
|
-
timestamp: 1,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function deferred<T>(): {
|
|
179
|
-
promise: Promise<T>;
|
|
180
|
-
resolve(value: T): void;
|
|
181
|
-
} {
|
|
182
|
-
let resolve!: (value: T) => void;
|
|
183
|
-
const promise = new Promise<T>((done) => {
|
|
184
|
-
resolve = done;
|
|
185
|
-
});
|
|
186
|
-
return { promise, resolve };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function positiveInteger(value: number | undefined, fallback: number): number {
|
|
190
|
-
const resolved = value ?? fallback;
|
|
191
|
-
if (!Number.isInteger(resolved) || resolved <= 0) {
|
|
192
|
-
throw new Error("Harness model turn limits must be positive integers");
|
|
193
|
-
}
|
|
194
|
-
return resolved;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* The only adapter that owns a live Pi AgentHarness for a Session.
|
|
199
|
-
*
|
|
200
|
-
* Submission and Cloudflare lifecycle code may accept, drive, abort, watch, or
|
|
201
|
-
* close through this object. It deliberately does not know how Host config or
|
|
202
|
-
* business repositories are loaded.
|
|
203
|
-
*/
|
|
204
|
-
export class SessionHarness<TContext extends object | undefined> {
|
|
205
|
-
private _openOperation: OpenOperation | null;
|
|
206
|
-
private readonly modelTurnsPerSlice: number;
|
|
207
|
-
private readonly maxModelTurns: number;
|
|
208
|
-
private readonly maxWrapUpModelTurns: number;
|
|
209
|
-
private readonly modelSessionId: string | undefined;
|
|
210
|
-
private readonly wrapUpInstruction: string;
|
|
211
|
-
private readonly configuredToolNames: string[];
|
|
212
|
-
private readonly compiledToolNames: ReadonlySet<string>;
|
|
213
|
-
private readonly onDemandToolNames: ReadonlySet<string>;
|
|
214
|
-
private readonly toolsToActivate:
|
|
215
|
-
| ((
|
|
216
|
-
toolName: string,
|
|
217
|
-
result: AgentToolResult<unknown>,
|
|
218
|
-
isError: boolean,
|
|
219
|
-
) => readonly string[])
|
|
220
|
-
| undefined;
|
|
221
|
-
private readonly modelToolResultExemptions: ReadonlySet<string>;
|
|
222
|
-
private readonly toolFailures = new Map<string, number>();
|
|
223
|
-
private totalToolFailures = 0;
|
|
224
|
-
private toolFailureWrapUp = false;
|
|
225
|
-
private toolFailureWrapUpAt: number | undefined;
|
|
226
|
-
private operationStartTip: string | null = null;
|
|
60
|
+
/** The host binds its ports; Pi owns generation, Tool tasks, waiting and recovery. */
|
|
61
|
+
export class SessionHarness {
|
|
62
|
+
private submission?: Submission;
|
|
63
|
+
private requestId?: string;
|
|
64
|
+
private release!: (reason: "approval" | "interaction" | "slice") => void;
|
|
227
65
|
private turnsAtOpen = 0;
|
|
228
|
-
private
|
|
229
|
-
private releaseReason:
|
|
230
|
-
| "slice"
|
|
231
|
-
| "budget"
|
|
232
|
-
| "tool-failure"
|
|
233
|
-
| "approval"
|
|
234
|
-
| "interaction"
|
|
235
|
-
| undefined;
|
|
236
|
-
private readonly releaseRequest = deferred<"approval" | "interaction">();
|
|
237
|
-
private readonly activeTools = new Set<string>();
|
|
238
|
-
private readonly toolsIdle = new Set<() => void>();
|
|
239
|
-
private readonly artifactRefs = new Map<string, ArtifactRef>();
|
|
240
|
-
|
|
66
|
+
private readonly released = new Promise<"approval" | "interaction" | "slice">((resolve) => { this.release = resolve; });
|
|
241
67
|
private constructor(
|
|
242
|
-
readonly harness:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
options: SessionHarnessOptions
|
|
246
|
-
) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
68
|
+
readonly harness: NativeHarness,
|
|
69
|
+
public conversation: Conversation,
|
|
70
|
+
readonly storage: Storage,
|
|
71
|
+
private readonly options: SessionHarnessOptions,
|
|
72
|
+
) {}
|
|
73
|
+
|
|
74
|
+
get openOperation(): { operationId: string } | null {
|
|
75
|
+
return this.submission && this.requestId ? { operationId: this.requestId } : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static async open(options: SessionHarnessOptions, context: Context): Promise<SessionHarness> {
|
|
79
|
+
const storage = await options.storage;
|
|
80
|
+
const registry = createRegistry<ToolRegistration<any, any>>();
|
|
81
|
+
let runtime!: SessionHarness;
|
|
82
|
+
const exemptions = new Set(options.modelToolResultExemptions ?? []);
|
|
83
|
+
const extension = defineExtension({
|
|
84
|
+
name: "springbrand.runtime", tools: options.tools ?? [],
|
|
85
|
+
sections: [section("system", () => options.systemPrompt, { tag: false })],
|
|
86
|
+
hooks: [
|
|
87
|
+
hook(GenerationTask, {
|
|
88
|
+
beforeRequest: ({ messages }) => ({ messages: messages.map((message): Message => {
|
|
89
|
+
if (message.role !== "toolResult" || exemptions.has(message.toolName)) return message;
|
|
90
|
+
const next = projectToolOutputForModel({ content: message.content, details: message.details }) as Pick<typeof message, "content" | "details">;
|
|
91
|
+
return { ...message, ...next };
|
|
92
|
+
}) }),
|
|
93
|
+
afterResponse: options.afterResponse,
|
|
94
|
+
afterTools: async (_assistant, _results, _api, hookContext) => {
|
|
95
|
+
const entries = await runtime.entries(hookContext);
|
|
96
|
+
const receipt = await runtime.submission?.status(hookContext);
|
|
97
|
+
const owned = receipt?.entry ? entries.filter((entry) => entry.id >= receipt.entry!) : entries;
|
|
98
|
+
const turns = owned.filter((entry) => entry.kind === "pi.assistant").length;
|
|
99
|
+
const total = entries.filter((entry) => entry.kind === "pi.assistant").length;
|
|
100
|
+
if (total - runtime.turnsAtOpen >= (options.limits?.modelTurnsPerSlice ?? 30)) runtime.release("slice");
|
|
101
|
+
if (turns >= (options.limits?.maxModelTurns ?? 300)) await runtime.conversation.configure({ tools: [], instructions: options.limits?.wrapUpInstruction ?? "Finish this request now without using tools. Summarize the completed work and any remaining limitation." }, hookContext);
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
hook(ToolTask, {
|
|
105
|
+
beforeTool: options.beforeTool,
|
|
106
|
+
afterTool: async (call, result) => {
|
|
107
|
+
const names = options.toolsToActivate?.(call.name, { content: [...result.content ?? []], details: result.details }, result.isError === true) ?? [];
|
|
108
|
+
return names.length ? { ...result, control: { ...result.control, addTools: names } } : undefined;
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
registry.install(extension);
|
|
114
|
+
const harness = await Harness.open(storage, {
|
|
115
|
+
models: options.models, registry,
|
|
116
|
+
settings: { stream: options.streamOptions, compaction: options.compaction, toolExecution: options.toolExecution, retry: options.retry ?? { maxRetries: 4 } },
|
|
279
117
|
}, context);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const message = entry.message;
|
|
292
|
-
if (message.role === "assistant") {
|
|
293
|
-
turns += 1;
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
this.recordCanonicalToolOutcome(entry);
|
|
297
|
-
if (this.toolFailureWrapUp) this.toolFailureWrapUpAt ??= turns;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private recordCanonicalToolOutcome(entry: Entry): void {
|
|
302
|
-
if (entry.type !== "message" || entry.message.role !== "toolResult") return;
|
|
303
|
-
const message = entry.message;
|
|
304
|
-
if (entry.toolExecution?.status === "executed") {
|
|
305
|
-
this.recordToolOutcome(message.toolName, entry.toolExecution.args, message.isError);
|
|
306
|
-
} else if (entry.toolExecution?.status === "blocked" && entry.toolExecution.args !== undefined) {
|
|
307
|
-
const key = toolCallKey(message.toolName, entry.toolExecution.args);
|
|
308
|
-
if ((this.toolFailures.get(key) ?? 0) >= MAX_TOOL_RETRIES) this.toolFailureWrapUp = true;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private recordToolOutcome(toolName: string, args: unknown, isError: boolean): void {
|
|
313
|
-
const key = toolCallKey(toolName, args);
|
|
314
|
-
if (!isError) {
|
|
315
|
-
this.toolFailures.delete(key);
|
|
316
|
-
return;
|
|
118
|
+
const pointer = await harness.snapshot(RuntimeConversation, context);
|
|
119
|
+
const conversation = pointer?.id ? await harness.conversation(pointer.id, context) : await harness.root(context);
|
|
120
|
+
if (!conversation) { await harness.close(context); throw new Error("The persisted Pi conversation is missing"); }
|
|
121
|
+
if (!pointer?.id) await harness.commit(async (tx) => { (await tx.doc(RuntimeConversation)).id = conversation.id; }, context);
|
|
122
|
+
runtime = new SessionHarness(harness, conversation, storage, options);
|
|
123
|
+
runtime.turnsAtOpen = (await runtime.entries(context)).filter((entry) => entry.kind === "pi.assistant").length;
|
|
124
|
+
const unfinished = (await harness.inspect(context)).submissions.filter((record) => record.conversationId === conversation.id && record.type === "input" && !record.requestId?.startsWith("steer:") && (record.status === "placed" || record.status === "queued"));
|
|
125
|
+
if (unfinished.length > 1) { await harness.close(context); throw new Error("Multiple host runs are active in one Pi conversation"); }
|
|
126
|
+
if (unfinished[0]) {
|
|
127
|
+
runtime.submission = await harness.submission(unfinished[0].id, context);
|
|
128
|
+
runtime.requestId = unfinished[0].requestId;
|
|
317
129
|
}
|
|
318
|
-
|
|
319
|
-
this.totalToolFailures += 1;
|
|
320
|
-
if (this.totalToolFailures >= TURN_FAILURE_CEILING) this.toolFailureWrapUp = true;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private async shouldRetry(
|
|
324
|
-
message: SettledAssistantMessage,
|
|
325
|
-
context: Context,
|
|
326
|
-
recovery: boolean,
|
|
327
|
-
): Promise<boolean> {
|
|
328
|
-
const stalled = isModelStreamStallMessage(message.errorMessage);
|
|
329
|
-
const serverToolTimeout = isOpenRouterServerToolTimeout(message);
|
|
330
|
-
if (!recovery && !stalled && !serverToolTimeout && !isRecoverableAssistantError(message)) return false;
|
|
331
|
-
const failures = 1 + (await this.operationEntries(context)).filter((entry) =>
|
|
332
|
-
entry.type === "message" && entry.message.role === "assistant" && entry.message.stopReason === "error"
|
|
333
|
-
).length;
|
|
334
|
-
return failures < (stalled ? STALL_MAX_FAILURES
|
|
335
|
-
: serverToolTimeout ? SERVER_TOOL_TIMEOUT_MAX_FAILURES : RECOVERY_MAX_FAILURES);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
private async shouldFinishRun(context: Context): Promise<boolean> {
|
|
339
|
-
const wrapUpAt = Math.min(this.maxModelTurns, this.toolFailureWrapUpAt ?? Infinity);
|
|
340
|
-
return await this.completedModelTurns(context) >= wrapUpAt + this.maxWrapUpModelTurns;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
get openOperation(): OpenOperation | null {
|
|
344
|
-
return this._openOperation;
|
|
130
|
+
return runtime;
|
|
345
131
|
}
|
|
346
132
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
): Promise<
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
133
|
+
async submissionFor(requestId: string, context: Context) {
|
|
134
|
+
return this.storage.submissionByRequest(this.conversation.id, requestId, context);
|
|
135
|
+
}
|
|
136
|
+
async entries(context: Context = BACKGROUND_CONTEXT): Promise<EntryRecord[]> {
|
|
137
|
+
const entries: EntryRecord[] = [];
|
|
138
|
+
let cursor;
|
|
139
|
+
do {
|
|
140
|
+
const page = await this.conversation.entries({ order: "ascending" }, 200, cursor, context);
|
|
141
|
+
entries.push(...page.items); cursor = page.next;
|
|
142
|
+
} while (cursor);
|
|
143
|
+
return entries;
|
|
144
|
+
}
|
|
145
|
+
watch(context: Context): Promise<AgentEventStream> { return watchEvents(this.harness, this.conversation.id, context); }
|
|
146
|
+
requestHumanRelease(reason: "approval" | "interaction"): void { this.release(reason); }
|
|
147
|
+
async fork(at: EntryId | null, context: Context): Promise<void> {
|
|
148
|
+
await this.conversation.abort(context);
|
|
149
|
+
this.submission = undefined;
|
|
150
|
+
this.requestId = undefined;
|
|
151
|
+
const created = await this.harness.commit(async (tx) => {
|
|
152
|
+
const fork = at === null ? await tx.createConversation({ ownership: { kind: "ownerless" } }) : await tx.forkConversation(this.conversation.id, at, { ownership: { kind: "ownerless" } });
|
|
153
|
+
(await tx.doc(RuntimeConversation)).id = fork.id;
|
|
154
|
+
return fork;
|
|
369
155
|
}, context);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
156
|
+
this.conversation = (await this.harness.conversation(created.id, context))!;
|
|
157
|
+
this.turnsAtOpen = (await this.entries(context)).filter((entry) => entry.kind === "pi.assistant").length;
|
|
158
|
+
}
|
|
159
|
+
async admit(request: SessionRunRequest, context: Context): Promise<void> {
|
|
160
|
+
if (this.submission && this.requestId !== request.operationId) throw new Error("A native Pi Submission is already active");
|
|
161
|
+
const existing = await this.submissionFor(request.operationId, context);
|
|
162
|
+
if (existing) {
|
|
163
|
+
this.requestId = request.operationId;
|
|
164
|
+
this.submission = await this.harness.submission(existing.id, context);
|
|
165
|
+
} else {
|
|
166
|
+
if (request.prompt === undefined) throw new Error("A new native Pi Submission needs input");
|
|
167
|
+
const names = new Set(this.options.activeToolNames ?? this.options.tools?.map((tool) => tool.name) ?? []);
|
|
168
|
+
const previous = await this.harness.snapshot(AgentDoc, this.conversation.id, context);
|
|
169
|
+
if (Array.isArray(previous?.tools)) for (const name of previous.tools) if (this.options.onDemandToolNames?.includes(name)) names.add(name);
|
|
170
|
+
await this.conversation.configure({ model: { provider: this.options.model.provider, modelId: this.options.model.id }, thinkingLevel: this.options.thinkingLevel ?? "off", tools: this.options.tools?.filter((tool) => names.has(tool.name)) ?? [] }, context);
|
|
171
|
+
this.requestId = request.operationId;
|
|
172
|
+
this.submission = await this.conversation.submit({ type: "input", requestId: request.operationId, content: typeof request.prompt === "string" ? request.prompt : request.prompt.content, whenBusy: "reject" }, context);
|
|
378
173
|
}
|
|
379
|
-
runtime = new SessionHarness(
|
|
380
|
-
harness,
|
|
381
|
-
await harness.lane("main", context),
|
|
382
|
-
open[0] ?? null,
|
|
383
|
-
options,
|
|
384
|
-
);
|
|
385
|
-
await runtime.install(context);
|
|
386
|
-
return runtime;
|
|
387
174
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
if (this.wrappingUp || this.toolFailureWrapUp) {
|
|
406
|
-
await this.lane.setActiveTools([], context);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
this.harness.hooks.on("before_request", async (event, requestContext) => {
|
|
410
|
-
if (event.step !== "assistant") return;
|
|
411
|
-
const resets = (await this.operationEntries(requestContext)).filter((entry) =>
|
|
412
|
-
entry.type === "message" && entry.message.role === "assistant" && isOpenRouterServerToolTimeout(entry.message)
|
|
413
|
-
).length;
|
|
414
|
-
if (resets > 0) {
|
|
415
|
-
return { streamOptions: { sessionId: `${this.modelSessionId ?? event.runId}:server-tool-recovery:${resets}` } };
|
|
416
|
-
}
|
|
417
|
-
if (this.modelSessionId !== undefined) return { streamOptions: { sessionId: this.modelSessionId } };
|
|
418
|
-
});
|
|
419
|
-
this.harness.hooks.on("transform_context", (event) => {
|
|
420
|
-
const messages = projectToolResultsForModel(
|
|
421
|
-
event.messages,
|
|
422
|
-
this.modelToolResultExemptions,
|
|
423
|
-
);
|
|
424
|
-
const manifest = artifactManifestMessage(this.artifactRefs);
|
|
425
|
-
const instruction = this.toolFailureWrapUp
|
|
426
|
-
? TOOL_FAILURE_WRAP_UP_INSTRUCTION
|
|
427
|
-
: this.wrappingUp
|
|
428
|
-
? this.wrapUpInstruction
|
|
429
|
-
: undefined;
|
|
430
|
-
const contextMessages = manifest ? [...messages, manifest] : messages;
|
|
431
|
-
return {
|
|
432
|
-
messages: instruction
|
|
433
|
-
? [...contextMessages, {
|
|
434
|
-
role: "user" as const,
|
|
435
|
-
content: instruction,
|
|
436
|
-
timestamp: 1,
|
|
437
|
-
}]
|
|
438
|
-
: contextMessages,
|
|
439
|
-
};
|
|
440
|
-
});
|
|
441
|
-
this.harness.hooks.on("before_tool", (event) => {
|
|
442
|
-
if (this.toolFailureWrapUp) {
|
|
443
|
-
return {
|
|
444
|
-
block: {
|
|
445
|
-
reason: "This turn reached its tool failure limit. Stop calling tools now.",
|
|
446
|
-
terminate: true,
|
|
447
|
-
},
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
if (
|
|
451
|
-
(this.toolFailures.get(toolCallKey(event.toolName, event.args)) ?? 0) <
|
|
452
|
-
MAX_TOOL_RETRIES
|
|
453
|
-
) return;
|
|
454
|
-
this.toolFailureWrapUp = true;
|
|
455
|
-
return {
|
|
456
|
-
block: {
|
|
457
|
-
reason:
|
|
458
|
-
`This exact ${event.toolName} call has already failed ${MAX_TOOL_RETRIES} times — change the input or approach instead of retrying it verbatim.`,
|
|
459
|
-
},
|
|
460
|
-
};
|
|
461
|
-
});
|
|
462
|
-
this.harness.events.on("entry_added", (event) => {
|
|
463
|
-
if (event.lane !== this.lane.name) return;
|
|
464
|
-
this.recordCanonicalToolOutcome(event.entry);
|
|
465
|
-
if (event.entry.type === "message") {
|
|
466
|
-
const ref = artifactRefFromMessage(event.entry.message);
|
|
467
|
-
if (ref) this.artifactRefs.set(ref.artifactId, ref);
|
|
468
|
-
}
|
|
469
|
-
});
|
|
470
|
-
this.harness.events.on("turn_end", async (_event, eventContext) => {
|
|
471
|
-
const total = await this.completedModelTurns(eventContext);
|
|
472
|
-
if (this.toolFailureWrapUp && this.toolFailureWrapUpAt === undefined) {
|
|
473
|
-
this.toolFailureWrapUpAt = total;
|
|
474
|
-
this.releaseReason = "tool-failure";
|
|
475
|
-
} else if (this.wrappingUp || this.toolFailureWrapUp) {
|
|
476
|
-
return;
|
|
477
|
-
} else if (total >= this.maxModelTurns) {
|
|
478
|
-
this.releaseReason = "budget";
|
|
479
|
-
} else if (total - this.turnsAtOpen >= this.modelTurnsPerSlice) {
|
|
480
|
-
this.releaseReason = "slice";
|
|
481
|
-
} else {
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
await this.harness.close(eventContext);
|
|
485
|
-
});
|
|
486
|
-
this.harness.hooks.on("after_tool", async (event, eventContext) => {
|
|
487
|
-
await this.activateToolsFromResult({
|
|
488
|
-
toolName: event.toolName,
|
|
489
|
-
result: {
|
|
490
|
-
content: event.content,
|
|
491
|
-
details: event.details,
|
|
492
|
-
},
|
|
493
|
-
isError: event.isError,
|
|
494
|
-
}, eventContext);
|
|
495
|
-
return undefined;
|
|
496
|
-
});
|
|
497
|
-
this.harness.events.on("tool_start", (event) => {
|
|
498
|
-
this.activeTools.add(event.toolCallId);
|
|
499
|
-
});
|
|
500
|
-
this.harness.events.on("tool_end", (event) => {
|
|
501
|
-
this.activeTools.delete(event.toolCallId);
|
|
502
|
-
if (this.activeTools.size === 0) {
|
|
503
|
-
for (const resolve of this.toolsIdle) resolve();
|
|
504
|
-
this.toolsIdle.clear();
|
|
505
|
-
}
|
|
175
|
+
async run(request: SessionRunRequest | undefined, context: Context): Promise<SessionHarnessRunResult> {
|
|
176
|
+
if (request) await this.admit(request, context);
|
|
177
|
+
if (!this.submission || !this.requestId) throw new Error("No native Pi Submission to resume");
|
|
178
|
+
const waiting = this.submission.wait(context).then(async (record) => {
|
|
179
|
+
// One input can be answered while Pi is still working on a later steer.
|
|
180
|
+
await this.conversation.waitForIdle(context);
|
|
181
|
+
let cursor;
|
|
182
|
+
do {
|
|
183
|
+
const page = await this.storage.scanSubmissions({ conversationId: this.conversation.id, order: "descending" }, 200, cursor, context);
|
|
184
|
+
for (const next of page.items) {
|
|
185
|
+
if (next.id <= record.id) return record;
|
|
186
|
+
if (next.type === "input" && next.requestId?.startsWith("steer:") && (next.status === "done" || next.status === "unanswered")) return next as SettledSubmissionRecord;
|
|
187
|
+
}
|
|
188
|
+
cursor = page.next;
|
|
189
|
+
} while (cursor);
|
|
190
|
+
return record;
|
|
506
191
|
});
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
private async waitForToolsToSettle(): Promise<void> {
|
|
515
|
-
if (this.activeTools.size === 0) return;
|
|
516
|
-
await new Promise<void>((resolve) => this.toolsIdle.add(resolve));
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
private async activeToolsForNewOperation(context: Context): Promise<string[]> {
|
|
520
|
-
const previous = await this.lane.getActiveTools(context);
|
|
521
|
-
const keepOnDemand = previous.filter((name) =>
|
|
522
|
-
this.onDemandToolNames.has(name) && this.compiledToolNames.has(name)
|
|
523
|
-
);
|
|
524
|
-
return uniqueToolNames([...this.configuredToolNames, ...keepOnDemand]);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
private async activateToolsFromResult(
|
|
528
|
-
event: {
|
|
529
|
-
toolName: string;
|
|
530
|
-
result: AgentToolResult<unknown>;
|
|
531
|
-
isError: boolean;
|
|
532
|
-
},
|
|
533
|
-
context: Context,
|
|
534
|
-
): Promise<void> {
|
|
535
|
-
const requested = this.toolsToActivate?.(
|
|
536
|
-
event.toolName,
|
|
537
|
-
event.result,
|
|
538
|
-
event.isError,
|
|
539
|
-
) ?? [];
|
|
540
|
-
const allowed = requested.filter((name) => this.compiledToolNames.has(name));
|
|
541
|
-
if (allowed.length === 0) return;
|
|
542
|
-
const current = await this.lane.getActiveTools(context);
|
|
543
|
-
if (allowed.every((name) => current.includes(name))) return;
|
|
544
|
-
await this.lane.setActiveTools(uniqueToolNames([...current, ...allowed]), context);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
private async completedModelTurns(context: Context): Promise<number> {
|
|
548
|
-
return (await this.operationEntries(context)).filter((entry) =>
|
|
549
|
-
entry.type === "message" && entry.message.role === "assistant"
|
|
550
|
-
).length;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
async run(
|
|
554
|
-
request: OperationRequest | undefined,
|
|
555
|
-
context: Context,
|
|
556
|
-
driveOptions: Omit<DriveOptions, "operationId"> = {},
|
|
557
|
-
): Promise<SessionHarnessRunResult> {
|
|
558
|
-
let operationId = this.openOperation?.operationId;
|
|
559
|
-
if (request) {
|
|
560
|
-
if (operationId) {
|
|
561
|
-
throw new Error(`Harness operation ${operationId} is already open`);
|
|
562
|
-
}
|
|
563
|
-
this.operationStartTip = await this.lane.getTipId(context);
|
|
564
|
-
this.turnsAtOpen = 0;
|
|
565
|
-
this.wrappingUp = false;
|
|
566
|
-
this.toolFailures.clear();
|
|
567
|
-
this.totalToolFailures = 0;
|
|
568
|
-
this.toolFailureWrapUp = false;
|
|
569
|
-
this.toolFailureWrapUpAt = undefined;
|
|
570
|
-
this.releaseReason = undefined;
|
|
571
|
-
await this.lane.setActiveTools(
|
|
572
|
-
await this.activeToolsForNewOperation(context),
|
|
573
|
-
context,
|
|
574
|
-
);
|
|
575
|
-
const admission = await this.lane.accept(request, context);
|
|
576
|
-
if (!admission.ok) throw admission.error;
|
|
577
|
-
operationId = admission.value.operationId;
|
|
578
|
-
this._openOperation = {
|
|
579
|
-
lane: "main",
|
|
580
|
-
operationId,
|
|
581
|
-
kind: admission.value.kind,
|
|
582
|
-
startedAt: admission.value.startedAt,
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
if (!operationId) throw new Error("No Harness operation to drive");
|
|
586
|
-
try {
|
|
587
|
-
const driving = this.lane.drive({ ...driveOptions, operationId }, context);
|
|
588
|
-
const winner = await Promise.race([
|
|
589
|
-
driving.then((result) => ({ kind: "drive" as const, result })),
|
|
590
|
-
this.releaseRequest.promise.then((reason) => ({
|
|
591
|
-
kind: "release" as const,
|
|
592
|
-
reason,
|
|
593
|
-
})),
|
|
594
|
-
]);
|
|
595
|
-
if (winner.kind === "release") {
|
|
596
|
-
this.releaseReason = winner.reason;
|
|
597
|
-
await this.waitForToolsToSettle();
|
|
598
|
-
await this.harness.close(context);
|
|
599
|
-
await driving.catch(() => undefined);
|
|
600
|
-
return { kind: "released", operationId, reason: winner.reason };
|
|
601
|
-
}
|
|
602
|
-
const result = winner.result;
|
|
603
|
-
if (!result.ok) throw result.error;
|
|
604
|
-
if (result.value.kind === "settled") {
|
|
605
|
-
this._openOperation = null;
|
|
606
|
-
}
|
|
607
|
-
return result.value;
|
|
608
|
-
} catch (error) {
|
|
609
|
-
if (
|
|
610
|
-
this.releaseReason && error instanceof Error &&
|
|
611
|
-
error.name === "HarnessClosed"
|
|
612
|
-
) {
|
|
613
|
-
return { kind: "released", operationId, reason: this.releaseReason };
|
|
614
|
-
}
|
|
615
|
-
throw error;
|
|
192
|
+
waiting.catch(() => {});
|
|
193
|
+
const result = await Promise.race([waiting.then((record) => ({ record })), this.released.then((reason) => ({ reason }))]);
|
|
194
|
+
if ("record" in result) {
|
|
195
|
+
this.submission = undefined;
|
|
196
|
+
return { kind: "settled", outcome: submissionOutcome(result.record) };
|
|
616
197
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
close(context: Context): Promise<void> {
|
|
626
|
-
return this.harness.close(context);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
export function openSessionHarness<TContext extends object | undefined>(
|
|
631
|
-
options: SessionHarnessOptions<TContext>,
|
|
632
|
-
context: Context,
|
|
633
|
-
): Promise<SessionHarness<TContext>> {
|
|
634
|
-
return SessionHarness.open(options, context);
|
|
198
|
+
// A pending approval is before intent. Let already-started parallel effects commit before detaching.
|
|
199
|
+
const executing = (await this.harness.inspect(context)).tasks.filter(({ record }) => record.kind === "pi.tool" && record.state.status === "running" && (record.state.checkpoint as { phase?: string }).phase === "execute");
|
|
200
|
+
await Promise.all(executing.map(({ record }) => this.harness.waitForTask(record.id, context)));
|
|
201
|
+
await this.harness.close(BACKGROUND_CONTEXT);
|
|
202
|
+
return { kind: "released", operationId: this.requestId, reason: result.reason };
|
|
203
|
+
}
|
|
204
|
+
async abort(context: Context): Promise<void> { await this.conversation.abort(context); }
|
|
205
|
+
async close(context: Context): Promise<void> { await this.harness.close(context); }
|
|
635
206
|
}
|
|
207
|
+
export const openSessionHarness = SessionHarness.open;
|