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