* `SubAgentSession` — wraps `AgentLoop.run` for a forked subagent invocation
* (C2 §6.2). Builds the forked message sequence, scopes the tool registry to
* `allowedTools`, drops project-instructions / git-status from the system prompt, and
* collects the final assistant report into a {@link SubagentReport}.
*
* The subagent always returns a single text report — even if the model
* produces extra tool calls, we trust the AgentLoop to drive them to a
* terminal `assistant_message` whose text we extract.
*/
import {
AgentLoop,
type AgentLoopRunResult,
} from "../loop/AgentLoop.js";
import type { AgentEvent } from "../protocol/events.js";
import type {
CanonicalAssistantTextSummary,
} from "./types.js";
import type {
CanonicalMessage,
CanonicalUsage,
} from "../../model/index.js";
import type { AgentRuntimeConfig } from "../runtime/AgentRuntimeConfig.js";
import type { AgentRuntimeDependencies } from "../runtime/AgentRuntimeDependencies.js";
import { ToolRegistry } from "../../tool/registry/ToolRegistry.js";
import type {
PilotDeckReadFileStateMap,
PilotDeckToolDefinition,
PilotDeckWriteSnapshotMap,
} from "../../tool/index.js";
import { ConcurrentToolScheduler } from "../../tool/scheduler/ConcurrentToolScheduler.js";
import { ToolRuntime } from "../../tool/execution/ToolRuntime.js";
import { PermissionRuntime } from "../../permission/index.js";
import {
buildForkedMessages,
} from "./buildForkedMessages.js";
import {
buildSubagentSystemPrompt,
type SubagentDefinition,
} from "./builtinSubagentTypes.js";
import {
applySystemPromptFilters,
cloneReadFileState,
cloneWriteSnapshots,
} from "./contextInheritance.js";
import { filterIncompleteToolCalls } from "./filterIncompleteToolCalls.js";
const SUMMARY_FIELDS = ["Scope", "Result", "Key files", "Files changed", "Issues"] as const;
const SUBAGENT_DEFAULT_MAX_TURNS = 16;
export type SubAgentSessionOptions = {
definition: SubagentDefinition;
directive: string;
* Parent's accumulated message history. We slice off the *last* assistant
* message to seed the fork (S1). Caller should pass parent's full history
* up to and including the assistant turn that issued the `agent` tool call.
*/
parentMessages: CanonicalMessage[];
parentConfig: AgentRuntimeConfig;
parentDependencies: AgentRuntimeDependencies;
parentReadFileState?: PilotDeckReadFileStateMap;
parentWriteSnapshots?: PilotDeckWriteSnapshotMap;
parentSessionId: string;
parentTurnId: string;
subagentSessionId: string;
subagentId: string;
maxTurns?: number;
abortSignal?: AbortSignal;
* Optional sidechain transcript writer for C3. When provided, each
* AgentLoop event that produces a durable message is mirrored here. The
* parent transcript only gets the started/completed reference entries.
*/
sidechainTranscript?: SidechainTranscriptWriter;
};
* Minimal sidechain writer surface used by SubAgentSession. Lives in this
* module so `agent/sub` doesn't import the session storage layer directly
* (the parent constructs the writer and passes it in).
*/
export type SidechainTranscriptWriter = {
recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): Promise<void>;
recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): Promise<void>;
};
export type SubagentReport = {
subagentId: string;
definitionId: string;
markdown: string;
parsed?: CanonicalAssistantTextSummary;
usage: CanonicalUsage;
turns: number;
durationMs: number;
};
export class SubAgentSession {
constructor(private readonly options: SubAgentSessionOptions) {}
async run(): Promise<SubagentReport> {
const startedAt = Date.now();
const messages = this.buildInitialMessages();
const subRegistry = this.buildScopedRegistry();
const subDependencies = this.cloneDependencies(subRegistry);
const subConfig = this.buildConfig();
const loop = new AgentLoop(subConfig, subDependencies, {
readFileState: cloneReadFileState(this.options.parentReadFileState),
writeSnapshots: cloneWriteSnapshots(this.options.parentWriteSnapshots),
});
let last: AgentLoopRunResult | undefined;
const turnId = `${this.options.subagentId}-t0`;
if (this.options.sidechainTranscript) {
await this.options.sidechainTranscript.recordAcceptedInput(
this.options.subagentSessionId,
turnId,
messages,
);
}
const generator = loop.run({
sessionId: this.options.subagentSessionId,
turnId,
messages,
maxTurns: this.options.maxTurns ?? SUBAGENT_DEFAULT_MAX_TURNS,
abortSignal: this.options.abortSignal,
});
while (true) {
const next = await generator.next();
if (next.done) {
last = next.value;
break;
}
const event = next.value;
this.forwardActivity(event);
if (
this.options.sidechainTranscript &&
(event.type === "assistant_message" || event.type === "tool_results_projected")
) {
await this.options.sidechainTranscript.recordDurableMessage(
this.options.subagentSessionId,
turnId,
event.type === "assistant_message" ? event.message : event.message,
);
}
}
if (!last) {
throw new Error("SubAgentSession: AgentLoop returned no result");
}
if (last.result.type === "error") {
const details = last.result.errors?.map((error) => error.message).join("; ");
throw new Error(
`SubAgentSession: subagent turn failed (${last.result.stopReason})${details ? `: ${details}` : ""}`,
);
}
const text = extractFinalAssistantText(last.messages);
const parsed = parseSummary(text);
return {
subagentId: this.options.subagentId,
definitionId: this.options.definition.id,
markdown: text,
parsed,
usage: last.result.usage,
turns: last.result.turns,
durationMs: Date.now() - startedAt,
};
}
private buildInitialMessages(): CanonicalMessage[] {
const parentLast = this.options.parentMessages[this.options.parentMessages.length - 1];
if (!parentLast || parentLast.role !== "assistant") {
const synthetic: CanonicalMessage = {
role: "assistant",
content: [
{
type: "text",
text: "(parent did not produce an assistant message before forking)",
},
],
};
return filterIncompleteToolCalls(buildForkedMessages(this.options.directive, synthetic));
}
return filterIncompleteToolCalls(
buildForkedMessages(this.options.directive, parentLast),
);
}
private buildScopedRegistry(): ToolRegistry {
const scoped = new ToolRegistry();
const allowedSet = new Set(this.options.definition.allowedTools);
const wildcard = allowedSet.has("*");
const forceReadOnly = this.options.definition.isReadOnly
|| this.options.parentConfig.permissionMode === "plan";
for (const tool of this.options.parentDependencies.tools.registry.list()) {
if (tool.name === "enter_plan_mode" || tool.name === "exit_plan_mode") {
continue;
}
if (tool.name === "agent") {
continue;
}
if (tool.name.startsWith("always_on_")) {
continue;
}
if (tool.name === "ask_user_question") {
continue;
}
if (forceReadOnly && tool.isDestructive?.({} as never) === true) {
continue;
}
if (!wildcard && !allowedSet.has(tool.name)) continue;
scoped.register(tool as PilotDeckToolDefinition);
}
return scoped;
}
private forwardActivity(event: AgentEvent): void {
const emit = this.options.parentDependencies.eventEmitter;
if (!emit) return;
const base = {
sessionId: this.options.parentSessionId,
turnId: this.options.parentTurnId,
subagentId: this.options.subagentId,
subagentType: this.options.definition.id,
};
if (event.type === "model_event") {
emit({
type: "subagent_model_event",
...base,
event: event.event,
});
return;
}
if (event.type === "tool_calls_detected") {
emit({
type: "subagent_tool_calls_detected",
...base,
calls: event.calls,
});
return;
}
if (event.type === "tool_result") {
emit({
type: "subagent_tool_result",
...base,
result: event.result,
});
}
}
private cloneDependencies(registry: ToolRegistry): AgentRuntimeDependencies {
const permissionRuntime = new PermissionRuntime();
const toolRuntime = new ToolRuntime(
registry,
permissionRuntime,
this.options.parentDependencies.lifecycle,
this.options.parentDependencies.eventEmitter,
);
const scheduler = new ConcurrentToolScheduler(toolRuntime, registry);
return {
router: this.options.parentDependencies.router,
tools: { scheduler, registry },
context: this.options.parentDependencies.context,
now: this.options.parentDependencies.now,
uuid: this.options.parentDependencies.uuid,
auditRecorder: this.options.parentDependencies.auditRecorder,
lifecycle: this.options.parentDependencies.lifecycle,
subagentTranscript: this.options.parentDependencies.subagentTranscript,
};
}
private buildConfig(): AgentRuntimeConfig {
const parent = this.options.parentConfig;
const subagentSystem = buildSubagentSystemPrompt(this.options.definition);
const filteredParentSystem = applySystemPromptFilters(
parent.systemPrompt ?? "",
this.options.definition,
);
const systemPrompt = filteredParentSystem.length > 0
? `${subagentSystem}\n\n${filteredParentSystem}`
: subagentSystem;
return {
...parent,
permissionContext: {
...parent.permissionContext,
rules: {
allow: parent.permissionContext.rules.allow,
deny: parent.permissionContext.rules.deny,
ask: parent.permissionContext.rules.ask,
},
},
systemPrompt,
stopOnStructuredOutput: false,
metadata: {
...(parent.metadata ?? {}),
subagentId: this.options.subagentId,
subagentType: this.options.definition.id,
},
};
}
}
function extractFinalAssistantText(messages: CanonicalMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (message.role !== "assistant") continue;
const parts: string[] = [];
for (const block of message.content) {
if (block.type === "text") parts.push(block.text);
}
if (parts.length > 0) return parts.join("\n").trim();
}
return "";
}
function parseSummary(text: string): CanonicalAssistantTextSummary | undefined {
const lines = text.split("\n");
const summary: Partial<CanonicalAssistantTextSummary> = {};
for (const field of SUMMARY_FIELDS) {
const idx = lines.findIndex((line) => line.startsWith(`${field}:`));
if (idx === -1) return undefined;
let value = lines[idx]!.slice(`${field}:`.length).trim();
for (let j = idx + 1; j < lines.length; j++) {
const next = lines[j]!;
if (SUMMARY_FIELDS.some((f) => next.startsWith(`${f}:`))) break;
value += "\n" + next;
}
(summary as Record<string, string>)[field] = value.trim();
}
return summary as CanonicalAssistantTextSummary;
}