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.
Files changed (112) hide show
  1. pythonclaw/__init__.py +17 -0
  2. pythonclaw/__main__.py +6 -0
  3. pythonclaw/channels/discord_bot.py +231 -0
  4. pythonclaw/channels/telegram_bot.py +236 -0
  5. pythonclaw/config.py +190 -0
  6. pythonclaw/core/__init__.py +25 -0
  7. pythonclaw/core/agent.py +773 -0
  8. pythonclaw/core/compaction.py +220 -0
  9. pythonclaw/core/knowledge/rag.py +93 -0
  10. pythonclaw/core/llm/anthropic_client.py +107 -0
  11. pythonclaw/core/llm/base.py +26 -0
  12. pythonclaw/core/llm/gemini_client.py +139 -0
  13. pythonclaw/core/llm/openai_compatible.py +39 -0
  14. pythonclaw/core/llm/response.py +57 -0
  15. pythonclaw/core/memory/manager.py +120 -0
  16. pythonclaw/core/memory/storage.py +164 -0
  17. pythonclaw/core/persistent_agent.py +103 -0
  18. pythonclaw/core/retrieval/__init__.py +6 -0
  19. pythonclaw/core/retrieval/chunker.py +78 -0
  20. pythonclaw/core/retrieval/dense.py +152 -0
  21. pythonclaw/core/retrieval/fusion.py +51 -0
  22. pythonclaw/core/retrieval/reranker.py +112 -0
  23. pythonclaw/core/retrieval/retriever.py +166 -0
  24. pythonclaw/core/retrieval/sparse.py +69 -0
  25. pythonclaw/core/session_store.py +269 -0
  26. pythonclaw/core/skill_loader.py +322 -0
  27. pythonclaw/core/skillhub.py +290 -0
  28. pythonclaw/core/tools.py +622 -0
  29. pythonclaw/core/utils.py +64 -0
  30. pythonclaw/daemon.py +221 -0
  31. pythonclaw/init.py +61 -0
  32. pythonclaw/main.py +489 -0
  33. pythonclaw/onboard.py +290 -0
  34. pythonclaw/scheduler/cron.py +310 -0
  35. pythonclaw/scheduler/heartbeat.py +178 -0
  36. pythonclaw/server.py +145 -0
  37. pythonclaw/session_manager.py +104 -0
  38. pythonclaw/templates/persona/demo_persona.md +2 -0
  39. pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
  40. pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
  41. pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
  42. pythonclaw/templates/skills/communication/email/send_email.py +88 -0
  43. pythonclaw/templates/skills/data/CATEGORY.md +4 -0
  44. pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
  45. pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
  46. pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
  47. pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
  48. pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
  49. pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
  50. pythonclaw/templates/skills/data/news/SKILL.md +39 -0
  51. pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
  52. pythonclaw/templates/skills/data/news/search_news.py +57 -0
  53. pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
  54. pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
  55. pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
  56. pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
  57. pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
  58. pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
  59. pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
  60. pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
  61. pythonclaw/templates/skills/data/weather/weather.py +142 -0
  62. pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
  63. pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
  64. pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
  65. pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
  66. pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
  67. pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
  68. pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
  69. pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
  70. pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
  71. pythonclaw/templates/skills/dev/github/gh.py +165 -0
  72. pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
  73. pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
  74. pythonclaw/templates/skills/dev/http_request/request.py +90 -0
  75. pythonclaw/templates/skills/google/CATEGORY.md +4 -0
  76. pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
  77. pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
  78. pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
  79. pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
  80. pythonclaw/templates/skills/system/CATEGORY.md +4 -0
  81. pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
  82. pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
  83. pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
  84. pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
  85. pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
  86. pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
  87. pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
  88. pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
  89. pythonclaw/templates/skills/system/random/SKILL.md +33 -0
  90. pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
  91. pythonclaw/templates/skills/system/random/random_util.py +45 -0
  92. pythonclaw/templates/skills/system/time/SKILL.md +33 -0
  93. pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
  94. pythonclaw/templates/skills/system/time/time_util.py +81 -0
  95. pythonclaw/templates/skills/text/CATEGORY.md +4 -0
  96. pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
  97. pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
  98. pythonclaw/templates/skills/text/translator/translate.py +66 -0
  99. pythonclaw/templates/skills/web/CATEGORY.md +4 -0
  100. pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
  101. pythonclaw/templates/soul/SOUL.md +54 -0
  102. pythonclaw/web/__init__.py +1 -0
  103. pythonclaw/web/app.py +585 -0
  104. pythonclaw/web/static/favicon.png +0 -0
  105. pythonclaw/web/static/index.html +1318 -0
  106. pythonclaw/web/static/logo.png +0 -0
  107. pythonclaw-0.2.0.dist-info/METADATA +410 -0
  108. pythonclaw-0.2.0.dist-info/RECORD +112 -0
  109. pythonclaw-0.2.0.dist-info/WHEEL +5 -0
  110. pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
  111. pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
  112. 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]