Converts the 9 TODO items into an executable phase roadmap (Protocol B, appended after phase 39): - 40 tuning toggle anonymous flash (TODO L3) - 41 sync fail-fast + modal when a model is down (TODO L4) - 42 no reply autoscroll (TODO L5) - 43 thinking scroll back — user scroll + gated autoscroll (TODO L7) - 44 markdown tables (TODO L6) - 45 agent unlimited tool calls behind BOR_AGENT_MAX_ROUNDS (TODO L8) - 46 mobile hamburger nav (TODO L9) - 47 quadlet + jinja import formats, A9 revision (TODO L10–L11) Each phase carries a user story, a dedicated Playwright E2E suite plan, and owner-locked decisions (R1 A9 format extension, R2 phase-37 budget revision, A1–A5 scope decisions) confirmed 2026-08-27. Also records the completed phases 30–39 todo/ -> complete/ moves that were pending in the working tree. TODO.md is cleared (items now live in .agent/phases/todo/).
5.5 KiB
Story: Agent makes as many tool calls as it wants
Phase: 45_agent_unlimited_tools · Source: TODO.md L8 ·
E2E: tests/e2e/test_agent_unlimited_tools.py
Bug report (verbatim, TODO.md L8)
"Allow the LLM to make as many tool calls as it wants, remove the restrictions, they're causing problems getting correct answers"
Narrative
As the owner, the phase-37 per-turn budgets — one list_documents
call and one read_document call — cap answers: complex questions need
several documents, and the "No reading budget left" refusal is where
correct answers die. The per-tool restrictions are being removed:
the model may call the tools as many times as it needs, within a single
configurable round cap that exists only to stop a pathological
infinite loop (and doubles as the no-tools kill switch).
- Given a grounded chat turn (retrieval found relevant docs)
- When the model calls
list_documents/read_document - Then every valid call is executed — re-lists included — until the model answers or the round cap is reached, at which point a final no-tools answer is forced.
Acceptance criteria
- No per-tool budgets:
BOR_AGENT_LIST_CALLS/BOR_AGENT_READ_CALLSare gone fromapp/config.py,.env.example, and the agent loop; theLIST_EXHAUSTED/READ_EXHAUSTEDrefusals no longer exist. - Round cap only: new
agent_max_rounds(BOR_AGENT_MAX_ROUNDS, default 10) counts tool rounds; at the cap the loop forces one finaltools=Noneanswer.0disables the tools entirely — the request goes out withtools=None, byte-identical to the pre-phase-37 path (the kill switch survives, per the owner-locked revision). - Non-budget rejections kept: unknown tool →
"Unknown tool.", missing args → theMISSING_READ_ARGSrefusal, already-in-context document →"Already in your context."— none of these consume a round's budget (there is none) but the round cap still bounds a stream that keeps emitting rejected calls. - Everything downstream unchanged: the
toolSSE event shape, the per-turntool_calls=Nlog field (budget-consuming → now: executed),done.sourcesextension, and the UI tool lines are untouched. - PLAN recorded: the phase-37 locked decision
("budgets-as-kill-switch") is revised in
.agent/PLAN.mdwith an owner-permission note (2026-08-27,TODO.mdL8), following the established revision-note pattern (phases 16/19/24/37).
Owner-confirmed (2026-08-27, roadmap R2)
- Both budget env vars removed. New single guard
BOR_AGENT_MAX_ROUNDS(default 10 tool rounds, then forced final answer);0= no-tools kill switch (pre-phase-37 behavior). - Re-listing is allowed (a second
list_documentsis a normal executed call — it even counts intool_calls=N).
UI Visualization & Structure
- Config (
app/config.py): deleteagent_list_calls/agent_read_calls; addagent_max_rounds: int = 10with a docstring (0 disables the tools entirely — the loop makes exactly one request withtools=None)..env.example: the twoBOR_AGENT_*_CALLSlines are replaced byBOR_AGENT_MAX_ROUNDS=10with an updated comment. - Agent loop (
app/rag/agent.py):run_agent—max_rounds = settings.agent_max_rounds;tools = AGENT_TOOLS if max_rounds > 0 else None; the loop dropslist_left/read_leftand the budget-driventools = Nonetransition; after each executed callrounds += 1and atrounds >= max_roundsthe existing forced final answer path runs (now the only exit besides "no calls")._execute_toolloses its budget parameters + the two exhaustion constants;AGENT_TOOLS'read_documentdescription drops "exactly one more"; module/docstrings and the probe reference updated.AgentHolderunchanged (tool_callsstill counts executed calls). - Tests:
tests/unit/test_agent.pyrewritten around the round cap (always-listing mock LLM: N rounds then forced answer;max_rounds=0→ singletools=Nonerequest; rejected-call spam bounded by the cap);tests/unit/test_config.py(default 10, env override, 0);tests/integration/test_chat_api.pybudget fixtures →agent_max_rounds;tests/e2e/mock_llm.py_tool_flowextended: the existing deterministic 3-step flow stays, plus a multi-read variant triggered by a marker (e.g. the user message containing"read two documents") — stateless classification by countingDocument …:tool messages (list → read #1 → read #2 → answer). - Non-goals: no per-call cost cap, no streaming change, no new endpoint.
Playwright Mapping Rule
Test Scenario → tests/e2e/test_agent_unlimited_tools.py (mock
LLM; DB up):
test_multi_read_turn— a grounded question carrying bothTOOLS_TRIGGERand the multi-read marker: the turn streams tool lines forlist_documentsand tworead_documentcalls, then a final answer; the bubble is not deflected.test_done_sources_include_reads— the finaldone(observed via the source chips) lists the retrieved doc(s) plus both read documents, deduped.test_relist_allowed(unit-level via integration, plus E2E observable state) — a re-listed catalog does not produce a refusal line; the UI shows a tool line per executed call.test_single_tool_flow_regression(phase 37) — the original 3-step flow still answers with exactly one read (runs against the unchangedtests/e2e/test_agent_document_tools.pyin the regression pass, not duplicated here).