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
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context compaction for pythonclaw.
|
|
3
|
+
|
|
4
|
+
Compaction summarises older conversation history into a compact summary entry
|
|
5
|
+
and keeps recent messages intact — preventing context-window overflows in long
|
|
6
|
+
sessions while preserving important information.
|
|
7
|
+
|
|
8
|
+
Inspired by openclaw's compaction model:
|
|
9
|
+
https://docs.openclaw.ai/concepts/compaction
|
|
10
|
+
|
|
11
|
+
How it works
|
|
12
|
+
------------
|
|
13
|
+
1. Split chat history into "old" (to summarise) + "recent" (to keep verbatim).
|
|
14
|
+
2. Memory flush — silently ask the LLM to extract key facts from the old
|
|
15
|
+
messages and persist them via the agent's `remember` tool, so nothing
|
|
16
|
+
important is lost permanently.
|
|
17
|
+
3. Summarise — call the LLM with a summarisation prompt to produce a concise
|
|
18
|
+
paragraph covering decisions, facts, tasks, and open questions.
|
|
19
|
+
4. Persist — append the summary to `context/compaction/history.jsonl` as an
|
|
20
|
+
audit trail.
|
|
21
|
+
5. Replace — swap the old messages with a single [Compaction Summary] system
|
|
22
|
+
message and increment the compaction counter.
|
|
23
|
+
|
|
24
|
+
Token estimation
|
|
25
|
+
----------------
|
|
26
|
+
We don't bundle a tokeniser, so we use a conservative character-based
|
|
27
|
+
approximation: 1 token ≈ 4 characters. This is good enough for triggering
|
|
28
|
+
auto-compaction; the actual model enforces the hard limit.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from .llm.base import LLMProvider
|
|
41
|
+
from .memory.manager import MemoryManager
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
CHARS_PER_TOKEN = 4
|
|
46
|
+
DEFAULT_AUTO_THRESHOLD_TOKENS = 6000 # trigger auto-compaction at ~6k tokens
|
|
47
|
+
DEFAULT_RECENT_KEEP = 6 # keep last N chat messages verbatim
|
|
48
|
+
COMPACTION_LOG_FILE = os.path.join("context", "compaction", "history.jsonl")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Token estimation ──────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def estimate_tokens(messages: list[dict]) -> int:
|
|
54
|
+
"""Rough character-based token estimate for a list of messages."""
|
|
55
|
+
total_chars = sum(len(str(m.get("content") or "")) for m in messages)
|
|
56
|
+
return total_chars // CHARS_PER_TOKEN
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── JSONL persistence ─────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def persist_compaction(summary: str, message_count: int, log_path: str = COMPACTION_LOG_FILE) -> None:
|
|
62
|
+
"""Append one compaction entry to the JSONL audit log."""
|
|
63
|
+
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
64
|
+
entry = {
|
|
65
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
66
|
+
"summarised_messages": message_count,
|
|
67
|
+
"summary": summary,
|
|
68
|
+
}
|
|
69
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
70
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
71
|
+
logger.debug("[Compaction] Persisted to %s", log_path)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Message → plain text ──────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def messages_to_text(messages: list[dict]) -> str:
|
|
77
|
+
"""Convert a message list to a readable transcript for summarisation."""
|
|
78
|
+
lines = []
|
|
79
|
+
for m in messages:
|
|
80
|
+
role = m.get("role", "?")
|
|
81
|
+
content = m.get("content") or ""
|
|
82
|
+
if role == "assistant" and not content and m.get("tool_calls"):
|
|
83
|
+
content = f"[called tools: {[tc.get('function', {}).get('name') if isinstance(tc, dict) else tc.function.name for tc in m.get('tool_calls', [])]}]"
|
|
84
|
+
if role == "tool":
|
|
85
|
+
content = f"[tool result]: {content[:300]}{'...' if len(content) > 300 else ''}"
|
|
86
|
+
if content:
|
|
87
|
+
lines.append(f"{role.upper()}: {content}")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Memory flush ──────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def memory_flush(
|
|
94
|
+
messages_to_flush: list[dict],
|
|
95
|
+
provider: "LLMProvider",
|
|
96
|
+
memory: "MemoryManager",
|
|
97
|
+
) -> int:
|
|
98
|
+
"""
|
|
99
|
+
Silent LLM call that extracts key facts from old messages and saves them
|
|
100
|
+
to long-term memory before those messages are discarded.
|
|
101
|
+
|
|
102
|
+
Returns the number of facts saved.
|
|
103
|
+
"""
|
|
104
|
+
if not messages_to_flush:
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
history_text = messages_to_text(messages_to_flush)
|
|
108
|
+
prompt = (
|
|
109
|
+
"You are a memory extraction assistant. "
|
|
110
|
+
"Given the following conversation transcript, identify ALL important facts, "
|
|
111
|
+
"decisions, preferences, and context that should be remembered long-term. "
|
|
112
|
+
"Return a JSON array of objects with 'key' and 'value' fields. "
|
|
113
|
+
"If nothing important, return [].\n\n"
|
|
114
|
+
f"TRANSCRIPT:\n{history_text}\n\n"
|
|
115
|
+
"Return ONLY valid JSON, no explanation."
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
response = provider.chat(
|
|
119
|
+
messages=[{"role": "user", "content": prompt}],
|
|
120
|
+
tools=[],
|
|
121
|
+
tool_choice="none",
|
|
122
|
+
)
|
|
123
|
+
raw = response.choices[0].message.content or "[]"
|
|
124
|
+
# Strip markdown fences if present
|
|
125
|
+
raw = raw.strip()
|
|
126
|
+
if raw.startswith("```"):
|
|
127
|
+
raw = "\n".join(raw.split("\n")[1:])
|
|
128
|
+
if raw.endswith("```"):
|
|
129
|
+
raw = raw[: raw.rfind("```")]
|
|
130
|
+
facts: list[dict] = json.loads(raw)
|
|
131
|
+
saved = 0
|
|
132
|
+
for fact in facts:
|
|
133
|
+
key = str(fact.get("key", "")).strip()
|
|
134
|
+
value = str(fact.get("value", "")).strip()
|
|
135
|
+
if key and value:
|
|
136
|
+
memory.remember(value, key)
|
|
137
|
+
saved += 1
|
|
138
|
+
logger.info("[Compaction] Memory flush saved %d fact(s).", saved)
|
|
139
|
+
return saved
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.warning("[Compaction] Memory flush failed (non-fatal): %s", exc)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── Core compact function ─────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def compact(
|
|
148
|
+
messages: list[dict],
|
|
149
|
+
provider: "LLMProvider",
|
|
150
|
+
memory: "MemoryManager | None" = None,
|
|
151
|
+
recent_keep: int = DEFAULT_RECENT_KEEP,
|
|
152
|
+
instruction: str | None = None,
|
|
153
|
+
log_path: str = COMPACTION_LOG_FILE,
|
|
154
|
+
) -> tuple[list[dict], str]:
|
|
155
|
+
"""
|
|
156
|
+
Compact conversation history.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
messages : full message list (system + chat)
|
|
161
|
+
provider : LLM provider used for summarisation
|
|
162
|
+
memory : MemoryManager for pre-compaction memory flush (optional)
|
|
163
|
+
recent_keep : number of recent chat messages to keep verbatim
|
|
164
|
+
instruction : optional extra focus hint for the summarisation prompt
|
|
165
|
+
log_path : where to persist the compaction JSONL entry
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
(new_messages, summary_text)
|
|
170
|
+
"""
|
|
171
|
+
system_msgs = [m for m in messages if m.get("role") == "system"]
|
|
172
|
+
chat_msgs = [m for m in messages if m.get("role") != "system"]
|
|
173
|
+
|
|
174
|
+
if len(chat_msgs) <= recent_keep:
|
|
175
|
+
logger.info("[Compaction] Not enough history to compact (%d messages).", len(chat_msgs))
|
|
176
|
+
return messages, ""
|
|
177
|
+
|
|
178
|
+
to_summarise = chat_msgs[:-recent_keep]
|
|
179
|
+
to_keep = chat_msgs[-recent_keep:]
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
"[Compaction] Summarising %d message(s), keeping %d recent.",
|
|
183
|
+
len(to_summarise), len(to_keep),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# 1. Memory flush — save important facts before discarding old messages
|
|
187
|
+
if memory is not None:
|
|
188
|
+
memory_flush(to_summarise, provider, memory)
|
|
189
|
+
|
|
190
|
+
# 2. Summarise
|
|
191
|
+
history_text = messages_to_text(to_summarise)
|
|
192
|
+
focus = f"\nAdditional focus: {instruction}" if instruction else ""
|
|
193
|
+
summarise_prompt = (
|
|
194
|
+
f"Summarise the following conversation history concisely. "
|
|
195
|
+
f"Focus on: decisions made, key facts learned, tasks completed, open questions.{focus}\n\n"
|
|
196
|
+
f"CONVERSATION:\n{history_text}\n\n"
|
|
197
|
+
f"Provide a compact summary (3–8 sentences or bullet points):"
|
|
198
|
+
)
|
|
199
|
+
try:
|
|
200
|
+
response = provider.chat(
|
|
201
|
+
messages=[{"role": "user", "content": summarise_prompt}],
|
|
202
|
+
tools=[],
|
|
203
|
+
tool_choice="none",
|
|
204
|
+
)
|
|
205
|
+
summary = (response.choices[0].message.content or "").strip()
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
logger.error("[Compaction] Summarisation failed: %s", exc)
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
# 3. Persist
|
|
211
|
+
persist_compaction(summary, len(to_summarise), log_path=log_path)
|
|
212
|
+
|
|
213
|
+
# 4. Build new message list
|
|
214
|
+
summary_system_msg = {
|
|
215
|
+
"role": "system",
|
|
216
|
+
"content": f"[Compaction Summary — {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}]\n{summary}",
|
|
217
|
+
}
|
|
218
|
+
new_messages = system_msgs + [summary_system_msg] + to_keep
|
|
219
|
+
|
|
220
|
+
return new_messages, summary
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Knowledge-base RAG wrapper.
|
|
3
|
+
|
|
4
|
+
Replaces the old SimpleRAG (keyword-only) with a HybridRetriever that
|
|
5
|
+
combines BM25 sparse retrieval, dense embedding retrieval, RRF fusion,
|
|
6
|
+
and an optional LLM re-ranker.
|
|
7
|
+
|
|
8
|
+
Backwards-compatible API:
|
|
9
|
+
rag = KnowledgeRAG(knowledge_dir, provider=llm_provider)
|
|
10
|
+
hits = rag.retrieve("my query", top_k=5)
|
|
11
|
+
# hits = [{"source": "filename.txt", "content": "..."}, ...]
|
|
12
|
+
|
|
13
|
+
Old SimpleRAG is kept as an alias for scripts that import it directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from ..retrieval.chunker import load_corpus_from_directory
|
|
22
|
+
from ..retrieval.retriever import HybridRetriever
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from ..llm.base import LLMProvider
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KnowledgeRAG:
|
|
31
|
+
"""
|
|
32
|
+
Loads .txt / .md files from a directory and retrieves relevant chunks
|
|
33
|
+
using hybrid sparse + dense retrieval with optional LLM re-ranking.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
knowledge_dir : path to the directory containing knowledge files.
|
|
38
|
+
provider : LLMProvider (enables LLM re-ranker when provided).
|
|
39
|
+
use_sparse : enable BM25 retrieval (default True).
|
|
40
|
+
use_dense : enable embedding retrieval (default True).
|
|
41
|
+
use_reranker : enable LLM re-ranking (default True; requires provider).
|
|
42
|
+
dense_model : sentence-transformers model name.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
knowledge_dir: str,
|
|
48
|
+
provider: "LLMProvider | None" = None,
|
|
49
|
+
use_sparse: bool = True,
|
|
50
|
+
use_dense: bool = True,
|
|
51
|
+
use_reranker: bool = True,
|
|
52
|
+
dense_model: str = "all-MiniLM-L6-v2",
|
|
53
|
+
) -> None:
|
|
54
|
+
self.knowledge_dir = knowledge_dir
|
|
55
|
+
|
|
56
|
+
self._retriever = HybridRetriever(
|
|
57
|
+
provider=provider,
|
|
58
|
+
use_sparse=use_sparse,
|
|
59
|
+
use_dense=use_dense,
|
|
60
|
+
use_reranker=use_reranker,
|
|
61
|
+
dense_model=dense_model,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
corpus = load_corpus_from_directory(knowledge_dir)
|
|
65
|
+
self._retriever.fit(corpus)
|
|
66
|
+
logger.info(
|
|
67
|
+
"[KnowledgeRAG] Loaded %d chunks from '%s'", len(corpus), knowledge_dir
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def retrieve(self, query: str, top_k: int = 5) -> list[dict]:
|
|
71
|
+
"""
|
|
72
|
+
Return up to *top_k* relevant chunks for *query*.
|
|
73
|
+
|
|
74
|
+
Each result dict has at least:
|
|
75
|
+
{"source": str, "content": str}
|
|
76
|
+
"""
|
|
77
|
+
return self._retriever.retrieve(query, top_k=top_k)
|
|
78
|
+
|
|
79
|
+
def reload(self) -> None:
|
|
80
|
+
"""Re-scan the knowledge directory and re-index (hot reload)."""
|
|
81
|
+
corpus = load_corpus_from_directory(self.knowledge_dir)
|
|
82
|
+
self._retriever.fit(corpus)
|
|
83
|
+
logger.info("[KnowledgeRAG] Reloaded %d chunks.", len(corpus))
|
|
84
|
+
|
|
85
|
+
def __len__(self) -> int:
|
|
86
|
+
return len(self._retriever)
|
|
87
|
+
|
|
88
|
+
def __bool__(self) -> bool:
|
|
89
|
+
return bool(self._retriever)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Backwards-compatibility alias
|
|
93
|
+
SimpleRAG = KnowledgeRAG
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Anthropic (Claude) provider — adapts the Anthropic API to the OpenAI-compatible
|
|
3
|
+
response format used by Agent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import anthropic
|
|
12
|
+
|
|
13
|
+
from .base import LLMProvider
|
|
14
|
+
from .response import MockChoice, MockFunction, MockMessage, MockResponse, MockToolCall
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnthropicProvider(LLMProvider):
|
|
18
|
+
def __init__(self, api_key: str, model_name: str = "claude-3-5-sonnet-20241022"):
|
|
19
|
+
self.client = anthropic.Anthropic(api_key=api_key)
|
|
20
|
+
self.model_name = model_name
|
|
21
|
+
|
|
22
|
+
def chat(
|
|
23
|
+
self,
|
|
24
|
+
messages: List[Dict[str, Any]],
|
|
25
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
26
|
+
tool_choice: Any = "auto",
|
|
27
|
+
) -> Any:
|
|
28
|
+
system_prompt = ""
|
|
29
|
+
filtered_messages: list[dict] = []
|
|
30
|
+
|
|
31
|
+
for msg in messages:
|
|
32
|
+
if msg["role"] == "system":
|
|
33
|
+
system_prompt += msg["content"] + "\n"
|
|
34
|
+
|
|
35
|
+
elif msg["role"] == "tool":
|
|
36
|
+
filtered_messages.append({
|
|
37
|
+
"role": "user",
|
|
38
|
+
"content": [{
|
|
39
|
+
"type": "tool_result",
|
|
40
|
+
"tool_use_id": msg["tool_call_id"],
|
|
41
|
+
"content": msg["content"],
|
|
42
|
+
}],
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
elif msg["role"] == "assistant" and "tool_calls" in msg:
|
|
46
|
+
content_block: list[dict] = []
|
|
47
|
+
if msg.get("content"):
|
|
48
|
+
content_block.append({"type": "text", "text": msg["content"]})
|
|
49
|
+
for tc in msg["tool_calls"]:
|
|
50
|
+
tc_id = tc["id"] if isinstance(tc, dict) else tc.id
|
|
51
|
+
func = tc["function"] if isinstance(tc, dict) else tc.function
|
|
52
|
+
fname = func["name"] if isinstance(func, dict) else func.name
|
|
53
|
+
fargs = json.loads(func["arguments"] if isinstance(func, dict) else func.arguments)
|
|
54
|
+
content_block.append({
|
|
55
|
+
"type": "tool_use",
|
|
56
|
+
"id": tc_id,
|
|
57
|
+
"name": fname,
|
|
58
|
+
"input": fargs,
|
|
59
|
+
})
|
|
60
|
+
filtered_messages.append({"role": "assistant", "content": content_block})
|
|
61
|
+
|
|
62
|
+
else:
|
|
63
|
+
filtered_messages.append(msg)
|
|
64
|
+
|
|
65
|
+
# Convert tool schemas
|
|
66
|
+
anthropic_tools = []
|
|
67
|
+
if tools:
|
|
68
|
+
for t in tools:
|
|
69
|
+
if t["type"] == "function":
|
|
70
|
+
anthropic_tools.append({
|
|
71
|
+
"name": t["function"]["name"],
|
|
72
|
+
"description": t["function"]["description"],
|
|
73
|
+
"input_schema": t["function"]["parameters"],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
kwargs: dict = {
|
|
77
|
+
"model": self.model_name,
|
|
78
|
+
"messages": filtered_messages,
|
|
79
|
+
"max_tokens": 4096,
|
|
80
|
+
}
|
|
81
|
+
if system_prompt:
|
|
82
|
+
kwargs["system"] = system_prompt
|
|
83
|
+
if anthropic_tools:
|
|
84
|
+
kwargs["tools"] = anthropic_tools
|
|
85
|
+
if tool_choice == "required":
|
|
86
|
+
kwargs["tool_choice"] = {"type": "any"}
|
|
87
|
+
|
|
88
|
+
response = self.client.messages.create(**kwargs)
|
|
89
|
+
|
|
90
|
+
# Convert to OpenAI-compatible format
|
|
91
|
+
content_text = ""
|
|
92
|
+
tool_calls: list[MockToolCall] = []
|
|
93
|
+
for block in response.content:
|
|
94
|
+
if block.type == "text":
|
|
95
|
+
content_text += block.text
|
|
96
|
+
elif block.type == "tool_use":
|
|
97
|
+
tool_calls.append(MockToolCall(
|
|
98
|
+
id=block.id,
|
|
99
|
+
function=MockFunction(name=block.name, arguments=json.dumps(block.input)),
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
return MockResponse(choices=[
|
|
103
|
+
MockChoice(message=MockMessage(
|
|
104
|
+
content=content_text or None,
|
|
105
|
+
tool_calls=tool_calls or None,
|
|
106
|
+
))
|
|
107
|
+
])
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Abstract base class for LLM providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LLMProvider(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def chat(
|
|
12
|
+
self,
|
|
13
|
+
messages: list[dict[str, Any]],
|
|
14
|
+
tools: list[dict[str, Any]] | None = None,
|
|
15
|
+
tool_choice: Any = "auto",
|
|
16
|
+
) -> Any:
|
|
17
|
+
"""
|
|
18
|
+
Send a chat request to the LLM.
|
|
19
|
+
|
|
20
|
+
All providers must return an object compatible with OpenAI's response
|
|
21
|
+
structure: ``response.choices[0].message.content`` and
|
|
22
|
+
``response.choices[0].message.tool_calls``.
|
|
23
|
+
|
|
24
|
+
Non-OpenAI providers should use the Mock* dataclasses from
|
|
25
|
+
``llm.response`` to build compatible return values.
|
|
26
|
+
"""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Gemini provider — adapts the Gemini API to the OpenAI-compatible
|
|
3
|
+
response format used by Agent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import google.generativeai as genai
|
|
13
|
+
from google.generativeai.types import content_types
|
|
14
|
+
|
|
15
|
+
from .base import LLMProvider
|
|
16
|
+
from .response import MockChoice, MockFunction, MockMessage, MockResponse, MockToolCall
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GeminiProvider(LLMProvider):
|
|
20
|
+
def __init__(self, api_key: str, model_name: str = "gemini-2.0-flash-exp"):
|
|
21
|
+
genai.configure(api_key=api_key)
|
|
22
|
+
self.model = genai.GenerativeModel(model_name)
|
|
23
|
+
|
|
24
|
+
def _convert_tool_calls_to_parts(self, tool_calls_data: list) -> list:
|
|
25
|
+
parts = []
|
|
26
|
+
for tc in tool_calls_data:
|
|
27
|
+
func = tc["function"] if isinstance(tc, dict) else tc.function
|
|
28
|
+
name = func["name"] if isinstance(func, dict) else func.name
|
|
29
|
+
args = json.loads(func["arguments"] if isinstance(func, dict) else func.arguments)
|
|
30
|
+
parts.append(content_types.FunctionCall(name=name, args=args))
|
|
31
|
+
return parts
|
|
32
|
+
|
|
33
|
+
def chat(
|
|
34
|
+
self,
|
|
35
|
+
messages: List[Dict[str, Any]],
|
|
36
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
37
|
+
tool_choice: Any = "auto",
|
|
38
|
+
) -> Any:
|
|
39
|
+
gemini_history: list[dict] = []
|
|
40
|
+
system_instruction: str | None = None
|
|
41
|
+
|
|
42
|
+
for msg in messages:
|
|
43
|
+
role = msg["role"]
|
|
44
|
+
content = msg.get("content")
|
|
45
|
+
|
|
46
|
+
if role == "system":
|
|
47
|
+
system_instruction = (
|
|
48
|
+
content if system_instruction is None
|
|
49
|
+
else system_instruction + "\n" + content
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
elif role == "user":
|
|
53
|
+
gemini_history.append({"role": "user", "parts": [content]})
|
|
54
|
+
|
|
55
|
+
elif role == "assistant":
|
|
56
|
+
parts: list = []
|
|
57
|
+
if content:
|
|
58
|
+
parts.append(content)
|
|
59
|
+
if msg.get("tool_calls"):
|
|
60
|
+
parts.extend(self._convert_tool_calls_to_parts(msg["tool_calls"]))
|
|
61
|
+
gemini_history.append({"role": "model", "parts": parts})
|
|
62
|
+
|
|
63
|
+
elif role == "tool":
|
|
64
|
+
func_name = self._find_tool_name(messages, msg["tool_call_id"])
|
|
65
|
+
try:
|
|
66
|
+
resp_dict = json.loads(msg["content"])
|
|
67
|
+
except (json.JSONDecodeError, TypeError):
|
|
68
|
+
resp_dict = {"result": msg["content"]}
|
|
69
|
+
gemini_history.append({
|
|
70
|
+
"role": "user",
|
|
71
|
+
"parts": [content_types.FunctionResponse(name=func_name, response=resp_dict)],
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
# Convert tool schemas
|
|
75
|
+
gemini_tools = None
|
|
76
|
+
if tools:
|
|
77
|
+
declarations = [
|
|
78
|
+
{
|
|
79
|
+
"name": t["function"]["name"],
|
|
80
|
+
"description": t["function"].get("description"),
|
|
81
|
+
"parameters": t["function"].get("parameters"),
|
|
82
|
+
}
|
|
83
|
+
for t in tools if t["type"] == "function"
|
|
84
|
+
]
|
|
85
|
+
if declarations:
|
|
86
|
+
gemini_tools = [declarations]
|
|
87
|
+
|
|
88
|
+
# Inject system instruction into the first user message
|
|
89
|
+
if gemini_history and gemini_history[0]["role"] == "model":
|
|
90
|
+
gemini_history.insert(0, {"role": "user", "parts": ["Hi"]})
|
|
91
|
+
if system_instruction and gemini_history:
|
|
92
|
+
first_parts = gemini_history[0]["parts"]
|
|
93
|
+
if isinstance(first_parts, list):
|
|
94
|
+
first_parts.insert(0, f"System Instruction: {system_instruction}")
|
|
95
|
+
|
|
96
|
+
response = self.model.generate_content(
|
|
97
|
+
contents=gemini_history,
|
|
98
|
+
tools=gemini_tools,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Convert to OpenAI-compatible format
|
|
102
|
+
try:
|
|
103
|
+
_ = response.parts[0]
|
|
104
|
+
except (IndexError, AttributeError, ValueError):
|
|
105
|
+
return MockResponse(choices=[
|
|
106
|
+
MockChoice(message=MockMessage(content="Error: empty response from Gemini", tool_calls=None))
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
content_text: str | None = None
|
|
110
|
+
tool_calls: list[MockToolCall] = []
|
|
111
|
+
for part in response.parts:
|
|
112
|
+
if part.text:
|
|
113
|
+
content_text = (content_text or "") + part.text
|
|
114
|
+
if part.function_call:
|
|
115
|
+
tool_calls.append(MockToolCall(
|
|
116
|
+
id=f"call_{uuid.uuid4().hex[:8]}",
|
|
117
|
+
function=MockFunction(
|
|
118
|
+
name=part.function_call.name,
|
|
119
|
+
arguments=json.dumps(dict(part.function_call.args)),
|
|
120
|
+
),
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
return MockResponse(choices=[
|
|
124
|
+
MockChoice(message=MockMessage(
|
|
125
|
+
content=content_text,
|
|
126
|
+
tool_calls=tool_calls or None,
|
|
127
|
+
))
|
|
128
|
+
])
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _find_tool_name(messages: list[dict], tool_call_id: str) -> str:
|
|
132
|
+
"""Walk backwards through messages to find the function name for a tool_call_id."""
|
|
133
|
+
for prev in reversed(messages):
|
|
134
|
+
for tc in prev.get("tool_calls") or []:
|
|
135
|
+
tc_id = tc["id"] if isinstance(tc, dict) else tc.id
|
|
136
|
+
if tc_id == tool_call_id:
|
|
137
|
+
func = tc["function"] if isinstance(tc, dict) else tc.function
|
|
138
|
+
return func["name"] if isinstance(func, dict) else func.name
|
|
139
|
+
return "unknown_tool"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""OpenAI-compatible LLM provider.
|
|
2
|
+
|
|
3
|
+
Works with any API that follows the OpenAI chat-completions contract:
|
|
4
|
+
DeepSeek, Grok (xAI), Kimi (Moonshot), GLM (Zhipu), and others.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from openai import OpenAI
|
|
12
|
+
|
|
13
|
+
from .base import LLMProvider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenAICompatibleProvider(LLMProvider):
|
|
17
|
+
"""Thin wrapper around the OpenAI SDK for chat completions."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, api_key: str, base_url: str, model_name: str) -> None:
|
|
20
|
+
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
|
21
|
+
self.model_name = model_name
|
|
22
|
+
|
|
23
|
+
def chat(
|
|
24
|
+
self,
|
|
25
|
+
messages: list[dict[str, Any]],
|
|
26
|
+
tools: list[dict[str, Any]] | None = None,
|
|
27
|
+
tool_choice: Any = "auto",
|
|
28
|
+
**kwargs: Any,
|
|
29
|
+
) -> Any:
|
|
30
|
+
req: dict[str, Any] = {
|
|
31
|
+
"model": self.model_name,
|
|
32
|
+
"messages": messages,
|
|
33
|
+
**kwargs,
|
|
34
|
+
}
|
|
35
|
+
if tools:
|
|
36
|
+
req["tools"] = tools
|
|
37
|
+
req["tool_choice"] = tool_choice
|
|
38
|
+
|
|
39
|
+
return self.client.chat.completions.create(**req)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI-compatible response dataclasses.
|
|
3
|
+
|
|
4
|
+
Non-OpenAI providers (Anthropic, Gemini) convert their native responses into
|
|
5
|
+
these dataclasses so the Agent can use a single code path regardless of which
|
|
6
|
+
LLM backend is active.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class MockFunction:
|
|
17
|
+
name: str
|
|
18
|
+
arguments: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class MockToolCall:
|
|
23
|
+
id: str
|
|
24
|
+
function: MockFunction
|
|
25
|
+
type: str = "function"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MockMessage:
|
|
30
|
+
content: Optional[str]
|
|
31
|
+
tool_calls: Optional[list[MockToolCall]]
|
|
32
|
+
|
|
33
|
+
def model_dump(self) -> dict:
|
|
34
|
+
d: dict = {"role": "assistant", "content": self.content}
|
|
35
|
+
if self.tool_calls:
|
|
36
|
+
d["tool_calls"] = [
|
|
37
|
+
{
|
|
38
|
+
"id": tc.id,
|
|
39
|
+
"type": "function",
|
|
40
|
+
"function": {
|
|
41
|
+
"name": tc.function.name,
|
|
42
|
+
"arguments": tc.function.arguments,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
for tc in self.tool_calls
|
|
46
|
+
]
|
|
47
|
+
return d
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class MockChoice:
|
|
52
|
+
message: MockMessage
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class MockResponse:
|
|
57
|
+
choices: list[MockChoice]
|