EvoScientist 0.0.1.dev4__py3-none-any.whl → 0.1.0rc2__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 +25 -61
- EvoScientist/__init__.py +0 -19
- EvoScientist/backends.py +0 -26
- EvoScientist/cli.py +1365 -480
- EvoScientist/middleware.py +7 -56
- EvoScientist/skills/clip/SKILL.md +253 -0
- EvoScientist/skills/clip/references/applications.md +207 -0
- EvoScientist/skills/langgraph-docs/SKILL.md +36 -0
- EvoScientist/skills/tensorboard/SKILL.md +629 -0
- EvoScientist/skills/tensorboard/references/integrations.md +638 -0
- EvoScientist/skills/tensorboard/references/profiling.md +545 -0
- EvoScientist/skills/tensorboard/references/visualization.md +620 -0
- EvoScientist/skills/vllm/SKILL.md +364 -0
- EvoScientist/skills/vllm/references/optimization.md +226 -0
- EvoScientist/skills/vllm/references/quantization.md +284 -0
- EvoScientist/skills/vllm/references/server-deployment.md +255 -0
- EvoScientist/skills/vllm/references/troubleshooting.md +447 -0
- EvoScientist/stream/__init__.py +0 -25
- EvoScientist/stream/utils.py +16 -23
- EvoScientist/tools.py +2 -75
- {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/METADATA +8 -153
- {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/RECORD +26 -24
- evoscientist-0.1.0rc2.dist-info/entry_points.txt +2 -0
- EvoScientist/config.py +0 -274
- EvoScientist/llm/__init__.py +0 -21
- EvoScientist/llm/models.py +0 -99
- EvoScientist/memory.py +0 -715
- EvoScientist/onboard.py +0 -725
- EvoScientist/paths.py +0 -44
- EvoScientist/skills_manager.py +0 -391
- EvoScientist/stream/display.py +0 -604
- EvoScientist/stream/events.py +0 -415
- EvoScientist/stream/state.py +0 -343
- evoscientist-0.0.1.dev4.dist-info/entry_points.txt +0 -5
- {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/WHEEL +0 -0
- {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/licenses/LICENSE +0 -0
- {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/top_level.txt +0 -0
EvoScientist/cli.py
CHANGED
|
@@ -10,216 +10,1370 @@ Features:
|
|
|
10
10
|
- Response panel (green) - shows final response
|
|
11
11
|
- Thread ID support for multi-turn conversations
|
|
12
12
|
- Interactive mode with prompt_toolkit
|
|
13
|
-
- Configuration management (onboard, config commands)
|
|
14
13
|
"""
|
|
15
14
|
|
|
16
|
-
import
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
17
|
import os
|
|
18
18
|
import sys
|
|
19
19
|
import uuid
|
|
20
20
|
from datetime import datetime
|
|
21
|
-
from typing import Any,
|
|
21
|
+
from typing import Any, AsyncIterator
|
|
22
22
|
|
|
23
|
-
import
|
|
23
|
+
from dotenv import load_dotenv # type: ignore[import-untyped]
|
|
24
24
|
from prompt_toolkit import PromptSession # type: ignore[import-untyped]
|
|
25
25
|
from prompt_toolkit.history import FileHistory # type: ignore[import-untyped]
|
|
26
26
|
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory # type: ignore[import-untyped]
|
|
27
27
|
from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped]
|
|
28
|
+
from rich.console import Console, Group # type: ignore[import-untyped]
|
|
29
|
+
from rich.panel import Panel # type: ignore[import-untyped]
|
|
30
|
+
from rich.markdown import Markdown # type: ignore[import-untyped]
|
|
31
|
+
from rich.live import Live # type: ignore[import-untyped]
|
|
28
32
|
from rich.text import Text # type: ignore[import-untyped]
|
|
29
|
-
from rich.
|
|
33
|
+
from rich.spinner import Spinner # type: ignore[import-untyped]
|
|
34
|
+
from langchain_core.messages import AIMessage, AIMessageChunk # type: ignore[import-untyped]
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
from .stream import (
|
|
37
|
+
StreamEventEmitter,
|
|
38
|
+
ToolCallTracker,
|
|
39
|
+
ToolResultFormatter,
|
|
40
|
+
DisplayLimits,
|
|
41
|
+
ToolStatus,
|
|
42
|
+
format_tool_compact,
|
|
43
|
+
is_success,
|
|
44
|
+
)
|
|
35
45
|
|
|
46
|
+
load_dotenv(override=True)
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if path.startswith(cwd):
|
|
44
|
-
# Remove cwd prefix, keep the relative part
|
|
45
|
-
rel = path[len(cwd):].lstrip(os.sep)
|
|
46
|
-
# Add current dir name for context
|
|
47
|
-
return os.path.join(os.path.basename(cwd), rel) if rel else os.path.basename(cwd)
|
|
48
|
-
return path
|
|
49
|
-
except Exception:
|
|
50
|
-
return path
|
|
48
|
+
console = Console(
|
|
49
|
+
legacy_windows=(sys.platform == 'win32'),
|
|
50
|
+
no_color=os.getenv('NO_COLOR') is not None,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
formatter = ToolResultFormatter()
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
# =============================================================================
|
|
54
|
-
#
|
|
57
|
+
# Stream event generator
|
|
55
58
|
# =============================================================================
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
r" ██╔════╝ ██║ ██║ ██╔═══██╗ ██╔════╝ ██╔════╝ ██║ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ██║ ██╔════╝ ╚══██╔══╝",
|
|
60
|
-
r" █████╗ ██║ ██║ ██║ ██║ ███████╗ ██║ ██║ █████╗ ██╔██╗ ██║ ██║ ██║ ███████╗ ██║ ",
|
|
61
|
-
r" ██╔══╝ ╚██╗ ██╔╝ ██║ ██║ ╚════██║ ██║ ██║ ██╔══╝ ██║╚██╗██║ ██║ ██║ ╚════██║ ██║ ",
|
|
62
|
-
r" ███████╗ ╚████╔╝ ╚██████╔╝ ███████║ ╚██████╗ ██║ ███████╗ ██║ ╚████║ ██║ ██║ ███████║ ██║ ",
|
|
63
|
-
r" ╚══════╝ ╚═══╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ",
|
|
64
|
-
]
|
|
60
|
+
async def stream_agent_events(agent: Any, message: str, thread_id: str) -> AsyncIterator[dict]:
|
|
61
|
+
"""Stream events from the agent graph using async iteration.
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
_GRADIENT_COLORS = ["#1a237e", "#1565c0", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9"]
|
|
63
|
+
Uses agent.astream() with subgraphs=True to see sub-agent activity.
|
|
68
64
|
|
|
65
|
+
Args:
|
|
66
|
+
agent: Compiled state graph from create_deep_agent()
|
|
67
|
+
message: User message
|
|
68
|
+
thread_id: Thread ID for conversation persistence
|
|
69
|
+
|
|
70
|
+
Yields:
|
|
71
|
+
Event dicts: thinking, text, tool_call, tool_result,
|
|
72
|
+
subagent_start, subagent_tool_call, subagent_tool_result, subagent_end,
|
|
73
|
+
done, error
|
|
74
|
+
"""
|
|
75
|
+
config = {"configurable": {"thread_id": thread_id}}
|
|
76
|
+
emitter = StreamEventEmitter()
|
|
77
|
+
main_tracker = ToolCallTracker()
|
|
78
|
+
full_response = ""
|
|
79
|
+
|
|
80
|
+
# Track sub-agent names
|
|
81
|
+
_key_to_name: dict[str, str] = {} # subagent_key → display name (cache)
|
|
82
|
+
_announced_names: list[str] = [] # ordered queue of announced task names
|
|
83
|
+
_assigned_names: set[str] = set() # names already assigned to a namespace
|
|
84
|
+
_announced_task_ids: list[str] = [] # ordered task tool_call_ids
|
|
85
|
+
_task_id_to_name: dict[str, str] = {} # tool_call_id → sub-agent name
|
|
86
|
+
_subagent_trackers: dict[str, ToolCallTracker] = {} # namespace_key → tracker
|
|
87
|
+
|
|
88
|
+
def _register_task_tool_call(tc_data: dict) -> str | None:
|
|
89
|
+
"""Register or update a task tool call, return subagent name if started/updated."""
|
|
90
|
+
tool_id = tc_data.get("id", "")
|
|
91
|
+
if not tool_id:
|
|
92
|
+
return None
|
|
93
|
+
args = tc_data.get("args", {}) or {}
|
|
94
|
+
desc = str(args.get("description", "")).strip()
|
|
95
|
+
sa_name = str(args.get("subagent_type", "")).strip()
|
|
96
|
+
if not sa_name:
|
|
97
|
+
# Fallback to description snippet (may be empty during streaming)
|
|
98
|
+
sa_name = desc[:30] + "..." if len(desc) > 30 else desc
|
|
99
|
+
if not sa_name:
|
|
100
|
+
sa_name = "sub-agent"
|
|
101
|
+
|
|
102
|
+
if tool_id not in _announced_task_ids:
|
|
103
|
+
_announced_task_ids.append(tool_id)
|
|
104
|
+
_announced_names.append(sa_name)
|
|
105
|
+
_task_id_to_name[tool_id] = sa_name
|
|
106
|
+
return sa_name
|
|
107
|
+
|
|
108
|
+
# Update mapping if we learned a better name later
|
|
109
|
+
current = _task_id_to_name.get(tool_id, "sub-agent")
|
|
110
|
+
if sa_name != "sub-agent" and current != sa_name:
|
|
111
|
+
_task_id_to_name[tool_id] = sa_name
|
|
112
|
+
try:
|
|
113
|
+
idx = _announced_task_ids.index(tool_id)
|
|
114
|
+
if idx < len(_announced_names):
|
|
115
|
+
_announced_names[idx] = sa_name
|
|
116
|
+
except ValueError:
|
|
117
|
+
pass
|
|
118
|
+
return sa_name
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def _extract_task_id(namespace: tuple) -> tuple[str | None, str | None]:
|
|
122
|
+
"""Extract task tool_call_id from namespace if present.
|
|
123
|
+
|
|
124
|
+
Returns (task_id, task_ns_element) or (None, None).
|
|
125
|
+
"""
|
|
126
|
+
for part in namespace:
|
|
127
|
+
part_str = str(part)
|
|
128
|
+
if "task:" in part_str:
|
|
129
|
+
tail = part_str.split("task:", 1)[1]
|
|
130
|
+
task_id = tail.split(":", 1)[0] if tail else ""
|
|
131
|
+
if task_id:
|
|
132
|
+
return task_id, part_str
|
|
133
|
+
return None, None
|
|
134
|
+
|
|
135
|
+
def _next_announced_name() -> str | None:
|
|
136
|
+
"""Get next announced name that hasn't been assigned yet."""
|
|
137
|
+
for announced in _announced_names:
|
|
138
|
+
if announced not in _assigned_names:
|
|
139
|
+
_assigned_names.add(announced)
|
|
140
|
+
return announced
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def _find_task_id_from_metadata(metadata: dict | None) -> str | None:
|
|
144
|
+
"""Try to find a task tool_call_id in metadata."""
|
|
145
|
+
if not metadata:
|
|
146
|
+
return None
|
|
147
|
+
candidates = (
|
|
148
|
+
"tool_call_id",
|
|
149
|
+
"task_id",
|
|
150
|
+
"parent_run_id",
|
|
151
|
+
"root_run_id",
|
|
152
|
+
"run_id",
|
|
153
|
+
)
|
|
154
|
+
for key in candidates:
|
|
155
|
+
val = metadata.get(key)
|
|
156
|
+
if val and val in _task_id_to_name:
|
|
157
|
+
return val
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def _get_subagent_key(namespace: tuple, metadata: dict | None) -> str | None:
|
|
161
|
+
"""Stable key for tracker/mapping per sub-agent namespace."""
|
|
162
|
+
if not namespace:
|
|
163
|
+
return None
|
|
164
|
+
task_id, task_ns = _extract_task_id(namespace)
|
|
165
|
+
if task_ns:
|
|
166
|
+
return task_ns
|
|
167
|
+
meta_task_id = _find_task_id_from_metadata(metadata)
|
|
168
|
+
if meta_task_id:
|
|
169
|
+
return f"task:{meta_task_id}"
|
|
170
|
+
if metadata:
|
|
171
|
+
for key in ("parent_run_id", "root_run_id", "run_id", "graph_id", "node_id"):
|
|
172
|
+
val = metadata.get(key)
|
|
173
|
+
if val:
|
|
174
|
+
return f"{key}:{val}"
|
|
175
|
+
return str(namespace)
|
|
176
|
+
|
|
177
|
+
def _get_subagent_name(namespace: tuple, metadata: dict | None) -> str | None:
|
|
178
|
+
"""Resolve sub-agent name from namespace, or None if main agent.
|
|
179
|
+
|
|
180
|
+
Priority:
|
|
181
|
+
0) metadata["lc_agent_name"] — most reliable, set by DeepAgents framework.
|
|
182
|
+
1) Match task_id embedded in namespace to announced tool_call_id.
|
|
183
|
+
2) Use cached key mapping (only real names, never "sub-agent").
|
|
184
|
+
3) Queue-based: assign next announced name to this key.
|
|
185
|
+
4) Fallback: return "sub-agent" WITHOUT caching.
|
|
186
|
+
"""
|
|
187
|
+
if not namespace:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
key = _get_subagent_key(namespace, metadata) or str(namespace)
|
|
191
|
+
|
|
192
|
+
# 0) lc_agent_name from metadata — the REAL sub-agent name
|
|
193
|
+
# set by the DeepAgents framework on every namespace event.
|
|
194
|
+
if metadata:
|
|
195
|
+
lc_name = metadata.get("lc_agent_name", "")
|
|
196
|
+
if isinstance(lc_name, str):
|
|
197
|
+
lc_name = lc_name.strip()
|
|
198
|
+
# Filter out generic/framework names
|
|
199
|
+
if lc_name and lc_name not in (
|
|
200
|
+
"sub-agent", "agent", "tools", "EvoScientist",
|
|
201
|
+
"LangGraph", "",
|
|
202
|
+
):
|
|
203
|
+
_key_to_name[key] = lc_name
|
|
204
|
+
return lc_name
|
|
205
|
+
|
|
206
|
+
# 1) Resolve by task_id if present in namespace
|
|
207
|
+
task_id, _task_ns = _extract_task_id(namespace)
|
|
208
|
+
if task_id and task_id in _task_id_to_name:
|
|
209
|
+
name = _task_id_to_name[task_id]
|
|
210
|
+
if name and name != "sub-agent":
|
|
211
|
+
_assigned_names.add(name)
|
|
212
|
+
_key_to_name[key] = name
|
|
213
|
+
return name
|
|
214
|
+
|
|
215
|
+
meta_task_id = _find_task_id_from_metadata(metadata)
|
|
216
|
+
if meta_task_id and meta_task_id in _task_id_to_name:
|
|
217
|
+
name = _task_id_to_name[meta_task_id]
|
|
218
|
+
if name and name != "sub-agent":
|
|
219
|
+
_assigned_names.add(name)
|
|
220
|
+
_key_to_name[key] = name
|
|
221
|
+
return name
|
|
222
|
+
|
|
223
|
+
# 2) Cached real name for this key (skip if it's "sub-agent")
|
|
224
|
+
cached = _key_to_name.get(key)
|
|
225
|
+
if cached and cached != "sub-agent":
|
|
226
|
+
return cached
|
|
227
|
+
|
|
228
|
+
# 3) Assign next announced name from queue (skip "sub-agent" entries)
|
|
229
|
+
for announced in _announced_names:
|
|
230
|
+
if announced not in _assigned_names and announced != "sub-agent":
|
|
231
|
+
_assigned_names.add(announced)
|
|
232
|
+
_key_to_name[key] = announced
|
|
233
|
+
return announced
|
|
234
|
+
|
|
235
|
+
# 4) No real names available yet — return generic WITHOUT caching
|
|
236
|
+
return "sub-agent"
|
|
69
237
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
238
|
+
try:
|
|
239
|
+
async for chunk in agent.astream(
|
|
240
|
+
{"messages": [{"role": "user", "content": message}]},
|
|
241
|
+
config=config,
|
|
242
|
+
stream_mode="messages",
|
|
243
|
+
subgraphs=True,
|
|
244
|
+
):
|
|
245
|
+
# With subgraphs=True, event is (namespace, (message, metadata))
|
|
246
|
+
namespace: tuple = ()
|
|
247
|
+
data: Any = chunk
|
|
248
|
+
|
|
249
|
+
if isinstance(chunk, tuple) and len(chunk) >= 2:
|
|
250
|
+
first = chunk[0]
|
|
251
|
+
if isinstance(first, tuple):
|
|
252
|
+
# (namespace_tuple, (message, metadata))
|
|
253
|
+
namespace = first
|
|
254
|
+
data = chunk[1]
|
|
255
|
+
else:
|
|
256
|
+
# (message, metadata) — no namespace
|
|
257
|
+
data = chunk
|
|
258
|
+
|
|
259
|
+
# Unpack message + metadata from data
|
|
260
|
+
msg: Any
|
|
261
|
+
metadata: dict = {}
|
|
262
|
+
if isinstance(data, tuple) and len(data) >= 2:
|
|
263
|
+
msg = data[0]
|
|
264
|
+
metadata = data[1] or {}
|
|
265
|
+
else:
|
|
266
|
+
msg = data
|
|
267
|
+
|
|
268
|
+
subagent = _get_subagent_name(namespace, metadata)
|
|
269
|
+
subagent_tracker = None
|
|
270
|
+
if subagent:
|
|
271
|
+
tracker_key = _get_subagent_key(namespace, metadata) or str(namespace)
|
|
272
|
+
subagent_tracker = _subagent_trackers.setdefault(tracker_key, ToolCallTracker())
|
|
273
|
+
|
|
274
|
+
# Process AIMessageChunk / AIMessage
|
|
275
|
+
if isinstance(msg, (AIMessageChunk, AIMessage)):
|
|
276
|
+
if subagent:
|
|
277
|
+
# Sub-agent content — emit sub-agent events
|
|
278
|
+
for ev in _process_chunk_content(msg, emitter, subagent_tracker):
|
|
279
|
+
if ev.type == "tool_call":
|
|
280
|
+
yield emitter.subagent_tool_call(
|
|
281
|
+
subagent, ev.data["name"], ev.data["args"], ev.data.get("id", "")
|
|
282
|
+
).data
|
|
283
|
+
# Skip text/thinking from sub-agents (too noisy)
|
|
284
|
+
|
|
285
|
+
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
|
286
|
+
for tc in msg.tool_calls:
|
|
287
|
+
name = tc.get("name", "")
|
|
288
|
+
args = tc.get("args", {})
|
|
289
|
+
tool_id = tc.get("id", "")
|
|
290
|
+
# Skip empty-name chunks (incomplete streaming fragments)
|
|
291
|
+
if not name and not tool_id:
|
|
292
|
+
continue
|
|
293
|
+
yield emitter.subagent_tool_call(
|
|
294
|
+
subagent, name, args if isinstance(args, dict) else {}, tool_id
|
|
295
|
+
).data
|
|
296
|
+
else:
|
|
297
|
+
# Main agent content
|
|
298
|
+
for ev in _process_chunk_content(msg, emitter, main_tracker):
|
|
299
|
+
if ev.type == "text":
|
|
300
|
+
full_response += ev.data.get("content", "")
|
|
301
|
+
yield ev.data
|
|
302
|
+
|
|
303
|
+
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
|
304
|
+
for ev in _process_tool_calls(msg.tool_calls, emitter, main_tracker):
|
|
305
|
+
yield ev.data
|
|
306
|
+
# Detect task tool calls → announce sub-agent
|
|
307
|
+
tc_data = ev.data
|
|
308
|
+
if tc_data.get("name") == "task":
|
|
309
|
+
started_name = _register_task_tool_call(tc_data)
|
|
310
|
+
if started_name:
|
|
311
|
+
desc = str(tc_data.get("args", {}).get("description", "")).strip()
|
|
312
|
+
yield emitter.subagent_start(started_name, desc).data
|
|
313
|
+
|
|
314
|
+
# Process ToolMessage (tool execution result)
|
|
315
|
+
elif hasattr(msg, "type") and msg.type == "tool":
|
|
316
|
+
if subagent:
|
|
317
|
+
if subagent_tracker:
|
|
318
|
+
subagent_tracker.finalize_all()
|
|
319
|
+
for info in subagent_tracker.emit_all_pending():
|
|
320
|
+
yield emitter.subagent_tool_call(
|
|
321
|
+
subagent,
|
|
322
|
+
info.name,
|
|
323
|
+
info.args,
|
|
324
|
+
info.id,
|
|
325
|
+
).data
|
|
326
|
+
name = getattr(msg, "name", "unknown")
|
|
327
|
+
raw_content = str(getattr(msg, "content", ""))
|
|
328
|
+
content = raw_content[:DisplayLimits.TOOL_RESULT_MAX]
|
|
329
|
+
success = is_success(content)
|
|
330
|
+
yield emitter.subagent_tool_result(subagent, name, content, success).data
|
|
331
|
+
else:
|
|
332
|
+
for ev in _process_tool_result(msg, emitter, main_tracker):
|
|
333
|
+
yield ev.data
|
|
334
|
+
# Tool result can re-emit tool_call with full args; update task mapping
|
|
335
|
+
if ev.type == "tool_call" and ev.data.get("name") == "task":
|
|
336
|
+
started_name = _register_task_tool_call(ev.data)
|
|
337
|
+
if started_name:
|
|
338
|
+
desc = str(ev.data.get("args", {}).get("description", "")).strip()
|
|
339
|
+
yield emitter.subagent_start(started_name, desc).data
|
|
340
|
+
# Check if this is a task result → sub-agent ended
|
|
341
|
+
name = getattr(msg, "name", "")
|
|
342
|
+
if name == "task":
|
|
343
|
+
tool_call_id = getattr(msg, "tool_call_id", "")
|
|
344
|
+
# Find the sub-agent name via tool_call_id map
|
|
345
|
+
sa_name = _task_id_to_name.get(tool_call_id, "sub-agent")
|
|
346
|
+
yield emitter.subagent_end(sa_name).data
|
|
347
|
+
|
|
348
|
+
except Exception as e:
|
|
349
|
+
yield emitter.error(str(e)).data
|
|
350
|
+
raise
|
|
351
|
+
|
|
352
|
+
yield emitter.done(full_response).data
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _process_chunk_content(chunk, emitter: StreamEventEmitter, tracker: ToolCallTracker):
|
|
356
|
+
"""Process content blocks from an AI message chunk."""
|
|
357
|
+
content = chunk.content
|
|
358
|
+
|
|
359
|
+
if isinstance(content, str):
|
|
360
|
+
if content:
|
|
361
|
+
yield emitter.text(content)
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
blocks = None
|
|
365
|
+
if hasattr(chunk, "content_blocks"):
|
|
366
|
+
try:
|
|
367
|
+
blocks = chunk.content_blocks
|
|
368
|
+
except Exception:
|
|
369
|
+
blocks = None
|
|
370
|
+
|
|
371
|
+
if blocks is None:
|
|
372
|
+
if isinstance(content, dict):
|
|
373
|
+
blocks = [content]
|
|
374
|
+
elif isinstance(content, list):
|
|
375
|
+
blocks = content
|
|
376
|
+
else:
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
for raw_block in blocks:
|
|
380
|
+
block = raw_block
|
|
381
|
+
if not isinstance(block, dict):
|
|
382
|
+
if hasattr(block, "model_dump"):
|
|
383
|
+
block = block.model_dump()
|
|
384
|
+
elif hasattr(block, "dict"):
|
|
385
|
+
block = block.dict()
|
|
386
|
+
else:
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
block_type = block.get("type")
|
|
390
|
+
|
|
391
|
+
if block_type in ("thinking", "reasoning"):
|
|
392
|
+
thinking_text = block.get("thinking") or block.get("reasoning") or ""
|
|
393
|
+
if thinking_text:
|
|
394
|
+
yield emitter.thinking(thinking_text)
|
|
395
|
+
|
|
396
|
+
elif block_type == "text":
|
|
397
|
+
text = block.get("text") or block.get("content") or ""
|
|
398
|
+
if text:
|
|
399
|
+
yield emitter.text(text)
|
|
400
|
+
|
|
401
|
+
elif block_type in ("tool_use", "tool_call"):
|
|
402
|
+
tool_id = block.get("id", "")
|
|
403
|
+
name = block.get("name", "")
|
|
404
|
+
args = block.get("input") if block_type == "tool_use" else block.get("args")
|
|
405
|
+
args_payload = args if isinstance(args, dict) else {}
|
|
406
|
+
|
|
407
|
+
if tool_id:
|
|
408
|
+
tracker.update(tool_id, name=name, args=args_payload)
|
|
409
|
+
if tracker.is_ready(tool_id):
|
|
410
|
+
tracker.mark_emitted(tool_id)
|
|
411
|
+
yield emitter.tool_call(name, args_payload, tool_id)
|
|
412
|
+
|
|
413
|
+
elif block_type == "input_json_delta":
|
|
414
|
+
partial_json = block.get("partial_json", "")
|
|
415
|
+
if partial_json:
|
|
416
|
+
tracker.append_json_delta(partial_json, block.get("index", 0))
|
|
417
|
+
|
|
418
|
+
elif block_type == "tool_call_chunk":
|
|
419
|
+
tool_id = block.get("id", "")
|
|
420
|
+
name = block.get("name", "")
|
|
421
|
+
if tool_id:
|
|
422
|
+
tracker.update(tool_id, name=name)
|
|
423
|
+
partial_args = block.get("args", "")
|
|
424
|
+
if isinstance(partial_args, str) and partial_args:
|
|
425
|
+
tracker.append_json_delta(partial_args, block.get("index", 0))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _process_tool_calls(tool_calls: list, emitter: StreamEventEmitter, tracker: ToolCallTracker):
|
|
429
|
+
"""Process tool_calls from chunk.tool_calls attribute."""
|
|
430
|
+
for tc in tool_calls:
|
|
431
|
+
tool_id = tc.get("id", "")
|
|
432
|
+
if tool_id:
|
|
433
|
+
name = tc.get("name", "")
|
|
434
|
+
args = tc.get("args", {})
|
|
435
|
+
args_payload = args if isinstance(args, dict) else {}
|
|
436
|
+
|
|
437
|
+
tracker.update(tool_id, name=name, args=args_payload)
|
|
438
|
+
if tracker.is_ready(tool_id):
|
|
439
|
+
tracker.mark_emitted(tool_id)
|
|
440
|
+
yield emitter.tool_call(name, args_payload, tool_id)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _process_tool_result(chunk, emitter: StreamEventEmitter, tracker: ToolCallTracker):
|
|
444
|
+
"""Process a ToolMessage result."""
|
|
445
|
+
tracker.finalize_all()
|
|
446
|
+
|
|
447
|
+
# Re-emit all tool calls with complete args
|
|
448
|
+
for info in tracker.get_all():
|
|
449
|
+
yield emitter.tool_call(info.name, info.args, info.id)
|
|
450
|
+
|
|
451
|
+
name = getattr(chunk, "name", "unknown")
|
|
452
|
+
raw_content = str(getattr(chunk, "content", ""))
|
|
453
|
+
content = raw_content[:DisplayLimits.TOOL_RESULT_MAX]
|
|
454
|
+
if len(raw_content) > DisplayLimits.TOOL_RESULT_MAX:
|
|
455
|
+
content += "\n... (truncated)"
|
|
456
|
+
|
|
457
|
+
success = is_success(content)
|
|
458
|
+
yield emitter.tool_result(name, content, success)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# =============================================================================
|
|
462
|
+
# Stream state
|
|
463
|
+
# =============================================================================
|
|
464
|
+
|
|
465
|
+
class SubAgentState:
|
|
466
|
+
"""Tracks a single sub-agent's activity."""
|
|
467
|
+
|
|
468
|
+
def __init__(self, name: str, description: str = ""):
|
|
469
|
+
self.name = name
|
|
470
|
+
self.description = description
|
|
471
|
+
self.tool_calls: list[dict] = []
|
|
472
|
+
self.tool_results: list[dict] = []
|
|
473
|
+
self._result_map: dict[str, dict] = {} # tool_call_id → result
|
|
474
|
+
self.is_active = True
|
|
475
|
+
|
|
476
|
+
def add_tool_call(self, name: str, args: dict, tool_id: str = ""):
|
|
477
|
+
# Skip empty-name calls without an id (incomplete streaming chunks)
|
|
478
|
+
if not name and not tool_id:
|
|
479
|
+
return
|
|
480
|
+
tc_data = {"id": tool_id, "name": name, "args": args}
|
|
481
|
+
if tool_id:
|
|
482
|
+
for i, tc in enumerate(self.tool_calls):
|
|
483
|
+
if tc.get("id") == tool_id:
|
|
484
|
+
# Merge: keep the non-empty name/args
|
|
485
|
+
if name:
|
|
486
|
+
self.tool_calls[i]["name"] = name
|
|
487
|
+
if args:
|
|
488
|
+
self.tool_calls[i]["args"] = args
|
|
489
|
+
return
|
|
490
|
+
# Skip if name is empty and we can't deduplicate by id
|
|
491
|
+
if not name:
|
|
492
|
+
return
|
|
493
|
+
self.tool_calls.append(tc_data)
|
|
494
|
+
|
|
495
|
+
def add_tool_result(self, name: str, content: str, success: bool = True):
|
|
496
|
+
result = {"name": name, "content": content, "success": success}
|
|
497
|
+
self.tool_results.append(result)
|
|
498
|
+
# Try to match result to the first unmatched tool call with same name
|
|
499
|
+
for tc in self.tool_calls:
|
|
500
|
+
tc_id = tc.get("id", "")
|
|
501
|
+
tc_name = tc.get("name", "")
|
|
502
|
+
if tc_id and tc_id not in self._result_map and tc_name == name:
|
|
503
|
+
self._result_map[tc_id] = result
|
|
504
|
+
return
|
|
505
|
+
# Fallback: match first unmatched tool call
|
|
506
|
+
for tc in self.tool_calls:
|
|
507
|
+
tc_id = tc.get("id", "")
|
|
508
|
+
if tc_id and tc_id not in self._result_map:
|
|
509
|
+
self._result_map[tc_id] = result
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
def get_result_for(self, tc: dict) -> dict | None:
|
|
513
|
+
"""Get matched result for a tool call."""
|
|
514
|
+
tc_id = tc.get("id", "")
|
|
515
|
+
if tc_id:
|
|
516
|
+
return self._result_map.get(tc_id)
|
|
517
|
+
# Fallback: index-based matching
|
|
518
|
+
try:
|
|
519
|
+
idx = self.tool_calls.index(tc)
|
|
520
|
+
if idx < len(self.tool_results):
|
|
521
|
+
return self.tool_results[idx]
|
|
522
|
+
except ValueError:
|
|
523
|
+
pass
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class StreamState:
|
|
528
|
+
"""Accumulates stream state for display updates."""
|
|
529
|
+
|
|
530
|
+
def __init__(self):
|
|
531
|
+
self.thinking_text = ""
|
|
532
|
+
self.response_text = ""
|
|
533
|
+
self.tool_calls = []
|
|
534
|
+
self.tool_results = []
|
|
535
|
+
self.is_thinking = False
|
|
536
|
+
self.is_responding = False
|
|
537
|
+
self.is_processing = False
|
|
538
|
+
# Sub-agent tracking
|
|
539
|
+
self.subagents: list[SubAgentState] = []
|
|
540
|
+
self._subagent_map: dict[str, SubAgentState] = {} # name → state
|
|
541
|
+
# Todo list tracking
|
|
542
|
+
self.todo_items: list[dict] = []
|
|
543
|
+
# Latest text segment (reset on each tool_call)
|
|
544
|
+
self.latest_text = ""
|
|
545
|
+
|
|
546
|
+
def _get_or_create_subagent(self, name: str, description: str = "") -> SubAgentState:
|
|
547
|
+
if name not in self._subagent_map:
|
|
548
|
+
# Case 1: real name arrives, "sub-agent" entry exists → rename it
|
|
549
|
+
if name != "sub-agent" and "sub-agent" in self._subagent_map:
|
|
550
|
+
old_sa = self._subagent_map.pop("sub-agent")
|
|
551
|
+
old_sa.name = name
|
|
552
|
+
if description:
|
|
553
|
+
old_sa.description = description
|
|
554
|
+
self._subagent_map[name] = old_sa
|
|
555
|
+
return old_sa
|
|
556
|
+
# Case 2: "sub-agent" arrives but a pre-registered real-name entry
|
|
557
|
+
# exists with no tool calls → merge into it
|
|
558
|
+
if name == "sub-agent":
|
|
559
|
+
active_named = [
|
|
560
|
+
sa for sa in self.subagents
|
|
561
|
+
if sa.is_active and sa.name != "sub-agent"
|
|
562
|
+
]
|
|
563
|
+
if len(active_named) == 1 and not active_named[0].tool_calls:
|
|
564
|
+
self._subagent_map[name] = active_named[0]
|
|
565
|
+
return active_named[0]
|
|
566
|
+
sa = SubAgentState(name, description)
|
|
567
|
+
self.subagents.append(sa)
|
|
568
|
+
self._subagent_map[name] = sa
|
|
569
|
+
else:
|
|
570
|
+
existing = self._subagent_map[name]
|
|
571
|
+
if description and not existing.description:
|
|
572
|
+
existing.description = description
|
|
573
|
+
# If this entry was created as "sub-agent" placeholder and the
|
|
574
|
+
# actual name is different, update.
|
|
575
|
+
if name != "sub-agent" and existing.name == "sub-agent":
|
|
576
|
+
existing.name = name
|
|
577
|
+
return self._subagent_map[name]
|
|
578
|
+
|
|
579
|
+
def _resolve_subagent_name(self, name: str) -> str:
|
|
580
|
+
"""Resolve "sub-agent" to the single active named sub-agent when possible."""
|
|
581
|
+
if name != "sub-agent":
|
|
582
|
+
return name
|
|
583
|
+
active_named = [
|
|
584
|
+
sa.name for sa in self.subagents
|
|
585
|
+
if sa.is_active and sa.name != "sub-agent"
|
|
586
|
+
]
|
|
587
|
+
if len(active_named) == 1:
|
|
588
|
+
return active_named[0]
|
|
589
|
+
return name
|
|
590
|
+
|
|
591
|
+
def handle_event(self, event: dict) -> str:
|
|
592
|
+
"""Process a single stream event, update internal state, return event type."""
|
|
593
|
+
event_type: str = event.get("type", "")
|
|
594
|
+
|
|
595
|
+
if event_type == "thinking":
|
|
596
|
+
self.is_thinking = True
|
|
597
|
+
self.is_responding = False
|
|
598
|
+
self.is_processing = False
|
|
599
|
+
self.thinking_text += event.get("content", "")
|
|
600
|
+
|
|
601
|
+
elif event_type == "text":
|
|
602
|
+
self.is_thinking = False
|
|
603
|
+
self.is_responding = True
|
|
604
|
+
self.is_processing = False
|
|
605
|
+
text_content = event.get("content", "")
|
|
606
|
+
self.response_text += text_content
|
|
607
|
+
self.latest_text += text_content
|
|
608
|
+
|
|
609
|
+
elif event_type == "tool_call":
|
|
610
|
+
self.is_thinking = False
|
|
611
|
+
self.is_responding = False
|
|
612
|
+
self.is_processing = False
|
|
613
|
+
self.latest_text = "" # Reset — next text segment is a new message
|
|
614
|
+
|
|
615
|
+
tool_id = event.get("id", "")
|
|
616
|
+
tool_name = event.get("name", "unknown")
|
|
617
|
+
tool_args = event.get("args", {})
|
|
618
|
+
tc_data = {
|
|
619
|
+
"id": tool_id,
|
|
620
|
+
"name": tool_name,
|
|
621
|
+
"args": tool_args,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if tool_id:
|
|
625
|
+
updated = False
|
|
626
|
+
for i, tc in enumerate(self.tool_calls):
|
|
627
|
+
if tc.get("id") == tool_id:
|
|
628
|
+
self.tool_calls[i] = tc_data
|
|
629
|
+
updated = True
|
|
630
|
+
break
|
|
631
|
+
if not updated:
|
|
632
|
+
self.tool_calls.append(tc_data)
|
|
633
|
+
else:
|
|
634
|
+
self.tool_calls.append(tc_data)
|
|
635
|
+
|
|
636
|
+
# Capture todo items from write_todos args (most reliable source)
|
|
637
|
+
if tool_name == "write_todos":
|
|
638
|
+
todos = tool_args.get("todos", [])
|
|
639
|
+
if isinstance(todos, list) and todos:
|
|
640
|
+
self.todo_items = todos
|
|
641
|
+
|
|
642
|
+
elif event_type == "tool_result":
|
|
643
|
+
self.is_processing = True
|
|
644
|
+
result_name = event.get("name", "unknown")
|
|
645
|
+
result_content = event.get("content", "")
|
|
646
|
+
self.tool_results.append({
|
|
647
|
+
"name": result_name,
|
|
648
|
+
"content": result_content,
|
|
649
|
+
})
|
|
650
|
+
# Update todo list from write_todos / read_todos results (fallback)
|
|
651
|
+
if result_name in ("write_todos", "read_todos"):
|
|
652
|
+
parsed = _parse_todo_items(result_content)
|
|
653
|
+
if parsed:
|
|
654
|
+
self.todo_items = parsed
|
|
655
|
+
|
|
656
|
+
elif event_type == "subagent_start":
|
|
657
|
+
name = event.get("name", "sub-agent")
|
|
658
|
+
desc = event.get("description", "")
|
|
659
|
+
sa = self._get_or_create_subagent(name, desc)
|
|
660
|
+
sa.is_active = True
|
|
661
|
+
|
|
662
|
+
elif event_type == "subagent_tool_call":
|
|
663
|
+
sa_name = self._resolve_subagent_name(event.get("subagent", "sub-agent"))
|
|
664
|
+
sa = self._get_or_create_subagent(sa_name)
|
|
665
|
+
sa.add_tool_call(
|
|
666
|
+
event.get("name", "unknown"),
|
|
667
|
+
event.get("args", {}),
|
|
668
|
+
event.get("id", ""),
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
elif event_type == "subagent_tool_result":
|
|
672
|
+
sa_name = self._resolve_subagent_name(event.get("subagent", "sub-agent"))
|
|
673
|
+
sa = self._get_or_create_subagent(sa_name)
|
|
674
|
+
sa.add_tool_result(
|
|
675
|
+
event.get("name", "unknown"),
|
|
676
|
+
event.get("content", ""),
|
|
677
|
+
event.get("success", True),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
elif event_type == "subagent_end":
|
|
681
|
+
name = self._resolve_subagent_name(event.get("name", "sub-agent"))
|
|
682
|
+
if name in self._subagent_map:
|
|
683
|
+
self._subagent_map[name].is_active = False
|
|
684
|
+
elif name == "sub-agent":
|
|
685
|
+
# Couldn't resolve — deactivate the oldest active sub-agent
|
|
686
|
+
for sa in self.subagents:
|
|
687
|
+
if sa.is_active:
|
|
688
|
+
sa.is_active = False
|
|
689
|
+
break
|
|
690
|
+
|
|
691
|
+
elif event_type == "done":
|
|
692
|
+
self.is_processing = False
|
|
693
|
+
if not self.response_text:
|
|
694
|
+
self.response_text = event.get("response", "")
|
|
695
|
+
|
|
696
|
+
elif event_type == "error":
|
|
697
|
+
self.is_processing = False
|
|
698
|
+
self.is_thinking = False
|
|
699
|
+
self.is_responding = False
|
|
700
|
+
error_msg = event.get("message", "Unknown error")
|
|
701
|
+
self.response_text += f"\n\n[Error] {error_msg}"
|
|
702
|
+
|
|
703
|
+
return event_type
|
|
704
|
+
|
|
705
|
+
def get_display_args(self) -> dict:
|
|
706
|
+
"""Get kwargs for create_streaming_display()."""
|
|
707
|
+
return {
|
|
708
|
+
"thinking_text": self.thinking_text,
|
|
709
|
+
"response_text": self.response_text,
|
|
710
|
+
"latest_text": self.latest_text,
|
|
711
|
+
"tool_calls": self.tool_calls,
|
|
712
|
+
"tool_results": self.tool_results,
|
|
713
|
+
"is_thinking": self.is_thinking,
|
|
714
|
+
"is_responding": self.is_responding,
|
|
715
|
+
"is_processing": self.is_processing,
|
|
716
|
+
"subagents": self.subagents,
|
|
717
|
+
"todo_items": self.todo_items,
|
|
718
|
+
}
|
|
119
719
|
|
|
120
720
|
|
|
121
721
|
# =============================================================================
|
|
122
|
-
#
|
|
722
|
+
# Display functions
|
|
123
723
|
# =============================================================================
|
|
124
724
|
|
|
725
|
+
def _parse_todo_items(content: str) -> list[dict] | None:
|
|
726
|
+
"""Parse todo items from write_todos output.
|
|
125
727
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
from .skills_manager import list_skills
|
|
129
|
-
from .paths import USER_SKILLS_DIR
|
|
728
|
+
Attempts to extract a list of dicts with 'status' and 'content' keys
|
|
729
|
+
from the tool result string. Returns None if parsing fails.
|
|
130
730
|
|
|
131
|
-
|
|
731
|
+
Handles formats like:
|
|
732
|
+
- Raw JSON/Python list: [{"content": "...", "status": "..."}]
|
|
733
|
+
- Prefixed: "Updated todo list to [{'content': '...', ...}]"
|
|
734
|
+
"""
|
|
735
|
+
import ast
|
|
736
|
+
import json
|
|
132
737
|
|
|
133
|
-
|
|
134
|
-
console.print("[dim]No user skills installed.[/dim]")
|
|
135
|
-
console.print("[dim]Install with:[/dim] /install-skill <path-or-url>")
|
|
136
|
-
console.print(f"[dim]Skills directory:[/dim] [cyan]{_shorten_path(str(USER_SKILLS_DIR))}[/cyan]")
|
|
137
|
-
console.print()
|
|
138
|
-
return
|
|
738
|
+
content = content.strip()
|
|
139
739
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
740
|
+
def _try_parse(text: str) -> list[dict] | None:
|
|
741
|
+
"""Try JSON then Python literal parsing."""
|
|
742
|
+
text = text.strip()
|
|
743
|
+
try:
|
|
744
|
+
data = json.loads(text)
|
|
745
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
746
|
+
return data
|
|
747
|
+
except (json.JSONDecodeError, ValueError):
|
|
748
|
+
pass
|
|
749
|
+
try:
|
|
750
|
+
data = ast.literal_eval(text)
|
|
751
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
752
|
+
return data
|
|
753
|
+
except (ValueError, SyntaxError):
|
|
754
|
+
pass
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
# Try the full content directly
|
|
758
|
+
result = _try_parse(content)
|
|
759
|
+
if result:
|
|
760
|
+
return result
|
|
761
|
+
|
|
762
|
+
# Extract embedded [...] from content (e.g. "Updated todo list to [{...}]")
|
|
763
|
+
bracket_start = content.find("[")
|
|
764
|
+
if bracket_start != -1:
|
|
765
|
+
bracket_end = content.rfind("]")
|
|
766
|
+
if bracket_end > bracket_start:
|
|
767
|
+
embedded = content[bracket_start:bracket_end + 1]
|
|
768
|
+
result = _try_parse(embedded)
|
|
769
|
+
if result:
|
|
770
|
+
return result
|
|
771
|
+
|
|
772
|
+
# Try line-by-line scan
|
|
773
|
+
for line in content.split("\n"):
|
|
774
|
+
line = line.strip()
|
|
775
|
+
if "[" in line:
|
|
776
|
+
start = line.find("[")
|
|
777
|
+
end = line.rfind("]")
|
|
778
|
+
if end > start:
|
|
779
|
+
result = _try_parse(line[start:end + 1])
|
|
780
|
+
if result:
|
|
781
|
+
return result
|
|
782
|
+
|
|
783
|
+
return None
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _build_todo_stats(items: list[dict]) -> str:
|
|
787
|
+
"""Build stats string like '2 active | 1 pending | 3 done'."""
|
|
788
|
+
counts: dict[str, int] = {}
|
|
789
|
+
for item in items:
|
|
790
|
+
status = str(item.get("status", "todo")).lower()
|
|
791
|
+
# Normalize status names
|
|
792
|
+
if status in ("done", "completed", "complete"):
|
|
793
|
+
status = "done"
|
|
794
|
+
elif status in ("active", "in_progress", "in-progress", "working"):
|
|
795
|
+
status = "active"
|
|
796
|
+
else:
|
|
797
|
+
status = "pending"
|
|
798
|
+
counts[status] = counts.get(status, 0) + 1
|
|
799
|
+
|
|
800
|
+
parts = []
|
|
801
|
+
for key in ("active", "pending", "done"):
|
|
802
|
+
if counts.get(key, 0) > 0:
|
|
803
|
+
parts.append(f"{counts[key]} {key}")
|
|
804
|
+
return " | ".join(parts) if parts else f"{len(items)} items"
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _format_single_todo(item: dict) -> Text:
|
|
808
|
+
"""Format a single todo item with status symbol."""
|
|
809
|
+
status = str(item.get("status", "todo")).lower()
|
|
810
|
+
content_text = str(item.get("content", item.get("task", item.get("title", ""))))
|
|
811
|
+
|
|
812
|
+
if status in ("done", "completed", "complete"):
|
|
813
|
+
symbol = "\u2713"
|
|
814
|
+
label = "done "
|
|
815
|
+
style = "green dim"
|
|
816
|
+
elif status in ("active", "in_progress", "in-progress", "working"):
|
|
817
|
+
symbol = "\u25cf"
|
|
818
|
+
label = "active"
|
|
819
|
+
style = "yellow"
|
|
820
|
+
else:
|
|
821
|
+
symbol = "\u25cb"
|
|
822
|
+
label = "todo "
|
|
823
|
+
style = "dim"
|
|
145
824
|
|
|
825
|
+
line = Text()
|
|
826
|
+
line.append(f" {symbol} ", style=style)
|
|
827
|
+
line.append(label, style=style)
|
|
828
|
+
line.append(" ", style="dim")
|
|
829
|
+
# Truncate long content
|
|
830
|
+
if len(content_text) > 60:
|
|
831
|
+
content_text = content_text[:57] + "..."
|
|
832
|
+
line.append(content_text, style=style)
|
|
833
|
+
return line
|
|
146
834
|
|
|
147
|
-
def _cmd_install_skill(source: str) -> None:
|
|
148
|
-
"""Install a skill from local path or GitHub URL."""
|
|
149
|
-
from .skills_manager import install_skill
|
|
150
835
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
console.print("[dim]Examples:[/dim]")
|
|
154
|
-
console.print(" /install-skill ./my-skill")
|
|
155
|
-
console.print(" /install-skill https://github.com/user/repo/tree/main/skill-name")
|
|
156
|
-
console.print(" /install-skill user/repo@skill-name")
|
|
157
|
-
console.print()
|
|
158
|
-
return
|
|
836
|
+
def format_tool_result_compact(_name: str, content: str, max_lines: int = 5) -> list:
|
|
837
|
+
"""Format tool result as tree output.
|
|
159
838
|
|
|
160
|
-
|
|
839
|
+
Special handling for write_todos: shows formatted checklist with status symbols.
|
|
840
|
+
"""
|
|
841
|
+
elements = []
|
|
842
|
+
|
|
843
|
+
if not content.strip():
|
|
844
|
+
elements.append(Text(" \u2514 (empty)", style="dim"))
|
|
845
|
+
return elements
|
|
846
|
+
|
|
847
|
+
# Special handling for write_todos
|
|
848
|
+
if _name == "write_todos":
|
|
849
|
+
items = _parse_todo_items(content)
|
|
850
|
+
if items:
|
|
851
|
+
stats = _build_todo_stats(items)
|
|
852
|
+
stats_line = Text()
|
|
853
|
+
stats_line.append(" \u2514 ", style="dim")
|
|
854
|
+
stats_line.append(stats, style="dim")
|
|
855
|
+
elements.append(stats_line)
|
|
856
|
+
elements.append(Text("", style="dim")) # blank line
|
|
857
|
+
|
|
858
|
+
max_preview = 4
|
|
859
|
+
for item in items[:max_preview]:
|
|
860
|
+
elements.append(_format_single_todo(item))
|
|
861
|
+
|
|
862
|
+
remaining = len(items) - max_preview
|
|
863
|
+
if remaining > 0:
|
|
864
|
+
elements.append(Text(f" ... {remaining} more", style="dim italic"))
|
|
865
|
+
|
|
866
|
+
return elements
|
|
867
|
+
|
|
868
|
+
lines = content.strip().split("\n")
|
|
869
|
+
total_lines = len(lines)
|
|
870
|
+
|
|
871
|
+
display_lines = lines[:max_lines]
|
|
872
|
+
for i, line in enumerate(display_lines):
|
|
873
|
+
prefix = "\u2514" if i == 0 else " "
|
|
874
|
+
if len(line) > 80:
|
|
875
|
+
line = line[:77] + "..."
|
|
876
|
+
style = "dim" if is_success(content) else "red dim"
|
|
877
|
+
elements.append(Text(f" {prefix} {line}", style=style))
|
|
878
|
+
|
|
879
|
+
remaining = total_lines - max_lines
|
|
880
|
+
if remaining > 0:
|
|
881
|
+
elements.append(Text(f" ... +{remaining} lines", style="dim italic"))
|
|
882
|
+
|
|
883
|
+
return elements
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _render_tool_call_line(tc: dict, tr: dict | None) -> Text:
|
|
887
|
+
"""Render a single tool call line with status indicator."""
|
|
888
|
+
is_task = tc.get('name', '').lower() == 'task'
|
|
889
|
+
|
|
890
|
+
if tr is not None:
|
|
891
|
+
content = tr.get('content', '')
|
|
892
|
+
if is_success(content):
|
|
893
|
+
style = "bold green"
|
|
894
|
+
indicator = "\u2713" if is_task else ToolStatus.SUCCESS.value
|
|
895
|
+
else:
|
|
896
|
+
style = "bold red"
|
|
897
|
+
indicator = "\u2717" if is_task else ToolStatus.ERROR.value
|
|
898
|
+
else:
|
|
899
|
+
style = "bold yellow" if not is_task else "bold cyan"
|
|
900
|
+
indicator = "\u25b6" if is_task else ToolStatus.RUNNING.value
|
|
161
901
|
|
|
162
|
-
|
|
902
|
+
tool_compact = format_tool_compact(tc['name'], tc.get('args'))
|
|
903
|
+
tool_text = Text()
|
|
904
|
+
tool_text.append(f"{indicator} ", style=style)
|
|
905
|
+
tool_text.append(tool_compact, style=style)
|
|
906
|
+
return tool_text
|
|
163
907
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
908
|
+
|
|
909
|
+
def _render_subagent_section(sa: 'SubAgentState', compact: bool = False) -> list:
|
|
910
|
+
"""Render a sub-agent's activity as a bordered section.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
sa: Sub-agent state to render
|
|
914
|
+
compact: If True, render minimal 1-line summary (completed sub-agents)
|
|
915
|
+
|
|
916
|
+
Header uses "Cooking with {name}" style matching task tool format.
|
|
917
|
+
Active sub-agents show bordered tool list; completed ones collapse to 1 line.
|
|
918
|
+
"""
|
|
919
|
+
elements = []
|
|
920
|
+
BORDER = "dim cyan" if sa.is_active else "dim"
|
|
921
|
+
|
|
922
|
+
# Filter out tool calls with empty names
|
|
923
|
+
valid_calls = [tc for tc in sa.tool_calls if tc.get("name")]
|
|
924
|
+
|
|
925
|
+
# Split into completed and pending
|
|
926
|
+
completed = []
|
|
927
|
+
pending = []
|
|
928
|
+
for tc in valid_calls:
|
|
929
|
+
tr = sa.get_result_for(tc)
|
|
930
|
+
if tr is not None:
|
|
931
|
+
completed.append((tc, tr))
|
|
932
|
+
else:
|
|
933
|
+
pending.append(tc)
|
|
934
|
+
|
|
935
|
+
succeeded = sum(1 for _, tr in completed if tr.get("success", True))
|
|
936
|
+
failed = len(completed) - succeeded
|
|
937
|
+
|
|
938
|
+
# Build display name
|
|
939
|
+
display_name = f"Cooking with {sa.name}"
|
|
940
|
+
if sa.description:
|
|
941
|
+
desc = sa.description[:50] + "..." if len(sa.description) > 50 else sa.description
|
|
942
|
+
display_name += f" \u2014 {desc}"
|
|
943
|
+
|
|
944
|
+
# --- Compact mode: 1-line summary for completed sub-agents ---
|
|
945
|
+
if compact:
|
|
946
|
+
line = Text()
|
|
947
|
+
if not sa.is_active:
|
|
948
|
+
line.append("\u2713 ", style="green")
|
|
949
|
+
line.append(display_name, style="green dim")
|
|
950
|
+
total = len(valid_calls)
|
|
951
|
+
line.append(f" ({total} tools)", style="dim")
|
|
952
|
+
else:
|
|
953
|
+
line.append("\u25b6 ", style="cyan")
|
|
954
|
+
line.append(display_name, style="bold cyan")
|
|
955
|
+
elements.append(line)
|
|
956
|
+
return elements
|
|
957
|
+
|
|
958
|
+
# --- Full mode: bordered section for Live streaming ---
|
|
959
|
+
|
|
960
|
+
# Header
|
|
961
|
+
header = Text()
|
|
962
|
+
header.append("\u250c ", style=BORDER)
|
|
963
|
+
if sa.is_active:
|
|
964
|
+
header.append(f"\u25b6 {display_name}", style="bold cyan")
|
|
170
965
|
else:
|
|
171
|
-
|
|
172
|
-
|
|
966
|
+
header.append(f"\u2713 {display_name}", style="bold green")
|
|
967
|
+
elements.append(header)
|
|
968
|
+
|
|
969
|
+
# Show every tool call with its status
|
|
970
|
+
for tc, tr in completed:
|
|
971
|
+
tc_line = Text("\u2502 ", style=BORDER)
|
|
972
|
+
tc_name = format_tool_compact(tc["name"], tc.get("args"))
|
|
973
|
+
if tr.get("success", True):
|
|
974
|
+
tc_line.append(f"\u2713 {tc_name}", style="green")
|
|
975
|
+
else:
|
|
976
|
+
tc_line.append(f"\u2717 {tc_name}", style="red")
|
|
977
|
+
content = tr.get("content", "")
|
|
978
|
+
first_line = content.strip().split("\n")[0][:70]
|
|
979
|
+
if first_line:
|
|
980
|
+
err_line = Text("\u2502 ", style=BORDER)
|
|
981
|
+
err_line.append(f"\u2514 {first_line}", style="red dim")
|
|
982
|
+
elements.append(tc_line)
|
|
983
|
+
elements.append(err_line)
|
|
984
|
+
continue
|
|
985
|
+
elements.append(tc_line)
|
|
986
|
+
|
|
987
|
+
# Pending/running tools
|
|
988
|
+
for tc in pending:
|
|
989
|
+
tc_line = Text("\u2502 ", style=BORDER)
|
|
990
|
+
tc_name = format_tool_compact(tc["name"], tc.get("args"))
|
|
991
|
+
tc_line.append(f"\u25cf {tc_name}", style="bold yellow")
|
|
992
|
+
elements.append(tc_line)
|
|
993
|
+
spinner_line = Text("\u2502 ", style=BORDER)
|
|
994
|
+
spinner_line.append("\u21bb running...", style="yellow dim")
|
|
995
|
+
elements.append(spinner_line)
|
|
996
|
+
|
|
997
|
+
# Footer
|
|
998
|
+
if not sa.is_active:
|
|
999
|
+
total = len(valid_calls)
|
|
1000
|
+
footer = Text(f"\u2514 done ({total} tools)", style="dim green")
|
|
1001
|
+
elements.append(footer)
|
|
1002
|
+
elif valid_calls:
|
|
1003
|
+
footer = Text("\u2514 running...", style="dim cyan")
|
|
1004
|
+
elements.append(footer)
|
|
1005
|
+
|
|
1006
|
+
return elements
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _render_todo_panel(todo_items: list[dict]) -> Panel:
|
|
1010
|
+
"""Render a bordered Task List panel from todo items.
|
|
1011
|
+
|
|
1012
|
+
Matches the style: cyan border, status icons per item.
|
|
1013
|
+
"""
|
|
1014
|
+
lines = Text()
|
|
1015
|
+
for i, item in enumerate(todo_items):
|
|
1016
|
+
if i > 0:
|
|
1017
|
+
lines.append("\n")
|
|
1018
|
+
status = str(item.get("status", "todo")).lower()
|
|
1019
|
+
content_text = str(item.get("content", item.get("task", item.get("title", ""))))
|
|
1020
|
+
|
|
1021
|
+
if status in ("done", "completed", "complete"):
|
|
1022
|
+
symbol = "\u2713" # ✓
|
|
1023
|
+
style = "green dim"
|
|
1024
|
+
elif status in ("active", "in_progress", "in-progress", "working"):
|
|
1025
|
+
symbol = "\u23f3" # ⏳
|
|
1026
|
+
style = "yellow"
|
|
1027
|
+
else:
|
|
1028
|
+
symbol = "\u25a1" # □
|
|
1029
|
+
style = "dim"
|
|
1030
|
+
|
|
1031
|
+
lines.append(f"{symbol} ", style=style)
|
|
1032
|
+
lines.append(content_text, style=style)
|
|
1033
|
+
|
|
1034
|
+
return Panel(
|
|
1035
|
+
lines,
|
|
1036
|
+
title="Task List",
|
|
1037
|
+
title_align="center",
|
|
1038
|
+
border_style="cyan",
|
|
1039
|
+
padding=(0, 1),
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def create_streaming_display(
|
|
1044
|
+
thinking_text: str = "",
|
|
1045
|
+
response_text: str = "",
|
|
1046
|
+
latest_text: str = "",
|
|
1047
|
+
tool_calls: list | None = None,
|
|
1048
|
+
tool_results: list | None = None,
|
|
1049
|
+
is_thinking: bool = False,
|
|
1050
|
+
is_responding: bool = False,
|
|
1051
|
+
is_waiting: bool = False,
|
|
1052
|
+
is_processing: bool = False,
|
|
1053
|
+
show_thinking: bool = True,
|
|
1054
|
+
subagents: list | None = None,
|
|
1055
|
+
todo_items: list | None = None,
|
|
1056
|
+
) -> Any:
|
|
1057
|
+
"""Create Rich display layout for streaming output.
|
|
173
1058
|
|
|
1059
|
+
Returns:
|
|
1060
|
+
Rich Group for Live display
|
|
1061
|
+
"""
|
|
1062
|
+
elements = []
|
|
1063
|
+
tool_calls = tool_calls or []
|
|
1064
|
+
tool_results = tool_results or []
|
|
1065
|
+
subagents = subagents or []
|
|
1066
|
+
|
|
1067
|
+
# Initial waiting state
|
|
1068
|
+
if is_waiting and not thinking_text and not response_text and not tool_calls:
|
|
1069
|
+
spinner = Spinner("dots", text=" Thinking...", style="cyan")
|
|
1070
|
+
elements.append(spinner)
|
|
1071
|
+
return Group(*elements)
|
|
1072
|
+
|
|
1073
|
+
# Thinking panel
|
|
1074
|
+
if show_thinking and thinking_text:
|
|
1075
|
+
thinking_title = "Thinking"
|
|
1076
|
+
if is_thinking:
|
|
1077
|
+
thinking_title += " ..."
|
|
1078
|
+
display_thinking = thinking_text
|
|
1079
|
+
if len(display_thinking) > DisplayLimits.THINKING_STREAM:
|
|
1080
|
+
display_thinking = "..." + display_thinking[-DisplayLimits.THINKING_STREAM:]
|
|
1081
|
+
elements.append(Panel(
|
|
1082
|
+
Text(display_thinking, style="dim"),
|
|
1083
|
+
title=thinking_title,
|
|
1084
|
+
border_style="blue",
|
|
1085
|
+
padding=(0, 1),
|
|
1086
|
+
))
|
|
1087
|
+
|
|
1088
|
+
# Tool calls and results paired display
|
|
1089
|
+
# Collapse older completed tools to prevent overflow in Live mode
|
|
1090
|
+
# Task tool calls are ALWAYS visible (they represent sub-agent delegations)
|
|
1091
|
+
MAX_VISIBLE_TOOLS = 4
|
|
1092
|
+
MAX_VISIBLE_RUNNING = 3
|
|
1093
|
+
|
|
1094
|
+
if tool_calls:
|
|
1095
|
+
# Split into categories
|
|
1096
|
+
completed_regular = [] # completed non-task tools
|
|
1097
|
+
task_tools = [] # task tools (always visible)
|
|
1098
|
+
running_regular = [] # running non-task tools
|
|
1099
|
+
|
|
1100
|
+
for i, tc in enumerate(tool_calls):
|
|
1101
|
+
has_result = i < len(tool_results)
|
|
1102
|
+
tr = tool_results[i] if has_result else None
|
|
1103
|
+
is_task = tc.get('name') == 'task'
|
|
1104
|
+
|
|
1105
|
+
if is_task:
|
|
1106
|
+
# Skip task calls with empty args (still streaming)
|
|
1107
|
+
if tc.get('args'):
|
|
1108
|
+
task_tools.append((tc, tr))
|
|
1109
|
+
elif has_result:
|
|
1110
|
+
completed_regular.append((tc, tr))
|
|
1111
|
+
else:
|
|
1112
|
+
running_regular.append((tc, None))
|
|
1113
|
+
|
|
1114
|
+
# --- Completed regular tools (collapsible) ---
|
|
1115
|
+
slots = max(0, MAX_VISIBLE_TOOLS - len(running_regular))
|
|
1116
|
+
hidden = completed_regular[:-slots] if slots and len(completed_regular) > slots else (completed_regular if not slots else [])
|
|
1117
|
+
visible = completed_regular[-slots:] if slots else []
|
|
1118
|
+
|
|
1119
|
+
if hidden:
|
|
1120
|
+
ok = sum(1 for _, tr in hidden if is_success(tr.get('content', '')))
|
|
1121
|
+
fail = len(hidden) - ok
|
|
1122
|
+
summary = Text()
|
|
1123
|
+
summary.append(f"\u2713 {ok} completed", style="dim green")
|
|
1124
|
+
if fail > 0:
|
|
1125
|
+
summary.append(f" | {fail} failed", style="dim red")
|
|
1126
|
+
elements.append(summary)
|
|
1127
|
+
|
|
1128
|
+
for tc, tr in visible:
|
|
1129
|
+
elements.append(_render_tool_call_line(tc, tr))
|
|
1130
|
+
content = tr.get('content', '') if tr else ''
|
|
1131
|
+
if tr and not is_success(content):
|
|
1132
|
+
result_elements = format_tool_result_compact(
|
|
1133
|
+
tr['name'], content, max_lines=5,
|
|
1134
|
+
)
|
|
1135
|
+
elements.extend(result_elements)
|
|
1136
|
+
|
|
1137
|
+
# --- Running regular tools (limit visible) ---
|
|
1138
|
+
hidden_running = len(running_regular) - MAX_VISIBLE_RUNNING
|
|
1139
|
+
if hidden_running > 0:
|
|
1140
|
+
summary = Text()
|
|
1141
|
+
summary.append(f"\u25cf {hidden_running} more running...", style="dim yellow")
|
|
1142
|
+
elements.append(summary)
|
|
1143
|
+
running_regular = running_regular[-MAX_VISIBLE_RUNNING:]
|
|
1144
|
+
|
|
1145
|
+
for tc, tr in running_regular:
|
|
1146
|
+
elements.append(_render_tool_call_line(tc, tr))
|
|
1147
|
+
spinner = Spinner("dots", text=" Running...", style="yellow")
|
|
1148
|
+
elements.append(spinner)
|
|
1149
|
+
|
|
1150
|
+
# Task tool calls are rendered as part of sub-agent sections below
|
|
1151
|
+
|
|
1152
|
+
# Response text handling
|
|
1153
|
+
has_pending_tools = len(tool_calls) > len(tool_results)
|
|
1154
|
+
any_active_subagent = any(sa.is_active for sa in subagents)
|
|
1155
|
+
has_used_tools = len(tool_calls) > 0
|
|
1156
|
+
all_done = not has_pending_tools and not any_active_subagent and not is_processing
|
|
1157
|
+
|
|
1158
|
+
# Intermediate narration (tools still running) — dim italic above Task List
|
|
1159
|
+
if latest_text and has_used_tools and not all_done:
|
|
1160
|
+
preview = latest_text.strip()
|
|
1161
|
+
if preview:
|
|
1162
|
+
last_line = preview.split("\n")[-1].strip()
|
|
1163
|
+
if last_line:
|
|
1164
|
+
if len(last_line) > 80:
|
|
1165
|
+
last_line = last_line[:77] + "..."
|
|
1166
|
+
elements.append(Text(f" {last_line}", style="dim italic"))
|
|
1167
|
+
|
|
1168
|
+
# Task List panel (persistent, updates on write_todos / read_todos)
|
|
1169
|
+
todo_items = todo_items or []
|
|
1170
|
+
if todo_items:
|
|
1171
|
+
elements.append(Text("")) # blank separator
|
|
1172
|
+
elements.append(_render_todo_panel(todo_items))
|
|
1173
|
+
|
|
1174
|
+
# Sub-agent activity sections
|
|
1175
|
+
# Active: full bordered view; Completed: compact 1-line summary
|
|
1176
|
+
for sa in subagents:
|
|
1177
|
+
if sa.tool_calls or sa.is_active:
|
|
1178
|
+
elements.extend(_render_subagent_section(sa, compact=not sa.is_active))
|
|
1179
|
+
|
|
1180
|
+
# Processing state after tool execution
|
|
1181
|
+
if is_processing and not is_thinking and not is_responding and not response_text:
|
|
1182
|
+
# Check if any sub-agent is active
|
|
1183
|
+
any_active = any(sa.is_active for sa in subagents)
|
|
1184
|
+
if not any_active:
|
|
1185
|
+
spinner = Spinner("dots", text=" Analyzing results...", style="cyan")
|
|
1186
|
+
elements.append(spinner)
|
|
1187
|
+
|
|
1188
|
+
# Final response — render as Markdown when all work is done
|
|
1189
|
+
if response_text and all_done:
|
|
1190
|
+
elements.append(Text("")) # blank separator
|
|
1191
|
+
elements.append(Markdown(response_text))
|
|
1192
|
+
elif is_responding and not thinking_text and not has_pending_tools:
|
|
1193
|
+
elements.append(Text("Generating response...", style="dim"))
|
|
1194
|
+
|
|
1195
|
+
return Group(*elements) if elements else Text("Processing...", style="dim")
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def display_final_results(
|
|
1199
|
+
state: StreamState,
|
|
1200
|
+
thinking_max_length: int = DisplayLimits.THINKING_FINAL,
|
|
1201
|
+
show_thinking: bool = True,
|
|
1202
|
+
show_tools: bool = True,
|
|
1203
|
+
) -> None:
|
|
1204
|
+
"""Display final results after streaming completes."""
|
|
1205
|
+
if show_thinking and state.thinking_text:
|
|
1206
|
+
display_thinking = state.thinking_text
|
|
1207
|
+
if len(display_thinking) > thinking_max_length:
|
|
1208
|
+
half = thinking_max_length // 2
|
|
1209
|
+
display_thinking = display_thinking[:half] + "\n\n... (truncated) ...\n\n" + display_thinking[-half:]
|
|
1210
|
+
console.print(Panel(
|
|
1211
|
+
Text(display_thinking, style="dim"),
|
|
1212
|
+
title="Thinking",
|
|
1213
|
+
border_style="blue",
|
|
1214
|
+
))
|
|
1215
|
+
|
|
1216
|
+
if show_tools and state.tool_calls:
|
|
1217
|
+
shown_sa_names: set[str] = set()
|
|
1218
|
+
|
|
1219
|
+
for i, tc in enumerate(state.tool_calls):
|
|
1220
|
+
has_result = i < len(state.tool_results)
|
|
1221
|
+
tr = state.tool_results[i] if has_result else None
|
|
1222
|
+
content = tr.get('content', '') if tr is not None else ''
|
|
1223
|
+
is_task = tc.get('name', '').lower() == 'task'
|
|
1224
|
+
|
|
1225
|
+
# Task tools: show delegation line + compact sub-agent summary
|
|
1226
|
+
if is_task:
|
|
1227
|
+
console.print(_render_tool_call_line(tc, tr))
|
|
1228
|
+
sa_name = tc.get('args', {}).get('subagent_type', '')
|
|
1229
|
+
task_desc = tc.get('args', {}).get('description', '')
|
|
1230
|
+
matched_sa = None
|
|
1231
|
+
for sa in state.subagents:
|
|
1232
|
+
if sa.name == sa_name or (task_desc and task_desc in (sa.description or '')):
|
|
1233
|
+
matched_sa = sa
|
|
1234
|
+
break
|
|
1235
|
+
if matched_sa:
|
|
1236
|
+
shown_sa_names.add(matched_sa.name)
|
|
1237
|
+
for elem in _render_subagent_section(matched_sa, compact=True):
|
|
1238
|
+
console.print(elem)
|
|
1239
|
+
continue
|
|
174
1240
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
1241
|
+
# Regular tools: show tool call line + result
|
|
1242
|
+
console.print(_render_tool_call_line(tc, tr))
|
|
1243
|
+
if has_result and tr is not None:
|
|
1244
|
+
result_elements = format_tool_result_compact(
|
|
1245
|
+
tr['name'],
|
|
1246
|
+
content,
|
|
1247
|
+
max_lines=10,
|
|
1248
|
+
)
|
|
1249
|
+
for elem in result_elements:
|
|
1250
|
+
console.print(elem)
|
|
1251
|
+
|
|
1252
|
+
# Render any sub-agents not already shown via task tool calls
|
|
1253
|
+
for sa in state.subagents:
|
|
1254
|
+
if sa.name not in shown_sa_names and (sa.tool_calls or sa.is_active):
|
|
1255
|
+
for elem in _render_subagent_section(sa, compact=True):
|
|
1256
|
+
console.print(elem)
|
|
178
1257
|
|
|
179
|
-
if not name:
|
|
180
|
-
console.print("[red]Usage:[/red] /uninstall-skill <skill-name>")
|
|
181
|
-
console.print("[dim]Use /skills to see installed skills.[/dim]")
|
|
182
1258
|
console.print()
|
|
183
|
-
return
|
|
184
1259
|
|
|
185
|
-
|
|
1260
|
+
# Task List panel in final output
|
|
1261
|
+
if state.todo_items:
|
|
1262
|
+
console.print(_render_todo_panel(state.todo_items))
|
|
1263
|
+
console.print()
|
|
186
1264
|
|
|
187
|
-
if
|
|
188
|
-
console.print(
|
|
189
|
-
console.print(
|
|
190
|
-
|
|
191
|
-
console.print(f"[red]Failed:[/red] {result['error']}")
|
|
192
|
-
console.print()
|
|
1265
|
+
if state.response_text:
|
|
1266
|
+
console.print()
|
|
1267
|
+
console.print(Markdown(state.response_text))
|
|
1268
|
+
console.print()
|
|
193
1269
|
|
|
194
1270
|
|
|
195
1271
|
# =============================================================================
|
|
196
|
-
#
|
|
1272
|
+
# Async-to-sync bridge
|
|
197
1273
|
# =============================================================================
|
|
198
1274
|
|
|
199
|
-
def
|
|
1275
|
+
def _run_streaming(
|
|
200
1276
|
agent: Any,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
model: str | None = None,
|
|
206
|
-
provider: str | None = None,
|
|
1277
|
+
message: str,
|
|
1278
|
+
thread_id: str,
|
|
1279
|
+
show_thinking: bool,
|
|
1280
|
+
interactive: bool,
|
|
207
1281
|
) -> None:
|
|
1282
|
+
"""Run async streaming and render with Rich Live display.
|
|
1283
|
+
|
|
1284
|
+
Bridges the async stream_agent_events() into synchronous Rich Live rendering
|
|
1285
|
+
using asyncio.run().
|
|
1286
|
+
|
|
1287
|
+
Args:
|
|
1288
|
+
agent: Compiled agent graph
|
|
1289
|
+
message: User message
|
|
1290
|
+
thread_id: Thread ID
|
|
1291
|
+
show_thinking: Whether to show thinking panel
|
|
1292
|
+
interactive: If True, use simplified final display (no panel)
|
|
1293
|
+
"""
|
|
1294
|
+
state = StreamState()
|
|
1295
|
+
|
|
1296
|
+
async def _consume() -> None:
|
|
1297
|
+
async for event in stream_agent_events(agent, message, thread_id):
|
|
1298
|
+
event_type = state.handle_event(event)
|
|
1299
|
+
live.update(create_streaming_display(
|
|
1300
|
+
**state.get_display_args(),
|
|
1301
|
+
show_thinking=show_thinking,
|
|
1302
|
+
))
|
|
1303
|
+
if event_type in (
|
|
1304
|
+
"tool_call", "tool_result",
|
|
1305
|
+
"subagent_start", "subagent_tool_call",
|
|
1306
|
+
"subagent_tool_result", "subagent_end",
|
|
1307
|
+
):
|
|
1308
|
+
live.refresh()
|
|
1309
|
+
|
|
1310
|
+
with Live(console=console, refresh_per_second=10, transient=True) as live:
|
|
1311
|
+
live.update(create_streaming_display(is_waiting=True))
|
|
1312
|
+
asyncio.run(_consume())
|
|
1313
|
+
|
|
1314
|
+
if interactive:
|
|
1315
|
+
display_final_results(
|
|
1316
|
+
state,
|
|
1317
|
+
thinking_max_length=500,
|
|
1318
|
+
show_thinking=False,
|
|
1319
|
+
show_tools=True,
|
|
1320
|
+
)
|
|
1321
|
+
else:
|
|
1322
|
+
console.print()
|
|
1323
|
+
display_final_results(
|
|
1324
|
+
state,
|
|
1325
|
+
show_tools=True,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
# =============================================================================
|
|
1330
|
+
# CLI commands
|
|
1331
|
+
# =============================================================================
|
|
1332
|
+
|
|
1333
|
+
EVOSCIENTIST_ASCII_LINES = [
|
|
1334
|
+
r" ███████╗ ██╗ ██╗ ██████╗ ███████╗ ██████╗ ██╗ ███████╗ ███╗ ██╗ ████████╗ ██╗ ███████╗ ████████╗",
|
|
1335
|
+
r" ██╔════╝ ██║ ██║ ██╔═══██╗ ██╔════╝ ██╔════╝ ██║ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ██║ ██╔════╝ ╚══██╔══╝",
|
|
1336
|
+
r" █████╗ ██║ ██║ ██║ ██║ ███████╗ ██║ ██║ █████╗ ██╔██╗ ██║ ██║ ██║ ███████╗ ██║ ",
|
|
1337
|
+
r" ██╔══╝ ╚██╗ ██╔╝ ██║ ██║ ╚════██║ ██║ ██║ ██╔══╝ ██║╚██╗██║ ██║ ██║ ╚════██║ ██║ ",
|
|
1338
|
+
r" ███████╗ ╚████╔╝ ╚██████╔╝ ███████║ ╚██████╗ ██║ ███████╗ ██║ ╚████║ ██║ ██║ ███████║ ██║ ",
|
|
1339
|
+
r" ╚══════╝ ╚═══╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ",
|
|
1340
|
+
]
|
|
1341
|
+
|
|
1342
|
+
# Blue gradient: deep navy → royal blue → sky blue → cyan
|
|
1343
|
+
_GRADIENT_COLORS = ["#1a237e", "#1565c0", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9"]
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def print_banner(thread_id: str, workspace_dir: str | None = None):
|
|
1347
|
+
"""Print welcome banner with ASCII art logo, thread ID, and workspace path."""
|
|
1348
|
+
for line, color in zip(EVOSCIENTIST_ASCII_LINES, _GRADIENT_COLORS):
|
|
1349
|
+
console.print(Text(line, style=f"{color} bold"))
|
|
1350
|
+
info = Text()
|
|
1351
|
+
info.append(" Thread: ", style="dim")
|
|
1352
|
+
info.append(thread_id, style="yellow")
|
|
1353
|
+
if workspace_dir:
|
|
1354
|
+
info.append("\n Workspace: ", style="dim")
|
|
1355
|
+
info.append(workspace_dir, style="cyan")
|
|
1356
|
+
info.append("\n Commands: ", style="dim")
|
|
1357
|
+
info.append("/exit", style="bold")
|
|
1358
|
+
info.append(", ", style="dim")
|
|
1359
|
+
info.append("/new", style="bold")
|
|
1360
|
+
info.append(" (new session), ", style="dim")
|
|
1361
|
+
info.append("/thread", style="bold")
|
|
1362
|
+
info.append(" (show thread ID)", style="dim")
|
|
1363
|
+
console.print(info)
|
|
1364
|
+
console.print()
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def cmd_interactive(agent: Any, show_thinking: bool = True, workspace_dir: str | None = None) -> None:
|
|
208
1368
|
"""Interactive conversation mode with streaming output.
|
|
209
1369
|
|
|
210
1370
|
Args:
|
|
211
1371
|
agent: Compiled agent graph
|
|
212
1372
|
show_thinking: Whether to display thinking panels
|
|
213
1373
|
workspace_dir: Per-session workspace directory path
|
|
214
|
-
workspace_fixed: If True, /new keeps the same workspace directory
|
|
215
|
-
mode: Workspace mode ('daemon' or 'run'), displayed in banner
|
|
216
|
-
model: Model name to display in banner
|
|
217
|
-
provider: LLM provider name to display in banner
|
|
218
1374
|
"""
|
|
219
1375
|
thread_id = str(uuid.uuid4())
|
|
220
|
-
|
|
221
|
-
memory_dir = MEMORY_DIR
|
|
222
|
-
print_banner(thread_id, workspace_dir, memory_dir, mode, model, provider)
|
|
1376
|
+
print_banner(thread_id, workspace_dir)
|
|
223
1377
|
|
|
224
1378
|
history_file = str(os.path.expanduser("~/.EvoScientist_history"))
|
|
225
1379
|
session = PromptSession(
|
|
@@ -254,40 +1408,22 @@ def cmd_interactive(
|
|
|
254
1408
|
break
|
|
255
1409
|
|
|
256
1410
|
if user_input.lower() == "/new":
|
|
257
|
-
# New session: new
|
|
258
|
-
|
|
259
|
-
workspace_dir = _create_session_workspace()
|
|
1411
|
+
# New session: new workspace, new agent, new thread
|
|
1412
|
+
workspace_dir = _create_session_workspace()
|
|
260
1413
|
console.print("[dim]Loading new session...[/dim]")
|
|
261
1414
|
agent = _load_agent(workspace_dir=workspace_dir)
|
|
262
1415
|
thread_id = str(uuid.uuid4())
|
|
263
1416
|
console.print(f"[green]New session:[/green] [yellow]{thread_id}[/yellow]")
|
|
264
|
-
|
|
265
|
-
console.print(f"[dim]Workspace:[/dim] [cyan]{_shorten_path(workspace_dir)}[/cyan]\n")
|
|
1417
|
+
console.print(f"[dim]Workspace:[/dim] [cyan]{workspace_dir}[/cyan]\n")
|
|
266
1418
|
continue
|
|
267
1419
|
|
|
268
1420
|
if user_input.lower() == "/thread":
|
|
269
1421
|
console.print(f"[dim]Thread:[/dim] [yellow]{thread_id}[/yellow]")
|
|
270
1422
|
if workspace_dir:
|
|
271
|
-
console.print(f"[dim]Workspace:[/dim] [cyan]{
|
|
272
|
-
if memory_dir:
|
|
273
|
-
console.print(f"[dim]Memory dir:[/dim] [cyan]{_shorten_path(memory_dir)}[/cyan]")
|
|
1423
|
+
console.print(f"[dim]Workspace:[/dim] [cyan]{workspace_dir}[/cyan]")
|
|
274
1424
|
console.print()
|
|
275
1425
|
continue
|
|
276
1426
|
|
|
277
|
-
if user_input.lower() == "/skills":
|
|
278
|
-
_cmd_list_skills()
|
|
279
|
-
continue
|
|
280
|
-
|
|
281
|
-
if user_input.lower().startswith("/install-skill"):
|
|
282
|
-
source = user_input[len("/install-skill"):].strip()
|
|
283
|
-
_cmd_install_skill(source)
|
|
284
|
-
continue
|
|
285
|
-
|
|
286
|
-
if user_input.lower().startswith("/uninstall-skill"):
|
|
287
|
-
name = user_input[len("/uninstall-skill"):].strip()
|
|
288
|
-
_cmd_uninstall_skill(name)
|
|
289
|
-
continue
|
|
290
|
-
|
|
291
1427
|
# Stream agent response
|
|
292
1428
|
console.print()
|
|
293
1429
|
_run_streaming(agent, user_input, thread_id, show_thinking, interactive=True)
|
|
@@ -297,13 +1433,7 @@ def cmd_interactive(
|
|
|
297
1433
|
console.print("\n[dim]Goodbye![/dim]")
|
|
298
1434
|
break
|
|
299
1435
|
except Exception as e:
|
|
300
|
-
|
|
301
|
-
if "authentication" in error_msg.lower() or "api_key" in error_msg.lower():
|
|
302
|
-
console.print("[red]Error: API key not configured.[/red]")
|
|
303
|
-
console.print("[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]")
|
|
304
|
-
break
|
|
305
|
-
else:
|
|
306
|
-
console.print(f"[red]Error: {e}[/red]")
|
|
1436
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
307
1437
|
|
|
308
1438
|
|
|
309
1439
|
def cmd_run(agent: Any, prompt: str, thread_id: str | None = None, show_thinking: bool = True, workspace_dir: str | None = None) -> None:
|
|
@@ -325,30 +1455,24 @@ def cmd_run(agent: Any, prompt: str, thread_id: str | None = None, show_thinking
|
|
|
325
1455
|
console.print(sep)
|
|
326
1456
|
console.print(f"[dim]Thread: {thread_id}[/dim]")
|
|
327
1457
|
if workspace_dir:
|
|
328
|
-
console.print(f"[dim]Workspace: {
|
|
1458
|
+
console.print(f"[dim]Workspace: {workspace_dir}[/dim]")
|
|
329
1459
|
console.print()
|
|
330
1460
|
|
|
331
1461
|
try:
|
|
332
1462
|
_run_streaming(agent, prompt, thread_id, show_thinking, interactive=False)
|
|
333
1463
|
except Exception as e:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
console.print("[red]Error: API key not configured.[/red]")
|
|
337
|
-
console.print("[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]")
|
|
338
|
-
raise typer.Exit(1)
|
|
339
|
-
else:
|
|
340
|
-
console.print(f"[red]Error: {e}[/red]")
|
|
341
|
-
raise
|
|
1464
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
1465
|
+
raise
|
|
342
1466
|
|
|
343
1467
|
|
|
344
1468
|
# =============================================================================
|
|
345
|
-
#
|
|
1469
|
+
# Entry point
|
|
346
1470
|
# =============================================================================
|
|
347
1471
|
|
|
348
1472
|
def _create_session_workspace() -> str:
|
|
349
1473
|
"""Create a per-session workspace directory and return its path."""
|
|
350
1474
|
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
351
|
-
workspace_dir =
|
|
1475
|
+
workspace_dir = os.path.join(".", "workspace", session_id)
|
|
352
1476
|
os.makedirs(workspace_dir, exist_ok=True)
|
|
353
1477
|
return workspace_dir
|
|
354
1478
|
|
|
@@ -363,305 +1487,66 @@ def _load_agent(workspace_dir: str | None = None):
|
|
|
363
1487
|
return create_cli_agent(workspace_dir=workspace_dir)
|
|
364
1488
|
|
|
365
1489
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
#
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
help="Skip API key validation during setup"
|
|
387
|
-
),
|
|
388
|
-
):
|
|
389
|
-
"""Interactive setup wizard for EvoScientist.
|
|
390
|
-
|
|
391
|
-
Guides you through configuring API keys, model selection,
|
|
392
|
-
workspace settings, and agent parameters.
|
|
393
|
-
"""
|
|
394
|
-
from .onboard import run_onboard
|
|
395
|
-
run_onboard(skip_validation=skip_validation)
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
# =============================================================================
|
|
399
|
-
# Config commands
|
|
400
|
-
# =============================================================================
|
|
401
|
-
|
|
402
|
-
@config_app.callback(invoke_without_command=True)
|
|
403
|
-
def config_callback(ctx: typer.Context):
|
|
404
|
-
"""Configuration management commands."""
|
|
405
|
-
if ctx.invoked_subcommand is None:
|
|
406
|
-
config_list()
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
@config_app.command("list")
|
|
410
|
-
def config_list():
|
|
411
|
-
"""List all configuration values."""
|
|
412
|
-
from .config import list_config, get_config_path
|
|
413
|
-
|
|
414
|
-
config_data = list_config()
|
|
415
|
-
|
|
416
|
-
table = Table(title="EvoScientist Configuration", show_header=True)
|
|
417
|
-
table.add_column("Setting", style="cyan")
|
|
418
|
-
table.add_column("Value")
|
|
419
|
-
|
|
420
|
-
# Mask API keys
|
|
421
|
-
def format_value(key: str, value: Any) -> str:
|
|
422
|
-
if "api_key" in key and value:
|
|
423
|
-
return "***" + str(value)[-4:] if len(str(value)) > 4 else "***"
|
|
424
|
-
if value == "":
|
|
425
|
-
return "[dim](not set)[/dim]"
|
|
426
|
-
return str(value)
|
|
427
|
-
|
|
428
|
-
for key, value in config_data.items():
|
|
429
|
-
table.add_row(key, format_value(key, value))
|
|
430
|
-
|
|
431
|
-
console.print(table)
|
|
432
|
-
console.print(f"\n[dim]Config file: {get_config_path()}[/dim]")
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
@config_app.command("get")
|
|
436
|
-
def config_get(key: str = typer.Argument(..., help="Configuration key to get")):
|
|
437
|
-
"""Get a single configuration value."""
|
|
438
|
-
from .config import get_config_value
|
|
439
|
-
|
|
440
|
-
value = get_config_value(key)
|
|
441
|
-
if value is None:
|
|
442
|
-
console.print(f"[red]Unknown key: {key}[/red]")
|
|
443
|
-
raise typer.Exit(1)
|
|
444
|
-
|
|
445
|
-
# Mask API keys
|
|
446
|
-
if "api_key" in key and value:
|
|
447
|
-
display_value = "***" + str(value)[-4:] if len(str(value)) > 4 else "***"
|
|
448
|
-
elif value == "":
|
|
449
|
-
display_value = "(not set)"
|
|
450
|
-
else:
|
|
451
|
-
display_value = str(value)
|
|
452
|
-
|
|
453
|
-
console.print(f"[cyan]{key}[/cyan]: {display_value}")
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
@config_app.command("set")
|
|
457
|
-
def config_set(
|
|
458
|
-
key: str = typer.Argument(..., help="Configuration key to set"),
|
|
459
|
-
value: str = typer.Argument(..., help="New value"),
|
|
460
|
-
):
|
|
461
|
-
"""Set a single configuration value."""
|
|
462
|
-
from .config import set_config_value
|
|
463
|
-
|
|
464
|
-
if set_config_value(key, value):
|
|
465
|
-
console.print(f"[green]Set {key}[/green]")
|
|
466
|
-
else:
|
|
467
|
-
console.print(f"[red]Invalid key: {key}[/red]")
|
|
468
|
-
raise typer.Exit(1)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
@config_app.command("reset")
|
|
472
|
-
def config_reset(
|
|
473
|
-
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
474
|
-
):
|
|
475
|
-
"""Reset configuration to defaults."""
|
|
476
|
-
from .config import reset_config, get_config_path
|
|
477
|
-
|
|
478
|
-
config_path = get_config_path()
|
|
479
|
-
|
|
480
|
-
if not config_path.exists():
|
|
481
|
-
console.print("[yellow]No config file to reset.[/yellow]")
|
|
482
|
-
return
|
|
483
|
-
|
|
484
|
-
if not yes:
|
|
485
|
-
confirm = typer.confirm("Reset configuration to defaults?")
|
|
486
|
-
if not confirm:
|
|
487
|
-
console.print("[dim]Cancelled.[/dim]")
|
|
488
|
-
return
|
|
489
|
-
|
|
490
|
-
reset_config()
|
|
491
|
-
console.print("[green]Configuration reset to defaults.[/green]")
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
@config_app.command("path")
|
|
495
|
-
def config_path():
|
|
496
|
-
"""Show the configuration file path."""
|
|
497
|
-
from .config import get_config_path
|
|
1490
|
+
def main():
|
|
1491
|
+
"""CLI entry point."""
|
|
1492
|
+
parser = argparse.ArgumentParser(
|
|
1493
|
+
description="EvoScientist Agent - AI-powered research & code execution CLI",
|
|
1494
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1495
|
+
epilog="""
|
|
1496
|
+
Examples:
|
|
1497
|
+
# Interactive mode (default)
|
|
1498
|
+
python -m EvoScientist --interactive
|
|
1499
|
+
|
|
1500
|
+
# Single-shot query
|
|
1501
|
+
python -m EvoScientist "What is quantum computing?"
|
|
1502
|
+
|
|
1503
|
+
# Resume a conversation thread
|
|
1504
|
+
python -m EvoScientist --thread-id <uuid> "Follow-up question"
|
|
1505
|
+
|
|
1506
|
+
# Disable thinking display
|
|
1507
|
+
python -m EvoScientist --no-thinking "Your query"
|
|
1508
|
+
""",
|
|
1509
|
+
)
|
|
498
1510
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
1511
|
+
parser.add_argument(
|
|
1512
|
+
"prompt",
|
|
1513
|
+
nargs="?",
|
|
1514
|
+
help="Query to execute (single-shot mode)",
|
|
1515
|
+
)
|
|
1516
|
+
parser.add_argument(
|
|
1517
|
+
"-i", "--interactive",
|
|
1518
|
+
action="store_true",
|
|
1519
|
+
help="Interactive conversation mode",
|
|
1520
|
+
)
|
|
1521
|
+
parser.add_argument(
|
|
1522
|
+
"--thread-id",
|
|
1523
|
+
type=str,
|
|
1524
|
+
default=None,
|
|
1525
|
+
help="Thread ID for conversation persistence (resume session)",
|
|
1526
|
+
)
|
|
1527
|
+
parser.add_argument(
|
|
1528
|
+
"--no-thinking",
|
|
1529
|
+
action="store_true",
|
|
1530
|
+
help="Disable thinking display",
|
|
1531
|
+
)
|
|
503
1532
|
|
|
1533
|
+
args = parser.parse_args()
|
|
1534
|
+
show_thinking = not args.no_thinking
|
|
504
1535
|
|
|
505
|
-
#
|
|
506
|
-
|
|
507
|
-
# =============================================================================
|
|
508
|
-
|
|
509
|
-
@app.callback(invoke_without_command=True)
|
|
510
|
-
def _main_callback(
|
|
511
|
-
ctx: typer.Context,
|
|
512
|
-
prompt: Optional[str] = typer.Option(None, "-p", "--prompt", help="Query to execute (single-shot mode)"),
|
|
513
|
-
thread_id: Optional[str] = typer.Option(None, "--thread-id", help="Thread ID for conversation persistence"),
|
|
514
|
-
no_thinking: bool = typer.Option(False, "--no-thinking", help="Disable thinking display"),
|
|
515
|
-
workdir: Optional[str] = typer.Option(None, "--workdir", help="Override workspace directory for this session"),
|
|
516
|
-
use_cwd: bool = typer.Option(False, "--use-cwd", help="Use current working directory as workspace"),
|
|
517
|
-
mode: Optional[str] = typer.Option(
|
|
518
|
-
None,
|
|
519
|
-
"--mode",
|
|
520
|
-
help="Workspace mode: 'daemon' (persistent, default) or 'run' (isolated per-session)"
|
|
521
|
-
),
|
|
522
|
-
):
|
|
523
|
-
"""EvoScientist Agent - AI-powered research & code execution CLI."""
|
|
524
|
-
# If a subcommand was invoked, don't run the default behavior
|
|
525
|
-
if ctx.invoked_subcommand is not None:
|
|
526
|
-
return
|
|
527
|
-
|
|
528
|
-
from dotenv import load_dotenv, find_dotenv # type: ignore[import-untyped]
|
|
529
|
-
# find_dotenv() traverses up the directory tree to locate .env
|
|
530
|
-
load_dotenv(find_dotenv(), override=True)
|
|
531
|
-
|
|
532
|
-
# Load and apply configuration
|
|
533
|
-
from .config import get_effective_config, apply_config_to_env
|
|
534
|
-
|
|
535
|
-
# Build CLI overrides dict
|
|
536
|
-
cli_overrides = {}
|
|
537
|
-
if mode:
|
|
538
|
-
cli_overrides["default_mode"] = mode
|
|
539
|
-
if workdir:
|
|
540
|
-
cli_overrides["default_workdir"] = workdir
|
|
541
|
-
if no_thinking:
|
|
542
|
-
cli_overrides["show_thinking"] = False
|
|
543
|
-
|
|
544
|
-
config = get_effective_config(cli_overrides)
|
|
545
|
-
apply_config_to_env(config)
|
|
546
|
-
|
|
547
|
-
show_thinking = config.show_thinking if not no_thinking else False
|
|
548
|
-
|
|
549
|
-
# Validate mutually exclusive options
|
|
550
|
-
if workdir and use_cwd:
|
|
551
|
-
raise typer.BadParameter("Use either --workdir or --use-cwd, not both.")
|
|
552
|
-
|
|
553
|
-
if mode and (workdir or use_cwd):
|
|
554
|
-
raise typer.BadParameter("--mode cannot be combined with --workdir or --use-cwd")
|
|
555
|
-
|
|
556
|
-
if mode and mode not in ("run", "daemon"):
|
|
557
|
-
raise typer.BadParameter("--mode must be 'run' or 'daemon'")
|
|
558
|
-
|
|
559
|
-
ensure_dirs()
|
|
560
|
-
|
|
561
|
-
# Resolve effective mode from config (CLI mode already applied via overrides)
|
|
562
|
-
effective_mode: str | None = None # None means explicit --workdir/--use-cwd was used
|
|
563
|
-
|
|
564
|
-
# Resolve workspace directory for this session
|
|
565
|
-
# Priority: --use-cwd > --workdir > --mode (explicit) > default_workdir > default_mode
|
|
566
|
-
if use_cwd:
|
|
567
|
-
workspace_dir = os.getcwd()
|
|
568
|
-
workspace_fixed = True
|
|
569
|
-
elif workdir:
|
|
570
|
-
workspace_dir = os.path.abspath(os.path.expanduser(workdir))
|
|
571
|
-
os.makedirs(workspace_dir, exist_ok=True)
|
|
572
|
-
workspace_fixed = True
|
|
573
|
-
elif mode:
|
|
574
|
-
# Explicit --mode overrides default_workdir
|
|
575
|
-
effective_mode = mode
|
|
576
|
-
workspace_root = config.default_workdir or str(default_workspace_dir())
|
|
577
|
-
workspace_root = os.path.abspath(os.path.expanduser(workspace_root))
|
|
578
|
-
if effective_mode == "run":
|
|
579
|
-
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
580
|
-
workspace_dir = os.path.join(workspace_root, "runs", session_id)
|
|
581
|
-
os.makedirs(workspace_dir, exist_ok=True)
|
|
582
|
-
workspace_fixed = False
|
|
583
|
-
else: # daemon
|
|
584
|
-
workspace_dir = workspace_root
|
|
585
|
-
os.makedirs(workspace_dir, exist_ok=True)
|
|
586
|
-
workspace_fixed = True
|
|
587
|
-
elif config.default_workdir:
|
|
588
|
-
# Use configured default workdir with configured mode
|
|
589
|
-
workspace_root = os.path.abspath(os.path.expanduser(config.default_workdir))
|
|
590
|
-
effective_mode = config.default_mode
|
|
591
|
-
if effective_mode == "run":
|
|
592
|
-
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
593
|
-
workspace_dir = os.path.join(workspace_root, "runs", session_id)
|
|
594
|
-
os.makedirs(workspace_dir, exist_ok=True)
|
|
595
|
-
workspace_fixed = False
|
|
596
|
-
else: # daemon
|
|
597
|
-
workspace_dir = workspace_root
|
|
598
|
-
os.makedirs(workspace_dir, exist_ok=True)
|
|
599
|
-
workspace_fixed = True
|
|
600
|
-
else:
|
|
601
|
-
effective_mode = config.default_mode
|
|
602
|
-
if effective_mode == "run":
|
|
603
|
-
workspace_dir = _create_session_workspace()
|
|
604
|
-
workspace_fixed = False
|
|
605
|
-
else: # daemon mode (default)
|
|
606
|
-
workspace_dir = str(default_workspace_dir())
|
|
607
|
-
os.makedirs(workspace_dir, exist_ok=True)
|
|
608
|
-
workspace_fixed = True
|
|
1536
|
+
# Create per-session workspace
|
|
1537
|
+
workspace_dir = _create_session_workspace()
|
|
609
1538
|
|
|
610
1539
|
# Load agent with session workspace
|
|
611
1540
|
console.print("[dim]Loading agent...[/dim]")
|
|
612
1541
|
agent = _load_agent(workspace_dir=workspace_dir)
|
|
613
1542
|
|
|
614
|
-
if
|
|
615
|
-
|
|
616
|
-
|
|
1543
|
+
if args.interactive:
|
|
1544
|
+
cmd_interactive(agent, show_thinking=show_thinking, workspace_dir=workspace_dir)
|
|
1545
|
+
elif args.prompt:
|
|
1546
|
+
cmd_run(agent, args.prompt, thread_id=args.thread_id, show_thinking=show_thinking, workspace_dir=workspace_dir)
|
|
617
1547
|
else:
|
|
618
|
-
#
|
|
619
|
-
cmd_interactive(
|
|
620
|
-
agent,
|
|
621
|
-
show_thinking=show_thinking,
|
|
622
|
-
workspace_dir=workspace_dir,
|
|
623
|
-
workspace_fixed=workspace_fixed,
|
|
624
|
-
mode=effective_mode,
|
|
625
|
-
model=config.model,
|
|
626
|
-
provider=config.provider,
|
|
627
|
-
)
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
def _configure_logging():
|
|
631
|
-
"""Configure logging with warning symbols for better visibility."""
|
|
632
|
-
from rich.logging import RichHandler
|
|
633
|
-
|
|
634
|
-
class DimWarningHandler(RichHandler):
|
|
635
|
-
"""Custom handler that renders warnings in dim style."""
|
|
636
|
-
|
|
637
|
-
def emit(self, record: logging.LogRecord) -> None:
|
|
638
|
-
if record.levelno == logging.WARNING:
|
|
639
|
-
# Use Rich console to print dim warning
|
|
640
|
-
msg = record.getMessage()
|
|
641
|
-
console.print(f"[dim yellow]\u26a0\ufe0f Warning:[/dim yellow] [dim]{msg}[/dim]")
|
|
642
|
-
else:
|
|
643
|
-
super().emit(record)
|
|
644
|
-
|
|
645
|
-
# Configure root logger to use our handler for WARNING and above
|
|
646
|
-
handler = DimWarningHandler(console=console, show_time=False, show_path=False, show_level=False)
|
|
647
|
-
handler.setLevel(logging.WARNING)
|
|
648
|
-
|
|
649
|
-
# Apply to root logger (catches all loggers including deepagents)
|
|
650
|
-
root_logger = logging.getLogger()
|
|
651
|
-
# Remove existing handlers to avoid duplicate output
|
|
652
|
-
for h in root_logger.handlers[:]:
|
|
653
|
-
root_logger.removeHandler(h)
|
|
654
|
-
root_logger.addHandler(handler)
|
|
655
|
-
root_logger.setLevel(logging.WARNING)
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
def main():
|
|
659
|
-
"""CLI entry point — delegates to the Typer app."""
|
|
660
|
-
import warnings
|
|
661
|
-
warnings.filterwarnings("ignore", message=".*not known to support tools.*")
|
|
662
|
-
warnings.filterwarnings("ignore", message=".*type is unknown and inference may fail.*")
|
|
663
|
-
_configure_logging()
|
|
664
|
-
app()
|
|
1548
|
+
# Default: interactive mode
|
|
1549
|
+
cmd_interactive(agent, show_thinking=show_thinking, workspace_dir=workspace_dir)
|
|
665
1550
|
|
|
666
1551
|
|
|
667
1552
|
if __name__ == "__main__":
|