Archived
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.
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user