EvoScientist 0.0.1.dev2__py3-none-any.whl → 0.0.1.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ """Stream state tracking for CLI display.
2
+
3
+ Contains SubAgentState, StreamState, and todo-item parsing helpers.
4
+ No Rich dependencies — stdlib only.
5
+ """
6
+
7
+ import ast
8
+ import json
9
+
10
+
11
+ class SubAgentState:
12
+ """Tracks a single sub-agent's activity."""
13
+
14
+ def __init__(self, name: str, description: str = ""):
15
+ self.name = name
16
+ self.description = description
17
+ self.tool_calls: list[dict] = []
18
+ self.tool_results: list[dict] = []
19
+ self._result_map: dict[str, dict] = {} # tool_call_id -> result
20
+ self.is_active = True
21
+
22
+ def add_tool_call(self, name: str, args: dict, tool_id: str = ""):
23
+ # Skip empty-name calls without an id (incomplete streaming chunks)
24
+ if not name and not tool_id:
25
+ return
26
+ tc_data = {"id": tool_id, "name": name, "args": args}
27
+ if tool_id:
28
+ for i, tc in enumerate(self.tool_calls):
29
+ if tc.get("id") == tool_id:
30
+ # Merge: keep the non-empty name/args
31
+ if name:
32
+ self.tool_calls[i]["name"] = name
33
+ if args:
34
+ self.tool_calls[i]["args"] = args
35
+ return
36
+ # Skip if name is empty and we can't deduplicate by id
37
+ if not name:
38
+ return
39
+ self.tool_calls.append(tc_data)
40
+
41
+ def add_tool_result(self, name: str, content: str, success: bool = True):
42
+ result = {"name": name, "content": content, "success": success}
43
+ self.tool_results.append(result)
44
+ # Try to match result to the first unmatched tool call with same name
45
+ for tc in self.tool_calls:
46
+ tc_id = tc.get("id", "")
47
+ tc_name = tc.get("name", "")
48
+ if tc_id and tc_id not in self._result_map and tc_name == name:
49
+ self._result_map[tc_id] = result
50
+ return
51
+ # Fallback: match first unmatched tool call
52
+ for tc in self.tool_calls:
53
+ tc_id = tc.get("id", "")
54
+ if tc_id and tc_id not in self._result_map:
55
+ self._result_map[tc_id] = result
56
+ return
57
+
58
+ def get_result_for(self, tc: dict) -> dict | None:
59
+ """Get matched result for a tool call."""
60
+ tc_id = tc.get("id", "")
61
+ if tc_id:
62
+ return self._result_map.get(tc_id)
63
+ # Fallback: index-based matching
64
+ try:
65
+ idx = self.tool_calls.index(tc)
66
+ if idx < len(self.tool_results):
67
+ return self.tool_results[idx]
68
+ except ValueError:
69
+ pass
70
+ return None
71
+
72
+
73
+ class StreamState:
74
+ """Accumulates stream state for display updates."""
75
+
76
+ def __init__(self):
77
+ self.thinking_text = ""
78
+ self.response_text = ""
79
+ self.tool_calls = []
80
+ self.tool_results = []
81
+ self.is_thinking = False
82
+ self.is_responding = False
83
+ self.is_processing = False
84
+ # Sub-agent tracking
85
+ self.subagents: list[SubAgentState] = []
86
+ self._subagent_map: dict[str, SubAgentState] = {} # name -> state
87
+ # Todo list tracking
88
+ self.todo_items: list[dict] = []
89
+ # Latest text segment (reset on each tool_call)
90
+ self.latest_text = ""
91
+
92
+ def _get_or_create_subagent(self, name: str, description: str = "") -> SubAgentState:
93
+ if name not in self._subagent_map:
94
+ # Case 1: real name arrives, "sub-agent" entry exists -> rename it
95
+ if name != "sub-agent" and "sub-agent" in self._subagent_map:
96
+ old_sa = self._subagent_map.pop("sub-agent")
97
+ old_sa.name = name
98
+ if description:
99
+ old_sa.description = description
100
+ self._subagent_map[name] = old_sa
101
+ return old_sa
102
+ # Case 2: "sub-agent" arrives but a pre-registered real-name entry
103
+ # exists with no tool calls -> merge into it
104
+ if name == "sub-agent":
105
+ active_named = [
106
+ sa for sa in self.subagents
107
+ if sa.is_active and sa.name != "sub-agent"
108
+ ]
109
+ if len(active_named) == 1 and not active_named[0].tool_calls:
110
+ self._subagent_map[name] = active_named[0]
111
+ return active_named[0]
112
+ sa = SubAgentState(name, description)
113
+ self.subagents.append(sa)
114
+ self._subagent_map[name] = sa
115
+ else:
116
+ existing = self._subagent_map[name]
117
+ if description and not existing.description:
118
+ existing.description = description
119
+ # If this entry was created as "sub-agent" placeholder and the
120
+ # actual name is different, update.
121
+ if name != "sub-agent" and existing.name == "sub-agent":
122
+ existing.name = name
123
+ return self._subagent_map[name]
124
+
125
+ def _resolve_subagent_name(self, name: str) -> str:
126
+ """Resolve "sub-agent" to the single active named sub-agent when possible."""
127
+ if name != "sub-agent":
128
+ return name
129
+ active_named = [
130
+ sa.name for sa in self.subagents
131
+ if sa.is_active and sa.name != "sub-agent"
132
+ ]
133
+ if len(active_named) == 1:
134
+ return active_named[0]
135
+ return name
136
+
137
+ def handle_event(self, event: dict) -> str:
138
+ """Process a single stream event, update internal state, return event type."""
139
+ event_type: str = event.get("type", "")
140
+
141
+ if event_type == "thinking":
142
+ self.is_thinking = True
143
+ self.is_responding = False
144
+ self.is_processing = False
145
+ self.thinking_text += event.get("content", "")
146
+
147
+ elif event_type == "text":
148
+ self.is_thinking = False
149
+ self.is_responding = True
150
+ self.is_processing = False
151
+ text_content = event.get("content", "")
152
+ self.response_text += text_content
153
+ self.latest_text += text_content
154
+
155
+ elif event_type == "tool_call":
156
+ self.is_thinking = False
157
+ self.is_responding = False
158
+ self.is_processing = False
159
+ self.latest_text = "" # Reset -- next text segment is a new message
160
+
161
+ tool_id = event.get("id", "")
162
+ tool_name = event.get("name", "unknown")
163
+ tool_args = event.get("args", {})
164
+ tc_data = {
165
+ "id": tool_id,
166
+ "name": tool_name,
167
+ "args": tool_args,
168
+ }
169
+
170
+ if tool_id:
171
+ updated = False
172
+ for i, tc in enumerate(self.tool_calls):
173
+ if tc.get("id") == tool_id:
174
+ self.tool_calls[i] = tc_data
175
+ updated = True
176
+ break
177
+ if not updated:
178
+ self.tool_calls.append(tc_data)
179
+ else:
180
+ self.tool_calls.append(tc_data)
181
+
182
+ # Capture todo items from write_todos args (most reliable source)
183
+ if tool_name == "write_todos":
184
+ todos = tool_args.get("todos", [])
185
+ if isinstance(todos, list) and todos:
186
+ self.todo_items = todos
187
+
188
+ elif event_type == "tool_result":
189
+ self.is_processing = True
190
+ result_name = event.get("name", "unknown")
191
+ result_content = event.get("content", "")
192
+ self.tool_results.append({
193
+ "name": result_name,
194
+ "content": result_content,
195
+ })
196
+ # Update todo list from write_todos / read_todos results (fallback)
197
+ if result_name in ("write_todos", "read_todos"):
198
+ parsed = _parse_todo_items(result_content)
199
+ if parsed:
200
+ self.todo_items = parsed
201
+
202
+ elif event_type == "subagent_start":
203
+ name = event.get("name", "sub-agent")
204
+ desc = event.get("description", "")
205
+ sa = self._get_or_create_subagent(name, desc)
206
+ sa.is_active = True
207
+
208
+ elif event_type == "subagent_tool_call":
209
+ sa_name = self._resolve_subagent_name(event.get("subagent", "sub-agent"))
210
+ sa = self._get_or_create_subagent(sa_name)
211
+ sa.add_tool_call(
212
+ event.get("name", "unknown"),
213
+ event.get("args", {}),
214
+ event.get("id", ""),
215
+ )
216
+
217
+ elif event_type == "subagent_tool_result":
218
+ sa_name = self._resolve_subagent_name(event.get("subagent", "sub-agent"))
219
+ sa = self._get_or_create_subagent(sa_name)
220
+ sa.add_tool_result(
221
+ event.get("name", "unknown"),
222
+ event.get("content", ""),
223
+ event.get("success", True),
224
+ )
225
+
226
+ elif event_type == "subagent_end":
227
+ name = self._resolve_subagent_name(event.get("name", "sub-agent"))
228
+ if name in self._subagent_map:
229
+ self._subagent_map[name].is_active = False
230
+ elif name == "sub-agent":
231
+ # Couldn't resolve -- deactivate the oldest active sub-agent
232
+ for sa in self.subagents:
233
+ if sa.is_active:
234
+ sa.is_active = False
235
+ break
236
+
237
+ elif event_type == "done":
238
+ self.is_processing = False
239
+ if not self.response_text:
240
+ self.response_text = event.get("response", "")
241
+
242
+ elif event_type == "error":
243
+ self.is_processing = False
244
+ self.is_thinking = False
245
+ self.is_responding = False
246
+ error_msg = event.get("message", "Unknown error")
247
+ self.response_text += f"\n\n[Error] {error_msg}"
248
+
249
+ return event_type
250
+
251
+ def get_display_args(self) -> dict:
252
+ """Get kwargs for create_streaming_display()."""
253
+ return {
254
+ "thinking_text": self.thinking_text,
255
+ "response_text": self.response_text,
256
+ "latest_text": self.latest_text,
257
+ "tool_calls": self.tool_calls,
258
+ "tool_results": self.tool_results,
259
+ "is_thinking": self.is_thinking,
260
+ "is_responding": self.is_responding,
261
+ "is_processing": self.is_processing,
262
+ "subagents": self.subagents,
263
+ "todo_items": self.todo_items,
264
+ }
265
+
266
+
267
+ def _parse_todo_items(content: str) -> list[dict] | None:
268
+ """Parse todo items from write_todos output.
269
+
270
+ Attempts to extract a list of dicts with 'status' and 'content' keys
271
+ from the tool result string. Returns None if parsing fails.
272
+
273
+ Handles formats like:
274
+ - Raw JSON/Python list: [{"content": "...", "status": "..."}]
275
+ - Prefixed: "Updated todo list to [{'content': '...', ...}]"
276
+ """
277
+ content = content.strip()
278
+
279
+ def _try_parse(text: str) -> list[dict] | None:
280
+ """Try JSON then Python literal parsing."""
281
+ text = text.strip()
282
+ try:
283
+ data = json.loads(text)
284
+ if isinstance(data, list) and data and isinstance(data[0], dict):
285
+ return data
286
+ except (json.JSONDecodeError, ValueError):
287
+ pass
288
+ try:
289
+ data = ast.literal_eval(text)
290
+ if isinstance(data, list) and data and isinstance(data[0], dict):
291
+ return data
292
+ except (ValueError, SyntaxError):
293
+ pass
294
+ return None
295
+
296
+ # Try the full content directly
297
+ result = _try_parse(content)
298
+ if result:
299
+ return result
300
+
301
+ # Extract embedded [...] from content (e.g. "Updated todo list to [{...}]")
302
+ bracket_start = content.find("[")
303
+ if bracket_start != -1:
304
+ bracket_end = content.rfind("]")
305
+ if bracket_end > bracket_start:
306
+ embedded = content[bracket_start:bracket_end + 1]
307
+ result = _try_parse(embedded)
308
+ if result:
309
+ return result
310
+
311
+ # Try line-by-line scan
312
+ for line in content.split("\n"):
313
+ line = line.strip()
314
+ if "[" in line:
315
+ start = line.find("[")
316
+ end = line.rfind("]")
317
+ if end > start:
318
+ result = _try_parse(line[start:end + 1])
319
+ if result:
320
+ return result
321
+
322
+ return None
323
+
324
+
325
+ def _build_todo_stats(items: list[dict]) -> str:
326
+ """Build stats string like '2 active | 1 pending | 3 done'."""
327
+ counts: dict[str, int] = {}
328
+ for item in items:
329
+ status = str(item.get("status", "todo")).lower()
330
+ # Normalize status names
331
+ if status in ("done", "completed", "complete"):
332
+ status = "done"
333
+ elif status in ("active", "in_progress", "in-progress", "working"):
334
+ status = "active"
335
+ else:
336
+ status = "pending"
337
+ counts[status] = counts.get(status, 0) + 1
338
+
339
+ parts = []
340
+ for key in ("active", "pending", "done"):
341
+ if counts.get(key, 0) > 0:
342
+ parts.append(f"{counts[key]} {key}")
343
+ return " | ".join(parts) if parts else f"{len(items)} items"
@@ -114,34 +114,40 @@ def format_tool_compact(name: str, args: dict | None) -> str:
114
114
  if name_lower == "execute":
