EvoScientist 0.0.1.dev2__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.
- EvoScientist/EvoScientist.py +45 -13
- EvoScientist/cli.py +237 -1363
- EvoScientist/memory.py +715 -0
- EvoScientist/middleware.py +49 -4
- EvoScientist/paths.py +45 -0
- EvoScientist/skills_manager.py +392 -0
- EvoScientist/stream/__init__.py +25 -0
- EvoScientist/stream/display.py +604 -0
- EvoScientist/stream/events.py +415 -0
- EvoScientist/stream/state.py +343 -0
- EvoScientist/stream/utils.py +23 -16
- EvoScientist/tools.py +64 -0
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/METADATA +97 -3
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/RECORD +18 -12
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/WHEEL +0 -0
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/entry_points.txt +0 -0
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/licenses/LICENSE +0 -0
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/top_level.txt +0 -0
|
@@ -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)
|