@mono-agent/agent-runtime 0.3.0 → 0.4.0
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/ARCHITECTURE.md +48 -3
- package/MIGRATION.md +115 -0
- package/README.md +24 -8
- package/package.json +3 -2
- package/src/agent/compaction.js +11 -677
- package/src/agent/index.js +1 -1
- package/src/agent/tools/pi-bridge.js +46 -10
- package/src/agent/tools/web-fetch.js +48 -12
- package/src/ai/failure.js +1 -1
- package/src/ai/index.js +1 -0
- package/src/ai/providers/claude-cli.js +5 -1
- package/src/ai/providers/pi-errors.js +65 -0
- package/src/ai/providers/pi-native.js +1445 -0
- package/src/ai/providers/pi-sdk.js +26 -1301
- package/src/ai/runtime/capabilities.js +3 -0
- package/src/ai/runtime/registry.js +4 -2
- package/src/ai/runtime/router.js +23 -7
- package/src/ai/runtime/sessions.js +3 -2
- package/src/ai/types.js +0 -1
- package/src/runtime.js +3 -2
|
@@ -0,0 +1,1445 @@
|
|
|
1
|
+
// Pi-NATIVE runtime bridge.
|
|
2
|
+
//
|
|
3
|
+
// This is the SOLE pi runtime path: the hand-rolled pi bridge (formerly
|
|
4
|
+
// pi-sdk.js, driving the low-level `Agent` with manual MCP init, transcript
|
|
5
|
+
// handling, compaction, a hand-rolled stream-retry loop, and session
|
|
6
|
+
// bookkeeping) was removed once this bridge reached parity. This bridge builds
|
|
7
|
+
// on pi-agent-core's high-level AgentHarness plus native primitives:
|
|
8
|
+
//
|
|
9
|
+
// * AgentHarness OWNS a session and performs durable writes itself, so resume
|
|
10
|
+
// is "open the session from a repo and hand it to a new harness". There is
|
|
11
|
+
// no separate live-session registry here.
|
|
12
|
+
// * The provider transport (pi-ai streamSimple) is invoked by the harness;
|
|
13
|
+
// retry/backoff is delegated to pi-ai via streamOptions.maxRetries instead
|
|
14
|
+
// of the legacy manual loop.
|
|
15
|
+
// * Tool sandboxing, approval gates, allowlist/bloat filtering, and the MCP
|
|
16
|
+
// tool bridge are reused shared pieces — they are wired into the harness
|
|
17
|
+
// via its `tools` option, never reimplemented.
|
|
18
|
+
//
|
|
19
|
+
// The result/event contract is the package's unified runtime-result shape, so
|
|
20
|
+
// callers and the test suite see the same artifact the retired bridge produced.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
AgentHarness,
|
|
24
|
+
InMemorySessionRepo,
|
|
25
|
+
JsonlSessionRepo,
|
|
26
|
+
calculateContextTokens,
|
|
27
|
+
estimateTokens,
|
|
28
|
+
getLastAssistantUsage,
|
|
29
|
+
} from "@earendil-works/pi-agent-core";
|
|
30
|
+
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import { estimateCost } from "../cost.js";
|
|
33
|
+
import { retryableProviderFailureInfo } from "../failure.js";
|
|
34
|
+
import { runtimeCapabilities } from "../runtime/capabilities.js";
|
|
35
|
+
import { createSessionRegistry } from "../runtime/sessions.js";
|
|
36
|
+
import { formatLiveInputGuidance } from "../live-input-prompt.js";
|
|
37
|
+
import { isLikelyContextTermination, resolveAgentCompactionPolicy } from "../../agent/compaction.js";
|
|
38
|
+
import {
|
|
39
|
+
closePiMcpClients,
|
|
40
|
+
createStructuredOutputTool,
|
|
41
|
+
getPiBuiltinTools,
|
|
42
|
+
initPiMcpTools,
|
|
43
|
+
} from "../../agent/tools/pi-bridge.js";
|
|
44
|
+
import { createApprovalManager } from "../../agent/approval.js";
|
|
45
|
+
import { buildCapabilitiesUsed, toolCompactionAppliedFromWarnings } from "../runtime/capabilities-used.js";
|
|
46
|
+
import { resolvePiRuntimeModel } from "./pi-models.js";
|
|
47
|
+
import {
|
|
48
|
+
textFromContent,
|
|
49
|
+
thinkingFromContent,
|
|
50
|
+
toAgentMessages,
|
|
51
|
+
toolResultContent,
|
|
52
|
+
} from "./pi-messages.js";
|
|
53
|
+
import {
|
|
54
|
+
compactToolRawResult,
|
|
55
|
+
emitCaptured,
|
|
56
|
+
eventToolArgs,
|
|
57
|
+
jsonSerializable,
|
|
58
|
+
streamContentKey,
|
|
59
|
+
} from "./pi-events.js";
|
|
60
|
+
import {
|
|
61
|
+
isContextLimitError,
|
|
62
|
+
normalizePiErrorMessage,
|
|
63
|
+
parseContextLimitFromError,
|
|
64
|
+
} from "./pi-errors.js";
|
|
65
|
+
|
|
66
|
+
function usageFromMessages(messages = []) {
|
|
67
|
+
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
68
|
+
for (const message of messages) {
|
|
69
|
+
if (message?.role !== "assistant") continue;
|
|
70
|
+
const next = message.usage || {};
|
|
71
|
+
usage.input += Number(next.input) || 0;
|
|
72
|
+
usage.output += Number(next.output) || 0;
|
|
73
|
+
usage.cacheRead += Number(next.cacheRead) || 0;
|
|
74
|
+
usage.cacheWrite += Number(next.cacheWrite) || 0;
|
|
75
|
+
usage.cost += Number(next.cost?.total) || 0;
|
|
76
|
+
}
|
|
77
|
+
return usage;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function thinkingLevelForEffort(effort, capabilities) {
|
|
81
|
+
if (!capabilities?.reasoning || capabilities.reasoning_mode === "none") return "off";
|
|
82
|
+
if (effort === "none") return "off";
|
|
83
|
+
if (effort === "max") return "xhigh";
|
|
84
|
+
if (effort === "xhigh") return "xhigh";
|
|
85
|
+
if (effort === "high") return "high";
|
|
86
|
+
if (effort === "medium") return "medium";
|
|
87
|
+
return "low";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function appendStructuredOutputInstruction(systemPrompt, outputSchema) {
|
|
91
|
+
if (!outputSchema) return systemPrompt;
|
|
92
|
+
return [
|
|
93
|
+
systemPrompt,
|
|
94
|
+
"",
|
|
95
|
+
"Structured output is available through the `StructuredOutput` tool.",
|
|
96
|
+
"When the final result is ready, call `StructuredOutput` with the complete JSON object matching the requested schema.",
|
|
97
|
+
"Do not also print the same JSON as prose unless tool calling is unavailable.",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toolStartProgressText(toolName) {
|
|
102
|
+
if (typeof toolName !== "string" || toolName.trim().length === 0) return null;
|
|
103
|
+
return `Running ${toolName}...`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function structuredOutputFinalizationPrompt() {
|
|
107
|
+
return [
|
|
108
|
+
"The previous assistant turn ended without submitting the required structured result.",
|
|
109
|
+
"Do not run tools, inspect files, or redo work.",
|
|
110
|
+
"Call only `StructuredOutput` once with the final object matching the requested schema, based on the completed transcript above.",
|
|
111
|
+
"Do not print prose before or after the tool call.",
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function shouldRetryStructuredOutputFinalization({
|
|
116
|
+
outputSchema,
|
|
117
|
+
structuredResult,
|
|
118
|
+
finalText,
|
|
119
|
+
stopReason,
|
|
120
|
+
externalAbort,
|
|
121
|
+
maxTurnsHit,
|
|
122
|
+
}) {
|
|
123
|
+
if (!outputSchema) return false;
|
|
124
|
+
if (structuredResult !== null && structuredResult !== undefined) return false;
|
|
125
|
+
if (String(finalText || "").trim()) return false;
|
|
126
|
+
if (externalAbort || maxTurnsHit) return false;
|
|
127
|
+
return stopReason !== "error" && stopReason !== "aborted";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function structuredOutputRetryDiagnostics(attempts, reason, failed) {
|
|
131
|
+
if (!attempts) return {};
|
|
132
|
+
return {
|
|
133
|
+
structured_output_finalization_retry_attempts: attempts,
|
|
134
|
+
structured_output_finalization_retry_reason: reason,
|
|
135
|
+
structured_output_finalization_retry_failed: !!failed,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function failureKindForPiError(message, diagnostics, { maxTurnsHit = false } = {}) {
|
|
140
|
+
if (!message) return null;
|
|
141
|
+
if (maxTurnsHit || isContextLimitError(message) || isLikelyContextTermination(message, diagnostics)) {
|
|
142
|
+
return "usage_limit";
|
|
143
|
+
}
|
|
144
|
+
return "provider_unavailable";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// AUTO-COMPACTION. pi-agent-core performs NO automatic in-loop compaction
|
|
148
|
+
// (shouldCompact/compact are exported helpers its loop never calls), so this
|
|
149
|
+
// bridge drives it: proactively before a turn when the running model's context
|
|
150
|
+
// is near the window, and reactively (compact + single re-prompt) if a turn
|
|
151
|
+
// still overflows. The window auto-tracks the model actually serving the request
|
|
152
|
+
// and self-corrects from any real ceiling stated in an overflow error.
|
|
153
|
+
|
|
154
|
+
// Per-process cache of real context-window ceilings discovered from overflow
|
|
155
|
+
// errors, keyed by model reference/id. The long-running host re-learns quickly
|
|
156
|
+
// after a restart; this just spares repeated first-overflow round-trips.
|
|
157
|
+
const discoveredContextWindows = new Map();
|
|
158
|
+
|
|
159
|
+
function modelWindowKey(harness, runtime, resolved) {
|
|
160
|
+
const live = typeof harness?.getModel === "function" ? harness.getModel() : null;
|
|
161
|
+
return resolved?.reference || runtime?.model?.id || live?.id || "unknown";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The window of the model that ACTUALLY serves this request: prefer the harness's
|
|
165
|
+
// live model (authoritative for native pi models), fall back to the resolved
|
|
166
|
+
// runtime model. Returns 0 when unknown so callers can skip the proactive trigger.
|
|
167
|
+
function liveModelContextWindow(harness, runtime) {
|
|
168
|
+
const live = typeof harness?.getModel === "function" ? harness.getModel() : null;
|
|
169
|
+
const win = Number(live?.contextWindow) || Number(runtime?.model?.contextWindow) || 0;
|
|
170
|
+
return win > 0 ? win : 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function effectiveContextWindow(harness, runtime, resolved) {
|
|
174
|
+
const declared = liveModelContextWindow(harness, runtime);
|
|
175
|
+
const discovered = discoveredContextWindows.get(modelWindowKey(harness, runtime, resolved));
|
|
176
|
+
if (Number.isFinite(discovered) && discovered > 0) {
|
|
177
|
+
return declared > 0 ? Math.min(declared, discovered) : discovered;
|
|
178
|
+
}
|
|
179
|
+
return declared;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function recordDiscoveredContextWindow(harness, runtime, resolved, limit) {
|
|
183
|
+
const n = Number(limit);
|
|
184
|
+
if (!Number.isFinite(n) || n <= 0) return;
|
|
185
|
+
const key = modelWindowKey(harness, runtime, resolved);
|
|
186
|
+
const existing = discoveredContextWindows.get(key);
|
|
187
|
+
discoveredContextWindows.set(key, Number.isFinite(existing) && existing > 0 ? Math.min(existing, n) : n);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Best-effort estimate of the current session's context size. The last assistant
|
|
191
|
+
// usage is authoritative (it reflects what the provider actually counted,
|
|
192
|
+
// including cache reads), but it can be stale/zero (e.g. seeded history), so we
|
|
193
|
+
// take the MAX of the usage-based count and a raw per-message estimate. Either
|
|
194
|
+
// being large is a reason to compact; overcounting only compacts slightly early.
|
|
195
|
+
async function estimateCurrentContextTokens(session) {
|
|
196
|
+
let usageTokens = 0;
|
|
197
|
+
let rawTokens = 0;
|
|
198
|
+
try {
|
|
199
|
+
const usage = getLastAssistantUsage(await session.getEntries());
|
|
200
|
+
if (usage) usageTokens = Number(calculateContextTokens(usage)) || 0;
|
|
201
|
+
} catch { /* ignore — fall back to the raw estimate */ }
|
|
202
|
+
try {
|
|
203
|
+
const context = await session.buildContext();
|
|
204
|
+
for (const message of context?.messages || []) rawTokens += Number(estimateTokens(message)) || 0;
|
|
205
|
+
} catch { /* ignore — usage-based estimate stands */ }
|
|
206
|
+
if (usageTokens === 0 && rawTokens === 0) return { tokens: 0, source: "unavailable" };
|
|
207
|
+
return usageTokens >= rawTokens
|
|
208
|
+
? { tokens: usageTokens, source: "usage" }
|
|
209
|
+
: { tokens: rawTokens, source: "estimate" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Run a single guarded compaction. Requires the harness idle (callers
|
|
213
|
+
// waitForIdle first). Never throws — classifies AgentHarnessError into a warning
|
|
214
|
+
// and reports back whether anything was compacted. Fires onCompactionRecorded on
|
|
215
|
+
// success so a host can persist the compaction row.
|
|
216
|
+
async function tryCompact(harness, { trigger, onEvent, runtimeWarnings, onCompactionRecorded, runId, model }) {
|
|
217
|
+
try {
|
|
218
|
+
const result = await harness.compact();
|
|
219
|
+
const tokensBefore = Number(result?.tokensBefore) || null;
|
|
220
|
+
onEvent?.({
|
|
221
|
+
type: "runtime_warning",
|
|
222
|
+
warning_kind: "context_compaction_applied",
|
|
223
|
+
source: "pi",
|
|
224
|
+
trigger,
|
|
225
|
+
tokens_before: tokensBefore,
|
|
226
|
+
});
|
|
227
|
+
if (typeof onCompactionRecorded === "function") {
|
|
228
|
+
try {
|
|
229
|
+
onCompactionRecorded({
|
|
230
|
+
task_run_id: runId || null,
|
|
231
|
+
trigger,
|
|
232
|
+
provider_kind: "pi",
|
|
233
|
+
model: model || null,
|
|
234
|
+
tokens_before: tokensBefore,
|
|
235
|
+
summary: result?.summary || "",
|
|
236
|
+
first_kept_entry_id: result?.firstKeptEntryId || null,
|
|
237
|
+
status: "succeeded",
|
|
238
|
+
created_at: Date.now(),
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
runtimeWarnings.push({
|
|
242
|
+
warning_kind: "context_compaction_record_failed",
|
|
243
|
+
source: "pi",
|
|
244
|
+
message: err?.message || String(err),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { applied: true, tokensBefore, nothingToCompact: false };
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const message = err?.message || String(err);
|
|
251
|
+
const code = err?.code;
|
|
252
|
+
const nothingToCompact = code === "compaction" && /nothing to compact/i.test(message);
|
|
253
|
+
const warningKind = nothingToCompact
|
|
254
|
+
? "context_compaction_nothing_to_compact"
|
|
255
|
+
: code === "auth"
|
|
256
|
+
? "context_compaction_auth_failed"
|
|
257
|
+
: code === "busy"
|
|
258
|
+
? "context_compaction_busy"
|
|
259
|
+
: "context_compaction_failed";
|
|
260
|
+
runtimeWarnings.push({ warning_kind: warningKind, source: "pi", trigger, message });
|
|
261
|
+
return { applied: false, tokensBefore: null, nothingToCompact };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isReactiveCompactionCandidate(errorMessage, diagnostics) {
|
|
266
|
+
if (!errorMessage) return false;
|
|
267
|
+
return isContextLimitError(errorMessage) || isLikelyContextTermination(errorMessage, diagnostics);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function resolveApiKey(provider, { apiKeys, resolvePiApiKey, runtimeWarnings }) {
|
|
271
|
+
if (apiKeys?.has(provider)) return apiKeys.get(provider);
|
|
272
|
+
if (typeof resolvePiApiKey !== "function") return undefined;
|
|
273
|
+
try {
|
|
274
|
+
return await resolvePiApiKey(provider);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
runtimeWarnings.push({
|
|
277
|
+
warning_kind: "pi_auth_failed",
|
|
278
|
+
provider,
|
|
279
|
+
message: err?.message || String(err),
|
|
280
|
+
});
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Normalize the incoming runtime messages into AgentMessages the harness can
|
|
286
|
+
// seed/prompt. Returns the prior messages (appended to the session before the
|
|
287
|
+
// run) and the final user text used to drive `harness.prompt`.
|
|
288
|
+
export function splitPromptMessages(messages, model) {
|
|
289
|
+
const source = Array.isArray(messages) && messages.length
|
|
290
|
+
? messages
|
|
291
|
+
: [{ role: "user", content: "" }];
|
|
292
|
+
// The harness `prompt` takes the trailing user turn; everything before it is
|
|
293
|
+
// seeded as transcript context.
|
|
294
|
+
let lastUserIndex = -1;
|
|
295
|
+
for (let i = source.length - 1; i >= 0; i -= 1) {
|
|
296
|
+
if (source[i]?.role === "user") { lastUserIndex = i; break; }
|
|
297
|
+
}
|
|
298
|
+
if (lastUserIndex === -1) {
|
|
299
|
+
// No user turn: seed everything (structure preserved), nothing to prompt.
|
|
300
|
+
return { priorMessages: toAgentMessages(source, model), promptText: "", promptImages: [] };
|
|
301
|
+
}
|
|
302
|
+
// Prior turns: preserve structure (incl. image blocks) via toAgentMessages
|
|
303
|
+
// instead of stringifying — this is the format the harness seeds from.
|
|
304
|
+
const priorMessages = lastUserIndex > 0 ? toAgentMessages(source.slice(0, lastUserIndex), model) : [];
|
|
305
|
+
// Final user turn: split into plain text + structured images so harness.prompt
|
|
306
|
+
// can receive them as ImageContent[] rather than a JSON-stringified blob.
|
|
307
|
+
const { text, images } = splitUserContent(source[lastUserIndex].content);
|
|
308
|
+
return { priorMessages, promptText: text, promptImages: images };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Split a user message's content into joined text and ImageContent[] image
|
|
312
|
+
// parts ({ type, data, mimeType }), preserving multimodal input for the runtime.
|
|
313
|
+
function splitUserContent(content) {
|
|
314
|
+
if (typeof content === "string") return { text: content, images: [] };
|
|
315
|
+
if (!Array.isArray(content)) return { text: String(content ?? ""), images: [] };
|
|
316
|
+
const texts = [];
|
|
317
|
+
const images = [];
|
|
318
|
+
for (const part of content) {
|
|
319
|
+
if (typeof part === "string") { texts.push(part); continue; }
|
|
320
|
+
if (part?.type === "text" && typeof part.text === "string") { texts.push(part.text); continue; }
|
|
321
|
+
if (part?.type === "image" && part.data) {
|
|
322
|
+
images.push({ type: "image", data: part.data, mimeType: part.mimeType || part.mime_type || "image/png" });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
texts.push(JSON.stringify(part ?? ""));
|
|
326
|
+
}
|
|
327
|
+
return { text: texts.join("\n"), images };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Live pi-native sessions, keyed by provider session id. Entries are
|
|
331
|
+
// { session, metadata, repo, durable, busy } — identical shape and lifecycle
|
|
332
|
+
// policy to the (now-retired) pi-sdk bridge: in-memory transcripts are freed
|
|
333
|
+
// when the registry evicts them; durable (jsonl) transcripts survive eviction
|
|
334
|
+
// so a later resume can reopen them from disk. Registering here gives
|
|
335
|
+
// runtime.disposeSession / disposeProviderSession + idle-TTL eviction the same
|
|
336
|
+
// reach over native pi sessions that the legacy bridge had.
|
|
337
|
+
const nativeSessionRepo = new InMemorySessionRepo();
|
|
338
|
+
const nativeSessions = createSessionRegistry({
|
|
339
|
+
isBusy: (entry) => entry.busy === true,
|
|
340
|
+
onEvict: async (entry) => {
|
|
341
|
+
if (entry.durable) return;
|
|
342
|
+
try {
|
|
343
|
+
await entry.repo.delete(entry.metadata);
|
|
344
|
+
} catch { /* best-effort */ }
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const durableNativeSessionRepos = new Map();
|
|
349
|
+
|
|
350
|
+
function resolveDurableNativeSessionRepo(piSessionsRoot) {
|
|
351
|
+
if (typeof piSessionsRoot !== "string" || !piSessionsRoot.trim()) return null;
|
|
352
|
+
const root = piSessionsRoot.trim();
|
|
353
|
+
let repo = durableNativeSessionRepos.get(root);
|
|
354
|
+
if (!repo) {
|
|
355
|
+
repo = new JsonlSessionRepo({
|
|
356
|
+
fs: new NodeExecutionEnv({ cwd: process.cwd() }),
|
|
357
|
+
sessionsRoot: root,
|
|
358
|
+
});
|
|
359
|
+
durableNativeSessionRepos.set(root, repo);
|
|
360
|
+
}
|
|
361
|
+
return repo;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Defense in depth (R4): create-on-miss passes the caller-controlled session id
|
|
365
|
+
// straight to durableRepo.create({ id }), and JsonlSessionRepo writes
|
|
366
|
+
// `${createdAt}_${id}.jsonl` — so an id like "../../../../tmp/pwn" would escape
|
|
367
|
+
// piSessionsRoot and name a file anywhere on disk. The harness-derived id is a
|
|
368
|
+
// sha256 hex (always safe), but the public runtime API is caller-controlled.
|
|
369
|
+
// Only an id that is a single safe filename component may CREATE a session;
|
|
370
|
+
// anything else falls through to the existing session_not_found fast-fail, so a
|
|
371
|
+
// malicious id can never name a file. (A genuinely on-disk session reopened by
|
|
372
|
+
// reopenDurableNativeSession is matched by `.id`, never used to build a path, so
|
|
373
|
+
// this gate is confined to the create path.)
|
|
374
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
375
|
+
|
|
376
|
+
function isSafeSessionId(id) {
|
|
377
|
+
return typeof id === "string"
|
|
378
|
+
&& SAFE_SESSION_ID.test(id)
|
|
379
|
+
&& !id.includes("..")
|
|
380
|
+
&& !id.includes("/")
|
|
381
|
+
&& !id.includes("\\");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function reopenDurableNativeSession(repo, sessionId) {
|
|
385
|
+
try {
|
|
386
|
+
const metadata = (await repo.list()).find((entry) => entry?.id === sessionId);
|
|
387
|
+
if (!metadata) return null;
|
|
388
|
+
const session = await repo.open(metadata);
|
|
389
|
+
return { session, metadata, repo, durable: true, busy: false };
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function sessionUnavailableResult({
|
|
396
|
+
resolved,
|
|
397
|
+
options,
|
|
398
|
+
events,
|
|
399
|
+
runtimeWarnings,
|
|
400
|
+
start,
|
|
401
|
+
sessionId,
|
|
402
|
+
errorMessage,
|
|
403
|
+
failureKind,
|
|
404
|
+
piErrorCode,
|
|
405
|
+
}) {
|
|
406
|
+
return {
|
|
407
|
+
text: null,
|
|
408
|
+
events,
|
|
409
|
+
usage: {},
|
|
410
|
+
durationMs: Date.now() - start,
|
|
411
|
+
numTurns: 0,
|
|
412
|
+
model: resolved?.reference || resolved?.model || null,
|
|
413
|
+
effort: options.effort || null,
|
|
414
|
+
sdk: resolved?.sdk || "pi",
|
|
415
|
+
cancelled: false,
|
|
416
|
+
error: errorMessage,
|
|
417
|
+
failureKind,
|
|
418
|
+
providerSessionId: sessionId,
|
|
419
|
+
runtimeWarnings,
|
|
420
|
+
diagnostics: {
|
|
421
|
+
provider_session_id: sessionId,
|
|
422
|
+
pi_error_code: piErrorCode,
|
|
423
|
+
pi_engine: "native",
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId }) {
|
|
429
|
+
return {
|
|
430
|
+
text: null,
|
|
431
|
+
thinking: "",
|
|
432
|
+
events,
|
|
433
|
+
usage: {},
|
|
434
|
+
durationMs: Date.now() - start,
|
|
435
|
+
numTurns: 0,
|
|
436
|
+
model: resolved?.reference || resolved?.model || null,
|
|
437
|
+
effort: options.effort || null,
|
|
438
|
+
sdk: resolved?.sdk || "pi",
|
|
439
|
+
cancelled: true,
|
|
440
|
+
error: null,
|
|
441
|
+
failureKind: null,
|
|
442
|
+
providerSessionId,
|
|
443
|
+
runtimeWarnings,
|
|
444
|
+
diagnostics: {
|
|
445
|
+
provider_session_id: providerSessionId,
|
|
446
|
+
pi_stop_reason: "aborted",
|
|
447
|
+
pi_engine: "native",
|
|
448
|
+
external_abort: true,
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function generatePiNativeResponse(systemPrompt, options = {}) {
|
|
454
|
+
const resolved = options.model;
|
|
455
|
+
const start = Date.now();
|
|
456
|
+
const events = [];
|
|
457
|
+
const runtimeWarnings = [];
|
|
458
|
+
const assistantTexts = [];
|
|
459
|
+
const assistantThinking = [];
|
|
460
|
+
const textDeltaIndexes = new Set();
|
|
461
|
+
const thinkingDeltaIndexes = new Set();
|
|
462
|
+
let structuredResult = null;
|
|
463
|
+
let mcpClients = [];
|
|
464
|
+
let externalAbort = false;
|
|
465
|
+
let maxTurnsHit = false;
|
|
466
|
+
let turnCount = 0;
|
|
467
|
+
let toolResultsSeen = 0;
|
|
468
|
+
let lastToolName = null;
|
|
469
|
+
// toolCallId -> start timestamp, so we can emit per-tool execution latency.
|
|
470
|
+
const toolStartTimes = new Map();
|
|
471
|
+
let harness = null;
|
|
472
|
+
let removeAbortHandler = null;
|
|
473
|
+
|
|
474
|
+
const providerSessionId = options.sessionId
|
|
475
|
+
|| options.providerSessionId
|
|
476
|
+
|| options.runId
|
|
477
|
+
|| randomUUID();
|
|
478
|
+
// Prefer the explicit sessionId, but fall back to providerSessionId so a caller
|
|
479
|
+
// that only supplies providerSessionId still resumes the prior session instead
|
|
480
|
+
// of being treated as a fresh run (which would drop prior context).
|
|
481
|
+
const requestedSessionId = typeof options.sessionId === "string" && options.sessionId.trim()
|
|
482
|
+
? options.sessionId
|
|
483
|
+
: (typeof options.providerSessionId === "string" && options.providerSessionId.trim()
|
|
484
|
+
? options.providerSessionId
|
|
485
|
+
: null);
|
|
486
|
+
// Bridge TTL is a backstop behind the host's session policy; the grace
|
|
487
|
+
// keeps host-side lazy expiry firing first.
|
|
488
|
+
const sessionTtlMs = Number.isFinite(Number(options.sessionIdleTimeoutMs))
|
|
489
|
+
? Number(options.sessionIdleTimeoutMs) + 60_000
|
|
490
|
+
: undefined;
|
|
491
|
+
let structuredOutputFinalizationRetryAttempts = 0;
|
|
492
|
+
let structuredOutputFinalizationRetryReason = null;
|
|
493
|
+
let structuredOutputFinalizationRetryFailed = false;
|
|
494
|
+
|
|
495
|
+
const onEvent = (event) => emitCaptured(events, options.onEvent, event);
|
|
496
|
+
const approvalManager = options.onToolApprovalRequest
|
|
497
|
+
? createApprovalManager({
|
|
498
|
+
onToolApprovalRequest: options.onToolApprovalRequest,
|
|
499
|
+
defaultRiskTier: options.approvalDefaultRiskTier,
|
|
500
|
+
timeoutMs: options.approvalTimeoutMs,
|
|
501
|
+
onEvent,
|
|
502
|
+
riskTiersByTool: options.toolRiskTiers,
|
|
503
|
+
alwaysAllowTools: options.approvalAlwaysAllowTools,
|
|
504
|
+
})
|
|
505
|
+
: null;
|
|
506
|
+
|
|
507
|
+
if (options.abortSignal?.aborted) {
|
|
508
|
+
return abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const durableRepo = resolveDurableNativeSessionRepo(options.piSessionsRoot);
|
|
512
|
+
let session = null;
|
|
513
|
+
let sessionEntry = null;
|
|
514
|
+
// True when a requestedSessionId had no live entry AND no durable transcript,
|
|
515
|
+
// so a fresh durable session was created under that id (cross-restart resume,
|
|
516
|
+
// first turn for the conversation). Distinct from a true resume (sessionEntry
|
|
517
|
+
// set): the on-disk transcript is empty, so prior messages must still be
|
|
518
|
+
// seeded — unlike a resume, where the session already holds them.
|
|
519
|
+
let createdOnMiss = false;
|
|
520
|
+
// True once the create-on-miss path inserted its BUSY placeholder into the
|
|
521
|
+
// registry (R8 concurrent-first-turn reservation). Used to clean the
|
|
522
|
+
// placeholder up on the drop/error/abort paths, since createdOnMiss leaves
|
|
523
|
+
// sessionEntry null so the finally's busy-clear does not cover it.
|
|
524
|
+
let createdOnMissPlaceholder = false;
|
|
525
|
+
let sessionBaselineCount = 0;
|
|
526
|
+
// Leaf captured before a resumed turn runs, so a failed resume can be rolled
|
|
527
|
+
// back to the last good transcript via moveTo. Hoisted to function scope so
|
|
528
|
+
// the OUTER catch (host/runtime-side throws after the session mutated) can
|
|
529
|
+
// roll back too, not just the success path.
|
|
530
|
+
let baselineLeafId = null;
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
// Resume check first: a session miss must stay cheap (no tool/MCP/harness
|
|
534
|
+
// init). This mirrors the legacy bridge's fail-fast contract.
|
|
535
|
+
if (requestedSessionId) {
|
|
536
|
+
let entry = nativeSessions.get(requestedSessionId);
|
|
537
|
+
if (!entry && durableRepo) {
|
|
538
|
+
entry = await reopenDurableNativeSession(durableRepo, requestedSessionId);
|
|
539
|
+
if (entry) {
|
|
540
|
+
// TOCTOU guard: the reopen above is an AWAIT, so a second concurrent
|
|
541
|
+
// cold resume could have reopened+inserted its own entry in this
|
|
542
|
+
// window. Re-read the registry and adopt any entry already present so
|
|
543
|
+
// the busy-claim below collapses back to the warm path's synchronous
|
|
544
|
+
// semantics (the loser sees the winner's shared entry with busy===true
|
|
545
|
+
// and returns session_busy). The discarded reopen is just an in-memory
|
|
546
|
+
// jsonl handle (no subprocess/socket), so dropping it is safe.
|
|
547
|
+
const concurrent = nativeSessions.get(requestedSessionId);
|
|
548
|
+
if (concurrent) {
|
|
549
|
+
entry = concurrent;
|
|
550
|
+
} else {
|
|
551
|
+
nativeSessions.set(requestedSessionId, entry, { idleTimeoutMs: sessionTtlMs });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (!entry) {
|
|
556
|
+
if (durableRepo && isSafeSessionId(providerSessionId)) {
|
|
557
|
+
// Create-on-miss (durable resume only): the requested id has no live
|
|
558
|
+
// registry entry AND no JSONL on disk under piSessionsRoot. This is
|
|
559
|
+
// the cross-restart resume case — the harness derives a stable id from
|
|
560
|
+
// the conversationId and passes it before any session exists on a
|
|
561
|
+
// fresh process. Rather than fail with session_not_found (which would
|
|
562
|
+
// make the harness re-send full history into yet another fresh,
|
|
563
|
+
// randomly-named session and orphan future resumes), create a durable
|
|
564
|
+
// session UNDER the requested id so this and every later turn for the
|
|
565
|
+
// conversation resolve to the same on-disk transcript. sessionEntry
|
|
566
|
+
// stays null so this proceeds exactly like a fresh run (prior messages
|
|
567
|
+
// are seeded, the keep-alive success path registers + persists it).
|
|
568
|
+
// The IN-MEMORY resume miss (no durableRepo) — and a create-on-miss
|
|
569
|
+
// with an UNSAFE id (R4) — keep fast-failing below, preserving the
|
|
570
|
+
// existing per-process session_not_found contract.
|
|
571
|
+
//
|
|
572
|
+
// Concurrent-first-turn race (R8): two concurrent first turns for the
|
|
573
|
+
// same durable id would BOTH miss here and BOTH create, producing two
|
|
574
|
+
// transcripts for one logical id (JsonlSessionRepo names files by
|
|
575
|
+
// `${createdAt}_${id}`, so there is no fs-level dedup). Mirror the
|
|
576
|
+
// cold-reopen-race defense: synchronously (NO await) re-check the
|
|
577
|
+
// registry, then reserve the id with a BUSY placeholder before the
|
|
578
|
+
// create await. The get→check→set span MUST stay await-free, so the
|
|
579
|
+
// loser observes the busy placeholder and returns session_busy via the
|
|
580
|
+
// same busy-claim path below — exactly one create per durable id.
|
|
581
|
+
const concurrent = nativeSessions.get(requestedSessionId);
|
|
582
|
+
if (concurrent) {
|
|
583
|
+
// A concurrent caller already reserved/created this id in the window
|
|
584
|
+
// since the miss above. Adopt its entry and fall into the busy-claim
|
|
585
|
+
// logic (session_busy if its turn is in flight, else resume).
|
|
586
|
+
entry = concurrent;
|
|
587
|
+
} else {
|
|
588
|
+
// Reserve the id with a busy placeholder BEFORE the create await so a
|
|
589
|
+
// second concurrent first turn observes busy and returns session_busy.
|
|
590
|
+
// The keep-alive success path (set(providerSessionId, ...) with
|
|
591
|
+
// busy:false) overwrites this placeholder on success; the drop/abort/
|
|
592
|
+
// catch paths delete it by requestedSessionId/providerSessionId (they
|
|
593
|
+
// are equal here). Keyed by requestedSessionId === providerSessionId.
|
|
594
|
+
nativeSessions.set(requestedSessionId, {
|
|
595
|
+
session: null,
|
|
596
|
+
metadata: null,
|
|
597
|
+
repo: durableRepo,
|
|
598
|
+
durable: true,
|
|
599
|
+
busy: true,
|
|
600
|
+
}, { idleTimeoutMs: sessionTtlMs });
|
|
601
|
+
createdOnMissPlaceholder = true;
|
|
602
|
+
session = await durableRepo.create({ id: providerSessionId, cwd: options.cwd || process.cwd() });
|
|
603
|
+
createdOnMiss = true;
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
return sessionUnavailableResult({
|
|
607
|
+
resolved,
|
|
608
|
+
options,
|
|
609
|
+
events,
|
|
610
|
+
runtimeWarnings,
|
|
611
|
+
start,
|
|
612
|
+
sessionId: requestedSessionId,
|
|
613
|
+
errorMessage: `Pi session ${requestedSessionId} is not live`,
|
|
614
|
+
failureKind: "session_not_found",
|
|
615
|
+
piErrorCode: "pi_session_not_found",
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (entry && !createdOnMiss) {
|
|
620
|
+
// The busy claim MUST stay await-free between the registry adoption
|
|
621
|
+
// above and `entry.busy = true` below: get/set + this check/claim are
|
|
622
|
+
// all synchronous, which is what makes the cold-resume race (F4) safe.
|
|
623
|
+
// Do not introduce any await in this span or the TOCTOU window reopens.
|
|
624
|
+
if (entry.busy) {
|
|
625
|
+
return sessionUnavailableResult({
|
|
626
|
+
resolved,
|
|
627
|
+
options,
|
|
628
|
+
events,
|
|
629
|
+
runtimeWarnings,
|
|
630
|
+
start,
|
|
631
|
+
sessionId: requestedSessionId,
|
|
632
|
+
errorMessage: `Pi session ${requestedSessionId} is busy with another turn`,
|
|
633
|
+
failureKind: "session_busy",
|
|
634
|
+
piErrorCode: "pi_session_busy",
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
entry.busy = true;
|
|
638
|
+
sessionEntry = entry;
|
|
639
|
+
session = entry.session;
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
// Fresh runs persist into the durable jsonl repo when piSessionsRoot is
|
|
643
|
+
// set, so a kept-alive session can be reopened from disk after the live
|
|
644
|
+
// entry is evicted; otherwise the in-memory repo is used.
|
|
645
|
+
session = await (durableRepo || nativeSessionRepo)
|
|
646
|
+
.create({ id: providerSessionId, cwd: options.cwd || process.cwd() });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// `piResolvedModel` is an advanced/test seam: when supplied it provides a
|
|
650
|
+
// ready pi-ai Model (e.g. a registered faux provider model) plus optional
|
|
651
|
+
// capabilities, bypassing the static model-registry lookup. Production
|
|
652
|
+
// callers leave it undefined and resolve through pi-ai's registry.
|
|
653
|
+
const runtime = options.piResolvedModel
|
|
654
|
+
? {
|
|
655
|
+
model: options.piResolvedModel,
|
|
656
|
+
capabilities: options.piResolvedCapabilities || {
|
|
657
|
+
tool_use: true,
|
|
658
|
+
reasoning: !!options.piResolvedModel.reasoning,
|
|
659
|
+
reasoning_mode: options.piResolvedModel.reasoning ? "effort" : "none",
|
|
660
|
+
json_mode: true,
|
|
661
|
+
},
|
|
662
|
+
apiKeys: new Map(),
|
|
663
|
+
}
|
|
664
|
+
: resolvePiRuntimeModel(resolved, options);
|
|
665
|
+
const capabilities = runtime.capabilities || {};
|
|
666
|
+
const effectiveThinkingLevel = thinkingLevelForEffort(options.effort || "medium", capabilities);
|
|
667
|
+
const reference = resolved.reference
|
|
668
|
+
|| (resolved.sdk === "pi" ? `pi:${resolved.provider}:${resolved.model}` : `${resolved.sdk}:${resolved.model}`);
|
|
669
|
+
|
|
670
|
+
// Tool-output limits (settings-driven clamps for tool/MCP payloads). The
|
|
671
|
+
// legacy pi-sdk bridge wired these via the compaction manager's `.policy`;
|
|
672
|
+
// resolveAgentCompactionPolicy is pure (no manager/Agent), so we compute the
|
|
673
|
+
// same policy directly and pass it into the tool builders + display
|
|
674
|
+
// normalization. Restores configurable clamping (agent_tool_text_limit_chars,
|
|
675
|
+
// agent_search_result_limit, ...) on top of the 256KB hard ceiling.
|
|
676
|
+
const toolLimits = resolveAgentCompactionPolicy(options.settings || {}, runtime.model);
|
|
677
|
+
|
|
678
|
+
// Auto-compaction state. The compaction policy is (re)computed at the
|
|
679
|
+
// decision point (Hook B) against the model actually serving the request, so
|
|
680
|
+
// it is declared there; these flags track whether a compaction fired so the
|
|
681
|
+
// run reports context_compaction_applied honestly and never double-compacts.
|
|
682
|
+
let contextCompactionApplied = false;
|
|
683
|
+
let contextCompactionReactiveAttempted = false;
|
|
684
|
+
let contextCompactedThisRun = false;
|
|
685
|
+
let compactionPolicy = null;
|
|
686
|
+
const contextCompactionDiagnostics = {};
|
|
687
|
+
|
|
688
|
+
const onTruncate = (info) => {
|
|
689
|
+
try {
|
|
690
|
+
onEvent({
|
|
691
|
+
type: "runtime_warning",
|
|
692
|
+
warning_kind: "tool_payload_truncated",
|
|
693
|
+
source: "tool_bloat_guard",
|
|
694
|
+
...info,
|
|
695
|
+
});
|
|
696
|
+
} catch { /* best-effort */ }
|
|
697
|
+
};
|
|
698
|
+
const persistArtifact = options.persistArtifact || null;
|
|
699
|
+
const qaOutputDir = options.qaOutputDir || options.runArtifactDir || null;
|
|
700
|
+
|
|
701
|
+
// REUSED custom pieces: built-in tool sandboxing + allowlist/bloat filter +
|
|
702
|
+
// approval gates. These are identical to the legacy bridge.
|
|
703
|
+
const builtIns = capabilities.tool_use === false
|
|
704
|
+
? []
|
|
705
|
+
: getPiBuiltinTools(options.allowedTools, {
|
|
706
|
+
skillNames: (options.skills || []).map((skill) => skill.name),
|
|
707
|
+
dataDir: options.dataDir,
|
|
708
|
+
cwd: options.cwd,
|
|
709
|
+
onEvent,
|
|
710
|
+
persistArtifact,
|
|
711
|
+
onTruncate,
|
|
712
|
+
toolLimits,
|
|
713
|
+
toolPayloadMaxBytes: toolLimits.toolPayloadMaxBytes,
|
|
714
|
+
toolPolicy: options.toolPolicy,
|
|
715
|
+
sandboxPolicy: options.sandboxPolicy,
|
|
716
|
+
sandboxEngine: options.sandboxEngine,
|
|
717
|
+
approvalManager,
|
|
718
|
+
approvalModel: runtime.model?.id || runtime.model?.name || resolved.model,
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
const structuredTool = createStructuredOutputTool(options.outputSchema, (value) => {
|
|
722
|
+
structuredResult = value;
|
|
723
|
+
});
|
|
724
|
+
const reservedNames = new Set(builtIns.map((toolDef) => toolDef.name));
|
|
725
|
+
if (structuredTool) reservedNames.add(structuredTool.name);
|
|
726
|
+
|
|
727
|
+
// REUSED MCP tool bridge: same initPiMcpTools sandboxing path.
|
|
728
|
+
const mcpInit = capabilities.tool_use === false
|
|
729
|
+
? { clients: [], tools: [], warnings: [] }
|
|
730
|
+
: await initPiMcpTools(options.mcpServers || {}, reservedNames, {
|
|
731
|
+
cwd: options.cwd,
|
|
732
|
+
persistArtifact,
|
|
733
|
+
qaOutputDir,
|
|
734
|
+
onTruncate,
|
|
735
|
+
limits: toolLimits,
|
|
736
|
+
toolPayloadMaxBytes: toolLimits.toolPayloadMaxBytes,
|
|
737
|
+
sandboxPolicy: options.sandboxPolicy,
|
|
738
|
+
sandboxEngine: options.sandboxEngine,
|
|
739
|
+
});
|
|
740
|
+
mcpClients = mcpInit.clients;
|
|
741
|
+
for (const warning of mcpInit.warnings || []) onEvent(warning);
|
|
742
|
+
|
|
743
|
+
const tools = [
|
|
744
|
+
...builtIns,
|
|
745
|
+
...mcpInit.tools,
|
|
746
|
+
...(structuredTool ? [structuredTool] : []),
|
|
747
|
+
];
|
|
748
|
+
|
|
749
|
+
// Provider retry/backoff is delegated to pi-ai via streamOptions, replacing
|
|
750
|
+
// the legacy hand-rolled stream-retry loop.
|
|
751
|
+
const maxRetries = Number.isFinite(Number(options.piMaxRetries))
|
|
752
|
+
? Math.max(0, Math.min(8, Number(options.piMaxRetries)))
|
|
753
|
+
: 2;
|
|
754
|
+
const maxRetryDelayMs = Number.isFinite(Number(options.maxRetryDelayMs))
|
|
755
|
+
? Number(options.maxRetryDelayMs)
|
|
756
|
+
: 60_000;
|
|
757
|
+
// Tool steering: default "one-at-a-time" (safe, deterministic ordering).
|
|
758
|
+
// Opt-in "all" lets pi-agent-core run a model step's tool calls concurrently
|
|
759
|
+
// (QueueMode). Only enable when tools in a step are independent.
|
|
760
|
+
const toolSteeringMode = options.piToolParallelismMode === "all" ? "all" : "one-at-a-time";
|
|
761
|
+
|
|
762
|
+
harness = new AgentHarness({
|
|
763
|
+
env: new NodeExecutionEnv({ cwd: options.cwd || process.cwd() }),
|
|
764
|
+
session,
|
|
765
|
+
model: runtime.model,
|
|
766
|
+
thinkingLevel: effectiveThinkingLevel,
|
|
767
|
+
systemPrompt: appendStructuredOutputInstruction(systemPrompt, options.outputSchema),
|
|
768
|
+
tools,
|
|
769
|
+
streamOptions: { maxRetries, maxRetryDelayMs },
|
|
770
|
+
getApiKeyAndHeaders: async (model) => {
|
|
771
|
+
const apiKey = await resolveApiKey(model.provider, {
|
|
772
|
+
apiKeys: runtime.apiKeys,
|
|
773
|
+
resolvePiApiKey: options.resolvePiApiKey,
|
|
774
|
+
runtimeWarnings,
|
|
775
|
+
});
|
|
776
|
+
return apiKey ? { apiKey } : undefined;
|
|
777
|
+
},
|
|
778
|
+
steeringMode: toolSteeringMode,
|
|
779
|
+
followUpMode: toolSteeringMode,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
harness.subscribe((event) => {
|
|
783
|
+
if (event.type === "message_update") {
|
|
784
|
+
const streamEvent = event.assistantMessageEvent;
|
|
785
|
+
if (streamEvent?.type === "text_delta" && streamEvent.delta) {
|
|
786
|
+
textDeltaIndexes.add(streamContentKey(streamEvent, "text"));
|
|
787
|
+
assistantTexts.push(streamEvent.delta);
|
|
788
|
+
onEvent({ type: "assistant", message: { content: [{ type: "text", text: streamEvent.delta }] } });
|
|
789
|
+
} else if (streamEvent?.type === "text_end" && streamEvent.content) {
|
|
790
|
+
const key = streamContentKey(streamEvent, "text");
|
|
791
|
+
if (!textDeltaIndexes.has(key)) {
|
|
792
|
+
assistantTexts.push(streamEvent.content);
|
|
793
|
+
onEvent({ type: "assistant", message: { content: [{ type: "text", text: streamEvent.content }] } });
|
|
794
|
+
}
|
|
795
|
+
} else if (streamEvent?.type === "thinking_delta" && streamEvent.delta) {
|
|
796
|
+
thinkingDeltaIndexes.add(streamContentKey(streamEvent, "thinking"));
|
|
797
|
+
assistantThinking.push(streamEvent.delta);
|
|
798
|
+
onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.delta }] } });
|
|
799
|
+
} else if (streamEvent?.type === "thinking_end" && streamEvent.content) {
|
|
800
|
+
const key = streamContentKey(streamEvent, "thinking");
|
|
801
|
+
if (!thinkingDeltaIndexes.has(key)) {
|
|
802
|
+
assistantThinking.push(streamEvent.content);
|
|
803
|
+
onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.content }] } });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
} else if (event.type === "tool_execution_start") {
|
|
807
|
+
if (event.toolName) lastToolName = event.toolName;
|
|
808
|
+
if (event.toolCallId) toolStartTimes.set(event.toolCallId, Date.now());
|
|
809
|
+
const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
|
|
810
|
+
const progressText = toolStartProgressText(event.toolName);
|
|
811
|
+
if (progressText) {
|
|
812
|
+
onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: progressText }] } });
|
|
813
|
+
}
|
|
814
|
+
onEvent({
|
|
815
|
+
type: "assistant",
|
|
816
|
+
message: { content: [{ type: "tool_use", id: event.toolCallId, name: event.toolName, input }] },
|
|
817
|
+
});
|
|
818
|
+
} else if (event.type === "tool_execution_update") {
|
|
819
|
+
const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
|
|
820
|
+
onEvent({
|
|
821
|
+
type: "tool_update",
|
|
822
|
+
tool_use_id: event.toolCallId,
|
|
823
|
+
name: event.toolName,
|
|
824
|
+
input,
|
|
825
|
+
partial_result: jsonSerializable(event.partialResult, String(event.partialResult ?? "")),
|
|
826
|
+
});
|
|
827
|
+
} else if (event.type === "tool_execution_end") {
|
|
828
|
+
const resultContent = toolResultContent(event.result);
|
|
829
|
+
if (!event.isError) toolResultsSeen += 1;
|
|
830
|
+
const startedAt = toolStartTimes.get(event.toolCallId);
|
|
831
|
+
if (startedAt !== undefined) {
|
|
832
|
+
toolStartTimes.delete(event.toolCallId);
|
|
833
|
+
onEvent({
|
|
834
|
+
type: "tool_timing",
|
|
835
|
+
tool_use_id: event.toolCallId,
|
|
836
|
+
name: event.toolName,
|
|
837
|
+
execution_ms: Date.now() - startedAt,
|
|
838
|
+
is_error: !!event.isError,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
onEvent({
|
|
842
|
+
type: "user",
|
|
843
|
+
message: {
|
|
844
|
+
content: [{
|
|
845
|
+
type: "tool_result",
|
|
846
|
+
tool_use_id: event.toolCallId,
|
|
847
|
+
content: resultContent,
|
|
848
|
+
raw_result: compactToolRawResult(jsonSerializable(event.result, resultContent), resultContent),
|
|
849
|
+
is_error: !!event.isError,
|
|
850
|
+
}],
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
} else if (event.type === "turn_end") {
|
|
854
|
+
turnCount += 1;
|
|
855
|
+
if (Number.isFinite(Number(options.maxTurns))
|
|
856
|
+
&& Number(options.maxTurns) > 0
|
|
857
|
+
&& turnCount >= Number(options.maxTurns)
|
|
858
|
+
&& event.message?.stopReason === "toolUse") {
|
|
859
|
+
maxTurnsHit = true;
|
|
860
|
+
harness.abort();
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
const abortHandler = () => {
|
|
866
|
+
externalAbort = true;
|
|
867
|
+
harness.abort();
|
|
868
|
+
};
|
|
869
|
+
if (options.abortSignal) {
|
|
870
|
+
options.abortSignal.addEventListener("abort", abortHandler, { once: true });
|
|
871
|
+
removeAbortHandler = () => options.abortSignal.removeEventListener?.("abort", abortHandler);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Seed prior transcript (everything before the trailing user turn) into the
|
|
875
|
+
// harness-owned session. On a true resume the session already holds the
|
|
876
|
+
// transcript, so prior messages are skipped; a fresh run AND a create-on-miss
|
|
877
|
+
// (requestedSessionId set but the durable session was just created empty)
|
|
878
|
+
// both seed, since their on-disk transcript is empty.
|
|
879
|
+
const { priorMessages, promptText, promptImages } = splitPromptMessages(options.messages, runtime.model);
|
|
880
|
+
sessionBaselineCount = (await session.buildContext()).messages.length;
|
|
881
|
+
if (!requestedSessionId || createdOnMiss) {
|
|
882
|
+
for (const message of priorMessages) {
|
|
883
|
+
await harness.appendMessage(message);
|
|
884
|
+
sessionBaselineCount += 1;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
// The harness persists each turn INLINE into the live session. To preserve
|
|
888
|
+
// the legacy "a failed resumed turn does not corrupt the session" contract,
|
|
889
|
+
// remember the leaf before the run so a failed resume can be rolled back to
|
|
890
|
+
// the last good transcript via the session tree's moveTo primitive. Only a
|
|
891
|
+
// TRUE resume needs this: a create-on-miss session is fresh, so a failure
|
|
892
|
+
// drops it entirely via the fresh-run path (no leaf to roll back to).
|
|
893
|
+
if (requestedSessionId && !createdOnMiss) {
|
|
894
|
+
try { baselineLeafId = await session.getLeafId(); } catch { /* best-effort */ }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Live steering: consume follow-up messages and steer the harness mid-run.
|
|
898
|
+
// The consumer is tied to run completion (runComplete) so it stops steering
|
|
899
|
+
// once the run finishes and does not swallow messages meant for a later turn.
|
|
900
|
+
let liveInputIterator = null;
|
|
901
|
+
let liveInputTask = null;
|
|
902
|
+
let runComplete = false;
|
|
903
|
+
if (options.liveInput) {
|
|
904
|
+
liveInputIterator = typeof options.liveInput[Symbol.asyncIterator] === "function"
|
|
905
|
+
? options.liveInput[Symbol.asyncIterator]()
|
|
906
|
+
: options.liveInput;
|
|
907
|
+
liveInputTask = (async () => {
|
|
908
|
+
try {
|
|
909
|
+
while (!runComplete && !options.abortSignal?.aborted) {
|
|
910
|
+
const next = await liveInputIterator.next();
|
|
911
|
+
if (next.done || runComplete || options.abortSignal?.aborted) break;
|
|
912
|
+
await harness.steer(formatLiveInputGuidance(next.value.body));
|
|
913
|
+
}
|
|
914
|
+
} catch (err) {
|
|
915
|
+
onEvent({
|
|
916
|
+
type: "runtime_warning",
|
|
917
|
+
warning_kind: "live_input_failed",
|
|
918
|
+
message: err?.message || String(err),
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
})();
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Re-check abort right before issuing the provider request. The abort
|
|
925
|
+
// handler is only installed at ~:639, AFTER a long stretch of awaited setup
|
|
926
|
+
// (reopen, create, MCP init, buildContext, appendMessage, getLeafId). If
|
|
927
|
+
// abort fired DURING any of those awaits the listener was not yet attached,
|
|
928
|
+
// so the event was dropped and no run is active for harness.abort() to
|
|
929
|
+
// target. Without this re-check a full provider/LLM request would be issued
|
|
930
|
+
// for a run the caller already aborted (mirrors the entry pre-check at ~:356).
|
|
931
|
+
if (options.abortSignal?.aborted) {
|
|
932
|
+
// Drop a freshly-created non-keep-alive session so an aborted-before-run
|
|
933
|
+
// turn does not leave an orphan jsonl on disk. Guarded `session &&
|
|
934
|
+
// !sessionEntry` so a resumed (user-owned) session is NEVER deleted —
|
|
935
|
+
// identical to the outer catch guard. The finally block clears
|
|
936
|
+
// sessionEntry.busy, removes the abort handler, and closes MCP clients.
|
|
937
|
+
// For a resume no transcript was appended yet (prompt never ran), so the
|
|
938
|
+
// live session is already at its pre-turn leaf and needs no rollback.
|
|
939
|
+
if (session && !sessionEntry) {
|
|
940
|
+
try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
|
|
941
|
+
}
|
|
942
|
+
// Drop the create-on-miss BUSY reservation too. The finally only clears
|
|
943
|
+
// sessionEntry.busy (null here), so without this the busy placeholder leaks
|
|
944
|
+
// and every future resume of this conversation's stable id returns
|
|
945
|
+
// session_busy forever (busy entries are never idle-evicted). Mirrors the
|
|
946
|
+
// lifecycle drop branch + outer catch cleanup.
|
|
947
|
+
if (createdOnMissPlaceholder) nativeSessions.delete(providerSessionId);
|
|
948
|
+
return abortedResult({ resolved, options, events, runtimeWarnings, start, providerSessionId });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Compaction policy against the LIVE model's context window (auto-recognized
|
|
952
|
+
// from the model actually serving the request, lowered by any ceiling learned
|
|
953
|
+
// from a prior overflow). Drives the proactive trigger + reactive recovery.
|
|
954
|
+
compactionPolicy = resolveAgentCompactionPolicy(
|
|
955
|
+
options.settings || {},
|
|
956
|
+
{ contextWindow: effectiveContextWindow(harness, runtime, resolved) },
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
// Proactive compaction: if the session is already near the window, compact
|
|
960
|
+
// BEFORE issuing the request so a long-lived session never overflows.
|
|
961
|
+
if (compactionPolicy.enabled && compactionPolicy.contextWindow > 0 && !options.abortSignal?.aborted) {
|
|
962
|
+
const est = await estimateCurrentContextTokens(session);
|
|
963
|
+
if (est.tokens >= compactionPolicy.triggerTokens) {
|
|
964
|
+
await harness.waitForIdle();
|
|
965
|
+
if (!options.abortSignal?.aborted) {
|
|
966
|
+
const res = await tryCompact(harness, {
|
|
967
|
+
trigger: "proactive",
|
|
968
|
+
onEvent,
|
|
969
|
+
runtimeWarnings,
|
|
970
|
+
onCompactionRecorded: options.onCompactionRecorded,
|
|
971
|
+
runId: options.runId,
|
|
972
|
+
model: reference,
|
|
973
|
+
});
|
|
974
|
+
if (res.applied) {
|
|
975
|
+
contextCompactionApplied = true;
|
|
976
|
+
contextCompactedThisRun = true;
|
|
977
|
+
Object.assign(contextCompactionDiagnostics, {
|
|
978
|
+
context_compaction_proactive: true,
|
|
979
|
+
context_compaction_tokens_before: res.tokensBefore,
|
|
980
|
+
context_compaction_estimate_source: est.source,
|
|
981
|
+
context_window: compactionPolicy.contextWindow,
|
|
982
|
+
});
|
|
983
|
+
// Compaction collapses the transcript prefix, so the pre-run baseline
|
|
984
|
+
// no longer aligns. Re-anchor it to the compacted length so the run's
|
|
985
|
+
// own turns (issued next) slice out correctly in captureState.
|
|
986
|
+
sessionBaselineCount = (await session.buildContext()).messages.length;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
onEvent({
|
|
993
|
+
type: "provider_request_started",
|
|
994
|
+
sdk: resolved.sdk,
|
|
995
|
+
model: reference,
|
|
996
|
+
runtime: "pi",
|
|
997
|
+
timestamp: Date.now(),
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
let runError = null;
|
|
1001
|
+
try {
|
|
1002
|
+
// Pass structured images (when present) so multimodal input reaches the
|
|
1003
|
+
// model as image blocks rather than stringified text. AgentHarness.prompt
|
|
1004
|
+
// takes them under an options object (`{ images }`); a bare array would be
|
|
1005
|
+
// read as `options` and silently dropped (options?.images === undefined).
|
|
1006
|
+
if (Array.isArray(promptImages) && promptImages.length > 0) {
|
|
1007
|
+
await harness.prompt(promptText, { images: promptImages });
|
|
1008
|
+
} else {
|
|
1009
|
+
await harness.prompt(promptText);
|
|
1010
|
+
}
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
runError = err;
|
|
1013
|
+
}
|
|
1014
|
+
await harness.waitForIdle();
|
|
1015
|
+
|
|
1016
|
+
// The run is done: stop the live-steering consumer so it cannot steer a
|
|
1017
|
+
// finished harness or swallow a follow-up meant for the next turn. We signal
|
|
1018
|
+
// completion, then best-effort return() the iterator to unblock a pending
|
|
1019
|
+
// next(). We do NOT await the task (it could block on next() if the source
|
|
1020
|
+
// has no return()), but the runComplete guard prevents any further steering.
|
|
1021
|
+
runComplete = true;
|
|
1022
|
+
if (liveInputIterator && typeof liveInputIterator.return === "function") {
|
|
1023
|
+
try { await liveInputIterator.return(); } catch { /* best-effort */ }
|
|
1024
|
+
}
|
|
1025
|
+
void liveInputTask;
|
|
1026
|
+
|
|
1027
|
+
externalAbort ||= !!options.abortSignal?.aborted;
|
|
1028
|
+
|
|
1029
|
+
const captureState = async () => {
|
|
1030
|
+
const context = await session.buildContext();
|
|
1031
|
+
const transcript = context.messages || [];
|
|
1032
|
+
const runTranscript = transcript.slice(sessionBaselineCount);
|
|
1033
|
+
const assistantMessages = runTranscript.filter((message) => message?.role === "assistant");
|
|
1034
|
+
const lastAssistant = assistantMessages[assistantMessages.length - 1] || null;
|
|
1035
|
+
return {
|
|
1036
|
+
transcript,
|
|
1037
|
+
runTranscript,
|
|
1038
|
+
assistantMessages,
|
|
1039
|
+
lastAssistant,
|
|
1040
|
+
stopReason: lastAssistant?.stopReason || null,
|
|
1041
|
+
finalText: textFromContent(lastAssistant?.content) || assistantTexts.join(""),
|
|
1042
|
+
finalThinking: thinkingFromContent(lastAssistant?.content) || assistantThinking.join(""),
|
|
1043
|
+
};
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
let state = await captureState();
|
|
1047
|
+
|
|
1048
|
+
// Structured-output finalization retry: if the turn ended with neither
|
|
1049
|
+
// text nor a StructuredOutput call, re-prompt ONCE in the same session with
|
|
1050
|
+
// only StructuredOutput active so the model can submit the required result.
|
|
1051
|
+
// This replicates the legacy bridge's single re-prompt via the harness's
|
|
1052
|
+
// followUp + setActiveTools instead of the low-level agent.continue() loop.
|
|
1053
|
+
if (!runError && shouldRetryStructuredOutputFinalization({
|
|
1054
|
+
outputSchema: options.outputSchema,
|
|
1055
|
+
structuredResult,
|
|
1056
|
+
finalText: state.finalText,
|
|
1057
|
+
stopReason: state.stopReason,
|
|
1058
|
+
externalAbort,
|
|
1059
|
+
maxTurnsHit,
|
|
1060
|
+
})) {
|
|
1061
|
+
structuredOutputFinalizationRetryAttempts = 1;
|
|
1062
|
+
structuredOutputFinalizationRetryReason = "empty_final_output";
|
|
1063
|
+
runtimeWarnings.push({
|
|
1064
|
+
warning_kind: "structured_output_finalization_retry",
|
|
1065
|
+
source: "pi",
|
|
1066
|
+
reason: structuredOutputFinalizationRetryReason,
|
|
1067
|
+
message: "Pi stopped without text or structured output; retrying once in the same session with only StructuredOutput enabled.",
|
|
1068
|
+
});
|
|
1069
|
+
const previousActive = harness.getActiveTools().map((toolDef) => toolDef.name);
|
|
1070
|
+
try {
|
|
1071
|
+
// The harness is idle after waitForIdle(), so re-prompt (not followUp,
|
|
1072
|
+
// which only queues onto an active run) with only StructuredOutput
|
|
1073
|
+
// active. This re-prompts ONCE in the same session, matching the legacy
|
|
1074
|
+
// single agent.continue() finalization re-prompt.
|
|
1075
|
+
await harness.setActiveTools(structuredTool ? [structuredTool.name] : []);
|
|
1076
|
+
await harness.prompt(structuredOutputFinalizationPrompt());
|
|
1077
|
+
await harness.waitForIdle();
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
runtimeWarnings.push({
|
|
1080
|
+
warning_kind: "structured_output_finalization_retry_failed",
|
|
1081
|
+
source: "pi",
|
|
1082
|
+
message: err?.message || String(err),
|
|
1083
|
+
});
|
|
1084
|
+
} finally {
|
|
1085
|
+
try { await harness.setActiveTools(previousActive); } catch { /* best-effort */ }
|
|
1086
|
+
}
|
|
1087
|
+
structuredOutputFinalizationRetryFailed = structuredResult === null || structuredResult === undefined;
|
|
1088
|
+
state = await captureState();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Reactive recovery: if the turn ended in a context overflow and we have not
|
|
1092
|
+
// already compacted-and-retried this run, compact once and re-prompt once.
|
|
1093
|
+
if (
|
|
1094
|
+
compactionPolicy?.enabled
|
|
1095
|
+
&& !contextCompactionReactiveAttempted
|
|
1096
|
+
&& !externalAbort
|
|
1097
|
+
&& !maxTurnsHit
|
|
1098
|
+
&& !options.abortSignal?.aborted
|
|
1099
|
+
) {
|
|
1100
|
+
const provisionalRaw = state.stopReason === "error" || state.stopReason === "aborted"
|
|
1101
|
+
? state.lastAssistant?.errorMessage || runError?.message || null
|
|
1102
|
+
: (runError ? runError.message || String(runError) : null);
|
|
1103
|
+
const provisionalError = normalizePiErrorMessage(provisionalRaw);
|
|
1104
|
+
if (provisionalError && isReactiveCompactionCandidate(provisionalError, contextCompactionDiagnostics)) {
|
|
1105
|
+
contextCompactionReactiveAttempted = true;
|
|
1106
|
+
// Learn the real ceiling from the error so future runs trigger
|
|
1107
|
+
// proactively at it even when the configured contextWindow was wrong.
|
|
1108
|
+
recordDiscoveredContextWindow(harness, runtime, resolved, parseContextLimitFromError(provisionalError));
|
|
1109
|
+
// A second compaction immediately after a fresh proactive one is almost
|
|
1110
|
+
// always "nothing to compact"; skip it and surface the original error.
|
|
1111
|
+
if (!contextCompactedThisRun) {
|
|
1112
|
+
await harness.waitForIdle();
|
|
1113
|
+
const res = await tryCompact(harness, {
|
|
1114
|
+
trigger: "reactive_overflow",
|
|
1115
|
+
onEvent,
|
|
1116
|
+
runtimeWarnings,
|
|
1117
|
+
onCompactionRecorded: options.onCompactionRecorded,
|
|
1118
|
+
runId: options.runId,
|
|
1119
|
+
model: reference,
|
|
1120
|
+
});
|
|
1121
|
+
if (res.applied) {
|
|
1122
|
+
contextCompactionApplied = true;
|
|
1123
|
+
contextCompactedThisRun = true;
|
|
1124
|
+
Object.assign(contextCompactionDiagnostics, {
|
|
1125
|
+
context_compaction_reactive: true,
|
|
1126
|
+
context_compaction_tokens_before: res.tokensBefore,
|
|
1127
|
+
});
|
|
1128
|
+
// Re-anchor the transcript baseline to the compacted length so the
|
|
1129
|
+
// re-prompt's turn (and its stopReason/usage) slices out correctly.
|
|
1130
|
+
sessionBaselineCount = (await session.buildContext()).messages.length;
|
|
1131
|
+
// Re-prompt ONCE in the now-compacted session. The trailing user turn
|
|
1132
|
+
// is already persisted, so a fresh prompt continues against it.
|
|
1133
|
+
runError = null;
|
|
1134
|
+
try {
|
|
1135
|
+
if (Array.isArray(promptImages) && promptImages.length > 0) {
|
|
1136
|
+
await harness.prompt(promptText, { images: promptImages });
|
|
1137
|
+
} else {
|
|
1138
|
+
await harness.prompt(promptText);
|
|
1139
|
+
}
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
runError = err;
|
|
1142
|
+
}
|
|
1143
|
+
await harness.waitForIdle();
|
|
1144
|
+
state = await captureState();
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const { runTranscript, lastAssistant, stopReason, finalText, finalThinking } = state;
|
|
1151
|
+
const runAssistantCount = state.assistantMessages.length;
|
|
1152
|
+
|
|
1153
|
+
const usage = usageFromMessages(runTranscript);
|
|
1154
|
+
const estimatedCost = estimateCost({
|
|
1155
|
+
resolveCustomPricing: options.resolveCustomPricing,
|
|
1156
|
+
model: reference,
|
|
1157
|
+
inputTokens: usage.input,
|
|
1158
|
+
outputTokens: usage.output,
|
|
1159
|
+
cachedTokens: usage.cacheRead,
|
|
1160
|
+
cacheWriteTokens: usage.cacheWrite,
|
|
1161
|
+
});
|
|
1162
|
+
if (usage.cacheRead > 0) {
|
|
1163
|
+
onEvent({ type: "cache_hit", sdk: resolved.sdk, model: reference, tokens: usage.cacheRead, source: "prompt_cache" });
|
|
1164
|
+
}
|
|
1165
|
+
if (usage.cacheWrite > 0) {
|
|
1166
|
+
onEvent({ type: "cache_miss", sdk: resolved.sdk, model: reference, tokens: usage.cacheWrite, source: "prompt_cache" });
|
|
1167
|
+
}
|
|
1168
|
+
onEvent({
|
|
1169
|
+
type: "cost_accumulated",
|
|
1170
|
+
sdk: resolved.sdk,
|
|
1171
|
+
model: reference,
|
|
1172
|
+
cumulativeUsd: Number(usage.cost) || Number(estimatedCost) || 0,
|
|
1173
|
+
tokens: {
|
|
1174
|
+
input: Number(usage.input) || 0,
|
|
1175
|
+
output: Number(usage.output) || 0,
|
|
1176
|
+
cacheReadTokens: Number(usage.cacheRead) || 0,
|
|
1177
|
+
cacheCreationTokens: Number(usage.cacheWrite) || 0,
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
onEvent({
|
|
1181
|
+
type: "provider_request_completed",
|
|
1182
|
+
sdk: resolved.sdk,
|
|
1183
|
+
model: reference,
|
|
1184
|
+
runtime: "pi",
|
|
1185
|
+
timestamp: Date.now(),
|
|
1186
|
+
durationMs: Date.now() - start,
|
|
1187
|
+
cancelled: externalAbort,
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
const rawErrorMessage = externalAbort
|
|
1191
|
+
? null
|
|
1192
|
+
: maxTurnsHit
|
|
1193
|
+
? "Pi agent stopped before final output: max turns reached"
|
|
1194
|
+
: (stopReason === "error" || stopReason === "aborted"
|
|
1195
|
+
? lastAssistant?.errorMessage || runError?.message || "Pi agent aborted before final output"
|
|
1196
|
+
: (runError ? runError.message || String(runError) : null));
|
|
1197
|
+
const errorMessage = normalizePiErrorMessage(rawErrorMessage);
|
|
1198
|
+
|
|
1199
|
+
const diagnostics = {
|
|
1200
|
+
provider_session_id: providerSessionId,
|
|
1201
|
+
pi_stop_reason: stopReason,
|
|
1202
|
+
pi_engine: "native",
|
|
1203
|
+
max_turns_hit: maxTurnsHit,
|
|
1204
|
+
max_turns: Number.isFinite(Number(options.maxTurns)) ? Number(options.maxTurns) : null,
|
|
1205
|
+
turn_count: turnCount || runAssistantCount,
|
|
1206
|
+
external_abort: externalAbort,
|
|
1207
|
+
pi_max_retries: maxRetries,
|
|
1208
|
+
...(lastToolName ? { last_tool_name: lastToolName } : {}),
|
|
1209
|
+
...structuredOutputRetryDiagnostics(
|
|
1210
|
+
structuredOutputFinalizationRetryAttempts,
|
|
1211
|
+
structuredOutputFinalizationRetryReason,
|
|
1212
|
+
structuredOutputFinalizationRetryFailed,
|
|
1213
|
+
),
|
|
1214
|
+
...contextCompactionDiagnostics,
|
|
1215
|
+
};
|
|
1216
|
+
const errorDetails = errorMessage ? {
|
|
1217
|
+
pi_stop_reason: stopReason || "error",
|
|
1218
|
+
last_tool_name: lastToolName,
|
|
1219
|
+
tool_results_seen: toolResultsSeen,
|
|
1220
|
+
turn_count: turnCount || runAssistantCount,
|
|
1221
|
+
max_turns_hit: maxTurnsHit,
|
|
1222
|
+
provider_session_id: providerSessionId,
|
|
1223
|
+
pi_engine: "native",
|
|
1224
|
+
...structuredOutputRetryDiagnostics(
|
|
1225
|
+
structuredOutputFinalizationRetryAttempts,
|
|
1226
|
+
structuredOutputFinalizationRetryReason,
|
|
1227
|
+
structuredOutputFinalizationRetryFailed,
|
|
1228
|
+
),
|
|
1229
|
+
...contextCompactionDiagnostics,
|
|
1230
|
+
} : null;
|
|
1231
|
+
|
|
1232
|
+
const capabilitiesUsed = buildCapabilitiesUsed({
|
|
1233
|
+
promptCacheActive: usage.cacheRead > 0 || usage.cacheWrite > 0,
|
|
1234
|
+
thinkingEnabled: effectiveThinkingLevel !== "off" && effectiveThinkingLevel !== "low",
|
|
1235
|
+
structuredOutputEnforced: !!options.outputSchema,
|
|
1236
|
+
subagentInvoked: false,
|
|
1237
|
+
mcpServersUsed: mcpClients.map((entry) => entry?.name).filter(Boolean),
|
|
1238
|
+
nativeSubagentsUsed: [],
|
|
1239
|
+
toolCompactionApplied: toolCompactionAppliedFromWarnings(runtimeWarnings),
|
|
1240
|
+
// Tristate: true = a compaction fired this run (proactive or reactive),
|
|
1241
|
+
// false = the path is enabled but did not need to fire, null = disabled via
|
|
1242
|
+
// agent_compaction_enabled. See docs/feature-registry.md runtime.context-compaction.
|
|
1243
|
+
contextCompactionApplied: compactionPolicy?.enabled ? contextCompactionApplied : null,
|
|
1244
|
+
});
|
|
1245
|
+
onEvent({ type: "capabilities_resolved", sdk: resolved.sdk, model: reference, capabilitiesUsed });
|
|
1246
|
+
|
|
1247
|
+
// Re-check the abort signal: a cancel can land during the post-run work above
|
|
1248
|
+
// (live-input teardown, structured-output finalization retry) after the line
|
|
1249
|
+
// ~780 check. Pick it up here so the lifecycle decision below rolls back the
|
|
1250
|
+
// cancelled turn instead of committing it into a durable transcript a later
|
|
1251
|
+
// resume would replay.
|
|
1252
|
+
externalAbort ||= !!options.abortSignal?.aborted;
|
|
1253
|
+
|
|
1254
|
+
// Session lifecycle parity with the legacy bridge. The harness already
|
|
1255
|
+
// durably persisted the transcript into its session object (in-memory for
|
|
1256
|
+
// the default repo, jsonl on disk when piSessionsRoot is set); the registry
|
|
1257
|
+
// tracks LIVENESS so disposeProviderSession / idle-TTL eviction can reach
|
|
1258
|
+
// native sessions, and a resume miss reports session_not_found.
|
|
1259
|
+
if (options.sessionKeepAlive === true && !externalAbort && !errorMessage) {
|
|
1260
|
+
try {
|
|
1261
|
+
if (sessionEntry) {
|
|
1262
|
+
// Resumed run: the harness appended this run's turns onto the live
|
|
1263
|
+
// session; just re-arm the idle window.
|
|
1264
|
+
nativeSessions.touch(requestedSessionId, { idleTimeoutMs: sessionTtlMs });
|
|
1265
|
+
// Surface a write failure the harness swallowed: a session that can
|
|
1266
|
+
// no longer persist must not pretend to be resumable.
|
|
1267
|
+
await session.buildContext();
|
|
1268
|
+
} else {
|
|
1269
|
+
const metadata = await session.getMetadata();
|
|
1270
|
+
nativeSessions.set(providerSessionId, {
|
|
1271
|
+
session,
|
|
1272
|
+
metadata,
|
|
1273
|
+
repo: durableRepo || nativeSessionRepo,
|
|
1274
|
+
durable: !!durableRepo,
|
|
1275
|
+
busy: false,
|
|
1276
|
+
}, { idleTimeoutMs: sessionTtlMs });
|
|
1277
|
+
}
|
|
1278
|
+
} catch (err) {
|
|
1279
|
+
// Session persistence must never fail the run; drop the (now
|
|
1280
|
+
// inconsistent) session instead of resuming from a broken transcript.
|
|
1281
|
+
onEvent({
|
|
1282
|
+
type: "runtime_warning",
|
|
1283
|
+
warning_kind: "pi_session_persist_failed",
|
|
1284
|
+
message: err?.message || String(err),
|
|
1285
|
+
});
|
|
1286
|
+
nativeSessions.delete(providerSessionId);
|
|
1287
|
+
if (requestedSessionId) nativeSessions.delete(requestedSessionId);
|
|
1288
|
+
const broken = sessionEntry;
|
|
1289
|
+
if (broken) {
|
|
1290
|
+
try { await broken.repo.delete(broken.metadata); } catch { /* best-effort */ }
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
} else if (sessionEntry) {
|
|
1294
|
+
// Resumed run that errored (or was aborted): roll the live session back to
|
|
1295
|
+
// the leaf captured before this turn so the failed turn never leaks into a
|
|
1296
|
+
// later resume. The next resume then sees the last good transcript. The
|
|
1297
|
+
// entry stays live (busy is cleared in finally) and its idle TTL re-arms.
|
|
1298
|
+
if (baselineLeafId && (errorMessage || externalAbort)) {
|
|
1299
|
+
try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
|
|
1300
|
+
}
|
|
1301
|
+
nativeSessions.touch(requestedSessionId, { idleTimeoutMs: sessionTtlMs });
|
|
1302
|
+
} else {
|
|
1303
|
+
// Fresh, non-keep-alive (or failed first) run: never leave a live session
|
|
1304
|
+
// behind. A durable jsonl transcript on disk is dropped too, matching the
|
|
1305
|
+
// legacy default contract that a non-keep-alive run is not resumable.
|
|
1306
|
+
// A create-on-miss BUSY placeholder (R8) is keyed under
|
|
1307
|
+
// requestedSessionId === providerSessionId; drop it here too so a
|
|
1308
|
+
// non-keep-alive / errored / aborted first turn never leaks a busy entry
|
|
1309
|
+
// (the success keep-alive path overwrites it with the finalized entry, so
|
|
1310
|
+
// it is only this drop branch that must clean it up).
|
|
1311
|
+
if (createdOnMissPlaceholder) nativeSessions.delete(providerSessionId);
|
|
1312
|
+
try {
|
|
1313
|
+
await (durableRepo || nativeSessionRepo).delete(await session.getMetadata());
|
|
1314
|
+
} catch { /* best-effort */ }
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Final abort guard (durable cancel TOCTOU): if a cancel raced the lifecycle
|
|
1318
|
+
// commit above — landing AFTER the keep-alive/!externalAbort decision but
|
|
1319
|
+
// before this return — the cancelled turn is still in the durable transcript
|
|
1320
|
+
// and (for keep-alive) the live registry. Roll it back so the next resume sees
|
|
1321
|
+
// the pre-turn state: a resumed session moves to its baseline leaf and drops
|
|
1322
|
+
// its live entry; a fresh durable session deletes its jsonl. There is no await
|
|
1323
|
+
// between here and the return, so an external cancel cannot newly fire past it.
|
|
1324
|
+
if (!externalAbort && options.abortSignal?.aborted) {
|
|
1325
|
+
externalAbort = true;
|
|
1326
|
+
if (sessionEntry) {
|
|
1327
|
+
if (baselineLeafId) {
|
|
1328
|
+
try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
|
|
1329
|
+
}
|
|
1330
|
+
nativeSessions.delete(requestedSessionId);
|
|
1331
|
+
} else {
|
|
1332
|
+
nativeSessions.delete(providerSessionId);
|
|
1333
|
+
try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
return {
|
|
1338
|
+
text: finalText,
|
|
1339
|
+
thinking: finalThinking,
|
|
1340
|
+
events,
|
|
1341
|
+
usage: {
|
|
1342
|
+
input_tokens: usage.input || null,
|
|
1343
|
+
output_tokens: usage.output || null,
|
|
1344
|
+
cache_read_tokens: usage.cacheRead || null,
|
|
1345
|
+
cache_creation_tokens: usage.cacheWrite || null,
|
|
1346
|
+
cache_write_tokens: usage.cacheWrite || null,
|
|
1347
|
+
cost_usd: usage.cost || estimatedCost,
|
|
1348
|
+
},
|
|
1349
|
+
durationMs: Date.now() - start,
|
|
1350
|
+
numTurns: turnCount || runAssistantCount,
|
|
1351
|
+
model: resolved.reference || `pi:${resolved.provider}:${resolved.model}`,
|
|
1352
|
+
effort: options.effort || null,
|
|
1353
|
+
sdk: resolved.sdk,
|
|
1354
|
+
cancelled: externalAbort,
|
|
1355
|
+
error: errorMessage,
|
|
1356
|
+
errorDetails,
|
|
1357
|
+
failureKind: errorMessage
|
|
1358
|
+
? failureKindForPiError(errorMessage, diagnostics, { maxTurnsHit })
|
|
1359
|
+
: null,
|
|
1360
|
+
providerSessionId,
|
|
1361
|
+
runtimeWarnings,
|
|
1362
|
+
diagnostics,
|
|
1363
|
+
capabilitiesUsed,
|
|
1364
|
+
...(structuredResult !== null && structuredResult !== undefined
|
|
1365
|
+
? { structuredResult, structuredResultSource: "StructuredOutput" }
|
|
1366
|
+
: { structuredResult: undefined, structuredResultSource: null }),
|
|
1367
|
+
};
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
externalAbort ||= !!options.abortSignal?.aborted;
|
|
1370
|
+
// Drop a just-created FRESH durable session so a setup/run failure does not
|
|
1371
|
+
// leave a resumable orphan jsonl on disk (the success path drops it at ~905;
|
|
1372
|
+
// the catch must mirror that). Guarded: `session && !sessionEntry` fires only
|
|
1373
|
+
// for fresh runs that actually created a session — NEVER for resumes
|
|
1374
|
+
// (sessionEntry is non-null only on resume; deleting a resumed user session
|
|
1375
|
+
// here would be data loss) and never when the throw preceded session create.
|
|
1376
|
+
if (session && !sessionEntry) {
|
|
1377
|
+
try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
|
|
1378
|
+
}
|
|
1379
|
+
// Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
|
|
1380
|
+
// during/after the reservation — including a throw inside the create await
|
|
1381
|
+
// itself, where `session` is still null so the jsonl-delete above is skipped.
|
|
1382
|
+
// Keyed under requestedSessionId === providerSessionId; never set on a resume
|
|
1383
|
+
// (sessionEntry would be non-null), so this never deletes a live user session.
|
|
1384
|
+
if (createdOnMissPlaceholder && !sessionEntry) nativeSessions.delete(providerSessionId);
|
|
1385
|
+
// Resumed-session rollback for host/runtime-side throws (e.g. a throwing
|
|
1386
|
+
// custom pricing resolver / bridge event callback) that land here AFTER the
|
|
1387
|
+
// harness already mutated the live session. Mirrors the success-path
|
|
1388
|
+
// rollback at ~:925-932: move the live session back to the pre-turn leaf so
|
|
1389
|
+
// the failed turn never leaks into a later resume. Gated on `sessionEntry &&
|
|
1390
|
+
// baselineLeafId` so it only fires for resumes that captured a baseline.
|
|
1391
|
+
if (sessionEntry && baselineLeafId) {
|
|
1392
|
+
try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
|
|
1393
|
+
}
|
|
1394
|
+
const errorMessage = normalizePiErrorMessage(err?.message || String(err));
|
|
1395
|
+
const isRetryable = retryableProviderFailureInfo({
|
|
1396
|
+
errorText: errorMessage,
|
|
1397
|
+
failureKind: "provider_unavailable",
|
|
1398
|
+
}).retryable;
|
|
1399
|
+
return {
|
|
1400
|
+
text: assistantTexts.join("") || null,
|
|
1401
|
+
events,
|
|
1402
|
+
usage: {},
|
|
1403
|
+
durationMs: Date.now() - start,
|
|
1404
|
+
numTurns: turnCount,
|
|
1405
|
+
model: resolved?.reference || resolved?.model || null,
|
|
1406
|
+
effort: options.effort || null,
|
|
1407
|
+
sdk: resolved?.sdk || "pi",
|
|
1408
|
+
cancelled: externalAbort,
|
|
1409
|
+
error: externalAbort ? null : errorMessage,
|
|
1410
|
+
errorDetails: externalAbort ? null : {
|
|
1411
|
+
pi_stop_reason: "error",
|
|
1412
|
+
last_tool_name: lastToolName,
|
|
1413
|
+
tool_results_seen: toolResultsSeen,
|
|
1414
|
+
turn_count: turnCount,
|
|
1415
|
+
max_turns_hit: maxTurnsHit,
|
|
1416
|
+
provider_session_id: providerSessionId,
|
|
1417
|
+
pi_engine: "native",
|
|
1418
|
+
pi_error_retryable: isRetryable,
|
|
1419
|
+
},
|
|
1420
|
+
failureKind: externalAbort ? null : failureKindForPiError(errorMessage, {}, { maxTurnsHit }),
|
|
1421
|
+
providerSessionId,
|
|
1422
|
+
runtimeWarnings,
|
|
1423
|
+
diagnostics: {
|
|
1424
|
+
provider_session_id: providerSessionId,
|
|
1425
|
+
pi_stop_reason: externalAbort ? "aborted" : "error",
|
|
1426
|
+
pi_engine: "native",
|
|
1427
|
+
max_turns_hit: maxTurnsHit,
|
|
1428
|
+
turn_count: turnCount,
|
|
1429
|
+
external_abort: externalAbort,
|
|
1430
|
+
},
|
|
1431
|
+
};
|
|
1432
|
+
} finally {
|
|
1433
|
+
if (sessionEntry) sessionEntry.busy = false;
|
|
1434
|
+
removeAbortHandler?.();
|
|
1435
|
+
await closePiMcpClients(mcpClients);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
export const piNativeRuntimeBridge = {
|
|
1440
|
+
id: "pi",
|
|
1441
|
+
kind: "pi",
|
|
1442
|
+
capabilities: runtimeCapabilities("pi"),
|
|
1443
|
+
supports: (ref) => ref?.sdk === "pi",
|
|
1444
|
+
execute: generatePiNativeResponse,
|
|
1445
|
+
};
|