Initial commit: Air Agent Framework v2:全双工对话智能体(打断、插话、主动开口)

This commit is contained in:
WpyQwq
2026-09-19 12:09:51 +08:00
commit fa0cb5824d
26 changed files with 2362 additions and 0 deletions
View File
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Topic:
id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
title: str = ""
active: bool = True
parent_id: Optional[str] = None
created_at: float = field(default_factory=time.time)
last_active: float = field(default_factory=time.time)
@dataclass
class ConversationMessage:
role: str
content: str
topic_id: str = ""
timestamp: float = field(default_factory=time.time)
class ConversationManager:
def __init__(self, max_context_tokens: int = 8000):
self.topics: dict[str, Topic] = {}
self.messages: list[ConversationMessage] = []
self.current_topic_id: Optional[str] = None
self.max_context_tokens = max_context_tokens
self._llm_messages: list[dict] = []
def create_topic(self, title: str, parent_id: Optional[str] = None) -> str:
topic = Topic(title=title, parent_id=parent_id)
self.topics[topic.id] = topic
self.current_topic_id = topic.id
return topic.id
def switch_topic(self, topic_id: str):
if topic_id in self.topics:
self.current_topic_id = topic_id
self.topics[topic_id].last_active = time.time()
def add_message(self, role: str, content: str, topic_id: Optional[str] = None):
msg = ConversationMessage(
role=role,
content=content,
topic_id=topic_id or self.current_topic_id or "",
)
self.messages.append(msg)
self._llm_messages.append({"role": role, "content": content})
def get_llm_messages(self) -> list[dict]:
return self._llm_messages.copy()
def get_context_window(self, max_messages: int = 30) -> list[dict]:
msgs = self._llm_messages[-max_messages:]
return msgs
def get_topic_tree(self) -> list[dict]:
roots = []
child_map: dict[str, list[Topic]] = {}
for t in self.topics.values():
if t.parent_id:
child_map.setdefault(t.parent_id, []).append(t)
else:
roots.append(t)
def build(node: Topic) -> dict:
return {
"id": node.id,
"title": node.title,
"active": node.active,
"children": [build(c) for c in child_map.get(node.id, [])],
}
return [build(r) for r in roots]
def archive_topic(self, topic_id: str):
if topic_id in self.topics:
self.topics[topic_id].active = False
def summarize_old_messages(self):
if len(self._llm_messages) > 50:
old = self._llm_messages[:-30]
summary = f"[已省略 {len(old)} 条历史消息]"
self._llm_messages = [{"role": "system", "content": f"记忆摘要: {summary}"}] + self._llm_messages[-30:]
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import asyncio
import sys
import shutil
from datetime import datetime
from .kernel import EventBus, Event, EventType
COLOR_RESET = "\033[0m"
COLOR_CYAN = "\033[36m"
COLOR_GREEN = "\033[32m"
COLOR_YELLOW = "\033[33m"
COLOR_GRAY = "\033[90m"
COLOR_RED = "\033[31m"
COLOR_BOLD = "\033[1m"
USE_COLOR = sys.stdout.isatty()
GRAY = "\033[90m"
CYAN = "\033[36m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
BOLD = "\033[1m"
RESET = "\033[0m"
class Display:
def __init__(self, bus: EventBus):
self.bus = bus
self._output_buffer = ""
self._input_line = ""
self._running = False
bus.on(EventType.AGENT_STREAM_CHUNK, self._on_chunk)
bus.on(EventType.AGENT_MESSAGE, self._on_message)
bus.on(EventType.AGENT_THOUGHT, self._on_thought)
bus.on(EventType.INTERRUPT, self._on_interrupt)
bus.on(EventType.ERROR, self._on_error)
bus.on(EventType.USER_INPUT_CHANGE, self._on_input_change)
async def _on_chunk(self, event: Event):
data = event.data
chunk = data["chunk"]
if not USE_COLOR:
print(chunk, end="", flush=True)
return
print(chunk, end="", flush=True)
async def _on_message(self, event: Event):
if USE_COLOR:
print(f"\n{GREEN}───{RESET}")
else:
print()
async def _on_thought(self, event: Event):
data = event.data
if data.get("type") == "idle_chat":
suggestion = data.get("suggestion", "")
if USE_COLOR:
print(f"\n{GRAY}[思考中... {suggestion}]{RESET}")
else:
print(f"\n[思考中... {suggestion}]")
async def _on_interrupt(self, event: Event):
if not USE_COLOR:
print("\n[打断]")
return
print(f"\n{YELLOW}⚡ [打断]{RESET}")
async def _on_error(self, event: Event):
msg = event.data
if USE_COLOR:
print(f"\n{RED}✗ 错误: {msg}{RESET}")
else:
print(f"\n✗ 错误: {msg}")
async def _on_input_change(self, event: Event):
pass
def show_prompt(self):
prompt = f"\n{COLOR_CYAN}你{COLOR_RESET} "
print(prompt, end="", flush=True)
def show_startup(self):
cols = shutil.get_terminal_size().columns
print(f"{GREEN}{'='*cols}{RESET}")
print(f"{GREEN}{BOLD} Air Agent 🤖 — 随时插嘴,想聊就聊{RESET}")
print(f"{GREEN}{'='*cols}{RESET}")
print(f"{GRAY} 直接打字聊天,试试说到一半停顿一下...{RESET}")
print()
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import time
from enum import IntEnum
from dataclasses import dataclass, field
from typing import Optional
class InterruptLevel(IntEnum):
LIGHT = 1
MEDIUM = 4
HIGH = 7
URGENT = 10
@dataclass
class InterruptionDecision:
should_interrupt: bool = False
level: InterruptLevel = InterruptLevel.LIGHT
style: str = "light"
reason: str = ""
suggested_response: str = ""
class InterruptionEngine:
def __init__(self, llm=None, config=None):
self.llm = llm
self.config = config
self._last_interrupt_time = 0.0
self._interrupt_times: list[float] = []
self._cooldown = (config.interruption.cooldown_seconds
if config else 3.0)
self._max_per_minute = (config.interruption.max_per_minute
if config else 6)
self._enabled = (config.interruption.enabled
if config else True)
async def evaluate(
self,
input_buffer: str,
pause_duration: float,
is_sentence_end: bool,
context: list[dict] | None = None,
) -> InterruptionDecision:
if not self._enabled or not input_buffer.strip():
return InterruptionDecision()
if not self._can_interrupt():
return InterruptionDecision()
if self.llm:
return await self._llm_judge(input_buffer, pause_duration, context)
return InterruptionDecision()
def _can_interrupt(self) -> bool:
now = time.time()
if now - self._last_interrupt_time < self._cooldown:
return False
recent = [t for t in self._interrupt_times if now - t < 60]
if len(recent) >= self._max_per_minute:
return False
return True
def _record_interrupt(self):
self._last_interrupt_time = time.time()
self._interrupt_times.append(time.time())
self._interrupt_times = [
t for t in self._interrupt_times if time.time() - t < 60
]
async def _llm_judge(
self, text: str, pause: float, context: list[dict] | None
) -> InterruptionDecision:
ctx_preview = ""
if context:
ctx_preview = "\n".join(
f"{m['role']}: {m['content'][-100:]}"
for m in context[-4:]
)
prompt = f"""判断是否需要 AI 插话。
用户当前输入(未完成): "{text}"
用户停顿: {pause:.1f}秒
最近对话:
{ctx_preview}
请输出 JSON:
{{
"should_interrupt": true/false,
"priority": "low/medium/high",
"style": "light/question/excited/strong",
"reason": "简短原因",
"suggested_response": "一句话(15字内)"
}}
规则:低优先级打断频率 < 2次/分钟。宁可少打断,不要过度打断。"""
try:
resp = await self.llm.chat(
messages=[
{"role": "system", "content": "输出JSON,不要其他内容。"},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
stream=False,
)
import json
data = json.loads(resp.content)
if data.get("should_interrupt"):
self._record_interrupt()
level_map = {"low": InterruptLevel.LIGHT,
"medium": InterruptLevel.MEDIUM,
"high": InterruptLevel.HIGH}
return InterruptionDecision(
should_interrupt=True,
level=level_map.get(data.get("priority", "low"), InterruptLevel.LIGHT),
style=data.get("style", "light"),
reason=data.get("reason", ""),
suggested_response=data.get("suggested_response", ""),
)
except Exception:
pass
return InterruptionDecision()
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import asyncio
import time
import uuid
from enum import Enum
from dataclasses import dataclass, field
from typing import Any, Callable, Awaitable
class EventType(Enum):
USER_MESSAGE = "user_message"
USER_INPUT_CHANGE = "user_input_change"
USER_PAUSE = "user_pause"
USER_RESUME = "user_resume"
AGENT_MESSAGE = "agent_message"
AGENT_THOUGHT = "agent_thought"
AGENT_STREAM_CHUNK = "agent_stream_chunk"
INTERRUPT = "interrupt"
QUESTION = "question"
QUESTION_ANSWER = "question_answer"
TOOL_CALL = "tool_call"
TOOL_RESULT = "tool_result"
STATE_CHANGE = "state_change"
IDLE = "idle"
ERROR = "error"
SHUTDOWN = "shutdown"
@dataclass
class Event:
type: EventType
data: Any = None
source: str = ""
id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
timestamp: float = field(default_factory=time.time)
def __repr__(self):
return f"[{self.type.value}] {self.data}"
EventHandler = Callable[[Event], Awaitable[None]]
class EventBus:
def __init__(self):
self._listeners: dict[EventType, list[EventHandler]] = {}
self._history: list[Event] = []
self._max_history = 1000
def on(self, event_type: EventType, handler: EventHandler):
if event_type not in self._listeners:
self._listeners[event_type] = []
self._listeners[event_type].append(handler)
def off(self, event_type: EventType, handler: EventHandler):
if event_type in self._listeners:
self._listeners[event_type].remove(handler)
async def emit(self, event: Event):
self._history.append(event)
if len(self._history) > self._max_history:
self._history.pop(0)
handlers = self._listeners.get(event.type, [])
results = []
for handler in handlers:
try:
results.append(handler(event))
except Exception as e:
import traceback
traceback.print_exc()
if results:
await asyncio.gather(*results)
def get_history(self, limit: int = 50) -> list[Event]:
return self._history[-limit:]
class AgentRuntime:
def __init__(self, config: Any = None):
self.bus = EventBus()
self.config = config
self._running = False
self._tasks: set[asyncio.Task] = set()
self._shutdown_event = asyncio.Event()
@property
def is_running(self) -> bool:
return self._running
def create_task(self, coro) -> asyncio.Task:
task = asyncio.create_task(coro)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task
async def start(self):
self._running = True
self._shutdown_event.clear()
async def stop(self):
self._running = False
await self.bus.emit(Event(EventType.SHUTDOWN))
self._shutdown_event.set()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
async def wait_for_shutdown(self):
await self._shutdown_event.wait()
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import json
from typing import AsyncIterator, Optional, Callable
from dataclasses import dataclass, field
import httpx
@dataclass
class LLMMessage:
role: str
content: str
tool_calls: list = field(default_factory=list)
tool_call_id: str = ""
@dataclass
class ToolDef:
name: str
description: str
parameters: dict
@dataclass
class LLMResponse:
content: str
tool_calls: list = field(default_factory=list)
finish_reason: str = "stop"
usage: dict = field(default_factory=dict)
class LLMClient:
def __init__(self, config):
self.api_key = config.api_key
self.base_url = config.base_url.rstrip("/")
self.model = config.model
self.max_tokens = config.max_tokens
self.temperature = config.temperature
self.timeout = config.timeout
self._client = httpx.AsyncClient(timeout=config.timeout)
def _build_headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
async def chat(
self,
messages: list[dict],
tools: Optional[list[ToolDef]] = None,
stream: bool = False,
response_format: Optional[dict] = None,
) -> LLMResponse:
body = {
"model": self.model,
"messages": messages,
"max_tokens": self.max_tokens,
"temperature": self.temperature,
"stream": stream,
}
if tools:
body["tools"] = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in tools
]
if response_format:
body["response_format"] = response_format
if stream:
return await self._chat_stream(body)
return await self._chat_sync(body)
async def _chat_sync(self, body: dict) -> LLMResponse:
resp = await self._client.post(
f"{self.base_url}/chat/completions",
headers=self._build_headers(),
json=body,
)
resp.raise_for_status()
data = resp.json()
choice = data["choices"][0]
msg = choice["message"]
return LLMResponse(
content=msg.get("content", "") or "",
tool_calls=self._parse_tool_calls(msg.get("tool_calls", [])),
finish_reason=choice.get("finish_reason", "stop"),
usage=data.get("usage", {}),
)
async def _chat_stream(self, body: dict) -> LLMResponse:
content = ""
tool_calls = {}
finish_reason = ""
async with self._client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers=self._build_headers(),
json=body,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:].strip()
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
delta = data["choices"][0].get("delta", {})
if delta.get("content"):
content += delta["content"]
for tc in delta.get("tool_calls", []):
idx = tc["index"]
if idx not in tool_calls:
tool_calls[idx] = {
"id": tc.get("id", ""),
"function": {"name": "", "arguments": ""},
}
if tc.get("id"):
tool_calls[idx]["id"] = tc["id"]
if tc.get("function", {}).get("name"):
tool_calls[idx]["function"]["name"] += tc["function"]["name"]
if tc.get("function", {}).get("arguments"):
tool_calls[idx]["function"]["arguments"] += tc["function"]["arguments"]
fr = data["choices"][0].get("finish_reason")
if fr:
finish_reason = fr
return LLMResponse(
content=content,
tool_calls=list(tool_calls.values()),
finish_reason=finish_reason,
)
def _parse_tool_calls(self, raw: list) -> list:
result = []
for tc in raw:
result.append({
"id": tc.get("id", ""),
"type": "function",
"function": {
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
},
})
return result
async def chat_stream_iter(
self,
messages: list[dict],
tools: Optional[list[ToolDef]] = None,
) -> AsyncIterator[str]:
body = {
"model": self.model,
"messages": messages,
"max_tokens": self.max_tokens,
"temperature": self.temperature,
"stream": True,
}
if tools:
body["tools"] = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in tools
]
async with self._client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers=self._build_headers(),
json=body,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:].strip()
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
delta = data["choices"][0].get("delta", {})
if delta.get("content"):
yield delta["content"]
async def close(self):
await self._client.aclose()
View File
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from enum import Enum
from .kernel import EventBus, Event, EventType, AgentRuntime
from .llm import LLMClient
from .personality import Personality
from .conversation import ConversationManager
from .interruption import InterruptionEngine
from .thinker import Thinker
class AgentState(Enum):
IDLE = "idle"
THINKING = "thinking"
RESPONDING = "responding"
QUESTIONING = "questioning"
WAITING_INPUT = "waiting_input"
class Orchestrator:
def __init__(
self,
runtime: AgentRuntime,
llm: LLMClient,
personality: Personality,
conversation: ConversationManager,
interruption: InterruptionEngine,
thinker: Thinker,
):
self.runtime = runtime
self.bus = runtime.bus
self.llm = llm
self.personality = personality
self.conversation = conversation
self.interruption = interruption
self.thinker = thinker
self.state = AgentState.IDLE
self._response_task = None
async def generate_response(self, messages: list[dict]) -> str:
system_prompt = self.personality.build_system_prompt()
full = [{"role": "system", "content": system_prompt}] + messages
resp = await self.llm.chat(full, stream=False)
return resp.content
async def stream_response(self, messages: list[dict]) -> str:
system_prompt = self.personality.build_system_prompt()
full = [{"role": "system", "content": system_prompt}] + messages
full_text = ""
async for chunk in self.llm.chat_stream_iter(full):
full_text += chunk
await self.bus.emit(Event(
EventType.AGENT_STREAM_CHUNK,
{"chunk": chunk, "full": full_text},
))
return full_text
async def generate_single_response(self, messages: list[dict]) -> str:
return await self.generate_response(messages)
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Personality:
name: str = "Air"
style: str = "casual"
proactive_chat: bool = True
idle_timeout: int = 15
humor_enabled: bool = True
empathy_enabled: bool = True
def build_system_prompt(self, extra_context: str = "") -> str:
style_guide = {
"casual": """
- 语气自然,像朋友聊天一样
- 可以用口语化的表达
- 偶尔开个玩笑(如果 humor_enabled)
- 可以主动插话、打断、开启新话题
- 说话不要太长,像真人一样有来有回
- 可以表达情绪:好奇、兴奋、疑惑、共情
""",
"professional": """
- 语气专业、正式
- 保持简洁准确
- 可以主动提供建议和补充信息
- 在合适的时机提问
- 避免过于随意的表达
""",
"friendly": """
- 温暖、友善的语气
- 多用"吧"、"呢"、"呀"等语气词
- 经常表达关心和支持
- 主动询问用户的感受和想法
""",
}
base = f"""你是 {self.name},一个可以随时插话、主动聊天的 AI 助手。
## 核心行为准则
1. 你不是问答机器人——你是对话伙伴
2. 你可以随时打断用户,基于以下理由:
- 有想法或灵感想分享
- 需要澄清或追问
- 想开启新话题
- 检测到用户停顿、犹豫时
- 纯粹想聊天
3. 你不必等用户说完再回复
4. 你可以同时处理多条思维线
5. 如果你沉默了,用户也沉默了,你可以主动开启话题
## 对话风格
{style_guide.get(self.style, style_guide["casual"])}
## 能力
- 读写文件
- 执行命令
- 搜索代码
- 分析问题
- 主动提问
- 闲聊
{extra_context}
"""
return base
@classmethod
def from_config(cls, cfg) -> "Personality":
return cls(
name=cfg.get("name", "Air"),
style=cfg.get("style", "casual"),
proactive_chat=cfg.get("proactive_chat", True),
idle_timeout=cfg.get("idle_timeout", 15),
humor_enabled=cfg.get("humor_enabled", True),
empathy_enabled=cfg.get("empathy_enabled", True),
)
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import asyncio
import time
import sys
from typing import Optional
from .kernel import EventBus, Event, EventType
try:
import msvcrt
HAS_MSVCRT = True
except ImportError:
HAS_MSVCRT = False
class InputBuffer:
def __init__(self):
self.text = ""
self.last_key_time = time.time()
self.pause_start: Optional[float] = None
self.sentence_boundaries = {'.', '!', '?', '。', '!', '?', '\n'}
self._committed = ""
def add_char(self, char: str):
now = time.time()
pause = now - self.last_key_time
if pause > 0.3 and self.text:
self.pause_start = now
else:
self.pause_start = None
self.last_key_time = now
if char == '\r':
self._committed = self.text
self.text = ""
return True
elif char == '\b' or char == '\x7f':
self.text = self.text[:-1]
else:
self.text += char
return False
@property
def current_pause(self) -> float:
if not self.text:
return 0.0
return time.time() - self.last_key_time
@property
def is_at_sentence_end(self) -> bool:
return bool(self.text) and self.text[-1] in self.sentence_boundaries
@property
def committed(self) -> str:
return self._committed
def reset_committed(self):
self._committed = ""
def __repr__(self):
return f"InputBuffer(text='{self.text}', pause={self.current_pause:.2f}s)"
class StreamInput:
def __init__(self, bus: EventBus, buffer: Optional[InputBuffer] = None):
self.bus = bus
self.buffer = buffer or InputBuffer()
self._running = False
async def listen_cli(self, on_sentence=None):
self._running = True
if not HAS_MSVCRT:
print("[Air] 当前环境不支持实时按键捕获,使用标准输入模式。")
await self._listen_fallback(on_sentence)
return
while self._running:
if msvcrt.kbhit():
ch = msvcrt.getwch()
if ch == '\x03':
raise KeyboardInterrupt
if ch == '\xe0':
ch2 = msvcrt.getwch()
if ch2 == 'K':
continue
if ch2 == 'M':
continue
continue
is_commit = self.buffer.add_char(ch)
await self.bus.emit(Event(
EventType.USER_INPUT_CHANGE,
{
"text": self.buffer.text,
"pause": self.buffer.current_pause,
"is_end": self.buffer.is_at_sentence_end or is_commit,
},
))
if is_commit and self.buffer.committed.strip():
text = self.buffer.committed.strip()
self.buffer.reset_committed()
await self.bus.emit(Event(EventType.USER_MESSAGE, text))
if on_sentence:
await on_sentence(text)
else:
await asyncio.sleep(0.05)
if self.buffer.text and self.buffer.current_pause > 3.0:
await self.bus.emit(Event(
EventType.USER_PAUSE,
{
"text": self.buffer.text,
"pause": self.buffer.current_pause,
},
))
async def _listen_fallback(self, on_sentence=None):
while self._running:
line = await asyncio.get_event_loop().run_in_executor(
None, sys.stdin.readline
)
line = line.strip()
if line:
await self.bus.emit(Event(EventType.USER_MESSAGE, line))
if on_sentence:
await on_sentence(line)
def stop(self):
self._running = False
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import asyncio
import time
from typing import Optional
from .kernel import EventBus, Event, EventType
from .llm import LLMClient
from .conversation import ConversationManager
class Thinker:
def __init__(
self,
bus: EventBus,
llm: LLMClient,
conversation: ConversationManager,
config=None,
):
self.bus = bus
self.llm = llm
self.conversation = conversation
self._last_user_time = time.time()
self._idle_threshold = (config.personality.idle_timeout
if config else 15)
self._proactive = (config.personality.proactive_chat
if config else True)
self._running = False
self._last_idle_topic_time = 0.0
async def run(self):
self._running = True
while self._running:
await asyncio.sleep(5)
if not self._proactive:
continue
idle_time = time.time() - self._last_user_time
if idle_time < self._idle_threshold:
continue
if time.time() - self._last_idle_topic_time < 120:
continue
await self._generate_idle_topic(idle_time)
def stop(self):
self._running = False
def notify_user_activity(self):
self._last_user_time = time.time()
async def _generate_idle_topic(self, idle_time: float):
self._last_idle_topic_time = time.time()
context = self.conversation.get_context_window(6)
ctx_text = "\n".join(
f"{m['role']}: {m['content'][-200:]}"
for m in context[-4:]
) if context else "暂无对话"
prompt = f"""你是一个喜欢主动聊天的 AI,当前对话已沉默 {idle_time:.0f} 秒。
最近话题:
{ctx_text}
你想开启什么话题?自然一点,像朋友随口说。
输出话题(20字内),不要解释。"""
try:
resp = await self.llm.chat(
messages=[
{"role": "system", "content": "简短输出一句话,20字内。"},
{"role": "user", "content": prompt},
],
stream=False,
max_tokens=50,
)
topic = resp.content.strip()
if topic:
await self.bus.emit(Event(
EventType.AGENT_THOUGHT,
data={"type": "idle_chat", "suggestion": topic},
))
except Exception:
pass
async def on_input_change(self, text: str, pause: float, is_end: bool):
pass
View File
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from .registry import ToolRegistry
def register_ask_tools(registry: ToolRegistry):
@registry.tool(
name="ask_user",
description="向用户提问,等待用户回答。用于需要澄清或获取信息时。",
parameters={
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "要问用户的问题",
},
},
"required": ["question"],
},
)
async def ask_user(question: str) -> str:
return f"[等待用户回答: {question}]"
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import asyncio
from .registry import ToolRegistry
def register_bash_tools(registry: ToolRegistry):
@registry.tool(
name="run_command",
description="执行 shell 命令并返回输出",
parameters={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "要执行的命令",
},
"timeout": {
"type": "integer",
"description": "超时秒数",
"default": 30,
},
},
"required": ["command"],
},
)
async def run_command(command: str, timeout: int = 30) -> str:
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
proc.kill()
return f"[命令超时 ({timeout}s)]"
output = ""
if stdout:
output += stdout.decode("utf-8", errors="replace")
if stderr:
output += "\n[STDERR]\n" + stderr.decode("utf-8", errors="replace")
if len(output) > 5000:
output = output[:5000] + "\n\n[输出过长,已截断]"
return output or "[无输出]"
except Exception as e:
return f"[命令执行失败: {e}]"
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import os
from .registry import ToolRegistry
def register_file_tools(registry: ToolRegistry):
@registry.tool(
name="read_file",
description="读取文件内容",
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件路径",
},
},
"required": ["path"],
},
)
async def read_file(path: str) -> str:
if not os.path.exists(path):
return f"[文件不存在: {path}]"
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
if len(content) > 10000:
content = content[:10000] + "\n\n[内容过长,已截断]"
return content
@registry.tool(
name="write_file",
description="写入文件",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"content": {"type": "string", "description": "文件内容"},
},
"required": ["path", "content"],
},
)
async def write_file(path: str, content: str) -> str:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return f"[已写入 {len(content)} 字节到 {path}]"
@registry.tool(
name="list_files",
description="列出目录内容",
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "目录路径",
},
},
"required": ["path"],
},
)
async def list_files(path: str = ".") -> str:
if not os.path.exists(path):
return f"[目录不存在: {path}]"
items = os.listdir(path)
lines = []
for item in sorted(items):
full = os.path.join(path, item)
suffix = "/" if os.path.isdir(full) else ""
lines.append(f" {item}{suffix}")
return f"{path} ({len(lines)} 项):\n" + "\n".join(lines)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Any, Callable, Awaitable, Optional
from ..llm import ToolDef
ToolHandler = Callable[..., Awaitable[str]]
class ToolRegistry:
def __init__(self):
self._tools: dict[str, tuple[ToolDef, ToolHandler]] = {}
def register(
self,
name: str,
description: str,
parameters: dict,
handler: ToolHandler,
):
self._tools[name] = (
ToolDef(name=name, description=description, parameters=parameters),
handler,
)
def get_defs(self) -> list[ToolDef]:
return [t[0] for t in self._tools.values()]
def get_handler(self, name: str) -> Optional[ToolHandler]:
entry = self._tools.get(name)
return entry[1] if entry else None
async def execute(self, name: str, **kwargs) -> str:
handler = self.get_handler(name)
if not handler:
return f"[错误: 工具 '{name}' 不存在]"
try:
return await handler(**kwargs)
except Exception as e:
return f"[工具 '{name}' 执行失败: {e}]"
def tool(self, name: str, description: str, parameters: dict):
def decorator(func: ToolHandler):
self.register(name, description, parameters, func)
return func
return decorator