diff --git a/docs/role-execution-policy.md b/docs/role-execution-policy.md index d15e086b07..02d7a5e1ce 100644 --- a/docs/role-execution-policy.md +++ b/docs/role-execution-policy.md @@ -104,9 +104,10 @@ with the model beside it: Sonnet at SOL's effort is refused too, because an effo model. The list keeps the order the file declares, and that order carries no preference. The file is refused when it is read, not at the first creation, if a `pairs` list is empty or is not a -list, names the same pair twice, holds an entry that is not exactly `{model, reasoningEffort}`, sits in -the same entry as `model` or `reasoningEffort`, is declared for the `supervisor`, or names a pair that the -file's `allowed` list does not approve. A list of one pair is the one-pair form written another way. +list, names the same pair twice, holds an entry that states anything beyond `model`, `reasoningEffort` +and the optional `autoCompactTokenLimit` (see "A pair may cap auto-compaction"), sits in the same entry +as `model` or `reasoningEffort`, is declared for the `supervisor`, or names a pair that the file's +`allowed` list does not approve. A list of one pair is the one-pair form written another way. The description of a policy whose roles list several pairs must also fit what the relay publishes about it (half of the 64 KiB its readers accept), so an absurdly long list is refused when the file is read instead of leaving every worker check reading "unreadable". @@ -136,6 +137,71 @@ role that lists more than one pair is described differently: withheld from a thread the host has not loaded, as `unverified_pair_for_unloaded_thread`, exactly as a supervisor's or an exception's pair is. +## A pair may cap auto-compaction + +A host can report a context window far larger than what the provider behind a model actually +accepts. `inferhub/deepseek-v4.1-flash` is the measured case: the host reports 950,000 tokens, and +the provider cuts every request above roughly 664,000 with `Provider stream error: upstream error`, +so the child stops without a receipt before Codex compacts on its own. A pair may therefore carry +`autoCompactTokenLimit`, a positive integer the bridge sends as `model_auto_compact_token_limit` in +the `thread/start` and `thread/resume` `config`, which makes the host compact below its own window: + + "child": {"model": "inferhub/deepseek-v4.1-flash", "reasoningEffort": "none", "autoCompactTokenLimit": 550000} + + "child": {"pairs": [ + {"model": "inferhub/deepseek-v4.1-flash", "reasoningEffort": "none", "autoCompactTokenLimit": 550000} + ]} + +The field is optional and belongs to the pair, so it goes on whichever spelling declares that pair: +the single `model`/`reasoningEffort` form, or one entry of `pairs`. A file that declares none +behaves exactly as before — no key is sent and the host's own threshold stands. A supervisor and an +exception name no pair, so neither can carry one: a `supervisor` entry that declares the field does +not load, and so does an entry that puts it beside `pairs` instead of inside one. + +The relay reads the same declaration. A task's recorded settings come from the host's creation +response, which never carries this value, so a relay delivery or correction send resolves the +recipient's role and the limit from the pair that role states, and adds it to the resume's config — +including on a settings-free resume, which transmits no pair but may still carry the limit of the +pair the thread is on. The role is the one the relay's own gate confirmed the task is bound to (a +record need not cite its own role), falling back to the record's own citation when the task is +bound to none. A pair that declares no limit sends none rather than inheriting a sibling pair's, +and a record citing an exception sends none. + +A value that is not a positive integer is refused when the file is read, through the same +`execution_policy_unreadable` path as every other unusable policy value: a string, a boolean, a +fraction or an exponent, zero and a negative number all fail there. A number written with an +exponent is refused even when it is integral — `1e6` is a float spelling, not an integer — so a +value the host would accept only after rounding is never silently taken. The limit is not part of what a +request is judged against, so it changes neither which pairs a role allows nor the description +`get_capabilities` reports — that description is the authorization surface, as it is for MCP +profiles. + +The host has no field that reports this value back, so the bridge never compares it. A creation or a +resume is not withheld, and its receipt does not fail, because the host said nothing about it. The +receipt records it the way it records any setting the host cannot confirm: under `requested`, with +the value that was sent, and under `unobservable`; `verified` never lists it. `verification` stays +`observed_at_creation` or `observed_at_resume` for the settings that were actually compared, and +`not_requested` when the limit was the only thing asked for. A relay resume records it the same way +on the receipt it keeps for its own send: the limit resolved from the record's pair is written under +`requested` and `unobservable`, with `not_requested`, so a later reader of that stored receipt +still sees what the send carried. + +**A resume does not install the value on a thread the host already has loaded.** The host answers a +resume with the thread's current state, so a resume that transmits the limit to a loaded thread +leaves that thread's own threshold as it was: the value takes effect at creation, or when the host +loads the thread again (a `notLoaded` recipient). An accepted resume receipt therefore records that the +limit was sent, not that it now applies. An operator who adds the value for a running child gets it +on that child's next load. + +**The recommended value is 550000**, about 110,000 below the highest input observed to pass +(664,238 tokens, 2026-10-06) and about 114,000 below the first observed failure. The margin is there +because compaction is decided between samples, and one tool result can add a lot between two of +them. The evidence is the parent's probe of 2026-10-06: two ephemeral threads under an isolated +`codex app-server`, both reporting a 950,000 window, given the same ~15,000-token input over three +turns. The thread given `config.model_auto_compact_token_limit=4000` compacted twice, its context +falling from 30,696 to 15,478; the control thread never compacted and grew 30,756 → 40,588 → +40,593. Writing the value into a host's policy file is an operator step, like the rest of that file. + ## A role may carry MCP profiles A thread starts every MCP server config.toml defines and every installed plugin provides, whether or diff --git a/internal/bridge/autocompact_test.go b/internal/bridge/autocompact_test.go new file mode 100644 index 0000000000..3dabcd0d35 --- /dev/null +++ b/internal/bridge/autocompact_test.go @@ -0,0 +1,175 @@ +package bridge + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver/fakehost" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" + "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" +) + +// autoCompactPolicy is one role whose pair carries the limit this issue recommends. +const autoCompactPolicy = `{"roles":{"child":{"model":"explicit-model","reasoningEffort":"high","autoCompactTokenLimit":550000}}}` + +// autoCompactRequest returns the parameters of the first request that used method. +func autoCompactRequest(t *testing.T, host *fakehost.Server, method string) (map[string]any, string) { + t.Helper() + for _, request := range host.Requests() { + if request.Method != method { + continue + } + var params map[string]any + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + t.Fatal(err) + } + return params, string(request.Params) + } + t.Fatalf("no %s request", method) + return nil, "" +} + +// autoCompactConfig is the config object the request carried, which is where the limit travels. +func autoCompactConfig(t *testing.T, host *fakehost.Server, method string) map[string]any { + t.Helper() + params, _ := autoCompactRequest(t, host, method) + return pyjson.Map(params["config"]) +} + +// autoCompactReceiptSettings is the settings receipt of a mutation, whose unobservable list is where +// a transmitted-but-unreported setting is recorded. +func autoCompactReceiptSettings(t *testing.T, receipt map[string]any) map[string]any { + t.Helper() + section := pyjson.Map(receipt["settings"]) + if section == nil { + t.Fatalf("receipt has no settings section: %v", receipt) + } + return section +} + +// autoCompactListed is whether a receipt list names the field, whichever slice type it was built as. +func autoCompactListed(value any, field string) bool { + switch list := value.(type) { + case []string: + return slices.Contains(list, field) + case []any: + return slices.Contains(list, any(field)) + } + return false +} + +// A creation under a pair that declares the limit sends it in thread/start's config, as an integer, +// and records it as sent but not observable. +func TestCreateThreadSendsThePairsAutoCompactLimit(t *testing.T) { + b, host := policyBridge(t, autoCompactPolicy) + cwd := t.TempDir() + host.Respond("thread/start", startReply(cwd)) + host.Respond("turn/start", fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": "turn-1"}}}) + input := createInput(cwd, "auto-compact-create") + input.Role = "child" + receipt, err := b.CreateThread(context.Background(), input) + if err != nil || receipt["status"] != "accepted" { + t.Fatalf("receipt=%v err=%v", receipt, err) + } + if got := autoCompactConfig(t, host, "thread/start")[settings.AutoCompactTokenLimitKey]; got != float64(550000) { + t.Fatalf("thread/start config = %v", autoCompactConfig(t, host, "thread/start")) + } + if _, raw := autoCompactRequest(t, host, "thread/start"); !strings.Contains(raw, `"model_auto_compact_token_limit":550000`) { + t.Fatalf("the limit did not leave as an integer: %s", raw) + } + section := autoCompactReceiptSettings(t, receipt) + if !autoCompactListed(section["unobservable"], settings.AutoCompactTokenLimitKey) { + t.Fatalf("settings receipt = %v", section) + } +} + +// A resume under the same pair sends the limit again, and a host answer that says nothing about it +// neither withholds the message nor fails the receipt. +func TestSendMessageSendsThePairsAutoCompactLimitAndStillDelivers(t *testing.T) { + b, host := policyBridge(t, autoCompactPolicy) + cwd := t.TempDir() + host.Respond("thread/read", fakehost.Reply{Result: map[string]any{"thread": map[string]any{"status": map[string]any{"type": "idle"}}}}) + host.Respond("thread/resume", startReply(cwd)) + host.Respond("turn/start", fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": "turn-1"}}}) + receipt, err := b.SendMessageToThread(context.Background(), SendMessage{RequestID: "auto-compact-send", ThreadID: "thread-1", Message: "work", Role: "child", Expected: map[string]any{"model": "explicit-model", "reasoning_effort": "high"}}) + if err != nil || receipt["status"] != "accepted" || host.Count("turn/start") != 1 { + t.Fatalf("the host's silence about the limit withheld the message: receipt=%v err=%v", receipt, err) + } + if got := autoCompactConfig(t, host, "thread/resume")[settings.AutoCompactTokenLimitKey]; got != float64(550000) { + t.Fatalf("thread/resume config = %v", autoCompactConfig(t, host, "thread/resume")) + } + section := autoCompactReceiptSettings(t, receipt) + if !autoCompactListed(section["unobservable"], settings.AutoCompactTokenLimitKey) { + t.Fatalf("settings receipt = %v", section) + } +} + +// A policy that declares no limit sends no such key and reports none, so every host keeps exactly +// the behaviour it had. +func TestAPolicyWithoutTheLimitSendsNoAutoCompactKey(t *testing.T) { + b, host := policyBridge(t, `{"roles":{"child":{"model":"explicit-model","reasoningEffort":"high"}}}`) + cwd := t.TempDir() + host.Respond("thread/start", startReply(cwd)) + host.Respond("turn/start", fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": "turn-1"}}}) + input := createInput(cwd, "auto-compact-absent") + input.Role = "child" + receipt, err := b.CreateThread(context.Background(), input) + if err != nil || receipt["status"] != "accepted" { + t.Fatalf("receipt=%v err=%v", receipt, err) + } + if _, present := autoCompactConfig(t, host, "thread/start")[settings.AutoCompactTokenLimitKey]; present { + t.Fatalf("thread/start config = %v", autoCompactConfig(t, host, "thread/start")) + } + if autoCompactListed(autoCompactReceiptSettings(t, receipt)["unobservable"], settings.AutoCompactTokenLimitKey) { + t.Fatalf("settings receipt = %v", receipt["settings"]) + } +} + +// The third creation path, create_worktree_thread, builds its own contract; it must carry the limit +// too, or a worktree child would be the one creation that keeps the host's larger window. +func TestCreateWorktreeThreadSendsThePairsAutoCompactLimit(t *testing.T) { + b, host := policyBridge(t, autoCompactPolicy) + input := worktreeInput(t) + input.Role = "child" + worktreeHost(host, input.Destination) + receipt, err := b.CreateWorktreeThread(context.Background(), input) + if err != nil || receipt["status"] != "accepted" || receipt["phase"] != "complete" { + t.Fatalf("receipt=%v err=%v", receipt, err) + } + if got := autoCompactConfig(t, host, "thread/start")[settings.AutoCompactTokenLimitKey]; got != float64(550000) { + t.Fatalf("thread/start config = %v", autoCompactConfig(t, host, "thread/start")) + } + section := autoCompactReceiptSettings(t, receipt) + if !autoCompactListed(section["unobservable"], settings.AutoCompactTokenLimitKey) { + t.Fatalf("settings receipt = %v", section) + } +} + +// An exception is not a pair, so it carries no limit even when the role's pair does. +func TestAnExceptionCarriesNoAutoCompactLimit(t *testing.T) { + b, host := testBridge(t) + cwd := t.TempDir() + policy := `{"roles":{"child":{"model":"explicit-model","reasoningEffort":"high","autoCompactTokenLimit":550000}},"exceptions":{"one-task":{"model":"gpt-6-astra","reasoningEffort":"high","cwd":[` + jsonQuote(cwd) + `],"role":"child"}}}` + loaded, err := execution.FromBytes([]byte(policy), "test") + if err != nil { + t.Fatal(err) + } + b.Policy = loaded + start := startReply(cwd) + start.Result["model"] = "gpt-6-astra" + host.Respond("thread/start", start) + host.Respond("turn/start", fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": "turn-1"}}}) + input := createInput(cwd, "auto-compact-exception") + input.Role, input.Model, input.Effort, input.Exception = "child", "gpt-6-astra", "high", "one-task" + receipt, err := b.CreateThread(context.Background(), input) + if err != nil || receipt["status"] != "accepted" { + t.Fatalf("receipt=%v err=%v", receipt, err) + } + if _, present := autoCompactConfig(t, host, "thread/start")[settings.AutoCompactTokenLimitKey]; present { + t.Fatalf("an exception carried the role pair's limit: %v", autoCompactConfig(t, host, "thread/start")) + } +} diff --git a/internal/bridge/create.go b/internal/bridge/create.go index aca33e64d7..c18b8edbdb 100644 --- a/internal/bridge/create.go +++ b/internal/bridge/create.go @@ -101,7 +101,7 @@ func (b *Bridge) CreateThread(ctx context.Context, in CreateThread) (ledger.Rece if err != nil { return err } - contract = settings.Contract{CWD: cwd, Sandbox: in.Sandbox, Model: authorized.Model, ReasoningEffort: authorized.Effort, Roots: in.Roots, ExpectedPolicy: in.Policy} + contract = settings.Contract{CWD: cwd, Sandbox: in.Sandbox, Model: authorized.Model, ReasoningEffort: authorized.Effort, Roots: in.Roots, ExpectedPolicy: in.Policy, AutoCompactTokenLimit: authorized.AutoCompactTokenLimit} if err := contract.Validate(); err != nil { return err } diff --git a/internal/bridge/execution/autocompact_test.go b/internal/bridge/execution/autocompact_test.go new file mode 100644 index 0000000000..b15c4d29ef --- /dev/null +++ b/internal/bridge/execution/autocompact_test.go @@ -0,0 +1,105 @@ +package execution + +import ( + "reflect" + "strings" + "testing" +) + +// The limit lives on a pair, and the legacy single model/reasoningEffort declaration is a pair +// written another way, so both spellings read it. +func TestAutoCompactTokenLimitIsReadOnAPairAndOnTheLegacyDeclaration(t *testing.T) { + for _, scenario := range []struct { + name string + roles doc + }{ + {"legacy-single", doc{"child": doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}}}, + {"pairs", doc{"child": doc{"pairs": []any{doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}}}}}, + } { + t.Run(scenario.name, func(t *testing.T) { + p := mustLoad(t, rolesPolicy(scenario.roles, true)) + role, ok := p.Role("child") + if !ok || len(role.Pairs) != 1 || role.Pairs[0].AutoCompactTokenLimit == nil || *role.Pairs[0].AutoCompactTokenLimit != 550000 { + t.Fatalf("role=%+v ok=%v", role, ok) + } + if !role.Allows(model, effort) { + t.Fatal("the limit changed which pairs the role allows") + } + }) + } +} + +// A policy that declares no limit is unchanged: the pair carries nothing and the file reads as it +// always did. +func TestAutoCompactTokenLimitAbsentLeavesThePolicyAsItWas(t *testing.T) { + p := mustLoad(t, rolesPolicy(nil, true)) + role, ok := p.Role("child") + if !ok || len(role.Pairs) != 1 || role.Pairs[0].AutoCompactTokenLimit != nil { + t.Fatalf("role=%+v ok=%v", role, ok) + } +} + +// A value that is not a positive integer is refused while the file is read, through the policy error +// every other unusable value is refused with. No new refusal name appears. +func TestAutoCompactTokenLimitIsRefusedWhenItIsNotAPositiveInteger(t *testing.T) { + for _, value := range []string{`"550000"`, `0`, `-1`, `550000.0`, `1.5e6`, `1e6`, `true`, `null`, `NaN`, `Infinity`, `99999999999999999999`} { + t.Run(value, func(t *testing.T) { + raw := []byte(`{"roles":{"child":{"model":"` + model + `","reasoningEffort":"` + effort + `","autoCompactTokenLimit":` + value + `}}}`) + _, err := FromBytes(raw, "test-policy") + refusal := policyError(t, err) + if !strings.Contains(refusal.Detail, "autoCompactTokenLimit") { + t.Fatalf("the refusal does not name the field: %q", refusal.Detail) + } + }) + } +} + +// A limit on the pairs list is refused on the same path, and a pairs entry is still exactly one +// pair of model and effort however the limit is written. +func TestAutoCompactTokenLimitOnAPairsEntryIsReadAndRefusedTheSameWay(t *testing.T) { + good := doc{"child": doc{"pairs": []any{doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 1}}}} + p := mustLoad(t, rolesPolicy(good, true)) + role, _ := p.Role("child") + if role.Pairs[0].AutoCompactTokenLimit == nil || *role.Pairs[0].AutoCompactTokenLimit != 1 { + t.Fatalf("role=%+v", role) + } + bad := doc{"child": doc{"pairs": []any{doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": "550000"}}}} + _, err := load(t, rolesPolicy(bad, true)) + policyError(t, err) + // Two entries that name the same model and effort are still the same pair, limit or not. + twice := doc{"child": doc{"pairs": []any{ + doc{"model": model, "reasoningEffort": effort}, + doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}, + }}} + _, err = load(t, rolesPolicy(twice, true)) + if !strings.Contains(policyError(t, err).Detail, "twice") { + t.Fatalf("err=%v", err) + } +} + +// The limit is not part of what a request is judged against; it travels out of the authorization so +// the caller can send it, and it is absent for a supervisor or an exception, which name no pair. +func TestAuthorizeCarriesTheMatchedPairsLimit(t *testing.T) { + p := mustLoad(t, rolesPolicy(doc{"child": doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}}, true)) + auth := authorize(t, p, Input{Model: model, Effort: effort, Role: "child"}) + if auth.AutoCompactTokenLimit == nil || *auth.AutoCompactTokenLimit != 550000 { + t.Fatalf("authorized=%+v", auth) + } + if role, _ := p.Role("child"); role.Pairs[0].AutoCompactTokenLimit == nil { + t.Fatal("the limit was dropped from the role") + } + // A supervisor declares no pair, so there is nothing for a limit to belong to. + if _, err := load(t, rolesPolicy(doc{"supervisor": doc{"expectation": "record", "autoCompactTokenLimit": 550000}}, true)); err == nil { + t.Fatal("a supervisor was allowed to declare a limit") + } +} + +// The policy description is the authorization surface, not an inventory of how a pair runs. The +// limit is deliberately absent from it, so every existing description and refusal is unchanged. +func TestThePolicyDescriptionDoesNotMentionTheLimit(t *testing.T) { + withLimit := mustLoad(t, rolesPolicy(doc{"child": doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}}, true)) + without := mustLoad(t, rolesPolicy(doc{"child": doc{"model": model, "reasoningEffort": effort}}, true)) + if !reflect.DeepEqual(withLimit.Summary()["roles"], without.Summary()["roles"]) { + t.Fatalf("with=%v without=%v", withLimit.Summary()["roles"], without.Summary()["roles"]) + } +} diff --git a/internal/bridge/execution/execution.go b/internal/bridge/execution/execution.go index ae3f3c2b04..237bcd9cb5 100644 --- a/internal/bridge/execution/execution.go +++ b/internal/bridge/execution/execution.go @@ -85,6 +85,10 @@ type Authorized struct { // to another of them since, and a caller's stale statement would undo that. Pinned bool Receipt map[string]any + // AutoCompactTokenLimit is the matched pair's optional model_auto_compact_token_limit, carried + // out so a creation or resume can send it. It is nil when the pair declares none, and for an + // exception or a supervisor, neither of which names a pair for a limit to belong to. + AutoCompactTokenLimit *int64 } func (p Policy) Mode() string { @@ -161,10 +165,11 @@ func (p Policy) Authorize(in Input) (Authorized, error) { return Authorized{}, &Refusal{Code: RoleUnknown, Field: "role", Requested: in.Role, Allowed: anys(sortedKeys(p.roles)), Detail: "no such role is declared in this host's execution policy; this bridge declares no pair of its own for any role"} } if role.Expectation == Pair { - if !role.Allows(model, effort) { + pair, matchedDeclared := role.pairFor(model, effort) + if !matchedDeclared { return Authorized{}, role.mismatch(in.Role, model, effort) } - matched = &RolePair{Model: model, Effort: effort} + matched = &pair provenance = "role_pair" pinned = len(role.Pairs) == 1 } @@ -187,7 +192,11 @@ func (p Policy) Authorize(in Input) (Authorized, error) { expectation["overriddenBy"] = overriddenBy receipt["roleExpectation"] = expectation } - return Authorized{Model: model, Effort: effort, Provenance: provenance, Pinned: pinned, Receipt: receipt}, nil + var limit *int64 + if matched != nil { + limit = matched.AutoCompactTokenLimit + } + return Authorized{Model: model, Effort: effort, Provenance: provenance, Pinned: pinned, AutoCompactTokenLimit: limit, Receipt: receipt}, nil } func (p Policy) exceptionCovers(in Input, model, effort string) error { diff --git a/internal/bridge/execution/roles.go b/internal/bridge/execution/roles.go index f5313bd7be..df9d90be2f 100644 --- a/internal/bridge/execution/roles.go +++ b/internal/bridge/execution/roles.go @@ -1,8 +1,9 @@ package execution import ( + "encoding/json" "fmt" - "slices" + "strconv" "strings" ) @@ -23,7 +24,15 @@ const supportedRoles = `"child", "parent", "supervisor"` // RolePair is one model and reasoning effort a role may run on. A request matches a pair only when // both halves are equal: an effort name belongs to the model beside it, so two models that both // offer "xhigh" do not thereby offer the same thing. -type RolePair struct{ Model, Effort string } +// +// AutoCompactTokenLimit is the pair's optional model_auto_compact_token_limit. It travels in the +// thread/start and thread/resume config, and the host never reports it back, so it is a property of +// the pair's execution and not part of what a request is judged against: the pair's identity stays +// model and effort, and a nil limit leaves the host's own auto-compaction threshold in place. +type RolePair struct { + Model, Effort string + AutoCompactTokenLimit *int64 +} // Role is roles.RoleExpectation. Pairs is every pair the role may run on, in the order the policy // file declares them (the legacy model and reasoningEffort keys declare one); it is empty for a @@ -35,9 +44,22 @@ type Role struct { MCP *MCPProfiles } +// pairFor is the declared pair whose model and effort a request states. The limit is not part of the +// identity: two entries that name the same model and effort are the same pair, and the declaration's +// limit travels with whichever entry matched. +func (r Role) pairFor(model, effort string) (RolePair, bool) { + for _, pair := range r.Pairs { + if pair.Model == model && pair.Effort == effort { + return pair, true + } + } + return RolePair{}, false +} + // Allows is whether a request stating model and effort is one of the role's pairs. func (r Role) Allows(model, effort string) bool { - return slices.Contains(r.Pairs, RolePair{Model: model, Effort: effort}) + _, ok := r.pairFor(model, effort) + return ok } // pairValues is the pairs as the policy description and a refusal carry them. @@ -115,7 +137,7 @@ func parseRoles(declared any) (map[string]Role, error) { if !ok { return nil, &PolicyError{fmt.Sprintf("role %s must be an object", repr(name))} } - if err := only(entry, []string{"expectation", "mcp", "model", "pairs", "reasoningEffort"}, "role "+repr(name)); err != nil { + if err := only(entry, []string{"autoCompactTokenLimit", "expectation", "mcp", "model", "pairs", "reasoningEffort"}, "role "+repr(name)); err != nil { return nil, err } role, err := parseRole(name, entry) @@ -153,14 +175,14 @@ func parseRole(name string, entry object) (Role, error) { return Role{}, &PolicyError{fmt.Sprintf("role %s expectation must be 'pair': only 'supervisor' defers to the recorded authorization, and letting another role do so would exempt it from the role check", repr(name))} } if name == Supervisor { - if named := present(entry, "model", "pairs", "reasoningEffort"); len(named) > 0 { - return Role{}, &PolicyError{fmt.Sprintf("role 'supervisor' cannot declare %s: its model and effort are the user's own selection, so its expectation is the recorded authorization", repr(named))} + if named := present(entry, "autoCompactTokenLimit", "model", "pairs", "reasoningEffort"); len(named) > 0 { + return Role{}, &PolicyError{fmt.Sprintf("role 'supervisor' cannot declare %s: its model and effort are the user's own selection, so its expectation is the recorded authorization and it carries no pair for a limit to belong to", repr(named))} } return Role{Expectation: Record}, nil } if has(entry, "pairs") { - if named := present(entry, "model", "reasoningEffort"); len(named) > 0 { - return Role{}, &PolicyError{fmt.Sprintf("role %s declares both pairs and %s; state one pair as model and reasoningEffort, or several as pairs", repr(name), repr(named))} + if named := present(entry, "autoCompactTokenLimit", "model", "reasoningEffort"); len(named) > 0 { + return Role{}, &PolicyError{fmt.Sprintf("role %s declares both pairs and %s; state one pair as model, reasoningEffort and autoCompactTokenLimit, or several as pairs", repr(name), repr(named))} } pairs, err := parsePairs(name, entry.Get("pairs")) if err != nil { @@ -179,11 +201,17 @@ func parseRole(name string, entry object) (Role, error) { if err != nil { return Role{}, err } - return Role{Pairs: []RolePair{{Model: model, Effort: effort}}, Expectation: Pair}, nil + var limit *int64 + if has(entry, "autoCompactTokenLimit") { + if limit, err = autoCompactTokenLimit(entry.Get("autoCompactTokenLimit"), "role "+repr(name)); err != nil { + return Role{}, err + } + } + return Role{Pairs: []RolePair{{Model: model, Effort: effort, AutoCompactTokenLimit: limit}}, Expectation: Pair}, nil } // parsePairs reads the pairs list of a role: a non-empty list of {model, reasoningEffort} objects, -// none repeated. +// none repeated, each optionally carrying an autoCompactTokenLimit. func parsePairs(name string, declared any) ([]RolePair, error) { list, ok := declared.([]any) if !ok || len(list) == 0 { @@ -196,7 +224,7 @@ func parsePairs(name string, declared any) ([]RolePair, error) { if !ok { return nil, &PolicyError{where + " must be an object"} } - if err := only(entry, []string{"model", "reasoningEffort"}, where); err != nil { + if err := only(entry, []string{"autoCompactTokenLimit", "model", "reasoningEffort"}, where); err != nil { return nil, err } if absent := absent(entry, "model", "reasoningEffort"); len(absent) > 0 { @@ -210,11 +238,32 @@ func parsePairs(name string, declared any) ([]RolePair, error) { if err != nil { return nil, err } - pair := RolePair{Model: model, Effort: effort} - if slices.Contains(pairs, pair) { + var limit *int64 + if has(entry, "autoCompactTokenLimit") { + if limit, err = autoCompactTokenLimit(entry.Get("autoCompactTokenLimit"), where); err != nil { + return nil, err + } + } + if _, repeated := (Role{Pairs: pairs}).pairFor(model, effort); repeated { return nil, &PolicyError{fmt.Sprintf("role %s lists the pair %s at %s twice", repr(name), repr(model), repr(effort))} } - pairs = append(pairs, pair) + pairs = append(pairs, RolePair{Model: model, Effort: effort, AutoCompactTokenLimit: limit}) } return pairs, nil } + +// autoCompactTokenLimit reads a pair's optional model_auto_compact_token_limit. It must be a +// positive integer: a string, a boolean, a fraction or an exponent, zero and a negative value are +// all refused while the policy is read, through the same PolicyError every other unusable policy +// value is refused with. The field is new, so nothing it refuses used to load. +func autoCompactTokenLimit(value any, where string) (*int64, error) { + spelled, ok := value.(json.Number) + if !ok || strings.ContainsAny(string(spelled), ".eE") { + return nil, &PolicyError{where + " must state autoCompactTokenLimit as a positive integer"} + } + parsed, err := strconv.ParseInt(string(spelled), 10, 64) + if err != nil || parsed <= 0 { + return nil, &PolicyError{where + " must state autoCompactTokenLimit as a positive integer"} + } + return &parsed, nil +} diff --git a/internal/bridge/mutations.go b/internal/bridge/mutations.go index 03b1cdd7d8..7e545aad04 100644 --- a/internal/bridge/mutations.go +++ b/internal/bridge/mutations.go @@ -76,7 +76,7 @@ func (b *Bridge) SendMessageToThread(ctx context.Context, in SendMessage) (ledge if err != nil { return err } - contract = settings.Contract{CWD: pyjson.Text(expected["cwd"]), Sandbox: pyjson.Text(expected["sandbox"]), ExpectedPolicy: pyjson.Map(expected["expected_sandbox_policy"]), Model: auth.Model, ReasoningEffort: auth.Effort} + contract = settings.Contract{CWD: pyjson.Text(expected["cwd"]), Sandbox: pyjson.Text(expected["sandbox"]), ExpectedPolicy: pyjson.Map(expected["expected_sandbox_policy"]), Model: auth.Model, ReasoningEffort: auth.Effort, AutoCompactTokenLimit: auth.AutoCompactTokenLimit} if roots, ok := expected["runtime_workspace_roots"].([]any); ok { for _, root := range roots { contract.Roots = append(contract.Roots, pyjson.Text(root)) diff --git a/internal/bridge/settings/autocompact_test.go b/internal/bridge/settings/autocompact_test.go new file mode 100644 index 0000000000..e6d6ff17eb --- /dev/null +++ b/internal/bridge/settings/autocompact_test.go @@ -0,0 +1,62 @@ +package settings + +import ( + "slices" + "testing" +) + +// The config object is what thread/start and thread/resume carry, so the limit travels on both. A +// contract that holds no limit sends no key at all, which is today's behaviour. +func TestConfigAndResumeParamsCarryTheAutoCompactLimitOnlyWhenItIsSet(t *testing.T) { + limit := int64(550000) + set := Contract{CWD: "/w", Model: "m", ReasoningEffort: "high", AutoCompactTokenLimit: &limit} + if got := set.Config()[AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("Config() = %v", set.Config()) + } + for _, params := range []map[string]any{set.StartParams(), set.ResumeParams("thread-1")} { + if got := params["config"].(map[string]any)[AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("params = %v", params) + } + } + without := Contract{CWD: "/w", Model: "m", ReasoningEffort: "high"} + if _, present := without.Config()[AutoCompactTokenLimitKey]; present { + t.Fatalf("Config() = %v", without.Config()) + } + if _, present := without.ResumeParams("thread-1")["config"].(map[string]any)[AutoCompactTokenLimitKey]; present { + t.Fatalf("ResumeParams = %v", without.ResumeParams("thread-1")) + } +} + +// The host has no field to report this in, so its silence is not a mismatch and never withholds a +// message; the receipt records the value as requested and unobservable, and never as verified. +func TestTheReceiptRecordsTheLimitAsUnobservableAndNeverWithholds(t *testing.T) { + limit := int64(550000) + c := Contract{Model: "m", ReasoningEffort: "high", AutoCompactTokenLimit: &limit} + response := map[string]any{"approvalPolicy": "never", "model": "m", "reasoningEffort": "high"} + if findings := c.Findings(response); len(findings) != 0 { + t.Fatalf("the host's silence about the limit was read as a mismatch: %v", findings) + } + receipt := c.Receipt(response, "resume") + if receipt["verification"] != "observed_at_resume" { + t.Fatalf("verification = %v", receipt["verification"]) + } + if !slices.Contains(receipt["unobservable"].([]string), AutoCompactTokenLimitKey) { + t.Fatalf("unobservable = %v", receipt["unobservable"]) + } + if slices.Contains(receipt["verified"].([]string), AutoCompactTokenLimitKey) { + t.Fatalf("verified = %v", receipt["verified"]) + } + if got := receipt["requested"].(map[string]any)[AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("requested = %v", receipt["requested"]) + } +} + +// A contract whose only setting is the limit has observed nothing, so its receipt says so rather +// than claiming a verification the host never gave. +func TestALimitAloneIsNotAVerification(t *testing.T) { + limit := int64(1) + receipt := (&Contract{AutoCompactTokenLimit: &limit}).Receipt(map[string]any{"approvalPolicy": "never"}, "resume") + if receipt["verification"] != "not_requested" || len(receipt["verified"].([]string)) != 0 { + t.Fatalf("receipt = %v", receipt) + } +} diff --git a/internal/bridge/settings/observation.go b/internal/bridge/settings/observation.go index 1ce5a1231c..86f7df0778 100644 --- a/internal/bridge/settings/observation.go +++ b/internal/bridge/settings/observation.go @@ -84,15 +84,44 @@ func (c Contract) Receipt(response map[string]any, at string) map[string]any { } else if len(asked) > 0 { verification = "observed_at_" + at for k := range asked { + // Asking the host for this one is not observing it: the protocol has no field for it, so + // a receipt that listed it as verified would claim a confirmation nothing can give. + if k == AutoCompactTokenLimitKey { + continue + } verified = append(verified, k) } sort.Strings(verified) + if len(verified) == 0 { + verification = "not_requested" + } } for _, f := range findings { if f.Code == Unobservable { unobservable = append(unobservable, f.Field) } } + if c.AutoCompactTokenLimit != nil { + unobservable = append(unobservable, AutoCompactTokenLimitKey) + } sort.Strings(unobservable) return map[string]any{"requested": asked, "actual": Observed(response), "verified": verified, "unobservable": unobservable, "findings": findings, "verification": verification, "observationLimits": observationLimits} } + +// AutoCompactReceipt is the settings observation a resume that carried the pair's limit records. +// The host has no field that reports the value back, so the limit is written in the same notation +// Contract.Receipt uses for any setting the host cannot confirm: the value that went out under +// requested, the field under unobservable, and nothing under verified. The relay's resume path +// resumes from a record rather than a Contract, so it builds this observation itself; a resume that +// carried no limit records none, and a caller must not write one on its behalf. +func AutoCompactReceipt(limit int64, response map[string]any) map[string]any { + return map[string]any{ + "requested": map[string]any{AutoCompactTokenLimitKey: limit}, + "actual": Observed(response), + "verified": []string{}, + "unobservable": []string{AutoCompactTokenLimitKey}, + "findings": []any{}, + "verification": "not_requested", + "observationLimits": observationLimits, + } +} diff --git a/internal/bridge/settings/settings.go b/internal/bridge/settings/settings.go index 5f02eb2ec6..20827e447b 100644 --- a/internal/bridge/settings/settings.go +++ b/internal/bridge/settings/settings.go @@ -18,6 +18,11 @@ const ( UnsupportedSandbox = "unsupported_sandbox_type" ) +// AutoCompactTokenLimitKey is the config key that carries a pair's optional auto-compaction +// threshold. It travels in the thread/start and thread/resume config; the host never reports it +// back, so nothing compares it and its silence withholds nothing. +const AutoCompactTokenLimitKey = "model_auto_compact_token_limit" + var ErrInvalid = errors.New("invalid settings") // invalid is a declaration the settings contract refuses; it matches ErrInvalid. @@ -51,6 +56,10 @@ type Contract struct { ExpectedPolicy map[string]any // MCP is what the role's MCP profile resolved to; nil when the thread has no profile. MCP *MCPExpectation + // AutoCompactTokenLimit is the authorized pair's optional model_auto_compact_token_limit, sent + // in the config so the host compacts below its own (larger) window. The host never reports it + // back, so it is recorded as unobservable rather than compared. Nil leaves the host's default. + AutoCompactTokenLimit *int64 } var defaults = map[string]map[string]any{ @@ -183,6 +192,9 @@ func (c Contract) Requested() map[string]any { if c.MCP != nil { asked["mcpServers"] = c.MCP.Shape() } + if c.AutoCompactTokenLimit != nil { + asked[AutoCompactTokenLimitKey] = *c.AutoCompactTokenLimit + } return asked } @@ -209,6 +221,9 @@ func (c Contract) Config() map[string]any { config[key] = section } } + if c.AutoCompactTokenLimit != nil { + config[AutoCompactTokenLimitKey] = *c.AutoCompactTokenLimit + } return config } diff --git a/internal/bridge/worktree.go b/internal/bridge/worktree.go index c631979804..74e5378e4d 100644 --- a/internal/bridge/worktree.go +++ b/internal/bridge/worktree.go @@ -72,7 +72,7 @@ func (b *Bridge) CreateWorktreeThread(ctx context.Context, in CreateWorktree) (l if err != nil { return err } - contract = settings.Contract{Sandbox: in.Sandbox, ExpectedPolicy: in.Policy, Model: auth.Model, ReasoningEffort: auth.Effort} + contract = settings.Contract{Sandbox: in.Sandbox, ExpectedPolicy: in.Policy, Model: auth.Model, ReasoningEffort: auth.Effort, AutoCompactTokenLimit: auth.AutoCompactTokenLimit} return contract.Validate() } result, err := b.mutate(ctx, mutation{in.RequestID, "create_worktree_thread", params, validate, func(ctx context.Context, receipt ledger.Receipt, effects *[]string) error { @@ -165,7 +165,7 @@ func (b *Bridge) CreateWorktreeThread(ctx context.Context, in CreateWorktree) (l if watch, err = b.watchSubscription(ctx, threadID, true); err != nil { return err } - placed := settings.Contract{CWD: w.Destination, Sandbox: in.Sandbox, ExpectedPolicy: in.Policy, Model: auth.Model, ReasoningEffort: auth.Effort, Roots: []string{w.Destination}} + placed := settings.Contract{CWD: w.Destination, Sandbox: in.Sandbox, ExpectedPolicy: in.Policy, Model: auth.Model, ReasoningEffort: auth.Effort, Roots: []string{w.Destination}, AutoCompactTokenLimit: auth.AutoCompactTokenLimit} receipt["threadId"] = threadID receipt["permissionReceipt"] = map[string]any{"approvalPolicy": created["approvalPolicy"], "sandbox": created["sandbox"], "activePermissionProfile": created["activePermissionProfile"], "runtimeWorkspaceRoots": created["runtimeWorkspaceRoots"]} receipt["desktopProjectAssociation"] = map[string]any{"status": "unverified", "sourceRepository": in.Source, "checkout": created["cwd"], "appServerProjectId": pyjson.Map(created["thread"])["projectId"]} diff --git a/internal/manage/autocompact_resume_test.go b/internal/manage/autocompact_resume_test.go new file mode 100644 index 0000000000..e09f9fc546 --- /dev/null +++ b/internal/manage/autocompact_resume_test.go @@ -0,0 +1,124 @@ +package manage + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" +) + +// autoCompactResumePolicy is the child pair child-resume must read the limit from. The command +// resumes a child the relay recorded, and the record cannot hold this value (the host never reports +// it back), so the limit is resolved from the policy by the pair the record states. +func autoCompactResumePolicy(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "execution-policy.json") + body := "{\"roles\": {\"child\": {\"model\": \"m\", \"reasoningEffort\": \"xhigh\", \"autoCompactTokenLimit\": 550000}}}" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// A child the relay recorded is resumed by this command too, and it is the same child whose +// provider cuts the stream above its window. The resume it builds has to carry the pair's limit, +// or reloading a capped child through this command would quietly drop the protection and report +// success without recording that the limit went out. +func TestResumeCarriesTheRecordedPairsAutoCompactLimit(t *testing.T) { + host := resumeHost(t, "notLoaded") + // settings-show answers the role the record cites beside the settings; the limit is resolved + // from that role's pair. + exe, _ := resumeRelayScript(t, resumeTestAssignment, autoCompactResumeSettings, 0) + e, _, _ := resumeEnv(t, exe) + t.Setenv(execution.EnvPolicy, autoCompactResumePolicy(t)) + cfg := resumeConfig(host, "alpha") + cfg.Bridge.ExecutionPolicy = os.Getenv(execution.EnvPolicy) + if _, err := resumeRun(context.Background(), e, cfg, resumeOptions{relationship: "rel-1", message: "m"}); err != nil { + t.Fatal(err) + } + var resume map[string]any + if err := json.Unmarshal(resumeHostRequests(host)[1].Params, &resume); err != nil { + t.Fatal(err) + } + config, _ := resume["config"].(map[string]any) + if config["model_auto_compact_token_limit"] != float64(550000) { + t.Fatalf("the resume config does not carry the pair's limit: %v", config) + } +} + +// autoCompactResumeSettings is resumeTestSettings with the role the record cites, which is what the +// relay's settings-show answer carries beside the settings. +const autoCompactResumeSettings = `{"task":"01child","usable":true,"citedRole":"child","settings":{"sandbox":{"type":"readOnly","networkAccess":false},"approvalPolicy":"never","cwd":"/w","runtimeWorkspaceRoots":["/w"],"model":"m","reasoningEffort":"xhigh"}}` + +// A pair that declares no limit adds no key: the command behaves exactly as it did before. +func TestResumeWithoutAPolicyLimitSendsNoKey(t *testing.T) { + host := resumeHost(t, "notLoaded") + exe, _ := resumeRelayScript(t, resumeTestAssignment, resumeTestSettings, 0) + e, _, _ := resumeEnv(t, exe) + path := filepath.Join(t.TempDir(), "execution-policy.json") + if err := os.WriteFile(path, []byte("{\"roles\": {\"child\": {\"model\": \"m\", \"reasoningEffort\": \"xhigh\"}}}"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(execution.EnvPolicy, path) + cfg := resumeConfig(host, "alpha") + cfg.Bridge.ExecutionPolicy = path + if _, err := resumeRun(context.Background(), e, cfg, resumeOptions{relationship: "rel-1", message: "m"}); err != nil { + t.Fatal(err) + } + var resume map[string]any + if err := json.Unmarshal(resumeHostRequests(host)[1].Params, &resume); err != nil { + t.Fatal(err) + } + config, _ := resume["config"].(map[string]any) + if _, present := config["model_auto_compact_token_limit"]; present { + t.Fatalf("a pair without a limit sent one: %v", config) + } +} + +// The gate that authorized the record read the policy the settings-show subprocess inherited from +// the environment, so that is the policy this command has to resolve the limit from. Reading only +// manage.bridge.execution_policy would find nothing under the supported environment-only +// configuration, while the gate had accepted the capped pair. +func TestResumeResolvesTheLimitFromTheGatePolicyEnvironment(t *testing.T) { + host := resumeHost(t, "notLoaded") + exe, _ := resumeRelayScript(t, resumeTestAssignment, autoCompactResumeSettings, 0) + e, _, _ := resumeEnv(t, exe) + t.Setenv(execution.EnvPolicy, autoCompactResumePolicy(t)) + cfg := resumeConfig(host, "alpha") + cfg.Bridge.ExecutionPolicy = "" + if _, err := resumeRun(context.Background(), e, cfg, resumeOptions{relationship: "rel-1", message: "m"}); err != nil { + t.Fatal(err) + } + var resume map[string]any + if err := json.Unmarshal(resumeHostRequests(host)[1].Params, &resume); err != nil { + t.Fatal(err) + } + config, _ := resume["config"].(map[string]any) + if config["model_auto_compact_token_limit"] != float64(550000) { + t.Fatalf("the resume config does not carry the pair's limit: %v", config) + } +} + +// The host never reports the value back, so the report has to say what went out and that the host +// cannot confirm it: an operator reading a bare ok cannot tell a capped reload from an uncapped one. +func TestResumeReportsTheLimitItSentAsUnobservable(t *testing.T) { + host := resumeHost(t, "notLoaded") + exe, _ := resumeRelayScript(t, resumeTestAssignment, autoCompactResumeSettings, 0) + e, _, _ := resumeEnv(t, exe) + t.Setenv(execution.EnvPolicy, autoCompactResumePolicy(t)) + cfg := resumeConfig(host, "alpha") + cfg.Bridge.ExecutionPolicy = os.Getenv(execution.EnvPolicy) + report, err := resumeRun(context.Background(), e, cfg, resumeOptions{relationship: "rel-1", message: "m"}) + if err != nil { + t.Fatal(err) + } + if report.AutoCompactTokenLimit == nil || *report.AutoCompactTokenLimit != 550000 { + t.Fatalf("the report does not carry the limit it sent: %+v", report) + } + if !report.AutoCompactUnobservable { + t.Fatalf("the report does not mark the limit unobservable: %+v", report) + } +} diff --git a/internal/manage/childresume.go b/internal/manage/childresume.go index dda7b24227..120b4b884e 100644 --- a/internal/manage/childresume.go +++ b/internal/manage/childresume.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" "github.com/thisisjun786/codex-relay-workflow/internal/quote" ) @@ -64,6 +65,14 @@ type resumeSettings struct { ReasoningEffort string CWD string ApprovalPolicy string + // CitedRole and CitedException are what the record cites, and BoundRole is the role the relay's + // gate checked the record against. The auto-compaction limit belongs to a pair rather than to the + // record, so it is resolved from the policy by the pair the record states, and the role to read + // that pair from is the bound one the gate authorized against, falling back to the record's own + // citation when the relay named no binding. + CitedRole string + CitedException *string + BoundRole string // RuntimeWorkspaceRoots is a pointer so an empty list is a value: the relay preserves the // recorded [] and the resume must send it as an empty array, while a key that is absent or // null supplied nothing and stays missing. @@ -83,6 +92,13 @@ type resumeReport struct { Servers map[string]string `json:"servers"` AdmitTurn string `json:"admitTurn,omitempty"` DryRun bool `json:"dryRun,omitempty"` + // AutoCompactTokenLimit is the value this resume sent, nil when the pair declares none. The + // host never reports it back, so the report states it the way the bridge states a setting the + // host cannot confirm: the value that went out, and AutoCompactUnobservable saying the host + // cannot be asked about it. Without these a reader cannot tell a capped reload from an + // uncapped one. + AutoCompactTokenLimit *int64 `json:"autoCompactTokenLimit,omitempty"` + AutoCompactUnobservable bool `json:"autoCompactUnobservable,omitempty"` } // resumeFailure is a refusal: the reason and, where the host or the relay gave one, its detail. @@ -159,10 +175,13 @@ func resumeRecorded(ctx context.Context, e *Env, cfg *Config, child string) (*re return nil, &resumeFailure{Reason: resumeSettingsUnavailable, Detail: "the relay exited with status " + fmt.Sprint(code) + ": " + resumeTrim(stdout)} } var answer struct { - Settings resumeSettings `json:"settings"` - Usable *bool `json:"usable"` - Deliverable *bool `json:"deliverable"` - RecordFinding *struct { + Settings resumeSettings `json:"settings"` + Usable *bool `json:"usable"` + Deliverable *bool `json:"deliverable"` + CitedRole *string `json:"citedRole"` + BoundRole *string `json:"boundRole"` + CitedException *string `json:"citedException"` + RecordFinding *struct { Code, Detail string } `json:"recordFinding"` RoleFinding *struct { @@ -203,12 +222,72 @@ func resumeRecorded(ctx context.Context, e *Env, cfg *Config, child string) (*re if len(missing) > 0 { return nil, &resumeFailure{Reason: resumeSettingsUnavailable, Detail: "the recorded settings are missing " + strings.Join(missing, ", ")} } + if answer.CitedRole != nil { + settings.CitedRole = *answer.CitedRole + } + if answer.BoundRole != nil { + settings.BoundRole = *answer.BoundRole + } + if answer.CitedException != nil { + settings.CitedException = answer.CitedException + } return &settings, nil } // resumeSandboxMode is the thread/resume mode the recorded sandbox takes. A type this command // cannot name is settings_unavailable, because resuming with a mode the record does not carry is // what the issue refuses. +// +// resumeAutoCompactLimit is the pair's optional model_auto_compact_token_limit for this child, nil +// when it has none. The host never reports the value back, so the relay record cannot hold it and a +// resume built from that record alone would drop it; it is resolved from the execution policy by the +// pair the record states, exactly as the relay's own delivery resume does. The role is the one the +// relay's gate confirmed the task is bound to, falling back to the record's own citation. A record +// that cites an exception names no pair and sends none. A configured policy that cannot be read is a +// refusal rather than a silent omission: resuming a capped child without its limit is the failure +// this command exists to prevent, and an unreadable policy means the limit cannot be established. +// +// The policy is the one the gate read. The gate is the settings-show subprocess, which reads +// CODEX_THREAD_BRIDGE_EXECUTION_POLICY from the environment it inherits, so that environment value +// is authoritative here and manage.bridge.execution_policy is the fallback for a configuration that +// carries the policy for other commands but leaves the environment unset. +func resumeAutoCompactLimit(e *Env, cfg *Config, settings resumeSettings) (*int64, error) { + if settings.CitedException != nil { + return nil, nil + } + role := settings.BoundRole + if role == "" { + role = settings.CitedRole + } + if role == "" { + return nil, nil + } + path := strings.TrimSpace(e.Getenv(execution.EnvPolicy)) + if path == "" { + path = strings.TrimSpace(cfg.Bridge.ExecutionPolicy) + } + if path == "" { + return nil, nil + } + raw, err := execution.ReadFile(path) + if err != nil { + return nil, &resumeFailure{Reason: resumeSettingsUnavailable, Detail: "the execution policy " + quote.Value(path) + " could not be read to resolve the pair's auto-compaction limit: " + err.Error() + "; nothing was sent"} + } + policy, err := execution.FromBytes(raw, path) + if err != nil { + return nil, &resumeFailure{Reason: resumeSettingsUnavailable, Detail: "the execution policy " + quote.Value(path) + " could not be read to resolve the pair's auto-compaction limit: " + err.Error() + "; nothing was sent"} + } + declared, ok := policy.Role(role) + if !ok { + return nil, nil + } + for _, pair := range declared.Pairs { + if pair.Model == settings.Model && pair.Effort == settings.ReasoningEffort { + return pair.AutoCompactTokenLimit, nil + } + } + return nil, nil +} func resumeSandboxMode(sandbox map[string]any) (string, error) { kind, _ := sandbox["type"].(string) mode := resumeSettingsModes[kind] @@ -271,8 +350,17 @@ func resumeRun(ctx context.Context, e *Env, cfg *Config, opts resumeOptions) (*r if err != nil { return nil, err } + limit, err := resumeAutoCompactLimit(e, cfg, *settings) + if err != nil { + return nil, err + } report := &resumeReport{OK: true, Thread: child, Generation: generation, Model: settings.Model, Effort: settings.ReasoningEffort, Servers: map[string]string{}, DryRun: opts.dryRun} + if limit != nil { + // The value is not in the host's answer, so the report carries it rather than reading it back. + report.AutoCompactTokenLimit = limit + report.AutoCompactUnobservable = true + } client := appserver.New(cfg.Relay.Socket, appserver.DefaultBounds) defer client.Close() if err := client.Connect(ctx); err != nil { @@ -346,12 +434,16 @@ func resumeRun(ctx context.Context, e *Env, cfg *Config, opts resumeOptions) (*r } // 2. thread/resume with the recorded settings and the servers switched off. subscribed = true + config := resumeSandboxConfig(map[string]any{"model_reasoning_effort": settings.ReasoningEffort, + "mcp_servers": resumeMCPConfig(disabled)}, settings.Sandbox) + if limit != nil { + config["model_auto_compact_token_limit"] = *limit + } resumed, err := call("thread/resume", map[string]any{ "threadId": child, "excludeTurns": true, "model": settings.Model, "cwd": settings.CWD, "sandbox": mode, "approvalPolicy": settings.ApprovalPolicy, "runtimeWorkspaceRoots": settings.RuntimeWorkspaceRoots, - "config": resumeSandboxConfig(map[string]any{"model_reasoning_effort": settings.ReasoningEffort, - "mcp_servers": resumeMCPConfig(disabled)}, settings.Sandbox), + "config": config, }) if err != nil { return nil, err diff --git a/internal/policystore/autocompact_editor_test.go b/internal/policystore/autocompact_editor_test.go new file mode 100644 index 0000000000..b5e4a8898c --- /dev/null +++ b/internal/policystore/autocompact_editor_test.go @@ -0,0 +1,118 @@ +package policystore + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" +) + +// twoPairsOneLimited is a document whose child role lists two pairs, the first carrying the +// auto-compaction limit this repository now accepts on a pair. +const twoPairsOneLimited = "{\"roles\": {\"child\": {\"pairs\": [" + + "{\"model\": \"inferhub/deepseek-v4.1-flash\", \"reasoningEffort\": \"none\", \"autoCompactTokenLimit\": 550000}, " + + "{\"model\": \"gpt-6.1-sol\", \"reasoningEffort\": \"xhigh\"}]}}}\n" + +// legacyLimited is the documented single declaration carrying the limit. +const legacyLimited = "{\"roles\": {\"child\": {\"model\": \"inferhub/deepseek-v4.1-flash\", \"reasoningEffort\": \"none\", \"autoCompactTokenLimit\": 550000}}}\n" + +// setRolePairs rewrites a role's whole pairs list, so a pair it retains must keep every property +// that pair declared. The auto-compaction limit belongs to the pair, and a change that rebuilt the +// retained pair without it would publish a policy whose limit is silently gone: the operator would +// see a valid document and the provider-limit failure this feature exists to prevent would return. +func TestSetRolePairsKeepsTheLimitOfAPairItRetains(t *testing.T) { + change := Change{Kind: KindSetRolePairs, Role: "child", Pairs: []Pair{ + {Model: "inferhub/deepseek-v4.1-flash", Effort: "none"}, + {Model: "gpt-6.1-sol", Effort: "xhigh"}, + }} + result := Check([]byte(twoPairsOneLimited), digestOf(twoPairsOneLimited), change) + if !result.Valid { + t.Fatalf("retaining a pair the document already declares was refused: %+v", result) + } + candidate, err := candidateOf(t, twoPairsOneLimited, change) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(candidate, "\"autoCompactTokenLimit\": 550000") { + t.Fatalf("the retained pair lost its auto-compaction limit: %s", candidate) + } +} + +// The editor also has to be able to move a legacy single declaration to the pairs form. A limit +// written on the legacy declaration belongs to the one pair it declares, so the candidate must +// carry it inside that pair rather than beside the list, which the parser refuses. +func TestSetRolePairsMovesALegacyLimitIntoItsPair(t *testing.T) { + change := Change{Kind: KindSetRolePairs, Role: "child", Pairs: []Pair{ + {Model: "inferhub/deepseek-v4.1-flash", Effort: "none"}, + {Model: "gpt-6.1-sol", Effort: "xhigh"}, + }} + result := Check([]byte(legacyLimited), digestOf(legacyLimited), change) + if !result.Valid { + t.Fatalf("adding a second pair to a legacy declaration that carries the limit was refused: %+v", result) + } + candidate, err := candidateOf(t, legacyLimited, change) + if err != nil { + t.Fatal(err) + } + document, err := decode([]byte(candidate)) + if err != nil { + t.Fatal(err) + } + roles, _ := document.Get("roles").(pyjson.Object) + entry, _ := roles.Get("child").(pyjson.Object) + if _, present := entry.Lookup("autoCompactTokenLimit"); present { + t.Fatalf("the limit was left beside the pairs list, where the parser refuses it: %s", candidate) + } + pairs, _ := entry.Get("pairs").([]any) + if len(pairs) != 2 { + t.Fatalf("pairs = %v", pairs) + } + first, _ := pairs[0].(pyjson.Object) + limit, present := first.Lookup("autoCompactTokenLimit") + if !present || canonical(limit) != "550000" { + t.Fatalf("the first pair does not carry the legacy limit: %s", candidate) + } +} + +// The read projection is what an operator sees before proposing a change, so a limit the file +// declares has to be visible on the pair it belongs to rather than silently dropped. +func TestReadingShowsTheLimitOnThePairThatDeclaresIt(t *testing.T) { + file := filepath.Join(t.TempDir(), "execution-policy.json") + if err := os.WriteFile(file, []byte(twoPairsOneLimited), 0o644); err != nil { + t.Fatal(err) + } + reading := Read(Located{State: Registered, Path: file}) + role, ok := roleOf(reading, "child") + if !ok { + t.Fatalf("the child role is not projected: %+v", reading.Roles) + } + if len(role.Pairs) != 2 || role.Pairs[0].AutoCompactTokenLimit != 550000 { + t.Fatalf("the projected pairs lost the limit: %+v", role.Pairs) + } + if role.Pairs[1].AutoCompactTokenLimit != 0 { + t.Fatalf("a pair that declares no limit reports one: %+v", role.Pairs) + } +} + +// A supplied limit is judged where it is supplied. Zero and null are not "no limit": they are +// values that are not positive integers, and the same rule the policy file is held to applies to a +// request that states one. Collapsing them into the absent representation would let the check +// approve a document whose pair silently kept an older limit, or dropped the invalid one. +func TestSetRolePairsRefusesASuppliedLimitThatIsNotAPositiveInteger(t *testing.T) { + for _, supplied := range []string{"0", "-1", "null", "1.5", "\"550000\""} { + t.Run(supplied, func(t *testing.T) { + raw := "{\"kind\":\"setRolePairs\",\"role\":\"child\",\"pairs\":[{\"model\":\"inferhub/deepseek-v4.1-flash\",\"reasoningEffort\":\"none\",\"autoCompactTokenLimit\":" + supplied + "}]}" + var change Change + if err := json.Unmarshal([]byte(raw), &change); err != nil { + t.Fatalf("a supplied limit must be judged by Check, not fail the decode: %v", err) + } + result := Check([]byte(legacyLimited), digestOf(legacyLimited), change) + if result.Valid { + t.Fatalf("autoCompactTokenLimit %s was accepted: %+v", supplied, result) + } + }) + } +} diff --git a/internal/policystore/check.go b/internal/policystore/check.go index e29d889097..d852712a06 100644 --- a/internal/policystore/check.go +++ b/internal/policystore/check.go @@ -10,6 +10,7 @@ import ( "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" + "github.com/thisisjun786/codex-relay-workflow/internal/pyvalue" ) // The five changes a caller may propose. Exactly one is applied per request, and nothing outside @@ -248,23 +249,83 @@ func applyRolePairs(document pyjson.Object, change Change) (pyjson.Object, []str existing, _ := roles.Get(change.Role).(pyjson.Object) entry := pyjson.Object{} for _, field := range existing { - if field.Key == "model" || field.Key == "pairs" || field.Key == "reasoningEffort" { + if field.Key == "model" || field.Key == "pairs" || field.Key == "reasoningEffort" || field.Key == "autoCompactTokenLimit" { continue } entry = append(entry, field) } + // The limit belongs to a pair, and this change rebuilds the whole list. Every limit the role + // declares today is read here, so a pair the change keeps keeps its limit and a legacy single + // declaration's limit follows its one pair into the list rather than being left beside it, + // where the parser refuses it. A change may still state a limit of its own for any pair. + declared := declaredLimits(existing) if len(change.Pairs) == 1 { entry = append(entry, pyjson.Field{Key: "model", Value: change.Pairs[0].Model}, pyjson.Field{Key: "reasoningEffort", Value: change.Pairs[0].Effort}) + if limit, ok := limitFor(change.Pairs[0], declared); ok { + entry = append(entry, pyjson.Field{Key: "autoCompactTokenLimit", Value: limit}) + } } else { list := make([]any, 0, len(change.Pairs)) for _, pair := range change.Pairs { - list = append(list, pyjson.Object{{Key: "model", Value: pair.Model}, {Key: "reasoningEffort", Value: pair.Effort}}) + built := pyjson.Object{{Key: "model", Value: pair.Model}, {Key: "reasoningEffort", Value: pair.Effort}} + if limit, ok := limitFor(pair, declared); ok { + built = append(built, pyjson.Field{Key: "autoCompactTokenLimit", Value: limit}) + } + list = append(list, built) } entry = append(entry, pyjson.Field{Key: "pairs", Value: list}) } return document.Set("roles", roles.Set(change.Role, entry)), []string{"roles." + change.Role}, nil } +// declaredLimits is every autoCompactTokenLimit a role entry declares today: the limit of each pair +// it lists, and the limit beside a legacy single declaration, which belongs to that one pair. The +// key is the pair's own model and effort in two nested maps, so no delimiter inside either name can +// make two different pairs look like one. +func declaredLimits(entry pyjson.Object) map[string]map[string]any { + limits := map[string]map[string]any{} + record := func(pair pyjson.Object) { + limit, present := pair.Lookup("autoCompactTokenLimit") + if !present { + return + } + model, _ := pair.Lookup("model") + effort, _ := pair.Lookup("reasoningEffort") + byEffort := limits[pyvalue.Str(model)] + if byEffort == nil { + byEffort = map[string]any{} + limits[pyvalue.Str(model)] = byEffort + } + byEffort[pyvalue.Str(effort)] = limit + } + if listed, ok := entry.Get("pairs").([]any); ok { + for _, item := range listed { + if pair, ok := item.(pyjson.Object); ok { + record(pair) + } + } + return limits + } + record(entry) + return limits +} + +// limitFor is the limit a rebuilt pair carries: the one the change states for it, or the one the +// document already declared for that same model and effort. The second value is false when neither +// has one, and then no key is written at all, so a pair without a limit is written the way it +// always was rather than gaining an explicit null. +func limitFor(pair Pair, declared map[string]map[string]any) (any, bool) { + if pair.limitPresent { + return pair.limit, true + } + byEffort, ok := declared[pair.Model] + if !ok { + return nil, false + } + limit, ok := byEffort[pair.Effort] + return limit, ok +} + // applyAllowed sets one model's approved efforts. func applyAllowed(document pyjson.Object, change Change) (pyjson.Object, []string, error) { if change.Model == "" { diff --git a/internal/policystore/policy.go b/internal/policystore/policy.go index 3a37a2fe5a..9d4a4a4ad7 100644 --- a/internal/policystore/policy.go +++ b/internal/policystore/policy.go @@ -65,6 +65,18 @@ type Located struct { // spells it, and effort is accepted as an alias. type Pair struct { Model, Effort string + // AutoCompactTokenLimit is the pair's optional model_auto_compact_token_limit, zero when the + // pair declares none. It is not part of the pair's identity - the pair is its model and effort - + // but it does belong to the pair, so a change that rebuilds a role's pairs has to carry it + // across for every pair it keeps. + AutoCompactTokenLimit int64 + // limit is the same field exactly as a request spelled it, present only when the request named the + // key. A request that states zero, null, a fraction or a string has stated a value, and the rule + // the policy file is held to applies to it: the value is carried into the candidate document and + // refused there. Collapsing it into the absent representation would approve a document whose pair + // silently kept an older limit, or quietly dropped the invalid one. + limit any + limitPresent bool // conflict is why the two spellings of the effort could not be reconciled, or "" when they // could. It is unexported because it is not part of the pair: it is how a mismatch reaches the // check as a refused change rather than as a decode failure the API would answer as a bad @@ -77,6 +89,9 @@ type pairWire struct { Model string `json:"model"` ReasoningEffort string `json:"reasoningEffort"` Effort string `json:"effort,omitempty"` + // AutoCompactTokenLimit is written only when the pair declares one, so a pair without it reads + // back the way it was written. + AutoCompactTokenLimit int64 `json:"autoCompactTokenLimit,omitempty"` } // pairRequest is a pair as a request carries it. The two effort spellings are raw so the reader can @@ -86,6 +101,9 @@ type pairRequest struct { Model string `json:"model"` ReasoningEffort json.RawMessage `json:"reasoningEffort"` Effort json.RawMessage `json:"effort"` + // AutoCompactTokenLimit is raw so an absent key is distinguishable from a zero one: zero is not + // a positive integer, and the parser refuses it rather than reading it as "no limit". + AutoCompactTokenLimit json.RawMessage `json:"autoCompactTokenLimit"` } // UnmarshalJSON reads a pair from either spelling of its effort. A pair that names neither is read @@ -107,6 +125,18 @@ func (p *Pair) UnmarshalJSON(raw []byte) error { } p.Model = wire.Model p.Effort, p.conflict = effortAlias(reasoningEffort, reasoningPresent, effort, effortPresent) + if len(wire.AutoCompactTokenLimit) > 0 { + value, err := pyjson.Loads(string(wire.AutoCompactTokenLimit), pyjson.LoadOptions{Constants: true, Numbers: pyjson.SpelledNumbers}) + if err != nil { + return err + } + p.limit, p.limitPresent = value, true + if spelled, ok := value.(json.Number); ok { + if parsed, err := strconv.ParseInt(string(spelled), 10, 64); err == nil { + p.AutoCompactTokenLimit = parsed + } + } + } return nil } @@ -141,7 +171,14 @@ func effortAlias(reasoningEffort string, reasoningPresent bool, effort string, e // MarshalJSON writes a pair as the policy document spells it. func (p Pair) MarshalJSON() ([]byte, error) { - return json.Marshal(pairWire{Model: p.Model, ReasoningEffort: p.Effort}) + if p.limitPresent { + return json.Marshal(struct { + Model string `json:"model"` + ReasoningEffort string `json:"reasoningEffort"` + AutoCompactTokenLimit any `json:"autoCompactTokenLimit"` + }{p.Model, p.Effort, p.limit}) + } + return json.Marshal(pairWire{Model: p.Model, ReasoningEffort: p.Effort, AutoCompactTokenLimit: p.AutoCompactTokenLimit}) } // RoleView is one declared role: its pairs, or the record expectation of a supervisor. The JSON @@ -290,7 +327,11 @@ func projectRoles(policy execution.Policy) []RoleView { } view := RoleView{Name: name, Expectation: role.Expectation} for _, pair := range role.Pairs { - view.Pairs = append(view.Pairs, Pair{Model: pair.Model, Effort: pair.Effort}) + limit := int64(0) + if pair.AutoCompactTokenLimit != nil { + limit = *pair.AutoCompactTokenLimit + } + view.Pairs = append(view.Pairs, Pair{Model: pair.Model, Effort: pair.Effort, AutoCompactTokenLimit: limit}) } roles = append(roles, view) } diff --git a/internal/relay/adapter/autocompact.go b/internal/relay/adapter/autocompact.go new file mode 100644 index 0000000000..5cce1b26f7 --- /dev/null +++ b/internal/relay/adapter/autocompact.go @@ -0,0 +1,76 @@ +package adapter + +import ( + bridgesettings "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" +) + +// autoCompactLimit is the model_auto_compact_token_limit a resume of this record carries. +// +// The host never reports the value back, so it is not part of a task's recorded settings, and a +// resume built from that record alone would drop it: a child created under a pair that declares a +// limit would return to the host's own (larger) window the first time it was unloaded and resumed. +// The relay's delivery and correction sends build their parameters from the record, so the limit is +// resolved here instead, from the policy, by the pair the record states. +// +// A record that cites an exception, names no role, or names a role the policy does not declare +// sends none: an exception and a supervisor name no pair, and the policy file is the only place a +// limit is written. Where the role lists several pairs the record's own pair says which one it is, +// and a pair that declares no limit sends none rather than inheriting a sibling pair's. +// +// The role is the one the sender's gate confirmed the task is bound to (record.BoundRole) when it +// has one: a record need not cite its own role, and the gate authorizes the send against the bound +// role, so reading only the record's citation would send no limit on a delivery the gate accepted. +// A record that cites a role and was bound to none keeps using the citation, which is the bridge's +// own path (the bridge reads a caller's request, not a binding). +func (a *Adapter) autoCompactLimit(record *delivery.TaskSettings) *int64 { + if a.bridge == nil || record == nil { + return nil + } + if cited, _ := record.Data.Lookup("citedException"); cited != nil { + return nil + } + role := record.BoundRole + if role == "" { + named, _ := record.Data.Lookup("citedRole") + role, _ = named.(string) + } + if role == "" { + return nil + } + declared, ok := a.bridge.Policy.Role(role) + if !ok { + return nil + } + model, _ := record.Data.Lookup("model") + effort, _ := record.Data.Lookup("reasoningEffort") + modelText, _ := model.(string) + effortText, _ := effort.(string) + for _, pair := range declared.Pairs { + if pair.Model == modelText && pair.Effort == effortText { + return pair.AutoCompactTokenLimit + } + } + return nil +} + +// withAutoCompactLimit adds the record's pair limit to a resume's parameters and reports the value it +// put there, nil when the record's pair declares none. It is additive: the config object a resume +// already carries keeps every key it had, and a record whose pair declares no limit adds nothing. +// +// The caller keeps the returned value: the host has no field that reports it back, so the receipt of +// this resume has to record what went out rather than read it from the answer. +func (a *Adapter) withAutoCompactLimit(record *delivery.TaskSettings, params map[string]any) *int64 { + limit := a.autoCompactLimit(record) + if limit == nil { + return nil + } + config, _ := params["config"].(map[string]any) + if config == nil { + config = map[string]any{} + } + config[bridgesettings.AutoCompactTokenLimitKey] = *limit + params["config"] = config + record.AutoCompactTokenLimit = limit + return limit +} diff --git a/internal/relay/adapter/autocompact_test.go b/internal/relay/adapter/autocompact_test.go new file mode 100644 index 0000000000..f574ceb5bf --- /dev/null +++ b/internal/relay/adapter/autocompact_test.go @@ -0,0 +1,397 @@ +package adapter + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/bridge" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver/fakehost" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" + "github.com/thisisjun786/codex-relay-workflow/internal/contract" + "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/managed" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/registry" +) + +// autoCompactManagedPolicy is the child pair a managed start creates under, carrying the limit this +// issue recommends. Managed start reaches the bridge settings contract, so the value the operator +// puts on the pair is what the creation sends. +const autoCompactManagedPolicy = `{"roles":{"parent":{"model":"gpt-5.4","reasoningEffort":"medium"},"child":{"model":"gpt-5.4","reasoningEffort":"medium","autoCompactTokenLimit":550000}}}` + +// autoCompactHostParams returns the parameters of the first request that used method. +func autoCompactHostParams(t *testing.T, host *fakehost.Server, method string) map[string]any { + t.Helper() + for _, request := range host.Requests() { + if request.Method != method { + continue + } + var params map[string]any + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + t.Fatal(err) + } + return params + } + t.Fatalf("no %s request", method) + return nil +} + +// A managed start whose child pair declares the limit carries it in the creation's thread/start +// config, which is the path an IF DeepSeek child is created through. +func TestManagedStartSendsTheChildPairsAutoCompactLimit(t *testing.T) { + root := t.TempDir() + state := filepath.Join(root, "state") + t.Setenv("CODEX_SESSION_RELAY_SCOPE_DIR", filepath.Join(root, "scopes")) + workspace := filepath.Join(root, "work") + if err := os.Mkdir(workspace, 0700); err != nil { + t.Fatal(err) + } + policyPath := filepath.Join(root, "policy.json") + if err := os.WriteFile(policyPath, []byte(autoCompactManagedPolicy), 0600); err != nil { + t.Fatal(err) + } + t.Setenv(execution.EnvPolicy, policyPath) + registry.ResetRolePolicySnapshot() + t.Cleanup(registry.ResetRolePolicySnapshot) + policy, err := execution.FromBytes([]byte(autoCompactManagedPolicy), policyPath) + if err != nil { + t.Fatal(err) + } + declared := func() map[string]any { + return map[string]any{"sandbox": map[string]any{"type": "readOnly", "networkAccess": false}, "approvalPolicy": "never", "cwd": workspace, "runtimeWorkspaceRoots": []any{workspace}, "model": "gpt-5.4", "reasoningEffort": "medium", "environments": []any{map[string]any{"environmentId": "local", "cwd": workspace, "runtimeWorkspaceRoots": []any{workspace}}}} + } + request := map[string]any{"schema": managed.Schema, "requestId": "managed-auto-compact", "issueKey": "CRW-852", "parent": map[string]any{"taskId": "parent", "hostId": "host", "settings": declared()}, "child": map[string]any{"hostId": "host", "title": "Verify", "settings": declared()}, "artifactRoots": []any{workspace}, "allowedRecipients": []any{"parent"}, "criteria": []any{map[string]any{"id": "c1", "title": "preserve replay identity", "required": true}}, "criteriaSource": "issue:CRW-852", "baselineRevision": "baseline", "scopeRef": "issue:CRW-852", "prompt": "business-secret"} + host := fakehost.Start(t) + var mu sync.Mutex + turns := 0 + response := func() map[string]any { + answer := map[string]any{} + for key, value := range declared() { + if key != "environments" { + answer[key] = value + } + } + answer["thread"] = map[string]any{"id": "managed-child", "environments": declared()["environments"]} + return answer + } + host.Respond("thread/start", fakehost.Reply{Result: response()}) + host.Respond("thread/name/set", fakehost.Reply{Result: map[string]any{}}) + host.Handle("turn/start", func(json.RawMessage) fakehost.Reply { + mu.Lock() + defer mu.Unlock() + turns++ + id := "standby" + if turns > 1 { + id = "business" + } + return fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": id}}} + }) + host.Respond("thread/read", fakehost.Reply{Result: map[string]any{"thread": map[string]any{"status": map[string]any{"type": "idle"}, "canAcceptDirectInput": true, "model": "gpt-5.4", "reasoningEffort": "medium", "cwd": workspace}}}) + host.Respond("thread/resume", fakehost.Reply{Result: response()}) + host.Respond("thread/goal/get", fakehost.Reply{Result: map[string]any{"goal": nil}}) + host.Respond("thread/turns/list", fakehost.Reply{Result: map[string]any{"data": []any{map[string]any{"id": "standby", "status": "completed"}}, "nextCursor": nil}}) + host.Handle("thread/list", func(raw json.RawMessage) fakehost.Reply { + var params map[string]any + if err := json.Unmarshal(raw, ¶ms); err != nil { + t.Error(err) + } + data := []any{} + if params["archived"] != true { + data = append(data, map[string]any{"id": "managed-child"}) + } + return fakehost.Reply{Result: map[string]any{"data": data, "nextCursor": nil}} + }) + store, err := openStore(context.Background(), filepath.Join(state, "relay.sqlite3"), host.SocketPath) + if err != nil { + t.Fatal(err) + } + a, err := Open(host.SocketPath, state, Options{Policy: policy, Clock: delivery.NewFakeClock()}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { a.Close(); store.Close() }) + raw, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + engine := managed.Start{Store: store, Adapter: Managed{a}, Now: delivery.NewFakeClock().ISO, Socket: host.SocketPath, MarkerRoot: filepath.Join(root, "markers"), StateSelector: state, Readiness: func(context.Context, map[string]any) (string, error) { return "", nil }} + result, err := engine.Run(context.Background(), raw) + if err != nil { + t.Fatal(err) + } + if result.Get("state") != "admitted" { + business, _ := a.GetOperation(context.Background(), pyjson.Text(result.Get("businessRequestId"))) + t.Fatalf("managed start did not admit: %s business=%v", dumps(result, false), business) + } + config, _ := autoCompactHostParams(t, host, "thread/start")["config"].(map[string]any) + if config == nil || config[settings.AutoCompactTokenLimitKey] != float64(550000) { + t.Fatalf("thread/start config = %v", config) + } +} + +// autoCompactChildPolicy is the child role a relay delivery is judged against: the pair the record +// states carries the limit and a second pair does not, so a record on that pair must not inherit its +// sibling's value. The pair's model is taken from the record itself, so this fixture cannot drift +// away from the settings the send is verifying. +func autoCompactChildPolicy(t *testing.T, record *delivery.TaskSettings) bridge.ExecutionPolicy { + t.Helper() + model, _ := record.Data.Lookup("model") + body := fmt.Sprintf(`{"roles":{"child":{"pairs":[{"model":%q,"reasoningEffort":"xhigh","autoCompactTokenLimit":550000},{"model":"gpt-6.1-sol","reasoningEffort":"xhigh"}]}}}`, model) + p, err := execution.FromBytes([]byte(body), "test") + if err != nil { + t.Fatal(err) + } + return p +} + +// A relay delivery resumes a child from its recorded settings, and that record cannot hold this value +// because the host never reports it. The limit is resolved from the policy by the pair the record +// states, so an unloaded child comes back under its pair's threshold rather than the host's window -- +// on both routes, including the settings-free resume that transmits no pair. +func TestARelayDeliveryResumeCarriesTheRecordedPairsAutoCompactLimit(t *testing.T) { + for _, free := range []bool{false, true} { + t.Run(map[bool]string{false: "with-pair", true: "settings-free"}[free], func(t *testing.T) { + record := childRecord(free, "") + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, record)) + if receipt := sendRecord(t, a, "send-auto-compact", record); receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if got := resumeConfig(rpc)[settings.AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("thread/resume config = %v", resumeConfig(rpc)) + } + }) + } +} + +// A record on the role's other pair sends no limit: the value belongs to the pair, and a pair that +// declares none must not inherit its sibling's. +func TestARelayDeliveryOnAPairWithoutALimitSendsNone(t *testing.T) { + record := childRecord(false, "") + record.Data = delivery.Obj(contract.OrderedObject(record.Data).Set("model", "gpt-6.1-sol")) + rpc := &mcpRPC{model: "gpt-6.1-sol"} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, childRecord(false, ""))) + if receipt := sendRecord(t, a, "send-other-pair", record); receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if _, present := resumeConfig(rpc)[settings.AutoCompactTokenLimitKey]; present { + t.Fatalf("thread/resume config = %v", resumeConfig(rpc)) + } +} + +// A record citing an exception names no pair, so no limit is derived for it. +func TestARelayDeliveryCitingAnExceptionSendsNoLimit(t *testing.T) { + record := childRecord(false, "") + record.Data = delivery.Obj(contract.OrderedObject(record.Data).Set("citedException", "one-task")) + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, record)) + if receipt := sendRecord(t, a, "send-excepted", record); receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if _, present := resumeConfig(rpc)[settings.AutoCompactTokenLimitKey]; present { + t.Fatalf("an exception carried a pair-derived limit: %v", resumeConfig(rpc)) + } +} + +// A settings-free resume transmits no pair, and its refusal says so. When the limit did go out, the +// sentence has to name it: a reader who was told nothing was transmitted would go looking for a +// different cause, and the limit is exactly what this record could not have carried. +// autoCompactProfilePolicy is the child role the wording test needs: the record's pair carries the +// limit and the role declares the MCP profiles its record names. +func autoCompactProfilePolicy(t *testing.T, record *delivery.TaskSettings) bridge.ExecutionPolicy { + t.Helper() + model, _ := record.Data.Lookup("model") + body := fmt.Sprintf(`{"roles":{"child":{"model":%q,"reasoningEffort":"xhigh","autoCompactTokenLimit":550000,"mcp":{"default":"minimal","profiles":{"minimal":{},"ui-qa":{"servers":["node_repl"]}}}}}}`, model) + p, err := execution.FromBytes([]byte(body), "test") + if err != nil { + t.Fatal(err) + } + return p +} + +func TestASettingsFreeRefusalNamesTheLimitItSent(t *testing.T) { + for _, profile := range []string{"", "ui-qa"} { + t.Run("profile="+profile, func(t *testing.T) { + record := childRecord(true, profile) + rpc := &mcpRPC{configured: []string{"node_repl", "oracle"}, applied: true, model: "other/model"} + a := mcpAdapter(t, rpc, autoCompactProfilePolicy(t, record)) + receipt := sendRecord(t, a, "send-limit-words", record) + rpcError, _ := receipt["rpcError"].(map[string]any) + message, _ := rpcError["message"].(string) + if receipt["status"] != "failed" || !strings.Contains(message, "auto-compaction limit") || strings.Contains(message, "nothing was transmitted") { + t.Fatalf("receipt=%v", receipt) + } + }) + } +} + +// autoCompactSettings is the receipt's settings observation of a resume, or nil when it carries +// none. It is the same notation the bridge uses for a setting the host cannot confirm: the value +// that went out under requested, and the field under unobservable, never under verified. +func autoCompactSettings(t *testing.T, receipt map[string]any) map[string]any { + t.Helper() + observed, _ := receipt["settings"].(map[string]any) + return observed +} + +// autoCompactSentValue reads the value the receipt recorded as sent, whatever JSON shape it took. +func autoCompactSentValue(t *testing.T, receipt map[string]any) (int64, bool) { + t.Helper() + observed := autoCompactSettings(t, receipt) + if observed == nil { + return 0, false + } + requested, _ := observed["requested"].(map[string]any) + value, present := requested[settings.AutoCompactTokenLimitKey] + return autoCompactInt(value), present +} + +// autoCompactInt reads a number the way the ledger round-trips it: the in-process receipt holds an +// int64, and the stored one holds whatever the decoder made of the same JSON. +func autoCompactInt(value any) int64 { + switch v := value.(type) { + case int64: + return v + case float64: + return int64(v) + case json.Number: + n, _ := v.Int64() + return n + } + return -1 +} + +// autoCompactMarkedUnobservable reports whether the receipt marks the limit as one the host does +// not report back, so a reader never takes its silence for confirmation. +func autoCompactMarkedUnobservable(t *testing.T, receipt map[string]any) bool { + t.Helper() + observed := autoCompactSettings(t, receipt) + if observed == nil { + return false + } + for _, field := range autoCompactStrings(observed["unobservable"]) { + if field == settings.AutoCompactTokenLimitKey { + return true + } + } + return false +} + +// autoCompactStrings reads a list of names from either shape the receipt takes. +func autoCompactStrings(value any) []string { + switch v := value.(type) { + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + text, _ := item.(string) + out = append(out, text) + } + return out + } + return nil +} + +// The host never reports the limit back, so a resume that sends it has to say so itself: the value +// that went out is recorded as requested and marked unobservable, exactly as the bridge records a +// setting the host cannot confirm. The record has to survive the ledger, because a later reader sees +// the stored or replayed receipt, not this call's. +func TestARelayResumeReceiptRecordsTheLimitItSentAsUnobservable(t *testing.T) { + for _, free := range []bool{false, true} { + t.Run(map[bool]string{false: "with-pair", true: "settings-free"}[free], func(t *testing.T) { + record := childRecord(free, "") + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, record)) + requestID := "send-limit-receipt" + receipt := sendRecord(t, a, requestID, record) + if receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + value, present := autoCompactSentValue(t, receipt) + if !present || value != 550000 || !autoCompactMarkedUnobservable(t, receipt) { + t.Fatalf("the receipt does not record the limit it sent as unobservable: %v", receipt) + } + if verified := autoCompactStrings(autoCompactSettings(t, receipt)["verified"]); len(verified) != 0 { + t.Fatalf("the limit was recorded as verified: %v", verified) + } + stored, err := a.GetOperation(context.Background(), requestID) + if err != nil { + t.Fatal(err) + } + value, present = autoCompactSentValue(t, plain(stored).(map[string]any)) + if !present || value != 550000 || !autoCompactMarkedUnobservable(t, plain(stored).(map[string]any)) { + t.Fatalf("the stored receipt lost the limit: %v", stored) + } + replayed := sendRecord(t, a, requestID, record) + if replayed["replayed"] != true { + t.Fatalf("the send was not replayed: %v", replayed) + } + value, present = autoCompactSentValue(t, replayed) + if !present || value != 550000 || !autoCompactMarkedUnobservable(t, replayed) { + t.Fatalf("the replayed receipt lost the limit: %v", replayed) + } + }) + } +} + +// A send whose pair declares no limit records nothing new: a reader of that receipt must not see a +// settings observation it never made. +func TestARelayResumeWithoutALimitRecordsNoSettingsObservation(t *testing.T) { + record := childRecord(false, "") + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, relayPolicy(t)) + receipt := sendRecord(t, a, "send-no-limit-receipt", record) + if receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if observed := autoCompactSettings(t, receipt); observed != nil { + t.Fatalf("a send without a limit recorded a settings observation: %v", observed) + } +} + +// autoCompactBoundRecord is a record registered without a cited role: the sender's role gate bound +// the task to the role and authorized the send against it, but the record itself cites none. +func autoCompactBoundRecord() *delivery.TaskSettings { + record := childRecord(false, "") + data := delivery.Obj{} + for _, field := range record.Data { + if field.Key == "citedRole" { + continue + } + data = append(data, field) + } + record.Data = data + record.BoundRole = "child" + return record +} + +// A record that cites no role still has one: the sender's role gate resolved the role the task is +// bound to and authorized this very send against that role's pair, so the limit is read from it. +// Reading only the record's own citation would send no limit on a delivery the gate accepted, which +// is the case this feature exists to cover. +func TestARelayResumeReadsTheLimitFromTheBoundRoleTheGateConfirmed(t *testing.T) { + record := autoCompactBoundRecord() + if _, cited := record.Data.Lookup("citedRole"); cited { + t.Fatal("the fixture still cites a role") + } + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, record)) + receipt := sendRecord(t, a, "send-bound-role", record) + if receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if got := resumeConfig(rpc)[settings.AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("thread/resume config = %v", resumeConfig(rpc)) + } + if value, present := autoCompactSentValue(t, receipt); !present || value != 550000 || !autoCompactMarkedUnobservable(t, receipt) { + t.Fatalf("the receipt does not record the limit it sent as unobservable: %v", receipt) + } +} diff --git a/internal/relay/adapter/cli.go b/internal/relay/adapter/cli.go index 01e94ff1b8..3b81020f1a 100644 --- a/internal/relay/adapter/cli.go +++ b/internal/relay/adapter/cli.go @@ -128,11 +128,11 @@ func (f hostFactory) supervisorHostCommand(ctx context.Context, command, state, channel := &supervisor.Channel{Store: s, Linkage: supervisor.StoreLinkage{Store: s}, Program: program, Socket: socket} channel.SettingsLoader = func(ctx context.Context, task string) (*delivery.TaskSettings, error) { r := ®istry.Registry{Store: s, Now: (&delivery.FakeClock{T: now}).ISO, Policy: registry.EnvironmentRolePolicy()} - settings, free, err := r.AuthorizedSettings(ctx, task) + settings, role, free, err := r.AuthorizedSettingsBound(ctx, task) if err != nil { return nil, err } - return &delivery.TaskSettings{Data: delivery.Obj(settings.Data), SettingsFreeResume: free}, nil + return &delivery.TaskSettings{Data: delivery.Obj(settings.Data), SettingsFreeResume: free, BoundRole: role}, nil } switch command { case "supervisor-send": diff --git a/internal/relay/adapter/daemon.go b/internal/relay/adapter/daemon.go index 8601d910e2..be589c9405 100644 --- a/internal/relay/adapter/daemon.go +++ b/internal/relay/adapter/daemon.go @@ -29,11 +29,11 @@ func (f hostFactory) daemonFactory(ctx context.Context, services dispatch.Servic channel := &supervisor.Channel{Store: s, Linkage: supervisor.StoreLinkage{Store: s}, Program: services.Program, Socket: services.SocketPath} channel.SettingsLoader = func(ctx context.Context, task string) (*delivery.TaskSettings, error) { r := ®istry.Registry{Store: s, Now: clock.ISO, Policy: registry.EnvironmentRolePolicy()} - settings, free, err := r.AuthorizedSettings(ctx, task) + settings, role, free, err := r.AuthorizedSettingsBound(ctx, task) if err != nil { return nil, err } - return &delivery.TaskSettings{Data: delivery.Obj(settings.Data), SettingsFreeResume: free}, nil + return &delivery.TaskSettings{Data: delivery.Obj(settings.Data), SettingsFreeResume: free, BoundRole: role}, nil } d := daemon.New(s, a, clock, channel) d.Faults = &faults.Ledger{Store: s, Clock: clock} diff --git a/internal/relay/adapter/json.go b/internal/relay/adapter/json.go index 7da684c9b6..f43566edd8 100644 --- a/internal/relay/adapter/json.go +++ b/internal/relay/adapter/json.go @@ -21,7 +21,7 @@ func dumps(value any, sorted bool) string { // returns a copy, so updatedAt is appended after the operation's fields. Nested values are ordered // at the RPC boundary and are not decoded into maps before being persisted. func receiptObject(r ledger.Receipt) contract.OrderedObject { - keys := []string{"requestId", "operation", "status", "startedAt", "retrySafe", "fingerprintVersion", "attempt", "priorAttempts", "threadId", "statusBeforeResume", "resumed", "settingsFreeResume", "settingsFindings", "settingsNotes", "turnId", "attemptedEffects", "error", "rpcError", "updatedAt", "replayed"} + keys := []string{"requestId", "operation", "status", "startedAt", "retrySafe", "fingerprintVersion", "attempt", "priorAttempts", "threadId", "statusBeforeResume", "resumed", "settingsFreeResume", "settings", "settingsFindings", "settingsNotes", "turnId", "attemptedEffects", "error", "rpcError", "updatedAt", "replayed"} if r["operation"] == "create_thread" { keys = []string{"requestId", "operation", "status", "startedAt", "retrySafe", "fingerprintVersion", "attempt", "priorAttempts", "executionPolicy", "threadId", "creation", "settings", "title", "turnId", "desktopProjectAssociation", "error", "rpcError", "attemptedEffects", "updatedAt", "settingsAfterDispatch", "replayed"} } diff --git a/internal/relay/adapter/mcp_profile.go b/internal/relay/adapter/mcp_profile.go index c1e3028cbb..cfbbc1315b 100644 --- a/internal/relay/adapter/mcp_profile.go +++ b/internal/relay/adapter/mcp_profile.go @@ -42,7 +42,9 @@ func (a *Adapter) withMCP(ctx context.Context, record *delivery.TaskSettings) (* data = append(data, field) } } - send := &delivery.TaskSettings{Data: data, SettingsFreeResume: record.SettingsFreeResume} + // The bound role travels with the copy: the limit is read from it, and this send is the one that + // carries both the profile and the limit. + send := &delivery.TaskSettings{Data: data, SettingsFreeResume: record.SettingsFreeResume, BoundRole: record.BoundRole} stated, _ := record.Data.Lookup("mcpProfile") cited, _ := record.Data.Lookup("citedRole") profile, _ := stated.(string) diff --git a/internal/relay/adapter/settings.go b/internal/relay/adapter/settings.go index 2e341b805c..a005125bf3 100644 --- a/internal/relay/adapter/settings.go +++ b/internal/relay/adapter/settings.go @@ -25,9 +25,18 @@ func verifyResume(settings delivery.TaskSettings, response any, status any) (con message := code + ": " + pyjson.Text(first.Get("field")) + " returned " + returned + "; message withheld" if !transmitted { code = registry.SettingsFreeRefusalCode(mismatches) + // A settings-free resume transmits no pair, which is what this sentence is about. It may + // still have sent the MCP profile's overrides and the pair's auto-compaction limit, neither + // of which is a pair, so the sentence names what went out rather than counting nothing. sent := "nothing was transmitted" - if _, overrides := settings.Data.Lookup("mcpServers"); overrides { + _, overrides := settings.Data.Lookup("mcpServers") + switch { + case overrides && settings.AutoCompactTokenLimit != nil: + sent = "only the MCP profile's overrides and the pair's auto-compaction limit were transmitted" + case overrides: sent = "only the MCP profile's overrides were transmitted" + case settings.AutoCompactTokenLimit != nil: + sent = "only the pair's auto-compaction limit was transmitted" } message = code + ": " + pyjson.Text(first.Get("field")) + " is " + returned + " on the loaded thread and " + pyvalue.Repr(first.Get("expected")) + " in the record; " + sent + " and no turn was started" } diff --git a/internal/relay/adapter/transport.go b/internal/relay/adapter/transport.go index bd0efbb23b..ee10360b25 100644 --- a/internal/relay/adapter/transport.go +++ b/internal/relay/adapter/transport.go @@ -10,6 +10,7 @@ import ( "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/ledger" + bridgesettings "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" "github.com/thisisjun786/codex-relay-workflow/internal/contract" "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" @@ -335,6 +336,11 @@ func (a *Adapter) guardedSend(ctx context.Context, requestID, thread, message st params["config"] = expectedMCP.Overrides() } } + // The limit is not in the record (the host never reports it back), so a resume built from the + // record would drop it and the thread would return to the host's own window. It is resolved + // from the policy by the pair the record states, on both routes: a settings-free resume sends + // no pair, but it may still send the limit of the pair the record says the thread is on. + limit := a.withAutoCompactLimit(send, params) if client, ok := a.rpc.(*appserver.Client); ok { watch, err = client.WatchTurn(ctx, thread) if err = awaited(err); err != nil { @@ -347,6 +353,13 @@ func (a *Adapter) guardedSend(ctx context.Context, requestID, thread, message st return err } receipt["resumed"] = resumed + // The host never reports this value back, so the receipt is the only place the send can say + // what it carried: recorded as requested and unobservable, never as verified, in the same + // notation the bridge uses. A resume that carried no limit records no settings observation. + if limit != nil { + observed, _ := plain(resumed).(map[string]any) + receipt["settings"] = bridgesettings.AutoCompactReceipt(*limit, observed) + } if settings.SettingsFreeResume { receipt["settingsFreeResume"] = true } diff --git a/internal/relay/delivery/autocompact_bound_role_test.go b/internal/relay/delivery/autocompact_bound_role_test.go new file mode 100644 index 0000000000..aba2f7907c --- /dev/null +++ b/internal/relay/delivery/autocompact_bound_role_test.go @@ -0,0 +1,73 @@ +package delivery + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/registry" +) + +// A task's record need not cite its own role: the sender's gate resolves the role the task is +// bound to and authorizes the send against that role's pair. A rule that reads the role from the +// record alone finds none and would silently send no auto-compaction limit on a delivery the gate +// accepted, which is exactly the case the limit exists to cover. The gate therefore reports the +// role it authorized against, so the transport reads the pair's limit from that role. +// sequential: t.Setenv(execution.EnvPolicy) is process-wide. +func TestTheDefaultRoleGateReportsTheBoundRoleItAuthorizedAgainst(t *testing.T) { + f := newFixture(t, "") + const task = "01parent-bound" + settings := taskSettings(f.root) + // The policy declares exactly the pair the fixture records, read from the fixture rather than + // restated, so this test cannot drift away from what the gate is asked to authorize. + record := loadsObj(settings) + model, _ := record.Lookup("model") + effort, _ := record.Lookup("reasoningEffort") + declared := fmt.Sprintf(`{"roles": {"parent": {"model": %q, "reasoningEffort": %q, "autoCompactTokenLimit": 550000}}}`, model, effort) + if _, err := execSQL(f.ctx, f.store, "INSERT INTO authorized_settings (task_id, settings, source, recorded_at) VALUES (?,?,?,?)", task, settings, "creation_result", "t"); err != nil { + t.Fatal(err) + } + var last *TaskSettings + gate := func() error { + var err error + last, err = AuthorizedSettings(f.ctx, f.store, task, nil) + return err + } + usePolicy := func(text string) { + t.Helper() + if text == "" { + t.Setenv(execution.EnvPolicy, "") + } else { + path := filepath.Join(t.TempDir(), "execution-policy.json") + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(execution.EnvPolicy, path) + } + registry.ResetRolePolicySnapshot() + } + t.Cleanup(registry.ResetRolePolicySnapshot) + + usePolicy("") + if err := gate(); err != nil { + t.Fatalf("a task bound to no role is gated: %v", err) + } + if last.BoundRole != "" { + t.Fatalf("a task bound to no role reported bound role %q", last.BoundRole) + } + if _, err := execSQL(f.ctx, f.store, "INSERT INTO scope_bindings (binding_id, role, scope_kind, scope_key, task_id, host_id, status, revision, created_at, updated_at) VALUES ('bnd-1','parent','project','PROJ-1',?,'host-a','active',1,'t','t')", task); err != nil { + t.Fatal(err) + } + usePolicy(declared) + if err := gate(); err != nil { + t.Fatalf("a bound parent whose recorded pair is the policy's is withheld: %v", err) + } + if _, cited := last.Data.Lookup("citedRole"); cited { + t.Fatal("the fixture cites its own role, so it does not exercise the bound-role path") + } + if last.BoundRole != "parent" { + t.Fatalf("BoundRole = %q, want the role the gate authorized against (parent)", last.BoundRole) + } +} diff --git a/internal/relay/delivery/settings.go b/internal/relay/delivery/settings.go index 2c5000c9f3..e44c9a83bd 100644 --- a/internal/relay/delivery/settings.go +++ b/internal/relay/delivery/settings.go @@ -15,6 +15,16 @@ import ( type TaskSettings struct { Data Obj SettingsFreeResume bool + // BoundRole is the role the sender's gate confirmed this task is bound to, "" when it is bound + // to none. A record need not cite its own role, and the gate authorizes the send against the + // bound role rather than the citation, so a rule that reads the role (the pair's auto-compaction + // limit) has to read the role the gate checked rather than only the record's own citation. + BoundRole string + // AutoCompactTokenLimit is the pair's auto-compaction limit this send put in the resume config, + // nil when it sent none. The limit is not in the record (the host never reports it back), so both + // a refusal that describes what a settings-free resume sent and the receipt that has to record + // the value as unobservable are told about it rather than infer it from the record. + AutoCompactTokenLimit *int64 } // ResumeParams is the registry's resume_params over this record: only fields ThreadResumeParams @@ -33,9 +43,10 @@ type RoleGate func(ctx context.Context, s *store.Store, taskID string, settings // derived from its role's pair (a supervisor's recorded pair, an exception) is resumed // settings-free, as every other sender resumes it, so a later user selection is never reverted. func DefaultRoleGate(ctx context.Context, s *store.Store, taskID string, settings *TaskSettings) error { - _, settingsFree, err := registry.CheckBoundRole(ctx, s, taskID, settings.Data, registry.EnvironmentRolePolicy()) + role, settingsFree, err := registry.CheckBoundRole(ctx, s, taskID, settings.Data, registry.EnvironmentRolePolicy()) if err == nil { settings.SettingsFreeResume = settingsFree + settings.BoundRole = role } return err } diff --git a/internal/relay/registry/record.go b/internal/relay/registry/record.go index ec74436260..468f893ea7 100644 --- a/internal/relay/registry/record.go +++ b/internal/relay/registry/record.go @@ -229,19 +229,28 @@ func describe(finding contract.OrderedObject) string { // sender that resumes a task, or the pre-send refusal (reason and detail) that withholds it. // settingsFree reports that the pair was not derived from the role's declared pair. func (r *Registry) AuthorizedSettings(ctx context.Context, task string) (settings TaskSettings, settingsFree bool, err error) { + settings, _, settingsFree, err = r.AuthorizedSettingsBound(ctx, task) + return settings, settingsFree, err +} + +// AuthorizedSettingsBound is AuthorizedSettings with the role the task is bound to ("" when it is +// bound to none). A sender that has to read the role for a rule the gate itself does not decide -- +// the pair's auto-compaction limit is resolved from that role's pair -- takes both from this one +// call, so the role and the authorization it was judged with come from the same read. +func (r *Registry) AuthorizedSettingsBound(ctx context.Context, task string) (settings TaskSettings, role string, settingsFree bool, err error) { settings, ok, err := r.LoadSettings(ctx, task) if err != nil { - return settings, false, err + return settings, "", false, err } if !ok { - return settings, false, refuse(contract.RefusalSettingsUnavailable, "no authorized settings recorded for %s; register them from the"+ + return settings, "", false, refuse(contract.RefusalSettingsUnavailable, "no authorized settings recorded for %s; register them from the"+ " creation result before a send can preserve them", pyvalue.StrRepr(task)) } if err := settings.RequireUsable(); err != nil { - return settings, false, err + return settings, "", false, err } - _, settingsFree, err = CheckBoundRole(ctx, r.Store, task, settings.Data, r.Policy) - return settings, settingsFree, err + role, settingsFree, err = CheckBoundRole(ctx, r.Store, task, settings.Data, r.Policy) + return settings, role, settingsFree, err } // CheckBoundRole is the role half of AuthorizedSettings, which every sender's pre-send check diff --git a/internal/relay/supervisor/autocompact_bound_role_test.go b/internal/relay/supervisor/autocompact_bound_role_test.go new file mode 100644 index 0000000000..9a30f1a236 --- /dev/null +++ b/internal/relay/supervisor/autocompact_bound_role_test.go @@ -0,0 +1,28 @@ +package supervisor + +import ( + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" +) + +// The settings fence cancels a claim when a re-read of the recipient's authorized settings differs +// from what the send read. The role the gate confirmed is part of what the send reads: the pair's +// auto-compaction limit is resolved from it, so a re-read that lands on a different binding must +// differ here too, or the transport would run on the pair the first read confirmed while the store +// now says the task is bound to another role. +func TestTheSettingsFenceSeesAChangedBoundRole(t *testing.T) { + data := delivery.Obj{{Key: "model", Value: "anthropic/claude-opus-5"}, {Key: "reasoningEffort", Value: "xhigh"}} + same := func(role string) *delivery.TaskSettings { + return &delivery.TaskSettings{Data: append(delivery.Obj{}, data...), BoundRole: role} + } + if !sameSettings24(same("child"), same("child")) { + t.Fatal("two reads of the same binding differ") + } + if sameSettings24(same("child"), same("parent")) { + t.Fatal("a re-read that landed on another bound role reads as the same settings") + } + if sameSettings24(same("child"), same("")) { + t.Fatal("a re-read that found no bound role reads as the same settings") + } +} diff --git a/internal/relay/supervisor/proposal.go b/internal/relay/supervisor/proposal.go index de5c7caece..c8347319d7 100644 --- a/internal/relay/supervisor/proposal.go +++ b/internal/relay/supervisor/proposal.go @@ -303,7 +303,10 @@ func sameSettings24(first, second *delivery.TaskSettings) bool { if first == nil || second == nil { return first == second } - return first.SettingsFreeResume == second.SettingsFreeResume && pyjson.Dumps(first.Data, pyjson.Options{SortKeys: true}) == pyjson.Dumps(second.Data, pyjson.Options{SortKeys: true}) + // The bound role is part of what a send reads from a record: a re-read that lands on a different + // binding must cancel the claim rather than let the transport run on the pair the first read + // confirmed (the auto-compaction limit is resolved from that role's pair). + return first.SettingsFreeResume == second.SettingsFreeResume && first.BoundRole == second.BoundRole && pyjson.Dumps(first.Data, pyjson.Options{SortKeys: true}) == pyjson.Dumps(second.Data, pyjson.Options{SortKeys: true}) } func optionalNumber(n sql.NullInt64) any { diff --git a/plugins/crw/.codex-plugin/plugin.json b/plugins/crw/.codex-plugin/plugin.json index b121f1f612..1567744ca8 100644 --- a/plugins/crw/.codex-plugin/plugin.json +++ b/plugins/crw/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "crw", - "version": "0.4.0+712166dbf1d3", + "version": "0.4.0+c1e4515b60ba", "description": "Linear-driven workflow skills for Codex: define an initiative, plan projects and issues, run delivery, and check the result against its canonical criteria.", "author": { "name": "thisisjun786", diff --git a/plugins/crw/skills/crw-run/references/bridge.md b/plugins/crw/skills/crw-run/references/bridge.md index 4999ead200..c50a005fee 100644 --- a/plugins/crw/skills/crw-run/references/bridge.md +++ b/plugins/crw/skills/crw-run/references/bridge.md @@ -230,6 +230,34 @@ Compare actual cwd, workspace roots, model, reasoningEffort, approvalPolicy, and the full sandbox/permission response. Do not infer OCX routing from a model ID or the generic modelProvider label. Missing served-provider proof stays unknown. +A pair may also carry `autoCompactTokenLimit`. The bridge sends it as +`config.model_auto_compact_token_limit` on `thread/start` and `thread/resume`, and the relay sends +it again on the resume it makes when it delivers a message to a task or carries a correction back +to it. It is how a thread is made to compact below a host window larger than what the provider +behind the model accepts. It is not a pair half and not something a caller states: the operator +writes it on the role's pair in the execution policy, and a file without it is unchanged. The host +does not report the value back, so the resume observation never compares it, its silence withholds +nothing, and the receipt lists it under `requested` and `unobservable` rather than `verified` -- +on a relay resume too, where the value is resolved from the policy by the pair the task's record +states — the role is the one the relay's gate confirmed the task is bound to, falling back to the +record's own citation — and the receipt is the only place the send can say what it carried. A +resume that sent no limit records no such observation. + +The value applies where the host takes the configuration: at creation, or when the host loads a +thread it did not have. A resume to a thread the host already holds answers with that thread's +current state, so it does not install a new threshold; the receipt saying the limit was sent is not +the same as the thread now compacting under it. + +**Write 550000.** The probe measured the mechanism, not this number: at +`config.model_auto_compact_token_limit=4000` the host compacted, and 550000 is the value +recommended for `inferhub/deepseek-v4.1-flash` because it sits about 110,000 below the highest +input observed to pass (664,238 tokens) and compaction is decided between samples, where one tool +result can add a lot between two of them. The probe: two ephemeral threads under an isolated +`codex app-server`, both reporting a 950,000 window, given the same ~15,000-token input over three +turns. The thread given the 4000 limit compacted twice, its context falling from 30,696 to 15,478; +the control thread never compacted and grew 30,756 to 40,588 to 40,593. A model whose provider cuts +the stream above roughly 664,000 tokens otherwise stops without a receipt. + The receipt also reports the runtime status the host gave before the resume. Where that status is `notLoaded`, it carries `echoIndependence: "not_established"`: the host may apply the settings it was sent while materializing the task, so an agreeing answer cannot be told apart