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
29 changes: 27 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ FRONTEND_PORT=8642
HF_TOKEN=

# LLM provider profile — which LiteLLM backend the agent talks to.
# Options are defined in braindb/config.py::_LLM_PROFILES
# (currently: nim, deepinfra, openai_compatible, vllm_workstation).
# Options are defined in braindb/config.py::_LLM_PROFILES — currently:
# hosted : nim, deepinfra
# bring-your-own: openai_compatible (set OPENAI_BASE_URL + AGENT_MODEL)
# self-hosted : vllm_workstation, vllm_workstation_qwen,
# vllm_workstation_gemma, vllm_workstation_gemma12b
# The vllm_* profiles carry a fixed host/port; check they match your server.
LLM_PROFILE=deepinfra

# Provider API keys — fill in whichever profile you're using.
Expand Down Expand Up @@ -98,6 +102,27 @@ AGENT_VERBOSE=false
# Layer 4 retry path).
# AGENT_COUNTDOWN_THRESHOLD=8

# Per-LLM-call HTTP deadline in seconds, default 4800 (80 min). Passed to
# LiteLLM as `timeout=`. Without an explicit value LiteLLM falls back to
# 600s, which is long enough for hosted providers but NOT for a self-hosted
# quantised 27B doing a full wiki write — the client abandoned a request the
# server was still completing, so the work was computed and thrown away.
# This is a ceiling, not a delay: fast providers finish far inside it and are
# unaffected. NOTE: setting this to exactly 6000 is a no-op (that value is
# LiteLLM's own sentinel).
# AGENT_REQUEST_TIMEOUT=4800

# Reasoning effort for the WIKI agents only (maintainer / writer /
# subagent). Blank = send nothing = the server's own default. On the Qwen3
# chat template that default is 'xhigh' (its maximum), so setting this to
# 'low' is a large latency win; the SDK discards reasoning between turns on
# non-DeepSeek/Claude models, so nothing is lost across turns. Valid on that
# template: low | medium | none. NOT minimal/high — the template raises.
# The general agent (/agent/query + ingest watcher) is deliberately unaffected.
# SELF-HOSTED vLLM ONLY — it rides in the body as chat_template_kwargs, which
# a hosted provider may reject. Leave blank on the deepinfra/nim profiles.
# AGENT_WIKI_REASONING_EFFORT=

# Ingest watcher poll interval (seconds) — how often the watcher sidecar
# scans data/sources/ for new files to ingest.
INGEST_POLL_INTERVAL=7
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,19 @@ jobs:
- name: Install pytest into the api container
run: docker exec braindb_api pip install pytest pytest-asyncio --quiet

- name: Run validator + handoff unit tests
- name: Run validator + handoff + wiki-writer guard tests
# The wiki files below are the revert-detectors for the writer-loop
# fixes: sections/guards are DB-free; selfheal + reconcile_dangling
# use the workflow's Postgres service via the container's
# DATABASE_URL (schema comes from the api's alembic upgrade).
run: |
docker exec braindb_api python -m pytest \
tests/test_final_answer_rename.py \
tests/test_handoff_hooks.py \
tests/test_wiki_sections.py \
tests/test_wiki_writer_guards.py \
tests/test_wiki_reconcile_dangling.py \
tests/test_wiki_selfheal_db.py \
-v

- name: Dump api logs on failure
Expand Down
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ data_bench/
# Test stack (docker-compose.test.yml): separate host data dir for test ingests
data_test/

