From 0030028ebc3a8edffc82e78e87ae68d304b21c24 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 21:19:14 -0500 Subject: [PATCH] chore(core): checkpoint model capability defaults --- packages/core/src/models-dev.ts | 2 +- packages/core/src/plugin/models-dev.ts | 10 +- packages/core/src/session/runner/llm.ts | 30 ++-- packages/core/src/session/runner/model.ts | 29 +++- .../core/src/session/runner/to-llm-message.ts | 36 ++++- packages/core/src/v1/config/migrate.ts | 12 +- packages/core/test/config/config.test.ts | 15 ++ packages/core/test/config/provider.test.ts | 2 + packages/core/test/models.test.ts | 17 ++- packages/core/test/plugin/models-dev.test.ts | 28 ++++ .../core/test/session-runner-message.test.ts | 139 ++++++++++++++++++ .../core/test/session-runner-recorded.test.ts | 20 ++- packages/core/test/session-runner.test.ts | 47 +++++- packages/schema/src/model.ts | 21 ++- 14 files changed, 380 insertions(+), 28 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index c2325cb57fd..51f6ded3374 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -49,7 +49,7 @@ export const Model = Schema.Struct({ name: Schema.String, family: Schema.optional(Schema.String), release_date: Schema.String, - attachment: Schema.Boolean, + attachment: Schema.optional(Schema.Boolean), reasoning: Schema.Boolean, temperature: Schema.Boolean, tool_call: Schema.Boolean, diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 075a6ed093c..b59d619bc33 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -2,6 +2,7 @@ import { define } from "./internal" import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" +import { ModelV2 } from "../model" import { ModelsDev } from "../models-dev" import { ProviderV2 } from "../provider" @@ -97,11 +98,12 @@ function applyModel( url: model.provider?.api, settings: {}, } - draft.capabilities = { + const capabilities = ModelV2.Capabilities.defaults({ tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } + input: model.modalities?.input ?? (model.attachment === false ? ["text"] : undefined), + output: model.modalities?.output, + }) + draft.capabilities = { ...capabilities, input: [...capabilities.input], output: [...capabilities.output] } draft.variants = [] draft.time.released = released(model.release_date) draft.cost = input.cost ?? cost(model.cost) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 72c761e10d9..817e654d012 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -10,13 +10,12 @@ import { } from "@opencode-ai/llm" import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" +import { Catalog } from "../../catalog" import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" -import { ModelV2 } from "../../model" import { PermissionV2 } from "../../permission" -import { ProviderV2 } from "../../provider" import { QuestionV2 } from "../../question" import { SystemContext } from "../../system-context/index" import { SystemContextRegistry } from "../../system-context/registry" @@ -98,6 +97,7 @@ const layer = Layer.effect( const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service const models = yield* SessionRunnerModel.Service + const catalog = yield* Catalog.Service const store = yield* SessionStore.Service const location = yield* Location.Service const systemContext = yield* SystemContextRegistry.Service @@ -196,11 +196,21 @@ const layer = Layer.effect( } const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) - const model = yield* models.resolve(session) + const resolved = yield* models.resolve(session) + const model = resolved.model + const catalogModel = yield* catalog.model.get(resolved.ref.providerID, resolved.ref.id) + if (!catalogModel) + return yield* new SessionRunnerModel.ModelUnavailableError({ + providerID: resolved.ref.providerID, + modelID: resolved.ref.id, + }) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps - const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) + const toolMaterialization = + isLastStep || !catalogModel.capabilities.tools + ? undefined + : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, @@ -208,7 +218,10 @@ const layer = Layer.effect( system: [agent.info?.system, system.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), - messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], + messages: [ + ...toLLMMessages(context, model, catalogModel.capabilities), + ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), + ], tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) @@ -218,11 +231,7 @@ const layer = Layer.effect( const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: agent.id, - model: { - id: ModelV2.ID.make(model.id), - providerID: ProviderV2.ID.make(model.provider), - ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), - }, + model: resolved.ref, snapshot: startSnapshot, }) const withPublication = Semaphore.makeUnsafe(1).withPermit @@ -420,6 +429,7 @@ export const node = makeLocationNode({ AgentV2.node, ToolRegistry.node, SessionRunnerModel.node, + Catalog.node, SessionStore.node, Location.node, SystemContextRegistry.node, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 74e78120c20..de232b48dc0 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -71,8 +71,15 @@ export type Error = | UnsupportedApiError | Integration.AuthorizationError +export interface Resolved { + /** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */ + readonly model: Model + /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ + readonly ref: ModelV2.Ref +} + export interface Interface { - readonly resolve: (session: SessionSchema.Info) => Effect.Effect + readonly resolve: (session: SessionSchema.Info) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionRunnerModel") {} @@ -80,6 +87,16 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) +/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ +export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({ + model, + ref: ModelV2.Ref.make({ + id: ModelV2.ID.make(model.id), + providerID: ProviderV2.ID.make(model.provider), + ...(variant === undefined ? {} : { variant }), + }), +}) + const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { if (credential?.type === "key") return Auth.value(credential.key) if (credential?.type === "oauth") return Auth.value(credential.access) @@ -205,11 +222,19 @@ export const locationLayer = Layer.effect( const connection = yield* integrations.connection.active( provider?.integrationID ?? Integration.ID.make(selected.providerID), ) - return yield* resolve( + const model = yield* resolve( session, selected, connection ? yield* integrations.connection.resolve(connection) : undefined, ) + return { + model, + ref: ModelV2.Ref.make({ + id: selected.id, + providerID: selected.providerID, + ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), + }), + } }), }) }), diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index b2b1af5d30f..93451d29cf8 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -7,6 +7,7 @@ import { type Model, type ProviderMetadata, } from "@opencode-ai/llm" +import type { ModelV2 } from "../../model" import { SessionMessage } from "../message" import type { FileAttachment } from "../prompt" @@ -18,6 +19,23 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) +const modality = (mime: string) => { + if (mime.startsWith("image/")) return "image" + if (mime.startsWith("audio/")) return "audio" + if (mime.startsWith("video/")) return "video" + if (mime === "application/pdf") return "pdf" + return undefined +} + +const attachment = (file: FileAttachment, capabilities?: ModelV2.Capabilities): ContentPart => { + const type = modality(file.mime) + if (!type || (capabilities?.input ?? ["text", "image"]).includes(type)) return media(file) + return { + type: "text", + text: `ERROR: Cannot read ${file.name ? `"${file.name}"` : type} (this model does not support ${type} input). Inform the user.`, + } +} + const toolInput = (tool: SessionMessage.AssistantTool) => { if (tool.state.status !== "pending") return tool.state.input try { @@ -112,7 +130,11 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { ] } -function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { +function toLLMMessage( + message: SessionMessage.Message, + model: Model, + capabilities?: ModelV2.Capabilities, +): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -122,7 +144,10 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] Message.make({ id: message.id, role: "user", - content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)], + content: [ + { type: "text", text: message.text }, + ...(message.files ?? []).map((file) => attachment(file, capabilities)), + ], metadata: { ...message.metadata, ...(message.agents?.length ? { agents: message.agents } : {}), @@ -167,5 +192,8 @@ ${message.recent} } /** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ -export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) => - messages.flatMap((message) => toLLMMessage(message, model)) +export const toLLMMessages = ( + messages: readonly SessionMessage.Message[], + model: Model, + capabilities?: ModelV2.Capabilities, +) => messages.flatMap((message) => toLLMMessage(message, model, capabilities)) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index ebe1b905ff8..1de34c2696b 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -6,6 +6,7 @@ import { ConfigMCPV1 } from "./mcp" import { ConfigPermissionV1 } from "./permission" import { ConfigProviderV1 } from "./provider" import { ConfigProviderOptionsV1 } from "./provider-options" +import { ModelV2 } from "../../model" const keys = new Set([ "logLevel", @@ -211,8 +212,15 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st : []), ] const capabilities = - info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined - ? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] } + info.tool_call !== undefined || + info.attachment !== undefined || + info.modalities?.input !== undefined || + info.modalities?.output !== undefined + ? ModelV2.Capabilities.defaults({ + tools: info.tool_call, + input: info.modalities?.input ?? (info.attachment === false ? ["text"] : undefined), + output: info.modalities?.output, + }) : undefined return { family: info.family, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index c3c42cab302..43c3549476e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -530,9 +530,17 @@ describe("Config", () => { options: { apiKey: "secret" }, models: { model: { + attachment: true, options: { reasoningEffort: "high" }, variants: { fast: { temperature: 0.2 } }, }, + text: { + attachment: false, + }, + audio: { + attachment: true, + modalities: { input: ["audio"], output: ["audio"] }, + }, }, }, openai: { @@ -608,9 +616,16 @@ describe("Config", () => { request: { body: { apiKey: "secret" } }, models: { model: { + capabilities: { tools: false, input: ["text", "image"], output: ["text"] }, request: { body: { reasoningEffort: "high" } }, variants: [{ id: "fast", body: { temperature: 0.2 } }], }, + text: { + capabilities: { tools: false, input: ["text"], output: ["text"] }, + }, + audio: { + capabilities: { tools: false, input: ["audio"], output: ["audio"] }, + }, }, }) expect(documents[0]?.info.providers?.openai).toMatchObject({ diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 605bcd63cf9..de5997c6451 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -239,6 +239,7 @@ describe("ConfigProviderPlugin.Plugin", () => { const provider = required(yield* catalog.provider.get(providerID)) const model = required(yield* catalog.model.get(providerID, modelID)) + const defaultModel = required(yield* catalog.model.get(providerID, ModelV2.ID.make("default"))) expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) expect(provider.name).toBe("Renamed") expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({ @@ -252,6 +253,7 @@ describe("ConfigProviderPlugin.Plugin", () => { expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) expect(model.name).toBe("Last") expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(defaultModel.capabilities).toEqual({ tools: false, input: ["text", "image"], output: ["text"] }) expect(model.enabled).toBe(false) expect(model.limit).toEqual({ context: 100, output: 75 }) expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index a3ed86dc8cd..4d93ced033f 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" -import { Effect, Layer, Ref } from "effect" +import { Effect, Layer, Ref, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" @@ -127,6 +127,21 @@ const initialState: MockState = { } describe("ModelsDev Service", () => { + it.effect("allows models.dev entries without legacy attachment metadata", () => + Effect.sync(() => { + const result = Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "no-attachment-model", + name: "No Attachment Model", + release_date: "2026-01-01", + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }) + + expect(result.attachment).toBeUndefined() + }), + ) it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { yield* writeCache(fixture) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 6c0f7e07029..9c24426ebff 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -76,6 +76,26 @@ describe("ModelsDevPlugin", () => { }, }, }, + default: { + id: "default", + name: "Default", + release_date: "2026-01-01", + reasoning: false, + temperature: true, + tool_call: false, + limit: { context: 128_000, output: 8_192 }, + }, + explicit: { + id: "explicit", + name: "Explicit", + release_date: "2026-01-01", + attachment: true, + reasoning: false, + temperature: true, + tool_call: false, + modalities: { input: ["audio"], output: ["audio"] }, + limit: { context: 128_000, output: 8_192 }, + }, }, }, } satisfies Record), @@ -92,9 +112,14 @@ describe("ModelsDevPlugin", () => { const providerID = ProviderV2.ID.make("acme") const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4")) const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast")) + const defaults = yield* catalog.model.get(providerID, ModelV2.ID.make("default")) + const explicit = yield* catalog.model.get(providerID, ModelV2.ID.make("explicit")) expect(base?.variants).toEqual([]) expect(base?.request.body).toEqual({}) + expect(base?.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(defaults?.capabilities).toEqual({ tools: false, input: ["text", "image"], output: ["text"] }) + expect(explicit?.capabilities).toEqual({ tools: false, input: ["audio"], output: ["audio"] }) expect(fast).toMatchObject({ id: "gpt-5.4-fast", providerID: "acme", @@ -159,6 +184,9 @@ describe("ModelsDevPlugin", () => { connections: [], }), ]) + expect(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"))).toMatchObject({ + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + }) }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))), (previous) => Effect.sync(() => { diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 5798b665a86..73edfad31fe 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -139,6 +139,145 @@ Recent work ]) }) + test("defaults missing model capabilities to text and image input", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-local-image"), + type: "user", + text: "Inspect this image", + files: [ + FileAttachment.make({ + uri: "data:image/png;base64,AAECAw==", + mime: "image/png", + name: "image.png", + }), + FileAttachment.make({ + uri: "data:application/pdf;base64,JVBERg==", + mime: "application/pdf", + name: "document.pdf", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect this image" }, + { type: "media", mediaType: "image/png", data: "data:image/png;base64,AAECAw==", filename: "image.png" }, + { + type: "text", + text: 'ERROR: Cannot read "document.pdf" (this model does not support pdf input). Inform the user.', + }, + ]) + }) + + test("uses explicit model input capabilities instead of attachment defaults", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-unsupported-pdf"), + type: "user", + text: "Inspect these files", + files: [ + FileAttachment.make({ + uri: "data:image/png;base64,AAECAw==", + mime: "image/png", + name: "image.png", + }), + FileAttachment.make({ + uri: "data:application/pdf;base64,JVBERg==", + mime: "application/pdf", + name: "document.pdf", + }), + ], + time: { created }, + }), + ], + model, + { tools: true, input: ["text", "pdf"], output: ["text"] }, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect these files" }, + { + type: "text", + text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.', + }, + { + type: "media", + mediaType: "application/pdf", + data: "data:application/pdf;base64,JVBERg==", + filename: "document.pdf", + }, + ]) + }) + + test("treats explicit empty input capabilities as authoritative", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-empty-capabilities"), + type: "user", + text: "Inspect this image", + files: [ + FileAttachment.make({ + uri: "data:image/png;base64,AAECAw==", + mime: "image/png", + name: "image.png", + }), + ], + time: { created }, + }), + ], + model, + { tools: false, input: [], output: [] }, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect this image" }, + { + type: "text", + text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.', + }, + ]) + }) + + test("classifies audio and video MIME families through explicit capabilities", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-media-capabilities"), + type: "user", + text: "Inspect this media", + files: [ + FileAttachment.make({ + uri: "data:audio/mpeg;base64,AAECAw==", + mime: "audio/mpeg", + name: "audio.mp3", + }), + FileAttachment.make({ + uri: "data:video/webm;base64,AAECAw==", + mime: "video/webm", + name: "video.webm", + }), + ], + time: { created }, + }), + ], + model, + { tools: false, input: ["text", "audio", "video"], output: ["text"] }, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect this media" }, + { type: "media", mediaType: "audio/mpeg", data: "data:audio/mpeg;base64,AAECAw==", filename: "audio.mp3" }, + { type: "media", mediaType: "video/webm", data: "data:video/webm;base64,AAECAw==", filename: "video.webm" }, + ]) + }) + test("replays durable tool media into canonical tool messages without structured base64", () => { const messages = toLLMMessages( [ diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index d45cc8c7341..8da32828b77 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -10,6 +10,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { PermissionV2 } from "@opencode-ai/core/permission" import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -32,6 +33,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry import { SystemContext } from "@opencode-ai/core/system-context" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { ModelV2 } from "@opencode-ai/core/model" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -67,7 +69,21 @@ const model = OpenAIChat.route generation: { maxTokens: 20, temperature: 0 }, }) .model({ id: "gpt-4o-mini" }) -const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) +const catalog = Layer.mock(Catalog.Service, { + provider: { + get: () => Effect.die("unused"), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + }, + model: { + get: (providerID, modelID) => Effect.succeed(ModelV2.Info.empty(providerID, modelID)), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + default: () => Effect.die("unused"), + small: () => Effect.die("unused"), + }, +}) const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) @@ -76,6 +92,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], + [Catalog.node, catalog], [SystemContextRegistry.node, systemContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], @@ -122,6 +139,7 @@ const it = testEffect( [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [SessionRunnerModel.node, models], + [Catalog.node, catalog], [SystemContextRegistry.node, systemContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0515d55cf5b..176dde9e538 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -38,6 +38,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigCompaction } from "@opencode-ai/core/config/compaction" import { Tool } from "@opencode-ai/core/tool/tool" @@ -155,8 +156,33 @@ const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: ec let modelResolveHook = Effect.void let currentModel = model const models = SessionRunnerModel.layerWith((session) => - modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)), + modelResolveHook.pipe( + Effect.as( + SessionRunnerModel.resolved( + session.model?.id === "replacement" ? replacementModel : currentModel, + session.model?.variant, + ), + ), + ), ) +let catalogCapabilities = ModelV2.Capabilities.defaults({ tools: true }) +const catalog = Layer.mock(Catalog.Service, { + provider: { + get: () => Effect.die("unused"), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + }, + model: { + get: (providerID, modelID) => + Effect.succeed( + ModelV2.Info.make({ ...ModelV2.Info.empty(providerID, modelID), capabilities: catalogCapabilities }), + ), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + default: () => Effect.die("unused"), + small: () => Effect.die("unused"), + }, +}) const systemContextKey = SystemContext.Key.make("test/context") let systemBaseline = "Initial context" let systemRemoved = false @@ -229,6 +255,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], + [Catalog.node, catalog], [SystemContextRegistry.node, systemContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], @@ -278,6 +305,7 @@ const it = testEffect( [LayerNodePlatform.llmClient, client], [PermissionV2.node, permission], [SessionRunnerModel.node, models], + [Catalog.node, catalog], [SystemContextRegistry.node, systemContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], @@ -318,6 +346,7 @@ const setup = Effect.gen(function* () { systemLoadHook = Effect.void modelResolveHook = Effect.void currentModel = model + catalogCapabilities = ModelV2.Capabilities.defaults({ tools: true }) skillBaselines.clear() responses = undefined streamFailure = undefined @@ -655,6 +684,22 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("does not advertise tools to a model without tool capability", () => + Effect.gen(function* () { + yield* setup + catalogCapabilities = ModelV2.Capabilities.defaults() + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "No tools" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(requests[0]?.tools).toEqual([]) + }), + ) + it.effect("retries the first provider turn after system context becomes available", () => Effect.gen(function* () { yield* setup diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 10fa175e247..0d5b1471ffa 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -26,7 +26,24 @@ export const Capabilities = Schema.Struct({ tools: Schema.Boolean, input: Schema.Array(Schema.String), output: Schema.Array(Schema.String), -}).annotate({ identifier: "Model.Capabilities" }) +}) + .annotate({ identifier: "Model.Capabilities" }) + .pipe( + statics((schema) => ({ + defaults: ( + input: { + readonly tools?: boolean + readonly input?: ReadonlyArray + readonly output?: ReadonlyArray + } = {}, + ) => + schema.make({ + tools: input.tools ?? false, + input: input.input === undefined ? ["text", "image"] : [...input.input], + output: input.output === undefined ? ["text"] : [...input.output], + }), + })), + ) export interface Cost extends Schema.Schema.Type {} export const Cost = Schema.Struct({ @@ -93,7 +110,7 @@ export const Info = Schema.Struct({ providerID, name: modelID, api: { id: modelID, type: "native", settings: {} }, - capabilities: { tools: false, input: [], output: [] }, + capabilities: Capabilities.defaults(), request: { headers: {}, body: {} }, variants: [], time: { released: 0 },