Skip to content

Commit 8d85a4f

Browse files
committed
feat(policy): a typed behavior-policy layer
memcode behaved one way for everyone. Where a user might reasonably want a say — which model explores, whether plans get reviewed and by whom, what a session starts on — the answer was a hardcoded constant or a scattered config field. The delegated pin proved the shape but solved exactly one decision point, and the obvious next step (explore_model, plan_review_model, advisor_model, startup_model, theme_mode) is a bag of special cases. This generalises it into one primitive: typed policy on named targets. natural-language instruction -> policy tool -> typed policy store -> scope resolution -> runtime decision point: policy.Resolve("agent.explore") The rule the package exists to enforce, stated in its doc comment and enforced by a guard test: Policy chooses behavior. The model may not synthesize policy. "Always review plans with grok" is a settings write. "Review this plan with kimi" is an override on that plan. A model deciding a plan looks hard enough to warrant a stronger model is neither, and is forbidden — that is the automatic routing this codebase deleted, and it does not come back through a settings API. v1 is UNCONDITIONAL. Every policy fires every time; nothing inspects or classifies a task. The representation leaves room for conditions later, but there is no condition field and no evaluator, because task classification is exactly where hidden routing regrows. Inheritance is DECLARED, not special-cased: a schema names a Parent, and agent.explore -> agent.delegated -> the primary pin falls out of that. A future agent.research or plan.scout declares a parent and needs no resolution code of its own. The shipped "unset delegated means inherit your own model" behavior is now schema (InheritsPrimaryModel) rather than a second hand-written chain. Invocation overrides belong to the OPERATION, not to a store. "Review this plan with kimi" lives on the plan controller and is cleared at every transition that begins or ends a plan — there is no global consume-on-next-use state that could leak into an unrelated later operation. A test asserts it does not survive its plan. fallback_models is narrow on purpose: models to try when the chosen one cannot be REACHED, under the same provider/transport semantics the catalog's declared chain already uses. Nothing consults it because a result looked weak. Capability gaps still refuse rather than substitute. Seven targets, chosen so this is provably about behavior and not models: agent.delegated, agent.explore (+ concurrency, replacing the hardcoded maxReaders), plan.review, plan.advisor, startup.model, ui.theme, session.effort. Modes are three-valued enums (off/offer/always) rather than a bool plus a nil model, so "never review", "offer review" and "always review with X" are each expressible. Policy is kept completely separate from internal/prefs, in both directions. That system infers standing preferences from repeated signals and injects advisory prose; this one is explicit, immediate and programmatic. A guard test asserts prefs has no import of policy: an inferred pattern must never rewire which model runs. Storage is two plain JSON files (.memcode/policy.json and the user-level equivalent), deliberately outside config.json, so a target can be listed, diffed and reset as a unit — "reset how explore agents behave" deletes one object. `/policy` and the tool's `show` report every effective value and which scope it came from, because per-field resolution across four layers is only comprehensible if the source is visible. The wire budget for tool defs is raised 31KB -> 32KB. The policy tool's description was trimmed twice first; what remains is the guardrail sentence, which is the last thing that should be cut for bytes.
1 parent 556fe78 commit 8d85a4f

29 files changed

Lines changed: 1630 additions & 595 deletions

‎cmd/explore.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/spf13/cobra"
77

88
"github.com/memcode-ai/memcode/internal/explore"
9+
"github.com/memcode-ai/memcode/internal/policy"
910
"github.com/memcode-ai/memcode/internal/provider"
1011
)
1112

@@ -33,7 +34,12 @@ Requires MEMCODE_API_TOKEN (from the environment or a gitignored .env at the rep
3334
defer st.Close()
3435
model := provider.EffectiveModel(cfg.Models.Coder)
3536

36-
return explore.Run(ctx, st, runner, cfg.Root, model, question, userOut())
37+
// Explore's model and its concurrency are both agent.explore policy.
38+
pol := sessionPolicy(cfg.Root, model).Resolve(policy.AgentExplore)
39+
if m := pol.Model("model"); m != "" {
40+
model = m
41+
}
42+
return explore.Run(ctx, st, runner, cfg.Root, model, question, pol.Int("concurrency"), userOut())
3743
},
3844
}
3945

