fix(agent): make read_document robust to combined source/path arguments
The model treated the combined 'source/path' string (as printed in search result lines, read-result headers and refusals) as the document's identity and passed it as 'source' — e.g. source='homelab/active/container_caddy/caddy.md' instead of source='homelab', path='active/container_caddy/caddy.md'. - Rewrite the read_document description with the split rule (source = before the FIRST '/', path = after it) and a worked example; share the source/path parameter descriptions between read_document and search_documents; map search result lines back onto the split. - New _resolve_document: on a lookup miss with a '/' in source, retry at the first slash (source names are directory basenames and can never contain '/'), plus a continuation candidate for a split at a later slash; a self-corrected combined form for an already-in-context document is still rejected as ALREADY_IN_CONTEXT. - A slash-carrying source that matches nothing gets an educational refusal naming the corrected arguments instead of the generic line that repeated the combined form. Verified live against aipi (lite) + the imported homelab KB: A/B on the exact failure scenario (5 runs each, right after a combined-source search result) — old descriptions 5/5 combined, new descriptions 5/5 clean; two live UI turns (Playwright) produced only clean split arguments, including a multi-hop read of install_caddy_deskwork.yaml that landed in done.sources. Full suite: 1376 passed, app coverage 99% (agent.py 100%), ruff + pyright clean, agent/search E2E green in isolation.
This commit is contained in:
@@ -7,7 +7,11 @@ document row (content included, for the never-truncated read) and return
|
||||
``search_documents`` tool is pinned here too — its locked parameter
|
||||
shape in ``AGENT_TOOLS``, and a scripted ``ToolCallPiece`` executed
|
||||
through ``run_agent`` against the real DB (``all_documents`` for a
|
||||
whole-KB search, ``find_document`` for a scoped one).
|
||||
whole-KB search, ``find_document`` for a scoped one). The
|
||||
combined-form self-correction (a ``source`` argument carrying
|
||||
``source/path``) is pinned here as well, through ``run_agent``:
|
||||
the split read executes against the real table, and a still-unknown
|
||||
split gets the educational refusal.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
@@ -164,6 +168,18 @@ def _run_search(
|
||||
return holder, llm
|
||||
|
||||
|
||||
def _run_read(
|
||||
db: Session, arguments: dict[str, Any]
|
||||
) -> tuple[AgentHolder, ScriptedToolLLM]:
|
||||
"""Drive one scripted ``read_document`` call through ``run_agent``."""
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedToolLLM(
|
||||
ToolCallPiece(id="call_1", name="read_document", arguments=arguments)
|
||||
)
|
||||
asyncio.run(_consume(llm, db, holder))
|
||||
return holder, llm
|
||||
|
||||
|
||||
async def _consume(
|
||||
llm: ScriptedToolLLM, db: Session, holder: AgentHolder
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
@@ -229,6 +245,67 @@ def test_search_scoped_missing_doc_refused_through_run_agent(kb, db) -> None:
|
||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||
|
||||
|
||||
# ---------- combined 'source/path' self-correction (read_document) ----------
|
||||
|
||||
|
||||
def test_read_combined_source_self_corrects_through_run_agent(kb, db) -> None:
|
||||
"""The model's combined 'source' ('Alpha/deep/nested/doc.md') resolves
|
||||
through the first-slash split against the REAL table: the read
|
||||
executes, the holder records the row, the result header carries the
|
||||
true source/path."""
|
||||
created = _doc(db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT")
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_read(
|
||||
db,
|
||||
{
|
||||
"source": "Alpha/deep/nested/doc.md",
|
||||
"path": "deep/nested/doc.md",
|
||||
},
|
||||
)
|
||||
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Document Alpha/deep/nested/doc.md:\nFULL-TEXT"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == [created]
|
||||
|
||||
|
||||
def test_read_combined_source_later_slash_split_through_run_agent(kb, db) -> None:
|
||||
"""source='Alpha/deep' + path='nested/doc.md' (a split at a LATER
|
||||
slash) resolves via the continuation candidate against the real
|
||||
table."""
|
||||
created = _doc(db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT")
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_read(
|
||||
db, {"source": "Alpha/deep", "path": "nested/doc.md"}
|
||||
)
|
||||
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Document Alpha/deep/nested/doc.md:\nFULL-TEXT"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == [created]
|
||||
|
||||
|
||||
def test_read_combined_source_refusal_teaches_split(kb, db) -> None:
|
||||
"""A combined source that matches nothing (even split) gets the
|
||||
educational refusal naming the corrected arguments."""
|
||||
_doc(db, "Alpha", "x.md", "X", "X-CONTENT")
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_read(
|
||||
db, {"source": "Alpha/nope/deep.md", "path": "nope/deep.md"}
|
||||
)
|
||||
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"source must not contain '/': for 'Alpha/nope/deep.md' call "
|
||||
"read_document(source='Alpha', path='nope/deep.md')."
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == []
|
||||
|
||||
|
||||
def test_search_no_matches_through_run_agent(kb, db) -> None:
|
||||
_doc(db, "Alpha", "a/one.md", "One", "nothing matching")
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user