File size: 26,486 Bytes
b74c6ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
"""Chat research: does the agent look beyond the post it used last time?

Seen on a real chat: after answers about "Agent Memory From Scratch", the writer asked "how does neo4j fit into this
graph based memory?". The notebook has a whole post on that ("Inside Neo4j's Agent Memory"), but the agent scoped every
search to the remembered post with `path=`, never called list_files, and cited only that one post. These tests script
that chat on a small notebook and record every tool call the agent makes.

The static tests always run: they check that the notebook reproduces the conditions, that the checker tells the real
bugged tool calls from good ones, and that the prompts carry the instruction. The live tests (opt-in) run the real
agent:

    LIVE_EVALS=1 pytest tests/evals/test_chat_tool_use.py -k live
    # the prompt from before the fix, for comparison
    LIVE_EVALS=1 EVAL_BASELINE=1 pytest tests/evals/test_chat_tool_use.py -k baseline -s

Each live case runs LIVE_EVAL_RUNS times (default 3) and needs a majority to pass, because a model's choices vary. It
uses LLM_API_KEY from the app's .env files and makes about 10 calls per run on the main model."""

import functools
import os
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime

import dspy
import pytest

from app.config import settings
from app.corpus import INDEX, Corpus, ScopedTools, index_line, render_post
from app.llm import provider
from app.llm.signatures import ResearchArchive, ResearchArchiveWithMemory
from app.pipeline.research import Turn, _retry_malformed_steps, archive_guide, conversation_text, verify_citations
from app.pipeline.trace import ChatTrace
from tests.evals.live import env_value

SOURCE = "sources/substack-com/"
UNIFIED = SOURCE + "2026-07-14-how-to-implement-a-unified-memory-from-scratch.md"  # the post the chat kept using
NEO4J = SOURCE + "2026-05-19-understanding-neo4j-graph-agent-memory-system.md"  # the post that answers the question
ONTOLOGY = SOURCE + "2026-05-26-ship-a-knowledge-graph-ontology-in-5-minutes.md"
CLEAN = SOURCE + "2026-06-02-keep-knowledge-graph-clean.md"
EVALS_POST = SOURCE + "2026-09-22-evaluate-ai-agents-benchmarks-regression-tests.md"
LOOP = SOURCE + "2026-07-28-the-coding-agent-loop.md"

# The notebook (title, date, sections). The unified post comes first and has more than 40 lines about memory and graphs,
# as on the real notebook, so one unscoped search for "memory" is filled by that post alone.
POSTS = {
    UNIFIED: ("Agent Memory From Scratch", datetime(2026, 7, 14, tzinfo=UTC), [
        ("Why knowledge graphs", "I build agent memory the most complicated way, with a knowledge graph, because all "
                                 "the popular vendors (cognee, Graphiti, Neo4j's agent memory) go that way.\n\n"
                                 + "\n\n".join(f"The memory layer keeps graph state number {i} next to its source."
                                              for i in range(45))),
        ("The ontology", "One powerful ontology design is based on the 5 POLE+O nouns from law-enforcement analysis: "
                         "Person, Object, Location, Event and Organization."),
        ("When to leave MongoDB", "When do you need to switch from a single database (MongoDB) to a graph database "
                                  "(Neo4j)? Past 4 hops, past 100M to 1B vectors, when graphs are your business logic, "
                                  "or to visualize them."),
        ("Build vs buy", "Level 2: keep the business logic and serving layer on top of a memory SDK, such as Graphiti, "
                         "neo4j-labs/agent-memory, or mem0."),
    ]),
    NEO4J: ("Inside Neo4j's Agent Memory", datetime(2026, 5, 19, tzinfo=UTC), [
        ("What's inside neo4j-labs/agent-memory", "The SDK keeps one Neo4j graph with three memory tiers: short-term "
                                                  "messages, long-term entities and a reasoning trace of tool calls."),
        ("The extraction pipeline", "Neo4j's agent memory runs a three-stage extraction pipeline: spaCy, then GLiNER, "
                                    "then an LLM, and merges their entities."),
        ("Resolving duplicates", "A composite resolver matches entities by exact name, fuzzy string and semantic "
                                 "similarity. Near matches are linked with a SAME_AS relationship instead of merged."),
    ]),
    ONTOLOGY: ("Stop Chasing the Perfect Ontology", datetime(2026, 5, 26, tzinfo=UTC), [
        (None, "Ship a first knowledge graph ontology in five minutes and refine it from real data. Neo4j or any graph "
               "store works.")]),
    CLEAN: ("How to Keep Your AI Agent's Knowledge Graph Clean", datetime(2026, 6, 2, tzinfo=UTC), [
        (None, "Deduplicate entities before they reach the knowledge graph, in Neo4j or elsewhere.")]),
    EVALS_POST: ("Agent Evals 101", datetime(2026, 9, 22, tzinfo=UTC), [
        (None, "Binary metrics tied to real behavior beat vague scores. Run regression tests on every change.")]),
    LOOP: ("The Bare-Bones Coding Agent Loop", datetime(2026, 7, 28, tzinfo=UTC), [
        (None, "A coding agent is a loop: call the model, run the tools it asks for, feed the results back.")]),
}


