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:
Federico Jaramillo Martinez
2026-06-19 23:48:12 +02:00
parent 62c2234b85
commit 997b821717
9 changed files with 749 additions and 35 deletions
+29 -9
View File
@@ -32,7 +32,8 @@ import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
import { cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.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";
/**
@@ -267,7 +268,8 @@ export interface PiSessionServiceDependencies {
spawnTargets?: SpawnTargetResolver;
/**
* 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
* main without being exposed in releases.
*/
@@ -321,7 +323,8 @@ export class PiSessionService {
!subsessionsActive ? undefined : {
spawn: (input) => this.spawnSubsession(input),
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;
@@ -453,11 +456,8 @@ export class PiSessionService {
}
/** Status and final result of a subsession, scoped to the caller's children. */
async readSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionReadResult> {
if (this.subsessionParents.get(sessionId) !== parentSessionId) {
throw new Error(`Session ${sessionId} is not one of your subsessions`);
}
const session = await this.getOrOpen(sessionId);
async checkSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult> {
const session = await this.openSubsession(parentSessionId, sessionId);
const messages = historyMessages(session);
return {
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 {
this.subsessionParents.set(childSessionId, parentSessionId);
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 finalText = finalAssistantText(historyMessages(session));
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);
}