115
115
  cmd = args.get("command", "")
116
116
  if len(cmd) > 50:
117
- cmd = cmd[:47] + "..."
117
+ cmd = cmd[:47] + "\u2026"
118
118
  return f"execute({cmd})"
119
119
 
120
- # File operations
120
+ # File operations (with special case for memory files)
121
121
  if name_lower == "read_file":
122
- path = _shorten_path(args.get("path", ""))
123
- return f"read_file({path})"
122
+ path = args.get("path", "")
123
+ if path.endswith("/MEMORY.md") or path == "/MEMORY.md":
124
+ return "Reading memory"
125
+ return f"read_file({_shorten_path(path)})"
124
126
 
125
127
  if name_lower == "write_file":
126
- path = _shorten_path(args.get("path", ""))
127
- return f"write_file({path})"
128
+ path = args.get("path", "")
129
+ if path.endswith("/MEMORY.md") or path == "/MEMORY.md":
130
+ return "Updating memory"
131
+ return f"write_file({_shorten_path(path)})"
128
132
 
129
133
  if name_lower == "edit_file":
130
- path = _shorten_path(args.get("path", ""))
131
- return f"edit_file({path})"
134
+ path = args.get("path", "")
135
+ if path.endswith("/MEMORY.md") or path == "/MEMORY.md":
136
+ return "Updating memory"
137
+ return f"edit_file({_shorten_path(path)})"
132
138
 