‎cmd/interactive.go‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/memcode-ai/memcode/internal/agent/runtime"
1111
"github.com/memcode-ai/memcode/internal/config"
1212
"github.com/memcode-ai/memcode/internal/llm"
13+
"github.com/memcode-ai/memcode/internal/policy"
1314
"github.com/memcode-ai/memcode/internal/provider"
1415
"github.com/memcode-ai/memcode/internal/update"
1516
"github.com/memcode-ai/memcode/internal/vxui"
@@ -79,12 +80,7 @@ func runInteractive(ctx context.Context, mode permissions.Mode, modeExplicit boo
7980
// place that chain lives.
8081
pin, win := config.ResolvePin(cfg, "")
8182
sess.SetPin(pin, win)
82-
// Delegated work (sub-agents, scouts, plan research) runs on the
83-
// delegated pin. Unset means inherit the primary, so by default every
84-
// worker stays on the model the user chose.
85-
if dp, dw := config.ResolveDelegatedPin(cfg, "", pin, win); dp != pin {
86-
sess.SetDelegatedPin(dp, dw)
87-
}
83+
sess.SetPolicy(sessionPolicy(cfg.Root, pin))
8884
}
8985
sess.SetServingDefault(cfg.ServingDefault) // cached cheap-lane model → banner/footer show it at once (refreshed by the /models fetch)
9086
if chrome {
@@ -124,3 +120,15 @@ func runInteractive(ctx context.Context, mode permissions.Mode, modeExplicit boo
124120
}
125121
return runErr
126122
}
123+
124+
// sessionPolicy loads the user's behavior policy for this session: the two
125+
// persisted layers plus the primary pin a model chain ends at. One call at the
126+
// cmd boundary; every decision point then resolves its own target.
127+
func sessionPolicy(root, primary string) *policy.Resolver {
128+
return &policy.Resolver{
129+
Session: policy.Set{},
130+
Workspace: policy.Load(policy.WorkspacePath(root)),
131+
User: policy.Load(policy.UserPath()),
132+
Primary: primary,
133+
}
134+
}