class Store:
    """Stands in for R2."""

    def __init__(self):
        self.objects: dict[str, bytes] = {}

    def put_text(self, key, text, content_type=None):
        self.objects[key] = text.encode()
        return key

    def get_bytes(self, key):
        return self.objects[key]

    def exists(self, key):
        return key in self.objects

    def delete(self, key):
        self.objects.pop(key, None)


def build_notebook(tmp_path) -> tuple[Corpus, dict[str, str]]:
    """(the corpus, the notebook's allowed posts as path -> title, the remembered post first)"""
    corpus = Corpus(uuid.uuid4(), store=Store(), cache_dir=str(tmp_path))
    files = {INDEX: "\n".join(index_line(p, t, d, [h for h, _ in s if h]) for p, (t, d, s) in POSTS.items())}
    for path, (title, date, sections) in POSTS.items():
        files[path] = render_post(title=title, url="https://x/p/" + path, published_at=date, source="substack-com",
                                  sections=sections)
    corpus.write_files(files)
    corpus.sync()
    return corpus, {p: t for p, (t, _, _) in POSTS.items()}


# What the agent was given on the real chat: the summary naming the post, and the last messages, all about that post.

CHAT = [
    Turn("user", "and how do we handle ai agents memory?"),
    Turn("assistant", "Your archive covers this in **Agent Memory From Scratch**, which builds a unified memory layer: "
                      "sources in, subgraphs out, with an ontology, a write path, one collection and a serving layer."),
    Turn("user", "explain this more"),
    Turn("assistant", "Let me go deeper into **Agent Memory From Scratch**: the ontology internals, the two-step "
                      "entity cleanup and the storage decision."),
    Turn("user", "graphs are also used in agent memory right?"),
    Turn("assistant", "Yes, in your archive they are the backbone of **Agent Memory From Scratch**, which builds "
                      "memory on knowledge graphs because popular vendors (cognee, Graphiti, Neo4j's agent) do."),
]

ANCHORED_MEMORY = (
    "Topic: Agent memory (first 2026-10-05, last discussed 2026-10-05, 4 rounds). Context only, never evidence about "
    "the posts. To cite what was said earlier, use the file and line given in brackets.\n\n"
    "Summary [chats/cd974679/agent-memory.md:16]: The writer asked how to handle AI agents' memory. The assistant "
    f"answered from the post Agent Memory From Scratch ({UNIFIED}), which walks through building a unified memory "
    "layer. Later, the writer asked whether graphs are used in agent memory, and the assistant said they are the "
    "backbone of the whole design: the post builds memory via knowledge graphs because popular vendors (cognee, "
    "Graphiti, Neo4j's agent memory) go that direction. This was discussed, not verified.\n\n"
    "Latest messages in this topic, word for word:\n"
    + "\n".join(f"{'Writer' if t.role == 'user' else 'Assistant'} (2026-10-05): {t.text}" for t in CHAT))

