import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ToolCall } from "@earendil-works/pi-ai"; import type { SessionEntry } from "@earendil-works/pi-coding-agent"; import type { CouncilContext, ParticipantId, ParticipantRunner, ParticipantRuntime, ParticipantState, } from "./types"; interface AssistantContextChild { readonly kind: "text" | "thinking"; readonly content: string; } type ContextBlock = | { readonly kind: "user"; readonly content: string } | { readonly kind: "custom"; readonly content: string } | { readonly kind: "assistant"; readonly children: readonly AssistantContextChild[]; } | { readonly kind: "tool"; readonly content: string }; /** Renders raw active-branch evidence as the participant context-file body. */ export function renderExternalContextPackage( entries: readonly SessionEntry[], toolCallId: string, ): string { const toolCalls = collectVisibleToolCalls(entries, toolCallId); const blocks = mergeAdjacentBlocks( entries.flatMap((entry) => renderContextEntry(entry, toolCallId, toolCalls), ), ).map(renderContextBlock); return ["", ...blocks, ""].join("\n"); } /** Records visible tool-call arguments before later tool-result rendering. */ function collectVisibleToolCalls( entries: readonly SessionEntry[], filteredToolCallId: string, ): ReadonlyMap { const toolCalls = new Map(); for (const entry of entries) { if (entry.type !== "message" || entry.message.role !== "assistant") { continue; } for (const block of entry.message.content) { if (block.type === "toolCall" && block.id !== filteredToolCallId) { toolCalls.set(block.id, block); } } } return toolCalls; } /** Converts one active-branch entry into decision-context blocks. */ function renderContextEntry( entry: SessionEntry, filteredToolCallId: string, toolCalls: ReadonlyMap, ): ContextBlock[] { switch (entry.type) { case "message": return renderContextMessage(entry.message, filteredToolCallId, toolCalls); case "custom_message": { const content = renderTextualContent(entry.content); return content.length === 0 ? [] : [{ kind: "custom", content }]; } case "compaction": case "branch_summary": case "custom": case "label": case "session_info": case "model_change": case "thinking_level_change": return []; } } /** Converts one parent-session message into zero or more context evidence blocks. */ function renderContextMessage( message: AgentMessage, filteredToolCallId: string, toolCalls: ReadonlyMap, ): ContextBlock[] { switch (message.role) { case "user": { const content = renderTextualContent(message.content); return content.length === 0 ? [] : [{ kind: "user", content }]; } case "assistant": { const children = mergeAdjacentAssistantChildren( message.content.flatMap((block): AssistantContextChild[] => { if (block.type === "text") { return block.text.length === 0 ? [] : [{ kind: "text", content: block.text }]; } if (block.type === "thinking") { return block.thinking.length === 0 ? [] : [{ kind: "thinking", content: block.thinking }]; } return []; }), ); return children.length === 0 ? [] : [{ kind: "assistant", children }]; } case "toolResult": return message.toolCallId === filteredToolCallId ? [] : [ { kind: "tool", content: renderToolResult( message, toolCalls.get(message.toolCallId), ), }, ]; case "custom": return []; case "branchSummary": case "compactionSummary": return []; case "bashExecution": return message.excludeFromContext === true ? [] : [{ kind: "tool", content: renderBashExecution(message) }]; } } /** Extracts stored text while skipping image blocks without artificial markers. */ function renderTextualContent( content: Extract["content"], ): string { if (typeof content === "string") { return content; } return content .flatMap((block) => (block.type === "text" ? [block.text] : [])) .join("\n"); } /** Merges adjacent same-kind actor blocks while preserving tool boundaries. */ function mergeAdjacentBlocks(blocks: readonly ContextBlock[]): ContextBlock[] { const merged: ContextBlock[] = []; for (const block of blocks) { const previous = merged.at(-1); if (previous === undefined || previous.kind !== block.kind) { merged.push(block); continue; } if (block.kind === "tool") { merged.push(block); continue; } if (previous.kind === "assistant" && block.kind === "assistant") { merged[merged.length - 1] = { kind: "assistant", children: mergeAdjacentAssistantChildren([ ...previous.children, ...block.children, ]), }; continue; } if (previous.kind === "user" && block.kind === "user") { merged[merged.length - 1] = { kind: "user", content: mergeText(previous.content, block.content), }; continue; } if (previous.kind === "custom" && block.kind === "custom") { merged[merged.length - 1] = { kind: "custom", content: mergeText(previous.content, block.content), }; } } return merged; } /** Merges adjacent assistant text or thinking segments without crossing segment kinds. */ function mergeAdjacentAssistantChildren( children: readonly AssistantContextChild[], ): AssistantContextChild[] { const merged: AssistantContextChild[] = []; for (const child of children) { const previous = merged.at(-1); if (previous === undefined || previous.kind !== child.kind) { merged.push(child); continue; } merged[merged.length - 1] = { kind: previous.kind, content: mergeText(previous.content, child.content), }; } return merged; } /** Adds one structural newline between merged evidence fragments for LLM readability. */ function mergeText(left: string, right: string): string { return `${left}\n${right}`; } /** Converts one merged context block into the XML-like reference document. */ function renderContextBlock(block: ContextBlock): string { switch (block.kind) { case "user": return renderTextBlock("user", block.content); case "custom": return renderTextBlock("custom", block.content); case "assistant": return [ "", ...block.children.map((child) => renderTextBlock(child.kind, child.content), ), "", ].join("\n"); case "tool": return block.content; } } /** Renders one text wrapper only when it carries participant-useful content. */ function renderTextBlock(tag: string, content: string): string { return `<${tag}>\n${escapeContextText(content)}\n`; } /** Escapes only fake section starts in readable text nodes. */ function escapeContextText(value: string): string { return value.replaceAll("<", "<"); } /** Escapes values that live inside XML-like attributes. */ function escapeContextAttribute(value: string): string { return value .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("<", "<"); } /** Renders one tool result as evidence and attaches matching call arguments when available. */ function renderToolResult( message: Extract, toolCall: ToolCall | undefined, ): string { const lines = [ ``, ]; if (toolCall !== undefined) { lines.push( "", escapeContextText(JSON.stringify(toolCall.arguments)), "", ); } lines.push( "", escapeContextText(renderTextualContent(message.content)), "", "", ); return lines.join("\n"); } /** Normalizes interactive shell entries into the same tool evidence shape as bash tool results. */ function renderBashExecution( message: Extract, ): string { const attributes = [ 'name="bash"', `status="${message.exitCode === 0 && message.cancelled !== true ? "ok" : "error"}"`, ...(message.exitCode === undefined ? [] : [`exitCode="${escapeContextAttribute(String(message.exitCode))}"`]), ...(message.cancelled === undefined ? [] : [`cancelled="${escapeContextAttribute(String(message.cancelled))}"`]), ...(message.truncated === undefined ? [] : [`truncated="${escapeContextAttribute(String(message.truncated))}"`]), ]; return [ ``, "", escapeContextText(JSON.stringify({ command: message.command })), "", "", escapeContextText(message.output), "", ...(message.fullOutputPath === undefined ? [] : [ "", escapeContextText(message.fullOutputPath), "", ]), "", ].join("\n"); } /** Builds parent-session evidence for the first participant prompt. */ export async function buildExternalCouncilContextPackage(options: { readonly ctx: CouncilContext; readonly toolCallId: string; }): Promise { return renderExternalContextPackage( options.ctx.sessionManager.getBranch(), options.toolCallId, ); } /** Creates the initial participant state with an isolated conversation history. */ export function createParticipantState( id: ParticipantId, runtime: ParticipantRuntime, runner: ParticipantRunner, ): ParticipantState { return { id, runtime, runner, history: [], reviewedOpponent: false, }; }