Skip to content

feat(wiki): harden the maintainer/writer loop for long runs - #23

Merged
dimknaf merged 10 commits into
mainfrom
feat/wiki-pipeline-hardening
Sep 12, 2026
Merged

dimknaf merged 10 commits into
mainfrom
feat/wiki-pipeline-hardening

Conversation

@dimknaf

@dimknaf dimknaf commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Changes

  • The section tools gained the shapes the work actually has: append, and paged reads so the model can see the whole of what it is asked to preserve.
  • One shared citation predicate replaces three copies, so the writer's tool and the router's gate can no longer disagree about whether a job is done.
  • Snapshots moved to claim time, so an interrupted run is always reversible; jobs orphaned by a restart return to the queue on startup; jobs past their lease fail and re-enter triage instead of wedging.
  • The page header became an editable section rather than being frozen after the first write.
  • Both agents can now see page sizes before choosing what to do with a page.
  • A wiki-scoped reasoning-effort knob, blank by default, self-hosted only. It is deliberately not threaded into the shared general agent — extraction is the most reasoning-dependent work in the stack — and a test fails if anyone does.

Everything here is plumbing and bookkeeping. Nothing added judges, gates or authors content; that stays with the model.

Validation

Suite 126 -> 230 passing, including two new DB-backed test files. Each fix targets a class of failure observed across repeated audits of real agent loops, not a single page or one model's quirk. The reasoning-effort delivery route was verified through the full provider stack rather than against the server alone — an earlier attempt passed a direct probe and still failed in production, which is how the wrong route got shipped and reverted once.

Also in this release

  • AGENT_REQUEST_TIMEOUT and AGENT_WIKI_REASONING_EFFORT documented and passed through compose.

dimknaf added 10 commits August 21, 2026 09:16
…ed long writes

LiteLLM's `request_timeout` defaults to the sentinel 6000, and chat
`completion()` maps that sentinel down to COMPLETION_HTTP_FALLBACK_SECONDS
(600) whenever the caller passes no explicit timeout. We passed none, so
every LLM call inherited a 600s client deadline.

Ample for recall, but a wiki writer regenerating a 50k-char body on a
self-hosted 27B exceeds it: LiteLLM times out, retries twice (3 x 600 =
1800s), and the scheduler logs `maintain: failed` on a 30-minute cadence
while vLLM is still completing the request. The work is computed and then
discarded — vLLM reported 271 requests that began prefill with no finish
and zero aborts.

Fixed at the BrainDB layer rather than via the REQUEST_TIMEOUT env var,
which is global and has a trap: setting it to exactly 6000 is a no-op
because that IS the sentinel. `ModelSettings.extra_args` is forwarded
verbatim into the LiteLLM call, so `timeout` resolves ahead of the
fallback.

`extra_args` is a transport deadline only — it never enters the request
body and cannot steer the model, unlike `output_type` / `tool_choice`,
which stay unset deliberately (see the module docstring).

Hosted providers finish far inside 4800s and are unaffected; the value is
a ceiling, not a delay.
Neither agent could see how big a wiki was. The maintainer's catalog was
{id, canonical_name}, so every candidate target looked equally empty and
`attach` was always a reasonable answer. The writer's prompt named only its
own page, so "keep this dense, that detail belongs elsewhere" was not
expressible -- there was no elsewhere it could name.

Add size, and give the writer a short list of neighbouring pages:

- list_active_wikis(): + length(e.content) AS char_count, so each catalog
  entry carries its current size.
- list_related_wikis(): wikis one hop from the members being written,
  bounded by LIMIT. Deliberately NOT the full catalog -- the writer only
  needs to know which neighbours exist, and the catalog grows without bound.
  Returns names and sizes only, never ids: the writer must not hold a peer
  id it could cite, since reconcile_summarises_additive() would then mint a
  wiki->wiki relation.
- Skipped entirely in consolidate mode, where entity_ids holds the DUPLICATE
  WIKI ids -- listing them would hand the writer the pages it must absorb
  and invite it to link out instead of merging.
- Writer prompt gains %%RELATED_WIKIS%%: name a neighbour in prose rather
  than restating it; do not open one; if none fits, leave the detail out and
  it returns on its own later.
