Compare commits

...
2 Commits
Author SHA1 Message Date
ducoterra 16f1cfbcaf add additional phases and launch.json
Build and Push Containers / build-and-push-app (push) Successful in 2m6s
Build and Push Containers / build-and-push-db (push) Successful in 11s
2026-09-02 18:59:09 -04:00
ducoterra 0bf96f22e1 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.
2026-09-02 17:42:57 -04:00
6 changed files with 511 additions and 64 deletions
@@ -0,0 +1,11 @@
Working tree is clean and committed. Task 03 complete.
**Report — Phase 69, Task 03 (E2E suite, regression update, README, commit)**
- New `tests/e2e/test_source_removal_cleanup.py` — 6 mapped tests: upload→modal→total removal (disk folder + index + one DELETE + "Removing…" in-flight state, deterministic via request-holding), git checkout removal from disk, no-checkout no-op, foreign local dir never touched (dir + marker survive), Cancel/Escape with zero DELETEs + focus return, modal a11y (aria wiring, 3px focus-visible on both buttons, ≥44px, announcer success line) + no-CDN.
- `tests/e2e/test_git_sources_admin.py` — test 4 reworked from the Playwright `dialog` handler to the modal (`#remove-confirm-remove`/`#remove-confirm-cancel`); `Dialog` import removed; docstring updated.
- `README.md` — git-sources + local-sources sections rewritten to the total-removal contract; no "next sync" removal semantics left.
- Defects fixed: task-02 bug where `loadSources()`' "N sources listed." immediately overwrote the removal announcement (success path reordered close→reload→announce; task-02 unit order-pin updated to match), and an un-indented docstring in `app/api/git_sources.py`.
- Gates: `uv run pytest --cov=app --cov-report=term-missing` → 1364 passed, TOTAL **99%**; `uv run ruff check . && uv run pyright` → clean (0 errors); `uv run pytest tests/e2e/test_source_removal_cleanup.py -v --no-cov` → 6 passed; `test_git_sources_admin.py` → 6 passed; `test_archive_upload_sources.py` → 5 passed (all in isolation, DB up).
- Committed `137d5fa --no-gpg-sign` (24 files, incl. the uncommitted phase-68 test-pin sync in `test_agent.py` needed to keep HEAD green); phase dir merged into `.agent/phases/complete/69_source_removal_cleanup/` (harness had pre-created it, so a plain `mv` would have nested).
- Next pending task: none in this phase — `todo/` is empty; phase 69 complete.
@@ -0,0 +1,74 @@
........................................................................ [ 5%]
........................................................................ [ 10%]
........................................................................ [ 15%]
........................................................................ [ 21%]
........................................................................ [ 26%]
........................................................................ [ 31%]
........................................................................ [ 36%]
........................................................................ [ 42%]
........................................................................ [ 47%]
........................................................................ [ 52%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 68%]
........................................................................ [ 73%]
........................................................................ [ 79%]
........................................................................ [ 84%]
........................................................................ [ 89%]
........................................................................ [ 95%]
.................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 153 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 126 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 52 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 120 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 213 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 65 0 100%
app/rag/retriever.py 94 3 97%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 205 0 100%
-----------------------------------------------
TOTAL 2707 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
+24
View File
@@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Remote Attach",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "."
}
]
}
]
}
+105 -39
View File
@@ -45,7 +45,13 @@ task 04):
half-specified ``source``/``path`` target; a document already in
context (seed or previously read) → ``"Already in your
context."``; an unknown ``source/path`` (read or scoped search) →
``"No document at …"``. A search that ran but found nothing is NOT a
``"No document at …"``. A ``source`` argument containing a ``'/'``
(the model passed the combined ``source/path`` form) is first
self-corrected by splitting at the first slash (see
:func:`_resolve_document` — source names are directory basenames and
can never contain ``'/'``); if the split still matches nothing, the
refusal teaches the split instead of repeating the combined form.
A search that ran but found nothing is NOT a
rejection — its ``"No matches for …"`` line is a (counted) result.
A rejected call still consumes a *round* in the loop, so a
pathological stream that keeps emitting rejected calls is bounded by
@@ -104,6 +110,33 @@ from app.rag.llm import (
logger = logging.getLogger("app.agent")
#: Parameter descriptions shared by ``read_document`` and
#: ``search_documents``. The model repeatedly conflated the two fields —
#: passing the combined ``source/path`` string (as printed in search
#: result lines, read-result headers and refusals) as ``source`` — so
#: the descriptions define the split explicitly: ``source`` is the part
#: BEFORE the first ``'/'``, ``path`` the part after it, with a worked
#: example in the ``read_document`` description itself.
_SOURCE_PARAM: dict[str, Any] = {
"type": "string",
"description": (
"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."
),
}
_PATH_PARAM: dict[str, Any] = {
"type": "string",
"description": (
"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."
),
}
#: The three agent tools (phase 37; ``search_documents`` added in phase
#: 68): OpenAI function definitions passed as ``tools=AGENT_TOOLS`` to
#: ``chat_stream`` for the whole grounded turn — phase 45 removed the
@@ -127,26 +160,21 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"name": "read_document",
"description": (
"Add the full content of one more indexed document "
"to your context"
"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')."
),
"parameters": {
"type": "object",
"properties": {
"source": {
"type": "string",
"description": (
"The document's source, as shown after 'source: ' in the "
"list_documents output."
),
},
"path": {
"type": "string",
"description": (
"The document's path, as shown after 'path: ' in the "
"list_documents output."
),
},
},
"properties": {"source": _SOURCE_PARAM, "path": _PATH_PARAM},
"required": ["source", "path"],
},
},
@@ -159,9 +187,11 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"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."
),
"parameters": {
"type": "object",
@@ -173,20 +203,8 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"substring, not a regex)"
),
},
"source": {
"type": "string",
"description": (
"The document's source, as shown after 'source: ' in the "
"list_documents output."
),
},
"path": {
"type": "string",
"description": (
"The document's path, as shown after 'path: ' in the "
"list_documents output."
),
},
"source": _SOURCE_PARAM,
"path": _PATH_PARAM,
},
"required": ["pattern"],
},
@@ -239,6 +257,36 @@ def find_document(db: Session, source: str, path: str) -> Document | None:
)
def _resolve_document(
db: Session, source: str, path: str
) -> tuple[Document | None, str, str]:
"""``(source, path)`` → document, with combined-form self-correction.
The exact pair is tried first. If it misses and *source* contains a
``'/'``, the model passed the combined ``source/path`` form — search
result lines, read-result headers and the generic refusal all print
that form, so the model treats it as the document's identity. Source
names are directory basenames (``app.rag.importer``: ``source =
root.name``) and can never contain a ``'/'``, so the pair is retried
at the FIRST slash: the part before is the source name, the part
after is the path. A second candidate covers a split at a LATER
slash (``source`` carried source + leading directories, ``path`` the
remainder).
Returns ``(doc, src, p)`` where ``(src, p)`` is the first-slash
split when one was attempted (so a refusal can teach it), else the
original pair.
"""
doc = find_document(db, source, path)
if doc is not None or "/" not in source:
return doc, source, path
split_source, _, split_path = source.partition("/")
doc = find_document(db, split_source, split_path)
if doc is None and path and path != split_path:
doc = find_document(db, split_source, f"{split_path}/{path}")
return doc, split_source, split_path
def all_documents(db: Session) -> list[Document]:
"""Every indexed document (full rows), ordered by ``(source, path)``
— catalog order.
@@ -300,7 +348,8 @@ def _execute_tool(
is a locator, locked A5); rejected calls return their refusal line
and count in nothing. A search that ran but found nothing is still a
successful (counted) call — its no-match line is a result, not a
refusal.
refusal. A combined-form ``source`` (containing a ``'/'``) is
self-corrected through :func:`_resolve_document` before any refusal.
"""
if call.name == "list_documents":
rows = list_catalog(db)
@@ -320,14 +369,26 @@ def _execute_tool(
known = {(doc.source, doc.path) for doc in (*seed_docs, *holder.read_docs)}
if (source, path) in known:
return ALREADY_IN_CONTEXT
doc = find_document(db, source, path)
doc, split_source, split_path = _resolve_document(db, source, path)
if doc is None:
if "/" in source:
# Educational refusal: the combined form is the model's
# mistake — teach the split instead of repeating it.
return (
f"source must not contain '/': for '{source}' call "
f"read_document(source='{split_source}', "
f"path='{split_path}')."
)
return (
f"No document at {source}/{path} — check the list_documents output."
)
if (doc.source, doc.path) in known:
# A self-corrected combined form for a document already in
# context (the raw pair above cannot have matched it).
return ALREADY_IN_CONTEXT
holder.read_docs.append(doc)
holder.tool_calls += 1
return f"Document {source}/{path}:\n{doc.content}"
return f"Document {doc.source}/{doc.path}:\n{doc.content}"
if call.name == "search_documents":
raw_pattern = call.arguments.get("pattern")
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
@@ -343,8 +404,13 @@ def _execute_tool(
# whole-KB search (house style).
return MISSING_SEARCH_ARGS
if source:
target = find_document(db, source, path)
target, split_source, split_path = _resolve_document(db, source, path)
if target is None:
if "/" in source:
return (
f"source must not contain '/': for '{source}' use "
f"source='{split_source}', path='{split_path}'."
)
return (
f"No document at {source}/{path} — check the list_documents output."
)
+78 -1
View File
@@ -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()
+219 -24
View File
@@ -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) ----------