# Later in the same chat, after two answers about Neo4j that also came only from the remembered post. The next message,
# "explainn in more detail", does not name a subject at all: the subject is only in the conversation and the notes.
LATER_CHAT = CHAT + [
    Turn("user", "how does neo4j fit into this graph based memory?"),
    Turn("assistant", "Neo4j fits into this graph-based memory design in four distinct ways, all from **Agent Memory "
                      "From Scratch**: it is the source of the ontology, the database you graduate to, an internal "
                      "exploration tool, and a buy option."),
    Turn("user", "explain more thoroughly on how neo4j could be useful and how it fits in"),
    Turn("assistant", "Let me lay out the full picture of Neo4j's usefulness, all from **Agent Memory From Scratch**: "
                      "traversal depth, Cypher for analysis, visualization, and the POLE+O ontology it borrows."),
]
LATER_MEMORY = (
    "Topic: Agent memory (first 2026-10-05, last discussed 2026-10-05, 5 rounds). Context only, never evidence about "
    "the posts. To cite what was said earlier, use the file and line given in brackets.\n\n"
    "Summary [chats/cd974679/agent-memory.md:16]: The writer asked how to handle AI agents' memory. The assistant "
    f"answered from the post Agent Memory From Scratch ({UNIFIED}), which walks through building a unified memory "
    "layer. Later the writer asked more thoroughly how Neo4j could be useful and how it fits in. The assistant said "
    "the post builds the memory as a knowledge graph on purpose, the way every popular vendor including Neo4j's agent "
    "memory does. Neo4j is useful technically (deep multi-hop traversal, Cypher for analysis, visualization) and "
    "conceptually (the POLE+O ontology and extraction shape the design borrows), fitting in as the escape hatch and "
    "exploration companion to a simple single-collection production setup. This was discussed, not verified.\n\n"
    "Latest messages in this topic, word for word:\n"
    + "\n".join(f"{'Writer' if t.role == 'user' else 'Assistant'} (2026-10-05): {t.text}" for t in LATER_CHAT[-6:]))

@dataclass
class Case:
    """One follow up on the anchored chat: the writer's message, the step budget triage gave it, the chat so far, the
    memory notes, and which check applies to the agent's tool calls (see `judge`)."""

    question: str
    budget: int
    chat: list
    memory: str
    check: str


# The first three are the real bugged messages (the third came after the first two on the same chat). The rest are other
# kinds of follow up on the same anchored chat, so a fix for one cannot break the others.
CASES = {
    "neo4j-quick": Case("how does neo4j fit into this graph based memory?", 4, CHAT, ANCHORED_MEMORY, "wide:neo4j"),
    "neo4j-deep": Case("explain more thoroughly on how neo4j could be useful and how it fits in", 10, CHAT,
                       ANCHORED_MEMORY, "wide:neo4j"),
    "bare-follow-up": Case("explainn in more detail", 10, LATER_CHAT, LATER_MEMORY, "wide:neo4j"),
    # a new subject: the answer is in a post the chat has not used
    "new-subject": Case("how do I evaluate my agents with regression tests?", 4, CHAT, ANCHORED_MEMORY, "wide:evals"),
    # the writer names the post: limiting the answer to it is what they asked for
    "explicit-post": Case("Using only the Agent Memory From Scratch post, explain how neo4j fits into this graph "
                          "based memory", 4, CHAT, ANCHORED_MEMORY, "only-remembered"),
    # a detail that the remembered post (or the ontology post) holds: the agent must still answer with citations
    "same-post-detail": Case("explain the POLE+O ontology part in more detail", 10, CHAT, ANCHORED_MEMORY,
                             "answers"),
}


# Judging the tool calls

def problems(calls: list[tuple[str, dict]], dedicated: str = NEO4J) -> list[str]:
    """What is wrong with an agent's tool calls for a question that a post other than the remembered one answers."""
    out = []
    looked_wide = any(name == "list_files" for name, _ in calls) or any(
        name == "search" and not args.get("path") for name, args in calls)
    if not looked_wide:
        out.append("never looked beyond the remembered post: no list_files, and every search was limited with path=")
    if not any(name == "read" and args.get("path") == dedicated for name, args in calls):
        out.append("never read the post devoted to the question")
    return out


class Recorder:
    """The agent's three tools, each call written down with its arguments before it runs."""

    def __init__(self, tools: ScopedTools):
        self.calls: list[tuple[str, dict]] = []
        for name in ("list_files", "search", "read"):
            setattr(self, name, self._wrap(getattr(tools, name)))

    def _wrap(self, fn):
        @functools.wraps(fn)  # keeps the name, docstring and signature the agent is shown
        def call(*args, **kwargs):
            self.calls.append((fn.__name__, kwargs))
            return fn(*args, **kwargs)

        return call


# The real tool calls from the chat, read from the model's cached replies

REAL_ANSWER_3 = [("search", {"pattern": "graph", "path": UNIFIED}),
                 ("read", {"path": UNIFIED, "start_line": 26, "end_line": 34}),
                 ("read", {"path": UNIFIED, "start_line": 56, "end_line": 82})]
REAL_ANSWER_4 = [("search", {"pattern": "neo4j", "path": UNIFIED}),
                 ("read", {"path": UNIFIED, "start_line": 180, "end_line": 215})]
