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.
@@ -0,0 +1,4 @@
1
+ from x_agent_kit.agent import Agent
2
+ from x_agent_kit.tools.base import tool
3
+ from x_agent_kit.plan import Plan, PlanStep, PlanManager
4
+ from x_agent_kit.conversation import ConversationManager
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,6 @@
1
+ from __future__ import annotations
2
+ from x_agent_kit.models import BrainResponse, Message
3
+
4
+ class BaseBrain:
5
+ def think(self, messages: list[Message], tools: list[dict], system_prompt: str = "") -> BrainResponse:
6
+ raise NotImplementedError
@@ -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)