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.
Files changed (37) hide show
  1. EvoScientist/EvoScientist.py +25 -61
  2. EvoScientist/__init__.py +0 -19
  3. EvoScientist/backends.py +0 -26
  4. EvoScientist/cli.py +1365 -480
  5. EvoScientist/middleware.py +7 -56
  6. EvoScientist/skills/clip/SKILL.md +253 -0
  7. EvoScientist/skills/clip/references/applications.md +207 -0
  8. EvoScientist/skills/langgraph-docs/SKILL.md +36 -0
  9. EvoScientist/skills/tensorboard/SKILL.md +629 -0
  10. EvoScientist/skills/tensorboard/references/integrations.md +638 -0
  11. EvoScientist/skills/tensorboard/references/profiling.md +545 -0
  12. EvoScientist/skills/tensorboard/references/visualization.md +620 -0
  13. EvoScientist/skills/vllm/SKILL.md +364 -0
  14. EvoScientist/skills/vllm/references/optimization.md +226 -0
  15. EvoScientist/skills/vllm/references/quantization.md +284 -0
  16. EvoScientist/skills/vllm/references/server-deployment.md +255 -0
  17. EvoScientist/skills/vllm/references/troubleshooting.md +447 -0
  18. EvoScientist/stream/__init__.py +0 -25
  19. EvoScientist/stream/utils.py +16 -23
  20. EvoScientist/tools.py +2 -75
  21. {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/METADATA +8 -153
  22. {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/RECORD +26 -24
  23. evoscientist-0.1.0rc2.dist-info/entry_points.txt +2 -0
  24. EvoScientist/config.py +0 -274
  25. EvoScientist/llm/__init__.py +0 -21
  26. EvoScientist/llm/models.py +0 -99
  27. EvoScientist/memory.py +0 -715
  28. EvoScientist/onboard.py +0 -725
  29. EvoScientist/paths.py +0 -44
  30. EvoScientist/skills_manager.py +0 -391
  31. EvoScientist/stream/display.py +0 -604
  32. EvoScientist/stream/events.py +0 -415
  33. EvoScientist/stream/state.py +0 -343
  34. evoscientist-0.0.1.dev4.dist-info/entry_points.txt +0 -5
  35. {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/WHEEL +0 -0
  36. {evoscientist-0.0.1.dev4.dist-info → evoscientist-0.1.0rc2.dist-info}/licenses/LICENSE +0 -0
  37. {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 logging
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, Optional
21
+ from typing import Any, AsyncIterator
22
22
 
23
- import typer # type: ignore[import-untyped]
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.table import Table # type: ignore[import-untyped]
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
- # Backward-compat re-exports (tests import these from EvoScientist.cli)
32
- from .stream.state import SubAgentState, StreamState, _parse_todo_items, _build_todo_stats # noqa: F401
33
- from .stream.display import console, _run_streaming
34
- from .paths import ensure_dirs, new_run_dir, default_workspace_dir
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
- def _shorten_path(path: str) -> str:
38
- """Shorten absolute path to relative path from current directory."""
39
- if not path:
40
- return path
41
- try:
42
- cwd = os.getcwd()
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
- # Banner
57
+ # Stream event generator
55
58
  # =============================================================================
56
59
 
57
- EVOSCIENTIST_ASCII_LINES = [
58
- r" ███████╗ ██╗ ██╗ ██████╗ ███████╗ ██████╗ ██╗ ███████╗ ███╗ ██╗ ████████╗ ██╗ ███████╗ ████████╗",
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
- # Blue gradient: deep navy -> royal blue -> sky blue -> cyan
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
- def print_banner(
71
- thread_id: str,
72
- workspace_dir: str | None = None,
73
- memory_dir: str | None = None,
74
- mode: str | None = None,
75
- model: str | None = None,
76
- provider: str | None = None,
77
- ):
78
- """Print welcome banner with ASCII art logo, thread ID, workspace path, and mode."""
79
- for line, color in zip(EVOSCIENTIST_ASCII_LINES, _GRADIENT_COLORS):
80
- console.print(Text(line, style=f"{color} bold"))
81
- info = Text()
82
- info.append(" Thread: ", style="dim")
83
- info.append(thread_id, style="yellow")
84
- if workspace_dir:
85
- info.append("\n Workspace: ", style="dim")
86
- info.append(_shorten_path(workspace_dir), style="cyan")
87
- if memory_dir:
88
- trimmed = memory_dir.rstrip("/").rstrip("\\")
89
- info.append("\n Memory dir: ", style="dim")
90
- info.append(_shorten_path(trimmed), style="cyan")
91
- if model or provider or mode:
92
- info.append("\n ", style="dim")
93
- parts = []
94
- if model:
95
- parts.append(("Model: ", model))
96
- if provider:
97
- parts.append(("Provider: ", provider))
98
- if mode:
99
- parts.append(("Mode: ", mode))
100
- for i, (label, value) in enumerate(parts):
101
- if i > 0:
102
- info.append(" ", style="dim")
103
- info.append(label, style="dim")
104
- info.append(value, style="magenta")
105
- info.append("\n Commands: ", style="dim")
106
- info.append("/exit", style="bold")
107
- info.append(", ", style="dim")
108
- info.append("/new", style="bold")
109
- info.append(", ", style="dim")
110
- info.append("/thread", style="bold")
111
- info.append(", ", style="dim")
112
- info.append("/skills", style="bold")
113
- info.append(", ", style="dim")
114
- info.append("/install-skill", style="bold")
115
- info.append(", ", style="dim")
116
- info.append("/uninstall-skill", style="bold")
117
- console.print(info)
118
- console.print()
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
- # Skill management commands
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
- def _cmd_list_skills() -> None:
127
- """List installed user skills."""
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
- skills = list_skills(include_system=False)
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
- if not skills:
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
- console.print(f"[bold]Installed Skills[/bold] ({len(skills)}):")
141
- for skill in skills:
142
- console.print(f" [green]{skill.name}[/green] - {skill.description}")
143
- console.print(f"\n[dim]Location:[/dim] [cyan]{_shorten_path(str(USER_SKILLS_DIR))}[/cyan]")
144
- console.print()
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
- if not source:
152
- console.print("[red]Usage:[/red] /install-skill <path-or-url>")
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
- console.print(f"[dim]Installing skill from:[/dim] {source}")
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
- result = install_skill(source)
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
- if result["success"]:
165
- console.print(f"[green]Installed:[/green] {result['name']}")
166
- console.print(f"[dim]Description:[/dim] {result.get('description', '(none)')}")
167
- console.print(f"[dim]Path:[/dim] [cyan]{_shorten_path(result['path'])}[/cyan]")
168
- console.print()
169
- console.print("[dim]Reload the agent with /new to use the skill.[/dim]")
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
- console.print(f"[red]Failed:[/red] {result['error']}")
172
- console.print()
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
- def _cmd_uninstall_skill(name: str) -> None:
176
- """Uninstall a user-installed skill."""
177
- from .skills_manager import uninstall_skill
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
- result = uninstall_skill(name)
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 result["success"]:
188
- console.print(f"[green]Uninstalled:[/green] {name}")
189
- console.print("[dim]Reload the agent with /new to apply changes.[/dim]")
190
- else:
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
- # CLI commands
1272
+ # Async-to-sync bridge
197
1273
  # =============================================================================
198
1274
 
199
- def cmd_interactive(
1275
+ def _run_streaming(
200
1276
  agent: Any,
201
- show_thinking: bool = True,
202
- workspace_dir: str | None = None,
203
- workspace_fixed: bool = False,
204
- mode: str | None = None,
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
- from .EvoScientist import MEMORY_DIR
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 thread; workspace only changes if not fixed
258
- if not workspace_fixed:
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
- if workspace_dir:
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]{_shorten_path(workspace_dir)}[/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
- error_msg = str(e)
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: {_shorten_path(workspace_dir)}[/dim]")
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
- error_msg = str(e)
335
- if "authentication" in error_msg.lower() or "api_key" in error_msg.lower():
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
- # Agent loading helpers
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 = str(new_run_dir(session_id))
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
- # Typer app
368
- # =============================================================================
369
-
370
- app = typer.Typer(no_args_is_help=False, add_completion=False)
371
-
372
- # Config subcommand group
373
- config_app = typer.Typer(help="Configuration management commands", invoke_without_command=True)
374
- app.add_typer(config_app, name="config")
375
-
376
-
377
- # =============================================================================
378
- # Onboard command
379
- # =============================================================================
380
-
381
- @app.command()
382
- def onboard(
383
- skip_validation: bool = typer.Option(
384
- False,
385
- "--skip-validation",
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
- path = get_config_path()
500
- exists = path.exists()
501
- status = "[green]exists[/green]" if exists else "[dim]not created yet[/dim]"
502
- console.print(f"{path} ({status})")
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
- # Main callback (default behavior)
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 prompt:
615
- # Single-shot mode: execute query and exit
616
- cmd_run(agent, prompt, thread_id=thread_id, show_thinking=show_thinking, workspace_dir=workspace_dir)
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
- # Interactive mode (default)
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__":