Archived
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.
313 lines
14 KiB
TypeScript
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;
|
|
}
|