‎cmd/plan.go‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ Requires MEMCODE_API_TOKEN (from the environment or a gitignored .env at the rep
3333
pin, win := config.ResolvePin(cfg, "")
3434
sess := runtime.New(st, runner, cfg.Root, pin, permissions.ModeAsk, userOut())
3535
sess.SetPin(pin, win)
36-
if dp, dw := config.ResolveDelegatedPin(cfg, "", pin, win); dp != pin {
37-
sess.SetDelegatedPin(dp, dw)
38-
}
36+
sess.SetPolicy(sessionPolicy(cfg.Root, pin))
3937

4038
_, err = sess.RunPlan(ctx, strings.Join(args, " "))
4139
return err

‎cmd/run.go‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,7 @@ for local gateway development. Never store keys in .memcode.`,
146146
modelFlag, _ := cmd.Flags().GetString("model")
147147
pin, win := config.ResolvePin(cfg, modelFlag)
148148
sess.SetPin(pin, win)
149-
if dp, dw := config.ResolveDelegatedPin(cfg, "", pin, win); dp != pin {
150-
sess.SetDelegatedPin(dp, dw) // sub-agents/scouts; unset = inherit
151-
}
149+
sess.SetPolicy(sessionPolicy(cfg.Root, pin))
152150
// The header must name the model that will actually serve. It used
153151
// to print a config/provider default, which under Automatic was a
154152
// guess and is now simply wrong.

‎internal/agent/plan/plan.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ type Controller struct {
103103
reflectRounds int // extra research rounds the reflection gate triggered
104104

105105
savedModel string // model to restore when leaving plan mode
106+
// policyOverride holds per-OPERATION policy for the plan in flight, e.g.
107+
// "review this plan with kimi". It belongs to this plan and is dropped when
108+
// the plan ends — there is deliberately no stored "consume on next use"
109+
// state that could leak into an unrelated later operation.
110+
policyOverride map[string]map[string]any
106111

107112
// lastPlan is the pinned apply contract: the most recently PRESENTED
108113
// plan-shaped synthesis. Preferred over any "last rendered text" at
@@ -145,6 +150,7 @@ func WithTask(task string) Opt { return func(c *Controller) { c.task = task } }
145150
func (c *Controller) Enter(currentModel string, opts ...Opt) Effects {
146151
c.mu.Lock()
147152
defer c.mu.Unlock()
153+
c.policyOverride = nil // per-operation policy never outlives its operation
148154
if c.phase != Idle {
149155
return Effects{}
150156
}
@@ -166,6 +172,38 @@ func (c *Controller) Enter(currentModel string, opts ...Opt) Effects {
166172
return eff
167173
}
168174

175+
// SetPolicyOverride attaches policy to the plan currently in flight. Scoped to
176+
// this operation only; Enter and the terminal transitions clear it.
177+
func (c *Controller) SetPolicyOverride(target string, fields map[string]any) {
178+
c.mu.Lock()
179+
defer c.mu.Unlock()
180+
if c.policyOverride == nil {
181+
c.policyOverride = map[string]map[string]any{}
182+
}
183+
dst := c.policyOverride[target]
184+
if dst == nil {
185+
dst = map[string]any{}
186+
c.policyOverride[target] = dst
187+
}
188+
for k, v := range fields {
189+
dst[k] = v
190+
}
191+
}
192+
193+
// PolicyOverride reports the per-operation policy for a target, nil when none.
194+
func (c *Controller) PolicyOverride(target string) map[string]any {
195+
c.mu.Lock()
196+
defer c.mu.Unlock()
197+
out := map[string]any{}
198+
for k, v := range c.policyOverride[target] {
199+
out[k] = v
200+
}
201+
if len(out) == 0 {
202+
return nil
203+
}
204+
return out
205+
}
206+
169207
// BeginTurn is the per-turn reset: a presented plan goes back to Researching at
170208
// the top of the next plan-mode turn, so an interrupted turn (Ctrl-C on a
171209
// clarifying question) never leaves a stale "Plan ready" selector armed with
@@ -234,6 +272,7 @@ func (c *Controller) NotePlanTurn(hasOutput bool) Effects {
234272
func (c *Controller) Approve(lastTextFallback string) (Effects, ApproveOutcome) {
235273
c.mu.Lock()
236274
defer c.mu.Unlock()
275+
c.policyOverride = nil // per-operation policy never outlives its operation
237276
if !c.phase.Planning() {
238277
return Effects{}, ApproveOutcome{}
239278
}
@@ -262,6 +301,7 @@ func (c *Controller) Approve(lastTextFallback string) (Effects, ApproveOutcome)
262301
func (c *Controller) Cancel() Effects {
263302
c.mu.Lock()
264303
defer c.mu.Unlock()
304+
c.policyOverride = nil // per-operation policy never outlives its operation
265305
if !c.phase.Planning() {
266306
return Effects{}
267307
}

‎internal/agent/runtime/agent.go‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66

77
"github.com/memcode-ai/memcode/internal/llm"
8+
"github.com/memcode-ai/memcode/internal/policy"
89
"github.com/memcode-ai/memcode/internal/wire"
910
)
1011

@@ -43,20 +44,30 @@ type AgentResult struct {
4344
// Task primitive: the caller (the LLM via a tool, or plan mode) blocks until the sub-agent
4445
// finishes, then acts on the result.
4546
func (s *Session) spawnAgent(ctx context.Context, spec AgentSpec) (AgentResult, error) {
46-
// Every delegated worker — agent-tool tasks, explore scouts, plan-mode
47-
// research — runs on the DELEGATED pin. Unset means inherit the primary, so
48-
// the default is "the model you chose runs everything" and a split only
49-
// happens because someone asked for one.
47+
// Which model a delegated worker runs on is POLICY, resolved at this
48+
// decision point. Read-only explorers resolve agent.explore, which declares
49+
// agent.delegated as its parent, which ends at the session's own model — so
50+
// the default is still "everything runs on the model you chose", and a
51+
// split exists only because someone asked for one.
5052
//
51-
// This is a pin, not a decision: nothing here inspects the task to pick a
52-
// model, and the agent tool has no model parameter to override it with.
53-
model, runner := s.model, s.runner.Fork()
54-
if s.delegatedPin != "" {
55-
model, runner = s.delegatedPin, s.runner.ForkWithModel(s.delegatedPin)
53+
// Nothing here inspects the task. The target is chosen by the worker's
54+
// PURPOSE, which the caller already fixed, and the agent tool has no model
55+
// parameter to override it with.
56+
target := policy.AgentDelegated
57+
if spec.Purpose == llm.Explore {
58+
target = policy.AgentExplore
59+
}
60+
model := s.policy.Resolve(target).Model("model")
61+
runner := s.runner.Fork()
62+
if model != "" && model != s.pin {
63+
runner = s.runner.ForkWithModel(model)
64+
}
65+
if model == "" {
66+
model = s.model
5667
}
5768

5869
sub := New(s.store, runner, s.root, model, s.effectiveMode(), io.Discard)
59-
sub.delegatedPin, sub.delegatedWindow = s.delegatedPin, s.delegatedWindow // a worker's own workers too
70+
sub.policy, sub.pin = s.policy, s.pin // a worker's own workers resolve the same way
6071
if spec.Purpose != "" {
6172
sub.purpose = spec.Purpose
6273
} else {

‎internal/agent/runtime/delegated_test.go‎

Lines changed: 0 additions & 181 deletions
This file was deleted.

‎internal/agent/runtime/exec.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,8 @@ func (s *Session) dispatch(ctx context.Context, u wire.Block) toolResult {
325325
return s.recallPlanTool(ctx, u.Input)
326326
case tools.PreferenceSignal:
327327
return s.preferenceSignalTool(ctx, u.Input)
328-
case tools.ModelPreference:
329-
return s.modelPreferenceTool(ctx, u.Input)
328+
case tools.UserPolicy:
329+
return s.policyTool(ctx, u.Input)
330330
case tools.BrowserNavigate:
331331
return s.browserNavigateTool(ctx, u.Input)
332332
case tools.BrowserClick:

0 commit comments

Comments
 (0)