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.
This commit is contained in:
can1357
2026-05-15 18:30:50 +02:00
parent 8499149627
commit e1aaf78874
51 changed files with 663 additions and 222 deletions
+14 -11
View File
@@ -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
+4 -3
View File
@@ -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
<handoff-context>
+1 -1
View File
@@ -59,7 +59,7 @@ The returned tool result is not the final rewind. `AgentSession` waits until `tu
- Session files are named `<ISO-timestamp-with-:-and-.-replaced>_<uuidv7>.jsonl` in the session directory; default directory selection is documented in `SessionManager.create()` as `~/.omp/agent/sessions/<encoded-cwd>/` 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.
+1
View File
@@ -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
+8
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
export * from "./compaction/index";
@@ -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<string, unknown>;
/** 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
@@ -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<string, unknown>;
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<string> {
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 = `<conversation>\n${conversationText}\n</conversation>\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<string, unknown>,
options?: SummaryOptions,
): Promise<string> {
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 = `<conversation>\n${conversationText}\n</conversation>\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") {
+133
View File
@@ -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<T = unknown> 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<string, unknown>;
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
fromExtension?: boolean;
}
export interface BranchSummaryEntry<T = unknown> 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<T = unknown> 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<T = unknown> 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<string, unknown>;
}
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;
}
+44
View File
@@ -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 `<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.`;
}
export function createHandoffFileName(date = new Date()): string {
const fileTimestamp = date.toISOString().replace(/[:.]/g, "-");
return `handoff-${fileTimestamp}.md`;
}
@@ -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";
+212
View File
@@ -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<T = unknown> {
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<T = unknown> {
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;
@@ -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);
}
@@ -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. */
@@ -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
+2
View File
@@ -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";
+60
View File
@@ -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(
"<handoff-context>\n## Goal\nContinue\n</handoff-context>\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");
});
});
@@ -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<Record<string, unknown>> | undefined;
using _hook = hookFetch(async (_input, init) => {
const body = JSON.parse(String(init?.body)) as { input: Array<Record<string, unknown>> };
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<Response>((_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<Response>();
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(
-5
View File
@@ -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
-1
View File
@@ -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";
+1 -1
View File
@@ -144,7 +144,7 @@
- Added `pr://<N>/diff`, `pr://<N>/diff/<i>`, and `pr://<N>/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://<N>` views (`github.cache.softTtlSec` / `github.cache.hardTtlSec` / `github.cache.enabled`). Single PR views now advertise the diff entry point via a `Diff: pr://<owner>/<repo>/<N>/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://<N>`, `issue://<owner>/<repo>/<N>`) 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`
-8
View File
@@ -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"
@@ -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";
@@ -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 {
@@ -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";
-2
View File
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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,
@@ -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";
// ============================================================================
+1 -1
View File
@@ -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";
@@ -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";
@@ -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<string, string[]>;
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 = `<handoff-context>\n${handoffText}\n</handoff-context>\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) {
+16 -51
View File
@@ -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<T = unknown> {
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",
@@ -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", () => {
@@ -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,
@@ -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", () => {