Spaces:
Sleeping
Sleeping
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
|