Skip to content

release: v0.6.1 (#411–#421) - #422

Merged
plombeer31 merged 25 commits into
mainfrom
rc/2026-09-13
Sep 13, 2026
Merged

plombeer31 merged 25 commits into
mainfrom
rc/2026-09-13

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

What

Release integration for v0.6.1: every open maintainer PR, merged into one branch so conflicts are resolved once and the release is built from exactly what was tested.

PR Change
#411 the query rewriter gets its own fallback partition — a refused rewrite no longer moves the turn onto a dead local link ("Turn failed")
#412 a memory sub-call timeout cancels its HTTP request
#413 providerPreferences is sent to OpenRouter as the request's provider routing
#414 memory sub-call schemas pass OpenAI strict mode; prompts with response_format mention JSON (Qwen)
#415 reflection and link-generator timeouts 60 s, config v65 migration
#416 the query rewriter runs once per turn, 10 s timeout (stacked on #415)
#417 a sub-call is retried once without response_format when the endpoint refuses structured outputs
#418 when every fallback link fails, the error names the primary's failure
#419 a one-time warning when memory sub-calls keep timing out or failing
#420 Telegram polling reconnects with backoff after a non-fatal stop (refs #409)
#421 pinned profile facts are never dropped by the prompt clip; memory.profile.maxEntries caps unpinned facts (fixes #407)

Why one PR

Two reconciliations exist only on this branch and are lost if the member PRs are merged one by one:

#421's merge also resolved additive conflicts with #418 / #419 in the trace formatter, its tests and the /report redaction lists.

How it was verified

The provider fallback chain partitions breaker state by the request's
session id, and every memory sub-call except the query rewriter already
used a prefixed id (reflection:, link-gen:, vote:). The rewriter ran on
the bare session id, i.e. on the turn's own partition.

A provider that refuses the rewriter's response_format request (a 404
"No endpoints found" on a pinned OpenRouter endpoint, a 400 on Qwen)
throws an advance-worthy error, and the first advance-worthy failure
sets the partition's sticky override. The user's next main step then
went to the next link, usually the auto-appended local llama-server;
with no local model running it died with "fetch failed", parked in the
outage wait, and the turn failed. The rewriter itself is fire-safe and
folds to the raw query, so the only visible symptom was the turn after.

Run the rewriter under rewriter:<sessionId>. Its trace and metrics stay
on the real session id. Usage metering keys by the same id, so the
rewriter's tokens now fall out of the per-turn message_sent totals the
way reflection, link-gen and vote already do (the global cost
accumulator still counts them).
…easoning models

Reflection, the link-generator and the vote-runner run fire-and-forget
after every turn. Their defaults (10 s for reflection, which the vote
runner reuses, and 8 s for the link-generator) were tuned against a
local llama-server. Hosted reasoning models answer the same requests
far more slowly. Median latency of byte-faithful sub-call requests on
OpenRouter:

  reflection      glm-5.3-flash 15.7 s, qwen3.6-plus 16.3 s,
                  kimi-k2.6 13.9 s, gemini-3.8-flash 4.7 s
  link-generator  kimi-k2.6 37.8 s

In a live 8-turn session glm-5.3-flash reflection timed out on 6 of 8
calls at the 10 s default (25.4 s median, 37.5 s max), so most turns
wrote no memory at all.

Both defaults become 60 s. The timers are unref'd and nothing waits on
the reflection chain, so a longer cap cannot hold a turn or a one-shot
process open.

Config v65 migrates existing installs: every config.json already
carries these fields, written by the schema rather than chosen, so a
pre-v65 file whose value equals the old default takes the new one. Any
other value is a deliberate pin and is kept, as is the old number on a
v65 file. Same shape as the v63 managed.parallel migration. The helper
lives in its own module because config-schema.ts is far past the
per-file line budget.
The agent loop refreshes memory context before the first step and
again after every step, each time with the same user message. The
rewriter decorator asked its runner on every refresh, so one turn sent
the same rewrite request once per step. It sits on the hot path: every
call blocks the step until it answers or times out. Against a hosted
model that is slower than the cap, each step paid the full timeout and
still recalled with the raw message.

Measured on OpenRouter (median, byte-faithful rewriter requests):
gemini-3.8-flash 4.2 s, glm-5.3-flash 4.2 s, kimi-k2.6 23.5 s. In live
8-turn sessions gemini-3.8-flash's rewriter timed out on 16 of 18 calls
at the 3 s default and kimi-k2.6's on 16 of 16.

