This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/server/sessions/subsessionTranscript.ts
T
Federico Jaramillo Martinez 997b821717 feat: add check_subsession and an exploratory read_subsession transcript reader
Split subsession inspection into two tools: check_subsession gives a quick
glance (status + latest assistant output), while read_subsession reads through
a child's transcript with role/content filters, full-content substring search
(including tool-call args), optional per-value maxChars truncation that flags
clipped parts, includeToolArgs, and pagination.

Filtering and search run on full untruncated content; truncation is an explicit,
caller-owned final projection (no default) so a narrow read never silently hides
a match. Empty page-windows are distinguished from zero-match results.
2026-06-19 23:48:12 +02:00

313 lines
14 KiB
TypeScript

/**
* Pure helpers for the `read_subsession` tool: turn a subsession's normalized
* history (as produced by `historyMessages`) into a filtered, projected,
* paginated view the parent agent can explore.
*
* The agent drives the read: it picks which roles and content kinds it cares
* about, how much detail it wants, and how far back to look. If a narrow read
* does not answer its question it can widen the filters or page further back,
* the same grep-then-read loop it already uses on files. Everything here is a
* pure transform over an array so it can be unit-tested without a live session.
*/
/** Message roles the parent can ask for, mapped from raw history roles. */
export type TranscriptRole = "assistant" | "user" | "tool" | "system" | "custom";
/** Content kinds the parent can keep within retained messages. */
export type TranscriptContentKind = "text" | "thinking" | "tool_call" | "tool_result" | "image";
/**
* Marks a text value that the caller's `maxChars` clipped. Carries the full
* length so the consumer knows *how much* was dropped and can re-read with a
* larger `maxChars` (or none). Truncation only ever happens when the caller
* passes `maxChars`, so a `truncated` marker is always something they asked
* for and should expect, never a silent surprise. Its presence (not a `…`
* glyph, which is indistinguishable from real content) is the reliable signal.
*/
export interface TranscriptTruncation {
/** Characters retained in `text`. */
shown: number;
/** Length of the original, untruncated text. */
full: number;
}
export type TranscriptPart =
| { kind: "text"; text: string; truncated?: TranscriptTruncation }
| { kind: "thinking"; text: string; truncated?: TranscriptTruncation }
| { kind: "tool_call"; toolName: string; summary: string; args?: unknown }
| { kind: "tool_result"; toolName?: string; text: string; isError: boolean; truncated?: TranscriptTruncation }
| { kind: "image" };
export interface TranscriptEntry {
/** Position of this message in the full transcript (stable across reads). */
index: number;
role: TranscriptRole;
parts: TranscriptPart[];
}
export interface TranscriptQuery {
/** Message roles to include. Omit for all roles. */
roles?: TranscriptRole[];
/** Content kinds to keep within retained messages. Omit for all kinds. */
include?: TranscriptContentKind[];
/** Case-insensitive substring; keep only entries whose text matches. */
search?: string;
/**
* Truncate each text/thinking/tool_result value to this many characters,
* flagging clipped parts with `truncated`. Omit for full, untruncated text:
* there is deliberately no default, so truncation only happens when asked for
* and a `truncated` marker is always expected. `search` always runs against
* the full content regardless, so clipping never hides a match.
*/
maxChars?: number;
/** Include raw tool-call arguments (can be large). The compact `summary` is always present. */
includeToolArgs?: boolean;
/** Upper bound (exclusive) on original index; page backward by passing the previous `start`. */
before?: number;
/** Keep at most this many of the most-recent matches in the window; entries are returned in chronological order. */
limit?: number;
}
export interface TranscriptView {
entries: TranscriptEntry[];
/** Total messages in the full transcript, before any filtering. */
total: number;
/** Entries matching the role/content/search filters across the whole transcript. */
matched: number;
/** Original index of the first returned entry, or `before` when nothing matched in-window. */
start: number;
/** True when matching entries exist before `start` (page back with `before: start`). */
hasMore: boolean;
}
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
/**
* Build a filtered, projected, paginated view of a normalized transcript.
*
* Filtering happens before paging for *semantics*, not speed: `search`, the
* `matched` count, and "page backward through matches" all require scanning the
* whole transcript, so a window-first approach could not answer them. The
* tradeoff is an O(total) scan per call (paging a raw window first would be
* cheaper), but `total` is a single session's history and this runs once per
* tool call, so the scan is negligible. The cost that matters for an LLM tool,
* the tokens returned, is bounded by `limit` regardless of ordering; `matched`
* is only a count, so the agent learns whether widening or paging is worthwhile
* without paying to receive every match.
*/
export function buildTranscriptView(messages: readonly unknown[], query: TranscriptQuery = {}): TranscriptView {
const total = messages.length;
// Explicit, caller-owned truncation: only when provided, and a malformed
// value (negative/fractional) is coerced to a safe non-negative integer
// rather than silently meaning "no cap".
const maxChars = query.maxChars === undefined ? undefined : Math.max(0, Math.floor(query.maxChars));
const includeToolArgs = query.includeToolArgs === true;
const roleFilter = query.roles === undefined ? undefined : new Set(query.roles);
const includeFilter = query.include === undefined ? undefined : new Set(query.include);
const search = query.search !== undefined && query.search !== "" ? query.search.toLowerCase() : undefined;
// Extract *full* (untruncated) parts and run all filtering/search on them, so
// matching never depends on `maxChars`. Projection (clipping, arg dropping)
// happens later and only on the entries we actually return.
const matchedEntries: FullEntry[] = [];
for (let index = 0; index < total; index++) {
const role = roleOf(messages[index]);
if (role === undefined) continue;
if (roleFilter !== undefined && !roleFilter.has(role)) continue;
let parts = fullPartsOf(messages[index], role);
if (includeFilter !== undefined) parts = parts.filter((part) => includeFilter.has(part.kind));
if (parts.length === 0) continue;
if (search !== undefined && !partsMatchSearch(parts, search)) continue;
matchedEntries.push({ index, role, parts });
}
const matched = matchedEntries.length;
const before = clampInteger(query.before ?? total, 0, total);
const limit = clampInteger(query.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT);
const inWindow = matchedEntries.filter((entry) => entry.index < before);
const windowed = inWindow.slice(Math.max(0, inWindow.length - limit));
const entries = windowed.map((entry) => projectEntry(entry, maxChars, includeToolArgs));
const first = windowed[0];
const start = first === undefined ? before : first.index;
const hasMore = inWindow.length > windowed.length;
return { entries, total, matched, start, hasMore };
}
/**
* A part before projection: tool calls keep their raw `args`, text-bearing
* parts keep their full untruncated `text`. Search and filtering run on these
* so a match is never hidden by `summary` truncation.
*/
type FullPart =
| { kind: "text"; text: string }
| { kind: "thinking"; text: string }
| { kind: "tool_call"; toolName: string; args?: unknown }
| { kind: "tool_result"; toolName?: string; text: string; isError: boolean }
| { kind: "image" };
interface FullEntry {
index: number;
role: TranscriptRole;
parts: FullPart[];
}
function partsMatchSearch(parts: readonly FullPart[], needle: string): boolean {
return parts.some((part) => {
if (part.kind === "text" || part.kind === "thinking") return part.text.toLowerCase().includes(needle);
if (part.kind === "tool_result") return part.text.toLowerCase().includes(needle) || (part.toolName?.toLowerCase().includes(needle) ?? false);
// Search the *full* serialized args, not the lossy one-line summary, so a
// term inside edit/write content, nested objects, or long values is found.
if (part.kind === "tool_call") return part.toolName.toLowerCase().includes(needle) || stringifyArgs(part.args).toLowerCase().includes(needle);
return false;
});
}
/** Full, search-friendly serialization of tool-call args (distinct from the lossy display summary). */
function stringifyArgs(args: unknown): string {
if (args === undefined) return "";
if (typeof args === "string") return args;
try {
// JSON.stringify can return undefined at runtime (e.g. a function/symbol),
// despite its string-typed signature; normalize that to "".
const json: unknown = JSON.stringify(args);
return typeof json === "string" ? json : "";
} catch {
return "";
}
}
/** Project a fully-extracted entry into the returned shape, clipping only when `maxChars` is set. */
function projectEntry(entry: FullEntry, maxChars: number | undefined, includeToolArgs: boolean): TranscriptEntry {
return { index: entry.index, role: entry.role, parts: entry.parts.map((part) => projectPart(part, maxChars, includeToolArgs)) };
}
function projectPart(part: FullPart, maxChars: number | undefined, includeToolArgs: boolean): TranscriptPart {
if (part.kind === "text") return { kind: "text", ...clip(part.text, maxChars) };
if (part.kind === "thinking") return { kind: "thinking", ...clip(part.text, maxChars) };
if (part.kind === "tool_result") {
return {
kind: "tool_result",
...(part.toolName === undefined ? {} : { toolName: part.toolName }),
isError: part.isError,
...clip(part.text, maxChars),
};
}
if (part.kind === "tool_call") {
return {
kind: "tool_call",
toolName: part.toolName,
summary: summarizeToolArgs(part.args),
...(includeToolArgs && part.args !== undefined ? { args: part.args } : {}),
};
}
return { kind: "image" };
}
/** Clip text to `maxChars`, attaching a `truncated` marker when it actually shortens. */
function clip(text: string, maxChars: number | undefined): { text: string; truncated?: TranscriptTruncation } {
if (maxChars === undefined || text.length <= maxChars) return { text };
return { text: text.slice(0, maxChars), truncated: { shown: maxChars, full: text.length } };
}
/** Map a raw history message to one of the agent-facing roles, or undefined to drop it. */
function roleOf(message: unknown): TranscriptRole | undefined {
const role = getString(message, "role");
if (role === "assistant") return "assistant";
if (role === "user") return "user";
if (role === "toolResult") return "tool";
if (role === "custom") return "custom";
if (role === "system") return "system";
return undefined;
}
/** Extract a message's *full* (untruncated) parts; projection happens later. */
function fullPartsOf(message: unknown, role: TranscriptRole): FullPart[] {
if (role === "tool") return toolResultParts(message);
const content = getProperty(message, "content");
if (typeof content === "string") return content === "" ? [] : [{ kind: "text", text: content }];
if (!Array.isArray(content)) return [];
return content.flatMap(contentPart);
}
function toolResultParts(message: unknown): FullPart[] {
const text = stringifyContent(getProperty(message, "content")) || (getString(message, "text") ?? "");
const toolName = getString(message, "toolName");
const isError = getProperty(message, "isError") === true;
return [{ kind: "tool_result", ...(toolName === undefined ? {} : { toolName }), text, isError }];
}
function contentPart(part: unknown): FullPart[] {
const type = getString(part, "type");
if (type === "text") {
const text = getString(part, "text") ?? "";
return text === "" ? [] : [{ kind: "text", text }];
}
if (type === "thinking") {
const text = getString(part, "thinking") ?? getString(part, "text") ?? "";
return text === "" ? [] : [{ kind: "thinking", text }];
}
if (type === "toolCall") {
const toolName = getString(part, "name") ?? "tool";
const args = getProperty(part, "arguments");
return [{ kind: "tool_call", toolName, ...(args === undefined ? {} : { args }) }];
}
if (type === "image") return [{ kind: "image" }];
return [];
}
/** Compact one-line description of tool arguments (mirrors the UI's summary). */
function summarizeToolArgs(args: unknown): string {
if (!isRecord(args)) return typeof args === "string" ? args : "";
const command = getString(args, "command");
if (command !== undefined) return command;
const path = getString(args, "path");
if (path !== undefined) return path;
if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement";
const edits = args["edits"];
if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(" · ");
}
function shortValue(value: unknown): string {
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}…` : value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
if (typeof value === "object" && value !== null) return "object";
return "";
}
function stringifyContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => (getString(part, "type") === "image" ? "[image]" : getString(part, "text") ?? ""))
.filter((text) => text !== "")
.join("\n");
}
return "";
}
function clampInteger(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return max;
return Math.max(min, Math.min(max, Math.floor(value)));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getProperty(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
function getString(value: unknown, key: string): string | undefined {
const property = getProperty(value, key);
return typeof property === "string" ? property : undefined;
}