File size: 6,384 Bytes
a170f20
7f94735
 
 
 
 
 
 
 
 
a170f20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f94735
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470138f
7f94735
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e3a1fd
 
 
7f94735
 
 
 
 
 
 
 
8e3a1fd
 
 
470138f
8e3a1fd
7f94735
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470138f
7f94735
 
 
 
 
 
 
 
 
470138f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from agent.loop import _humanize, _parse_action, answer_agentic
from agent.schemas import Answer


def test_parse_action_extracts_json_from_noise():
    assert _parse_action('sure! {"action":"search_docs","query":"sgd"} ')["query"] == "sgd"
    assert _parse_action("no json here")["action"] == "answer"
    assert _parse_action('{"action":"bogus"}')["action"] == "answer"


def test_humanize_renders_each_step_and_falls_back():
    # a search step β†’ query + the last segment of each heading path, capped at 4
    line = "search_docs('sgd momentum') β†’ ['torch.optim > SGD', 'SGD > Per-parameter options']"
    out = _humanize(line)
    assert "πŸ”" in out and "sgd momentum" in out
    assert "SGD" in out and "Per-parameter options" in out
    assert "torch.optim >" not in out  # only the leaf heading is shown
    assert _humanize("read_page(http://x) β†’ full page added").startswith("πŸ“–")
    assert _humanize("ask_source β†’ 2 referral links").startswith("πŸ”—")
    # an unrecognised shape degrades to the raw line, never crashes
    assert "weird step" in _humanize("weird step")


def test_loop_streams_a_progress_trace(monkeypatch):
    scripted = ScriptedAgent(
        ['{"action":"search_docs","query":"dataloader"}', '{"action":"answer"}'], None
    )
    monkeypatch.setattr("agent.llm._raw_completion", scripted.plan)
    monkeypatch.setattr(
        "agent.tools.search_docs",
        lambda q, library=None, kind=None, k=8: {"query": q, "sections": [], "titles": ["H"]},
    )
    monkeypatch.setattr(
        "agent.grounded.answer_from_sections",
        lambda q, s, referrals=None, provider=None, client=None: Answer(answer_md="ok"),
    )
    seen: list[str] = []
    answer_agentic("how do I load data?", progress=seen.append)
    # seed search + planner search are both surfaced, and the final writing step
    assert sum(1 for line in seen if line.startswith("πŸ”")) == 2
    assert seen[-1].startswith("✍️")


class ScriptedAgent:
    """Drives the loop: a queue of planner actions, then a final Answer JSON."""

    def __init__(self, plan_actions, final_answer):
        self._plans = list(plan_actions)
        self._final = final_answer

    def plan(self, prompt, system, provider=None, client=None):
        return self._plans.pop(0)

    def final(self, *a, **k):
        return self._final


def test_loop_decomposes_and_answers(monkeypatch):
    # planner asks for two searches, then answers
    scripted = ScriptedAgent(
        ['{"action":"search_docs","query":"cnn image classification"}',
         '{"action":"search_docs","query":"dataloader images"}',
         '{"action":"answer"}'],
        None,
    )
    calls = {"search": 0}

    def fake_search(query, library=None, kind=None, k=8):
        calls["search"] += 1
        return {"query": query, "sections": [{"url": f"u{calls['search']}", "anchor": "",
                "heading_path": "H", "content": "text"}], "titles": ["H"]}

    monkeypatch.setattr("agent.llm._raw_completion", scripted.plan)
    monkeypatch.setattr("agent.tools.search_docs", fake_search)

    captured = {}

    def fake_answer(question, sections, referrals=None, provider=None, client=None):
        captured["sections"] = sections
        captured["referrals"] = referrals
        return Answer(answer_md="done")

    monkeypatch.setattr("agent.grounded.answer_from_sections", fake_answer)

    result = answer_agentic("how do I build a CNN for images?")
    assert result.answer_md == "done"
    # 1 seed search + 2 planner-driven searches, all distinct results accumulated
    assert calls["search"] == 3
    assert len(captured["sections"]) == 3


def test_loop_source_question_adds_referrals(monkeypatch):
    scripted = ScriptedAgent(
        ['{"action":"ask_source","question":"conv2d internals"}', '{"action":"answer"}'],
        None,
    )
    monkeypatch.setattr("agent.llm._raw_completion", scripted.plan)
    # seed search runs first β€” stub it so the test stays offline
    monkeypatch.setattr(
        "agent.tools.search_docs",
        lambda q, library=None, kind=None, k=8: {"query": q, "sections": [], "titles": []},
    )

    captured = {}

    def fake_answer(question, sections, referrals=None, provider=None, client=None):
        captured["referrals"] = referrals
        return Answer(answer_md="see source")

    monkeypatch.setattr("agent.grounded.answer_from_sections", fake_answer)
    answer_agentic("how is conv2d implemented?")
    assert captured["referrals"]  # ask_source contributed referral links
    assert any("deepwiki" in r.url for r in captured["referrals"])


def test_loop_stops_at_budget(monkeypatch):
    # planner always wants to search; budget must cap it
    scripted = ScriptedAgent(
        ['{"action":"search_docs","query":"x"}'] * 20, None
    )
    monkeypatch.setattr("agent.llm._raw_completion", scripted.plan)
    monkeypatch.setattr(
        "agent.tools.search_docs",
        lambda q, library=None, kind=None, k=8: {"query": q, "sections": [], "titles": []},
    )
    captured = {}
    monkeypatch.setattr(
        "agent.grounded.answer_from_sections",
        lambda q, s, referrals=None, provider=None, client=None: captured.setdefault("hit", True)
        or Answer(answer_md="x"),
    )
    answer_agentic("q")  # must terminate (MAX_STEPS / budget), not loop forever
    assert captured["hit"]


def test_planner_kind_reaches_search_docs(monkeypatch):
    # the planner decides the content space; its kind must reach the tool
    scripted = ScriptedAgent(
        ['{"action":"search_docs","query":"cross entropy loss","kind":"api"}',
         '{"action":"answer"}'],
        None,
    )
    seen = []

    def fake_search(query, library=None, kind=None, k=8):
        seen.append((query, kind))
        return {"query": query, "sections": [], "titles": []}

    monkeypatch.setattr("agent.llm._raw_completion", scripted.plan)
    monkeypatch.setattr("agent.tools.search_docs", fake_search)
    monkeypatch.setattr(
        "agent.grounded.answer_from_sections",
        lambda q, s, referrals=None, provider=None, client=None: Answer(answer_md="ok"),
    )
    answer_agentic("what loss functions exist for classification?")
    # seed search first (no kind), then the planner's api-scoped search
    assert seen[0] == ("what loss functions exist for classification?", None)
    assert seen[1] == ("cross entropy loss", "api")