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 }
}