The decorator now remembers the outcome per session, keyed by the user
message and a digest of the history slice it sent, and reuses it for
every later refresh with the same key, a timed-out or failed attempt
(outcome "use the raw message") included. A slow provider costs a turn
one timeout, not one per step. Only the latest key per session is kept,
for at most 256 sessions. An attempt whose caller's signal aborted is
not remembered: a cancelled turn says nothing about the provider and
must not decide the next turn's identical retry.

With one call per turn the rewriter can afford a realistic cap, so its
default goes 3 s -> 10 s inside the same v65 migration as the reflection
and link-generator timeouts: a pre-v65 file carrying the old 3000 takes
10000, any other value is kept as a pin. It stays well below the
background sub-calls because it still blocks the turn.
…refuses structured outputs

Memory sub-calls (query rewriter, link generator, vote runner, distill)
send OpenAI Structured Outputs. Endpoints without the feature refuse the
request: OpenRouter pinned to Z.AI's endpoint for z-ai/glm-5.3-flash
answers 404 "No endpoints found" because its "Filter by Parameters"
routing step drops that endpoint, and vendor APIs without json_schema
answer 400/422 naming the field. The sub-call then failed on every run
(the rewriter failed 18 of 18 times in a live session), and because every
cloud OpenAiHttpError classifies as transport, each refusal also advanced
the provider fallback chain.

OpenAiProvider.complete now recognises that refusal narrowly and sends
the same body once more without response_format. The prompts still ask
for their text formats and every parser reads them when the reply is not
JSON, so only decode enforcement is lost. The retry happens inside the
provider, so a handled refusal never reaches runWithFallback. The
provider and model pair is remembered for the process once the stripped
send is accepted, later sub-calls skip the field up front, and the first
time logs one warn line.

Not treated as refusals: 401/402/403/429/5xx, timeouts, network failures,
size rejections, and the "messages must contain the word json" 400, which
is a prompt problem on an endpoint that does support the feature.
Streamed requests never carry response_format and are unchanged.
Every memory sub-call wrapper in bootstrap (reflection, link generator,
vote, query rewriter, distill) enforced its runner's timeout by racing
the completion against the abort signal without handing that signal to
llmComplete. The runner gave up while the HTTP request kept running:
still billing on a cloud provider, still holding a llama-server slot.

The five copies are replaced by abortableSubcall, which forwards the
signal into the request and keeps the race as a backstop for a provider
that ignores it. Each wrapper still sends exactly the fields it did.

Forwarding alone was not enough, for two reasons found on the way:

- OpenAI-compatible unary requests stopped listening to the signal once
  headers arrived (openAiFetch unlinks it because it also opens
  streams), so a provider that sends headers before the body could not
  be cancelled. openAiPostJson now reads the body through a reader the
  signal cancels.
- An aborted llama-server request surfaces as a status-null
  LlamaServerError, which classifies as transport and makes
  shouldAdvance switch providers immediately. The unary fallback seam
  now rethrows an aborted request as the signal's reason, so it
  classifies as cancelled and never trips a breaker or flips the
  override, the rule completeStream already applied.
OpenAI Structured Outputs compiles a strict schema before the model
runs and refuses it unless every object lists every property in
`required`. The link-generator and vote schemas left `links` and
`votes` optional for the `none` branch, so every one of those calls on
an OpenAI model answered 400 ("Missing 'links'") and nothing was ever
linked or voted on that provider.

Both keys are now required and the abstain branch carries an empty
array. The parsers already read `none`, and an empty array under
either kind, as none; new tests pin that. A test discovers every
*-response-format.ts under src/ and checks each exported schema
against the strict rules (closed objects, every key required, at every
depth), so the next sub-call schema cannot ship the same mistake. The
rewriter and both distill schemas already complied.
…vider routing

`llm.providers[].providerPreferences` has been parsed and validated
since the config fix that taught the loader about it, but nothing ever
put it on the wire. An operator who pinned OpenRouter's upstream host —
`{ "order": ["z-ai"], "allow_fallbacks": false }` — kept being routed
to whichever host OpenRouter picked, and structured-output sub-calls
the pinned host cannot serve kept "working" because they ran somewhere
else. The same object under `extraBody.provider` was honoured, which is
how the gap was found.

