Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions docs/role-execution-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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
Expand Down
175 changes: 175 additions & 0 deletions internal/bridge/autocompact_test.go
Original file line number Diff line number Diff line change
@@ -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, &params); 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"))
}
}
2 changes: 1 addition & 1 deletion internal/bridge/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Comment thread
thisisjun786 marked this conversation as resolved.
if err := contract.Validate(); err != nil {
return err
}
Expand Down
Loading
Loading