spagestic commited on
Commit
9d4c4db
·
1 Parent(s): 8639ef8

todo: langgraph integration

Browse files
requirements.txt CHANGED
@@ -50,6 +50,9 @@ jiter==0.15.0
50
  joserfc==1.7.0
51
  jsonschema==4.26.0
52
  jsonschema-specifications==2025.9.1
 
 
 
53
  markdown-it-py==4.2.0
54
  MarkupSafe==3.0.3
55
  matplotlib-inline==0.2.2
 
50
  joserfc==1.7.0
51
  jsonschema==4.26.0
52
  jsonschema-specifications==2025.9.1
53
+ langchain-core==1.4.6
54
+ langchain-openai==1.3.0
55
+ langgraph==1.2.4
56
  markdown-it-py==4.2.0
57
  MarkupSafe==3.0.3
58
  matplotlib-inline==0.2.2
ui/agent/graph/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # ui/agent/graph/__init__.py
2
+ from .respond import respond_with_graph
3
+
4
+ __all__ = ["respond_with_graph"]
ui/agent/graph/llm.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/agent/graph/llm.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from langchain_core.runnables import RunnableConfig
7
+ from langchain_openai import ChatOpenAI
8
+
9
+ from ..config import MODEL_ID
10
+
11
+ HF_ROUTER_BASE_URL = "https://router.huggingface.co/v1"
12
+
13
+
14
+ def build_llm(config: RunnableConfig, **overrides: Any) -> ChatOpenAI:
15
+ """Build a chat model from the per-request configurable values."""
16
+ configurable = config.get("configurable", {})
17
+ params: dict[str, Any] = {
18
+ "model": MODEL_ID,
19
+ "api_key": configurable["hf_token"],
20
+ "base_url": HF_ROUTER_BASE_URL,
21
+ "max_tokens": configurable.get("max_tokens", 1800),
22
+ "temperature": configurable.get("temperature", 0.35),
23
+ "top_p": configurable.get("top_p", 0.9),
24
+ }
25
+ params.update(overrides)
26
+ return ChatOpenAI(**params)
ui/agent/graph/nodes.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/agent/graph/nodes.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ import uuid
7
+ from typing import Any
8
+
9
+ from langchain_core.messages import AIMessage, ToolMessage
10
+ from langchain_core.runnables import RunnableConfig
11
+ from langgraph.config import get_stream_writer
12
+ from langgraph.types import Send
13
+
14
+ from ..tool_schemas import (
15
+ crawl_web_site,
16
+ get_country_profile,
17
+ scrape_web_page,
18
+ search_immigration_info,
19
+ )
20
+ from ..tools import (
21
+ _done_tool_message,
22
+ _pending_tool_message,
23
+ _tool_log_metadata,
24
+ run_tool,
25
+ truncate,
26
+ )
27
+ from ..traces import record_tool_trace
28
+ from .llm import build_llm
29
+ from .state import AgentState, Finding, ResearchTask, TodoItem
30
+
31
+ RESEARCH_TOOL_SCHEMAS = [
32
+ get_country_profile,
33
+ search_immigration_info,
34
+ scrape_web_page,
35
+ crawl_web_site,
36
+ ]
37
+
38
+ MAX_TODOS = 5
39
+ MAX_RESEARCH_ROUNDS = 4
40
+ TOOL_RESULT_LIMIT = 4000
41
+
42
+ PLANNER_SYSTEM_PROMPT = """
43
+ You are the planning supervisor of Borderless, an immigration research agency.
44
+
45
+ Read the user's profile and goals, then produce a focused research plan that a
46
+ team of parallel research analysts will execute. Identify 3-5 plausible
47
+ destination countries (prefer realistic fit over popular destinations) and
48
+ break the research into 3-5 self-contained to-dos. Each to-do must be
49
+ researchable independently — typically one to-do per recommended country
50
+ covering its best visa pathway, eligibility, documents, costs, timelines, and
51
+ risks. You may add one cross-cutting to-do (e.g. comparing costs or document
52
+ preparation) when useful.
53
+
54
+ Respond with ONLY a JSON object, no other text:
55
+ {
56
+ "thinking": "brief reasoning about the user's profile and country choices",
57
+ "countries": ["ISO-2 codes of recommended countries, e.g. CA", "DE"],
58
+ "labels": ["short marker label per country, e.g. Skilled worker - 6-12 mo"],
59
+ "profile_summary": "2-3 sentence summary of the user's profile and constraints",
60
+ "todos": [
61
+ {"title": "short title", "description": "specific research instructions for the analyst"}
62
+ ]
63
+ }
64
+ """.strip()
65
+
66
+ RESEARCHER_SYSTEM_PROMPT = """
67
+ You are a research analyst at Borderless, an immigration research agency.
68
+ You are assigned ONE research to-do. Use your tools to research it thoroughly:
69
+
70
+ - Use search_immigration_info to find current visa rules, fees, documents,
71
+ processing times, and official government pages. Prefer official immigration
72
+ authority, government, and embassy sources.
73
+ - Use scrape_web_page on the best official URLs before making concrete claims.
74
+ - Use get_country_profile when country metadata helps.
75
+ - Do not invent point scores, income thresholds, fees, or processing times.
76
+
77
+ When you have enough information (after at most a few tool calls), STOP calling
78
+ tools and write a dense findings report in markdown covering: visa pathway(s),
79
+ eligibility, required documents, approximate costs, realistic timeline, risks,
80
+ and the official source URLs you verified. Label unofficial sources clearly.
81
+ Note anything you could not verify.
82
+ """.strip()
83
+
84
+ CONSOLIDATOR_SYSTEM_PROMPT = """
85
+ You are Borderless, an agentic immigration research assistant. Your research
86
+ team has completed parallel research on each to-do. Consolidate their findings
87
+ into one final answer for the user. You are not a lawyer and must not present
88
+ the answer as legal advice. Be practical, specific, and clear about
89
+ uncertainty. Only state facts supported by the research findings; if something
90
+ is missing, say what still needs verification.
91
+
92
+ Final answer format:
93
+ Start with a short, plain-English recommendation. Then use these sections:
94
+
95
+ ## Snapshot
96
+ Summarize the user's profile and key constraints in 2-4 bullets.
97
+
98
+ ## Best-Fit Countries
99
+ Use a compact table with country, recommended pathway, why it fits, main risk,
100
+ and rough timeline.
101
+
102
+ ## Pathway Details
103
+ For each recommended country, list the visa pathway, eligibility notes,
104
+ required documents, approximate steps, timeline, and budget sensitivity.
105
+
106
+ ## Documents To Prepare
107
+ Group common documents first, then country-specific documents.
108
+
109
+ ## Risks And Tradeoffs
110
+ Mention language, job market, funds, age/points, credential recognition,
111
+ family constraints, and policy uncertainty where relevant.
112
+
113
+ ## Official Sources
114
+ List cited official URLs and what each source supports.
115
+
116
+ ## Next Steps
117
+ Give 3-5 concrete actions the user can take next. End by reminding them to
118
+ verify details on official sites or with a qualified immigration professional.
119
+ """.strip()
120
+
121
+
122
+ def _extract_json(text: str) -> dict[str, Any] | None:
123
+ decoder = json.JSONDecoder()
124
+ for start in range(len(text)):
125
+ if text[start] != "{":
126
+ continue
127
+ try:
128
+ parsed, _ = decoder.raw_decode(text[start:])
129
+ except json.JSONDecodeError:
130
+ continue
131
+ if isinstance(parsed, dict):
132
+ return parsed
133
+ return None
134
+
135
+
136
+ def _fallback_plan(user_text: str) -> dict[str, Any]:
137
+ return {
138
+ "thinking": "",
139
+ "countries": [],
140
+ "labels": [],
141
+ "profile_summary": truncate(user_text, 600),
142
+ "todos": [
143
+ {
144
+ "title": "Research migration options",
145
+ "description": (
146
+ "Research realistic migration options for this profile: "
147
+ f"{truncate(user_text, 1200)}"
148
+ ),
149
+ }
150
+ ],
151
+ }
152
+
153
+
154
+ def _user_text(user_content: str | list[dict[str, Any]]) -> str:
155
+ if isinstance(user_content, str):
156
+ return user_content
157
+ parts = [
158
+ item.get("text", "")
159
+ for item in user_content
160
+ if isinstance(item, dict) and item.get("type") == "text"
161
+ ]
162
+ return "\n".join(part for part in parts if part)
163
+
164
+
165
+ def planner_node(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
166
+ writer = get_stream_writer()
167
+ llm = build_llm(config)
168
+
169
+ messages: list[Any] = [
170
+ {"role": "system", "content": PLANNER_SYSTEM_PROMPT},
171
+ *state.get("history_messages", []),
172
+ {"role": "user", "content": state["user_content"]},
173
+ ]
174
+
175
+ plan: dict[str, Any] | None = None
176
+ for _ in range(2):
177
+ response = llm.invoke(messages)
178
+ plan = _extract_json(str(response.content or ""))
179
+ if plan and plan.get("todos"):
180
+ break
181
+ plan = None
182
+
183
+ user_text = _user_text(state["user_content"])
184
+ if plan is None:
185
+ plan = _fallback_plan(user_text)
186
+
187
+ thinking = str(plan.get("thinking") or "").strip()
188
+ if thinking:
189
+ writer({"type": "thinking", "text": thinking})
190
+
191
+ todos: list[TodoItem] = []
192
+ for index, raw in enumerate(plan.get("todos", [])[:MAX_TODOS]):
193
+ if not isinstance(raw, dict):
194
+ continue
195
+ title = str(raw.get("title") or f"Research task {index + 1}").strip()
196
+ description = str(raw.get("description") or title).strip()
197
+ todos.append({"id": index + 1, "title": title, "description": description})
198
+ if not todos:
199
+ fallback = _fallback_plan(user_text)["todos"][0]
200
+ todos = [{"id": 1, "title": fallback["title"], "description": fallback["description"]}]
201
+
202
+ writer({"type": "plan", "todos": todos})
203
+
204
+ countries = [str(code) for code in plan.get("countries") or [] if code]
205
+ if countries:
206
+ labels = [str(label) for label in plan.get("labels") or []]
207
+ writer(
208
+ {
209
+ "type": "globe",
210
+ "args": {"action": "show", "countries": countries, "labels": labels},
211
+ }
212
+ )
213
+
214
+ profile_summary = str(plan.get("profile_summary") or "").strip() or truncate(
215
+ user_text, 600
216
+ )
217
+ return {"todos": todos, "profile_summary": profile_summary}
218
+
219
+
220
+ def fan_out_research(state: AgentState) -> list[Send]:
221
+ return [
222
+ Send(
223
+ "researcher",
224
+ {"todo": todo, "profile_summary": state.get("profile_summary", "")},
225
+ )
226
+ for todo in state["todos"]
227
+ ]
228
+
229
+
230
+ def _execute_tool_call(
231
+ writer: Any,
232
+ todo_title: str,
233
+ tool_name: str,
234
+ args: dict[str, Any],
235
+ ) -> str:
236
+ event_id = uuid.uuid4().hex
237
+ title, pending_message = _pending_tool_message(tool_name, args)
238
+ writer(
239
+ {
240
+ "type": "tool_start",
241
+ "id": event_id,
242
+ "title": f"{title} · {todo_title}",
243
+ "message": pending_message,
244
+ "log": {"tool": tool_name, "arguments": args},
245
+ }
246
+ )
247
+
248
+ started = time.monotonic()
249
+ result, _ = run_tool(tool_name, json.dumps(args), globe_state=None)
250
+ duration = time.monotonic() - started
251
+ record_tool_trace(
252
+ tool_name=tool_name,
253
+ arguments=json.dumps(args),
254
+ result=result,
255
+ duration=duration,
256
+ )
257
+
258
+ writer(
259
+ {
260
+ "type": "tool_end",
261
+ "id": event_id,
262
+ "title": f"{title} · {todo_title}",
263
+ "message": _done_tool_message(tool_name, args, result),
264
+ "duration": duration,
265
+ "log": _tool_log_metadata(tool_name, args, result),
266
+ }
267
+ )
268
+ return result
269
+
270
+
271
+ def researcher_node(task: ResearchTask, config: RunnableConfig) -> dict[str, Any]:
272
+ writer = get_stream_writer()
273
+ todo = task["todo"]
274
+ llm = build_llm(config).bind_tools(RESEARCH_TOOL_SCHEMAS)
275
+
276
+ messages: list[Any] = [
277
+ {"role": "system", "content": RESEARCHER_SYSTEM_PROMPT},
278
+ {
279
+ "role": "user",
280
+ "content": (
281
+ f"Applicant profile:\n{task.get('profile_summary') or 'Not provided.'}\n\n"
282
+ f"Your research to-do: {todo['title']}\n\n{todo['description']}"
283
+ ),
284
+ },
285
+ ]
286
+
287
+ summary = ""
288
+ for round_index in range(MAX_RESEARCH_ROUNDS):
289
+ response: AIMessage = llm.invoke(messages)
290
+ if not response.tool_calls:
291
+ summary = str(response.content or "").strip()
292
+ break
293
+
294
+ messages.append(response)
295
+ for tool_call in response.tool_calls:
296
+ result = _execute_tool_call(
297
+ writer, todo["title"], tool_call["name"], tool_call["args"] or {}
298
+ )
299
+ messages.append(
300
+ ToolMessage(
301
+ content=truncate(result, TOOL_RESULT_LIMIT),
302
+ tool_call_id=tool_call["id"],
303
+ )
304
+ )
305
+
306
+ if round_index == MAX_RESEARCH_ROUNDS - 1:
307
+ messages.append(
308
+ {
309
+ "role": "user",
310
+ "content": (
311
+ "Stop researching now. Write your findings report based "
312
+ "on the information gathered so far, labeling anything "
313
+ "unverified."
314
+ ),
315
+ }
316
+ )
317
+ final = build_llm(config).invoke(messages)
318
+ summary = str(final.content or "").strip()
319
+
320
+ if not summary:
321
+ summary = "No findings could be produced for this to-do."
322
+
323
+ finding: Finding = {
324
+ "todo_id": todo["id"],
325
+ "todo_title": todo["title"],
326
+ "summary": summary,
327
+ }
328
+ writer({"type": "finding", "todo_title": todo["title"], "summary": summary})
329
+ return {"findings": [finding]}
330
+
331
+
332
+ def consolidator_node(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
333
+ findings = sorted(state.get("findings", []), key=lambda item: item["todo_id"])
334
+ findings_text = "\n\n".join(
335
+ f"### Finding {item['todo_id']}: {item['todo_title']}\n{item['summary']}"
336
+ for item in findings
337
+ )
338
+
339
+ llm = build_llm(config, max_tokens=3000)
340
+ messages: list[Any] = [
341
+ {"role": "system", "content": CONSOLIDATOR_SYSTEM_PROMPT},
342
+ *state.get("history_messages", []),
343
+ {"role": "user", "content": state["user_content"]},
344
+ {
345
+ "role": "user",
346
+ "content": (
347
+ "Research team findings:\n\n"
348
+ f"{findings_text or 'No findings were produced.'}\n\n"
349
+ "Consolidate these findings into the final answer now, following "
350
+ "the required format."
351
+ ),
352
+ },
353
+ ]
354
+ response = llm.invoke(messages)
355
+ answer = str(response.content or "").strip()
356
+ return {"final_answer": answer}
ui/agent/graph/respond.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/agent/graph/respond.py
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import Any
6
+
7
+ import gradio as gr
8
+ from gradio import ChatMessage
9
+
10
+ from ui.globe_commands import apply_update_globe
11
+
12
+ from ..messages import history_to_api_messages, multimodal_input_to_api_content
13
+ from ..respond import (
14
+ AUTH_REQUIRED_MESSAGE,
15
+ SESSION_EXPIRED_MESSAGE,
16
+ _auth_error_message,
17
+ )
18
+ from ..streaming import yield_response
19
+ from .workflow import build_workflow
20
+
21
+ FAILURE_MESSAGE = (
22
+ "I gathered research but could not finish the full synthesis. "
23
+ "Please ask me to summarize the countries already found."
24
+ )
25
+
26
+
27
+ def _format_plan_content(todos: list[dict[str, Any]]) -> str:
28
+ lines = ["Research to-dos:"]
29
+ for todo in todos:
30
+ lines.append(f"{todo['id']}. {todo['title']} — {todo['description']}")
31
+ return "\n".join(lines)
32
+
33
+
34
+ class _UiState:
35
+ """Maps custom graph events onto the running ChatMessage list."""
36
+
37
+ def __init__(self, globe_state: dict[str, Any]):
38
+ self.ui_messages: list[ChatMessage] = []
39
+ self.globe_state = globe_state
40
+ self._pending_by_id: dict[str, int] = {}
41
+
42
+ def handle(self, event: dict[str, Any]) -> bool:
43
+ kind = event.get("type")
44
+ if kind == "thinking":
45
+ self.ui_messages.append(
46
+ ChatMessage(
47
+ role="assistant",
48
+ content=str(event.get("text") or ""),
49
+ metadata={"title": "Thinking", "status": "done"},
50
+ )
51
+ )
52
+ return True
53
+
54
+ if kind == "plan":
55
+ todos = event.get("todos") or []
56
+ self.ui_messages.append(
57
+ ChatMessage(
58
+ role="assistant",
59
+ content=_format_plan_content(todos),
60
+ metadata={
61
+ "title": "Research plan",
62
+ "status": "done",
63
+ "log": {"tool": "plan_research", "result": todos},
64
+ },
65
+ )
66
+ )
67
+ return True
68
+
69
+ if kind == "globe":
70
+ args = event.get("args") or {}
71
+ result, self.globe_state = apply_update_globe(self.globe_state, args)
72
+ countries = ", ".join(args.get("countries") or [])
73
+ self.ui_messages.append(
74
+ ChatMessage(
75
+ role="assistant",
76
+ content=f"Marked {countries or 'recommended countries'} on the globe.",
77
+ metadata={
78
+ "title": "Updating globe",
79
+ "status": "done",
80
+ "log": {
81
+ "tool": "update_globe",
82
+ "arguments": args,
83
+ "result": result,
84
+ },
85
+ },
86
+ )
87
+ )
88
+ return True
89
+
90
+ if kind == "tool_start":
91
+ self.ui_messages.append(
92
+ ChatMessage(
93
+ role="assistant",
94
+ content=str(event.get("message") or ""),
95
+ metadata={
96
+ "title": str(event.get("title") or "Tool"),
97
+ "status": "pending",
98
+ "log": event.get("log") or {},
99
+ },
100
+ )
101
+ )
102
+ self._pending_by_id[str(event.get("id"))] = len(self.ui_messages) - 1
103
+ return True
104
+
105
+ if kind == "tool_end":
106
+ index = self._pending_by_id.pop(str(event.get("id")), None)
107
+ message = ChatMessage(
108
+ role="assistant",
109
+ content=str(event.get("message") or ""),
110
+ metadata={
111
+ "title": str(event.get("title") or "Tool"),
112
+ "status": "done",
113
+ "duration": event.get("duration"),
114
+ "log": event.get("log") or {},
115
+ },
116
+ )
117
+ if index is not None and index < len(self.ui_messages):
118
+ self.ui_messages[index] = message
119
+ else:
120
+ self.ui_messages.append(message)
121
+ return True
122
+
123
+ if kind == "finding":
124
+ self.ui_messages.append(
125
+ ChatMessage(
126
+ role="assistant",
127
+ content=str(event.get("summary") or ""),
128
+ metadata={
129
+ "title": f"Findings · {event.get('todo_title') or 'research'}",
130
+ "status": "done",
131
+ },
132
+ )
133
+ )
134
+ return True
135
+
136
+ return False
137
+
138
+
139
+ def respond_with_graph(
140
+ message: str | dict[str, Any],
141
+ history: list[dict[str, Any]],
142
+ system_message: str,
143
+ max_tokens: int,
144
+ temperature: float,
145
+ top_p: float,
146
+ globe_state: dict[str, Any],
147
+ hf_token: gr.OAuthToken | None,
148
+ ):
149
+ """LangGraph-backed drop-in replacement for ui.agent.respond.respond."""
150
+ if hf_token is None:
151
+ yield from yield_response([], AUTH_REQUIRED_MESSAGE, globe_state)
152
+ return
153
+
154
+ if hf_token.expires_at <= time.time():
155
+ yield from yield_response([], SESSION_EXPIRED_MESSAGE, globe_state)
156
+ return
157
+
158
+ user_content = multimodal_input_to_api_content(message)
159
+ if not user_content:
160
+ yield from yield_response(
161
+ [],
162
+ "Please enter a message or attach a file.",
163
+ globe_state,
164
+ )
165
+ return
166
+
167
+ workflow = build_workflow()
168
+ ui_state = _UiState(globe_state)
169
+ final_answer = ""
170
+
171
+ config = {
172
+ "configurable": {
173
+ "hf_token": hf_token.token,
174
+ "max_tokens": max_tokens,
175
+ "temperature": temperature,
176
+ "top_p": top_p,
177
+ },
178
+ "recursion_limit": 50,
179
+ }
180
+ graph_input = {
181
+ "user_content": user_content,
182
+ "history_messages": history_to_api_messages(history),
183
+ "findings": [],
184
+ }
185
+
186
+ try:
187
+ for mode, payload in workflow.stream(
188
+ graph_input,
189
+ config=config,
190
+ stream_mode=["custom", "values"],
191
+ ):
192
+ if mode == "custom":
193
+ if ui_state.handle(payload):
194
+ yield list(ui_state.ui_messages), ui_state.globe_state
195
+ elif mode == "values":
196
+ final_answer = payload.get("final_answer") or final_answer
197
+ except Exception as exc:
198
+ error_message = _auth_error_message(exc) or (
199
+ f"Sorry, something went wrong while generating a response: {exc}"
200
+ )
201
+ yield from yield_response(
202
+ ui_state.ui_messages, error_message, ui_state.globe_state
203
+ )
204
+ return
205
+
206
+ yield from yield_response(
207
+ ui_state.ui_messages,
208
+ final_answer or FAILURE_MESSAGE,
209
+ ui_state.globe_state,
210
+ )
ui/agent/graph/state.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/agent/graph/state.py
2
+ from __future__ import annotations
3
+
4
+ import operator
5
+ from typing import Annotated, Any, TypedDict
6
+
7
+
8
+ class TodoItem(TypedDict):
9
+ id: int
10
+ title: str
11
+ description: str
12
+
13
+
14
+ class Finding(TypedDict):
15
+ todo_id: int
16
+ todo_title: str
17
+ summary: str
18
+
19
+
20
+ class AgentState(TypedDict, total=False):
21
+ """Top-level workflow state."""
22
+
23
+ user_content: str | list[dict[str, Any]]
24
+ history_messages: list[dict[str, Any]]
25
+ profile_summary: str
26
+ todos: list[TodoItem]
27
+ findings: Annotated[list[Finding], operator.add]
28
+ final_answer: str
29
+
30
+
31
+ class ResearchTask(TypedDict):
32
+ """Payload sent to each parallel researcher via Send."""
33
+
34
+ todo: TodoItem
35
+ profile_summary: str
ui/agent/graph/workflow.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/agent/graph/workflow.py
2
+ from __future__ import annotations
3
+
4
+ from functools import lru_cache
5
+
6
+ from langgraph.graph import END, START, StateGraph
7
+
8
+ from .nodes import consolidator_node, fan_out_research, planner_node, researcher_node
9
+ from .state import AgentState
10
+
11
+
12
+ @lru_cache(maxsize=1)
13
+ def build_workflow():
14
+ builder = StateGraph(AgentState)
15
+ builder.add_node("planner", planner_node)
16
+ builder.add_node("researcher", researcher_node)
17
+ builder.add_node("consolidator", consolidator_node)
18
+
19
+ builder.add_edge(START, "planner")
20
+ builder.add_conditional_edges("planner", fan_out_research, ["researcher"])
21
+ builder.add_edge("researcher", "consolidator")
22
+ builder.add_edge("consolidator", END)
23
+ return builder.compile()
ui/server_api.py CHANGED
@@ -6,7 +6,7 @@ from typing import Any
6
  import gradio as gr
7
  from gradio import ChatMessage
8
 
9
- from ui.agent.respond import respond
10
  from ui.agent.system_prompt import BORDERLESS_SYSTEM_PROMPT
11
  from ui.chat.defaults import DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE, DEFAULT_TOP_P
12
  from ui.globe_commands import empty_globe_state
 
6
  import gradio as gr
7
  from gradio import ChatMessage
8
 
9
+ from ui.agent.graph import respond_with_graph as respond
10
  from ui.agent.system_prompt import BORDERLESS_SYSTEM_PROMPT
11
  from ui.chat.defaults import DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE, DEFAULT_TOP_P
12
  from ui.globe_commands import empty_globe_state