From e1aaf78874b1211e2831387c2e54f39ec7b98d46 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 15 May 2026 18:30:50 +0200 Subject: [PATCH] refactor(compaction): moved compaction APIs to @oh-my-pi/pi-agent-core - Relocated compaction, branch-summarization, pruning, and utils from coding-agent to packages/agent/src/compaction. - Moved OpenAI remote compaction helpers from packages/ai to the new compaction module. - Added handoff.ts with extractHandoffDocument, createHandoffContext, and renderHandoffPrompt helpers. - Exposed new entries.ts with standalone SessionEntry types so coding-agent no longer owns them. --- docs/compaction.md | 25 ++- docs/handoff-generation-pipeline.md | 7 +- docs/tools/rewind.md | 2 +- packages/agent/CHANGELOG.md | 1 + packages/agent/package.json | 8 + packages/agent/src/compaction.ts | 1 + .../src}/compaction/branch-summarization.ts | 23 +- .../src}/compaction/compaction.ts | 82 ++++--- packages/agent/src/compaction/entries.ts | 133 +++++++++++ .../src}/compaction/errors.ts | 0 packages/agent/src/compaction/handoff.ts | 44 ++++ .../session => agent/src}/compaction/index.ts | 5 + packages/agent/src/compaction/messages.ts | 212 ++++++++++++++++++ .../src/compaction/openai.ts} | 19 +- .../prompts}/auto-handoff-threshold-focus.md | 0 .../prompts}/branch-summary-context.md | 0 .../prompts}/branch-summary-preamble.md | 0 .../src/compaction/prompts}/branch-summary.md | 0 .../prompts}/compaction-short-summary.md | 0 .../prompts}/compaction-summary-context.md | 0 .../compaction/prompts}/compaction-summary.md | 0 .../prompts}/compaction-turn-prefix.md | 0 .../prompts}/compaction-update-summary.md | 0 .../compaction/prompts}/file-operations.md | 0 .../compaction/prompts}/handoff-document.md | 0 .../prompts}/summarization-system.md | 0 .../src}/compaction/pruning.ts | 5 +- .../session => agent/src}/compaction/utils.ts | 7 +- packages/agent/src/index.ts | 2 + packages/agent/test/handoff.test.ts | 60 +++++ .../test/remote-compaction.test.ts | 44 +++- packages/ai/CHANGELOG.md | 5 - packages/ai/src/index.ts | 1 - packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/package.json | 8 - .../src/extensibility/custom-tools/types.ts | 2 +- .../src/extensibility/extensions/types.ts | 2 +- .../src/extensibility/shared-events.ts | 2 +- packages/coding-agent/src/index.ts | 2 - .../modes/controllers/command-controller.ts | 2 +- .../src/modes/controllers/event-controller.ts | 2 +- .../src/modes/interactive-mode.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-client.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 2 +- packages/coding-agent/src/modes/types.ts | 2 +- .../src/modes/utils/context-usage.ts | 4 +- .../coding-agent/src/session/agent-session.ts | 81 +++---- packages/coding-agent/src/session/messages.ts | 67 ++---- .../test/compaction-serialization.test.ts | 2 +- packages/coding-agent/test/compaction.test.ts | 13 +- ...issue-986-compaction-auth-fallback.test.ts | 2 +- 51 files changed, 663 insertions(+), 222 deletions(-) create mode 100644 packages/agent/src/compaction.ts rename packages/{coding-agent/src/session => agent/src}/compaction/branch-summarization.ts (94%) rename packages/{coding-agent/src/session => agent/src}/compaction/compaction.ts (93%) create mode 100644 packages/agent/src/compaction/entries.ts rename packages/{coding-agent/src/session => agent/src}/compaction/errors.ts (100%) create mode 100644 packages/agent/src/compaction/handoff.ts rename packages/{coding-agent/src/session => agent/src}/compaction/index.ts (56%) create mode 100644 packages/agent/src/compaction/messages.ts rename packages/{ai/src/remote-compaction.ts => agent/src/compaction/openai.ts} (96%) rename packages/{coding-agent/src/prompts/system => agent/src/compaction/prompts}/auto-handoff-threshold-focus.md (100%) rename packages/{coding-agent/src/prompts/compaction => agent/src/compaction/prompts}/branch-summary-context.md (100%) rename packages/{coding-agent/src/prompts/compaction => agent/src/compaction/prompts}/branch-summary-preamble.md (100%) rename packages/{coding-agent/src/prompts/compaction => agent/src/compaction/prompts}/branch-summary.md (100%) rename packages/{coding-agent/src/prompts/compaction => agent/src/compaction/prompts}/compaction-short-summary.md (100%) rename packages/{coding-agent/src/prompts/compaction => agent/src/compaction/prompts}/compaction-summary-context.md (100%) rename packages/{coding-agent/src/prompts/compaction => agent/src/compaction/prompts}/compaction-summary.md (100%) rename packages/{coding-agent/src/prompts/compaction => agent/src/compaction/prompts}/compaction-turn-prefix.md (100%) rename packages/{coding-agent/src/prompts/compaction => agent/src/compaction/prompts}/compaction-update-summary.md (100%) rename packages/{coding-agent/src/prompts/system => agent/src/compaction/prompts}/file-operations.md (100%) rename packages/{coding-agent/src/prompts/system => agent/src/compaction/prompts}/handoff-document.md (100%) rename packages/{coding-agent/src/prompts/system => agent/src/compaction/prompts}/summarization-system.md (100%) rename packages/{coding-agent/src/session => agent/src}/compaction/pruning.ts (94%) rename packages/{coding-agent/src/session => agent/src}/compaction/utils.ts (95%) create mode 100644 packages/agent/test/handoff.test.ts rename packages/{ai => agent}/test/remote-compaction.test.ts (73%) diff --git a/docs/compaction.md b/docs/compaction.md index 430a585a4..35fb41db6 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -9,15 +9,17 @@ Both are persisted as session entries and converted back into user-context messa ## Key implementation files -- `src/session/compaction/compaction.ts` -- `src/session/compaction/branch-summarization.ts` -- `src/session/compaction/pruning.ts` -- `src/session/compaction/utils.ts` -- `src/session/session-manager.ts` -- `src/session/agent-session.ts` -- `src/session/messages.ts` -- `src/extensibility/hooks/types.ts` -- `src/config/settings-schema.ts` +- `packages/agent/src/compaction/compaction.ts` +- `packages/agent/src/compaction/branch-summarization.ts` +- `packages/agent/src/compaction/pruning.ts` +- `packages/agent/src/compaction/utils.ts` +- `packages/agent/src/compaction/openai.ts` +- `packages/agent/src/compaction/handoff.ts` +- `packages/coding-agent/src/session/session-manager.ts` +- `packages/coding-agent/src/session/agent-session.ts` +- `packages/coding-agent/src/session/messages.ts` +- `packages/coding-agent/src/extensibility/hooks/types.ts` +- `packages/coding-agent/src/config/settings-schema.ts` ## Session entry model @@ -44,8 +46,9 @@ When context is rebuilt (`buildSessionContext`): Those custom roles are then transformed into LLM-facing user messages in `convertToLlm()` using the static templates: -- `prompts/compaction/compaction-summary-context.md` -- `prompts/compaction/branch-summary-context.md` +- `packages/agent/src/compaction/prompts/compaction-summary-context.md` +- `packages/agent/src/compaction/prompts/branch-summary-context.md` +- `packages/agent/src/compaction/prompts/handoff-document.md` ## Compaction pipeline diff --git a/docs/handoff-generation-pipeline.md b/docs/handoff-generation-pipeline.md index 00c0e7f33..0d93c1663 100644 --- a/docs/handoff-generation-pipeline.md +++ b/docs/handoff-generation-pipeline.md @@ -22,6 +22,7 @@ Does not cover: - [`../src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts) - [`../src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts) - [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts) +- [`packages/agent/src/compaction/handoff.ts`](../packages/agent/src/compaction/handoff.ts) - [`../src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts) - [`../src/extensibility/slash-commands.ts`](../packages/coding-agent/src/extensibility/slash-commands.ts) @@ -45,7 +46,7 @@ The same minimum-content guard exists again inside `AgentSession.handoff()` and - Reads current branch entries (`sessionManager.getBranch()`) - Validates minimum message count (`>= 2`) - Creates `#handoffAbortController` -- Renders the fixed prompt template `prompts/system/handoff-document.md` with optional `additionalFocus` +- Renders the fixed prompt template `packages/agent/src/compaction/prompts/handoff-document.md` via `renderHandoffPrompt(...)` with optional `additionalFocus` - Appends `Additional focus: ...` if custom instructions are provided Prompt is sent as an agent-authored developer message via: @@ -67,7 +68,7 @@ Because handoff bypasses `prompt(...)`, normal slash/prompt-template expansion i Before sending prompt, `handoff()` subscribes to session events and waits for `agent_end`. -On `agent_end`, it extracts handoff text from agent state by scanning backward for the most recent `assistant` message, then concatenating all `content` blocks where `type === "text"` with `\n`. +On `agent_end`, it calls `extractHandoffDocument(...)`, which scans agent state backward for the most recent `assistant` message, then concatenates all `content` blocks where `type === "text"` with `\n`. Important extraction assumptions: @@ -100,7 +101,7 @@ If text was captured and not aborted: ### 5) Handoff-context injection -The generated handoff document is wrapped and appended to the new session as a `custom_message` entry: +The generated handoff document is wrapped by `createHandoffContext(...)` and appended to the new session as a `custom_message` entry: ```text diff --git a/docs/tools/rewind.md b/docs/tools/rewind.md index 5ffa90945..4b96da236 100644 --- a/docs/tools/rewind.md +++ b/docs/tools/rewind.md @@ -59,7 +59,7 @@ The returned tool result is not the final rewind. `AgentSession` waits until `tu - Session files are named `_.jsonl` in the session directory; default directory selection is documented in `SessionManager.create()` as `~/.omp/agent/sessions//` when no override is passed. - User-visible prompts / interactive UI - The tool result itself is visible. - - The persisted `branch_summary` becomes an LLM-visible `branchSummary` message when context is rebuilt from `SessionManager.buildSessionContext()`; `messages.ts` renders it as a user-role text message using `prompts/compaction/branch-summary-context.md`. + - The persisted `branch_summary` becomes an LLM-visible `branchSummary` message when context is rebuilt from `SessionManager.buildSessionContext()`; `messages.ts` renders it as a user-role text message using `packages/agent/src/compaction/prompts/branch-summary-context.md`. - The persisted `rewind-report` custom message also participates in rebuilt LLM context because `custom_message` entries are converted through `createCustomMessage()`. - Background work / cancellation - Rewind application is deferred to `turn_end`. There is no separate job object or cancel handle. diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 6a9baeb5f..98329c6ae 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -23,6 +23,7 @@ - Added `AgentTelemetryConfig` hooks (`onSpanStart`, `onSpanEnd`, `costEstimator`), `agent` identity, `attributes` envelope merged onto every span, `captureMessageContent` toggle (defaults to the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` env var) emitting OTEL-shaped `gen_ai.input.messages` / `gen_ai.output.messages` / `gen_ai.system_instructions` / `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result`, and tracer/tracerName override surfaces. - Added `Agent#setTelemetry(config)` so consumers can swap or disable instrumentation between invocations. - Added `@opentelemetry/api` as a runtime dependency; SDK setup (exporters, samplers, processors) remains the host's responsibility per standard OTEL conventions. When no SDK is registered, helpers fall through to no-op spans with zero overhead. +- Added compaction APIs under `@oh-my-pi/pi-agent-core/compaction`, including context compaction, branch summarization, handoff prompt/context helpers, pruning, token budgeting, prompt templates, and OpenAI `/responses/compact` helpers. ### Changed diff --git a/packages/agent/package.json b/packages/agent/package.json index 46ce56fb2..a3047dcf8 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -58,6 +58,14 @@ "types": "./src/index.ts", "import": "./src/index.ts" }, + "./compaction": { + "types": "./src/compaction.ts", + "import": "./src/compaction.ts" + }, + "./compaction/*": { + "types": "./src/compaction/*.ts", + "import": "./src/compaction/*.ts" + }, "./*": { "types": "./src/*.ts", "import": "./src/*.ts" diff --git a/packages/agent/src/compaction.ts b/packages/agent/src/compaction.ts new file mode 100644 index 000000000..22005bf70 --- /dev/null +++ b/packages/agent/src/compaction.ts @@ -0,0 +1 @@ +export * from "./compaction/index"; diff --git a/packages/coding-agent/src/session/compaction/branch-summarization.ts b/packages/agent/src/compaction/branch-summarization.ts similarity index 94% rename from packages/coding-agent/src/session/compaction/branch-summarization.ts rename to packages/agent/src/compaction/branch-summarization.ts index ed40dc64a..6e70c6af6 100644 --- a/packages/coding-agent/src/session/compaction/branch-summarization.ts +++ b/packages/agent/src/compaction/branch-summarization.ts @@ -4,20 +4,22 @@ * When navigating to a different point in the session tree, this generates * a summary of the branch being left so context isn't lost. */ -import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; + import type { Model } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import branchSummaryPrompt from "../../prompts/compaction/branch-summary.md" with { type: "text" }; -import branchSummaryPreamble from "../../prompts/compaction/branch-summary-preamble.md" with { type: "text" }; +import type { AgentMessage } from "../types"; +import { estimateTokens } from "./compaction"; +import type { ReadonlySessionManager, SessionEntry } from "./entries"; import { + type ConvertToLlm, convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, -} from "../../session/messages"; -import type { ReadonlySessionManager, SessionEntry } from "../../session/session-manager"; -import { estimateTokens } from "./compaction"; +} from "./messages"; +import branchSummaryPrompt from "./prompts/branch-summary.md" with { type: "text" }; +import branchSummaryPreamble from "./prompts/branch-summary-preamble.md" with { type: "text" }; import { computeFileLists, createFileOps, @@ -77,6 +79,8 @@ export interface GenerateBranchSummaryOptions { reserveTokens?: number; /** Optional metadata forwarded to the underlying API request (e.g. user_id for session attribution). */ metadata?: Record; + /** Convert app-specific messages before serializing the branch summary prompt. */ + convertToLlm?: ConvertToLlm; } // ============================================================================ @@ -171,6 +175,11 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { case "model_change": case "custom": case "label": + case "service_tier_change": + case "ttsr_injection": + case "mcp_tool_selection": + case "session_init": + case "mode_change": return undefined; } } @@ -274,7 +283,7 @@ export async function generateBranchSummary( // Transform to LLM-compatible messages, then serialize to text // Serialization prevents the model from treating it as a conversation to continue - const llmMessages = convertToLlm(messages); + const llmMessages = (options.convertToLlm ?? convertToLlm)(messages); const conversationText = serializeConversation(llmMessages); // Build prompt diff --git a/packages/coding-agent/src/session/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts similarity index 93% rename from packages/coding-agent/src/session/compaction/compaction.ts rename to packages/agent/src/compaction/compaction.ts index 9cb6b1056..8f9166191 100644 --- a/packages/coding-agent/src/session/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -4,29 +4,32 @@ * Pure functions for compaction logic. The session manager handles I/O, * and after compaction the session is reloaded. */ -import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; + import { type AssistantMessage, - buildOpenAiNativeHistory, completeSimple, Effort, - getPreservedOpenAiRemoteCompactionData, type MessageAttribution, type Model, - requestOpenAiRemoteCompaction, - requestRemoteCompaction, - shouldUseOpenAiRemoteCompaction, type Usage, - withOpenAiRemoteCompactionPreserveData, } from "@oh-my-pi/pi-ai"; import { countTokens } from "@oh-my-pi/pi-natives"; import { logger, prompt } from "@oh-my-pi/pi-utils"; -import compactionShortSummaryPrompt from "../../prompts/compaction/compaction-short-summary.md" with { type: "text" }; -import compactionSummaryPrompt from "../../prompts/compaction/compaction-summary.md" with { type: "text" }; -import compactionTurnPrefixPrompt from "../../prompts/compaction/compaction-turn-prefix.md" with { type: "text" }; -import compactionUpdateSummaryPrompt from "../../prompts/compaction/compaction-update-summary.md" with { type: "text" }; -import { convertToLlm, createBranchSummaryMessage, createCustomMessage } from "../../session/messages"; -import type { CompactionEntry, SessionEntry } from "../../session/session-manager"; +import type { AgentMessage } from "../types"; +import type { CompactionEntry, SessionEntry } from "./entries"; +import { type ConvertToLlm, convertToLlm, createBranchSummaryMessage, createCustomMessage } from "./messages"; +import { + buildOpenAiNativeHistory, + getPreservedOpenAiRemoteCompactionData, + requestOpenAiRemoteCompaction, + requestRemoteCompaction, + shouldUseOpenAiRemoteCompaction, + withOpenAiRemoteCompactionPreserveData, +} from "./openai"; +import compactionShortSummaryPrompt from "./prompts/compaction-short-summary.md" with { type: "text" }; +import compactionSummaryPrompt from "./prompts/compaction-summary.md" with { type: "text" }; +import compactionTurnPrefixPrompt from "./prompts/compaction-turn-prefix.md" with { type: "text" }; +import compactionUpdateSummaryPrompt from "./prompts/compaction-update-summary.md" with { type: "text" }; import { computeFileLists, @@ -247,6 +250,12 @@ const IMAGE_TOKEN_ESTIMATE = 1200; export function estimateTokens(message: AgentMessage): number { const fragments: string[] = []; let extra = 0; + if ((message as { role?: string }).role === "bashExecution") { + const bash = message as { command?: unknown; output?: unknown }; + if (typeof bash.command === "string") fragments.push(bash.command); + if (typeof bash.output === "string") fragments.push(bash.output); + return fragments.length === 0 ? 0 : countTokens(fragments); + } switch (message.role) { case "user": { @@ -291,11 +300,6 @@ export function estimateTokens(message: AgentMessage): number { } break; } - case "bashExecution": { - fragments.push(message.command); - fragments.push(message.output); - break; - } case "branchSummary": case "compactionSummary": { fragments.push(message.summary); @@ -333,7 +337,7 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde const entry = entries[i]; switch (entry.type) { case "message": { - const role = entry.message.role; + const role = entry.message.role as string; switch (role) { case "bashExecution": case "hookMessage": @@ -377,7 +381,7 @@ export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, return i; } if (entry.type === "message") { - const role = entry.message.role; + const role = entry.message.role as string; if (role === "user" || role === "bashExecution") { return i; } @@ -502,6 +506,7 @@ export interface SummaryOptions { remoteInstructions?: string; initiatorOverride?: MessageAttribution; metadata?: Record; + convertToLlm?: ConvertToLlm; } export async function generateSummary( @@ -526,8 +531,8 @@ export async function generateSummary( } // Serialize conversation to text so model doesn't try to continue it - // Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.) - const llmMessages = convertToLlm(currentMessages); + // Convert to LLM messages first (handles custom app messages when caller provides a transformer). + const llmMessages = (options?.convertToLlm ?? convertToLlm)(currentMessages); const conversationText = serializeConversation(llmMessages); // Build the prompt with conversation wrapped in tags @@ -593,7 +598,7 @@ async function generateShortSummary( options?: SummaryOptions, ): Promise { const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens)); - const llmMessages = convertToLlm(recentMessages); + const llmMessages = (options?.convertToLlm ?? convertToLlm)(recentMessages); const conversationText = serializeConversation(llmMessages); let promptText = `\n${conversationText}\n\n\n`; @@ -809,6 +814,7 @@ export async function compact( remoteInstructions: options?.remoteInstructions, initiatorOverride: options?.initiatorOverride, metadata: options?.metadata, + convertToLlm: options?.convertToLlm, }; let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined); @@ -819,7 +825,11 @@ export async function compact( previousRemoteCompaction?.provider === model.provider ? previousRemoteCompaction.replacementHistory : undefined; - const remoteHistory = buildOpenAiNativeHistory(convertToLlm(remoteMessages), model, previousReplacementHistory); + const remoteHistory = buildOpenAiNativeHistory( + (summaryOptions.convertToLlm ?? convertToLlm)(remoteMessages), + model, + previousReplacementHistory, + ); if (remoteHistory.length > 0) { try { const remote = await requestOpenAiRemoteCompaction( @@ -858,15 +868,7 @@ export async function compact( summaryOptions, ) : Promise.resolve("No prior history."), - generateTurnPrefixSummary( - turnPrefixMessages, - model, - settings.reserveTokens, - apiKey, - signal, - summaryOptions.initiatorOverride, - summaryOptions.metadata, - ), + generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal, summaryOptions), ]); // Merge into single summary summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`; @@ -932,12 +934,11 @@ async function generateTurnPrefixSummary( reserveTokens: number, apiKey: string, signal?: AbortSignal, - initiatorOverride?: MessageAttribution, - metadata?: Record, + options?: SummaryOptions, ): Promise { const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix - const llmMessages = convertToLlm(messages); + const llmMessages = (options?.convertToLlm ?? convertToLlm)(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; const summarizationMessages = [ @@ -951,7 +952,14 @@ async function generateTurnPrefixSummary( const response = await completeSimple( model, { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages }, - { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride, metadata }, + { + maxTokens, + signal, + apiKey, + reasoning: Effort.High, + initiatorOverride: options?.initiatorOverride, + metadata: options?.metadata, + }, ); if (response.stopReason === "error") { diff --git a/packages/agent/src/compaction/entries.ts b/packages/agent/src/compaction/entries.ts new file mode 100644 index 000000000..e5f4101f8 --- /dev/null +++ b/packages/agent/src/compaction/entries.ts @@ -0,0 +1,133 @@ +import type { ImageContent, MessageAttribution, ServiceTier, TextContent } from "@oh-my-pi/pi-ai"; +import type { AgentMessage } from "../types"; + +export interface SessionEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface SessionMessageEntry extends SessionEntryBase { + type: "message"; + message: AgentMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionEntryBase { + type: "thinking_level_change"; + thinkingLevel?: string | null; +} + +export interface ModelChangeEntry extends SessionEntryBase { + type: "model_change"; + /** Model in "provider/modelId" format */ + model: string; + /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */ + role?: string; +} + +export interface ServiceTierChangeEntry extends SessionEntryBase { + type: "service_tier_change"; + serviceTier: ServiceTier | null; +} + +export interface CompactionEntry extends SessionEntryBase { + type: "compaction"; + summary: string; + shortSummary?: string; + firstKeptEntryId: string; + tokensBefore: number; + /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + details?: T; + /** Hook-provided data to persist across compaction */ + preserveData?: Record; + /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ + fromExtension?: boolean; +} + +export interface BranchSummaryEntry extends SessionEntryBase { + type: "branch_summary"; + fromId: string; + summary: string; + /** Extension-specific data (not sent to LLM) */ + details?: T; + /** True if generated by an extension, false if pi-generated */ + fromExtension?: boolean; +} + +export interface CustomMessageEntry extends SessionEntryBase { + type: "custom_message"; + customType: string; + content: string | (TextContent | ImageContent)[]; + details?: T; + display: boolean; + /** Who initiated this message for billing/attribution semantics. */ + attribution?: MessageAttribution; +} + +export interface CustomEntry extends SessionEntryBase { + type: "custom"; + customType: string; + data?: T; +} + +export interface LabelEntry extends SessionEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + +export interface TtsrInjectionEntry extends SessionEntryBase { + type: "ttsr_injection"; + /** Names of rules that were injected */ + injectedRules: string[]; +} + +export interface MCPToolSelectionEntry extends SessionEntryBase { + type: "mcp_tool_selection"; + /** MCP tool names selected for visibility in discovery mode. */ + selectedToolNames: string[]; +} + +export interface SessionInitEntry extends SessionEntryBase { + type: "session_init"; + /** Full system prompt sent to the model */ + systemPrompt: string; + /** Initial task/user message */ + task: string; + /** Tools available to the agent */ + tools: string[]; + /** Output schema if structured output was requested */ + outputSchema?: unknown; +} + +export interface ModeChangeEntry extends SessionEntryBase { + type: "mode_change"; + /** Current mode name, or "none" when exiting a mode */ + mode: string; + /** Optional mode-specific data (e.g. plan file path) */ + data?: Record; +} + +export interface CustomCompactionSessionEntries {} + +export type SessionEntry = + | SessionMessageEntry + | ThinkingLevelChangeEntry + | ModelChangeEntry + | ServiceTierChangeEntry + | CompactionEntry + | BranchSummaryEntry + | CustomEntry + | CustomMessageEntry + | LabelEntry + | TtsrInjectionEntry + | MCPToolSelectionEntry + | SessionInitEntry + | ModeChangeEntry + | CustomCompactionSessionEntries[keyof CustomCompactionSessionEntries]; + +export interface ReadonlySessionManager { + getBranch(leafId?: string | null): SessionEntry[]; + getEntry(id: string): SessionEntry | undefined; +} diff --git a/packages/coding-agent/src/session/compaction/errors.ts b/packages/agent/src/compaction/errors.ts similarity index 100% rename from packages/coding-agent/src/session/compaction/errors.ts rename to packages/agent/src/compaction/errors.ts diff --git a/packages/agent/src/compaction/handoff.ts b/packages/agent/src/compaction/handoff.ts new file mode 100644 index 000000000..f40ce61aa --- /dev/null +++ b/packages/agent/src/compaction/handoff.ts @@ -0,0 +1,44 @@ +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; +import type { AgentMessage } from "../types"; +import autoHandoffThresholdFocusPrompt from "./prompts/auto-handoff-threshold-focus.md" with { type: "text" }; +import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "text" }; + +/** Result from a handoff operation. */ +export interface HandoffResult { + document: string; + savedPath?: string; +} + +export interface HandoffOptions { + autoTriggered?: boolean; + signal?: AbortSignal; +} + +export const AUTO_HANDOFF_THRESHOLD_FOCUS = prompt.render(autoHandoffThresholdFocusPrompt); + +export function renderHandoffPrompt(customInstructions?: string): string { + return prompt.render(handoffDocumentPrompt, { + additionalFocus: customInstructions, + }); +} + +export function extractHandoffDocument(messages: AgentMessage[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role !== "assistant") continue; + const content = (message as AssistantMessage).content; + const textParts = content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text); + if (textParts.length > 0) return textParts.join("\n"); + } + return undefined; +} + +export function createHandoffContext(document: string): string { + return `\n${document}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`; +} + +export function createHandoffFileName(date = new Date()): string { + const fileTimestamp = date.toISOString().replace(/[:.]/g, "-"); + return `handoff-${fileTimestamp}.md`; +} diff --git a/packages/coding-agent/src/session/compaction/index.ts b/packages/agent/src/compaction/index.ts similarity index 56% rename from packages/coding-agent/src/session/compaction/index.ts rename to packages/agent/src/compaction/index.ts index ff9b076ea..7ca4f9812 100644 --- a/packages/coding-agent/src/session/compaction/index.ts +++ b/packages/agent/src/compaction/index.ts @@ -4,5 +4,10 @@ export * from "./branch-summarization"; export * from "./compaction"; +export * from "./entries"; export * from "./errors"; +export * from "./handoff"; +export * from "./messages"; +export * from "./openai"; +export * from "./pruning"; export * from "./utils"; diff --git a/packages/agent/src/compaction/messages.ts b/packages/agent/src/compaction/messages.ts new file mode 100644 index 000000000..62d6c7879 --- /dev/null +++ b/packages/agent/src/compaction/messages.ts @@ -0,0 +1,212 @@ +import type { + AssistantMessage, + ImageContent, + Message, + MessageAttribution, + ProviderPayload, + TextContent, + ToolResultMessage, +} from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; +import type { AgentMessage } from "../types"; +import branchSummaryContextPrompt from "./prompts/branch-summary-context.md" with { type: "text" }; +import compactionSummaryContextPrompt from "./prompts/compaction-summary-context.md" with { type: "text" }; + +const COMPACTION_SUMMARY_TEMPLATE = compactionSummaryContextPrompt; +const BRANCH_SUMMARY_TEMPLATE = branchSummaryContextPrompt; + +export interface CustomMessage { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + /** Who initiated this message for billing/attribution semantics. */ + attribution?: MessageAttribution; + timestamp: number; +} + +/** Legacy hook message type (pre-extensions). Kept for session migration. */ +export interface HookMessage { + role: "hookMessage"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + /** Who initiated this message for billing/attribution semantics. */ + attribution?: MessageAttribution; + timestamp: number; +} + +export interface BranchSummaryMessage { + role: "branchSummary"; + summary: string; + fromId: string; + timestamp: number; +} + +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + shortSummary?: string; + tokensBefore: number; + providerPayload?: ProviderPayload; + timestamp: number; +} + +export type CoreCompactionMessage = CustomMessage | HookMessage | BranchSummaryMessage | CompactionSummaryMessage; + +declare module "../types" { + interface CustomAgentMessages { + custom: CustomMessage; + hookMessage: HookMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; + } +} +export type ConvertToLlm = (messages: AgentMessage[]) => Message[]; + +function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] { + if (message.prunedAt === undefined) { + return message.content; + } + const textBlocks = message.content.filter((content): content is TextContent => content.type === "text"); + const text = textBlocks.map(block => block.text).join("") || "[Output truncated]"; + return [{ type: "text", text }]; +} + +export function renderBranchSummaryContext(summary: string): string { + return prompt.render(BRANCH_SUMMARY_TEMPLATE, { summary }); +} + +export function renderCompactionSummaryContext(summary: string): string { + return prompt.render(COMPACTION_SUMMARY_TEMPLATE, { summary }); +} + +export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { + return { + role: "branchSummary", + summary, + fromId, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCompactionSummaryMessage( + summary: string, + tokensBefore: number, + timestamp: string, + shortSummary?: string, + providerPayload?: ProviderPayload, +): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary, + shortSummary, + tokensBefore, + providerPayload, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCustomMessage( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details: unknown | undefined, + timestamp: string, + attribution?: MessageAttribution, +): CustomMessage { + return { + role: "custom", + customType, + content, + display, + details, + attribution, + timestamp: new Date(timestamp).getTime(), + }; +} + +function isCoreCompactionMessage(message: AgentMessage): message is AgentMessage & CoreCompactionMessage { + return ( + message.role === "custom" || + message.role === "hookMessage" || + message.role === "branchSummary" || + message.role === "compactionSummary" + ); +} + +/** + * Default compaction-domain transformer. + * + * Embedders with their own app messages should pass a richer transformer through + * `SummaryOptions.convertToLlm`; this default intentionally preserves only the + * core LLM roles and the compaction messages owned by this package. + */ +export function defaultConvertToLlm(messages: AgentMessage[]): Message[] { + return messages + .map((message): Message | undefined => { + if (isCoreCompactionMessage(message)) { + switch (message.role) { + case "custom": + case "hookMessage": { + const content = + typeof message.content === "string" + ? [{ type: "text" as const, text: message.content }] + : message.content; + return { + role: "user", + content, + attribution: message.attribution, + timestamp: message.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: renderBranchSummaryContext(message.summary), + }, + ], + attribution: "agent", + timestamp: message.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: renderCompactionSummaryContext(message.summary), + }, + ], + attribution: "agent", + providerPayload: message.providerPayload, + timestamp: message.timestamp, + }; + } + } + + switch (message.role) { + case "user": + return { ...message, attribution: message.attribution ?? "user" }; + case "developer": + return { ...message, attribution: message.attribution ?? "agent" }; + case "assistant": + return message as AssistantMessage; + case "toolResult": + return { + ...message, + content: getPrunedToolResultContent(message as ToolResultMessage), + attribution: message.attribution ?? "agent", + }; + default: + return undefined; + } + }) + .filter(message => message !== undefined); +} +export const convertToLlm = defaultConvertToLlm; diff --git a/packages/ai/src/remote-compaction.ts b/packages/agent/src/compaction/openai.ts similarity index 96% rename from packages/ai/src/remote-compaction.ts rename to packages/agent/src/compaction/openai.ts index 7786a21dc..55c7d9cd9 100644 --- a/packages/ai/src/remote-compaction.ts +++ b/packages/agent/src/compaction/openai.ts @@ -12,21 +12,21 @@ * with `{ summary, shortSummary? }`. */ -import { logger } from "@oh-my-pi/pi-utils"; import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS, -} from "./providers/openai-codex/constants"; -import { parseTextSignature } from "./providers/openai-responses-shared"; -import { transformMessages } from "./providers/transform-messages"; -import type { AssistantMessage, Message, Model } from "./types"; +} from "@oh-my-pi/pi-ai/providers/openai-codex/constants"; +import { parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; +import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages"; +import type { AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai/types"; import { getOpenAIResponsesHistoryItems, getOpenAIResponsesHistoryPayload, normalizeResponsesToolCallId, -} from "./utils"; +} from "@oh-my-pi/pi-ai/utils"; +import { logger } from "@oh-my-pi/pi-utils"; // ============================================================================ // Public types @@ -200,13 +200,12 @@ function trimOpenAiCompactInput( const trimmed = [...input]; while (trimmed.length > 0 && estimateOpenAiCompactInputTokens(trimmed, instructions) > contextWindow) { const last = trimmed[trimmed.length - 1]; - if (last?.type === "function_call_output") { + if (last?.type === "function_call_output" || last?.type === "custom_tool_call_output") { const callId = typeof last.call_id === "string" ? last.call_id : undefined; + const callType = last.type === "custom_tool_call_output" ? "custom_tool_call" : "function_call"; trimmed.pop(); if (callId) { - const matchingCallIndex = trimmed.findLastIndex( - item => item.type === "function_call" && item.call_id === callId, - ); + const matchingCallIndex = trimmed.findLastIndex(item => item.type === callType && item.call_id === callId); if (matchingCallIndex >= 0) { trimmed.splice(matchingCallIndex, 1); } diff --git a/packages/coding-agent/src/prompts/system/auto-handoff-threshold-focus.md b/packages/agent/src/compaction/prompts/auto-handoff-threshold-focus.md similarity index 100% rename from packages/coding-agent/src/prompts/system/auto-handoff-threshold-focus.md rename to packages/agent/src/compaction/prompts/auto-handoff-threshold-focus.md diff --git a/packages/coding-agent/src/prompts/compaction/branch-summary-context.md b/packages/agent/src/compaction/prompts/branch-summary-context.md similarity index 100% rename from packages/coding-agent/src/prompts/compaction/branch-summary-context.md rename to packages/agent/src/compaction/prompts/branch-summary-context.md diff --git a/packages/coding-agent/src/prompts/compaction/branch-summary-preamble.md b/packages/agent/src/compaction/prompts/branch-summary-preamble.md similarity index 100% rename from packages/coding-agent/src/prompts/compaction/branch-summary-preamble.md rename to packages/agent/src/compaction/prompts/branch-summary-preamble.md diff --git a/packages/coding-agent/src/prompts/compaction/branch-summary.md b/packages/agent/src/compaction/prompts/branch-summary.md similarity index 100% rename from packages/coding-agent/src/prompts/compaction/branch-summary.md rename to packages/agent/src/compaction/prompts/branch-summary.md diff --git a/packages/coding-agent/src/prompts/compaction/compaction-short-summary.md b/packages/agent/src/compaction/prompts/compaction-short-summary.md similarity index 100% rename from packages/coding-agent/src/prompts/compaction/compaction-short-summary.md rename to packages/agent/src/compaction/prompts/compaction-short-summary.md diff --git a/packages/coding-agent/src/prompts/compaction/compaction-summary-context.md b/packages/agent/src/compaction/prompts/compaction-summary-context.md similarity index 100% rename from packages/coding-agent/src/prompts/compaction/compaction-summary-context.md rename to packages/agent/src/compaction/prompts/compaction-summary-context.md diff --git a/packages/coding-agent/src/prompts/compaction/compaction-summary.md b/packages/agent/src/compaction/prompts/compaction-summary.md similarity index 100% rename from packages/coding-agent/src/prompts/compaction/compaction-summary.md rename to packages/agent/src/compaction/prompts/compaction-summary.md diff --git a/packages/coding-agent/src/prompts/compaction/compaction-turn-prefix.md b/packages/agent/src/compaction/prompts/compaction-turn-prefix.md similarity index 100% rename from packages/coding-agent/src/prompts/compaction/compaction-turn-prefix.md rename to packages/agent/src/compaction/prompts/compaction-turn-prefix.md diff --git a/packages/coding-agent/src/prompts/compaction/compaction-update-summary.md b/packages/agent/src/compaction/prompts/compaction-update-summary.md similarity index 100% rename from packages/coding-agent/src/prompts/compaction/compaction-update-summary.md rename to packages/agent/src/compaction/prompts/compaction-update-summary.md diff --git a/packages/coding-agent/src/prompts/system/file-operations.md b/packages/agent/src/compaction/prompts/file-operations.md similarity index 100% rename from packages/coding-agent/src/prompts/system/file-operations.md rename to packages/agent/src/compaction/prompts/file-operations.md diff --git a/packages/coding-agent/src/prompts/system/handoff-document.md b/packages/agent/src/compaction/prompts/handoff-document.md similarity index 100% rename from packages/coding-agent/src/prompts/system/handoff-document.md rename to packages/agent/src/compaction/prompts/handoff-document.md diff --git a/packages/coding-agent/src/prompts/system/summarization-system.md b/packages/agent/src/compaction/prompts/summarization-system.md similarity index 100% rename from packages/coding-agent/src/prompts/system/summarization-system.md rename to packages/agent/src/compaction/prompts/summarization-system.md diff --git a/packages/coding-agent/src/session/compaction/pruning.ts b/packages/agent/src/compaction/pruning.ts similarity index 94% rename from packages/coding-agent/src/session/compaction/pruning.ts rename to packages/agent/src/compaction/pruning.ts index 76ea24437..7e99cb87e 100644 --- a/packages/coding-agent/src/session/compaction/pruning.ts +++ b/packages/agent/src/compaction/pruning.ts @@ -1,10 +1,11 @@ /** * Tool output pruning utilities for compaction. */ -import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; + import type { ToolResultMessage } from "@oh-my-pi/pi-ai"; -import type { SessionEntry, SessionMessageEntry } from "../session-manager"; +import type { AgentMessage } from "../types"; import { estimateTokens } from "./compaction"; +import type { SessionEntry, SessionMessageEntry } from "./entries"; export interface PruneConfig { /** Keep the most recent tool output tokens intact. */ diff --git a/packages/coding-agent/src/session/compaction/utils.ts b/packages/agent/src/compaction/utils.ts similarity index 95% rename from packages/coding-agent/src/session/compaction/utils.ts rename to packages/agent/src/compaction/utils.ts index 7da8de00b..7375952da 100644 --- a/packages/coding-agent/src/session/compaction/utils.ts +++ b/packages/agent/src/compaction/utils.ts @@ -1,11 +1,12 @@ /** * Shared utilities for compaction and branch summarization. */ -import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; + import type { Message } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import fileOperationsTemplate from "../../prompts/system/file-operations.md" with { type: "text" }; -import summarizationSystemPrompt from "../../prompts/system/summarization-system.md" with { type: "text" }; +import type { AgentMessage } from "../types"; +import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" }; +import summarizationSystemPrompt from "./prompts/summarization-system.md" with { type: "text" }; // ============================================================================ // File Operation Tracking diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 8ee9a8734..6a17b5d88 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,6 +2,8 @@ export * from "./agent"; // Loop functions export * from "./agent-loop"; +// Compaction +export * from "./compaction"; export * from "./harmony-leak"; // Proxy utilities export * from "./proxy"; diff --git a/packages/agent/test/handoff.test.ts b/packages/agent/test/handoff.test.ts new file mode 100644 index 000000000..0add25d8d --- /dev/null +++ b/packages/agent/test/handoff.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { + AUTO_HANDOFF_THRESHOLD_FOCUS, + createHandoffContext, + createHandoffFileName, + extractHandoffDocument, + renderHandoffPrompt, +} from "@oh-my-pi/pi-agent-core/compaction/handoff"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; + +function assistantText(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + timestamp: Date.now(), + provider: "mock", + model: "mock", + api: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + }; +} + +describe("handoff helpers", () => { + test("renders custom focus into the handoff prompt", () => { + const rendered = renderHandoffPrompt("preserve failing test name"); + expect(rendered).toContain("Write a handoff document"); + expect(rendered).toContain("Additional focus: preserve failing test name"); + }); + + test("exports the threshold focus text used by auto-handoff", () => { + expect(AUTO_HANDOFF_THRESHOLD_FOCUS).toBe( + "Threshold-triggered maintenance: preserve critical implementation state and immediate next actions.", + ); + }); + + test("extracts the latest assistant text document", () => { + const document = extractHandoffDocument([ + { role: "user", content: "older", timestamp: 1 }, + assistantText("old handoff"), + { role: "user", content: "newer", timestamp: 2 }, + assistantText("new handoff"), + ]); + expect(document).toBe("new handoff"); + }); + + test("creates the persisted handoff context and filename", () => { + expect(createHandoffContext("## Goal\nContinue")).toBe( + "\n## Goal\nContinue\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.", + ); + expect(createHandoffFileName(new Date("2026-05-15T12:34:56.789Z"))).toBe("handoff-2026-05-15T12-34-56-789Z.md"); + }); +}); diff --git a/packages/ai/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts similarity index 73% rename from packages/ai/test/remote-compaction.test.ts rename to packages/agent/test/remote-compaction.test.ts index 743555d18..811c1b227 100644 --- a/packages/ai/test/remote-compaction.test.ts +++ b/packages/agent/test/remote-compaction.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildOpenAiNativeHistory, requestOpenAiRemoteCompaction } from "@oh-my-pi/pi-ai/remote-compaction"; +import { buildOpenAiNativeHistory, requestOpenAiRemoteCompaction } from "@oh-my-pi/pi-agent-core/compaction/openai"; import type { AssistantMessage, Model, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { hookFetch } from "@oh-my-pi/pi-utils"; @@ -105,21 +105,47 @@ describe("buildOpenAiNativeHistory custom tool calls", () => { }); }); +describe("remote compaction input trimming", () => { + test("trims custom tool outputs with their matching custom calls", async () => { + let requestInput: Array> | undefined; + using _hook = hookFetch(async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { input: Array> }; + requestInput = body.input; + return Response.json({ + output: [{ type: "compaction_summary", summary: "compact" }], + }); + }); + + await requestOpenAiRemoteCompaction( + makeOpenAiModel({ contextWindow: 1 }), + "test-key", + [ + { type: "custom_tool_call", call_id: "call_apply_1", name: "apply_patch", input: "x".repeat(10_000) }, + { type: "custom_tool_call_output", call_id: "call_apply_1", output: "patch applied".repeat(1_000) }, + ], + "compact", + ); + + expect(requestInput?.some(item => item.type === "custom_tool_call")).toBe(false); + expect(requestInput?.some(item => item.type === "custom_tool_call_output")).toBe(false); + }); +}); + describe("requestOpenAiRemoteCompaction abort", () => { test("rejects when the abort signal is aborted mid-fetch", async () => { const controller = new AbortController(); using _hook = hookFetch((_input, init) => { // Honor the provided abort signal: hang until aborted, then reject. const signal = init?.signal as AbortSignal | undefined; - return new Promise((_resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError")); - return; - } - signal?.addEventListener("abort", () => { - reject(signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError")); - }); + const { promise, reject } = Promise.withResolvers(); + if (signal?.aborted) { + reject(signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError")); + return promise; + } + signal?.addEventListener("abort", () => { + reject(signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError")); }); + return promise; }); const promise = requestOpenAiRemoteCompaction( diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e42cde873..d7b9e823c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -14,9 +14,6 @@ - Added `streamMock` and `registerMockApi` so mock responses can be consumed through `stream()` and the global custom API registry without an external model backend - Added async/sync response scripting with optional context-based handlers, and new `push()`/`reset()` controls to drive multi-turn mock interactions and inspect per-call invocation state - Added support in mock responses for simulating tool calls, usage metadata, custom stop reasons, delayed emissions, and terminal error/aborted outcomes -- Added `requestOpenAiRemoteCompaction` and `shouldUseOpenAiRemoteCompaction` to call OpenAI-compatible `/responses/compact` endpoints, with replacement-history and preserve-data handling for remote compaction flows -- Added `buildOpenAiNativeHistory`, `getPreservedOpenAiRemoteCompactionData`, and `withOpenAiRemoteCompactionPreserveData` to construct OpenAI-native compaction payloads and persist compaction context across turns -- Added generic `requestRemoteCompaction` for POSTing `{systemPrompt,prompt}` to self-hosted compaction endpoints and retrieving `{summary, shortSummary}` responses ### Changed @@ -43,8 +40,6 @@ - Fixed recursive `$ref` schemas being treated as universally valid: the validator previously short-circuited on the second occurrence of any ref it had already seen, so nested values violating the referenced sub-schema passed. Cycle detection now keys on (ref, value-identity) pairs with a depth cap for primitive values, so genuine sub-tree violations are still caught. - Fixed JSON Schema meta-validator accepting malformed `if`/`then`/`else` and `dependencies` keywords; each conditional sub-schema is now structurally validated and draft-07 `dependencies` accepts either a schema or a string array of dependent keys. - Fixed Zod-emitted wire schemas dropping null-valued unknown root fields before `preserveUnknownRootFields` could snapshot them, so callers like `task.simple` no longer lose a `schema: null` argument and downstream rejection paths fire as intended. -- Fixed `buildOpenAiNativeHistory` to serialize freeform tool calls (e.g. `apply_patch`) as `custom_tool_call` / `custom_tool_call_output` items matching the live Responses provider, instead of demoting them to `function_call` and breaking remote-compaction replay -- Threaded an `AbortSignal` through `requestOpenAiRemoteCompaction` and `requestRemoteCompaction` so canceled compaction requests no longer keep the underlying `fetch` alive - Fixed mock provider partial `Usage` to recompute `totalTokens` (and `cost.total` when cost components are supplied) when omitted, instead of reporting 0 - Fixed mock provider auto-generated tool-call IDs to use a per-instance counter (now reset by `reset()`), so test order no longer affects IDs across `createMockModel()` instances diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 95e18631c..89088192a 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -23,7 +23,6 @@ export * from "./providers/openai-completions"; export * from "./providers/openai-responses"; export * from "./providers/synthetic"; export * from "./rate-limit-utils"; -export * from "./remote-compaction"; export * from "./stream"; export * from "./types"; export * from "./usage"; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 26dda8382..567385afa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -144,7 +144,7 @@ - Added `pr:///diff`, `pr:///diff/`, and `pr:///diff/all` internal-URL shapes covering changed-file listings, per-file slices, and the full unified diff. They share one `pr-diff` SQLite cache row with the same TTL knobs as `pr://` views (`github.cache.softTtlSec` / `github.cache.hardTtlSec` / `github.cache.enabled`). Single PR views now advertise the diff entry point via a `Diff: pr://///diff` note. Cache schema bumped to `user_version = 3`; older rows are dropped on first open to add credential-scoped keys and relax the `kind` CHECK constraint. - Added `issue://` / `pr://` internal-URL schemes that share a SQLite-backed cache with the rest of the `github` tool. Single-item reads (`issue://`, `issue:////`) return rendered markdown and within `github.cache.softTtlSec` (default 5 minutes) skip the `gh` round-trip entirely; within `github.cache.hardTtlSec` (default 7 days) the cached row is returned and a background refresh is scheduled. Root and repo-scoped reads (`issue://`, `pr://owner/repo`) issue a live `gh issue list` / `gh pr list` for browsing, supporting `?state=open|closed|all` for issues, `?state=open|closed|merged|all` for PRs, and `?limit=`, `?author=`, `?label=` query params. Rendered output lands in `~/.omp/cache/github-cache.db` (override via `OMP_GITHUB_CACHE_DB`); disable the cache entirely with `github.cache.enabled = false`. Cwd→default-repo lookups (`gh repo view`) are memoized per-process. - Added new `Approve and compact context` choice to the ExitPlanMode approval selector. Sits between `Approve and execute` (purge session) and `Approve and keep context` (full transcript) — runs `/compact` on the plan-mode transcript with a planning-specific summarization hint, then dispatches the plan-approved execution turn so it lands on a fresh cache anchor with the summarized rationale carried over. Cancelling the compaction (Esc or any other abort source) defers the execution dispatch and surfaces a warning so the operator can resubmit manually; non-abort failures proceed best-effort. -- Added `CompactionCancelledError` typed sentinel and `CompactionOutcome` (`"ok" | "cancelled" | "failed"`) return type to `@oh-my-pi/pi-coding-agent/session/compaction`. `CommandController.executeCompaction` and `handleCompactCommand` now return the outcome instead of `void` so callers can discriminate user-driven aborts from generic failures without inspecting error messages. +- Added `CompactionCancelledError` typed sentinel and `CompactionOutcome` (`"ok" | "cancelled" | "failed"`) return type to `@oh-my-pi/pi-agent-core/compaction`. `CommandController.executeCompaction` and `handleCompactCommand` now return the outcome instead of `void` so callers can discriminate user-driven aborts from generic failures without inspecting error messages. - Added a `credential_disabled` extension event so extensions can subscribe via `pi.on("credential_disabled", handler)` and react when `AuthStorage` automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Replaces the current `agent_end` errorMessage regex pattern downstream extensions have to match against. Handler payload is `{ type, provider, disabledCause }`. `createAgentSession()` subscribes the per-session extension runner to the shared `AuthStorage` via `authStorage.onCredentialDisabled(...)` at the very top of session creation — before any startup model probes run — so events fire on every disable regardless of whether the embedder also has a constructor `onCredentialDisabled` handler attached. The SDK forwards through `ExtensionRunner.emitCredentialDisabled(event)`, which buffers events until `runner.initialize(...)` runs in the mode controller and then flushes them through `emit()` so extension handlers see populated UI/runtime context (rather than the constructor's no-op default with `hasUI=false`, an unset model, and no-op runtime actions). On `session.dispose()` the subscription is unsubscribed; the embedder's constructor-attached listener keeps firing through its own permanent subscription. The outer `createAgentSession()` catch also releases the subscription if startup throws before the dispose-wrap is wired, so repeated retries don't accumulate dead listeners. - Added `omp acp` subcommand for launching as an ACP (Agent Client Protocol) server over stdio - Added explicit `type` discriminators to ACP `initialize` auth methods, including a `terminal` setup method gated on `clientCapabilities.auth.terminal` diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 9e703ac20..5561e7826 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -471,14 +471,6 @@ "types": "./src/session/*.ts", "import": "./src/session/*.ts" }, - "./session/compaction": { - "types": "./src/session/compaction/index.ts", - "import": "./src/session/compaction/index.ts" - }, - "./session/compaction/*": { - "types": "./src/session/compaction/*.ts", - "import": "./src/session/compaction/*.ts" - }, "./slash-commands/*": { "types": "./src/slash-commands/*.ts", "import": "./src/slash-commands/*.ts" diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts index 001f7901f..92be225d1 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -5,6 +5,7 @@ * They can provide custom rendering for tool calls and results in the TUI. */ import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { Model, Static, TSchema } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import type { Rule } from "../../capability/rule"; @@ -13,7 +14,6 @@ import type { Settings } from "../../config/settings"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { HookUIContext } from "../../extensibility/hooks/types"; import type { Theme } from "../../modes/theme/theme"; -import type { CompactionResult } from "../../session/compaction"; import type { ReadonlySessionManager } from "../../session/session-manager"; import type { TodoItem } from "../../tools/todo-write"; diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 5062601a5..5b1bd9f0c 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -8,6 +8,7 @@ * - Interact with the user via UI primitives */ import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { Api, AssistantMessageEvent, @@ -32,7 +33,6 @@ import type { BashResult } from "../../exec/bash-executor"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { CustomEditor } from "../../modes/components/custom-editor"; import type { Theme } from "../../modes/theme/theme"; -import type { CompactionResult } from "../../session/compaction"; import type { CustomMessage } from "../../session/messages"; import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts index def0612c9..8828065f6 100644 --- a/packages/coding-agent/src/extensibility/shared-events.ts +++ b/packages/coding-agent/src/extensibility/shared-events.ts @@ -13,10 +13,10 @@ * `types.ts` files and is documented there. */ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { CompactionPreparation, CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; import type { Rule } from "../capability/rule"; import type { Goal, GoalModeState } from "../goals/state"; -import type { CompactionPreparation, CompactionResult } from "../session/compaction"; import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-manager"; import type { TodoItem } from "../tools/todo-write"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3641452fa..350d392d7 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -43,8 +43,6 @@ export * from "./sdk"; export * from "./session/agent-session"; // Auth and model registry export * from "./session/auth-storage"; -// Compaction -export * from "./session/compaction"; export * from "./session/messages"; export * from "./session/session-dump-format"; export * from "./session/session-manager"; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index c578452d0..ead2e8746 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { CompactionCancelledError, type CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; import { getEnvApiKey, getProviderDetails, @@ -37,7 +38,6 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown"; import { buildToolsMarkdown } from "../../modes/utils/tools-markdown"; import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import type { AuthStorage } from "../../session/auth-storage"; -import { CompactionCancelledError, type CompactionOutcome } from "../../session/compaction"; import type { NewSessionOptions } from "../../session/session-manager"; import { outputMeta } from "../../tools/output-meta"; import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils"; diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 8b3117287..6e6521318 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1,4 +1,5 @@ import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; +import { calculatePromptTokens } from "@oh-my-pi/pi-agent-core/compaction/compaction"; import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; import { type Component, Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { settings } from "../../config/settings"; @@ -15,7 +16,6 @@ import { getSymbolTheme, theme } from "../../modes/theme/theme"; import type { InteractiveModeContext, TodoPhase } from "../../modes/types"; import type { PlanApprovalDetails } from "../../plan-mode/approved-plan"; import type { AgentSessionEvent } from "../../session/agent-session"; -import { calculatePromptTokens } from "../../session/compaction/compaction"; import { isSilentAbort, readPendingDisplayTag } from "../../session/messages"; import type { ResolveToolDetails } from "../../tools/resolve"; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 9b979f9f8..dc9178f32 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -5,6 +5,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { type Agent, type AgentMessage, type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; import { type AssistantMessage, type ImageContent, @@ -46,7 +47,6 @@ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compa type: "text", }; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; -import type { CompactionOutcome } from "../session/compaction"; import { HistoryStorage } from "../session/history-storage"; import type { SessionContext, SessionManager } from "../session/session-manager"; import { getRecentSessions } from "../session/session-manager"; diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 179f4e0ed..1758a068c 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -4,11 +4,11 @@ * Spawns the agent in RPC mode and provides a typed API for all operations. */ import type { AgentEvent, AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { ImageContent, Model } from "@oh-my-pi/pi-ai"; import { isRecord, ptree, readJsonl } from "@oh-my-pi/pi-utils"; import type { BashResult } from "../../exec/bash-executor"; import type { SessionStats } from "../../session/agent-session"; -import type { CompactionResult } from "../../session/compaction"; import type { RpcCommand, RpcExtensionUIRequest, diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index c074796fd..568611758 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -5,11 +5,11 @@ * Responses and events are emitted as JSON lines on stdout. */ import type { AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai"; import type { BashResult } from "../../exec/bash-executor"; import type { ContextUsage } from "../../extensibility/extensions/types"; import type { SessionStats } from "../../session/agent-session"; -import type { CompactionResult } from "../../session/compaction"; import type { TodoPhase } from "../../tools/todo-write"; // ============================================================================ diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 4869a3c53..9f1cd4c85 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai"; import type { Component, Container, EditorTheme, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui"; import type { KeybindingsManager } from "../config/keybindings"; @@ -13,7 +14,6 @@ import type { CompactOptions } from "../extensibility/extensions/types"; import type { MCPManager } from "../mcp"; import type { PlanApprovalDetails } from "../plan-mode/approved-plan"; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; -import type { CompactionOutcome } from "../session/compaction"; import type { HistoryStorage } from "../session/history-storage"; import type { SessionContext, SessionManager } from "../session/session-manager"; import type { LspStartupServerInfo } from "../tools"; diff --git a/packages/coding-agent/src/modes/utils/context-usage.ts b/packages/coding-agent/src/modes/utils/context-usage.ts index 3e4c96ee4..e9f508e0f 100644 --- a/packages/coding-agent/src/modes/utils/context-usage.ts +++ b/packages/coding-agent/src/modes/utils/context-usage.ts @@ -1,10 +1,10 @@ +import type { CompactionSettings } from "@oh-my-pi/pi-agent-core/compaction"; +import { effectiveReserveTokens, estimateTokens, resolveThresholdTokens } from "@oh-my-pi/pi-agent-core/compaction"; import type { Model } from "@oh-my-pi/pi-ai"; import { countTokens } from "@oh-my-pi/pi-natives"; import { formatNumber } from "@oh-my-pi/pi-utils"; import type { Skill } from "../../extensibility/skills"; import type { AgentSession } from "../../session/agent-session"; -import type { CompactionSettings } from "../../session/compaction"; -import { effectiveReserveTokens, estimateTokens, resolveThresholdTokens } from "../../session/compaction"; import type { Tool } from "../../tools"; import type { theme as Theme } from "../theme/theme"; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 4af745594..d77dbac30 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -26,6 +26,28 @@ import { type AgentTool, ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; +import { + AUTO_HANDOFF_THRESHOLD_FOCUS, + CompactionCancelledError, + type CompactionPreparation, + type CompactionResult, + calculateContextTokens, + calculatePromptTokens, + collectEntriesForBranchSummary, + compact, + createHandoffContext, + createHandoffFileName, + estimateTokens, + extractHandoffDocument, + generateBranchSummary, + type HandoffOptions, + type HandoffResult, + prepareCompaction, + renderHandoffPrompt, + type SummaryOptions, + shouldCompact, +} from "@oh-my-pi/pi-agent-core/compaction"; +import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning"; import type { AssistantMessage, Context, @@ -126,9 +148,7 @@ import { resolveMemoryBackend } from "../memory-backend"; import { getCurrentThemeName, theme } from "../modes/theme/theme"; import type { PlanModeState } from "../plan-mode/state"; import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" }; -import autoHandoffThresholdFocusPrompt from "../prompts/system/auto-handoff-threshold-focus.md" with { type: "text" }; import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" }; -import handoffDocumentPrompt from "../prompts/system/handoff-document.md" with { type: "text" }; import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" }; import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" }; import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" }; @@ -160,21 +180,6 @@ import { extractFileMentions, generateFileMentionMessages } from "../utils/file- import { buildNamedToolChoice } from "../utils/tool-choice"; import type { AuthStorage } from "./auth-storage"; import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge"; -import { - CompactionCancelledError, - type CompactionPreparation, - type CompactionResult, - calculateContextTokens, - calculatePromptTokens, - collectEntriesForBranchSummary, - compact, - estimateTokens, - generateBranchSummary, - prepareCompaction, - type SummaryOptions, - shouldCompact, -} from "./compaction"; -import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "./compaction/pruning"; import { type BashExecutionMessage, type CompactionSummaryMessage, @@ -369,17 +374,6 @@ export interface SessionStats { cost: number; } -/** Result from handoff() */ -export interface HandoffResult { - document: string; - savedPath?: string; -} - -interface HandoffOptions { - autoTriggered?: boolean; - signal?: AbortSignal; -} - /** Internal marker for hook messages queued through the agent loop */ // ============================================================================ // Constants @@ -387,8 +381,6 @@ interface HandoffOptions { /** Standard thinking levels */ -const AUTO_HANDOFF_THRESHOLD_FOCUS = prompt.render(autoHandoffThresholdFocusPrompt); - type RetryFallbackChains = Record; type RetryFallbackRevertPolicy = "never" | "cooldown-expiry"; @@ -4999,6 +4991,7 @@ export class AgentSession { promptOverride: compactionPrep.hookPrompt, extraContext: compactionPrep.hookContext, remoteInstructions: this.#baseSystemPrompt.join("\n\n"), + convertToLlm, }, ); summary = result.summary; @@ -5175,9 +5168,7 @@ export class AgentSession { } // Build the handoff prompt - const handoffPrompt = prompt.render(handoffDocumentPrompt, { - additionalFocus: customInstructions, - }); + const handoffPrompt = renderHandoffPrompt(customInstructions); // Create a promise that resolves when the agent completes let handoffText: string | undefined; @@ -5198,21 +5189,7 @@ export class AgentSession { if (event.type === "agent_end") { unsubscribe?.(); handoffSignal.removeEventListener("abort", onCompletionAbort); - // Extract text from the last assistant message - const messages = this.agent.state.messages; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.role === "assistant") { - const content = (msg as AssistantMessage).content; - const textParts = content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map(c => c.text); - if (textParts.length > 0) { - handoffText = textParts.join("\n"); - break; - } - } - } + handoffText = extractHandoffDocument(this.agent.state.messages); resolveCompletion(); } }); @@ -5261,15 +5238,14 @@ export class AgentSession { this.#todoReminderCount = 0; // Inject the handoff document as a custom message - const handoffContent = `\n${handoffText}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`; + const handoffContent = createHandoffContext(handoffText); this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent"); await this.sessionManager.ensureOnDisk(); let savedPath: string | undefined; if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) { const artifactsDir = this.sessionManager.getArtifactsDir(); if (artifactsDir) { - const fileTimestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const handoffFilePath = path.join(artifactsDir, `handoff-${fileTimestamp}.md`); + const handoffFilePath = path.join(artifactsDir, createHandoffFileName()); try { await Bun.write(handoffFilePath, `${handoffText}\n`); savedPath = handoffFilePath; @@ -5954,6 +5930,7 @@ export class AgentSession { return await compact(preparation, candidate, apiKey, customInstructions, signal, { ...options, metadata: this.agent.metadataForProvider(candidate.provider), + convertToLlm, }); } catch (error) { if (!this.#isCompactionAuthFailure(error)) { @@ -6206,6 +6183,7 @@ export class AgentSession { remoteInstructions: this.#baseSystemPrompt.join("\n\n"), metadata: this.agent.metadataForProvider(candidate.provider), initiatorOverride: "agent", + convertToLlm, }); break; } catch (error) { @@ -7809,6 +7787,7 @@ export class AgentSession { customInstructions: options.customInstructions, reserveTokens: branchSummarySettings.reserveTokens, metadata: this.agent.metadataForProvider(model.provider), + convertToLlm, }); this.#branchSummaryAbortController = undefined; if (result.aborted) { diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 3b1ad1fae..6215d25d3 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -5,24 +5,31 @@ * and provides a transformer to convert them to LLM-compatible messages. */ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { + type BranchSummaryMessage, + type CompactionSummaryMessage, + renderBranchSummaryContext, + renderCompactionSummaryContext, +} from "@oh-my-pi/pi-agent-core/compaction/messages"; import type { AssistantMessage, ImageContent, Message, MessageAttribution, - ProviderPayload, TextContent, ToolResultMessage, } from "@oh-my-pi/pi-ai"; -import { prompt } from "@oh-my-pi/pi-utils"; -import branchSummaryContextPrompt from "../prompts/compaction/branch-summary-context.md" with { type: "text" }; -import compactionSummaryContextPrompt from "../prompts/compaction/compaction-summary-context.md" with { type: "text" }; + +export { + type BranchSummaryMessage, + type CompactionSummaryMessage, + createBranchSummaryMessage, + createCompactionSummaryMessage, +} from "@oh-my-pi/pi-agent-core/compaction/messages"; + import type { OutputMeta } from "../tools/output-meta"; import { formatOutputNotice } from "../tools/output-meta"; -const COMPACTION_SUMMARY_TEMPLATE = compactionSummaryContextPrompt; -const BRANCH_SUMMARY_TEMPLATE = branchSummaryContextPrompt; - export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt"; export interface SkillPromptDetails { @@ -168,22 +175,6 @@ export interface HookMessage { timestamp: number; } -export interface BranchSummaryMessage { - role: "branchSummary"; - summary: string; - fromId: string; - timestamp: number; -} - -export interface CompactionSummaryMessage { - role: "compactionSummary"; - summary: string; - shortSummary?: string; - tokensBefore: number; - providerPayload?: ProviderPayload; - timestamp: number; -} - /** * Message type for auto-read file mentions via @filepath syntax. */ @@ -254,32 +245,6 @@ export function pythonExecutionToText(msg: PythonExecutionMessage): string { return text; } -export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { - return { - role: "branchSummary", - summary, - fromId, - timestamp: new Date(timestamp).getTime(), - }; -} - -export function createCompactionSummaryMessage( - summary: string, - tokensBefore: number, - timestamp: string, - shortSummary?: string, - providerPayload?: ProviderPayload, -): CompactionSummaryMessage { - return { - role: "compactionSummary", - summary, - shortSummary, - tokensBefore, - providerPayload, - timestamp: new Date(timestamp).getTime(), - }; -} - export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: AssistantMessage): AssistantMessage { if (message.providerPayload?.type !== "openaiResponsesHistory") { return message; @@ -376,7 +341,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { content: [ { type: "text" as const, - text: prompt.render(BRANCH_SUMMARY_TEMPLATE, { summary: m.summary }), + text: renderBranchSummaryContext(m.summary), }, ], attribution: "agent", @@ -388,7 +353,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { content: [ { type: "text" as const, - text: prompt.render(COMPACTION_SUMMARY_TEMPLATE, { summary: m.summary }), + text: renderCompactionSummaryContext(m.summary), }, ], attribution: "agent", diff --git a/packages/coding-agent/test/compaction-serialization.test.ts b/packages/coding-agent/test/compaction-serialization.test.ts index f1fe745eb..86738c230 100644 --- a/packages/coding-agent/test/compaction-serialization.test.ts +++ b/packages/coding-agent/test/compaction-serialization.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; +import { serializeConversation } from "@oh-my-pi/pi-agent-core/compaction/utils"; import type { Message } from "@oh-my-pi/pi-ai"; -import { serializeConversation } from "../src/session/compaction/utils"; describe("serializeConversation", () => { it("truncates long tool results in serialized summaries", () => { diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index cb3b2f2cb..9f65fe293 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -1,12 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import * as ai from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; -import { encodeTextSignatureV1 } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; -import type { AssistantMessage, Model, ProviderPayload, Usage } from "@oh-my-pi/pi-ai/types"; -import { hookFetch } from "@oh-my-pi/pi-utils"; - import { type CompactionSettings, calculateContextTokens, @@ -16,7 +10,12 @@ import { getLastAssistantUsage, prepareCompaction, shouldCompact, -} from "../src/session/compaction/compaction"; +} from "@oh-my-pi/pi-agent-core/compaction/compaction"; +import * as ai from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { encodeTextSignatureV1 } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; +import type { AssistantMessage, Model, ProviderPayload, Usage } from "@oh-my-pi/pi-ai/types"; +import { hookFetch } from "@oh-my-pi/pi-utils"; import { buildSessionContext, type CompactionEntry, diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index 39831af24..2722e5aeb 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; +import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import { getBundledModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -8,7 +9,6 @@ import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import * as compactionModule from "../src/session/compaction/compaction"; import { assistantMsg, userMsg } from "./utilities"; describe("issue #986 compaction auth fallback", () => {