File size: 4,398 Bytes
95d6832
c07ec17
6802438
 
 
 
c07ec17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6802438
c07ec17
 
 
 
 
 
 
 
 
 
 
6802438
 
 
 
c07ec17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6802438
 
c07ec17
6802438
 
 
 
 
 
 
 
 
 
c07ec17
 
6802438
 
c07ec17
 
95d6832
 
 
 
 
 
 
 
 
 
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
"""The same agent, as a LangGraph state machine.

Identical tools, budgets, planner, and forced seed search as agent/loop.py β€”
the tool step itself is the shared agent/tools_exec.execute_tool, so the two
drivers cannot drift. Only the control flow differs: an explicit graph instead
of a Python while-loop:

    planner ──(action?)──▢ tools ──▢ planner        (cycle)
        └────(answer / budget spent)────▢ generate ──▢ END

The comparison (LoC, debuggability, latency) lives in
docs/loop-vs-langgraph.md.
"""

from __future__ import annotations

from typing import Any, TypedDict

from agent.loop import BUDGETS, MAX_STEPS, _plan
from agent.schemas import Answer, Referral


class AgentState(TypedDict):
    question: str
    provider: str | None
    client: Any
    budgets: dict
    sections: list
    referrals: list
    seen: set
    transcript: list
    steps: int
    action: dict
    answer: Answer | None


def _planner_node(state: AgentState) -> dict:
    action = _plan(
        state["question"], state["transcript"], state["budgets"],
        state["provider"], state["client"],
    )
    return {"action": action, "steps": state["steps"] + 1}


def _route(state: AgentState) -> str:
    action = state["action"]
    if action.get("action") == "answer":
        return "generate"
    if all(v == 0 for v in state["budgets"].values()) or state["steps"] >= MAX_STEPS:
        return "generate"
    return "tools"


def _tools_node(state: AgentState) -> dict:
    from agent.tools_exec import execute_tool

    name = state["action"].get("action")
    budgets, sections = dict(state["budgets"]), list(state["sections"])
    referrals, seen = list(state["referrals"]), set(state["seen"])
    transcript = list(state["transcript"])

    if budgets.get(name, 0) == 0:
        transcript.append(f"{name}: budget exhausted, answer or pick another action")
        return {"budgets": budgets, "transcript": transcript}
    budgets[name] -= 1

    execute_tool(
        name, state["action"], state["question"],
        sections=sections, referrals=referrals, seen_urls=seen, transcript=transcript,
    )

    return {"budgets": budgets, "sections": sections, "referrals": referrals,
            "seen": seen, "transcript": transcript}


def _generate_node(state: AgentState) -> dict:
    from agent.grounded import answer_from_sections

    answer = answer_from_sections(
        state["question"], state["sections"], referrals=state["referrals"],
        provider=state["provider"], client=state["client"],
    )
    return {"answer": answer}


def build_graph():
    from langgraph.graph import END, StateGraph

    g = StateGraph(AgentState)
    g.add_node("planner", _planner_node)
    g.add_node("tools", _tools_node)
    g.add_node("generate", _generate_node)
    g.set_entry_point("planner")
    g.add_conditional_edges("planner", _route, {"tools": "tools", "generate": "generate"})
    g.add_edge("tools", "planner")
    g.add_edge("generate", END)
    return g.compile()


def answer_graph(question: str, provider: str | None = None, client=None) -> Answer:
    """Run the LangGraph version; returns the same grounded Answer as the loop."""
    from agent.tools_exec import do_search

    graph = build_graph()

    # same forced seed search as the manual loop β€” retrieve once for the raw
    # question before the (possibly rate-limited) planner ever runs
    budgets = dict(BUDGETS)
    sections: list[dict] = []
    seen: set[str] = set()
    transcript: list[str] = []
    budgets["search_docs"] -= 1
    do_search(question, None, sections=sections, seen_urls=seen, transcript=transcript)

    initial: AgentState = {
        "question": question, "provider": provider, "client": client,
        "budgets": budgets, "sections": sections, "referrals": [], "seen": seen,
        "transcript": transcript, "steps": 0, "action": {}, "answer": None,
    }
    final = graph.invoke(initial, config={"recursion_limit": 2 * MAX_STEPS + 5})
    if final["answer"] is not None:
        return final["answer"]
    # generate always produces an Answer, so this is pure defensiveness β€” keep
    # it consistent with grounded's empty path instead of a divergent URL
    from agent.grounded import SEARCH_URL

    return Answer(
        answer_md="(no answer produced)",
        referrals=[Referral(url=SEARCH_URL, reason="docs search")],
    )