pythonclaw 0.2.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.
- pythonclaw/__init__.py +17 -0
- pythonclaw/__main__.py +6 -0
- pythonclaw/channels/discord_bot.py +231 -0
- pythonclaw/channels/telegram_bot.py +236 -0
- pythonclaw/config.py +190 -0
- pythonclaw/core/__init__.py +25 -0
- pythonclaw/core/agent.py +773 -0
- pythonclaw/core/compaction.py +220 -0
- pythonclaw/core/knowledge/rag.py +93 -0
- pythonclaw/core/llm/anthropic_client.py +107 -0
- pythonclaw/core/llm/base.py +26 -0
- pythonclaw/core/llm/gemini_client.py +139 -0
- pythonclaw/core/llm/openai_compatible.py +39 -0
- pythonclaw/core/llm/response.py +57 -0
- pythonclaw/core/memory/manager.py +120 -0
- pythonclaw/core/memory/storage.py +164 -0
- pythonclaw/core/persistent_agent.py +103 -0
- pythonclaw/core/retrieval/__init__.py +6 -0
- pythonclaw/core/retrieval/chunker.py +78 -0
- pythonclaw/core/retrieval/dense.py +152 -0
- pythonclaw/core/retrieval/fusion.py +51 -0
- pythonclaw/core/retrieval/reranker.py +112 -0
- pythonclaw/core/retrieval/retriever.py +166 -0
- pythonclaw/core/retrieval/sparse.py +69 -0
- pythonclaw/core/session_store.py +269 -0
- pythonclaw/core/skill_loader.py +322 -0
- pythonclaw/core/skillhub.py +290 -0
- pythonclaw/core/tools.py +622 -0
- pythonclaw/core/utils.py +64 -0
- pythonclaw/daemon.py +221 -0
- pythonclaw/init.py +61 -0
- pythonclaw/main.py +489 -0
- pythonclaw/onboard.py +290 -0
- pythonclaw/scheduler/cron.py +310 -0
- pythonclaw/scheduler/heartbeat.py +178 -0
- pythonclaw/server.py +145 -0
- pythonclaw/session_manager.py +104 -0
- pythonclaw/templates/persona/demo_persona.md +2 -0
- pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
- pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
- pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/communication/email/send_email.py +88 -0
- pythonclaw/templates/skills/data/CATEGORY.md +4 -0
- pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
- pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
- pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
- pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
- pythonclaw/templates/skills/data/news/SKILL.md +39 -0
- pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/news/search_news.py +57 -0
- pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
- pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
- pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
- pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
- pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
- pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/weather/weather.py +142 -0
- pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
- pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
- pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
- pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
- pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
- pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
- pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/github/gh.py +165 -0
- pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
- pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/http_request/request.py +90 -0
- pythonclaw/templates/skills/google/CATEGORY.md +4 -0
- pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
- pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
- pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
- pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
- pythonclaw/templates/skills/system/CATEGORY.md +4 -0
- pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
- pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
- pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
- pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
- pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
- pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
- pythonclaw/templates/skills/system/random/SKILL.md +33 -0
- pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/random/random_util.py +45 -0
- pythonclaw/templates/skills/system/time/SKILL.md +33 -0
- pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/time/time_util.py +81 -0
- pythonclaw/templates/skills/text/CATEGORY.md +4 -0
- pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
- pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/text/translator/translate.py +66 -0
- pythonclaw/templates/skills/web/CATEGORY.md +4 -0
- pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
- pythonclaw/templates/soul/SOUL.md +54 -0
- pythonclaw/web/__init__.py +1 -0
- pythonclaw/web/app.py +585 -0
- pythonclaw/web/static/favicon.png +0 -0
- pythonclaw/web/static/index.html +1318 -0
- pythonclaw/web/static/logo.png +0 -0
- pythonclaw-0.2.0.dist-info/METADATA +410 -0
- pythonclaw-0.2.0.dist-info/RECORD +112 -0
- pythonclaw-0.2.0.dist-info/WHEEL +5 -0
- pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
- pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
- pythonclaw-0.2.0.dist-info/top_level.txt +1 -0
pythonclaw/core/agent.py
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent — the core reasoning loop for PythonClaw.
|
|
3
|
+
|
|
4
|
+
Responsibilities
|
|
5
|
+
----------------
|
|
6
|
+
- Maintain conversation history (messages list)
|
|
7
|
+
- Build the per-session tool set and dispatch tool calls
|
|
8
|
+
- Three-tier progressive skill loading (catalog → instructions → resources)
|
|
9
|
+
- Trigger context compaction (auto or manual)
|
|
10
|
+
- Interface with memory (MemoryManager) and knowledge (KnowledgeRAG)
|
|
11
|
+
|
|
12
|
+
What this class is NOT responsible for
|
|
13
|
+
---------------------------------------
|
|
14
|
+
- Session lifecycle (→ SessionManager)
|
|
15
|
+
- Persistence across restarts (→ PersistentAgent subclass)
|
|
16
|
+
- I/O channels like Telegram (→ channels/)
|
|
17
|
+
- Scheduling (→ scheduler/)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
|
|
29
|
+
from .. import config
|
|
30
|
+
from .compaction import (
|
|
31
|
+
DEFAULT_AUTO_THRESHOLD_TOKENS,
|
|
32
|
+
DEFAULT_RECENT_KEEP,
|
|
33
|
+
compact as _do_compact,
|
|
34
|
+
estimate_tokens,
|
|
35
|
+
)
|
|
36
|
+
from .knowledge.rag import KnowledgeRAG
|
|
37
|
+
from .llm.base import LLMProvider
|
|
38
|
+
from .memory.manager import MemoryManager
|
|
39
|
+
from .skill_loader import SkillRegistry
|
|
40
|
+
from .tools import (
|
|
41
|
+
AVAILABLE_TOOLS,
|
|
42
|
+
CRON_TOOLS,
|
|
43
|
+
KNOWLEDGE_TOOL,
|
|
44
|
+
MEMORY_TOOLS,
|
|
45
|
+
META_SKILL_TOOLS,
|
|
46
|
+
PRIMITIVE_TOOLS,
|
|
47
|
+
SKILL_TOOLS,
|
|
48
|
+
WEB_SEARCH_TOOL,
|
|
49
|
+
configure_venv,
|
|
50
|
+
set_sandbox,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_text_dir_or_file(path: str | None, label: str = "File") -> str:
|
|
57
|
+
"""
|
|
58
|
+
Load text from a single file or from all .md/.txt files in a directory.
|
|
59
|
+
Returns an empty string if *path* is None or does not exist.
|
|
60
|
+
"""
|
|
61
|
+
if not path or not os.path.exists(path):
|
|
62
|
+
return ""
|
|
63
|
+
if os.path.isfile(path):
|
|
64
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
65
|
+
return f.read()
|
|
66
|
+
if os.path.isdir(path):
|
|
67
|
+
parts = []
|
|
68
|
+
for filename in sorted(os.listdir(path)):
|
|
69
|
+
if filename.lower().endswith((".md", ".txt")):
|
|
70
|
+
with open(os.path.join(path, filename), "r", encoding="utf-8") as f:
|
|
71
|
+
parts.append(f"\n\n--- {label}: {filename} ---\n" + f.read())
|
|
72
|
+
return "".join(parts)
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_DETAIL_LOG_DIR = os.path.join("context", "logs")
|
|
77
|
+
_DETAIL_LOG_FILE = os.path.join(_DETAIL_LOG_DIR, "history_detail.jsonl")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _log_detail(entry: dict) -> None:
|
|
81
|
+
"""Append a JSON line to the detailed interaction log."""
|
|
82
|
+
try:
|
|
83
|
+
os.makedirs(_DETAIL_LOG_DIR, exist_ok=True)
|
|
84
|
+
entry["ts"] = datetime.now().isoformat(timespec="milliseconds")
|
|
85
|
+
with open(_DETAIL_LOG_FILE, "a", encoding="utf-8") as f:
|
|
86
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Agent:
|
|
92
|
+
"""
|
|
93
|
+
Stateful LLM agent with tool use, three-tier skill loading, memory,
|
|
94
|
+
and compaction.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
provider : LLM backend (DeepSeek, Grok, Claude, Gemini, …)
|
|
99
|
+
memory_dir : path to memory directory (auto-detected if None)
|
|
100
|
+
skills_dirs : list of skill directory paths
|
|
101
|
+
knowledge_path : path to knowledge directory for RAG
|
|
102
|
+
persona_path : path to persona .md file or directory
|
|
103
|
+
soul_path : path to SOUL.md file or directory
|
|
104
|
+
verbose : print debug info to stdout
|
|
105
|
+
show_full_context : print the full context window before each LLM call
|
|
106
|
+
max_chat_history : max non-system messages kept in the sliding window
|
|
107
|
+
auto_compaction : trigger compaction when token estimate exceeds threshold
|
|
108
|
+
compaction_threshold : token threshold for auto-compaction
|
|
109
|
+
compaction_recent_keep : number of recent messages kept verbatim after compaction
|
|
110
|
+
cron_manager : CronScheduler instance (enables cron_add/remove/list tools)
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
MAX_TOOL_ROUNDS = 15
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
provider: LLMProvider,
|
|
118
|
+
memory_dir: str | None = None,
|
|
119
|
+
skills_dirs: list[str] | None = None,
|
|
120
|
+
knowledge_path: str | None = None,
|
|
121
|
+
persona_path: str | None = None,
|
|
122
|
+
soul_path: str | None = None,
|
|
123
|
+
verbose: bool = False,
|
|
124
|
+
show_full_context: bool = False,
|
|
125
|
+
max_chat_history: int = 10,
|
|
126
|
+
auto_compaction: bool = True,
|
|
127
|
+
compaction_threshold: int = DEFAULT_AUTO_THRESHOLD_TOKENS,
|
|
128
|
+
compaction_recent_keep: int = DEFAULT_RECENT_KEEP,
|
|
129
|
+
cron_manager=None,
|
|
130
|
+
) -> None:
|
|
131
|
+
if memory_dir is None and skills_dirs is None and knowledge_path is None and persona_path is None:
|
|
132
|
+
cwd = os.getcwd()
|
|
133
|
+
context_dir = os.path.join(cwd, "context")
|
|
134
|
+
if not os.path.exists(context_dir):
|
|
135
|
+
if verbose:
|
|
136
|
+
print(f"[Agent] Context not found. Initialising default context in {context_dir}...")
|
|
137
|
+
try:
|
|
138
|
+
from ...init import init
|
|
139
|
+
init(cwd)
|
|
140
|
+
except ImportError:
|
|
141
|
+
try:
|
|
142
|
+
from pythonclaw.init import init
|
|
143
|
+
init(cwd)
|
|
144
|
+
except ImportError:
|
|
145
|
+
print("[Agent] Warning: Could not auto-initialise context.")
|
|
146
|
+
if verbose:
|
|
147
|
+
print(f"[Agent] Using default context at {context_dir}")
|
|
148
|
+
memory_dir = os.path.join(context_dir, "memory")
|
|
149
|
+
knowledge_path = os.path.join(context_dir, "knowledge")
|
|
150
|
+
skills_dirs = [os.path.join(context_dir, "skills")]
|
|
151
|
+
persona_path = os.path.join(context_dir, "persona")
|
|
152
|
+
if soul_path is None:
|
|
153
|
+
soul_path = os.path.join(context_dir, "soul")
|
|
154
|
+
|
|
155
|
+
# Sandbox: restrict file-write tools to the project working tree
|
|
156
|
+
sandbox_root = os.getcwd()
|
|
157
|
+
set_sandbox([sandbox_root])
|
|
158
|
+
if verbose:
|
|
159
|
+
print(f"[Agent] Sandbox root: {sandbox_root}")
|
|
160
|
+
|
|
161
|
+
# Venv: ensure all subprocesses use the project's virtual environment
|
|
162
|
+
venv_path = configure_venv()
|
|
163
|
+
if verbose and venv_path:
|
|
164
|
+
print(f"[Agent] Virtual env: {venv_path}")
|
|
165
|
+
|
|
166
|
+
self.provider = provider
|
|
167
|
+
self.messages: list[dict] = []
|
|
168
|
+
self.verbose = verbose
|
|
169
|
+
self.show_full_context = show_full_context
|
|
170
|
+
self.max_chat_history = max_chat_history
|
|
171
|
+
self.auto_compaction = auto_compaction
|
|
172
|
+
self.compaction_threshold = compaction_threshold
|
|
173
|
+
self.compaction_recent_keep = compaction_recent_keep
|
|
174
|
+
self.compaction_count: int = 0
|
|
175
|
+
self._cron_manager = cron_manager
|
|
176
|
+
|
|
177
|
+
self.loaded_skill_names: set[str] = set()
|
|
178
|
+
self.pending_injections: list[str] = []
|
|
179
|
+
|
|
180
|
+
# Memory
|
|
181
|
+
mem_dir = memory_dir or config.get("memory", "dir", env="PYTHONCLAW_MEMORY_DIR")
|
|
182
|
+
self.memory = MemoryManager(mem_dir)
|
|
183
|
+
|
|
184
|
+
# Knowledge RAG (hybrid retrieval)
|
|
185
|
+
self.rag: KnowledgeRAG | None = None
|
|
186
|
+
if knowledge_path and os.path.exists(knowledge_path):
|
|
187
|
+
self.rag = KnowledgeRAG(
|
|
188
|
+
knowledge_dir=knowledge_path,
|
|
189
|
+
provider=provider,
|
|
190
|
+
use_reranker=True,
|
|
191
|
+
)
|
|
192
|
+
if verbose:
|
|
193
|
+
print(f"[Agent] KnowledgeRAG: '{knowledge_path}' ({len(self.rag)} chunks)")
|
|
194
|
+
|
|
195
|
+
# Web search (Tavily)
|
|
196
|
+
self._web_search_enabled = bool(
|
|
197
|
+
config.get("tavily", "apiKey", env="TAVILY_API_KEY")
|
|
198
|
+
)
|
|
199
|
+
if verbose and self._web_search_enabled:
|
|
200
|
+
print("[Agent] Web search enabled (Tavily)")
|
|
201
|
+
|
|
202
|
+
# Skills — always include the built-in templates + user context/skills
|
|
203
|
+
self.skills_dirs: list[str] = []
|
|
204
|
+
pkg_templates = os.path.join(
|
|
205
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
206
|
+
"templates", "skills",
|
|
207
|
+
)
|
|
208
|
+
if os.path.isdir(pkg_templates):
|
|
209
|
+
self.skills_dirs.append(pkg_templates)
|
|
210
|
+
if skills_dirs:
|
|
211
|
+
for d in ([skills_dirs] if isinstance(skills_dirs, str) else skills_dirs):
|
|
212
|
+
if d not in self.skills_dirs:
|
|
213
|
+
self.skills_dirs.append(d)
|
|
214
|
+
|
|
215
|
+
# Identity layers
|
|
216
|
+
self.soul_instruction = _load_text_dir_or_file(soul_path, label="Soul")
|
|
217
|
+
self.persona_instruction = _load_text_dir_or_file(persona_path, label="Persona")
|
|
218
|
+
|
|
219
|
+
# Detect if the user has set up their own soul/persona (not template defaults)
|
|
220
|
+
self._needs_onboarding = not self._has_user_identity(soul_path, persona_path)
|
|
221
|
+
|
|
222
|
+
if verbose and self.soul_instruction:
|
|
223
|
+
print(f"[Agent] Soul loaded ({len(self.soul_instruction)} chars)")
|
|
224
|
+
if verbose and self.persona_instruction:
|
|
225
|
+
print(f"[Agent] Persona loaded ({len(self.persona_instruction)} chars)")
|
|
226
|
+
if verbose and self._needs_onboarding:
|
|
227
|
+
print("[Agent] No user identity found — onboarding will be triggered")
|
|
228
|
+
|
|
229
|
+
self._init_system_prompt()
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _has_user_identity(soul_path: str | None, persona_path: str | None) -> bool:
|
|
233
|
+
"""Return True if the user has customized soul or persona files."""
|
|
234
|
+
for p in (soul_path, persona_path):
|
|
235
|
+
if p is None:
|
|
236
|
+
continue
|
|
237
|
+
if os.path.isdir(p):
|
|
238
|
+
for fname in os.listdir(p):
|
|
239
|
+
fpath = os.path.join(p, fname)
|
|
240
|
+
if os.path.isfile(fpath) and os.path.getsize(fpath) > 0:
|
|
241
|
+
return True
|
|
242
|
+
elif os.path.isfile(p) and os.path.getsize(p) > 0:
|
|
243
|
+
return True
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
# ── Initialisation ────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def _init_system_prompt(self) -> None:
|
|
249
|
+
"""
|
|
250
|
+
Build the initial system message with three-tier skill loading.
|
|
251
|
+
|
|
252
|
+
Level 1 (Metadata) is injected here — the full skill catalog
|
|
253
|
+
(name + description for every installed skill). This lets the
|
|
254
|
+
LLM decide when to activate a skill without any discovery calls.
|
|
255
|
+
"""
|
|
256
|
+
self._registry = SkillRegistry(skills_dirs=self.skills_dirs)
|
|
257
|
+
skill_catalog = self._registry.build_catalog()
|
|
258
|
+
|
|
259
|
+
soul_section = f"\n\n## Core Identity (Soul)\n{self.soul_instruction}" if self.soul_instruction else ""
|
|
260
|
+
persona_section = f"\n\n## Role & Persona\n{self.persona_instruction}" if self.persona_instruction else ""
|
|
261
|
+
|
|
262
|
+
web_search_section = ""
|
|
263
|
+
if self._web_search_enabled:
|
|
264
|
+
web_search_section = """
|
|
265
|
+
3. **Web Search**: `web_search` (powered by Tavily)
|
|
266
|
+
Search the web for real-time information when you need up-to-date data,
|
|
267
|
+
current events, facts you're unsure about, or technical documentation.
|
|
268
|
+
Supports topic filters (general/news/finance) and time range filters."""
|
|
269
|
+
|
|
270
|
+
system_msg = f"""You are a PythonClaw agent — an autonomous AI assistant.{soul_section}{persona_section}
|
|
271
|
+
|
|
272
|
+
You operate in a potentially sandboxed environment where you can execute code.
|
|
273
|
+
|
|
274
|
+
### Tool Capabilities
|
|
275
|
+
1. **Primitive Tools**: `run_command`, `read_file`, `write_file`, `list_files`
|
|
276
|
+
Note: `write_file` can only write within the project directory.
|
|
277
|
+
2. **Skills** (three-tier progressive loading):
|
|
278
|
+
You have access to the following skills. Each skill's description
|
|
279
|
+
tells you WHAT it does and WHEN to use it.
|
|
280
|
+
|
|
281
|
+
**Installed Skills:**
|
|
282
|
+
{skill_catalog}
|
|
283
|
+
|
|
284
|
+
To activate a skill, call `use_skill(skill_name="<name>")`.
|
|
285
|
+
This loads detailed instructions into context. After activation
|
|
286
|
+
you can use `list_skill_resources` to discover bundled scripts
|
|
287
|
+
and reference files, then `read_file` / `run_command` to use them.
|
|
288
|
+
{web_search_section}
|
|
289
|
+
|
|
290
|
+
### Skill Creation ("God Mode")
|
|
291
|
+
If NO existing skill can fulfill the user's request, you can **create a new skill
|
|
292
|
+
on the fly** using `create_skill`. This lets you:
|
|
293
|
+
- Write a SKILL.md with instructions and bundled Python/shell scripts
|
|
294
|
+
- Automatically install pip dependencies
|
|
295
|
+
- The new skill becomes immediately available via `use_skill`
|
|
296
|
+
|
|
297
|
+
Use this when the user needs a capability that doesn't exist yet. Think carefully
|
|
298
|
+
about the skill design: write clean, reusable code and a clear SKILL.md.
|
|
299
|
+
|
|
300
|
+
**CRITICAL**: Always create GENERIC, reusable skills — NEVER task-specific ones.
|
|
301
|
+
- BAD: `us_iran_news_fetcher` (only one topic), `send_bob_email` (one recipient)
|
|
302
|
+
- GOOD: `news` (any topic as parameter), `email` (any recipient as parameter)
|
|
303
|
+
All specifics (topics, recipients, URLs) must be command-line arguments.
|
|
304
|
+
|
|
305
|
+
### Workflow
|
|
306
|
+
1. User asks a question.
|
|
307
|
+
2. Match the request against the skill catalog above.
|
|
308
|
+
3. If a skill fits, call `use_skill` to load its instructions (Level 2).
|
|
309
|
+
4. Follow the injected instructions. Use `read_file` / `run_command`
|
|
310
|
+
to access bundled resources as needed (Level 3).
|
|
311
|
+
5. If NO skill fits, consider creating one with `create_skill` — write
|
|
312
|
+
the script, install dependencies, then immediately `use_skill` to
|
|
313
|
+
activate and run it.
|
|
314
|
+
|
|
315
|
+
### Performance — Be Efficient
|
|
316
|
+
**CRITICAL RULES for speed:**
|
|
317
|
+
1. **Batch tool calls**: When you need multiple independent searches or
|
|
318
|
+
tool calls, issue them ALL in a single response. They run in parallel
|
|
319
|
+
and are MUCH faster. NEVER do one search per round.
|
|
320
|
+
2. **Minimize search rounds**: For most topics, 1-3 web searches total
|
|
321
|
+
is enough. Combine queries (e.g. "NVDA stock price P/E ratio analyst
|
|
322
|
+
ratings 2025" instead of 3 separate searches). Use `max_results=2-3`.
|
|
323
|
+
3. **Don't repeat**: If a previous search already covered a topic, use
|
|
324
|
+
that data. Never search for the same information twice.
|
|
325
|
+
4. **Answer quickly**: Gather just enough data to answer well, then
|
|
326
|
+
respond. Don't exhaustively search every sub-topic.
|
|
327
|
+
|
|
328
|
+
### Memory
|
|
329
|
+
You have a long-term memory.
|
|
330
|
+
- **Proactively save** ALL user profile details, preferences, and key facts using `remember`.
|
|
331
|
+
- Use `recall(query="<topic>")` to search memory semantically. Use `recall(query="*")` to retrieve ALL memories (full dump).
|
|
332
|
+
- ALWAYS check memory (`recall`) if the user asks something that might be stored from a previous session.
|
|
333
|
+
|
|
334
|
+
Always verify the output of your commands.
|
|
335
|
+
|
|
336
|
+
### Response Guidelines
|
|
337
|
+
- Answer the user's question directly and concisely.
|
|
338
|
+
- Do NOT mention what skills or tools you have available, unless explicitly asked.
|
|
339
|
+
- Do NOT list other things you can do at the end of your response.
|
|
340
|
+
"""
|
|
341
|
+
if getattr(self, "_needs_onboarding", False):
|
|
342
|
+
system_msg += """
|
|
343
|
+
### First-Time Onboarding
|
|
344
|
+
**IMPORTANT**: No user identity (soul/persona) has been configured yet.
|
|
345
|
+
On the VERY FIRST user message, start a friendly onboarding conversation.
|
|
346
|
+
|
|
347
|
+
**Language rule**: Always conduct onboarding in **English** by default.
|
|
348
|
+
If the user replies in another language, switch to that language for
|
|
349
|
+
the rest of the onboarding (and set that as their language preference).
|
|
350
|
+
|
|
351
|
+
1. Greet the user warmly and introduce yourself as PythonClaw
|
|
352
|
+
2. Ask: "What should I call you?" (wait for response)
|
|
353
|
+
3. Ask: "What kind of personality would you like me to have? (e.g. professional, friendly, humorous, encouraging)"
|
|
354
|
+
4. Ask: "What area would you like me to focus on? (e.g. software development, finance, research, daily assistant)"
|
|
355
|
+
|
|
356
|
+
After collecting ALL answers, use the `onboarding` skill to write the
|
|
357
|
+
soul.md and persona.md files. Detect the user's language from their
|
|
358
|
+
replies (default to English if they replied in English) and pass it as
|
|
359
|
+
the `--language` argument. Then use `remember` to save the user's name
|
|
360
|
+
and preferences to long-term memory.
|
|
361
|
+
|
|
362
|
+
Ask the questions ONE AT A TIME, waiting for each answer before asking the next.
|
|
363
|
+
If the user's first message already contains task content (not just "hi"),
|
|
364
|
+
still start onboarding but keep it brief — you can help with their task after.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
self.messages.append({"role": "system", "content": system_msg})
|
|
368
|
+
if self.verbose:
|
|
369
|
+
logger.debug("System prompt built. Skill catalog: %d skills.", len(self._registry.discover()))
|
|
370
|
+
|
|
371
|
+
# ── Tool management ───────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
def _build_tools(self) -> list[dict]:
|
|
374
|
+
"""Assemble the full tool schema list for the current session."""
|
|
375
|
+
tools = PRIMITIVE_TOOLS + SKILL_TOOLS + META_SKILL_TOOLS + MEMORY_TOOLS
|
|
376
|
+
if self._web_search_enabled:
|
|
377
|
+
tools = tools + [WEB_SEARCH_TOOL]
|
|
378
|
+
if self.rag:
|
|
379
|
+
tools = tools + [KNOWLEDGE_TOOL]
|
|
380
|
+
if self._cron_manager:
|
|
381
|
+
tools = tools + CRON_TOOLS
|
|
382
|
+
return tools
|
|
383
|
+
|
|
384
|
+
def _execute_tool_call(self, tool_call) -> str:
|
|
385
|
+
"""Dispatch a single tool call and return the string result."""
|
|
386
|
+
func_name: str = tool_call.function.name
|
|
387
|
+
try:
|
|
388
|
+
args: dict = json.loads(tool_call.function.arguments)
|
|
389
|
+
except json.JSONDecodeError as exc:
|
|
390
|
+
return f"Error: could not parse tool arguments: {exc}"
|
|
391
|
+
|
|
392
|
+
if self.verbose:
|
|
393
|
+
logger.debug("Tool: %s args=%s", func_name, args)
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
if func_name == "use_skill":
|
|
397
|
+
result = self._use_skill(args.get("skill_name"))
|
|
398
|
+
elif func_name == "list_skill_resources":
|
|
399
|
+
resources = self._registry.list_resources(args.get("skill_name", ""))
|
|
400
|
+
if resources:
|
|
401
|
+
result = "Resources:\n" + "\n".join(f" - {r}" for r in resources)
|
|
402
|
+
else:
|
|
403
|
+
result = "No bundled resources found (or skill not found)."
|
|
404
|
+
elif func_name == "remember":
|
|
405
|
+
result = self.memory.remember(args.get("content"), args.get("key"))
|
|
406
|
+
elif func_name == "recall":
|
|
407
|
+
result = self.memory.recall(args.get("query", "*"))
|
|
408
|
+
elif func_name == "consult_knowledge_base" and self.rag:
|
|
409
|
+
hits = self.rag.retrieve(args.get("query"), top_k=5)
|
|
410
|
+
if hits:
|
|
411
|
+
result = "Found relevant info:\n" + "\n".join(
|
|
412
|
+
f"- [{h['source']}]: {h['content']}" for h in hits
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
result = "No relevant information found in the knowledge base."
|
|
416
|
+
elif func_name == "cron_add" and self._cron_manager:
|
|
417
|
+
result = self._cron_manager.add_dynamic_job(
|
|
418
|
+
job_id=args.get("job_id"),
|
|
419
|
+
cron_expr=args.get("cron"),
|
|
420
|
+
prompt=args.get("prompt"),
|
|
421
|
+
deliver_to="telegram" if args.get("deliver_to_chat_id") else None,
|
|
422
|
+
chat_id=args.get("deliver_to_chat_id"),
|
|
423
|
+
)
|
|
424
|
+
elif func_name == "cron_remove" and self._cron_manager:
|
|
425
|
+
result = self._cron_manager.remove_dynamic_job(args.get("job_id"))
|
|
426
|
+
elif func_name == "cron_list" and self._cron_manager:
|
|
427
|
+
result = self._cron_manager.list_jobs()
|
|
428
|
+
elif func_name == "create_skill":
|
|
429
|
+
result = AVAILABLE_TOOLS["create_skill"](**args)
|
|
430
|
+
self._refresh_skill_registry()
|
|
431
|
+
elif func_name in AVAILABLE_TOOLS:
|
|
432
|
+
result = AVAILABLE_TOOLS[func_name](**args)
|
|
433
|
+
else:
|
|
434
|
+
result = f"Error: unknown tool '{func_name}'."
|
|
435
|
+
except Exception as exc:
|
|
436
|
+
result = f"Error executing '{func_name}': {exc}"
|
|
437
|
+
|
|
438
|
+
if self.verbose:
|
|
439
|
+
preview = str(result)[:200] + ("..." if len(str(result)) > 200 else "")
|
|
440
|
+
logger.debug("Result: %s", preview)
|
|
441
|
+
|
|
442
|
+
return str(result)
|
|
443
|
+
|
|
444
|
+
# ── Skill registry refresh (after create_skill) ────────────────────────
|
|
445
|
+
|
|
446
|
+
def _refresh_skill_registry(self) -> None:
|
|
447
|
+
"""Invalidate the registry cache so newly created skills are discovered."""
|
|
448
|
+
self._registry.invalidate()
|
|
449
|
+
new_catalog = self._registry.build_catalog()
|
|
450
|
+
self.messages.append({
|
|
451
|
+
"role": "system",
|
|
452
|
+
"content": (
|
|
453
|
+
"[Skill Registry Updated]\n"
|
|
454
|
+
"A new skill has been created. Updated skill catalog:\n\n"
|
|
455
|
+
f"{new_catalog}"
|
|
456
|
+
),
|
|
457
|
+
})
|
|
458
|
+
if self.verbose:
|
|
459
|
+
count = len(self._registry.discover())
|
|
460
|
+
logger.debug("Skill registry refreshed — %d skills now available.", count)
|
|
461
|
+
|
|
462
|
+
# ── Skill loading (Level 2) ───────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
def _use_skill(self, skill_name: str) -> str:
|
|
465
|
+
"""
|
|
466
|
+
Level 2: Load a skill's full instructions into context.
|
|
467
|
+
|
|
468
|
+
Called when the LLM triggers ``use_skill``. The SKILL.md body
|
|
469
|
+
is injected as a system message so subsequent turns can follow
|
|
470
|
+
the instructions.
|
|
471
|
+
|
|
472
|
+
If the skill directory contains a ``check_setup.sh`` script, it
|
|
473
|
+
is executed automatically before activation. When the check fails
|
|
474
|
+
(non-zero exit), the skill is still loaded but a prominent warning
|
|
475
|
+
with the script output is included so the LLM can guide the user
|
|
476
|
+
through the fix.
|
|
477
|
+
"""
|
|
478
|
+
if skill_name in self.loaded_skill_names:
|
|
479
|
+
return f"Skill '{skill_name}' is already active."
|
|
480
|
+
|
|
481
|
+
skill = self._registry.load_skill(skill_name)
|
|
482
|
+
if not skill:
|
|
483
|
+
return f"Error: skill '{skill_name}' not found in catalog."
|
|
484
|
+
|
|
485
|
+
# ── Pre-activation environment check ─────────────────────────────────
|
|
486
|
+
setup_warning = ""
|
|
487
|
+
check_script = os.path.join(skill.metadata.path, "check_setup.sh")
|
|
488
|
+
if os.path.isfile(check_script):
|
|
489
|
+
import subprocess
|
|
490
|
+
from .tools import _venv_env
|
|
491
|
+
try:
|
|
492
|
+
proc = subprocess.run(
|
|
493
|
+
["bash", check_script],
|
|
494
|
+
capture_output=True, text=True, timeout=15,
|
|
495
|
+
env=_venv_env(),
|
|
496
|
+
)
|
|
497
|
+
if proc.returncode != 0:
|
|
498
|
+
output = (proc.stdout + proc.stderr).strip()
|
|
499
|
+
setup_warning = (
|
|
500
|
+
f"\n\n⚠️ **SETUP CHECK FAILED** (exit code {proc.returncode}):\n"
|
|
501
|
+
f"```\n{output}\n```\n"
|
|
502
|
+
f"Please tell the user what went wrong and how to fix it "
|
|
503
|
+
f"before attempting to use this skill's commands.\n"
|
|
504
|
+
)
|
|
505
|
+
if self.verbose:
|
|
506
|
+
logger.debug("Skill '%s' setup check FAILED: %s", skill_name, output)
|
|
507
|
+
else:
|
|
508
|
+
setup_info = proc.stdout.strip()
|
|
509
|
+
setup_warning = f"\n\n✅ Setup check passed:\n```\n{setup_info}\n```\n"
|
|
510
|
+
if self.verbose:
|
|
511
|
+
logger.debug("Skill '%s' setup check passed.", skill_name)
|
|
512
|
+
except Exception as exc:
|
|
513
|
+
setup_warning = f"\n\n⚠️ Setup check could not run: {exc}\n"
|
|
514
|
+
|
|
515
|
+
resources = self._registry.list_resources(skill_name)
|
|
516
|
+
resource_hint = ""
|
|
517
|
+
if resources:
|
|
518
|
+
resource_hint = (
|
|
519
|
+
"\n\n**Bundled resources** (use `read_file` / `run_command` to access):\n"
|
|
520
|
+
+ "\n".join(f" - `{skill.metadata.path}/{r}`" for r in resources)
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
injection = (
|
|
524
|
+
f"\n[SKILL ACTIVATED: {skill.name}]\n"
|
|
525
|
+
f"Path: {skill.metadata.path}\n\n"
|
|
526
|
+
f"{skill.instructions}{resource_hint}{setup_warning}\n"
|
|
527
|
+
)
|
|
528
|
+
self.pending_injections.append(injection)
|
|
529
|
+
self.loaded_skill_names.add(skill_name)
|
|
530
|
+
if self.verbose:
|
|
531
|
+
logger.debug("Skill activated: %s (Level 2 loaded)", skill_name)
|
|
532
|
+
|
|
533
|
+
status = "activated"
|
|
534
|
+
if "FAILED" in setup_warning:
|
|
535
|
+
status = "activated with setup warnings — tell the user how to fix"
|
|
536
|
+
return (
|
|
537
|
+
f"Skill '{skill_name}' {status}. "
|
|
538
|
+
f"Instructions loaded into context. "
|
|
539
|
+
f"Bundled resources: {resources or 'none'}."
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# ── History management ────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
def _get_pruned_messages(self) -> list[dict]:
|
|
545
|
+
"""
|
|
546
|
+
Build a context window for the API call:
|
|
547
|
+
- All system messages (system prompt + skill injections + compaction summaries)
|
|
548
|
+
- The most recent `max_chat_history` non-system messages
|
|
549
|
+
|
|
550
|
+
Ensures the window never starts with an orphaned tool result.
|
|
551
|
+
"""
|
|
552
|
+
system_msgs = [m for m in self.messages if m.get("role") == "system"]
|
|
553
|
+
chat_msgs = [m for m in self.messages if m.get("role") != "system"]
|
|
554
|
+
|
|
555
|
+
if len(chat_msgs) > self.max_chat_history:
|
|
556
|
+
chat_msgs = chat_msgs[-self.max_chat_history:]
|
|
557
|
+
while chat_msgs and chat_msgs[0].get("role") == "tool":
|
|
558
|
+
chat_msgs.pop(0)
|
|
559
|
+
|
|
560
|
+
return system_msgs + chat_msgs
|
|
561
|
+
|
|
562
|
+
# ── Compaction ────────────────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
def compact(self, instruction: str | None = None) -> str:
|
|
565
|
+
"""
|
|
566
|
+
Manually compact conversation history.
|
|
567
|
+
|
|
568
|
+
Summarises older messages into a single [Compaction Summary] system
|
|
569
|
+
entry, flushes important facts to long-term memory, and persists the
|
|
570
|
+
summary to context/compaction/history.jsonl.
|
|
571
|
+
|
|
572
|
+
Parameters
|
|
573
|
+
----------
|
|
574
|
+
instruction : optional focus hint, e.g. "focus on open tasks"
|
|
575
|
+
"""
|
|
576
|
+
chat_msgs = [m for m in self.messages if m.get("role") != "system"]
|
|
577
|
+
if len(chat_msgs) <= self.compaction_recent_keep:
|
|
578
|
+
return (
|
|
579
|
+
f"Nothing to compact yet — only {len(chat_msgs)} message(s) in history "
|
|
580
|
+
f"(threshold: {self.compaction_recent_keep})."
|
|
581
|
+
)
|
|
582
|
+
try:
|
|
583
|
+
new_messages, summary = _do_compact(
|
|
584
|
+
messages=self.messages,
|
|
585
|
+
provider=self.provider,
|
|
586
|
+
memory=self.memory,
|
|
587
|
+
recent_keep=self.compaction_recent_keep,
|
|
588
|
+
instruction=instruction,
|
|
589
|
+
)
|
|
590
|
+
except Exception as exc:
|
|
591
|
+
return f"Compaction failed: {exc}"
|
|
592
|
+
|
|
593
|
+
self.messages = new_messages
|
|
594
|
+
self.compaction_count += 1
|
|
595
|
+
|
|
596
|
+
lines = summary.splitlines()
|
|
597
|
+
preview = "\n".join(lines[:5])
|
|
598
|
+
if len(lines) > 5:
|
|
599
|
+
preview += f"\n... ({len(lines) - 5} more lines)"
|
|
600
|
+
return f"Compaction #{self.compaction_count} complete.\n\nSummary:\n{preview}"
|
|
601
|
+
|
|
602
|
+
def _maybe_auto_compact(self) -> bool:
|
|
603
|
+
"""Auto-compact if the estimated token count exceeds the threshold."""
|
|
604
|
+
if not self.auto_compaction:
|
|
605
|
+
return False
|
|
606
|
+
if estimate_tokens(self.messages) < self.compaction_threshold:
|
|
607
|
+
return False
|
|
608
|
+
if self.verbose:
|
|
609
|
+
logger.debug("Auto-compaction triggered.")
|
|
610
|
+
try:
|
|
611
|
+
new_messages, _ = _do_compact(
|
|
612
|
+
messages=self.messages,
|
|
613
|
+
provider=self.provider,
|
|
614
|
+
memory=self.memory,
|
|
615
|
+
recent_keep=self.compaction_recent_keep,
|
|
616
|
+
)
|
|
617
|
+
self.messages = new_messages
|
|
618
|
+
self.compaction_count += 1
|
|
619
|
+
return True
|
|
620
|
+
except Exception as exc:
|
|
621
|
+
if self.verbose:
|
|
622
|
+
logger.debug("Auto-compaction failed (non-fatal): %s", exc)
|
|
623
|
+
return False
|
|
624
|
+
|
|
625
|
+
# ── Session management ─────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
def clear_history(self) -> None:
|
|
628
|
+
"""Clear conversation history but keep the agent intact.
|
|
629
|
+
|
|
630
|
+
Preserves loaded skills, memory, RAG, provider, and all config.
|
|
631
|
+
Only resets messages to a fresh system prompt and clears
|
|
632
|
+
conversation-specific state.
|
|
633
|
+
"""
|
|
634
|
+
self.messages.clear()
|
|
635
|
+
self.loaded_skill_names.clear()
|
|
636
|
+
self.compaction_count = 0
|
|
637
|
+
self._init_system_prompt()
|
|
638
|
+
|
|
639
|
+
# ── Main chat loop ────────────────────────────────────────────────────────
|
|
640
|
+
|
|
641
|
+
def chat(self, user_input: str) -> str:
|
|
642
|
+
"""
|
|
643
|
+
Send *user_input* to the LLM and return the final text response.
|
|
644
|
+
|
|
645
|
+
Runs the standard tool-use loop:
|
|
646
|
+
1. Build context window (auto-compact if needed)
|
|
647
|
+
2. Call LLM
|
|
648
|
+
3. If the model requests tool calls → execute → repeat
|
|
649
|
+
4. When the model replies with text → return it
|
|
650
|
+
"""
|
|
651
|
+
self.messages.append({"role": "user", "content": user_input})
|
|
652
|
+
|
|
653
|
+
_log_detail({"event": "user_input", "content": user_input})
|
|
654
|
+
|
|
655
|
+
current_tools = self._build_tools()
|
|
656
|
+
tool_rounds = 0
|
|
657
|
+
chat_start = time.monotonic()
|
|
658
|
+
|
|
659
|
+
while True:
|
|
660
|
+
try:
|
|
661
|
+
self._maybe_auto_compact()
|
|
662
|
+
messages_to_send = self._get_pruned_messages()
|
|
663
|
+
|
|
664
|
+
if self.show_full_context:
|
|
665
|
+
logger.debug(
|
|
666
|
+
"Context window (%d messages):\n%s",
|
|
667
|
+
len(messages_to_send),
|
|
668
|
+
json.dumps(messages_to_send, indent=2, ensure_ascii=False),
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
response = self.provider.chat(
|
|
672
|
+
messages=messages_to_send,
|
|
673
|
+
tools=current_tools,
|
|
674
|
+
tool_choice="auto",
|
|
675
|
+
)
|
|
676
|
+
message = response.choices[0].message
|
|
677
|
+
|
|
678
|
+
if not message.tool_calls:
|
|
679
|
+
self.messages.append(message.model_dump())
|
|
680
|
+
_log_detail({
|
|
681
|
+
"event": "response",
|
|
682
|
+
"tool_rounds": tool_rounds,
|
|
683
|
+
"elapsed_ms": int((time.monotonic() - chat_start) * 1000),
|
|
684
|
+
"response_len": len(message.content or ""),
|
|
685
|
+
})
|
|
686
|
+
return message.content
|
|
687
|
+
|
|
688
|
+
tool_rounds += 1
|
|
689
|
+
if tool_rounds > self.MAX_TOOL_ROUNDS:
|
|
690
|
+
self.messages.append(message.model_dump())
|
|
691
|
+
limit_msg = (
|
|
692
|
+
f"Reached the maximum of {self.MAX_TOOL_ROUNDS} tool-call rounds. "
|
|
693
|
+
"Please provide a final answer with the information gathered so far."
|
|
694
|
+
)
|
|
695
|
+
self.messages.append({"role": "system", "content": limit_msg})
|
|
696
|
+
if self.verbose:
|
|
697
|
+
logger.debug("Tool round limit (%d) reached, forcing text reply.", self.MAX_TOOL_ROUNDS)
|
|
698
|
+
# One more LLM call with tool_choice="none" to force a text reply
|
|
699
|
+
try:
|
|
700
|
+
final = self.provider.chat(
|
|
701
|
+
messages=self._get_pruned_messages(),
|
|
702
|
+
tools=current_tools,
|
|
703
|
+
tool_choice="none",
|
|
704
|
+
)
|
|
705
|
+
final_msg = final.choices[0].message
|
|
706
|
+
self.messages.append(final_msg.model_dump())
|
|
707
|
+
return final_msg.content
|
|
708
|
+
except Exception as exc:
|
|
709
|
+
return f"Error (after hitting tool limit): {exc}"
|
|
710
|
+
|
|
711
|
+
self.messages.append(message.model_dump())
|
|
712
|
+
self.pending_injections = []
|
|
713
|
+
|
|
714
|
+
tool_calls = message.tool_calls
|
|
715
|
+
_log_detail({
|
|
716
|
+
"event": "tool_calls",
|
|
717
|
+
"round": tool_rounds,
|
|
718
|
+
"calls": [
|
|
719
|
+
{"name": tc.function.name, "args": tc.function.arguments}
|
|
720
|
+
for tc in tool_calls
|
|
721
|
+
],
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
if len(tool_calls) == 1:
|
|
725
|
+
t0 = time.monotonic()
|
|
726
|
+
result = self._execute_tool_call(tool_calls[0])
|
|
727
|
+
_log_detail({
|
|
728
|
+
"event": "tool_result",
|
|
729
|
+
"round": tool_rounds,
|
|
730
|
+
"name": tool_calls[0].function.name,
|
|
731
|
+
"elapsed_ms": int((time.monotonic() - t0) * 1000),
|
|
732
|
+
"result_len": len(result),
|
|
733
|
+
})
|
|
734
|
+
self.messages.append({
|
|
735
|
+
"role": "tool",
|
|
736
|
+
"tool_call_id": tool_calls[0].id,
|
|
737
|
+
"content": result,
|
|
738
|
+
})
|
|
739
|
+
else:
|
|
740
|
+
t0 = time.monotonic()
|
|
741
|
+
results: dict[str, str] = {}
|
|
742
|
+
with ThreadPoolExecutor(max_workers=min(len(tool_calls), 8)) as pool:
|
|
743
|
+
futures = {
|
|
744
|
+
pool.submit(self._execute_tool_call, tc): tc
|
|
745
|
+
for tc in tool_calls
|
|
746
|
+
}
|
|
747
|
+
for future in as_completed(futures):
|
|
748
|
+
tc = futures[future]
|
|
749
|
+
try:
|
|
750
|
+
results[tc.id] = future.result()
|
|
751
|
+
except Exception as exc:
|
|
752
|
+
results[tc.id] = f"Error: {exc}"
|
|
753
|
+
_log_detail({
|
|
754
|
+
"event": "tool_results_parallel",
|
|
755
|
+
"round": tool_rounds,
|
|
756
|
+
"count": len(tool_calls),
|
|
757
|
+
"elapsed_ms": int((time.monotonic() - t0) * 1000),
|
|
758
|
+
"tools": [tc.function.name for tc in tool_calls],
|
|
759
|
+
})
|
|
760
|
+
for tc in tool_calls:
|
|
761
|
+
self.messages.append({
|
|
762
|
+
"role": "tool",
|
|
763
|
+
"tool_call_id": tc.id,
|
|
764
|
+
"content": results[tc.id],
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
for injection in self.pending_injections:
|
|
768
|
+
self.messages.append({"role": "system", "content": injection})
|
|
769
|
+
self.pending_injections = []
|
|
770
|
+
|
|
771
|
+
except Exception as exc:
|
|
772
|
+
logger.exception("Critical error in Agent.chat()")
|
|
773
|
+
return f"Error: {exc}"
|