- Density nudge folded into the existing _body_block_or_stub() above 30000
  chars -- advisory text on the stub it already returns, no new gate.
- Maintainer step 4 gains a size exception: when the target is already very
  large AND the seed is a coherent narrower subject, prefer `create`. Step 3
  gains the matching carve-out so a deliberately narrow page is not treated
  as a fragment and consolidated straight back.

Triage, routing, claiming and the incremental one-entity-at-a-time flow are
unchanged. No new entity types, relation types, tools, schema fields, job
types or migrations.
A budget set far above where it can fire silently disables the
successor path: on large pages the writer keeps accumulating context
until it hits its turn limit instead of handing off to a fresh
successor. 30000 keeps handoff live without firing on routine work.
Tool results arrive as `function_call_output` items, which keep their
payload under `output`, not `content`. The estimate read only `content`,
so the items that dominate a writer's context contributed zero and the
handoff nudge never fired at any budget, leaving long writes to grow
unbounded. Lookup only; the summing logic already handled both shapes
`output` can take.
A 70h bench run spent 58h (83%) on ONE page whose work was already done:
all 39 members of all 39 stuck jobs were already cited. `summarises` had
been frozen at 114 for three days while the body grew 44K -> 73.5K, and
`writer no-op accepted` -- the exit that closes those jobs -- had fired
zero times.

Root cause: an append-shaped job forced through a replace-shaped tool.
`edit_wiki_section` appended only when the section NAME was new; for an
existing section it always replaced, so adding one citation to a 23,463
char `references` section meant re-emitting all of it. Reads are capped at
MAX_OUTPUT_CHARS (8000) while writes are uncapped, so the writer was
ordered to preserve a section it could only see a third of. Its only escape
was to rebuild the section from the body's [[ref:UUID]] tokens, one
get_entity per citation. Subagents, told by the docstring they had "all the
same BrainDB tools" but given none of the wiki tools, retyped whole bodies
through update_entity instead -- flipping one hex digit of a cited UUID,
after which reconcile raised a FK violation, `finish_jobs` (the next line)
never ran, and every job re-queued forever, uncapped.

Changes, all plumbing -- nothing judges, gates or authors content (C2/C3):

- reconcile_summarises_additive skips a cited id with no entity row and
  reports it, instead of raising. Content can no longer block bookkeeping.
  Still additive-only; skipped ids reach the activity log via the caller's
  existing `**rel` spread, so it is visible, not silent.
- wiki_sections.append_to_section: add to a section keeping what is there
  byte-for-byte, resolved from the body we already hold. Reuses
  parse_sections + splice_section. Surfaced as edit_wiki_section(mode=).
- read_wiki_section takes offset/limit and pages via the existing
  slice_content helper, with the same content_meta/next_offset contract
  get_entity uses. No longer silently _truncate'd -- hiding that content
  exists past the cap is how a "preserve everything" replace loses data.
- check_members_cited: the exact computation the router already runs, as a
  tool, so the writer can answer "is there any work left?" in one call
  rather than thirty turns of re-reading.
- update_entity refuses a wiki body, generalising the existing datasource
  guard, and points at the section tools.
- Subagents get the wiki READ tools; the docstring and system prompt now
  say what they actually have. Edit/delete stay writer-only so one writer
  per wiki keeps the revision CAS meaningful.
- _claimable() bounds the lease-reclaim branch by attempts (pending stays
  unconditional), and the lease goes 20 -> 120 min: its stated premise,
  "AGENT_TIMEOUT ~10 min", expired three bumps ago.
- The handoff successor is seeded with the rendered job prompt as well as
  the brief, making "You have the SAME prompt" true -- it was running on
  the generic system prompt with no members and no preservation rule.
- The token estimator counts function_call `arguments`, the largest term in
  a writer's context and previously invisible. Budget stays 30000.
- Writer prompt: "leave that detail out" is scoped to non-members (for a
  MEMBER it is false -- a dropped member is recorded as covered and never
  returns), and the section protocol now leads with check_members_cited
  and append.

Tests: 126 -> 159 passing, no regressions. New coverage pins the
properties that failed live -- append preserves an >8000-char section
intact, the reclaim ceiling bounds only the reclaim branch, a dangling ref
does not block the good refs, the subagent has read tools and no write
tools, and delegation depth is isolated between concurrent runs.
Round two on the writer-loop fixes: the post-fix audit (and the user)
found 90acc76 damaged two core principles. This restores both and makes
the tests able to catch a revert.

