// ADR-003 + ADR-004 + ADR-005 + v0.10.6: // 6 prompt part types → OpenAI native request body ({ messages, tools }). // shell state NEVER leaks into the prompt (the termination result is a // control signal for the caller only). // Tools are resolved from the registry by ref. // // The part types are: // 1. system — fixed system prompt // 2. userTemplate — templated user prompt (becomes the first user message) // 3. systemTool — reference to a registered system tool // 4. mcp — reference to one or more tools under a registered MCP server // 5. skill — reference to a registered module-form skill (tool_call path) // 6. skillText — pre-injected body of a text-form skill (v0.10.6). // Appended to the last system message so the LLM sees // the skill's content as part of the system prompt, // WITHOUT a tool_call round-trip. Text skills are pure // content (rules, guides, reference text) the model // should read before answering. // 7. turn — an already-translated ChatMessage from the IM databus // // The order of types in the union mirrors the ordering in the final prompt. import type { ToolRegistry } from './registry.js' import type { ChatMessage, OpenAITool } from '../protocol/types.js' import type { JSONSchema } from '../shared/json-schema.js' import { reasonField } from '../im/tools/helpers.js' // v0.10.5: inject the `reason` field into MCP/skill tool schemas at compose // time (the shell pattern). The server's original schema is never mutated — // compose appends `reason` to properties and `required` only in the request // body sent to the LLM. System tools already declare `reason` in their own // schema (ADR-013), so they are passed through unchanged. The registry's // execute guard then validates `reason` at runtime for MCP/skill calls. const withReason = (parameters: JSONSchema): JSONSchema => { const props = { ...(parameters.properties ?? {}), reason: reasonField } // `required` is readonly string[] in JSONSchema; build a fresh mutable array. const required = [...(parameters.required ?? []), 'reason'] return { ...parameters, type: 'object', properties: props, required } } export type PromptPart = | { type: 'system'; content: string } | { type: 'userTemplate'; content: string } | { type: 'systemTool'; ref: string } | { type: 'mcp'; server: string; refs: string[] } | { type: 'skill'; ref: string } | { type: 'skillText'; content: string; skillName: string } | { type: 'turn'; message: ChatMessage } // v0.18: progressive tool disclosure parts. // mcpServerSummary: server/skill description block injected into system message. | { type: 'mcpServerSummary'; content: string } // dynamicSchema: already-loaded tool schemas injected as system messages. // These carry `tools` in the message body — the wire contract for dynamic tool schemas. | { type: 'dynamicSchema'; tools: OpenAITool[]; sourceLabel: string } export type FinalPrompt = { messages: ChatMessage[] tools: OpenAITool[] } export const compose = (registry: ToolRegistry, parts: PromptPart[]): FinalPrompt => { const messages: ChatMessage[] = [] const tools: OpenAITool[] = [] for (const part of parts) { switch (part.type) { case 'system': messages.push({ role: 'system', content: part.content }) break case 'userTemplate': messages.push({ role: 'user', content: part.content }) break case 'systemTool': { const tool = registry.getSystemTool(part.ref) if (tool) { tools.push({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters }, }) } break } case 'mcp': { // v0.18: removed — MCP tools are now loaded dynamically via load_tools. // This case is a no-op for backward compat with callers still emitting // mcp parts. The tools are in the registry; load_tools picks them up. break } case 'skill': { // v0.18: removed — module skills are now loaded dynamically via load_tools. // This case is a no-op for backward compat with callers still emitting // skill parts. break } case 'mcpServerSummary': { // v0.18: inject server/skill summary block into the last system message. // Same pattern as skillText — append to existing system message, or create one. const lastSysForSummary = [...messages].reverse().find((m) => m.role === 'system') if (lastSysForSummary) { lastSysForSummary.content = `${lastSysForSummary.content}\n\n${part.content}` } else { messages.push({ role: 'system', content: part.content }) } break } case 'dynamicSchema': { // v0.18: append a system message carrying loaded tool schemas. // This is the wire contract for dynamic tool schemas (see dynamic-tool-context.ts). // The message's `tools` field is read by the provider to make these // tools callable in the current step. const content = part.sourceLabel ? `Loaded tools: ${part.sourceLabel}` : 'Loaded tools' messages.push({ role: 'system', content, } as ChatMessage & { tools: OpenAITool[] }) // Attach tools to the last message for the protocol layer to pick up. // The ChatMessage type doesn't have a `tools` field, but the wire format // supports it — this is the same pattern as dynamic tool schema injection // in KimiCode's context.ts. const lastMsg = messages[messages.length - 1]! ;(lastMsg as Record).tools = part.tools break } case 'skillText': { // v0.10.6: pre-inject a text-form skill's body into the system prompt. // The body is appended to the LAST system message already in `messages` // (the fixed system prompt + any earlier skillText parts), separated by // a blank line. If no system message exists yet, create one — this // keeps skillText usable even if the caller omitted a `system` part // (defensive, not the common path: loop.ts always emits a system part // first). // // Mutating `lastSys.content` in place is safe here because `messages` // is a local array built by this very function — no external aliasing. // Each skillText part appends in iteration order, so multiple text // skills stack under the same system message in declaration order. const lastSys = [...messages].reverse().find((m) => m.role === 'system') if (lastSys) { lastSys.content = `${lastSys.content}\n\n${part.content}` } else { messages.push({ role: 'system', content: part.content }) } break } case 'turn': messages.push(part.message) break } } return { messages, tools } }