Skip to content

Commit fa871bf

Browse files
feat(core): add configurable plan reminders
1 parent 85e278b commit fa871bf

11 files changed

Lines changed: 289 additions & 18 deletions

File tree

packages/core/src/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
2222
model: ModelV2.Ref.pipe(Schema.optional),
2323
request: ProviderV2.Request,
2424
system: Schema.String.pipe(Schema.optional),
25+
planReminder: Schema.String.pipe(Schema.optional),
26+
buildSwitchReminder: Schema.String.pipe(Schema.optional),
2527
description: Schema.String.pipe(Schema.optional),
2628
mode: Schema.Literals(["subagent", "primary", "all"]),
2729
hidden: Schema.Boolean,

packages/core/src/config/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
1515
variant: Schema.String.pipe(Schema.optional),
1616
request: ConfigProvider.Request.pipe(Schema.optional),
1717
system: Schema.String.pipe(Schema.optional),
18+
plan_reminder: Schema.String.pipe(Schema.optional),
19+
build_switch_reminder: Schema.String.pipe(Schema.optional),
1820
description: Schema.String.pipe(Schema.optional),
1921
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
2022
hidden: Schema.Boolean.pipe(Schema.optional),

packages/core/src/config/plugin/agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const agentKeys = new Set([
2424
"variant",
2525
"request",
2626
"system",
27+
"plan_reminder",
28+
"build_switch_reminder",
2729
"description",
2830
"mode",
2931
"hidden",
@@ -87,6 +89,8 @@ export const Plugin = PluginV2.define({
8789
Object.assign(agent.request.body, item.request.body ?? {})
8890
}
8991
if (item.system !== undefined) agent.system = item.system
92+
if (item.plan_reminder !== undefined) agent.planReminder = item.plan_reminder
93+
if (item.build_switch_reminder !== undefined) agent.buildSwitchReminder = item.build_switch_reminder
9094
if (item.description !== undefined) agent.description = item.description
9195
if (item.mode !== undefined) agent.mode = item.mode
9296
if (item.hidden !== undefined) agent.hidden = item.hidden

packages/core/src/v1/config/agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const AgentSchema = Schema.StructWithRest(
1818
temperature: Schema.optional(Schema.Finite),
1919
top_p: Schema.optional(Schema.Finite),
2020
prompt: Schema.optional(Schema.String),
21+
plan_reminder: Schema.optional(Schema.String),
22+
build_switch_reminder: Schema.optional(Schema.String),
2123
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
2224
description: "@deprecated Use 'permission' field instead",
2325
}),
@@ -45,6 +47,8 @@ const KNOWN_KEYS = new Set([
4547
"model",
4648
"variant",
4749
"prompt",
50+
"plan_reminder",
51+
"build_switch_reminder",
4852
"description",
4953
"temperature",
5054
"top_p",

packages/core/src/v1/config/migrate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
115115
variant: info.variant,
116116
request: Object.keys(body).length ? { body } : undefined,
117117
system: info.prompt,
118+
plan_reminder: info.plan_reminder,
119+
build_switch_reminder: info.build_switch_reminder,
118120
description: info.description,
119121
mode: info.mode,
120122
hidden: info.hidden,

packages/core/test/config/agent.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
121121
reviewer: {
122122
model: "anthropic/claude-sonnet",
123123
system: "Review carefully.",
124+
plan_reminder: "Plan carefully ${planInfo}",
125+
build_switch_reminder: "Build carefully ${planInfo}",
124126
description: "Reviews changes",
125127
mode: "subagent",
126128
hidden: true,
@@ -159,6 +161,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
159161
if (!reviewer) throw new Error("expected configured reviewer agent")
160162
expect(reviewer).toMatchObject({
161163
system: "Review carefully.",
164+
planReminder: "Plan carefully ${planInfo}",
165+
buildSwitchReminder: "Build carefully ${planInfo}",
162166
description: "Reviews changes",
163167
mode: "subagent",
164168
hidden: true,
@@ -215,6 +219,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
215219
model: openrouter/openai/gpt-5
216220
description: Markdown description
217221
temperature: 0.5
222+
plan_reminder: Markdown plan reminder \${planInfo}
223+
build_switch_reminder: Markdown build reminder \${planInfo}
218224
tools:
219225
write: false
220226
---
@@ -259,6 +265,8 @@ Use native v2 fields.`,
259265
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
260266
model: { providerID: "openrouter", id: "openai/gpt-5" },
261267
system: "Review carefully.",
268+
planReminder: "Markdown plan reminder ${planInfo}",
269+
buildSwitchReminder: "Markdown build reminder ${planInfo}",
262270
description: "Markdown description",
263271
request: { body: { temperature: 0.5 } },
264272
permissions: [{ action: "edit", resource: "*", effect: "deny" }],

packages/core/test/config/config.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,30 @@ describe("Config", () => {
144144
}),
145145
)
146146

147+
it.effect("migrates v1 agent reminder overrides", () =>
148+
Effect.sync(() => {
149+
const migrated = ConfigMigrateV1.migrate({
150+
agent: {
151+
plan: {
152+
prompt: "Plan system",
153+
plan_reminder: "Plan reminder ${planInfo}",
154+
},
155+
build: {
156+
build_switch_reminder: "Build reminder ${planInfo}",
157+
},
158+
},
159+
})
160+
161+
expect(migrated.agents?.plan).toMatchObject({
162+
system: "Plan system",
163+
plan_reminder: "Plan reminder ${planInfo}",
164+
})
165+
expect(migrated.agents?.build).toMatchObject({
166+
build_switch_reminder: "Build reminder ${planInfo}",
167+
})
168+
}),
169+
)
170+
147171
it.live("returns an empty configuration when directory files do not exist", () =>
148172
Effect.acquireRelease(
149173
Effect.promise(() => tmpdir()),

packages/opencode/src/agent/agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export const Info = Schema.Struct({
5050
),
5151
variant: Schema.optional(Schema.String),
5252
prompt: Schema.optional(Schema.String),
53+
planReminder: Schema.optional(Schema.String),
54+
buildSwitchReminder: Schema.optional(Schema.String),
5355
options: Schema.Record(Schema.String, Schema.Unknown),
5456
steps: Schema.optional(Schema.Finite),
5557
}).annotate({ identifier: "Agent" })
@@ -279,6 +281,8 @@ export const layer = Layer.effect(
279281
if (value.model) item.model = Provider.parseModel(value.model)
280282
item.variant = value.variant ?? item.variant
281283
item.prompt = value.prompt ?? item.prompt
284+
item.planReminder = value.plan_reminder ?? item.planReminder
285+
item.buildSwitchReminder = value.build_switch_reminder ?? item.buildSwitchReminder
282286
item.description = value.description ?? item.description
283287
item.temperature = value.temperature ?? item.temperature
284288
item.topP = value.top_p ?? item.topP

packages/opencode/src/session/reminders.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
66
import { InstanceState } from "@/effect/instance-state"
77
import { RuntimeFlags } from "@/effect/runtime-flags"
88
import { PartID } from "./schema"
9-
import { MessageV2 } from "./message-v2"
109
import { Session } from "./session"
1110
import PROMPT_PLAN from "./prompt/plan.txt"
1211
import BUILD_SWITCH from "./prompt/build-switch.txt"
@@ -25,23 +24,33 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
2524

2625
if (!flags.experimentalPlanMode) {
2726
if (input.agent.name === "plan") {
27+
const text = yield* Effect.gen(function* () {
28+
if (!input.agent.planReminder) return PROMPT_PLAN
29+
const state = yield* planState(input.session, fsys)
30+
return render(input.agent.planReminder, state.planReminder)
31+
})
2832
userMessage.parts.push({
2933
id: PartID.ascending(),
3034
messageID: userMessage.info.id,
3135
sessionID: userMessage.info.sessionID,
3236
type: "text",
33-
text: PROMPT_PLAN,
37+
text,
3438
synthetic: true,
3539
})
3640
}
3741
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
3842
if (wasPlan && input.agent.name === "build") {
43+
const text = yield* Effect.gen(function* () {
44+
if (!input.agent.buildSwitchReminder) return BUILD_SWITCH
45+
const state = yield* planState(input.session, fsys)
46+
return render(input.agent.buildSwitchReminder, state.buildSwitch)
47+
})
3948
userMessage.parts.push({
4049
id: PartID.ascending(),
4150
messageID: userMessage.info.id,
4251
sessionID: userMessage.info.sessionID,
4352
type: "text",
44-
text: BUILD_SWITCH,
53+
text,
4554
synthetic: true,
4655
})
4756
}
@@ -50,17 +59,17 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
5059

5160
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
5261
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
53-
const ctx = yield* InstanceState.context
54-
const plan = Session.plan(input.session, ctx)
55-
const exists = yield* fsys.existsSafe(plan)
62+
const state = yield* planState(input.session, fsys)
5663
const part = yield* sessions.updatePart({
5764
id: PartID.ascending(),
5865
messageID: userMessage.info.id,
5966
sessionID: userMessage.info.sessionID,
6067
type: "text",
61-
text: exists
62-
? `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`
63-
: BUILD_SWITCH,
68+
text: input.agent.buildSwitchReminder
69+
? render(input.agent.buildSwitchReminder, state.buildSwitch)
70+
: state.exists
71+
? `${BUILD_SWITCH}\n\n${state.buildSwitch}`
72+
: BUILD_SWITCH,
6473
synthetic: true,
6574
})
6675
userMessage.parts.push(part)
@@ -69,24 +78,38 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
6978

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

72-
const ctx = yield* InstanceState.context
73-
const plan = Session.plan(input.session, ctx)
74-
const exists = yield* fsys.existsSafe(plan)
75-
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
81+
const state = yield* planState(input.session, fsys)
82+
if (!state.exists) yield* fsys.ensureDir(path.dirname(state.filepath)).pipe(Effect.catch(Effect.die))
7683
const part = yield* sessions.updatePart({
7784
id: PartID.ascending(),
7885
messageID: userMessage.info.id,
7986
sessionID: userMessage.info.sessionID,
8087
type: "text",
81-
text: PLAN_MODE.replace("${planInfo}", () =>
82-
exists
83-
? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.`
84-
: `No plan file exists yet. You should create your plan at ${plan} using the write tool.`,
85-
),
88+
text: input.agent.planReminder ? render(input.agent.planReminder, state.planReminder) : render(PLAN_MODE, state.planReminder),
8689
synthetic: true,
8790
})
8891
userMessage.parts.push(part)
8992
return input.messages
9093
})
9194

95+
function render(template: string, planInfo: string) {
96+
return template.replaceAll("${planInfo}", planInfo)
97+
}
98+
99+
function planState(session: Session.Info, fsys: FSUtil.Interface) {
100+
return Effect.gen(function* () {
101+
const ctx = yield* InstanceState.context
102+
const filepath = Session.plan(session, ctx)
103+
const exists = yield* fsys.existsSafe(filepath)
104+
return {
105+
exists,
106+
filepath,
107+
planReminder: exists
108+
? `A plan file already exists at ${filepath}. You can read it and make incremental edits using the edit tool.`
109+
: `No plan file exists yet. You should create your plan at ${filepath} using the write tool.`,
110+
buildSwitch: exists ? `A plan file exists at ${filepath}. You should execute on the plan defined within it` : "",
111+
}
112+
})
113+
}
114+
92115
export * as SessionReminders from "./reminders"

packages/opencode/test/agent/agent.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,25 @@ it.instance(
370370
},
371371
)
372372

373+
it.instance(
374+
"agent reminder overrides can be set from config",
375+
() =>
376+
Effect.gen(function* () {
377+
const build = yield* load((svc) => svc.get("build"))
378+
const plan = yield* load((svc) => svc.get("plan"))
379+
expect(build?.buildSwitchReminder).toBe("Custom build reminder")
380+
expect(plan?.planReminder).toBe("Custom plan reminder")
381+
}),
382+
{
383+
config: {
384+
agent: {
385+
build: { build_switch_reminder: "Custom build reminder" },
386+
plan: { plan_reminder: "Custom plan reminder" },
387+
},
388+
},
389+
},
390+
)
391+
373392
it.instance(
374393
"unknown agent properties are placed into options",
375394
() =>

0 commit comments

Comments
 (0)