diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f29d854f50a7..81d41da9c25d 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -9,6 +9,10 @@ export const Info = Schema.Struct({ urls: Schema.optional(Schema.Array(Schema.String)).annotate({ description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", }), + format: Schema.optional(Schema.Union([Schema.Literal("xml"), Schema.Literal("json"), Schema.Literal("markdown")])).annotate({ + description: + "Format used to serialize skills into the system prompt. Defaults to 'xml' for Anthropic models and 'json' for all others. Override if your model handles a specific format better.", + }), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/provider/openai-compatible-compat.ts b/packages/opencode/src/provider/openai-compatible-compat.ts new file mode 100644 index 000000000000..9968ce31e0cb --- /dev/null +++ b/packages/opencode/src/provider/openai-compatible-compat.ts @@ -0,0 +1,294 @@ +type ToolParserType = "raw-function-call" | "json" | "single-tool-text" + +type RawFunctionCallParser = { type: "raw-function-call" } +type JsonParser = { type: "json" } +type SingleToolTextParser = { type: "single-tool-text"; tool: string } + +export type ToolParserConfig = RawFunctionCallParser | JsonParser | SingleToolTextParser + +export function getOpenAICompatibleToolParsers(options: Record): ToolParserConfig[] { + const parsers = options["toolParser"] + if (!Array.isArray(parsers) || parsers.length === 0) return [] + return parsers.filter((p): p is ToolParserConfig => typeof p?.type === "string") as ToolParserConfig[] +} + +// For raw-function-call: convert modern tools/tool_choice to legacy functions/function_call format +export function rewriteOpenAICompatibleRequestBody(body: any, parsers: ToolParserConfig[]): any { + if (!parsers.some((p) => p.type === "raw-function-call")) return body + if (!Array.isArray(body.tools) || body.tools.length === 0) return body + + const functions = body.tools.map((t: any) => ({ + name: t.function?.name ?? t.name, + description: t.function?.description ?? t.description, + parameters: t.function?.parameters ?? t.parameters ?? { type: "object", properties: {} }, + })) + + const result = { ...body } + delete result.tools + delete result.tool_choice + result.functions = functions + result.function_call = "auto" + return result +} + +// For non-streaming JSON responses: recover tool calls from text content +export function rewriteOpenAICompatibleJsonResponse(body: any, parsers: ToolParserConfig[]): any { + if (!body?.choices?.[0]) return body + + const choice = body.choices[0] + const message = choice.message + if (!message) return body + + // Handle raw-function-call response: function_call field in message + if (parsers.some((p) => p.type === "raw-function-call") && message.function_call) { + return { + ...body, + choices: [ + { + ...choice, + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: `call-${Date.now()}`, + type: "function", + function: { + name: message.function_call.name, + arguments: message.function_call.arguments ?? "{}", + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + } + } + + // Handle json parser: model outputs {"name":"...","arguments":{...}} as text + if (parsers.some((p) => p.type === "json") && typeof message.content === "string" && message.content.trim()) { + try { + const parsed = JSON.parse(message.content.trim()) as { name?: string; arguments?: unknown } + if (typeof parsed.name === "string") { + return { + ...body, + choices: [ + { + ...choice, + message: { + ...message, + content: null, + tool_calls: [ + { + id: `call-${Date.now()}`, + type: "function", + function: { + name: parsed.name, + arguments: JSON.stringify(parsed.arguments ?? {}), + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + } + } + } catch {} + } + + // Handle single-tool-text: map bare text response to a named tool + const singleToolParser = parsers.find((p): p is SingleToolTextParser => p.type === "single-tool-text") + if (singleToolParser && typeof message.content === "string" && message.content.trim()) { + return { + ...body, + choices: [ + { + ...choice, + message: { + ...message, + content: null, + tool_calls: [ + { + id: `call-${Date.now()}`, + type: "function", + function: { + name: singleToolParser.tool, + arguments: message.content.trim(), + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + } + } + + return body +} + +interface SseChunk { + id?: string + object?: string + created?: number + model?: string + choices: Array<{ + index?: number + delta?: { + role?: string + content?: string | null + tool_calls?: any[] + function_call?: { name?: string; arguments?: string } + } + finish_reason?: string | null + }> + usage?: any +} + +// For SSE streaming responses: buffer the full text, accumulate content, rewrite as tool_calls if needed +export function rewriteOpenAICompatibleStreamResponse(text: string, parsers: ToolParserConfig[]): string { + const jsonParser = parsers.some((p) => p.type === "json") + const rawFuncParser = parsers.some((p) => p.type === "raw-function-call") + const singleToolParser = parsers.find((p): p is SingleToolTextParser => p.type === "single-tool-text") + + const lines = text.split("\n") + const dataChunks: Array<{ lineIndex: number; chunk: SseChunk }> = [] + let accumulatedText = "" + let accumulatedFuncName = "" + let accumulatedFuncArgs = "" + let hasFuncCall = false + let baseChunk: SseChunk | null = null + + // Parse all SSE data lines + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (!line.startsWith("data: ") || line === "data: [DONE]") continue + try { + const chunk = JSON.parse(line.slice(6)) as SseChunk + dataChunks.push({ lineIndex: i, chunk }) + if (!baseChunk) baseChunk = chunk + + const delta = chunk.choices?.[0]?.delta + if (delta?.content) accumulatedText += delta.content + if (delta?.function_call) { + hasFuncCall = true + if (delta.function_call.name) accumulatedFuncName += delta.function_call.name + if (delta.function_call.arguments) accumulatedFuncArgs += delta.function_call.arguments + } + } catch {} + } + + // Determine if we should rewrite + let toolCall: { name: string; arguments: string } | null = null + + if (rawFuncParser && hasFuncCall && accumulatedFuncName) { + toolCall = { name: accumulatedFuncName, arguments: accumulatedFuncArgs } + } else if (jsonParser && accumulatedText.trim()) { + try { + const parsed = JSON.parse(accumulatedText.trim()) as { name?: string; arguments?: unknown } + if (typeof parsed.name === "string") { + toolCall = { + name: parsed.name, + arguments: JSON.stringify(parsed.arguments ?? {}), + } + } + } catch {} + } else if (singleToolParser && accumulatedText.trim()) { + toolCall = { + name: singleToolParser.tool, + arguments: accumulatedText.trim(), + } + } + + if (!toolCall || !baseChunk) return text + + // Build rewritten SSE output + const callId = `call-${Date.now()}` + const base = baseChunk + + const makeDataLine = (chunk: SseChunk) => `data: ${JSON.stringify(chunk)}` + + const startChunk: SseChunk = { + ...base, + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: null, + tool_calls: [ + { + index: 0, + id: callId, + type: "function", + function: { name: toolCall.name, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + } + + const argsChunk: SseChunk = { + ...base, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: toolCall.arguments }, + }, + ], + }, + finish_reason: null, + }, + ], + } + + const finishChunk: SseChunk = { + ...base, + choices: [ + { + index: 0, + delta: {}, + finish_reason: "tool_calls", + }, + ], + } + + // Find the usage chunk (if any) and [DONE] line, preserve them + const usageLines: string[] = [] + let hasDone = false + for (const line of lines) { + if (line === "data: [DONE]") { + hasDone = true + continue + } + if (line.startsWith("data: ")) { + try { + const chunk = JSON.parse(line.slice(6)) as SseChunk + if (chunk.usage && (!chunk.choices || chunk.choices.length === 0)) { + usageLines.push(line) + } + } catch {} + } + } + + const output: string[] = [ + makeDataLine(startChunk), + "", + makeDataLine(argsChunk), + "", + makeDataLine(finishChunk), + "", + ...usageLines.flatMap((l) => [l, ""]), + ...(hasDone ? ["data: [DONE]", ""] : []), + ] + + return output.join("\n") +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c05d05319353..04bdacd736ff 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -28,6 +28,12 @@ import { withStatics } from "@/util/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" +import { + getOpenAICompatibleToolParsers, + rewriteOpenAICompatibleJsonResponse, + rewriteOpenAICompatibleRequestBody, + rewriteOpenAICompatibleStreamResponse, +} from "./openai-compatible-compat" const log = Log.create({ service: "provider" }) @@ -1404,7 +1410,11 @@ const layer: Layer.Layer< delete options.fetch } - if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { + if ( + model.api.npm.includes("@ai-sdk/openai-compatible") && + options["includeUsage"] === undefined && + provider.source !== "config" + ) { options["includeUsage"] = true } @@ -1450,6 +1460,9 @@ const layer: Layer.Layer< const customFetch = options["fetch"] const chunkTimeout = options["chunkTimeout"] delete options["chunkTimeout"] + const toolParsers = model.api.npm.includes("@ai-sdk/openai-compatible") + ? getOpenAICompatibleToolParsers(options) + : [] options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { const fetchFn = customFetch ?? fetch @@ -1480,14 +1493,49 @@ const layer: Layer.Layer< } } + // Rewrite request body for tool parsers + if (toolParsers.length > 0 && opts.body && opts.method === "POST") { + try { + const body = JSON.parse(opts.body as string) + opts.body = JSON.stringify(rewriteOpenAICompatibleRequestBody(body, toolParsers)) + } catch {} + } + const res = await fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 timeout: false, }) - if (!chunkAbortCtl) return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl) + if (toolParsers.length === 0) { + if (!chunkAbortCtl) return res + return wrapSSE(res, chunkTimeout, chunkAbortCtl) + } + + // With tool parsers: buffer and rewrite response + const headers = new Headers(res.headers) + headers.delete("content-length") + const contentType = headers.get("content-type") ?? "" + if (contentType.includes("text/event-stream")) { + const text = await res.text() + return new Response(rewriteOpenAICompatibleStreamResponse(text, toolParsers), { + status: res.status, + statusText: res.statusText, + headers, + }) + } + if (contentType.includes("application/json")) { + const text = await res.text() + try { + return new Response( + JSON.stringify(rewriteOpenAICompatibleJsonResponse(JSON.parse(text), toolParsers)), + { status: res.status, statusText: res.statusText, headers }, + ) + } catch { + return new Response(text, { status: res.status, statusText: res.statusText, headers }) + } + } + return res } const bundledLoader = BUNDLED_PROVIDERS[model.api.npm] diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index b8b8911858fc..1f7f93977b8d 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -398,6 +398,50 @@ const live: Layer.Layer< return args.params }, }, + ...(item.source === "config" + ? [ + // @ts-ignore wrapStream type mismatch between LanguageModelV3Middleware and PromiseLike + { + specificationVersion: "v3" as const, + async wrapStream({ doStream }: { doStream: () => PromiseLike }) { + const result = await doStream() + let text = "" + let hasToolCall = false + const transformed = result.stream.pipeThrough( + new TransformStream({ + transform(chunk: any, ctrl: TransformStreamDefaultController) { + if (chunk.type === "tool-call" || chunk.type === "tool-input-start") hasToolCall = true + if (chunk.type === "text-delta") text += chunk.delta + if ( + chunk.type === "finish" && + chunk.finishReason === "stop" && + !hasToolCall && + text.trim() + ) { + try { + const parsed = JSON.parse(text.trim()) as { name?: string; arguments?: unknown } + if (typeof parsed.name === "string" && parsed.name in tools) { + l.info("repairing raw JSON tool call", { tool: parsed.name }) + ctrl.enqueue({ + type: "tool-call", + toolCallId: `raw-${Date.now()}`, + toolName: parsed.name, + input: JSON.stringify(parsed.arguments ?? {}), + }) + ctrl.enqueue({ ...chunk, finishReason: "tool-calls" }) + return + } + } catch {} + } + ctrl.enqueue(chunk) + }, + }), + ) + return { ...result, stream: transformed } + }, + }, + ] + : []), ], }), experimental_telemetry: { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4c259e4aef5a..aa38c5ed488c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -70,6 +70,20 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) +// Detect when a local model wrote tool-call JSON in its text output instead of +// emitting a proper tool-call part. Returns the tool name if found. +function detectEmbeddedToolCallName(text: string): string | null { + const nameRegex = /"name"\s*:\s*"([^"]+)"/g + let match: RegExpExecArray | null + while ((match = nameRegex.exec(text)) !== null) { + const surrounding = text.slice(Math.max(0, match.index - 100), match.index + 400) + if (/["']arguments["']\s*:/.test(surrounding) || /["']parameters["']\s*:/.test(surrounding)) { + return match[1] + } + } + return null +} + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect @@ -1277,6 +1291,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const slog = elog.with({ sessionID }) let structured: unknown | undefined let step = 0 + let nudgeCount = 0 const session = yield* sessions.get(sessionID) while (true) { @@ -1317,6 +1332,38 @@ NOTE: At any point in time through this workflow you should feel free to ask the !hasToolCalls && lastUser.id < lastAssistant.id ) { + // Check if the model described a tool call in text instead of calling it + if (nudgeCount < 3) { + const assistantText = lastAssistantMsg?.parts + .filter((p) => p.type === "text") + .map((p) => (p as MessageV2.TextPart).text) + .join("") + const embeddedTool = detectEmbeddedToolCallName(assistantText ?? "") + if (embeddedTool) { + nudgeCount++ + yield* slog.info("detected embedded tool call in text, nudging", { tool: embeddedTool, nudgeCount }) + const nudgeMsg: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + } + yield* sessions.updateMessage(nudgeMsg) + const nudgePart: MessageV2.TextPart = { + id: PartID.ascending(), + messageID: nudgeMsg.id, + sessionID, + type: "text", + text: `You described calling the "${embeddedTool}" tool in your response text but did not actually call it. You must invoke the tool directly — do not write tool calls as text or JSON.`, + synthetic: true, + time: { start: Date.now(), end: Date.now() }, + } + yield* sessions.updatePart(nudgePart) + continue + } + } yield* slog.info("exiting loop") break } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 9099f2d1742b..59692fd2fcf2 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -15,6 +15,7 @@ import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" +import { Config } from "@/config/config" export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) @@ -43,6 +44,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const skill = yield* Skill.Service + const config = yield* Config.Service return Service.of({ environment(model) { @@ -66,19 +68,19 @@ export const layer = Layer.effect( if (Permission.disabled(["skill"], agent.permission).has("skill")) return const list = yield* skill.available(agent) + const cfg = yield* config.get() + const format = cfg.skills?.format ?? "xml" return [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", - // the agents seem to ingest the information about skills a bit better if we present a more verbose - // version of them here and a less verbose version in tool description, rather than vice versa. - Skill.fmt(list, { verbose: true }), + Skill.fmt(list, { format }), ].join("\n") }), }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer)) +export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer), Layer.provide(Config.defaultLayer)) export * as SystemPrompt from "./system" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 701ecaba8957..e6cdfcfe2054 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -261,29 +261,38 @@ export const defaultLayer = layer.pipe( Layer.provide(AppFileSystem.defaultLayer), ) -export function fmt(list: Info[], opts: { verbose: boolean }) { +export function fmt(list: Info[], opts: { format: "xml" | "json" | "markdown" }) { if (list.length === 0) return "No skills are currently available." - if (opts.verbose) { + const sorted = list.toSorted((a, b) => a.name.localeCompare(b.name)) + if (opts.format === "json") { + return JSON.stringify( + { + available_skills: sorted.map((skill) => ({ + name: skill.name, + description: skill.description, + location: pathToFileURL(skill.location).href, + })), + }, + null, + 2, + ) + } + if (opts.format === "xml") { return [ "", - ...list - .sort((a, b) => a.name.localeCompare(b.name)) - .flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), + ...sorted.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), "", ].join("\n") } - return [ "## Available Skills", - ...list - .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((skill) => `- **${skill.name}**: ${skill.description}`), + ...sorted.map((skill) => `- **${skill.name}**: ${skill.description}`), ].join("\n") } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4b442d4e3a00..bbf2fc29cbaa 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -257,7 +257,7 @@ export const layer: Layer.Layer< "The following skills provide specialized sets of instructions for particular tasks", "Invoke this tool to load a skill when a task matches one of the available skills listed below:", "", - Skill.fmt(list, { verbose: false }), + Skill.fmt(list, { format: "markdown" }), ].join("\n") })