ragnarbot-ai 0.1.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.
- ragnarbot/__init__.py +6 -0
- ragnarbot/__main__.py +8 -0
- ragnarbot/agent/__init__.py +8 -0
- ragnarbot/agent/context.py +223 -0
- ragnarbot/agent/loop.py +365 -0
- ragnarbot/agent/memory.py +109 -0
- ragnarbot/agent/skills.py +228 -0
- ragnarbot/agent/subagent.py +241 -0
- ragnarbot/agent/tools/__init__.py +6 -0
- ragnarbot/agent/tools/base.py +102 -0
- ragnarbot/agent/tools/cron.py +114 -0
- ragnarbot/agent/tools/filesystem.py +191 -0
- ragnarbot/agent/tools/message.py +86 -0
- ragnarbot/agent/tools/registry.py +73 -0
- ragnarbot/agent/tools/shell.py +141 -0
- ragnarbot/agent/tools/spawn.py +65 -0
- ragnarbot/agent/tools/web.py +163 -0
- ragnarbot/bus/__init__.py +6 -0
- ragnarbot/bus/events.py +37 -0
- ragnarbot/bus/queue.py +81 -0
- ragnarbot/channels/__init__.py +6 -0
- ragnarbot/channels/base.py +121 -0
- ragnarbot/channels/manager.py +129 -0
- ragnarbot/channels/telegram.py +302 -0
- ragnarbot/cli/__init__.py +1 -0
- ragnarbot/cli/commands.py +568 -0
- ragnarbot/config/__init__.py +6 -0
- ragnarbot/config/loader.py +95 -0
- ragnarbot/config/schema.py +114 -0
- ragnarbot/cron/__init__.py +6 -0
- ragnarbot/cron/service.py +346 -0
- ragnarbot/cron/types.py +59 -0
- ragnarbot/heartbeat/__init__.py +5 -0
- ragnarbot/heartbeat/service.py +130 -0
- ragnarbot/providers/__init__.py +6 -0
- ragnarbot/providers/base.py +69 -0
- ragnarbot/providers/litellm_provider.py +135 -0
- ragnarbot/providers/transcription.py +67 -0
- ragnarbot/session/__init__.py +5 -0
- ragnarbot/session/manager.py +202 -0
- ragnarbot/skills/README.md +24 -0
- ragnarbot/skills/cron/SKILL.md +40 -0
- ragnarbot/skills/github/SKILL.md +48 -0
- ragnarbot/skills/skill-creator/SKILL.md +371 -0
- ragnarbot/skills/summarize/SKILL.md +67 -0
- ragnarbot/skills/tmux/SKILL.md +121 -0
- ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
- ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
- ragnarbot/skills/weather/SKILL.md +49 -0
- ragnarbot/utils/__init__.py +5 -0
- ragnarbot/utils/helpers.py +91 -0
- ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
- ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
- ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
- ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
- ragnarbot_ai-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Cron tool for scheduling reminders and tasks."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ragnarbot.agent.tools.base import Tool
|
|
6
|
+
from ragnarbot.cron.service import CronService
|
|
7
|
+
from ragnarbot.cron.types import CronSchedule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CronTool(Tool):
|
|
11
|
+
"""Tool to schedule reminders and recurring tasks."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, cron_service: CronService):
|
|
14
|
+
self._cron = cron_service
|
|
15
|
+
self._channel = ""
|
|
16
|
+
self._chat_id = ""
|
|
17
|
+
|
|
18
|
+
def set_context(self, channel: str, chat_id: str) -> None:
|
|
19
|
+
"""Set the current session context for delivery."""
|
|
20
|
+
self._channel = channel
|
|
21
|
+
self._chat_id = chat_id
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
return "cron"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def description(self) -> str:
|
|
29
|
+
return "Schedule reminders and recurring tasks. Actions: add, list, remove."
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def parameters(self) -> dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"action": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"enum": ["add", "list", "remove"],
|
|
39
|
+
"description": "Action to perform"
|
|
40
|
+
},
|
|
41
|
+
"message": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Reminder message (for add)"
|
|
44
|
+
},
|
|
45
|
+
"every_seconds": {
|
|
46
|
+
"type": "integer",
|
|
47
|
+
"description": "Interval in seconds (for recurring tasks)"
|
|
48
|
+
},
|
|
49
|
+
"cron_expr": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
|
|
52
|
+
},
|
|
53
|
+
"job_id": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "Job ID (for remove)"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"required": ["action"]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async def execute(
|
|
62
|
+
self,
|
|
63
|
+
action: str,
|
|
64
|
+
message: str = "",
|
|
65
|
+
every_seconds: int | None = None,
|
|
66
|
+
cron_expr: str | None = None,
|
|
67
|
+
job_id: str | None = None,
|
|
68
|
+
**kwargs: Any
|
|
69
|
+
) -> str:
|
|
70
|
+
if action == "add":
|
|
71
|
+
return self._add_job(message, every_seconds, cron_expr)
|
|
72
|
+
elif action == "list":
|
|
73
|
+
return self._list_jobs()
|
|
74
|
+
elif action == "remove":
|
|
75
|
+
return self._remove_job(job_id)
|
|
76
|
+
return f"Unknown action: {action}"
|
|
77
|
+
|
|
78
|
+
def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str:
|
|
79
|
+
if not message:
|
|
80
|
+
return "Error: message is required for add"
|
|
81
|
+
if not self._channel or not self._chat_id:
|
|
82
|
+
return "Error: no session context (channel/chat_id)"
|
|
83
|
+
|
|
84
|
+
# Build schedule
|
|
85
|
+
if every_seconds:
|
|
86
|
+
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
|
|
87
|
+
elif cron_expr:
|
|
88
|
+
schedule = CronSchedule(kind="cron", expr=cron_expr)
|
|
89
|
+
else:
|
|
90
|
+
return "Error: either every_seconds or cron_expr is required"
|
|
91
|
+
|
|
92
|
+
job = self._cron.add_job(
|
|
93
|
+
name=message[:30],
|
|
94
|
+
schedule=schedule,
|
|
95
|
+
message=message,
|
|
96
|
+
deliver=True,
|
|
97
|
+
channel=self._channel,
|
|
98
|
+
to=self._chat_id,
|
|
99
|
+
)
|
|
100
|
+
return f"Created job '{job.name}' (id: {job.id})"
|
|
101
|
+
|
|
102
|
+
def _list_jobs(self) -> str:
|
|
103
|
+
jobs = self._cron.list_jobs()
|
|
104
|
+
if not jobs:
|
|
105
|
+
return "No scheduled jobs."
|
|
106
|
+
lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs]
|
|
107
|
+
return "Scheduled jobs:\n" + "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
def _remove_job(self, job_id: str | None) -> str:
|
|
110
|
+
if not job_id:
|
|
111
|
+
return "Error: job_id is required for remove"
|
|
112
|
+
if self._cron.remove_job(job_id):
|
|
113
|
+
return f"Removed job {job_id}"
|
|
114
|
+
return f"Job {job_id} not found"
|
|
@@ -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 ragnarbot.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)}"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Message tool for sending messages to users."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Awaitable
|
|
4
|
+
|
|
5
|
+
from ragnarbot.agent.tools.base import Tool
|
|
6
|
+
from ragnarbot.bus.events import OutboundMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MessageTool(Tool):
|
|
10
|
+
"""Tool to send messages to users on chat channels."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
|
15
|
+
default_channel: str = "",
|
|
16
|
+
default_chat_id: str = ""
|
|
17
|
+
):
|
|
18
|
+
self._send_callback = send_callback
|
|
19
|
+
self._default_channel = default_channel
|
|
20
|
+
self._default_chat_id = default_chat_id
|
|
21
|
+
|
|
22
|
+
def set_context(self, channel: str, chat_id: str) -> None:
|
|
23
|
+
"""Set the current message context."""
|
|
24
|
+
self._default_channel = channel
|
|
25
|
+
self._default_chat_id = chat_id
|
|
26
|
+
|
|
27
|
+
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
|
28
|
+
"""Set the callback for sending messages."""
|
|
29
|
+
self._send_callback = callback
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def name(self) -> str:
|
|
33
|
+
return "message"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def description(self) -> str:
|
|
37
|
+
return "Send a message to the user. Use this when you want to communicate something."
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def parameters(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"content": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "The message content to send"
|
|
47
|
+
},
|
|
48
|
+
"channel": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "Optional: target channel (e.g. telegram)"
|
|
51
|
+
},
|
|
52
|
+
"chat_id": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "Optional: target chat/user ID"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"required": ["content"]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async def execute(
|
|
61
|
+
self,
|
|
62
|
+
content: str,
|
|
63
|
+
channel: str | None = None,
|
|
64
|
+
chat_id: str | None = None,
|
|
65
|
+
**kwargs: Any
|
|
66
|
+
) -> str:
|
|
67
|
+
channel = channel or self._default_channel
|
|
68
|
+
chat_id = chat_id or self._default_chat_id
|
|
69
|
+
|
|
70
|
+
if not channel or not chat_id:
|
|
71
|
+
return "Error: No target channel/chat specified"
|
|
72
|
+
|
|
73
|
+
if not self._send_callback:
|
|
74
|
+
return "Error: Message sending not configured"
|
|
75
|
+
|
|
76
|
+
msg = OutboundMessage(
|
|
77
|
+
channel=channel,
|
|
78
|
+
chat_id=chat_id,
|
|
79
|
+
content=content
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
await self._send_callback(msg)
|
|
84
|
+
return f"Message sent to {channel}:{chat_id}"
|
|
85
|
+
except Exception as e:
|
|
86
|
+
return f"Error sending message: {str(e)}"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tool registry for dynamic tool management."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ragnarbot.agent.tools.base import Tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolRegistry:
|
|
9
|
+
"""
|
|
10
|
+
Registry for agent tools.
|
|
11
|
+
|
|
12
|
+
Allows dynamic registration and execution of tools.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._tools: dict[str, Tool] = {}
|
|
17
|
+
|
|
18
|
+
def register(self, tool: Tool) -> None:
|
|
19
|
+
"""Register a tool."""
|
|
20
|
+
self._tools[tool.name] = tool
|
|
21
|
+
|
|
22
|
+
def unregister(self, name: str) -> None:
|
|
23
|
+
"""Unregister a tool by name."""
|
|
24
|
+
self._tools.pop(name, None)
|
|
25
|
+
|
|
26
|
+
def get(self, name: str) -> Tool | None:
|
|
27
|
+
"""Get a tool by name."""
|
|
28
|
+
return self._tools.get(name)
|
|
29
|
+
|
|
30
|
+
def has(self, name: str) -> bool:
|
|
31
|
+
"""Check if a tool is registered."""
|
|
32
|
+
return name in self._tools
|
|
33
|
+
|
|
34
|
+
def get_definitions(self) -> list[dict[str, Any]]:
|
|
35
|
+
"""Get all tool definitions in OpenAI format."""
|
|
36
|
+
return [tool.to_schema() for tool in self._tools.values()]
|
|
37
|
+
|
|
38
|
+
async def execute(self, name: str, params: dict[str, Any]) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Execute a tool by name with given parameters.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
name: Tool name.
|
|
44
|
+
params: Tool parameters.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tool execution result as string.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
KeyError: If tool not found.
|
|
51
|
+
"""
|
|
52
|
+
tool = self._tools.get(name)
|
|
53
|
+
if not tool:
|
|
54
|
+
return f"Error: Tool '{name}' not found"
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
errors = tool.validate_params(params)
|
|
58
|
+
if errors:
|
|
59
|
+
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
|
60
|
+
return await tool.execute(**params)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return f"Error executing {name}: {str(e)}"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def tool_names(self) -> list[str]:
|
|
66
|
+
"""Get list of registered tool names."""
|
|
67
|
+
return list(self._tools.keys())
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
return len(self._tools)
|
|
71
|
+
|
|
72
|
+
def __contains__(self, name: str) -> bool:
|
|
73
|
+
return name in self._tools
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Shell execution tool."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ragnarbot.agent.tools.base import Tool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExecTool(Tool):
|
|
13
|
+
"""Tool to execute shell commands."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
timeout: int = 60,
|
|
18
|
+
working_dir: str | None = None,
|
|
19
|
+
deny_patterns: list[str] | None = None,
|
|
20
|
+
allow_patterns: list[str] | None = None,
|
|
21
|
+
restrict_to_workspace: bool = False,
|
|
22
|
+
):
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.working_dir = working_dir
|
|
25
|
+
self.deny_patterns = deny_patterns or [
|
|
26
|
+
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
|
27
|
+
r"\bdel\s+/[fq]\b", # del /f, del /q
|
|
28
|
+
r"\brmdir\s+/s\b", # rmdir /s
|
|
29
|
+
r"\b(format|mkfs|diskpart)\b", # disk operations
|
|
30
|
+
r"\bdd\s+if=", # dd
|
|
31
|
+
r">\s*/dev/sd", # write to disk
|
|
32
|
+
r"\b(shutdown|reboot|poweroff)\b", # system power
|
|
33
|
+
r":\(\)\s*\{.*\};\s*:", # fork bomb
|
|
34
|
+
]
|
|
35
|
+
self.allow_patterns = allow_patterns or []
|
|
36
|
+
self.restrict_to_workspace = restrict_to_workspace
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
return "exec"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def description(self) -> str:
|
|
44
|
+
return "Execute a shell command and return its output. Use with caution."
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def parameters(self) -> dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"command": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "The shell command to execute"
|
|
54
|
+
},
|
|
55
|
+
"working_dir": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "Optional working directory for the command"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": ["command"]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
|
|
64
|
+
cwd = working_dir or self.working_dir or os.getcwd()
|
|
65
|
+
guard_error = self._guard_command(command, cwd)
|
|
66
|
+
if guard_error:
|
|
67
|
+
return guard_error
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
process = await asyncio.create_subprocess_shell(
|
|
71
|
+
command,
|
|
72
|
+
stdout=asyncio.subprocess.PIPE,
|
|
73
|
+
stderr=asyncio.subprocess.PIPE,
|
|
74
|
+
cwd=cwd,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
stdout, stderr = await asyncio.wait_for(
|
|
79
|
+
process.communicate(),
|
|
80
|
+
timeout=self.timeout
|
|
81
|
+
)
|
|
82
|
+
except asyncio.TimeoutError:
|
|
83
|
+
process.kill()
|
|
84
|
+
return f"Error: Command timed out after {self.timeout} seconds"
|
|
85
|
+
|
|
86
|
+
output_parts = []
|
|
87
|
+
|
|
88
|
+
if stdout:
|
|
89
|
+
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
|
90
|
+
|
|
91
|
+
if stderr:
|
|
92
|
+
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
93
|
+
if stderr_text.strip():
|
|
94
|
+
output_parts.append(f"STDERR:\n{stderr_text}")
|
|
95
|
+
|
|
96
|
+
if process.returncode != 0:
|
|
97
|
+
output_parts.append(f"\nExit code: {process.returncode}")
|
|
98
|
+
|
|
99
|
+
result = "\n".join(output_parts) if output_parts else "(no output)"
|
|
100
|
+
|
|
101
|
+
# Truncate very long output
|
|
102
|
+
max_len = 10000
|
|
103
|
+
if len(result) > max_len:
|
|
104
|
+
result = result[:max_len] + f"\n... (truncated, {len(result) - max_len} more chars)"
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
return f"Error executing command: {str(e)}"
|
|
110
|
+
|
|
111
|
+
def _guard_command(self, command: str, cwd: str) -> str | None:
|
|
112
|
+
"""Best-effort safety guard for potentially destructive commands."""
|
|
113
|
+
cmd = command.strip()
|
|
114
|
+
lower = cmd.lower()
|
|
115
|
+
|
|
116
|
+
for pattern in self.deny_patterns:
|
|
117
|
+
if re.search(pattern, lower):
|
|
118
|
+
return "Error: Command blocked by safety guard (dangerous pattern detected)"
|
|
119
|
+
|
|
120
|
+
if self.allow_patterns:
|
|
121
|
+
if not any(re.search(p, lower) for p in self.allow_patterns):
|
|
122
|
+
return "Error: Command blocked by safety guard (not in allowlist)"
|
|
123
|
+
|
|
124
|
+
if self.restrict_to_workspace:
|
|
125
|
+
if "..\\" in cmd or "../" in cmd:
|
|
126
|
+
return "Error: Command blocked by safety guard (path traversal detected)"
|
|
127
|
+
|
|
128
|
+
cwd_path = Path(cwd).resolve()
|
|
129
|
+
|
|
130
|
+
win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd)
|
|
131
|
+
posix_paths = re.findall(r"/[^\s\"']+", cmd)
|
|
132
|
+
|
|
133
|
+
for raw in win_paths + posix_paths:
|
|
134
|
+
try:
|
|
135
|
+
p = Path(raw).resolve()
|
|
136
|
+
except Exception:
|
|
137
|
+
continue
|
|
138
|
+
if cwd_path not in p.parents and p != cwd_path:
|
|
139
|
+
return "Error: Command blocked by safety guard (path outside working dir)"
|
|
140
|
+
|
|
141
|
+
return None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Spawn tool for creating background subagents."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ragnarbot.agent.tools.base import Tool
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ragnarbot.agent.subagent import SubagentManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpawnTool(Tool):
|
|
12
|
+
"""
|
|
13
|
+
Tool to spawn a subagent for background task execution.
|
|
14
|
+
|
|
15
|
+
The subagent runs asynchronously and announces its result back
|
|
16
|
+
to the main agent when complete.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, manager: "SubagentManager"):
|
|
20
|
+
self._manager = manager
|
|
21
|
+
self._origin_channel = "cli"
|
|
22
|
+
self._origin_chat_id = "direct"
|
|
23
|
+
|
|
24
|
+
def set_context(self, channel: str, chat_id: str) -> None:
|
|
25
|
+
"""Set the origin context for subagent announcements."""
|
|
26
|
+
self._origin_channel = channel
|
|
27
|
+
self._origin_chat_id = chat_id
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
return "spawn"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def description(self) -> str:
|
|
35
|
+
return (
|
|
36
|
+
"Spawn a subagent to handle a task in the background. "
|
|
37
|
+
"Use this for complex or time-consuming tasks that can run independently. "
|
|
38
|
+
"The subagent will complete the task and report back when done."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def parameters(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"task": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "The task for the subagent to complete",
|
|
49
|
+
},
|
|
50
|
+
"label": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "Optional short label for the task (for display)",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"required": ["task"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
|
59
|
+
"""Spawn a subagent to execute the given task."""
|
|
60
|
+
return await self._manager.spawn(
|
|
61
|
+
task=task,
|
|
62
|
+
label=label,
|
|
63
|
+
origin_channel=self._origin_channel,
|
|
64
|
+
origin_chat_id=self._origin_chat_id,
|
|
65
|
+
)
|