Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { buildEffectiveSystemPrompt, composeAppendedSystemPrompt } from "./instr
import { promptWithTextAttachments } from "./text-attachment.js";
import type { LoadedTextAttachment } from "./text-attachment.js";
import { compactMessages, buildCompactedTranscript } from "./compaction.js";
import { ObservationPack } from "./observation-pack.js";
import type { LoadedImage } from "./image-input.js";
import type { TurnImageCollector } from "./turn-checkpoint.js";
import type { BottleneckCollector } from "./run-bottleneck.js";
Expand Down Expand Up @@ -388,6 +389,11 @@ export async function runAgent(
// Provider stream: the network `streamChat` by default, or a deterministic
// scripted provider for task-fixture replay (#224).
const stream = opts.streamProvider ?? streamChat;
// ObservationPack (SoL-Pi): archives big tool results to a per-session spill
// file on arrival and substitutes them in the live transcript once they are
// about to be sent for the third time. The on-disk JSONL stays full-fidelity
// (onMessage appends before any rewrite); only the live array is rewritten.
const observationPack = new ObservationPack({ sessionId: opts.sessionId });

const messages: SessionMessage[] = [...existingMessages];

Expand Down Expand Up @@ -628,6 +634,12 @@ export async function runAgent(
});
}

// ObservationPack rewrite point: after compaction (so identities are checked
// against the current array) and before this round's provider request. A
// spilled result about to be sent for the third time is substituted with
// marker + excerpt + handle; the most recent two tool messages are exempt.
observationPack.beforeProviderRequest(messages);

let assistantText = "";
let assistantToolCalls: Array<{ id: string; name: string; arguments: string }> = [];

Expand Down Expand Up @@ -993,11 +1005,16 @@ export async function runAgent(
bump(toolCalls, tc.name);
if (result.isError) bump(toolFailures, tc.name);

// Spill on arrival, before the result joins the live array: the full
// content lands in the per-session spill file first, then the full result
// is pushed and persisted (JSONL stays full-fidelity) regardless.
observationPack.onToolResult(result.content, tc.id);
const toolMsg: SessionMessage = {
role: "tool",
content: result.content,
tool_call_id: tc.id,
};
observationPack.bindToolMessage(tc.id, toolMsg);
messages.push(toolMsg);
opts.onMessage(toolMsg);
}
Expand Down
294 changes: 294 additions & 0 deletions src/observation-pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
// ObservationPack (SoL-Pi): big tool results are archived to a per-session
// spill file on arrival, ride in full for the next two provider requests (the
// model's immediate use is unharmed), and are then substituted in the live
// in-memory transcript with a marker + bounded head/tail excerpt + a retrieval
// handle. The on-disk session JSONL is never touched: messages are appended
// full-fidelity at push time, before any rewrite, and the rewrite replaces only
// the live array entry. One rewrite per result, guarded by an idempotence
// marker; the provider cache prefix busts once per spilled result — the
// accepted trade against carrying the full body forever.

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { SessionMessage } from "./session.js";
import { safeByteCutEnd, safeByteTailStart } from "./text-cut.js";

// First line of every substitute; doubles as the idempotence marker.
export const SPILL_MARKER_PREFIX = "⤷ spilled tool result (";

// Default spill threshold: results larger than this many UTF-8 bytes spill.
export const DEFAULT_SPILL_BYTES = 10240;

// Excerpt budget: complete head lines and complete tail lines, ~500 bytes each.
const EXCERPT_HEAD_BYTES = 500;
const EXCERPT_TAIL_BYTES = 500;

// A spilled result rides in full for this many provider sends, then is
// substituted on the next one (its third send).
const FULL_SENDS = 2;

export function spillThresholdBytes(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.OMC_SPILL_BYTES;
if (raw === undefined) return DEFAULT_SPILL_BYTES;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_SPILL_BYTES;
}

// The session store root. Mirrors SessionStore's HOME resolution so spill
// directories sit beside the session JSONL they belong to.
export function sessionsBaseDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(env.HOME ?? os.homedir(), ".oh-my-cli", "sessions");
}

export function spillPathFor(sessionId: string, seq: number, baseDir: string = sessionsBaseDir()): string {
return path.join(baseDir, `${sessionId}.spill`, `${seq}.log`);
}

// Write the full tool result to its spill file and return the absolute path.
export function writeSpillFile(
sessionId: string,
seq: number,
content: string,
baseDir: string = sessionsBaseDir(),
): string {
const fp = spillPathFor(sessionId, seq, baseDir);
fs.mkdirSync(path.dirname(fp), { recursive: true });
fs.writeFileSync(fp, content, "utf-8");
return fp;
}