REAL_ANSWER_5 = [("search", {"pattern": "neo4j|cypher|graph database", "path": UNIFIED}),
                 ("read", {"path": UNIFIED, "start_line": 175, "end_line": 215}),
                 ("read", {"path": UNIFIED, "start_line": 24, "end_line": 36})]
# An earlier chat with nothing anchoring it did what is wanted: it searched the whole archive and read the right post.
GOOD = [("search", {"pattern": "graph memor|knowledge graph|graphrag"}),
        ("read", {"path": NEO4J, "start_line": 1, "end_line": 145}),
        ("read", {"path": UNIFIED, "start_line": 1, "end_line": 160})]


def test_the_notebook_reproduces_the_conditions_of_the_bug(tmp_path):
    corpus, allowed = build_notebook(tmp_path)
    tools = ScopedTools(corpus, allowed)
    assert list(allowed)[0] == UNIFIED and len(allowed) == 6
    found = [line.split(":")[0] for line in tools.search("memory").splitlines() if line.startswith("sources")]
    assert set(found) == {UNIFIED} and len(found) == 40  # one unscoped search is filled by the remembered post alone
    assert "more matches" in tools.search("memory")
    scoped = tools.search("neo4j", path=UNIFIED)
    assert scoped.count(UNIFIED) >= 2 and NEO4J not in scoped  # scoping hides the post devoted to the question
    assert "SAME_AS" in "\n".join(corpus.read_lines(NEO4J)) and "SAME_AS" not in "\n".join(corpus.read_lines(UNIFIED))


def test_the_memory_block_names_the_post_like_the_real_one():
    assert f"answered from the post Agent Memory From Scratch ({UNIFIED})" in ANCHORED_MEMORY
    assert ANCHORED_MEMORY.count("Agent Memory From Scratch") >= 4
    assert len(ANCHORED_MEMORY) < settings.chat_memory_context_chars
    assert "Latest messages in this topic, word for word:" in ANCHORED_MEMORY
    assert conversation_text(CHAT).count("Writer:") == 3


def test_the_bare_follow_up_case_carries_the_later_chat_like_the_real_one():
    case = CASES["bare-follow-up"]
    assert case.question == "explainn in more detail" and case.budget == 10 and case.chat is LATER_CHAT
    assert "neo4j" not in case.question.lower()  # the subject is only in the conversation and the notes
    assert f"answered from the post Agent Memory From Scratch ({UNIFIED})" in case.memory
    assert len(case.memory) < settings.chat_memory_context_chars and "Neo4j" in case.memory
    assert conversation_text(case.chat).count("Writer:") == 4  # the agent sees the last 8 turns, as in the app


def test_every_case_is_anchored_on_the_remembered_post_and_has_a_known_check():
    assert len(CASES) == 6
    for name, case in CASES.items():
        assert UNIFIED in case.memory and case.check in {"wide:neo4j", "wide:evals", "only-remembered", "answers"}, name
        assert case.budget in (4, 10) and case.question.strip()
    assert "only" in CASES["explicit-post"].question.lower()  # the one case where limiting to a post is right
    assert not any(w in CASES["new-subject"].question.lower() for w in ("neo4j", "memory", "graph"))


@pytest.mark.parametrize("calls", [REAL_ANSWER_3, REAL_ANSWER_4, REAL_ANSWER_5], ids=["answer3", "answer4", "answer5"])
def test_the_real_bugged_tool_calls_are_flagged(calls):
    found = problems(calls)
    assert len(found) == 2 and "never looked beyond the remembered post" in found[0], found


def test_the_checker_accepts_a_search_over_the_whole_archive_and_a_read_of_the_right_post():
    assert problems(GOOD) == []
    assert problems([("list_files", {}), ("read", {"path": NEO4J})]) == []  # listing the posts is looking wide too


