Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/opencode/src/config/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Info>
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,7 +1440,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })

const [skills, env, instructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.skills(agent, model),
Effect.sync(() => sys.environment(model)),
instruction.system().pipe(Effect.orDie),
MessageV2.toModelMessagesEffect(msgs, model),
Expand Down
18 changes: 12 additions & 6 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -32,9 +33,13 @@ export function provider(model: Provider.Model) {
return [PROMPT_DEFAULT]
}

function isAnthropicModel(model: Provider.Model) {
return model.providerID === "anthropic" || model.api.id.includes("claude")
}

export interface Interface {
readonly environment: (model: Provider.Model) => string[]
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
readonly skills: (agent: Agent.Info, model: Provider.Model) => Effect.Effect<string | undefined>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
Expand All @@ -43,6 +48,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) {
Expand All @@ -62,23 +68,23 @@ export const layer = Layer.effect(
]
},

skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info, model: Provider.Model) {
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 ?? (isAnthropicModel(model) ? "xml" : "json")

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"
39 changes: 24 additions & 15 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
"<available_skills>",
...list
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
...sorted.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].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")
}

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})

Expand Down
66 changes: 64 additions & 2 deletions packages/opencode/test/session/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,27 @@ import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { SystemPrompt } from "../../src/session/system"
import type { Provider } from "../../src/provider/provider"
import { provideInstance, tmpdir } from "../fixture/fixture"

function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}

const anthropicModel = {
providerID: "anthropic",
id: "claude-sonnet-4-5",
api: { id: "claude-sonnet-4-5" },
} as unknown as Provider.Model

const ollamaModel = {
providerID: "ollama",
id: "devstral:24b",
api: { id: "devstral:24b" },
} as unknown as Provider.Model

describe("session.system", () => {
test("skills output is sorted by name and stable across calls", async () => {
test("skills output is sorted by name and stable across calls (Anthropic/XML)", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
Expand Down Expand Up @@ -45,7 +58,7 @@ description: ${description}
const build = await load(tmp.path, (svc) => svc.get("build"))
const runSkills = Effect.gen(function* () {
const svc = yield* SystemPrompt.Service
return yield* svc.skills(build!)
return yield* svc.skills(build!, anthropicModel)
}).pipe(Effect.provide(SystemPrompt.defaultLayer))

const first = await Effect.runPromise(runSkills)
Expand All @@ -66,4 +79,53 @@ description: ${description}
process.env.OPENCODE_TEST_HOME = home
}
})

test("skills output uses JSON format for non-Anthropic models", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
for (const [name, description] of [
["zeta-skill", "Zeta skill."],
["alpha-skill", "Alpha skill."],
]) {
const skillDir = path.join(dir, ".opencode", "skill", name)
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: ${name}
description: ${description}
---

# ${name}
`,
)
}
},
})

const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path

try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await load(tmp.path, (svc) => svc.get("build"))
const result = await Effect.runPromise(
Effect.gen(function* () {
const svc = yield* SystemPrompt.Service
return yield* svc.skills(build!, ollamaModel)
}).pipe(Effect.provide(SystemPrompt.defaultLayer)),
)

const parsed = JSON.parse(result!.split("\n").slice(2).join("\n"))
const names = parsed.available_skills.map((s: { name: string }) => s.name)

expect(names).toEqual(["alpha-skill", "zeta-skill"])
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
})
Loading