EvoScientist 0.0.1.dev1__py3-none-any.whl → 0.0.1.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- EvoScientist/EvoScientist.py +45 -13
- EvoScientist/cli.py +237 -1363
- EvoScientist/memory.py +715 -0
- EvoScientist/middleware.py +49 -4
- EvoScientist/paths.py +45 -0
- EvoScientist/skills_manager.py +392 -0
- EvoScientist/stream/__init__.py +25 -0
- EvoScientist/stream/display.py +604 -0
- EvoScientist/stream/events.py +415 -0
- EvoScientist/stream/state.py +343 -0
- EvoScientist/stream/utils.py +23 -16
- EvoScientist/tools.py +64 -0
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/METADATA +106 -7
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/RECORD +18 -12
- evoscientist-0.0.1.dev3.dist-info/entry_points.txt +5 -0
- evoscientist-0.0.1.dev1.dist-info/entry_points.txt +0 -2
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/WHEEL +0 -0
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/licenses/LICENSE +0 -0
- {evoscientist-0.0.1.dev1.dist-info → evoscientist-0.0.1.dev3.dist-info}/top_level.txt +0 -0
|
@@ -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"
|
EvoScientist/stream/utils.py
CHANGED
|
@@ -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 =
|
|
123
|
-
|
|
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 =
|
|
127
|
-
|
|
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 =
|
|
131
|
-
|
|
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
|
@@ -106,6 +106,70 @@ async def tavily_search(
|
|
|
106
106
|
return f"Search failed: {str(e)}"
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
@tool(parse_docstring=True)
|
|
110
|
+
def skill_manager(
|
|
111
|
+
action: Literal["install", "list", "uninstall"],
|
|
112
|
+
source: str = "",
|
|
113
|
+
name: str = "",
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Manage user skills: install, list, or uninstall.
|
|
116
|
+
|
|
117
|
+
Use this tool when the user asks to:
|
|
118
|
+
- Install a skill (action="install", source required)
|
|
119
|
+
- List installed skills (action="list")
|
|
120
|
+
- Uninstall a skill (action="uninstall", name required)
|
|
121
|
+
|
|
122
|
+
Supported sources for install:
|
|
123
|
+
- Local path: "./my-skill" or "/path/to/skill"
|
|
124
|
+
- GitHub URL: "https://github.com/owner/repo/tree/main/skill-name"
|
|
125
|
+
- GitHub shorthand: "owner/repo@skill-name"
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
action: One of "install", "list", or "uninstall"
|
|
129
|
+
source: For install - local path or GitHub URL/shorthand
|
|
130
|
+
name: For uninstall - skill name to remove
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Result message
|
|
134
|
+
"""
|
|
135
|
+
from .skills_manager import install_skill, list_skills, uninstall_skill
|
|
136
|
+
|
|
137
|
+
if action == "install":
|
|
138
|
+
if not source:
|
|
139
|
+
return "Error: 'source' is required for install action"
|
|
140
|
+
result = install_skill(source)
|
|
141
|
+
if result["success"]:
|
|
142
|
+
return (
|
|
143
|
+
f"Successfully installed skill: {result['name']}\n"
|
|
144
|
+
f"Description: {result.get('description', '(none)')}\n"
|
|
145
|
+
f"Path: {result['path']}\n\n"
|
|
146
|
+
f"Use load_skill to activate it."
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
return f"Failed to install skill: {result['error']}"
|
|
150
|
+
|
|
151
|
+
elif action == "list":
|
|
152
|
+
skills = list_skills(include_system=False)
|
|
153
|
+
if not skills:
|
|
154
|
+
return "No user skills installed. Use action='install' to add skills."
|
|
155
|
+
lines = [f"Installed User Skills ({len(skills)}):"]
|
|
156
|
+
for skill in skills:
|
|
157
|
+
lines.append(f" - {skill.name}: {skill.description}")
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
|
|
160
|
+
elif action == "uninstall":
|
|
161
|
+
if not name:
|
|
162
|
+
return "Error: 'name' is required for uninstall action"
|
|
163
|
+
result = uninstall_skill(name)
|
|
164
|
+
if result["success"]:
|
|
165
|
+
return f"Successfully uninstalled skill: {name}"
|
|
166
|
+
else:
|
|
167
|
+
return f"Failed to uninstall skill: {result['error']}"
|
|
168
|
+
|
|
169
|
+
else:
|
|
170
|
+
return f"Unknown action: {action}. Use 'install', 'list', or 'uninstall'."
|
|
171
|
+
|
|
172
|
+
|
|
109
173
|
@tool(parse_docstring=True)
|
|
110
174
|
def think_tool(reflection: str) -> str:
|
|
111
175
|
"""Tool for strategic reflection on research progress and decision-making.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: EvoScientist
|
|
3
|
-
Version: 0.0.1.
|
|
3
|
+
Version: 0.0.1.dev3
|
|
4
4
|
Summary: EvoScientist: Towards Self-Evolving AI Scientists for End-to-End Scientific Discovery
|
|
5
5
|
Author: Xi Zhang
|
|
6
6
|
Maintainer: Xi Zhang
|
|
@@ -21,6 +21,7 @@ Requires-Dist: tavily-python>=0.7
|
|
|
21
21
|
Requires-Dist: pyyaml>=6.0
|
|
22
22
|
Requires-Dist: rich>=14.0
|
|
23
23
|
Requires-Dist: prompt-toolkit>=3.0
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
24
25
|
Requires-Dist: python-dotenv>=1.0
|
|
25
26
|
Requires-Dist: langgraph-cli[inmem]>=0.4
|
|
26
27
|
Requires-Dist: httpx>=0.27
|
|
@@ -41,11 +42,12 @@ Dynamic: license-file
|
|
|
41
42
|
|
|
42
43
|
<a href="https://git.io/typing-svg"><img src="https://readme-typing-svg.demolab.com?font=Fira+Code&pause=1000&width=435&lines=Towards+Self-Evolving+AI+Scientists+for+End-to-End+Scientific+Discovery" alt="Typing SVG" /></a>
|
|
43
44
|
|
|
44
|
-
[](https://pypi.org/project/EvoScientist/)
|
|
46
|
+
[]()
|
|
45
47
|
[]()
|
|
46
|
-
[]()
|
|
47
|
-
[]()
|
|
48
48
|
[]()
|
|
49
|
+
<!-- []()
|
|
50
|
+
[]() -->
|
|
49
51
|
|
|
50
52
|
</div>
|
|
51
53
|
|
|
@@ -60,6 +62,7 @@ Dynamic: license-file
|
|
|
60
62
|
|
|
61
63
|
## 📖 Contents
|
|
62
64
|
- [⛏️ Installation](#️-installation)
|
|
65
|
+
- [🔑 API Key Configuration](#-api-key-configuration)
|
|
63
66
|
- [⚡ Quick Start](#-quick-start)
|
|
64
67
|
- [CLI Inference](#cli-inference)
|
|
65
68
|
- [Script Inference](#script-inference)
|
|
@@ -105,6 +108,42 @@ uv pip install -e .
|
|
|
105
108
|
|
|
106
109
|
</details>
|
|
107
110
|
|
|
111
|
+
## 🔑 API Key Configuration
|
|
112
|
+
|
|
113
|
+
EvoScientist requires API keys for LLM inference and web search. You can configure them in two ways:
|
|
114
|
+
|
|
115
|
+
### Option A: Environment Variables (Global)
|
|
116
|
+
|
|
117
|
+
Set keys directly in your terminal session. Add these to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to persist across sessions:
|
|
118
|
+
|
|
119
|
+
```Shell
|
|
120
|
+
export ANTHROPIC_API_KEY="your_anthropic_api_key_here"
|
|
121
|
+
export TAVILY_API_KEY="your_tavily_api_key_here"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Option B: `.env` File (Project-level)
|
|
125
|
+
|
|
126
|
+
Create a `.env` file in the project root. This keeps keys scoped to the project and out of your shell history:
|
|
127
|
+
|
|
128
|
+
```Shell
|
|
129
|
+
cp .env.example .env
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Then edit `.env` and fill in your keys:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
|
136
|
+
TAVILY_API_KEY=your_tavily_api_key_here
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
> [!WARNING]
|
|
140
|
+
> Never commit `.env` files containing real API keys to version control. The `.env` file is already included in `.gitignore`.
|
|
141
|
+
|
|
142
|
+
| Key | Required | Description |
|
|
143
|
+
|-----|----------|-------------|
|
|
144
|
+
| `ANTHROPIC_API_KEY` | Yes | Anthropic API key for Claude ([console.anthropic.com](https://console.anthropic.com/)) |
|
|
145
|
+
| `TAVILY_API_KEY` | Yes | Tavily API key for web search ([app.tavily.com](https://app.tavily.com/)) |
|
|
146
|
+
|
|
108
147
|
## ⚡ Quick Start
|
|
109
148
|
|
|
110
149
|
### CLI Inference
|
|
@@ -113,11 +152,71 @@ You can perform inference directly from the command line using our CLI tool:
|
|
|
113
152
|

|
|
114
153
|
|
|
115
154
|
```Shell
|
|
116
|
-
python -m EvoScientist
|
|
155
|
+
python -m EvoScientist
|
|
117
156
|
```
|
|
118
|
-
|
|
157
|
+
or
|
|
158
|
+
```Shell
|
|
159
|
+
EvoSci # or EvoScientist
|
|
160
|
+
```
|
|
161
|
+
**Optional arguments:**
|
|
119
162
|
|
|
120
|
-
|
|
163
|
+
```
|
|
164
|
+
--workdir <path> Override workspace directory for this session
|
|
165
|
+
--use-cwd Use current working directory as workspace
|
|
166
|
+
--thread-id <id> Resume a conversation thread
|
|
167
|
+
--no-thinking Disable thinking display
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Interactive Commands:**
|
|
171
|
+
|
|
172
|
+
| Command | Description |
|
|
173
|
+
|---------|-------------|
|
|
174
|
+
| `/exit` | Quit the session |
|
|
175
|
+
| `/new` | Start a new session (new workspace + thread) |
|
|
176
|
+
| `/thread` | Show current thread ID and workspace path |
|
|
177
|
+
| `/skills` | List installed user skills |
|
|
178
|
+
| `/install-skill <source>` | Install a skill from local path or GitHub |
|
|
179
|
+
| `/uninstall-skill <name>` | Uninstall a user-installed skill |
|
|
180
|
+
|
|
181
|
+
**Skill Installation Examples:**
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# Install from local path
|
|
185
|
+
/install-skill ./my-skill
|
|
186
|
+
|
|
187
|
+
# Install from GitHub URL
|
|
188
|
+
/install-skill https://github.com/owner/repo/tree/main/skill-name
|
|
189
|
+
|
|
190
|
+
# Install from GitHub shorthand
|
|
191
|
+
/install-skill owner/repo@skill-name
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Runtime Directories
|
|
195
|
+
|
|
196
|
+
By default, the **workspace** is created under a hidden directory in the current
|
|
197
|
+
project directory:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
./.evoscientist/workspace/
|
|
201
|
+
memory/ # shared MEMORY.md
|
|
202
|
+
skills/ # user-installed skills
|
|
203
|
+
runs/ # per-thread workspaces
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
You can force workspace to be the current directory via `--use-cwd`.
|
|
207
|
+
|
|
208
|
+
If you set `EVOSCIENTIST_HOME`, EvoScientist will place `workspace/` under that
|
|
209
|
+
directory instead of the project root:
|
|
210
|
+
|
|
211
|
+
Example with `EVOSCIENTIST_HOME=~/.evoscientist`:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
~/.evoscientist/
|
|
215
|
+
workspace/
|
|
216
|
+
memory/
|
|
217
|
+
skills/
|
|
218
|
+
runs/
|
|
219
|
+
```
|
|
121
220
|
|
|
122
221
|
### Script Inference
|
|
123
222
|
```python
|