feat(rag): unbounded agent tool calls behind a round cap (owner revision)
Phase 45 (owner permission 2026-08-27, TODO.md L8: "allow the LLM
to make as many tool calls as it wants"): the phase-37 per-turn tool
budgets (BOR_AGENT_LIST_CALLS / BOR_AGENT_READ_CALLS, default 1 each)
and their exhaustion refusals are removed — a grounded turn now offers
list_documents / read_document for the whole turn (re-lists included),
bounded only by the round cap:
- app/config.py: agent_max_rounds (BOR_AGENT_MAX_ROUNDS, default 10,
negative rejected) replaces agent_list_calls / agent_read_calls;
.env.example + README document the single knob; app/rag/prompts.py
docstrings follow.
- app/rag/agent.py: the loop runs tools until the model answers or
rounds >= max_rounds, at which point it forces one final no-tools
answer (the cap is the only forced exit); 0 = no tools — exactly one
tools=None request, byte-identical to the pre-phase-37 path (the
kill switch). Rejected calls (unknown tool / missing args /
already-in-context / unknown path) still consume a round, so
pathological rejected-call streams are bounded by the cap. The
per-call log line is now tool/args/round=N/M; the per-turn
tool_calls=N field and the tool SSE event are unchanged.
- tests/e2e/mock_llm.py: MULTI_READ_TRIGGER ("read two documents") —
the deterministic list -> read #1 -> read #2 -> forced-answer flow
(byte-stable "I read <sp1> and <sp2>." line), classified by the
count of tool-role read results; the phase-37 single-read flow stays
byte-identical (unit-pinned in tests/unit/test_mock_tool_flow.py).
- tests/e2e/test_agent_unlimited_tools.py (new, story suite,
mock-only): three tool frames/lines in order (one list, two reads —
the second read is what the old read budget refused) + the
both-named non-deflected answer; done.sources + chips = retrieval
doc + both reads, deduped; no budget refusal rendered; the
single-read marker flow regression (exactly one read, single tool
pair).
- .agent/PLAN.md: the phase-45 SSE revision note (owner-locked, R2) —
the only PLAN edit this phase; the phase-37 note's budget clause is
marked removed.
Unit/integration rewrites (test_agent.py round-cap matrix incl. the
kill switch and rejected-call spam, test_config.py, test_chat_api.py
agent_max_rounds=0 fixtures) landed with the server core so every gate
stays green.
uv run pytest: 756 passed, app/ coverage 99%; ruff + pyright clean;
story E2E 4/4 in isolation (ran twice); regression E2E suites
(agent_document_tools unmodified, chat_rag, smoke) green in isolation.
Also records the 45_agent_unlimited_tools todo/ -> complete/ task-file
moves (00/01/02 pending in the working tree, task 03 moves on success).
This commit is contained in:
+21
-20
@@ -48,26 +48,26 @@ outline (0 when absent) and the per-turn log line records
|
||||
``kb_chars=N`` after ``tuning=N`` (PLAN §9 line extension).
|
||||
|
||||
Agent document tools (phase 37, PLAN §4 extension, owner permission
|
||||
2026-08-26): a **grounded** turn (``not plan.deflected``) no longer
|
||||
2026-08-26; phase 45 removed the per-tool budgets — owner permission
|
||||
2026-08-27): a **grounded** turn (``not plan.deflected``) no longer
|
||||
streams a bare ``chat_stream`` — it runs the agent loop
|
||||
(``app.rag.agent.run_agent``), which offers the model the two
|
||||
server-side tools ``list_documents`` / ``read_document`` while the
|
||||
per-turn budgets (``BOR_AGENT_LIST_CALLS`` / ``BOR_AGENT_READ_CALLS``,
|
||||
default 1 each) last; once both budgets are spent the ``tools`` key is
|
||||
dropped from the request and the model must answer. Each
|
||||
model-requested call streams as an SSE ``tool`` event —
|
||||
``{"type": "tool", "name": …, "argument": "source/path" | null}`` —
|
||||
ahead of the answer's ``delta`` frames. ``done.sources``,
|
||||
``query_log.sources`` and the per-turn log line all report the same
|
||||
combined source list (retrieval docs + the agent's read docs, deduped
|
||||
by ``(source, path)``, order preserved), and the log line records
|
||||
``tool_calls=N`` after ``thinking_chars=N`` (PLAN §9 line extension —
|
||||
``N`` counts budget-consuming executions; rejected calls do not
|
||||
count). **Deflected turns keep the direct ``chat_stream`` — byte-
|
||||
identical to the pre-phase path (A8):** the LOW prompt never carries
|
||||
tools, and with **both budgets at 0** ``run_agent`` makes exactly one
|
||||
``tools=None`` request, reproducing the pre-phase behavior (budgets-
|
||||
as-kill-switch).
|
||||
server-side tools ``list_documents`` / ``read_document`` for the whole
|
||||
turn (as many calls as the model wants, re-lists included) until it
|
||||
answers or the round cap (``BOR_AGENT_MAX_ROUNDS``, default 10) forces
|
||||
one final no-tools answer. Each model-requested call streams as an SSE
|
||||
``tool`` event — ``{"type": "tool", "name": …, "argument":
|
||||
"source/path" | null}`` — ahead of the answer's ``delta`` frames.
|
||||
``done.sources``, ``query_log.sources`` and the per-turn log line all
|
||||
report the same combined source list (retrieval docs + the agent's
|
||||
read docs, deduped by ``(source, path)``, order preserved), and the log
|
||||
line records ``tool_calls=N`` after ``thinking_chars=N`` (PLAN §9 line
|
||||
extension — ``N`` counts executed tool calls; rejected calls do not
|
||||
count). **Deflected turns keep the direct ``chat_stream`` —
|
||||
byte-identical to the pre-phase path (A8):** the LOW prompt never
|
||||
carries tools, and with ``agent_max_rounds`` at **0** ``run_agent``
|
||||
makes exactly one ``tools=None`` request, reproducing the pre-phase
|
||||
behavior (the kill switch).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -299,8 +299,9 @@ async def chat(
|
||||
# bare ``chat_stream`` — its ``ToolCallPiece``s stream as
|
||||
# ``tool`` events ahead of the answer. A deflected turn keeps
|
||||
# the direct ``chat_stream`` (byte-identical, A8): the LOW
|
||||
# prompt never carries tools, and with both budgets at 0
|
||||
# ``run_agent`` is a single ``tools=None`` request anyway.
|
||||
# prompt never carries tools, and with
|
||||
# ``agent_max_rounds=0`` ``run_agent`` is a single
|
||||
# ``tools=None`` request anyway (the kill switch).
|
||||
holder = AgentHolder()
|
||||
answer_stream: AsyncIterator[StreamPiece | ToolCallPiece]
|
||||
if plan.deflected:
|
||||
|
||||
Reference in New Issue
Block a user