File size: 5,709 Bytes
7f94735
 
 
 
 
95d6832
 
7f94735
 
 
 
a170f20
7f94735
a170f20
7f94735
 
 
 
 
 
 
 
 
470138f
7f94735
 
 
470138f
 
 
 
 
7f94735
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a170f20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f94735
 
6802438
7f94735
 
 
 
 
 
 
a170f20
 
 
 
8e3a1fd
 
 
6802438
a170f20
8e3a1fd
7f94735
 
 
 
 
 
 
 
 
 
 
6802438
 
 
 
 
a170f20
7f94735
a170f20
 
7f94735
 
 
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
"""The manual agent loop: the LLM drives the three tools within budgets.

Each step, a planner call returns a JSON action (search_docs / read_page /
ask_source / answer). We execute it, append a short observation, and repeat
until the model declares it can answer or a budget trips β€” then generate the
grounded Answer from everything accumulated. agent/graph.py is a LangGraph
twin of this loop that shares the same tools, budgets, and planner.
"""

from __future__ import annotations

import ast
import json
import re

from agent.schemas import Answer, Referral

BUDGETS = {"search_docs": 6, "read_page": 2, "ask_source": 1}
MAX_STEPS = sum(BUDGETS.values()) + 2

PLANNER_SYSTEM = (
    "You are planning how to answer a PyTorch question using tools. Each turn, "
    "reply with ONE json object and nothing else:\n"
    '  {"action":"search_docs","query":"english keywords","library":null,"kind":null}\n'
    '  {"action":"read_page","url":"<a url from a previous search result>"}\n'
    '  {"action":"ask_source","question":"..."}  (ONLY for source-code internals)\n'
    '  {"action":"answer"}  (when the gathered context can answer the question)\n'
    'kind picks the content space: "api" = reference pages (use for catalog '
    'questions like "what loss functions exist?" or to find a specific class), '
    '"tutorial" or "guide" = walkthroughs; null = everything. If a search '
    "returned only tutorials but you need the actual API, search again with "
    'kind "api" and the likely symbol names as the query.\n'
    "Guidance: decompose multi-part questions into several search_docs calls "
    "with different queries. Use read_page to open a promising page in full. "
    "Use ask_source only when the answer needs implementation details the docs "
    "don't cover. Answer as soon as you have enough β€” don't waste tool calls."
)


def _plan(question: str, transcript: list[str], budgets: dict, provider, client) -> dict:
    from agent.llm import GenerationError, _raw_completion

    state = "\n".join(transcript) if transcript else "(no tools used yet)"
    remaining = ", ".join(f"{t}:{n}" for t, n in budgets.items())
    prompt = (
        f"Question: {question}\n\nTool calls so far:\n{state}\n\n"
        f"Remaining tool budget: {remaining}\nYour next action as one json object:"
    )
    try:
        raw = _raw_completion(prompt, system=PLANNER_SYSTEM, provider=provider, client=client)
    except GenerationError:
        return {"action": "answer"}  # planner unreachable β†’ answer with what we have
    return _parse_action(raw)


def _parse_action(raw: str) -> dict:
    start, end = raw.find("{"), raw.rfind("}")
    if start == -1 or end == -1:
        return {"action": "answer"}
    try:
        action = json.loads(raw[start : end + 1])
    except json.JSONDecodeError:
        return {"action": "answer"}
    if action.get("action") not in BUDGETS and action.get("action") != "answer":
        return {"action": "answer"}
    return action


def _humanize(line: str) -> str:
    """Turn a transcript step into a short grey trace line for the web UI.

    The transcript entries are terse function-call records (see tools_exec.py);
    this renders them as the model's visible reasoning. Falls back to the raw
    line on any shape it doesn't recognise, so a format change degrades to a
    still-useful trace rather than crashing the answer path.
    """
    m = re.match(r"search_docs\((.+?)\) β†’ (\[.*\])$", line)
    if m:
        try:
            query = ast.literal_eval(m.group(1))
            titles = [str(t).split(" > ")[-1] for t in ast.literal_eval(m.group(2))]
        except (ValueError, SyntaxError):
            return f"πŸ” {line}"
        shown = " Β· ".join(t for t in titles[:4] if t)
        return f"πŸ” searched β€œ{query}”" + (f" β†’ {shown}" if shown else "")
    if line.startswith("read_page"):
        return "πŸ“– read a full page"
    if line.startswith("ask_source"):
        return "πŸ”— checked the source-code references"
    return f"β€’ {line}"


def answer_agentic(
    question: str, provider: str | None = None, client=None, progress=None
) -> Answer:
    """Run the tool loop and return a grounded Answer."""
    from agent.grounded import answer_from_sections
    from agent.tools_exec import do_search, execute_tool

    budgets = dict(BUDGETS)
    sections: list[dict] = []
    referrals: list[Referral] = []
    seen_urls: set[str] = set()
    transcript: list[str] = []

    def emit_last():  # surface the step we just recorded, in the UI's grey trace
        if progress and transcript:
            progress(_humanize(transcript[-1]))

    # always retrieve once for the raw question β€” never depend on a (possibly
    # rate-limited) planner call just to do the obvious first search
    budgets["search_docs"] -= 1
    do_search(question, None, sections=sections, seen_urls=seen_urls, transcript=transcript)
    emit_last()

    for _ in range(MAX_STEPS):
        action = _plan(question, transcript, budgets, provider, client)
        name = action.get("action")

        if name == "answer" or all(v == 0 for v in budgets.values()):
            break
        if budgets.get(name, 0) == 0:
            transcript.append(f"{name}: budget exhausted, pick another action or answer")
            continue
        budgets[name] -= 1

        execute_tool(
            name, action, question,
            sections=sections, referrals=referrals,
            seen_urls=seen_urls, transcript=transcript,
        )
        emit_last()

    if progress:
        progress("✍️ writing the answer")
    return answer_from_sections(
        question, sections, referrals=referrals, provider=provider, client=client
    )