Self-healing (code):
- A job past the lease AND the reclaim ceiling was un-claimable and never
  dispositioned -- it wedged in `assigned` forever, and since `assigned`
  counts as an active job in _orphan_conditions(), its entity was
  permanently barred from re-triage: silent, unbounded loss. run_cron now
  flips such rows to `failed` -- the status that, by design, returns the
  entity to the orphan pool -- and re-enqueues a fresh triage job in the
  SAME tick. Count reported as `assigned_expired_failed` in the cron
  activity log. Bounded per job, self-healing per entity.
- ASSIGNED_LEASE_MIN comment rewritten as an operator invariant (lease >
  your WIKI_AGENT_TIMEOUT / agent_request_timeout) instead of a number the
  bench overlay (18000s) falsifies.

One citation predicate (code):
- New wiki_jobs.uncited_members(conn, body, member_ids) -> (missing, gone)
  built on the existing parse_refs + liveness SELECT. The router's no-op
  gate and the check_members_cited tool both call it, so they can never
  drift; a member DELETED since triage is reported `gone` and no longer
  wedges its job in a fail/retry loop (same premise as reconcile's
  dangling-ref skip). Three private copies of the arithmetic collapse to
  one.

The LLM's editing judgement (prompts/docstrings -- the quality fix):
- 90acc76's wording made append "the normal way", said "you do NOT need to
  read the section first", and forbade rebuilding. Measured live: 26
  append / 1 replace, ref-stacking collapsed 33-40% -> 10%, filler
  sentences 3x, and a section narrating an accepted offer then appending
  "the user is applying". All three directives removed. New protocol:
  choose by CONTENT -- corroborating member -> revise the sentence and
  stack the citation; genuinely new -> append; changed story -> rewrite
  freely (the snapshot makes it reversible). Coherence rule added; the
  UUID-retyping warning moved to where it belongs (full rewrites).
- system_prompt/docstrings: the subagent "cannot write" claim corrected --
  it has no WIKI write tools; saves/relations/update_entity remain.
- "byte for byte" corrected to the true guarantee (content preserved;
  trailing blank lines collapse to one; markers re-emitted canonically).

Integrity guards (code):
- update_entity: content="" is now a warned no-op instead of silent
  destruction (observed wiping a thought cited inside a wiki); real
  rewrites and delete_entity untouched.
- edit_wiki_section activity-log key "appended" -> "created" (was
  contradictory beside the new "mode" field).

Tests that fail on a revert:
- tests/test_wiki_selfheal_db.py (new, DB-backed): the self-heal loop
  end-to-end (wedged job -> failed -> fresh triage in one cron tick);
  claim_jobs honours the ceiling behaviourally; update_entity exercised
  through the REAL tool via on_invoke_tool (wiki blocked + DB unchanged,
  fact updated, wiki notes/importance applied, ""-wipe warned);
  uncited_members split + tool `gone` reporting.
- test_wiki_writer_guards.py reworked: toolsets asserted on the BUILT
  agents (dropping extra_tools= now fails -- verified by actually
  reverting it: 1 failed, then restored: green); delegation depth proven
  behaviourally (two CONCURRENT delegations proceed, max_active==2, while
  a NESTED one is refused -- a process-global counter fails this test);
  the vacuous _router_missing copies deleted.
- CI allow-list extended with the four wiki test files (the workflow's
  Postgres service + alembic schema already support the DB-backed ones).

Suite: 213 passed, 0 failed (LLM-provider smoke test excluded locally).
…restart self-clean

Every fix targets a CLASS observed live across three audits of real agent
loops; nothing is keyed to one page, one model's typo, or bench timing.
No new gates, no content-shaping code (C2/C3 hold).

A. Reversibility + self-healing
- Snapshots move to CLAIM time (pure move — content was always the
  claim-time body; only the write was deferred to persist). A run that
  dies after a section edit now always has a durable pre-run snapshot.
  Prompt reworded to the true promise: reversible to the run's starting
  revision. Failed/no-op runs leave a truthful "claimed at rev N" row.