133
139
  # Search operations
134
140
  if name_lower == "glob":
135
141
  pattern = args.get("pattern", "")
136
142
  if len(pattern) > 40:
137
- pattern = pattern[:37] + "..."
143
+ pattern = pattern[:37] + "\u2026"
138
144
  return f"glob({pattern})"
139
145
 
140
146
  if name_lower == "grep":
141
147
  pattern = args.get("pattern", "")
142
148
  path = args.get("path", ".")
143
149
  if len(pattern) > 30:
144
- pattern = pattern[:27] + "..."
150
+ pattern = pattern[:27] + "\u2026"
145
151
  return f"grep({pattern}, {path})"
146
152
 
147
153
  # Directory listing
@@ -163,16 +169,17 @@ def format_tool_compact(name: str, args: dict | None) -> str:
163
169
  if name_lower == "task":
164
170
  sa_type = args.get("subagent_type", "").strip()
165
171
  task_desc = args.get("description", args.get("task", "")).strip()
172
+ task_desc = task_desc.split("\n")[0].strip() if task_desc else ""
166
173
  if sa_type:
167
174
  if task_desc:
168
175
  if len(task_desc) > 50:
169
- task_desc = task_desc[:47] + "..."
176
+ task_desc = task_desc[:47] + "\u2026"
170
177
  return f"Cooking with {sa_type} — {task_desc}"