def test_the_judge_applies_the_check_each_case_asks_for():
    wide = CASES["neo4j-quick"]
    assert judge(wide, GOOD, [NEO4J, UNIFIED]) == []
    assert "does not cite the post devoted" in " ".join(judge(wide, GOOD, [UNIFIED]))
    assert len(judge(wide, REAL_ANSWER_4, [UNIFIED])) == 3  # scoped, never read it, never cited it
    evals_calls = [("search", {"pattern": "regression"}), ("read", {"path": EVALS_POST})]
    assert judge(CASES["new-subject"], evals_calls, [EVALS_POST]) == []
    assert judge(CASES["new-subject"], REAL_ANSWER_4, [UNIFIED])  # staying on the remembered post fails here
    only = CASES["explicit-post"]
    assert judge(only, REAL_ANSWER_4, [UNIFIED]) == []  # the writer asked for that post, so staying in it is right
    assert judge(only, GOOD, [NEO4J, UNIFIED]) == ["read another post: " + NEO4J.rsplit("/", 1)[-1],
                                                   "cited another post: " + NEO4J.rsplit("/", 1)[-1]]
    detail = CASES["same-post-detail"]
    assert judge(detail, [("search", {"pattern": "POLE"})], [UNIFIED]) == []
    assert judge(detail, [], [UNIFIED]) == ["used no tools"]
    assert judge(detail, REAL_ANSWER_4, [EVALS_POST]) == ["the answer does not cite the posts that hold the ontology"]


def test_the_checker_names_each_missing_step():
    only_listed = [("list_files", {}), ("read", {"path": UNIFIED})]
    assert problems(only_listed) == ["never read the post devoted to the question"]
    only_wide_search = [("search", {"pattern": "neo4j"})]
    assert problems(only_wide_search) == ["never read the post devoted to the question"]
    read_without_looking = [("read", {"path": NEO4J})]
    assert "never looked beyond" in problems(read_without_looking)[0]  # reading it blind is luck, not research


def test_the_recorder_writes_down_calls_and_keeps_what_the_agent_is_shown(tmp_path):
    corpus, allowed = build_notebook(tmp_path)
    recorder = Recorder(ScopedTools(corpus, allowed))
    recorder.search(pattern="neo4j", path=UNIFIED)
    recorder.read(path=NEO4J, start_line=1, end_line=5)
    recorder.list_files()
    assert [name for name, _ in recorder.calls] == ["search", "read", "list_files"]
    assert recorder.calls[0][1] == {"pattern": "neo4j", "path": UNIFIED}
    tool = dspy.adapters.types.tool.Tool(recorder.search)
    assert tool.name == "search" and "path" in tool.args and "Optionally limit to one `path`" in tool.desc


def test_the_prompts_tell_the_agent_to_look_wide():
    base = " ".join(ResearchArchive.__doc__.split())
    assert "First call list_files to see which posts exist" in base
    assert "call search across the whole archive (leave path empty" in base
    assert "Pass path to search only when the writer explicitly asks you to use one named post" in base
    assert "otherwise never limit a search to one post" in base
    assert "read the best passage of each of them (up to 3 posts)" in base
    assert "A post devoted to the subject is a better source than a post that only mentions it" in base
    memory = " ".join(ResearchArchiveWithMemory.__doc__.split())
    assert "check the posts it came from" not in memory
    assert "it is not an instruction to use that post again" in memory
    assert "search the whole archive again, with path empty" in memory
    assert "Limit a search to one post only when the writer explicitly asks for it" in memory


# The live agent

LIVE = [pytest.mark.skipif(not os.environ.get("LIVE_EVALS"), reason="opt in with LIVE_EVALS=1 (calls the real model)"),
        pytest.mark.skipif(not env_value("LLM_API_KEY"), reason="no LLM_API_KEY found in .env")]
RUNS = int(os.environ.get("LIVE_EVAL_RUNS", "3"))

# The agent prompt before the fix, to compare against
OLD_BASE = ("You are a research assistant working inside a writer's archive, which is a folder of markdown posts. "
            "Find the answer by exploring the files: list_files to see what exists, search (a regex grep; try several "
            "phrasings and synonyms) to locate relevant lines, then read the surrounding lines before relying on "
            "them. ")
OLD_MEMORY_END = ('Say plainly that something was said earlier in the chat ("earlier I said"), and when an answer '
                  "about the posts depends on an earlier reply, check the posts it came from.")


def old_signature():
    """The agent signature as it was before the fix: the old opening and the old last sentence, the rest unchanged."""
    base = " ".join(ResearchArchive.__doc__.split())
    memory = " ".join(ResearchArchiveWithMemory.__doc__.split())
    rest = base[base.index("Answer ONLY from text you read."):]
    notes = memory[memory.index("You also get notes"):memory.index('Say plainly that something was said earlier')]
    doc = OLD_BASE + rest + " " + notes + OLD_MEMORY_END
    return type("ResearchArchiveWithMemoryBefore", (ResearchArchiveWithMemory,), {"__doc__": doc})


