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:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
"@jmfederico/pi-web": minor
|
"@jmfederico/pi-web": patch
|
||||||
---
|
---
|
||||||
|
|
||||||
Add tracked subsessions (beta, off by default): agents can spawn child sessions they stay attached to. The new `spawn_subsession` tool starts a child session linked to its parent (recorded in the session tree), notifies the parent when the child stops working, and lets the parent inspect children via `list_subsessions` and `read_subsession`. The completion notice is delivered as a system-authored message (not attributed to the human), and still wakes an idle parent while queueing behind any in-flight work. Unlike the fire-and-forget `spawn_session`, subsessions are observable by their spawner.
|
Add tracked subsessions (beta, off by default): agents can spawn child sessions they stay attached to. The new `spawn_subsession` tool starts a child session linked to its parent (recorded in the session tree), notifies the parent when the child stops working, and lets the parent inspect children via `list_subsessions`, `check_subsession` (a quick glance at a child's status and latest output), and `read_subsession` (read through a child's transcript with role/content filters, full-content substring search, optional per-value `maxChars` truncation that flags clipped parts, and pagination). The completion notice is delivered as a system-authored message (not attributed to the human), and still wakes an idle parent while queueing behind any in-flight work. Unlike the fire-and-forget `spawn_session`, subsessions are observable by their spawner.
|
||||||
|
|
||||||
The capability is gated behind a beta flag so it can ship without being exposed in releases: enable it with the `PI_WEB_SUBSESSIONS` env var, the `subsessions` config key, or the "Allow agents to start tracked subsessions" toggle in Settings → Session daemon. It also requires `spawnSessions` to be enabled. Requires a manual session daemon restart to take effect.
|
The capability is gated behind a beta flag so it can ship without being exposed in releases: enable it with the `PI_WEB_SUBSESSIONS` env var, the `subsessions` config key, or the "Allow agents to start tracked subsessions" toggle in Settings → Session daemon. It also requires `spawnSessions` to be enabled. Requires a manual session daemon restart to take effect.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
>
|
>
|
||||||
<span>Enable the <code>spawn_subsession</code> tools</span>
|
<span>Enable the <code>spawn_subsession</code> tools</span>
|
||||||
</label>
|
</label>
|
||||||
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
||||||
</div>
|
</div>
|
||||||
<section class="effective-card" aria-label="Effective configuration summary">
|
<section class="effective-card" aria-label="Effective configuration summary">
|
||||||
<h3>Effective after environment overrides</h3>
|
<h3>Effective after environment overrides</h3>
|
||||||
|
|||||||
@@ -960,12 +960,13 @@ describe("PiSessionService", () => {
|
|||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("read_subsession refuses sessions that are not the caller's children", async () => {
|
it("check_subsession and read_subsession refuse sessions that are not the caller's children", async () => {
|
||||||
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||||
await service.start("/workspace");
|
await service.start("/workspace");
|
||||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
|
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
|
||||||
|
|
||||||
await expect(service.readSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions");
|
await expect(service.checkSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions");
|
||||||
|
await expect(service.readSubsession("someone-else", "child-1", {})).rejects.toThrow("not one of your subsessions");
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
|
|||||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||||
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||||
|
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||||
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
|
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,7 +268,8 @@ export interface PiSessionServiceDependencies {
|
|||||||
spawnTargets?: SpawnTargetResolver;
|
spawnTargets?: SpawnTargetResolver;
|
||||||
/**
|
/**
|
||||||
* Beta: when true (and `spawnTargets` is provided), the tracked-subsession
|
* Beta: when true (and `spawnTargets` is provided), the tracked-subsession
|
||||||
* tools (`spawn_subsession`, `list_subsessions`, `read_subsession`) are
|
* tools (`spawn_subsession`, `list_subsessions`, `check_subsession`,
|
||||||
|
* `read_subsession`) are
|
||||||
* registered on every session. Off by default so the capability can ship in
|
* registered on every session. Off by default so the capability can ship in
|
||||||
* main without being exposed in releases.
|
* main without being exposed in releases.
|
||||||
*/
|
*/
|
||||||
@@ -321,7 +323,8 @@ export class PiSessionService {
|
|||||||
!subsessionsActive ? undefined : {
|
!subsessionsActive ? undefined : {
|
||||||
spawn: (input) => this.spawnSubsession(input),
|
spawn: (input) => this.spawnSubsession(input),
|
||||||
list: (parentSessionId) => this.listSubsessions(parentSessionId),
|
list: (parentSessionId) => this.listSubsessions(parentSessionId),
|
||||||
read: (parentSessionId, sessionId) => this.readSubsession(parentSessionId, sessionId),
|
check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId),
|
||||||
|
read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||||
@@ -453,11 +456,8 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Status and final result of a subsession, scoped to the caller's children. */
|
/** Status and final result of a subsession, scoped to the caller's children. */
|
||||||
async readSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionReadResult> {
|
async checkSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult> {
|
||||||
if (this.subsessionParents.get(sessionId) !== parentSessionId) {
|
const session = await this.openSubsession(parentSessionId, sessionId);
|
||||||
throw new Error(`Session ${sessionId} is not one of your subsessions`);
|
|
||||||
}
|
|
||||||
const session = await this.getOrOpen(sessionId);
|
|
||||||
const messages = historyMessages(session);
|
const messages = historyMessages(session);
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -468,6 +468,26 @@ export class PiSessionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
|
||||||
|
async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult> {
|
||||||
|
const session = await this.openSubsession(parentSessionId, sessionId);
|
||||||
|
const view = buildTranscriptView(historyMessages(session), query);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
cwd: session.sessionManager.getCwd(),
|
||||||
|
status: await this.subsessionStatus(session),
|
||||||
|
...view,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a session after verifying it is one of the caller's tracked children. */
|
||||||
|
private async openSubsession(parentSessionId: string, sessionId: string): Promise<PiAgentSession> {
|
||||||
|
if (this.subsessionParents.get(sessionId) !== parentSessionId) {
|
||||||
|
throw new Error(`Session ${sessionId} is not one of your subsessions`);
|
||||||
|
}
|
||||||
|
return this.getOrOpen(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
private registerSubsession(parentSessionId: string, childSessionId: string): void {
|
private registerSubsession(parentSessionId: string, childSessionId: string): void {
|
||||||
this.subsessionParents.set(childSessionId, parentSessionId);
|
this.subsessionParents.set(childSessionId, parentSessionId);
|
||||||
const children = this.subsessionChildren.get(parentSessionId) ?? new Set<string>();
|
const children = this.subsessionChildren.get(parentSessionId) ?? new Set<string>();
|
||||||
@@ -512,7 +532,7 @@ export class PiSessionService {
|
|||||||
const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
|
const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
|
||||||
const finalText = finalAssistantText(historyMessages(session));
|
const finalText = finalAssistantText(historyMessages(session));
|
||||||
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
|
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
|
||||||
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse read_subsession with sessionId "${childId}" for the full result.`;
|
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`;
|
||||||
void this.notifyParentOfSubsession(parentId, childId, text);
|
void this.notifyParentOfSubsession(parentId, childId, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ function tools(deps: Partial<SubsessionToolDeps>) {
|
|||||||
const full: SubsessionToolDeps = {
|
const full: SubsessionToolDeps = {
|
||||||
spawn: deps.spawn ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a" })),
|
spawn: deps.spawn ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a" })),
|
||||||
list: deps.list ?? vi.fn(() => Promise.resolve([])),
|
list: deps.list ?? vi.fn(() => Promise.resolve([])),
|
||||||
read: deps.read ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, finalText: "", messageCount: 0 })),
|
check: deps.check ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, finalText: "", messageCount: 0 })),
|
||||||
|
read: deps.read ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
|
||||||
};
|
};
|
||||||
const definitions = createSubsessionToolDefinitions("/repos/a", full);
|
const definitions = createSubsessionToolDefinitions("/repos/a", full);
|
||||||
const find = (name: string) => {
|
const find = (name: string) => {
|
||||||
@@ -22,7 +23,7 @@ function tools(deps: Partial<SubsessionToolDeps>) {
|
|||||||
if (tool === undefined) throw new Error(`missing tool ${name}`);
|
if (tool === undefined) throw new Error(`missing tool ${name}`);
|
||||||
return tool;
|
return tool;
|
||||||
};
|
};
|
||||||
return { spawn: find("spawn_subsession"), list: find("list_subsessions"), read: find("read_subsession") };
|
return { spawn: find("spawn_subsession"), list: find("list_subsessions"), check: find("check_subsession"), read: find("read_subsession") };
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstText(content: readonly (TextContent | ImageContent)[]): string {
|
function firstText(content: readonly (TextContent | ImageContent)[]): string {
|
||||||
@@ -71,22 +72,78 @@ describe("createSubsessionToolDefinitions", () => {
|
|||||||
expect(result.content[0]).toMatchObject({ type: "text", text: "You have not spawned any subsessions." });
|
expect(result.content[0]).toMatchObject({ type: "text", text: "You have not spawned any subsessions." });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("read_subsession scopes by parent and returns the final result", async () => {
|
it("check_subsession scopes by parent and returns the final result", async () => {
|
||||||
const read = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 }));
|
const check = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 }));
|
||||||
const { read: readTool } = tools({ read });
|
const { check: checkTool } = tools({ check });
|
||||||
|
|
||||||
const result = await readTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
|
const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||||
|
|
||||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1");
|
expect(check).toHaveBeenCalledWith("parent-1", "child-1");
|
||||||
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
|
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
|
||||||
expect(firstText(result.content)).toContain("all done");
|
expect(firstText(result.content)).toContain("all done");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("check_subsession propagates scope errors so the agent loop reports them", async () => {
|
||||||
|
const check = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions")));
|
||||||
|
const { check: checkTool } = tools({ check });
|
||||||
|
|
||||||
|
await expect(checkTool.execute("call-5", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined)))
|
||||||
|
.rejects.toThrow("not one of your subsessions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read_subsession forwards filter params and renders the transcript", async () => {
|
||||||
|
const read = vi.fn(() => Promise.resolve({
|
||||||
|
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
|
||||||
|
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "the answer" }] }],
|
||||||
|
total: 5, matched: 1, start: 2, hasMore: false,
|
||||||
|
}));
|
||||||
|
const { read: readTool } = tools({ read });
|
||||||
|
|
||||||
|
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||||
|
|
||||||
|
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 });
|
||||||
|
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
|
||||||
|
expect(firstText(result.content)).toContain("the answer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => {
|
||||||
|
const read = vi.fn(() => Promise.resolve({
|
||||||
|
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
|
||||||
|
entries: [{
|
||||||
|
index: 1, role: "assistant" as const, parts: [
|
||||||
|
{ kind: "tool_call" as const, toolName: "bash", summary: "ls", args: { command: "ls -la" } },
|
||||||
|
{ kind: "text" as const, text: "clipped", truncated: { shown: 7, full: 50 } },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
total: 3, matched: 1, start: 1, hasMore: false,
|
||||||
|
}));
|
||||||
|
const { read: readTool } = tools({ read });
|
||||||
|
|
||||||
|
const result = await readTool.execute("call-7", { sessionId: "child-1", includeToolArgs: true }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||||
|
const text = firstText(result.content);
|
||||||
|
expect(text).toContain("command"); // raw args surfaced in text, not only details
|
||||||
|
expect(text).toContain("ls -la");
|
||||||
|
expect(text).toContain("[+43 chars truncated"); // 50 - 7
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read_subsession distinguishes an empty page-window from a zero-match result", async () => {
|
||||||
|
const read = vi.fn(() => Promise.resolve({
|
||||||
|
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
|
||||||
|
entries: [], total: 5, matched: 4, start: 0, hasMore: false,
|
||||||
|
}));
|
||||||
|
const { read: readTool } = tools({ read });
|
||||||
|
|
||||||
|
const result = await readTool.execute("call-8", { sessionId: "child-1", before: 0 }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||||
|
const text = firstText(result.content);
|
||||||
|
expect(text).toContain("4 matched"); // not "nothing matched"
|
||||||
|
expect(text).not.toContain("nothing matched");
|
||||||
|
});
|
||||||
|
|
||||||
it("read_subsession propagates scope errors so the agent loop reports them", async () => {
|
it("read_subsession propagates scope errors so the agent loop reports them", async () => {
|
||||||
const read = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions")));
|
const read = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions")));
|
||||||
const { read: readTool } = tools({ read });
|
const { read: readTool } = tools({ read });
|
||||||
|
|
||||||
await expect(readTool.execute("call-5", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined)))
|
await expect(readTool.execute("call-9", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined)))
|
||||||
.rejects.toThrow("not one of your subsessions");
|
.rejects.toThrow("not one of your subsessions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
|
||||||
|
|
||||||
/** Lifecycle phase of a tracked subsession as seen by its parent. */
|
/** Lifecycle phase of a tracked subsession as seen by its parent. */
|
||||||
export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown";
|
export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown";
|
||||||
@@ -26,7 +27,8 @@ export interface SubsessionSummary {
|
|||||||
status: SubsessionStatus;
|
status: SubsessionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsessionReadResult {
|
/** Quick glance at a subsession: status plus its most recent assistant output. */
|
||||||
|
export interface SubsessionCheckResult {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
status: SubsessionStatus;
|
status: SubsessionStatus;
|
||||||
@@ -34,10 +36,29 @@ export interface SubsessionReadResult {
|
|||||||
messageCount: number;
|
messageCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exploratory transcript read: a filtered, paginated slice of the subsession's history. */
|
||||||
|
export interface SubsessionReadResult extends TranscriptView {
|
||||||
|
sessionId: string;
|
||||||
|
cwd: string;
|
||||||
|
status: SubsessionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters the parent passes to narrow a transcript read; mirrors {@link TranscriptQuery}. */
|
||||||
|
export interface SubsessionReadQuery {
|
||||||
|
roles?: TranscriptRole[];
|
||||||
|
include?: TranscriptContentKind[];
|
||||||
|
search?: string;
|
||||||
|
maxChars?: number;
|
||||||
|
includeToolArgs?: boolean;
|
||||||
|
before?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubsessionToolDeps {
|
export interface SubsessionToolDeps {
|
||||||
spawn(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult>;
|
spawn(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult>;
|
||||||
list(parentSessionId: string): Promise<SubsessionSummary[]>;
|
list(parentSessionId: string): Promise<SubsessionSummary[]>;
|
||||||
read(parentSessionId: string, sessionId: string): Promise<SubsessionReadResult>;
|
check(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult>;
|
||||||
|
read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpawnSubsessionParams = Type.Object({
|
const SpawnSubsessionParams = Type.Object({
|
||||||
@@ -51,16 +72,95 @@ const SpawnSubsessionParams = Type.Object({
|
|||||||
|
|
||||||
const ListSubsessionsParams = Type.Object({});
|
const ListSubsessionsParams = Type.Object({});
|
||||||
|
|
||||||
const ReadSubsessionParams = Type.Object({
|
const CheckSubsessionParams = Type.Object({
|
||||||
sessionId: Type.String({
|
sessionId: Type.String({
|
||||||
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
|
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ReadSubsessionParams = Type.Object({
|
||||||
|
sessionId: Type.String({
|
||||||
|
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
|
||||||
|
}),
|
||||||
|
roles: Type.Optional(Type.Array(
|
||||||
|
Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]),
|
||||||
|
{ description: "Message roles to include. Omit for all roles." },
|
||||||
|
)),
|
||||||
|
include: Type.Optional(Type.Array(
|
||||||
|
Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call"), Type.Literal("tool_result"), Type.Literal("image")]),
|
||||||
|
{ description: "Content kinds to keep within messages. Omit for all kinds." },
|
||||||
|
)),
|
||||||
|
search: Type.Optional(Type.String({
|
||||||
|
description: "Case-insensitive substring; keep only messages whose text or tool name matches. Always searches full message content, even when maxChars is set.",
|
||||||
|
})),
|
||||||
|
maxChars: Type.Optional(Type.Integer({
|
||||||
|
minimum: 0,
|
||||||
|
description: "Truncate each text/thinking/tool-result value to this many characters; clipped parts are marked '[+N chars truncated]'. Omit for full, untruncated text (there is no default, so truncation only happens when you ask for it).",
|
||||||
|
})),
|
||||||
|
includeToolArgs: Type.Optional(Type.Boolean({
|
||||||
|
description: "Include raw tool-call arguments (can be large). A compact one-line summary of each call is always shown regardless.",
|
||||||
|
})),
|
||||||
|
before: Type.Optional(Type.Integer({
|
||||||
|
minimum: 0,
|
||||||
|
description: "Return only messages before this transcript index; page backward by passing the previous response's 'start'.",
|
||||||
|
})),
|
||||||
|
limit: Type.Optional(Type.Integer({
|
||||||
|
minimum: 1,
|
||||||
|
description: "Maximum number of most-recent matching messages to return within the window (returned in chronological order). Defaults to 50.",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
function statusLine(summary: SubsessionSummary): string {
|
function statusLine(summary: SubsessionSummary): string {
|
||||||
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
|
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEntry(entry: TranscriptEntry): string {
|
||||||
|
const header = `#${String(entry.index)} ${entry.role}`;
|
||||||
|
const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n");
|
||||||
|
return body === "" ? header : `${header}\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipNotice(part: TranscriptEntry["parts"][number]): string {
|
||||||
|
if ((part.kind === "text" || part.kind === "thinking" || part.kind === "tool_result") && part.truncated !== undefined) {
|
||||||
|
return ` [+${String(part.truncated.full - part.truncated.shown)} chars truncated; re-read with a larger maxChars]`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPart(part: TranscriptEntry["parts"][number]): string {
|
||||||
|
if (part.kind === "text") return `${part.text}${clipNotice(part)}`;
|
||||||
|
if (part.kind === "thinking") return `[thinking] ${part.text}${clipNotice(part)}`;
|
||||||
|
if (part.kind === "tool_call") {
|
||||||
|
// Raw args are only present when the caller asked (includeToolArgs); when
|
||||||
|
// present, surface them in the model-facing text, not just `details`.
|
||||||
|
const args = "args" in part && part.args !== undefined ? `\n args: ${JSON.stringify(part.args)}` : "";
|
||||||
|
return `[tool ${part.toolName}] ${part.summary}${args}`;
|
||||||
|
}
|
||||||
|
if (part.kind === "tool_result") return `[result${part.isError ? " error" : ""}${part.toolName === undefined ? "" : ` ${part.toolName}`}] ${part.text}${clipNotice(part)}`;
|
||||||
|
return "[image]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTranscript(result: SubsessionReadResult): string {
|
||||||
|
const last = result.entries[result.entries.length - 1];
|
||||||
|
// Distinguish "nothing matched at all" (widen filters) from "matches exist but
|
||||||
|
// this page/window is empty" (page differently) so the agent isn't misled.
|
||||||
|
const range = last === undefined
|
||||||
|
? (result.matched === 0
|
||||||
|
? "no messages matched your filters"
|
||||||
|
: `no messages in this window (${String(result.matched)} matched outside it)`)
|
||||||
|
: `messages ${String(result.start)}–${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`;
|
||||||
|
const more = result.hasMore ? `\n\nMore matching messages exist earlier; page back with before: ${String(result.start)}.` : "";
|
||||||
|
// Empty entries with matches means the `before` cursor excluded every match
|
||||||
|
// (they all sit at index >= before): the agent paged too far back and should
|
||||||
|
// raise `before` or omit it, not page back further.
|
||||||
|
const body = result.entries.length > 0
|
||||||
|
? result.entries.map(renderEntry).join("\n\n")
|
||||||
|
: (result.matched === 0
|
||||||
|
? "(nothing matched; try widening roles/include, dropping search, or raising limit)"
|
||||||
|
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches are later — raise 'before' or omit it)`);
|
||||||
|
return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools that let an agent spawn *tracked* child sessions and inspect them.
|
* Tools that let an agent spawn *tracked* child sessions and inspect them.
|
||||||
*
|
*
|
||||||
@@ -74,7 +174,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
|||||||
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
|
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
|
||||||
name: "spawn_subsession",
|
name: "spawn_subsession",
|
||||||
label: "Spawn subsession",
|
label: "Spawn subsession",
|
||||||
description: "Start a tracked child session and send it an initial prompt. The subsession runs independently and a human can interact with it, but unlike spawn_session it is linked to you: you are notified when it stops working (finished, idle, or errored), and you can inspect it with list_subsessions and read_subsession. Use this to delegate work you intend to follow up on.",
|
description: "Start a tracked child session and send it an initial prompt. The subsession runs independently and a human can interact with it, but unlike spawn_session it is linked to you: you are notified when it stops working (finished, idle, or errored), and you can inspect it with list_subsessions, check_subsession (a quick glance at its latest output), and read_subsession (read through its transcript). Use this to delegate work you intend to follow up on.",
|
||||||
promptSnippet: "spawn_subsession: start a tracked child session you will be notified about",
|
promptSnippet: "spawn_subsession: start a tracked child session you will be notified about",
|
||||||
parameters: SpawnSubsessionParams,
|
parameters: SpawnSubsessionParams,
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
@@ -104,15 +204,15 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
|
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
|
||||||
name: "read_subsession",
|
name: "check_subsession",
|
||||||
label: "Read subsession",
|
label: "Check subsession",
|
||||||
description: "Read a subsession you spawned: its status and final result. Returns the subsession's most recent assistant output so you can react to what it produced.",
|
description: "Quick glance at a subsession you spawned: its current status and most recent assistant output. Use this to react to what a subsession produced. When the summary is not enough, use read_subsession to look through its full transcript.",
|
||||||
promptSnippet: "read_subsession: read the result of a subsession you spawned",
|
promptSnippet: "check_subsession: glance at a subsession's status and latest output",
|
||||||
parameters: ReadSubsessionParams,
|
parameters: CheckSubsessionParams,
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||||
const result = await deps.read(parentSessionId, params.sessionId);
|
const result = await deps.check(parentSessionId, params.sessionId);
|
||||||
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
|
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
|
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
|
||||||
@@ -121,5 +221,22 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return [spawnTool, listTool, readTool];
|
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
|
||||||
|
name: "read_subsession",
|
||||||
|
label: "Read subsession",
|
||||||
|
description: "Read through the transcript of a subsession you spawned. Returns its messages filtered and paginated however you ask: choose which roles (assistant, user, tool, system, custom) and content kinds (text, thinking, tool_call, tool_result, image) to include, search for a substring (always over full content), cap each value's length with maxChars (omit for full text; clipped parts are flagged so truncation is never silent), optionally include raw tool args, and page backward with 'before'/'limit'. Start narrow (e.g. assistant text with a small maxChars) and widen the filters, raise maxChars, or page further back if you don't find what you need. For just the final result, use check_subsession instead.",
|
||||||
|
promptSnippet: "read_subsession: read through a subsession's transcript with filters",
|
||||||
|
parameters: ReadSubsessionParams,
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||||
|
const { sessionId, ...query } = params;
|
||||||
|
const result = await deps.read(parentSessionId, sessionId, query);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: renderTranscript(result) }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return [spawnTool, listTool, checkTool, readTool];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||||
|
|
||||||
|
const user = (text: string) => ({ role: "user", content: text });
|
||||||
|
const assistant = (text: string) => ({ role: "assistant", content: [{ type: "text", text }] });
|
||||||
|
const thinking = (text: string) => ({ role: "assistant", content: [{ type: "thinking", thinking: text }] });
|
||||||
|
const toolCall = (name: string, args?: unknown) => ({ role: "assistant", content: [{ type: "toolCall", name, ...(args === undefined ? {} : { arguments: args }) }] });
|
||||||
|
const toolResult = (text: string, toolName = "bash", isError = false) => ({ role: "toolResult", toolName, content: text, isError });
|
||||||
|
const custom = (text: string) => ({ role: "custom", content: text, customType: "subsession.completion" });
|
||||||
|
|
||||||
|
describe("buildTranscriptView", () => {
|
||||||
|
it("returns all entries with stable indices by default", () => {
|
||||||
|
const messages = [user("do it"), thinking("plan"), toolCall("bash"), toolResult("ok"), assistant("done")];
|
||||||
|
const view = buildTranscriptView(messages);
|
||||||
|
|
||||||
|
expect(view.total).toBe(5);
|
||||||
|
expect(view.matched).toBe(5);
|
||||||
|
expect(view.entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3, 4]);
|
||||||
|
expect(view.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by role", () => {
|
||||||
|
const messages = [user("do it"), thinking("plan"), assistant("done")];
|
||||||
|
const view = buildTranscriptView(messages, { roles: ["assistant"] });
|
||||||
|
|
||||||
|
expect(view.matched).toBe(2); // thinking + text are both assistant-role
|
||||||
|
expect(view.entries.every((entry) => entry.role === "assistant")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by content kind, dropping entries left empty", () => {
|
||||||
|
const messages = [user("do it"), thinking("plan"), assistant("answer"), toolCall("bash")];
|
||||||
|
const view = buildTranscriptView(messages, { include: ["text"] });
|
||||||
|
|
||||||
|
// user text + assistant text survive; thinking-only and tool_call-only entries drop out
|
||||||
|
expect(view.matched).toBe(2);
|
||||||
|
expect(view.entries.flatMap((entry) => entry.parts.map((part) => part.kind))).toEqual(["text", "text"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not truncate by default and omits tool args", () => {
|
||||||
|
const long = "x".repeat(800);
|
||||||
|
const messages = [assistant(long), toolCall("bash", { command: "ls", extra: "y" })];
|
||||||
|
const view = buildTranscriptView(messages);
|
||||||
|
|
||||||
|
const textPart = view.entries[0]?.parts[0];
|
||||||
|
if (textPart?.kind !== "text") throw new Error("expected text part");
|
||||||
|
expect(textPart.text).toBe(long);
|
||||||
|
expect(textPart.truncated).toBeUndefined();
|
||||||
|
|
||||||
|
const callPart = view.entries[1]?.parts[0];
|
||||||
|
if (callPart?.kind !== "tool_call") throw new Error("expected tool_call part");
|
||||||
|
expect(callPart.summary).toBe("ls");
|
||||||
|
expect("args" in callPart).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maxChars clips text and flags it with the full length", () => {
|
||||||
|
const long = "x".repeat(800);
|
||||||
|
const messages = [assistant(long)];
|
||||||
|
const view = buildTranscriptView(messages, { maxChars: 100 });
|
||||||
|
|
||||||
|
const textPart = view.entries[0]?.parts[0];
|
||||||
|
if (textPart?.kind !== "text") throw new Error("expected text part");
|
||||||
|
expect(textPart.text).toBe("x".repeat(100));
|
||||||
|
expect(textPart.truncated).toEqual({ shown: 100, full: 800 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maxChars does not flag values at or under the cap", () => {
|
||||||
|
const messages = [assistant("short")];
|
||||||
|
const view = buildTranscriptView(messages, { maxChars: 100 });
|
||||||
|
const textPart = view.entries[0]?.parts[0];
|
||||||
|
if (textPart?.kind !== "text") throw new Error("expected text part");
|
||||||
|
expect(textPart.text).toBe("short");
|
||||||
|
expect(textPart.truncated).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includeToolArgs returns raw args alongside the summary", () => {
|
||||||
|
const messages = [toolCall("bash", { command: "ls" })];
|
||||||
|
const view = buildTranscriptView(messages, { includeToolArgs: true });
|
||||||
|
const callPart = view.entries[0]?.parts[0];
|
||||||
|
if (callPart?.kind !== "tool_call") throw new Error("expected tool_call part");
|
||||||
|
expect(callPart.summary).toBe("ls");
|
||||||
|
expect(callPart.args).toEqual({ command: "ls" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search keeps only matching entries across text and tool names", () => {
|
||||||
|
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read")];
|
||||||
|
const view = buildTranscriptView(messages, { search: "auth" });
|
||||||
|
|
||||||
|
expect(view.matched).toBe(2);
|
||||||
|
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search runs against full content even when maxChars would clip the match away", () => {
|
||||||
|
// The match sits past the clip point; a window-first or clip-first search would miss it.
|
||||||
|
const text = `${"a".repeat(300)} NEEDLE ${"b".repeat(300)}`;
|
||||||
|
const messages = [assistant(text)];
|
||||||
|
const view = buildTranscriptView(messages, { search: "needle", maxChars: 50 });
|
||||||
|
|
||||||
|
expect(view.matched).toBe(1);
|
||||||
|
const textPart = view.entries[0]?.parts[0];
|
||||||
|
if (textPart?.kind !== "text") throw new Error("expected text part");
|
||||||
|
// The match is found, and the returned (clipped) text honestly flags truncation.
|
||||||
|
expect(textPart.truncated).toEqual({ shown: 50, full: text.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search matches tool-call arguments even without includeToolArgs", () => {
|
||||||
|
const messages = [toolCall("bash", { command: "grep NEEDLE src" })];
|
||||||
|
const view = buildTranscriptView(messages, { search: "needle" });
|
||||||
|
expect(view.matched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search finds args the display summary would drop (edit/write content, nested, beyond first 3 keys)", () => {
|
||||||
|
// summarizeToolArgs collapses these to 'edit text replacement' / 'object' / first-3-keys,
|
||||||
|
// so matching must serialize the full args, not the summary.
|
||||||
|
const editArgs = { oldText: "before", newText: "NEEDLE_IN_NEWTEXT" };
|
||||||
|
const nestedArgs = { a: 1, b: 2, c: 3, payload: { deep: "NEEDLE_NESTED" } };
|
||||||
|
const messages = [toolCall("edit", editArgs), toolCall("write", nestedArgs)];
|
||||||
|
|
||||||
|
expect(buildTranscriptView(messages, { search: "needle_in_newtext" }).matched).toBe(1);
|
||||||
|
expect(buildTranscriptView(messages, { search: "needle_nested" }).matched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maxChars boundary: exact length is not flagged, one over is", () => {
|
||||||
|
const exact = buildTranscriptView([assistant("x".repeat(50))], { maxChars: 50 }).entries[0]?.parts[0];
|
||||||
|
if (exact?.kind !== "text") throw new Error("expected text part");
|
||||||
|
expect(exact.truncated).toBeUndefined();
|
||||||
|
|
||||||
|
const over = buildTranscriptView([assistant("x".repeat(51))], { maxChars: 50 }).entries[0]?.parts[0];
|
||||||
|
if (over?.kind !== "text") throw new Error("expected text part");
|
||||||
|
expect(over.truncated).toEqual({ shown: 50, full: 51 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maxChars: 0 clips everything and flags it (not treated as 'no cap')", () => {
|
||||||
|
const part = buildTranscriptView([assistant("abc")], { maxChars: 0 }).entries[0]?.parts[0];
|
||||||
|
if (part?.kind !== "text") throw new Error("expected text part");
|
||||||
|
expect(part.text).toBe("");
|
||||||
|
expect(part.truncated).toEqual({ shown: 0, full: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negative or fractional maxChars is coerced to a safe non-negative integer, never 'no cap'", () => {
|
||||||
|
const negative = buildTranscriptView([assistant("abc")], { maxChars: -5 }).entries[0]?.parts[0];
|
||||||
|
if (negative?.kind !== "text") throw new Error("expected text part");
|
||||||
|
expect(negative.text).toBe(""); // coerced to 0, still truncates
|
||||||
|
expect(negative.truncated).toEqual({ shown: 0, full: 3 });
|
||||||
|
|
||||||
|
const fractional = buildTranscriptView([assistant("abcdef")], { maxChars: 2.9 }).entries[0]?.parts[0];
|
||||||
|
if (fractional?.kind !== "text") throw new Error("expected text part");
|
||||||
|
expect(fractional.text).toBe("ab"); // floored to 2
|
||||||
|
expect(fractional.truncated).toEqual({ shown: 2, full: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty window with matches reports matched > 0 (paged past all matches)", () => {
|
||||||
|
const messages = [assistant("a"), assistant("b"), assistant("c")];
|
||||||
|
const view = buildTranscriptView(messages, { before: 0 });
|
||||||
|
expect(view.entries).toEqual([]);
|
||||||
|
expect(view.matched).toBe(3); // matches exist, the window just excluded them
|
||||||
|
expect(view.start).toBe(0);
|
||||||
|
expect(view.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pages from the end and reports hasMore", () => {
|
||||||
|
const messages = [assistant("a"), assistant("b"), assistant("c"), assistant("d")];
|
||||||
|
const view = buildTranscriptView(messages, { limit: 2 });
|
||||||
|
|
||||||
|
expect(view.entries.map((entry) => entry.index)).toEqual([2, 3]);
|
||||||
|
expect(view.matched).toBe(4);
|
||||||
|
expect(view.start).toBe(2);
|
||||||
|
expect(view.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pages backward using before: previous start", () => {
|
||||||
|
const messages = [assistant("a"), assistant("b"), assistant("c"), assistant("d")];
|
||||||
|
const view = buildTranscriptView(messages, { limit: 2, before: 2 });
|
||||||
|
|
||||||
|
expect(view.entries.map((entry) => entry.index)).toEqual([0, 1]);
|
||||||
|
expect(view.start).toBe(0);
|
||||||
|
expect(view.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limit bounds matched entries, not raw messages", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1"), user("u2"), assistant("a2"), user("u3"), assistant("a3")];
|
||||||
|
const view = buildTranscriptView(messages, { roles: ["assistant"], limit: 2 });
|
||||||
|
|
||||||
|
expect(view.matched).toBe(3);
|
||||||
|
expect(view.entries.map((entry) => entry.index)).toEqual([3, 5]);
|
||||||
|
expect(view.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports start as before when nothing matches in the window", () => {
|
||||||
|
const messages = [assistant("a"), assistant("b")];
|
||||||
|
const view = buildTranscriptView(messages, { search: "absent" });
|
||||||
|
|
||||||
|
expect(view.entries).toEqual([]);
|
||||||
|
expect(view.matched).toBe(0);
|
||||||
|
expect(view.start).toBe(2);
|
||||||
|
expect(view.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes custom and system roles", () => {
|
||||||
|
const messages = [custom("subsession done"), { role: "system", source: "compaction", content: "Compacted history:\n\nstuff" }];
|
||||||
|
const all = buildTranscriptView(messages);
|
||||||
|
expect(all.entries.map((entry) => entry.role)).toEqual(["custom", "system"]);
|
||||||
|
|
||||||
|
const onlyCustom = buildTranscriptView(messages, { roles: ["custom"] });
|
||||||
|
expect(onlyCustom.matched).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -63,7 +63,8 @@ export interface PiWebConfigValues {
|
|||||||
spawnSessions?: boolean;
|
spawnSessions?: boolean;
|
||||||
/**
|
/**
|
||||||
* Beta: when true, LLMs can start tracked child sessions via the
|
* Beta: when true, LLMs can start tracked child sessions via the
|
||||||
* spawn_subsession / list_subsessions / read_subsession tools. Off by default
|
* spawn_subsession / list_subsessions / check_subsession / read_subsession
|
||||||
|
* tools. Off by default
|
||||||
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
||||||
*/
|
*/
|
||||||
subsessions?: boolean;
|
subsessions?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user