Skip to content

fix(llm): provider errors told apart, size rejections repacked, server chat templates, argument hints, literal grep, write-time warnings (F29–F34, F24) - #435

Open
plombeer31 wants to merge 8 commits into
harness/09-loopfrom
harness/10-errors
Open

plombeer31 wants to merge 8 commits into
harness/09-loopfrom
harness/10-errors

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

What

Before: a 429 credit_balance_exhausted was retried as rate limiting (168 requests), a 402 in_flight_budget_exhausted with a retry hint ended a run, and a 504 after 10K streamed tokens lost its billed cost; a context-length 400 ended the turn with no repack and a learned window was forgotten; GGUFs without a hand-built profile ran as plain-instruct with no turn markers and no thinking switch; the claude/codex CLI providers announced native tools they never forwarded; argument errors said "path must be a non-empty string" to a model that had sent "\"path\"" or patternes; os.fs.grep had no literal mode and .add( failed with a raw regex error.

After: HTTP error bodies are read — exhausted credit stops the turn resumable and names the provider, a retry hint is honoured up to 180 s, a stream cut after output records its generation id; a size rejection learns the window from the body (else 0.8 × the estimate), repacks and retries once with a notice, learned windows persist and only grow, and context_length comes from OpenRouter's live catalog when no entry exists; localModels.useServerTemplate (auto/on/off) renders local prompts through llama-server's /apply-template for families without a profile, with localModels.thinking mapped to chat_template_kwargs; subscription CLIs declare the text transport; argument errors append the keys received and expected with the nearest match, and quoted or fused keys are normalised before dispatch; os.fs.grep { literal: true } maps to rg -F and the parse error suggests it; writes warn on inline-script parse errors and content after </html>, transcript markup at line start, and double-escaped content. Config bump for the new fields.

Why

Each of these turned a one-line fix into a blind retry loop or a dead run. Error wording is provider-specific, so a small table with a generic fallback; retry hints are bounded so a provider cannot park a run; write-time checks warn only, never block.

How it was verified

  • npm run lint clean
  • npx vitest run --minWorkers=1 --maxWorkers=3 src/agent src/cli src/config src/llm src/llm/provider src/llm/provider/llama-server src/llm/provider/openai src/llm/provider/registry src/llm/provider/subscription-cli src/llm/reliability src/prompt src/runtime/bootstrap.test.ts src/runtime/learned-context-windows.test.ts src/runtime/llm-link-attempt.test.ts src/runtime/resolve-model-pricing.test.ts src/tools src/tools/fusion src/tools/os src/tracing/trace src/tui/agent-event-reducer.test.ts — 308 files / 4118 tests green, 3 skipped (baseline failures: none)
  • verified live: the bench-2 matrix (local, cloud, fusion both ways; 16 turns) ran on this code with no provider-error regression; the argument-key hints are the base the unknown-key refusals in PR 13 build on

Stacked on #434; merge in order.

A tool's argument error named the key it wanted and nothing else, so a
model that had sent `patternes`, `"path"` (quotes included) or a key
fused with prompt markup retried the same call blind.

- batch-executor: an error a tool throws on its arguments now ends with
  `received keys: …; expected: …` (expected from the tool's args schema)
  and `did you mean X instead of Y?` for any received key within two
  edits of an accepted one. Keys only, never values. Runtime failures
  (ENOENT, timeouts) get no key report.
- coerce-tool-args: before dispatch, a quoted key (`"path"`, `\"path\"`)
  and a fused label fragment (`<label>…</label>,limit`) are renamed to
  the schema key they were meant to be — only when the clean key is in
  the schema and the model did not also send it.
`os.fs.grep` always ran the pattern as a regex, so a code fragment such
as `.add(` failed with a raw ripgrep "regex parse error: unclosed group"
and the model had to guess at escaping.

- `literal?: boolean` maps to `rg -F` (fixed-string search); added to the
  descriptor's argsSchema and the native-tools JSON schema.
- A ripgrep regex parse error now ends with a hint naming `literal: true`
  (also in `details.hint`); nothing is added when literal was already
  set or the failure is something else.
…double-escaping

Patch 4's parse check knew `.js`/`.json` only. A local model then wrote
an `index.html` with 16,627 bytes of serialized tool-call text after
`</html>`, and a `game.js` whose newlines were all literal `\n`; both
were found many steps later.

New `fs-content-check.ts`, warn-only like the parse check, composed with
it through one entry point (`checkChangedFile`) that `os.fs.write`,
`os.fs.edit` and `os.fs.patch` now call:
- `.html`/`.htm`: every inline classic `<script>` block is parsed with
  the JS checker (module, JSON and `src=` scripts are skipped) and the
  error line is the line in the file; non-whitespace content after
  `</html>` is reported with its size and start line.
- any text: a line starting with `assistant_tool_call:`, `tool_result[`,
  `<|channel|>` or `<|turn>`; more literal `\n` sequences than real
  newlines (four or more, so a one-line JSON string is not an alarm).
An edit or patch reports only the problems it introduced or made worse;
a write reports everything.
…ited, generation ids kept

A 429 carrying `credit_balance_exhausted` was parked and retried as
rate limiting (42 times per worker); an OpenRouter 402
`in_flight_budget_exhausted` with a "retry in 120 s" hint ended a run
as final; a 504 after 10,528 streamed tokens left the billed
generation invisible. The status said one thing, the body another.

- `parseProviderErrorBody` (pure) reads the OpenAI and OpenRouter
  shapes, OpenRouter's upstream `metadata.raw`, and textual cooldowns;
  `readProviderErrorReason` turns them into `credit_exhausted` /
  `retry_after`. `OpenAiHttpError` carries the parsed `body`.
- `readProviderErrorVerdict` (reliability) walks the cause chain from
  the loop's TransportError to the HTTP error and names the provider.
- The HTTP client no longer spends its three retries on a 429 whose
  body says the credit is gone.
- Agent loop: `credit_exhausted` stops the turn where it is, resumable
  (`stopCause: "credit_exhausted"`, `task_stopped:credit_exhausted`,
  a synthetic reply naming the provider, a `credit_exhausted` event the
  TUI renders as one yellow line). A cooldown hint is waited for as
  asked, clipped to 180 s, inside the existing outage budget — a 402
  with a hint is waitable now; a plain 429 keeps the backoff park.
- Fusion workers report a credit stop as `failed` with the quota hint.
- SSE generation ids: every chunk's `id` reaches
  `CompletionResult.generationId` and the `llm_completion` trace row;
  a mid-stream `error` event throws a typed error and a body that died
  after output carries the id, which the trace `error` row records.
…try once

A model without a catalog entry is assumed to have 128K; a 400/413
about the context length ended the turn with no repack, and a learned
window was forgotten the moment a later completion was larger.

- `planSizeRejectionRepack` (agent): on `isRequestSizeRejection` from a
  native-tool link, learn the window from the body's own number
  ("maximum context length is N tokens", "N-token context window", …)
  or, failing that, 0.8 × the loop's estimate of the refused prompt;
  observe it through the existing learned-window path, retry the same
  step once with a `### notice` saying history was trimmed to fit, and
  emit `prompt_repacked` (TUI line, trace row). A refusal that names
  only the reply cap, the loop's own raised-cap retry, or a second
  refusal on the step still fail as before.
- `readContextLengthFromRejection` / `requestSizeRejectionNamesContext`
  on the size-rejection classifier.
- Learned windows persist and only move towards what the server showed:
  `LearnedContextWindows` (extracted from bootstrap) lowers on an
  observation and raises — no longer forgets — when a completion held
  more. AGENTS.md invariant 6 updated accordingly.
- `resolveModelPricingFor` falls back to OpenRouter's cached live model
  list (context_length, prices) for a model neither configured nor
  bundled, so the assumed 128K only applies when nothing is known.
…y use

The claude and codex CLI providers announced `native_tools`, so the
prompt's persona said "never write tool-call JSON as text" and the
instructions pointed at a `tools` payload that is never forwarded to
the CLI — while Codex's own steering text asked for the JSON array.

- `SubscriptionCliProvider` reports `toolTransport: "grammar"` (the
  JSON-array text transport in this runtime; there is no separate
  "text" value) with no tool-call adapter. The stable prefix now
  renders the JSON-array persona, rules and emission instructions for
  it, and the step executor parses the call out of `content`. A
  format drift costs the parser's one-shot retry (a second CLI
  invocation) instead of the native recovery ladder.
- `llm-link-attempt` forwards `responseFormat` on grammar links too,
  so the CLI's `--json-schema` staging keeps serving the memory
  sub-calls; llama-server ignores the field in favour of the grammar.
Only Gemma and Qwen had hand-built prompt profiles; every other GGUF
ran as plain-instruct with no turn markers, no reasoning handling and
no way to switch thinking off on /completion.

- `localModels.useServerTemplate: auto|on|off` (default auto = on for
  every family without a hand-built profile, i.e. plain-instruct) and
  `localModels.thinking: auto|on|off`, parsed with defaults so older
  files need no change (the version bump is its own commit).
- `resolveServerTemplatePolicy` decides once per step from config and
  the active profile. When on, the step executor builds a framing-free
  prompt (no prefill, no turn framing — as for a chat transport) and
  hands the grammar link `chat: { system: <stable prefix>, user:
  <tail>, prefixHash, enableThinking? }` next to the raw text.
- `LlamaServerClient.applyTemplate` posts `POST /apply-template` with
  `messages` and `chat_template_kwargs`; `ServerTemplateRenderer` (in
  the llama-server provider) renders the prefix once per stable-prefix
  hash, model and thinking setting with a sentinel in the user slot and
  splices each step's tail in, so the rendered head stays byte-stable
  for the KV cache. A 404 disables the path for the process; any other
  failure or a template that drops the sentinel sends the raw prompt.
  The GBNF grammar is kept.
- `ModelProfile.supportsThinkingSwitch` marks templates that read
  `enable_thinking`; the thinking setting only applies there.
- `ProviderFactoryContext.getModelId` keys the renderer cache on the
  live model so a hot swap never reuses another template's framing.
…nking

v66: the two F31 fields, both defaulting to auto; v65 files are accepted
and inherit the defaults. Everything else in fix/errors is independent of
the exact number, so the integrator can squash this with other bumps.
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