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:
+219
-24
@@ -112,32 +112,49 @@ def test_agent_tools_names_and_parameters() -> None:
|
||||
read_params = by_name["read_document"]["function"]["parameters"]
|
||||
assert read_params["required"] == ["source", "path"]
|
||||
assert set(read_params["properties"]) == {"source", "path"}
|
||||
# Phase 45: the per-tool budgets are gone — "exactly one more"
|
||||
# dropped out of the read_document description.
|
||||
# The model repeatedly conflated the two fields — passing the
|
||||
# combined 'source/path' string as 'source' — so the read_document
|
||||
# description pins the split rule with a worked example.
|
||||
assert by_name["read_document"]["function"]["description"] == (
|
||||
"Add the full content of one more indexed document to your context"
|
||||
"Add the full content of one more indexed document to your "
|
||||
"context. A document is identified by the (source, path) pair "
|
||||
"exactly as shown in the list_documents output: 'source' is "
|
||||
"the top-level source name only (e.g. 'homelab'), 'path' is "
|
||||
"the file path inside that source (e.g. "
|
||||
"'active/container_caddy/caddy.md'). If you only have a "
|
||||
"combined 'source/path' string (as in search_documents "
|
||||
"results), split it at the FIRST '/': the part before is the "
|
||||
"source, the part after is the path. Example: "
|
||||
"read_document(source='homelab', "
|
||||
"path='active/container_caddy/caddy.md')."
|
||||
)
|
||||
# Phase 63 (A2): the parameter descriptions point the LLM at the
|
||||
# labeled `source:` / `path:` fields of the list_documents output
|
||||
# (the example was dropped by the phase-68 description fix — the
|
||||
# wording stays pinned, the model saw invented paths in calls).
|
||||
# The parameter descriptions define the split: source = before the
|
||||
# first '/', path = after it.
|
||||
assert read_params["properties"]["source"]["description"] == (
|
||||
"The document's source, as shown after 'source: ' in the "
|
||||
"list_documents output."
|
||||
"Top-level source name only (e.g. 'homelab') — the part BEFORE "
|
||||
"the first '/' of a combined 'source/path' string, exactly as "
|
||||
"shown after 'source: ' in the list_documents output. Must not "
|
||||
"contain '/' itself — do not pass the full source/path here."
|
||||
)
|
||||
assert read_params["properties"]["path"]["description"] == (
|
||||
"The document's path, as shown after 'path: ' in the "
|
||||
"list_documents output."
|
||||
"File path relative to the source directory (e.g. "
|
||||
"'active/container_caddy/caddy.md') — the part AFTER the first "
|
||||
"'/' of a combined 'source/path' string, exactly as shown "
|
||||
"after 'path: ' in the list_documents output. Must not start "
|
||||
"with the source name."
|
||||
)
|
||||
# Phase 68: search_documents — the third tool, a locator (locked A5).
|
||||
# Phase 68: search_documents — the third tool, a locator (locked
|
||||
# A5); its description maps result lines back onto the split.
|
||||
search = by_name["search_documents"]["function"]
|
||||
assert search["description"] == (
|
||||
"Search every indexed document for an exact string "
|
||||
"(case-insensitive) and return up to 20 matching lines as "
|
||||
"'source/path:line: text' — use this to locate content, "
|
||||
"then read_document the winner. Optionally pass 'source' "
|
||||
"and 'path' (as shown in list_documents) to search one "
|
||||
"document only."
|
||||
"then read_document the winner (each result line's "
|
||||
"'source/path' splits at the first '/': the part before "
|
||||
"is the source, the part after is the path). Optionally "
|
||||
"pass 'source' and 'path' (as shown in list_documents) "
|
||||
"to search one document only."
|
||||
)
|
||||
search_params = search["parameters"]
|
||||
assert search_params["type"] == "object"
|
||||
@@ -146,15 +163,10 @@ def test_agent_tools_names_and_parameters() -> None:
|
||||
assert search_params["properties"]["pattern"]["description"] == (
|
||||
"The exact text to search for (a plain substring, not a regex)"
|
||||
)
|
||||
# Phase 63 labeled-field wording, same as read_document's parameters.
|
||||
assert search_params["properties"]["source"]["description"] == (
|
||||
"The document's source, as shown after 'source: ' in the "
|
||||
"list_documents output."
|
||||
)
|
||||
assert search_params["properties"]["path"]["description"] == (
|
||||
"The document's path, as shown after 'path: ' in the "
|
||||
"list_documents output."
|
||||
)
|
||||
# Shared constants: search's source/path params ARE read_document's
|
||||
# (one definition, no drift between the two tools).
|
||||
assert search_params["properties"]["source"] is read_params["properties"]["source"]
|
||||
assert search_params["properties"]["path"] is read_params["properties"]["path"]
|
||||
|
||||
|
||||
# ---------- happy path: list → read → answer ----------
|
||||
@@ -574,6 +586,189 @@ def test_read_document_missing_arguments_refused(
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
|
||||
|
||||
# ---------- combined 'source/path' self-correction ----------
|
||||
# The model treats the combined 'source/path' string (search result
|
||||
# lines, read-result headers, refusals) as the document's identity and
|
||||
# sometimes passes it as 'source'. _resolve_document splits it at the
|
||||
# first slash (source names are directory basenames — they can never
|
||||
# contain '/'); a refusal for a still-unknown split teaches the split.
|
||||
|
||||
|
||||
def test_read_combined_source_is_split_and_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""source='S/a/b.md' (the combined form) + path='a/b.md': the exact
|
||||
lookup misses, the first-slash split hits — the read executes, the
|
||||
holder records the document, and the result header carries the TRUE
|
||||
source/path (not the model's raw arguments)."""
|
||||
doc = _doc("S", "a/b.md", "B", "B-CONTENT")
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||||
calls.append((source, path))
|
||||
return doc if (source, path) == ("S", "a/b.md") else None
|
||||
|
||||
monkeypatch.setattr(agent, "find_document", _find)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="read_document",
|
||||
arguments={"source": "S/a/b.md", "path": "a/b.md"},
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
# Exact pair first, then the first-slash split (no third attempt).
|
||||
assert calls == [("S/a/b.md", "a/b.md"), ("S", "a/b.md")]
|
||||
assert holder.read_docs == [doc]
|
||||
assert holder.tool_calls == 1
|
||||
assert llm.requests[1][0][3]["content"] == "Document S/a/b.md:\nB-CONTENT"
|
||||
|
||||
|
||||
def test_read_combined_source_split_at_later_slash(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""source='S/a' + path='b.md' — a split at a LATER slash (source
|
||||
carried source + leading directory, path the remainder): the exact
|
||||
lookup and the first-slash split miss, the continuation candidate
|
||||
(source, split/path) hits."""
|
||||
doc = _doc("S", "a/b.md", "B", "B-CONTENT")
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||||
calls.append((source, path))
|
||||
return doc if (source, path) == ("S", "a/b.md") else None
|
||||
|
||||
monkeypatch.setattr(agent, "find_document", _find)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="read_document",
|
||||
arguments={"source": "S/a", "path": "b.md"},
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert calls == [("S/a", "b.md"), ("S", "a"), ("S", "a/b.md")]
|
||||
assert holder.read_docs == [doc]
|
||||
assert holder.tool_calls == 1
|
||||
assert llm.requests[1][0][3]["content"] == "Document S/a/b.md:\nB-CONTENT"
|
||||
|
||||
|
||||
def test_read_combined_source_unknown_teaches_the_split(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A combined source that matches nothing — even split — gets the
|
||||
EDUCATIONAL refusal: it names the corrected arguments instead of
|
||||
repeating the combined form (the old generic line reinforced the
|
||||
mistake)."""
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="read_document",
|
||||
arguments={"source": "S/a/b.md", "path": "a/b.md"},
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"source must not contain '/': for 'S/a/b.md' call "
|
||||
"read_document(source='S', path='a/b.md')."
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
|
||||
|
||||
|
||||
def test_read_combined_source_for_seed_doc_is_already_in_context(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The combined form of a document ALREADY in context: the raw pair
|
||||
cannot match the dedupe set, so the split resolves it — and it is
|
||||
still rejected as already-in-context (no duplicate read_docs entry,
|
||||
no re-read into the context)."""
|
||||
seed = [_doc("S", "a.md", "A", "A-CONTENT")]
|
||||
|
||||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||||
return seed[0] if (source, path) == ("S", "a.md") else None
|
||||
|
||||
monkeypatch.setattr(agent, "find_document", _find)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="read_document",
|
||||
arguments={"source": "S/a.md", "path": "a.md"},
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings(), seed_docs=seed))
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
assert llm.requests[1][0][3]["content"] == agent.ALREADY_IN_CONTEXT
|
||||
|
||||
|
||||
def test_search_scoped_combined_source_is_split(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A scoped search whose 'source' carries the combined form resolves
|
||||
through the split — the search runs on the right document."""
|
||||
doc = _doc("S", "a.md", "A", "needle here")
|
||||
|
||||
def _find(db: Any, source: str, path: str) -> Document | None:
|
||||
return doc if (source, path) == ("S", "a.md") else None
|
||||
|
||||
monkeypatch.setattr(agent, "find_document", _find)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="search_documents",
|
||||
arguments={"pattern": "needle", "source": "S/a.md", "path": "a.md"},
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"] == "S/a.md:1: needle here"
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == [] # searched doc did not enter the context
|
||||
|
||||
|
||||
def test_search_scoped_combined_source_unknown_teaches_the_split(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[
|
||||
ToolCallPiece(
|
||||
id="call_1",
|
||||
name="search_documents",
|
||||
arguments={"pattern": "x", "source": "S/ghost.md", "path": "ghost.md"},
|
||||
)
|
||||
],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"source must not contain '/': for 'S/ghost.md' use "
|
||||
"source='S', path='ghost.md'."
|
||||
)
|
||||
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
|
||||
|
||||
|
||||
# ---------- search_documents (phase 68, locked A5/A6) ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user