kyber-chat 1.0.0__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.
- kyber/__init__.py +6 -0
- kyber/__main__.py +8 -0
- kyber/agent/__init__.py +8 -0
- kyber/agent/context.py +224 -0
- kyber/agent/loop.py +687 -0
- kyber/agent/memory.py +109 -0
- kyber/agent/skills.py +244 -0
- kyber/agent/subagent.py +379 -0
- kyber/agent/tools/__init__.py +6 -0
- kyber/agent/tools/base.py +102 -0
- kyber/agent/tools/filesystem.py +191 -0
- kyber/agent/tools/message.py +86 -0
- kyber/agent/tools/registry.py +73 -0
- kyber/agent/tools/shell.py +141 -0
- kyber/agent/tools/spawn.py +65 -0
- kyber/agent/tools/task_status.py +53 -0
- kyber/agent/tools/web.py +163 -0
- kyber/bridge/package.json +26 -0
- kyber/bridge/src/index.ts +50 -0
- kyber/bridge/src/server.ts +104 -0
- kyber/bridge/src/types.d.ts +3 -0
- kyber/bridge/src/whatsapp.ts +185 -0
- kyber/bridge/tsconfig.json +16 -0
- kyber/bus/__init__.py +6 -0
- kyber/bus/events.py +37 -0
- kyber/bus/queue.py +81 -0
- kyber/channels/__init__.py +6 -0
- kyber/channels/base.py +121 -0
- kyber/channels/discord.py +304 -0
- kyber/channels/feishu.py +263 -0
- kyber/channels/manager.py +161 -0
- kyber/channels/telegram.py +302 -0
- kyber/channels/whatsapp.py +141 -0
- kyber/cli/__init__.py +1 -0
- kyber/cli/commands.py +736 -0
- kyber/config/__init__.py +6 -0
- kyber/config/loader.py +95 -0
- kyber/config/schema.py +205 -0
- kyber/cron/__init__.py +6 -0
- kyber/cron/service.py +346 -0
- kyber/cron/types.py +59 -0
- kyber/dashboard/__init__.py +5 -0
- kyber/dashboard/server.py +122 -0
- kyber/dashboard/static/app.js +458 -0
- kyber/dashboard/static/favicon.png +0 -0
- kyber/dashboard/static/index.html +107 -0
- kyber/dashboard/static/kyber_logo.png +0 -0
- kyber/dashboard/static/styles.css +608 -0
- kyber/heartbeat/__init__.py +5 -0
- kyber/heartbeat/service.py +130 -0
- kyber/providers/__init__.py +6 -0
- kyber/providers/base.py +69 -0
- kyber/providers/litellm_provider.py +227 -0
- kyber/providers/transcription.py +65 -0
- kyber/session/__init__.py +5 -0
- kyber/session/manager.py +202 -0
- kyber/skills/README.md +47 -0
- kyber/skills/github/SKILL.md +48 -0
- kyber/skills/skill-creator/SKILL.md +371 -0
- kyber/skills/summarize/SKILL.md +67 -0
- kyber/skills/tmux/SKILL.md +121 -0
- kyber/skills/tmux/scripts/find-sessions.sh +112 -0
- kyber/skills/tmux/scripts/wait-for-text.sh +83 -0
- kyber/skills/weather/SKILL.md +49 -0
- kyber/utils/__init__.py +5 -0
- kyber/utils/helpers.py +91 -0
- kyber_chat-1.0.0.dist-info/METADATA +35 -0
- kyber_chat-1.0.0.dist-info/RECORD +71 -0
- kyber_chat-1.0.0.dist-info/WHEEL +4 -0
- kyber_chat-1.0.0.dist-info/entry_points.txt +2 -0
- kyber_chat-1.0.0.dist-info/licenses/LICENSE +21 -0
kyber/agent/subagent.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Subagent manager for background task execution."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
from kyber.bus.events import InboundMessage
|
|
14
|
+
from kyber.bus.queue import MessageBus
|
|
15
|
+
from kyber.providers.base import LLMProvider
|
|
16
|
+
from kyber.agent.tools.registry import ToolRegistry
|
|
17
|
+
from kyber.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool
|
|
18
|
+
from kyber.agent.tools.shell import ExecTool
|
|
19
|
+
from kyber.agent.tools.web import WebSearchTool, WebFetchTool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TaskProgress:
|
|
24
|
+
"""Live progress tracker for a running subagent."""
|
|
25
|
+
task_id: str
|
|
26
|
+
label: str
|
|
27
|
+
task: str
|
|
28
|
+
status: str = "starting" # starting, running, completed, failed
|
|
29
|
+
iteration: int = 0
|
|
30
|
+
max_iterations: int = 15
|
|
31
|
+
current_action: str = ""
|
|
32
|
+
actions_completed: list[str] = field(default_factory=list)
|
|
33
|
+
started_at: datetime = field(default_factory=datetime.now)
|
|
34
|
+
finished_at: datetime | None = None
|
|
35
|
+
|
|
36
|
+
def to_summary(self) -> str:
|
|
37
|
+
elapsed = (self.finished_at or datetime.now()) - self.started_at
|
|
38
|
+
mins, secs = divmod(int(elapsed.total_seconds()), 60)
|
|
39
|
+
time_str = f"{mins}m {secs}s" if mins else f"{secs}s"
|
|
40
|
+
|
|
41
|
+
lines = [
|
|
42
|
+
f"Task: {self.label}",
|
|
43
|
+
f"Status: {self.status}",
|
|
44
|
+
f"Progress: step {self.iteration}/{self.max_iterations}",
|
|
45
|
+
f"Elapsed: {time_str}",
|
|
46
|
+
]
|
|
47
|
+
if self.current_action:
|
|
48
|
+
lines.append(f"Current: {self.current_action}")
|
|
49
|
+
if self.actions_completed:
|
|
50
|
+
recent = self.actions_completed[-5:]
|
|
51
|
+
lines.append(f"Recent actions: {', '.join(recent)}")
|
|
52
|
+
return "\n".join(lines)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SubagentManager:
|
|
56
|
+
"""
|
|
57
|
+
Manages background subagent execution.
|
|
58
|
+
|
|
59
|
+
Subagents are lightweight agent instances that run in the background
|
|
60
|
+
to handle specific tasks. They share the same LLM provider but have
|
|
61
|
+
isolated context and a focused system prompt.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
provider: LLMProvider,
|
|
67
|
+
workspace: Path,
|
|
68
|
+
bus: MessageBus,
|
|
69
|
+
model: str | None = None,
|
|
70
|
+
brave_api_key: str | None = None,
|
|
71
|
+
exec_config: "ExecToolConfig | None" = None,
|
|
72
|
+
):
|
|
73
|
+
from kyber.config.schema import ExecToolConfig
|
|
74
|
+
self.provider = provider
|
|
75
|
+
self.workspace = workspace
|
|
76
|
+
self.bus = bus
|
|
77
|
+
self.model = model or provider.get_default_model()
|
|
78
|
+
self.brave_api_key = brave_api_key
|
|
79
|
+
self.exec_config = exec_config or ExecToolConfig()
|
|
80
|
+
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
|
81
|
+
self._progress: dict[str, TaskProgress] = {}
|
|
82
|
+
# Also keep recently finished tasks for a short window so status
|
|
83
|
+
# checks right after completion still return useful info.
|
|
84
|
+
self._finished: dict[str, TaskProgress] = {}
|
|
85
|
+
|
|
86
|
+
async def spawn(
|
|
87
|
+
self,
|
|
88
|
+
task: str,
|
|
89
|
+
label: str | None = None,
|
|
90
|
+
origin_channel: str = "cli",
|
|
91
|
+
origin_chat_id: str = "direct",
|
|
92
|
+
) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Spawn a subagent to execute a task in the background.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
task: The task description for the subagent.
|
|
98
|
+
label: Optional human-readable label for the task.
|
|
99
|
+
origin_channel: The channel to announce results to.
|
|
100
|
+
origin_chat_id: The chat ID to announce results to.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Status message indicating the subagent was started.
|
|
104
|
+
"""
|
|
105
|
+
task_id = str(uuid.uuid4())[:8]
|
|
106
|
+
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
|
107
|
+
|
|
108
|
+
origin = {
|
|
109
|
+
"channel": origin_channel,
|
|
110
|
+
"chat_id": origin_chat_id,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Create progress tracker
|
|
114
|
+
progress = TaskProgress(
|
|
115
|
+
task_id=task_id,
|
|
116
|
+
label=display_label,
|
|
117
|
+
task=task,
|
|
118
|
+
)
|
|
119
|
+
self._progress[task_id] = progress
|
|
120
|
+
|
|
121
|
+
# Create background task
|
|
122
|
+
bg_task = asyncio.create_task(
|
|
123
|
+
self._run_subagent(task_id, task, display_label, origin)
|
|
124
|
+
)
|
|
125
|
+
self._running_tasks[task_id] = bg_task
|
|
126
|
+
|
|
127
|
+
# Cleanup when done
|
|
128
|
+
def _on_done(_: asyncio.Task[None]) -> None:
|
|
129
|
+
self._running_tasks.pop(task_id, None)
|
|
130
|
+
# Move to finished cache
|
|
131
|
+
if task_id in self._progress:
|
|
132
|
+
self._finished[task_id] = self._progress.pop(task_id)
|
|
133
|
+
# Prune finished cache to last 20 entries
|
|
134
|
+
while len(self._finished) > 20:
|
|
135
|
+
oldest = next(iter(self._finished))
|
|
136
|
+
del self._finished[oldest]
|
|
137
|
+
|
|
138
|
+
bg_task.add_done_callback(_on_done)
|
|
139
|
+
|
|
140
|
+
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
|
|
141
|
+
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
|
142
|
+
|
|
143
|
+
async def _run_subagent(
|
|
144
|
+
self,
|
|
145
|
+
task_id: str,
|
|
146
|
+
task: str,
|
|
147
|
+
label: str,
|
|
148
|
+
origin: dict[str, str],
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Execute the subagent task and announce the result."""
|
|
151
|
+
logger.info(f"Subagent [{task_id}] starting task: {label}")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
# Build subagent tools (no message tool, no spawn tool)
|
|
155
|
+
tools = ToolRegistry()
|
|
156
|
+
tools.register(ReadFileTool())
|
|
157
|
+
tools.register(WriteFileTool())
|
|
158
|
+
tools.register(ListDirTool())
|
|
159
|
+
tools.register(ExecTool(
|
|
160
|
+
working_dir=str(self.workspace),
|
|
161
|
+
timeout=self.exec_config.timeout,
|
|
162
|
+
restrict_to_workspace=self.exec_config.restrict_to_workspace,
|
|
163
|
+
))
|
|
164
|
+
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
|
165
|
+
tools.register(WebFetchTool())
|
|
166
|
+
|
|
167
|
+
# Build messages with subagent-specific prompt
|
|
168
|
+
system_prompt = self._build_subagent_prompt(task)
|
|
169
|
+
messages: list[dict[str, Any]] = [
|
|
170
|
+
{"role": "system", "content": system_prompt},
|
|
171
|
+
{"role": "user", "content": task},
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
# Run agent loop (limited iterations)
|
|
175
|
+
max_iterations = 15
|
|
176
|
+
iteration = 0
|
|
177
|
+
final_result: str | None = None
|
|
178
|
+
|
|
179
|
+
# Update progress tracker
|
|
180
|
+
progress = self._progress.get(task_id)
|
|
181
|
+
if progress:
|
|
182
|
+
progress.status = "running"
|
|
183
|
+
progress.max_iterations = max_iterations
|
|
184
|
+
|
|
185
|
+
while iteration < max_iterations:
|
|
186
|
+
iteration += 1
|
|
187
|
+
if progress:
|
|
188
|
+
progress.iteration = iteration
|
|
189
|
+
|
|
190
|
+
response = await self.provider.chat(
|
|
191
|
+
messages=messages,
|
|
192
|
+
tools=tools.get_definitions(),
|
|
193
|
+
model=self.model,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if response.has_tool_calls:
|
|
197
|
+
# Add assistant message with tool calls
|
|
198
|
+
tool_call_dicts = [
|
|
199
|
+
{
|
|
200
|
+
"id": tc.id,
|
|
201
|
+
"type": "function",
|
|
202
|
+
"function": {
|
|
203
|
+
"name": tc.name,
|
|
204
|
+
"arguments": json.dumps(tc.arguments),
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
for tc in response.tool_calls
|
|
208
|
+
]
|
|
209
|
+
messages.append({
|
|
210
|
+
"role": "assistant",
|
|
211
|
+
"content": response.content or "",
|
|
212
|
+
"tool_calls": tool_call_dicts,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
# Execute tools and update progress
|
|
216
|
+
for tool_call in response.tool_calls:
|
|
217
|
+
if progress:
|
|
218
|
+
progress.current_action = f"{tool_call.name}"
|
|
219
|
+
logger.debug(f"Subagent [{task_id}] executing: {tool_call.name}")
|
|
220
|
+
result = await tools.execute(tool_call.name, tool_call.arguments)
|
|
221
|
+
if progress:
|
|
222
|
+
progress.actions_completed.append(tool_call.name)
|
|
223
|
+
progress.current_action = ""
|
|
224
|
+
messages.append({
|
|
225
|
+
"role": "tool",
|
|
226
|
+
"tool_call_id": tool_call.id,
|
|
227
|
+
"name": tool_call.name,
|
|
228
|
+
"content": result,
|
|
229
|
+
})
|
|
230
|
+
else:
|
|
231
|
+
final_result = response.content
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
if final_result is None:
|
|
235
|
+
final_result = "Task completed but no final response was generated."
|
|
236
|
+
|
|
237
|
+
if progress:
|
|
238
|
+
progress.status = "completed"
|
|
239
|
+
progress.finished_at = datetime.now()
|
|
240
|
+
|
|
241
|
+
logger.info(f"Subagent [{task_id}] completed successfully")
|
|
242
|
+
await self._announce_result(task_id, label, task, final_result, origin, "ok")
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
error_msg = f"Error: {str(e)}"
|
|
246
|
+
logger.error(f"Subagent [{task_id}] failed: {e}")
|
|
247
|
+
progress = self._progress.get(task_id)
|
|
248
|
+
if progress:
|
|
249
|
+
progress.status = "failed"
|
|
250
|
+
progress.finished_at = datetime.now()
|
|
251
|
+
await self._announce_result(task_id, label, task, error_msg, origin, "error")
|
|
252
|
+
|
|
253
|
+
async def _announce_result(
|
|
254
|
+
self,
|
|
255
|
+
task_id: str,
|
|
256
|
+
label: str,
|
|
257
|
+
task: str,
|
|
258
|
+
result: str,
|
|
259
|
+
origin: dict[str, str],
|
|
260
|
+
status: str,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Announce the subagent result to the main agent via the message bus."""
|
|
263
|
+
status_text = "completed successfully" if status == "ok" else "failed"
|
|
264
|
+
|
|
265
|
+
announce_content = f"""[Subagent '{label}' {status_text}]
|
|
266
|
+
|
|
267
|
+
Task: {task}
|
|
268
|
+
|
|
269
|
+
Result:
|
|
270
|
+
{result}
|
|
271
|
+
|
|
272
|
+
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
|
|
273
|
+
|
|
274
|
+
# Inject as system message to trigger main agent
|
|
275
|
+
msg = InboundMessage(
|
|
276
|
+
channel="system",
|
|
277
|
+
sender_id="subagent",
|
|
278
|
+
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
|
279
|
+
content=announce_content,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
await self.bus.publish_inbound(msg)
|
|
283
|
+
logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
|
|
284
|
+
|
|
285
|
+
def _build_subagent_prompt(self, task: str) -> str:
|
|
286
|
+
"""Build a focused system prompt for the subagent."""
|
|
287
|
+
return f"""# Subagent
|
|
288
|
+
|
|
289
|
+
You are a subagent spawned by the main agent to complete a specific task.
|
|
290
|
+
|
|
291
|
+
## Your Task
|
|
292
|
+
{task}
|
|
293
|
+
|
|
294
|
+
## Rules
|
|
295
|
+
1. Stay focused - complete only the assigned task, nothing else
|
|
296
|
+
2. Your final response will be reported back to the main agent
|
|
297
|
+
3. Do not initiate conversations or take on side tasks
|
|
298
|
+
4. Be concise but informative in your findings
|
|
299
|
+
|
|
300
|
+
## What You Can Do
|
|
301
|
+
- Read and write files in the workspace
|
|
302
|
+
- Execute shell commands
|
|
303
|
+
- Search the web and fetch web pages
|
|
304
|
+
- Complete the task thoroughly
|
|
305
|
+
|
|
306
|
+
## What You Cannot Do
|
|
307
|
+
- Send messages directly to users (no message tool available)
|
|
308
|
+
- Spawn other subagents
|
|
309
|
+
- Access the main agent's conversation history
|
|
310
|
+
|
|
311
|
+
## Workspace
|
|
312
|
+
Your workspace is at: {self.workspace}
|
|
313
|
+
|
|
314
|
+
When you have completed the task, provide a clear summary of your findings or actions."""
|
|
315
|
+
|
|
316
|
+
def get_running_count(self) -> int:
|
|
317
|
+
"""Return the number of currently running subagents."""
|
|
318
|
+
return len(self._running_tasks)
|
|
319
|
+
|
|
320
|
+
def has_active_tasks(self) -> bool:
|
|
321
|
+
"""Return True if any subagents are currently running."""
|
|
322
|
+
return len(self._running_tasks) > 0
|
|
323
|
+
|
|
324
|
+
def get_all_status(self) -> str:
|
|
325
|
+
"""Return a formatted summary of all tracked subagent tasks."""
|
|
326
|
+
if not self._progress and not self._finished:
|
|
327
|
+
return "No subagent tasks have been started."
|
|
328
|
+
|
|
329
|
+
parts: list[str] = []
|
|
330
|
+
|
|
331
|
+
if self._progress:
|
|
332
|
+
parts.append("=== Active Tasks ===")
|
|
333
|
+
for p in self._progress.values():
|
|
334
|
+
parts.append(p.to_summary())
|
|
335
|
+
parts.append("")
|
|
336
|
+
|
|
337
|
+
# Show recently finished tasks
|
|
338
|
+
recent_finished = list(self._finished.values())[-5:]
|
|
339
|
+
if recent_finished:
|
|
340
|
+
parts.append("=== Recently Finished ===")
|
|
341
|
+
for p in recent_finished:
|
|
342
|
+
parts.append(p.to_summary())
|
|
343
|
+
parts.append("")
|
|
344
|
+
|
|
345
|
+
return "\n".join(parts).strip() if parts else "No subagent tasks tracked."
|
|
346
|
+
|
|
347
|
+
def get_task_status(self, task_id: str) -> str:
|
|
348
|
+
"""Return status for a specific task by ID."""
|
|
349
|
+
p = self._progress.get(task_id) or self._finished.get(task_id)
|
|
350
|
+
if not p:
|
|
351
|
+
return f"No task found with id '{task_id}'."
|
|
352
|
+
return p.to_summary()
|
|
353
|
+
|
|
354
|
+
def register_task(self, task_id: str, label: str, task: str) -> TaskProgress:
|
|
355
|
+
"""Register an externally-managed long-running task for tracking.
|
|
356
|
+
|
|
357
|
+
This lets the agent loop register its own in-progress work so the
|
|
358
|
+
task_status tool can report on it alongside subagent tasks.
|
|
359
|
+
"""
|
|
360
|
+
progress = TaskProgress(
|
|
361
|
+
task_id=task_id,
|
|
362
|
+
label=label,
|
|
363
|
+
task=task,
|
|
364
|
+
status="running",
|
|
365
|
+
)
|
|
366
|
+
self._progress[task_id] = progress
|
|
367
|
+
return progress
|
|
368
|
+
|
|
369
|
+
def complete_task(self, task_id: str, status: str = "completed") -> None:
|
|
370
|
+
"""Mark an externally-managed task as finished."""
|
|
371
|
+
progress = self._progress.pop(task_id, None)
|
|
372
|
+
if progress:
|
|
373
|
+
progress.status = status
|
|
374
|
+
progress.finished_at = datetime.now()
|
|
375
|
+
self._finished[task_id] = progress
|
|
376
|
+
# Prune finished cache
|
|
377
|
+
while len(self._finished) > 20:
|
|
378
|
+
oldest = next(iter(self._finished))
|
|
379
|
+
del self._finished[oldest]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Base class for agent tools."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Tool(ABC):
|
|
8
|
+
"""
|
|
9
|
+
Abstract base class for agent tools.
|
|
10
|
+
|
|
11
|
+
Tools are capabilities that the agent can use to interact with
|
|
12
|
+
the environment, such as reading files, executing commands, etc.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
_TYPE_MAP = {
|
|
16
|
+
"string": str,
|
|
17
|
+
"integer": int,
|
|
18
|
+
"number": (int, float),
|
|
19
|
+
"boolean": bool,
|
|
20
|
+
"array": list,
|
|
21
|
+
"object": dict,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
"""Tool name used in function calls."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def description(self) -> str:
|
|
33
|
+
"""Description of what the tool does."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def parameters(self) -> dict[str, Any]:
|
|
39
|
+
"""JSON Schema for tool parameters."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def execute(self, **kwargs: Any) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Execute the tool with given parameters.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
**kwargs: Tool-specific parameters.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
String result of the tool execution.
|
|
52
|
+
"""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
|
56
|
+
"""Validate tool parameters against JSON schema. Returns error list (empty if valid)."""
|
|
57
|
+
schema = self.parameters or {}
|
|
58
|
+
if schema.get("type", "object") != "object":
|
|
59
|
+
raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
|
|
60
|
+
return self._validate(params, {**schema, "type": "object"}, "")
|
|
61
|
+
|
|
62
|
+
def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]:
|
|
63
|
+
t, label = schema.get("type"), path or "parameter"
|
|
64
|
+
if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]):
|
|
65
|
+
return [f"{label} should be {t}"]
|
|
66
|
+
|
|
67
|
+
errors = []
|
|
68
|
+
if "enum" in schema and val not in schema["enum"]:
|
|
69
|
+
errors.append(f"{label} must be one of {schema['enum']}")
|
|
70
|
+
if t in ("integer", "number"):
|
|
71
|
+
if "minimum" in schema and val < schema["minimum"]:
|
|
72
|
+
errors.append(f"{label} must be >= {schema['minimum']}")
|
|
73
|
+
if "maximum" in schema and val > schema["maximum"]:
|
|
74
|
+
errors.append(f"{label} must be <= {schema['maximum']}")
|
|
75
|
+
if t == "string":
|
|
76
|
+
if "minLength" in schema and len(val) < schema["minLength"]:
|
|
77
|
+
errors.append(f"{label} must be at least {schema['minLength']} chars")
|
|
78
|
+
if "maxLength" in schema and len(val) > schema["maxLength"]:
|
|
79
|
+
errors.append(f"{label} must be at most {schema['maxLength']} chars")
|
|
80
|
+
if t == "object":
|
|
81
|
+
props = schema.get("properties", {})
|
|
82
|
+
for k in schema.get("required", []):
|
|
83
|
+
if k not in val:
|
|
84
|
+
errors.append(f"missing required {path + '.' + k if path else k}")
|
|
85
|
+
for k, v in val.items():
|
|
86
|
+
if k in props:
|
|
87
|
+
errors.extend(self._validate(v, props[k], path + '.' + k if path else k))
|
|
88
|
+
if t == "array" and "items" in schema:
|
|
89
|
+
for i, item in enumerate(val):
|
|
90
|
+
errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]"))
|
|
91
|
+
return errors
|
|
92
|
+
|
|
93
|
+
def to_schema(self) -> dict[str, Any]:
|
|
94
|
+
"""Convert tool to OpenAI function schema format."""
|
|
95
|
+
return {
|
|
96
|
+
"type": "function",
|
|
97
|
+
"function": {
|
|
98
|
+
"name": self.name,
|
|
99
|
+
"description": self.description,
|
|
100
|
+
"parameters": self.parameters,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""File system tools: read, write, edit."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from kyber.agent.tools.base import Tool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReadFileTool(Tool):
|
|
10
|
+
"""Tool to read file contents."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def name(self) -> str:
|
|
14
|
+
return "read_file"
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def description(self) -> str:
|
|
18
|
+
return "Read the contents of a file at the given path."
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def parameters(self) -> dict[str, Any]:
|
|
22
|
+
return {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"path": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "The file path to read"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"required": ["path"]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async def execute(self, path: str, **kwargs: Any) -> str:
|
|
34
|
+
try:
|
|
35
|
+
file_path = Path(path).expanduser()
|
|
36
|
+
if not file_path.exists():
|
|
37
|
+
return f"Error: File not found: {path}"
|
|
38
|
+
if not file_path.is_file():
|
|
39
|
+
return f"Error: Not a file: {path}"
|
|
40
|
+
|
|
41
|
+
content = file_path.read_text(encoding="utf-8")
|
|
42
|
+
return content
|
|
43
|
+
except PermissionError:
|
|
44
|
+
return f"Error: Permission denied: {path}"
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return f"Error reading file: {str(e)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class WriteFileTool(Tool):
|
|
50
|
+
"""Tool to write content to a file."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
return "write_file"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def description(self) -> str:
|
|
58
|
+
return "Write content to a file at the given path. Creates parent directories if needed."
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def parameters(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"path": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "The file path to write to"
|
|
68
|
+
},
|
|
69
|
+
"content": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "The content to write"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"required": ["path", "content"]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
|
|
78
|
+
try:
|
|
79
|
+
file_path = Path(path).expanduser()
|
|
80
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
file_path.write_text(content, encoding="utf-8")
|
|
82
|
+
return f"Successfully wrote {len(content)} bytes to {path}"
|
|
83
|
+
except PermissionError:
|
|
84
|
+
return f"Error: Permission denied: {path}"
|
|
85
|
+
except Exception as e:
|
|
86
|
+
return f"Error writing file: {str(e)}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EditFileTool(Tool):
|
|
90
|
+
"""Tool to edit a file by replacing text."""
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def name(self) -> str:
|
|
94
|
+
return "edit_file"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def description(self) -> str:
|
|
98
|
+
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def parameters(self) -> dict[str, Any]:
|
|
102
|
+
return {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {
|
|
105
|
+
"path": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"description": "The file path to edit"
|
|
108
|
+
},
|
|
109
|
+
"old_text": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"description": "The exact text to find and replace"
|
|
112
|
+
},
|
|
113
|
+
"new_text": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"description": "The text to replace with"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"required": ["path", "old_text", "new_text"]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
|
|
122
|
+
try:
|
|
123
|
+
file_path = Path(path).expanduser()
|
|
124
|
+
if not file_path.exists():
|
|
125
|
+
return f"Error: File not found: {path}"
|
|
126
|
+
|
|
127
|
+
content = file_path.read_text(encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
if old_text not in content:
|
|
130
|
+
return f"Error: old_text not found in file. Make sure it matches exactly."
|
|
131
|
+
|
|
132
|
+
# Count occurrences
|
|
133
|
+
count = content.count(old_text)
|
|
134
|
+
if count > 1:
|
|
135
|
+
return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
|
|
136
|
+
|
|
137
|
+
new_content = content.replace(old_text, new_text, 1)
|
|
138
|
+
file_path.write_text(new_content, encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
return f"Successfully edited {path}"
|
|
141
|
+
except PermissionError:
|
|
142
|
+
return f"Error: Permission denied: {path}"
|
|
143
|
+
except Exception as e:
|
|
144
|
+
return f"Error editing file: {str(e)}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ListDirTool(Tool):
|
|
148
|
+
"""Tool to list directory contents."""
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def name(self) -> str:
|
|
152
|
+
return "list_dir"
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def description(self) -> str:
|
|
156
|
+
return "List the contents of a directory."
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def parameters(self) -> dict[str, Any]:
|
|
160
|
+
return {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"path": {
|
|
164
|
+
"type": "string",
|
|
165
|
+
"description": "The directory path to list"
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"required": ["path"]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async def execute(self, path: str, **kwargs: Any) -> str:
|
|
172
|
+
try:
|
|
173
|
+
dir_path = Path(path).expanduser()
|
|
174
|
+
if not dir_path.exists():
|
|
175
|
+
return f"Error: Directory not found: {path}"
|
|
176
|
+
if not dir_path.is_dir():
|
|
177
|
+
return f"Error: Not a directory: {path}"
|
|
178
|
+
|
|
179
|
+
items = []
|
|
180
|
+
for item in sorted(dir_path.iterdir()):
|
|
181
|
+
prefix = "📁 " if item.is_dir() else "📄 "
|
|
182
|
+
items.append(f"{prefix}{item.name}")
|
|
183
|
+
|
|
184
|
+
if not items:
|
|
185
|
+
return f"Directory {path} is empty"
|
|
186
|
+
|
|
187
|
+
return "\n".join(items)
|
|
188
|
+
except PermissionError:
|
|
189
|
+
return f"Error: Permission denied: {path}"
|
|
190
|
+
except Exception as e:
|
|
191
|
+
return f"Error listing directory: {str(e)}"
|