@oh-my-pi/pi-coding-agent 16.0.11 → 16.1.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/CHANGELOG.md +36 -0
- package/dist/cli.js +3166 -3202
- package/dist/types/config/settings-schema.d.ts +40 -39
- package/dist/types/lsp/types.d.ts +5 -3
- package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
- package/dist/types/modes/components/assistant-message.d.ts +8 -0
- package/dist/types/modes/components/cache-invalidation-marker.d.ts +39 -0
- package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
- package/dist/types/modes/components/index.d.ts +0 -1
- package/dist/types/modes/components/message-frame.d.ts +6 -4
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/theme/theme.d.ts +7 -1
- package/dist/types/modes/types.d.ts +7 -1
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +20 -1
- package/dist/types/session/session-context.d.ts +7 -0
- package/dist/types/session/session-dump-format.d.ts +1 -0
- package/dist/types/session/tool-choice-queue.d.ts +14 -0
- package/dist/types/system-prompt.d.ts +3 -3
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/resolve.d.ts +15 -5
- package/package.json +12 -12
- package/src/config/settings-schema.ts +48 -39
- package/src/config/settings.ts +40 -0
- package/src/debug/log-viewer.ts +4 -4
- package/src/debug/raw-sse.ts +4 -4
- package/src/edit/renderer.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/lsp/client.ts +9 -9
- package/src/lsp/render.ts +7 -7
- package/src/lsp/types.ts +6 -3
- package/src/modes/components/__tests__/skill-message.test.ts +92 -0
- package/src/modes/components/agent-dashboard.ts +1 -1
- package/src/modes/components/assistant-message.ts +21 -0
- package/src/modes/components/cache-invalidation-marker.ts +94 -0
- package/src/modes/components/chat-transcript-builder.ts +16 -2
- package/src/modes/components/compaction-summary-message.ts +29 -1
- package/src/modes/components/custom-message.ts +4 -1
- package/src/modes/components/dynamic-border.ts +1 -1
- package/src/modes/components/extensions/extension-dashboard.ts +1 -1
- package/src/modes/components/extensions/inspector-panel.ts +5 -5
- package/src/modes/components/hook-selector.ts +2 -2
- package/src/modes/components/index.ts +0 -1
- package/src/modes/components/message-frame.ts +10 -6
- package/src/modes/components/model-selector.ts +2 -2
- package/src/modes/components/overlay-box.ts +10 -9
- package/src/modes/components/settings-defs.ts +7 -0
- package/src/modes/components/skill-message.ts +39 -19
- package/src/modes/components/tiny-title-download-progress.ts +1 -1
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/event-controller.ts +14 -0
- package/src/modes/controllers/selector-controller.ts +7 -0
- package/src/modes/interactive-mode.ts +9 -1
- package/src/modes/theme/theme.ts +14 -0
- package/src/modes/types.ts +7 -1
- package/src/modes/utils/ui-helpers.ts +20 -2
- package/src/prompts/steering/user-interjection.md +3 -4
- package/src/sdk.ts +8 -6
- package/src/session/agent-session.ts +96 -23
- package/src/session/messages.ts +7 -9
- package/src/session/session-context.ts +54 -7
- package/src/session/session-dump-format.ts +3 -1
- package/src/session/snapcompact-inline.ts +2 -2
- package/src/session/tool-choice-queue.ts +59 -0
- package/src/system-prompt.ts +10 -9
- package/src/tools/bash-interactive.ts +4 -4
- package/src/tools/index.ts +4 -0
- package/src/tools/resolve.ts +66 -41
- package/src/tui/output-block.ts +9 -9
- package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
- package/src/modes/components/branch-summary-message.ts +0 -46
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
countTokens,
|
|
36
36
|
resolveTelemetry,
|
|
37
37
|
ThinkingLevel,
|
|
38
|
+
type ToolChoiceDirective,
|
|
38
39
|
} from "@oh-my-pi/pi-agent-core";
|
|
39
40
|
import {
|
|
40
41
|
AGGRESSIVE_SHAKE_CONFIG,
|
|
@@ -102,6 +103,7 @@ import {
|
|
|
102
103
|
resolveServiceTier,
|
|
103
104
|
streamSimple,
|
|
104
105
|
} from "@oh-my-pi/pi-ai";
|
|
106
|
+
import { stripToolDescriptions } from "@oh-my-pi/pi-ai/utils/schema";
|
|
105
107
|
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
106
108
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
107
109
|
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
|
|
@@ -260,6 +262,7 @@ import type { CheckpointState } from "../tools/checkpoint";
|
|
|
260
262
|
import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
|
|
261
263
|
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
|
|
262
264
|
import { isAutoQaEnabled } from "../tools/report-tool-issue";
|
|
265
|
+
import { buildResolveReminderMessage } from "../tools/resolve";
|
|
263
266
|
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
|
|
264
267
|
import { ToolAbortError, ToolError } from "../tools/tool-errors";
|
|
265
268
|
import { clampTimeout } from "../tools/tool-timeouts";
|
|
@@ -367,6 +370,23 @@ const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
|
|
|
367
370
|
deferredHandoff: false,
|
|
368
371
|
continuationScheduled: true,
|
|
369
372
|
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Per-turn prune cache window. A tool result whose all-message suffix exceeds
|
|
376
|
+
* this is in the warm, already-sent prompt-cache prefix: re-writing it costs the
|
|
377
|
+
* cacheWrite premium on the whole suffix. Per-turn passes only reclaim inside
|
|
378
|
+
* this tail (matches the supersede pass's default `suffixTokenLimit`); deeper
|
|
379
|
+
* stale/age victims are left to compaction/shake, which rebuild the cache anyway.
|
|
380
|
+
*/
|
|
381
|
+
const PRUNE_CACHE_WARM_SUFFIX_TOKENS = 8_000;
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Idle gap after which the supersede pass may flush the whole sent region (the
|
|
385
|
+
* provider cache is cold, so re-writing it is free). MUST exceed the maximum
|
|
386
|
+
* Anthropic prompt-cache TTL — "long" retention (the OAuth default) is 1h — or a
|
|
387
|
+
* still-warm prefix is busted by the flush. 90 min leaves margin over the 1h TTL.
|
|
388
|
+
*/
|
|
389
|
+
const PRUNE_IDLE_FLUSH_MS = 90 * 60_000;
|
|
370
390
|
export type CommandMetadataChangedListener = () => void | Promise<void>;
|
|
371
391
|
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
|
|
372
392
|
|
|
@@ -516,6 +536,12 @@ export interface AgentSessionConfig {
|
|
|
516
536
|
advisorReadOnlyTools?: AgentTool[];
|
|
517
537
|
/** Preloaded watchdog prompt content for the advisor. */
|
|
518
538
|
advisorWatchdogPrompt?: string;
|
|
539
|
+
/**
|
|
540
|
+
* Strip tool descriptions from provider-bound tool specs on side requests
|
|
541
|
+
* (handoff). Must match the session-start value used to build the system
|
|
542
|
+
* prompt so inline descriptors are not also sent through provider schemas.
|
|
543
|
+
*/
|
|
544
|
+
pruneToolDescriptions?: boolean;
|
|
519
545
|
/**
|
|
520
546
|
* Disconnect this session's OWNED MCP manager on dispose. Provided only when
|
|
521
547
|
* the session created the manager (top-level sessions); subagents reuse a
|
|
@@ -1305,6 +1331,8 @@ export class AgentSession {
|
|
|
1305
1331
|
// unchanged — otherwise a mid-turn estimate would survive into idle.
|
|
1306
1332
|
#contextUsageRevision = 0;
|
|
1307
1333
|
#obfuscator: SecretObfuscator | undefined;
|
|
1334
|
+
/** Session-start value of `inlineToolDescriptors`; drives handoff tool pruning. */
|
|
1335
|
+
#pruneToolDescriptions = false;
|
|
1308
1336
|
#checkpointState: CheckpointState | undefined = undefined;
|
|
1309
1337
|
#pendingRewindReport: string | undefined = undefined;
|
|
1310
1338
|
#lastSuccessfulYieldToolCallId: string | undefined = undefined;
|
|
@@ -1316,19 +1344,15 @@ export class AgentSession {
|
|
|
1316
1344
|
if (process.platform !== "darwin") return;
|
|
1317
1345
|
if (isBunTestRuntime()) return;
|
|
1318
1346
|
if (this.#powerAssertion) return;
|
|
1319
|
-
const
|
|
1320
|
-
|
|
1321
|
-
const user = this.settings.get("power.declareUserActive");
|
|
1322
|
-
const display = this.settings.get("power.preventDisplaySleep");
|
|
1323
|
-
// All four off → user opted out; do nothing.
|
|
1324
|
-
if (!idle && !system && !user && !display) return;
|
|
1347
|
+
const mode = this.settings.get("power.sleepPrevention");
|
|
1348
|
+
if (mode === "off") return;
|
|
1325
1349
|
try {
|
|
1326
1350
|
this.#powerAssertion = MacOSPowerAssertion.start({
|
|
1327
1351
|
reason: "Oh My Pi agent session",
|
|
1328
|
-
idle,
|
|
1329
|
-
system,
|
|
1330
|
-
|
|
1331
|
-
|
|
1352
|
+
idle: true,
|
|
1353
|
+
display: mode === "display" || mode === "system",
|
|
1354
|
+
system: mode === "system",
|
|
1355
|
+
user: mode === "system",
|
|
1332
1356
|
});
|
|
1333
1357
|
} catch (error) {
|
|
1334
1358
|
logger.warn("Failed to acquire macOS power assertion", { error: String(error) });
|
|
@@ -1513,6 +1537,7 @@ export class AgentSession {
|
|
|
1513
1537
|
this.#modelRegistry = config.modelRegistry;
|
|
1514
1538
|
this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
|
|
1515
1539
|
this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
|
|
1540
|
+
this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
|
|
1516
1541
|
this.#validateRetryFallbackChains();
|
|
1517
1542
|
this.#toolRegistry = config.toolRegistry ?? new Map();
|
|
1518
1543
|
this.#requestedToolNames = config.requestedToolNames;
|
|
@@ -2124,6 +2149,36 @@ export class AgentSession {
|
|
|
2124
2149
|
return undefined;
|
|
2125
2150
|
}
|
|
2126
2151
|
|
|
2152
|
+
/**
|
|
2153
|
+
* The per-turn tool-choice directive for the agent loop's `getToolChoice`. Priority:
|
|
2154
|
+
* 1. a HARD forced choice from the queue (genuine forces: user-force, eager-todo, …) —
|
|
2155
|
+
* consuming, unchanged from `nextToolChoice`;
|
|
2156
|
+
* 2. else, when a non-forcing preview is pending, a {@link SoftToolRequirement} — a
|
|
2157
|
+
* PEEK (advances/pops nothing), so the agent-loop injects the reminder once per head
|
|
2158
|
+
* and escalates to a forced `resolve` only if the model declines. A compliant turn
|
|
2159
|
+
* pays ZERO tool_choice change (no prompt-cache messages-cache invalidation);
|
|
2160
|
+
* 3. else undefined.
|
|
2161
|
+
*/
|
|
2162
|
+
nextToolChoiceDirective(): ToolChoiceDirective | undefined {
|
|
2163
|
+
const hard = this.nextToolChoice();
|
|
2164
|
+
if (hard !== undefined) return hard;
|
|
2165
|
+
const head = this.#toolChoiceQueue.peekPendingHead();
|
|
2166
|
+
if (head !== undefined) {
|
|
2167
|
+
return {
|
|
2168
|
+
soft: true,
|
|
2169
|
+
id: head.id,
|
|
2170
|
+
toolName: "resolve",
|
|
2171
|
+
reminder: [buildResolveReminderMessage(head.sourceToolName)],
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
return undefined;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/** Peek the head non-forcing pending preview invoker, for the `resolve` tool's dispatch. */
|
|
2178
|
+
peekPendingInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
|
|
2179
|
+
return this.#toolChoiceQueue.peekPendingInvoker();
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2127
2182
|
/**
|
|
2128
2183
|
* Force the next model call to target a specific active tool, then terminate
|
|
2129
2184
|
* the agent loop. Pushes a two-step sequence [forced, "none"] so the model
|
|
@@ -4851,7 +4906,7 @@ export class AgentSession {
|
|
|
4851
4906
|
* cache per-tool strings without preserving this property.
|
|
4852
4907
|
*
|
|
4853
4908
|
* Inputs NOT covered: tool input schemas; memory instructions read from disk;
|
|
4854
|
-
* and SDK-init-time closure constants in `sdk.ts` (`
|
|
4909
|
+
* and SDK-init-time closure constants in `sdk.ts` (`inlineToolDescriptors`,
|
|
4855
4910
|
* `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
|
|
4856
4911
|
* closure-captured ones cannot change at runtime regardless of skip behavior.
|
|
4857
4912
|
* For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
|
|
@@ -7299,11 +7354,16 @@ export class AgentSession {
|
|
|
7299
7354
|
|
|
7300
7355
|
async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
|
|
7301
7356
|
const branchEntries = this.sessionManager.getBranch();
|
|
7357
|
+
const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
|
|
7302
7358
|
const result = pruneToolOutputs(
|
|
7303
7359
|
branchEntries,
|
|
7304
7360
|
this.#withPlanProtection({
|
|
7305
7361
|
...DEFAULT_PRUNE_CONFIG,
|
|
7306
7362
|
pruneUseless: this.settings.getGroup("compaction").dropUseless,
|
|
7363
|
+
// Cache-stable boundary: never re-write the warm, already-sent prefix
|
|
7364
|
+
// (deep stale/age victims) or summarized-away entries every turn.
|
|
7365
|
+
keepBoundaryId,
|
|
7366
|
+
cacheWarmSuffixTokens: PRUNE_CACHE_WARM_SUFFIX_TOKENS,
|
|
7307
7367
|
}),
|
|
7308
7368
|
);
|
|
7309
7369
|
if (result.prunedCount === 0) {
|
|
@@ -7331,12 +7391,17 @@ export class AgentSession {
|
|
|
7331
7391
|
const { supersedeReads, dropUseless } = this.settings.getGroup("compaction");
|
|
7332
7392
|
if (!supersedeReads && !dropUseless) return undefined;
|
|
7333
7393
|
const branchEntries = this.sessionManager.getBranch();
|
|
7394
|
+
const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
|
|
7334
7395
|
const result = pruneSupersededToolResults(
|
|
7335
7396
|
branchEntries,
|
|
7336
7397
|
this.#withPlanProtection({
|
|
7337
7398
|
supersedeKey: supersedeReads ? readToolSupersedeKey : undefined,
|
|
7338
7399
|
pruneUseless: dropUseless,
|
|
7339
7400
|
protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools],
|
|
7401
|
+
// Never re-write summarized-away entries; only flush the whole sent
|
|
7402
|
+
// region once the cache is genuinely cold (idle exceeds the 1h TTL).
|
|
7403
|
+
keepBoundaryId,
|
|
7404
|
+
idleFlushMs: PRUNE_IDLE_FLUSH_MS,
|
|
7340
7405
|
}),
|
|
7341
7406
|
);
|
|
7342
7407
|
if (result.prunedCount === 0) {
|
|
@@ -7420,8 +7485,14 @@ export class AgentSession {
|
|
|
7420
7485
|
return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
|
|
7421
7486
|
}
|
|
7422
7487
|
|
|
7423
|
-
const
|
|
7424
|
-
const
|
|
7488
|
+
const branchEntries = this.sessionManager.getBranch();
|
|
7489
|
+
const config = this.#withPlanProtection({
|
|
7490
|
+
...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG),
|
|
7491
|
+
// Skip entries summarized away by the latest compaction — shaking them
|
|
7492
|
+
// only churns persisted history with no prompt/cache effect.
|
|
7493
|
+
keepBoundaryId: getLatestCompactionEntry(branchEntries)?.firstKeptEntryId,
|
|
7494
|
+
});
|
|
7495
|
+
const regions = collectShakeRegions(branchEntries, config);
|
|
7425
7496
|
if (regions.length === 0) {
|
|
7426
7497
|
return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
|
|
7427
7498
|
}
|
|
@@ -7598,9 +7669,6 @@ export class AgentSession {
|
|
|
7598
7669
|
convertToLlm,
|
|
7599
7670
|
model: this.model,
|
|
7600
7671
|
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
7601
|
-
// Providers with hard image caps (OpenRouter: 8) silently drop
|
|
7602
|
-
// frames past the cap — keep the archive within budget.
|
|
7603
|
-
maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
|
|
7604
7672
|
});
|
|
7605
7673
|
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
7606
7674
|
const budget =
|
|
@@ -7849,7 +7917,10 @@ export class AgentSession {
|
|
|
7849
7917
|
this.#modelRegistry.resolver(model, this.sessionId),
|
|
7850
7918
|
{
|
|
7851
7919
|
systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
|
|
7852
|
-
tools: obfuscateProviderTools(
|
|
7920
|
+
tools: obfuscateProviderTools(
|
|
7921
|
+
this.#obfuscator,
|
|
7922
|
+
this.#pruneToolDescriptions ? stripToolDescriptions(this.agent.state.tools) : this.agent.state.tools,
|
|
7923
|
+
),
|
|
7853
7924
|
customInstructions: this.#obfuscateTextForProvider(customInstructions),
|
|
7854
7925
|
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
|
7855
7926
|
initiatorOverride: "agent",
|
|
@@ -9154,14 +9225,15 @@ export class AgentSession {
|
|
|
9154
9225
|
*/
|
|
9155
9226
|
#projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
|
|
9156
9227
|
const archive = snapcompact.getPreservedArchive(result.preserveData);
|
|
9157
|
-
const
|
|
9228
|
+
const blocks = archive ? snapcompact.historyBlocks(archive) : undefined;
|
|
9158
9229
|
const summaryMessage = createCompactionSummaryMessage(
|
|
9159
9230
|
result.summary,
|
|
9160
9231
|
result.tokensBefore,
|
|
9161
9232
|
new Date().toISOString(),
|
|
9162
9233
|
result.shortSummary,
|
|
9163
9234
|
undefined,
|
|
9164
|
-
|
|
9235
|
+
undefined,
|
|
9236
|
+
blocks,
|
|
9165
9237
|
);
|
|
9166
9238
|
let tokens = computeNonMessageTokens(this) + estimateTokens(summaryMessage);
|
|
9167
9239
|
for (const message of preparation.recentMessages) {
|
|
@@ -9389,15 +9461,15 @@ export class AgentSession {
|
|
|
9389
9461
|
let details: unknown;
|
|
9390
9462
|
|
|
9391
9463
|
// Snapcompact runs locally first; if its frame archive plus the kept
|
|
9392
|
-
// history still overflows the model window (frames
|
|
9393
|
-
//
|
|
9394
|
-
// far cheaper — downgrade to context-full and take the
|
|
9464
|
+
// history still overflows the model window (frames default to
|
|
9465
|
+
// MAX_FRAMES_DEFAULT and cost ~FRAME_TOKEN_ESTIMATE each), an LLM
|
|
9466
|
+
// summary is far cheaper — downgrade to context-full and take the
|
|
9467
|
+
// summarizer path.
|
|
9395
9468
|
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
9396
9469
|
if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
|
|
9397
9470
|
snapcompactResult = await snapcompact.compact(preparation, {
|
|
9398
9471
|
convertToLlm,
|
|
9399
9472
|
model: this.model,
|
|
9400
|
-
maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
|
|
9401
9473
|
});
|
|
9402
9474
|
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
9403
9475
|
const budget =
|
|
@@ -12236,6 +12308,7 @@ export class AgentSession {
|
|
|
12236
12308
|
model: this.agent.state.model,
|
|
12237
12309
|
thinkingLevel: this.#thinkingLevel,
|
|
12238
12310
|
tools: this.agent.state.tools,
|
|
12311
|
+
inlineToolDescriptors: this.#pruneToolDescriptions,
|
|
12239
12312
|
});
|
|
12240
12313
|
}
|
|
12241
12314
|
|
package/src/session/messages.ts
CHANGED
|
@@ -204,16 +204,14 @@ function wrapSteeringUserMessage(message: UserMessage): UserMessage {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
207
|
+
// Wrap EVERY steering message, not just a trailing run. The wire bytes of a
|
|
208
|
+
// steering message must be a pure function of the message itself, independent
|
|
209
|
+
// of its position in the array. When only the trailing steer was wrapped, the
|
|
210
|
+
// same persisted message was sent enveloped while it was the tail and raw once
|
|
211
|
+
// the assistant's reply buried it — rewriting already-cached prefix bytes and
|
|
212
|
+
// busting the provider prompt cache from that message onward on the next turn.
|
|
215
213
|
let wrappedMessages: AgentMessage[] | undefined;
|
|
216
|
-
for (let i =
|
|
214
|
+
for (let i = 0; i < messages.length; i++) {
|
|
217
215
|
const message = messages[i];
|
|
218
216
|
if (!isSteeringUserMessage(message)) continue;
|
|
219
217
|
const wrappedMessage = wrapSteeringUserMessage(message);
|
|
@@ -20,6 +20,13 @@ export interface SessionContext {
|
|
|
20
20
|
mode: string;
|
|
21
21
|
/** Mode-specific data from the last mode_change entry */
|
|
22
22
|
modeData?: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Array parallel to messages, indicating which assistant turns should
|
|
25
|
+
* have their prompt-cache misses suppressed/explained (because a model,
|
|
26
|
+
* compaction, or plan-mode transition directly preceded them).
|
|
27
|
+
* Only populated in transcript mode.
|
|
28
|
+
*/
|
|
29
|
+
cacheMissExplainedAt?: boolean[];
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/** Lists session model strings to try when restoring, in fallback order. */
|
|
@@ -191,12 +198,45 @@ export function buildSessionContext(
|
|
|
191
198
|
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
|
|
192
199
|
// 3. Emit messages after compaction
|
|
193
200
|
const messages: AgentMessage[] = [];
|
|
201
|
+
const cacheMissExplainedAt: boolean[] = [];
|
|
202
|
+
let pendingReset = false;
|
|
203
|
+
let currentMode = "none";
|
|
204
|
+
let lastAssistantModel: string | undefined;
|
|
205
|
+
|
|
206
|
+
const handleEntryResetTracking = (entry: SessionEntry) => {
|
|
207
|
+
if (entry.type === "compaction") {
|
|
208
|
+
pendingReset = true;
|
|
209
|
+
} else if (entry.type === "model_change") {
|
|
210
|
+
pendingReset = true;
|
|
211
|
+
} else if (entry.type === "mode_change") {
|
|
212
|
+
const isPlanTransition = (entry.mode === "plan") !== (currentMode === "plan");
|
|
213
|
+
if (isPlanTransition) {
|
|
214
|
+
pendingReset = true;
|
|
215
|
+
}
|
|
216
|
+
currentMode = entry.mode;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const pushMessage = (msg: AgentMessage) => {
|
|
221
|
+
messages.push(msg);
|
|
222
|
+
if (!options?.transcript) return;
|
|
223
|
+
if (msg.role === "assistant") {
|
|
224
|
+
const currentModel = `${msg.provider}/${msg.model}`;
|
|
225
|
+
const modelChanged = lastAssistantModel !== undefined && lastAssistantModel !== currentModel;
|
|
226
|
+
lastAssistantModel = currentModel;
|
|
227
|
+
cacheMissExplainedAt.push(pendingReset || modelChanged);
|
|
228
|
+
pendingReset = false;
|
|
229
|
+
} else {
|
|
230
|
+
cacheMissExplainedAt.push(false);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
194
233
|
|
|
195
234
|
const appendMessage = (entry: SessionEntry) => {
|
|
235
|
+
handleEntryResetTracking(entry);
|
|
196
236
|
if (entry.type === "message") {
|
|
197
|
-
|
|
237
|
+
pushMessage(entry.message);
|
|
198
238
|
} else if (entry.type === "custom_message") {
|
|
199
|
-
|
|
239
|
+
pushMessage(
|
|
200
240
|
createCustomMessage(
|
|
201
241
|
entry.customType,
|
|
202
242
|
entry.content,
|
|
@@ -207,7 +247,7 @@ export function buildSessionContext(
|
|
|
207
247
|
),
|
|
208
248
|
);
|
|
209
249
|
} else if (entry.type === "branch_summary" && entry.summary) {
|
|
210
|
-
|
|
250
|
+
pushMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
|
211
251
|
}
|
|
212
252
|
};
|
|
213
253
|
|
|
@@ -217,16 +257,18 @@ export function buildSessionContext(
|
|
|
217
257
|
// TUI) at the point it fired, with any snapcompact frames re-attached so
|
|
218
258
|
// the component can report them.
|
|
219
259
|
for (const entry of path) {
|
|
260
|
+
handleEntryResetTracking(entry);
|
|
220
261
|
if (entry.type === "compaction") {
|
|
221
262
|
const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
|
|
222
|
-
|
|
263
|
+
pushMessage(
|
|
223
264
|
createCompactionSummaryMessage(
|
|
224
265
|
entry.summary,
|
|
225
266
|
entry.tokensBefore,
|
|
226
267
|
entry.timestamp,
|
|
227
268
|
entry.shortSummary,
|
|
228
269
|
undefined,
|
|
229
|
-
|
|
270
|
+
undefined,
|
|
271
|
+
snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
|
|
230
272
|
),
|
|
231
273
|
);
|
|
232
274
|
} else {
|
|
@@ -251,14 +293,15 @@ export function buildSessionContext(
|
|
|
251
293
|
// Emit summary first; re-attach any archived snapcompact frames so the
|
|
252
294
|
// model can keep reading the archived history after every context rebuild.
|
|
253
295
|
const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
|
|
254
|
-
|
|
296
|
+
pushMessage(
|
|
255
297
|
createCompactionSummaryMessage(
|
|
256
298
|
compaction.summary,
|
|
257
299
|
compaction.tokensBefore,
|
|
258
300
|
compaction.timestamp,
|
|
259
301
|
compaction.shortSummary,
|
|
260
302
|
providerPayload,
|
|
261
|
-
|
|
303
|
+
undefined,
|
|
304
|
+
snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined,
|
|
262
305
|
),
|
|
263
306
|
);
|
|
264
307
|
|
|
@@ -333,6 +376,9 @@ export function buildSessionContext(
|
|
|
333
376
|
);
|
|
334
377
|
if (normalized.length === 0) {
|
|
335
378
|
messages.splice(i, 1);
|
|
379
|
+
if (options?.transcript) {
|
|
380
|
+
cacheMissExplainedAt.splice(i, 1);
|
|
381
|
+
}
|
|
336
382
|
} else {
|
|
337
383
|
messages[i] = { ...message, content: normalized };
|
|
338
384
|
}
|
|
@@ -340,6 +386,7 @@ export function buildSessionContext(
|
|
|
340
386
|
|
|
341
387
|
return {
|
|
342
388
|
messages,
|
|
389
|
+
cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined,
|
|
343
390
|
thinkingLevel,
|
|
344
391
|
serviceTier,
|
|
345
392
|
models,
|
|
@@ -38,6 +38,7 @@ export interface FormatSessionDumpTextOptions {
|
|
|
38
38
|
model?: Model | null;
|
|
39
39
|
thinkingLevel?: ThinkingLevel | string | null;
|
|
40
40
|
tools?: readonly SessionDumpToolInfo[];
|
|
41
|
+
inlineToolDescriptors?: boolean;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
interface InventoryTool {
|
|
@@ -78,7 +79,8 @@ function renderDumpHeader(options: FormatSessionDumpTextOptions, inventoryTools:
|
|
|
78
79
|
lines.push(`Thinking Level: ${options.thinkingLevel ?? ""}`);
|
|
79
80
|
lines.push("\n");
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
const hasSystemPromptToolInventory = options.inlineToolDescriptors === true;
|
|
83
|
+
if (inventoryTools.length > 0 && !hasSystemPromptToolInventory) {
|
|
82
84
|
lines.push("## Available Tools\n");
|
|
83
85
|
lines.push(renderToolInventory(inventoryTools, model?.id ?? ""));
|
|
84
86
|
lines.push("\n");
|
|
@@ -46,8 +46,8 @@ export type SnapcompactSavingsSink = (
|
|
|
46
46
|
// Per-provider image-count budgets live in @oh-my-pi/snapcompact
|
|
47
47
|
// (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so
|
|
48
48
|
// dimension/size limits never bind; only COUNT does. Once the budget is
|
|
49
|
-
// spent
|
|
50
|
-
//
|
|
49
|
+
// spent by already-attached archive/system-prompt images, tool results ship
|
|
50
|
+
// verbatim as text.
|
|
51
51
|
const MAX_SYSTEM_PROMPT_FRAMES = 6;
|
|
52
52
|
/** Tool results under this many tokens are never rasterized — the swap can't
|
|
53
53
|
* save enough to justify trading crisp text for an image. */
|
|
@@ -65,6 +65,20 @@ interface InFlight {
|
|
|
65
65
|
invoked: boolean;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A non-forcing pending preview invoker. Registered by `queueResolveHandler`
|
|
70
|
+
* (resolve previews) so the `resolve` tool can dispatch to a staged action
|
|
71
|
+
* WITHOUT this queue forcing `tool_choice`. The agent-loop's
|
|
72
|
+
* SoftToolRequirement lifecycle (remind-then-escalate) owns any forcing.
|
|
73
|
+
*/
|
|
74
|
+
interface PendingInvoker {
|
|
75
|
+
/** Unique id for this staged preview; never reused (never clobbered by label). */
|
|
76
|
+
id: string;
|
|
77
|
+
/** Source tool that staged the preview (e.g. "ast_edit"), for the reminder. */
|
|
78
|
+
sourceToolName: string;
|
|
79
|
+
onInvoked: (input: unknown) => Promise<unknown> | unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
// ── Queue ───────────────────────────────────────────────────────────────────
|
|
69
83
|
|
|
70
84
|
export class ToolChoiceQueue {
|
|
@@ -75,6 +89,12 @@ export class ToolChoiceQueue {
|
|
|
75
89
|
* Consumers (e.g. todo reminder suppression) read via consumeLastServedLabel().
|
|
76
90
|
*/
|
|
77
91
|
#lastResolvedLabel: string | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Non-forcing pending preview invokers, stacked by UNIQUE id. The `resolve`
|
|
94
|
+
* tool dispatches to the head; the agent-loop's soft-tool-requirement
|
|
95
|
+
* lifecycle drives resolution without this queue forcing `tool_choice`.
|
|
96
|
+
*/
|
|
97
|
+
#pendingInvokers: PendingInvoker[] = [];
|
|
78
98
|
|
|
79
99
|
// ── Push ──────────────────────────────────────────────────────────────
|
|
80
100
|
|
|
@@ -190,6 +210,44 @@ export class ToolChoiceQueue {
|
|
|
190
210
|
};
|
|
191
211
|
}
|
|
192
212
|
|
|
213
|
+
// ── Non-forcing pending invokers ──────────────────────────────────────
|
|
214
|
+
// Preview producers (queueResolveHandler) register here so `resolve` can
|
|
215
|
+
// dispatch to a staged action WITHOUT a forced tool_choice (no messages-cache
|
|
216
|
+
// bust). Stacked by UNIQUE id: a re-register replaces only the same id, so
|
|
217
|
+
// concurrent/sequential previews each survive and resolve independently.
|
|
218
|
+
|
|
219
|
+
/** Register (or replace by exact id) a non-forcing pending preview invoker. */
|
|
220
|
+
registerPendingInvoker(
|
|
221
|
+
id: string,
|
|
222
|
+
sourceToolName: string,
|
|
223
|
+
onInvoked: (input: unknown) => Promise<unknown> | unknown,
|
|
224
|
+
): void {
|
|
225
|
+
this.removePendingInvoker(id);
|
|
226
|
+
this.#pendingInvokers.push({ id, sourceToolName, onInvoked });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Drop the pending invoker with this id (e.g. after it resolves). */
|
|
230
|
+
removePendingInvoker(id: string): void {
|
|
231
|
+
this.#pendingInvokers = this.#pendingInvokers.filter(p => p.id !== id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** True when at least one non-forcing pending preview is registered. */
|
|
235
|
+
get hasPendingInvoker(): boolean {
|
|
236
|
+
return this.#pendingInvokers.length > 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The head (most-recently registered) pending invoker's handler, for resolve dispatch. */
|
|
240
|
+
peekPendingInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
|
|
241
|
+
return this.#pendingInvokers.at(-1)?.onInvoked;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The head pending preview's stable id + source tool, for building the agent-level
|
|
245
|
+
* SoftToolRequirement (the id drives reminder re-injection when the head changes). */
|
|
246
|
+
peekPendingHead(): { id: string; sourceToolName: string } | undefined {
|
|
247
|
+
const head = this.#pendingInvokers.at(-1);
|
|
248
|
+
return head ? { id: head.id, sourceToolName: head.sourceToolName } : undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
193
251
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
|
194
252
|
|
|
195
253
|
/** Remove all directives with the given label. Rejects in-flight if it matches. */
|
|
@@ -206,6 +264,7 @@ export class ToolChoiceQueue {
|
|
|
206
264
|
this.reject("cleared");
|
|
207
265
|
}
|
|
208
266
|
this.#queue = [];
|
|
267
|
+
this.#pendingInvokers = [];
|
|
209
268
|
this.#lastResolvedLabel = undefined;
|
|
210
269
|
}
|
|
211
270
|
|
package/src/system-prompt.ts
CHANGED
|
@@ -373,11 +373,11 @@ export interface BuildSystemPromptOptions {
|
|
|
373
373
|
toolNames?: string[];
|
|
374
374
|
/** Text to append to system prompt. */
|
|
375
375
|
appendSystemPrompt?: string;
|
|
376
|
-
/**
|
|
377
|
-
|
|
376
|
+
/** Inline full tool descriptors in the system prompt. Default: true */
|
|
377
|
+
inlineToolDescriptors?: boolean;
|
|
378
378
|
/**
|
|
379
379
|
* Whether provider-native tool calling is active (no owned/in-band syntax).
|
|
380
|
-
* When true and `
|
|
380
|
+
* When true and `inlineToolDescriptors` is false, the inventory renders as a
|
|
381
381
|
* compact tool-name list; otherwise it renders full `# Tool:` sections. Default: true
|
|
382
382
|
*/
|
|
383
383
|
nativeTools?: boolean;
|
|
@@ -433,7 +433,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
433
433
|
customPrompt,
|
|
434
434
|
tools,
|
|
435
435
|
appendSystemPrompt,
|
|
436
|
-
|
|
436
|
+
inlineToolDescriptors: providedInlineToolDescriptors,
|
|
437
437
|
nativeTools = true,
|
|
438
438
|
skillsSettings,
|
|
439
439
|
toolNames: providedToolNames,
|
|
@@ -454,6 +454,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
454
454
|
model,
|
|
455
455
|
personality = "default",
|
|
456
456
|
} = options;
|
|
457
|
+
const inlineToolDescriptors = providedInlineToolDescriptors ?? true;
|
|
457
458
|
const resolvedCwd = cwd ?? getProjectDir();
|
|
458
459
|
|
|
459
460
|
const prepDefaults = {
|
|
@@ -599,10 +600,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
599
600
|
examples: meta?.examples,
|
|
600
601
|
};
|
|
601
602
|
});
|
|
602
|
-
// List mode shows a compact tool-name list; it only applies when
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
const toolListMode = !
|
|
603
|
+
// List mode shows a compact tool-name list; it only applies when descriptors
|
|
604
|
+
// stay in provider-native tool schemas AND native tool calling is active.
|
|
605
|
+
// Otherwise render full `# Tool:` sections inline in the system prompt.
|
|
606
|
+
const toolListMode = !inlineToolDescriptors && nativeTools;
|
|
606
607
|
const toolInventory = toolListMode ? "" : renderToolInventory(inventoryTools, model ?? "");
|
|
607
608
|
|
|
608
609
|
// Filter skills for the rendered system prompt:
|
|
@@ -632,7 +633,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
632
633
|
tools: toolNames,
|
|
633
634
|
toolInfo,
|
|
634
635
|
toolInventory,
|
|
635
|
-
|
|
636
|
+
inlineToolDescriptors,
|
|
636
637
|
toolListMode,
|
|
637
638
|
toolRefs,
|
|
638
639
|
environment,
|
|
@@ -274,16 +274,16 @@ class BashInteractiveOverlayComponent implements Component {
|
|
|
274
274
|
: truncateToWidth(this.uiTheme.fg("dim", "session finished"), innerWidth);
|
|
275
275
|
const visibleLines = this.#readViewport(innerWidth, maxContentRows);
|
|
276
276
|
const content = visibleLines.length > 0 ? visibleLines : [padding(innerWidth)];
|
|
277
|
-
const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.
|
|
278
|
-
const borderVertical = this.uiTheme.fg("border", this.uiTheme.
|
|
277
|
+
const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxRound.horizontal.repeat(innerWidth));
|
|
278
|
+
const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxRound.vertical);
|
|
279
279
|
const boxLine = (line: string) =>
|
|
280
280
|
`${borderVertical}${line}${padding(Math.max(0, innerWidth - visibleWidth(line)))}${borderVertical}`;
|
|
281
281
|
return [
|
|
282
|
-
`${this.uiTheme.fg("border", this.uiTheme.
|
|
282
|
+
`${this.uiTheme.fg("border", this.uiTheme.boxRound.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.topRight)}`,
|
|
283
283
|
boxLine(header),
|
|
284
284
|
...content.map(boxLine),
|
|
285
285
|
boxLine(footer),
|
|
286
|
-
`${this.uiTheme.fg("border", this.uiTheme.
|
|
286
|
+
`${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxRound.bottomRight)}`,
|
|
287
287
|
];
|
|
288
288
|
}
|
|
289
289
|
|
package/src/tools/index.ts
CHANGED
|
@@ -312,6 +312,10 @@ export interface ToolSession {
|
|
|
312
312
|
steer?(message: { customType: string; content: string; details?: unknown }): void;
|
|
313
313
|
/** Peek the currently in-flight tool-choice queue directive's invocation handler. Used by the `resolve` tool to dispatch to the pending action. */
|
|
314
314
|
peekQueueInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
|
|
315
|
+
/** Peek the most-recently registered non-forcing pending preview invoker. The `resolve`
|
|
316
|
+
* tool dispatches to it so a staged preview resolves WITHOUT forcing tool_choice — the
|
|
317
|
+
* agent-loop's SoftToolRequirement lifecycle owns reminder injection and escalation. */
|
|
318
|
+
peekPendingInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
|
|
315
319
|
/** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
|
|
316
320
|
* Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
|
|
317
321
|
* letting modes accept `resolve` invocations without forcing the tool choice every turn. */
|