@bevel-software/ael 0.0.0-stage → 0.1.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/LICENSE +13 -0
- package/README.md +32 -3
- package/bin/bevel-agent.mjs +15 -0
- package/executor-entrypoint.sh +41 -0
- package/executor.Dockerfile +37 -0
- package/package.json +61 -6
- package/src/agent/agent-file.ts +242 -0
- package/src/cli/main.ts +73 -0
- package/src/cli/run-local.ts +224 -0
- package/src/cli/run-step.ts +147 -0
- package/src/executor/activities.ts +295 -0
- package/src/executor/docker-cli.ts +148 -0
- package/src/executor/driver.ts +80 -0
- package/src/executor/harness-mount.ts +66 -0
- package/src/executor/in-place.ts +180 -0
- package/src/executor/main.ts +197 -0
- package/src/executor/personal.ts +355 -0
- package/src/executor/slots.ts +60 -0
- package/src/harness/claude-code.ts +397 -0
- package/src/harness/pi-in-process.ts +444 -0
- package/src/harness/session-state.ts +86 -0
- package/src/harness/stall-guard.ts +97 -0
- package/src/index.ts +164 -0
- package/src/interpreter/contract.ts +403 -0
- package/src/interpreter/dispatcher.workflow.ts +412 -0
- package/src/interpreter/interpret.workflow.ts +907 -0
- package/src/interpreter/scheduled.workflow.ts +28 -0
- package/src/interpreter/workflows.ts +8 -0
- package/src/run/contract.ts +150 -0
- package/src/run/outcome.ts +63 -0
- package/src/run/reporter.ts +127 -0
- package/src/run/system-prompt.ts +35 -0
- package/src/run/verdict.ts +46 -0
- package/src/shared/front-matter.ts +27 -0
- package/src/shared/redact.ts +78 -0
- package/src/shared/schedule.ts +93 -0
- package/src/temporal/payload-codec.ts +84 -0
- package/src/transcript/render.ts +106 -0
- package/src/workflow/definition.ts +196 -0
- package/src/workflow/failure-policy.ts +100 -0
- package/src/workflow/parameters.ts +175 -0
- package/src/workflow/workflow-file.ts +457 -0
- package/src/workitem/node.ts +170 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { AsyncResource } from 'node:async_hooks';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { InMemoryCredentialStore } from '@earendil-works/pi-ai';
|
|
5
|
+
import {
|
|
6
|
+
createAgentSession,
|
|
7
|
+
createMcpExtension,
|
|
8
|
+
DefaultResourceLoader,
|
|
9
|
+
getPackageDir,
|
|
10
|
+
ModelRuntime,
|
|
11
|
+
SessionManager,
|
|
12
|
+
SettingsManager,
|
|
13
|
+
type AgentSession,
|
|
14
|
+
type AgentSessionEvent,
|
|
15
|
+
} from '@earendil-works/pi-coding-agent';
|
|
16
|
+
import { MCP_SERVER_NAME, type Harness, type RunEvent, type SessionResult, type SessionStartParams, type SessionState, type Verdict } from '../run/contract.js';
|
|
17
|
+
import { VERDICT_NUDGE, parseOutputs, parseVerdict } from '../run/verdict.js';
|
|
18
|
+
import { stallGuardedFetch } from './stall-guard.js';
|
|
19
|
+
import { pruneSessionEntries } from './session-state.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* pi as a library, inside the platform process: the harness of
|
|
23
|
+
* `environment: none`. No shell, no filesystem, no files of its own; the
|
|
24
|
+
* model through the platform's gateway with the run key; tools through the
|
|
25
|
+
* platform's MCP endpoint as the actor; the session state exported after
|
|
26
|
+
* every turn and restored from the thread on resume.
|
|
27
|
+
*
|
|
28
|
+
* Verified by the phase 0 spike (ticket `bp-workflows-phase-0-pi-in-process`).
|
|
29
|
+
* Two facts from it shape this file: `noTools: "all"` drops custom tools too,
|
|
30
|
+
* so the built-in tools are registered under `"builtin"` and never activated;
|
|
31
|
+
* and the in-memory MCP credential store is not exported by pi's index, so it
|
|
32
|
+
* is reached by file URL (an upstream request is open).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface PiInProcessOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Activate pi's built-in tools (shell, files) too. Off in the platform
|
|
38
|
+
* process, where a run has no shell by design; on inside a run container,
|
|
39
|
+
* where the workspace is the session's to use.
|
|
40
|
+
*/
|
|
41
|
+
builtinTools?: boolean;
|
|
42
|
+
/** How long the MCP extension may take to connect before the first prompt. */
|
|
43
|
+
mcpStartupWaitMs?: number;
|
|
44
|
+
/** Default step budget when the caller gives none. */
|
|
45
|
+
defaultTimeoutMs?: number;
|
|
46
|
+
/** The nudge is one short turn; it never gets a step's full budget. */
|
|
47
|
+
verdictNudgeTimeoutMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STATE_VERSION = 1;
|
|
51
|
+
/** pi spells a server's tools `mcp__<name>__<tool>` with the name's other characters as `_`: `knowledge-base` becomes `knowledge_base`. */
|
|
52
|
+
const PI_MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME.replace(/[^A-Za-z0-9_]/g, '_')}__`;
|
|
53
|
+
const BUILTIN_TOOLS = new Set(['read', 'bash', 'powershell', 'edit', 'write', 'grep', 'find', 'ls']);
|
|
54
|
+
const AUTH_REFUSED = /\b40[13]\b|unauthori[sz]ed|invalid[_ ]api[_ ]key|incorrect api key|api key (is )?(missing|not set|required)|no api key/i;
|
|
55
|
+
const RATE_LIMITED = /\b429\b|rate.?limit|too many requests|quota|usage limit|session limit/i;
|
|
56
|
+
|
|
57
|
+
// pi's package index exports neither; the extension's own credential store
|
|
58
|
+
// would otherwise write `<agentDir>/mcp-auth.json`. Loaded once, lazily.
|
|
59
|
+
type McpCredentialStoreCtor = new (backend: unknown) => unknown;
|
|
60
|
+
type AuthBackendCtor = new () => unknown;
|
|
61
|
+
let piInternals: Promise<{ McpOAuthCredentialStore: McpCredentialStoreCtor; InMemoryAuthStorageBackend: AuthBackendCtor }> | undefined;
|
|
62
|
+
function loadPiInternals() {
|
|
63
|
+
piInternals ??= (async () => {
|
|
64
|
+
const dist = path.join(getPackageDir(), 'dist');
|
|
65
|
+
const oauth = (await import(pathToFileURL(path.join(dist, 'extensions/mcp/oauth.js')).href)) as { McpOAuthCredentialStore: McpCredentialStoreCtor };
|
|
66
|
+
const auth = (await import(pathToFileURL(path.join(dist, 'core/auth-storage.js')).href)) as { InMemoryAuthStorageBackend: AuthBackendCtor };
|
|
67
|
+
return { McpOAuthCredentialStore: oauth.McpOAuthCredentialStore, InMemoryAuthStorageBackend: auth.InMemoryAuthStorageBackend };
|
|
68
|
+
})();
|
|
69
|
+
return piInternals;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const now = () => new Date().toISOString();
|
|
73
|
+
|
|
74
|
+
/** What a session is told when its last message had nothing in it: the model answered with reasoning alone, and the loop took that for its end. */
|
|
75
|
+
const EMPTY_NUDGE =
|
|
76
|
+
'Your last message was empty: it said nothing and called no tool. Go on with the work where you left off, and end with what you did, or that there was nothing to do, in one line.';
|
|
77
|
+
|
|
78
|
+
/** Whether the session's last message is an assistant message with nothing in it: no text, no tool call, ended by the model itself. */
|
|
79
|
+
function endedEmpty(session: AgentSession): boolean {
|
|
80
|
+
const messages = session.agent.state.messages as ReadonlyArray<{ role: string; stopReason?: string; content?: ReadonlyArray<{ type: string; text?: string }> }>;
|
|
81
|
+
const last = messages[messages.length - 1];
|
|
82
|
+
if (!last || last.role !== 'assistant' || last.stopReason === 'error' || last.stopReason === 'aborted') return false;
|
|
83
|
+
return !(last.content ?? []).some((c) => c.type === 'toolCall' || (c.type === 'text' && c.text?.trim()));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Why the session's last assistant message ended, when that was not the model's own end: the error pi gave up on, or an abort. */
|
|
87
|
+
function stoppedBy(session: AgentSession): string | undefined {
|
|
88
|
+
const messages = session.agent.state.messages as ReadonlyArray<{ role: string; stopReason?: string; errorMessage?: string }>;
|
|
89
|
+
const last = [...messages].reverse().find((m) => m.role === 'assistant');
|
|
90
|
+
if (last?.stopReason === 'error') return last.errorMessage?.trim() || 'the model answered with an error';
|
|
91
|
+
if (last?.stopReason === 'aborted') return 'the session was aborted before it answered';
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The reasoning summary an assistant message carries (pi's thinking parts), in one text; empty for any other message, or one with none. */
|
|
96
|
+
function reasoningOf(message: unknown): string {
|
|
97
|
+
const m = message as { role?: string; content?: unknown } | null;
|
|
98
|
+
if (m?.role !== 'assistant' || !Array.isArray(m.content)) return '';
|
|
99
|
+
return (m.content as ReadonlyArray<{ type?: string; thinking?: unknown }>)
|
|
100
|
+
.flatMap((c) => (c.type === 'thinking' && typeof c.thinking === 'string' && c.thinking.trim() ? [c.thinking.trim()] : []))
|
|
101
|
+
.join('\n\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `provider/id[:thinking]` as a configuration writes it. */
|
|
105
|
+
function splitModel(model: string): { provider: string; id: string; thinking?: string } {
|
|
106
|
+
const [spec, thinking] = model.split(':');
|
|
107
|
+
const slash = spec.indexOf('/');
|
|
108
|
+
if (slash === -1) return { provider: 'gateway', id: spec, ...(thinking ? { thinking } : {}) };
|
|
109
|
+
return { provider: spec.slice(0, slash), id: spec.slice(slash + 1), ...(thinking ? { thinking } : {}) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `"$NAME"` values in a `providers:` block are read from the session's environment. */
|
|
113
|
+
function resolveEnvRefs(value: unknown, env: Record<string, string>): unknown {
|
|
114
|
+
if (typeof value === 'string') {
|
|
115
|
+
const m = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value.trim());
|
|
116
|
+
return m ? (env[m[1]] ?? '') : value;
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(value)) return value.map((v) => resolveEnvRefs(v, env));
|
|
119
|
+
if (value && typeof value === 'object') {
|
|
120
|
+
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, resolveEnvRefs(v, env)]));
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class PiInProcessHarness implements Harness {
|
|
126
|
+
readonly name = 'pi';
|
|
127
|
+
|
|
128
|
+
constructor(private readonly options: PiInProcessOptions = {}) {}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The sessions running in this process, by the caller's key: where a message that arrives mid-run is handed. `inRun`
|
|
132
|
+
* runs a function in the run's own async context: the caller's event handler heartbeats the workflow engine, which
|
|
133
|
+
* answers only inside the activity that started the run ("Activity context not initialized" from a chat request).
|
|
134
|
+
*/
|
|
135
|
+
private readonly live = new Map<string, { session: AgentSession; emit: (event: RunEvent) => void; inRun: <T>(work: () => T) => T }>();
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Hand a message to a session still working under `runKey`: pi queues it as a follow-up and answers it in the same
|
|
139
|
+
* run, after the turn in progress, so the person gets one reply for both. On 8 Nov 2026 "no, i want rich widgets"
|
|
140
|
+
* and "in this chat" came seconds apart and were answered twice, the second answer a repeat of the first.
|
|
141
|
+
*/
|
|
142
|
+
async followUp(runKey: string, text: string): Promise<'queued' | 'absent'> {
|
|
143
|
+
const entry = this.live.get(runKey);
|
|
144
|
+
if (!entry || !entry.session.isStreaming) {
|
|
145
|
+
console.log(`[ael] follow-up for ${runKey}: ${!entry ? `no live session under that key (live: ${[...this.live.keys()].join(', ') || 'none'})` : 'the session is not working any more'}`);
|
|
146
|
+
return 'absent';
|
|
147
|
+
}
|
|
148
|
+
await entry.inRun(async () => {
|
|
149
|
+
entry.emit({ type: 'user', at: now(), text });
|
|
150
|
+
await entry.session.prompt(text, { streamingBehavior: 'followUp' } as never);
|
|
151
|
+
});
|
|
152
|
+
return 'queued';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One run of the configuration: build or restore the session, send the input, and store the
|
|
157
|
+
* state after every turn. An empty last reply, or a missing verdict the step needs, gets one
|
|
158
|
+
* short turn more in the same session. While it works, `followUp` reaches it under its run key.
|
|
159
|
+
*/
|
|
160
|
+
async run(p: SessionStartParams): Promise<SessionResult> {
|
|
161
|
+
const events: RunEvent[] = [];
|
|
162
|
+
const emit = (event: RunEvent) => {
|
|
163
|
+
events.push(event);
|
|
164
|
+
p.onEvent?.(event);
|
|
165
|
+
};
|
|
166
|
+
const env = p.env ?? {};
|
|
167
|
+
const { session, sessionManager, modelName, thinking } = await this.buildSession(p, env);
|
|
168
|
+
if (p.runKey) this.live.set(p.runKey, { session, emit, inRun: AsyncResource.bind(<T>(work: () => T): T => work()) as <T>(work: () => T) => T });
|
|
169
|
+
let started = false;
|
|
170
|
+
let finalText = '';
|
|
171
|
+
let lastError: string | undefined;
|
|
172
|
+
|
|
173
|
+
const unsubscribe = session.subscribe((ev: AgentSessionEvent) => {
|
|
174
|
+
switch (ev.type) {
|
|
175
|
+
case 'tool_execution_start':
|
|
176
|
+
started = true;
|
|
177
|
+
emit({ type: 'tool_call', at: now(), id: ev.toolCallId, name: ev.toolName, input: ev.args });
|
|
178
|
+
break;
|
|
179
|
+
case 'tool_execution_end':
|
|
180
|
+
emit({ type: 'tool_result', at: now(), id: ev.toolCallId, name: ev.toolName, output: ev.result, isError: ev.isError });
|
|
181
|
+
break;
|
|
182
|
+
case 'message_update':
|
|
183
|
+
started = true;
|
|
184
|
+
break;
|
|
185
|
+
case 'message_end': {
|
|
186
|
+
// The summary of what the model weighed for this message, ahead of the tools it then calls.
|
|
187
|
+
const text = reasoningOf(ev.message);
|
|
188
|
+
if (text) emit({ type: 'reasoning', at: now(), text });
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'agent_end': {
|
|
192
|
+
const error = (ev as { error?: unknown }).error;
|
|
193
|
+
if (error) lastError = String(error instanceof Error ? error.message : error);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
default:
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const timeoutMs = p.timeoutMs ?? this.options.defaultTimeoutMs ?? 30 * 60_000;
|
|
202
|
+
let timedOut = false;
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
timedOut = true;
|
|
205
|
+
void session.abort();
|
|
206
|
+
}, timeoutMs);
|
|
207
|
+
// A cancelled run: the session is aborted where it is, as on timeout.
|
|
208
|
+
if (p.signal?.aborted) void session.abort();
|
|
209
|
+
else p.signal?.addEventListener('abort', () => void session.abort(), { once: true });
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const tools = await this.connectMcp(session, p.agent.harnessTools);
|
|
213
|
+
// What the session runs with, in the platform's log: the one place that says which model, which thinking level, how big a prompt, how many tools.
|
|
214
|
+
console.log(`[ael] session on ${modelName}, thinking ${thinking}, ${tools.length} tools, system prompt ${p.systemPrompt.length} chars, input ${(p.input.text ?? '').length} chars`);
|
|
215
|
+
emit({ type: 'session_started', at: now(), harness: this.name, model: modelName, sessionId: sessionManager.getSessionId(), resumed: !!p.state, tools });
|
|
216
|
+
const storeState = () => p.onState?.(exportState(sessionManager));
|
|
217
|
+
emit({ type: 'user', at: now(), text: p.input.text });
|
|
218
|
+
await this.turn(session, p.input.text, p.input.images, storeState);
|
|
219
|
+
// A last message with nothing in it (the model answered with reasoning
|
|
220
|
+
// alone, which some gateways return with empty content) is not the end
|
|
221
|
+
// of the work: one short turn asks for the rest, and the work before it is kept.
|
|
222
|
+
if (!timedOut && !lastError && endedEmpty(session)) {
|
|
223
|
+
emit({ type: 'user', at: now(), text: EMPTY_NUDGE });
|
|
224
|
+
await this.turn(session, EMPTY_NUDGE, undefined, storeState);
|
|
225
|
+
}
|
|
226
|
+
finalText = session.getLastAssistantText() ?? '';
|
|
227
|
+
if (finalText) emit({ type: 'assistant', at: now(), text: finalText });
|
|
228
|
+
// How the loop ended is on its last assistant message: a model error pi
|
|
229
|
+
// gave up on, or an abort, is not the model's own end, whatever text
|
|
230
|
+
// came before it, and the step must not pass on it.
|
|
231
|
+
const stopped = stoppedBy(session);
|
|
232
|
+
if (stopped && !timedOut && !lastError) lastError = stopped;
|
|
233
|
+
|
|
234
|
+
let verdict: Verdict | undefined = parseVerdict(finalText);
|
|
235
|
+
if (p.expectVerdict && !verdict && !timedOut && !lastError) {
|
|
236
|
+
// One short turn in the same session, asking for the sentence the
|
|
237
|
+
// interpreter routes on; the finished work is never thrown away.
|
|
238
|
+
emit({ type: 'user', at: now(), text: VERDICT_NUDGE });
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
const nudgeTimer = setTimeout(() => void session.abort(), this.options.verdictNudgeTimeoutMs ?? 5 * 60_000);
|
|
241
|
+
try {
|
|
242
|
+
await this.turn(session, VERDICT_NUDGE, undefined, storeState);
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(nudgeTimer);
|
|
245
|
+
}
|
|
246
|
+
const asked = session.getLastAssistantText() ?? '';
|
|
247
|
+
if (asked) emit({ type: 'assistant', at: now(), text: asked });
|
|
248
|
+
verdict = parseVerdict(asked);
|
|
249
|
+
if (verdict) finalText = `${finalText}\n\n${asked}`;
|
|
250
|
+
}
|
|
251
|
+
const outputs = parseOutputs(finalText) ?? undefined;
|
|
252
|
+
if (verdict) emit({ type: 'verdict', at: now(), verdict });
|
|
253
|
+
if (outputs) emit({ type: 'outputs', at: now(), values: outputs });
|
|
254
|
+
|
|
255
|
+
const accountLimit = lastError ? classifyAccountLimit(lastError) : undefined;
|
|
256
|
+
if (lastError && !accountLimit) emit({ type: 'error', at: now(), message: lastError });
|
|
257
|
+
const ok = !timedOut && !lastError;
|
|
258
|
+
const state = exportState(sessionManager);
|
|
259
|
+
emit({ type: 'session_ended', at: now(), ok, finalText });
|
|
260
|
+
return {
|
|
261
|
+
ok,
|
|
262
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
263
|
+
finalText,
|
|
264
|
+
...(verdict ? { verdict } : {}),
|
|
265
|
+
...(outputs ? { outputs } : {}),
|
|
266
|
+
...(accountLimit ? { accountLimit } : {}),
|
|
267
|
+
...(!started && !finalText ? { sessionNotStarted: true } : {}),
|
|
268
|
+
state,
|
|
269
|
+
events,
|
|
270
|
+
};
|
|
271
|
+
} finally {
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
unsubscribe();
|
|
274
|
+
if (p.runKey && this.live.get(p.runKey)?.session === session) this.live.delete(p.runKey);
|
|
275
|
+
// `dispose()` alone leaves the MCP servers connected: the extension closes them on
|
|
276
|
+
// `session_shutdown`. A local server is a child process whose open pipe would keep
|
|
277
|
+
// the step's process alive past its session, until the executor's timeout.
|
|
278
|
+
await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }).catch(() => undefined);
|
|
279
|
+
session.dispose();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async turn(session: AgentSession, text: string, images?: SessionStartParams['input']['images'], onState?: () => void): Promise<void> {
|
|
284
|
+
const imageParts = (images ?? []).map((i) => ({ type: 'image' as const, data: i.dataBase64, mimeType: i.mediaType }));
|
|
285
|
+
await session.prompt(text, imageParts.length > 0 ? ({ images: imageParts } as never) : undefined);
|
|
286
|
+
await session.waitForIdle();
|
|
287
|
+
// The state after the turn, whatever pi emitted on the way: a turn that
|
|
288
|
+
// ends is the unit the thread stores, so the next turn or a resumed step
|
|
289
|
+
// starts from here.
|
|
290
|
+
onState?.();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `harnessTools` is the configuration's allowlist of built-in tools: with
|
|
295
|
+
* one, a container session gets exactly those (a read-only configuration
|
|
296
|
+
* gets no `bash`); without one, every built-in. In process the built-ins
|
|
297
|
+
* are never on, whatever the list says.
|
|
298
|
+
*/
|
|
299
|
+
private async connectMcp(session: AgentSession, harnessTools: readonly string[]): Promise<string[]> {
|
|
300
|
+
// Binding the extensions emits session_start, which connects the MCP
|
|
301
|
+
// servers in the background; their tools appear once connected. Poll
|
|
302
|
+
// briefly for them rather than sleeping the whole window.
|
|
303
|
+
await session.bindExtensions({});
|
|
304
|
+
const deadline = Date.now() + (this.options.mcpStartupWaitMs ?? 5000);
|
|
305
|
+
const builtinAllowed = (name: string) => this.options.builtinTools === true && (harnessTools.length === 0 || harnessTools.includes(name));
|
|
306
|
+
let active: string[] = [];
|
|
307
|
+
const activate = () => {
|
|
308
|
+
active = session.getAllTools().map((t) => t.name).filter((n) => (BUILTIN_TOOLS.has(n) ? builtinAllowed(n) : true));
|
|
309
|
+
session.setActiveToolsByName(active);
|
|
310
|
+
return active.some((n) => n.startsWith(PI_MCP_TOOL_PREFIX));
|
|
311
|
+
};
|
|
312
|
+
while (!activate() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
|
|
313
|
+
return active;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The pi session a run works in: the platform gateway always registered with the run key, the
|
|
318
|
+
* configuration's own providers beside it, tools from the platform's MCP endpoint, and the thread's
|
|
319
|
+
* earlier state restored when there is one. A model no provider offers throws here, before the run starts.
|
|
320
|
+
*/
|
|
321
|
+
private async buildSession(p: SessionStartParams, env: Record<string, string>) {
|
|
322
|
+
const { McpOAuthCredentialStore, InMemoryAuthStorageBackend } = await loadPiInternals();
|
|
323
|
+
const modelRuntime = await ModelRuntime.create({
|
|
324
|
+
credentials: new InMemoryCredentialStore(),
|
|
325
|
+
modelsPath: null,
|
|
326
|
+
allowModelNetwork: false,
|
|
327
|
+
refreshOnCreate: false,
|
|
328
|
+
});
|
|
329
|
+
// The deployment's gateway is always registered: a configuration without
|
|
330
|
+
// a model runs on it, authenticated with the run key, so usage is metered
|
|
331
|
+
// to the actor like every other call.
|
|
332
|
+
modelRuntime.registerProvider('gateway', {
|
|
333
|
+
name: 'Platform gateway',
|
|
334
|
+
baseUrl: p.platform.gatewayUrl,
|
|
335
|
+
apiKey: p.actorKey.token,
|
|
336
|
+
api: 'openai-completions',
|
|
337
|
+
models: [gatewayModel(p.platform.gatewayModel, p.platform.gatewayContextWindow)],
|
|
338
|
+
});
|
|
339
|
+
// A gateway call that never answers ends after GATEWAY_STALL_MS and pi retries it, instead of the five-minute
|
|
340
|
+
// silence of 8 Nov 2026 (see stall-guard.ts). Pi takes the fetch per request, so it rides on every stream.
|
|
341
|
+
const guardedFetch = stallGuardedFetch(globalThis.fetch, { headersMs: GATEWAY_STALL_MS, bodyMs: GATEWAY_STALL_MS });
|
|
342
|
+
const streamSimple = modelRuntime.streamSimple.bind(modelRuntime);
|
|
343
|
+
modelRuntime.streamSimple = (model, context, options) =>
|
|
344
|
+
streamSimple(model, context, model.provider === 'gateway' ? { ...options, fetch: guardedFetch } : options);
|
|
345
|
+
for (const [providerId, config] of Object.entries(p.agent.providers ?? {})) {
|
|
346
|
+
modelRuntime.registerProvider(providerId, resolveEnvRefs(config, env) as never);
|
|
347
|
+
}
|
|
348
|
+
const spec = splitModel(p.agent.model ?? `gateway/${p.platform.gatewayModel}`);
|
|
349
|
+
const model = modelRuntime.getModel(spec.provider, spec.id);
|
|
350
|
+
if (!model) throw new Error(`configuration "${p.agent.id}" names model ${spec.provider}/${spec.id}, which no provider offers`);
|
|
351
|
+
|
|
352
|
+
const cwd = p.cwd ?? process.cwd();
|
|
353
|
+
const agentDir = path.join(cwd, '.pi-agent-dir-unused');
|
|
354
|
+
const settingsManager = SettingsManager.inMemory({ compaction: { enabled: true }, retry: { enabled: true, maxRetries: 2 } });
|
|
355
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
356
|
+
cwd,
|
|
357
|
+
agentDir,
|
|
358
|
+
settingsManager,
|
|
359
|
+
noSkills: true,
|
|
360
|
+
noPromptTemplates: true,
|
|
361
|
+
noThemes: true,
|
|
362
|
+
noContextFiles: true,
|
|
363
|
+
systemPrompt: p.systemPrompt,
|
|
364
|
+
extensionFactories: [
|
|
365
|
+
createMcpExtension({
|
|
366
|
+
loadConfig: () => ({
|
|
367
|
+
servers: [
|
|
368
|
+
{
|
|
369
|
+
name: MCP_SERVER_NAME,
|
|
370
|
+
source: 'platform',
|
|
371
|
+
// In a run container the local Hexis MCP server (local tools run there, git among them); in the platform process its endpoint.
|
|
372
|
+
config: p.platform.localMcp
|
|
373
|
+
? { type: 'stdio', command: p.platform.localMcp.command, args: p.platform.localMcp.args, env: p.platform.localMcp.env, exposure: 'direct' }
|
|
374
|
+
: { url: p.platform.mcpUrl, headers: { Authorization: `Bearer ${p.actorKey.token}` }, exposure: 'direct' },
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
errors: [],
|
|
378
|
+
}),
|
|
379
|
+
credentials: new McpOAuthCredentialStore(new InMemoryAuthStorageBackend()) as never,
|
|
380
|
+
startupWaitMs: this.options.mcpStartupWaitMs ?? 5000,
|
|
381
|
+
}),
|
|
382
|
+
],
|
|
383
|
+
});
|
|
384
|
+
await resourceLoader.reload();
|
|
385
|
+
|
|
386
|
+
// A state from an earlier run, pruned as it is stored (session-state.ts): one copy of the tool list, no pictures.
|
|
387
|
+
const restored = p.state && p.state.harness === this.name && Array.isArray(p.state.data) ? (pruneSessionEntries(p.state.data) as never[]) : undefined;
|
|
388
|
+
const sessionManager = restored ? SessionManager.inMemory(cwd, undefined, restored) : SessionManager.inMemory(cwd);
|
|
389
|
+
// The deployment's thinking level is the gateway model's default only: a model on any other provider runs off unless the configuration names a level.
|
|
390
|
+
const thinking = spec.thinking ?? (spec.provider === 'gateway' ? p.platform.gatewayThinking : undefined) ?? 'off';
|
|
391
|
+
|
|
392
|
+
const { session } = await createAgentSession({
|
|
393
|
+
cwd,
|
|
394
|
+
agentDir,
|
|
395
|
+
model,
|
|
396
|
+
thinkingLevel: thinking as never,
|
|
397
|
+
modelRuntime,
|
|
398
|
+
resourceLoader,
|
|
399
|
+
settingsManager,
|
|
400
|
+
sessionManager,
|
|
401
|
+
// Built-in tools stay registered; in the platform process they are
|
|
402
|
+
// never activated (no shell, no filesystem by design), in a run
|
|
403
|
+
// container `builtinTools` turns them on.
|
|
404
|
+
noTools: 'builtin',
|
|
405
|
+
customTools: [],
|
|
406
|
+
});
|
|
407
|
+
return { session, sessionManager, modelName: `${spec.provider}/${spec.id}`, thinking };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** No response headers, or no byte of a started reply, for this long: the call is given up and retried. */
|
|
412
|
+
const GATEWAY_STALL_MS = 90_000;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The gateway's model as pi takes one. The gateway names a handle, not a model, so reasoning is offered
|
|
416
|
+
* whatever the model behind it, and the window is the deployment's when it gives one, else 200k tokens.
|
|
417
|
+
*/
|
|
418
|
+
function gatewayModel(id: string, contextWindow?: number) {
|
|
419
|
+
return {
|
|
420
|
+
type: 'chat' as const,
|
|
421
|
+
id,
|
|
422
|
+
name: id,
|
|
423
|
+
// The gateway is a handle (`default`), so support cannot be read off the id: reasoning is offered, and a
|
|
424
|
+
// model that takes no `reasoning_effort` has the gateway drop it (litellm `drop_params`). Off before
|
|
425
|
+
// 2026-11-06, which ran every gateway session, gpt-5.1 included, without any reasoning at all.
|
|
426
|
+
reasoning: true,
|
|
427
|
+
input: ['text' as const, 'image' as const],
|
|
428
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
429
|
+
// The model's real window, when the deployment says it (gpt-6-luna takes 922k, gpt-5.1 272k): pi cuts the output
|
|
430
|
+
// budget to what fits under it, and a window smaller than the model's sent max_output_tokens: 1 on 7 Nov 2026.
|
|
431
|
+
contextWindow: contextWindow && contextWindow > 0 ? contextWindow : 200_000,
|
|
432
|
+
maxTokens: 16_384,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function exportState(sessionManager: SessionManager): SessionState {
|
|
437
|
+
return { harness: 'pi', version: STATE_VERSION, data: pruneSessionEntries([sessionManager.getHeader(), ...sessionManager.getEntries()]) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function classifyAccountLimit(text: string): SessionResult['accountLimit'] {
|
|
441
|
+
if (RATE_LIMITED.test(text)) return { kind: 'session-limit', text };
|
|
442
|
+
if (AUTH_REFUSED.test(text)) return { kind: 'not-logged-in', text };
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a session's stored state keeps between runs. A conversation that
|
|
3
|
+
* continues over many messages carries its pi session entries from run to
|
|
4
|
+
* run, and two things made that grow without bound on 7 Nov 2026 (6.6 MB
|
|
5
|
+
* after thirty messages, and the model's output budget clamped to one token
|
|
6
|
+
* by the context estimate): pi appends a system entry listing every tool at
|
|
7
|
+
* each session start, since the MCP tools arrive after the session opens, so
|
|
8
|
+
* thirty identical copies of 66 tool definitions sat in the state; and a
|
|
9
|
+
* screenshot the agent read stayed in it as 750 KB of base64, which the
|
|
10
|
+
* token estimate counts as a quarter of a million tokens. Neither is needed
|
|
11
|
+
* after the run: the model keeps the latest tool list, and a picture it saw
|
|
12
|
+
* once is read again when it is needed.
|
|
13
|
+
*
|
|
14
|
+
* A tool-list entry is dropped only when it changes nothing: replayed in order, as pi replays them, the tools in
|
|
15
|
+
* place before it and after it are the same. The first declaration stays where it was, before the calls that use it,
|
|
16
|
+
* and a tool removed and added again keeps each step, which a model shown tool changes in place reads in order.
|
|
17
|
+
*
|
|
18
|
+
* Entries form a parent-linked chain (`id`, `parentId`); a pruned entry's
|
|
19
|
+
* children are linked to its parent, so the chain stays whole.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface Entry {
|
|
23
|
+
id?: string;
|
|
24
|
+
parentId?: string | null;
|
|
25
|
+
type?: string;
|
|
26
|
+
message?: { role?: string; sections?: unknown; toolsAdded?: unknown; toolsRemoved?: unknown; content?: unknown };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const KB = 1024;
|
|
30
|
+
|
|
31
|
+
/** The one-line stand-in for an image the model was shown in an earlier turn. */
|
|
32
|
+
export function imageStandIn(mimeType: string | undefined, bytes: number): { type: 'text'; text: string } {
|
|
33
|
+
const kind = mimeType && typeof mimeType === 'string' ? mimeType : 'image';
|
|
34
|
+
return { type: 'text', text: `[An image (${kind}, ${Math.max(1, Math.round((bytes * 3) / 4 / KB))} KB) was shown here in an earlier turn; read the file again to see it.]` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The tools in place after a system entry, by name, as pi replays them: its removals, then its additions. */
|
|
38
|
+
function withToolChanges(tools: ReadonlyMap<string, string>, m: NonNullable<Entry['message']>): Map<string, string> {
|
|
39
|
+
const next = new Map(tools);
|
|
40
|
+
for (const t of Array.isArray(m.toolsRemoved) ? (m.toolsRemoved as Array<{ name?: unknown }>) : []) if (typeof t?.name === 'string') next.delete(t.name);
|
|
41
|
+
for (const t of Array.isArray(m.toolsAdded) ? (m.toolsAdded as Array<{ name?: unknown }>) : []) if (typeof t?.name === 'string') next.set(t.name, JSON.stringify(t));
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sameTools = (a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>) => a.size === b.size && [...a].every(([name, tool]) => b.get(name) === tool);
|
|
46
|
+
|
|
47
|
+
/** The entries as the next run should start from: a tool list that changes nothing dropped, pictures replaced by a line. */
|
|
48
|
+
export function pruneSessionEntries<T>(entries: readonly T[]): T[] {
|
|
49
|
+
const list = entries as unknown as Entry[];
|
|
50
|
+
// An entry that only changes the tools (no prompt sections, no instruction text), and leaves them as they were, is dropped.
|
|
51
|
+
const onlyTools = (m: NonNullable<Entry['message']>) =>
|
|
52
|
+
m.sections === undefined && (m.toolsAdded !== undefined || m.toolsRemoved !== undefined) && (m.content === undefined || m.content === '' || (Array.isArray(m.content) && m.content.length === 0));
|
|
53
|
+
const drop = new Set<Entry>();
|
|
54
|
+
let tools = new Map<string, string>();
|
|
55
|
+
for (const e of list) {
|
|
56
|
+
const m = e.message;
|
|
57
|
+
if (e.type !== 'message' || !m || m.role !== 'system') continue;
|
|
58
|
+
const next = withToolChanges(tools, m);
|
|
59
|
+
if (onlyTools(m) && sameTools(tools, next)) drop.add(e);
|
|
60
|
+
tools = next;
|
|
61
|
+
}
|
|
62
|
+
const parentOf = new Map<string, string | null | undefined>();
|
|
63
|
+
for (const e of drop) if (e.id) parentOf.set(e.id, e.parentId);
|
|
64
|
+
const resolveParent = (id: string | null | undefined): string | null | undefined => {
|
|
65
|
+
let cur = id;
|
|
66
|
+
while (cur && parentOf.has(cur)) cur = parentOf.get(cur);
|
|
67
|
+
return cur;
|
|
68
|
+
};
|
|
69
|
+
const out: Entry[] = [];
|
|
70
|
+
for (const e of list) {
|
|
71
|
+
if (drop.has(e)) continue;
|
|
72
|
+
let next = e;
|
|
73
|
+
if (e.parentId && parentOf.has(e.parentId)) next = { ...e, parentId: resolveParent(e.parentId) };
|
|
74
|
+
const m = next.message;
|
|
75
|
+
if (next.type === 'message' && m?.role === 'toolResult' && Array.isArray(m.content)) {
|
|
76
|
+
const content = (m.content as Array<Record<string, unknown>>).map((block) =>
|
|
77
|
+
block && typeof block === 'object' && block.type === 'image' && typeof block.data === 'string'
|
|
78
|
+
? imageStandIn(typeof block.mimeType === 'string' ? block.mimeType : undefined, block.data.length)
|
|
79
|
+
: block,
|
|
80
|
+
);
|
|
81
|
+
if (content.some((b, i) => b !== (m.content as unknown[])[i])) next = { ...next, message: { ...m, content } };
|
|
82
|
+
}
|
|
83
|
+
out.push(next);
|
|
84
|
+
}
|
|
85
|
+
return out as unknown as T[];
|
|
86
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fetch for the model gateway that ends a stalled request instead of sitting on it.
|
|
3
|
+
*
|
|
4
|
+
* Pi retries a model call that fails, but a call that never answers is not a failure to it: nothing
|
|
5
|
+
* bounds the wait but Node's own 300-second header limit and the run's 30-minute limit. On 8 Nov 2026
|
|
6
|
+
* a Teams reply sat five minutes before its first model call for exactly that reason, with no progress
|
|
7
|
+
* lines, since there was nothing to narrate. This fetch aborts when the response headers take longer
|
|
8
|
+
* than `headersMs`, or when a reply that has started sends nothing for `bodyMs`, and throws an error
|
|
9
|
+
* whose words pi's retry classifier accepts ("connection lost", "stream was interrupted"), so the next
|
|
10
|
+
* attempt follows at once.
|
|
11
|
+
*/
|
|
12
|
+
export interface StallGuardOptions {
|
|
13
|
+
/** How long to wait for the response headers. */
|
|
14
|
+
headersMs: number;
|
|
15
|
+
/** How long a started reply may go without a byte. */
|
|
16
|
+
bodyMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type FetchLike = typeof globalThis.fetch;
|
|
20
|
+
|
|
21
|
+
export class StalledRequestError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
readonly phase: 'headers' | 'body',
|
|
24
|
+
waitedMs: number,
|
|
25
|
+
) {
|
|
26
|
+
super(
|
|
27
|
+
phase === 'headers'
|
|
28
|
+
? `gateway stalled: no response headers in ${Math.round(waitedMs / 1000)} s (connection lost)`
|
|
29
|
+
: `gateway stalled: no bytes for ${Math.round(waitedMs / 1000)} s mid-reply (stream was interrupted)`,
|
|
30
|
+
);
|
|
31
|
+
this.name = 'StalledRequestError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stallGuardedFetch(inner: FetchLike, options: StallGuardOptions): FetchLike {
|
|
36
|
+
return async (input, init) => {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
let stalled: StalledRequestError | undefined;
|
|
39
|
+
const outer = init?.signal;
|
|
40
|
+
if (outer?.aborted) controller.abort(outer.reason);
|
|
41
|
+
else outer?.addEventListener('abort', () => controller.abort(outer.reason), { once: true });
|
|
42
|
+
|
|
43
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
44
|
+
const arm = (phase: 'headers' | 'body', ms: number) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
timer = setTimeout(() => {
|
|
47
|
+
stalled = new StalledRequestError(phase, ms);
|
|
48
|
+
controller.abort(stalled);
|
|
49
|
+
}, ms);
|
|
50
|
+
(timer as { unref?: () => void }).unref?.();
|
|
51
|
+
};
|
|
52
|
+
const translate = (err: unknown): never => {
|
|
53
|
+
if (stalled) throw stalled;
|
|
54
|
+
throw err;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
arm('headers', options.headersMs);
|
|
58
|
+
let response: Response;
|
|
59
|
+
try {
|
|
60
|
+
response = await inner(input, { ...init, signal: controller.signal });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
return translate(err);
|
|
64
|
+
}
|
|
65
|
+
if (!response.body) {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
return response;
|
|
68
|
+
}
|
|
69
|
+
// The reply has started: every chunk re-arms the watchdog; the stream ends it.
|
|
70
|
+
arm('body', options.bodyMs);
|
|
71
|
+
const reader = response.body.getReader();
|
|
72
|
+
const watched = new ReadableStream<Uint8Array>({
|
|
73
|
+
async pull(ctrl) {
|
|
74
|
+
let next: ReadableStreamReadResult<Uint8Array>;
|
|
75
|
+
try {
|
|
76
|
+
next = await reader.read();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
ctrl.error(stalled ?? err);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (next.done) {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
ctrl.close();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
arm('body', options.bodyMs);
|
|
88
|
+
ctrl.enqueue(next.value);
|
|
89
|
+
},
|
|
90
|
+
cancel(reason) {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
return reader.cancel(reason);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
return new Response(watched, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
96
|
+
};
|
|
97
|
+
}
|