Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 4 additions & 11 deletions packages/core/src/plugin/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Model } from "@opencode/schema/model"
import { Effect } from "effect"

export function identity(model: { readonly provider: string; readonly name: string; readonly ref: Model.Ref }) {
export function identity(model: { readonly name: string; readonly ref: Model.Ref }) {
return [
"# Your Model",
`- Provider: ${model.provider}`,
`- Name: ${model.name}`,
`- ID: ${model.ref.providerID}/${model.ref.id}`,
`- Provider ID: ${model.ref.providerID}`,
`- Model ID: ${model.ref.id}`,
].join("\n")
}

Expand All @@ -24,14 +24,7 @@ export const Plugin = define({
(yield* ctx.model.list()).data.find(
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
) ?? Model.Info.default(event.model.providerID, event.model.id)
const provider = (yield* ctx.provider.list()).data.find((provider) => provider.id === event.model.providerID)
event.system.splice(
1,
0,
SystemPart.make(
identity({ provider: provider?.name ?? event.model.providerID, name: model.name, ref: event.model }),
),
)
event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model })))
}).pipe(Effect.catch(() => Effect.void))
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
Expand Down
112 changes: 111 additions & 1 deletion packages/core/src/tool/plugin/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as OpenCodeTools from "./opencode.js"
import { SystemPart, ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Model } from "@opencode/schema/model"
import { AbsolutePath } from "@opencode/schema/schema"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
Expand All @@ -23,6 +24,47 @@ export const MoveInput = Schema.Struct({

const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })

export const ModelsInput = Schema.Struct({
query: Schema.optionalKey(Schema.String).annotate({
description: "Text to search for in model names and IDs.",
}),
provider: Schema.optionalKey(Schema.String).annotate({
description: "Provider ID or name to filter by. Try your own provider first.",
}),
all: Schema.optionalKey(Schema.Boolean).annotate({
description: "Include older versions of each model family. By default only the newest version is listed.",
}),
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))).annotate({
description: "Maximum number of models to return. Defaults to 20.",
}),
offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({
description: "Number of models to skip, for paging through results.",
}),
})

const ModelEntry = Schema.Struct({
id: Schema.String.annotate({ description: "providerID/modelID" }),
name: Schema.String,
released: Model.Info.fields.time.fields.released.annotate({
description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.",
}),
variants: Schema.Array(Model.VariantID),
cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }),
status: Model.Info.fields.status,
})

const ModelsOutput = Schema.Struct({
providers: Schema.Array(
Schema.Struct({
id: Schema.String,
name: Schema.String,
models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }),
}),
).annotate({ description: "Matching models grouped by provider. Your own provider comes first." }),
total: Schema.Int.annotate({ description: "Number of matching models across all pages." }),
next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }),
})

export const Plugin = {
id: "opencode.tools",
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
Expand All @@ -39,7 +81,11 @@ export const Plugin = {
yield* ctx.session.hook("generate", hook)
yield* ctx.tool
.transform((draft) => {
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
draft.namespace({
name: "opencode",
description:
"Tools for managing OpenCode itself, such as working with sessions and searching the available models.",
})
draft.add({
name: "session_rename",
description:
Expand Down Expand Up @@ -85,6 +131,70 @@ export const Plugin = {
),
),
})
draft.add({
name: "models",
description:
"Search the models available to use. Use this to turn a model name the user mentions into an exact reference before running a subagent on it. Check your own provider first.",
input: ModelsInput,
output: ModelsOutput,
options: { namespace: "opencode", codemode: true },
execute: (input, context) =>
Effect.gen(function* () {
const offset = input.offset ?? 0
const limit = input.limit ?? 20
const own = (yield* ctx.session.get({ sessionID: context.sessionID })).model?.providerID
const terms = input.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? []
const names = new Map((yield* ctx.provider.list()).data.map((provider) => [provider.id, provider.name]))
const provider = input.provider?.toLowerCase()
const matching = (yield* ctx.model.list()).data
.filter(
(model) =>
provider === undefined ||
model.providerID.toLowerCase() === provider ||
names.get(model.providerID)?.toLowerCase() === provider,
)
.filter((model) => {
const text = `${model.providerID}/${model.id} ${model.name}`.toLowerCase()
return terms.every((term) => text.includes(term))
})
.toSorted(
(left, right) =>
Number(right.providerID === own) - Number(left.providerID === own) ||
left.providerID.localeCompare(right.providerID) ||
right.time.released - left.time.released,
)
.filter((model, index, sorted) => {
if (input.all || model.family === undefined) return true
return (
sorted.findIndex(
(other) => other.providerID === model.providerID && other.family === model.family,
) === index
)
})
const page = matching.slice(offset, offset + limit)
const providers = Array.from(new Set(page.map((model) => model.providerID))).map((id) => ({
id,
name: names.get(id) ?? id,
models: page
.filter((model) => model.providerID === id)
.map((model) => ({
id: `${model.providerID}/${model.id}`,
name: model.name,
released: model.time.released,
variants: model.variants.map((variant) => variant.id),
cost: model.cost,
status: model.status,
})),
}))
return {
output: {
providers,
total: matching.length,
next: offset + limit < matching.length ? offset + limit : null,
},
}
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error }))),
})
})
.pipe(Effect.orDie)
}),
Expand Down
59 changes: 46 additions & 13 deletions packages/core/src/tool/plugin/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Job } from "../../job.js"
import { Model } from "../../model.js"
import { Permission } from "../../permission.js"
import { Session } from "../../session.js"
import { SessionSchema } from "../../session/schema.js"
Expand All @@ -26,9 +27,16 @@ const backgroundResult = (sessionID: SessionSchema.ID) => ({
})

