chore(agent): phase roadmap from TODO.md — 8 phases (40–47), 24 tasks
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/).
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Phase 45 — Agent makes as many tool calls as it wants
|
||||
|
||||
**Source:** `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"
|
||||
**Story:** `.agent/user_stories/agent-unlimited-tools.md`
|
||||
**Context:** Phase 37 shipped the grounded-turn agent loop (`app/rag/agent.py::run_agent`) with per-turn budgets — `agent_list_calls` / `agent_read_calls` (default 1 each, `BOR_AGENT_LIST_CALLS` / `BOR_AGENT_READ_CALLS`), "budgets-as-kill-switch" locked decision. The exhaustion refusals (`LIST_EXHAUSTED` / `READ_EXHAUSTED`) are where correct multi-document answers die. Owner direction (2026-08-27): remove both budgets; the loop keeps one guard — a configurable **round cap** that also doubles as the no-tools kill switch (`0`).
|
||||
|
||||
## Objective
|
||||
`list_documents` / `read_document` can be called as many times as the model needs (re-lists included), bounded only by `BOR_AGENT_MAX_ROUNDS` (default 10; `0` = no tools, byte-identical to the pre-phase-37 path).
|
||||
|
||||
## Dependencies
|
||||
- `44_markdown_tables` (todo) — sequential only (no shared files: this phase is `app/` + tests + mock).
|
||||
- `37_agent_document_tools` (complete) — the loop, the `tool` SSE event, the UI tool lines, the per-turn `tool_calls=N` log field, and the phase-37 locked decision being revised.
|
||||
- `31_kb_overview_prompt` (complete) — the `lite` one-shot path is untouched by this phase.
|
||||
|
||||
## Tasks
|
||||
1. `01_config_round_cap.md` — the server core, atomically: `agent_max_rounds` replaces the budgets in `app/config.py` + `app/rag/agent.py`, unit + integration rewrites, `.env.example` (one task so the per-task gate stays green).
|
||||
2. `02_mock_multi_read_flow.md` — the E2E mock's deterministic multi-read (list → read #1 → read #2 → answer) flow.
|
||||
3. `03_unlimited_tools_e2e_and_commit.md` — story E2E + phase-37 regression + PLAN.md revision note + commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_agent.py` rewritten around the round cap (always-calling mock LLM: N tool rounds then a forced `tools=None` final answer; `max_rounds=0` → exactly one request with `tools=None`; rejected-call spam — unknown tool / already-in-context — is bounded by the cap, not by budgets; re-lists execute and count in `tool_calls`); `tests/unit/test_config.py` (default 10, `BOR_AGENT_MAX_ROUNDS` override, `0`, the budget env vars are gone).
|
||||
- Integration: `tests/integration/test_chat_api.py` — the `agent_list_calls=0, agent_read_calls=0` fixtures become `agent_max_rounds=0`; the tool SSE event shape and the `done.sources` extension assertions stay.
|
||||
- Coverage: **>90%** on `app/` — `agent.py` + `config.py` fully covered.
|
||||
- E2E (mandatory, A16): `tests/e2e/test_agent_unlimited_tools.py`, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `BOR_AGENT_LIST_CALLS` / `BOR_AGENT_READ_CALLS` are gone (config, `.env.example`, agent, tests); no exhaustion refusal remains.
|
||||
- [ ] A multi-read turn (list + 2 reads) streams three tool lines, answers non-deflected, and `done.sources` lists the retrieved doc(s) + both reads deduped.
|
||||
- [ ] `agent_max_rounds=0` → single `tools=None` request (kill switch); at the cap the loop forces a final no-tools answer (log warning kept).
|
||||
- [ ] `tool` SSE event shape and `tool_calls=N` per-turn log field unchanged.
|
||||
- [ ] `.agent/PLAN.md` carries the phase-37 revision note (owner permission 2026-08-27, `TODO.md` L8) — the only PLAN edit in this phase.
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
|
||||
- [ ] `uv run pytest tests/e2e/test_agent_unlimited_tools.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] Regression E2E suites green in isolation: `test_agent_document_tools.py`, `test_chat_rag.py`, `test_smoke.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **Owner-locked revision (2026-08-27, roadmap R2):** the phase-37 "budgets-as-kill-switch" decision is **revised** — both per-tool budgets removed; `BOR_AGENT_MAX_ROUNDS` (default 10) is the only loop guard and the kill switch (`0`). Recorded as a PLAN.md revision note (the established owner-permission pattern, like the A10/A7/A9/A15 notes) — a recorded revision, not a silent deviation (AGENTS.md rule 3).
|
||||
- **A15 extension unchanged** — the `tool` SSE event shape, the `done` shape, and the per-turn log line (`tool_calls=N`) are untouched; the revision note amends the phase-37 note's budget wording only.
|
||||
- **Rejections kept:** `Unknown tool.`, `MISSING_READ_ARGS`, `Already in your context.` (non-budget rejections; the cap bounds their pathological repetition).
|
||||
- **A17 honoured** — one atomic commit.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Task 01 — Server core: round cap replaces the budgets (config + loop + unit/integration)
|
||||
|
||||
**Phase:** `45_agent_unlimited_tools` · **Source:** `TODO.md:8` — "Allow the LLM to make as many tool calls as it wants, remove the restrictions, they're causing problems getting correct answers"
|
||||
**Story:** `.agent/user_stories/agent-unlimited-tools.md`
|
||||
|
||||
## Objective
|
||||
One coherent server-side change, landed atomically so the suite is green at the checkpoint: `agent_max_rounds` (`BOR_AGENT_MAX_ROUNDS`, default 10; `0` = no tools) replaces both per-tool budgets in config, the agent loop, and every test that pins them.
|
||||
|
||||
## Work
|
||||
1. `app/config.py` —
|
||||
- **delete** the `agent_list_calls` and `agent_read_calls` fields (with docstrings);
|
||||
- **add** in their place, same "RAG tuning" section:
|
||||
```python
|
||||
#: Hard cap on the agent tool rounds per grounded turn (phase 45,
|
||||
#: revising phase 37's per-tool budgets — owner permission
|
||||
#: 2026-08-27, TODO L8: "allow the LLM to make as many tool calls
|
||||
#: as it wants"). Every tool call the model emits consumes a
|
||||
#: round; at the cap the loop forces one final no-tools answer.
|
||||
#: ``0`` disables the tools entirely — the turn is a single
|
||||
#: request with ``tools=None`` (the pre-phase-37 path — the kill
|
||||
#: switch).
|
||||
agent_max_rounds: int = 10
|
||||
```
|
||||
- optional: a `field_validator` rejecting negative values (note it in the docstring if added).
|
||||
2. `app/rag/agent.py` —
|
||||
- `run_agent`: `max_rounds = settings.agent_max_rounds`; `tools = AGENT_TOOLS if max_rounds > 0 else None` (the kill switch — at 0 the loop makes exactly one request with `tools=None`, byte-identical to the pre-phase-37 path);
|
||||
- delete `list_left` / `read_left` and the budget-driven `tools = None if (list_left == 0 and read_left == 0) else AGENT_TOOLS` transition — `tools` stays `AGENT_TOOLS` while rounds remain;
|
||||
- after each executed call: `rounds += 1`; the existing cap branch becomes the **only** forced-exit: `if rounds >= max_rounds:` → the `logger.warning("agent round cap reached …")` + final `chat_stream(messages, tools=None)` (update the warning text: it is no longer belt-and-braces — it is the cap);
|
||||
- `_execute_tool(db, call, seed_docs, holder)`: drop the `list_left` / `read_left` parameters and the `LIST_EXHAUSTED` / `READ_EXHAUSTED` early returns; keep the `ALREADY_IN_CONTEXT`, `UNKNOWN_TOOL`, `MISSING_READ_ARGS` rejections (non-budget — a repeated rejected call still consumes a *round* in the loop, so a pathological stream is bounded by `max_rounds`); return type simplifies to `str`;
|
||||
- delete the `LIST_EXHAUSTED` / `READ_EXHAUSTED` constants;
|
||||
- `AGENT_TOOLS`: `read_document` description "Add the full content of exactly one more indexed document to your context" → "Add the full content of one more indexed document to your context";
|
||||
- module docstring: the budget paragraph (points 1, 3, 4) rewritten for the round cap (owner revision 2026-08-27, `TODO.md` L8); `run_agent` docstring updated (`seed_docs` note unchanged); `AgentHolder` unchanged (`tool_calls` still counts executed calls — now including re-lists);
|
||||
- the per-call `logger.info("agent tool=… budget list_left=… read_left=…")` line becomes `logger.info("agent tool=%s args=%s round=%d/%d", …)` (or equivalent — the per-turn `tool_calls=N` field in `app/api/chat.py` is untouched).
|
||||
3. `tests/unit/test_agent.py` — **rewrite** the budget tests around the round cap (keep the file's fake-LLM harness):
|
||||
- an always-`list_documents`-calling mock with `agent_max_rounds=3`: exactly 3 tool rounds execute, then one forced `tools=None` request streams the answer; `holder.tool_calls == 3`;
|
||||
- `agent_max_rounds=0`: exactly one request, `tools=None`, no tool lines, `holder.tool_calls == 0` (kill switch);
|
||||
- an always-`read_document`-with-unknown-path mock (every call rejected — `No document at …`): the loop runs to `max_rounds` and forces the final answer (rejections no longer end the loop early via budgets, the cap bounds them);
|
||||
- the existing rejections tests (`Unknown tool.`, `MISSING_READ_ARGS`, `Already in your context.`) keep passing — update their `_settings(...)` calls (`agent_max_rounds=…` instead of the budget kwargs);
|
||||
- a **re-list** test: `list_documents` called twice in one turn executes both (the second returns the catalog again) and counts 2 in `holder.tool_calls`.
|
||||
4. `tests/unit/test_config.py` — default 10; `BOR_AGENT_MAX_ROUNDS=0` / `=5` overrides; (negative validator, if added); delete the old budget assertions.
|
||||
5. `tests/integration/test_chat_api.py` — the `agent_list_calls=0, agent_read_calls=0` fixture kwargs (~line 661) become `agent_max_rounds=0`; any other budget kwarg in the file the same; the tool SSE-event and `done.sources` assertions stay untouched.
|
||||
6. `.env.example` — the two `BOR_AGENT_*_CALLS` lines become one: `# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools)` (file's optional-setting comment style).
|
||||
7. Grep the repo for `agent_list_calls|agent_read_calls|BOR_AGENT_(LIST|READ)_CALLS|LIST_EXHAUSTED|READ_EXHAUSTED` — zero hits outside `.agent/phases/complete/**` (history).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit + integration: full `uv run pytest` green at this checkpoint (the mock/E2E multi-read flow lands in task 02 — the existing 3-step mock flow still works unmodified, so `test_agent_document_tools.py` E2E is not yet run by the gate).
|
||||
- Coverage: **>90%** on `app/` — `agent.py` + `config.py` fully covered by the rewritten tests.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] No per-tool budgets anywhere in `app/` or `tests/`; `agent_max_rounds` is the single knob (default 10, `0` = kill switch).
|
||||
- [ ] Re-lists execute; non-budget rejections intact; the cap bounds pathological streams; `tool_calls=N` log field and `tool` SSE event unchanged.
|
||||
- [ ] `uv run pytest` + coverage gate green at this checkpoint.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 02 — Mock: deterministic multi-read tool flow
|
||||
|
||||
**Phase:** `45_agent_unlimited_tools` · **Source:** `TODO.md:8` — "Allow the LLM to make as many tool calls as it wants, remove the restrictions, they're causing problems getting correct answers"
|
||||
**Story:** `.agent/user_stories/agent-unlimited-tools.md`
|
||||
|
||||
## Objective
|
||||
The E2E mock gains a deterministic **multi-read** agent flow (list → read #1 → read #2 → answer) so "as many tool calls as it wants" is provable statelessly, without disturbing the existing 3-step flow.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/mock_llm.py` —
|
||||
- the existing phase-37 flow (documented in the module docstring and `_tool_flow`): marker `TOOLS_TRIGGER` ("use your tools") + `<tools>` system section → step classification **statelessly from the messages**: no tool results yet → `list`; one `tool`-role message with the catalog prefix → `read` (first catalog doc, parsed from the listing via the `rsplit("/", 1)` convention); one `tool`-role message with the `Document <source/path>:` prefix → forced answer.
|
||||
- add a **multi-read variant**: when the user message contains **both** `TOOLS_TRIGGER` and a new marker `MULTI_READ_TRIGGER = "read two documents"`, the classifier reads the *count* of `tool`-role messages whose content starts with `"Document "` (the read-result prefix, `app.rag.agent`'s `_execute_tool` output):
|
||||
- 0 read results (+ no catalog yet) → `list`;
|
||||
- 0 read results (catalog present) → `read` the **first** catalog doc;
|
||||
- 1 read result → `read` the **second** catalog doc (the listing minus the already-read doc — parse the catalog lines the same way the existing read step does, skipping the path already read);
|
||||
- 2 read results → forced answer: the existing answer shape (tail echo) plus a deterministic line naming **both** read paths (e.g. `"I read <path1> and <path2>."` — byte-stable) so the E2E can assert the model actually used both;
|
||||
- the single-read flow (no `MULTI_READ_TRIGGER`) stays byte-identical — the variant must be a strict superset (the existing `test_agent_document_tools.py` E2E keeps passing unmodified).
|
||||
- update the module docstring's tool-flow documentation (the multi-read steps + the marker).
|
||||
2. `uv run pytest` green (mock-only change; the existing 3-step E2E is not run by the unit gate but must stay conceptually intact — the regression run in task 03 proves it).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: full suite green; if a mock-specific unit test file exists (check `tests/unit/`), add the multi-read classification case there (catalog → read #1 → read #2 → answer) so the new branch is unit-covered; otherwise the E2E (task 03) covers it.
|
||||
- Coverage: **>90%** on `app/` (unchanged — `tests/` only).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `TOOLS_TRIGGER` + `MULTI_READ_TRIGGER` → deterministic 4-step flow (list, read #1, read #2, answer naming both paths); the 3-step flow is unchanged for marker-less turns.
|
||||
- [ ] Full unit/integration suite green.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Task 03 — Unlimited-tools E2E + PLAN revision note + commit
|
||||
|
||||
**Phase:** `45_agent_unlimited_tools` · **Source:** `TODO.md:8` — "Allow the LLM to make as many tool calls as it wants, remove the restrictions, they're causing problems getting correct answers"
|
||||
**Story:** `.agent/user_stories/agent-unlimited-tools.md`
|
||||
|
||||
## Objective
|
||||
Prove the multi-tool turn end to end, record the phase-37 decision revision in PLAN.md, run the regressions, and commit the phase.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_agent_unlimited_tools.py` (new) — mock-only, DB up (the grounded-turn prerequisite: the fixture KB imported, per the `test_agent_document_tools.py` fixture pattern):
|
||||
- `test_multi_read_turn` — a grounded question carrying `TOOLS_TRIGGER` + `MULTI_READ_TRIGGER`: the turn streams **three** tool lines (`.tool-call` rows: one `list_documents` — "is listing documents" — and two `read_document` — "is reading <source/path>") in order, then a final non-deflected answer containing the mock's "I read <path1> and <path2>." line;
|
||||
- `test_done_sources_include_reads` — the source chips under the answer list the retrieval doc(s) **plus both** read documents, deduped (the phase-37 `done.sources` extension contract, now with 2 reads);
|
||||
- `test_relist_allowed` — the listing tool ran without a "No listing budget left" refusal: assert no refusal text anywhere in the bubble/tool lines (the old refusal strings must be gone — grep the app for them is task 01's job; here assert the UI never shows one);
|
||||
- `test_single_tool_flow_regression` (phase 37) — the original 3-step flow (marker without the multi-read trigger) still answers after exactly one read with its single tool pair (this may be a targeted re-assertion; the full suite `test_agent_document_tools.py` runs in the regression pass).
|
||||
2. `.agent/PLAN.md` — **the only PLAN edit in this phase** (owner-locked revision, roadmap R2): in the §4 SSE-revision block, after the phase-37 revision note, add a new note in the established style:
|
||||
> **SSE revision (phase 45, owner permission 2026-08-27):** the phase-37
|
||||
> per-turn tool budgets are **removed** (owner: "allow the LLM to make
|
||||
> as many tool calls as it wants — `TODO.md` L8): `BOR_AGENT_LIST_CALLS`
|
||||
> / `BOR_AGENT_READ_CALLS` no longer exist; `BOR_AGENT_MAX_ROUNDS`
|
||||
> (default 10) caps the tool rounds and `0` disables the tools
|
||||
> entirely (the pre-phase-37 path). The `tool` event shape and the
|
||||
> `done` shape are unchanged — a recorded revision of the phase-37
|
||||
> note's budget wording, not a silent deviation.
|
||||
Also update the phase-37 note's budget clause if it reads as current
|
||||
truth ("budgeted by `BOR_AGENT_LIST_CALLS` / `BOR_AGENT_READ_CALLS`")
|
||||
by appending "(removed in phase 45 — see the revision note below)".
|
||||
Touch **nothing else** in PLAN.md (Protocol B: no roadmap-table edit for appended phases).
|
||||
3. Regression pass (isolation runs): `test_agent_document_tools.py` (phase 37 — must pass **unmodified**), `test_chat_rag.py`, `test_smoke.py`.
|
||||
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
5. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(rag): unbounded agent tool calls behind a round cap (owner revision)`, staging this phase's files **including the force-added `.agent/PLAN.md`** (AGENTS.md rule 8: `git add -f .agent/PLAN.md`) and the phase dir move `.agent/phases/todo/45_agent_unlimited_tools/` → `.agent/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_agent_unlimited_tools.py -v --no-cov` green in isolation.
|
||||
- Coverage: **>90%** on `app/` (task 01's rewritten tests carry it).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The multi-read E2E suite passes in isolation; the phase-37 suite passes unmodified in isolation.
|
||||
- [ ] PLAN.md carries the phase-45 revision note (owner permission 2026-08-27) and nothing else changed.
|
||||
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
Reference in New Issue
Block a user