x-agent-kit 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.
- x_agent_kit/__init__.py +4 -0
- x_agent_kit/agent.py +243 -0
- x_agent_kit/approval_queue.py +60 -0
- x_agent_kit/brain/__init__.py +0 -0
- x_agent_kit/brain/base.py +6 -0
- x_agent_kit/brain/claude.py +264 -0
- x_agent_kit/brain/gemini.py +56 -0
- x_agent_kit/brain/openai_brain.py +38 -0
- x_agent_kit/channels/__init__.py +0 -0
- x_agent_kit/channels/base.py +16 -0
- x_agent_kit/channels/cli_channel.py +45 -0
- x_agent_kit/channels/feishu.py +418 -0
- x_agent_kit/channels/feishu_cards.py +373 -0
- x_agent_kit/config.py +101 -0
- x_agent_kit/conversation.py +23 -0
- x_agent_kit/i18n/__init__.py +47 -0
- x_agent_kit/i18n/en.json +53 -0
- x_agent_kit/i18n/zh_CN.json +53 -0
- x_agent_kit/memory.py +168 -0
- x_agent_kit/models.py +20 -0
- x_agent_kit/plan.py +185 -0
- x_agent_kit/progress.py +45 -0
- x_agent_kit/scheduler.py +26 -0
- x_agent_kit/skills/__init__.py +0 -0
- x_agent_kit/skills/loader.py +51 -0
- x_agent_kit/tools/__init__.py +1 -0
- x_agent_kit/tools/base.py +55 -0
- x_agent_kit/tools/builtin.py +285 -0
- x_agent_kit/tools/registry.py +34 -0
- x_agent_kit-0.2.0.dist-info/METADATA +20 -0
- x_agent_kit-0.2.0.dist-info/RECORD +33 -0
- x_agent_kit-0.2.0.dist-info/WHEEL +5 -0
- x_agent_kit-0.2.0.dist-info/top_level.txt +1 -0
x_agent_kit/__init__.py
ADDED
x_agent_kit/agent.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
from loguru import logger
|
|
5
|
+
from x_agent_kit.config import Config, load_config
|
|
6
|
+
from x_agent_kit.i18n import t, set_locale
|
|
7
|
+
from x_agent_kit.models import BrainResponse, Message
|
|
8
|
+
from x_agent_kit.progress import ProgressRenderer
|
|
9
|
+
from x_agent_kit.skills.loader import SkillLoader
|
|
10
|
+
from x_agent_kit.tools.builtin import create_list_skills_tool, create_load_skill_tool, create_notify_tool, create_request_approval_tool, create_save_memory_tool, create_recall_memories_tool, create_search_memory_tool, create_clear_memory_tool, create_plan_tool, create_submit_plan_tool, create_get_plan_tool, create_execute_approved_steps_tool, create_update_step_tool, create_resubmit_step_tool
|
|
11
|
+
from x_agent_kit.tools.registry import ToolRegistry
|
|
12
|
+
|
|
13
|
+
def create_brain(config: Config):
|
|
14
|
+
provider_name = config.brain.provider
|
|
15
|
+
provider = config.providers.get(provider_name)
|
|
16
|
+
if provider is None:
|
|
17
|
+
raise ValueError(f"Provider '{provider_name}' not configured")
|
|
18
|
+
model = config.brain.model or provider.default_model
|
|
19
|
+
if provider.type == "api" and provider_name == "gemini":
|
|
20
|
+
from x_agent_kit.brain.gemini import GeminiBrain
|
|
21
|
+
return GeminiBrain(api_key=provider.resolve_api_key(), model=model)
|
|
22
|
+
elif provider.type == "api" and provider_name == "openai":
|
|
23
|
+
from x_agent_kit.brain.openai_brain import OpenAIBrain
|
|
24
|
+
return OpenAIBrain(api_key=provider.resolve_api_key(), model=model)
|
|
25
|
+
elif provider.type == "cli":
|
|
26
|
+
from x_agent_kit.brain.claude import ClaudeBrain
|
|
27
|
+
return ClaudeBrain()
|
|
28
|
+
else:
|
|
29
|
+
raise ValueError(f"Unknown provider type: {provider.type}")
|
|
30
|
+
|
|
31
|
+
def create_channels(config: Config) -> dict[str, Any]:
|
|
32
|
+
channels = {}
|
|
33
|
+
raw = config.channels
|
|
34
|
+
default_name = raw.get("default", "cli") if isinstance(raw, dict) else "cli"
|
|
35
|
+
from x_agent_kit.channels.cli_channel import CLIChannel
|
|
36
|
+
channels["cli"] = CLIChannel()
|
|
37
|
+
if isinstance(raw, dict) and "feishu" in raw and isinstance(raw["feishu"], dict):
|
|
38
|
+
import os
|
|
39
|
+
fc = raw["feishu"]
|
|
40
|
+
app_id = os.environ.get(fc.get("app_id_env", ""), "")
|
|
41
|
+
app_secret = os.environ.get(fc.get("app_secret_env", ""), "")
|
|
42
|
+
chat_id = os.environ.get(fc.get("default_chat_id_env", ""), "")
|
|
43
|
+
if app_id and app_secret and chat_id:
|
|
44
|
+
from x_agent_kit.channels.feishu import FeishuChannel
|
|
45
|
+
channels["feishu"] = FeishuChannel(app_id, app_secret, chat_id)
|
|
46
|
+
channels["default"] = channels.get(default_name, channels["cli"])
|
|
47
|
+
return channels
|
|
48
|
+
|
|
49
|
+
class Agent:
|
|
50
|
+
def __init__(self, config_dir: str = ".agent", stop_condition: Callable[[str, Any], bool] | None = None) -> None:
|
|
51
|
+
self._config = load_config(config_dir)
|
|
52
|
+
set_locale(self._config.locale)
|
|
53
|
+
self._stop_condition = stop_condition
|
|
54
|
+
self._brain = create_brain(self._config)
|
|
55
|
+
self._tools = ToolRegistry()
|
|
56
|
+
self._skills = SkillLoader(self._config.skills.paths)
|
|
57
|
+
self._channels = create_channels(self._config)
|
|
58
|
+
self._tools.register(create_load_skill_tool(self._skills))
|
|
59
|
+
self._tools.register(create_list_skills_tool(self._skills))
|
|
60
|
+
self._tools.register(create_notify_tool(self._channels))
|
|
61
|
+
|
|
62
|
+
self._memory = None
|
|
63
|
+
self._approval_queue = None
|
|
64
|
+
self._reply_mode = False
|
|
65
|
+
if self._config.memory.enabled:
|
|
66
|
+
from x_agent_kit.memory import Memory
|
|
67
|
+
from x_agent_kit.approval_queue import ApprovalQueue
|
|
68
|
+
self._memory = Memory(memory_dir=self._config.memory.dir)
|
|
69
|
+
self._approval_queue = ApprovalQueue(db_path=str(Path(self._config.memory.dir) / "memory.db"))
|
|
70
|
+
self._tools.register(create_save_memory_tool(self._memory))
|
|
71
|
+
self._tools.register(create_recall_memories_tool(self._memory))
|
|
72
|
+
self._tools.register(create_search_memory_tool(self._memory))
|
|
73
|
+
self._tools.register(create_clear_memory_tool(self._memory))
|
|
74
|
+
|
|
75
|
+
# Wire up feishu channel for async execution (WebSocket started in serve() only)
|
|
76
|
+
feishu = self._channels.get("feishu")
|
|
77
|
+
if feishu and hasattr(feishu, 'set_approval_queue'):
|
|
78
|
+
feishu.set_approval_queue(self._approval_queue)
|
|
79
|
+
feishu.set_tool_executor(lambda name, args: self._tools.execute(name, args))
|
|
80
|
+
|
|
81
|
+
self._tools.register(create_request_approval_tool(self._channels, self._approval_queue))
|
|
82
|
+
|
|
83
|
+
# Plan manager (requires memory enabled)
|
|
84
|
+
self._plan_manager = None
|
|
85
|
+
self._conversation = None
|
|
86
|
+
if self._config.memory.enabled:
|
|
87
|
+
from x_agent_kit.plan import PlanManager
|
|
88
|
+
from x_agent_kit.conversation import ConversationManager
|
|
89
|
+
plan_db = str(Path(self._config.memory.dir) / "plans.db") if self._config.memory.dir != ":memory:" else ":memory:"
|
|
90
|
+
self._plan_manager = PlanManager(db_path=plan_db)
|
|
91
|
+
self._conversation = ConversationManager()
|
|
92
|
+
|
|
93
|
+
tool_executor = lambda name, args: self._tools.execute(name, args)
|
|
94
|
+
self._tools.register(create_plan_tool(self._plan_manager))
|
|
95
|
+
self._tools.register(create_submit_plan_tool(self._plan_manager, self._channels))
|
|
96
|
+
self._tools.register(create_get_plan_tool(self._plan_manager))
|
|
97
|
+
self._tools.register(create_execute_approved_steps_tool(self._plan_manager, tool_executor, self._channels))
|
|
98
|
+
self._tools.register(create_update_step_tool(self._plan_manager))
|
|
99
|
+
self._tools.register(create_resubmit_step_tool(self._plan_manager, self._channels))
|
|
100
|
+
|
|
101
|
+
feishu = self._channels.get("feishu")
|
|
102
|
+
if feishu and hasattr(feishu, 'set_plan_manager'):
|
|
103
|
+
feishu.set_plan_manager(self._plan_manager)
|
|
104
|
+
|
|
105
|
+
def register_tools(self, tools: list) -> None:
|
|
106
|
+
for t in tools:
|
|
107
|
+
self._tools.register(t)
|
|
108
|
+
|
|
109
|
+
def run(self, task: str) -> str:
|
|
110
|
+
if self._memory is not None:
|
|
111
|
+
mem_summary = self._memory.summary()
|
|
112
|
+
task_with_memory = f"{mem_summary}\n\n{task}"
|
|
113
|
+
else:
|
|
114
|
+
task_with_memory = task
|
|
115
|
+
messages = [Message(role="user", content=task_with_memory)]
|
|
116
|
+
max_iter = self._config.agent.max_iterations
|
|
117
|
+
notified = False
|
|
118
|
+
notify_content = ""
|
|
119
|
+
loaded_skills = set()
|
|
120
|
+
|
|
121
|
+
reply_mode = getattr(self, "_reply_mode", False)
|
|
122
|
+
default_ch = self._channels.get("default")
|
|
123
|
+
renderer = ProgressRenderer(channel=default_ch, enabled=not reply_mode)
|
|
124
|
+
|
|
125
|
+
for i in range(max_iter):
|
|
126
|
+
logger.info(f"Agent iteration {i+1}/{max_iter}")
|
|
127
|
+
renderer.update_text(t("agent.thinking"))
|
|
128
|
+
|
|
129
|
+
response = self._brain.think(messages=messages, tools=self._tools.schemas())
|
|
130
|
+
if response.done or (response.text and not response.tool_calls):
|
|
131
|
+
final = notify_content or response.text or t("agent.complete")
|
|
132
|
+
renderer.finish(t("agent.complete_title"), final, "green")
|
|
133
|
+
return notify_content or response.text or ""
|
|
134
|
+
|
|
135
|
+
if response.tool_calls:
|
|
136
|
+
for call in response.tool_calls:
|
|
137
|
+
if call.name == "notify":
|
|
138
|
+
if notified:
|
|
139
|
+
messages.append(Message(
|
|
140
|
+
role="tool_result", content="Already sent.",
|
|
141
|
+
tool_call_id=call.name,
|
|
142
|
+
))
|
|
143
|
+
continue
|
|
144
|
+
notified = True
|
|
145
|
+
notify_content = call.arguments.get("message", "")
|
|
146
|
+
renderer.update_text(notify_content)
|
|
147
|
+
if not renderer._card:
|
|
148
|
+
self._tools.execute(call.name, call.arguments)
|
|
149
|
+
messages.append(Message(role="tool_result", content="OK", tool_call_id=call.name))
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if call.name == "request_approval":
|
|
153
|
+
meta = self._tools.get_meta(call.name)
|
|
154
|
+
label = meta.label if meta and meta.label else f"📋 {call.arguments.get('action', '')}"
|
|
155
|
+
renderer.add_step(label)
|
|
156
|
+
logger.info(f"Tool call: {call.name}({call.arguments})")
|
|
157
|
+
result = self._tools.execute(call.name, call.arguments)
|
|
158
|
+
renderer.complete_step(label)
|
|
159
|
+
messages.append(Message(role="tool_result", content=str(result), tool_call_id=call.name))
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if call.name == "load_skill":
|
|
163
|
+
skill_name = call.arguments.get("name", "")
|
|
164
|
+
if skill_name in loaded_skills:
|
|
165
|
+
logger.info(f"Skipping duplicate load_skill: {skill_name}")
|
|
166
|
+
messages.append(Message(
|
|
167
|
+
role="tool_result", content=f"Skill '{skill_name}' already loaded.",
|
|
168
|
+
tool_call_id=call.name,
|
|
169
|
+
))
|
|
170
|
+
continue
|
|
171
|
+
loaded_skills.add(skill_name)
|
|
172
|
+
|
|
173
|
+
meta = self._tools.get_meta(call.name)
|
|
174
|
+
label = meta.label if meta and meta.label else f"🔧 {call.name}"
|
|
175
|
+
if call.name == "load_skill":
|
|
176
|
+
label = f"📚 {call.arguments.get('name', 'skill')}"
|
|
177
|
+
renderer.add_step(label)
|
|
178
|
+
|
|
179
|
+
logger.info(f"Tool call: {call.name}({call.arguments})")
|
|
180
|
+
result = self._tools.execute(call.name, call.arguments)
|
|
181
|
+
messages.append(Message(role="tool_result", content=str(result), tool_call_id=call.name))
|
|
182
|
+
renderer.complete_step(label)
|
|
183
|
+
|
|
184
|
+
if self._stop_condition and self._stop_condition(call.name, result):
|
|
185
|
+
logger.info(f"Stop condition met after {call.name}")
|
|
186
|
+
final = notify_content or t("agent.complete")
|
|
187
|
+
renderer.finish(t("agent.complete_title"), final, "green")
|
|
188
|
+
return response.text or ""
|
|
189
|
+
|
|
190
|
+
if response.text:
|
|
191
|
+
messages.append(Message(role="assistant", content=response.text))
|
|
192
|
+
|
|
193
|
+
renderer.warn(t("agent.max_iterations"))
|
|
194
|
+
return "Max iterations reached"
|
|
195
|
+
|
|
196
|
+
def serve(self, schedules: list | None = None) -> None:
|
|
197
|
+
"""Start scheduled agent. If schedules not provided, reads from config."""
|
|
198
|
+
feishu = self._channels.get("feishu")
|
|
199
|
+
|
|
200
|
+
# Register message handler BEFORE starting WebSocket so it gets registered
|
|
201
|
+
if self._conversation and feishu and hasattr(feishu, 'set_message_handler'):
|
|
202
|
+
def on_message(chat_id: str, text: str, message_id: str = ""):
|
|
203
|
+
logger.info(f"Incoming message from {chat_id}: {text[:50]}...")
|
|
204
|
+
# Show "processing" reaction immediately
|
|
205
|
+
reaction_id = None
|
|
206
|
+
if message_id and hasattr(feishu, 'add_reaction'):
|
|
207
|
+
reaction_id = feishu.add_reaction(message_id, "OnIt")
|
|
208
|
+
self._conversation.add_message("user", text, chat_id)
|
|
209
|
+
ctx = self._conversation.get_context(chat_id)
|
|
210
|
+
context_str = "\n".join(f"[{m['role']}] {m['content']}" for m in ctx[:-1]) if len(ctx) > 1 else ""
|
|
211
|
+
task = f"对话上下文:\n{context_str}\n\n用户消息: {text}" if context_str else text
|
|
212
|
+
# Run agent without streaming card (reply mode)
|
|
213
|
+
self._reply_mode = True
|
|
214
|
+
try:
|
|
215
|
+
result = self.run(task)
|
|
216
|
+
finally:
|
|
217
|
+
self._reply_mode = False
|
|
218
|
+
self._conversation.add_message("assistant", result, chat_id)
|
|
219
|
+
# Reply to original message
|
|
220
|
+
if message_id and hasattr(feishu, 'reply_text'):
|
|
221
|
+
feishu.reply_text(message_id, result)
|
|
222
|
+
# Replace "processing" reaction with "done"
|
|
223
|
+
if reaction_id:
|
|
224
|
+
feishu.remove_reaction(message_id, reaction_id)
|
|
225
|
+
feishu.add_reaction(message_id, "DONE")
|
|
226
|
+
logger.info(f"Replied to message {message_id[:20]}...")
|
|
227
|
+
feishu.set_message_handler(on_message)
|
|
228
|
+
logger.info("Feishu message handler registered for bidirectional comms")
|
|
229
|
+
|
|
230
|
+
# Start WebSocket AFTER handlers are registered
|
|
231
|
+
if feishu and hasattr(feishu, "_ensure_ws"):
|
|
232
|
+
feishu._ensure_ws()
|
|
233
|
+
logger.info("Feishu WebSocket started (card actions + message receive)")
|
|
234
|
+
|
|
235
|
+
from x_agent_kit.scheduler import Scheduler
|
|
236
|
+
sched = Scheduler()
|
|
237
|
+
items = schedules or self._config.schedules
|
|
238
|
+
for s in items:
|
|
239
|
+
cron_expr = s.cron if hasattr(s, 'cron') else s['cron']
|
|
240
|
+
task_str = s.task if hasattr(s, 'task') else s['task']
|
|
241
|
+
logger.info(f"Schedule: {cron_expr} -> {task_str[:50]}...")
|
|
242
|
+
sched.add(cron_expr, lambda t=task_str: self.run(t))
|
|
243
|
+
sched.start()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Async approval queue — stores pending approvals and executes on button click."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApprovalQueue:
|
|
14
|
+
"""SQLite-backed pending approval queue."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, db_path: str = ".agent/memory/memory.db") -> None:
|
|
17
|
+
self._db_path = db_path
|
|
18
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
19
|
+
self._conn.row_factory = sqlite3.Row
|
|
20
|
+
self._init_db()
|
|
21
|
+
|
|
22
|
+
def _init_db(self) -> None:
|
|
23
|
+
self._conn.executescript("""
|
|
24
|
+
CREATE TABLE IF NOT EXISTS pending_approvals (
|
|
25
|
+
request_id TEXT PRIMARY KEY,
|
|
26
|
+
action TEXT NOT NULL,
|
|
27
|
+
details TEXT NOT NULL,
|
|
28
|
+
tool_name TEXT NOT NULL,
|
|
29
|
+
tool_args TEXT NOT NULL,
|
|
30
|
+
status TEXT DEFAULT 'pending',
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
resolved_at TEXT
|
|
33
|
+
);
|
|
34
|
+
""")
|
|
35
|
+
self._conn.commit()
|
|
36
|
+
|
|
37
|
+
def add(self, request_id: str, action: str, details: str, tool_name: str, tool_args: dict) -> None:
|
|
38
|
+
self._conn.execute(
|
|
39
|
+
"INSERT OR REPLACE INTO pending_approvals (request_id, action, details, tool_name, tool_args, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', ?)",
|
|
40
|
+
(request_id, action, details, tool_name, json.dumps(tool_args, ensure_ascii=False), datetime.now().isoformat()),
|
|
41
|
+
)
|
|
42
|
+
self._conn.commit()
|
|
43
|
+
logger.info(f"Approval queued: {request_id} → {tool_name}")
|
|
44
|
+
|
|
45
|
+
def get(self, request_id: str) -> dict | None:
|
|
46
|
+
row = self._conn.execute("SELECT * FROM pending_approvals WHERE request_id = ?", (request_id,)).fetchone()
|
|
47
|
+
if not row:
|
|
48
|
+
return None
|
|
49
|
+
return dict(row)
|
|
50
|
+
|
|
51
|
+
def resolve(self, request_id: str, status: str) -> None:
|
|
52
|
+
self._conn.execute(
|
|
53
|
+
"UPDATE pending_approvals SET status = ?, resolved_at = ? WHERE request_id = ?",
|
|
54
|
+
(status, datetime.now().isoformat(), request_id),
|
|
55
|
+
)
|
|
56
|
+
self._conn.commit()
|
|
57
|
+
|
|
58
|
+
def pending_count(self) -> int:
|
|
59
|
+
row = self._conn.execute("SELECT COUNT(*) as c FROM pending_approvals WHERE status = 'pending'").fetchone()
|
|
60
|
+
return row["c"]
|
|
File without changes
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Claude Brain — uses claude CLI with session resume and streaming.
|
|
2
|
+
|
|
3
|
+
Approach (inspired by OpenClaw):
|
|
4
|
+
- First call: start new session with --session-id, stream-json output
|
|
5
|
+
- Subsequent calls: --resume session, pipe new messages via stdin
|
|
6
|
+
- Supports multi-turn conversation without re-sending full history
|
|
7
|
+
- Uses local Claude auth (OAuth), no API key needed
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from loguru import logger
|
|
17
|
+
|
|
18
|
+
from x_agent_kit.brain.base import BaseBrain
|
|
19
|
+
from x_agent_kit.models import BrainResponse, Message, ToolCall
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeBrain(BaseBrain):
|
|
23
|
+
"""Claude via CLI subprocess with session persistence."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, timeout: int = 300, model: str = "") -> None:
|
|
26
|
+
self._timeout = timeout
|
|
27
|
+
self._model = model
|
|
28
|
+
self._session_id = str(uuid.uuid4())
|
|
29
|
+
self._first_call = True
|
|
30
|
+
|
|
31
|
+
def think(
|
|
32
|
+
self,
|
|
33
|
+
messages: list[Message],
|
|
34
|
+
tools: list[dict],
|
|
35
|
+
system_prompt: str = "",
|
|
36
|
+
) -> BrainResponse:
|
|
37
|
+
if self._first_call:
|
|
38
|
+
return self._first_think(messages, tools, system_prompt)
|
|
39
|
+
else:
|
|
40
|
+
return self._resume_think(messages, tools)
|
|
41
|
+
|
|
42
|
+
def _first_think(
|
|
43
|
+
self, messages: list[Message], tools: list[dict], system_prompt: str
|
|
44
|
+
) -> BrainResponse:
|
|
45
|
+
"""First call: start a new session."""
|
|
46
|
+
prompt = self._build_prompt(messages, tools)
|
|
47
|
+
|
|
48
|
+
cmd = [
|
|
49
|
+
"claude", "-p",
|
|
50
|
+
"--output-format", "json",
|
|
51
|
+
"--session-id", self._session_id,
|
|
52
|
+
"--permission-mode", "bypassPermissions",
|
|
53
|
+
]
|
|
54
|
+
if self._model:
|
|
55
|
+
cmd.extend(["--model", self._model])
|
|
56
|
+
if system_prompt:
|
|
57
|
+
cmd.extend(["--system-prompt", system_prompt])
|
|
58
|
+
|
|
59
|
+
cmd.append(prompt)
|
|
60
|
+
|
|
61
|
+
result = self._run(cmd)
|
|
62
|
+
self._first_call = False
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
def _resume_think(
|
|
66
|
+
self, messages: list[Message], tools: list[dict]
|
|
67
|
+
) -> BrainResponse:
|
|
68
|
+
"""Subsequent calls: resume existing session."""
|
|
69
|
+
# Build only the new messages (tool results) as the prompt
|
|
70
|
+
new_content = self._build_resume_prompt(messages, tools)
|
|
71
|
+
|
|
72
|
+
cmd = [
|
|
73
|
+
"claude", "-p",
|
|
74
|
+
"--output-format", "json",
|
|
75
|
+
"--resume", self._session_id,
|
|
76
|
+
"--permission-mode", "bypassPermissions",
|
|
77
|
+
]
|
|
78
|
+
cmd.append(new_content)
|
|
79
|
+
|
|
80
|
+
return self._run(cmd)
|
|
81
|
+
|
|
82
|
+
def _run(self, cmd: list[str]) -> BrainResponse:
|
|
83
|
+
"""Execute claude CLI and parse response."""
|
|
84
|
+
logger.debug(f"Claude CLI call: session={self._session_id[:8]}...")
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=self._timeout,
|
|
91
|
+
)
|
|
92
|
+
if result.returncode != 0:
|
|
93
|
+
stderr = result.stderr[:500] if result.stderr else ""
|
|
94
|
+
logger.error(f"Claude CLI error: {stderr}")
|
|
95
|
+
return BrainResponse(text=f"Claude CLI error: {stderr}")
|
|
96
|
+
|
|
97
|
+
raw = result.stdout.strip()
|
|
98
|
+
logger.debug(f"Claude CLI raw output ({len(raw)} chars): {raw[:300]}...")
|
|
99
|
+
return self._parse_output(raw)
|
|
100
|
+
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
logger.error("Claude CLI not found in PATH")
|
|
103
|
+
return BrainResponse(text="Claude CLI not found")
|
|
104
|
+
except subprocess.TimeoutExpired:
|
|
105
|
+
logger.error(f"Claude CLI timeout ({self._timeout}s)")
|
|
106
|
+
return BrainResponse(text=f"Claude CLI timeout after {self._timeout}s")
|
|
107
|
+
|
|
108
|
+
def _build_prompt(
|
|
109
|
+
self, messages: list[Message], tools: list[dict]
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Build the initial prompt with tool definitions."""
|
|
112
|
+
parts = []
|
|
113
|
+
|
|
114
|
+
if tools:
|
|
115
|
+
# Compact tool descriptions (not full JSON schema)
|
|
116
|
+
tool_list = []
|
|
117
|
+
for t in tools:
|
|
118
|
+
func = t.get("function", {})
|
|
119
|
+
name = func.get("name", "")
|
|
120
|
+
desc = func.get("description", "")
|
|
121
|
+
params = func.get("parameters", {}).get("properties", {})
|
|
122
|
+
param_str = ", ".join(
|
|
123
|
+
f"{k}: {v.get('type', 'string')}"
|
|
124
|
+
for k, v in params.items()
|
|
125
|
+
)
|
|
126
|
+
tool_list.append(f"- {name}({param_str}) — {desc}")
|
|
127
|
+
|
|
128
|
+
parts.append("You have these tools available:\n" + "\n".join(tool_list))
|
|
129
|
+
parts.append(
|
|
130
|
+
"\nTo call a tool, respond with ONLY a JSON object like this:\n"
|
|
131
|
+
'{"tool_calls": [{"name": "tool_name", "arguments": {"param": "value"}}]}\n'
|
|
132
|
+
"To give a final answer, respond with:\n"
|
|
133
|
+
'{"text": "your answer", "done": true}\n'
|
|
134
|
+
"Always respond with valid JSON. No other text."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
for msg in messages:
|
|
138
|
+
if msg.role == "user":
|
|
139
|
+
parts.append(f"\nUser: {msg.content}")
|
|
140
|
+
elif msg.role == "tool_result":
|
|
141
|
+
parts.append(f"\nTool result ({msg.tool_call_id}): {msg.content}")
|
|
142
|
+
elif msg.role == "assistant":
|
|
143
|
+
parts.append(f"\nAssistant: {msg.content}")
|
|
144
|
+
|
|
145
|
+
return "\n".join(parts)
|
|
146
|
+
|
|
147
|
+
def _build_resume_prompt(
|
|
148
|
+
self, messages: list[Message], tools: list[dict]
|
|
149
|
+
) -> str:
|
|
150
|
+
"""Build prompt for resume — only include recent tool results."""
|
|
151
|
+
parts = []
|
|
152
|
+
# Find the last assistant message and only include messages after it
|
|
153
|
+
last_assistant_idx = -1
|
|
154
|
+
for i, msg in enumerate(messages):
|
|
155
|
+
if msg.role == "assistant":
|
|
156
|
+
last_assistant_idx = i
|
|
157
|
+
|
|
158
|
+
recent = messages[last_assistant_idx + 1:] if last_assistant_idx >= 0 else messages[-3:]
|
|
159
|
+
|
|
160
|
+
for msg in recent:
|
|
161
|
+
if msg.role == "tool_result":
|
|
162
|
+
parts.append(f"Tool result ({msg.tool_call_id}): {msg.content}")
|
|
163
|
+
elif msg.role == "user":
|
|
164
|
+
parts.append(msg.content)
|
|
165
|
+
|
|
166
|
+
if not parts:
|
|
167
|
+
parts.append("Continue with the next step.")
|
|
168
|
+
|
|
169
|
+
parts.append(
|
|
170
|
+
"\nRespond with JSON: "
|
|
171
|
+
'{"tool_calls": [...]} or {"text": "...", "done": true}'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return "\n".join(parts)
|
|
175
|
+
|
|
176
|
+
def _parse_output(self, output: str) -> BrainResponse:
|
|
177
|
+
"""Parse claude CLI JSON output."""
|
|
178
|
+
if not output:
|
|
179
|
+
return BrainResponse(text="")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
data = json.loads(output)
|
|
183
|
+
|
|
184
|
+
# claude --output-format json wraps in {"type":"result","result":"..."}
|
|
185
|
+
if isinstance(data, dict) and "result" in data:
|
|
186
|
+
inner = data["result"]
|
|
187
|
+
if isinstance(inner, str):
|
|
188
|
+
# Try to parse the inner result as JSON (tool calls)
|
|
189
|
+
# Handle mixed text+JSON: "Some text\n\n{\"tool_calls\": [...]}"
|
|
190
|
+
result = self._parse_inner(inner)
|
|
191
|
+
if result.text and not result.tool_calls:
|
|
192
|
+
# Maybe JSON is embedded in text, try to extract it
|
|
193
|
+
extracted = self._extract_json_from_text(inner)
|
|
194
|
+
if extracted.tool_calls:
|
|
195
|
+
return extracted
|
|
196
|
+
return result
|
|
197
|
+
return BrainResponse(text=str(inner))
|
|
198
|
+
|
|
199
|
+
# Direct JSON response
|
|
200
|
+
return self._parse_inner(output)
|
|
201
|
+
|
|
202
|
+
except json.JSONDecodeError:
|
|
203
|
+
# Try to extract JSON from mixed text
|
|
204
|
+
return self._extract_json_from_text(output)
|
|
205
|
+
|
|
206
|
+
def _parse_inner(self, text: str) -> BrainResponse:
|
|
207
|
+
"""Parse the inner content which may contain tool calls."""
|
|
208
|
+
try:
|
|
209
|
+
data = json.loads(text) if isinstance(text, str) else text
|
|
210
|
+
except (json.JSONDecodeError, TypeError):
|
|
211
|
+
return BrainResponse(text=str(text))
|
|
212
|
+
|
|
213
|
+
if isinstance(data, dict):
|
|
214
|
+
tool_calls = None
|
|
215
|
+
if data.get("tool_calls"):
|
|
216
|
+
tool_calls = [
|
|
217
|
+
ToolCall(
|
|
218
|
+
id=tc.get("name", str(i)),
|
|
219
|
+
name=tc["name"],
|
|
220
|
+
arguments=tc.get("arguments", {}),
|
|
221
|
+
)
|
|
222
|
+
for i, tc in enumerate(data["tool_calls"])
|
|
223
|
+
]
|
|
224
|
+
return BrainResponse(
|
|
225
|
+
text=data.get("text"),
|
|
226
|
+
tool_calls=tool_calls,
|
|
227
|
+
done=data.get("done", False),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return BrainResponse(text=str(data))
|
|
231
|
+
|
|
232
|
+
def _extract_json_from_text(self, text: str) -> BrainResponse:
|
|
233
|
+
"""Try to find JSON in text that may have extra content."""
|
|
234
|
+
import re
|
|
235
|
+
# Find the start of a JSON object containing tool_calls
|
|
236
|
+
idx = text.find('{"tool_calls"')
|
|
237
|
+
if idx == -1:
|
|
238
|
+
idx = text.find('{ "tool_calls"')
|
|
239
|
+
if idx >= 0:
|
|
240
|
+
# Extract from { to the end, try parsing progressively shorter substrings
|
|
241
|
+
candidate = text[idx:]
|
|
242
|
+
for end in range(len(candidate), 0, -1):
|
|
243
|
+
try:
|
|
244
|
+
data = json.loads(candidate[:end])
|
|
245
|
+
return self._parse_inner(candidate[:end])
|
|
246
|
+
except (json.JSONDecodeError, ValueError):
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Fallback: regex for any JSON with tool_calls
|
|
250
|
+
match = re.search(r'\{.*"tool_calls".*\}', text, re.DOTALL)
|
|
251
|
+
if match:
|
|
252
|
+
try:
|
|
253
|
+
return self._parse_inner(match.group())
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
match = re.search(r'\{[^{}]*"text"[^{}]*\}', text, re.DOTALL)
|
|
258
|
+
if match:
|
|
259
|
+
try:
|
|
260
|
+
return self._parse_inner(match.group())
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
return BrainResponse(text=text)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from google import genai
|
|
4
|
+
from google.genai import types
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from x_agent_kit.brain.base import BaseBrain
|
|
7
|
+
from x_agent_kit.models import BrainResponse, Message, ToolCall
|
|
8
|
+
|
|
9
|
+
class GeminiBrain(BaseBrain):
|
|
10
|
+
def __init__(self, api_key: str, model: str = "gemini-2.5-flash") -> None:
|
|
11
|
+
self._client = genai.Client(api_key=api_key)
|
|
12
|
+
self._model = model
|
|
13
|
+
|
|
14
|
+
def think(self, messages: list[Message], tools: list[dict], system_prompt: str = "") -> BrainResponse:
|
|
15
|
+
contents = self._build_contents(messages)
|
|
16
|
+
gemini_tools = self._convert_tools(tools) if tools else None
|
|
17
|
+
config = types.GenerateContentConfig()
|
|
18
|
+
if system_prompt:
|
|
19
|
+
config.system_instruction = system_prompt
|
|
20
|
+
if gemini_tools:
|
|
21
|
+
config.tools = gemini_tools
|
|
22
|
+
response = self._client.models.generate_content(model=self._model, contents=contents, config=config)
|
|
23
|
+
return self._parse_response(response)
|
|
24
|
+
|
|
25
|
+
def _build_contents(self, messages: list[Message]) -> list[dict[str, Any]]:
|
|
26
|
+
contents = []
|
|
27
|
+
for msg in messages:
|
|
28
|
+
if msg.role == "user":
|
|
29
|
+
contents.append({"role": "user", "parts": [{"text": msg.content}]})
|
|
30
|
+
elif msg.role == "assistant":
|
|
31
|
+
contents.append({"role": "model", "parts": [{"text": msg.content}]})
|
|
32
|
+
elif msg.role == "tool_result":
|
|
33
|
+
contents.append({"role": "user", "parts": [{"function_response": {"name": msg.tool_call_id or "unknown", "response": {"result": msg.content}}}]})
|
|
34
|
+
return contents
|
|
35
|
+
|
|
36
|
+
def _convert_tools(self, tools: list[dict]) -> list[types.Tool]:
|
|
37
|
+
declarations = []
|
|
38
|
+
for t in tools:
|
|
39
|
+
func = t.get("function", {})
|
|
40
|
+
declarations.append(types.FunctionDeclaration(name=func.get("name", ""), description=func.get("description", ""), parameters=func.get("parameters", {})))
|
|
41
|
+
return [types.Tool(function_declarations=declarations)]
|
|
42
|
+
|
|
43
|
+
def _parse_response(self, response) -> BrainResponse:
|
|
44
|
+
if not response.candidates:
|
|
45
|
+
return BrainResponse(text="")
|
|
46
|
+
content = response.candidates[0].content
|
|
47
|
+
parts = content.parts if content and content.parts else []
|
|
48
|
+
text_parts = []
|
|
49
|
+
tool_calls = []
|
|
50
|
+
for part in parts:
|
|
51
|
+
if part.function_call:
|
|
52
|
+
fc = part.function_call
|
|
53
|
+
tool_calls.append(ToolCall(id=fc.name, name=fc.name, arguments=dict(fc.args) if fc.args else {}))
|
|
54
|
+
elif part.text:
|
|
55
|
+
text_parts.append(part.text)
|
|
56
|
+
return BrainResponse(text="".join(text_parts) if text_parts else None, tool_calls=tool_calls if tool_calls else None)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import openai
|
|
4
|
+
from loguru import logger
|
|
5
|
+
from x_agent_kit.brain.base import BaseBrain
|
|
6
|
+
from x_agent_kit.models import BrainResponse, Message, ToolCall
|
|
7
|
+
|
|
8
|
+
class OpenAIBrain(BaseBrain):
|
|
9
|
+
def __init__(self, api_key: str, model: str = "gpt-4o") -> None:
|
|
10
|
+
self._client = openai.OpenAI(api_key=api_key)
|
|
11
|
+
self._model = model
|
|
12
|
+
|
|
13
|
+
def think(self, messages: list[Message], tools: list[dict], system_prompt: str = "") -> BrainResponse:
|
|
14
|
+
oai_messages = self._build_messages(messages, system_prompt)
|
|
15
|
+
kwargs = {"model": self._model, "messages": oai_messages}
|
|
16
|
+
if tools:
|
|
17
|
+
kwargs["tools"] = tools
|
|
18
|
+
response = self._client.chat.completions.create(**kwargs)
|
|
19
|
+
return self._parse_response(response)
|
|
20
|
+
|
|
21
|
+
def _build_messages(self, messages: list[Message], system_prompt: str) -> list[dict]:
|
|
22
|
+
oai = []
|
|
23
|
+
if system_prompt:
|
|
24
|
+
oai.append({"role": "system", "content": system_prompt})
|
|
25
|
+
for msg in messages:
|
|
26
|
+
if msg.role == "tool_result":
|
|
27
|
+
oai.append({"role": "tool", "content": msg.content, "tool_call_id": msg.tool_call_id or ""})
|
|
28
|
+
else:
|
|
29
|
+
oai.append({"role": msg.role, "content": msg.content})
|
|
30
|
+
return oai
|
|
31
|
+
|
|
32
|
+
def _parse_response(self, response) -> BrainResponse:
|
|
33
|
+
choice = response.choices[0]
|
|
34
|
+
msg = choice.message
|
|
35
|
+
tool_calls = None
|
|
36
|
+
if msg.tool_calls:
|
|
37
|
+
tool_calls = [ToolCall(id=tc.id, name=tc.function.name, arguments=json.loads(tc.function.arguments)) for tc in msg.tool_calls]
|
|
38
|
+
return BrainResponse(text=msg.content, tool_calls=tool_calls)
|