# Integration runtime state — per-caller conversation transcripts and other
# local state written by integrations at run time. Never belongs in the repo.
integrations/*/.state/

# Local operational artefacts. DB dumps, logs and editor backups have no place
# in a public repo and are easy to stage by accident with `git add .`.
*.log
*.bak
*.sql
!scripts/*.sql
backups/

# Hermes sandbox (integrations/hermes/sandbox): throwaway agent profile dir —
# holds a .env with the LLM key + provider state; never commit it.
integrations/hermes/sandbox/hermes-data/
Expand Down
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,75 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.10.0] — 2026-09-12

Headline: **the wiki pipeline is now dependable under long, unattended runs.** The writer loop
always terminates, crashed and restarted jobs recover on their own, and the page header is editable
instead of frozen after the first write. Also in this release: a reasoning-effort knob for the wiki
agents that cuts latency several-fold on self-hosted models, a per-call LLM timeout so slow local
writes are no longer abandoned mid-flight, and both agents can now see how big a page is before
deciding what to do with it.

### Added

- **Append mode and paging for the wiki section tools.** `edit_wiki_section` gains `mode="append"`,
and `read_wiki_section` takes `offset`/`limit`. Reads were capped well below the uncapped write
size, so a writer could be asked to preserve a section it was only ever shown part of — an
append-shaped job forced through a replace-shaped tool. The model now picks the edit that fits the
content rather than the one the tool allows.
- **`check_members_cited`.** One shared predicate answering "is this member cited yet?", used by
both the writer's tool and the router's gate, so the two cannot disagree. Three copies of that
logic previously existed and had drifted.
- **The page header is an editable section.** Meta line, title, Summary and Disambiguation were
effectively frozen once a page grew past the inline-body limit. They are now readable and
replaceable through the existing section tools, so a page whose story changes can have its opening
changed too.
- **`AGENT_WIKI_REASONING_EFFORT`.** Wiki agents only, blank by default. Some chat templates default
reasoning to their maximum when no value is sent, and the SDK discards that reasoning between
turns on most model families — so it is generated, paid for, and dropped. Self-hosted vLLM only;
leave blank on hosted providers.
- **`AGENT_REQUEST_TIMEOUT`.** The per-call transport deadline, default 4800s.
- **Size awareness for both agents.** The maintainer sees each page's size in its catalog; the
writer sees neighbouring page names and sizes. Neither could previously tell a large page from an
empty one, so every candidate target looked equally reasonable.

### Changed

- **Writer handoff budget raised 20000 -> 30000.** A budget set where it cannot fire silently
disables the successor path, leaving long writes to grow until they hit the turn limit instead of
handing off to a fresh successor.
- **Job lease raised 20 -> 120 min, with a bounded reclaim ceiling.** A long write is no longer
mistaken for an abandoned one.
- **`vllm_workstation_qwen` profile** now points at the Qwen model and port the wiki pipeline is
actually tuned against, so selecting it needs no `AGENT_MODEL` override. `deepinfra` remains the
default profile.

### Fixed

- **The writer loop now terminates.** A long unattended run spent most of its time on a single page
whose work was already complete — every member was already cited, yet the job kept being re-run.
Cause was the tool mismatch above plus a reconcile step that raised on a stale reference instead
of skipping it, aborting the very transaction that would have closed the job.
- **Self-healing restored.** Jobs past their lease and reclaim ceiling now fail and re-enter triage
in the same sweep instead of wedging indefinitely. An entity can no longer be silently lost.
- **Crashes and restarts are recoverable.** The pre-write snapshot is taken when the job is claimed
rather than after the model runs, so an interrupted run is always reversible; jobs orphaned by a
restart are returned to the queue on startup.
- **Token estimate counts tool results.** It read only message content, missing the tool-result
payloads that dominate a writer's context — so the handoff nudge never fired at any budget.
- **Per-call LLM timeout.** LiteLLM's own 600s client fallback was abandoning self-hosted writes the
server was still completing, so the work was computed and discarded. Hosted providers finish well
inside the new ceiling and are unaffected — it is a ceiling, not a delay.
- **Duplicate page creation.** Two create jobs for the same proposed name within the same window now
collapse to one.
- **`update_entity` no longer overwrites wiki bodies**, and a blank body is a warned no-op rather
than a silent wipe. Subagents gained the wiki READ tools so they no longer fall back to retyping a
body they can only partly see.

### Upgrading from v0.9.0

No DB migration and no required env changes. Both new knobs default to the previous behaviour.

## [0.9.0] — 2026-06-26

Headline: **custom profiles** — opt-in, self-contained overlays that reshape what BrainDB ingests
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ When debugging the agent: set `AGENT_VERBOSE=true` in `.env` and watch `docker l

## Important Notes

- `.env` contains real DB credentials and provider API keys (`DEEPINFRA_API_KEY`, `NVIDIA_NIM_API_KEY`, etc.) — **never commit it**, it is in `.gitignore`. Active provider is picked by `LLM_PROFILE` (see `braindb/config.py::_LLM_PROFILES`). `LLM_PROFILE=deepinfra` (model `google/gemma-4-31B-it`) is the recommended starting point — fast, cheap, validated end-to-end; the `vllm_*` profiles are for advanced/offline use and need a workstation GPU + SSH tunnel.
- `.env` contains real DB credentials and provider API keys (`DEEPINFRA_API_KEY`, `NVIDIA_NIM_API_KEY`, etc.) — **never commit it**, it is in `.gitignore`. Active provider is picked by `LLM_PROFILE` (see `braindb/config.py::_LLM_PROFILES`). `LLM_PROFILE=deepinfra` (model `google/gemma-4-31B-it`) is the recommended starting point — fast, cheap, validated end-to-end; the `vllm_*` profiles need a GPU serving an OpenAI-compatible endpoint reachable from the docker network. Note the wiki pipeline's tuning constants (turn budget, request timeout, writer handoff budget, `AGENT_WIKI_REASONING_EFFORT`) were measured against self-hosted Qwen on vLLM — `vllm_workstation_qwen` is that profile.
- Always-on rules (priority 100, `always_on: true`) are returned on every `/memory/context` call
- `notes` field on any entity or relation is for running commentary — append observations over time
- Keywords are stored as both a `TEXT[]` column on the entity AND as separate keyword entities linked via `tagged_with` relations (the keyword entities carry the embeddings for semantic search)
Expand Down
80 changes: 75 additions & 5 deletions braindb/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"""
import json
import logging
from uuid import uuid4
from pathlib import Path
from typing import TypeVar

Expand All @@ -31,14 +32,21 @@
from pydantic import BaseModel

from braindb.agent.hooks import CountdownHooks
from braindb.agent.run_state import install_slot, release_slot
from braindb.agent.run_state import (
get_run_tag,
install_slot,
release_slot,
reset_run_tag,
set_run_tag,
)
from braindb.agent.schemas import (
AgentAnswer,
MaintainerDecision,
SubagentResult,
WikiWriteResult,
)
from braindb.agent.tools import (
check_members_cited,
create_relation,
delegate_to_subagent,
delete_entity,
Expand Down Expand Up @@ -169,6 +177,7 @@ def _build(
submit_tool,
extra_tools: tuple = (),
extra_stop_tools: tuple[str, ...] = (),
reasoning_effort: str = "",
) -> Agent:
"""Build an agent. NOTE: no `output_type` — see module docstring. The
structured contract lives on `submit_tool`'s argument schema, not on
Expand All @@ -181,13 +190,43 @@ def _build(
`extra_stop_tools` adds extra stop-tool names beyond `final_answer`.
The writer adds `handoff_to_successor` here so the run halts cleanly
when handoff is called instead of continuing wastefully.

`reasoning_effort` (blank = send nothing) is passed only by the wiki
agents; see `settings.agent_wiki_reasoning_effort`.
"""
set_tracing_disabled(disabled=True)
# `extra_args` is forwarded verbatim into the LiteLLM call. `timeout`
# lands as `kwargs["timeout"]` — which LiteLLM resolves ahead of its
# 600s fallback; a TRANSPORT deadline only, it never enters the request
# body and cannot steer the model, unlike `output_type` / `tool_choice`
# (see the module docstring — those stay unset deliberately). Without it
# a long wiki write is abandoned client-side at 600s while the server is
# still working.
#
# `reasoning_effort` belongs in the request BODY, but it cannot travel as
# a plain kwarg: the SDK's LitellmModel lifts that exact key out of
# `reasoning` / `extra_body` / `extra_args` and promotes it to a top-level
# `reasoning_effort=` argument on `litellm.acompletion()`, where LiteLLM
# checks it against a PER-PROVIDER allow-list — `openai` (which every
# OpenAI-compatible profile resolves to) does not list it, so the call
# raises `UnsupportedParamsError` before any request is sent. Nesting it
# under `chat_template_kwargs` sidesteps that: the SDK only intercepts the
# literal top-level key, so the dict is copied through into LiteLLM's
# `extra_body` and forwarded into the JSON body unfiltered, which is where
# vLLM reads chat-template variables from. VLLM-SPECIFIC by nature — a
# hosted provider may reject the unknown body key, so this stays blank
# unless an operator opts in on a self-hosted profile.
extra_args = {"timeout": settings.agent_request_timeout}
extra_body = (
{"chat_template_kwargs": {"reasoning_effort": reasoning_effort}}
if reasoning_effort else None
)
agent = Agent(
name=name,
instructions=SYSTEM_PROMPT,
model=_model(),
model_settings=ModelSettings(),
model_settings=ModelSettings(extra_args=extra_args,
extra_body=extra_body),
tools=[*_BASE_TOOLS, *extra_tools, submit_tool],
tool_use_behavior=StopAtTools(
stop_at_tool_names=["final_answer", *extra_stop_tools],
Expand All @@ -209,13 +248,15 @@ def _cached(
submit_tool,
extra_tools: tuple = (),
extra_stop_tools: tuple[str, ...] = (),
reasoning_effort: str = "",
) -> Agent:
a = _cache.get(key)
if a is None:
a = _build(
name, submit_tool,
extra_tools=extra_tools,
extra_stop_tools=extra_stop_tools,
reasoning_effort=reasoning_effort,
)
_cache[key] = a
return a
Expand All @@ -230,33 +271,56 @@ def _cached(
_WRITER_EXTRA_TOOLS = (
read_wiki_outline,
read_wiki_section,
check_members_cited,
edit_wiki_section,
delete_wiki_section,
validate_wiki,
handoff_to_successor,
)
_WRITER_EXTRA_STOP_TOOLS = ("handoff_to_successor",)

# Subagent extras: the wiki READ tools, and nothing that writes. A writer
# routinely delegates "check/read this page" work, and without these the
# subagent could not do it the safe way — it fell back to paging the raw body
# and re-emitting it through `update_entity`, which is both enormously
# expensive and how a cited UUID gets corrupted. Giving it the read tools
# removes the reason to do that. Edit/delete stay writer-only on purpose:
# one writer per wiki keeps the revision CAS meaningful, and a subagent
# cannot hand off, so it has no business holding a revision token.
_SUBAGENT_EXTRA_TOOLS = (
read_wiki_outline,
read_wiki_section,
check_members_cited,
validate_wiki,
)


def get_agent() -> Agent:
"""Default agent: general recall/save (public /agent/query)."""
return _cached("answer", "BrainDB Memory Agent", submit_answer)


def get_maintainer_agent() -> Agent:
return _cached("maintainer", "BrainDB Wiki Maintainer", submit_maintainer)
return _cached("maintainer", "BrainDB Wiki Maintainer", submit_maintainer,
reasoning_effort=settings.agent_wiki_reasoning_effort)


def get_writer_agent() -> Agent:
return _cached(
"writer", "BrainDB Wiki Writer", submit_wiki,
extra_tools=_WRITER_EXTRA_TOOLS,
extra_stop_tools=_WRITER_EXTRA_STOP_TOOLS,
reasoning_effort=settings.agent_wiki_reasoning_effort,
)


def get_subagent() -> Agent:
return _cached("subagent", "BrainDB Subagent", submit_subagent)
# Shared surface: any agent can delegate, so a subagent spawned from
# /agent/query also inherits the wiki effort setting. Accepted because
# subagent runs are overwhelmingly wiki work.
return _cached("subagent", "BrainDB Subagent", submit_subagent,
extra_tools=_SUBAGENT_EXTRA_TOOLS,
reasoning_effort=settings.agent_wiki_reasoning_effort)


def create_braindb_agent() -> Agent:
Expand Down Expand Up @@ -292,6 +356,10 @@ async def run_typed(
"""
turns = max_turns or settings.agent_max_turns
slot, token = install_slot()
# Short log tag for THIS run, inherited by the SDK's child Tasks so
# every TOOL line it emits is attributable (see run_state.set_run_tag).
# Set before Runner.run — ContextVars are captured at Task creation.
tag_token = set_run_tag(uuid4().hex[:6])
# Layer-3 nudge: when the run is about to exhaust `max_turns`, the hook
# appends a synthetic "you have N turns left, finalise via final_answer"
# user message to the conversation. One nudge per run; disabled when
Expand All @@ -308,7 +376,8 @@ async def run_typed(
handoff_tool_name="handoff_to_successor",
)
try:
logger.info("Running typed query (%s): %s", agent.name, query[:160])
logger.info("Running typed query [%s] (%s): %s",
get_run_tag(), agent.name, query[:160])
result = await Runner.run(
starting_agent=agent, input=query, max_turns=turns, hooks=hooks,
)
Expand Down Expand Up @@ -424,6 +493,7 @@ async def run_typed(
_bad_request_retried=True,
)
finally:
reset_run_tag(tag_token)
release_slot(token)


Expand Down
Loading
Loading