The field is now threaded exactly like `extraBody` and `strictTools`:
entry -> `openrouter` factory -> `OpenAiProvider` -> the request body,
where it becomes the `provider` object on every chat completion the
entry makes: turns and sub-calls, streaming and unary, and
`vision.describe` (routing is where `data_collection`, `only` and
`ignore` live, and those matter most for the operator's images).

Only the `openrouter` factory forwards it. `provider` is OpenRouter's
field, not the OpenAI schema's, and no other kind documents it.

It is set before the `extraBody` merge, so an explicit
`extraBody.provider` — the workaround people already use — still wins.
With no preferences configured the body is byte-identical to before.

Deliberately not sent by the pre-save key check (it probes the cheapest
paid model, which a host pinned for the operator's model may not serve,
and would report a good key as `model_unavailable`), the contract probe
(built from wizard state that carries no entry passthroughs, `extraBody`
included), the catalog fetch (`GET /models`, no body), or OpenRouter
embeddings (a pin chosen for a chat model's hosts would strand an
embedding model).
Alibaba-served Qwen refuses any request with `response_format` unless
the messages contain the word "json" ("'messages' must contain the
word 'json' in some form"), and OpenAI documents the same rule for
JSON mode. The rewriter, vote and link-generator prompts were written
for the llama-server GBNF path and never say it, so on qwen3.6-plus
every one of those calls answered 400.

buildOpenAiChatBody now appends one short instruction when it attaches
`response_format` and the prompt does not already mention JSON. It is
the only place that changes: the llama-server /completion payload is
built elsewhere, so the reflection slot's prompt bytes and KV cache are
untouched; the main agent turn sends no `response_format`, and a
request with `tools` never gets one, so their bodies stay
byte-identical. Tests pin all three.
runWithFallback dropped each failed link's error on advance and rethrew
only the last one. On the common chain [cloud provider, auto-appended
llama-server], a cloud 404 (OpenRouter: "No endpoints found for ...")
followed by a local server that is not running reached the operator as
`Turn failed [transport]: fetch failed`, the trace error row said the
same, and the cloud's own refusal was logged nowhere, not even at debug.

The last link's error is still the one thrown, unmodified: it decides
classification, fallover and the outage wait, and those match on its
class, cause, status and an anchored `fetch failed`, so rewriting its
message would reclassify a bare TypeError as `tool`. The failed links
are kept in a WeakMap beside it instead, found through the cause chain,
which survives the step executor's TransportError re-wrap:

- the TUI, Telegram and Discord failure lines append
  `(after "<id>" failed: <reason>)`; a single-link failure renders
  byte-for-byte as before
- the trace error row carries them as `fallbackFailures` and keeps
  `message` verbatim; `trace show` prints them
- the chain logs every advance at warn with from, to, status and the
  capped reason the switch notice already shows

A turn already on a sticky override never retries the primary, and every
retry of a parked turn is such a call, so the chain also remembers the
primary failure that put the partition there and runWithFallback carries
it onto a failure of the override link. Without that the turn still
ended on a bare `fetch failed` once the five-minute wait ran out.

describeReason and the breaker/partition state move to their own
modules so provider-fallback-chain.ts stays within 300 lines.
Reflection, link generation, voting and the query rewriter run
fire-and-forget after a turn and fail without a word. On hosted
reasoning models, live sessions saw reflection time out 6 of 8 calls,
the rewriter 16 of 16, and the vote runner refuse its schema 8 of 8.
Those outcomes reached logs, metrics and trace rows but never the
operator, and an empty link graph means the consolidator never distils
a lesson, which nobody notices from the chat.

A pure tracker (src/memory/health) counts consecutive timeout/failed
outcomes per session and sub-call. ok, none and skipped reset the
streak; aborted is neutral, because the next turn aborts a stale
reflection by design. At three in a row it returns one warning per
(session, kind) for the runtime's lifetime. The text names the knob:
the per-call timeout key for timeouts, or the sub-call's switch plus
the last failure reason (one line, credential shapes masked, capped)
for failures. It quotes no default values, since those are moving.

Bootstrap feeds the tracker from the existing emitTrace hooks, after
the per-call trace row is written and whether or not the session is
traced, and from the vote runner's run() result, which now carries the
failure reason. The rewriter's trace event carries one too.

The warning is logged at warn and emitted as a memory_health_warning
event on the sub-call's own session: the trace recorder writes a row,
`trace show` prints it, issue reports keep it at errors level without
the reason, and the TUI shows a warn-styled system notice plus a feed
line for the session on screen. Telegram and Discord get nothing,
matching the fallover announcement.
…llback

#417 moved complete()'s body build into a closure handed to
sendWithStructuredOutputFallback; thread this.providerPreferences through
that closure (after strictTools) so unary sub-calls keep OpenRouter
provider routing on both the first send and the prompt-only retry.
#414 appends a JSON instruction to the prompt of any body that carries
response_format; #417's prompt-only retry drops response_format, so its
prompt is the caller's own. The retry test asserted the two bodies were
identical apart from response_format; assert the JSON mention on the
first send and the original prompt on the retry instead. No runtime
change.
…ned ones

The ### profile section had one bound: render every fact sorted by key,
then cut the string at memory.profile.maxTokens. In a long-running store
the cut landed mid-section on every turn, sliced the last fact mid-value
and removed whatever sorted late, pinned consent and security facts
included, with a bare "[truncated]" inside the prompt as the only trace
(#407). profile_facts was also the one memory table without maxEntries.

- Render pinned facts first, then contextual ones (key order inside each
  group), and pack the section a whole line at a time: a line that does
  not fit is skipped, the last line counts what was left out, and
  BuiltPrompt.profileClip carries rendered / dropped / pinnedDropped.
- AgentLoop turns a clip into a warn log and a profile_clipped event
  (trace row, trace show line, runtime_info line in the TUI feed), once
  per session and again only when the number of pinned facts left out
  changes. The total moves with every message because contextual facts
  are keyword-gated, so keying on it would warn most turns. Ephemeral
  fusion-worker turns skip it.
- memory.profile.maxEntries (default 500) caps active unpinned facts. A
  set() past it deletes the lowest-utility unpinned facts (vote_score,
  then updated_at, then id; never the row being written) inside the
  insert transaction, the same delete remove() does, so superseded
  history stays readable. Pinned facts are never counted or evicted.
  Evictions log counts and write a profile_facts_evicted trace row whose
  keys /report strips below the full level.

The key is additive with a default, so the config version stays at 64,
as agent.task, agent.providerWait and agent.conversationMaxPairs did.
…ent stop

When grammy's bot.start() settled without a stop() we asked for, the
channel released its lock and went `down` for good. The process kept
running and looked healthy while every Telegram message went unanswered
until someone restarted it. The Discord gateway in the same codebase has
always reconnected.

An unexpected stop now arms one unref'd retry of start() on the Discord
gateway's full-jitter backoff. The backoff moves to
src/channels/reconnect-backoff.ts and is re-exported from the Discord
transport, so its import path, behaviour and tests are unchanged. While
waiting, the channel reports `down` with "polling stopped: <reason> —
reconnecting in Ns (attempt n)": a new ChannelState would ripple through
the Integrations hub, the setup panel, the status lines and the sidecar
for no gain. Each attempt is a warn line with the scrubbed reason, so
`serve` leaves a post-mortem trail on stderr.

No retry can fix a Bot API 401 (revoked token), 404 (malformed token) or
409 (another process polls the bot, and retrying would fight it), nor a
lock another process holds. Those still end `down` with the plain reason.
The attempt count starts over only after a full 30 s long-poll round of
`up`, so a stop that recurs right after every start backs off to the cap
instead of spinning. A first start() that fails stays `down`, as before.

stop() disarms the timer and bumps a generation that a start() still
awaiting Telegram checks before committing, so neither a waiting nor an
in-flight retry can resurrect a stopped channel. Any other start()
supersedes the waiting retry; restart() and setToken() treat a waiting
channel as running. An unexpected stop also drops the per-session
approval bindings: they pointed at the dead bot's bridge and would have
posted keyboards whose clicks the reconnected bot ignores.

Refs #409: the reconnect covers every non-fatal loop exit; a 409/401 exit stays terminal by
design and is now logged with its reason, so the issue stays open until that reason is confirmed.
… drop pinned ones

# Conflicts:
#	src/cli/trace-formatter.test.ts
#	src/cli/trace-formatter.ts
#	src/tui/issue-report/trace-redaction.ts
@plombeer31
plombeer31 merged commit 24333a9 into main Sep 13, 2026
7 checks passed
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.

[enhancement] [memory] profile_facts has no entry cap or eviction — unbounded growth silently truncates "### profile" and drops pinned facts

1 participant