feat(agent): align the document tools with the harness-trained shape — ls, read(path), grep(pattern, path?)

This commit is contained in:
2026-09-03 11:17:47 -04:00
parent 16f1cfbcaf
commit 801639efcc
55 changed files with 4031 additions and 1466 deletions
+31 -33
View File
@@ -49,27 +49,28 @@ outline (0 when absent) and the per-turn log line records
Agent document tools (phase 37, PLAN §4 extension, owner permission
2026-08-26; phase 45 removed the per-tool budgets — owner permission
2026-08-27; phase 68 added the ``search_documents`` grep): a
2026-08-27; phase 70 aligned the surface to the harness-trained
``ls`` / ``read`` / ``grep`` — owner permission 2026-09-03): 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 three server-side tools
``list_documents`` / ``read_document`` / ``search_documents`` for the
whole turn (as many calls as the model wants, re-lists and re-searches
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" | pattern | null}`` — ahead of the answer's
``delta`` frames: ``argument`` is the read document's path for
``read_document``, the raw search pattern for ``search_documents``
(a non-string pattern — a model error the backend refuses — yields
null), and null for ``list_documents``. ``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 — a search adds no source; it is
a locator, locked A5), 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):**
``ls`` / ``read`` / ``grep`` for the whole turn (as many calls as the
model wants, re-lists and re-greps 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": … | null}`` — ahead
of the answer's ``delta`` frames: ``argument`` is the single string the
model passed — ``read``'s ``path`` (the combined ``source/path``),
``grep``'s ``pattern``, ``ls``'s ``path`` — or null (a non-string
value — a model error the backend refuses — and an omitted argument
both yield null). ``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 — a grep adds no source; it is a locator, locked A5), 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).
@@ -400,21 +401,18 @@ async def chat(
try:
async for piece in answer_stream: # StreamPiece | ToolCallPiece | RetryPiece
if isinstance(piece, ToolCallPiece):
# Phase 37 (PLAN §4 extension): one SSE ``tool``
# frame per model-requested call. ``argument`` is
# the read_document "source/path"; phase 68
# extends it with the search_documents pattern
# (a non-string pattern — a model error the
# backend refuses — is null); null otherwise.
if piece.name == "read_document":
argument = (
f"{piece.arguments.get('source')}/{piece.arguments.get('path')}"
)
elif piece.name == "search_documents":
pattern = piece.arguments.get("pattern")
argument = pattern if isinstance(pattern, str) else None
else:
argument = None
# Phase 37 (PLAN §4 extension; phase 70): one SSE
# ``tool`` frame per model-requested call.
# ``argument`` is the single string the model
# passed — ``read``'s ``path`` (the combined
# ``source/path``), ``grep``'s ``pattern``,
# ``ls``'s ``path`` — or null (a non-string value
# is a model error the backend refuses, as is an
# omitted argument).
argument = piece.arguments.get(
"pattern" if piece.name == "grep" else "path"
)
argument = argument if isinstance(argument, str) else None
yield sse_event(
ChatToolEvent(name=piece.name, argument=argument).model_dump()
)