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.
Files changed (56) hide show
  1. ragnarbot/__init__.py +6 -0
  2. ragnarbot/__main__.py +8 -0
  3. ragnarbot/agent/__init__.py +8 -0
  4. ragnarbot/agent/context.py +223 -0
  5. ragnarbot/agent/loop.py +365 -0
  6. ragnarbot/agent/memory.py +109 -0
  7. ragnarbot/agent/skills.py +228 -0
  8. ragnarbot/agent/subagent.py +241 -0
  9. ragnarbot/agent/tools/__init__.py +6 -0
  10. ragnarbot/agent/tools/base.py +102 -0
  11. ragnarbot/agent/tools/cron.py +114 -0
  12. ragnarbot/agent/tools/filesystem.py +191 -0
  13. ragnarbot/agent/tools/message.py +86 -0
  14. ragnarbot/agent/tools/registry.py +73 -0
  15. ragnarbot/agent/tools/shell.py +141 -0
  16. ragnarbot/agent/tools/spawn.py +65 -0
  17. ragnarbot/agent/tools/web.py +163 -0
  18. ragnarbot/bus/__init__.py +6 -0
  19. ragnarbot/bus/events.py +37 -0
  20. ragnarbot/bus/queue.py +81 -0
  21. ragnarbot/channels/__init__.py +6 -0
  22. ragnarbot/channels/base.py +121 -0
  23. ragnarbot/channels/manager.py +129 -0
  24. ragnarbot/channels/telegram.py +302 -0
  25. ragnarbot/cli/__init__.py +1 -0
  26. ragnarbot/cli/commands.py +568 -0
  27. ragnarbot/config/__init__.py +6 -0
  28. ragnarbot/config/loader.py +95 -0
  29. ragnarbot/config/schema.py +114 -0
  30. ragnarbot/cron/__init__.py +6 -0
  31. ragnarbot/cron/service.py +346 -0
  32. ragnarbot/cron/types.py +59 -0
  33. ragnarbot/heartbeat/__init__.py +5 -0
  34. ragnarbot/heartbeat/service.py +130 -0
  35. ragnarbot/providers/__init__.py +6 -0
  36. ragnarbot/providers/base.py +69 -0
  37. ragnarbot/providers/litellm_provider.py +135 -0
  38. ragnarbot/providers/transcription.py +67 -0
  39. ragnarbot/session/__init__.py +5 -0
  40. ragnarbot/session/manager.py +202 -0
  41. ragnarbot/skills/README.md +24 -0
  42. ragnarbot/skills/cron/SKILL.md +40 -0
  43. ragnarbot/skills/github/SKILL.md +48 -0
  44. ragnarbot/skills/skill-creator/SKILL.md +371 -0
  45. ragnarbot/skills/summarize/SKILL.md +67 -0
  46. ragnarbot/skills/tmux/SKILL.md +121 -0
  47. ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
  48. ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
  49. ragnarbot/skills/weather/SKILL.md +49 -0
  50. ragnarbot/utils/__init__.py +5 -0
  51. ragnarbot/utils/helpers.py +91 -0
  52. ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
  53. ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
  54. ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
  55. ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
  56. 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
+ )