/** * Renderer for inline command expansion tokens in skill bodies. * * Given a skill body and its parsed `InlineCommandExpansion` descriptors, * replaces each `!\`command\`` token by executing the command through the * sandbox-only runner and wrapping the result in XML tags: * * ...output... * * Render failures produce stable inline stubs rather than dumping raw * shell stderr into the prompt: * * [inline command unavailable: ] */ import { getLogger } from "../util/logger.js"; import { escapeXmlContent } from "../util/xml.js"; import type { InlineCommandExpansion } from "./inline-command-expansions.js"; import type { InlineCommandResult } from "./inline-command-runner.js"; import { runInlineCommand } from "./inline-command-runner.js"; const log = getLogger("inline-command-render"); // ─── Types ──────────────────────────────────────────────────────────────────── /** Result of rendering all inline command expansions in a skill body. */ export interface InlineCommandRenderResult { /** The body with all inline command tokens replaced. */ renderedBody: string; /** Count of successfully expanded tokens. */ expandedCount: number; /** Count of tokens that failed to expand (rendered as stubs). */ failedCount: number; } // ─── Failure reason mapping ─────────────────────────────────────────────────── /** * Map a machine-readable failure reason to a human-readable stub message * suitable for inclusion in the prompt. These messages are intentionally * terse and deterministic so they don't leak raw stderr or confuse the LLM. */ function failureReasonToStub(result: InlineCommandResult): string { switch (result.failureReason) { case "timeout": return "command timed out"; case "non_zero_exit": return "command failed"; case "binary_output": return "command produced binary output"; case "spawn_failure": return "command could not be started"; default: return "unknown error"; } } // ─── Public API ─────────────────────────────────────────────────────────────── /** * Render all inline command expansion tokens in a skill body. * * Each `!\`command\`` token is executed through the sandbox-only runner and * replaced with its output wrapped in XML tags. Expansions are processed * sequentially (not in parallel) to keep execution order deterministic and * avoid overwhelming the sandbox. * * @param body The skill body containing `!\`command\`` tokens. * @param expansions Parsed expansion descriptors from `parseInlineCommandExpansions`. * @param workingDir The conversation's working directory (repo root). */ export async function renderInlineCommands( body: string, expansions: InlineCommandExpansion[], workingDir: string, ): Promise { if (expansions.length === 0) { return { renderedBody: body, expandedCount: 0, failedCount: 0 }; } let expandedCount = 0; let failedCount = 0; // Process replacements in reverse offset order so that earlier offsets // remain valid after splicing in replacement text. const sorted = [...expansions].sort((a, b) => b.startOffset - a.startOffset); let result = body; for (const expansion of sorted) { const commandResult = await runInlineCommand(expansion.command, workingDir); let replacement: string; if (commandResult.ok) { replacement = wrapInXml( expansion.placeholderId, escapeXmlContent(commandResult.output), ); expandedCount++; } else { const stub = failureReasonToStub(commandResult); replacement = wrapInXml( expansion.placeholderId, `[inline command unavailable: ${stub}]`, ); failedCount++; log.warn( { command: expansion.command, placeholderId: expansion.placeholderId, failureReason: commandResult.failureReason, }, "Inline command expansion failed, rendering stub", ); } // Replace the original token with the rendered output result = result.slice(0, expansion.startOffset) + replacement + result.slice(expansion.endOffset); } return { renderedBody: result, expandedCount, failedCount }; } // ─── Helpers ────────────────────────────────────────────────────────────────── function wrapInXml(index: number, content: string): string { return `${content}`; }