- release_stale_assigned(): on API startup, jobs still `assigned` from
  before the process existed return to `pending` (agents run only
  in-process; scheduler/watcher are HTTP clients — the invariant is
  documented, incl. where it breaks: multi-worker). Observed cost of not
  having this: a restart-orphaned consolidate dark for hours while
  writers re-derived the identity it would have settled.
- Crashed maintainer/writer runs and the handoff-depth cap now write
  activity rows mirroring their success shapes — a DB-only auditor no
  longer sees a suspiciously clean run.

B. The header becomes editable (the quality centrepiece)
- Reserved section name "header" on the EXISTING tools — no new tool:
  wiki_sections.replace_header (3-line reuse of parse_sections+_rebuild),
  read/edit dispatch, delete refused, outline surfaces "header: Nch".
  Root cause closed: on any page >4000ch the Summary/Disambiguation/meta
  froze at creation (observed: "is applying" against a recorded
  acceptance; friend labelled "son"; revision=1 on rev-41 pages).
  Persist propagation needed zero plumbing — the section path already
  falls through to extract_summary_disambig + finalize_wiki_write.
- check_grammar gains one advisory check (missing wiki:meta line) — the
  real risk of a header replace is silently zeroing keywords_from_meta.
- `revision=` dropped from the prompt's meta template (verified: no
  consumer anywhere) — the perpetual stale token dies with zero code.

C. Idempotency
- create dedupe keys on lower(proposed_name): the 65-second same-name
  double-create (reproduced three runs in a row) now collapses; the
  second orphan re-enters the pool via cron and attaches to the page the
  first run built. No canonical_name uniqueness added (that would be
  code gating LLM naming).

D. Honesty + judgement alignment
- Router density nudge member-scoped (a writer obeying the old text was
  rejected by our own uncited_members gate).
- Successor seed: trust the brief for STATE, still read any section you
  replace; body="" restated as ATTACH-only; "choose by content" echoed.
- References ledger: scoped compaction carve-out reconciled with the
  superset rule; the "append is preferred" leftover removed.
- One uniform wiki-not-found message across all six tools with the
  general recovery ("copy the id exactly"); search_sql docstring gains
  the real schema + PG dialect (6 dead queries observed from guessing);
  dead used_section_edits removed; stale "gated body" docstring fixed.

E. Observability
- Per-run log tag (read-downward ContextVar, same pattern as the
  delegation depth) on the "Running typed query" line and every TOOL
  line — concurrent runs no longer need argument-fingerprint forensics.
- VERBOSE_PREVIEW_CHARS=1500 replaces the three bare [:500]s.

Tests: 230 passed (was 213). New coverage is behavioural: header
replace via the real tool (only the header changes, CAS holds,
extract_summary_disambig reads the new values), append/delete on header
refused, uniform not-found across all six tools, restart release honours
the cutoff and preserves attempts, name-keyed create dedupe collapses
then re-admits after completion, and two router-level tests that
monkeypatch run_typed to die: the claim-time snapshot already exists
when the writer crashes, and both crash paths leave activity rows.
Revert spot-check performed (header dispatch removed -> test failed ->
restored).
The Qwen3 chat template resolves `reasoning_effort|default('xhigh')`, so
sending nothing runs EVERY request at the model's maximum reasoning
setting and injects a "think carefully, validate assumptions, consider
alternatives" instruction into each system block. Meanwhile the SDK only
replays reasoning for DeepSeek/Claude models (reasoning_content_replay.py
returns False otherwise), so on Qwen every reasoning token is generated,
paid for, and dropped before the next turn — it buys no cross-turn
consistency.

Measured on the bench box: ~2750 output tokens/turn, GPU generating in
99.7% of samples, sum of LLM gaps = 99.3% of wall clock, tool bodies at
0.01-0.06s. Wall clock is ~entirely generation, so trimming reasoning
trims latency almost linearly. Probe against the live server, same prompt
and tools: default 66 completion tokens vs 45 with effort=low, both
returning a valid tool call.

Adds nothing new — every mechanism already existed:
- `agent_wiki_reasoning_effort: str = ""` beside the other agent_* knobs
  (agent_writer_handoff_* is the precedent for a wiki-scoped model knob).
  Blank = send nothing = byte-identical behaviour; verified no-op.
