ragnarbot-ai 0.1.0__tar.gz

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_ai-0.1.0/.gitignore +17 -0
  2. ragnarbot_ai-0.1.0/LICENSE +22 -0
  3. ragnarbot_ai-0.1.0/PKG-INFO +28 -0
  4. ragnarbot_ai-0.1.0/README.md +183 -0
  5. ragnarbot_ai-0.1.0/pyproject.toml +77 -0
  6. ragnarbot_ai-0.1.0/ragnarbot/__init__.py +6 -0
  7. ragnarbot_ai-0.1.0/ragnarbot/__main__.py +8 -0
  8. ragnarbot_ai-0.1.0/ragnarbot/agent/__init__.py +8 -0
  9. ragnarbot_ai-0.1.0/ragnarbot/agent/context.py +223 -0
  10. ragnarbot_ai-0.1.0/ragnarbot/agent/loop.py +365 -0
  11. ragnarbot_ai-0.1.0/ragnarbot/agent/memory.py +109 -0
  12. ragnarbot_ai-0.1.0/ragnarbot/agent/skills.py +228 -0
  13. ragnarbot_ai-0.1.0/ragnarbot/agent/subagent.py +241 -0
  14. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/__init__.py +6 -0
  15. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/base.py +102 -0
  16. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/cron.py +114 -0
  17. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/filesystem.py +191 -0
  18. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/message.py +86 -0
  19. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/registry.py +73 -0
  20. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/shell.py +141 -0
  21. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/spawn.py +65 -0
  22. ragnarbot_ai-0.1.0/ragnarbot/agent/tools/web.py +163 -0
  23. ragnarbot_ai-0.1.0/ragnarbot/bus/__init__.py +6 -0
  24. ragnarbot_ai-0.1.0/ragnarbot/bus/events.py +37 -0
  25. ragnarbot_ai-0.1.0/ragnarbot/bus/queue.py +81 -0
  26. ragnarbot_ai-0.1.0/ragnarbot/channels/__init__.py +6 -0
  27. ragnarbot_ai-0.1.0/ragnarbot/channels/base.py +121 -0
  28. ragnarbot_ai-0.1.0/ragnarbot/channels/manager.py +129 -0
  29. ragnarbot_ai-0.1.0/ragnarbot/channels/telegram.py +302 -0
  30. ragnarbot_ai-0.1.0/ragnarbot/cli/__init__.py +1 -0
  31. ragnarbot_ai-0.1.0/ragnarbot/cli/commands.py +568 -0
  32. ragnarbot_ai-0.1.0/ragnarbot/config/__init__.py +6 -0
  33. ragnarbot_ai-0.1.0/ragnarbot/config/loader.py +95 -0
  34. ragnarbot_ai-0.1.0/ragnarbot/config/schema.py +114 -0
  35. ragnarbot_ai-0.1.0/ragnarbot/cron/__init__.py +6 -0
  36. ragnarbot_ai-0.1.0/ragnarbot/cron/service.py +346 -0
  37. ragnarbot_ai-0.1.0/ragnarbot/cron/types.py +59 -0
  38. ragnarbot_ai-0.1.0/ragnarbot/heartbeat/__init__.py +5 -0
  39. ragnarbot_ai-0.1.0/ragnarbot/heartbeat/service.py +130 -0
  40. ragnarbot_ai-0.1.0/ragnarbot/providers/__init__.py +6 -0
  41. ragnarbot_ai-0.1.0/ragnarbot/providers/base.py +69 -0
  42. ragnarbot_ai-0.1.0/ragnarbot/providers/litellm_provider.py +135 -0
  43. ragnarbot_ai-0.1.0/ragnarbot/providers/transcription.py +67 -0
  44. ragnarbot_ai-0.1.0/ragnarbot/session/__init__.py +5 -0
  45. ragnarbot_ai-0.1.0/ragnarbot/session/manager.py +202 -0
  46. ragnarbot_ai-0.1.0/ragnarbot/skills/README.md +24 -0
  47. ragnarbot_ai-0.1.0/ragnarbot/skills/cron/SKILL.md +40 -0
  48. ragnarbot_ai-0.1.0/ragnarbot/skills/github/SKILL.md +48 -0
  49. ragnarbot_ai-0.1.0/ragnarbot/skills/skill-creator/SKILL.md +371 -0
  50. ragnarbot_ai-0.1.0/ragnarbot/skills/summarize/SKILL.md +67 -0
  51. ragnarbot_ai-0.1.0/ragnarbot/skills/tmux/SKILL.md +121 -0
  52. ragnarbot_ai-0.1.0/ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
  53. ragnarbot_ai-0.1.0/ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
  54. ragnarbot_ai-0.1.0/ragnarbot/skills/weather/SKILL.md +49 -0
  55. ragnarbot_ai-0.1.0/ragnarbot/utils/__init__.py +5 -0
  56. ragnarbot_ai-0.1.0/ragnarbot/utils/helpers.py +91 -0
