pum-agent 0.2.35-beta.1 → 0.2.36-beta.1
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/README.md +2 -2
- package/package.json +3 -4
- package/src/context-window.ts +80 -42
- package/src/headless.ts +0 -2
- package/src/main.tsx +0 -2
- package/src/project-validation.ts +10 -25
- package/src/runtime-settings.ts +22 -11
- package/src/tool-groups.ts +25 -1
- package/src/model-catalog.ts +0 -47
package/README.md
CHANGED
|
@@ -23,8 +23,8 @@ pum
|
|
|
23
23
|
PUM opens the login panel automatically on the first start. Press `?` on an
|
|
24
24
|
empty prompt to see every control.
|
|
25
25
|
|
|
26
|
-
Select a model in `Ctrl+P`. GPT-6 Astra
|
|
27
|
-
|
|
26
|
+
Select a model in `Ctrl+P`. GPT-6 Astra, Luna and Sol are listed for OpenAI
|
|
27
|
+
and Codex. To use them, your provider account must have access. Press `r` in the model
|
|
28
28
|
list to refresh providers that support catalog discovery.
|
|
29
29
|
|
|
30
30
|
Use `/model <name or provider/model-id> [effort]` to change the main-agent model.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pum-agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.36-beta.1",
|
|
4
4
|
"description": "A compact terminal coding agent powered by pi and OpenTUI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,9 +50,8 @@
|
|
|
50
50
|
"pack:check": "bun run scripts/validate-pack.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@earendil-works/pi-ai": "^0.
|
|
54
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
55
|
-
"@earendil-works/pi-server": "^0.85.0",
|
|
53
|
+
"@earendil-works/pi-ai": "^0.87.1",
|
|
54
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
56
55
|
"@opentui/core": "^0.5.1",
|
|
57
56
|
"@opentui/react": "^0.5.1",
|
|
58
57
|
"react": "^19.2.8",
|
package/src/context-window.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { getCurrentSystemMessage, getCurrentSystemPrompt, getCurrentTools, type Message, type Tool } from "@earendil-works/pi-ai";
|
|
4
5
|
import {
|
|
5
|
-
sessionEntryToContextMessages,
|
|
6
6
|
type AgentSession,
|
|
7
7
|
type ExtensionAPI,
|
|
8
8
|
type InlineExtension,
|
|
9
9
|
type SessionEntry,
|
|
10
|
+
type SessionProjection,
|
|
10
11
|
type TurnEndEvent,
|
|
11
12
|
} from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { registerTranscriptHistoryTool } from "./transcript-history";
|
|
@@ -72,7 +73,7 @@ function boundaryData(value: unknown): BoundaryData {
|
|
|
72
73
|
const { handoff } = handoffParams({ handoff: value.handoff });
|
|
73
74
|
return { version: 1, ...(handoff === undefined ? {} : { handoff }) };
|
|
74
75
|
}
|
|
75
|
-
function
|
|
76
|
+
function createHeader(id: string, handoff?: string, navigation?: { userId?: string; previousId?: string | null }, recovery = ""): AgentMessage {
|
|
76
77
|
return {
|
|
77
78
|
role: "custom", customType: CONTEXT_WINDOW_CUSTOM_TYPE, display: false, timestamp: 0,
|
|
78
79
|
content: `Fresh PUM context window: ${id}. Earlier transcript entries remain retained in the full session transcript. The rollover generated no summary. Current system instructions still apply. ${recovery}`
|
|
@@ -83,6 +84,12 @@ function header(id: string, handoff?: string, navigation?: { userId?: string; pr
|
|
|
83
84
|
function isBoundary(entry: SessionEntry): boolean {
|
|
84
85
|
return entry.type === "custom" && entry.customType === CONTEXT_WINDOW_CUSTOM_TYPE;
|
|
85
86
|
}
|
|
87
|
+
// System messages carry the prompt and tool declarations, which the meter
|
|
88
|
+
// fingerprints separately. Message lists and indexes here exclude them.
|
|
89
|
+
function conversation(messages: readonly AgentMessage[]): AgentMessage[] {
|
|
90
|
+
return messages.filter((message) => message.role !== "system");
|
|
91
|
+
}
|
|
92
|
+
const transcript = (messages: readonly AgentMessage[]) => messages as readonly Message[];
|
|
86
93
|
const textResult = (details: Record<string, unknown>) => ({
|
|
87
94
|
content: [{ type: "text" as const, text: JSON.stringify(details, null, 2) }], details,
|
|
88
95
|
});
|
|
@@ -91,8 +98,8 @@ const textResult = (details: Record<string, unknown>) => ({
|
|
|
91
98
|
export class ContextWindowController {
|
|
92
99
|
private session?: AgentSession;
|
|
93
100
|
private pending?: Pending;
|
|
94
|
-
private refreshPending = false;
|
|
95
101
|
private windowId: string | null = null;
|
|
102
|
+
private windowHeader?: { id: string; message: AgentMessage };
|
|
96
103
|
private observedExtraTokens = 0;
|
|
97
104
|
private modelKey?: string;
|
|
98
105
|
private usageFloor = 0;
|
|
@@ -122,7 +129,7 @@ export class ContextWindowController {
|
|
|
122
129
|
if (key !== this.modelKey) {
|
|
123
130
|
this.modelKey = key;
|
|
124
131
|
this.generation++;
|
|
125
|
-
this.usageFloor = this.session
|
|
132
|
+
this.usageFloor = this.session ? this.activeConversation().length : 0;
|
|
126
133
|
}
|
|
127
134
|
});
|
|
128
135
|
pi.on("before_agent_start", (event) => {
|
|
@@ -209,22 +216,31 @@ export class ContextWindowController {
|
|
|
209
216
|
this.disableAutomaticCompaction();
|
|
210
217
|
this.restore();
|
|
211
218
|
const transform = session.agent.transformContext;
|
|
212
|
-
session.agent.transformContext = async (
|
|
213
|
-
|
|
219
|
+
session.agent.transformContext = async (input, signal) => {
|
|
220
|
+
// pi rebuilds request history from the session projection. The rollover
|
|
221
|
+
// window is applied here, before extension context handlers inject memory.
|
|
222
|
+
const messages = this.windowRequest(input);
|
|
223
|
+
const source = conversation(messages);
|
|
224
|
+
const originalTokens = source.reduce((sum, message) => sum + estimateTokens(message), 0);
|
|
214
225
|
const stateSystemPrompt = fingerprint(session.agent.state.systemPrompt);
|
|
215
226
|
const stateTools = this.toolSchemas();
|
|
216
|
-
const prompt = this.preparedPrompt ?? { systemPrompt: stateSystemPrompt, tools: stateTools, modelKey: modelIdentity(session.model) };
|
|
217
227
|
this.requestSnapshot = undefined;
|
|
218
228
|
const generation = this.generation;
|
|
219
229
|
const windowId = this.windowId;
|
|
220
|
-
const sourceHash = messageHash(
|
|
221
|
-
const sourceCount =
|
|
230
|
+
const sourceHash = messageHash(source);
|
|
231
|
+
const sourceCount = source.length;
|
|
222
232
|
const transformed = transform ? await transform.call(session.agent, messages, signal) : messages;
|
|
223
233
|
// Observe the complete extension chain, including memory injected after our
|
|
224
234
|
// extension. Return it unchanged; memory and dynamic instructions stay active.
|
|
225
235
|
if (generation === this.generation && !signal?.aborted) {
|
|
226
|
-
|
|
227
|
-
|
|
236
|
+
const prompt: PromptSnapshot = {
|
|
237
|
+
systemPrompt: fingerprint(getCurrentSystemPrompt(transcript(transformed))),
|
|
238
|
+
tools: this.toolSchemas(getCurrentTools(transcript(transformed))),
|
|
239
|
+
modelKey: modelIdentity(session.model),
|
|
240
|
+
};
|
|
241
|
+
this.preparedPrompt = prompt;
|
|
242
|
+
const transformedTokens = conversation(transformed).reduce((sum, message) => sum + estimateTokens(message), 0);
|
|
243
|
+
this.observedExtraTokens = Math.max(0, transformedTokens - originalTokens);
|
|
228
244
|
const measured = this.latestUsageSnapshot;
|
|
229
245
|
if (measured?.generation === generation && measured.modelKey === prompt.modelKey) {
|
|
230
246
|
// A failed request may have observed growth followed by shrinkage
|
|
@@ -236,8 +252,7 @@ export class ContextWindowController {
|
|
|
236
252
|
}
|
|
237
253
|
this.requestSnapshot = { ...prompt, windowId, generation, stateSystemPrompt, stateTools,
|
|
238
254
|
injectedTokens: this.observedExtraTokens, sourceHash, sourceCount,
|
|
239
|
-
estimatedInputTokens:
|
|
240
|
-
+ prompt.systemPrompt.tokens + prompt.tools.tokens };
|
|
255
|
+
estimatedInputTokens: transformedTokens + prompt.systemPrompt.tokens + prompt.tools.tokens };
|
|
241
256
|
}
|
|
242
257
|
return transformed;
|
|
243
258
|
};
|
|
@@ -245,14 +260,7 @@ export class ContextWindowController {
|
|
|
245
260
|
const legacy = session.agent.prepareNextTurn;
|
|
246
261
|
session.agent.prepareNextTurnWithContext = async (turn, signal) => {
|
|
247
262
|
this.disableAutomaticCompaction();
|
|
248
|
-
|
|
249
|
-
this.refreshPending = false;
|
|
250
|
-
const input = fresh ? { ...turn, context: { ...turn.context, messages: session.agent.state.messages.slice() } } : turn;
|
|
251
|
-
const update = previous ? await previous.call(session.agent, input, signal) : await legacy?.call(session.agent, signal);
|
|
252
|
-
const context = update?.context ?? input.context;
|
|
253
|
-
this.preparedPrompt = { systemPrompt: fingerprint(context.systemPrompt), tools: this.toolSchemas(context.tools),
|
|
254
|
-
modelKey: modelIdentity(update?.model ?? session.model) };
|
|
255
|
-
return fresh ? { ...update, context } : update;
|
|
263
|
+
return previous ? await previous.call(session.agent, turn, signal) : await legacy?.call(session.agent, signal);
|
|
256
264
|
};
|
|
257
265
|
}
|
|
258
266
|
|
|
@@ -272,6 +280,7 @@ export class ContextWindowController {
|
|
|
272
280
|
const branch = session.sessionManager.getBranch();
|
|
273
281
|
const index = branch.findLastIndex(isBoundary);
|
|
274
282
|
this.windowId = index < 0 ? null : branch[index]!.id;
|
|
283
|
+
this.windowHeader = undefined;
|
|
275
284
|
this.generation++;
|
|
276
285
|
this.usageFloor = 0;
|
|
277
286
|
this.modelKey = modelIdentity(session.model);
|
|
@@ -279,28 +288,57 @@ export class ContextWindowController {
|
|
|
279
288
|
this.preparedPrompt = undefined;
|
|
280
289
|
this.usageSnapshots = new WeakMap();
|
|
281
290
|
this.latestUsageSnapshot = undefined;
|
|
282
|
-
|
|
291
|
+
// Validate now so tree and session events report a corrupt boundary. Every
|
|
292
|
+
// request validates again and fails closed.
|
|
293
|
+
if (index >= 0) this.activeWindow();
|
|
294
|
+
this.restoreUsageFloor(branch);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The request messages of the active window, or undefined without a boundary.
|
|
298
|
+
* The projection is authoritative: pi builds every request from it.
|
|
299
|
+
*/
|
|
300
|
+
private activeWindow(projection?: SessionProjection): AgentMessage[] | undefined {
|
|
301
|
+
const manager = this.requireSession().sessionManager;
|
|
302
|
+
const branch = manager.getBranch();
|
|
303
|
+
const index = branch.findLastIndex(isBoundary);
|
|
304
|
+
if (index < 0) return undefined;
|
|
283
305
|
const boundary = branch[index]!;
|
|
284
|
-
if (boundary.type !== "custom") return;
|
|
285
|
-
let
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
306
|
+
if (boundary.type !== "custom") return undefined;
|
|
307
|
+
let header = this.windowHeader?.id === boundary.id ? this.windowHeader.message : undefined;
|
|
308
|
+
if (!header) {
|
|
309
|
+
const data = boundaryData(boundary.data);
|
|
310
|
+
const latestUser = branch.slice(0, index).findLast((entry) => entry.type === "message" && entry.message.role === "user");
|
|
311
|
+
header = createHeader(boundary.id, data.handoff, { userId: latestUser?.id, previousId: boundary.parentId },
|
|
312
|
+
this.recoveryGuidance());
|
|
313
|
+
// Keep the header bytes stable for the whole window. Tool changes after
|
|
314
|
+
// rollover must not rewrite the start of every later request.
|
|
315
|
+
this.windowHeader = { id: boundary.id, message: header };
|
|
293
316
|
}
|
|
294
317
|
// Legacy sessions can contain a later compaction. Filter its kept entries
|
|
295
318
|
// at the boundary, but never discard the boundary's literal handoff.
|
|
296
319
|
const activeIds = new Set(branch.slice(index + 1).map((entry) => entry.id));
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
320
|
+
projection ??= manager.buildSessionProjection();
|
|
321
|
+
const archived = projection.entries.filter((entry) => !activeIds.has(entry.sourceEntry.id)).flatMap((entry) => entry.messages);
|
|
322
|
+
const active = projection.entries.filter((entry) => activeIds.has(entry.sourceEntry.id)).flatMap((entry) => entry.messages);
|
|
323
|
+
// Archived system messages still define the prompt and tools that apply.
|
|
324
|
+
const system = getCurrentSystemMessage(transcript(archived.filter((message) => message.role === "system")));
|
|
325
|
+
return [...(system ? [system as AgentMessage] : []), header, ...active];
|
|
326
|
+
}
|
|
327
|
+
private windowRequest(input: AgentMessage[]): AgentMessage[] {
|
|
328
|
+
const projection = this.requireSession().sessionManager.buildSessionProjection();
|
|
329
|
+
const active = this.activeWindow(projection);
|
|
330
|
+
if (!active) return input;
|
|
331
|
+
// Request input must be the persisted projection. Refuse an unexpected
|
|
332
|
+
// shape rather than send messages from an archived window.
|
|
333
|
+
if (conversation(input).length !== conversation(projection.messages).length) {
|
|
334
|
+
throw new Error("PUM context-window request does not match the persisted session. Refusing to send older context.");
|
|
335
|
+
}
|
|
336
|
+
return active;
|
|
337
|
+
}
|
|
338
|
+
/** Conversation messages that the next request of the active window contains. */
|
|
339
|
+
private activeConversation(): AgentMessage[] {
|
|
340
|
+
const projection = this.requireSession().sessionManager.buildSessionProjection();
|
|
341
|
+
return conversation(this.activeWindow(projection) ?? projection.messages);
|
|
304
342
|
}
|
|
305
343
|
private restoreUsageFloor(branch: SessionEntry[]): void {
|
|
306
344
|
const change = branch.findLastIndex((entry) => entry.type === "model_change" || entry.type === "compaction");
|
|
@@ -309,7 +347,7 @@ export class ContextWindowController {
|
|
|
309
347
|
// identity fallback for runtimes that copy their state on restoration.
|
|
310
348
|
const older = new Set(branch.slice(0, change).filter((entry) => entry.type === "message")
|
|
311
349
|
.map((entry) => JSON.stringify(entry.message)));
|
|
312
|
-
const messages = this.
|
|
350
|
+
const messages = this.activeConversation();
|
|
313
351
|
for (let index = 0; index < messages.length; index++) {
|
|
314
352
|
if (older.has(JSON.stringify(messages[index]))) this.usageFloor = index + 1;
|
|
315
353
|
}
|
|
@@ -349,7 +387,7 @@ export class ContextWindowController {
|
|
|
349
387
|
const capacity = model?.contextWindow;
|
|
350
388
|
const branch = session.sessionManager.getBranch();
|
|
351
389
|
const latestUser = branch.findLast((entry) => entry.type === "message" && entry.message.role === "user");
|
|
352
|
-
const freshTokens = estimateTokens(
|
|
390
|
+
const freshTokens = estimateTokens(createHeader("pending", handoff, {
|
|
353
391
|
userId: latestUser?.id, previousId: session.sessionManager.getLeafId(),
|
|
354
392
|
}, this.recoveryGuidance())) + this.overheadTokens();
|
|
355
393
|
const configuredReserve = this.reserveTokens();
|
|
@@ -377,7 +415,7 @@ export class ContextWindowController {
|
|
|
377
415
|
const factor = typeof meter.calibrationFactor === "number" ? meter.calibrationFactor : 1;
|
|
378
416
|
return Math.max(0, Math.floor(available / factor) - CONTEXT_MESSAGE_TOKENS);
|
|
379
417
|
}
|
|
380
|
-
private toolSchemas(tools = this.requireSession().agent.state.tools): EstimateFingerprint {
|
|
418
|
+
private toolSchemas(tools: readonly Tool[] = this.requireSession().agent.state.tools): EstimateFingerprint {
|
|
381
419
|
const schema = fingerprint(JSON.stringify(tools.map((tool) => ({
|
|
382
420
|
name: tool.name, description: tool.description, parameters: tool.parameters,
|
|
383
421
|
}))));
|
|
@@ -408,7 +446,7 @@ export class ContextWindowController {
|
|
|
408
446
|
private remaining(): Record<string, unknown> {
|
|
409
447
|
const session = this.requireSession();
|
|
410
448
|
const model = session.model;
|
|
411
|
-
const messages =
|
|
449
|
+
const messages = this.activeConversation();
|
|
412
450
|
const key = modelIdentity(model);
|
|
413
451
|
if (key !== this.modelKey) { this.modelKey = key; this.generation++; this.usageFloor = messages.length; }
|
|
414
452
|
let usageIndex = -1;
|
package/src/headless.ts
CHANGED
|
@@ -12,7 +12,6 @@ import { conversationBranchState } from "./conversation-branch";
|
|
|
12
12
|
import { isPlanModeActive, planModeExtension } from "./plan-mode";
|
|
13
13
|
import { createFilesystemSandboxExtension } from "./filesystem-sandbox";
|
|
14
14
|
import { bindFileCheckpointSession, createFileCheckpointExtension } from "./file-checkpoints";
|
|
15
|
-
import { installModelCatalogFallbacks } from "./model-catalog";
|
|
16
15
|
import { AGENT_DIR, AUTH_PATH, MODELS_PATH } from "./config";
|
|
17
16
|
import { createMemoryExtension, hasMemoryContextExtension, MEMORY_EDIT_TOOL_NAME, MEMORY_READ_TOOL_NAME } from "./memory";
|
|
18
17
|
import { ContextWindowController, CONTEXT_TOOL_NAMES } from "./context-window";
|
|
@@ -172,7 +171,6 @@ async function runPromptSession(
|
|
|
172
171
|
authPath: AUTH_PATH,
|
|
173
172
|
modelsPath: MODELS_PATH,
|
|
174
173
|
});
|
|
175
|
-
installModelCatalogFallbacks(modelRuntime);
|
|
176
174
|
if ((await modelRuntime.getAvailable()).length === 0) {
|
|
177
175
|
process.stderr.write("pum: no provider is available. Run 'pum login' first.\n");
|
|
178
176
|
return 1;
|
package/src/main.tsx
CHANGED
|
@@ -24,7 +24,6 @@ import { checkPathsForProject, loadSettings } from "./settings";
|
|
|
24
24
|
import { setBashOutputSettingsIfPresent } from "./bash-output";
|
|
25
25
|
import { bindSearchSession, installWebSearch, revokeSearchSession, webSearch } from "./web-search";
|
|
26
26
|
import { bindRuntimeSettingsActivity, isRuntimeIdle, runtimeSettings } from "./runtime-settings";
|
|
27
|
-
import { installModelCatalogFallbacks } from "./model-catalog";
|
|
28
27
|
import { identityExtension } from "./identity";
|
|
29
28
|
import { setWritingStyle, writingStyleExtension } from "./writing-style";
|
|
30
29
|
import { checkModePromptExtension, setSandboxModeSource } from "./check-mode-prompt";
|
|
@@ -112,7 +111,6 @@ export async function start(
|
|
|
112
111
|
authPath: AUTH_PATH,
|
|
113
112
|
modelsPath: MODELS_PATH,
|
|
114
113
|
});
|
|
115
|
-
installModelCatalogFallbacks(modelRuntime);
|
|
116
114
|
const loginRequired = options.login || (await modelRuntime.getAvailable()).length === 0;
|
|
117
115
|
|
|
118
116
|
const settings = loadSettings();
|
|
@@ -129,7 +129,6 @@ export class ProjectValidationController {
|
|
|
129
129
|
private generation = 0;
|
|
130
130
|
private batchHasMutations = false;
|
|
131
131
|
private latest?: ValidationEvidence;
|
|
132
|
-
private refreshEvidence = false;
|
|
133
132
|
private unbindHooks?: () => void;
|
|
134
133
|
private readonly cwd: string;
|
|
135
134
|
private readonly readonly: boolean;
|
|
@@ -187,30 +186,17 @@ export class ProjectValidationController {
|
|
|
187
186
|
liveControllers.set(session.sessionId, this);
|
|
188
187
|
const dispose = session.dispose.bind(session);
|
|
189
188
|
session.dispose = () => { this.dispose(); dispose(); };
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
signal?.throwIfAborted();
|
|
198
|
-
const refresh = this.refreshEvidence;
|
|
199
|
-
this.refreshEvidence = false;
|
|
200
|
-
const input = refresh ? { ...turn, context: { ...turn.context, messages: session.agent.state.messages.slice() } } : turn;
|
|
201
|
-
const update = prepare ? await prepare.call(session.agent, input, signal) : await legacy?.call(session.agent, signal);
|
|
202
|
-
return refresh ? { ...update, context: update?.context ?? input.context } : update;
|
|
189
|
+
// pi dispatches turn_end, and so runs validation, inside its finishTurn hook.
|
|
190
|
+
// Delegate first, then end the run when that batch was cancelled.
|
|
191
|
+
const finish = session.agent.finishTurn;
|
|
192
|
+
const finishWrapper: NonNullable<typeof session.agent.finishTurn> = async (turn, signal) => {
|
|
193
|
+
const decision = await finish?.call(session.agent, turn, signal);
|
|
194
|
+
if (!this.retired && signal?.aborted) return { action: "end" };
|
|
195
|
+
return decision ?? undefined;
|
|
203
196
|
};
|
|
204
|
-
session.agent.
|
|
205
|
-
const stop = session.agent.shouldStopAfterTurn;
|
|
206
|
-
const stopWrapper: NonNullable<typeof session.agent.shouldStopAfterTurn> = async (turn, signal) => {
|
|
207
|
-
if (!this.retired && signal?.aborted) return true;
|
|
208
|
-
return await stop?.call(session.agent, turn, signal) ?? false;
|
|
209
|
-
};
|
|
210
|
-
session.agent.shouldStopAfterTurn = stopWrapper;
|
|
197
|
+
session.agent.finishTurn = finishWrapper;
|
|
211
198
|
this.unbindHooks = () => {
|
|
212
|
-
if (session.agent.
|
|
213
|
-
if (session.agent.shouldStopAfterTurn === stopWrapper) session.agent.shouldStopAfterTurn = stop;
|
|
199
|
+
if (session.agent.finishTurn === finishWrapper) session.agent.finishTurn = finish;
|
|
214
200
|
};
|
|
215
201
|
}
|
|
216
202
|
extension(): InlineExtension {
|
|
@@ -241,7 +227,6 @@ export class ProjectValidationController {
|
|
|
241
227
|
this.latest = evidence;
|
|
242
228
|
if (this.retired || !this.session) return;
|
|
243
229
|
await this.session.sendCustomMessage({ customType: VALIDATION_CUSTOM_TYPE, content: validationEvidenceText(evidence), display: true, details: evidence }, { triggerTurn: false });
|
|
244
|
-
this.refreshEvidence = true;
|
|
245
230
|
}
|
|
246
231
|
private stillApproved(proposal: Proposal, generation: number): boolean {
|
|
247
232
|
if (this.retired || this.proposal !== proposal || this.generation !== generation) return false;
|
|
@@ -290,7 +275,7 @@ export class ProjectValidationController {
|
|
|
290
275
|
const tool = session.agent.state.tools.find((entry) => entry.name === "bash");
|
|
291
276
|
if (!tool || !session.agent.beforeToolCall) throw new Error("Checked Bash is unavailable.");
|
|
292
277
|
const { id, args } = createSyntheticCheckCall({ command: command.command, timeout: command.timeoutSeconds, max_bytes: OUTPUT_BYTES });
|
|
293
|
-
const context = {
|
|
278
|
+
const context = { messages: session.agent.state.messages, tools: session.agent.state.tools };
|
|
294
279
|
// Exactly the session's Check hooks and native-sandbox registered Bash.
|
|
295
280
|
// Never executeBash(): that API deliberately bypasses model Check policy.
|
|
296
281
|
const toolCall = { type: "toolCall" as const, id, name: "bash", arguments: args };
|
package/src/runtime-settings.ts
CHANGED
|
@@ -143,7 +143,7 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
|
|
|
143
143
|
// an older run's extension hook. Async scope follows the SDK's entire
|
|
144
144
|
// _runAgentPrompt (including retry/queued continuations and true settlement).
|
|
145
145
|
// Never settle by session key: extensions run before ordinary subscribers.
|
|
146
|
-
type Admission = { epoch: number; started: boolean; released: boolean; parent?: Admission; release: () => void };
|
|
146
|
+
type Admission = { epoch: number; started: boolean; released: boolean; parent?: Admission; outer?: Admission; release: () => void };
|
|
147
147
|
let epoch = 0;
|
|
148
148
|
let disposed = false;
|
|
149
149
|
let frozen = false;
|
|
@@ -170,27 +170,36 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
|
|
|
170
170
|
// compatibility lease separate; unscoped events cannot release scoped work.
|
|
171
171
|
let unscoped: Admission | undefined = session.isStreaming || session.agent?.state?.isStreaming ? reserve() : undefined;
|
|
172
172
|
if (unscoped) unscoped.started = true;
|
|
173
|
+
// The async scope in which the unscoped lease started. Only a settled event
|
|
174
|
+
// from that same scope ends it.
|
|
175
|
+
let unscopedScope: Admission | undefined;
|
|
173
176
|
const unsubscribe = session.subscribe((event) => {
|
|
174
177
|
const admission = scopes.getStore();
|
|
175
178
|
// A cancelled preflight may unwind after a newer accepted prompt starts.
|
|
176
179
|
// Its SDK finally still emits settled; that event does not own the new run.
|
|
177
180
|
if (disposed) return;
|
|
181
|
+
// pi defers a run requested from agent_settled until those handlers finish.
|
|
182
|
+
// It starts in the settled admission's scope, after that admission released,
|
|
183
|
+
// and needs its own lease until its own settled event in that scope.
|
|
184
|
+
const leaseRun = (scope: Admission | undefined) => {
|
|
185
|
+
if (!unscoped) { unscoped = reserve(); unscopedScope = scope; }
|
|
186
|
+
unscoped.started = true;
|
|
187
|
+
};
|
|
178
188
|
if (event.type === "agent_start") {
|
|
179
|
-
if (admission) {
|
|
180
|
-
if (admission.epoch === epoch
|
|
189
|
+
if (admission && !admission.released) {
|
|
190
|
+
if (admission.epoch === epoch) {
|
|
181
191
|
admission.started = true;
|
|
182
192
|
if (admission.parent && !admission.parent.released) admission.parent.started = true;
|
|
193
|
+
if (admission.outer) leaseRun(admission.outer);
|
|
183
194
|
}
|
|
184
|
-
} else
|
|
185
|
-
unscoped ??= reserve();
|
|
186
|
-
unscoped.started = true;
|
|
187
|
-
}
|
|
195
|
+
} else if (!admission || admission.epoch === epoch) leaseRun(admission);
|
|
188
196
|
} else if (event.type === "agent_settled") {
|
|
189
|
-
if (admission) admission.release();
|
|
190
|
-
else {
|
|
197
|
+
if (admission && !admission.released) admission.release();
|
|
198
|
+
else if (unscoped && unscopedScope === admission) {
|
|
191
199
|
const settled = unscoped;
|
|
192
200
|
unscoped = undefined;
|
|
193
|
-
|
|
201
|
+
unscopedScope = undefined;
|
|
202
|
+
settled.release();
|
|
194
203
|
}
|
|
195
204
|
}
|
|
196
205
|
});
|
|
@@ -222,6 +231,7 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
|
|
|
222
231
|
// Its promise, not the session's shared idle flag, proves completion.
|
|
223
232
|
direct.started = true;
|
|
224
233
|
if (admission && !admission.released) direct.parent = admission;
|
|
234
|
+
else if (admission && admission.epoch === epoch) direct.outer = admission;
|
|
225
235
|
try {
|
|
226
236
|
if (disposed || frozen || direct.released || direct.epoch !== epoch) throw new Error("Runtime admission was cancelled");
|
|
227
237
|
return await scopes.run(direct, run);
|
|
@@ -310,7 +320,7 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
|
|
|
310
320
|
// SDK waitForIdle can return early after a stale preflight finally resets
|
|
311
321
|
// isStreaming. Scoped execution still needs its own settled/promise proof.
|
|
312
322
|
cancelledUnscoped?.release();
|
|
313
|
-
if (unscoped?.released) unscoped = undefined;
|
|
323
|
+
if (unscoped?.released) { unscoped = undefined; unscopedScope = undefined; }
|
|
314
324
|
} finally {
|
|
315
325
|
for (const admission of cancelled) if (!admission.started) admission.release();
|
|
316
326
|
}
|
|
@@ -323,6 +333,7 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
|
|
|
323
333
|
finally {
|
|
324
334
|
unsubscribe();
|
|
325
335
|
unscoped = undefined;
|
|
336
|
+
unscopedScope = undefined;
|
|
326
337
|
for (const admission of admissions) admission.release();
|
|
327
338
|
admissions.clear();
|
|
328
339
|
for (const release of shellReleases) release();
|
package/src/tool-groups.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { getCurrentSystemMessage, type Message } from "@earendil-works/pi-ai";
|
|
1
3
|
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import { companionFileFor, readCompanion, writeCompanion } from "./session-companion";
|
|
3
5
|
import { Type } from "typebox";
|
|
@@ -414,11 +416,33 @@ export class ToolGroupsController {
|
|
|
414
416
|
});
|
|
415
417
|
}
|
|
416
418
|
|
|
419
|
+
/** Sort tools into the canonical role-allowlist order. Unknown tools keep their order at the end. */
|
|
420
|
+
canonicalOrder<T extends { name: string }>(tools: readonly T[]): T[] {
|
|
421
|
+
const allowlist = this.audience === "main" ? mainAllowedToolNames(this.isReadonly) : childAllowedToolNames(this.isReadonly);
|
|
422
|
+
const rank = new Map(allowlist.map((name, index) => [name, index]));
|
|
423
|
+
return tools.map((tool, index) => ({ tool, index }))
|
|
424
|
+
.sort((a, b) => (rank.get(a.tool.name) ?? allowlist.length) - (rank.get(b.tool.name) ?? allowlist.length) || a.index - b.index)
|
|
425
|
+
.map(({ tool }) => tool);
|
|
426
|
+
}
|
|
427
|
+
|
|
417
428
|
/** The inline extension that registers `enable_tools` for one session. */
|
|
418
429
|
extension(): InlineExtension {
|
|
419
430
|
return {
|
|
420
431
|
name: `pum-tool-groups-${this.audience}`,
|
|
421
|
-
factory: (pi) =>
|
|
432
|
+
factory: (pi) => {
|
|
433
|
+
this.registerTool(pi);
|
|
434
|
+
// pi declares revealed tools in the transcript in reveal order. Send the
|
|
435
|
+
// canonical order, so that a request does not depend on that history.
|
|
436
|
+
pi.on("context_with_system", (event) => {
|
|
437
|
+
const head = getCurrentSystemMessage(event.messages as Message[]);
|
|
438
|
+
const tools = head?.toolsAdded;
|
|
439
|
+
if (!head || !tools) return undefined;
|
|
440
|
+
const ordered = this.canonicalOrder(tools);
|
|
441
|
+
if (ordered.every((tool, index) => tool === tools[index])) return undefined;
|
|
442
|
+
return { messages: [{ ...head, toolsAdded: ordered } as AgentMessage,
|
|
443
|
+
...event.messages.filter((message) => message.role !== "system")] };
|
|
444
|
+
});
|
|
445
|
+
},
|
|
422
446
|
};
|
|
423
447
|
}
|
|
424
448
|
}
|
package/src/model-catalog.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import type { Model, Provider } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
|
|
4
|
-
const ASTRA_ID = "gpt-6-astra";
|
|
5
|
-
|
|
6
|
-
/** Temporary catalog fallback until pi includes Astra. Never replace upstream metadata. */
|
|
7
|
-
export function withAstraModel(base: Provider): Provider {
|
|
8
|
-
if (base.id !== "openai" && base.id !== "openai-codex") return base;
|
|
9
|
-
if (base.getModels().some((model) => model.id === ASTRA_ID)) return base;
|
|
10
|
-
const codex = base.id === "openai-codex";
|
|
11
|
-
// https://developers.openai.com/api/docs/models/gpt-6-astra
|
|
12
|
-
const astra: Model<any> = {
|
|
13
|
-
id: ASTRA_ID,
|
|
14
|
-
name: "GPT-6 Astra",
|
|
15
|
-
provider: base.id,
|
|
16
|
-
api: codex ? "openai-codex-responses" : "openai-responses",
|
|
17
|
-
baseUrl: base.baseUrl ?? (codex ? "https://chatgpt.com/backend-api" : "https://api.openai.com/v1"),
|
|
18
|
-
reasoning: true,
|
|
19
|
-
input: ["text", "image"],
|
|
20
|
-
// Keep pi's conservative Codex context budget until that transport publishes one.
|
|
21
|
-
contextWindow: codex ? 272000 : 1050000,
|
|
22
|
-
maxTokens: 128000,
|
|
23
|
-
cost: {
|
|
24
|
-
input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5,
|
|
25
|
-
tiers: [{ inputTokensAbove: 272000, input: 20, output: 75, cacheRead: 2, cacheWrite: 25 }],
|
|
26
|
-
},
|
|
27
|
-
thinkingLevelMap: {
|
|
28
|
-
off: null, minimal: null, low: "low", medium: "medium",
|
|
29
|
-
high: "high", xhigh: "xhigh", max: "max",
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
const wrapped: Provider = Object.create(base);
|
|
33
|
-
wrapped.getModels = () => {
|
|
34
|
-
const models = base.getModels();
|
|
35
|
-
return models.some((model) => model.id === ASTRA_ID) ? models : [...models, astra];
|
|
36
|
-
};
|
|
37
|
-
return wrapped;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function installModelCatalogFallbacks(runtime: ModelRuntime): void {
|
|
41
|
-
for (const id of ["openai", "openai-codex"]) {
|
|
42
|
-
const base = runtime.getProvider(id);
|
|
43
|
-
if (!base) continue;
|
|
44
|
-
const provider = withAstraModel(base);
|
|
45
|
-
if (provider !== base) runtime.registerNativeProvider(provider);
|
|
46
|
-
}
|
|
47
|
-
}
|