File size: 6,397 Bytes
f019486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""LangGraph ReAct loop: state, pure helpers, nodes, graph build, and a streaming runner."""

import json
import time
from typing import Annotated, Optional, TypedDict

from langgraph.config import get_stream_writer
from langgraph.graph import END, START, StateGraph
from google.genai import types

from agent import config, llm


def split_response(response):
    """Return (thought_text, answer_text, function_calls, model_content)."""
    content = response.candidates[0].content
    thoughts, answers, calls = [], [], []
    for part in (content.parts or []):
        fc = getattr(part, "function_call", None)
        if fc:
            calls.append(fc)
            continue
        text = getattr(part, "text", None)
        if not text:
            continue
        if getattr(part, "thought", False):
            thoughts.append(text)
        else:
            answers.append(text)
    return "\n".join(thoughts).strip(), "\n".join(answers).strip(), calls, content


def classify_weak(tool_name: str, observation: str) -> bool:
    """True when an observation should trigger reconsideration."""
    text = (observation or "").strip()
    if text.startswith("ERROR:") or text.startswith("NO_RESULTS"):
        return True
    if tool_name == "calculator":
        return False
    if len(text) < config.WEAK_OBS_MIN_LEN:
        return True
    low = text.lower()
    return any(marker in low for marker in config.WEAK_MARKERS)


def signature(call) -> tuple:
    """Stable identity of a tool call (name + sorted args) for re-query detection."""
    args = dict(getattr(call, "args", None) or {})
    return call.name, json.dumps(args, sort_keys=True, default=str)


class AgentState(TypedDict):
    contents: Annotated[list, lambda a, b: a + b]
    step: int
    last_observation_weak: bool
    weak_call_sigs: set
    pending_calls: list
    final_answer: Optional[str]
    stop_reason: Optional[str]


def build_graph(*, client, tool_fns, declarations, system_prompt,

                max_steps=config.MAX_STEPS, sleep=time.sleep):

    def reason(state: AgentState):
        writer = get_stream_writer()
        step = state["step"] + 1
        if step > max_steps:
            writer({"kind": "limit", "text": "Reached the step limit; stopping.", "step": step})
            return {"step": step, "stop_reason": "limit"}

        writer({"kind": "status", "text": "model is thinking..."})
        response = llm.generate(
            client, contents=state["contents"], tools=declarations,
            system_instruction=system_prompt, sleep=sleep,
        )
        thought, answer, calls, model_content = split_response(response)

        new_sigs = [signature(c) for c in calls]
        is_revision = (
            bool(calls)
            and state["last_observation_weak"]
            and not set(new_sigs).issubset(state["weak_call_sigs"])
        )
        if thought:
            writer({"kind": "thought", "text": thought, "revision": is_revision, "step": step})

        updates = {"step": step, "contents": [model_content]}
        if calls:
            for call in calls:
                writer({"kind": "tool_call", "tool": call.name,
                        "args": dict(call.args or {}), "step": step})
            updates["pending_calls"] = calls
        else:
            final = answer or "(the model produced no answer)"
            writer({"kind": "final", "text": final, "step": step})
            updates["final_answer"] = final
            updates["stop_reason"] = "answered"
        return updates

    def act(state: AgentState):
        writer = get_stream_writer()
        tool_contents, weak_sigs, any_weak = [], set(), False
        for call in state["pending_calls"]:
            name = call.name
            args = dict(call.args or {})
            writer({"kind": "status", "text": f"running {name}..."})
            fn = tool_fns.get(name)
            if fn is None:
                obs = f"ERROR: unknown tool '{name}'"
            else:
                try:
                    obs = str(fn(**args))
                except Exception as exc:
                    obs = f"ERROR: {type(exc).__name__}: {exc}"
            weak = classify_weak(name, obs)
            if weak:
                any_weak = True
                weak_sigs.add(signature(call))
            writer({"kind": "observation", "tool": name, "text": obs,
                    "weak": weak, "step": state["step"]})
            tool_contents.append(types.Content(
                role="tool",
                parts=[types.Part.from_function_response(name=name, response={"result": obs})],
            ))
        return {"contents": tool_contents, "last_observation_weak": any_weak,
                "weak_call_sigs": weak_sigs, "pending_calls": []}

    def route_after_reason(state: AgentState):
        if state.get("stop_reason"):
            return END
        if state.get("pending_calls"):
            return "act"
        return END

    graph = StateGraph(AgentState)
    graph.add_node("reason", reason)
    graph.add_node("act", act)
    graph.add_edge(START, "reason")
    graph.add_conditional_edges("reason", route_after_reason, {"act": "act", END: END})
    graph.add_edge("act", "reason")
    return graph.compile()


def stream_run(task, *, client, tool_fns, declarations, system_prompt,

               max_steps=config.MAX_STEPS, sleep=time.sleep):
    """Yield UI event dicts as the agent runs."""
    compiled = build_graph(
        client=client, tool_fns=tool_fns, declarations=declarations,
        system_prompt=system_prompt, max_steps=max_steps, sleep=sleep,
    )
    initial = {
        "contents": [types.Content(role="user", parts=[types.Part.from_text(text=task)])],
        "step": 0,
        "last_observation_weak": False,
        "weak_call_sigs": set(),
        "pending_calls": [],
        "final_answer": None,
        "stop_reason": None,
    }
    recursion_limit = max_steps * 2 + 5
    # langgraph's stream_mode="custom" yields each writer payload as a bare dict.
    for chunk in compiled.stream(initial, stream_mode="custom",
                                 config={"recursion_limit": recursion_limit}):
        if isinstance(chunk, dict) and "kind" in chunk:
            yield chunk