refactor(coding-agent/session): restructured monolithic agent session

- Extracted internal handlers and logic from AgentSession into dedicated runner, guard, and coordinator modules.
- Created standalone modules for bash execution, evaluation runners, IRC bridging, and prewalk coordination.
- Established dedicated session components for tracking stats, todos, streams, and retry fallback chains.
- Preserved existing session behavior while significantly reducing monolithic class size and complexity.
This commit is contained in:
can1357
2026-07-23 23:58:24 +02:00
parent 126765c020
commit 7eeaba0471
31 changed files with 13825 additions and 12083 deletions
+2
View File
@@ -22,6 +22,8 @@
### Changed
- Split the `AgentSession` implementation into focused session-domain controllers while preserving its public API and runtime behavior.
- Subagents now inherit `async.enabled` and `bash.autoBackground.enabled` from the parent instead of having both force-disabled. Subagent runs complete only after their own background jobs settle and the agent submits a `yield` that postdates every delivered result: a terminal yield with jobs still pending parks the run (recoverable turn stop) instead of completing it, async results are folded in as follow-up turns (with a one-time notice offering `hub` wait/cancel), a result delivered after a yield supersedes that yield and re-runs the yield reminder ladder, and a run that never refreshes a superseded yield fails with the stale payload preserved as salvage. Teardown cancels and awaits surviving jobs before isolation worktree capture and cleanup.
- Added ordered `bash.patterns` command approval rules so selected bash commands can be allowed, prompted, or denied by command pattern.
- Cache full-session retention transcript incrementally instead of re-formatting the entire message history on every retain cycle ([#4246](https://github.com/can1357/oh-my-pi/issues/4246))
@@ -41,7 +41,18 @@ export interface ContextBreakdown {
snapcompact?: SnapcompactSavingsEstimate;
}
const EMPTY_STRING_PARTS: readonly string[] = [];
/** Stable inputs used to cache non-message token estimates. */
export interface NonMessageTokenSource {
readonly systemPrompt?: string[];
readonly agent?: {
readonly state?: {
readonly tools?: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>;
};
};
readonly skills?: readonly Skill[];
}
const EMPTY_STRING_PARTS: string[] = [];
const EMPTY_TOOLS: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">> = [];
const EMPTY_SKILLS: readonly Skill[] = [];
@@ -111,13 +122,18 @@ interface NonMessageTokenCache {
| undefined;
}
const nonMessageTokenCache = new WeakMap<AgentSession, NonMessageTokenCache>();
const NON_MESSAGE_TOKEN_CACHE = Symbol("non-message-token-cache");
function nonMessageTokenCacheEntry(session: AgentSession): NonMessageTokenCache {
interface CachedNonMessageTokenSource extends NonMessageTokenSource {
[NON_MESSAGE_TOKEN_CACHE]?: NonMessageTokenCache;
}
function nonMessageTokenCacheEntry(session: NonMessageTokenSource): NonMessageTokenCache {
const cachedSession: CachedNonMessageTokenSource = session;
const systemPromptRef = session.systemPrompt ?? EMPTY_STRING_PARTS;
const toolsRef = session.agent?.state?.tools ?? EMPTY_TOOLS;
const skillsRef = session.skills ?? EMPTY_SKILLS;
let entry = nonMessageTokenCache.get(session);
let entry = cachedSession[NON_MESSAGE_TOKEN_CACHE];
if (
entry &&
entry.systemPromptRef === systemPromptRef &&
@@ -127,11 +143,11 @@ function nonMessageTokenCacheEntry(session: AgentSession): NonMessageTokenCache
return entry;
}
entry = { systemPromptRef, toolsRef, skillsRef, tokens: undefined, breakdown: undefined };
nonMessageTokenCache.set(session, entry);
cachedSession[NON_MESSAGE_TOKEN_CACHE] = entry;
return entry;
}
export function computeNonMessageTokens(session: AgentSession): number {
export function computeNonMessageTokens(session: NonMessageTokenSource): number {
const entry = nonMessageTokenCacheEntry(session);
if (entry.tokens !== undefined) return entry.tokens;
const systemPromptParts = session.systemPrompt ?? EMPTY_STRING_PARTS;
@@ -147,7 +163,7 @@ export function computeNonMessageTokens(session: AgentSession): number {
* the status-line fast path intentionally uses the equivalent collapsed total
* in `computeNonMessageTokens`.
*/
export function computeNonMessageBreakdown(session: AgentSession): {
export function computeNonMessageBreakdown(session: NonMessageTokenSource): {
skillsTokens: number;
toolsTokens: number;
systemContextTokens: number;
@@ -0,0 +1,40 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
/** Reads a plan from a local URL or cwd-relative filesystem path. */
export async function readPlanFile(
planFilePath: string,
options: { localProtocolOptions: LocalProtocolOptions; cwd: string },
): Promise<string | null> {
const resolvedPath = planFilePath.startsWith("local:")
? resolveLocalUrlToPath(normalizeLocalScheme(planFilePath), options.localProtocolOptions)
: resolveToCwd(planFilePath, options.cwd);
try {
return await Bun.file(resolvedPath).text();
} catch (error) {
if (isEnoent(error)) return null;
throw error;
}
}
/** Lists session-local plan files from newest to oldest. */
export async function listPlanFiles(options: { localProtocolOptions: LocalProtocolOptions }): Promise<string[]> {
const localRoot = resolveLocalUrlToPath("local://", options.localProtocolOptions);
try {
const entries = await fs.promises.readdir(localRoot, { withFileTypes: true });
const plans = await Promise.all(
entries
.filter(entry => entry.isFile() && /plan\.md$/i.test(entry.name))
.map(async entry => {
const stat = await fs.promises.stat(path.join(localRoot, entry.name)).catch(() => null);
return { url: `local://${entry.name}`, mtime: stat?.mtimeMs ?? 0 };
}),
);
return plans.sort((a, b) => b.mtime - a.mtime).map(plan => plan.url);
} catch {
return [];
}
}
@@ -0,0 +1,170 @@
import { Patch } from "@oh-my-pi/hashline";
import { isRecord, stringProperty } from "@oh-my-pi/pi-utils";
import { expandApplyPatchToEntries } from "../edit";
import { resolveToCwd } from "../tools/path-utils";
import type { ClientBridgePermissionOption } from "./client-bridge";
/** Tools that require user permission before execution when an ACP client is connected. */
export const PERMISSION_REQUIRED_TOOLS: Record<string, true> = {
bash: true,
edit: true,
delete: true,
move: true,
};
/** Permission options indexed by their wire identifiers. */
export const PERMISSION_OPTIONS_BY_ID: Record<string, ClientBridgePermissionOption> = {
allow_once: { optionId: "allow_once", name: "Allow once", kind: "allow_once" },
allow_always: { optionId: "allow_always", name: "Always allow", kind: "allow_always" },
reject_once: { optionId: "reject_once", name: "Reject", kind: "reject_once" },
reject_always: { optionId: "reject_always", name: "Always reject", kind: "reject_always" },
};
/** Permission options presented to the client on each gated tool call. */
export const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
PERMISSION_OPTIONS_BY_ID.allow_once,
PERMISSION_OPTIONS_BY_ID.allow_always,
PERMISSION_OPTIONS_BY_ID.reject_once,
PERMISSION_OPTIONS_BY_ID.reject_always,
];
function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; paths: string[] } | undefined {
if (!isRecord(args)) return undefined;
const edits = Array.isArray(args.edits) ? args.edits : undefined;
if (edits) {
const filePath = stringProperty(args, "path");
if (filePath) {
for (const edit of edits) {
if (!isRecord(edit)) continue;
if (stringProperty(edit, "op") === "delete") return { kind: "delete", paths: [filePath] };
}
}
for (const edit of edits) {
if (!isRecord(edit)) continue;
const op = stringProperty(edit, "op");
const rename = stringProperty(edit, "rename");
if (op !== "create" && rename) return { kind: "move", paths: filePath ? [filePath, rename] : [rename] };
}
}
const input = stringProperty(args, "input");
if (input) {
try {
const patch = Patch.parse(input);
for (const section of patch.sections) {
if (section.fileOp?.kind === "rem") return { kind: "delete", paths: [section.path] };
if (section.fileOp?.kind === "move") return { kind: "move", paths: [section.path, section.fileOp.dest] };
}
} catch {
// Not a hashline patch — fall through to apply_patch parsing.
}
try {
const entries = expandApplyPatchToEntries({ input });
const deleteEntry = entries.find(entry => entry.op === "delete");
if (deleteEntry) return { kind: "delete", paths: [deleteEntry.path] };
const moveEntry = entries.find(entry => entry.rename);
if (moveEntry?.rename) return { kind: "move", paths: [moveEntry.path, moveEntry.rename] };
} catch {
// If the edit input is not an apply-patch envelope, it is not a delete/move operation.
}
}
return undefined;
}
/** Describes the permission prompt required for a destructive tool call. */
export function getPermissionIntent(
toolName: string,
args: unknown,
): { toolName: string; title: string; paths?: string[]; cacheKey: string } | undefined {
const input = isRecord(args) ? args : {};
if (toolName === "bash") {
const command = stringProperty(input, "command")?.slice(0, 80);
return { toolName, title: command || toolName, cacheKey: toolName };
}
if (toolName === "delete") {
const filePath = stringProperty(input, "path");
return {
toolName,
title: filePath ? `Delete ${filePath}` : toolName,
paths: filePath ? [filePath] : undefined,
cacheKey: toolName,
};
}
if (toolName === "move") {
const from = stringProperty(input, "oldPath") ?? stringProperty(input, "path") ?? stringProperty(input, "from");
const to =
stringProperty(input, "newPath") ?? stringProperty(input, "to") ?? stringProperty(input, "destination");
if (from && to) return { toolName, title: `Move ${from} to ${to}`, paths: [from, to], cacheKey: toolName };
return {
toolName,
title: from ? `Move ${from}` : toolName,
paths: from ? [from] : undefined,
cacheKey: toolName,
};
}
if (toolName === "edit") {
const intent = getEditDestructiveIntent(args);
if (!intent) return undefined;
if (intent.kind === "delete") {
return {
toolName,
title: `Delete ${intent.paths[0] ?? "edit target"}`,
paths: intent.paths,
cacheKey: "edit:delete",
};
}
const from = intent.paths[0];
const to = intent.paths[1];
return {
toolName,
title: from && to ? `Move ${from} to ${to}` : `Move ${from ?? to ?? "edit target"}`,
paths: intent.paths,
cacheKey: "edit:move",
};
}
return undefined;
}
/** Converts tool path arguments into absolute ACP editor locations. */
export function extractPermissionLocations(
args: unknown,
cwd: string,
explicitPaths?: string[],
): { path: string; line?: number }[] {
if (!isRecord(args)) return [];
const out: { path: string; line?: number }[] = [];
const pushPath = (value: unknown) => {
if (typeof value !== "string" || value.length === 0) return;
// ACP locations carry file paths that the editor host will open or focus;
// they must be absolute or the client cannot resolve them. Resolve raw
// tool args (often cwd-relative) against the session cwd before sending.
let resolved: string;
try {
resolved = resolveToCwd(value, cwd);
} catch {
return;
}
if (out.some(location => location.path === resolved)) return;
out.push({ path: resolved });
};
if (explicitPaths) {
for (const filePath of explicitPaths) pushPath(filePath);
return out;
}
pushPath(args.path);
pushPath(args.file);
if (Array.isArray(args.paths)) {
for (const filePath of args.paths) {
if (typeof filePath === "string") pushPath(filePath);
}
}
pushPath(args.oldPath);
pushPath(args.newPath);
pushPath(args.from);
pushPath(args.to);
pushPath(args.source);
pushPath(args.destination);
return out;
}
@@ -7,7 +7,7 @@
import { afterAll, afterEach, describe, expect, it } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { logProviderTurnError } from "./agent-session";
import { logProviderTurnError } from "./messages";
function makeMessage(overrides: Partial<AssistantMessage>): AssistantMessage {
return {
@@ -0,0 +1,66 @@
import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
import type { Effort } from "@oh-my-pi/pi-ai";
import type { Rule } from "../capability/rule";
import type { RecoveredRetryError } from "../extensibility/shared-events";
import type { Goal, GoalModeState } from "../goals/state";
import type { ConfiguredThinkingLevel } from "../thinking";
import type { TodoItem } from "../tools/todo";
import type { CustomMessage } from "./messages";
/** Session-specific events that extend the core AgentEvent. */
export type AgentSessionEvent =
| Exclude<AgentEvent, { type: "agent_end" }>
| (Extract<AgentEvent, { type: "agent_end" }> & {
/** False when an async delivery will resume the session before its true final settle. */
isTerminal?: boolean;
})
| {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle" | "incomplete";
action: "context-full" | "handoff" | "shake" | "snapcompact";
}
| {
type: "auto_compaction_end";
action: "context-full" | "handoff" | "shake" | "snapcompact";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
/** True when compaction was skipped for a benign reason. */
skipped?: boolean;
}
| {
type: "auto_retry_start";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
errorId?: number;
}
| {
type: "auto_retry_end";
success: boolean;
attempt: number;
finalError?: string;
recoveredErrors?: RecoveredRetryError[];
}
| { type: "retry_fallback_applied"; from: string; to: string; role: string }
| { type: "retry_fallback_succeeded"; model: string; role: string }
| { type: "ttsr_triggered"; rules: Rule[] }
| { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
| { type: "todo_auto_clear" }
| { type: "irc_message"; message: CustomMessage }
| { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string }
| {
type: "thinking_level_changed";
thinkingLevel: ThinkingLevel | undefined;
/** The user-configured selector when it differs from the effective level. */
configured?: ConfiguredThinkingLevel;
/** The level `auto` resolved to this turn, once classified. */
resolved?: Effort;
}
| { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
/** Listener function for agent session events. */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -0,0 +1,325 @@
import type { Agent, AgentMessage, AgentTool, StreamFn, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type {
Context,
ImageContent,
Message,
MessageAttribution,
Model,
ServiceTierByFamily,
SimpleStreamOptions,
ToolChoice,
} from "@oh-my-pi/pi-ai";
import type { postmortem } from "@oh-my-pi/pi-utils";
import type { AdvisorConfig } from "../advisor";
import type { AsyncJob, AsyncJobDeliveryState, AsyncJobManager } from "../async";
import type { ModelRegistry } from "../config/model-registry";
import type { PromptTemplate } from "../config/prompt-templates";
import type { Settings, SkillsSettings } from "../config/settings";
import type { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
import type { TtsrManager } from "../export/ttsr";
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
import type { ExtensionRunner } from "../extensibility/extensions";
import type { ContextUsage } from "../extensibility/extensions/types";
import type { Skill, SkillWarning } from "../extensibility/skills";
import type { FileSlashCommand } from "../extensibility/slash-commands";
import type { SecretObfuscator } from "../secrets/obfuscator";
import type { ConfiguredThinkingLevel } from "../thinking";
import type { XdevRegistry } from "../tools/xdev";
import type { SessionManager } from "./session-manager";
/** Maximum time the interactive shutdown path waits for Mnemopi consolidation. */
export const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 1_500;
/** Options controlling session disposal. */
export interface AgentSessionDisposeOptions {
mnemopiConsolidateTimeoutMs?: number;
/**
* Postmortem reason that triggered this dispose (signal/fatal teardown
* paths). When set, the persisted `session_exit` diagnostic records it
* instead of the generic `"dispose"` used for normal programmatic disposal
* (`/quit`, test teardown, subagent completion).
*/
reason?: postmortem.Reason;
}
/** Listener notified when command metadata changes. */
export type CommandMetadataChangedListener = () => void | Promise<void>;
/** Public summary of an asynchronous job. */
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
/** Snapshot of running, recent, and pending-delivery asynchronous jobs. */
export interface AsyncJobSnapshot {
running: AsyncJobSnapshotItem[];
recent: AsyncJobSnapshotItem[];
delivery: AsyncJobDeliveryState;
}
export type { ShakeMode, ShakeResult } from "./shake-types";
/**
* Prewalk switches an active session one-way from its starting model to a
* fast/cheap target after implementation begins.
*/
export interface Prewalk {
target: Model;
thinkingLevel?: ConfiguredThinkingLevel;
}
/**
* PlanYolo starts in read-only plan mode, auto-approves the proposal, then
* switches to a target model for implementation.
*/
export interface PlanYolo {
target: Model;
thinkingLevel?: ConfiguredThinkingLevel;
}
/** Identifies a retry fallback chain already entered during startup model resolution. */
export interface InitialRetryFallbackState {
/** Role whose configured primary was unavailable. */
role: string;
/** Configured primary selector retained for restoration when it becomes available. */
originalSelector: string;
/** Thinking selector configured for the unavailable primary. */
originalThinkingLevel: ConfiguredThinkingLevel | undefined;
}
/** Dependencies and initial state used to construct an AgentSession. */
export interface AgentSessionConfig {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
/** Whether the caller explicitly requested yolo/auto-approve behavior for this session. */
autoApprove?: boolean;
/** Models to cycle through with Ctrl+P (from --models flag). */
scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
/** Initial session thinking selector. */
thinkingLevel?: ConfiguredThinkingLevel;
/** Retry chain ownership when startup selected one of its fallback entries. */
initialRetryFallback?: InitialRetryFallbackState;
/** Prewalk from the starting model to a fast/cheap target after implementation begins. */
prewalk?: Prewalk;
/** Force read-only plan mode at start, auto-approve, then switch to the target. */
planYolo?: PlanYolo;
/** Initial per-family service tiers for the live session. */
serviceTierByFamily?: ServiceTierByFamily;
/** Prompt templates for expansion. */
promptTemplates?: PromptTemplate[];
/** File-based slash commands for expansion. */
slashCommands?: FileSlashCommand[];
/** Extension runner created with wrapped tools. */
extensionRunner?: ExtensionRunner;
/** Loaded skills already discovered by the SDK. */
skills?: Skill[];
/** Skill loading warnings already captured by the SDK. */
skillWarnings?: SkillWarning[];
/** Whether runtime reloads may rediscover disk-backed skills. */
skillsReloadable?: boolean;
/** Custom TypeScript slash commands. */
customCommands?: LoadedCustomCommand[];
skillsSettings?: SkillsSettings;
/** Agent directory used when changing memory backends in a live session. */
memoryAgentDir?: string;
/** Recursion depth used to suppress live backend replacement in subagents. */
memoryTaskDepth?: number;
/** Creates built-in memory tools for the current backend. */
createMemoryTools?: () => Promise<AgentTool[]>;
/** Model registry for API key resolution and model discovery. */
modelRegistry: ModelRegistry;
/** Tool registry for LSP and settings. */
toolRegistry?: Map<string, AgentTool>;
/** Creates tools registered only while vibe mode is active. */
createVibeTools?: () => AgentTool[];
/** Names whose current registry entry is the built-in implementation. */
builtInToolNames?: Iterable<string>;
/** Updates tool-session predicates from the live active tool set. */
setActiveToolNames?: (names: Iterable<string>) => void;
/** Registers the write transport when runtime xdev mounts first need it. */
ensureWriteRegistered?: () => Promise<boolean>;
/** Current session pre-LLM message transform pipeline. */
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
/** Provider request transform applied after message conversion. */
transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
/** Stream wrapper for side-channel requests. */
sideStreamFn?: StreamFn;
/** Stream wrapper for advisor requests. */
advisorStreamFn?: StreamFn;
/** Prefer websocket transport for OpenAI Codex requests when supported. */
preferWebsockets?: boolean;
/** Provider payload hook used by the active session request path. */
onPayload?: SimpleStreamOptions["onPayload"];
/** Provider response hook used by the active session request path. */
onResponse?: SimpleStreamOptions["onResponse"];
/** Raw SSE hook used by the active session request path. */
onSseEvent?: SimpleStreamOptions["onSseEvent"];
/** Per-session raw SSE diagnostic buffer. */
rawSseDebugBuffer?: RawSseDebugBuffer;
/** Current session message-to-LLM conversion pipeline. */
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
/** System prompt builder that can consider tool availability. */
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
/** Local calendar date provider used by prompt-cache invalidation. */
getLocalCalendarDate?: () => string;
/** Tools mounted under `xd://`, for `/tools` display. */
getXdevToolEntries?: () => Array<{ name: string; summary: string }>;
/** Session-owned `xd://` registry. */
xdevRegistry?: XdevRegistry;
/** Discoverable tools mounted under `xd://` in the initial enabled set. */
initialMountedXdevToolNames?: string[];
/** Names pinned top-level during runtime repartitioning. */
presentationPinnedToolNames?: ReadonlySet<string>;
/** Accessor for live MCP server instructions. */
getMcpServerInstructions?: () => Map<string, string> | undefined;
/** Time-traveling stream-rule manager. */
ttsrManager?: TtsrManager;
/** Secret obfuscator for provider and edit content. */
obfuscator?: SecretObfuscator;
/** Inherited eval executor session id from a parent agent. */
parentEvalSessionId?: string;
/** Logical owner for retained eval kernels created by this session. */
evalKernelOwnerId?: string;
/** Async job manager owned and disposed by this session. */
ownedAsyncJobManager?: AsyncJobManager;
/** Async job manager visible to this session. */
asyncJobManager?: AsyncJobManager;
/** Registry identity used for IRC routing. */
agentId?: string;
/** Whether this is a top-level or subagent session. */
agentKind?: "main" | "sub";
/** Provider-facing session ID override. */
providerSessionId?: string;
/** Whether the provider prompt-cache key was explicit or fork-inherited. */
providerPromptCacheKeySource?: "explicit" | "fork";
/** Full advisor toolset built against an advisor-scoped tool session. */
advisorTools?: AgentTool[];
/** Preloaded watchdog prompt content for the advisor. */
advisorWatchdogPrompt?: string;
/** Shared advisor instructions loaded from WATCHDOG.yml. */
advisorSharedInstructions?: string;
/** Project context rendered for advisor sessions. */
advisorContextPrompt?: string;
/** Advisors discovered from WATCHDOG.yml. */
advisorConfigs?: AdvisorConfig[];
/** Strip tool descriptions from provider-bound side-request tool specs. */
pruneToolDescriptions?: boolean;
/** Disconnect the MCP manager owned by this session during disposal. */
disconnectOwnedMcpManager?: () => Promise<void>;
/** System prompt used by automatic session-title generation. */
titleSystemPrompt?: string;
}
/** Options for AgentSession.prompt(). */
export interface PromptOptions {
/** Whether to expand file-based prompt templates (default: true). */
expandPromptTemplates?: boolean;
/** Image attachments. */
images?: ImageContent[];
/** Queue behavior while streaming. */
streamingBehavior?: "steer" | "followUp";
/** Optional tool choice override for the next LLM call. */
toolChoice?: ToolChoice;
/** Send as a developer/system message instead of user. */
synthetic?: boolean;
/** Whether this prompt is a deliberate user action. */
userInitiated?: boolean;
/** Explicit billing/initiator attribution. */
attribution?: MessageAttribution;
/** Skip pre-send compaction checks for this prompt. */
skipCompactionCheck?: boolean;
}
/** Options for AgentSession.followUp(). */
export interface FollowUpOptions {
/** Enqueue as a hidden developer message instead of a user follow-up. */
synthetic?: boolean;
/** Whether to expand file-based prompt templates (default: true). */
expandPromptTemplates?: boolean;
/** Explicit billing/initiator attribution. */
attribution?: MessageAttribution;
}
/** Result from a handoff operation. */
export interface HandoffResult {
document: string;
savedPath?: string;
}
/** Options controlling handoff generation. */
export interface SessionHandoffOptions {
autoTriggered?: boolean;
signal?: AbortSignal;
onSwitchCancelled?: () => void;
}
/** Result from cycleModel(). */
export interface ModelCycleResult {
model: Model;
thinkingLevel: ThinkingLevel | undefined;
/** Whether cycling through scoped models or all available models. */
isScoped: boolean;
}
/** Result from cycleRoleModels(). */
export interface RoleModelCycleResult {
model: Model;
thinkingLevel: ThinkingLevel | undefined;
role: string;
}
/** A configured role resolved to a concrete model. */
export interface ResolvedRoleModel {
role: string;
model: Model;
thinkingLevel?: ConfiguredThinkingLevel;
explicitThinkingLevel: boolean;
}
/** Resolvable role models and the currently active index. */
export interface RoleModelCycle {
models: ResolvedRoleModel[];
currentIndex: number;
}
/** Token breakdown for the current provider context. */
export interface ContextUsageBreakdown {
contextWindow: number;
anchored: boolean;
usedTokens: number;
systemPromptTokens: number;
systemToolsTokens: number;
systemContextTokens: number;
skillsTokens: number;
messagesTokens: number;
}
/** Session statistics for the `/session` command. */
export interface SessionStats {
sessionFile: string | undefined;
sessionId: string;
userMessages: number;
assistantMessages: number;
toolCalls: number;
toolResults: number;
totalMessages: number;
tokens: {
input: number;
output: number;
reasoning: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
premiumRequests: number;
cost: number;
contextUsage?: ContextUsage;
}
/** IDs for a newly created session and the session it replaced. */
export interface FreshSessionResult {
previousSessionId: string;
sessionId: string;
closedProviderSessions: number;
}
/** Queued user content restored to the editor. */
export type RestoredQueuedMessage = { text: string; images?: ImageContent[] };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,326 @@
import * as path from "node:path";
import type { Agent } from "@oh-my-pi/pi-agent-core";
import { logger } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
import type { ExtensionRunner } from "../extensibility/extensions";
import { outputMeta } from "../tools/output-meta";
import { clampTimeout } from "../tools/tool-timeouts";
import type { BashExecutionMessage } from "./messages";
import type { SessionManager } from "./session-manager";
/** Destination that owns a bash result after a session or branch transition. */
export type BashAppendDestination =
| { kind: "current"; manager: SessionManager }
| { kind: "detached"; manager: SessionManager }
| { kind: "branch"; manager: SessionManager; parentId: string | null };
/** Reference-counted session target captured when a bash execution starts. */
export interface BashSessionTarget {
sessionId: string;
refs: number;
destination?: BashAppendDestination;
pending?: Promise<BashAppendDestination>;
}
interface PendingBashMessage {
target: BashSessionTarget;
message: BashExecutionMessage;
}
/** Ownership snapshot spanning a session or branch transition. */
export interface BashSessionTransition {
oldTarget: BashSessionTarget;
newTarget: BashSessionTarget;
oldSessionId: string;
oldSessionFile: string | undefined;
oldLeafId: string | null;
detachedManager: SessionManager | undefined;
resolveOld: ((destination: BashAppendDestination) => void) | undefined;
resolveNew: (destination: BashAppendDestination) => void;
}
/** Capabilities the bash runner borrows from its owning session. */
export interface BashRunnerHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
extensionRunner(): ExtensionRunner | undefined;
isStreaming(): boolean;
}
/** Owns bash execution and preserves result ownership across transcript transitions. */
export class BashRunner {
readonly #host: BashRunnerHost;
#abortControllers = new Set<AbortController>();
#pendingMessages: PendingBashMessage[] = [];
#sessionTarget: BashSessionTarget;
constructor(host: BashRunnerHost) {
this.#host = host;
this.#sessionTarget = {
sessionId: host.sessionManager.getSessionId(),
refs: 0,
destination: { kind: "current", manager: host.sessionManager },
};
}
/** Executes a bash command while retaining the session and branch that owned its start. */
async executeBash(
command: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean; useUserShell?: boolean },
): Promise<BashResult> {
const target = this.#captureSessionTarget();
let targetTransferred = false;
const excludeFromContext = options?.excludeFromContext === true;
const cwd = this.#host.sessionManager.getCwd();
try {
const extensionRunner = this.#host.extensionRunner();
if (extensionRunner?.hasHandlers("user_bash")) {
const hookResult = await extensionRunner.emitUserBash({
type: "user_bash",
command,
excludeFromContext,
cwd,
});
if (hookResult?.result) {
targetTransferred = true;
await this.#recordResultForTarget(target, command, hookResult.result, options);
return hookResult.result;
}
}
const abortController = new AbortController();
this.#abortControllers.add(abortController);
let result: BashResult;
try {
result = await executeBashCommand(command, {
onChunk,
signal: abortController.signal,
sessionKey: target.sessionId,
cwd,
timeout: clampTimeout("bash", undefined, this.#host.settings.get("tools.maxTimeout")) * 1000,
onMinimizedSave: originalText => this.#saveOriginalArtifact(target, originalText),
useUserShell: options?.useUserShell,
});
} finally {
this.#abortControllers.delete(abortController);
}
targetTransferred = true;
await this.#recordResultForTarget(target, command, result, options);
return result;
} finally {
if (!targetTransferred) await this.#releaseSessionTarget(target);
}
}
/** Records a bash result supplied outside executeBash in the current ownership scope. */
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
const target = this.#captureSessionTarget();
const message = this.#createMessage(command, result, options);
if (this.#host.isStreaming() && target === this.#sessionTarget) {
this.#pendingMessages.push({ target, message });
return;
}
if (target.destination) {
try {
this.#appendMessage(target.destination, message);
} finally {
void this.#releaseSessionTarget(target);
}
return;
}
void this.#appendOwnedMessage(target, message).catch(error => {
logger.error("Failed to record bash result in its owning session", { error: String(error) });
});
}
/** Cancels every running bash command. */
abort(): void {
for (const abortController of this.#abortControllers) abortController.abort();
}
/** Whether a bash command is currently running. */
get isRunning(): boolean {
return this.#abortControllers.size > 0;
}
/** Whether bash results are waiting for a safe persistence boundary. */
get hasPendingMessages(): boolean {
return this.#pendingMessages.length > 0;
}
/** Flushes deferred bash results without changing their captured ownership. */
async flushPending(): Promise<void> {
if (this.#pendingMessages.length === 0) return;
const pending = this.#pendingMessages;
this.#pendingMessages = [];
for (const { target, message } of pending) await this.#appendOwnedMessage(target, message);
}
/** Runs a leaf rewrite while retaining in-flight bash on its originating branch. */
withBranchTransition<T>(mutate: () => T): T {
const transition = this.beginSessionTransition();
let transitioned = false;
try {
const result = mutate();
this.markSessionTransition(transition);
transitioned = true;
return result;
} finally {
this.finishSessionTransition(transition, transitioned);
}
}
/** Snapshots the owner of in-flight bash before a session or branch transition. */
beginSessionTransition(options?: { persistDetached?: boolean }): BashSessionTransition {
const oldTarget = this.#sessionTarget;
let detachedManager: SessionManager | undefined;
let resolveOld: ((destination: BashAppendDestination) => void) | undefined;
if (oldTarget.refs > 0) {
detachedManager = this.#host.sessionManager.cloneCurrentSession({ persist: options?.persistDetached });
const pendingOld = Promise.withResolvers<BashAppendDestination>();
oldTarget.destination = undefined;
oldTarget.pending = pendingOld.promise;
resolveOld = pendingOld.resolve;
}
const pendingNew = Promise.withResolvers<BashAppendDestination>();
return {
oldTarget,
newTarget: {
sessionId: this.#host.sessionManager.getSessionId(),
refs: 0,
pending: pendingNew.promise,
},
oldSessionId: this.#host.sessionManager.getSessionId(),
oldSessionFile: this.#host.sessionManager.getSessionFile(),
oldLeafId: this.#host.sessionManager.getLeafId(),
detachedManager,
resolveOld,
resolveNew: pendingNew.resolve,
};
}
/** Adopts a transition's new target as the live bash owner. */
markSessionTransition(transition: BashSessionTransition): void {
transition.newTarget.sessionId = this.#host.sessionManager.getSessionId();
this.#sessionTarget = transition.newTarget;
}
/** Resolves destinations opened by beginSessionTransition. */
finishSessionTransition(transition: BashSessionTransition, success: boolean): void {
const manager = this.#host.sessionManager;
const currentDestination: BashAppendDestination = { kind: "current", manager };
let oldDestination: BashAppendDestination = currentDestination;
if (success && transition.resolveOld) {
const currentFile = manager.getSessionFile();
const sameFile =
transition.oldSessionFile === currentFile ||
(transition.oldSessionFile !== undefined &&
currentFile !== undefined &&
path.resolve(transition.oldSessionFile) === path.resolve(currentFile));
const sameSession = transition.oldSessionId === manager.getSessionId() && sameFile;
if (sameSession) {
oldDestination =
transition.oldLeafId === manager.getLeafId()
? currentDestination
: { kind: "branch", manager, parentId: transition.oldLeafId };
} else if (transition.detachedManager) {
oldDestination = { kind: "detached", manager: transition.detachedManager };
}
}
if (transition.resolveOld) {
transition.oldTarget.pending = undefined;
transition.oldTarget.destination = oldDestination;
transition.resolveOld(oldDestination);
}
transition.newTarget.pending = undefined;
transition.newTarget.destination = currentDestination;
if (!success) transition.newTarget.sessionId = manager.getSessionId();
transition.resolveNew(currentDestination);
if (transition.detachedManager && (oldDestination.kind !== "detached" || transition.oldTarget.refs === 0)) {
void transition.detachedManager.close().catch(error => {
logger.warn("Failed to close detached bash session writer", { error: String(error) });
});
}
}
async #saveOriginalArtifact(target: BashSessionTarget, originalText: string): Promise<string | undefined> {
try {
const destination = target.destination ?? (await target.pending);
return await destination?.manager.saveArtifact(originalText, "bash-original");
} catch {
return undefined;
}
}
#createMessage(
command: string,
result: BashResult,
options?: { excludeFromContext?: boolean },
): BashExecutionMessage {
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
return {
role: "bashExecution",
command,
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncated,
meta,
timestamp: Date.now(),
excludeFromContext: options?.excludeFromContext,
};
}
#captureSessionTarget(): BashSessionTarget {
this.#sessionTarget.refs++;
return this.#sessionTarget;
}
async #releaseSessionTarget(target: BashSessionTarget): Promise<void> {
if (target.refs <= 0) throw new Error("Bash session target released more than once");
target.refs--;
if (target.refs === 0 && target.destination?.kind === "detached") await target.destination.manager.close();
}
#appendMessage(destination: BashAppendDestination, message: BashExecutionMessage): void {
switch (destination.kind) {
case "current":
this.#host.agent.appendMessage(message);
destination.manager.appendMessage(message);
break;
case "detached":
destination.manager.appendMessage(message);
break;
case "branch":
destination.parentId = destination.manager.appendMessageToBranch(message, destination.parentId);
break;
}
}
async #appendOwnedMessage(target: BashSessionTarget, message: BashExecutionMessage): Promise<void> {
try {
const destination = target.destination ?? (await target.pending);
if (!destination) throw new Error("Bash session target has no append destination");
this.#appendMessage(destination, message);
} finally {
await this.#releaseSessionTarget(target);
}
}
async #recordResultForTarget(
target: BashSessionTarget,
command: string,
result: BashResult,
options?: { excludeFromContext?: boolean },
): Promise<void> {
const message = this.#createMessage(command, result, options);
if (this.#host.isStreaming() && target === this.#sessionTarget) {
this.#pendingMessages.push({ target, message });
return;
}
await this.#appendOwnedMessage(target, message);
}
}
@@ -0,0 +1,81 @@
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { stringProperty } from "@oh-my-pi/pi-utils";
import type { CompletedRewindState } from "../tools/checkpoint";
import { writeDeviceDispatch } from "../tools/resolve";
import type { SessionEntry } from "./session-entries";
/** Extracts text from custom message content. */
export function customMessageContentText(content: string | (TextContent | ImageContent)[]): string {
if (typeof content === "string") return content;
const parts: string[] = [];
for (const part of content) {
if (part.type === "text") parts.push(part.text);
}
return parts.join("\n");
}
/** Extracts the report body from persisted rewind-report content. */
export function reportFromRewindReportContent(content: string): string {
const marker = "\nReport:\n";
const index = content.lastIndexOf(marker);
const report = index >= 0 ? content.slice(index + marker.length) : content;
return report.trim();
}
/** Checkpoint-domain tool names normalized from native and xdev calls. */
export type SemanticCheckpointToolName = "checkpoint" | "rewind";
/** Normalized checkpoint-domain tool result. */
export interface SemanticToolResult {
toolName: SemanticCheckpointToolName;
details?: unknown;
}
/** Normalizes checkpoint and rewind results across native calls and xdev dispatches. */
export function semanticToolResult(toolName: string | undefined, result: unknown): SemanticToolResult | undefined {
if (toolName === "checkpoint" || toolName === "rewind") {
const details = result && typeof result === "object" && "details" in result ? result.details : undefined;
return { toolName, details };
}
const dispatch = writeDeviceDispatch(toolName ?? "", result);
if (dispatch?.mode !== "execute" || (dispatch.tool !== "checkpoint" && dispatch.tool !== "rewind")) {
return undefined;
}
return { toolName: dispatch.tool, details: dispatch.inner };
}
/** Restores completed rewind state from a persisted session entry. */
export function completedRewindFromEntry(entry: SessionEntry): CompletedRewindState | undefined {
if (entry.type !== "custom_message" || entry.customType !== "rewind-report") return undefined;
const details = entry.details;
if (!details || typeof details !== "object") return undefined;
const startedAt = stringProperty(details, "startedAt");
const rewoundAt = stringProperty(details, "rewoundAt");
if (!startedAt || !rewoundAt) return undefined;
const report =
stringProperty(details, "report")?.trim() ||
reportFromRewindReportContent(customMessageContentText(entry.content));
return report.length > 0 ? { report, startedAt, rewoundAt } : undefined;
}
/** Whether an entry is a successful checkpoint tool result. */
export function isSuccessfulCheckpointEntry(
entry: SessionEntry,
): entry is SessionEntry & { type: "message"; message: Extract<AgentMessage, { role: "toolResult" }> } {
if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.isError === true) {
return false;
}
return semanticToolResult(entry.message.toolName, entry.message)?.toolName === "checkpoint";
}
/** Returns the checkpoint start timestamp represented by an entry. */
export function checkpointStartedAtFromEntry(entry: SessionEntry): string | undefined {
if (!isSuccessfulCheckpointEntry(entry)) return undefined;
const details = semanticToolResult(entry.message.toolName, entry.message)?.details;
if (details && typeof details === "object") {
const startedAt = stringProperty(details, "startedAt");
if (startedAt) return startedAt;
}
return entry.timestamp;
}
@@ -0,0 +1,212 @@
import type { Agent } from "@oh-my-pi/pi-agent-core";
import { logger } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import { disposeJuliaKernelSessionsByOwner } from "../eval/jl/executor";
import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
import {
disposeKernelSessionsByOwner,
executePython as executePythonCommand,
type PythonResult,
} from "../eval/py/executor";
import { disposeRubyKernelSessionsByOwner } from "../eval/rb/executor";
import { defaultEvalSessionId } from "../eval/session-id";
import type { ExtensionRunner } from "../extensibility/extensions";
import { outputMeta } from "../tools/output-meta";
import type { PythonExecutionMessage } from "./messages";
import type { SessionManager } from "./session-manager";
/** Capabilities the eval runner borrows from its owning session. */
export interface EvalRunnerHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
extensionRunner(): ExtensionRunner | undefined;
isStreaming(): boolean;
appendSessionMessage(message: PythonExecutionMessage): void;
}
/** Owns user-initiated Python execution and retained eval-kernel lifecycle. */
export class EvalRunner {
readonly #host: EvalRunnerHost;
readonly #kernelOwnerId: string;
readonly #parentSessionId: string | undefined;
#abortControllers = new Set<AbortController>();
#pendingMessages: PythonExecutionMessage[] = [];
#activeExecutions = new Set<Promise<unknown>>();
#disposing = false;
constructor(host: EvalRunnerHost, options: { kernelOwnerId: string; parentSessionId: string | undefined }) {
this.#host = host;
this.#kernelOwnerId = options.kernelOwnerId;
this.#parentSessionId = options.parentSessionId;
}
/** Executes Python in the session's shared kernel. */
async executePython(
code: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean },
): Promise<PythonResult> {
const excludeFromContext = options?.excludeFromContext === true;
const cwd = this.#host.sessionManager.getCwd();
this.assertExecutionAllowed();
const abortController = new AbortController();
const execution = (async (): Promise<PythonResult> => {
const extensionRunner = this.#host.extensionRunner();
if (extensionRunner?.hasHandlers("user_python")) {
const hookResult = await extensionRunner.emitUserPython({
type: "user_python",
code,
excludeFromContext,
cwd,
});
this.assertExecutionAllowed();
if (hookResult?.result) {
this.recordPythonResult(code, hookResult.result, options);
return hookResult.result;
}
}
const sessionId =
this.getSessionId() ??
defaultEvalSessionId({
cwd,
getSessionFile: () => this.#host.sessionManager.getSessionFile() ?? null,
});
const result = await executePythonCommand(code, {
cwd,
sessionId: namespacePythonSessionId(sessionId),
kernelOwnerId: this.#kernelOwnerId,
kernelMode: this.#host.settings.get("python.kernelMode"),
interpreter: this.#host.settings.get("python.interpreter")?.trim() || undefined,
onChunk,
signal: abortController.signal,
});
this.recordPythonResult(code, result, options);
return result;
})();
return await this.trackExecution(execution, abortController);
}
/** Rejects new eval work once session disposal begins. */
assertExecutionAllowed(): void {
if (this.#disposing) throw new Error("Python execution is unavailable while session disposal is in progress");
}
/** Tracks externally started Python work so disposal can await and abort it. */
trackExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
this.#abortControllers.add(abortController);
this.#activeExecutions.add(execution);
void execution.then(
() => {
this.#abortControllers.delete(abortController);
this.#activeExecutions.delete(execution);
},
() => {
this.#abortControllers.delete(abortController);
this.#activeExecutions.delete(execution);
},
);
return execution;
}
/** Records a Python execution result in session history. */
recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void {
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
const message: PythonExecutionMessage = {
role: "pythonExecution",
code,
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncated,
meta,
timestamp: Date.now(),
excludeFromContext: options?.excludeFromContext,
};
if (this.#host.isStreaming()) {
this.#pendingMessages.push(message);
} else {
this.#host.appendSessionMessage(message);
}
}
/** Cancels every running Python execution. */
abort(): void {
for (const abortController of this.#abortControllers) abortController.abort();
}
/** Whether a Python execution is currently running. */
get isRunning(): boolean {
return this.#abortControllers.size > 0;
}
/** Whether Python results are waiting for a safe persistence boundary. */
get hasPendingMessages(): boolean {
return this.#pendingMessages.length > 0;
}
/** Returns the eval session shared with the Python backend. */
getSessionId(): string | null {
if (this.#parentSessionId !== undefined) return this.#parentSessionId;
return defaultEvalSessionId({
cwd: this.#host.sessionManager.getCwd(),
getSessionFile: () => this.#host.sessionManager.getSessionFile() ?? null,
});
}
/** Flushes deferred Python results into agent state and persistence. */
flushPending(): void {
if (this.#pendingMessages.length === 0) return;
for (const message of this.#pendingMessages) this.#host.appendSessionMessage(message);
this.#pendingMessages = [];
}
/** Prevents new Python executions before asynchronous disposal starts. */
beginDispose(): void {
this.#disposing = true;
}
/** Waits for active work and disposes every retained eval kernel owned by the session. */
async disposeKernels(): Promise<void> {
const settled = await this.#prepareExecutionsForDispose();
if (!settled) {
logger.warn("Detaching retained eval-kernel ownership during dispose while eval execution is still active");
}
const results = await Promise.allSettled([
disposeKernelSessionsByOwner(this.#kernelOwnerId),
disposeRubyKernelSessionsByOwner(this.#kernelOwnerId),
disposeJuliaKernelSessionsByOwner(this.#kernelOwnerId),
]);
const errors: unknown[] = [];
for (const result of results) if (result.status === "rejected") errors.push(result.reason);
if (errors.length > 0) throw new AggregateError(errors, "Failed to dispose one or more eval kernels");
}
async #waitForExecutionsToSettle(timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (this.#activeExecutions.size > 0) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) return false;
const settled = await Promise.race([
Promise.allSettled(Array.from(this.#activeExecutions)).then(() => true),
Bun.sleep(remainingMs).then(() => false),
]);
if (!settled && this.#activeExecutions.size > 0) return false;
}
return true;
}
async #prepareExecutionsForDispose(): Promise<boolean> {
if (!(await this.#waitForExecutionsToSettle(3_000))) {
logger.warn("Aborting active Python execution during dispose before retained kernel cleanup");
this.abort();
if (!(await this.#waitForExecutionsToSettle(1_000))) {
logger.warn(
"Python execution is still active after dispose aborted all active runs; retained kernel ownership will still be detached",
);
return false;
}
}
return true;
}
}
@@ -0,0 +1,203 @@
import type { Agent } from "@oh-my-pi/pi-agent-core";
import { logger, prompt } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import { IrcBus, type IrcMessage } from "../irc/bus";
import parentIrcSteerTemplate from "../prompts/steering/parent-irc.md" with { type: "text" };
import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
import { AgentRegistry } from "../registry/agent-registry";
import type { AgentSessionEvent } from "./agent-session-events";
import type { CustomMessage } from "./messages";
import type { SessionManager } from "./session-manager";
/** Capabilities the IRC bridge borrows from its owning session. */
export interface IrcBridgeHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
isDisposed(): boolean;
isStreaming(): boolean;
planModeEnabled(): boolean;
emitSessionEvent(event: AgentSessionEvent): Promise<void>;
wakeForIrc(records: CustomMessage[]): void;
runEphemeralTurn(args: { promptText: string }): Promise<{ replyText: string }>;
}
/** Owns incoming IRC queues, injection, and side-channel auto-replies. */
export class IrcBridge {
readonly #host: IrcBridgeHost;
#interrupts: CustomMessage[] = [];
#asides: CustomMessage[] = [];
constructor(host: IrcBridgeHost) {
this.#host = host;
}
/** Whether an incoming peer message can interrupt a wait. */
hasInterrupts(): boolean {
return this.#interrupts.length > 0;
}
/** Whether any undelivered IRC record remains queued. */
hasPending(): boolean {
return this.#interrupts.length > 0 || this.#asides.length > 0;
}
/** Takes every queued IRC record in interrupt-before-aside order. */
drainPending(): CustomMessage[] {
const records = [...this.#interrupts, ...this.#asides];
this.#interrupts = [];
this.#asides = [];
return records;
}
/** Surfaces and consumes queued incoming records before automatic injection. */
drainInboxMessages(agentId: string, opts?: { from?: string; limit?: number }): IrcMessage[] {
const messages: IrcMessage[] = [];
const remainingInterrupts: CustomMessage[] = [];
const remainingAsides: CustomMessage[] = [];
const queues = [
{ records: this.#interrupts, remaining: remainingInterrupts },
{ records: this.#asides, remaining: remainingAsides },
];
for (const queue of queues) {
for (const record of queue.records) {
if (record.customType !== "irc:incoming") {
queue.remaining.push(record);
continue;
}
const details = record.details;
if (!details || typeof details !== "object") {
queue.remaining.push(record);
continue;
}
const id = Reflect.get(details, "id");
const from = Reflect.get(details, "from");
const body = Reflect.get(details, "message");
const replyTo = Reflect.get(details, "replyTo");
if (typeof id !== "string" || typeof from !== "string" || typeof body !== "string") {
queue.remaining.push(record);
continue;
}
if (opts?.from !== undefined && from !== opts.from) {
queue.remaining.push(record);
continue;
}
if (opts?.limit !== undefined && messages.length >= opts.limit) {
queue.remaining.push(record);
continue;
}
messages.push({
id,
from,
to: agentId,
body,
ts: record.timestamp,
...(typeof replyTo === "string" ? { replyTo } : {}),
});
}
}
this.#interrupts = remainingInterrupts;
this.#asides = remainingAsides;
return messages;
}
/** Delivers an IRC message into the recipient session without awaiting any wake turn. */
async deliver(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> {
if (this.#host.isDisposed()) throw new Error("Recipient session is disposed.");
const streaming = this.#host.isStreaming();
const planModeIdle = !streaming && this.#host.planModeEnabled();
const autoReply =
(opts?.expectsReply ?? false) && ((streaming && !this.#host.settings.get("async.enabled")) || planModeIdle);
const record: CustomMessage = {
role: "custom",
customType: "irc:incoming",
content: prompt.render(ircIncomingTemplate, {
from: msg.from,
message: msg.body,
replyTo: msg.replyTo ?? "",
autoReplied: autoReply,
interrupting: streaming,
}),
display: true,
details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
attribution: "agent",
timestamp: msg.ts,
};
void this.#host.emitSessionEvent({ type: "irc_message", message: record });
if (streaming) {
const recipientParentId = AgentRegistry.global().get(msg.to)?.parentId;
if (recipientParentId === msg.from) {
this.#host.agent.steer({
role: "user",
content: prompt.render(parentIrcSteerTemplate, { from: msg.from, message: msg.body }),
attribution: "agent",
timestamp: msg.ts,
steering: true,
});
} else {
this.#interrupts.push(record);
}
if (autoReply) void this.#runAutoReply(msg);
return "injected";
}
if (this.#host.planModeEnabled()) {
this.#host.agent.appendMessage(record);
this.#host.sessionManager.appendCustomMessageEntry(
record.customType,
record.content,
record.display,
record.details,
record.attribution ?? "agent",
);
if (autoReply) void this.#runAutoReply(msg);
return "injected";
}
this.#host.wakeForIrc([record]);
return "woken";
}
/** Emits an IRC relay observation for rendering without persisting it. */
emitRelayObservation(record: CustomMessage): void {
void this.#host.emitSessionEvent({ type: "irc_message", message: record });
}
/** Persists queued IRC records that missed their step-boundary injection. */
flushPending(): void {
for (const record of this.drainPending()) {
this.#host.agent.emitExternalEvent({ type: "message_start", message: record });
this.#host.agent.emitExternalEvent({ type: "message_end", message: record });
}
}
async #runAutoReply(msg: IrcMessage): Promise<void> {
try {
const { replyText } = await this.#host.runEphemeralTurn({
promptText: prompt.render(ircAutoReplyTemplate, {
from: msg.from,
message: msg.body,
replyTo: msg.replyTo ?? "",
}),
});
const body = replyText.trim();
if (!body || this.#host.isDisposed()) return;
const record: CustomMessage = {
role: "custom",
customType: "irc:autoreply",
content: `[IRC you → \`${msg.from}\` (auto)]\n\n${body}`,
display: true,
details: { to: msg.from, body, replyTo: msg.id },
attribution: "agent",
timestamp: Date.now(),
};
void this.#host.emitSessionEvent({ type: "irc_message", message: record });
this.#asides.push(record);
const receipt = await IrcBus.global().send({ from: msg.to, to: msg.from, body, replyTo: msg.id });
if (receipt.outcome === "failed") {
logger.warn("IRC auto-reply delivery failed", { to: msg.from, error: receipt.error });
}
} catch (error) {
logger.warn("IRC auto-reply turn failed", { from: msg.from, error: String(error) });
}
}
}
+263 -1
View File
@@ -23,8 +23,9 @@ import type {
UserMessage,
} from "@oh-my-pi/pi-ai";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { prompt } from "@oh-my-pi/pi-utils";
import { isRecord, logger, prompt } from "@oh-my-pi/pi-utils";
import userInterjectionTemplate from "../prompts/steering/user-interjection.md" with { type: "text" };
import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/message-preproc";
export {
type BranchSummaryMessage,
@@ -41,6 +42,267 @@ export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
/**
* Logs provider-error turns so their actual cause is available outside the
* session transcript. No-op for non-error stop reasons.
*/
export function logProviderTurnError(msg: AssistantMessage): void {
if (msg.stopReason !== "error") return;
logger.warn("agent turn ended with provider error", {
provider: msg.provider,
model: msg.model,
errorMessage: msg.errorMessage,
errorStatus: msg.errorStatus,
errorId: msg.errorId,
});
}
const EPHEMERAL_REPLY_MAX_BYTES = 4096;
const REPLAN_TITLE_CONTEXT_TURN_LIMIT = 6;
/**
* Removes replay-bound provider state before reparenting an assistant message
* under a different user turn.
*/
export function sanitizeAssistantForReparentedHistory(message: AssistantMessage): AssistantMessage {
const content: AssistantMessage["content"] = [];
for (const block of message.content) {
if (block.type === "redactedThinking") continue;
if (block.type === "thinking") {
content.push({ type: "thinking", thinking: block.thinking });
continue;
}
content.push(block);
}
return { ...message, content, providerPayload: undefined };
}
/**
* Collapses degenerate repeated lines and bounds an ephemeral side-channel
* reply to 4 KiB.
*/
export function dedupeEphemeralReply(text: string): string {
if (!text) return text;
const lines = text.split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
let j = i + 1;
while (j < lines.length && lines[j] === lines[i]) j++;
const runLen = j - i;
if (runLen > 3) {
out.push(lines[i], `[…${runLen}×]`);
} else {
for (let k = 0; k < runLen; k++) out.push(lines[i]);
}
i = j;
}
let result = out.join("\n");
if (Buffer.byteLength(result, "utf8") > EPHEMERAL_REPLY_MAX_BYTES) {
const suffix = "\n[…truncated]";
const budget = EPHEMERAL_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
while (Buffer.byteLength(result, "utf8") > budget) {
result = result.slice(0, -1);
}
result += suffix;
}
return result;
}
/** Builds the recent user/assistant context supplied to title regeneration. */
export function buildReplanTitleContext(messages: AgentMessage[]): string {
const turns: TitleConversationTurn[] = [];
for (let i = messages.length - 1; i >= 0 && turns.length < REPLAN_TITLE_CONTEXT_TURN_LIMIT; i--) {
const message = messages[i];
if (!message) continue;
const turn = titleConversationTurnFromMessage(message);
if (turn) turns.push(turn);
}
turns.reverse();
return formatTitleConversationContext(turns);
}
/**
* Compares session messages by provider-replay semantics, ignoring runtime-only
* fields that do not change a restored request.
*/
export function didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean {
if (previousMessages.length !== nextMessages.length) return true;
return previousMessages.some(
(message, i) =>
!Bun.deepEquals(
normalizeSessionMessageForProviderReplay(message),
normalizeSessionMessageForProviderReplay(nextMessages[i]),
),
);
}
function textFromContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") continue;
const text = block.text.trim();
if (text) parts.push(text);
}
return parts.join("\n\n");
}
function thinkingFromContent(content: unknown): string {
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!isRecord(block) || block.type !== "thinking" || typeof block.thinking !== "string") continue;
const thinking = block.thinking.trim();
if (thinking) parts.push(thinking);
}
return parts.join("\n\n");
}
function titleConversationTurnFromMessage(message: AgentMessage): TitleConversationTurn | undefined {
if (message.role !== "user" && message.role !== "assistant") return undefined;
const text = textFromContent(message.content);
const thinking = message.role === "assistant" ? thinkingFromContent(message.content) : undefined;
if (!text && !thinking) return undefined;
return { role: message.role, ...(text ? { text } : {}), ...(thinking ? { thinking } : {}) };
}
function normalizeProviderReplayValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(normalizeProviderReplayValue);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [key, normalizeProviderReplayValue(entryValue)]),
);
}
return value;
}
function normalizeSessionMessageForProviderReplay(message: AgentMessage): unknown {
switch (message.role) {
case "user":
case "developer":
return {
role: message.role,
content: normalizeProviderReplayValue(message.content),
providerPayload: message.providerPayload,
};
case "assistant": {
const isResponsesFamilyMessage =
message.api === "openai-responses" || message.api === "openai-codex-responses";
return {
role: message.role,
content:
isResponsesFamilyMessage && Array.isArray(message.content)
? message.content.flatMap(block => {
if (block.type === "thinking") {
return [];
}
if (block.type === "toolCall") {
return [
{
type: block.type,
id: block.id,
name: block.name,
arguments: block.arguments,
},
];
}
if (block.type === "text") {
return [{ type: block.type, text: block.text, textSignature: block.textSignature }];
}
return [normalizeProviderReplayValue(block)];
})
: normalizeProviderReplayValue(message.content),
api: message.api,
provider: message.provider,
model: message.model,
stopReason: message.stopReason,
errorMessage: message.errorMessage,
providerPayload: isResponsesFamilyMessage ? undefined : message.providerPayload,
};
}
case "toolResult":
return {
role: message.role,
toolName: message.toolName,
toolCallId: message.toolCallId,
isError: message.isError,
content: normalizeProviderReplayValue(message.content),
};
case "bashExecution":
return {
role: message.role,
command: message.command,
output: message.output,
exitCode: message.exitCode,
cancelled: message.cancelled,
meta: message.meta
? {
truncation: normalizeProviderReplayValue(message.meta.truncation),
limits: normalizeProviderReplayValue(message.meta.limits),
diagnostics: message.meta.diagnostics
? normalizeProviderReplayValue({
summary: message.meta.diagnostics.summary,
messages: message.meta.diagnostics.messages,
})
: undefined,
}
: undefined,
excludeFromContext: message.excludeFromContext,
};
case "pythonExecution":
return {
role: message.role,
code: message.code,
output: message.output,
exitCode: message.exitCode,
cancelled: message.cancelled,
meta: message.meta
? {
truncation: normalizeProviderReplayValue(message.meta.truncation),
limits: normalizeProviderReplayValue(message.meta.limits),
diagnostics: message.meta.diagnostics
? normalizeProviderReplayValue({
summary: message.meta.diagnostics.summary,
messages: message.meta.diagnostics.messages,
})
: undefined,
}
: undefined,
excludeFromContext: message.excludeFromContext,
};
case "custom":
case "hookMessage":
return {
role: message.role,
customType: message.customType,
content: normalizeProviderReplayValue(message.content),
};
case "branchSummary":
return { role: message.role, summary: message.summary };
case "compactionSummary":
return {
role: message.role,
summary: message.summary,
providerPayload: message.providerPayload,
};
case "fileMention":
return {
role: message.role,
files: message.files.map(file => ({
path: file.path,
content: file.content,
image: file.image,
})),
};
default:
return normalizeProviderReplayValue(message);
}
}
/** Fallback type for extension-injected messages that omit a custom type. */
export const DEFAULT_CUSTOM_MESSAGE_TYPE = "custom-message";
@@ -0,0 +1,714 @@
import { type Agent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Model, ProviderSessionState, ServiceTier, ServiceTierByFamily, ServiceTierFamily } from "@oh-my-pi/pi-ai";
import {
clearAnthropicFastModeFallback,
Effort,
realizesPriorityServiceTier,
resolveModelServiceTier,
serviceTierFamily,
} from "@oh-my-pi/pi-ai";
import { isFireworksFastModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
import { logger } from "@oh-my-pi/pi-utils";
import { classifyDifficulty } from "../auto-thinking/classifier";
import type { ModelRegistry } from "../config/model-registry";
import {
filterAvailableModelsByEnabledPatterns,
formatModelStringWithRouting,
getModelMatchPreferences,
type ResolvedModelRoleValue,
resolveModelRoleValue,
} from "../config/model-resolver";
import { getKnownRoleIds } from "../config/model-roles";
import type { Settings } from "../config/settings";
import { containsUltrathink } from "../modes/ultrathink";
import {
AUTO_THINKING,
type ConfiguredThinkingLevel,
clampAutoThinkingEffort,
resolveProvisionalAutoLevel,
resolveThinkingLevelForModel,
shouldDisableReasoning,
toReasoningEffort,
} from "../thinking";
import type { EditMode } from "../utils/edit-mode";
import type { AgentSessionEvent } from "./agent-session-events";
import type { ModelCycleResult, ResolvedRoleModel, RoleModelCycle, RoleModelCycleResult } from "./agent-session-types";
import { formatRoleModelValue, resolveRoleModelFull } from "./role-models";
import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
import type { SessionManager } from "./session-manager";
/** Capabilities borrowed from the owning AgentSession. */
export interface ModelControlsHost {
agent: Agent;
settings: Settings;
modelRegistry: ModelRegistry;
sessionManager: SessionManager;
providerSessionState: Map<string, ProviderSessionState>;
model(): Model | undefined;
sessionId(): string;
promptGeneration(): number;
resolveActiveEditMode(): EditMode;
syncAfterModelChange(previousEditMode: EditMode): Promise<void>;
setModelWithProviderSessionReset(model: Model): void;
clearActiveRetryFallback(): void;
clearInheritedProviderPromptCacheKey(): void;
magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean;
emit(event: AgentSessionEvent): void;
emitSessionEvent(event: AgentSessionEvent): Promise<void>;
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
}
/** Owns model selection, thinking effort, role cycling, and service tiers. */
export class ModelControls {
readonly #host: ModelControlsHost;
#scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
#thinkingLevel: ThinkingLevel | undefined;
#autoThinking = false;
#autoResolvedLevel: Effort | undefined;
#serviceTierByFamily: ServiceTierByFamily;
constructor(
host: ModelControlsHost,
options: {
scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
thinkingLevel?: ConfiguredThinkingLevel;
serviceTierByFamily?: ServiceTierByFamily;
},
) {
this.#host = host;
this.#scopedModels = options.scopedModels ?? [];
this.#serviceTierByFamily = options.serviceTierByFamily ?? {};
if (options.thinkingLevel === AUTO_THINKING) {
// Keep auto pending until the first turn while exposing a valid wire effort.
this.#autoThinking = true;
this.#thinkingLevel = resolveProvisionalAutoLevel(this.#model);
} else {
this.#thinkingLevel = options.thinkingLevel;
}
this.#applyThinkingLevelToAgent(this.#thinkingLevel);
}
get #model(): Model | undefined {
return this.#host.model();
}
/** Effective metadata-clamped thinking level applied to the agent. */
get thinkingLevel(): ThinkingLevel | undefined {
return this.#thinkingLevel;
}
/** Configured selector, preserving `auto` while classification is active. */
configuredThinkingLevel(): ConfiguredThinkingLevel | undefined {
return this.#autoThinking ? AUTO_THINKING : this.#thinkingLevel;
}
/** Whether per-turn automatic thinking classification is enabled. */
get isAutoThinking(): boolean {
return this.#autoThinking;
}
/** Last concrete effort selected by automatic classification. */
get autoResolvedThinkingLevel(): Effort | undefined {
return this.#autoResolvedLevel;
}
/** Models explicitly scoped to the session's cycle command. */
get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> {
return this.#scopedModels;
}
/** Live per-provider-family service-tier selection. */
get serviceTierByFamily(): ServiceTierByFamily {
return this.#serviceTierByFamily;
}
/** Restores thinking state from a transcript without persisting a new entry. */
restoreThinkingLevel(level: ConfiguredThinkingLevel | undefined): void {
this.#autoThinking = level === AUTO_THINKING;
this.#autoResolvedLevel = undefined;
this.#thinkingLevel =
level === AUTO_THINKING
? resolveProvisionalAutoLevel(this.#model)
: resolveThinkingLevelForModel(this.#model, level);
this.#applyThinkingLevelToAgent(this.#thinkingLevel);
}
/** Restores an exact thinking snapshot after a failed session switch. */
restoreThinkingSnapshot(level: ThinkingLevel | undefined, auto: boolean, resolved: Effort | undefined): void {
this.#thinkingLevel = level;
this.#autoThinking = auto;
this.#autoResolvedLevel = resolved;
this.#applyThinkingLevelToAgent(level);
}
/** Restores service tiers without persisting a duplicate transcript entry. */
restoreServiceTiers(tiers: ServiceTierByFamily): void {
this.#serviceTierByFamily = tiers;
}
resolveRoleModel(role: string): Model | undefined {
return resolveRoleModelFull(this.#host.settings, role, this.#host.modelRegistry.getAvailable(), this.#model)
.model;
}
resolveRoleModelWithThinking(role: string): ResolvedModelRoleValue {
return resolveRoleModelFull(this.#host.settings, role, this.#host.modelRegistry.getAvailable(), this.#model);
}
resolveTemporaryModelThinkingLevel(model: Model): ConfiguredThinkingLevel | undefined {
const availableModels = this.#host.modelRegistry.getAvailable();
if (availableModels.length === 0) return undefined;
const matchPreferences = getModelMatchPreferences(this.#host.settings);
for (const role of getKnownRoleIds(this.#host.settings)) {
const roleValue = this.#host.settings.getModelRole(role);
if (!roleValue) continue;
const resolved = resolveModelRoleValue(roleValue, availableModels, {
settings: this.#host.settings,
matchPreferences,
});
if (!resolved.explicitThinkingLevel || resolved.thinkingLevel === undefined || !resolved.model) continue;
if (modelsAreEqual(resolved.model, model)) return resolved.thinkingLevel;
}
return undefined;
}
async setModel(
model: Model,
role: string = "default",
options?: {
selector?: string;
thinkingLevel?: ThinkingLevel;
persist?: boolean;
currentContextTokens?: number;
},
): Promise<{ switched: boolean }> {
const previousEditMode = this.#host.resolveActiveEditMode();
if (!this.#host.modelRegistry.hasConfiguredAuth(model)) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
const targetModel = await this.#host.modelRegistry.refreshSelectedModelMetadata(model);
this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
this.#host.clearActiveRetryFallback();
this.#host.setModelWithProviderSessionReset(targetModel);
this.#host.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
if (options?.persist) {
this.#host.settings.setModelRole(
role,
formatRoleModelValue(
this.#host.settings,
this.#host.modelRegistry,
role,
targetModel,
options.selector,
options.thinkingLevel,
),
);
}
this.#host.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
// Re-apply thinking for the newly selected model. Prefer the model's
// configured defaultLevel; otherwise preserve the current level (or auto).
this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
await this.#host.syncAfterModelChange(previousEditMode);
return { switched: true };
}
/**
* Set model temporarily (for this session only).
* Validates that a credential source is configured (synchronously, without
* refreshing OAuth or running command-backed key programs), saves to session
* log but NOT to settings.
* @throws Error if no API key available for the model
*/
async setModelTemporary(
model: Model,
thinkingLevel?: ConfiguredThinkingLevel,
options?: { ephemeral?: boolean },
): Promise<void> {
const previousEditMode = this.#host.resolveActiveEditMode();
if (!this.#host.modelRegistry.hasConfiguredAuth(model)) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
const targetModel = await this.#host.modelRegistry.refreshSelectedModelMetadata(model);
this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
this.#host.clearActiveRetryFallback();
this.#host.setModelWithProviderSessionReset(targetModel);
this.#host.sessionManager.appendModelChange(
`${targetModel.provider}/${targetModel.id}`,
options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
);
this.#host.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
// Apply explicit thinking level if given; otherwise prefer the model's
// configured defaultLevel; otherwise re-clamp the current level (or auto).
if (thinkingLevel !== undefined) {
this.setThinkingLevel(thinkingLevel);
} else {
this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
}
await this.#host.syncAfterModelChange(previousEditMode);
}
/**
* Cycle to next/previous model.
* Uses scoped models (from --models flag) if available, otherwise all available models.
* @param direction - "forward" (default) or "backward"
* @returns The new model info, or undefined if only one model available
*/
async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
if (this.#scopedModels.length > 0) {
return this.#cycleScopedModel(direction);
}
return this.#cycleAvailableModel(direction);
}
/**
* Resolve the configured role models in the given order plus the index of
* the currently active one. Roles that have no configured model, or whose
* configured model is not currently available, are skipped. The `default`
* role falls back to the active model when no explicit assignment exists.
*
* Returns `undefined` only when there is no current model or no available
* models at all; an empty `models` array is never returned (callers should
* still guard on `models.length`).
*/
getRoleModelCycle(roleOrder: readonly string[]): RoleModelCycle | undefined {
const availableModels = this.#host.modelRegistry.getAvailable();
if (availableModels.length === 0) return undefined;
const currentModel = this.#model;
if (!currentModel) return undefined;
const matchPreferences = getModelMatchPreferences(this.#host.settings);
const models: ResolvedRoleModel[] = [];
for (const role of roleOrder) {
const roleModelStr =
role === "default"
? (this.#host.settings.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`)
: this.#host.settings.getModelRole(role);
if (!roleModelStr) continue;
const resolved = resolveModelRoleValue(roleModelStr, availableModels, {
settings: this.#host.settings,
matchPreferences,
});
if (!resolved.model) continue;
models.push({
role,
model: resolved.model,
thinkingLevel: resolved.thinkingLevel,
explicitThinkingLevel: resolved.explicitThinkingLevel,
});
}
if (models.length === 0) return undefined;
// Trust the recorded role only while its resolved model still IS the
// active model. A model switch through another surface (alt+m, retry
// fallback, /model) or a role re-configuration leaves the recorded role
// pointing at a model the session no longer runs; cycling from that
// stale slot lands on the wrong neighbor and reads as a skipped entry.
const lastRole = this.#host.sessionManager.getLastModelChangeRole();
let currentIndex = lastRole ? models.findIndex(entry => entry.role === lastRole) : -1;
if (currentIndex !== -1 && !modelsAreEqual(models[currentIndex].model, currentModel)) {
currentIndex = -1;
}
if (currentIndex === -1) {
currentIndex = models.findIndex(entry => modelsAreEqual(entry.model, currentModel));
}
if (currentIndex === -1) currentIndex = 0;
return { models, currentIndex };
}
/**
* Apply a resolved role model as the active model without changing global
* settings. Shared with role cycling and the plan-approval model slider.
*/
async applyRoleModel(entry: ResolvedRoleModel): Promise<void> {
await this.setModel(entry.model, entry.role);
if (entry.explicitThinkingLevel && entry.thinkingLevel !== undefined) {
this.setThinkingLevel(entry.thinkingLevel);
}
}
/**
* Cycle through configured role models in a fixed order.
* Skips missing roles and changes only the active session model.
* @param roleOrder - Order of roles to cycle through (e.g., ["slow", "default", "smol"])
* @param direction - "forward" (default) or "backward"
*/
async cycleRoleModels(
roleOrder: readonly string[],
direction: "forward" | "backward" = "forward",
): Promise<RoleModelCycleResult | undefined> {
const cycle = this.getRoleModelCycle(roleOrder);
if (!cycle || cycle.models.length <= 1) return undefined;
const step = direction === "backward" ? -1 : 1;
const next = cycle.models[(cycle.currentIndex + step + cycle.models.length) % cycle.models.length];
await this.applyRoleModel(next);
return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
}
async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel?: ThinkingLevel }>> {
const apiKeysByProvider = new Map<string, string | undefined>();
const result: Array<{ model: Model; thinkingLevel?: ThinkingLevel }> = [];
for (const scoped of this.#scopedModels) {
const provider = scoped.model.provider;
let apiKey: string | undefined;
if (apiKeysByProvider.has(provider)) {
apiKey = apiKeysByProvider.get(provider);
} else {
apiKey = await this.#host.modelRegistry.getApiKeyForProvider(provider, this.#host.sessionId());
apiKeysByProvider.set(provider, apiKey);
}
if (apiKey) {
result.push(scoped);
}
}
return result;
}
async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const previousEditMode = this.#host.resolveActiveEditMode();
const scopedModels = await this.#getScopedModelsWithApiKey();
if (scopedModels.length <= 1) return undefined;
const currentModel = this.#model;
let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
if (currentIndex === -1) currentIndex = 0;
const len = scopedModels.length;
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
const next = scopedModels[nextIndex];
// Apply model
this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(next.model));
this.#host.clearActiveRetryFallback();
this.#host.setModelWithProviderSessionReset(next.model);
this.#host.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
this.#host.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
// Apply the scoped model's configured thinking level, preserving auto.
this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : next.thinkingLevel);
await this.#host.syncAfterModelChange(previousEditMode);
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
}
async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const previousEditMode = this.#host.resolveActiveEditMode();
const availableModels = this.#host.modelRegistry.getAvailable();
if (availableModels.length <= 1) return undefined;
const currentModel = this.#model;
let currentIndex = availableModels.findIndex(m => modelsAreEqual(m, currentModel));
if (currentIndex === -1) currentIndex = 0;
const len = availableModels.length;
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
const nextModel = availableModels[nextIndex];
const apiKey = await this.#host.modelRegistry.getApiKey(nextModel, this.#host.sessionId());
if (!apiKey) {
throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
}
this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(nextModel));
this.#host.clearActiveRetryFallback();
this.#host.setModelWithProviderSessionReset(nextModel);
this.#host.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
this.#host.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
// Re-apply the current thinking level (or auto) for the newly selected model
this.#reapplyThinkingLevel();
await this.#host.syncAfterModelChange(previousEditMode);
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
}
/**
* Get all available models with valid API keys, filtered by `enabledModels` when configured.
* See {@link filterAvailableModelsByEnabledPatterns} for supported pattern forms and limitations.
*/
getAvailableModels(): Model[] {
const all = this.#host.modelRegistry.getAvailable();
const patterns = this.#host.settings.get("enabledModels");
if (!patterns || patterns.length === 0) return all;
return filterAvailableModelsByEnabledPatterns(all, patterns, this.#host.settings);
}
// =========================================================================
// Thinking Level Management
// =========================================================================
#applyThinkingLevelToAgent(level: ThinkingLevel | undefined): void {
this.#host.agent.setThinkingLevel(toReasoningEffort(level));
this.#host.agent.setDisableReasoning(shouldDisableReasoning(level));
}
/**
* Set the thinking level. `auto` enables per-turn classification. Entering
* auto writes its provisional level plus `configured: "auto"` immediately,
* giving external readers an authoritative selection receipt before the next
* user turn. Later classifications persist only changed concrete resolutions.
*/
setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void {
if (level === AUTO_THINKING) {
const provisional = resolveProvisionalAutoLevel(this.#model);
const wasAuto = this.#autoThinking;
const previousLevel = this.#thinkingLevel;
this.#autoThinking = true;
this.#autoResolvedLevel = undefined;
this.#thinkingLevel = provisional;
if (!wasAuto) {
this.#host.clearInheritedProviderPromptCacheKey();
}
this.#applyThinkingLevelToAgent(provisional);
if (persist) {
this.#host.settings.set("defaultThinkingLevel", AUTO_THINKING);
}
const isChanging = !wasAuto || previousLevel !== provisional;
if (isChanging) {
this.#host.sessionManager.appendThinkingLevelChange(provisional, AUTO_THINKING);
this.#host.emit({ type: "thinking_level_changed", thinkingLevel: provisional, configured: AUTO_THINKING });
}
return;
}
const wasAuto = this.#autoThinking;
this.#autoThinking = false;
this.#autoResolvedLevel = undefined;
const effectiveLevel = resolveThinkingLevelForModel(this.#model, level);
// Leaving auto must persist even when the resolved effort is unchanged (e.g.
// auto resolved to medium, then the user pins medium): otherwise the latest
// session entry keeps `configured: "auto"` and resume re-enables auto.
const isChanging = wasAuto || effectiveLevel !== this.#thinkingLevel;
this.#thinkingLevel = effectiveLevel;
this.#applyThinkingLevelToAgent(effectiveLevel);
if (isChanging) {
this.#host.clearInheritedProviderPromptCacheKey();
this.#host.sessionManager.appendThinkingLevelChange(effectiveLevel, effectiveLevel);
if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
this.#host.settings.set("defaultThinkingLevel", effectiveLevel);
}
this.#host.emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel });
}
}
/**
* Re-apply the active thinking selection after a model change. Preserves `auto`
* (re-clamping the provisional level to the new model); otherwise re-applies the
* preferred default or the current effective level.
*/
#reapplyThinkingLevel(preferredDefault?: ThinkingLevel): void {
this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : (preferredDefault ?? this.#thinkingLevel));
}
/**
* Cycle to next thinking level: off → auto → minimal..max → off.
* @returns New selector, or undefined if model doesn't support thinking
*/
cycleThinkingLevel(): ConfiguredThinkingLevel | undefined {
if (!this.#model?.reasoning) return undefined;
const levels: ConfiguredThinkingLevel[] = [
ThinkingLevel.Off,
AUTO_THINKING,
...this.getAvailableThinkingLevels(),
];
const configured = this.configuredThinkingLevel();
const currentLevel = configured === ThinkingLevel.Inherit ? ThinkingLevel.Off : configured;
const currentIndex = currentLevel ? levels.indexOf(currentLevel) : -1;
const nextIndex = (currentIndex + 1) % levels.length;
const nextLevel = levels[nextIndex];
if (!nextLevel) return undefined;
this.setThinkingLevel(nextLevel);
return nextLevel;
}
/** Timeout (ms) for per-turn auto-thinking classification before falling back. */
static readonly #AUTO_THINKING_TIMEOUT_MS = 4000;
/**
* Classify the current user turn and set the effective thinking level for it.
* Bounded by a timeout + abort; on any failure (no smol model, timeout, parse
* error) it falls back to the provisional concrete level and continues. Never
* throws into the turn, and never clears `#autoThinking` (auto stays active).
*/
async applyAutoThinkingLevel(promptText: string, generation: number): Promise<void> {
const model = this.#model;
if (!model?.reasoning) return;
// Models with reasoning but no controllable effort surface (devin-agent
// Cascade routes effort via sibling model ids, not a wire param) have
// nothing to pick — skip classification rather than discard its result.
if (getSupportedEfforts(model).length === 0) return;
let resolved: Effort | undefined;
if (this.#host.magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
// The user explicitly asked for maximum thinking; bypass the classifier
// (and its xhigh auto ceiling) and jump straight to the highest
// supported level for this model.
resolved = clampAutoThinkingEffort(model, Effort.Max);
} else {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ModelControls.#AUTO_THINKING_TIMEOUT_MS);
try {
resolved = await classifyDifficulty(promptText, {
settings: this.#host.settings,
registry: this.#host.modelRegistry,
model,
sessionId: this.#host.sessionId(),
signal: controller.signal,
metadataResolver: provider => this.#host.agent.metadataForProvider(provider),
});
} catch (error) {
logger.debug("auto-thinking: classification failed; using fallback level", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
clearTimeout(timer);
}
}
// Drop the result if the turn was aborted/superseded while classifying.
if (this.#host.promptGeneration() !== generation || !this.#autoThinking) return;
const effort = resolved ?? resolveProvisionalAutoLevel(model);
if (effort === undefined) return;
const shouldPersistResolution = this.#thinkingLevel !== effort;
this.#autoResolvedLevel = effort;
this.#thinkingLevel = effort;
this.#applyThinkingLevelToAgent(effort);
if (shouldPersistResolution) {
this.#host.sessionManager.appendThinkingLevelChange(effort, AUTO_THINKING);
}
this.#host.emit({
type: "thinking_level_changed",
thinkingLevel: effort,
configured: AUTO_THINKING,
resolved: effort,
});
}
/**
* True when the currently selected model's family is set to `priority` — the
* `/fast` on/off state for the active model. Returns false when no model is
* selected or the model exposes no service-tier family (e.g. Fireworks, which
* has its own Providers › Fireworks Tier toggle).
*
* For "is priority actually applied to the next request?" use
* {@link isFastModeActive} instead.
*/
isFastModeEnabled(): boolean {
const family = this.#model ? serviceTierFamily(this.#model) : undefined;
return family ? this.#serviceTierByFamily[family] === "priority" : false;
}
/**
* True when `priority` is actually realized on the wire for the currently
* selected model (OpenAI/Google `service_tier`, direct Anthropic fast mode,
* or Fireworks priority). Returns false for tiers the active model can't
* realize and when no model is selected.
*/
isFastModeActive(): boolean {
const model = this.#model;
return !!model && realizesPriorityServiceTier(this.effectiveServiceTier(model), model);
}
/**
* Effective wire service-tier for a request to `model`. Fireworks models take
* the Priority serving path only when the Providers › Fireworks Tier setting
* is `"priority"` (and never for `-fast` variants, whose Fast serving path is
* mutually exclusive with Priority). Every other model resolves the live
* per-family tier map down to the entry for its family.
*/
effectiveServiceTier(model: Model | undefined = this.#model): ServiceTier | undefined {
if (model?.provider === "fireworks") {
return this.#host.settings.get("providers.fireworksTier") === "priority" && !isFireworksFastModelId(model.id)
? "priority"
: undefined;
}
if (!model) return undefined;
return resolveModelServiceTier(this.#serviceTierByFamily, model);
}
/** The live per-family tier map, or `null` when empty (for session persistence). */
serviceTierEntry(): ServiceTierByFamily | null {
return Object.keys(this.#serviceTierByFamily).length > 0 ? this.#serviceTierByFamily : null;
}
/** Set one family's tier (or clear it with `undefined`); persists the change. */
setServiceTierFamily(family: ServiceTierFamily, tier: ServiceTier | undefined): void {
if (this.#serviceTierByFamily[family] === tier) return;
const next: ServiceTierByFamily = { ...this.#serviceTierByFamily };
if (tier) next[family] = tier;
else delete next[family];
this.#applyServiceTierByFamily(next);
}
/** Replace the whole per-family tier map; persists + re-arms Anthropic fast mode. */
#applyServiceTierByFamily(next: ServiceTierByFamily): void {
// Re-arming Anthropic priority clears the per-session fast-mode auto-disable
// so the next request actually carries `speed: "fast"` again.
if (next.anthropic === "priority" && this.#serviceTierByFamily.anthropic !== "priority") {
clearAnthropicFastModeFallback(this.#host.providerSessionState);
}
this.#serviceTierByFamily = next;
this.#host.sessionManager.appendServiceTierChange(this.serviceTierEntry());
}
/**
* `/fast on|off` targets the family of the currently selected model: it sets
* (or clears) that family's `priority` tier. Returns `false` when the model
* has no service-tier family, so callers can report that fast mode is
* unavailable instead of claiming success.
*/
setFastMode(enabled: boolean): boolean {
const family = this.#model ? serviceTierFamily(this.#model) : undefined;
if (!family) {
this.#host.emitNotice(
"info",
"The current model has no service-tier control for /fast to toggle.",
"priority",
);
return false;
}
if (!enabled) {
if (this.#serviceTierByFamily[family] === "priority") this.setServiceTierFamily(family, undefined);
return true;
}
this.setServiceTierFamily(family, "priority");
return true;
}
toggleFastMode(): boolean {
if (!this.setFastMode(!this.isFastModeEnabled())) return false;
return this.isFastModeEnabled();
}
/**
* Get available thinking levels for current model.
*/
getAvailableThinkingLevels(): ReadonlyArray<Effort> {
if (!this.#model) return [];
return getSupportedEfforts(this.#model);
}
}
@@ -0,0 +1,252 @@
import type { Agent, AgentMessage, AgentToolResult, AgentTurnEndContext } from "@oh-my-pi/pi-agent-core";
import { invalidateMessageCache } from "@oh-my-pi/pi-agent-core/compaction";
import type { Model } from "@oh-my-pi/pi-ai";
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
import { prompt } from "@oh-my-pi/pi-utils";
import type { LocalProtocolOptions } from "../internal-urls";
import { resolveApprovedPlan } from "../plan-mode/approved-plan";
import { listPlanFiles, readPlanFile } from "../plan-mode/plan-files";
import type { PlanModeState } from "../plan-mode/state";
import planYoloHandoffPrompt from "../prompts/system/plan-yolo-handoff.md" with { type: "text" };
import prewalkChecklistPrompt from "../prompts/system/prewalk-checklist.md" with { type: "text" };
import prewalkContinuePrompt from "../prompts/system/prewalk-continue.md" with { type: "text" };
import prewalkPlanPrompt from "../prompts/system/prewalk-plan.md" with { type: "text" };
import type { ConfiguredThinkingLevel } from "../thinking";
import type { PlanProposalHandler } from "../tools/resolve";
import { ToolError } from "../tools/tool-errors";
import type { PlanYolo, Prewalk } from "./agent-session-types";
import type { SessionManager } from "./session-manager";
const PREWALK_PLAN_MESSAGE_TYPE = "prewalk-plan";
const PREWALK_CONTINUE_MESSAGE_TYPE = "prewalk-continue";
const PREWALK_CHECKLIST_MESSAGE_TYPE = "prewalk-checklist";
const PREWALK_ACTION_TOOLS: Record<string, true> = {
edit: true,
write: true,
};
const PLAN_YOLO_HANDOFF_MESSAGE_TYPE = "plan-yolo-handoff";
/** Capabilities the prewalk coordinator borrows from its owning session. */
export interface PrewalkCoordinatorHost {
agent: Agent;
sessionManager: SessionManager;
model(): Model | undefined;
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
setModelTemporary(
model: Model,
thinkingLevel?: ConfiguredThinkingLevel,
options?: { ephemeral?: boolean },
): Promise<void>;
setActiveToolsByName(names: string[]): Promise<void>;
getActiveToolNames(): string[];
getEnabledToolNames(): string[];
hasBuiltInTool(name: string): boolean;
getPlanModeState(): PlanModeState | undefined;
setPlanModeState(state: PlanModeState | undefined): void;
getPlanReferencePath(): string;
setPlanProposalHandler(handler: PlanProposalHandler | null): void;
waitForSessionMessagePersistence(message: AgentMessage): Promise<void>;
localProtocolOptions(): LocalProtocolOptions;
}
/** Initial state for prewalk and plan-yolo startup flows. */
export interface PrewalkCoordinatorOptions {
prewalk?: Prewalk;
planYolo?: PlanYolo;
}
/** Coordinates one-way model prewalks and automatic plan-yolo handoffs. */
export class PrewalkCoordinator {
readonly #host: PrewalkCoordinatorHost;
#prewalk: Prewalk | undefined;
#planInjected = false;
#continuePending = false;
#todoSeen = false;
#planYolo: PlanYolo | undefined;
#planYoloPreviousTools: string[] | undefined;
#planYoloArmed = false;
constructor(host: PrewalkCoordinatorHost, options: PrewalkCoordinatorOptions = {}) {
this.#host = host;
this.#prewalk = options.prewalk;
this.#planYolo = options.planYolo;
}
/** Current prewalk target, if the one-way switch remains armed. */
get state(): Prewalk | undefined {
return this.#prewalk;
}
/** Advances the one-way prewalk switch at a completed assistant-turn boundary. */
async advanceAtTurnEnd(liveMessages: AgentMessage[], context: AgentTurnEndContext | undefined): Promise<void> {
const prewalk = this.#prewalk;
if (!prewalk || context?.message.role !== "assistant") return;
if (context.toolResults.some(result => result.toolName === "todo" && !result.isError)) this.#todoSeen = true;
const hasToolResults = context.toolResults.length > 0;
if (this.#planInjected && hasToolResults) {
this.#continuePending = true;
} else if (this.#continuePending) {
this.#continuePending = false;
this.#host.agent.steer({
role: "custom",
customType: PREWALK_CONTINUE_MESSAGE_TYPE,
content: prewalkContinuePrompt,
attribution: "agent",
display: false,
timestamp: Date.now(),
});
}
const todoGateOpen = this.#todoSeen || !this.#host.getActiveToolNames().includes("todo");
const action = todoGateOpen
? context.toolResults.find(result => PREWALK_ACTION_TOOLS[result.toolName])
: undefined;
if (!action) {
if (!this.#planInjected) {
this.#planInjected = true;
this.#continuePending = true;
this.#host.agent.steer({
role: "custom",
customType: PREWALK_PLAN_MESSAGE_TYPE,
content: prewalkPlanPrompt,
display: false,
attribution: "agent",
timestamp: Date.now(),
});
this.#host.emitNotice("info", "Prewalk: injected deep-plan nudge.", "prewalk");
}
return;
}
await this.#host.waitForSessionMessagePersistence(context.message);
for (const toolResult of context.toolResults) {
await this.#host.waitForSessionMessagePersistence(toolResult);
}
this.#scrubPlanNudge(liveMessages);
const target = prewalk.target;
const currentModel = this.#host.model();
if (currentModel && modelsAreEqual(currentModel, target)) {
this.#prewalk = undefined;
return;
}
await this.#host.setModelTemporary(target, prewalk.thinkingLevel, { ephemeral: true });
this.#prewalk = undefined;
this.#host.emitNotice(
"info",
`Prewalk: switched to ${target.provider}/${target.id} after first ${action.toolName} call.`,
"prewalk",
);
this.#host.agent.steer({
role: "custom",
customType: PREWALK_CHECKLIST_MESSAGE_TYPE,
content: prewalkChecklistPrompt,
attribution: "agent",
display: false,
timestamp: Date.now(),
});
}
/** Arms a prewalk immediately for an explicit slash-command request. */
arm(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void {
if (this.#prewalk) {
this.#host.emitNotice(
"info",
`Prewalk: already armed for ${this.#prewalk.target.provider}/${this.#prewalk.target.id}, waiting for the first edit/write.`,
"prewalk",
);
return;
}
this.#prewalk = { target, thinkingLevel };
this.#planInjected = true;
this.#continuePending = true;
this.#host.agent.steer({
role: "custom",
customType: PREWALK_PLAN_MESSAGE_TYPE,
content: prewalkPlanPrompt,
display: false,
attribution: "agent",
timestamp: Date.now(),
});
this.#host.emitNotice(
"info",
`Prewalk: armed for ${target.provider}/${target.id} — will switch at the first edit/write once the todo list exists.`,
"prewalk",
);
}
/** Lazily enables plan-yolo's plan phase before the first prompt is built. */
async armPlanYoloIfNeeded(): Promise<void> {
if (!this.#planYolo || this.#planYoloArmed) return;
this.#planYoloArmed = true;
const previousTools = this.#host.getEnabledToolNames();
const augmentations = this.#host.hasBuiltInTool("write") ? ["write"] : [];
await this.#host.setActiveToolsByName([...new Set([...previousTools, ...augmentations])]);
this.#planYoloPreviousTools = previousTools;
this.#host.setPlanModeState({
enabled: true,
planFilePath: this.#host.getPlanReferencePath() || "local://PLAN.md",
workflow: "parallel",
});
this.#host.setPlanProposalHandler(title => this.#finalizePlanYoloProposal(title));
}
#scrubPlanNudge(liveMessages: AgentMessage[]): void {
if (!this.#planInjected) return;
const isPlanNudge = (message: AgentMessage): boolean =>
message.role === "custom" && message.customType === PREWALK_PLAN_MESSAGE_TYPE;
for (let index = liveMessages.length - 1; index >= 0; index--) {
if (!isPlanNudge(liveMessages[index])) continue;
invalidateMessageCache(liveMessages[index]);
liveMessages.splice(index, 1);
}
const stateMessages = this.#host.agent.state.messages;
const filtered = stateMessages.filter(message => !isPlanNudge(message));
if (filtered.length !== stateMessages.length) this.#host.agent.replaceMessages(filtered);
}
async #finalizePlanYoloProposal(title: string): Promise<AgentToolResult<unknown>> {
const planYolo = this.#planYolo;
const state = this.#host.getPlanModeState();
if (!planYolo || !state?.enabled) throw new ToolError("Plan mode is not active.");
const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
suppliedTitle: title,
statePlanFilePath: state.planFilePath,
readPlan: url =>
readPlanFile(url, {
localProtocolOptions: this.#host.localProtocolOptions(),
cwd: this.#host.sessionManager.getCwd(),
}),
listPlanFiles: () => listPlanFiles({ localProtocolOptions: this.#host.localProtocolOptions() }),
});
this.#host.setPlanModeState(undefined);
const previousTools = this.#planYoloPreviousTools;
try {
if (previousTools) await this.#host.setActiveToolsByName(previousTools);
} catch (error) {
this.#host.setPlanModeState(state);
throw error;
}
this.#host.setPlanProposalHandler(null);
this.#planYolo = undefined;
this.#planYoloPreviousTools = undefined;
await this.#host.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true });
this.#host.emitNotice(
"info",
`Plan-yolo: plan approved, switched to ${planYolo.target.provider}/${planYolo.target.id} to implement "${resolvedTitle}".`,
"plan-yolo",
);
this.#host.agent.steer({
role: "custom",
customType: PLAN_YOLO_HANDOFF_MESSAGE_TYPE,
content: prompt.render(planYoloHandoffPrompt, { planFilePath, title: resolvedTitle }),
attribution: "agent",
display: false,
timestamp: Date.now(),
});
return {
content: [{ type: "text", text: `Plan approved. Implementing now with ${planYolo.target.id}.` }],
details: { planFilePath, title: resolvedTitle, planExists: true },
};
}
}
@@ -0,0 +1,93 @@
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import type { RestoredQueuedMessage } from "./agent-session-types";
import { type CustomMessage, readQueueChipText } from "./messages";
function queuedTextContent(message: AgentMessage): string | undefined {
if (!("content" in message)) return undefined;
const content = message.content;
if (typeof content === "string") return content;
for (const part of content) {
if (part.type === "text") return part.text;
}
return undefined;
}
function queuedImageContent(message: AgentMessage): ImageContent[] | undefined {
if (!("content" in message) || typeof message.content === "string") return undefined;
const images: ImageContent[] = [];
for (const part of message.content) {
if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") {
images.push(part);
}
}
return images.length > 0 ? images : undefined;
}
/** Whether a queued message should render in the queue UI. */
export function isDisplayableQueuedMessage(message: AgentMessage): boolean {
return !(message.role === "custom" && message.display === false);
}
/** Whether a queued message is an advisor card. */
export function isAdvisorCard(message: AgentMessage): message is CustomMessage {
return message.role === "custom" && message.customType === "advisor";
}
/** Whether a message is a terminal assistant answer containing text and no tools. */
export function isTerminalTextAssistantAnswer(message: AgentMessage | undefined): message is AssistantMessage {
if (message?.role !== "assistant" || message.stopReason !== "stop") return false;
let hasText = false;
for (const part of message.content) {
if (part.type === "toolCall") return false;
if (part.type === "text") {
if (part.text.trim().length > 0) hasText = true;
continue;
}
if (part.type === "thinking" || part.type === "redactedThinking" || part.type === "fallback") continue;
return false;
}
return hasText;
}
/** Whether queued content was authored by the user and can be restored to the editor. */
export function isUserQueuedMessage(message: AgentMessage): boolean {
if (message.role === "user") return true;
return message.role === "custom" && message.attribution === "user" && message.display !== false;
}
/** Hidden magic-keyword notices queued alongside a user prompt. */
export const MAGIC_KEYWORD_NOTICE_TYPES: Record<string, true> = {
"ultrathink-notice": true,
"orchestrate-notice": true,
"workflow-notice": true,
};
/** Hidden companion carrying vision descriptions for a text-only model. */
export const IMAGE_ATTACHMENT_DESCRIPTION_TYPE = "image-attachment-description";
/** Whether a hidden queued message is a companion of an adjacent user prompt. */
export function isHiddenUserCompanion(message: AgentMessage): boolean {
return (
message.role === "custom" &&
message.attribution === "user" &&
message.display === false &&
(MAGIC_KEYWORD_NOTICE_TYPES[message.customType] === true ||
message.customType === IMAGE_ATTACHMENT_DESCRIPTION_TYPE)
);
}
/** Human-readable text shown for a queued-message chip. */
export function queueChipText(message: AgentMessage): string {
if (message.role === "custom") {
return readQueueChipText(message.details) ?? queuedTextContent(message) ?? "";
}
const text = queuedTextContent(message) ?? "";
if (text) return text;
return queuedImageContent(message) ? "[Image]" : "";
}
/** Converts a queued user message to editor-restorable content. */
export function toRestoredQueuedMessage(message: AgentMessage): RestoredQueuedMessage {
return { text: queueChipText(message), images: queuedImageContent(message) };
}
@@ -0,0 +1,257 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { formatModelSelectorValue, formatModelStringWithRouting, parseModelString } from "../config/model-resolver";
import type { Settings } from "../config/settings";
import { type ConfiguredThinkingLevel, concreteThinkingLevel } from "../thinking";
/** Configured fallback chains keyed by role or model selector. */
export type RetryFallbackChains = Record<string, string[]>;
/** Policy controlling restoration of a fallback chain's primary model. */
export type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
/** Parsed model selector used by retry fallback resolution. */
export interface RetryFallbackSelector {
raw: string;
provider: string;
id: string;
thinkingLevel: ThinkingLevel | undefined;
}
/** Active retry fallback state retained until the primary can be restored. */
export interface ActiveRetryFallbackState {
/** Chain key that produced this fallback: a model-role name or a model-selector key. */
role: string;
originalSelector: string;
originalThinkingLevel: ConfiguredThinkingLevel | undefined;
lastAppliedFallbackThinkingLevel: ConfiguredThinkingLevel | undefined;
pinned: boolean;
}
const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
const RETRY_BACKOFF_JITTER_RATIO = 0.25;
/** Calculates capped exponential retry delay with downward jitter. */
export function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number {
const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS);
const jitter = 1 - Math.random() * RETRY_BACKOFF_JITTER_RATIO;
return cappedDelayMs * jitter;
}
/** Parses a configured retry fallback selector. */
export function parseRetryFallbackSelector(
selector: string,
modelLookup?: { find(provider: string, id: string): Model | undefined },
): RetryFallbackSelector | undefined {
const trimmed = selector.trim();
if (!trimmed) return undefined;
const parsed = parseModelString(trimmed, {
allowMaxSuffix: true,
allowAutoAlias: true,
isLiteralModelId: (provider, id) => modelLookup?.find(provider, id) !== undefined,
});
if (!parsed) return undefined;
return {
raw: trimmed,
provider: parsed.provider,
id: parsed.id,
thinkingLevel: concreteThinkingLevel(parsed.thinkingLevel),
};
}
/** Whether a fallback-chain key is a model selector rather than a role. */
export function isRetryFallbackModelKey(key: string): boolean {
return key.includes("/");
}
/** Whether a fallback-chain key or entry is a provider wildcard. */
export function isRetryFallbackWildcardKey(key: string): boolean {
return key.endsWith("/*");
}
/** Splits a wildcard selector into provider and optional model-id prefix. */
export function parseRetryFallbackWildcard(
key: string,
isKnownProvider: (provider: string) => boolean,
): { provider: string; idPrefix: string | undefined } {
const template = key.slice(0, -2);
const slash = template.indexOf("/");
if (slash < 0 || isKnownProvider(template)) return { provider: template, idPrefix: undefined };
return { provider: template.slice(0, slash), idPrefix: template.slice(slash + 1) };
}
/** Formats a concrete model and thinking level as a fallback selector. */
export function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
}
/** Formats the model-only portion of a parsed fallback selector. */
export function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string {
return `${selector.provider}/${selector.id}`;
}
/** Whether a provider is registered or configured for discovery. */
export function isKnownProvider(modelRegistry: ModelRegistry, provider: string): boolean {
return modelRegistry.hasProvider(provider);
}
/** Resolves configured fallback chains, applying the default chain to named roles. */
export function getRetryFallbackChains(settings: Settings): RetryFallbackChains {
const configuredChains = settings.get("retry.fallbackChains");
if (!configuredChains || typeof configuredChains !== "object") return {};
const chains: RetryFallbackChains = { ...configuredChains };
const defaultChain = chains.default;
if (Array.isArray(defaultChain)) {
for (const role in settings.getModelRoles()) {
if (role !== "default" && chains[role] === undefined) chains[role] = defaultChain;
}
}
return chains;
}
/** Validates configured fallback chains and reports each warning. */
export function validateRetryFallbackChains(
settings: Settings,
modelRegistry: ModelRegistry,
warn: (message: string) => void,
): void {
const configuredChains = settings.get("retry.fallbackChains");
if (configuredChains === undefined) return;
const report = (message: string) => {
logger.warn(message);
warn(message);
};
if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) {
report("retry.fallbackChains must be a mapping of role names or model selectors to selector arrays.");
return;
}
for (const key in configuredChains) {
const chain = configuredChains[key];
const keyKind = isRetryFallbackModelKey(key) ? "model" : "role";
if (keyKind === "model") {
if (isRetryFallbackWildcardKey(key)) {
const { provider } = parseRetryFallbackWildcard(key, candidate =>
isKnownProvider(modelRegistry, candidate),
);
if (!isKnownProvider(modelRegistry, provider)) {
report(`retry.fallbackChains wildcard key references unknown provider: ${key}`);
}
} else {
const parsedKey = parseRetryFallbackSelector(key, modelRegistry);
if (!parsedKey) {
report(`Invalid model selector key in retry.fallbackChains: ${key}`);
} else if (!modelRegistry.find(parsedKey.provider, parsedKey.id)) {
report(`retry.fallbackChains key references unknown model: ${key}`);
}
}
}
if (!Array.isArray(chain)) {
report(`Fallback chain for ${keyKind} '${key}' must be an array of selector strings.`);
continue;
}
for (const selectorStr of chain) {
if (typeof selectorStr !== "string") {
report(`Fallback chain for ${keyKind} '${key}' contains a non-string selector.`);
continue;
}
if (isRetryFallbackWildcardKey(selectorStr)) {
const { provider } = parseRetryFallbackWildcard(selectorStr, candidate =>
isKnownProvider(modelRegistry, candidate),
);
if (!isKnownProvider(modelRegistry, provider)) {
report(`Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`);
}
continue;
}
const parsed = parseRetryFallbackSelector(selectorStr, modelRegistry);
if (!parsed) {
report(`Invalid fallback selector format in ${keyKind} '${key}': ${selectorStr}`);
continue;
}
if (!modelRegistry.find(parsed.provider, parsed.id)) {
report(`Fallback chain for ${keyKind} '${key}' references unknown model: ${selectorStr}`);
}
}
}
}
/** Returns the configured fallback-primary restoration policy. */
export function getRetryFallbackRevertPolicy(settings: Settings): RetryFallbackRevertPolicy {
return settings.get("retry.fallbackRevertPolicy") === "never" ? "never" : "cooldown-expiry";
}
/** Resolves the primary selector represented by a fallback-chain key. */
export function getRetryFallbackPrimarySelector(
settings: Settings,
modelRegistry: ModelRegistry,
role: string,
): RetryFallbackSelector | undefined {
if (isRetryFallbackWildcardKey(role)) return undefined;
if (isRetryFallbackModelKey(role)) return parseRetryFallbackSelector(role, modelRegistry);
const configuredSelector = settings.getModelRole(role);
return configuredSelector ? parseRetryFallbackSelector(configuredSelector, modelRegistry) : undefined;
}
/** Parses one configured fallback-chain entry relative to the current model. */
export function parseRetryFallbackChainEntry(
entry: string,
current: RetryFallbackSelector | undefined,
modelRegistry: ModelRegistry,
): RetryFallbackSelector | undefined {
if (isRetryFallbackWildcardKey(entry)) {
if (!current) return undefined;
const { provider, idPrefix } = parseRetryFallbackWildcard(entry, candidate =>
isKnownProvider(modelRegistry, candidate),
);
const bareId = current.id.slice(current.id.lastIndexOf("/") + 1);
let id: string;
if (idPrefix !== undefined) {
id = `${idPrefix}/${bareId}`;
} else if (
bareId !== current.id &&
!modelRegistry.find(provider, current.id) &&
modelRegistry.find(provider, bareId)
) {
// Aggregator → direct: the failing id carries a vendor prefix the
// target provider does not use (openrouter/google/x → google-vertex/x).
id = bareId;
} else {
id = current.id;
}
return { raw: `${provider}/${id}`, provider, id, thinkingLevel: undefined };
}
return parseRetryFallbackSelector(entry, modelRegistry);
}
/** Builds a fallback chain beginning with its effective primary selector. */
export function getRetryFallbackEffectiveChain(
settings: Settings,
modelRegistry: ModelRegistry,
role: string,
currentSelector?: string,
): RetryFallbackSelector[] {
const parsedCurrent = currentSelector ? parseRetryFallbackSelector(currentSelector, modelRegistry) : undefined;
const seen = new Set<string>();
const chain: RetryFallbackSelector[] = [];
if (isRetryFallbackWildcardKey(role)) {
if (parsedCurrent) {
chain.push(parsedCurrent);
seen.add(parsedCurrent.raw);
}
} else {
const primarySelector = getRetryFallbackPrimarySelector(settings, modelRegistry, role);
if (!primarySelector) return [];
chain.push(primarySelector);
seen.add(primarySelector.raw);
}
for (const selector of getRetryFallbackChains(settings)[role] ?? []) {
const parsed = parseRetryFallbackChainEntry(selector, parsedCurrent, modelRegistry);
if (!parsed || seen.has(parsed.raw)) continue;
seen.add(parsed.raw);
chain.push(parsed);
}
return chain;
}
@@ -0,0 +1,85 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import type { ModelRegistry } from "../config/model-registry";
import {
extractExplicitThinkingSelector,
formatModelSelectorValue,
getModelMatchPreferences,
parseModelString,
type ResolvedModelRoleValue,
resolveModelRoleValue,
} from "../config/model-resolver";
import type { Settings } from "../config/settings";
/** Formats a role assignment while preserving its explicit thinking selector. */
export function formatRoleModelValue(
settings: Settings,
modelRegistry: ModelRegistry,
role: string,
model: Model,
selectorOverride?: string,
thinkingLevelOverride?: ThinkingLevel,
): string {
const modelKey = selectorOverride ?? `${model.provider}/${model.id}`;
if (thinkingLevelOverride !== undefined) return formatModelSelectorValue(modelKey, thinkingLevelOverride);
const existingRoleValue = settings.getModelRole(role);
if (!existingRoleValue) return modelKey;
const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, settings, {
isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined,
});
return formatModelSelectorValue(modelKey, thinkingLevel);
}
/** Resolves a configured model target relative to the current provider. */
export function resolveConfiguredModelTarget(
configuredTarget: string | undefined,
currentModel: Model,
availableModels: Model[],
): Model | undefined {
const trimmedTarget = configuredTarget?.trim();
if (!trimmedTarget) return undefined;
const parsed = parseModelString(trimmedTarget, {
allowMaxSuffix: true,
allowAutoAlias: true,
isLiteralModelId: (provider, id) => availableModels.some(model => model.provider === provider && model.id === id),
});
if (parsed) {
const explicitModel = availableModels.find(model => model.provider === parsed.provider && model.id === parsed.id);
if (explicitModel) return explicitModel;
}
return availableModels.find(model => model.provider === currentModel.provider && model.id === trimmedTarget);
}
/** Resolves a model's configured context-promotion target. */
export function resolveContextPromotionConfiguredTarget(
currentModel: Model,
availableModels: Model[],
): Model | undefined {
return resolveConfiguredModelTarget(currentModel.contextPromotionTarget, currentModel, availableModels);
}
/** Resolves a model's configured compaction target. */
export function resolveCompactionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
return resolveConfiguredModelTarget(currentModel.compactionModel, currentModel, availableModels);
}
/** Resolves a model role and its explicit thinking selection. */
export function resolveRoleModelFull(
settings: Settings,
role: string,
availableModels: Model[],
currentModel: Model | undefined,
): ResolvedModelRoleValue {
const roleModelStr =
role === "default"
? (settings.getModelRole("default") ??
(currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
: settings.getModelRole(role);
if (!roleModelStr) {
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
}
return resolveModelRoleValue(roleModelStr, availableModels, {
settings,
matchPreferences: getModelMatchPreferences(settings),
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
/** Handoff generation and session transition orchestration. */
import * as path from "node:path";
import {
type Agent,
type AgentMessage,
resolveTelemetry,
type StreamFn,
type ThinkingLevel,
} from "@oh-my-pi/pi-agent-core";
import { generateHandoffFromContext, renderHandoffPrompt } from "@oh-my-pi/pi-agent-core/compaction";
import type { Message, Model, ServiceTier, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import type { Settings } from "../config/settings";
import type { ExtensionRunner, SessionBeforeSwitchResult } from "../extensibility/extensions";
import { obfuscateProviderContext, type SecretObfuscator } from "../secrets/obfuscator";
import type { HandoffResult, SessionHandoffOptions } from "./agent-session-types";
import type { BashSessionTransition } from "./bash-runner";
import type { SessionContext } from "./session-context";
import type { SessionManager } from "./session-manager";
function createHandoffContext(document: string): string {
return `<handoff-context>\n${document}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
}
function createHandoffFileName(date = new Date()): string {
const fileTimestamp = date.toISOString().replace(/[:.]/g, "-");
return `handoff-${fileTimestamp}.md`;
}
/** Capabilities borrowed from the owning AgentSession. */
export interface SessionHandoffHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
modelRegistry: ModelRegistry;
extensionRunner: ExtensionRunner | undefined;
sideStreamFn: StreamFn;
obfuscator: SecretObfuscator | undefined;
model(): Model | undefined;
thinkingLevel(): ThinkingLevel | undefined;
sessionId(): string;
sessionFile(): string | undefined;
baseSystemPrompt(): string[];
assertVibeSessionTransitionAllowed(action: string): void;
setSkipPostTurnMaintenance(timestamp: number | undefined): void;
obfuscateTextForProvider(text: string | undefined): string | undefined;
deobfuscateFromProvider(text: string): string;
convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]>;
prepareSimpleStreamOptions(options: SimpleStreamOptions, provider?: string): SimpleStreamOptions;
effectiveServiceTier(model: Model | undefined): ServiceTier | undefined;
flushPendingBash(): Promise<void>;
beginBashSessionTransition(): BashSessionTransition;
markBashSessionTransition(transition: BashSessionTransition): void;
finishBashSessionTransition(transition: BashSessionTransition, success: boolean): void;
cancelOwnAsyncJobs(): void;
clearCheckpointRuntimeState(): void;
clearFreshProviderSessionId(): void;
syncAgentSessionId(): void;
rekeyMemoryForCurrentSessionId(): void;
resetMemoryContextForNewTranscript(): Promise<void>;
clearPendingNextTurnMessages(): void;
resetTodoCycle(): void;
buildDisplaySessionContext(): SessionContext;
resetAdvisorRuntimes(): void;
syncTodoPhasesFromBranch(): void;
}
/** Generates handoff documents and owns the handoff session transition. */
export class SessionHandoff {
#handoffAbortController: AbortController | undefined;
readonly #host: SessionHandoffHost;
constructor(host: SessionHandoffHost) {
this.#host = host;
}
/**
* Cancel in-progress handoff generation.
*/
abortHandoff(): void {
this.#handoffAbortController?.abort();
}
/**
* Check if handoff generation is in progress.
*/
get isGeneratingHandoff(): boolean {
return this.#handoffAbortController !== undefined;
}
/**
* Generate a handoff document with a oneshot LLM call, then start a new session with it.
*
* @param customInstructions Optional focus for the handoff document
* @param options Handoff execution options
* @returns The handoff document text, or undefined if cancelled/failed
*/
async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined> {
this.#host.assertVibeSessionTransitionAllowed("handoff to a new session");
const entries = this.#host.sessionManager.getBranch();
const messageCount = entries.filter(e => e.type === "message").length;
if (messageCount < 2) {
throw new Error("Nothing to hand off (no messages yet)");
}
this.#host.setSkipPostTurnMaintenance(undefined);
this.#handoffAbortController = new AbortController();
const handoffAbortController = this.#handoffAbortController;
const handoffSignal = handoffAbortController.signal;
const sourceSignal = options?.signal;
const onSourceAbort = () => {
if (!handoffSignal.aborted) {
handoffAbortController.abort();
}
};
if (sourceSignal) {
sourceSignal.addEventListener("abort", onSourceAbort, { once: true });
if (sourceSignal.aborted) {
onSourceAbort();
}
}
try {
if (handoffSignal.aborted) {
throw new Error("Handoff cancelled");
}
const model = this.#host.model();
if (!model) {
throw new Error("No model selected for handoff");
}
const apiKey = await this.#host.modelRegistry.getApiKey(model, this.#host.sessionId());
if (!apiKey) {
throw new Error(`No API key for ${model.provider}`);
}
// Build the handoff request through the SAME pipeline a live turn uses
// (`runEphemeralTurn` / `/btw` share it) so the oneshot reads the
// provider prompt cache the main turn populated instead of cold-missing
// the whole prefix: identical system prompt, normalized tools, and
// transform-/obfuscation-matched message history via
// `convertMessagesToLlm` + `buildSideRequestContext`, plus the live turn's
// effective provider cache key with a unique side `sessionId` so
// OpenAI/Codex append-only state never mixes with the live turn.
const cacheSessionId = this.#host.sessionId();
// The loop sends `promptCacheKey` (providerPromptCacheKey) and falls back to
// the provider session id; providers route on `promptCacheKey ?? sessionId`.
// Both can diverge from this.#host.sessionId() (tan/subagent/shared sessions), so
// mirror exactly what the live turn populated the cache under.
const handoffPromptCacheKey = this.#host.agent.promptCacheKey ?? this.#host.agent.sessionId;
const handoffPromptText = renderHandoffPrompt(this.#host.obfuscateTextForProvider(customInstructions));
const handoffSnapshot: AgentMessage[] = [
...this.#host.agent.state.messages,
{
role: "user",
content: [{ type: "text", text: handoffPromptText }],
attribution: "agent",
timestamp: Date.now(),
},
];
const handoffLlmMessages = await this.#host.convertMessagesToLlm(handoffSnapshot, handoffSignal);
// Base system prompt, not a per-turn `before_agent_start` hook override —
// the handoff seeds a fresh session and must not carry prompt-specific
// hook state. Matches the prompt the old handoff path sent.
const handoffContext = await this.#host.agent.buildSideRequestContext(
handoffLlmMessages,
this.#host.baseSystemPrompt(),
);
const handoffStreamOptions = this.#host.prepareSimpleStreamOptions(
{
apiKey: this.#host.modelRegistry.resolver(model, cacheSessionId),
sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
promptCacheKey: handoffPromptCacheKey,
preferWebsockets: false,
serviceTier: this.#host.effectiveServiceTier(model),
hideThinkingSummary: this.#host.agent.hideThinkingSummary,
initiatorOverride: "agent",
signal: handoffSignal,
},
model.provider,
);
const rawHandoffText = await generateHandoffFromContext(
obfuscateProviderContext(this.#host.obfuscator, handoffContext),
model,
{
streamOptions: handoffStreamOptions,
completeImpl: async (requestModel, requestContext, requestOptions) => {
const stream = await this.#host.sideStreamFn(requestModel, requestContext, requestOptions);
return stream.result();
},
telemetry: resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()),
// Honor the user's /model thinking selection on the handoff path.
// Clamped per-model inside generateHandoffFromContext via
// resolveCompactionEffort so unsupported-effort models don't trip
// requireSupportedEffort.
thinkingLevel: this.#host.thinkingLevel(),
},
);
const handoffText = this.#host.deobfuscateFromProvider(rawHandoffText);
if (handoffSignal.aborted) {
throw new Error("Handoff cancelled");
}
if (!handoffText) {
return undefined;
}
// Start a new session
const previousSessionFile = this.#host.sessionFile();
if (this.#host.extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this.#host.extensionRunner.emit({
type: "session_before_switch",
reason: "handoff",
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
options?.onSwitchCancelled?.();
return undefined;
}
}
await this.#host.flushPendingBash();
await this.#host.sessionManager.flush();
const bashTransition = this.#host.beginBashSessionTransition();
this.#host.cancelOwnAsyncJobs();
let sessionTransitioned = false;
try {
await this.#host.sessionManager.newSession(
previousSessionFile ? { parentSession: previousSessionFile } : undefined,
);
this.#host.markBashSessionTransition(bashTransition);
sessionTransitioned = true;
} finally {
this.#host.finishBashSessionTransition(bashTransition, sessionTransitioned);
}
this.#host.clearCheckpointRuntimeState();
// agent.reset() clears the core steering/follow-up queues. Preserve any queued
// steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a
// pre-loader TUI steer) so they survive into the post-handoff session instead of
// being silently dropped. Capture is synchronous immediately before reset and
// restore is synchronous immediately after — no await gap — so a steer arriving
// later (during ensureOnDisk/Bun.write below) appends to the restored queue
// rather than being clobbered.
const preservedSteering = this.#host.agent.peekSteeringQueue().slice();
const preservedFollowUp = this.#host.agent.peekFollowUpQueue().slice();
this.#host.agent.reset();
this.#host.agent.replaceQueues(preservedSteering, preservedFollowUp);
this.#host.clearFreshProviderSessionId();
this.#host.syncAgentSessionId();
this.#host.rekeyMemoryForCurrentSessionId();
await this.#host.resetMemoryContextForNewTranscript();
this.#host.clearPendingNextTurnMessages();
this.#host.resetTodoCycle();
// Inject the handoff document as a custom message
const handoffContent = createHandoffContext(handoffText);
this.#host.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
await this.#host.sessionManager.ensureOnDisk();
let savedPath: string | undefined;
if (options?.autoTriggered && this.#host.settings.get("compaction.handoffSaveToDisk")) {
const artifactsDir = this.#host.sessionManager.getArtifactsDir();
if (artifactsDir) {
const handoffFilePath = path.join(artifactsDir, createHandoffFileName());
try {
await Bun.write(handoffFilePath, `${handoffText}\n`);
savedPath = handoffFilePath;
} catch (error) {
logger.warn("Failed to save handoff document to disk", {
path: handoffFilePath,
error: error instanceof Error ? error.message : String(error),
});
}
} else {
logger.debug("Skipping handoff document save because session is not persisted");
}
}
// Rebuild agent messages from session
const sessionContext = this.#host.buildDisplaySessionContext();
this.#host.agent.replaceMessages(sessionContext.messages);
this.#host.resetAdvisorRuntimes();
this.#host.syncTodoPhasesFromBranch();
if (this.#host.extensionRunner) {
await this.#host.extensionRunner.emit({
type: "session_switch",
reason: "handoff",
previousSessionFile,
});
}
return { document: handoffText, savedPath };
} catch (error) {
if (handoffSignal.aborted || (error instanceof Error && error.name === "AbortError")) {
throw new Error("Handoff cancelled");
}
throw error;
} finally {
sourceSignal?.removeEventListener("abort", onSourceAbort);
this.#handoffAbortController = undefined;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,222 @@
/** Session memory backend lifecycle and transcript resets. */
import type { Agent, AgentTool } from "@oh-my-pi/pi-agent-core";
import { logger } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import type { Settings } from "../config/settings";
import type { HindsightSessionState } from "../hindsight/state";
import { resolveMemoryBackend } from "../memory-backend/resolve";
import type { MemoryBackendStartOptions } from "../memory-backend/types";
import type { MnemopiSessionState } from "../mnemopi/state";
/** Capabilities borrowed from the owning AgentSession. */
export interface SessionMemoryHost {
agent: Agent;
settings: Settings;
modelRegistry: ModelRegistry;
isDisposed(): boolean;
memoryBackendSession(): MemoryBackendStartOptions["session"];
getHindsightSessionState(): HindsightSessionState | undefined;
setHindsightSessionState(state: HindsightSessionState | undefined): void;
getMnemopiSessionState(): MnemopiSessionState | undefined;
takeMnemopiSessionState(): MnemopiSessionState | undefined;
setBaseSystemPrompt(prompt: string[]): void;
refreshBaseSystemPrompt(): Promise<void>;
replaceMemoryTools(tools: AgentTool[]): Promise<void>;
}
/** Owns memory backend transitions and transcript-scoped memory state. */
export class SessionMemory {
readonly #host: SessionMemoryHost;
readonly #memoryAgentDir: string | undefined;
readonly #memoryTaskDepth: number;
readonly #createMemoryTools: (() => Promise<AgentTool[]>) | undefined;
#memoryBackendTransition: Promise<void> = Promise.resolve();
#localMemoryStartupAbort: AbortController | undefined;
#baseSystemPromptBeforeMemoryPromotion: string[] | undefined;
constructor(
host: SessionMemoryHost,
options: {
memoryAgentDir?: string;
memoryTaskDepth?: number;
createMemoryTools?: () => Promise<AgentTool[]>;
},
) {
this.#host = host;
this.#memoryAgentDir = options.memoryAgentDir;
this.#memoryTaskDepth = options.memoryTaskDepth ?? 0;
this.#createMemoryTools = options.createMemoryTools;
}
/** Current serialized backend transition, used by prompt and disposal drains. */
get transition(): Promise<void> {
return this.#memoryBackendTransition;
}
/** Base prompt captured before a per-turn memory promotion. */
get promotionSnapshot(): string[] | undefined {
return this.#baseSystemPromptBeforeMemoryPromotion;
}
/** Clears the per-turn memory promotion after a canonical prompt rebuild. */
clearPromotionSnapshot(): void {
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
}
/** Captures the canonical prompt before the first per-turn memory promotion. */
capturePromotionSnapshot(prompt: string[]): void {
this.#baseSystemPromptBeforeMemoryPromotion ??= prompt;
}
/** Restores a promotion snapshot while rolling back a failed session switch. */
restorePromotionSnapshot(prompt: string[] | undefined): void {
this.#baseSystemPromptBeforeMemoryPromotion = prompt;
}
/** Rekeys every active memory backend to the current provider session. */
rekeyForCurrentSessionId(): void {
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
}
#rekeyHindsightMemoryForCurrentSessionId(): void {
if (this.#host.settings.get("memory.backend") !== "hindsight") return;
const sid = this.#host.agent.sessionId;
if (!sid) return;
this.#host.getHindsightSessionState()?.setSessionId(sid);
}
#rekeyMnemopiMemoryForCurrentSessionId(): void {
if (this.#host.settings.get("memory.backend") !== "mnemopi") return;
const sid = this.#host.agent.sessionId;
if (!sid) return;
this.#host.getMnemopiSessionState()?.setSessionId(sid);
}
/** New session file: reset auto-recall / retain-threshold counters for the new transcript. */
#resetHindsightConversationTrackingIfHindsight(): boolean {
if (this.#host.settings.get("memory.backend") !== "hindsight") return false;
const state = this.#host.getHindsightSessionState();
if (!state || state.aliasOf) return false;
state.resetConversationTracking();
return true;
}
#resetMnemopiConversationTrackingIfMnemopi(): boolean {
if (this.#host.settings.get("memory.backend") !== "mnemopi") return false;
const state = this.#host.getMnemopiSessionState();
if (!state || state.aliasOf) return false;
state.resetConversationTracking();
return true;
}
/** Resets transcript-scoped memory counters and removes a promoted prompt. */
async resetContextForNewTranscript(): Promise<void> {
const hadPromotedMemoryPrompt = this.#baseSystemPromptBeforeMemoryPromotion !== undefined;
const resetHindsight = this.#resetHindsightConversationTrackingIfHindsight();
const resetMnemopi = this.#resetMnemopiConversationTrackingIfMnemopi();
if (hadPromotedMemoryPrompt) {
this.#host.setBaseSystemPrompt(this.#baseSystemPromptBeforeMemoryPromotion!);
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
}
if (resetHindsight || resetMnemopi || hadPromotedMemoryPrompt) {
await this.#host.refreshBaseSystemPrompt();
}
}
/** Cancel the local rollout-memory startup owned by this session. */
cancelLocalMemoryStartup(): void {
this.#localMemoryStartupAbort?.abort();
this.#localMemoryStartupAbort = undefined;
}
/** Start a new local rollout-memory generation and cancel its predecessor. */
beginLocalMemoryStartup(): AbortSignal {
this.cancelLocalMemoryStartup();
const controller = new AbortController();
this.#localMemoryStartupAbort = controller;
return controller.signal;
}
/** Release the local startup slot if `signal` still owns it. */
endLocalMemoryStartup(signal: AbortSignal): void {
if (this.#localMemoryStartupAbort?.signal === signal) this.#localMemoryStartupAbort = undefined;
}
async #disposeMemoryBackendState(consolidateMnemopi = true): Promise<void> {
this.cancelLocalMemoryStartup();
const hindsight = this.#host.getHindsightSessionState();
if (hindsight) {
try {
await hindsight.flushRetainQueue();
} catch (error) {
logger.warn("Memory lifecycle: Hindsight flush failed", { error: String(error) });
}
this.#host.setHindsightSessionState(undefined);
hindsight.dispose();
}
const mnemopi = this.#host.takeMnemopiSessionState();
if (mnemopi) {
try {
await mnemopi.dispose({ consolidate: consolidateMnemopi });
} catch (error) {
logger.warn("Memory lifecycle: Mnemopi dispose failed", { error: String(error) });
}
}
}
/**
* Apply the selected memory backend to runtime state, tools, and prompt.
* Concurrent settings changes run in order and settle before the next turn.
*/
async applyMemoryBackend(): Promise<void> {
if (this.#host.isDisposed()) return;
const transition = this.#memoryBackendTransition.then(() => this.#applyMemoryBackend());
this.#memoryBackendTransition = transition.then(
() => undefined,
() => undefined,
);
await transition;
}
async #applyMemoryBackend(): Promise<void> {
if (this.#host.isDisposed()) return;
try {
await this.#disposeMemoryBackendState();
if (this.#memoryAgentDir && this.#memoryTaskDepth === 0 && !this.#host.isDisposed()) {
const backend = await resolveMemoryBackend(this.#host.settings);
await backend.start({
session: this.#host.memoryBackendSession(),
settings: this.#host.settings,
modelRegistry: this.#host.modelRegistry,
agentDir: this.#memoryAgentDir,
taskDepth: this.#memoryTaskDepth,
});
}
if (this.#host.isDisposed()) return;
await this.#refreshMemoryTools();
if (this.#host.isDisposed()) return;
await this.#host.refreshBaseSystemPrompt();
} catch (error) {
await this.#disposeMemoryBackendState(false);
if (!this.#host.isDisposed()) {
await this.#replaceMemoryTools([]).catch(refreshError => {
logger.warn("Failed to remove memory tools after backend apply error", {
error: String(refreshError),
});
});
}
throw error;
}
}
async #refreshMemoryTools(): Promise<void> {
const tools = (await this.#createMemoryTools?.()) ?? [];
await this.#replaceMemoryTools(tools);
}
#replaceMemoryTools(tools: AgentTool[]): Promise<void> {
return this.#host.replaceMemoryTools(tools);
}
}
@@ -0,0 +1,307 @@
/** Provider-facing message, image, secret, and stream normalization for a session. */
import type { Agent, AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { CompactionPreparation } from "@oh-my-pi/pi-agent-core/compaction";
import type { AssistantMessage, ImageContent, Message, Model, SimpleStreamOptions, TextContent } from "@oh-my-pi/pi-ai";
import { isRecord, logger } from "@oh-my-pi/pi-utils";
import * as snapcompact from "@oh-my-pi/snapcompact";
import type { ModelRegistry } from "../config/model-registry";
import { formatModelString } from "../config/model-resolver";
import type { Settings } from "../config/settings";
import { validateProviderMaxInFlightRequests } from "../config/settings";
import type { LocalProtocolOptions } from "../internal-urls";
import {
deobfuscateSessionContext,
obfuscateMessages,
type SecretObfuscator,
stripPendingSecretPlaceholderSuffix,
} from "../secrets/obfuscator";
import { normalizeModelContextImages } from "../utils/image-loading";
import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallback";
import { type CustomMessage, convertToLlm } from "./messages";
import { IMAGE_ATTACHMENT_DESCRIPTION_TYPE } from "./queued-messages";
import type { BuildSessionContextOptions, SessionContext } from "./session-context";
import type { SessionManager } from "./session-manager";
type NormalizableContentBlock = AssistantMessage["content"][number] | TextContent | ImageContent;
/** Capabilities borrowed from the owning AgentSession. */
export interface SessionProviderBoundaryHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
modelRegistry: ModelRegistry;
model(): Model | undefined;
sessionId(): string;
localProtocolOptions(): LocalProtocolOptions;
transformContext(messages: AgentMessage[], signal?: AbortSignal): AgentMessage[] | Promise<AgentMessage[]>;
convertToLlm(messages: AgentMessage[]): Message[] | Promise<Message[]>;
onPayload: SimpleStreamOptions["onPayload"] | undefined;
onResponse: SimpleStreamOptions["onResponse"] | undefined;
onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
obfuscator: SecretObfuscator | undefined;
}
/** Owns the transformations at the session/provider boundary. */
export class SessionProviderBoundary {
readonly #host: SessionProviderBoundaryHost;
constructor(host: SessionProviderBoundaryHost) {
this.#host = host;
}
/** Latest image attachments addressable by tools as `Image #N` or `attachment://N`. */
getImageAttachments(): { label: string; uri: string; image: ImageContent }[] {
for (let i = this.#host.agent.state.messages.length - 1; i >= 0; i--) {
const message = this.#host.agent.state.messages[i];
if (!message || (message.role !== "user" && message.role !== "developer") || !Array.isArray(message.content)) {
continue;
}
const images = message.content.filter((part): part is ImageContent => part.type === "image");
if (images.length === 0) continue;
return images.map((image, index) => ({
label: `Image #${index + 1}`,
uri: `attachment://${index + 1}`,
image,
}));
}
return [];
}
/** Builds the current deobfuscated context for agent display and replay. */
buildDisplaySessionContext(): SessionContext {
return deobfuscateSessionContext(this.#host.sessionManager.buildSessionContext(), this.#host.obfuscator);
}
/** Builds the full display-only transcript context. */
buildTranscriptSessionContext(
options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory" | "keepDanglingToolCalls">,
): SessionContext {
return deobfuscateSessionContext(
this.#host.sessionManager.buildSessionContext({
transcript: true,
collapseCompactedHistory: options?.collapseCompactedHistory,
keepDanglingToolCalls: options?.keepDanglingToolCalls,
}),
this.#host.obfuscator,
true,
);
}
/** Obfuscates optional plaintext before a provider request. */
obfuscateText(text: string | undefined): string | undefined {
if (!text || !this.#host.obfuscator?.hasSecrets()) return text;
return this.#host.obfuscator.obfuscate(text);
}
/** Obfuscates summaries and snapcompact plaintext carried into compaction. */
obfuscateCompactionPreparation(preparation: CompactionPreparation): CompactionPreparation {
if (!this.#host.obfuscator?.hasSecrets()) return preparation;
const previousSummary = this.obfuscateText(preparation.previousSummary);
const previousPreserveData = this.#obfuscatePreservedArchiveText(preparation.previousPreserveData);
if (
previousSummary === preparation.previousSummary &&
previousPreserveData === preparation.previousPreserveData
) {
return preparation;
}
return { ...preparation, previousSummary, previousPreserveData };
}
/** Deobfuscates provider text before exposing it to the session. */
deobfuscateText(text: string): string {
if (!this.#host.obfuscator?.hasSecrets()) return text;
return this.#host.obfuscator.deobfuscate(text);
}
/** Deobfuscates a streamed delta and removes an incomplete secret placeholder suffix. */
deobfuscateDelta(text: string): string {
const deobfuscated = this.deobfuscateText(text);
if (!this.#host.obfuscator?.hasSecrets()) return deobfuscated;
return stripPendingSecretPlaceholderSuffix(deobfuscated);
}
/** Converts side-request messages through the session's secret boundary. */
convertToLlmForSideRequest(messages: AgentMessage[]): Message[] {
const converted = convertToLlm(messages);
return this.#host.obfuscator?.hasSecrets() ? obfuscateMessages(this.#host.obfuscator, converted) : converted;
}
/** Converts session messages using the configured pre-LLM pipeline. */
async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]> {
const transformedMessages = await this.#host.transformContext(messages, signal);
return await this.#host.convertToLlm(transformedMessages);
}
/** Applies session-level stream hooks and provider defaults to a side request. */
prepareSimpleStreamOptions(options: SimpleStreamOptions, provider = "anthropic"): SimpleStreamOptions {
const sessionOnPayload = this.#host.onPayload;
const sessionOnResponse = this.#host.onResponse;
const sessionMetadata = this.#host.agent.metadataForProvider(provider);
const sessionOnSseEvent = this.#host.onSseEvent;
const openrouterRoutingPreset =
provider === "openrouter" ? this.#host.settings.get("providers.openrouterVariant") : "default";
const openrouterVariant =
openrouterRoutingPreset !== "default" && options.openrouterVariant === undefined
? openrouterRoutingPreset
: undefined;
const antigravityEndpointMode =
provider === "google-antigravity" ? this.#host.settings.get("providers.antigravityEndpoint") : undefined;
const preparedOptions: SimpleStreamOptions = {
...options,
...(openrouterVariant !== undefined && { openrouterVariant }),
...(antigravityEndpointMode !== undefined && { antigravityEndpointMode }),
maxInFlightRequests: validateProviderMaxInFlightRequests(
options.maxInFlightRequests ?? this.#host.settings.get("providers.maxInFlightRequests"),
),
loopGuard: {
enabled: this.#host.settings.get("model.loopGuard.enabled"),
checkAssistantContent: this.#host.settings.get("model.loopGuard.checkAssistantContent"),
...options.loopGuard,
},
};
if (sessionMetadata && !options.metadata) {
preparedOptions.metadata = sessionMetadata;
}
if (sessionOnPayload) {
if (!options.onPayload) {
preparedOptions.onPayload = sessionOnPayload;
} else {
const requestOnPayload = options.onPayload;
preparedOptions.onPayload = async (payload, model) => {
const sessionPayload = await sessionOnPayload(payload, model);
const sessionResolvedPayload = sessionPayload ?? payload;
const requestPayload = await requestOnPayload(sessionResolvedPayload, model);
return requestPayload ?? sessionResolvedPayload;
};
}
}
if (sessionOnResponse) {
if (!options.onResponse) {
preparedOptions.onResponse = sessionOnResponse;
} else {
const requestOnResponse = options.onResponse;
preparedOptions.onResponse = async (response, model) => {
await sessionOnResponse(response, model);
await requestOnResponse(response, model);
};
}
}
if (sessionOnSseEvent) {
if (!options.onSseEvent) {
preparedOptions.onSseEvent = sessionOnSseEvent;
} else {
const requestOnSseEvent = options.onSseEvent;
preparedOptions.onSseEvent = (event, model) => {
sessionOnSseEvent(event, model);
requestOnSseEvent(event, model);
};
}
}
return preparedOptions;
}
/** Normalizes image payloads for the active model. */
normalizeImagesForModel(images: ImageContent[] | undefined): Promise<ImageContent[] | undefined> {
return normalizeModelContextImages(images, { model: this.#host.model() });
}
/** Builds a hidden vision-model description for attachments sent to a text-only model. */
async buildImageDescriptionNotice(
normalizedImages: ImageContent[],
signal?: AbortSignal,
): Promise<CustomMessage | undefined> {
const model = this.#host.model();
const shouldDescribe =
!!model &&
!model.input.includes("image") &&
!this.#host.settings.get("images.blockImages") &&
this.#host.settings.get("images.describeForTextModels");
if (!shouldDescribe || !model) return undefined;
let blocks: TextContent[];
try {
blocks = await describeAttachedImagesForTextModel(
normalizedImages,
{
activeModel: model,
modelRegistry: this.#host.modelRegistry,
settings: this.#host.settings,
localProtocolOptions: this.#host.localProtocolOptions(),
activeModelString: formatModelString(model),
telemetryConfig: this.#host.agent.telemetry,
sessionId: this.#host.sessionId(),
},
signal,
);
} catch (error) {
logger.warn("image attachment vision fallback failed; image left undescribed", {
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
if (blocks.length === 0) return undefined;
return {
role: "custom",
customType: IMAGE_ATTACHMENT_DESCRIPTION_TYPE,
content: blocks,
display: false,
attribution: "user",
timestamp: Date.now(),
};
}
/** Normalizes every image embedded in an agent message. */
async normalizeAgentMessageImages<T extends AgentMessage>(message: T): Promise<T> {
if (!("content" in message)) return message;
const content = message.content;
if (typeof content !== "string" && !Array.isArray(content)) return message;
const normalized = await this.#normalizeMessageContentImages(content);
if (normalized === content) return message;
return Object.assign({}, message, { content: normalized });
}
async #normalizeMessageContentImages(
content: string | NormalizableContentBlock[],
): Promise<string | NormalizableContentBlock[]> {
if (typeof content === "string") return content;
const images = content.filter((part): part is ImageContent => part.type === "image");
if (images.length === 0) return content;
const normalizedImages = await this.normalizeImagesForModel(images);
if (!normalizedImages) return content;
let imageIndex = 0;
return content.map(part => (part.type === "image" ? normalizedImages[imageIndex++]! : part));
}
#obfuscatePreservedArchiveText(
preserveData: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const obfuscator = this.#host.obfuscator;
const slot = preserveData?.[snapcompact.PRESERVE_KEY];
if (
!obfuscator?.hasSecrets() ||
!preserveData ||
!isRecord(slot) ||
!snapcompact.getPreservedArchive(preserveData)
) {
return preserveData;
}
const obfuscated: Record<string, unknown> = { ...slot };
let changed = false;
for (const key of ["text", "textHead", "textTail"] as const) {
const value = slot[key];
if (typeof value !== "string" || value.length === 0) continue;
const next = obfuscator.obfuscate(value);
if (next === value) continue;
obfuscated[key] = next;
changed = true;
}
return changed ? { ...preserveData, [snapcompact.PRESERVE_KEY]: obfuscated } : preserveData;
}
}
@@ -0,0 +1,293 @@
import type { Agent, AgentMessage } from "@oh-my-pi/pi-agent-core";
import { calculatePromptTokens, estimateTokens, type SessionMessageEntry } from "@oh-my-pi/pi-agent-core/compaction";
import type { AssistantMessage, Model, ProviderResponseMetadata, Usage } from "@oh-my-pi/pi-ai";
import { isRecord } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import type { ContextUsage } from "../extensibility/extensions/types";
import {
computeNonMessageBreakdown,
computeNonMessageTokens,
type NonMessageTokenSource,
} from "../modes/utils/context-usage";
import type { ContextUsageBreakdown, SessionStats } from "./agent-session-types";
import { getLatestCompactionEntry } from "./session-context";
import type { SessionManager } from "./session-manager";
interface PendingContextSnapshot {
promptTokens: number;
nonMessageTokens: number;
cutoffCount: number;
}
/** Capabilities the stats tracker borrows from its owning session. */
export interface SessionStatsTrackerHost {
session: NonMessageTokenSource;
agent: Agent;
sessionManager: SessionManager;
modelRegistry: ModelRegistry;
model(): Model | undefined;
sessionId(): string;
}
/** Computes session totals and tracks the in-flight context estimate. */
export class SessionStatsTracker {
readonly #host: SessionStatsTrackerHost;
#pendingContextSnapshot: PendingContextSnapshot | undefined;
#contextUsageRevision = 0;
constructor(host: SessionStatsTrackerHost) {
this.#host = host;
}
/** Returns aggregate message, token, and cost statistics for the session. */
getSessionStats(): SessionStats {
const state = this.#host.agent.state;
const userMessages = state.messages.filter(message => message.role === "user").length;
const assistantMessages = state.messages.filter(message => message.role === "assistant").length;
const toolResults = state.messages.filter(message => message.role === "toolResult").length;
let toolCalls = 0;
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalReasoning = 0;
let totalCacheWrite = 0;
let totalTokens = 0;
let totalCost = 0;
let totalPremiumRequests = 0;
for (const message of state.messages) {
if (message.role === "assistant") {
const assistant = message;
toolCalls += assistant.content.filter(content => content.type === "toolCall").length;
totalInput += assistant.usage.input;
totalOutput += assistant.usage.output;
totalReasoning += assistant.usage.reasoningTokens ?? 0;
totalCacheRead += assistant.usage.cacheRead;
totalCacheWrite += assistant.usage.cacheWrite;
totalTokens += assistant.usage.totalTokens;
totalPremiumRequests += assistant.usage.premiumRequests ?? 0;
totalCost += assistant.usage.cost.total;
}
if (message.role === "toolResult" && message.toolName === "task") {
const usage = taskToolUsage(message.details);
if (!usage) continue;
totalInput += usage.input;
totalOutput += usage.output;
totalReasoning += usage.reasoningTokens ?? 0;
totalCacheRead += usage.cacheRead;
totalCacheWrite += usage.cacheWrite;
totalTokens += usage.totalTokens;
totalPremiumRequests += usage.premiumRequests ?? 0;
totalCost += usage.cost.total;
}
}
return {
sessionFile: this.#host.sessionManager.getSessionFile(),
sessionId: this.#host.sessionId(),
userMessages,
assistantMessages,
toolCalls,
toolResults,
totalMessages: state.messages.length,
tokens: {
input: totalInput,
output: totalOutput,
reasoning: totalReasoning,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
total: totalTokens,
},
cost: totalCost,
premiumRequests: totalPremiumRequests,
contextUsage: this.getContextUsage(),
};
}
/** Returns the current provider-context token breakdown. */
getContextBreakdown(options?: {
contextWindow?: number;
pendingMessages?: AgentMessage[];
}): ContextUsageBreakdown | undefined {
const rawContextWindow = options?.contextWindow ?? this.#host.model()?.contextWindow ?? 0;
const contextWindow = Number.isFinite(rawContextWindow) && rawContextWindow > 0 ? rawContextWindow : 0;
const { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens } = computeNonMessageBreakdown(
this.#host.session,
);
const categoryNonMessageTokens = skillsTokens + toolsTokens + systemContextTokens + systemPromptTokens;
const currentNonMessageTokens = computeNonMessageTokens(this.#host.session);
const branchEntries = this.#host.sessionManager.getBranch();
const latestCompaction = getLatestCompactionEntry(branchEntries);
const compactionIndex = latestCompaction ? branchEntries.lastIndexOf(latestCompaction) : -1;
let usedTokens = 0;
let anchored = false;
const pendingMessages = options?.pendingMessages ?? [];
const pending = this.#pendingContextSnapshot;
let anchorEntry: SessionMessageEntry | undefined;
for (let index = branchEntries.length - 1; index > compactionIndex; index--) {
const entry = branchEntries[index];
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
const assistant = entry.message;
if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && assistant.usage) {
anchorEntry = entry;
break;
}
}
const activeMessages = this.#host.agent.state.messages;
let anchorIndex = -1;
let anchorAssistant: AssistantMessage | undefined;
if (anchorEntry?.message.role === "assistant") {
const assistant = anchorEntry.message;
anchorAssistant = assistant;
anchorIndex = activeMessages.indexOf(assistant);
if (anchorIndex === -1) {
anchorIndex = activeMessages.findIndex(
message => message.role === "assistant" && message.timestamp === assistant.timestamp,
);
}
}
const useAnchor =
anchorAssistant !== undefined && anchorIndex !== -1 && (!pending || anchorIndex >= pending.cutoffCount);
if (useAnchor && anchorAssistant) {
const promptTokens =
anchorAssistant.contextSnapshot?.promptTokens ?? calculatePromptTokens(anchorAssistant.usage);
const nonMessageTokens =
anchorAssistant.contextSnapshot?.nonMessageTokens ?? computeNonMessageTokens(this.#host.session);
anchored = true;
let tailTokens = 0;
for (let index = anchorIndex + 1; index < activeMessages.length; index++) {
tailTokens += estimateTokens(activeMessages[index]);
}
usedTokens =
promptTokens +
Math.max(0, currentNonMessageTokens - nonMessageTokens) +
tailTokens +
pendingMessages.reduce((sum, message) => sum + estimateTokens(message), 0);
} else if (pending) {
anchored = true;
let tailTokens = 0;
for (let index = pending.cutoffCount; index < activeMessages.length; index++) {
tailTokens += estimateTokens(activeMessages[index]);
}
usedTokens =
pending.promptTokens +
Math.max(0, currentNonMessageTokens - pending.nonMessageTokens) +
tailTokens +
pendingMessages.reduce((sum, message) => sum + estimateTokens(message), 0);
}
if (!anchored && !pending && branchEntries.length === 0) {
for (let index = activeMessages.length - 1; index >= 0; index--) {
const message = activeMessages[index];
if (
message.role !== "assistant" ||
message.stopReason === "aborted" ||
message.stopReason === "error" ||
!message.usage
) {
continue;
}
const promptTokens = message.contextSnapshot?.promptTokens ?? calculatePromptTokens(message.usage);
const nonMessageTokens =
message.contextSnapshot?.nonMessageTokens ?? computeNonMessageTokens(this.#host.session);
let tailTokens = 0;
for (let tailIndex = index + 1; tailIndex < activeMessages.length; tailIndex++) {
tailTokens += estimateTokens(activeMessages[tailIndex]);
}
usedTokens =
promptTokens +
Math.max(0, currentNonMessageTokens - nonMessageTokens) +
tailTokens +
pendingMessages.reduce((sum, pendingMessage) => sum + estimateTokens(pendingMessage), 0);
anchored = true;
break;
}
}
if (!anchored) {
let messagesTokens = 0;
for (const message of activeMessages) messagesTokens += estimateTokens(message);
usedTokens =
currentNonMessageTokens +
messagesTokens +
pendingMessages.reduce((sum, message) => sum + estimateTokens(message), 0);
}
return {
contextWindow,
anchored,
usedTokens,
systemPromptTokens,
systemToolsTokens: toolsTokens,
systemContextTokens,
skillsTokens,
messagesTokens: Math.max(0, usedTokens - categoryNonMessageTokens),
};
}
/** Returns current context tokens, capacity, and percentage. */
getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined {
const breakdown = this.getContextBreakdown(options);
if (!breakdown) return undefined;
return {
tokens: breakdown.usedTokens,
contextWindow: breakdown.contextWindow,
percent: breakdown.contextWindow > 0 ? (breakdown.usedTokens / breakdown.contextWindow) * 100 : 0,
};
}
/** Monotonic revision for in-flight context snapshot changes. */
get revision(): number {
return this.#contextUsageRevision;
}
/** Non-message token count captured for the active provider request. */
get pendingNonMessageTokens(): number | undefined {
return this.#pendingContextSnapshot?.nonMessageTokens;
}
/** Sets or clears the in-flight context snapshot. */
setPendingSnapshot(snapshot: PendingContextSnapshot | undefined): void {
this.#pendingContextSnapshot = snapshot;
this.#contextUsageRevision++;
}
/** Recomputes an in-flight snapshot after history is compacted or rewritten. */
rebaseAfterCompaction(): void {
if (!this.#pendingContextSnapshot) return;
const nonMessageTokens = computeNonMessageTokens(this.#host.session);
const messages = this.#host.agent.state.messages;
this.setPendingSnapshot({
promptTokens: nonMessageTokens + messages.reduce((sum, message) => sum + estimateTokens(message), 0),
nonMessageTokens,
cutoffCount: messages.length,
});
}
/** Records provider usage headers against the active session account. */
ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
const provider = model?.provider;
if (!provider) return;
this.#host.modelRegistry.authStorage.ingestUsageHeaders(provider, response.headers, {
sessionId: this.#host.agent.sessionId,
baseUrl: this.#host.modelRegistry.getProviderBaseUrl?.(provider),
});
}
}
function taskToolUsage(details: unknown): Usage | undefined {
if (!details || typeof details !== "object") return undefined;
const usage = Reflect.get(details, "usage");
return isUsage(usage) ? usage : undefined;
}
function isUsage(value: unknown): value is Usage {
if (!isRecord(value) || !isRecord(value.cost)) return false;
return (
typeof value.input === "number" &&
typeof value.output === "number" &&
typeof value.cacheRead === "number" &&
typeof value.cacheWrite === "number" &&
typeof value.totalTokens === "number" &&
typeof value.cost.total === "number"
);
}
@@ -0,0 +1,933 @@
import type { Agent, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import { logger, prompt, stringProperty } from "@oh-my-pi/pi-utils";
import { reset as resetCapabilities } from "../capability";
import type { ModelRegistry } from "../config/model-registry";
import { formatModelString } from "../config/model-resolver";
import type { Settings, SkillsSettings } from "../config/settings";
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
import { CustomToolAdapter } from "../extensibility/custom-tools/wrapper";
import type { ExtensionRunner } from "../extensibility/extensions";
import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
import { loadSkills, type Skill, type SkillWarning, setActiveSkills } from "../extensibility/skills";
import type { LocalProtocolOptions } from "../internal-urls";
import { resolveMemoryBackend } from "../memory-backend/resolve";
import { MEMORY_BACKEND_TOOL_NAMES } from "../memory-backend/tool-names";
import type { MemoryBackendStartOptions } from "../memory-backend/types";
import xdevMountNoticePrompt from "../prompts/system/xdev-mount-notice.md" with { type: "text" };
import { usesCodexTaskPrompt } from "../task/prompt-policy";
import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
import { wrapToolWithMetaNotice } from "../tools/output-meta";
import { ToolAbortError, ToolError } from "../tools/tool-errors";
import { isMountableUnderXdev, type XdevRegistry } from "../tools/xdev";
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { formatLocalCalendarDate } from "../utils/local-date";
import {
extractPermissionLocations,
getPermissionIntent,
PERMISSION_OPTIONS,
PERMISSION_OPTIONS_BY_ID,
PERMISSION_REQUIRED_TOOLS,
} from "./acp-permission-gate";
import type { ClientBridge, ClientBridgePermissionOutcome } from "./client-bridge";
import type { CustomMessage } from "./messages";
import type { SessionManager } from "./session-manager";
/** Capabilities borrowed from the owning AgentSession. */
export interface SessionToolsHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
modelRegistry: ModelRegistry;
extensionRunner(): ExtensionRunner | undefined;
clientBridge(): ClientBridge | undefined;
agentKind(): "main" | "sub";
isDisposed(): boolean;
isStreaming(): boolean;
queuedMessageCount(): number;
planModeEnabled(): boolean;
model(): Model | undefined;
memoryBackendSession(): MemoryBackendStartOptions["session"];
clearInheritedProviderPromptCacheKey(): void;
clearMemoryPromotionSnapshot(): void;
captureMemoryPromotionSnapshot(prompt: string[]): void;
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
notifyCommandMetadataChanged(): void;
localProtocolOptions(): LocalProtocolOptions;
}
interface SessionToolsOptions {
autoApprove?: boolean;
toolRegistry?: Map<string, AgentTool>;
createVibeTools?: () => AgentTool[];
builtInToolNames?: Iterable<string>;
presentationPinnedToolNames?: ReadonlySet<string>;
ensureWriteRegistered?: () => Promise<boolean>;
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
getLocalCalendarDate?: () => string;
getMcpServerInstructions?: () => Map<string, string> | undefined;
xdevRegistry?: XdevRegistry;
initialMountedXdevToolNames?: string[];
setActiveToolNames?: (names: Iterable<string>) => void;
baseSystemPrompt: string[];
skills?: Skill[];
skillWarnings?: SkillWarning[];
skillsSettings?: SkillsSettings;
skillsReloadable?: boolean;
}
const XDEV_MOUNT_NOTICE_MESSAGE_TYPE = "xdev-mount-notice";
/** Owns tool registration, presentation, prompt rebuilding, skills, and permissions. */
export class SessionTools {
readonly #host: SessionToolsHost;
#autoApprove: boolean;
#toolRegistry: Map<string, AgentTool>;
#createVibeTools: (() => AgentTool[]) | undefined;
#installedVibeToolNames = new Set<string>();
#builtInToolNames: Set<string>;
#rpcHostToolNames = new Set<string>();
#xdevRegistry: XdevRegistry | undefined;
#mountedXdevToolNames: Set<string>;
#pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
#presentationPinnedToolNames: ReadonlySet<string> | undefined;
#runtimeSelectedToolNames: ReadonlySet<string> | undefined;
#baseSystemPrompt: string[];
#lastAppliedToolSignature: string | undefined;
#promptModelKey: string | undefined;
#rebuildSystemPrompt: SessionToolsOptions["rebuildSystemPrompt"];
#getLocalCalendarDate: () => string;
#getMcpServerInstructions: SessionToolsOptions["getMcpServerInstructions"];
#setActiveToolNames: SessionToolsOptions["setActiveToolNames"];
#ensureWriteRegistered: SessionToolsOptions["ensureWriteRegistered"];
#skills: Skill[];
#skillWarnings: SkillWarning[];
#skillsSettings: SkillsSettings | undefined;
#skillsReloadable: boolean;
#acpPermissionDecisions = new Map<string, "allow_always" | "reject_always">();
constructor(host: SessionToolsHost, options: SessionToolsOptions) {
this.#host = host;
this.#autoApprove = options.autoApprove === true;
this.#toolRegistry = options.toolRegistry ?? new Map();
this.#createVibeTools = options.createVibeTools;
this.#builtInToolNames = new Set(options.builtInToolNames ?? []);
this.#presentationPinnedToolNames = options.presentationPinnedToolNames;
this.#ensureWriteRegistered = options.ensureWriteRegistered;
this.#rebuildSystemPrompt = options.rebuildSystemPrompt;
this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate;
this.#getMcpServerInstructions = options.getMcpServerInstructions;
this.#xdevRegistry = options.xdevRegistry;
this.#mountedXdevToolNames = new Set(options.initialMountedXdevToolNames ?? []);
this.#setActiveToolNames = options.setActiveToolNames;
this.#baseSystemPrompt = options.baseSystemPrompt;
this.#skills = options.skills ?? [];
this.#skillWarnings = options.skillWarnings ?? [];
this.#skillsSettings = options.skillsSettings;
this.#skillsReloadable = options.skillsReloadable ?? true;
this.#promptModelKey = this.#currentPromptModelKey();
}
/** Mutable registry shared with controller hosts that inspect available tools. */
get registry(): Map<string, AgentTool> {
return this.#toolRegistry;
}
/** Current stable base system prompt. */
get baseSystemPrompt(): string[] {
return this.#baseSystemPrompt;
}
/** Replaces the controller-owned base prompt without applying it to the agent. */
setBaseSystemPrompt(prompt: string[]): void {
this.#baseSystemPrompt = prompt;
}
/** Skills currently rendered into the system prompt. */
get skills(): Skill[] {
return this.#skills;
}
/** Diagnostics produced while loading the current skills. */
get skillWarnings(): SkillWarning[] {
return this.#skillWarnings;
}
/** Settings snapshot used for the current skill discovery. */
get skillsSettings(): SkillsSettings | undefined {
return this.#skillsSettings;
}
/** Re-wraps active and mounted tools after the ACP client changes. */
refreshAcpPermissionGates(): void {
this.#acpPermissionDecisions.clear();
const activeTools = this.getActiveToolNames()
.map(name => this.#toolRegistry.get(name))
.filter((tool): tool is AgentTool => tool !== undefined)
.map(tool => this.#wrapToolForAcpPermission(tool));
this.#host.agent.setTools(activeTools);
const mountedTools = [...this.#mountedXdevToolNames]
.map(name => this.#toolRegistry.get(name))
.filter((tool): tool is AgentTool => tool !== undefined)
.map(tool => this.#wrapToolForAcpPermission(tool));
this.#xdevRegistry?.reconcile(mountedTools);
}
#getActiveNonMCPToolNames(): string[] {
return this.getEnabledToolNames().filter(name => !isMCPToolName(name) && this.#toolRegistry.has(name));
}
/** Names of tools currently exposed at the top level. */
getActiveToolNames(): string[] {
return this.#host.agent.state.tools.map(t => t.name);
}
/** Enabled top-level and discoverable tool names. */
getEnabledToolNames(): string[] {
if (this.#mountedXdevToolNames.size === 0) return this.getActiveToolNames();
return [...this.getActiveToolNames(), ...this.#mountedXdevToolNames];
}
/** Names of dynamic tools mounted under `xd://`. */
getMountedXdevToolNames(): string[] {
return [...this.#mountedXdevToolNames];
}
/** Whether the edit tool is registered. */
get hasEditTool(): boolean {
return this.#toolRegistry.has("edit");
}
/** Looks up a registered tool by name. */
getToolByName(name: string): AgentTool | undefined {
return this.#toolRegistry.get(name);
}
/** Whether a registry entry came from a built-in factory. */
hasBuiltInTool(name: string): boolean {
return this.#builtInToolNames.has(name);
}
/** Names of every registered tool. */
getAllToolNames(): string[] {
return Array.from(this.#toolRegistry.keys());
}
#wrapRuntimeTool(tool: AgentTool): AgentTool {
const wrapped = wrapToolWithMetaNotice(tool);
const extensionRunner = this.#host.extensionRunner();
return extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped;
}
/** Installs and activates the ephemeral vibe tool set. */
async activateVibeTools(baseToolNames: string[]): Promise<void> {
const createVibeTools = this.#createVibeTools;
if (!createVibeTools) {
throw new Error("Vibe tools are unavailable in this session.");
}
const tools = createVibeTools();
const vibeToolNames = tools.map(tool => tool.name);
if (new Set(vibeToolNames).size !== vibeToolNames.length) {
throw new Error("Vibe tool names must be unique.");
}
for (const tool of tools) {
if (this.#toolRegistry.has(tool.name)) continue;
this.#toolRegistry.set(tool.name, this.#wrapRuntimeTool(tool));
this.#builtInToolNames.add(tool.name);
this.#installedVibeToolNames.add(tool.name);
}
await this.applyActiveToolsByName([...new Set([...baseToolNames, ...vibeToolNames])]);
}
/** Uninstalls vibe tools and activates the replacement set. */
async deactivateVibeTools(nextToolNames: string[]): Promise<void> {
this.#uninstallVibeTools();
await this.applyActiveToolsByName(nextToolNames);
}
/** Removes vibe tools without restoring a source-session snapshot. */
async removeVibeToolsPreservingActive(): Promise<void> {
const removed = new Set(this.#installedVibeToolNames);
this.#uninstallVibeTools();
const nextActive = this.getActiveToolNames().filter(name => !removed.has(name));
await this.applyActiveToolsByName(nextActive);
}
#uninstallVibeTools(): void {
for (const name of this.#installedVibeToolNames) {
this.#toolRegistry.delete(name);
this.#builtInToolNames.delete(name);
}
this.#installedVibeToolNames.clear();
}
#getEditModeSession() {
return {
settings: this.#host.settings,
getActiveModelString: () => {
const model = this.#host.model();
return model ? formatModelString(model) : undefined;
},
} as const;
}
/** Resolves the edit mode for the active model and settings. */
resolveActiveEditMode(): EditMode {
return resolveEditMode(this.#getEditModeSession());
}
#currentPromptModelKey(): string | undefined {
const activeModel = this.#host.model();
const model = activeModel ? formatModelString(activeModel) : undefined;
if (!model || this.#host.settings.get("includeModelInPrompt")) return model;
return usesCodexTaskPrompt(model) ? "task-policy:gpt-5.6" : "task-policy:default";
}
/** Rebuilds model-dependent tool prompts after a model change. */
async syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
const currentEditMode = this.resolveActiveEditMode();
const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit");
// The system prompt selects model-specific policy even when it does not display the model id.
const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey;
if (editModeChanged || modelChanged) {
await this.refreshBaseSystemPrompt();
}
}
/** Enabled MCP tools in their current presentation partition. */
getSelectedMCPToolNames(): string[] {
// Every connected MCP tool is enabled; presentation (top-level vs xd://) is
// decided by loadMode. Return the enabled MCP tools in the current set.
return this.getEnabledToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name));
}
/**
* Wrap a tool with a permission-gate proxy when an ACP client is connected.
* Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
* the bridge exposes `requestPermission`. No-ops for all other cases.
*
* When the user has explicitly opted into `yolo` / auto-approve behavior (via
* the SDK/CLI `autoApprove` flag or a configured `tools.approvalMode: yolo`),
* skips the gate unless the per-tool policy explicitly requires a prompt or
* deny. The schema default is also `yolo`, so an explicit configuration or
* explicit session flag is required: default-config ACP sessions keep the
* client-side permission gate.
*/
#wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
const bridge = this.#host.clientBridge();
// Match the capability+method gating pattern used by read/write/bash.
if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
if (PERMISSION_REQUIRED_TOOLS[tool.name] !== true) return tool;
// Skip the gate only on explicit yolo opt-in; honour per-tool policies
// that require a prompt or deny (matching the normal approval wrapper).
if (this.#isExplicitAutoApproveMode()) {
const userPolicies = (this.#host.settings.get("tools.approval") ?? {}) as Record<string, unknown>;
const toolPolicy = userPolicies[tool.name];
if (!toolPolicy || toolPolicy === "allow") return tool;
}
return new Proxy(tool, {
get: (target, prop) => {
if (prop !== "execute") return target[prop as keyof T];
return async (
toolCallId: string,
args: unknown,
signal: AbortSignal | undefined,
onUpdate: never,
ctx: never,
) => {
const permissionIntent = getPermissionIntent(target.name, args);
if (!permissionIntent) {
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
}
const command =
target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
? stringProperty(args, "command")
: undefined;
const commandContent = command
? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }]
: undefined;
// Short-circuit on persisted decisions.
const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
if (persisted === "allow_always") {
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
}
if (persisted === "reject_always") {
throw new ToolError(`Tool call rejected by user (preference)`);
}
if (signal?.aborted) {
throw new ToolAbortError("Permission request cancelled");
}
type PermissionRaceResult =
| { kind: "permission"; outcome: ClientBridgePermissionOutcome }
| { kind: "aborted" };
const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();
const onAbort = () => resolveAbort({ kind: "aborted" });
signal?.addEventListener("abort", onAbort, { once: true });
let raced: PermissionRaceResult;
try {
const permissionPromise = bridge.requestPermission!(
{
toolCallId,
toolName: target.name,
title: permissionIntent.title,
...(target.name === "bash" ? { kind: "execute" } : {}),
status: "pending",
rawInput: args,
...(commandContent ? { content: commandContent } : {}),
locations: extractPermissionLocations(
args,
this.#host.sessionManager.getCwd(),
permissionIntent.paths,
),
},
PERMISSION_OPTIONS,
signal,
).then(outcome => ({ kind: "permission" as const, outcome }));
raced = await Promise.race([permissionPromise, abortPromise]);
} finally {
signal?.removeEventListener("abort", onAbort);
}
if (raced.kind === "aborted" || signal?.aborted) {
throw new ToolAbortError("Permission request cancelled");
}
const outcome = raced.outcome;
if (outcome.outcome === "cancelled") {
throw new ToolAbortError("Permission request cancelled");
}
const selectedOption = PERMISSION_OPTIONS_BY_ID[outcome.optionId];
if (!selectedOption) {
throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
}
if (selectedOption.kind === "allow_always") {
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always");
} else if (selectedOption.kind === "reject_always") {
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always");
}
if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
throw new ToolError(`Tool call rejected by user (${target.name})`);
}
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
};
},
}) as T;
}
#isExplicitAutoApproveMode(): boolean {
return (
this.#autoApprove ||
(this.#host.settings.isConfigured("tools.approvalMode") &&
this.#host.settings.get("tools.approvalMode") === "yolo")
);
}
/** Applies an enabled tool set and reconciles its `xd://` partition. */
async applyActiveToolsByName(toolNames: string[]): Promise<void> {
toolNames = normalizeToolNames(toolNames);
const selectedTools = toolNames.flatMap(name => {
const tool = this.#toolRegistry.get(name);
return tool ? [{ name, tool }] : [];
});
const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read");
const isPresentationPinned = (name: string): boolean =>
this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
const mountCandidates = selectedTools.filter(
({ name, tool }) =>
this.#xdevRegistry !== undefined &&
xdevReadAvailable &&
!isPresentationPinned(name) &&
isMountableUnderXdev(tool),
);
let builtInWriteAvailable = this.#builtInToolNames.has("write");
if (mountCandidates.length > 0 && !builtInWriteAvailable) {
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
}
const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
const tools: AgentTool[] = [];
const validToolNames: string[] = [];
const mountedTools: AgentTool[] = [];
for (const { name, tool } of selectedTools) {
if (mountNames.has(name)) {
mountedTools.push(this.#wrapToolForAcpPermission(tool));
} else {
tools.push(this.#wrapToolForAcpPermission(tool));
validToolNames.push(name);
}
}
const pinnedWrite = isPresentationPinned("write");
const activeDeferrableTool = tools.some(tool => tool.deferrable === true);
const transportNeeded = mountedTools.length > 0 || activeDeferrableTool || this.#host.planModeEnabled();
if (transportNeeded && !builtInWriteAvailable) {
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
}
if (transportNeeded && builtInWriteAvailable) {
const write = this.#toolRegistry.get("write");
if (write && !validToolNames.includes("write")) {
tools.push(this.#wrapToolForAcpPermission(write));
validToolNames.push("write");
}
} else if (
!pinnedWrite &&
(this.#presentationPinnedToolNames !== undefined || this.#runtimeSelectedToolNames !== undefined)
) {
const writeNameIndex = validToolNames.indexOf("write");
if (writeNameIndex >= 0 && this.#builtInToolNames.has("write")) validToolNames.splice(writeNameIndex, 1);
const writeToolIndex = tools.findIndex(tool => tool.name === "write" && this.#builtInToolNames.has("write"));
if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1);
}
const previousMounted = this.#mountedXdevToolNames;
const previousMountedTools = [...previousMounted].flatMap(name => {
const tool = this.#xdevRegistry?.get(name);
return tool ? [tool] : [];
});
const previousActiveToolNames = this.getActiveToolNames();
this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name));
this.#xdevRegistry?.reconcile(mountedTools);
this.#setActiveToolNames?.(validToolNames);
let rebuiltSystemPrompt: string[] | undefined;
let rebuiltSignature: string | undefined;
try {
if (this.#rebuildSystemPrompt) {
const signature = this.#computeAppliedToolSignature(validToolNames, tools);
if (signature !== this.#lastAppliedToolSignature) {
const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
rebuiltSystemPrompt = built.systemPrompt;
rebuiltSignature = signature;
}
}
} catch (error) {
this.#mountedXdevToolNames = previousMounted;
this.#xdevRegistry?.reconcile(previousMountedTools);
this.#setActiveToolNames?.(previousActiveToolNames);
throw error;
}
this.#notifyXdevMountDelta(previousMounted);
this.#host.agent.setTools(tools);
if (rebuiltSystemPrompt && rebuiltSignature) {
if (this.#lastAppliedToolSignature !== undefined) this.#host.clearInheritedProviderPromptCacheKey();
this.#baseSystemPrompt = rebuiltSystemPrompt;
this.#host.clearMemoryPromotionSnapshot();
this.#host.agent.setSystemPrompt(this.#baseSystemPrompt);
this.#lastAppliedToolSignature = rebuiltSignature;
this.#promptModelKey = this.#currentPromptModelKey();
}
}
/**
* Record a mid-session `xd://` mount delta for the model without rewriting
* the system prompt: the prompt (and its provider cache prefix) stays
* byte-stable across MCP connects and disconnects. The delta is NOT steered
* immediately — a steered notice landing at a run's stop boundary (or while
* the session is idle) forces an unsolicited extra assistant turn — it is
* coalesced into {@link #pendingXdevMountDelta} and rides along with the
* next prompt (docs + schema stay one `read xd://<tool>` away). The full
* docs join the system prompt opportunistically on the next unrelated
* rebuild.
*/
#notifyXdevMountDelta(previousMounted: ReadonlySet<string>): void {
const registry = this.#xdevRegistry;
if (!registry) return;
const current = this.#mountedXdevToolNames;
const addedNames = [...current].filter(name => !previousMounted.has(name));
const removedNames = [...previousMounted].filter(name => !current.has(name));
if (addedNames.length === 0 && removedNames.length === 0) return;
// Coalesce against the unannounced delta: an unmount cancels a pending
// mount the model never learned about, and a remount cancels a pending
// unmount.
const pending = this.#pendingXdevMountDelta ?? { added: new Set<string>(), removed: new Set<string>() };
for (const name of addedNames) {
if (!pending.removed.delete(name)) pending.added.add(name);
}
for (const name of removedNames) {
if (!pending.added.delete(name)) pending.removed.add(name);
}
this.#pendingXdevMountDelta = pending.added.size > 0 || pending.removed.size > 0 ? pending : undefined;
if (this.#host.settings.get("startup.quiet")) return;
const parts: string[] = [];
if (addedNames.length > 0) parts.push(`mounted ${addedNames.join(", ")}`);
if (removedNames.length > 0) parts.push(`unmounted ${removedNames.join(", ")}`);
this.#host.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
}
/** Consumes the hidden notice for unannounced `xd://` mount changes. */
takePendingXdevMountNotice(): CustomMessage | undefined {
const pending = this.#pendingXdevMountDelta;
if (!pending) return undefined;
this.#pendingXdevMountDelta = undefined;
const summaries = new Map(this.#xdevRegistry?.entries().map(entry => [entry.name, entry.summary]) ?? []);
const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
const removed = [...pending.removed].map(name => ({ name }));
return {
role: "custom",
customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
content: prompt.render(xdevMountNoticePrompt, { added, removed }),
attribution: "agent",
display: false,
timestamp: Date.now(),
};
}
/** Rediscovers reloadable skills and refreshes prompt metadata. */
async refreshSkills(): Promise<void> {
if (!this.#skillsReloadable) {
return;
}
resetCapabilities();
const skillsSettings = this.#host.settings.getGroup("skills");
const discovered = await loadSkills({
...skillsSettings,
cwd: this.#host.sessionManager.getCwd(),
disabledExtensions: this.#host.settings.get("disabledExtensions") ?? [],
});
this.#skills = discovered.skills;
this.#skillWarnings = discovered.warnings;
this.#skillsSettings = skillsSettings;
if (this.#host.agentKind() === "main") {
setActiveSkills(this.#skills);
}
await this.refreshBaseSystemPrompt();
this.#host.notifyCommandMetadataChanged();
}
/** Selects enabled tools, ignoring names absent from the registry. */
async setActiveToolsByName(toolNames: string[]): Promise<void> {
const normalized = normalizeToolNames(toolNames);
// Transport-write eligibility keys off the *current* active set: an ordinary
// selection change should not demote `write` unless it is already active.
await this.#applyToolPresentation(
normalized,
this.#mountedXdevToolNames,
this.getActiveToolNames().includes("write"),
);
}
/**
* Restore an enabled tool set with its exact top-level versus `xd://` partition.
*
* Both inputs are required because {@link setActiveToolsByName} only receives the
* enabled name list and classifies mounts from the current `#mountedXdevToolNames`.
* Rollback/restore callers must pass the snapshotted mounted subset so names that
* were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
* `xd://` remain mount-eligible, even when the live mount set has drifted.
*
* Names outside `mountedToolNames` are pinned top-level for this application;
* names in the mounted subset remain eligible for xdev mounting. Delegates the
* actual apply through {@link applyActiveToolsByName} and restores the prior runtime
* selection if that apply throws.
*/
async setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void> {
const normalized = normalizeToolNames(toolNames);
// Restoration targets a snapshot, so write eligibility comes from the
// *target* set rather than whatever happens to be active mid-rollback.
await this.#applyToolPresentation(
normalized,
new Set(normalizeToolNames(mountedToolNames)),
normalized.includes("write"),
);
}
/**
* Shared body for {@link setActiveToolsByName} and {@link setActiveToolPresentation}:
* pins non-mounted names as the runtime selection (holding `write` back when it is
* transport-only) and applies the set, rolling the selection back if apply throws.
*/
async #applyToolPresentation(
normalized: string[],
mounted: ReadonlySet<string>,
writeSelected: boolean,
): Promise<void> {
const transportWriteActive =
writeSelected &&
this.#builtInToolNames.has("write") &&
this.#presentationPinnedToolNames?.has("write") !== true &&
this.#runtimeSelectedToolNames?.has("write") !== true &&
(mounted.size > 0 || this.#host.planModeEnabled());
const previousRuntimeSelectedToolNames = this.#runtimeSelectedToolNames;
this.#runtimeSelectedToolNames = new Set(
normalized.filter(name => !mounted.has(name) && !(name === "write" && transportWriteActive)),
);
try {
await this.applyActiveToolsByName(normalized);
} catch (error) {
this.#runtimeSelectedToolNames = previousRuntimeSelectedToolNames;
throw error;
}
}
/** Replaces memory-backend tools while preserving unrelated selections. */
async replaceMemoryTools(tools: AgentTool[]): Promise<void> {
const removed = new Set<string>(MEMORY_BACKEND_TOOL_NAMES.filter(name => this.#builtInToolNames.has(name)));
const nextActive = this.getEnabledToolNames().filter(name => !removed.has(name));
for (const name of removed) {
this.#toolRegistry.delete(name);
this.#builtInToolNames.delete(name);
}
for (const tool of tools) {
if (!MEMORY_BACKEND_TOOL_NAMES.some(name => name === tool.name) || this.#toolRegistry.has(tool.name)) {
continue;
}
const wrapped = this.#wrapRuntimeTool(tool);
this.#toolRegistry.set(wrapped.name, wrapped);
this.#builtInToolNames.add(wrapped.name);
nextActive.push(wrapped.name);
}
await this.applyActiveToolsByName([...new Set(nextActive)]);
}
/** Rebuilds the stable base prompt for the current tools and model. */
async refreshBaseSystemPrompt(): Promise<void> {
if (this.#host.isDisposed() || !this.#rebuildSystemPrompt) return;
const activeToolNames = this.getActiveToolNames();
this.#setActiveToolNames?.(activeToolNames);
const previousBaseSystemPrompt = this.#baseSystemPrompt;
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
if (this.#host.isDisposed()) return;
this.#baseSystemPrompt = built.systemPrompt;
this.#host.clearMemoryPromotionSnapshot();
if (
previousBaseSystemPrompt.length !== this.#baseSystemPrompt.length ||
previousBaseSystemPrompt.some((part, index) => part !== this.#baseSystemPrompt[index])
) {
this.#host.clearInheritedProviderPromptCacheKey();
}
this.#host.agent.setSystemPrompt(this.#baseSystemPrompt);
this.#promptModelKey = this.#currentPromptModelKey();
// Refresh the cached signature so a subsequent `applyActiveToolsByName` with
// the same tool set does not re-rebuild on top of the explicit refresh we
// just performed (and conversely, a different set forces a fresh rebuild).
const activeTools = activeToolNames
.map(name => this.#toolRegistry.get(name))
.filter((tool): tool is AgentTool => tool != null);
this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
}
/** Applies one-turn memory prompt injection before an agent run. */
async buildSystemPromptForAgentStart(promptText: string): Promise<string[]> {
const backend = await resolveMemoryBackend(this.#host.settings);
if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
try {
const injected = await backend.beforeAgentStartPrompt(this.#host.memoryBackendSession(), promptText);
if (!injected) return this.#baseSystemPrompt;
const previousBaseSystemPrompt = this.#baseSystemPrompt;
try {
await this.refreshBaseSystemPrompt();
} catch (refreshErr) {
logger.debug("Memory backend prompt refresh after beforeAgentStartPrompt failed", {
backend: backend.id,
error: String(refreshErr),
});
}
if (
this.#baseSystemPrompt.length !== previousBaseSystemPrompt.length ||
this.#baseSystemPrompt.some((part, index) => part !== previousBaseSystemPrompt[index])
) {
return this.#baseSystemPrompt;
}
this.#host.captureMemoryPromotionSnapshot(previousBaseSystemPrompt);
const stablePrompt = [...previousBaseSystemPrompt, injected];
this.#baseSystemPrompt = stablePrompt;
this.#host.agent.setSystemPrompt(stablePrompt);
return stablePrompt;
} catch (err) {
logger.debug("Memory backend beforeAgentStartPrompt failed", {
backend: backend.id,
error: String(err),
});
return this.#baseSystemPrompt;
}
}
/**
* Compose a stable signature for the inputs that `rebuildSystemPrompt` reads.
* Two calls producing identical signatures are guaranteed to produce identical
* system prompt bytes, so the rebuild can be skipped.
*
* The signature covers:
* 1. Active tool names in order (the prompt renders them in this order).
* 2. Active tool labels, descriptions, and wire-visible names — all are
* rendered into the prompt body (see `system-prompt.md` `{{label}}: \`{{name}}\``
* and `toolPromptNames` in `buildSystemPrompt`). The wire name comes from
* `tool.customWireName` and overrides the internal name on the model wire
* (e.g. `edit` exposes itself as `apply_patch` to GPT-5 in apply_patch mode);
* a stale wire name would desync prompt guidance from actual tool routing.
* 3. When MCP discovery is on, every registry tool's name+label+description+
* customWireName, since `rebuildSystemPrompt` summarizes discoverable MCP
* tools that are not in the active set.
* 4. MCP server instructions text (per server), since `rebuildSystemPrompt`
* embeds these in the appended prompt under "## MCP Server Instructions".
* A server upgrade can change instructions while keeping tools identical.
*
* Settings-driven tool metadata is covered automatically: built-in tools that
* depend on settings expose `description`/`label` via getters (see `TaskTool`,
* `SearchToolBm25Tool`, `EditTool`), and the signature reads them live on every
* call - so a settings flip that mutates the rendered string differs the signature
* the next time {@link applyActiveToolsByName} runs. Do not refactor `describeTool`
* to cache per-tool strings without preserving this property.
*
* Inputs NOT covered: tool input schemas; memory instructions read from disk;
* and SDK-init-time closure constants in `sdk.ts` (`inlineToolDescriptors`,
* `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
* closure-captured ones cannot change at runtime regardless of skip behavior.
* For everything else, callers must explicitly call {@link refreshBaseSystemPrompt}
* after side-effecting changes; see the memory hooks and {@link syncAfterModelChange}.
*
* The current calendar date IS covered (appended as a segment) because
* `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
* Without this, a session spanning midnight with only tool-stable MCP
* reconnects would keep yesterday's date indefinitely.
*/
#computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
// Order-preserving join: any reorder must produce a different signature so
// the rebuild fires and the new tool list reaches the API.
const nameSegment = toolNames.join("\u0001");
const describeTool = (tool: AgentTool): string =>
`${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
const descriptionSegment = tools.map(describeTool).join("\u0002");
let instructionsSegment = "";
const serverInstructions = this.#getMcpServerInstructions?.();
if (serverInstructions && serverInstructions.size > 0) {
// Sort by server name so transport flap order does not perturb the signature.
const entries: string[] = [];
for (const [server, instructions] of serverInstructions) {
entries.push(`${server}=${instructions}`);
}
entries.sort();
instructionsSegment = entries.join("\u0006");
}
// The xd:// device inventory is deliberately NOT part of the signature:
// a mount/unmount announces itself via `#notifyXdevMountDelta` instead of
// rewriting the system prompt, so MCP connects/disconnects keep the
// prompt (and its provider cache prefix) byte-stable. Rebuilds triggered
// by other inputs pick up the current device docs opportunistically.
const date = this.#getLocalCalendarDate();
return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}|${date}`;
}
/**
* Replace MCP tools in the registry and enable them immediately. Every
* connected MCP tool becomes available (mounted under `xd://` when that
* transport is active, else top-level). Lets `/mcp add/remove/reauth` take
* effect without restarting the session.
*/
async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
const existingNames = Array.from(this.#toolRegistry.keys());
const previousMcpTools = new Map(
existingNames.flatMap(name => {
const tool = this.#toolRegistry.get(name);
return isMCPToolName(name) && tool ? [[name, tool] as const] : [];
}),
);
for (const name of existingNames) {
if (isMCPToolName(name)) {
this.#toolRegistry.delete(name);
}
}
const getCustomToolContext = (): CustomToolContext => ({
sessionManager: this.#host.sessionManager,
modelRegistry: this.#host.modelRegistry,
model: this.#host.model(),
isIdle: () => !this.#host.isStreaming(),
hasQueuedMessages: () => this.#host.queuedMessageCount() > 0,
abort: () => {
this.#host.agent.abort();
},
settings: this.#host.settings,
localProtocolOptions: this.#host.localProtocolOptions(),
});
const extensionRunner = this.#host.extensionRunner();
for (const customTool of mcpTools) {
const wrapped = wrapToolWithMetaNotice(CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool);
const finalTool = (
extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped
) as AgentTool;
this.#toolRegistry.set(finalTool.name, finalTool);
}
// Every connected MCP tool is selected; centralized repartitioning owns
// presentation pins and write-transport activation/removal.
const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...mcpTools.map(tool => tool.name)])];
try {
await this.applyActiveToolsByName(nextActive);
} catch (error) {
for (const name of this.#toolRegistry.keys()) {
if (isMCPToolName(name)) this.#toolRegistry.delete(name);
}
for (const [name, tool] of previousMcpTools) this.#toolRegistry.set(name, tool);
throw error;
}
}
/** Replaces RPC host-owned tools and refreshes the active set before the next model call. */
async refreshRpcHostTools(rpcTools: AgentTool[]): Promise<void> {
const nextToolNames = rpcTools.map(tool => tool.name);
const uniqueToolNames = new Set(nextToolNames);
if (uniqueToolNames.size !== nextToolNames.length) {
throw new Error("RPC host tool names must be unique");
}
for (const name of uniqueToolNames) {
if (this.#toolRegistry.has(name) && !this.#rpcHostToolNames.has(name)) {
throw new Error(`RPC host tool "${name}" conflicts with an existing tool`);
}
}
const previousRpcHostToolNames = new Set(this.#rpcHostToolNames);
const previousActiveToolNames = this.getEnabledToolNames();
const previousRpcHostTools = new Map(
[...previousRpcHostToolNames].flatMap(name => {
const tool = this.#toolRegistry.get(name);
return tool ? [[name, tool] as const] : [];
}),
);
for (const name of previousRpcHostToolNames) {
this.#toolRegistry.delete(name);
}
this.#rpcHostToolNames.clear();
const extensionRunner = this.#host.extensionRunner();
for (const tool of rpcTools) {
const metaWrapped = wrapToolWithMetaNotice(tool);
const finalTool = (
extensionRunner ? new ExtensionToolWrapper(metaWrapped, extensionRunner) : metaWrapped
) as AgentTool;
this.#toolRegistry.set(finalTool.name, finalTool);
this.#rpcHostToolNames.add(finalTool.name);
}
const activeNonRpcToolNames = previousActiveToolNames.filter(name => !previousRpcHostToolNames.has(name));
const preservedRpcToolNames = previousActiveToolNames.filter(
name => previousRpcHostToolNames.has(name) && this.#rpcHostToolNames.has(name),
);
const autoActivatedRpcToolNames = rpcTools
.filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
.map(tool => tool.name);
try {
await this.applyActiveToolsByName(
Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
);
} catch (error) {
for (const name of this.#rpcHostToolNames) this.#toolRegistry.delete(name);
this.#rpcHostToolNames = previousRpcHostToolNames;
for (const [name, tool] of previousRpcHostTools) this.#toolRegistry.set(name, tool);
throw error;
}
}
}
@@ -0,0 +1,417 @@
import * as fs from "node:fs";
import type { Agent, AgentEvent, AgentMessage, AgentTurnEndContext } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, AssistantMessageEvent, Model, ToolCall } from "@oh-my-pi/pi-ai";
import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
import { isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-reminder.md" with { type: "text" };
import toolCallLoopRedirectTemplate from "../prompts/system/tool-call-loop-redirect.md" with { type: "text" };
import type { SecretObfuscator } from "../secrets/obfuscator";
import { assertEditableFile } from "../tools/auto-generated-guard";
import { isInternalUrlPath, normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
import { ToolError } from "../tools/tool-errors";
import type { CustomMessage } from "./messages";
import type { SessionManager } from "./session-manager";
const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
const TOOL_CALL_LOOP_REDIRECT_TYPE = "tool-call-loop-redirect";
/** Capabilities borrowed by the session's streaming and loop guards. */
export interface StreamGuardsHost {
agent: Agent;
settings: Settings;
sessionManager: SessionManager;
obfuscator: SecretObfuscator | undefined;
model(): Model | undefined;
isDisposed(): boolean;
promptGeneration(): number;
localProtocolOptions(): LocalProtocolOptions;
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
schedulePostPromptTask(task: (signal: AbortSignal) => Promise<void>): void;
discardAssistantTurn(message: AssistantMessage): void;
}
/** Guards streamed edit calls against generated files and invalid patch previews. */
export class StreamingEditGuard {
readonly #host: StreamGuardsHost;
#abortTriggered = false;
#checkedLineCounts = new Map<string, number>();
#precheckedToolCallIds = new Set<string>();
#fileCache = new Map<string, string>();
#lastToolCallId: string | undefined;
constructor(host: StreamGuardsHost) {
this.#host = host;
}
/** Whether the current turn was aborted by streaming edit validation. */
get abortTriggered(): boolean {
return this.#abortTriggered;
}
/** Clears all turn-scoped streaming edit state. */
reset(): void {
this.#abortTriggered = false;
this.#checkedLineCounts.clear();
this.#precheckedToolCallIds.clear();
this.#fileCache.clear();
}
/** Pre-caches and validates a streamed edit as its arguments arrive. */
preCache(event: AgentEvent): void {
if (this.#abortTriggered || event.type !== "message_update") return;
const assistantEvent = event.assistantMessageEvent;
if (
assistantEvent.type !== "toolcall_start" &&
assistantEvent.type !== "toolcall_delta" &&
assistantEvent.type !== "toolcall_end"
) {
return;
}
const streamingEdit = this.#getToolCall(event);
if (!streamingEdit) return;
// The auto-generated guard runs unconditionally: editing a generated file
// is never the user's intent, and the cost of a false-positive abort is one
// wasted turn vs. silently corrupting a regenerated source.
const shouldCheckAutoGenerated =
!streamingEdit.toolCall.id || !this.#precheckedToolCallIds.has(streamingEdit.toolCall.id);
if (shouldCheckAutoGenerated) {
if (streamingEdit.toolCall.id) this.#precheckedToolCallIds.add(streamingEdit.toolCall.id);
this.#abortForAutoGeneratedPath(streamingEdit.toolCall, streamingEdit.path, streamingEdit.resolvedPath);
}
// File-cache priming feeds maybeAbort's removed-lines check, which is the
// optional patch-preview verification gated by edit.streamingAbort.
if (this.#host.settings.get("edit.streamingAbort")) this.#ensureFileCache(streamingEdit.resolvedPath);
}
/** Invalidates cached source text after an edit tool result lands. */
invalidate(filePath: string): void {
const resolvedPath = this.#resolveSessionFsPath(filePath);
if (resolvedPath !== undefined) this.#fileCache.delete(resolvedPath);
}
/** Aborts a streamed edit whose completed patch preview cannot apply. */
maybeAbort(event: AgentEvent): void {
if (!this.#host.settings.get("edit.streamingAbort") || this.#abortTriggered || event.type !== "message_update") {
return;
}
const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
const streamingEdit = this.#getToolCall(event);
if (!streamingEdit?.toolCall.id) return;
const { toolCall, path, resolvedPath, diff, op, rename } = streamingEdit;
if (!diff || (op && op !== "update") || !diff.includes("\n")) return;
const lastNewlineIndex = diff.lastIndexOf("\n");
if (lastNewlineIndex < 0) return;
const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
if (diffForCheck.trim().length === 0) return;
let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
if (!normalizedDiff) return;
if (this.#host.obfuscator) normalizedDiff = this.#host.obfuscator.deobfuscate(normalizedDiff);
if (!normalizedDiff) return;
const lines = normalizedDiff.split("\n");
if (!lines.some(line => line.startsWith("+") || line.startsWith("-"))) return;
const lineCount = lines.length;
const lastChecked = this.#checkedLineCounts.get(toolCall.id);
if (lastChecked !== undefined && lineCount <= lastChecked) return;
this.#checkedLineCounts.set(toolCall.id, lineCount);
const removedLines = lines
.filter(line => line.startsWith("-") && !line.startsWith("--- "))
.map(line => line.slice(1));
if (removedLines.length > 0) {
let cachedContent = this.#fileCache.get(resolvedPath);
if (cachedContent === undefined) {
this.#ensureFileCache(resolvedPath);
cachedContent = this.#fileCache.get(resolvedPath);
}
if (cachedContent !== undefined) {
const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
if (missing) this.#abortPatch(toolCall.id, path, `Failed to find expected lines in ${path}:\n${missing}`);
return;
}
if (assistantEvent.type === "toolcall_delta") return;
void this.#checkRemovedLines(toolCall.id, path, resolvedPath, removedLines);
return;
}
if (assistantEvent.type === "toolcall_delta") return;
void this.#checkPreviewPatch(toolCall.id, path, rename, normalizedDiff);
}
#getToolCall(event: AgentEvent):
| {
toolCall: ToolCall;
path: string;
resolvedPath: string;
diff?: string;
op?: string;
rename?: string;
}
| undefined {
if (event.type !== "message_update" || event.message.role !== "assistant") return undefined;
const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
const messageContent = event.message.content;
if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) return undefined;
const toolCall = messageContent[contentIndex] as ToolCall;
if (toolCall.name !== "edit") return undefined;
const args = toolCall.arguments;
if (!args || typeof args !== "object" || Array.isArray(args) || "old_text" in args || "new_text" in args) {
return undefined;
}
const filePath = typeof args.path === "string" ? args.path : undefined;
if (!filePath) return undefined;
// local:// URLs resolve to artifacts; other internal URLs have no local path.
const resolvedPath = this.#resolveSessionFsPath(filePath);
if (resolvedPath === undefined) return undefined;
return {
toolCall,
path: filePath,
resolvedPath,
diff: typeof args.diff === "string" ? args.diff : undefined,
op: typeof args.op === "string" ? args.op : undefined,
rename: typeof args.rename === "string" ? args.rename : undefined,
};
}
#abortForAutoGeneratedPath(toolCall: ToolCall, filePath: string, resolvedPath: string): void {
if (this.#lastToolCallId === toolCall.id) return;
this.#lastToolCallId = toolCall.id;
void assertEditableFile(resolvedPath, filePath).catch(error => {
if (!(error instanceof ToolError) || this.#lastToolCallId !== toolCall.id) return;
if (!this.#abortTriggered) {
this.#abortTriggered = true;
logger.warn("Streaming edit aborted due to auto-generated file guard", {
toolCallId: toolCall.id,
path: filePath,
});
this.#host.agent.abort();
}
});
}
#ensureFileCache(resolvedPath: string): void {
if (this.#fileCache.has(resolvedPath)) return;
try {
const rawText = fs.readFileSync(resolvedPath, "utf-8");
const { text } = stripBom(rawText);
this.#fileCache.set(resolvedPath, normalizeToLF(text));
} catch {
// Read errors are handled by the edit tool itself.
}
}
#resolveSessionFsPath(filePath: string): string | undefined {
const normalized = normalizeLocalScheme(filePath);
if (normalized.startsWith("local:")) {
return resolveLocalUrlToPath(normalized, this.#host.localProtocolOptions());
}
if (isInternalUrlPath(normalized)) return undefined;
return resolveToCwd(normalized, this.#host.sessionManager.getCwd());
}
async #checkRemovedLines(
toolCallId: string,
filePath: string,
resolvedPath: string,
removedLines: string[],
): Promise<void> {
if (this.#abortTriggered) return;
try {
const { text } = stripBom(await Bun.file(resolvedPath).text());
const normalizedContent = normalizeToLF(text);
const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line)));
if (missing)
this.#abortPatch(toolCallId, filePath, `Failed to find expected lines in ${filePath}:\n${missing}`);
} catch (error) {
if (!isEnoent(error)) {
// Unexpected fallback read errors remain non-fatal.
}
}
}
async #checkPreviewPatch(
toolCallId: string,
filePath: string,
rename: string | undefined,
normalizedDiff: string,
): Promise<void> {
if (this.#abortTriggered) return;
try {
await previewPatch(
{ path: filePath, op: "update", rename, diff: normalizedDiff },
{
cwd: this.#host.sessionManager.getCwd(),
allowFuzzy: this.#host.settings.get("edit.fuzzyMatch"),
fuzzyThreshold: this.#host.settings.get("edit.fuzzyThreshold"),
},
);
} catch (error) {
if (error instanceof ParseError) return;
this.#abortPatch(toolCallId, filePath, error instanceof Error ? error.message : String(error));
}
}
#abortPatch(toolCallId: string, filePath: string, error: string): void {
this.#abortTriggered = true;
logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId, path: filePath, error });
this.#host.agent.abort();
}
}
/** Detects cross-turn tool loops and Gemini reasoning-header runaways. */
export class LoopGuards {
readonly #host: StreamGuardsHost;
#geminiHeaderDetector: GeminiHeaderRunDetector | undefined;
#toolCallLoopGuard: ToolCallLoopGuard | undefined;
#toolCallLoopGuardSettingsKey: string | undefined;
constructor(host: StreamGuardsHost) {
this.#host = host;
}
/** Records a completed turn and injects a redirect when calls repeat. */
recordTurn(messages: AgentMessage[], context: AgentTurnEndContext | undefined): void {
if (context?.message.role !== "assistant") return;
const detection = this.#activeToolCallLoopGuard()?.recordTurn({
message: context.message,
toolResults: context.toolResults,
});
if (detection) this.#injectToolCallLoopRedirect(messages, detection);
}
/** Feeds a streamed assistant event to the Gemini header-runaway detector. */
onAssistantEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
if (event.type === "thinking_start") {
this.#geminiHeaderDetector = this.#geminiHeaderGuardActive() ? new GeminiHeaderRunDetector() : undefined;
return;
}
const detector = this.#geminiHeaderDetector;
if (!detector) return;
if (event.type === "thinking_delta") {
if (detector.push(event.delta)) this.#interruptGeminiHeaderRunaway(detector.count, message.timestamp);
return;
}
if (event.type === "text_start" || event.type === "toolcall_start") detector.reset();
}
#activeToolCallLoopGuard(): ToolCallLoopGuard | undefined {
if (this.#host.settings.get("model.toolCallLoopGuard.enabled") !== true) {
this.#toolCallLoopGuard = undefined;
this.#toolCallLoopGuardSettingsKey = undefined;
return undefined;
}
const threshold = this.#host.settings.get("model.toolCallLoopGuard.threshold");
const exemptTools = this.#host.settings
.get("model.toolCallLoopGuard.exemptTools")
.filter((tool): tool is string => typeof tool === "string" && tool.length > 0);
const settingsKey = `${threshold}:${JSON.stringify(exemptTools)}`;
if (!this.#toolCallLoopGuard || this.#toolCallLoopGuardSettingsKey !== settingsKey) {
this.#toolCallLoopGuard = new ToolCallLoopGuard({ threshold, exemptTools });
this.#toolCallLoopGuardSettingsKey = settingsKey;
}
return this.#toolCallLoopGuard;
}
#injectToolCallLoopRedirect(messages: AgentMessage[], detection: RepeatedToolCallDetection): void {
const content = prompt.render(toolCallLoopRedirectTemplate, {
tool_name: detection.toolName,
count: detection.count,
arguments_summary: detection.argumentsSummary,
result_summary: detection.resultSummary || "(no text result)",
});
const details = {
toolName: detection.toolName,
count: detection.count,
argumentsSummary: detection.argumentsSummary,
resultSummary: detection.resultSummary,
};
logger.warn("cross-turn tool-call loop detected", { toolName: detection.toolName, count: detection.count });
const redirectMessage: CustomMessage = {
role: "custom",
customType: TOOL_CALL_LOOP_REDIRECT_TYPE,
content,
display: false,
details,
attribution: "agent",
timestamp: Date.now(),
};
messages.push(redirectMessage);
if (this.#host.agent.state.messages !== messages) this.#host.agent.appendMessage(redirectMessage);
this.#host.sessionManager.appendCustomMessageEntry(
TOOL_CALL_LOOP_REDIRECT_TYPE,
content,
false,
details,
"agent",
);
}
#geminiHeaderGuardActive(): boolean {
const model = this.#host.model();
return (
process.env.PI_NO_THINKING_LOOP_GUARD !== "1" &&
this.#host.settings.get("model.loopGuard.enabled") === true &&
this.#host.settings.get("model.loopGuard.toolCallReminder") === true &&
model !== undefined &&
isGeminiThinkingModel(model)
);
}
#interruptGeminiHeaderRunaway(headerCount: number, targetTimestamp: number): void {
const model = this.#host.model();
logger.warn("Gemini reasoning-header runaway; interrupting to require a tool call", {
model: model?.id,
provider: model?.provider,
headers: headerCount,
});
this.#host.emitNotice(
"warning",
`Interrupted ${headerCount} planning headers with no tool call; reminded the model to issue one.`,
"loop-guard",
);
this.#host.agent.abort(GEMINI_HEADER_INTERRUPT_REASON);
const generation = this.#host.promptGeneration();
this.#host.schedulePostPromptTask(async signal => {
if (signal.aborted || this.#host.isDisposed() || this.#host.promptGeneration() !== generation) return;
await this.#host.agent.waitForIdle();
if (signal.aborted || this.#host.isDisposed() || this.#host.promptGeneration() !== generation) return;
const aborted = this.#host.agent.state.messages.findLast(
(message): message is AssistantMessage =>
message.role === "assistant" && message.timestamp === targetTimestamp,
);
if (aborted) this.#host.discardAssistantTurn(aborted);
const content = prompt.render(geminiToolReminderTemplate, { count: headerCount });
const details = { headers: headerCount };
this.#host.agent.appendMessage({
role: "custom",
customType: GEMINI_TOOL_REMINDER_TYPE,
content,
display: false,
details,
attribution: "agent",
timestamp: Date.now(),
});
this.#host.sessionManager.appendCustomMessageEntry(
GEMINI_TOOL_REMINDER_TYPE,
content,
false,
details,
"agent",
);
try {
await this.#host.agent.continue();
} catch (error) {
logger.warn("gemini tool-call reminder continue failed", { error: String(error) });
}
});
}
}
@@ -0,0 +1,380 @@
import type { Agent, AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Message, Model, TextContent, ToolChoice } from "@oh-my-pi/pi-ai";
import { isRecord, logger, prompt, stringProperty } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
import midRunTodoNudgePrompt from "../prompts/system/mid-run-todo-nudge.md" with { type: "text" };
import { getLatestTodoPhasesFromEntries, isTodoPhase, type TodoItem, type TodoPhase } from "../tools/todo";
import { buildNamedToolChoice } from "../utils/tool-choice";
import type { AgentSessionEvent } from "./agent-session-events";
import type { SessionManager } from "./session-manager";
const MID_RUN_NUDGE_MUTATION_THRESHOLD = 12;
const MID_RUN_NUDGE_MAX_PER_CYCLE = 2;
const MUTATING_TOOLS: Record<string, true> = {
bash: true,
eval: true,
edit: true,
write: true,
ast_edit: true,
};
const MID_RUN_NUDGE_MESSAGE_TYPE = "mid-run-todo-nudge";
const MARKDOWN_PROMPT_PREFIX_RE = /^(?:>\s*)?(?:(?:[-*+]|\d+[.)])\s+)*/;
const PROMPT_LABEL_RE = /^(?:q(?:uestion)?|ask)\s*\d*\s*[:.)-]\s*/i;
const QUESTION_PROMPT_RE =
/^(?:what|which|when|where|why|how|who|whom|whose|do|does|did|can|could|would|will|should|is|are|am|may|shall)\b/i;
const USER_DIRECTED_PROMPT_RE = /\b(?:you|your|we|our)\b/i;
const USER_RESPONSE_CUE_RE =
/^(?:please\s+)?(?:confirm|reply|choose|pick|decide|advise)\b|^(?:please\s+)?answer\b|^(?:please\s+)?(?:let\s+me\s+know|tell\s+me)\b/i;
interface PromptLine {
text: string;
hadPromptLabel: boolean;
}
/** Capabilities the todo tracker borrows from its owning session. */
export interface TodoTrackerHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
model(): Model | undefined;
agentKind(): "main" | "sub";
emitSessionEvent(event: AgentSessionEvent): Promise<void>;
scheduleAgentContinue(options: { generation?: number }): void;
promptGeneration(): number;
hasPendingAsyncWake(): boolean;
getActiveToolNames(): string[];
toolRegistry(): Map<string, AgentTool>;
planModeEnabled(): boolean;
consumeLastServedToolChoiceLabel(): string | undefined;
}
/** Owns canonical todo state, eager preludes, and completion reminders. */
export class TodoTracker {
readonly #host: TodoTrackerHost;
#phases: TodoPhase[] = [];
#reminderCount = 0;
#reminderAwaitingProgress = false;
#mutationsSinceLastTouch = 0;
#midRunNudgeCount = 0;
constructor(host: TodoTrackerHost) {
this.#host = host;
}
/** Returns a defensive clone of the current todo phases. */
get phases(): TodoPhase[] {
return this.#clonePhases(this.#phases);
}
/** Replaces todo phases with a defensive clone. */
setPhases(phases: TodoPhase[]): void {
this.#phases = this.#clonePhases(phases);
}
/** Rehydrates todo phases from the current transcript branch. */
syncFromBranch(): void {
this.setPhases(getLatestTodoPhasesFromEntries(this.#host.sessionManager.getBranch()));
}
/** Returns a defensive clone suitable for snapshots and branch state. */
clonePhases(phases: TodoPhase[]): TodoPhase[] {
return this.#clonePhases(phases);
}
/** Resets per-prompt reminder and mutation budgets. */
resetCycle(): void {
this.#reminderCount = 0;
this.#reminderAwaitingProgress = false;
this.#mutationsSinceLastTouch = 0;
this.#midRunNudgeCount = 0;
}
/** Records a completed tool result before asynchronous event processing begins. */
onToolResult(toolName: string, isError: boolean): void {
if (toolName === "todo") {
this.#mutationsSinceLastTouch = 0;
} else if (!isError && MUTATING_TOOLS[toolName]) {
this.#mutationsSinceLastTouch++;
}
this.#reminderAwaitingProgress = false;
}
/** Detects whether a successful todo result came from an init operation. */
onTodoResultDetails(details: Record<string, unknown>, toolCallId: string | undefined): boolean {
const phases = details.phases;
if (!Array.isArray(phases) || !phases.every(isTodoPhase)) return false;
const detailOp = stringProperty(details, "op");
if (detailOp) return detailOp === "init";
if (!toolCallId) return false;
for (let index = this.#host.agent.state.messages.length - 1; index >= 0; index--) {
const message = this.#host.agent.state.messages[index];
if (!message) continue;
const op = toolCallOpFromMessage(message, toolCallId);
if (op) return op === "init";
}
return false;
}
/** Builds the first-turn eager todo prelude and optional forced tool choice. */
createEagerTodoPrelude(
promptText: string | undefined,
): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
const mode = this.#host.settings.get("todo.eager");
if (mode === "default" || !this.#host.settings.get("todo.enabled")) return undefined;
if (this.#host.planModeEnabled() || this.#phases.length > 0) return undefined;
if (promptText !== undefined) {
if (this.#host.agent.state.messages.some(message => message.role === "user")) return undefined;
const trimmedPromptText = promptText.trimEnd();
if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) return undefined;
}
const activeToolNames = this.#host.getActiveToolNames();
if (!activeToolNames.includes("todo")) {
logger.warn("Eager todo enforcement skipped because todo is not active", { activeToolNames });
return undefined;
}
const message: AgentMessage = {
role: "custom",
customType: "eager-todo-prelude",
content: prompt.render(eagerTodoPrompt, { ...this.#buildEagerPreludeContext(), forced: mode === "always" }),
display: false,
attribution: "agent",
timestamp: Date.now(),
};
if (promptText === undefined || mode === "preferred") return { message };
const model = this.#host.model();
const toolChoice = buildNamedToolChoice("todo", model);
if (!toolChoice) {
logger.warn(
"Eager todo proceeding with the reminder only because the current model does not support a forced todo tool_choice",
{ modelApi: model?.api, modelId: model?.id },
);
return { message };
}
return { message, toolChoice };
}
/** Builds the first-turn eager task-delegation prelude. */
createEagerTaskPrelude(promptText: string | undefined): AgentMessage | undefined {
if (this.#host.settings.get("task.eager") !== "always") return undefined;
if (this.#host.agentKind() === "sub" || this.#host.planModeEnabled()) return undefined;
if (promptText !== undefined) {
if (this.#host.agent.state.messages.some(message => message.role === "user")) return undefined;
const trimmed = promptText.trimEnd();
if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
}
if (!this.#host.getActiveToolNames().includes("task")) return undefined;
return {
role: "custom",
customType: "eager-task-prelude",
content: prompt.render(eagerTaskPrompt, this.#buildEagerPreludeContext()),
display: false,
attribution: "agent",
timestamp: Date.now(),
};
}
/** Builds reminder-only eager preludes after compaction. */
buildPostCompactionEagerNudges(): AgentMessage[] {
const nudges: AgentMessage[] = [];
const todo = this.createEagerTodoPrelude(undefined);
if (todo) nudges.push(todo.message);
const task = this.createEagerTaskPrelude(undefined);
if (task) nudges.push(task);
return nudges;
}
/** Checks a terminal assistant turn and schedules continuation for incomplete todos. */
async checkCompletion(message: AssistantMessage): Promise<boolean> {
if (this.#host.consumeLastServedToolChoiceLabel() === "user-force") return false;
if (this.#host.planModeEnabled()) return false;
if (this.#reminderAwaitingProgress) {
logger.debug("Todo completion: prior reminder still awaiting agent action; staying silent", {
attempt: this.#reminderCount,
});
return false;
}
if (!this.#host.settings.get("todo.reminders") || !this.#host.settings.get("todo.enabled")) {
this.#reminderCount = 0;
this.#reminderAwaitingProgress = false;
return false;
}
const remindersMax = this.#host.settings.get("todo.remindersMax");
if (this.#reminderCount >= remindersMax) {
logger.debug("Todo completion: max reminders reached", { count: this.#reminderCount });
return false;
}
const phases = this.phases;
if (phases.length === 0) {
this.#reminderCount = 0;
this.#reminderAwaitingProgress = false;
return false;
}
const incompleteByPhase = phases
.map(phase => ({
name: phase.name,
tasks: phase.tasks
.filter(
(task): task is TodoItem & { status: "pending" | "in_progress" } =>
task.status === "pending" || task.status === "in_progress",
)
.map(task => ({ content: task.content, status: task.status })),
}))
.filter(phase => phase.tasks.length > 0);
const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
if (incomplete.length === 0) {
this.#reminderCount = 0;
this.#reminderAwaitingProgress = false;
return false;
}
if (isAwaitingUserAnswer(message)) {
logger.debug("Todo completion: assistant is waiting for user input; skipping reminder", {
incomplete: incomplete.length,
});
return false;
}
if (this.#host.hasPendingAsyncWake()) {
logger.debug("Todo completion: async jobs in flight will re-wake the loop; skipping reminder", {
incomplete: incomplete.length,
});
return false;
}
this.#reminderCount++;
const todoList = incompleteByPhase
.map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`)
.join("\n");
const reminder =
`<system-reminder>\n` +
`You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
`Please continue working on these tasks or mark them complete if finished.\n` +
`(Reminder ${this.#reminderCount}/${remindersMax})\n` +
`</system-reminder>`;
logger.debug("Todo completion: sending reminder", {
incomplete: incomplete.length,
attempt: this.#reminderCount,
});
await this.#host.emitSessionEvent({
type: "todo_reminder",
todos: incomplete,
attempt: this.#reminderCount,
maxAttempts: remindersMax,
});
const reminderMessage: Message = {
role: "developer",
content: [{ type: "text", text: reminder }],
attribution: "agent",
timestamp: Date.now(),
};
this.#mutationsSinceLastTouch = 0;
this.#reminderAwaitingProgress = true;
this.#host.agent.appendMessage(reminderMessage);
this.#host.sessionManager.appendMessage(reminderMessage);
this.#host.scheduleAgentContinue({ generation: this.#host.promptGeneration() });
return true;
}
/** Takes the next hidden mid-run reconciliation nudge, if its budget and guards allow. */
takeMidRunNudge(): AgentMessage | null {
if (this.#mutationsSinceLastTouch < MID_RUN_NUDGE_MUTATION_THRESHOLD) return null;
if (this.#midRunNudgeCount >= MID_RUN_NUDGE_MAX_PER_CYCLE) return null;
if (!this.#host.settings.get("todo.enabled") || !this.#host.settings.get("todo.reminders")) return null;
if (this.#host.planModeEnabled() || !this.#host.getActiveToolNames().includes("todo")) return null;
const incomplete = this.#phases
.flatMap(phase => phase.tasks)
.filter(task => task.status === "pending" || task.status === "in_progress");
if (incomplete.length === 0) return null;
this.#mutationsSinceLastTouch = 0;
this.#midRunNudgeCount++;
const { toolRefs } = this.#buildEagerPreludeContext();
const reminder = prompt.render(midRunTodoNudgePrompt, {
toolRefs,
incompleteCount: incomplete.length,
plural: incomplete.length !== 1,
});
logger.debug("Mid-run todo nudge fired", {
incomplete: incomplete.length,
nudge: this.#midRunNudgeCount,
});
return {
role: "custom",
customType: MID_RUN_NUDGE_MESSAGE_TYPE,
content: reminder,
display: false,
attribution: "agent",
timestamp: Date.now(),
};
}
#buildEagerPreludeContext(): { toolRefs: Record<string, string>; taskBatch: boolean } {
const wireName = (name: string): string => {
const tool = this.#host.toolRegistry().get(name);
return typeof tool?.customWireName === "string" ? tool.customWireName : name;
};
return {
toolRefs: { task: wireName("task"), todo: wireName("todo") },
taskBatch: this.#host.settings.get("task.batch"),
};
}
#clonePhases(phases: TodoPhase[]): TodoPhase[] {
return phases.map(phase => ({
name: phase.name,
tasks: phase.tasks.map(task =>
task.blocker !== undefined
? { content: task.content, status: task.status, blocker: task.blocker }
: { content: task.content, status: task.status },
),
}));
}
}
function toolCallOpFromMessage(message: AgentMessage, toolCallId: string): string | undefined {
if (message.role !== "assistant" || !Array.isArray(message.content)) return undefined;
for (const block of message.content) {
if (!isRecord(block) || block.type !== "toolCall" || block.id !== toolCallId) continue;
return isRecord(block.arguments) ? stringProperty(block.arguments, "op") : undefined;
}
return undefined;
}
function assistantText(message: AssistantMessage): string {
return message.content
.filter((content): content is TextContent => content.type === "text")
.map(content => content.text)
.join("\n")
.trim();
}
function promptLine(line: string): PromptLine {
const withoutMarkdownPrefix = line.trim().replace(MARKDOWN_PROMPT_PREFIX_RE, "").trim();
const withoutPromptLabel = withoutMarkdownPrefix.replace(PROMPT_LABEL_RE, "").trim();
return {
text: withoutPromptLabel,
hadPromptLabel: withoutPromptLabel !== withoutMarkdownPrefix,
};
}
function isQuestionPromptLine(line: string): boolean {
const candidate = promptLine(line);
if (!/[??]\s*$/.test(candidate.text)) return false;
return (
candidate.hadPromptLabel ||
QUESTION_PROMPT_RE.test(candidate.text) ||
USER_DIRECTED_PROMPT_RE.test(candidate.text)
);
}
function isResponseCueLine(line: string): boolean {
const candidate = promptLine(line)
.text.replace(/[.!?。!?]+$/, "")
.trim();
return USER_RESPONSE_CUE_RE.test(candidate);
}
function isAwaitingUserAnswer(message: AssistantMessage): boolean {
const text = assistantText(message);
if (!text) return false;
const lastLine = text.split(/\r?\n/).at(-1)?.trim();
return lastLine !== undefined && (isQuestionPromptLine(lastLine) || isResponseCueLine(lastLine));
}
@@ -0,0 +1,496 @@
import * as os from "node:os";
import * as path from "node:path";
import {
type AfterToolCallContext,
type AfterToolCallResult,
type Agent,
type AgentEvent,
type AgentMessage,
createToolScopedAbortReason,
} from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ToolCall } from "@oh-my-pi/pi-ai";
import { isRecord, prompt, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
import type { Rule } from "../capability/rule";
import type { Settings } from "../config/settings";
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
import type { AgentSessionEvent } from "./agent-session-events";
import type { SessionManager } from "./session-manager";
interface TtsrContinueOptions {
delayMs?: number;
generation?: number;
shouldContinue?: () => boolean;
onSkip?: () => void;
onError?: () => void;
}
/** Capabilities the TTSR coordinator borrows from its owning session. */
export interface TtsrCoordinatorHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
emitSessionEvent(event: AgentSessionEvent): Promise<void>;
schedulePostPromptTask(task: (signal: AbortSignal) => Promise<void>, options?: { delayMs?: number }): void;
scheduleAgentContinue(options: TtsrContinueOptions): void;
promptGeneration(): number;
}
/** Coordinates TTSR stream matching, interruption, injection, and resume gates. */
export class TtsrCoordinator {
readonly #host: TtsrCoordinatorHost;
readonly #manager: TtsrManager | undefined;
#pendingInjections: Rule[] = [];
#perToolInjections = new Map<string, Rule[]>();
#abortPending = false;
#retryToken = 0;
#resumePromise: Promise<void> | undefined;
#resumeResolve: (() => void) | undefined;
constructor(host: TtsrCoordinatorHost, manager: TtsrManager | undefined) {
this.#host = host;
this.#manager = manager;
}
/** Configured TTSR manager, when stream rules are enabled. */
get manager(): TtsrManager | undefined {
return this.#manager;
}
/** Whether a TTSR-triggered stream abort is awaiting its continuation. */
get abortPending(): boolean {
return this.#abortPending;
}
/** Current resume gate awaited by post-prompt recovery. */
get resumeGate(): Promise<void> | undefined {
return this.#resumePromise;
}
/** Resets stream buffers at turn start. */
onTurnStart(): void {
this.#manager?.resetBuffer();
}
/** Advances repeat-after-gap tracking at turn end. */
onTurnEnd(): void {
this.#manager?.incrementMessageCount();
}
/** Checks one streamed message update and reports whether TTSR consumed it by aborting. */
async checkMessageUpdate(event: AgentEvent): Promise<boolean> {
if (event.type !== "message_update" || !this.#manager?.hasRules()) return false;
const assistantEvent = event.assistantMessageEvent;
let matchContext: TtsrMatchContext | undefined;
let streamingToolCall: ToolCall | undefined;
if (assistantEvent.type === "text_delta") {
matchContext = { source: "text" };
} else if (assistantEvent.type === "thinking_delta") {
matchContext = { source: "thinking" };
} else if (assistantEvent.type === "toolcall_delta") {
streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex);
matchContext = this.#getToolMatchContext(streamingToolCall, assistantEvent.contentIndex);
}
if (!matchContext || !("delta" in assistantEvent)) return false;
const targetMessageTimestamp = event.message.role === "assistant" ? event.message.timestamp : undefined;
const matches = this.#checkStream(assistantEvent.delta, matchContext, streamingToolCall);
if (matches.length > 0 && this.#handleMatches(matches, matchContext, targetMessageTimestamp)) return true;
// AST rules use the reconstructed edit/write snapshot and are awaited so
// the manager self-throttles native matching.
if (matchContext.source === "tool" && this.#manager.hasAstRules()) {
const astMatches = await this.#checkAstStream(matchContext, streamingToolCall);
if (astMatches.length > 0 && this.#handleMatches(astMatches, matchContext, targetMessageTimestamp))
return true;
}
return false;
}
/** Settles the previous resume gate and queues any deferred injection. */
onAssistantMessageEnd(message: AssistantMessage): void {
// Gate on abortPending, not stopReason: unrelated aborts have no TTSR continuation.
if (!this.#abortPending) this.resolveResume();
this.#queueDeferredInjectionIfNeeded(message);
}
/** Marks names persisted with a delivered TTSR injection as injected. */
markInjectedFromDetails(details: unknown): void {
if (!details || typeof details !== "object" || Array.isArray(details)) return;
const rules = "rules" in details ? details.rules : undefined;
if (!Array.isArray(rules)) return;
this.#markInjected(rules.filter((ruleName): ruleName is string => typeof ruleName === "string"));
}
/** Folds per-tool reminders into the matched tool's result. */
afterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined {
const rules = this.#perToolInjections.get(ctx.toolCall.id);
if (!rules || rules.length === 0) return undefined;
this.#perToolInjections.delete(ctx.toolCall.id);
const reminder = rules
.map(rule =>
prompt.render(ttsrToolReminderTemplate, {
name: rule.name,
path: this.#displayRulePath(rule.path),
content: rule.content,
}),
)
.join("\n\n");
const ruleNames = rules.map(rule => rule.name.trim()).filter(name => name.length > 0);
if (ruleNames.length > 0) this.#host.sessionManager.appendTtsrInjection(ruleNames);
return { content: [{ type: "text", text: reminder }, ...ctx.result.content] };
}
/** Resolves and clears the current resume gate. */
resolveResume(): void {
if (!this.#resumeResolve) return;
this.#resumeResolve();
this.#resumeResolve = undefined;
this.#resumePromise = undefined;
}
#ensureResumePromise(): void {
if (this.#resumePromise) return;
const { promise, resolve } = Promise.withResolvers<void>();
this.#resumePromise = promise;
this.#resumeResolve = resolve;
}
#formatAbortReason(rules: Rule[]): string {
const label = rules.length === 1 ? "rule" : "rules";
return `TTSR matched ${label}: ${rules.map(rule => rule.name).join(", ")}`;
}
#getInjectionContent(): { content: string; rules: Rule[] } | undefined {
if (this.#pendingInjections.length === 0) return undefined;
const rules = this.#pendingInjections;
const content = rules
.map(rule =>
prompt.render(ttsrInterruptTemplate, {
name: rule.name,
path: this.#displayRulePath(rule.path),
content: rule.content,
}),
)
.join("\n\n");
this.#pendingInjections = [];
return { content, rules };
}
#displayRulePath(rulePath: string): string {
const cwd = this.#host.sessionManager.getCwd();
const cwdRelative = relativePathWithinRoot(cwd, rulePath) ?? this.#displayPathWithinRoot(cwd, rulePath);
if (cwdRelative) return cwdRelative;
const homeRelative = relativePathWithinRoot(os.homedir(), rulePath);
if (homeRelative) return `~/${homeRelative}`;
return rulePath;
}
#displayPathWithinRoot(root: string, candidate: string): string | null {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : null;
}
#addPendingInjections(rules: Rule[]): void {
const seen = new Set(this.#pendingInjections.map(rule => rule.name));
for (const rule of rules) {
if (seen.has(rule.name)) continue;
this.#pendingInjections.push(rule);
seen.add(rule.name);
}
}
#extractToolCallId(matchContext: TtsrMatchContext): string | undefined {
if (matchContext.source !== "tool") return undefined;
const key = matchContext.streamKey;
if (typeof key !== "string" || !key.startsWith("toolcall:")) return undefined;
const id = key.slice("toolcall:".length);
return id.length > 0 ? id : undefined;
}
#addPerToolInjections(toolCallId: string, rules: Rule[]): void {
const bucket = this.#perToolInjections.get(toolCallId) ?? [];
const seen = new Set(bucket.map(rule => rule.name));
const claimedElsewhere = new Set<string>();
for (const [otherId, otherBucket] of this.#perToolInjections) {
if (otherId === toolCallId) continue;
for (const rule of otherBucket) claimedElsewhere.add(rule.name);
}
const newlyAdded: string[] = [];
for (const rule of rules) {
if (seen.has(rule.name) || claimedElsewhere.has(rule.name)) continue;
bucket.push(rule);
seen.add(rule.name);
newlyAdded.push(rule.name);
}
if (bucket.length === 0) return;
this.#perToolInjections.set(toolCallId, bucket);
if (newlyAdded.length > 0) this.#manager?.markInjectedByNames(newlyAdded);
}
#markInjected(ruleNames: string[]): void {
const uniqueRuleNames = Array.from(
new Set(ruleNames.map(ruleName => ruleName.trim()).filter(ruleName => ruleName.length > 0)),
);
if (uniqueRuleNames.length === 0) return;
this.#manager?.markInjectedByNames(uniqueRuleNames);
this.#host.sessionManager.appendTtsrInjection(uniqueRuleNames);
}
#findAssistantIndex(targetTimestamp: number | undefined): number {
const messages = this.#host.agent.state.messages;
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message.role === "assistant" && (targetTimestamp === undefined || message.timestamp === targetTimestamp)) {
return index;
}
}
return -1;
}
#shouldInterrupt(matches: Rule[], matchContext: TtsrMatchContext): boolean {
const globalMode = this.#manager?.getSettings().interruptMode ?? "always";
for (const rule of matches) {
const mode = rule.interruptMode ?? globalMode;
if (mode === "never") continue;
if (mode === "prose-only" && (matchContext.source === "text" || matchContext.source === "thinking")) {
return true;
}
if (mode === "tool-only" && matchContext.source === "tool") return true;
if (mode === "always") return true;
}
return false;
}
#queueDeferredInjectionIfNeeded(message: AssistantMessage): void {
if (message.stopReason === "aborted" || message.stopReason === "error") this.#perToolInjections.clear();
if (this.#abortPending || this.#pendingInjections.length === 0) return;
if (message.stopReason === "aborted" || message.stopReason === "error") {
this.#pendingInjections = [];
return;
}
const injection = this.#getInjectionContent();
if (!injection) return;
this.#host.agent.followUp({
role: "custom",
customType: "ttsr-injection",
content: injection.content,
display: false,
details: { rules: injection.rules.map(rule => rule.name) },
attribution: "agent",
timestamp: Date.now(),
});
this.#ensureResumePromise();
this.#host.scheduleAgentContinue({
delayMs: 1,
generation: this.#host.promptGeneration(),
onSkip: () => this.resolveResume(),
shouldContinue: () => {
if (this.#host.agent.state.isStreaming || !this.#host.agent.hasQueuedMessages()) {
this.resolveResume();
return false;
}
return true;
},
onError: () => this.resolveResume(),
});
}
#getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined {
if (message.role !== "assistant") return undefined;
const content = message.content;
if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) return undefined;
const block = content[contentIndex];
return block && typeof block === "object" && block.type === "toolCall" ? (block as ToolCall) : undefined;
}
#getToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext {
const context: TtsrMatchContext = { source: "tool" };
if (!toolCall) return context;
context.toolName = toolCall.name;
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
context.filePaths = this.#extractToolFilePaths(toolCall);
return context;
}
#extractToolFilePaths(toolCall: ToolCall): string[] | undefined {
const args = toolCall.arguments ?? {};
const tool = this.#resolveTool(toolCall);
const toolPaths = tool?.matcherPaths?.(args);
if (toolPaths && toolPaths.length > 0) {
const normalized = toolPaths.flatMap(filePath => this.#normalizePathCandidates(filePath));
if (normalized.length > 0) return Array.from(new Set(normalized));
}
return this.#extractFilePathsFromArgs(args);
}
#checkStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] {
if (!this.#manager) return [];
const entries = this.#resolveMatcherEntries(toolCall);
if (entries) {
const matches: Rule[] = [];
for (const entry of entries) {
matches.push(...this.#manager.checkSnapshot(entry.digest, this.#perFileContext(matchContext, entry.path)));
}
return matches;
}
const digest = this.#resolveMatcherDigest(toolCall);
return digest !== undefined
? this.#manager.checkSnapshot(digest, matchContext)
: this.#manager.checkDelta(delta, matchContext);
}
#resolveMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
const tool = this.#resolveTool(toolCall);
return tool?.matcherDigest?.(toolCall?.arguments ?? {});
}
#resolveMatcherEntries(toolCall: ToolCall | undefined): readonly { path: string; digest: string }[] | undefined {
const tool = this.#resolveTool(toolCall);
const entries = tool?.matcherEntries?.(toolCall?.arguments ?? {});
return entries && entries.length > 0 ? entries : undefined;
}
#resolveTool(toolCall: ToolCall | undefined) {
if (!toolCall) return undefined;
const tools = this.#host.agent.state.tools;
return (
tools.find(tool => tool.name === toolCall.name) ??
tools.find(tool => tool.customWireName !== undefined && tool.customWireName === toolCall.name)
);
}
#perFileContext(base: TtsrMatchContext, filePath: string): TtsrMatchContext {
const filePaths = this.#normalizePathCandidates(filePath);
return {
...base,
filePaths: filePaths.length > 0 ? filePaths : [filePath],
streamKey: base.streamKey ? `${base.streamKey}#${filePath}` : undefined,
};
}
async #checkAstStream(matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Promise<Rule[]> {
if (!this.#manager) return [];
const entries = this.#resolveMatcherEntries(toolCall);
if (entries) {
const matches: Rule[] = [];
for (const entry of entries) {
matches.push(
...(await this.#manager.checkAstSnapshot(entry.digest, this.#perFileContext(matchContext, entry.path))),
);
}
return matches;
}
const digest = this.#resolveMatcherDigest(toolCall);
return digest === undefined ? [] : this.#manager.checkAstSnapshot(digest, matchContext);
}
#handleMatches(matches: Rule[], matchContext: TtsrMatchContext, targetTimestamp: number | undefined): boolean {
const shouldInterrupt = this.#shouldInterrupt(matches, matchContext);
const matchedToolId = this.#extractToolCallId(matchContext);
const perToolId = shouldInterrupt ? undefined : matchedToolId;
if (perToolId) {
this.#addPerToolInjections(perToolId, matches);
this.#host.emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
return false;
}
this.#addPendingInjections(matches);
if (!shouldInterrupt) return false;
this.#abortPending = true;
this.#ensureResumePromise();
const abortReason = this.#formatAbortReason(matches);
this.#host.agent.abort(
matchedToolId
? createToolScopedAbortReason(
abortReason,
{ [matchedToolId]: abortReason },
"TTSR interrupt on another tool call",
)
: abortReason,
);
this.#host.emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
const retryToken = ++this.#retryToken;
const generation = this.#host.promptGeneration();
this.#host.schedulePostPromptTask(
async () => {
if (this.#retryToken !== retryToken) {
this.resolveResume();
return;
}
const targetAssistantIndex = this.#findAssistantIndex(targetTimestamp);
if (!this.#abortPending || this.#host.promptGeneration() !== generation || targetAssistantIndex === -1) {
this.#abortPending = false;
this.#pendingInjections = [];
this.#perToolInjections.clear();
this.resolveResume();
return;
}
this.#abortPending = false;
this.#perToolInjections.clear();
if (this.#manager?.getSettings().contextMode === "discard") {
this.#host.agent.replaceMessages(this.#host.agent.state.messages.slice(0, targetAssistantIndex));
}
const injection = this.#getInjectionContent();
if (injection) {
const details = { rules: injection.rules.map(rule => rule.name) };
this.#host.agent.appendMessage({
role: "custom",
customType: "ttsr-injection",
content: injection.content,
display: false,
details,
attribution: "agent",
timestamp: Date.now(),
});
this.#host.sessionManager.appendCustomMessageEntry(
"ttsr-injection",
injection.content,
false,
details,
"agent",
);
this.#markInjected(details.rules);
}
try {
await this.#host.agent.continue();
} catch {
this.resolveResume();
}
},
{ delayMs: 50 },
);
return true;
}
#extractFilePathsFromArgs(args: unknown): string[] | undefined {
if (!isRecord(args)) return undefined;
const rawPaths: string[] = [];
for (const key in args) {
const value = args[key];
const normalizedKey = key.toLowerCase();
if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
rawPaths.push(value);
continue;
}
if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
for (const candidate of value) if (typeof candidate === "string") rawPaths.push(candidate);
}
}
const normalizedPaths = rawPaths.flatMap(filePath => this.#normalizePathCandidates(filePath));
return normalizedPaths.length === 0 ? undefined : Array.from(new Set(normalizedPaths));
}
#normalizePathCandidates(rawPath: string): string[] {
const trimmed = rawPath.trim();
if (trimmed.length === 0) return [];
const normalizedInput = trimmed.replaceAll("\\", "/");
const candidates = new Set<string>([normalizedInput]);
if (normalizedInput.startsWith("./")) candidates.add(normalizedInput.slice(2));
const cwd = this.#host.sessionManager.getCwd();
const absolutePath = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(cwd, trimmed);
candidates.add(absolutePath.replaceAll("\\", "/"));
const relative = path.relative(cwd, absolutePath).replaceAll("\\", "/");
if (relative && relative !== "." && !relative.startsWith("../") && relative !== "..") candidates.add(relative);
return Array.from(candidates);
}
}
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
import type { ToolExample } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { prompt } from "@oh-my-pi/pi-utils";
import { isRecord, prompt } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import chalk from "chalk";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -34,6 +34,21 @@ export interface TodoPhase {
tasks: TodoItem[];
}
/** Whether an unknown value is a persisted todo phase. */
export function isTodoPhase(value: unknown): value is TodoPhase {
if (!isRecord(value) || typeof value.name !== "string" || !Array.isArray(value.tasks)) return false;
return value.tasks.every(
task =>
isRecord(task) &&
typeof task.content === "string" &&
(task.status === "pending" ||
task.status === "in_progress" ||
task.status === "completed" ||
task.status === "abandoned" ||
task.status === "blocked"),
);
}
export interface TodoCompletionTransition {
phase: string;
content: string;
+6
View File
@@ -2,6 +2,12 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/** Reads an own string-valued property without invoking accessors. */
export function stringProperty(value: object, key: string): string | undefined {
const field = Object.getOwnPropertyDescriptor(value, key)?.value;
return typeof field === "string" ? field : undefined;
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}