agent-shell / src /shell /compose.ts
uos1231234's picture
agent-shell published to Hugging Face
06c3755
Raw
History Blame Contribute Delete
7.17 kB
// 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 }
}