observable-agent / agent /graph.py
sukhrobnurali's picture
Deploy Observable Agent Space
f019486 verified
Raw
History Blame Contribute Delete
6.4 kB
"""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