EvoScientist 0.0.1.dev1__py3-none-any.whl → 0.0.1.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ """Stream event generator and chunk processing helpers.
2
+
3
+ Async generator that streams events from an agent graph,
4
+ plus helpers for processing AI message chunks and tool results.
5
+ """
6
+
7
+ from typing import Any, AsyncIterator
8
+
9
+ from langchain_core.messages import AIMessage, AIMessageChunk # type: ignore[import-untyped]
10
+
11
+ from .emitter import StreamEventEmitter
12
+ from .tracker import ToolCallTracker
13
+ from .utils import DisplayLimits, is_success
14
+
15
+
16
+ async def stream_agent_events(agent: Any, message: str, thread_id: str) -> AsyncIterator[dict]:
17
+ """Stream events from the agent graph using async iteration.
18
+
19
+ Uses agent.astream() with subgraphs=True to see sub-agent activity.
20
+
21
+ Args:
22
+ agent: Compiled state graph from create_deep_agent()
23
+ message: User message
24
+ thread_id: Thread ID for conversation persistence
25
+
26
+ Yields:
27
+ Event dicts: thinking, text, tool_call, tool_result,
28
+ subagent_start, subagent_tool_call, subagent_tool_result, subagent_end,
29
+ done, error
30
+ """
31
+ config = {"configurable": {"thread_id": thread_id}}
32
+ emitter = StreamEventEmitter()
33
+ main_tracker = ToolCallTracker()
34
+ full_response = ""
35
+
36
+ # Track sub-agent names
37
+ _key_to_name: dict[str, str] = {} # subagent_key -> display name (cache)
38
+ _announced_names: list[str] = [] # ordered queue of announced task names
39
+ _assigned_names: set[str] = set() # names already assigned to a namespace
40
+ _announced_task_ids: list[str] = [] # ordered task tool_call_ids
41
+ _task_id_to_name: dict[str, str] = {} # tool_call_id -> sub-agent name
42
+ _subagent_trackers: dict[str, ToolCallTracker] = {} # namespace_key -> tracker
43
+
44
+ def _register_task_tool_call(tc_data: dict) -> str | None:
45
+ """Register or update a task tool call, return subagent name if started/updated."""
46
+ tool_id = tc_data.get("id", "")
47
+ if not tool_id:
48
+ return None
49
+ args = tc_data.get("args", {}) or {}
50
+ desc = str(args.get("description", "")).strip()
51
+ sa_name = str(args.get("subagent_type", "")).strip()
52
+ if not sa_name:
53
+ # Fallback to description snippet (may be empty during streaming)
54
+ sa_name = desc.split("\n")[0].strip()
55
+ sa_name = sa_name[:30] + "\u2026" if len(sa_name) > 30 else sa_name
56
+ if not sa_name:
57
+ sa_name = "sub-agent"
58
+
59
+ if tool_id not in _announced_task_ids:
60
+ _announced_task_ids.append(tool_id)
61
+ _announced_names.append(sa_name)
62
+ _task_id_to_name[tool_id] = sa_name
63
+ return sa_name
64
+
65
+ # Update mapping if we learned a better name later
66
+ current = _task_id_to_name.get(tool_id, "sub-agent")
67
+ if sa_name != "sub-agent" and current != sa_name:
68
+ _task_id_to_name[tool_id] = sa_name
69
+ try:
70
+ idx = _announced_task_ids.index(tool_id)
71
+ if idx < len(_announced_names):
72
+ _announced_names[idx] = sa_name
73
+ except ValueError:
74
+ pass
75
+ return sa_name
76
+ return None
77
+
78
+ def _extract_task_id(namespace: tuple) -> tuple[str | None, str | None]:
79
+ """Extract task tool_call_id from namespace if present.
80
+
81
+ Returns (task_id, task_ns_element) or (None, None).
82
+ """
83
+ for part in namespace:
84
+ part_str = str(part)
85
+ if "task:" in part_str:
86
+ tail = part_str.split("task:", 1)[1]
87
+ task_id = tail.split(":", 1)[0] if tail else ""
88
+ if task_id:
89
+ return task_id, part_str
90
+ return None, None
91
+
92
+ def _next_announced_name() -> str | None:
93
+ """Get next announced name that hasn't been assigned yet."""
94
+ for announced in _announced_names:
95
+ if announced not in _assigned_names:
96
+ _assigned_names.add(announced)
97
+ return announced
98
+ return None
99
+
100
+ def _find_task_id_from_metadata(metadata: dict | None) -> str | None:
101
+ """Try to find a task tool_call_id in metadata."""
102
+ if not metadata:
103
+ return None
104
+ candidates = (
105
+ "tool_call_id",
106
+ "task_id",
107
+ "parent_run_id",
108
+ "root_run_id",
109
+ "run_id",
110
+ )
111
+ for key in candidates:
112
+ val = metadata.get(key)
113
+ if val and val in _task_id_to_name:
114
+ return val
115
+ return None
116
+
117
+ def _get_subagent_key(namespace: tuple, metadata: dict | None) -> str | None:
118
+ """Stable key for tracker/mapping per sub-agent namespace."""
119
+ if not namespace:
120
+ return None
121
+ task_id, task_ns = _extract_task_id(namespace)
122
+ if task_ns:
123
+ return task_ns
124
+ meta_task_id = _find_task_id_from_metadata(metadata)
125
+ if meta_task_id:
126
+ return f"task:{meta_task_id}"
127
+ if metadata:
128
+ for key in ("parent_run_id", "root_run_id", "run_id", "graph_id", "node_id"):
129
+ val = metadata.get(key)
130
+ if val:
131
+ return f"{key}:{val}"
132
+ return str(namespace)
133
+
134
+ def _get_subagent_name(namespace: tuple, metadata: dict | None) -> str | None:
135
+ """Resolve sub-agent name from namespace, or None if main agent.
136
+
137
+ Priority:
138
+ 0) metadata["lc_agent_name"] -- most reliable, set by DeepAgents framework.
139
+ 1) Match task_id embedded in namespace to announced tool_call_id.
140
+ 2) Use cached key mapping (only real names, never "sub-agent").
141
+ 3) Queue-based: assign next announced name to this key.
142
+ 4) Fallback: return "sub-agent" WITHOUT caching.
143
+ """
144
+ if not namespace:
145
+ return None
146
+
147
+ key = _get_subagent_key(namespace, metadata) or str(namespace)
148
+
149
+ # 0) lc_agent_name from metadata -- the REAL sub-agent name
150
+ # set by the DeepAgents framework on every namespace event.
151
+ if metadata:
152
+ lc_name = metadata.get("lc_agent_name", "")
153
+ if isinstance(lc_name, str):
154
+ lc_name = lc_name.strip()
155
+ # Filter out generic/framework names
156
+ if lc_name and lc_name not in (
157
+ "sub-agent", "agent", "tools", "EvoScientist",
158
+ "LangGraph", "",
159
+ ):
160
+ _key_to_name[key] = lc_name
161
+ return lc_name
162
+
163
+ # 1) Resolve by task_id if present in namespace
164
+ task_id, _task_ns = _extract_task_id(namespace)
165
+ if task_id and task_id in _task_id_to_name:
166
+ name = _task_id_to_name[task_id]
167
+ if name and name != "sub-agent":
168
+ _assigned_names.add(name)
169
+ _key_to_name[key] = name
170
+ return name
171
+
172
+ meta_task_id = _find_task_id_from_metadata(metadata)
173
+ if meta_task_id and meta_task_id in _task_id_to_name:
174
+ name = _task_id_to_name[meta_task_id]
175
+ if name and name != "sub-agent":
176
+ _assigned_names.add(name)
177
+ _key_to_name[key] = name
178
+ return name
179
+
180
+ # 2) Cached real name for this key (skip if it's "sub-agent")
181
+ cached = _key_to_name.get(key)
182
+ if cached and cached != "sub-agent":
183
+ return cached
184
+
185
+ # 3) Assign next announced name from queue (skip "sub-agent" entries)
186
+ for announced in _announced_names:
187
+ if announced not in _assigned_names and announced != "sub-agent":
188
+ _assigned_names.add(announced)
189
+ _key_to_name[key] = announced
190
+ return announced
191
+
192
+ # 4) No real names available yet -- return generic WITHOUT caching
193
+ return "sub-agent"
194
+
195
+ try:
196
+ async for chunk in agent.astream(
197
+ {"messages": [{"role": "user", "content": message}]},
198
+ config=config,
199
+ stream_mode="messages",
200
+ subgraphs=True,
201
+ ):
202
+ # With subgraphs=True, event is (namespace, (message, metadata))
203
+ namespace: tuple = ()
204
+ data: Any = chunk
205
+
206
+ if isinstance(chunk, tuple) and len(chunk) >= 2:
207
+ first = chunk[0]
208
+ if isinstance(first, tuple):
209
+ # (namespace_tuple, (message, metadata))
210
+ namespace = first
211
+ data = chunk[1]
212
+ else:
213
+ # (message, metadata) -- no namespace
214
+ data = chunk
215
+
216
+ # Unpack message + metadata from data
217
+ msg: Any
218
+ metadata: dict = {}
219
+ if isinstance(data, tuple) and len(data) >= 2:
220
+ msg = data[0]
221
+ metadata = data[1] or {}
222
+ else:
223
+ msg = data
224
+
225
+ subagent = _get_subagent_name(namespace, metadata)
226
+ subagent_tracker = None
227
+ if subagent:
228
+ tracker_key = _get_subagent_key(namespace, metadata) or str(namespace)
229
+ subagent_tracker = _subagent_trackers.setdefault(tracker_key, ToolCallTracker())
230
+
231
+ # Process AIMessageChunk / AIMessage
232
+ if isinstance(msg, (AIMessageChunk, AIMessage)):
233
+ if subagent:
234
+ # Sub-agent content -- emit sub-agent events
235
+ for ev in _process_chunk_content(msg, emitter, subagent_tracker):
236
+ if ev.type == "tool_call":
237
+ yield emitter.subagent_tool_call(
238
+ subagent, ev.data["name"], ev.data["args"], ev.data.get("id", "")
239
+ ).data
240
+ # Skip text/thinking from sub-agents (too noisy)
241
+
242
+ if hasattr(msg, "tool_calls") and msg.tool_calls:
243
+ for tc in msg.tool_calls:
244
+ name = tc.get("name", "")
245
+ args = tc.get("args", {})
246
+ tool_id = tc.get("id", "")
247
+ # Skip empty-name chunks (incomplete streaming fragments)
248
+ if not name and not tool_id:
249
+ continue
250
+ yield emitter.subagent_tool_call(
251
+ subagent, name, args if isinstance(args, dict) else {}, tool_id
252
+ ).data
253
+ else:
254
+ # Main agent content
255
+ for ev in _process_chunk_content(msg, emitter, main_tracker):
256
+ if ev.type == "text":
257
+ full_response += ev.data.get("content", "")
258
+ yield ev.data
259
+
260
+ if hasattr(msg, "tool_calls") and msg.tool_calls:
261
+ for ev in _process_tool_calls(msg.tool_calls, emitter, main_tracker):
262
+ yield ev.data
263
+ # Detect task tool calls -> announce sub-agent
264
+ tc_data = ev.data
265
+ if tc_data.get("name") == "task":
266
+ started_name = _register_task_tool_call(tc_data)
267
+ if started_name:
268
+ desc = str(tc_data.get("args", {}).get("description", "")).strip()
269
+ yield emitter.subagent_start(started_name, desc).data
270
+
271
+ # Process ToolMessage (tool execution result)
272
+ elif hasattr(msg, "type") and msg.type == "tool":
273
+ if subagent:
274
+ if subagent_tracker:
275
+ subagent_tracker.finalize_all()
276
+ for info in subagent_tracker.emit_all_pending():
277
+ yield emitter.subagent_tool_call(
278
+ subagent,
279
+ info.name,
280
+ info.args,
281
+ info.id,
282
+ ).data
283
+ name = getattr(msg, "name", "unknown")
284
+ raw_content = str(getattr(msg, "content", ""))
285
+ content = raw_content[:DisplayLimits.TOOL_RESULT_MAX]
286
+ success = is_success(content)
287
+ yield emitter.subagent_tool_result(subagent, name, content, success).data
288
+ else:
289
+ for ev in _process_tool_result(msg, emitter, main_tracker):
290
+ yield ev.data
291
+ # Tool result can re-emit tool_call with full args; update task mapping
292
+ if ev.type == "tool_call" and ev.data.get("name") == "task":
293
+ started_name = _register_task_tool_call(ev.data)
294
+ if started_name:
295
+ desc = str(ev.data.get("args", {}).get("description", "")).strip()
296
+ yield emitter.subagent_start(started_name, desc).data
297
+ # Check if this is a task result -> sub-agent ended
298
+ name = getattr(msg, "name", "")
299
+ if name == "task":
300
+ tool_call_id = getattr(msg, "tool_call_id", "")
301
+ # Find the sub-agent name via tool_call_id map
302
+ sa_name = _task_id_to_name.get(tool_call_id, "sub-agent")
303
+ yield emitter.subagent_end(sa_name).data
304
+
305
+ except Exception as e:
306
+ yield emitter.error(str(e)).data
307
+ raise
308
+
309
+ yield emitter.done(full_response).data
310
+
311
+
312
+ def _process_chunk_content(chunk, emitter: StreamEventEmitter, tracker: ToolCallTracker):
313
+ """Process content blocks from an AI message chunk."""
314
+ content = chunk.content
315
+
316
+ if isinstance(content, str):
317
+ if content:
318
+ yield emitter.text(content)
319
+ return
320
+
321
+ blocks = None
322
+ if hasattr(chunk, "content_blocks"):
323
+ try:
324
+ blocks = chunk.content_blocks
325
+ except Exception:
326
+ blocks = None
327
+
328
+ if blocks is None:
329
+ if isinstance(content, dict):
330
+ blocks = [content]
331
+ elif isinstance(content, list):
332
+ blocks = content
333
+ else:
334
+ return
335
+
336
+ for raw_block in blocks:
337
+ block = raw_block
338
+ if not isinstance(block, dict):
339
+ if hasattr(block, "model_dump"):
340
+ block = block.model_dump()
341
+ elif hasattr(block, "dict"):
342
+ block = block.dict()
343
+ else:
344
+ continue
345
+
346
+ block_type = block.get("type")
347
+
348
+ if block_type in ("thinking", "reasoning"):
349
+ thinking_text = block.get("thinking") or block.get("reasoning") or ""
350
+ if thinking_text:
351
+ yield emitter.thinking(thinking_text)
352
+
353
+ elif block_type == "text":
354
+ text = block.get("text") or block.get("content") or ""
355
+ if text:
356
+ yield emitter.text(text)
357
+
358
+ elif block_type in ("tool_use", "tool_call"):
359
+ tool_id = block.get("id", "")
360
+ name = block.get("name", "")
361
+ args = block.get("input") if block_type == "tool_use" else block.get("args")
362
+ args_payload = args if isinstance(args, dict) else {}
363
+
364
+ if tool_id:
365
+ tracker.update(tool_id, name=name, args=args_payload)
366
+ if tracker.is_ready(tool_id):
367
+ tracker.mark_emitted(tool_id)
368
+ yield emitter.tool_call(name, args_payload, tool_id)
369
+
370
+ elif block_type == "input_json_delta":
371
+ partial_json = block.get("partial_json", "")
372
+ if partial_json:
373
+ tracker.append_json_delta(partial_json, block.get("index", 0))
374
+
375
+ elif block_type == "tool_call_chunk":
376
+ tool_id = block.get("id", "")
377
+ name = block.get("name", "")
378
+ if tool_id:
379
+ tracker.update(tool_id, name=name)
380
+ partial_args = block.get("args", "")
381
+ if isinstance(partial_args, str) and partial_args:
382
+ tracker.append_json_delta(partial_args, block.get("index", 0))
383
+
384
+
385
+ def _process_tool_calls(tool_calls: list, emitter: StreamEventEmitter, tracker: ToolCallTracker):
386
+ """Process tool_calls from chunk.tool_calls attribute."""
387
+ for tc in tool_calls:
388
+ tool_id = tc.get("id", "")
389
+ if tool_id:
390
+ name = tc.get("name", "")
391
+ args = tc.get("args", {})
392
+ args_payload = args if isinstance(args, dict) else {}
393
+
394
+ tracker.update(tool_id, name=name, args=args_payload)
395
+ if tracker.is_ready(tool_id):
396
+ tracker.mark_emitted(tool_id)
397
+ yield emitter.tool_call(name, args_payload, tool_id)
398
+
399
+
400
+ def _process_tool_result(chunk, emitter: StreamEventEmitter, tracker: ToolCallTracker):
401
+ """Process a ToolMessage result."""
402
+ tracker.finalize_all()
403
+
404
+ # Re-emit all tool calls with complete args
405
+ for info in tracker.get_all():
406
+ yield emitter.tool_call(info.name, info.args, info.id)
407
+
408
+ name = getattr(chunk, "name", "unknown")
409
+ raw_content = str(getattr(chunk, "content", ""))
410
+ content = raw_content[:DisplayLimits.TOOL_RESULT_MAX]
411
+ if len(raw_content) > DisplayLimits.TOOL_RESULT_MAX:
412
+ content += "\n... (truncated)"
413
+
414
+ success = is_success(content)
415
+ yield emitter.tool_result(name, content, success)