// Complete lines from the head of `content` within `budgetBytes`. Always keeps
// at least one line; a single line over budget is cut surrogate-safely by bytes.
function headExcerpt(content: string, budgetBytes: number): string {
const out: string[] = [];
let used = 0;
for (const line of content.split("\n")) {
const len = Buffer.byteLength(line, "utf8");
if (len > budgetBytes) {
out.push(line.slice(0, safeByteCutEnd(line, budgetBytes)));
break;
}
if (out.length > 0 && used + len + 1 > budgetBytes) break;
out.push(line);
used += len + 1;
}
return out.join("\n");
}

// Complete lines from the tail of `content` within `budgetBytes`, in original
// order. Always keeps at least one line; a single line over budget keeps its
// trailing bytes, cut surrogate-safely.
function tailExcerpt(content: string, budgetBytes: number): string {
const lines = content.split("\n");
const out: string[] = [];
let used = 0;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
const len = Buffer.byteLength(line, "utf8");
if (len > budgetBytes) {
out.unshift(line.slice(safeByteTailStart(line, budgetBytes)));
break;
}
if (out.length > 0 && used + len + 1 > budgetBytes) break;
out.unshift(line);
used += len + 1;
}
return out.join("\n");
}

// ~1 KB excerpt: complete head lines (~500 B) + complete tail lines (~500 B).
export function buildSpillExcerpt(
content: string,
headBytes: number = EXCERPT_HEAD_BYTES,
tailBytes: number = EXCERPT_TAIL_BYTES,
): string {
return `${headExcerpt(content, headBytes)}\n… [middle elided] …\n${tailExcerpt(content, tailBytes)}`;
}

// The substitute that replaces a spilled result in the live transcript: marker
// line, bounded excerpt, retrieval handle.
export function buildSpillSubstitute(
originalBytes: number,
toolCallId: string,
spillPath: string,
content: string,
): string {
return [
`⤷ spilled tool result (${originalBytes} bytes, tool_call_id ${toolCallId})`,
buildSpillExcerpt(content),
`full output: ${spillPath} — read it with the read tool (offset/limit) or grep it`,
].join("\n");
}

// Resolve an absolute spill-file path for the `read` tool, or null when the
// path is not exactly <sessions>/<sessionId>.spill/<file>. The workspace path
// guard otherwise confines reads to the workspace; spill files are the one
// explicitly allowed exception so a seat can page its own archived output.
export function resolveSpillReadPath(p: string, baseDir: string = sessionsBaseDir()): string | null {
if (!path.isAbsolute(p)) return null;
const resolved = path.resolve(p);
const spillDir = path.dirname(resolved);
if (path.dirname(spillDir) !== path.resolve(baseDir)) return null;
if (!path.basename(spillDir).endsWith(".spill")) return null;
return resolved;
}

// Opt-in debug instrumentation (OMC_SPILL_DEBUG=1): append one JSON line per
// spill/rewrite event to <sessionId>.spill/events.log. Off by default; used to
// prove rollout behavior (what a given provider request carried) without
// touching the transcript.
export function spillDebugLog(
sessionId: string,
event: Record<string, unknown>,
baseDir: string = sessionsBaseDir(),
env: NodeJS.ProcessEnv = process.env,
): void {
if (!env.OMC_SPILL_DEBUG) return;
try {
const dir = path.join(baseDir, `${sessionId}.spill`);
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, "events.log"), JSON.stringify(event) + "\n", "utf-8");
} catch {
/* best-effort debug log */
}
}

interface SpillEntry {
toolCallId: string;
spillPath: string;
// Byte size of the full result at arrival, before any harness truncation.
originalBytes: number;
// Provider sends that have already carried this message.
sends: number;
rewritten: boolean;
// Identity of the live-array message object, bound when the (possibly
// truncated) result message is pushed; undefined until then.
msg?: SessionMessage;
// False once compaction has dropped the message from the live window.
live: boolean;
}

export interface ObservationPackOptions {
sessionId: string;
thresholdBytes?: number;
baseDir?: string;
}