- One keyword arg through _build/_cached, exactly the shape extra_tools
  and extra_stop_tools already use, adding one key to the extra_args dict
  that already carries `timeout`. LiteLLM folds unknown kwargs into
  extra_body, so it reaches an OpenAI-compatible server and is ignored by
  one that doesn't know it.
- Passed by the maintainer, writer and subagent only.

NOT by get_agent(): that cache key is shared by /agent/query AND the
ingest watcher, whose extraction runs (max_turns 40/30) are the most
reasoning-dependent work in the stack. A test asserts that scoping and
fails if anyone later threads it there.

Valid values for this template are low/medium/none — minimal/high pass
vLLM's Literal but raise in the template's own validator.

Suite: 160 DB-free passed (incl. 2 new scoping tests); the 70 errors are
the DB-backed files with no local Postgres running — 160+70 = 230, the
same total as before.
The previous route, ModelSettings(extra_args={"reasoning_effort": ...}),
raised litellm.UnsupportedParamsError on every call and failed 61 triage
jobs live before it was reverted.

Root cause is a layer above LiteLLM: the SDK's LitellmModel lifts the key
reasoning_effort out of reasoning / extra_body / extra_args and promotes it
to a top-level kwarg on litellm.acompletion(), popping it back out of
extra_body so it cannot also ride in the body. LiteLLM then validates that
kwarg against a per-provider allow-list; the openai provider, which every
OpenAI-compatible profile resolves to, does not list it, so the call raises
before a request is ever sent. All three of the SDK's documented routes
converge on that one rejected kwarg.

Nesting the value under chat_template_kwargs avoids the interception
entirely: the SDK only special-cases the literal top-level key, so the dict
is copied verbatim into LiteLLM's extra_body and forwarded into the request
JSON unfiltered - which is where vLLM reads chat-template variables, and
reasoning_effort is one (chat_template.jinja resolves it with default
'xhigh', so sending nothing runs at the maximum setting).

Verified through the full stack rather than against vLLM directly - probing
vLLM alone is what hid the bug last time. Same prompt, same profile:
reasoning drops 155 -> 44 chars and completion_tokens 55 -> 42, with the
answer unchanged.

Scoped to the wiki agents; the general agent, shared with the ingest
watcher, is untouched. Blank default keeps this inert, and the body key is
self-hosted vLLM only - a hosted provider may reject it. Profiles and model
resolution are not touched.

Adds a regression test pinning that reasoning_effort appears at neither top
level, so the outage cannot recur silently.
Nine commits that take the wiki maintainer/writer loop from "works if you
watch it" to "runs unattended for days": the writer loop always terminates,
jobs past their lease re-enter triage instead of wedging, an interrupted run
is always reversible, and the page header is editable rather than frozen
after the first write. Plus a wiki-scoped reasoning-effort knob and a
per-call LLM timeout so slow self-hosted writes are no longer abandoned
while the server is still completing them.

Release bookkeeping in this commit:

- Version 0.9.0 -> 0.10.0 in pyproject.toml and braindb/main.py (the latter
  was missed in the 0.3.0-0.6.0 releases; both are bumped here).
- CHANGELOG section for 0.10.0.
- `vllm_workstation_qwen` pointed at the Qwen model and port the wiki
  pipeline's tuning constants were actually measured against, so selecting
  it needs no AGENT_MODEL override. `deepinfra` remains the default profile
  in config.py, .env.example and docker-compose.yml — no existing setup
  changes.
- .env.example: the profile list claimed four profiles when there are seven;
  corrected, and AGENT_REQUEST_TIMEOUT documented for the first time.
- docker-compose.yml: env passthrough for AGENT_REQUEST_TIMEOUT and
  AGENT_WIKI_REASONING_EFFORT, per the recipe in CONTRIBUTING.md.
- .gitignore: integrations/*/.state/ (per-caller transcripts written at run
  time), plus *.log, *.bak, *.sql plus a !scripts/*.sql negation, and
  backups/ — local operational artefacts that are one `git add .` away from
  a public repo.

No DB migration. No public response field renamed. Both new knobs default to
the previous behaviour.
@dimknaf
dimknaf merged commit 99cd121 into main Sep 12, 2026
1 check 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.

1 participant