@@ -0,0 +1,17 @@
1
+ .assets
2
+ .env
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ docs/
7
+ *.egg-info/
8
+ *.egg
9
+ *.pyc
10
+ *.pyo
11
+ *.pyd
12
+ *.pyw
13
+ *.pyz
14
+ *.pywz
15
+ *.pyzz
16
+ .venv/
17
+ __pycache__/
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 nanobot contributors
4
+ Copyright (c) 2025 BlckLvls
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: ragnarbot-ai
3
+ Version: 0.1.0
4
+ Summary: A lightweight personal AI assistant framework
5
+ Author: BlckLvls
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: agent,ai,chatbot
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: croniter>=2.0.0
16
+ Requires-Dist: httpx>=0.25.0
17
+ Requires-Dist: litellm>=1.0.0
18
+ Requires-Dist: loguru>=0.7.0
19
+ Requires-Dist: pydantic-settings>=2.0.0
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: python-telegram-bot>=21.0
22
+ Requires-Dist: readability-lxml>=0.8.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: typer>=0.9.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
@@ -0,0 +1,183 @@
1
+ <div align="center">
2
+ <img src="ragnarbot_logo.jpg" alt="ragnarbot" width="500">
3
+ </div>
4
+
5
+ <p align="center">
6
+ <em>Async-first personal AI assistant. Lightweight, extensible, runs anywhere.</em>
7
+ </p>
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ Install [uv](https://github.com/astral-sh/uv) if you don't have it:
14
+
15
+ ```bash
16
+ curl -LsSf https://astral.sh/uv/install.sh | sh
17
+ ```
18
+
19
+ Then install ragnarbot:
20
+
21
+ ```bash
22
+ uv tool install ragnarbot-ai
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ **1. Initialize**
28
+
29
+ ```bash
30
+ ragnarbot onboard
31
+ ```
32
+
33
+ **2. Configure**
34
+
35
+ Add your API key to `~/.ragnarbot/config.json`:
36
+
37
+ ```json
38
+ {
39
+ "providers": {
40
+ "anthropic": {
41
+ "apiKey": "sk-ant-xxx"
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ Get an API key from [Anthropic](https://console.anthropic.com/keys), [OpenAI](https://platform.openai.com/api-keys), or [Google AI Studio](https://aistudio.google.com/apikey).
48
+
49
+ **3. Chat**
50
+
51
+ ```bash
52
+ ragnarbot agent -m "What can you do?"
53
+ ```
54
+
55
+ Or start an interactive session:
56
+
57
+ ```bash
58
+ ragnarbot agent
59
+ ```
60
+
61
+ ## Telegram
62
+
63
+ ragnarbot is designed to work through Telegram. Set up a bot, point it at your instance, and you have a personal AI assistant in your pocket.
64
+
65
+ **Create a bot**
66
+
67
+ Open Telegram, find `@BotFather`, send `/newbot`, and follow the prompts. Copy the token.
68
+
69
+ **Get your user ID**
70
+
71
+ Message `@userinfobot` on Telegram to get your numeric user ID.
72
+
73
+ **Add to config**
74
+
75
+ ```json
76
+ {
77
+ "channels": {
78
+ "telegram": {
79
+ "enabled": true,
80
+ "token": "YOUR_BOT_TOKEN",
81
+ "allowFrom": ["YOUR_USER_ID"]
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ **Run the gateway**
88
+
89
+ ```bash
90
+ ragnarbot gateway
91
+ ```
92
+
93
+ Your bot is live. Message it from Telegram.
94
+
95
+ ## Configuration
96
+
97
+ All configuration lives in `~/.ragnarbot/config.json`. Keys are camelCase.
98
+
99
+ ### Providers
100
+
101
+ Only one provider is required. ragnarbot uses [LiteLLM](https://github.com/BerriAI/litellm) under the hood, so any model string LiteLLM supports will work.
102
+
103
+ | Provider | Config key | Models | API Key |
104
+ |----------|-----------|--------|---------|
105
+ | Anthropic | `providers.anthropic` | `anthropic/claude-*` | [console.anthropic.com](https://console.anthropic.com) |
106
+ | OpenAI | `providers.openai` | `openai/gpt-*` | [platform.openai.com](https://platform.openai.com) |
107
+ | Gemini | `providers.gemini` | `gemini/*` | [aistudio.google.com](https://aistudio.google.com) |
108
+
109
+ Set the default model under `agents.defaults.model`.
110
+
111
+ ### Transcription
112
+
113
+ Voice messages in Telegram are automatically transcribed when a Groq API key is configured. Groq provides free access to Whisper.
114
+
115
+ ```json
116
+ {
117
+ "transcription": {
118
+ "apiKey": "gsk_xxx"
119
+ }
120
+ }
121
+ ```
122
+
123
+ Get a key at [console.groq.com](https://console.groq.com).
124
+
125
+ ### Web Search
126
+
127
+ ragnarbot can search the web via the Brave Search API.
128
+
129
+ ```json
130
+ {
131
+ "tools": {
132
+ "web": {
133
+ "search": {
134
+ "apiKey": "BSA-xxx"
135
+ }
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ Get a key at [brave.com/search/api](https://brave.com/search/api/).
142
+
143
+ ## CLI Reference
144
+
145
+ | Command | Description |
146
+ |---------|-------------|
147
+ | `ragnarbot onboard` | Initialize config and workspace |
148
+ | `ragnarbot agent -m "..."` | Send a single message |
149
+ | `ragnarbot agent` | Interactive chat session |
150
+ | `ragnarbot gateway` | Start gateway (Telegram + cron + heartbeat) |
151
+ | `ragnarbot status` | Show configuration status |
152
+ | `ragnarbot channels status` | Show channel status |
153
+ | `ragnarbot cron list` | List scheduled jobs |
154
+ | `ragnarbot cron add` | Add a scheduled job |
155
+ | `ragnarbot cron remove <id>` | Remove a scheduled job |
156
+
157
+ ## Architecture
158
+
159
+ ragnarbot is async-first and built around a simple message-passing architecture:
160
+
161
+ ```
162
+ Telegram --> MessageBus --> AgentLoop --> LLM --> Tools
163
+ \ /
164
+ \-- Sessions --/
165
+ ```
166
+
167
+ **MessageBus** decouples channels from agent logic using async queues. Channels publish inbound messages; the agent publishes responses back.
168
+
169
+ **AgentLoop** consumes messages, builds context (system prompt, conversation history, skills), calls the LLM, and executes tool calls in a loop. It also exposes a `process_direct()` path for CLI usage and cron jobs.
170
+
171
+ **Tools** are registered in a `ToolRegistry` and exposed to the LLM as OpenAI-compatible function calls. Built-in tools include file operations, shell execution, web search, cron management, and sub-agent spawning.
172
+
173
+ **Skills** are markdown files with YAML frontmatter. Skills marked `always: true` are included in every prompt; others appear as summaries the agent can load on demand.
174
+
175
+ **Sessions** persist conversation history as JSONL files under `~/.ragnarbot/sessions/`.
176
+
177
+ ## License
178
+
179
+ MIT
180
+
181
+ ---
182
+
183
+ <sub>Based on [nanobot](https://github.com/HKUDS/nanobot)</sub>
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "ragnarbot-ai"
3
+ version = "0.1.0"
4
+ description = "A lightweight personal AI assistant framework"
5
+ requires-python = ">=3.11"
6
+ license = {text = "MIT"}
7
+ authors = [
8
+ {name = "BlckLvls"}
9
+ ]
10
+ keywords = ["ai", "agent", "chatbot"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ ]
18
+
19
+ dependencies = [
20
+ "typer>=0.9.0",
21
+ "litellm>=1.0.0",
22
+ "pydantic>=2.0.0",
23
+ "pydantic-settings>=2.0.0",
24
+ "httpx>=0.25.0",
25
+ "loguru>=0.7.0",
26
+ "readability-lxml>=0.8.0",
27
+ "rich>=13.0.0",
28
+ "croniter>=2.0.0",
29
+ "python-telegram-bot>=21.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.0.0",
35
+ "pytest-asyncio>=0.21.0",
36
+ "ruff>=0.1.0",
37
+ ]
38
+
39
+ [project.scripts]
40
+ ragnarbot = "ragnarbot.cli.commands:app"
41
+
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["ragnarbot"]
48
+
49
+ [tool.hatch.build.targets.wheel.sources]
50
+ "ragnarbot" = "ragnarbot"
51
+
52
+ # Include non-Python files in skills
53
+ [tool.hatch.build]
54
+ include = [
55
+ "ragnarbot/**/*.py",
56
+ "ragnarbot/skills/**/*.md",
57
+ "ragnarbot/skills/**/*.sh",
58
+ ]
59
+
60
+ [tool.hatch.build.targets.sdist]
61
+ include = [
62
+ "ragnarbot/",
63
+ "README.md",
64
+ "LICENSE",
65
+ ]
66
+
67
+ [tool.ruff]
68
+ line-length = 100
69
+ target-version = "py311"
70
+
71
+ [tool.ruff.lint]
72
+ select = ["E", "F", "I", "N", "W"]
73
+ ignore = ["E501"]
74
+
75
+ [tool.pytest.ini_options]
76
+ asyncio_mode = "auto"
77
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ """
2
+ ragnarbot - A lightweight AI agent framework
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+ __logo__ = "🤖"
@@ -0,0 +1,8 @@
1
+ """
2
+ Entry point for running ragnarbot as a module: python -m ragnarbot
3
+ """
4
+
5
+ from ragnarbot.cli.commands import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -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