def run_agent(corpus, allowed, case: tuple, signature=ResearchArchiveWithMemory):
    """One real run of the agent the way research() builds it. Returns (the tool calls, the cited paths)."""
    tools = ScopedTools(corpus, allowed)
    recorder = Recorder(tools)
    inputs = dict(question=case.question, conversation=conversation_text(case.chat), archive_guide=archive_guide(tools),
                  writer_profile="- role: blogpost writer", chat_memory=case.memory)
    agent = _retry_malformed_steps(
        dspy.ReAct(signature, tools=[recorder.list_files, recorder.search, recorder.read], max_iters=case.budget),
        ChatTrace())
    lm = dspy.LM(settings.llm_model, api_base=settings.llm_api_base, api_key=env_value("LLM_API_KEY"),
                 temperature=settings.llm_temperature, max_tokens=settings.llm_max_tokens, cache=False,
                 **provider._extra_kwargs(None))
    with dspy.context(lm=lm, adapter=dspy.JSONAdapter()):  # the adapter the app uses
        pred = agent(**inputs)
    _, cites = verify_citations(tools, pred.answer, pred.citations or [])
    return recorder.calls, [c.path for c in cites]


def describe(calls, cited) -> str:
    steps = " -> ".join(f"{n}({', '.join(f'{k}={str(v).rsplit('/', 1)[-1]}' for k, v in a.items() if k != 'end_line')})"
                        for n, a in calls)
    return f"{steps}  |  cited: {sorted({p.rsplit('/', 1)[-1] for p in cited})}"


def judge(case: Case, calls, cited) -> list[str]:
    """What is wrong with one run, by the kind of check the case asks for. An empty list is a pass."""
    kind, _, subject = case.check.partition(":")
    reads = {args.get("path") for name, args in calls if name == "read"}
    if kind == "wide":  # the answer is in a post other than the remembered one: look wide, read it, cite it
        dedicated = {"neo4j": NEO4J, "evals": EVALS_POST}[subject]
        found = problems(calls, dedicated)
        if dedicated not in cited:
            found.append("the answer does not cite the post devoted to the question")
        return found
    if kind == "only-remembered":  # the writer named the post: stay inside it
        found = [f"read another post: {p.rsplit('/', 1)[-1]}" for p in sorted(reads - {UNIFIED}) if p]
        found += [f"cited another post: {p.rsplit('/', 1)[-1]}" for p in sorted(set(cited) - {UNIFIED})]
        if UNIFIED not in cited:
            found.append("the answer does not cite the post the writer asked for")
        return found
    # "answers": any good source will do, but the agent must research and cite
    found = []
    if not calls:
        found.append("used no tools")
    if not set(cited) & {UNIFIED, ONTOLOGY}:
        found.append("the answer does not cite the posts that hold the ontology")
    return found


def run_case(tmp_path, case: str, signature=ResearchArchiveWithMemory) -> tuple[int, list[str]]:
    corpus, allowed = build_notebook(tmp_path)
    passed, report = 0, []
    for n in range(RUNS):
        calls, cited = run_agent(corpus, allowed, CASES[case], signature)
        found = judge(CASES[case], calls, cited)
        passed += not found
        report.append(f"run {n + 1}: {'ok' if not found else 'FAIL ' + '; '.join(found)}\n    {describe(calls, cited)}")
    return passed, report


@pytest.mark.parametrize("case", list(CASES))
class TestLiveToolUse:
    pytestmark = LIVE

    def test_the_agent_looks_beyond_the_remembered_post(self, tmp_path, case):
        passed, report = run_case(tmp_path, case)
        print(f"\n[{case}] current prompt: {passed}/{RUNS} runs passed the '{CASES[case].check}' check")
        print("\n".join(report))
        assert passed > RUNS / 2, f"{passed}/{RUNS} runs passed\n" + "\n".join(report)


@pytest.mark.skipif(not os.environ.get("EVAL_BASELINE"), reason="opt in with EVAL_BASELINE=1 (a comparison only)")
@pytest.mark.parametrize("case", list(CASES))
def test_baseline_with_the_prompt_from_before_the_fix(tmp_path, case):
    if not (os.environ.get("LIVE_EVALS") and env_value("LLM_API_KEY")):
        pytest.skip("needs LIVE_EVALS=1 and LLM_API_KEY")
    passed, report = run_case(tmp_path, case, old_signature())
    print(f"\n[{case}] OLD prompt: {passed}/{RUNS} runs passed the '{CASES[case].check}' check")
    print("\n".join(report))