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:
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user