// Per-run tracker. Spills big tool results on arrival and rewrites them in the
// live messages array once they are about to be sent for the third time.
export class ObservationPack {
private readonly sessionId: string;
private readonly thresholdBytes: number;
private readonly baseDir: string;
private entries: SpillEntry[] = [];
private seq = 0;

constructor(opts: ObservationPackOptions) {
this.sessionId = opts.sessionId;
this.thresholdBytes = opts.thresholdBytes ?? spillThresholdBytes();
this.baseDir = opts.baseDir ?? sessionsBaseDir();
}

// Called when a tool result arrives, BEFORE any harness truncation: the full
// original content is archived to the spill file so nothing is discarded.
// Results over the threshold are tracked; smaller ones never are. Spilling is
// best-effort: a filesystem failure never breaks the run.
onToolResult(content: string, toolCallId: string): void {
const seq = this.seq++;
if (typeof content !== "string") return;
const originalBytes = Buffer.byteLength(content, "utf8");
if (originalBytes <= this.thresholdBytes) return;
let spillPath: string;
try {
spillPath = writeSpillFile(this.sessionId, seq, content, this.baseDir);
} catch {
return;
}
this.entries.push({
toolCallId,
spillPath,
originalBytes,
sends: 0,
rewritten: false,
live: true,
});
spillDebugLog(
this.sessionId,
{ event: "spill", seq, toolCallId, originalBytes, spillPath },
this.baseDir,
);
}

// Bind a spilled result to its live-array message object when the result is
// pushed. The message content may already be truncated by the harness; the
// spill file (and originalBytes) always hold the full arrival content.
bindToolMessage(toolCallId: string, msg: SessionMessage): void {
for (let i = this.entries.length - 1; i >= 0; i--) {
const entry = this.entries[i];
if (entry.live && entry.msg === undefined && entry.toolCallId === toolCallId) {
entry.msg = msg;
return;
}
}
}

// Called once per round at the round boundary, before the provider request
// is sent. A tracked result that has already ridden in full for FULL_SENDS
// sends is substituted now — its third send carries marker + excerpt +
// handle. One rewrite per result (idempotence marker + flag), never for the
// most recent two tool messages, never for results at or under the threshold
// (they are not tracked at all).
beforeProviderRequest(messages: SessionMessage[]): void {
if (this.entries.length === 0) return;
const toolIdx: number[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].role === "tool") toolIdx.push(i);
}
const protectedIdx = new Set(toolIdx.slice(-2));
for (const entry of this.entries) {
if (entry.rewritten || !entry.live || entry.msg === undefined) continue;
const idx = messages.indexOf(entry.msg);
if (idx === -1) {
// Compaction replaced the live window; the spilled message is gone from
// it and compacts as before. Stop tracking the stale identity.
entry.live = false;
continue;
}
if (entry.sends < FULL_SENDS) {
entry.sends++;
continue;
}
if (protectedIdx.has(idx)) continue;
const current = typeof entry.msg.content === "string" ? entry.msg.content : "";
if (current.startsWith(SPILL_MARKER_PREFIX)) {
entry.rewritten = true;
continue;
}
// Replace the array entry rather than mutating in place: consumers that
// already hold the original object (the append-only session log wrote it
// at push time) keep the full content. The excerpt is taken from the live
// body: the harness truncation keeps the original head and tail, so the
// excerpt's complete head/tail lines match the full result either way.
const rewritten: SessionMessage = {
...entry.msg,
content: buildSpillSubstitute(entry.originalBytes, entry.toolCallId, entry.spillPath, current),
};
messages[idx] = rewritten;
entry.msg = rewritten;
entry.rewritten = true;
spillDebugLog(
this.sessionId,
{
event: "rewrite",
toolCallId: entry.toolCallId,
originalBytes: entry.originalBytes,
substituteBytes: Buffer.byteLength(rewritten.content as string, "utf8"),
spillPath: entry.spillPath,
sends: entry.sends,
},
this.baseDir,
);
}
}
}
11 changes: 8 additions & 3 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
formatGrepResult,
} from "./discovery.js";
import { createCappedUtf8Decoder, type OutputByteCapState } from "./utf8-cap.js";
import { resolveSpillReadPath } from "./observation-pack.js";

export interface ToolResult {
content: string;
Expand Down Expand Up @@ -87,12 +88,14 @@ export function createTools(): ToolDef[] {
return [
{
name: "read",
description: "Read a workspace-relative file with optional line offset and limit",
description:
"Read a workspace-relative file with optional line offset and limit. " +
"Also reads absolute spill-file paths under ~/.oh-my-cli/sessions/*.spill/ (archived tool output).",
parameters: ReadParams,
jsonSchema: {
type: "object",
properties: {
path: { type: "string", description: "Workspace-relative file path" },
path: { type: "string", description: "Workspace-relative file path, or an absolute spill-file path under ~/.oh-my-cli/sessions/*.spill/" },
offset: { type: "number", description: "0-based line offset" },
limit: { type: "number", description: "Max lines to return" },
},
Expand All @@ -102,7 +105,9 @@ export function createTools(): ToolDef[] {
execute: async (args, workspace) => {
const { path: relPath, offset, limit } = ReadParams.parse(args);
const fs = await import("node:fs");
const absPath = workspace.resolveSafe(relPath);
// Spill files (ObservationPack archives) are the one allowed exception
// to workspace confinement; everything else resolves as before.
const absPath = resolveSpillReadPath(relPath) ?? workspace.resolveSafe(relPath);
const content = fs.readFileSync(absPath, "utf-8");
if (offset !== undefined || limit !== undefined) {
const lines = content.split("\n");
Expand Down
Loading