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
2 changes: 2 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const Flag = {
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
OPENCODE_EXPERIMENTAL_SYSTEM_PROMPT_SPLIT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SYSTEM_PROMPT_SPLIT"),
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
Expand All @@ -86,6 +87,7 @@ export const Flag = {
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"),
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),

// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
Expand Down
49 changes: 36 additions & 13 deletions packages/opencode/src/session/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function extract(messages: MessageV2.WithParts[]) {
export interface Interface {
readonly clear: (messageID: MessageID) => Effect.Effect<void>
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
readonly system: () => Effect.Effect<{ global: string[]; project: string[] }, AppFileSystem.Error>
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly resolve: (
messages: MessageV2.WithParts[],
Expand Down Expand Up @@ -103,17 +103,21 @@ export const layer: Layer.Layer<
s.claims.delete(messageID)
})

const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const ctx = yield* InstanceState.context
const globalInstructionPaths = Effect.fnUntraced(function* () {
const paths = new Set<string>()

for (const file of globalFiles) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
break
}
}
return paths
})

const projectInstructionPaths = Effect.fnUntraced(function* () {
const config = yield* cfg.get()
const ctx = yield* InstanceState.context
const paths = new Set<string>()

// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
Expand Down Expand Up @@ -146,20 +150,39 @@ export const layer: Layer.Layer<
return paths
})

const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const [global, project] = yield* Effect.all([globalInstructionPaths(), projectInstructionPaths()])
return new Set([...global, ...project])
})

const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)

const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })

return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
const [global, project, remote] = yield* Effect.all([
globalInstructionPaths(),
projectInstructionPaths(),
Effect.forEach(urls, fetch, { concurrency: 4 }),
])

const [globalFiles, projectFiles] = yield* Effect.all([
Effect.forEach(Array.from(global), read, { concurrency: 8 }),
Effect.forEach(Array.from(project), read, { concurrency: 8 }),
])

return {
global: Array.from(global).flatMap((item, i) =>
globalFiles[i] ? [`Instructions from: ${item}\n${globalFiles[i]}`] : [],
),
project: [
...Array.from(project).flatMap((item, i) =>
projectFiles[i] ? [`Instructions from: ${item}\n${projectFiles[i]}`] : [],
),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
],
}
})

