diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index f76d1aaf9d22..654843175180 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -73,6 +73,8 @@ 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_EXPERIMENTAL_CACHE_STABILIZATION: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION"), 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"], diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 6629ce67bc9f..d5835469ce8e 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -36,7 +36,7 @@ function extract(messages: MessageV2.WithParts[]) { export interface Interface { readonly clear: (messageID: MessageID) => Effect.Effect readonly systemPaths: () => Effect.Effect, AppFileSystem.Error> - readonly system: () => Effect.Effect + readonly system: () => Effect.Effect<{ global: string[]; project: string[] }, AppFileSystem.Error> readonly find: (dir: string) => Effect.Effect readonly resolve: ( messages: MessageV2.WithParts[], @@ -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() - 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() // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { @@ -146,20 +150,44 @@ 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* () { + if (Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION && _cachedSystem) return _cachedSystem + 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 }) + 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 [ - ...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 result = { + 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]}`] : [])), + ], + } + + if (Flag.OPENCODE_EXPERIMENTAL_CACHE_STABILIZATION) _cachedSystem = result + return result }) const find = Effect.fn("Instruction.find")(function* (dir: string) { @@ -225,6 +253,12 @@ export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), ) +let _cachedSystem: { global: string[]; project: string[] } | undefined + +export function clearCache() { + _cachedSystem = undefined +} + export function loaded(messages: MessageV2.WithParts[]) { return extract(messages) } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 116254a81e75..fcd6939f02a2 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -32,6 +32,10 @@ type Result = Awaited> const mergeOptions = (target: Record, source: Record | undefined): Record => mergeDeep(target, source ?? {}) as Record +export type SystemSplit = + | string[] + | { stable: string[]; dynamic: string[] } + export type StreamInput = { user: MessageV2.User sessionID: string @@ -39,7 +43,7 @@ export type StreamInput = { model: Provider.Model agent: Agent.Info permission?: Permission.Ruleset - system: string[] + system: SystemSplit messages: ModelMessage[] small?: boolean tools: Record @@ -101,18 +105,33 @@ const live: Layer.Layer< const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" const system: string[] = [] - system.push( - [ - // use agent prompt otherwise provider prompt + + if (!Array.isArray(input.system)) { + const stable = [ ...(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.system.stable, + ] + .filter((x) => x) + .join("\n") + const dynamic = [ + ...input.system.dynamic, ...(input.user.system ? [input.user.system] : []), ] .filter((x) => x) - .join("\n"), - ) + .join("\n") + system.push(stable) + system.push(dynamic) + } 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( @@ -121,7 +140,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")) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bc58fbdf356b..0ab1250b4fd2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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], + dynamic: [...env, ...instructions.project, ...(skills ? [skills] : [])], + } + : [...env, ...instructions.global, ...instructions.project, ...(skills ? [skills] : [])] 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, diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 06c71fa7dbdd..f2582ef5cb5f 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -56,7 +56,7 @@ export const layer = Layer.effect( ` Workspace root folder: ${ctx.worktree}`, ` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`, ` Platform: ${process.platform}`, - ` Today's date: ${new Date().toDateString()}`, + ` Today's date: ${_cachedDate ??= new Date().toDateString()}`, ``, ].join("\n"), ] @@ -81,4 +81,10 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer)) +let _cachedDate: string | undefined + +export function clearCache() { + _cachedDate = undefined +} + export * as SystemPrompt from "./system" diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 5d40933954eb..37354eb6f1c0 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -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 })) }), ) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4a6b1e8b7f04..b45f0172fce7 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1289,4 +1289,219 @@ describe("session.llm.stream", () => { }, }) }) + + test("sends split system as two messages for Anthropic-compatible models", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const source = await loadFixture("anthropic", "claude-opus-4-6") + const model = source.model + const chunks = [ + { + type: "message_start", + message: { + id: "msg-split", + model: model.id, + usage: { input_tokens: 3, cache_creation_input_tokens: null, cache_read_input_tokens: null }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null, container: null }, + usage: { input_tokens: 3, output_tokens: 2, cache_creation_input_tokens: null, cache_read_input_tokens: null }, + }, + { type: "message_stop" }, + ] + const request = waitRequest("/messages", createEventResponse(chunks)) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["anthropic"], + provider: { + anthropic: { + name: "Anthropic", + env: ["ANTHROPIC_API_KEY"], + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + models: { [model.id]: model }, + options: { + apiKey: "test-anthropic-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-split-system") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user-split"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id }, + } satisfies MessageV2.User + + await drain({ + user, + sessionID, + model: resolved, + agent, + system: { stable: ["Global instructions"], dynamic: ["Project instructions"] }, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + const capture = await request + const body = capture.body + const system = body.system as Array<{ text: string }> | undefined + + expect(capture.url.pathname.endsWith("/messages")).toBe(true) + expect(Array.isArray(system)).toBe(true) + expect(system?.length).toBe(2) + expect(system?.[0]?.text).toContain("Global instructions") + expect(system?.[1]?.text).toContain("Project instructions") + }, + }) + }) + + test("sends flat system as single message for Anthropic-compatible models", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const source = await loadFixture("anthropic", "claude-opus-4-6") + const model = source.model + const chunks = [ + { + type: "message_start", + message: { + id: "msg-flat", + model: model.id, + usage: { input_tokens: 3, cache_creation_input_tokens: null, cache_read_input_tokens: null }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null, container: null }, + usage: { input_tokens: 3, output_tokens: 2, cache_creation_input_tokens: null, cache_read_input_tokens: null }, + }, + { type: "message_stop" }, + ] + const request = waitRequest("/messages", createEventResponse(chunks)) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["anthropic"], + provider: { + anthropic: { + name: "Anthropic", + env: ["ANTHROPIC_API_KEY"], + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + models: { [model.id]: model }, + options: { + apiKey: "test-anthropic-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-flat-system") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user-flat"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id }, + } satisfies MessageV2.User + + await drain({ + user, + sessionID, + model: resolved, + agent, + system: ["Combined instructions"], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + const capture = await request + const body = capture.body + const system = body.system as Array<{ text: string }> | string | undefined + + expect(capture.url.pathname.endsWith("/messages")).toBe(true) + // Flat system should produce a single system message (string or single-element array) + if (Array.isArray(system)) { + expect(system.length).toBe(1) + expect(system[0]?.text).toContain("Combined instructions") + } else { + expect(system).toContain("Combined instructions") + } + }, + }) + }) })