Skip to content

feat(opencode): model capability tiers, honest context limits, structural stops, and budget telemetry - #2

Draft
linfangy-int wants to merge 33 commits into
devfrom
small-model-tiers
Draft

feat(opencode): model capability tiers, honest context limits, structural stops, and budget telemetry#2
linfangy-int wants to merge 33 commits into
devfrom
small-model-tiers

Conversation

@linfangy-int

@linfangy-int linfangy-int commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Issue for this PR

N/A — fork-internal review PR for a patch series; relates to the upstream discussion about a model-tier capability field.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

A 32-commit patch series making opencode work well with small (~4B) and mid-size (~30B) local models, which today fall through every model-specific code path. Each commit carries a stable item ID (A0…E7, B6) for review:

  • Capability tiers (A0–A4): per-model tier: "minimal" | "default" (config, catalog field when available, or parameter-count heuristic; vendor-family models keep their existing prompts byte-identically). minimal = compact ~1.1k-token system prompt, reduced tool roster, grammar-safe tool schemas (no format/pattern/anyOf/$ref — llama.cpp GBNF-safe), pinned sampling. Per-model prompt and sampling config overrides.
  • Honest context arithmetic (C1–C8): unset limit.context no longer silently disables compaction; proportional compaction reserve min(20k, max(2048, ctx*0.15)); truthful overflow recovery text; window-aware output-token fallback + per-request headroom clamp; format-aware token estimation replacing flat chars/4.
  • Structural enforcement (B1–B5): max-steps now actually strips tools from the final request (the prompt already claimed this); deterministic plugin load order; plugin-hook error isolation; tier-scoped doom-loop tool stripping; mechanical JSON tool-call repair (smart quotes, trailing commas, brackets, snake_case keys).
  • Text tool-call lifting (B6): stream middleware converting <tool_call>/fenced-JSON/bare-JSON call shapes into native tool calls for capabilities.toolcall === false or minimal-tier models — the first consumer of that catalog flag.
  • Budget observability (D1–D5): GET /session/:id/context-budget (limits, reserve, usable, per-component baseline breakdown, next-request estimate), x-opencode-* telemetry headers on every provider request, typed budget/overflow/compaction events, and a session.turn.completed reconciliation event.

Why: stock opencode reserves a fixed 20k tokens for compaction (36% of a 56k window), assumes 32k output when limits are unset, estimates tokens at chars/4 (2.8x off on CSV), and sends a long vendor-tuned prompt plus full tool schemas (~28–34k tokens) — a small-context model ends up with ~5k usable tokens and compacts after every step. Loop guards are prose-only, which exactly the models that loop ignore.

How did you verify your code works?

  • ~1,200 targeted tests across packages/opencode and packages/core (all new suites + touched existing suites), typecheck green in every touched package; frontier-regression tests assert vendor-model prompts/tools/schemas are byte-identical to upstream.
  • Built the binary and smoke-tested: context-budget returns the specified arithmetic (reserve 8448, usable 48128 on a 56320 window; minimal baseline 5,181 tokens vs ~28–34k stock).
  • End-to-end validation in a downstream embedding driving a 4B local model through an OpenAI-compatible router, on a 116-task agent benchmark: score 0.6134 → 0.6820, task error rate 35% → 6.9%, context-overflow failures 31 → 1.

Screenshots / recordings

N/A (no UI change).

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Draft for review. A follow-up wave (config-owned tier rosters, token turn budgets, enforced subagent enums, headroom-aware reads) is under design review before implementation.
Draft for review.

Update: the follow-up wave has landed on this branch as W6 (commit d29b13a8fc) — config-owned tier rosters with a custom-tool exemption, token/wall-clock turn budgets routed through the existing max-steps stop, an enforced subagent_type enum, headroom-aware reads, honest budget-stop semantics for delegated tasks, and an optional expected_artifacts deliverable contract. Rationale and per-item detail are in the W6 comment; notably, the validation run reversed the roster decision — the narrow minimal roster proved protective, so it stays the default and widens only by explicit config.