export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
agent: Schema.String.annotate({
description:
"The type of specialized agent to use for this task. If the user asks for a subagent by a name that is not one of the available subagents, they most likely mean a model: pick a suitable agent and pass the name through the model parameter instead.",
}),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
model: Schema.optionalKey(Schema.String).annotate({
description:
'NEVER set this unless the user explicitly asks for a particular model or variant. The value is written as "providerID/modelID", or "providerID/modelID#variant" to include a variant. Do not guess the ID: look the model up with the models tool, filtering to your own provider first.',
}),
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
description:
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
Expand Down Expand Up @@ -61,8 +69,34 @@ export const Plugin = {
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const models = yield* Model.Service
const subagents = yield* SubagentJob.make

const resolveModel = Effect.fn("SubagentTool.resolveModel")(function* (input: string) {
const ref = yield* Effect.try({
try: () => Model.Ref.parse(input),
catch: () =>
new ToolFailure({
message: `Invalid model "${input}". Use "providerID/modelID" or "providerID/modelID#variant".`,
}),
})
const model = (yield* models.available()).find(
(model) => model.providerID === ref.providerID && model.id === ref.id,
)
if (model === undefined)
return yield* new ToolFailure({
message: `Model "${ref.providerID}/${ref.id}" is not available. Use the models tool to see what is available.`,
})
if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant))
return yield* new ToolFailure({
message:
model.variants.length === 0
? `Model "${ref.providerID}/${ref.id}" has no variants. Omit the variant.`
: `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ")}.`,
})
return ref
})

yield* ctx.tool
.transform((editor) =>
editor.add({
Expand Down Expand Up @@ -131,24 +165,23 @@ export const Plugin = {
return yield* new ToolFailure({
message: `Session ${existing.id} is not a child of the current session`,
})
const override = input.model === undefined ? undefined : yield* resolveModel(input.model)
// Continuing with a different agent switches the child, mirroring create semantics
// where the agent's configured model wins over the inherited one.
if (existing !== undefined && existing.agent !== agent.id) {
yield* sessions.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe(
Effect.andThen(
agent.model === undefined
? Effect.void
: sessions.switchModel({ sessionID: existing.id, model: agent.model }),
),
// where an explicit model wins over the agent's configured model, which wins over the inherited one.
if (existing !== undefined) {
const switched = existing.agent !== agent.id
const model = override ?? (switched ? agent.model : undefined)
yield* Effect.all([
switched ? sessions.switchAgent({ sessionID: existing.id, agent: agent.id }) : Effect.void,
model === undefined ? Effect.void : sessions.switchModel({ sessionID: existing.id, model }),
]).pipe(
Effect.mapError(
(error) =>
new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }),
(error) => new ToolFailure({ message: `Failed to switch subagent session: ${existing.id}`, error }),
),
)
}

// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const model = override ?? agent.model ?? parent.model
const child =
existing ??
(yield* sessions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Provider: openai\\n- Name: gpt-4o-mini\\n- ID: openai/gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Name: gpt-4o-mini\\n- Provider ID: openai\\n- Model ID: gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
Expand Down
Loading
Loading