171
178
  return f"Cooking with {sa_type}"
172
179
  # Fallback if no subagent_type
173
180
  if task_desc:
174
181
  if len(task_desc) > 50:
175
- task_desc = task_desc[:47] + "..."
182
+ task_desc = task_desc[:47] + "\u2026"
176
183
  return f"Cooking with sub-agent — {task_desc}"
177
184
  return "Cooking with sub-agent"
178
185
 
@@ -185,14 +192,14 @@ def format_tool_compact(name: str, args: dict | None) -> str:
185
192
  if name_lower in ("tavily_search", "internet_search"):
186
193
  query = args.get("query", "")
187
194
  if len(query) > 40:
188
- query = query[:37] + "..."
195
+ query = query[:37] + "\u2026"
189
196
  return f"{name}({query})"
190
197
 
191
198
  # Think/reflection
192
199
  if name_lower == "think_tool":
193
200
  reflection = args.get("reflection", "")
194
201
  if len(reflection) > 40:
195
- reflection = reflection[:37] + "..."
202
+ reflection = reflection[:37] + "\u2026"
196
203
  return f"think_tool({reflection})"
197
204
 
198
205
  # Default: show first few params
@@ -200,12 +207,12 @@ def format_tool_compact(name: str, args: dict | None) -> str:
200
207
  for k, v in list(args.items())[:2]:
201
208
  v_str = str(v)
202
209
  if len(v_str) > 20:
203
- v_str = v_str[:17] + "..."
210
+ v_str = v_str[:17] + "\u2026"
204
211
  params.append(f"{k}={v_str}")
205
212
 
206
213
  params_str = ", ".join(params)
207
214
  if len(params_str) > 50:
208
- params_str = params_str[:47] + "..."
215
+ params_str = params_str[:47] + "\u2026"
209
216
 
210
217
  return f"{name}({params_str})"
211
218
 
EvoScientist/tools.py CHANGED
@@ -16,7 +16,16 @@ from typing_extensions import Annotated
16
16
 
17
17
  load_dotenv(override=True)
18
18
 
19
- tavily_client = TavilyClient()
19
+ # Lazy initialization - only create client when needed
20
+ _tavily_client = None
21
+
22
+
23
+ def _get_tavily_client() -> TavilyClient:
24
+ """Get or create the Tavily client (lazy initialization)."""
25
+ global _tavily_client
26
+ if _tavily_client is None:
27
+ _tavily_client = TavilyClient()
28
+ return _tavily_client
20
29
 
21
30
 
22
31
  async def fetch_webpage_content(url: str, timeout: float = 10.0) -> str:
@@ -67,7 +76,7 @@ async def tavily_search(
67
76
  """
68
77
 
69
78
  def _sync_search() -> dict:
70
- return tavily_client.search(
79
+ return _get_tavily_client().search(
71
80
  query,
72
81
  max_results=max_results,
73
82
  topic=topic,
@@ -106,6 +115,70 @@ async def tavily_search(
106
115
  return f"Search failed: {str(e)}"
107
116
 
108
117
 
118
+ @tool(parse_docstring=True)
119
+ def skill_manager(
120
+ action: Literal["install", "list", "uninstall"],
121
+ source: str = "",
122
+ name: str = "",
123
+ ) -> str:
124
+ """Manage user skills: install, list, or uninstall.
125
+
126
+ Use this tool when the user asks to:
127
+ - Install a skill (action="install", source required)
128
+ - List installed skills (action="list")
129
+ - Uninstall a skill (action="uninstall", name required)
130
+
131
+ Supported sources for install:
132
+ - Local path: "./my-skill" or "/path/to/skill"
133
+ - GitHub URL: "https://github.com/owner/repo/tree/main/skill-name"
134
+ - GitHub shorthand: "owner/repo@skill-name"
135
+
136
+ Args:
137
+ action: One of "install", "list", or "uninstall"
138
+ source: For install - local path or GitHub URL/shorthand
139
+ name: For uninstall - skill name to remove
140
+
141
+ Returns:
142
+ Result message
143
+ """
144
+ from .skills_manager import install_skill, list_skills, uninstall_skill
145
+
146
+ if action == "install":
147
+ if not source:
148
+ return "Error: 'source' is required for install action"
149
+ result = install_skill(source)
150
+ if result["success"]:
151
+ return (
152
+ f"Successfully installed skill: {result['name']}\n"
153
+ f"Description: {result.get('description', '(none)')}\n"
154
+ f"Path: {result['path']}\n\n"
155
+ f"Use load_skill to activate it."
156
+ )
157
+ else:
158
+ return f"Failed to install skill: {result['error']}"
159
+
160
+ elif action == "list":
161
+ skills = list_skills(include_system=False)
162
+ if not skills:
163
+ return "No user skills installed. Use action='install' to add skills."
164
+ lines = [f"Installed User Skills ({len(skills)}):"]
165
+ for skill in skills:
166
+ lines.append(f" - {skill.name}: {skill.description}")
167
+ return "\n".join(lines)
168
+
169
+ elif action == "uninstall":
170
+ if not name:
171
+ return "Error: 'name' is required for uninstall action"
172
+ result = uninstall_skill(name)
173
+ if result["success"]:
174
+ return f"Successfully uninstalled skill: {name}"
175
+ else:
176
+ return f"Failed to uninstall skill: {result['error']}"
177
+
178
+ else:
179
+ return f"Unknown action: {action}. Use 'install', 'list', or 'uninstall'."
180
+
181
+
109
182
  @tool(parse_docstring=True)
110
183
  def think_tool(reflection: str) -> str:
111
184
  """Tool for strategic reflection on research progress and decision-making.