File size: 7,173 Bytes
06c3755 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | // 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<string, unknown>).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 }
}
|