linfangy-int and others added 30 commits August 14, 2026 22:43
Add a three-tier capability taxonomy (minimal/default/vendor) resolved per
model: explicit per-model config tier, then the models.dev model-tier catalog
field (read defensively until upstream anomalyco#41372 lands), then a parameter-count
heuristic over the model id, with a vendor family guard so frontier ids stay
untouched. Band edges are exported constants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route minimal-tier models to a new compact task-first prompt (prompt/minimal.txt,
mode A/B decision, real-tool-call and step-budget rules) and default-tier models
to a trimmed default (prompt/default-compact.txt). The vendor family ladder is
unchanged and byte-identical for claude/gpt/gemini/kimi ids. A per-model config
field `prompt` replaces the family prompt entirely; file loading uses the
existing {file:./path} config substitution resolved against the config dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Minimal-tier models get only the core roster (bash, read, write, edit, glob,
grep, todowrite, plus the internal invalid fallback); default tier keeps the
standard roster minus apply_patch, with the gpt usePatch swap forced off so
edit/write survive. The tier is resolved once in session tools resolution and
passed into the registry; per-agent permission/tool overrides still filter
later in request prep. Untiered callers keep the existing behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add sanitizeGrammarSafeSchema: sanitizeOpenAISchema plus $ref/$defs inlining
(cycle-safe), anyOf/oneOf/allOf flattening to the first non-null variant,
boolean-only additionalProperties, and typed single-object items — the subset
llama.cpp's GBNF converter accepts. ProviderTransform.schema applies it when
the resolved tier is minimal or default, which covers every non-vendor
@ai-sdk/openai-compatible model while keeping claude/gpt/gemini/kimi schemas
byte-identical (kimi keeps its moonshot sanitizer even over openai-compatible).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add per-model config sampling { temperature, topP, topK } consulted before the
substring ladders in ProviderTransform. Minimal tier pins the llama.cpp launch
tuning (0.1 / 0.95 / 20) ahead of the ladders; default tier uses those values
only when the ladder has no entry, and only when the tier is backed by explicit
config or a parameter-count match, so unknown cloud ids (deepseek-v4-flash)
and frontier families keep today's values. Config-declared options such as
chat_template_kwargs and reasoning already reach the openai-compatible request
body via the providerOptions namespace routing; a test now locks that in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the fixed 20k compaction buffer with a window-proportional
reserve: min(20_000, max(2_048, floor(context * 0.15))). The explicit
compaction.reserved config keeps absolute priority and large explicit
windows keep the 20k reserve via the min().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unset/zero model context limit no longer disables proactive
compaction. Unless compaction.auto is explicitly false, overflow math
assumes a conservative 32k usable window and logs a warning once per
session. On a provider ContextOverflowError with no configured limit,
the failing request's estimated input size is recorded as a session-
level cap upper bound (in-memory per session) that shrinks the default
window for subsequent overflow checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The auto-continue message after an overflow compaction only blames
media attachments when media parts were actually dropped with the
compacted head. Text-only overflows now state the real cause and carry
an explicit negative instruction so small models do not repeat the
attachment story to the user.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(a) maxOutputTokens: when limit.output is unset, fall back to
min(32_000, max(1_024, floor(limit.context * 0.25))) instead of a flat
32k that could swallow an entire small window. Unset limit.context
keeps the flat fallback (the overflow layer's conservative default is
not double-applied here).
(b) LLMRequestPrep.prepare clamps maxOutputTokens to
max(256, usable_window - estimated_input) reusing the overflow math.
prepare is the chosen seam because the fully composed request (system,
messages, resolved tools) first exists there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified by trace that PATCH /config already re-resolves provider model
limits without restart: ConfigHttpApi.update marks the instance for
disposal, disposeMiddleware runs InstanceStore.dispose after the
response, and runDisposers(directory) invalidates every InstanceState
cache including the Provider state that holds resolved limits. No code
change needed; this adds a focused test pinning the per-directory
disposer invalidation that path relies on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New config compaction.prompt replaces the built-in summary template
used when compacting a session. The value goes through the standard
config {file:./path} substitution so the prompt can live in a file
(e.g. a simpler template for minimal-tier models). Plugin-provided
compaction prompts keep precedence; default behavior is unchanged.

Also regenerates the legacy JS SDK types, which picks up the Wave 1
per-model tier/prompt/sampling config fields alongside compaction.prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the prompt loop reaches its last permitted step, the request now
carries no tools and toolChoice "none" instead of only the
MAX_STEPS_PROMPT prose claiming tools are disabled. The lastStep flag
flows from the loop through LLM.StreamInput into request prep, where the
resolved roster is emptied (StructuredOutput survives so json_schema
turns can still deliver their result). The V2 runner already drops tools
on the last step, so only the V1 engine changes. Applies to all tiers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sort Glob.scan results lexicographically before mapping directory
plugins to file URLs, making numeric filename prefixes (00_, 05_, 10_)
a real load-order convention instead of a decorative one. Load order
previously depended on filesystem enumeration order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Split trigger hooks into two classes: blocking hooks
(tool.execute.before, permission.ask, command.execute.before) keep
first-throw propagation because a throw is semantic, while all
accumulating hooks (chat.params, chat.headers, chat.message, event,
experimental transforms, tool.execute.after, ...) now catch per-plugin
errors, log them, and continue with the remaining plugins. Previously
the first throwing hook cancelled every later plugin's hook for the
turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the minimal tier, a doom_loop detection that would raise a
permission ask instead strips the offending tool: the third identical
call fails with exact recovery text and the tool is excluded from the
session's next two prepared requests (resolveTools). The public
permission Action enum stays untouched — extending it would break the
generated SDK/config contract — so the behavior is tier-gated:
default and vendor tiers keep today's ask semantics. Strip state is
per-session in-memory, mirroring overflow.ts's learned limits; small
model calls (title/summary) do not consume the strip budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
experimental_repairToolCall now attempts mechanical repair of the
arguments before routing to the invalid tool: smart quotes to straight,
single-quoted keys/strings to double where unambiguous, python
True/False/None literals, trailing commas, and unbalanced brackets —
ported from a production router-side repair table as a pure helper. The
repaired call is returned only when it parses and validates against the
tool's schema; otherwise the existing lowercase-name fix and invalid
fallback apply unchanged. Saves a full provider round-trip per
malformed call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /session/:id/context-budget on the V1 instance API reports the
session's context arithmetic for arithmetic routing: effective model
limits and tier, compaction reserve, usable window, dry-run baseline
cost (system prompt, tool roster, instructions), history estimate with
provider-reported usage from the last finished assistant message, and
projected next-request input/headroom. The dry run mirrors the prompt
loop's model/agent resolution and request prep assembly without
dispatch side effects (documented approximations in the schema).
Regenerated SDK artifacts (openapi.json, hey-api client).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every provider request now carries x-opencode-est-input-tokens,
-history-tokens, -baseline-tokens, -tools-tokens, -limit-context,
-limit-output, -usable, -tier, -session-id, -agent, and -subagents
headers, computed from what request prep already assembled after tool
resolution (reusing the C6 clamp estimates, no double estimation).
est-input = history + baseline; baseline includes the tools figure.
The subagent roster is computed in the prompt loop from the same source
as the task tool description and threaded through StreamInput. Native
values precede the chat.headers plugin hook so plugins may override
but can never silently lose them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Typed bus events (SSE-visible via the existing bridge) for context
arithmetic decisions: session.overflow.detected {tokens, usable,
reserve, action} at both overflow gates (prompt-loop check via
SessionCompaction.isOverflow and the processor step-finish check),
session.compaction.started/completed {before_tokens, after_tokens}
around compaction processing (after_tokens approximates the retained
tail), session.output.clamped {requested, granted} when the C6 clamp
reduces the output budget (prepare reports it, llm.ts publishes), and
session.tool.stripped {tool} augmenting B4's strip log. Regenerated
SDK artifacts for the extended event union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The context-budget response's baseline.tools now carries tools_detail
[{id, chars, est_tokens}] — per-tool serialized cost (id + description
+ provider-transformed JSON schema). Adds the C3 CI budget gate:
minimal-tier baseline (system_prompt + tools) must stay <= 6k tokens
(measured 5189: prompt 1598 + tools 3591 across 8), default tier
<= 12k (measured 6669: prompt 1347 + tools 5322 across 12). Regenerated
SDK artifacts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session status disagreed with on-disk artifacts in both directions
(idle-with-nothing, error-after-successful-writes). Additive fixes,
existing status semantics untouched: (a) session.error events now carry
parts_written when the erroring turn had already completed file-writing
tool parts (write/edit/apply_patch), computed across the turn's
assistant messages; (b) every runLoop exit path (break, error, abort)
emits session.turn.completed {sessionID, status: idle|error,
parts_written, last_error?} via Effect.onExit as the reconciliation
channel for graders and routers. Regenerated SDK artifacts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Token.estimate(text, hint?) resolves a bytes-per-token density from a
format tag or filename extension (csv/tsv 1.3, json/ndjson/jsonl 1.5,
log 2.0, prose default 4 unchanged). Token.register(fn) installs a
custom estimator that wins over the density table (an embedder wires a
exact tokenizer through it via plugin). Compaction prune passes the
read filename as a hint; general estimates keep the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a tool call fails validation, the repair callback retries with
top-level argument keys converted snake_case -> camelCase (file_path ->
filePath, old_string -> oldString; generic transform, nested keys are
left alone as they may be data). The transformed args are accepted only
when they validate against the tool schema. Retires the downstream plugin
05_file_path_arg_rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A task_id without the ses session-id prefix on a fresh dispatch is a
model-invented label, not a resume; SessionID.make previously died on
it and burned the whole step (anomalyco#1367). The label is now ignored and the
dispatch proceeds fresh. camelCase variants (taskId/taskID) are dropped
by the parameter schema decode, which the new tests pin. Retires
the downstream product plugin 07_task_id_fresh_dispatch_strip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
experimental.omit_model_identity omits the model identity line (You are
powered by the model named ...) from the system prompt environment
block. Default off everywhere except the minimal tier, which omits the
line by default as part of the C3 baseline budget; an explicit false
restores it. Retires the downstream plugin 03_strip_model_id_prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getSmallModel gains a substring fallback (flash/nano/haiku/mini over
family and id) after the exact-family ladder misses, so config-defined
models with an empty family can still be auto-selected. A configured
small_model that does not resolve now logs a warning naming the missing
model instead of failing silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reasoning-in-text models leak <think>...</think> blocks into normal
assistant turns; the only scrub today is title generation. On the
minimal tier the completed text part is now scrubbed with the same
regex at text-end (streaming deltas still flow raw). Other tiers are
byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The volatile date line in the environment block invalidated the prompt
cache prefix daily (observed 83% -> 27% hit-rate collapse). On the
minimal/default tiers SystemPrompt.environment drops the date from the
env block and request prep appends it as a trailing system message, so
the leading system message is byte-stable across days. Vendor tiers and
small utility calls are byte-identical to upstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified current defaults: no chunkTimeout is applied anywhere unless
provider config sets one (only openai gets a headerTimeout default), so
slow local decode had no stall guard and a hung stream waited forever.
Minimal/default-tier models on @ai-sdk/openai-compatible endpoints now
default chunkTimeout to 300_000 ms when the provider config sets no
explicit chunkTimeout/timeout. Scoped to openai-compatible so cloud SDK
behavior is untouched; applied before the SDK cache key so tiered and
vendor models on one provider get distinct SDK instances. Verified by
trace through the existing streaming suite (the wrapSSE chunk guard is
now exercised by every openai-compatible llm test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
linfangy-int and others added 3 commits August 15, 2026 19:01
Stream middleware beside the existing wrapLanguageModel transform,
gated on capabilities.toolcall === false or the minimal tier, finally
consuming the stored toolcall capability on the request path. Detects
<tool_call>{json}</tool_call> blocks, fenced json blocks, and bare
JSON objects of the {name|tool, arguments|parameters|input} shape,
validates the name against the prepared tools, converts to a native
tool-call stream part and suppresses the source text, rewriting a stop
finish to tool-calls so the step loop executes the tool. Conservative:
text is held only while it can still become a call and passes through
untouched on any ambiguity; capable models bypass entirely. Detection
patterns ported from a downstream router's prose-call lifting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…olution

SessionTier.vendor() and the parameter-count heuristic read model.api.id
unconditionally; config-defined models (and the compaction suite's fake
model) can carry no upstream api id, so the E5 text-end scrub crashed the
processor mid-stream ("undefined is not an object (evaluating
'id.includes')") and left compaction summaries marked errored, breaking
anchored re-compaction. Fall back to the opencode model id (then empty).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…op semantics

Follow-up wave to the tier/limits series, driven by an end-to-end validation
run of this branch against a 4B local model on a 116-task agent benchmark.
Two findings from that run shaped it:

- The minimal tool roster is protective, not merely small. A patch restoring
  `task` to it cost a research-task category 0.868 -> 0.396: the model
  delegated work it should have done directly, then exhausted its step budget.
  A prompt-level fix scored worse (0.354). The roster stays narrow by default.
- The failure mode moved rather than disappeared. Seven of eight remaining
  failures were timeouts, not overflow; one turn ran 70 steps / 1.83M tokens
  before the provider gave up. `steps` alone does not bound a turn.

W6-1 config-owned tier roster + custom-tool exemption
  MINIMAL_TIER_TOOLS becomes a default, overridable per model via
  `tier_tools: { include, exclude }`. Tools contributed by plugins or a
  `tool/` directory are exempt from the tier cut by default: the cut exists
  to trim opencode's own surface, and silently dropping an integrator's tool
  turns an advertised capability into a no-op. `exclude` still wins over the
  exemption; `invalid` is not removable, being the LLM layer's landing slot
  for malformed tool calls.

W6-2 enforced subagent enum on task
  `subagent_type` is published as a schema enum of the agents this agent may
  actually reach, from the same list the description renders (extracted as
  `permittedSubagents` so the two cannot drift). Listing them only in prose
  leaves the field free-form, and a small model will invent a name or reach
  for an agent it is not permitted to use. No-ops when the permitted set is
  empty, since an empty enum would reject every call.

W6-3 token and wall-clock turn budgets
  New `turn_tokens` / `turn_seconds` agent config, both routed through the
  existing B1 structural stop rather than adding a second termination path:
  whichever trips first, the next request carries the max-steps directive and
  no tools. Token accounting sums per-request usage the way isOverflow does.

W6-4 headroom-aware reads
  Request preparation publishes the budget arithmetic it already computes;
  `Tool.Context.budget()` exposes live remaining headroom, and `read` refuses
  a slice that would not fit, reporting the numbers and a concrete narrower
  call. Proactive compaction structurally cannot cover this: a single
  oversized tool result goes from under-budget to over-window inside one step
  and never crosses the trigger on the way up (observed at 57,632 tokens
  against a 56,320 window).

W6-5 honest task failure semantics
  A subagent stopped by a budget returns `state="max_steps"` with partial
  output and parent-directed recovery text, instead of leaking the raw stop
  directive. Models routinely answer that directive by restating it, so the
  subagent's final text was often the directive itself, which reads to the
  parent as an instruction addressed to it and gets surfaced as an
  unexplained failure even when real work was done.

W6-6 deliverable contract
  Optional `expected_artifacts` on a prompt. If the turn would go idle
  without them, the agent is told once, precisely what is missing. Exactly
  one nudge, and none at all when a budget already forced the text-only step
  and the tools are gone.

Also: `script/build.ts` gains `--target <name>` to build a single named
target regardless of host platform (`--single` is host-only, so producing a
Linux container binary from a Windows box meant a full 12-target run).
@linfangy-int

linfangy-int commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Follow-up wave (W6): structural bounds, enforced rosters, honest stop semantics

The wave flagged at the bottom of the PR description is now implemented, driven by an end-to-end validation run of this branch against a 4B local model on a 116-task agent benchmark. That run is also what changed one of the decisions below, so the reasoning is recorded inline in the code rather than only here.

What the validation run changed

The headline result held: score 0.6134 → 0.6820, error rate 35% → 6.9%, context-overflow failures 31 → 1. But two findings reshaped this wave:

  1. The minimal roster is protective, not merely small. The run carried a patch restoring task to MINIMAL_TIER_TOOLS (the roster looked over-aggressive for an embedding product whose only route to subagents is task). That patch cost a research-task category 0.868 → 0.396: the model delegated work it should have done directly, then burned its step budget. A prompt-level fix scored 0.354 — worse. So the default roster stays narrow and is widened only by explicit configuration.
  2. The failure mode moved rather than disappearing. 7 of the 8 remaining failures were timeouts, not overflow. One turn ran 70 steps / 1.83M tokens before the provider gave up. steps alone does not bound a turn.

The items

Item What it changes
W6-1 MINIMAL_TIER_TOOLS becomes a default, overridable per model via tier_tools: { include, exclude }. Plugin- and tool/-directory-contributed tools are exempt from the tier cut by default.
W6-2 subagent_type is published as a schema enum of the agents this agent may actually reach — enforce, don't instruct.
W6-3 New turn_tokens / turn_seconds agent budgets, both routed through the existing B1 structural stop rather than a second termination path.
W6-4 Request preparation publishes its budget arithmetic; Tool.Context.budget() exposes live remaining headroom, and read refuses a slice that would not fit, with the arithmetic and a narrower call to make.
W6-5 A subagent stopped by a budget returns state="max_steps" with partial output and recovery text, instead of leaking the raw stop directive to its parent.
W6-6 Optional expected_artifacts on a prompt; if the turn would go idle without them, the agent is told once, precisely what is missing.

Notes on the design choices

  • W6-1 exempts custom tools but not built-ins. The tier cut exists to trim opencode's own surface for a small window. An integrator who registered a tool has already made that call, and silently dropping it turns an advertised capability into a no-op. exclude still wins, so an over-large custom roster remains trimmable. invalid is not removable — it is the LLM layer's landing slot for malformed tool calls and is never advertised to the model.
  • W6-2 no-ops when the permitted set is empty. An empty enum is invalid JSON Schema and would reject every call; that case keeps the free-form field and fails at permission time with a message that explains itself. The enum survives the A3 grammar-safe lowering, so constrained decoders can enforce it at generation time.
  • W6-3 reuses B1 rather than adding a stop. Whichever budget trips first, the next request goes out with the max-steps directive and no tools — one termination path, one set of semantics. Token accounting sums per-request usage the same way isOverflow does.
  • W6-4 exists because compaction structurally cannot cover this. Proactive compaction triggers when accumulated history crosses usable; a single oversized tool result goes from under-budget to over-window inside one step and never crosses the trigger on the way up. Observed at 57,632 tokens against a 56,320 window. The guard reports the numbers and a concrete narrower call, because a fact about this file is more actionable to a small model than a rule it has to remember.
  • W6-5 strips the directive because models restate it. "MAXIMUM STEPS REACHED / tools are disabled until next user input" reads to a parent as an instruction addressed to it, and the delegation gets surfaced as an unexplained failure even when real work was done.
  • W6-6 nudges exactly once, and not at all when a budget already forced the text-only step — that turn has no tools left, so asking it to write a file would spend a request on something it cannot do.

Also included

script/build.ts gains --target <name> to build a single named target regardless of host platform (--single is host-only, so producing a Linux container binary from a Windows box previously meant a full 12-target run).

Verification

  • Typecheck green in packages/opencode, packages/core, packages/schema.
  • New unit suites for the roster filter, the subagent enum, and the turn-budget / headroom bookkeeping, plus an integration test asserting the advertised task schema carries the same roster its description lists.
  • Full packages/opencode suite: 3390 pass / 29 fail across 263 files. All 29 failures are pre-existing and reproduce identically at the branch point (922a552fec) — verified by re-running the failing files on that commit and set-diffing the two failure sets, which leaves zero regressions. They are the known Windows-environment classes: symlink support (Glob, filesystem.resolve, snapshot symlink handling), the HttpApi SDK generated-client block, a Git-Bash path-translation assertion in tool/shell, and session/snapshot-tool-race.

@linfangy-int

Copy link
Copy Markdown
Owner Author

W6 validation on real hardware — best score yet, but a serious throughput regression

Second full end-to-end run of this branch on the PTL 32 GB box (4B local bundle, qwen3.5-4b, llama-server n_ctx=65536), same 116-task benchmark, same fixtures, same protocol as the run quoted in the PR description. Built from d29b13a8fc as 1.18.18-superclaw.2.

This time the roster came from config, not a source patch: tier_tools: { include: ["task"] } on the local model, nothing else. --target opencode-linux-x64-baseline worked exactly as intended — single Linux target from a Windows host, no 12-target run.

Three-way result

baseline (stock 1.17.11) superclaw.1 (2889cd7b93 + local roster patch) superclaw.2 (W6, config-driven)
L2 mean 0.6134 0.6820 0.7053
Errored 41 (35 %) 8 (6.9 %) 12 (10.3 %)
ContextOverflow 31 1 2
UnknownError 10 7 10
L2 zeros 21 13 10
Perfect 1.000 13 16 17
Total task time 12.60 h 12.40 h 23.94 h
Mean per task 391 s 385 s 743 s
Tokens 44.5 M 67.9 M

W6-5 is confirmed, and the evidence isolates it

The research category recovered 0.396 → 0.874, essentially back to its 0.868 baseline — while task stayed enabled.

task baseline sc.1 sc.2 delegated in sc.2
task_executive_lookup 1.000 0.000 1.000 ['local-file-agent']
task_pricing_research 0.890 0.000 0.900 ['local-file-agent']
task_competitive_research 0.750 0.180 0.820 ['local-file-agent']
task_stock 1.000 1.000 1.000 ['local-file-agent']
task_events 0.700 0.800 0.650 ['local-file-agent']

All five still delegate to local-file-agent — the same routing that scored 0.000 before. Same delegation, different outcome, so the variable is W6-5's stripping of the raw stop directive, not a change in routing. The roster/delegation trade I reported last time was not a real trade; the cost was the leaked "MAXIMUM STEPS REACHED / tools are disabled" text being read by the parent as an instruction to itself.

W6-1 also behaved better than my hand-written list: the custom-tool exemption automatically kept confirm_file_protection, which my patch had missed and which is the entry point for the protected-file flow. Live roster (13 tools): bash, confirm_file_protection, edit, get_mcp_status, glob, grep, ocr, parse_pdf, read, sc_search_web, task, todowrite, write; webfetch/websearch correctly absent. Tier/limit arithmetic unchanged and correct (tier=minimal, usable=48128, reserve=8448).

The regression: runs got ~2× slower and 9 tasks now pin the timeout ceiling

Total task time nearly doubled and 30 tasks ran >10 min slower than sc.1. Nine tasks sat at or near the harness's 2700 s per-task limit, against one in sc.1:

task sc.1 sc.2
task_meeting_council_budget 80 s (idle) 2641 s (idle)
task_log_ssh_user_activity 346 s (idle) 2761 s (timeout)
task_log_mapreduce_jobs 156 s (idle) 2099 s (idle)
task_log_mapreduce_slow_tasks 437 s (idle) 2676 s (idle)
task_earnings_analysis 497 s (idle) 2681 s (idle)
task_log_ssh_successful 567 s (idle) 2762 s (timeout)
task_log_ssh_unusual_times 804 s (idle) 2761 s (timeout)

12 failures, 9 of them new relative to sc.1, and 10 of 12 are UnknownError: The operation timed out — the timeout class is now the whole failure surface. Two (task_csv_temp_trend, task_log_ssh_successful) completed at both baseline and sc.1 and fail here.

csv_analysis also fell 0.760 → 0.659 across 26 tasks, broadly rather than via one outlier: task_csv_gdp_regions 0.775 → 0.000, task_csv_stock_best_worst 0.875 → 0.275, task_csv_cities_filter 0.900 → 0.550, task_csv_life_exp_change 0.945 → 0.608.

Leading hypothesis, not a finding: W6-4. The clustering is suggestive — the worst-hit tasks are log_* and csv_*, i.e. exactly the large-fixture reads W6-4 now refuses, and a refusal that the model answers with another slightly-narrower read would produce precisely this signature (many more requests, far more tokens, no overflow, eventual timeout). Overflow did stay near zero (2), so the guard is doing its job on the metric it targets; the question is what the model does after being refused. I have not verified the retry pattern in the trajectories yet.

Two caveats on all of the above: n = 1 per configuration, and this benchmark has demonstrably high run-to-run variance (task_events has now scored 0.700 / 0.800 / 0.650 and task_meeting_council_votes produced three different outcomes across three runs; the judge needed 7 variance retries here vs 4 last time). Per-category deltas on n=5 categories should be read as directional only.

Not exercised

I deliberately changed one variable, so W6-3 (turn_tokens/turn_seconds) and W6-6 (expected_artifacts) were left unconfigured. Given that the timeout class is now the entire remaining failure surface and 9 tasks pin the ceiling, W6-3 looks like the directly relevant lever and is the obvious next arm.

Per-category

category baseline → sc.1 → sc.2 n Δ vs sc.1
research 0.868 → 0.396 → 0.874 5 +0.478
coding 0.720 → 0.775 → 0.899 5 +0.124
productivity 0.771 → 0.852 → 0.922 7 +0.070
meeting_analysis 0.481 → 0.535 → 0.584 28 +0.049
log_analysis 0.652 → 0.680 → 0.691 30 +0.011
analysis 0.708 → 0.787 → 0.783 9 −0.004
writing 0.888 → 0.907 → 0.863 5 −0.045
csv_analysis 0.507 → 0.760 → 0.659 26 −0.101

Artifacts: runs/arm_A_20260818_091736/ (judge, tokens, by-turn model, workspace snapshot). Happy to pull specific trajectories if the W6-4 hypothesis is worth chasing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant