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
ragnarbot/__init__.py
ADDED
ragnarbot/__main__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Agent core module."""
|
|
2
|
+
|
|
3
|
+
from ragnarbot.agent.loop import AgentLoop
|
|
4
|
+
from ragnarbot.agent.context import ContextBuilder
|
|
5
|
+
from ragnarbot.agent.memory import MemoryStore
|
|
6
|
+
from ragnarbot.agent.skills import SkillsLoader
|
|
7
|
+
|
|
8
|
+
__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Context builder for assembling agent prompts."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import mimetypes
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ragnarbot.agent.memory import MemoryStore
|
|
9
|
+
from ragnarbot.agent.skills import SkillsLoader
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ContextBuilder:
|
|
13
|
+
"""
|
|
14
|
+
Builds the context (system prompt + messages) for the agent.
|
|
15
|
+
|
|
16
|
+
Assembles bootstrap files, memory, skills, and conversation history
|
|
17
|
+
into a coherent prompt for the LLM.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
|
|
21
|
+
|
|
22
|
+
def __init__(self, workspace: Path):
|
|
23
|
+
self.workspace = workspace
|
|
24
|
+
self.memory = MemoryStore(workspace)
|
|
25
|
+
self.skills = SkillsLoader(workspace)
|
|
26
|
+
|
|
27
|
+
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Build the system prompt from bootstrap files, memory, and skills.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
skill_names: Optional list of skills to include.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Complete system prompt.
|
|
36
|
+
"""
|
|
37
|
+
parts = []
|
|
38
|
+
|
|
39
|
+
# Core identity
|
|
40
|
+
parts.append(self._get_identity())
|
|
41
|
+
|
|
42
|
+
# Bootstrap files
|
|
43
|
+
bootstrap = self._load_bootstrap_files()
|
|
44
|
+
if bootstrap:
|
|
45
|
+
parts.append(bootstrap)
|
|
46
|
+
|
|
47
|
+
# Memory context
|
|
48
|
+
memory = self.memory.get_memory_context()
|
|
49
|
+
if memory:
|
|
50
|
+
parts.append(f"# Memory\n\n{memory}")
|
|
51
|
+
|
|
52
|
+
# Skills - progressive loading
|
|
53
|
+
# 1. Always-loaded skills: include full content
|
|
54
|
+
always_skills = self.skills.get_always_skills()
|
|
55
|
+
if always_skills:
|
|
56
|
+
always_content = self.skills.load_skills_for_context(always_skills)
|
|
57
|
+
if always_content:
|
|
58
|
+
parts.append(f"# Active Skills\n\n{always_content}")
|
|
59
|
+
|
|
60
|
+
# 2. Available skills: only show summary (agent uses read_file to load)
|
|
61
|
+
skills_summary = self.skills.build_skills_summary()
|
|
62
|
+
if skills_summary:
|
|
63
|
+
parts.append(f"""# Skills
|
|
64
|
+
|
|
65
|
+
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
|
|
66
|
+
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
|
|
67
|
+
|
|
68
|
+
{skills_summary}""")
|
|
69
|
+
|
|
70
|
+
return "\n\n---\n\n".join(parts)
|
|
71
|
+
|
|
72
|
+
def _get_identity(self) -> str:
|
|
73
|
+
"""Get the core identity section."""
|
|
74
|
+
from datetime import datetime
|
|
75
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
|
76
|
+
workspace_path = str(self.workspace.expanduser().resolve())
|
|
77
|
+
|
|
78
|
+
return f"""# ragnarbot 🤖
|
|
79
|
+
|
|
80
|
+
You are ragnarbot, a helpful AI assistant. You have access to tools that allow you to:
|
|
81
|
+
- Read, write, and edit files
|
|
82
|
+
- Execute shell commands
|
|
83
|
+
- Search the web and fetch web pages
|
|
84
|
+
- Send messages to users on chat channels
|
|
85
|
+
- Spawn subagents for complex background tasks
|
|
86
|
+
|
|
87
|
+
## Current Time
|
|
88
|
+
{now}
|
|
89
|
+
|
|
90
|
+
## Workspace
|
|
91
|
+
Your workspace is at: {workspace_path}
|
|
92
|
+
- Memory files: {workspace_path}/memory/MEMORY.md
|
|
93
|
+
- Daily notes: {workspace_path}/memory/YYYY-MM-DD.md
|
|
94
|
+
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
|
95
|
+
|
|
96
|
+
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
|
|
97
|
+
Only use the 'message' tool when you need to send a message to a specific chat channel.
|
|
98
|
+
For normal conversation, just respond with text - do not call the message tool.
|
|
99
|
+
|
|
100
|
+
Always be helpful, accurate, and concise. When using tools, explain what you're doing.
|
|
101
|
+
When remembering something, write to {workspace_path}/memory/MEMORY.md"""
|
|
102
|
+
|
|
103
|
+
def _load_bootstrap_files(self) -> str:
|
|
104
|
+
"""Load all bootstrap files from workspace."""
|
|
105
|
+
parts = []
|
|
106
|
+
|
|
107
|
+
for filename in self.BOOTSTRAP_FILES:
|
|
108
|
+
file_path = self.workspace / filename
|
|
109
|
+
if file_path.exists():
|
|
110
|
+
content = file_path.read_text(encoding="utf-8")
|
|
111
|
+
parts.append(f"## {filename}\n\n{content}")
|
|
112
|
+
|
|
113
|
+
return "\n\n".join(parts) if parts else ""
|
|
114
|
+
|
|
115
|
+
def build_messages(
|
|
116
|
+
self,
|
|
117
|
+
history: list[dict[str, Any]],
|
|
118
|
+
current_message: str,
|
|
119
|
+
skill_names: list[str] | None = None,
|
|
120
|
+
media: list[str] | None = None,
|
|
121
|
+
channel: str | None = None,
|
|
122
|
+
chat_id: str | None = None,
|
|
123
|
+
) -> list[dict[str, Any]]:
|
|
124
|
+
"""
|
|
125
|
+
Build the complete message list for an LLM call.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
history: Previous conversation messages.
|
|
129
|
+
current_message: The new user message.
|
|
130
|
+
skill_names: Optional skills to include.
|
|
131
|
+
media: Optional list of local file paths for images/media.
|
|
132
|
+
channel: Current channel (e.g. telegram).
|
|
133
|
+
chat_id: Current chat/user ID.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of messages including system prompt.
|
|
137
|
+
"""
|
|
138
|
+
messages = []
|
|
139
|
+
|
|
140
|
+
# System prompt
|
|
141
|
+
system_prompt = self.build_system_prompt(skill_names)
|
|
142
|
+
if channel and chat_id:
|
|
143
|
+
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
|
|
144
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
145
|
+
|
|
146
|
+
# History
|
|
147
|
+
messages.extend(history)
|
|
148
|
+
|
|
149
|
+
# Current message (with optional image attachments)
|
|
150
|
+
user_content = self._build_user_content(current_message, media)
|
|
151
|
+
messages.append({"role": "user", "content": user_content})
|
|
152
|
+
|
|
153
|
+
return messages
|
|
154
|
+
|
|
155
|
+
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
|
|
156
|
+
"""Build user message content with optional base64-encoded images."""
|
|
157
|
+
if not media:
|
|
158
|
+
return text
|
|
159
|
+
|
|
160
|
+
images = []
|
|
161
|
+
for path in media:
|
|
162
|
+
p = Path(path)
|
|
163
|
+
mime, _ = mimetypes.guess_type(path)
|
|
164
|
+
if not p.is_file() or not mime or not mime.startswith("image/"):
|
|
165
|
+
continue
|
|
166
|
+
b64 = base64.b64encode(p.read_bytes()).decode()
|
|
167
|
+
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
|
|
168
|
+
|
|
169
|
+
if not images:
|
|
170
|
+
return text
|
|
171
|
+
return images + [{"type": "text", "text": text}]
|
|
172
|
+
|
|
173
|
+
def add_tool_result(
|
|
174
|
+
self,
|
|
175
|
+
messages: list[dict[str, Any]],
|
|
176
|
+
tool_call_id: str,
|
|
177
|
+
tool_name: str,
|
|
178
|
+
result: str
|
|
179
|
+
) -> list[dict[str, Any]]:
|
|
180
|
+
"""
|
|
181
|
+
Add a tool result to the message list.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
messages: Current message list.
|
|
185
|
+
tool_call_id: ID of the tool call.
|
|
186
|
+
tool_name: Name of the tool.
|
|
187
|
+
result: Tool execution result.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Updated message list.
|
|
191
|
+
"""
|
|
192
|
+
messages.append({
|
|
193
|
+
"role": "tool",
|
|
194
|
+
"tool_call_id": tool_call_id,
|
|
195
|
+
"name": tool_name,
|
|
196
|
+
"content": result
|
|
197
|
+
})
|
|
198
|
+
return messages
|
|
199
|
+
|
|
200
|
+
def add_assistant_message(
|
|
201
|
+
self,
|
|
202
|
+
messages: list[dict[str, Any]],
|
|
203
|
+
content: str | None,
|
|
204
|
+
tool_calls: list[dict[str, Any]] | None = None
|
|
205
|
+
) -> list[dict[str, Any]]:
|
|
206
|
+
"""
|
|
207
|
+
Add an assistant message to the message list.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
messages: Current message list.
|
|
211
|
+
content: Message content.
|
|
212
|
+
tool_calls: Optional tool calls.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Updated message list.
|
|
216
|
+
"""
|
|
217
|
+
msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
|
|
218
|
+
|
|
219
|
+
if tool_calls:
|
|
220
|
+
msg["tool_calls"] = tool_calls
|
|
221
|
+
|
|
222
|
+
messages.append(msg)
|
|
223
|
+
return messages
|
ragnarbot/agent/loop.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Agent loop: the core processing engine."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from ragnarbot.bus.events import InboundMessage, OutboundMessage
|
|
11
|
+
from ragnarbot.bus.queue import MessageBus
|
|
12
|
+
from ragnarbot.providers.base import LLMProvider
|
|
13
|
+
from ragnarbot.agent.context import ContextBuilder
|
|
14
|
+
from ragnarbot.agent.tools.registry import ToolRegistry
|
|
15
|
+
from ragnarbot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
|
|
16
|
+
from ragnarbot.agent.tools.shell import ExecTool
|
|
17
|
+
from ragnarbot.agent.tools.web import WebSearchTool, WebFetchTool
|
|
18
|
+
from ragnarbot.agent.tools.message import MessageTool
|
|
19
|
+
from ragnarbot.agent.tools.spawn import SpawnTool
|
|
20
|
+
from ragnarbot.agent.tools.cron import CronTool
|
|
21
|
+
from ragnarbot.agent.subagent import SubagentManager
|
|
22
|
+
from ragnarbot.session.manager import SessionManager
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentLoop:
|
|
26
|
+
"""
|
|
27
|
+
The agent loop is the core processing engine.
|
|
28
|
+
|
|
29
|
+
It:
|
|
30
|
+
1. Receives messages from the bus
|
|
31
|
+
2. Builds context with history, memory, skills
|
|
32
|
+
3. Calls the LLM
|
|
33
|
+
4. Executes tool calls
|
|
34
|
+
5. Sends responses back
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
bus: MessageBus,
|
|
40
|
+
provider: LLMProvider,
|
|
41
|
+
workspace: Path,
|
|
42
|
+
model: str | None = None,
|
|
43
|
+
max_iterations: int = 20,
|
|
44
|
+
brave_api_key: str | None = None,
|
|
45
|
+
exec_config: "ExecToolConfig | None" = None,
|
|
46
|
+
cron_service: "CronService | None" = None,
|
|
47
|
+
):
|
|
48
|
+
from ragnarbot.config.schema import ExecToolConfig
|
|
49
|
+
from ragnarbot.cron.service import CronService
|
|
50
|
+
self.bus = bus
|
|
51
|
+
self.provider = provider
|
|
52
|
+
self.workspace = workspace
|
|
53
|
+
self.model = model or provider.get_default_model()
|
|
54
|
+
self.max_iterations = max_iterations
|
|
55
|
+
self.brave_api_key = brave_api_key
|
|
56
|
+
self.exec_config = exec_config or ExecToolConfig()
|
|
57
|
+
self.cron_service = cron_service
|
|
58
|
+
|
|
59
|
+
self.context = ContextBuilder(workspace)
|
|
60
|
+
self.sessions = SessionManager(workspace)
|
|
61
|
+
self.tools = ToolRegistry()
|
|
62
|
+
self.subagents = SubagentManager(
|
|
63
|
+
provider=provider,
|
|
64
|
+
workspace=workspace,
|
|
65
|
+
bus=bus,
|
|
66
|
+
model=self.model,
|
|
67
|
+
brave_api_key=brave_api_key,
|
|
68
|
+
exec_config=self.exec_config,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self._running = False
|
|
72
|
+
self._register_default_tools()
|
|
73
|
+
|
|
74
|
+
def _register_default_tools(self) -> None:
|
|
75
|
+
"""Register the default set of tools."""
|
|
76
|
+
# File tools
|
|
77
|
+
self.tools.register(ReadFileTool())
|
|
78
|
+
self.tools.register(WriteFileTool())
|
|
79
|
+
self.tools.register(EditFileTool())
|
|
80
|
+
self.tools.register(ListDirTool())
|
|
81
|
+
|
|
82
|
+
# Shell tool
|
|
83
|
+
self.tools.register(ExecTool(
|
|
84
|
+
working_dir=str(self.workspace),
|
|
85
|
+
timeout=self.exec_config.timeout,
|
|
86
|
+
restrict_to_workspace=self.exec_config.restrict_to_workspace,
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
# Web tools
|
|
90
|
+
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
|
91
|
+
self.tools.register(WebFetchTool())
|
|
92
|
+
|
|
93
|
+
# Message tool
|
|
94
|
+
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
|
|
95
|
+
self.tools.register(message_tool)
|
|
96
|
+
|
|
97
|
+
# Spawn tool (for subagents)
|
|
98
|
+
spawn_tool = SpawnTool(manager=self.subagents)
|
|
99
|
+
self.tools.register(spawn_tool)
|
|
100
|
+
|
|
101
|
+
# Cron tool (for scheduling)
|
|
102
|
+
if self.cron_service:
|
|
103
|
+
self.tools.register(CronTool(self.cron_service))
|
|
104
|
+
|
|
105
|
+
async def run(self) -> None:
|
|
106
|
+
"""Run the agent loop, processing messages from the bus."""
|
|
107
|
+
self._running = True
|
|
108
|
+
logger.info("Agent loop started")
|
|
109
|
+
|
|
110
|
+
while self._running:
|
|
111
|
+
try:
|
|
112
|
+
# Wait for next message
|
|
113
|
+
msg = await asyncio.wait_for(
|
|
114
|
+
self.bus.consume_inbound(),
|
|
115
|
+
timeout=1.0
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Process it
|
|
119
|
+
try:
|
|
120
|
+
response = await self._process_message(msg)
|
|
121
|
+
if response:
|
|
122
|
+
await self.bus.publish_outbound(response)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.error(f"Error processing message: {e}")
|
|
125
|
+
# Send error response
|
|
126
|
+
await self.bus.publish_outbound(OutboundMessage(
|
|
127
|
+
channel=msg.channel,
|
|
128
|
+
chat_id=msg.chat_id,
|
|
129
|
+
content=f"Sorry, I encountered an error: {str(e)}"
|
|
130
|
+
))
|
|
131
|
+
except asyncio.TimeoutError:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
def stop(self) -> None:
|
|
135
|
+
"""Stop the agent loop."""
|
|
136
|
+
self._running = False
|
|
137
|
+
logger.info("Agent loop stopping")
|
|
138
|
+
|
|
139
|
+
async def _process_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
|
140
|
+
"""
|
|
141
|
+
Process a single inbound message.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
msg: The inbound message to process.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The response message, or None if no response needed.
|
|
148
|
+
"""
|
|
149
|
+
# Handle system messages (subagent announces)
|
|
150
|
+
# The chat_id contains the original "channel:chat_id" to route back to
|
|
151
|
+
if msg.channel == "system":
|
|
152
|
+
return await self._process_system_message(msg)
|
|
153
|
+
|
|
154
|
+
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}")
|
|
155
|
+
|
|
156
|
+
# Get or create session
|
|
157
|
+
session = self.sessions.get_or_create(msg.session_key)
|
|
158
|
+
|
|
159
|
+
# Update tool contexts
|
|
160
|
+
message_tool = self.tools.get("message")
|
|
161
|
+
if isinstance(message_tool, MessageTool):
|
|
162
|
+
message_tool.set_context(msg.channel, msg.chat_id)
|
|
163
|
+
|
|
164
|
+
spawn_tool = self.tools.get("spawn")
|
|
165
|
+
if isinstance(spawn_tool, SpawnTool):
|
|
166
|
+
spawn_tool.set_context(msg.channel, msg.chat_id)
|
|
167
|
+
|
|
168
|
+
cron_tool = self.tools.get("cron")
|
|
169
|
+
if isinstance(cron_tool, CronTool):
|
|
170
|
+
cron_tool.set_context(msg.channel, msg.chat_id)
|
|
171
|
+
|
|
172
|
+
# Build initial messages (use get_history for LLM-formatted messages)
|
|
173
|
+
messages = self.context.build_messages(
|
|
174
|
+
history=session.get_history(),
|
|
175
|
+
current_message=msg.content,
|
|
176
|
+
media=msg.media if msg.media else None,
|
|
177
|
+
channel=msg.channel,
|
|
178
|
+
chat_id=msg.chat_id,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Agent loop
|
|
182
|
+
iteration = 0
|
|
183
|
+
final_content = None
|
|
184
|
+
|
|
185
|
+
while iteration < self.max_iterations:
|
|
186
|
+
iteration += 1
|
|
187
|
+
|
|
188
|
+
# Call LLM
|
|
189
|
+
response = await self.provider.chat(
|
|
190
|
+
messages=messages,
|
|
191
|
+
tools=self.tools.get_definitions(),
|
|
192
|
+
model=self.model
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Handle tool calls
|
|
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) # Must be JSON string
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for tc in response.tool_calls
|
|
208
|
+
]
|
|
209
|
+
messages = self.context.add_assistant_message(
|
|
210
|
+
messages, response.content, tool_call_dicts
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Execute tools
|
|
214
|
+
for tool_call in response.tool_calls:
|
|
215
|
+
args_str = json.dumps(tool_call.arguments)
|
|
216
|
+
logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}")
|
|
217
|
+
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
|
218
|
+
messages = self.context.add_tool_result(
|
|
219
|
+
messages, tool_call.id, tool_call.name, result
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
# No tool calls, we're done
|
|
223
|
+
final_content = response.content
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
if final_content is None:
|
|
227
|
+
final_content = "I've completed processing but have no response to give."
|
|
228
|
+
|
|
229
|
+
# Save to session
|
|
230
|
+
session.add_message("user", msg.content)
|
|
231
|
+
session.add_message("assistant", final_content)
|
|
232
|
+
self.sessions.save(session)
|
|
233
|
+
|
|
234
|
+
return OutboundMessage(
|
|
235
|
+
channel=msg.channel,
|
|
236
|
+
chat_id=msg.chat_id,
|
|
237
|
+
content=final_content
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
|
241
|
+
"""
|
|
242
|
+
Process a system message (e.g., subagent announce).
|
|
243
|
+
|
|
244
|
+
The chat_id field contains "original_channel:original_chat_id" to route
|
|
245
|
+
the response back to the correct destination.
|
|
246
|
+
"""
|
|
247
|
+
logger.info(f"Processing system message from {msg.sender_id}")
|
|
248
|
+
|
|
249
|
+
# Parse origin from chat_id (format: "channel:chat_id")
|
|
250
|
+
if ":" in msg.chat_id:
|
|
251
|
+
parts = msg.chat_id.split(":", 1)
|
|
252
|
+
origin_channel = parts[0]
|
|
253
|
+
origin_chat_id = parts[1]
|
|
254
|
+
else:
|
|
255
|
+
# Fallback
|
|
256
|
+
origin_channel = "cli"
|
|
257
|
+
origin_chat_id = msg.chat_id
|
|
258
|
+
|
|
259
|
+
# Use the origin session for context
|
|
260
|
+
session_key = f"{origin_channel}:{origin_chat_id}"
|
|
261
|
+
session = self.sessions.get_or_create(session_key)
|
|
262
|
+
|
|
263
|
+
# Update tool contexts
|
|
264
|
+
message_tool = self.tools.get("message")
|
|
265
|
+
if isinstance(message_tool, MessageTool):
|
|
266
|
+
message_tool.set_context(origin_channel, origin_chat_id)
|
|
267
|
+
|
|
268
|
+
spawn_tool = self.tools.get("spawn")
|
|
269
|
+
if isinstance(spawn_tool, SpawnTool):
|
|
270
|
+
spawn_tool.set_context(origin_channel, origin_chat_id)
|
|
271
|
+
|
|
272
|
+
cron_tool = self.tools.get("cron")
|
|
273
|
+
if isinstance(cron_tool, CronTool):
|
|
274
|
+
cron_tool.set_context(origin_channel, origin_chat_id)
|
|
275
|
+
|
|
276
|
+
# Build messages with the announce content
|
|
277
|
+
messages = self.context.build_messages(
|
|
278
|
+
history=session.get_history(),
|
|
279
|
+
current_message=msg.content,
|
|
280
|
+
channel=origin_channel,
|
|
281
|
+
chat_id=origin_chat_id,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Agent loop (limited for announce handling)
|
|
285
|
+
iteration = 0
|
|
286
|
+
final_content = None
|
|
287
|
+
|
|
288
|
+
while iteration < self.max_iterations:
|
|
289
|
+
iteration += 1
|
|
290
|
+
|
|
291
|
+
response = await self.provider.chat(
|
|
292
|
+
messages=messages,
|
|
293
|
+
tools=self.tools.get_definitions(),
|
|
294
|
+
model=self.model
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if response.has_tool_calls:
|
|
298
|
+
tool_call_dicts = [
|
|
299
|
+
{
|
|
300
|
+
"id": tc.id,
|
|
301
|
+
"type": "function",
|
|
302
|
+
"function": {
|
|
303
|
+
"name": tc.name,
|
|
304
|
+
"arguments": json.dumps(tc.arguments)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for tc in response.tool_calls
|
|
308
|
+
]
|
|
309
|
+
messages = self.context.add_assistant_message(
|
|
310
|
+
messages, response.content, tool_call_dicts
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
for tool_call in response.tool_calls:
|
|
314
|
+
args_str = json.dumps(tool_call.arguments)
|
|
315
|
+
logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}")
|
|
316
|
+
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
|
317
|
+
messages = self.context.add_tool_result(
|
|
318
|
+
messages, tool_call.id, tool_call.name, result
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
final_content = response.content
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
if final_content is None:
|
|
325
|
+
final_content = "Background task completed."
|
|
326
|
+
|
|
327
|
+
# Save to session (mark as system message in history)
|
|
328
|
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
|
329
|
+
session.add_message("assistant", final_content)
|
|
330
|
+
self.sessions.save(session)
|
|
331
|
+
|
|
332
|
+
return OutboundMessage(
|
|
333
|
+
channel=origin_channel,
|
|
334
|
+
chat_id=origin_chat_id,
|
|
335
|
+
content=final_content
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async def process_direct(
|
|
339
|
+
self,
|
|
340
|
+
content: str,
|
|
341
|
+
session_key: str = "cli:direct",
|
|
342
|
+
channel: str = "cli",
|
|
343
|
+
chat_id: str = "direct",
|
|
344
|
+
) -> str:
|
|
345
|
+
"""
|
|
346
|
+
Process a message directly (for CLI or cron usage).
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
content: The message content.
|
|
350
|
+
session_key: Session identifier.
|
|
351
|
+
channel: Source channel (for context).
|
|
352
|
+
chat_id: Source chat ID (for context).
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
The agent's response.
|
|
356
|
+
"""
|
|
357
|
+
msg = InboundMessage(
|
|
358
|
+
channel=channel,
|
|
359
|
+
sender_id="user",
|
|
360
|
+
chat_id=chat_id,
|
|
361
|
+
content=content
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
response = await self._process_message(msg)
|
|
365
|
+
return response.content if response else ""
|