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/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
model: ModelV2.Ref.pipe(Schema.optional),
request: ProviderV2.Request,
system: Schema.String.pipe(Schema.optional),
planReminder: Schema.String.pipe(Schema.optional),
buildSwitchReminder: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]),
hidden: Schema.Boolean,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
variant: Schema.String.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
plan_reminder: Schema.String.pipe(Schema.optional).annotate({
description:
"Override the reminder injected when the plan agent enters plan mode. Supports ${planInfo} interpolation.",
}),
build_switch_reminder: Schema.String.pipe(Schema.optional).annotate({
description:
"Override the reminder injected when switching from plan mode back to the build agent. Supports ${planInfo} interpolation.",
}),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/config/plugin/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const agentKeys = new Set([
"variant",
"request",
"system",
"plan_reminder",
"build_switch_reminder",
"description",
"mode",
"hidden",
Expand Down Expand Up @@ -87,6 +89,8 @@ export const Plugin = PluginV2.define({
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.plan_reminder !== undefined) agent.planReminder = item.plan_reminder
if (item.build_switch_reminder !== undefined) agent.buildSwitchReminder = item.build_switch_reminder
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/v1/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ const AgentSchema = Schema.StructWithRest(
temperature: Schema.optional(Schema.Finite),
top_p: Schema.optional(Schema.Finite),
prompt: Schema.optional(Schema.String),
plan_reminder: Schema.optional(Schema.String).annotate({
description:
"Override the reminder injected when the plan agent enters plan mode. Supports ${planInfo} interpolation.",
}),
build_switch_reminder: Schema.optional(Schema.String).annotate({
description:
"Override the reminder injected when switching from plan mode back to the build agent. Supports ${planInfo} interpolation.",
}),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
}),
Expand Down Expand Up @@ -45,6 +53,8 @@ const KNOWN_KEYS = new Set([
"model",
"variant",
"prompt",
"plan_reminder",
"build_switch_reminder",
"description",
"temperature",
"top_p",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/v1/config/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
variant: info.variant,
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
plan_reminder: info.plan_reminder,
build_switch_reminder: info.build_switch_reminder,
description: info.description,
mode: info.mode,
hidden: info.hidden,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/test/config/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
reviewer: {
model: "anthropic/claude-sonnet",
system: "Review carefully.",
plan_reminder: "Plan carefully ${planInfo}",
build_switch_reminder: "Build carefully ${planInfo}",
description: "Reviews changes",
mode: "subagent",
hidden: true,
Expand Down Expand Up @@ -159,6 +161,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
if (!reviewer) throw new Error("expected configured reviewer agent")
expect(reviewer).toMatchObject({
system: "Review carefully.",
planReminder: "Plan carefully ${planInfo}",
buildSwitchReminder: "Build carefully ${planInfo}",
description: "Reviews changes",
mode: "subagent",
hidden: true,
Expand Down Expand Up @@ -215,6 +219,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: openrouter/openai/gpt-5
description: Markdown description
temperature: 0.5
plan_reminder: Markdown plan reminder \${planInfo}
build_switch_reminder: Markdown build reminder \${planInfo}
tools:
write: false
---
Expand Down Expand Up @@ -259,6 +265,8 @@ Use native v2 fields.`,
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
model: { providerID: "openrouter", id: "openai/gpt-5" },
system: "Review carefully.",
planReminder: "Markdown plan reminder ${planInfo}",
buildSwitchReminder: "Markdown build reminder ${planInfo}",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ describe("Config", () => {
}),
)

it.effect("migrates v1 agent reminder overrides", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
agent: {
plan: {
prompt: "Plan system",
plan_reminder: "Plan reminder ${planInfo}",
},
build: {
build_switch_reminder: "Build reminder ${planInfo}",
},
},
})

expect(migrated.agents?.plan).toMatchObject({
system: "Plan system",
plan_reminder: "Plan reminder ${planInfo}",
})
expect(migrated.agents?.build).toMatchObject({
build_switch_reminder: "Build reminder ${planInfo}",
})
}),
)

it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export const Info = Schema.Struct({
),
variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String),
planReminder: Schema.optional(Schema.String),
buildSwitchReminder: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Finite),
}).annotate({ identifier: "Agent" })
Expand Down Expand Up @@ -279,6 +281,8 @@ export const layer = Layer.effect(
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.planReminder = value.plan_reminder ?? item.planReminder
item.buildSwitchReminder = value.build_switch_reminder ?? item.buildSwitchReminder
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
Expand Down
61 changes: 43 additions & 18 deletions packages/opencode/src/session/reminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import { Session } from "./session"
import PROMPT_PLAN from "./prompt/plan.txt"
import BUILD_SWITCH from "./prompt/build-switch.txt"
Expand All @@ -25,23 +24,33 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {

if (!flags.experimentalPlanMode) {
if (input.agent.name === "plan") {
const text = yield* Effect.gen(function* () {
if (!input.agent.planReminder) return PROMPT_PLAN
const state = yield* planState(input.session, fsys)
return render(input.agent.planReminder, state.planReminder)
})
userMessage.parts.push({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PROMPT_PLAN,
text,
synthetic: true,
})
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
const text = yield* Effect.gen(function* () {
if (!input.agent.buildSwitchReminder) return BUILD_SWITCH
const state = yield* planState(input.session, fsys)
return render(input.agent.buildSwitchReminder, state.buildSwitch)
})
userMessage.parts.push({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
text,
synthetic: true,
})
}
Expand All @@ -50,17 +59,17 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {

const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const exists = yield* fsys.existsSafe(plan)
const state = yield* planState(input.session, fsys)
const part = yield* sessions.updatePart({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: exists
? `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`
: BUILD_SWITCH,
text: input.agent.buildSwitchReminder
? render(input.agent.buildSwitchReminder, state.buildSwitch)
: state.exists
? `${BUILD_SWITCH}\n\n${state.buildSwitch}`
: BUILD_SWITCH,
synthetic: true,
})
userMessage.parts.push(part)
Expand All @@ -69,24 +78,40 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {

if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages

const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const exists = yield* fsys.existsSafe(plan)
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
const state = yield* planState(input.session, fsys)
if (!state.exists) yield* fsys.ensureDir(path.dirname(state.filepath)).pipe(Effect.catch(Effect.die))
const part = yield* sessions.updatePart({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PLAN_MODE.replace("${planInfo}", () =>
exists
? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.`
: `No plan file exists yet. You should create your plan at ${plan} using the write tool.`,
),
text: input.agent.planReminder ? render(input.agent.planReminder, state.planReminder) : render(PLAN_MODE, state.planReminder),
synthetic: true,
})
userMessage.parts.push(part)
return input.messages
})

function render(template: string, planInfo: string) {
return template.replaceAll("${planInfo}", planInfo)
}

function planState(session: Session.Info, fsys: FSUtil.Interface) {
return Effect.gen(function* () {
const ctx = yield* InstanceState.context
const filepath = Session.plan(session, ctx)
const exists = yield* fsys.existsSafe(filepath)
return {
exists,
filepath,
planReminder: exists
? `A plan file already exists at ${filepath}. You can read it and make incremental edits using the edit tool.`
: `No plan file exists yet. You should create your plan at ${filepath} using the write tool.`,
buildSwitch: exists
? `A plan file exists at ${filepath}. You should execute on the plan defined within it`
: `No plan file exists yet. There is no saved plan to execute from.`,
}
})
}

export * as SessionReminders from "./reminders"
19 changes: 19 additions & 0 deletions packages/opencode/test/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,25 @@ it.instance(
},
)

it.instance(
"agent reminder overrides can be set from config",
() =>
Effect.gen(function* () {
const build = yield* load((svc) => svc.get("build"))
const plan = yield* load((svc) => svc.get("plan"))
expect(build?.buildSwitchReminder).toBe("Custom build reminder")
expect(plan?.planReminder).toBe("Custom plan reminder")
}),
{
config: {
agent: {
build: { build_switch_reminder: "Custom build reminder" },
plan: { plan_reminder: "Custom plan reminder" },
},
},
},
)

it.instance(
"unknown agent properties are placed into options",
() =>
Expand Down
Loading
Loading