const find = Effect.fn("Instruction.find")(function* (dir: string) {
Expand Down
61 changes: 47 additions & 14 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ type Result = Awaited<ReturnType<typeof streamText>>
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
mergeDeep(target, source ?? {}) as Record<string, any>

export type SystemSplit =
| string[]
| { stable: string[]; dynamic: string[] }

export type StreamInput = {
user: MessageV2.User
sessionID: string
parentSessionID?: string
model: Provider.Model
agent: Agent.Info
permission?: Permission.Ruleset
system: string[]
system: SystemSplit
messages: ModelMessage[]
small?: boolean
tools: Record<string, Tool>
Expand Down Expand Up @@ -101,18 +105,47 @@ const live: Layer.Layer<
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"

const system: string[] = []
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)

if (!Array.isArray(input.system)) {
const shouldSplit = item.options?.["splitSystemPrompt"] !== false
if (shouldSplit) {
const stable = [
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system.stable,
]
.filter((x) => x)
.join("\n")
const dynamic = [
...input.system.dynamic,
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n")
system.push(stable)
system.push(dynamic)
} else {
system.push(
[
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system.stable,
...input.system.dynamic,
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
}
} else {
system.push(
[
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system,
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
}

const header = system[0]
yield* plugin.trigger(
Expand All @@ -121,7 +154,7 @@ const live: Layer.Layer<
{ system },
)
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
if (Array.isArray(input.system) && system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1800,9 +1800,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
instruction.system().pipe(Effect.orDie),
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
const system: string[] | { stable: string[]; dynamic: string[] } = Flag.OPENCODE_EXPERIMENTAL_SYSTEM_PROMPT_SPLIT
? {
stable: [...instructions.global, ...(skills.global ? [skills.global] : [])],
dynamic: [...env, ...(skills.project ? [skills.project] : []), ...instructions.project],
}
: [...env, ...instructions.global, ...(skills.global ? [skills.global] : []), ...instructions.project, ...(skills.project ? [skills.project] : [])]
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
if (format.type === "json_schema") {
if (Array.isArray(system)) system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
else system.dynamic.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
}
const result = yield* handle.process({
user: lastUser,
agent,
Expand Down
23 changes: 17 additions & 6 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function provider(model: Provider.Model) {

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

export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
Expand Down Expand Up @@ -63,17 +63,28 @@ export const layer = Layer.effect(
}),

skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
if (Permission.disabled(["skill"], agent.permission).has("skill")) return {}

const list = yield* skill.available(agent)
const globalSkills = list.filter((s) => s.scope === "global")
const projectSkills = list.filter((s) => s.scope === "project")

return [
const preamble = [
"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 }),
].join("\n")

return {
global: globalSkills.length > 0
? [preamble, Skill.fmt(globalSkills, { verbose: true })].join("\n")
: undefined,
project: projectSkills.length > 0
? [
...(globalSkills.length === 0 ? [preamble] : []),
Skill.fmt(projectSkills, { verbose: true }),
].join("\n")
: undefined,
}
}),
})
}),
Expand Down
34 changes: 20 additions & 14 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Config } from "@/config/config"
import { ConfigMarkdown } from "@/config/markdown"
import { Glob } from "@opencode-ai/core/util/glob"
import { withStatics } from "@opencode-ai/core/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
Expand All @@ -38,7 +39,8 @@ export const Info = Schema.Struct({
description: Schema.optional(Schema.String),
location: Schema.String,
content: Schema.String,
})
scope: Schema.Union([Schema.Literal("global"), Schema.Literal("project")]),
}).pipe(withStatics(() => ({})))
export type Info = Schema.Schema.Type<typeof Info>

const Issue = Schema.StructWithRest(
Expand Down Expand Up @@ -75,12 +77,12 @@ type State = {
}

type DiscoveryState = {
matches: string[]
matches: Array<{ path: string; scope: "global" | "project" }>
dirs: string[]
}

type ScanState = {
matches: Set<string>
matches: Map<string, "global" | "project">
dirs: Set<string>
}

Expand All @@ -91,7 +93,7 @@ export interface Interface {
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}

const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
const add = Effect.fnUntraced(function* (state: State, match: string, scope: "global" | "project", bus: Bus.Interface) {
const md = yield* Effect.tryPromise({
try: () => ConfigMarkdown.parse(match),
catch: (err) => err,
Expand Down Expand Up @@ -127,6 +129,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
description: md.data.description,
location: match,
content: md.content,
scope,
}
})

Expand Down Expand Up @@ -155,7 +158,7 @@ const scan = Effect.fnUntraced(function* (
)

for (const match of matches) {
state.matches.add(match)
state.matches.set(match, (opts?.scope as "global" | "project") ?? "project")
state.dirs.add(path.dirname(match))
}
})
Expand All @@ -168,7 +171,7 @@ const discoverSkills = Effect.fnUntraced(function* (
directory: string,
worktree: string,
) {
const state: ScanState = { matches: new Set(), dirs: new Set() }
const state: ScanState = { matches: new Map(), dirs: new Set() }

const externalDirs: string[] = []
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
Expand All @@ -192,7 +195,7 @@ const discoverSkills = Effect.fnUntraced(function* (

const configDirs = yield* config.directories()
for (const dir of configDirs) {
yield* scan(state, dir, OPENCODE_SKILL_PATTERN)
yield* scan(state, dir, OPENCODE_SKILL_PATTERN, { scope: "global" })
}

const cfg = yield* config.get()
Expand All @@ -215,13 +218,13 @@ const discoverSkills = Effect.fnUntraced(function* (
}

return {
matches: Array.from(state.matches),
matches: Array.from(state.matches.entries()).map(([path, scope]) => ({ path, scope })),
dirs: Array.from(state.dirs),
}
})

const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, bus: Bus.Interface) {
yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), {
yield* Effect.forEach(discovered.matches, (item) => add(state, item.path, item.scope, bus), {
concurrency: "unbounded",
discard: true,
})
Expand Down Expand Up @@ -249,11 +252,14 @@ export const layer = Layer.effect(
const s: State = { skills: {}, dirs: new Set() }
// Register the built-in skill BEFORE disk discovery so a user-disk
// skill with the same name can override it.
s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = {
name: CUSTOMIZE_OPENCODE_SKILL_NAME,
description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION,
location: "<built-in>",
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) {
s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = {
name: CUSTOMIZE_OPENCODE_SKILL_NAME,
description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION,
location: "<built-in>",
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
scope: "global",
}
}
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
return s
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/test/session/instruction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,10 @@ describe("Instruction.system", () => {
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)

const rules = yield* svc.system()
expect(rules).toHaveLength(2)
expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`)
expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`)
expect(rules.global).toHaveLength(1)
expect(rules.global[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`)
expect(rules.project).toHaveLength(1)
expect(rules.project[0]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`)
}).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
}),
)
Expand Down
Loading
Loading