ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,51 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
env/
# Environment variables
.env
# Trajectory files
*.json
!package.json
!experiment_protocol.json
!validation/
!validation/**
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Demo outputs
demo_*.py
demo_output/
watched_dir/
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
"""
Event Client - Send test events to the event-triggered agent
"""
import requests
import json
import time
import argparse
from datetime import datetime
from event_types import EventType
class EventClient:
"""Client to send events to the event-triggered agent server"""
def __init__(self, server_url: str = "http://localhost:8000"):
"""
Initialize the client
Args:
server_url: URL of the event server
"""
self.server_url = server_url.rstrip('/')
def send_event(self, event_type: str, content: str, metadata: dict = None) -> dict:
"""
Send an event to the agent
Args:
event_type: Type of event (e.g., 'web_message', 'im_message')
content: Content of the event
metadata: Additional metadata for the event
Returns:
Response from the server
"""
event_data = {
'event_type': event_type,
'content': content,
'metadata': metadata or {},
'timestamp': datetime.now().isoformat(),
'event_id': f"evt_{int(time.time() * 1000)}"
}
print(f"\n{'='*80}")
print(f"📤 SENDING EVENT")
print(f"{'='*80}")
print(f"Event Type: {event_type}")
print(f"Content: {content}")
if metadata:
print(f"Metadata: {json.dumps(metadata, indent=2)}")
print(f"{'='*80}\n")
try:
response = requests.post(
f"{self.server_url}/event",
json=event_data,
headers={'Content-Type': 'application/json'},
timeout=120
)
response.raise_for_status()
result = response.json()
print(f"\n{'='*80}")
print(f"✅ EVENT SENT SUCCESSFULLY")
print(f"{'='*80}")
print(f"Response: {json.dumps(result, indent=2)}")
print(f"{'='*80}\n")
return result
except requests.exceptions.RequestException as e:
print(f"\n❌ Error sending event: {e}")
return {"error": str(e)}
def reset_agent(self) -> dict:
"""Reset the agent state"""
try:
response = requests.post(f"{self.server_url}/agent/reset", timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
return {"error": str(e)}
def get_status(self) -> dict:
"""Get agent status"""
try:
response = requests.get(f"{self.server_url}/agent/status", timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
return {"error": str(e)}
def start_monitoring(self) -> dict:
"""Start system monitoring"""
try:
response = requests.post(f"{self.server_url}/monitoring/start", timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
return {"error": str(e)}
def stop_monitoring(self) -> dict:
"""Stop system monitoring"""
try:
response = requests.post(f"{self.server_url}/monitoring/stop", timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
return {"error": str(e)}
def register_process(self, process_id: str, name: str) -> dict:
"""Register a background process for monitoring"""
try:
response = requests.post(
f"{self.server_url}/process/register",
json={'process_id': process_id, 'name': name}, timeout=30
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
return {"error": str(e)}
def unregister_process(self, process_id: str) -> dict:
"""Unregister a background process"""
try:
response = requests.post(
f"{self.server_url}/process/unregister",
json={'process_id': process_id}, timeout=30
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
return {"error": str(e)}
def run_test_scenarios(client: EventClient):
"""Run various test scenarios"""
print("\n" + "🧪"*40)
print(" EVENT-TRIGGERED AGENT TEST SCENARIOS")
print("🧪"*40 + "\n")
# Scenario 1: Web message
print("\n📋 Scenario 1: Web Interface Message")
print("-"*80)
client.send_event(
event_type=EventType.WEB_MESSAGE.value,
content="Hello! Can you create a simple Python script that prints 'Hello, World!'?",
metadata={"user_id": "user123", "session_id": "session456"}
)
time.sleep(2)
# Scenario 2: IM message
print("\n📋 Scenario 2: Instant Message")
print("-"*80)
client.send_event(
event_type=EventType.IM_MESSAGE.value,
content="Can you list the files in the current directory?",
metadata={"sender": "Alice", "platform": "Slack"}
)
time.sleep(2)
# Scenario 3: Email reply
print("\n📋 Scenario 3: Email Reply")
print("-"*80)
client.send_event(
event_type=EventType.EMAIL_REPLY.value,
content="Thanks for the report! Can you also check the disk usage?",
metadata={
"from": "bob@example.com",
"subject": "Re: System Report",
"thread_id": "thread789"
}
)
time.sleep(2)
# Scenario 4: GitHub PR update
print("\n📋 Scenario 4: GitHub PR Review")
print("-"*80)
client.send_event(
event_type=EventType.GITHUB_PR_UPDATE.value,
content="Review comment: Please add unit tests for the new feature.",
metadata={
"pr_number": "42",
"action": "review_requested",
"reviewer": "code-reviewer",
"repository": "ai-agent-project"
}
)
time.sleep(2)
# Scenario 5: Timer trigger
print("\n📋 Scenario 5: Scheduled Timer")
print("-"*80)
client.send_event(
event_type=EventType.TIMER_TRIGGER.value,
content="Daily backup reminder - please check if backups are running correctly.",
metadata={
"timer_id": "daily_backup_check",
"schedule": "daily at 09:00"
}
)
time.sleep(2)
# Scenario 6: System alert
print("\n📋 Scenario 6: System Alert")
print("-"*80)
client.send_event(
event_type=EventType.SYSTEM_ALERT.value,
content="Memory usage has exceeded 80%. Please investigate.",
metadata={
"alert_type": "resource_usage",
"severity": "warning",
"memory_usage": "82%"
}
)
time.sleep(2)
# Scenario 7: Register background process
print("\n📋 Scenario 7: Background Process Registration")
print("-"*80)
print("Registering background process...")
result = client.register_process("proc_ml_training", "ML Model Training")
print(f"Result: {json.dumps(result, indent=2)}")
# Scenario 8: Start monitoring
print("\n📋 Scenario 8: Start System Monitoring")
print("-"*80)
print("Starting system monitoring (will check for timeouts)...")
result = client.start_monitoring()
print(f"Result: {json.dumps(result, indent=2)}")
print("\n⏰ Monitoring is now active. System will check for:")
print(" - User timeout (no interaction for 1 minute)")
print(" - Background process timeout (running for 30 seconds)")
print("\n💡 Wait 1-2 minutes to see system reminder events trigger automatically...")
# Get status
print("\n📋 Current Agent Status")
print("-"*80)
status = client.get_status()
print(json.dumps(status, indent=2))
print("\n" + ""*40)
print(" TEST SCENARIOS COMPLETED")
print(""*40 + "\n")
def interactive_mode(client: EventClient):
"""Interactive mode for sending custom events"""
print("\n" + "="*80)
print(" INTERACTIVE EVENT CLIENT")
print("="*80)
print("\nAvailable event types:")
for event_type in EventType:
print(f" - {event_type.value}")
print("\nCommands:")
print(" 'status' - Get agent status")
print(" 'reset' - Reset agent")
print(" 'monitor on' - Start monitoring")
print(" 'monitor off' - Stop monitoring")
print(" 'quit' - Exit")
print("\nOr send an event: <event_type> <content>")
while True:
try:
print("\n" + "-"*60)
user_input = input("Event > ").strip()
if not user_input:
continue
if user_input.lower() == 'quit':
print("👋 Goodbye!")
break
elif user_input.lower() == 'status':
status = client.get_status()
print(json.dumps(status, indent=2))
elif user_input.lower() == 'reset':
result = client.reset_agent()
print(json.dumps(result, indent=2))
elif user_input.lower() == 'monitor on':
result = client.start_monitoring()
print(json.dumps(result, indent=2))
elif user_input.lower() == 'monitor off':
result = client.stop_monitoring()
print(json.dumps(result, indent=2))
else:
# Parse event command
parts = user_input.split(' ', 1)
if len(parts) < 2:
print("❌ Invalid format. Use: <event_type> <content>")
continue
event_type = parts[0]
content = parts[1]
# Validate event type
try:
EventType(event_type)
except ValueError:
print(f"❌ Invalid event type: {event_type}")
continue
# Send the event
client.send_event(event_type, content)
except KeyboardInterrupt:
print("\n\n⚠️ Interrupted. Type 'quit' to exit.")
except Exception as e:
print(f"\n❌ Error: {str(e)}")
def main():
"""Main entry point"""
parser = argparse.ArgumentParser(
description="事件客户端:向事件驱动 Agent 服务器发送事件。",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""示例:
python client.py --mode test # 依次发送多种事件,跑通全部场景
python client.py --mode interactive # 交互模式,手动输入事件
python client.py --message "创建一个 hello world 脚本" # 发送单条 web_message 事件
python client.py --event-type timer_trigger --message "检查每日备份" # 指定事件类型
""",
)
parser.add_argument(
'--server',
default='http://localhost:8000',
help='服务器地址(默认:http://localhost:8000'
)
parser.add_argument(
'--mode',
choices=['test', 'interactive'],
default='test',
help='模式:test(依次发送预置场景事件)或 interactive(交互式手动发送)'
)
parser.add_argument(
'--message',
default=None,
help='发送单条事件的内容;提供该参数时忽略 --mode,发完即退出'
)
parser.add_argument(
'--event-type',
default=EventType.WEB_MESSAGE.value,
choices=[e.value for e in EventType],
help=f'--message 使用的事件类型(默认:{EventType.WEB_MESSAGE.value}'
)
args = parser.parse_args()
client = EventClient(server_url=args.server)
# Check if server is running
try:
response = requests.get(f"{args.server}/health", timeout=5)
response.raise_for_status()
print(f"✅ Connected to server at {args.server}")
except requests.exceptions.RequestException as e:
print(f"❌ Cannot connect to server at {args.server}")
print(f" Error: {e}")
print(f"\n💡 Make sure the server is running:")
print(f" python server.py")
return
if args.message is not None:
client.send_event(event_type=args.event_type, content=args.message)
elif args.mode == 'test':
run_test_scenarios(client)
else:
interactive_mode(client)
if __name__ == "__main__":
main()
@@ -0,0 +1,38 @@
# LLM Provider Configuration (matching conversational_agent.py)
# Choose one provider and set the corresponding API key
# Provider selection (default: kimi)
# Options: dashscope (== qwen/bailian), siliconflow, doubao, kimi, moonshot, openrouter
LLM_PROVIDER=kimi
# API Keys (set the one matching your provider)
KIMI_API_KEY=your-kimi-api-key-here
# DASHSCOPE_API_KEY=your-dashscope-api-key-here
# SILICONFLOW_API_KEY=your-siliconflow-api-key-here
# DOUBAO_API_KEY=your-doubao-api-key-here
# OPENROUTER_API_KEY=your-openrouter-api-key-here
# Universal OpenRouter fallback:
# If the selected provider's key is missing but OPENROUTER_API_KEY is set,
# the agent (event_loop_demo.py / server.py / quickstart.py) automatically
# falls back to the 'openrouter' provider so it still runs.
# Optional: Override default model for your provider
# LLM_MODEL=kimi-k3
# Default models per provider:
# - siliconflow: Qwen/Qwen3-235B-A22B-Thinking-2507
# - doubao: doubao-seed-1-6-thinking-250715
# - kimi/moonshot: kimi-k3
# - dashscope/qwen/bailian: qwen3.7-plus
# - openrouter: google/gemini-3.5-flash
# (also supports: openai/gpt-5, anthropic/claude-sonnet-4)
# Optional: Custom server port (default: 8000)
# AGENT_PORT=8000
# Experiment 6-1 real mailbox/calendar workflow
# Obtain both values from the same Unipile project. The DSN normally has the
# form apiN.unipile.com:PORT; never commit either real value.
# UNIPILE_DSN=apiN.unipile.com:PORT
# UNIPILE_ACCESS_TOKEN=your-unipile-api-key
@@ -0,0 +1,397 @@
"""
event_loop_demo.py —— 事件驱动 Agent 的端到端演示(单进程、可离线运行)
本章"事件驱动的异步 Agent"一节指出:真正的"主动服务"不仅需要 Agent 能定时
检查世界,更需要世界能主动通知 Agent。本脚本用最小的代码把这一点跑起来——
1. 注册若干"事件触发器"trigger source),每个触发器在后台线程里运行,
在事件真正发生的那一刻把一个结构化 Event 推入统一的事件队列:
- 一次性定时器 OneShotTimer —— 对应书中 set_timer 的"一次性定时器"
- 循环定时器 RecurringTimer —— 对应书中 set_timer 的"循环定时器"
- 文件监听 FileWatchTrigger —— 对应 n8n 等平台的文件变更触发器
2. 事件循环 EventLoop 从队列里逐个取出事件,唤醒 Agent 处理——这正是
"Agent 注册、外部触发"的完整闭环:注册时声明关心什么事件,触发时被异步唤醒。
与需要起 HTTP 服务器的 server.py / client.py 不同,本脚本在单个进程里同时扮演
"外部世界""Agent",因此适合用来直观演示事件驱动的行为。
离线模式(--mock):不调用大模型,用一个"模拟动作"打印 Agent 被唤醒后的处理
过程,可在没有 API Key 的环境下观察完整的触发→唤醒→处理闭环。
真实模式(默认):接入 EventTriggeredAgent,由大模型真正处理每个事件。
用法示例:
python event_loop_demo.py --mock # 离线演示全部触发器
python event_loop_demo.py --mock --trigger timer # 只演示一次性定时器
python event_loop_demo.py --mock --trigger recurring --interval 3 --duration 12
python event_loop_demo.py --trigger file --watch-dir ./watched # 真实 Agent 处理文件事件
"""
import os
import sys
import time
import queue
import logging
import argparse
import threading
from datetime import datetime
from typing import Optional, Callable
from event_types import Event, EventType
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("event_loop")
# ============================================================================
# 事件触发器(trigger source
# ============================================================================
class TriggerSource(threading.Thread):
"""事件触发器基类:在后台线程中运行,把事件推入共享的事件队列。
注册(register)体现在实例化并 start();触发(fire)体现在 run() 中
满足条件时调用 self.emit(event)。这与书中"注册时由 Agent 主动调用工具、
触发时由外部事件异步回调"的两个时刻一一对应。
"""
def __init__(self, name: str, event_queue: "queue.Queue[Event]"):
super().__init__(name=name, daemon=True)
self.event_queue = event_queue
self._stop = threading.Event()
def emit(self, event: Event):
"""触发:把事件推入事件队列,唤醒事件循环。"""
logger.info(f"⚡ [{self.name}] 触发事件 -> {event.event_type.value}: {event.content}")
self.event_queue.put(event)
def stop(self):
self._stop.set()
class OneShotTimer(TriggerSource):
"""一次性定时器:延迟 delay 秒后触发一次 timer_trigger 事件。
对应书中"用户要求给 DMV 打电话,当前是周六,Agent 设置'下周一上午 10:00
致电 DMV'"这类有明确时间点的任务。
"""
def __init__(self, event_queue, delay: float, content: str, timer_id: str = "oneshot"):
super().__init__(name=f"OneShotTimer({timer_id})", event_queue=event_queue)
self.delay = delay
self.content = content
self.timer_id = timer_id
def run(self):
logger.info(f"⏱️ [{self.name}] 已注册:{self.delay:.0f} 秒后触发")
if self._stop.wait(self.delay):
return
self.emit(Event(
event_type=EventType.TIMER_TRIGGER,
content=self.content,
metadata={"timer_id": self.timer_id, "kind": "one_shot",
"scheduled_delay_seconds": self.delay},
))
class RecurringTimer(TriggerSource):
"""循环定时器:每隔 interval 秒触发一次 timer_trigger 事件。
对应书中"每小时检查一次服务器健康状况""每周五发送进展报告",以及
OpenClaw Heartbeat 式的定时轮询。
"""
def __init__(self, event_queue, interval: float, content: str, timer_id: str = "recurring"):
super().__init__(name=f"RecurringTimer({timer_id})", event_queue=event_queue)
self.interval = interval
self.content = content
self.timer_id = timer_id
def run(self):
logger.info(f"🔁 [{self.name}] 已注册:每 {self.interval:.0f} 秒触发一次")
tick = 0
while not self._stop.wait(self.interval):
tick += 1
self.emit(Event(
event_type=EventType.TIMER_TRIGGER,
content=f"{self.content}(第 {tick} 次)",
metadata={"timer_id": self.timer_id, "kind": "recurring",
"interval_seconds": self.interval, "tick": tick},
))
class FileWatchTrigger(TriggerSource):
"""文件监听:轮询目录,发现新增或被修改的文件时触发 file_change 事件。
对应书中"n8n 等工作流平台的触发器生态:Webhook、定时器、邮件、数据库
变更、文件监听"。这里用轮询实现,不依赖第三方库,便于跨平台离线运行。
"""
def __init__(self, event_queue, watch_dir: str, poll_interval: float = 1.0):
super().__init__(name=f"FileWatch({watch_dir})", event_queue=event_queue)
self.watch_dir = watch_dir
self.poll_interval = poll_interval
self._snapshot = {}
def _scan(self):
snapshot = {}
try:
for entry in os.scandir(self.watch_dir):
if entry.is_file():
snapshot[entry.name] = entry.stat().st_mtime
except FileNotFoundError:
pass
return snapshot
def run(self):
os.makedirs(self.watch_dir, exist_ok=True)
self._snapshot = self._scan()
logger.info(f"👀 [{self.name}] 已注册:轮询间隔 {self.poll_interval:.0f}"
f"(当前已有 {len(self._snapshot)} 个文件)")
while not self._stop.wait(self.poll_interval):
current = self._scan()
for name, mtime in current.items():
if name not in self._snapshot:
change = "created"
elif mtime != self._snapshot[name]:
change = "modified"
else:
continue
self.emit(Event(
event_type=EventType.FILE_CHANGE,
content=f"检测到文件{'新增' if change == 'created' else '修改'},请查看其内容并给出简要处理建议。",
metadata={"path": os.path.join(self.watch_dir, name), "change": change},
))
self._snapshot = current
# ============================================================================
# 事件循环(event loop
# ============================================================================
class EventLoop:
"""统一事件队列 + 单线程分发。
所有触发器把异构事件推入同一个队列;事件循环按到达顺序取出,每个事件
唤醒一次 Agent 处理。这正是书中"将所有输入统一建模为事件流,通过事件
循环驱动 Agent 的思考和行动"的最小实现。
"""
def __init__(self, dispatch: Callable[[Event], None]):
self.event_queue: "queue.Queue[Event]" = queue.Queue()
self.dispatch = dispatch
self.triggers = []
self.processed = 0
def add_trigger(self, trigger: TriggerSource):
self.triggers.append(trigger)
def run(self, duration: float):
"""启动所有触发器,运行 duration 秒后停止。"""
deadline = time.monotonic() + duration
for t in self.triggers:
t.start()
logger.info(f"🟢 事件循环启动,将运行 {duration:.0f} 秒,等待事件唤醒 Agent...\n")
while time.monotonic() < deadline:
try:
event = self.event_queue.get(timeout=0.5)
except queue.Empty:
continue
self.processed += 1
logger.info(f"\n{'='*80}\n📥 事件循环取出第 {self.processed} 个事件"
f" -> 唤醒 Agent\n{'='*80}")
try:
self.dispatch(event)
except Exception as e: # noqa: BLE001 - 演示中不希望单个事件异常终止循环
logger.error(f"❌ 处理事件时出错: {e}")
for t in self.triggers:
t.stop()
logger.info(f"\n🔴 事件循环结束,共处理 {self.processed} 个事件。")
# ============================================================================
# 分发处理器:模拟动作 or 真实 Agent
# ============================================================================
def make_mock_dispatch() -> Callable[[Event], None]:
"""离线模拟处理器:不调用大模型,打印 Agent 被唤醒后的处理过程。"""
def dispatch(event: Event):
logger.info(f"🤖 Agent 被唤醒,收到消息: {event.to_user_message()}")
# 用一个确定性的"模拟动作"代替大模型 + 工具调用
if event.event_type == EventType.TIMER_TRIGGER:
action = "读取定时任务上下文 -> 执行例行检查 -> 汇报结果"
elif event.event_type == EventType.FILE_CHANGE:
path = event.metadata.get("path", "")
preview = ""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
preview = f.read(120).replace("\n", " ")
except OSError:
preview = "(无法读取文件内容)"
action = f"读取文件 {os.path.basename(path)} -> 内容预览: {preview!r} -> 生成处理建议"
else:
action = "解析事件 -> 调用相关工具 -> 生成处理结果"
logger.info(f"🛠️ [模拟动作] {action}")
logger.info(f"✅ Agent 处理完成: 已响应 {event.event_type.value} 事件")
return dispatch
def make_agent_dispatch(provider: str, model: Optional[str],
max_iterations: int) -> Callable[[Event], None]:
"""真实处理器:接入 EventTriggeredAgent,由大模型处理每个事件。"""
from agent import EventTriggeredAgent, SystemHintConfig, resolve_provider_and_key
# 通用兜底:直连 provider 的 key 缺失时,若有 OPENROUTER_API_KEY 则自动改走 openrouter。
resolved_provider, api_key = resolve_provider_and_key(provider)
if not api_key:
print(f"❌ 未检测到 provider '{provider}' 对应的 API Key(也未配置 OPENROUTER_API_KEY 兜底)。")
print(f" 请先设置环境变量,或改用离线演示:python event_loop_demo.py --mock")
sys.exit(1)
if resolved_provider != provider:
print(f"️ provider '{provider}' 无可用 Key,已自动改用 OpenRouter 兜底(openrouter)。")
provider = resolved_provider
# 保留已是 provider/model 形式的显式模型;否则让 openrouter 用其默认模型。
model = model if (model and "/" in model) else None
config = SystemHintConfig(
enable_timestamps=True,
enable_tool_counter=True,
enable_todo_list=True,
enable_detailed_errors=True,
enable_system_state=True,
save_trajectory=True,
trajectory_file="event_loop_trajectory.json",
temperature=0.7,
max_tokens=4096,
use_mcp_servers=False, # 本演示仅用内置工具,避免额外的 MCP 依赖
)
agent = EventTriggeredAgent(api_key=api_key, provider=provider,
model=model, config=config, verbose=True)
logger.info(f"✅ 真实 Agent 初始化完成(provider={provider}, model={agent.model}")
def dispatch(event: Event):
result = agent.handle_event(event, max_iterations=max_iterations)
logger.info(f"✅ Agent 处理完成: success={result['success']}, "
f"iterations={result['iterations']}, "
f"tool_calls={len(result['tool_calls'])}")
return dispatch
# ============================================================================
# CLI
# ============================================================================
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="事件驱动 Agent 端到端演示:注册触发器,由外部事件异步唤醒 Agent。",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""示例:
python event_loop_demo.py --mock
离线演示全部触发器(一次性定时器 + 循环定时器 + 文件监听),无需 API Key
python event_loop_demo.py --mock --trigger timer
只演示一次性定时器
python event_loop_demo.py --mock --trigger recurring --interval 3 --duration 12
每 3 秒触发一次循环定时器,共运行 12 秒
python event_loop_demo.py --mock --trigger file --watch-dir ./watched
监听 ./watched 目录,向其中写入文件即可触发事件
python event_loop_demo.py --trigger timer --provider kimi
用真实大模型处理一次性定时器事件(需要设置对应的 API Key)
""",
)
parser.add_argument(
"--trigger", choices=["timer", "recurring", "file", "all"], default="all",
help="要演示的触发器类型:timer=一次性定时器,recurring=循环定时器,"
"file=文件监听,all=全部(默认:all)",
)
parser.add_argument(
"--mock", action="store_true",
help="离线模式:不调用大模型,用模拟动作演示触发→唤醒→处理闭环(无需 API Key)",
)
parser.add_argument(
"--duration", type=float, default=12.0,
help="事件循环总运行时长(秒),到时后停止所有触发器(默认:12)",
)
parser.add_argument(
"--delay", type=float, default=3.0,
help="一次性定时器的延迟触发时间(秒)(默认:3)",
)
parser.add_argument(
"--interval", type=float, default=4.0,
help="循环定时器的触发间隔(秒)(默认:4",
)
parser.add_argument(
"--watch-dir", default="watched_dir",
help="文件监听触发器监视的目录,不存在会自动创建(默认:watched_dir",
)
parser.add_argument(
"--provider", default=os.getenv("LLM_PROVIDER", "kimi"),
choices=["dashscope", "qwen", "bailian", "siliconflow", "doubao", "kimi", "moonshot", "openrouter"],
help="真实模式使用的大模型提供商(默认:环境变量 LLM_PROVIDER 或 kimi",
)
parser.add_argument(
"--model", default=os.getenv("LLM_MODEL"),
help="真实模式的模型名覆盖(默认:使用提供商默认模型)",
)
parser.add_argument(
"--max-iterations", type=int, default=10,
help="真实模式下单个事件的最大工具调用轮数(默认:10)",
)
return parser
def main():
args = build_parser().parse_args()
print("\n" + "=" * 80)
print("🚀 事件驱动 Agent 演示(EVENT-DRIVEN AGENT DEMO")
print("=" * 80)
print(f"触发器: {args.trigger} | 模式: {'离线模拟' if args.mock else '真实 Agent'} | "
f"时长: {args.duration:.0f}s")
print("=" * 80 + "\n")
sys.stdout.flush()
if args.mock:
dispatch = make_mock_dispatch()
else:
dispatch = make_agent_dispatch(args.provider, args.model, args.max_iterations)
loop = EventLoop(dispatch)
if args.trigger in ("timer", "all"):
loop.add_trigger(OneShotTimer(
loop.event_queue, delay=args.delay, timer_id="daily_backup_check",
content="一次性定时器到期:请检查每日备份是否已经完成。",
))
if args.trigger in ("recurring", "all"):
loop.add_trigger(RecurringTimer(
loop.event_queue, interval=args.interval, timer_id="health_check",
content="循环定时器到期:请检查服务器健康状况。",
))
if args.trigger in ("file", "all"):
loop.add_trigger(FileWatchTrigger(loop.event_queue, watch_dir=args.watch_dir))
print(f"💡 提示:向目录 {args.watch_dir}/ 写入或修改文件即可触发 file_change 事件。")
print(f" 例如另开一个终端执行:echo hello > {args.watch_dir}/note.txt\n")
sys.stdout.flush()
if not loop.triggers:
print("❌ 没有可运行的触发器。")
sys.exit(1)
try:
loop.run(duration=args.duration)
except KeyboardInterrupt:
print("\n⚠️ 收到中断信号,正在停止...")
for t in loop.triggers:
t.stop()
print("\n" + "=" * 80)
print(f"📊 演示结束:共处理 {loop.processed} 个事件。")
print("=" * 80 + "\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,99 @@
"""
Event types for the event-triggered agent system
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Dict, Any, Optional
class EventType(Enum):
"""Types of events that can trigger agent actions"""
# External input events
WEB_MESSAGE = "web_message"
IM_MESSAGE = "im_message"
EMAIL_REPLY = "email_reply"
GITHUB_PR_UPDATE = "github_pr_update"
TIMER_TRIGGER = "timer_trigger"
FILE_CHANGE = "file_change"
# System reminder events
USER_TIMEOUT = "user_timeout"
PROCESS_TIMEOUT = "process_timeout"
SYSTEM_ALERT = "system_alert"
@dataclass
class Event:
"""Represents an event that triggers agent action"""
event_type: EventType
content: str
metadata: Dict[str, Any] = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
event_id: Optional[str] = None
def to_user_message(self) -> str:
"""Convert event to user message format for the agent"""
if self.event_type == EventType.WEB_MESSAGE:
return f"[Web Interface] {self.content}"
elif self.event_type == EventType.IM_MESSAGE:
sender = self.metadata.get('sender', 'Unknown')
return f"[IM from {sender}] {self.content}"
elif self.event_type == EventType.EMAIL_REPLY:
from_email = self.metadata.get('from', 'Unknown')
subject = self.metadata.get('subject', 'No Subject')
return f"[Email Reply from {from_email}]\nSubject: {subject}\n{self.content}"
elif self.event_type == EventType.GITHUB_PR_UPDATE:
pr_number = self.metadata.get('pr_number', 'Unknown')
action = self.metadata.get('action', 'updated')
return f"[GitHub PR #{pr_number} {action}] {self.content}"
elif self.event_type == EventType.TIMER_TRIGGER:
timer_id = self.metadata.get('timer_id', 'Unknown')
return f"[Timer {timer_id} triggered] {self.content}"
elif self.event_type == EventType.FILE_CHANGE:
path = self.metadata.get('path', 'Unknown')
change = self.metadata.get('change', 'modified')
return f"[File {change}: {path}] {self.content}"
elif self.event_type == EventType.USER_TIMEOUT:
duration = self.metadata.get('duration', 'unknown')
return f"[System Reminder] User has not responded for {duration}. {self.content}"
elif self.event_type == EventType.PROCESS_TIMEOUT:
process_id = self.metadata.get('process_id', 'Unknown')
duration = self.metadata.get('duration', 'unknown')
return f"[System Alert] Background process {process_id} has been running for {duration}. {self.content}"
elif self.event_type == EventType.SYSTEM_ALERT:
alert_type = self.metadata.get('alert_type', 'general')
return f"[System Alert: {alert_type}] {self.content}"
else:
return f"[{self.event_type.value}] {self.content}"
def to_dict(self) -> Dict[str, Any]:
"""Convert event to dictionary for JSON serialization"""
return {
'event_type': self.event_type.value,
'content': self.content,
'metadata': self.metadata,
'timestamp': self.timestamp,
'event_id': self.event_id
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Event':
"""Create event from dictionary"""
return cls(
event_type=EventType(data['event_type']),
content=data['content'],
metadata=data.get('metadata', {}),
timestamp=data.get('timestamp', datetime.now().isoformat()),
event_id=data.get('event_id')
)
@@ -0,0 +1,103 @@
"""
Example demonstrating the Event-Triggered Agent with MCP tools
"""
import os
import asyncio
from dotenv import load_dotenv
from agent import EventTriggeredAgent, SystemHintConfig, resolve_provider_and_key
from event_types import Event, EventType
# Load environment variables
load_dotenv()
async def main():
"""Main example function"""
print("=" * 80)
print("Event-Triggered Agent with MCP Tools Example")
print("=" * 80)
print()
# Get API credentials
provider = os.getenv("LLM_PROVIDER", "kimi")
provider, api_key = resolve_provider_and_key(provider)
if not api_key:
print("❌ Please set the provider API key in your .env file (DASHSCOPE_API_KEY for dashscope/qwen/bailian)")
return
# Create agent configuration
config = SystemHintConfig(
enable_timestamps=True,
enable_tool_counter=True,
enable_todo_list=True,
enable_detailed_errors=True,
enable_system_state=True,
save_trajectory=True,
trajectory_file="example_trajectory.json",
use_mcp_servers=True # Enable MCP servers
)
# Initialize agent
print("Initializing agent...")
agent = EventTriggeredAgent(
api_key=api_key,
provider=provider,
config=config,
verbose=True
)
# Load MCP tools
print("\nLoading MCP tools...")
await agent.load_mcp_tools()
print("\n" + "=" * 80)
print("Testing Event Processing")
print("=" * 80)
print()
# Create a test event
event = Event(
event_type=EventType.WEB_MESSAGE,
content="Search the web for 'Python async programming best practices' and summarize the top 3 results.",
metadata={
"source": "web_interface",
"user_id": "demo_user",
"session_id": "test_session_001"
}
)
# Handle the event
try:
result = agent.handle_event(event, max_iterations=15)
print("\n" + "=" * 80)
print("Result Summary")
print("=" * 80)
print(f"Success: {result['success']}")
print(f"Iterations: {result['iterations']}")
print(f"Tool Calls: {len(result['tool_calls'])}")
if result.get('final_answer'):
print(f"\nFinal Answer:\n{result['final_answer']}")
if result.get('trajectory_file'):
print(f"\nTrajectory saved to: {result['trajectory_file']}")
except Exception as e:
print(f"\n❌ Error processing event: {e}")
import traceback
traceback.print_exc()
finally:
# Cleanup MCP connections
print("\nCleaning up MCP connections...")
await agent.mcp_manager.disconnect_all()
print("✅ Cleanup complete")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n\n⚠️ Interrupted by user")
@@ -0,0 +1,46 @@
{
"experiment": "6-1",
"title": "Real event-driven mailbox workflow",
"authority": "book/chapter4.md:466",
"mail_provider": "Unipile Email API",
"listener": {
"mode": "polling",
"endpoint": "GET /api/v1/emails",
"event_channel": "unipile_mailbox_poll",
"queue": "FIFO by provider timestamp then email id"
},
"scenarios": [
{
"classification": "meeting_invitation",
"required_actions": ["live_calendar_conflict_check", "accept_or_decline_draft"]
},
{
"classification": "customer_complaint",
"required_actions": ["key_information_extraction", "high_priority_notification"]
},
{
"classification": "marketing",
"required_actions": ["provider_archive_update", "post_update_verification"]
}
],
"official_schema_sources": [
"https://developer.unipile.com/reference/accountscontroller_listaccounts.md",
"https://developer.unipile.com/reference/mailscontroller_listmails.md",
"https://developer.unipile.com/reference/mailscontroller_getmail.md",
"https://developer.unipile.com/reference/mailscontroller_updatemail.md",
"https://developer.unipile.com/reference/folderscontroller_listfolders.md",
"https://developer.unipile.com/reference/calendarscontroller_listcalendars.md",
"https://developer.unipile.com/reference/calendarscontroller_listcalendareventsbycalendar.md",
"https://developer.unipile.com/docs/new-emails-webhook.md"
],
"acceptance": {
"no_local_or_mock_mailbox_substitute": true,
"three_real_inbound_email_objects": true,
"calendar_query_receipted": true,
"draft_artifact_hashed": true,
"high_priority_notification_delivered": true,
"marketing_email_archived_and_verified": true,
"identifiers_and_credentials_redacted": true,
"fail_closed_on_missing_or_invalid_credentials": true
}
}
@@ -0,0 +1,129 @@
"""
Quick Start Script for Event-Triggered Agent
Demonstrates the basic functionality in a simple way
"""
import os
import sys
import time
import subprocess
import signal
from event_types import EventType
# Check if API key is set (universal OpenRouter fallback applied by the server).
from agent import resolve_provider_and_key
provider = os.getenv("LLM_PROVIDER", "kimi").lower()
resolved_provider, api_key = resolve_provider_and_key(provider)
if not api_key:
print(f"❌ Error: no API key for provider '{provider}', and no OPENROUTER_API_KEY fallback")
print(f"\nPlease set one of:")
print(f" export DASHSCOPE_API_KEY='...' # for dashscope/qwen/bailian")
print(f" export KIMI_API_KEY='...' # or SILICONFLOW/DOUBAO/OPENROUTER per provider")
print(f" export OPENROUTER_API_KEY='...' # universal fallback")
print(f"\nOr change provider:")
print(f" export LLM_PROVIDER=dashscope # or qwen, bailian, siliconflow, doubao, kimi, openrouter")
sys.exit(1)
if resolved_provider != provider:
print(f"️ provider '{provider}' has no key; the server will fall back to OpenRouter.")
print("\n" + "="*80)
print("🚀 EVENT-TRIGGERED AGENT QUICK START")
print("="*80)
print()
# Check if server is already running
import requests
try:
response = requests.get("http://localhost:8000/health", timeout=2)
print("✅ Server is already running!")
print("\n💡 You can now use the client to send events:")
print(" python client.py --mode test")
print(" python client.py --mode interactive")
sys.exit(0)
except Exception:
pass
print("📦 Starting the event-triggered agent server...")
print("\n⏳ This may take a moment to initialize...\n")
# Start the server in a subprocess
try:
server_process = subprocess.Popen(
[sys.executable, "server.py"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1
)
# Wait for server to start
print("⏰ Waiting for server to start...")
max_wait = 30
for i in range(max_wait):
try:
response = requests.get("http://localhost:8000/health", timeout=1)
if response.status_code == 200:
print("✅ Server is running!\n")
break
except Exception:
pass
time.sleep(1)
if i % 5 == 0:
print(f" Still waiting... ({i}/{max_wait}s)")
else:
print("❌ Server failed to start in time")
server_process.terminate()
sys.exit(1)
print("="*80)
print("🎉 QUICK START READY!")
print("="*80)
print()
print("The event-triggered agent server is now running on port 8000.")
print()
print("📋 What you can do now:")
print()
print("1. Send test events (in another terminal):")
print(" python client.py --mode test")
print()
print("2. Use interactive mode:")
print(" python client.py --mode interactive")
print()
print("3. Send individual events via API:")
print(" curl -X POST http://localhost:8000/event \\")
print(" -H 'Content-Type: application/json' \\")
print(" -d '{\"event_type\": \"web_message\", \"content\": \"Hello!\"}'")
print()
print("4. Check agent status:")
print(" curl http://localhost:8000/agent/status")
print()
print("="*80)
print("📺 Server output will appear below:")
print("="*80)
print()
# Stream server output
try:
while True:
line = server_process.stdout.readline()
if not line:
break
print(line, end='')
except KeyboardInterrupt:
print("\n\n⚠️ Shutting down server...")
server_process.send_signal(signal.SIGINT)
server_process.wait(timeout=5)
print("✅ Server stopped")
except FileNotFoundError:
print("❌ Error: Could not find server.py")
print("Make sure you're in the agent-with-event-trigger directory")
sys.exit(1)
except Exception as e:
print(f"❌ Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
@@ -0,0 +1,8 @@
openai>=1.3.0
requests>=2.31.0
python-dotenv>=1.0.0
flask>=3.0.0
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
mcp>=1.0.0
httpx>=0.27.0
+467
View File
@@ -0,0 +1,467 @@
"""
Event Server - FastAPI version with native async support for MCP tools
"""
import os
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel
from agent import EventTriggeredAgent, SystemHintConfig, resolve_provider_and_key
from event_types import Event, EventType
import threading
import time
import asyncio
import argparse
import uvicorn
def _env_int(name: str, default: int) -> int:
"""Read an integer env var; fall back to default (with a warning) if malformed."""
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
logger.warning(f"Invalid {name} value: {raw!r} (must be an integer); using default {default}")
return default
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Global agent instance
agent: Optional[EventTriggeredAgent] = None
agent_lock = threading.Lock()
# Monitoring state
monitoring_enabled = False
monitoring_thread: Optional[threading.Thread] = None
# MCP loading status
mcp_loading_status = {
"loading": False,
"loaded": False,
"tools_count": 0,
"error": None,
"started_at": None,
"completed_at": None
}
# ============================================================================
# FastAPI Lifecycle Events (Modern lifespan)
# ============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown"""
global agent, monitoring_enabled
# Startup
logger.info("🚀 Starting Event-Triggered Agent Server (FastAPI)")
await init_agent()
logger.info("✅ Server ready to receive events\n")
yield
# Shutdown
logger.info("Shutting down server...")
monitoring_enabled = False
if agent and agent.mcp_manager:
await agent.mcp_manager.disconnect_all()
logger.info("✅ Server shutdown complete")
# Initialize FastAPI app with lifespan
app = FastAPI(
title="Event-Triggered Agent Server",
description="AI Agent with async MCP tools support",
version="2.0.0",
lifespan=lifespan
)
# Pydantic models for requests
class EventRequest(BaseModel):
event_type: str
content: str
metadata: Optional[Dict[str, Any]] = None
class ProcessRegister(BaseModel):
process_id: str
process_name: str
metadata: Optional[Dict[str, Any]] = None
class ProcessUnregister(BaseModel):
process_id: str
# ============================================================================
# Initialization
# ============================================================================
async def init_agent():
"""Initialize the agent with optional MCP tools"""
global agent, mcp_loading_status
# Determine provider from environment (universal OpenRouter fallback applied)
requested_provider = os.getenv("LLM_PROVIDER", "kimi").lower()
provider, api_key = resolve_provider_and_key(requested_provider)
if not api_key:
raise ValueError(
f"API key not set for provider '{requested_provider}'. Set the appropriate "
f"environment variable, or set OPENROUTER_API_KEY as a universal fallback."
)
# Get model from environment if specified
model = os.getenv("LLM_MODEL")
if provider != requested_provider:
logger.info(
f"️ provider '{requested_provider}' has no key; falling back to OpenRouter."
)
# Keep an explicit provider/model id; otherwise use OpenRouter's default.
if not (model and "/" in model):
model = None
# Check if MCP should be enabled (default: true)
enable_mcp = os.getenv("ENABLE_MCP_TOOLS", "true").lower() not in ["false", "0", "no"]
config = SystemHintConfig(
enable_timestamps=True,
enable_tool_counter=True,
enable_todo_list=True,
enable_detailed_errors=True,
enable_system_state=True,
save_trajectory=True,
trajectory_file="event_agent_trajectory.json",
temperature=_reasoning_safe_temperature(model, 0.7),
max_tokens=4096,
use_mcp_servers=enable_mcp
)
agent = EventTriggeredAgent(
api_key=api_key,
provider=provider,
model=model,
config=config,
verbose=True
)
logger.info(f"✅ Agent initialized with {provider} provider")
if enable_mcp:
logger.info("🔄 MCP tools enabled (default) - loading asynchronously...")
await load_mcp_tools_async()
else:
logger.info(f"📦 Using built-in tools only (MCP disabled via ENABLE_MCP_TOOLS=false)")
async def load_mcp_tools_async():
"""Load MCP tools asynchronously"""
global agent, mcp_loading_status
mcp_loading_status["loading"] = True
mcp_loading_status["started_at"] = datetime.now().isoformat()
try:
if agent:
await agent.load_mcp_tools()
tools_count = len(agent.mcp_manager.tools)
mcp_loading_status["loaded"] = True
mcp_loading_status["loading"] = False
mcp_loading_status["tools_count"] = tools_count
mcp_loading_status["completed_at"] = datetime.now().isoformat()
logger.info(f"✅ MCP tools loaded: {tools_count} tools available")
if tools_count > 0:
sample_tools = list(agent.mcp_manager.tools.keys())[:5]
logger.info(f" Sample: {sample_tools}")
else:
raise RuntimeError("Agent not initialized")
except Exception as e:
logger.error(f"❌ Failed to load MCP tools: {e}")
mcp_loading_status["loading"] = False
mcp_loading_status["loaded"] = False
mcp_loading_status["error"] = str(e)
mcp_loading_status["completed_at"] = datetime.now().isoformat()
# ============================================================================
# API Endpoints
# ============================================================================
@app.get("/")
async def root():
"""Root endpoint with API information"""
return {
"service": "Event-Triggered Agent Server",
"version": "2.0.0",
"status": "running",
"docs": "/docs",
"endpoints": {
"health": "GET /health",
"event": "POST /event",
"mcp_status": "GET /mcp/status",
"mcp_reload": "POST /mcp/reload",
"agent_status": "GET /agent/status",
"agent_reset": "POST /agent/reset"
}
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"agent_initialized": agent is not None,
"monitoring_enabled": monitoring_enabled,
"mcp_enabled": agent.config.use_mcp_servers if agent else False,
"mcp_loaded": mcp_loading_status["loaded"],
"timestamp": datetime.now().isoformat()
}
@app.get("/mcp/status")
async def get_mcp_status():
"""Get MCP tools loading status"""
status = mcp_loading_status.copy()
# Add tool list if loaded
if status["loaded"] and agent:
status["tools"] = list(agent.mcp_manager.tools.keys())
# Group by server
status["tools_by_server"] = {}
for tool_name in agent.mcp_manager.tools.keys():
server = tool_name.split("_")[0]
if server not in status["tools_by_server"]:
status["tools_by_server"][server] = []
status["tools_by_server"][server].append(tool_name)
return status
@app.post("/mcp/reload")
async def reload_mcp_tools(background_tasks: BackgroundTasks):
"""Manually trigger MCP tools reload"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
if mcp_loading_status["loading"]:
raise HTTPException(status_code=409, detail="MCP tools are already loading")
# Use FastAPI background tasks
background_tasks.add_task(load_mcp_tools_async)
return {
"success": True,
"message": "MCP tools reload started in background"
}
@app.post("/event")
async def handle_event(event_req: EventRequest):
"""Handle incoming event"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
try:
# Create event
event_data = {
"event_type": event_req.event_type,
"content": event_req.content,
"metadata": event_req.metadata or {}
}
event = Event.from_dict(event_data)
# Handle the event
with agent_lock:
result = agent.handle_event(event, max_iterations=20)
return {
"success": True,
"event_id": event.event_id,
"result": {
"final_answer": result.get('final_answer'),
"iterations": result.get('iterations'),
"tool_calls_count": len(result.get('tool_calls') or []),
"todo_items": len(result.get('todo_list') or []),
"success": result.get('success', False),
"trajectory_file": result.get('trajectory_file')
}
}
except Exception as e:
logger.error(f"Error handling event: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/agent/status")
async def get_agent_status():
"""Get current agent status"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
with agent_lock:
return {
"provider": agent.provider,
"model": agent.model,
"tool_calls_count": len(agent.tool_calls),
"todo_items": len(agent.todo_list),
"current_directory": agent.current_directory,
"mcp_tools_loaded": agent.mcp_tools_loaded,
"mcp_tools_count": len(agent.mcp_manager.tools) if agent.mcp_tools_loaded else 0
}
@app.post("/agent/reset")
async def reset_agent():
"""Reset agent state"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
with agent_lock:
agent.reset()
return {
"success": True,
"message": "Agent state reset successfully"
}
@app.post("/process/register")
async def register_process(process: ProcessRegister):
"""Register a background process for monitoring"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
with agent_lock:
agent.background_processes[process.process_id] = {
"name": process.process_name,
"start_time": datetime.now().isoformat(),
"metadata": process.metadata or {},
"reminded": False
}
return {
"success": True,
"message": f"Process '{process.process_name}' registered"
}
@app.post("/process/unregister")
async def unregister_process(process: ProcessUnregister):
"""Unregister a background process"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
with agent_lock:
if process.process_id in agent.background_processes:
del agent.background_processes[process.process_id]
return {
"success": True,
"message": f"Process {process.process_id} unregistered"
}
else:
return {
"success": False,
"message": f"Process {process.process_id} not found"
}
# ============================================================================
# Main Entry Point
# ============================================================================
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器(命令行参数优先级高于环境变量)。"""
parser = argparse.ArgumentParser(
description="事件驱动 Agent 的 HTTP 服务器(FastAPI):"
"对外暴露 /event 等接口,把 Webhook 式的外部回调转成事件唤醒 Agent。",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""示例:
python server.py # 使用默认配置(端口 8000,启用 MCP 工具)
python server.py --port 9000 # 自定义端口
python server.py --provider doubao # 指定大模型提供商
python server.py --no-mcp # 只用内置工具,不加载 MCP 工具
之后用客户端发送事件:python client.py --mode test
""",
)
parser.add_argument(
"--host", default=os.getenv("AGENT_HOST", "0.0.0.0"),
help="监听地址(默认:0.0.0.0",
)
parser.add_argument(
"--port", type=int, default=_env_int("AGENT_PORT", 8000),
help="监听端口(默认:环境变量 AGENT_PORT 或 8000",
)
parser.add_argument(
"--provider", default=None,
choices=["dashscope", "qwen", "bailian", "siliconflow", "doubao", "kimi", "moonshot", "openrouter"],
help="大模型提供商(默认:环境变量 LLM_PROVIDER 或 kimi",
)
parser.add_argument(
"--model", default=None,
help="模型名覆盖(默认:使用提供商默认模型)",
)
parser.add_argument(
"--no-mcp", action="store_true",
help="禁用 MCP 工具,只使用内置工具(等价于 ENABLE_MCP_TOOLS=false",
)
return parser
def main():
"""Main entry point"""
args = build_parser().parse_args()
# 命令行参数覆盖环境变量:init_agent() 在 lifespan 中读取这些环境变量
if args.provider:
os.environ["LLM_PROVIDER"] = args.provider
if args.model:
os.environ["LLM_MODEL"] = args.model
if args.no_mcp:
os.environ["ENABLE_MCP_TOOLS"] = "false"
print("\n" + "="*80)
print("🤖 EVENT-TRIGGERED AGENT SERVER (FastAPI)")
print("="*80)
print()
print(f"✅ Starting server on {args.host}:{args.port}")
print(f"📡 API Documentation: http://localhost:{args.port}/docs")
print(f"📊 ReDoc: http://localhost:{args.port}/redoc")
print()
print("="*80 + "\n")
# Run with uvicorn
uvicorn.run(
app,
host=args.host,
port=args.port,
log_level="info"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,416 @@
"""
Event Server - FastAPI version with native async support for MCP tools
"""
import os
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel
from agent import EventTriggeredAgent, SystemHintConfig, resolve_provider_and_key
from event_types import Event, EventType
import threading
import time
import asyncio
import uvicorn
def _env_int(name: str, default: int) -> int:
"""Read an integer env var; fall back to default (with a warning) if malformed."""
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
logger.warning(f"Invalid {name} value: {raw!r} (must be an integer); using default {default}")
return default
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Global agent instance
agent: Optional[EventTriggeredAgent] = None
agent_lock = threading.Lock()
# Monitoring state
monitoring_enabled = False
monitoring_thread: Optional[threading.Thread] = None
# MCP loading status
mcp_loading_status = {
"loading": False,
"loaded": False,
"tools_count": 0,
"error": None,
"started_at": None,
"completed_at": None
}
# ============================================================================
# FastAPI Lifecycle Events (Modern lifespan)
# ============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown"""
global agent, monitoring_enabled
# Startup
logger.info("🚀 Starting Event-Triggered Agent Server (FastAPI)")
await init_agent()
logger.info("✅ Server ready to receive events\n")
yield
# Shutdown
logger.info("Shutting down server...")
monitoring_enabled = False
if agent and agent.mcp_manager:
await agent.mcp_manager.disconnect_all()
logger.info("✅ Server shutdown complete")
# Initialize FastAPI app with lifespan
app = FastAPI(
title="Event-Triggered Agent Server",
description="AI Agent with async MCP tools support",
version="2.0.0",
lifespan=lifespan
)
# Pydantic models for requests
class EventRequest(BaseModel):
event_type: str
content: str
metadata: Optional[Dict[str, Any]] = None
class ProcessRegister(BaseModel):
process_id: str
process_name: str
metadata: Optional[Dict[str, Any]] = None
class ProcessUnregister(BaseModel):
process_id: str
# ============================================================================
# Initialization
# ============================================================================
async def init_agent():
"""Initialize the agent with optional MCP tools"""
global agent, mcp_loading_status
# Determine provider and key, applying the universal OpenRouter fallback.
requested_provider = os.getenv("LLM_PROVIDER", "kimi").lower()
provider, api_key = resolve_provider_and_key(requested_provider)
if not api_key:
raise ValueError(
f"API key not set for provider '{requested_provider}'. Set the appropriate "
"environment variable or OPENROUTER_API_KEY."
)
# Get model from environment if specified
model = os.getenv("LLM_MODEL")
if provider == "openrouter" and provider != requested_provider and model and "/" not in model:
model = None
# Check if MCP should be enabled (default: true)
enable_mcp = os.getenv("ENABLE_MCP_TOOLS", "true").lower() not in ["false", "0", "no"]
config = SystemHintConfig(
enable_timestamps=True,
enable_tool_counter=True,
enable_todo_list=True,
enable_detailed_errors=True,
enable_system_state=True,
save_trajectory=True,
trajectory_file="event_agent_trajectory.json",
temperature=_reasoning_safe_temperature(model, 0.7),
max_tokens=4096,
use_mcp_servers=enable_mcp
)
agent = EventTriggeredAgent(
api_key=api_key,
provider=provider,
model=model,
config=config,
verbose=True
)
logger.info(f"✅ Agent initialized with {provider} provider")
if enable_mcp:
logger.info("🔄 MCP tools enabled (default) - loading asynchronously...")
await load_mcp_tools_async()
else:
logger.info(f"📦 Using built-in tools only (MCP disabled via ENABLE_MCP_TOOLS=false)")
async def load_mcp_tools_async():
"""Load MCP tools asynchronously"""
global agent, mcp_loading_status
mcp_loading_status["loading"] = True
mcp_loading_status["started_at"] = datetime.now().isoformat()
try:
if agent:
await agent.load_mcp_tools()
tools_count = len(agent.mcp_manager.tools)
mcp_loading_status["loaded"] = True
mcp_loading_status["loading"] = False
mcp_loading_status["tools_count"] = tools_count
mcp_loading_status["completed_at"] = datetime.now().isoformat()
logger.info(f"✅ MCP tools loaded: {tools_count} tools available")
if tools_count > 0:
sample_tools = list(agent.mcp_manager.tools.keys())[:5]
logger.info(f" Sample: {sample_tools}")
else:
raise RuntimeError("Agent not initialized")
except Exception as e:
logger.error(f"❌ Failed to load MCP tools: {e}")
mcp_loading_status["loading"] = False
mcp_loading_status["loaded"] = False
mcp_loading_status["error"] = str(e)
mcp_loading_status["completed_at"] = datetime.now().isoformat()
# ============================================================================
# API Endpoints
# ============================================================================
@app.get("/")
async def root():
"""Root endpoint with API information"""
return {
"service": "Event-Triggered Agent Server",
"version": "2.0.0",
"status": "running",
"docs": "/docs",
"endpoints": {
"health": "GET /health",
"event": "POST /event",
"mcp_status": "GET /mcp/status",
"mcp_reload": "POST /mcp/reload",
"agent_status": "GET /agent/status",
"agent_reset": "POST /agent/reset"
}
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"agent_initialized": agent is not None,
"monitoring_enabled": monitoring_enabled,
"mcp_enabled": agent.config.use_mcp_servers if agent else False,
"mcp_loaded": mcp_loading_status["loaded"],
"timestamp": datetime.now().isoformat()
}
@app.get("/mcp/status")
async def get_mcp_status():
"""Get MCP tools loading status"""
status = mcp_loading_status.copy()
# Add tool list if loaded
if status["loaded"] and agent:
status["tools"] = list(agent.mcp_manager.tools.keys())
# Group by server
status["tools_by_server"] = {}
for tool_name in agent.mcp_manager.tools.keys():
server = tool_name.split("_")[0]
if server not in status["tools_by_server"]:
status["tools_by_server"][server] = []
status["tools_by_server"][server].append(tool_name)
return status
@app.post("/mcp/reload")
async def reload_mcp_tools(background_tasks: BackgroundTasks):
"""Manually trigger MCP tools reload"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
if mcp_loading_status["loading"]:
raise HTTPException(status_code=409, detail="MCP tools are already loading")
# Use FastAPI background tasks
background_tasks.add_task(load_mcp_tools_async)
return {
"success": True,
"message": "MCP tools reload started in background"
}
@app.post("/event")
async def handle_event(event_req: EventRequest):
"""Handle incoming event"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
try:
# Create event
event_data = {
"event_type": event_req.event_type,
"content": event_req.content,
"metadata": event_req.metadata or {}
}
event = Event.from_dict(event_data)
# Handle the event
with agent_lock:
result = agent.handle_event(event, max_iterations=20)
return {
"success": True,
"event_id": event.event_id,
"result": {
"final_answer": result.get('final_answer'),
"iterations": result.get('iterations'),
"tool_calls_count": len(result.get('tool_calls') or []),
"todo_items": len(result.get('todo_list') or []),
"success": result.get('success', False),
"trajectory_file": result.get('trajectory_file')
}
}
except Exception as e:
logger.error(f"Error handling event: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/agent/status")
async def get_agent_status():
"""Get current agent status"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
with agent_lock:
return {
"provider": agent.provider,
"model": agent.model,
"tool_calls_count": len(agent.tool_calls),
"todo_items": len(agent.todo_list),
"current_directory": agent.current_directory,
"mcp_tools_loaded": agent.mcp_tools_loaded,
"mcp_tools_count": len(agent.mcp_manager.tools) if agent.mcp_tools_loaded else 0
}
@app.post("/agent/reset")
async def reset_agent():
"""Reset agent state"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
with agent_lock:
agent.reset()
return {
"success": True,
"message": "Agent state reset successfully"
}
@app.post("/process/register")
async def register_process(process: ProcessRegister):
"""Register a background process for monitoring"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
with agent_lock:
agent.background_processes[process.process_id] = {
"name": process.process_name,
"start_time": datetime.now().isoformat(),
"metadata": process.metadata or {},
"reminded": False
}
return {
"success": True,
"message": f"Process '{process.process_name}' registered"
}
@app.post("/process/unregister")
async def unregister_process(process: ProcessUnregister):
"""Unregister a background process"""
if agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
with agent_lock:
if process.process_id in agent.background_processes:
del agent.background_processes[process.process_id]
return {
"success": True,
"message": f"Process {process.process_id} unregistered"
}
else:
return {
"success": False,
"message": f"Process {process.process_id} not found"
}
# ============================================================================
# Main Entry Point
# ============================================================================
def main():
"""Main entry point"""
print("\n" + "="*80)
print("🤖 EVENT-TRIGGERED AGENT SERVER (FastAPI)")
print("="*80)
print()
# Get port from environment
port = _env_int('AGENT_PORT', 8000)
print(f"✅ Starting server on port {port}")
print(f"📡 API Documentation: http://localhost:{port}/docs")
print(f"📊 ReDoc: http://localhost:{port}/redoc")
print()
print("="*80 + "\n")
# Run with uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=port,
log_level="info"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
"""Offline contract tests; these are not substitutes for the live campaign receipt."""
from __future__ import annotations
import importlib.util
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
import httpx
import pytest
HERE = Path(__file__).resolve().parent
MODULE_PATH = HERE / "unipile_mailbox_experiment.py"
SPEC = importlib.util.spec_from_file_location("experiment_6_1_unipile", MODULE_PATH)
experiment = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = experiment
SPEC.loader.exec_module(experiment)
def _email(kind: str, date: str, suffix: str) -> dict:
base = {"id": f"email-{suffix}", "account_id": "account-real",
"date": date, "role": "inbox", "folders": ["Inbox"]}
if kind == "meeting_invitation":
return {**base, "subject": "Meeting invitation: design review",
"body_plain": "START_UTC: 2026-08-03T10:00:00.000Z\n"
"END_UTC: 2026-08-03T11:00:00.000Z"}
if kind == "customer_complaint":
return {**base, "subject": "Customer complaint: delayed order",
"body_plain": "Customer complaint for order #E44-TEST. Please escalate."}
return {**base, "subject": "Marketing newsletter",
"body_plain": "Marketing newsletter promotion. Click to unsubscribe."}
def _provider(request: httpx.Request) -> httpx.Response:
assert request.headers.get("X-API-KEY") == "unit-secret"
if request.method == "GET" and request.url.path == "/api/v1/calendars":
return httpx.Response(200, json={"data": [{"id": "calendar-real",
"is_primary": True}]})
if request.method == "GET" and request.url.path.endswith("/events"):
return httpx.Response(200, json={"data": []})
if request.method == "GET" and request.url.path == "/api/v1/folders":
return httpx.Response(200, json={"items": [{"id": "folder-archive",
"name": "Archive",
"role": "archive"}]})
if request.method == "PUT" and request.url.path == "/api/v1/emails/email-marketing":
assert json.loads(request.content) == {"folders": ["archive"]}
return httpx.Response(200, json={"object": "EmailUpdated"})
if request.method == "GET" and request.url.path == "/api/v1/emails/email-marketing":
return httpx.Response(200, json={"id": "email-marketing",
"role": "archive", "folders": ["Archive"]})
return httpx.Response(404, json={"type": "unexpected_test_request"})
def test_classification_is_unique_and_meeting_interval_is_exact():
email = _email("meeting_invitation", "2026-08-01T00:00:01.000Z", "meeting")
assert experiment.classify_email(email) == "meeting_invitation"
start, end = experiment.meeting_interval(email)
assert start.isoformat() == "2026-08-03T10:00:00+00:00"
assert (end - start).total_seconds() == 3600
ambiguous = {**email, "subject": "Meeting invitation and marketing newsletter",
"body_plain": email["body_plain"] + "\nMarketing unsubscribe"}
with pytest.raises(ValueError, match="not unique"):
experiment.classify_email(ambiguous)
def test_real_api_error_is_receipted_and_raises():
def unauthorized(_: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"status": 401,
"type": "errors/missing_credentials",
"title": "Missing credentials"})
client = experiment.UnipileClient(
"api.example.invalid:12345", "unit-secret",
transport=httpx.MockTransport(unauthorized),
)
with pytest.raises(experiment.UnipileAPIError):
client.list_accounts()
assert client.calls == [{
**client.calls[0], "status": 401, "success": False,
"credential_scheme": "X-API-KEY",
"error_type": "errors/missing_credentials",
}]
assert "unit-secret" not in experiment.canonical_json(client.calls)
client.close()
def test_three_email_workflow_and_acceptance(tmp_path):
client = experiment.UnipileClient(
"api.example.invalid:12345", "unit-secret",
transport=httpx.MockTransport(_provider),
)
runner = experiment.MailboxExperiment(
client, tmp_path, "account-real", "account-real"
)
# Deliberately unordered input proves that the FIFO queue uses provider time.
emails = [
_email("marketing", "2026-08-01T00:00:03.000Z", "marketing"),
_email("meeting_invitation", "2026-08-01T00:00:01.000Z", "meeting"),
_email("customer_complaint", "2026-08-01T00:00:02.000Z", "complaint"),
]
runner.process_queue(emails)
assert [row["classification"] for row in runner.workflows] == [
"meeting_invitation", "customer_complaint", "marketing"
]
result = experiment.derive_acceptance(
runner.events, runner.workflows, client.calls,
[{"object": "EmailSent"}] * 3,
credential_secret="unit-secret", dsn_secret="api.example.invalid:12345",
)
assert result["status"] == "passed"
assert all(result["gates"].values())
assert (tmp_path / "artifacts" / "meeting_reply_draft.txt").stat().st_size > 100
assert (tmp_path / "artifacts" / "high_priority_notifications.jsonl").is_file()
assert "unit-secret" not in experiment.canonical_json(client.calls)
client.close()
def test_acceptance_fails_when_provider_archive_verification_is_missing(tmp_path):
client = experiment.UnipileClient(
"api.example.invalid:12345", "unit-secret",
transport=httpx.MockTransport(_provider),
)
runner = experiment.MailboxExperiment(client, tmp_path, "account-real", "account-real")
runner.process_queue([
_email("meeting_invitation", "2026-08-01T00:00:01.000Z", "meeting"),
_email("customer_complaint", "2026-08-01T00:00:02.000Z", "complaint"),
_email("marketing", "2026-08-01T00:00:03.000Z", "marketing"),
])
runner.workflows[-1]["archive"]["verified"] = False
result = experiment.derive_acceptance(
runner.events, runner.workflows, client.calls, [{}, {}, {}],
credential_secret="unit-secret", dsn_secret="api.example.invalid:12345",
)
assert result["status"] == "failed"
assert not result["gates"]["marketing_archived_and_verified_through_provider"]
client.close()
@@ -0,0 +1,9 @@
"""Test import bootstrap for the agent-with-event-trigger experiment."""
from pathlib import Path
import sys
EXPERIMENT_ROOT = Path(__file__).resolve().parents[1]
if str(EXPERIMENT_ROOT) not in sys.path:
sys.path.insert(0, str(EXPERIMENT_ROOT))
@@ -0,0 +1,137 @@
"""
Simple demo script to test the event-triggered agent locally
without needing the server/client architecture
"""
import os
import sys
from pathlib import Path
EXPERIMENT_ROOT = Path(__file__).resolve().parents[2]
if str(EXPERIMENT_ROOT) not in sys.path:
sys.path.insert(0, str(EXPERIMENT_ROOT))
from agent import EventTriggeredAgent, SystemHintConfig, resolve_provider_and_key
from event_types import Event, EventType
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
def main():
"""Run a simple demo of the event-triggered agent"""
print("\n" + "="*80)
print("🧪 EVENT-TRIGGERED AGENT DEMO")
print("="*80)
print()
# Get provider and API key (including DashScope/Bailian aliases and fallback)
provider = os.getenv("LLM_PROVIDER", "kimi").lower()
provider, api_key = resolve_provider_and_key(provider)
if not api_key:
print(f"❌ Error: Please set API key for provider '{provider}'")
print(" export DASHSCOPE_API_KEY='your-api-key-here' # for dashscope/qwen/bailian")
return
# Get optional model override
model = os.getenv("LLM_MODEL")
# Create agent with full system hints (matching conversational_agent.py config)
config = SystemHintConfig(
enable_timestamps=True,
enable_tool_counter=True,
enable_todo_list=True,
enable_detailed_errors=True,
enable_system_state=True,
save_trajectory=True,
trajectory_file="demo_trajectory.json",
temperature=_reasoning_safe_temperature(model, 0.7), # Matching conversational_agent.py
max_tokens=4096 # Matching conversational_agent.py
)
agent = EventTriggeredAgent(
api_key=api_key,
provider=provider,
model=model,
config=config,
verbose=True
)
print("✅ Agent initialized\n")
# Demo 1: Web message
print("\n" + "-"*80)
print("📋 Demo 1: Web Interface Message")
print("-"*80)
event1 = Event(
event_type=EventType.WEB_MESSAGE,
content="Create a simple Python script that prints 'Hello, Event-Triggered Agent!' and save it as demo_hello.py",
metadata={"user_id": "demo_user"}
)
result1 = agent.handle_event(event1, max_iterations=10)
print(f"\n✅ Event handled. Success: {result1['success']}")
print(f" Iterations: {result1['iterations']}")
print(f" Tool calls: {len(result1['tool_calls'])}")
# Demo 2: IM message
print("\n" + "-"*80)
print("📋 Demo 2: Instant Message")
print("-"*80)
event2 = Event(
event_type=EventType.IM_MESSAGE,
content="Can you run the script you just created?",
metadata={"sender": "Alice", "platform": "Slack"}
)
result2 = agent.handle_event(event2, max_iterations=10)
print(f"\n✅ Event handled. Success: {result2['success']}")
print(f" Iterations: {result2['iterations']}")
print(f" Tool calls: {len(result2['tool_calls'])}")
# Demo 3: System alert
print("\n" + "-"*80)
print("📋 Demo 3: System Alert")
print("-"*80)
event3 = Event(
event_type=EventType.SYSTEM_ALERT,
content="Please check the current directory and list all Python files.",
metadata={"alert_type": "routine_check"}
)
result3 = agent.handle_event(event3, max_iterations=10)
print(f"\n✅ Event handled. Success: {result3['success']}")
print(f" Iterations: {result3['iterations']}")
print(f" Tool calls: {len(result3['tool_calls'])}")
# Summary
print("\n" + "="*80)
print("📊 DEMO SUMMARY")
print("="*80)
print(f"Total events processed: 3")
print(f"Total tool calls: {len(agent.tool_calls)}")
print(f"Total conversation messages: {len(agent.conversation_history)}")
print(f"Trajectory saved to: {config.trajectory_file}")
print()
print("✅ Demo completed successfully!")
print()
print("💡 Next steps:")
print(" - Check demo_hello.py to see the created file")
print(" - View demo_trajectory.json to see the full conversation")
print(" - Run 'python server.py' and 'python client.py' for the full system")
print("="*80 + "\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,73 @@
"""Regression tests for the code interpreter's execution namespace."""
import importlib.util
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import patch
def _load_agent_module():
openai = types.ModuleType("openai")
openai.OpenAI = type("OpenAI", (), {})
mcp = types.ModuleType("mcp")
mcp.__path__ = []
mcp.ClientSession = type("ClientSession", (), {})
mcp.StdioServerParameters = type("StdioServerParameters", (), {})
mcp_client = types.ModuleType("mcp.client")
mcp_client.__path__ = []
mcp_stdio = types.ModuleType("mcp.client.stdio")
mcp_stdio.stdio_client = lambda *args, **kwargs: None
mcp_types = types.ModuleType("mcp.types")
mcp_types.TextContent = type("TextContent", (), {})
stubs = {
"openai": openai,
"mcp": mcp,
"mcp.client": mcp_client,
"mcp.client.stdio": mcp_stdio,
"mcp.types": mcp_types,
}
module_path = Path(__file__).resolve().parents[1] / "agent.py"
module_name = "_event_trigger_agent_under_test"
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, stubs):
sys.modules[module_name] = module
sys.path.insert(0, str(module_path.parent))
try:
spec.loader.exec_module(module)
finally:
sys.path.pop(0)
return module
class CodeInterpreterNamespaceTests(unittest.TestCase):
def test_code_interpreter_shares_names_with_defined_functions(self):
agent_module = _load_agent_module()
agent = agent_module.EventTriggeredAgent.__new__(agent_module.EventTriggeredAgent)
result = agent._tool_code_interpreter(
"value = 5\n"
"def double():\n"
" return value * 2\n"
"print(double())"
)
self.assertEqual(
result,
{
"success": True,
"stdout": "10\n",
"stderr": "",
},
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,36 @@
"""Regression test: malformed AGENT_PORT must not crash server startup.
Both server variants parsed AGENT_PORT with bare int() (one inside
build_parser's default, one in main), so AGENT_PORT=abc crashed with an
unhandled ValueError at startup. They now fall back to 8000 with a warning.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
import server
import server_fastapi
def test_env_int_falls_back_on_malformed(monkeypatch):
monkeypatch.setenv("AGENT_PORT", "abc")
assert server._env_int("AGENT_PORT", 8000) == 8000
assert server_fastapi._env_int("AGENT_PORT", 8000) == 8000
def test_env_int_parses_valid_value(monkeypatch):
monkeypatch.setenv("AGENT_PORT", "9000")
assert server._env_int("AGENT_PORT", 8000) == 9000
assert server_fastapi._env_int("AGENT_PORT", 8000) == 9000
def test_env_int_default_when_unset(monkeypatch):
monkeypatch.delenv("AGENT_PORT", raising=False)
assert server._env_int("AGENT_PORT", 8000) == 8000
def test_build_parser_survives_malformed_env(monkeypatch):
monkeypatch.setenv("AGENT_PORT", "not-a-port")
args = server.build_parser().parse_args([])
assert args.port == 8000
@@ -0,0 +1,30 @@
"""
Test suite locking out TypeError in event server response formatting
when handle_event returns a result dictionary with tool_calls or todo_list set to None.
"""
def test_server_result_formatting_handles_null_lists():
"""
Ensure event response dictionary formats tool_calls_count and todo_items without TypeError
when tool_calls or todo_list is None.
"""
result = {
'final_answer': 'Done',
'iterations': 1,
'tool_calls': None,
'todo_list': None,
'success': True,
'trajectory_file': None
}
formatted = {
"final_answer": result.get('final_answer'),
"iterations": result.get('iterations'),
"tool_calls_count": len(result.get('tool_calls') or []),
"todo_items": len(result.get('todo_list') or []),
"success": result.get('success', False),
"trajectory_file": result.get('trajectory_file')
}
assert formatted["tool_calls_count"] == 0
assert formatted["todo_items"] == 0
@@ -0,0 +1,760 @@
#!/usr/bin/env python3
"""Run Chapter 4 Experiment 6-1 against a real Unipile mailbox.
The listener uses documented mailbox polling, which the manuscript explicitly
allows as an alternative to push notifications. It never substitutes local
mail files for provider objects. Missing/invalid credentials produce a durable
blocked receipt instead of a successful demonstration.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
HERE = Path(__file__).resolve().parent
PROTOCOL_PATH = HERE / "experiment_protocol.json"
VALIDATION_ROOT = HERE / "validation" / "experiment_6_1"
UTC = timezone.utc
SIMULATION_PATTERN = re.compile(
r"\b(mock(?:ed)?|placeholder|synthetic|simulat(?:ed|ion))\b", re.IGNORECASE
)
CREDENTIAL_PATTERN = re.compile(r"\b(?:sk|gh[opusr])-[A-Za-z0-9_-]{12,}\b")
def canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":"), default=str)
def sha256(value: bytes | str) -> str:
if isinstance(value, str):
value = value.encode()
return hashlib.sha256(value).hexdigest()
def write_json(path: Path, value: Any) -> None:
text = json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n"
if CREDENTIAL_PATTERN.search(text):
raise ValueError(f"credential-shaped value in {path}")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def iso_millis(value: datetime) -> str:
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def parse_datetime(value: Any) -> datetime | None:
if isinstance(value, dict):
value = value.get("date_time") or value.get("dateTime") or value.get("datetime") \
or value.get("date")
if not value:
return None
text = str(value).strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def redacted(value: Any, key: str = "") -> Any:
"""Retain audit shape while hashing identities and message bodies."""
lower = key.lower()
if isinstance(value, str) and re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value):
return {"sha256": sha256(value)[:20], "present": True, "kind": "email_address"}
if any(token in lower for token in ("token", "authorization", "api_key", "credential")):
return "<redacted>"
if lower in {"body", "body_plain", "text", "content"} and isinstance(value, str):
return {"sha256": sha256(value), "characters": len(value)}
if lower == "subject" and isinstance(value, str):
# Experiment subjects contain no personal data and prove scenario fidelity.
return value
if lower.endswith("_id") or lower in {"id", "identifier", "email", "from", "to"}:
if isinstance(value, (str, int)):
return {"sha256": sha256(str(value))[:20], "present": bool(value)}
if isinstance(value, dict):
return {str(child_key): redacted(child, str(child_key))
for child_key, child in value.items()}
if isinstance(value, list):
return [redacted(child, key) for child in value]
return value
class UnipileAPIError(RuntimeError):
def __init__(self, method: str, path: str, status: int, payload: Any):
self.method = method
self.path = path
self.status = status
self.payload = payload
title = payload.get("title") if isinstance(payload, dict) else None
error_type = payload.get("type") if isinstance(payload, dict) else None
super().__init__(f"{method} {path} returned {status}: {error_type or title or 'API error'}")
class UnipileClient:
"""Small receipt-producing adapter for the official Email/Calendar API."""
def __init__(self, dsn: str, access_token: str, *, timeout: float = 45,
transport: httpx.BaseTransport | None = None):
if not dsn or not access_token:
raise ValueError("UNIPILE_DSN and UNIPILE_ACCESS_TOKEN are required")
base = dsn.strip().rstrip("/")
if not base.startswith(("http://", "https://")):
base = "https://" + base
self.base_url = base
self.access_token = access_token.strip()
self.calls: list[dict[str, Any]] = []
self.http = httpx.Client(
base_url=base,
timeout=timeout,
follow_redirects=True,
headers={"X-API-KEY": self.access_token,
"User-Agent": "ai-agent-book-experiment/4.4"},
transport=transport,
)
def close(self) -> None:
self.http.close()
def request(self, method: str, path: str, *, params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
multipart: dict[str, Any] | None = None,
expected: set[int] | None = None) -> Any:
expected = expected or {200}
started = time.perf_counter()
request_kwargs: dict[str, Any] = {"params": params}
if json_body is not None:
request_kwargs["json"] = json_body
if multipart is not None:
request_kwargs["files"] = {key: (None, value) for key, value in multipart.items()}
try:
response = self.http.request(method, path, **request_kwargs)
try:
payload: Any = response.json()
except ValueError:
payload = {"non_json_sha256": sha256(response.content),
"bytes": len(response.content)}
receipt = {
"method": method.upper(),
"path": path,
"request": redacted({"params": params or {},
"json": json_body,
"multipart": multipart}),
"credential_scheme": "X-API-KEY",
"status": response.status_code,
"success": response.status_code in expected,
"latency_seconds": round(time.perf_counter() - started, 3),
"response_sha256": sha256(response.content),
"response_bytes": len(response.content),
"response_shape": sorted(payload) if isinstance(payload, dict)
else type(payload).__name__,
"error_type": payload.get("type") if isinstance(payload, dict) else None,
"error_title": payload.get("title") if isinstance(payload, dict) else None,
}
self.calls.append(receipt)
if response.status_code not in expected:
raise UnipileAPIError(method.upper(), path, response.status_code, payload)
return payload
except UnipileAPIError:
raise
except Exception as exc:
self.calls.append({
"method": method.upper(), "path": path,
"request": redacted({"params": params or {}, "json": json_body,
"multipart": multipart}),
"credential_scheme": "X-API-KEY", "status": None, "success": False,
"latency_seconds": round(time.perf_counter() - started, 3),
"error_type": type(exc).__name__, "error_title": str(exc)[:300],
})
raise
def list_accounts(self) -> list[dict[str, Any]]:
payload = self.request("GET", "/api/v1/accounts", params={"limit": 100})
items = payload.get("items", []) if isinstance(payload, dict) else []
if not isinstance(items, list):
raise ValueError("Unipile accounts response did not contain an items list")
return items
def list_folders(self, account_id: str) -> list[dict[str, Any]]:
payload = self.request("GET", "/api/v1/folders",
params={"account_id": account_id})
return payload.get("items", []) if isinstance(payload, dict) else []
def list_emails(self, account_id: str, *, after: datetime,
folder: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
params: dict[str, Any] = {
"account_id": account_id, "after": iso_millis(after),
"limit": min(max(limit, 1), 250), "meta_only": False,
}
if folder:
params["folder"] = folder
payload = self.request("GET", "/api/v1/emails", params=params)
items = payload.get("items", []) if isinstance(payload, dict) else []
if not isinstance(items, list):
raise ValueError("Unipile email response did not contain an items list")
return items
def get_email(self, email_id: str) -> dict[str, Any]:
payload = self.request("GET", f"/api/v1/emails/{email_id}")
if not isinstance(payload, dict):
raise ValueError("Unipile email response was not an object")
return payload
def update_email_folders(self, email_id: str, folders: list[str]) -> dict[str, Any]:
payload = self.request("PUT", f"/api/v1/emails/{email_id}",
json_body={"folders": folders})
if not isinstance(payload, dict):
raise ValueError("Unipile update response was not an object")
return payload
def send_email(self, account_id: str, recipient: str, subject: str,
body: str) -> dict[str, Any]:
payload = self.request(
"POST", "/api/v1/emails", expected={201}, multipart={
"account_id": account_id,
"to": json.dumps([{"display_name": "Experiment 6-1 mailbox",
"identifier": recipient}]),
"subject": subject,
"body": body,
},
)
if not isinstance(payload, dict):
raise ValueError("Unipile send response was not an object")
return payload
def list_calendars(self, account_id: str) -> list[dict[str, Any]]:
payload = self.request("GET", "/api/v1/calendars",
params={"account_id": account_id, "limit": 100})
data = payload.get("data", []) if isinstance(payload, dict) else []
if not isinstance(data, list):
raise ValueError("Unipile calendars response did not contain a data list")
return data
def list_calendar_events(self, account_id: str, calendar_id: str,
start: datetime, end: datetime) -> list[dict[str, Any]]:
payload = self.request(
"GET", f"/api/v1/calendars/{calendar_id}/events", params={
"account_id": account_id,
"start": iso_millis(start - timedelta(days=1)),
"end": iso_millis(end + timedelta(days=1)),
"expand_recurring": True, "limit": 250,
},
)
data = payload.get("data", []) if isinstance(payload, dict) else []
if not isinstance(data, list):
raise ValueError("Unipile events response did not contain a data list")
return data
def account_email(account: dict[str, Any]) -> str | None:
"""Find an email-looking account identity without exposing it in receipts."""
preferred = ("email", "identifier", "username", "user", "name")
for key in preferred:
value = account.get(key)
if isinstance(value, str) and re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value):
return value
for value in account.values():
if isinstance(value, dict):
found = account_email(value)
if found:
return found
return None
def email_text(email: dict[str, Any]) -> str:
for key in ("body_plain", "body", "text"):
value = email.get(key)
if isinstance(value, str) and value.strip():
return value
return ""
def classify_email(email: dict[str, Any]) -> str:
text = f"{email.get('subject', '')}\n{email_text(email)}".lower()
matches = []
if "meeting invitation" in text and "start_utc:" in text and "end_utc:" in text:
matches.append("meeting_invitation")
if "customer complaint" in text and re.search(r"order\s*#[a-z0-9-]+", text):
matches.append("customer_complaint")
if "marketing" in text and ("unsubscribe" in text or "newsletter" in text):
matches.append("marketing")
if len(matches) != 1:
raise ValueError(f"email classification was not unique: {matches}")
return matches[0]
def meeting_interval(email: dict[str, Any]) -> tuple[datetime, datetime]:
text = email_text(email)
start_match = re.search(r"START_UTC:\s*([^\s]+)", text, re.IGNORECASE)
end_match = re.search(r"END_UTC:\s*([^\s]+)", text, re.IGNORECASE)
start = parse_datetime(start_match.group(1)) if start_match else None
end = parse_datetime(end_match.group(1)) if end_match else None
if not start or not end or end <= start:
raise ValueError("meeting email lacked a valid START_UTC/END_UTC interval")
return start, end
def event_overlaps(event: dict[str, Any], start: datetime, end: datetime) -> bool:
event_start = parse_datetime(event.get("start") or event.get("start_at"))
event_end = parse_datetime(event.get("end") or event.get("end_at"))
cancelled = bool(event.get("is_cancelled")) or str(event.get("status", "")).lower() == "cancelled"
return bool(event_start and event_end and not cancelled
and event_start < end and event_end > start)
def canonical_event(email: dict[str, Any], sequence: int) -> dict[str, Any]:
email_id = str(email.get("id", ""))
account_id = str(email.get("account_id", ""))
if not email_id or not account_id:
raise ValueError("provider email object lacked id/account_id")
return {
"sequence": sequence,
"source": {"type": "email", "provider": "unipile",
"email_id_sha256": sha256(email_id)[:20],
"account_id_sha256": sha256(account_id)[:20]},
"channel": "unipile_mailbox_poll",
"content": {"subject": email.get("subject", ""),
"body_sha256": sha256(email_text(email)),
"body_characters": len(email_text(email))},
"context": {"provider_date": email.get("date"), "role": email.get("role"),
"folders_count": len(email.get("folders") or [])},
"provider_receipt_sha256": sha256(canonical_json(redacted(email))),
}
def provider_date(email: dict[str, Any]) -> tuple[datetime, str]:
return (parse_datetime(email.get("date")) or datetime.min.replace(tzinfo=UTC),
str(email.get("id", "")))
class MailboxExperiment:
def __init__(self, client: UnipileClient, campaign_dir: Path,
account_id: str, calendar_account_id: str):
self.client = client
self.campaign_dir = campaign_dir
self.account_id = account_id
self.calendar_account_id = calendar_account_id
self.events: list[dict[str, Any]] = []
self.workflows: list[dict[str, Any]] = []
def _meeting(self, email: dict[str, Any]) -> dict[str, Any]:
start, end = meeting_interval(email)
calendars = self.client.list_calendars(self.calendar_account_id)
if not calendars:
raise RuntimeError("calendar conflict check returned no calendars")
calendar = next((row for row in calendars
if row.get("is_primary") or row.get("is_default")), calendars[0])
calendar_id = str(calendar.get("id", ""))
if not calendar_id:
raise ValueError("selected calendar lacked an id")
events = self.client.list_calendar_events(
self.calendar_account_id, calendar_id, start, end
)
conflicts = [event for event in events if event_overlaps(event, start, end)]
disposition = "decline" if conflicts else "accept"
draft = (
f"Subject: Re: {email.get('subject', 'Meeting invitation')}\n\n"
+ ("Thank you for the invitation. I have a calendar conflict during the proposed "
"time, so I must decline. Could we find another time?"
if conflicts else
"Thank you for the invitation. I checked the calendar and the proposed time is "
"available. I am happy to accept.")
+ "\n"
)
draft_path = self.campaign_dir / "artifacts" / "meeting_reply_draft.txt"
draft_path.parent.mkdir(parents=True, exist_ok=True)
draft_path.write_text(draft, encoding="utf-8")
return {
"classification": "meeting_invitation",
"calendar_check": {"performed": True, "calendar_id_sha256": sha256(calendar_id)[:20],
"events_examined": len(events), "conflict_count": len(conflicts),
"conflict": bool(conflicts), "start": iso_millis(start),
"end": iso_millis(end)},
"draft": {"disposition": disposition, "path": str(draft_path),
"bytes": draft_path.stat().st_size,
"sha256": sha256(draft_path.read_bytes())},
}
def _complaint(self, email: dict[str, Any]) -> dict[str, Any]:
text = email_text(email)
order = re.search(r"order\s*#([A-Za-z0-9-]+)", text, re.IGNORECASE)
if not order:
raise ValueError("complaint lacked an order identifier")
notification = {
"created_at": datetime.now(UTC).isoformat(),
"priority": "high", "delivered": True,
"channel": "durable_console_and_jsonl",
"classification": "customer_complaint",
"subject": email.get("subject", ""),
"order_reference": order.group(1),
"summary": "Customer reports an unresolved delayed order and requests human follow-up.",
}
path = self.campaign_dir / "artifacts" / "high_priority_notifications.jsonl"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(notification, ensure_ascii=False) + "\n")
stream.flush()
os.fsync(stream.fileno())
print(f"HIGH PRIORITY: customer complaint for order #{order.group(1)}", file=sys.stderr)
return {"classification": "customer_complaint",
"extracted": {"order_reference": order.group(1),
"requires_human_follow_up": True},
"notification": {**notification, "path": str(path),
"file_sha256": sha256(path.read_bytes())}}
def _marketing(self, email: dict[str, Any]) -> dict[str, Any]:
folders = self.client.list_folders(self.account_id)
archive_candidates = [row for row in folders if
"archive" in str(row.get("role", "")).lower()
or "archive" in str(row.get("name", "")).lower()]
update = self.client.update_email_folders(str(email["id"]), ["archive"])
verified = self.client.get_email(str(email["id"]))
role = str(verified.get("role", "")).lower()
folder_text = " ".join(str(value).lower()
for value in (verified.get("folders") or []))
archived = role == "archive" or "archive" in folder_text \
or (role != "inbox" and "inbox" not in folder_text)
return {
"classification": "marketing",
"archive": {"update_object": update.get("object"),
"archive_folder_candidates": len(archive_candidates),
"verified": archived,
"verified_role": role,
"verified_folders_sha256": sha256(folder_text)},
}
def process_queue(self, emails: list[dict[str, Any]]) -> None:
queue = deque(sorted(emails, key=provider_date))
while queue:
email = queue.popleft()
sequence = len(self.events)
event = canonical_event(email, sequence)
classification = classify_email(email)
event["context"]["classification"] = classification
self.events.append(event)
if classification == "meeting_invitation":
workflow = self._meeting(email)
elif classification == "customer_complaint":
workflow = self._complaint(email)
else:
workflow = self._marketing(email)
workflow["sequence"] = sequence
workflow["email_id_sha256"] = event["source"]["email_id_sha256"]
self.workflows.append(workflow)
def seed_messages(client: UnipileClient, sender_account_id: str, recipient: str,
campaign_id: str) -> list[dict[str, Any]]:
start = (datetime.now(UTC) + timedelta(days=2)).replace(
hour=10, minute=0, second=0, microsecond=0
)
end = start + timedelta(hours=1)
messages = [
(
f"[EXP6-1 {campaign_id}] Meeting invitation: design review",
"Meeting invitation for the agent experiment.\n"
f"START_UTC: {iso_millis(start)}\nEND_UTC: {iso_millis(end)}\n"
"Please accept if the calendar is free, otherwise decline.",
),
(
f"[EXP6-1 {campaign_id}] Customer complaint: delayed order",
f"Customer complaint for order #{campaign_id[-8:]}. The delivery is overdue and "
"support has not resolved it. Please arrange urgent human follow-up.",
),
(
f"[EXP6-1 {campaign_id}] Marketing newsletter",
"Marketing newsletter: save 20 percent on productivity software. "
"This bulk promotion includes an unsubscribe link.",
),
]
return [client.send_email(sender_account_id, recipient, subject, body)
for subject, body in messages]
def inbox_folder(client: UnipileClient, account_id: str) -> str | None:
folders = client.list_folders(account_id)
inbox = next((row for row in folders if
str(row.get("role", "")).lower() == "inbox"
or str(row.get("name", "")).lower() == "inbox"), None)
if not inbox:
return None
return str(inbox.get("provider_id") or inbox.get("id") or inbox.get("name"))
def poll_campaign_emails(client: UnipileClient, account_id: str, campaign_id: str,
after: datetime, *, timeout: float,
interval: float) -> list[dict[str, Any]]:
folder = inbox_folder(client, account_id)
deadline = time.monotonic() + timeout
found: dict[str, dict[str, Any]] = {}
marker = f"[EXP6-1 {campaign_id}]"
while time.monotonic() < deadline and len(found) < 3:
for reference in client.list_emails(account_id, after=after, folder=folder):
if marker not in str(reference.get("subject", "")):
continue
email_id = str(reference.get("id", ""))
if email_id and email_id not in found:
full = client.get_email(email_id)
if str(full.get("role", reference.get("role", ""))).lower() == "sent":
continue
found[email_id] = full
if len(found) < 3:
time.sleep(interval)
if len(found) != 3:
raise TimeoutError(f"received {len(found)} of three campaign emails before timeout")
return sorted(found.values(), key=provider_date)
def official_schema_receipts(urls: list[str]) -> list[dict[str, Any]]:
receipts = []
with httpx.Client(timeout=30, follow_redirects=True) as client:
for url in urls:
try:
response = client.get(url)
receipts.append({"url": url, "status": response.status_code,
"bytes": len(response.content),
"sha256": sha256(response.content),
"retrieved_at": datetime.now(UTC).isoformat()})
except Exception as exc:
receipts.append({"url": url, "status": None,
"error_type": type(exc).__name__})
return receipts
def bearer_diagnostic(client: UnipileClient) -> dict[str, Any]:
"""Preserve the rejected alternate auth probe without leaking its token."""
started = time.perf_counter()
try:
response = httpx.get(
client.base_url + "/api/v1/accounts",
headers={"Authorization": "Bearer " + client.access_token,
"User-Agent": "ai-agent-book-experiment/4.4-diagnostic"},
timeout=30,
)
try:
payload = response.json()
except ValueError:
payload = {}
return {"method": "GET", "path": "/api/v1/accounts",
"credential_scheme": "Authorization: Bearer <redacted>",
"status": response.status_code,
"error_type": payload.get("type"), "error_title": payload.get("title"),
"response_sha256": sha256(response.content),
"latency_seconds": round(time.perf_counter() - started, 3)}
except Exception as exc:
return {"method": "GET", "path": "/api/v1/accounts",
"credential_scheme": "Authorization: Bearer <redacted>",
"status": None, "error_type": type(exc).__name__,
"latency_seconds": round(time.perf_counter() - started, 3)}
def derive_acceptance(events: list[dict[str, Any]], workflows: list[dict[str, Any]],
calls: list[dict[str, Any]], seed_receipts: list[dict[str, Any]],
*, credential_secret: str, dsn_secret: str) -> dict[str, Any]:
by_class = {row.get("classification"): row for row in workflows}
meeting = by_class.get("meeting_invitation", {})
complaint = by_class.get("customer_complaint", {})
marketing = by_class.get("marketing", {})
encoded = canonical_json({"events": events, "workflows": workflows, "calls": calls})
control_plane = canonical_json([{key: value for key, value in call.items()
if key not in {"response_sha256"}}
for call in calls])
gates = {
"three_real_inbound_unipile_events": (
len(events) == 3 and all(
event.get("source", {}).get("provider") == "unipile"
and event.get("channel") == "unipile_mailbox_poll"
and bool(event.get("provider_receipt_sha256")) for event in events
)
),
"fifo_event_queue": [event.get("sequence") for event in events] == [0, 1, 2]
and [row.get("sequence") for row in workflows] == [0, 1, 2],
"exact_three_scenario_classifications": set(by_class) == {
"meeting_invitation", "customer_complaint", "marketing"
} and len(workflows) == 3,
"meeting_calendar_checked_and_reply_drafted": (
meeting.get("calendar_check", {}).get("performed") is True
and meeting.get("calendar_check", {}).get("conflict") in {True, False}
and meeting.get("draft", {}).get("disposition") in {"accept", "decline"}
and meeting.get("draft", {}).get("bytes", 0) > 100
and len(meeting.get("draft", {}).get("sha256", "")) == 64
),
"complaint_extracted_and_high_priority_notification_delivered": (
complaint.get("extracted", {}).get("requires_human_follow_up") is True
and bool(complaint.get("extracted", {}).get("order_reference"))
and complaint.get("notification", {}).get("priority") == "high"
and complaint.get("notification", {}).get("delivered") is True
and len(complaint.get("notification", {}).get("file_sha256", "")) == 64
),
"marketing_archived_and_verified_through_provider": (
marketing.get("archive", {}).get("update_object") == "EmailUpdated"
and marketing.get("archive", {}).get("verified") is True
),
"required_unipile_calls_succeeded": bool(calls) and all(
call.get("success") is True for call in calls
),
"three_seed_messages_sent_through_unipile": (
len(seed_receipts) == 3 and all(isinstance(row, dict) for row in seed_receipts)
),
"credentials_and_dsn_absent_from_receipts": (
credential_secret not in encoded and dsn_secret not in encoded
),
"no_simulation_markers_in_control_plane": not SIMULATION_PATTERN.search(control_plane),
}
return {"status": "passed" if all(gates.values()) else "failed", "gates": gates}
def build_manifest(campaign_dir: Path, summary: dict[str, Any]) -> dict[str, Any]:
files = []
for path in sorted(campaign_dir.rglob("*")):
if path.is_file() and path.name != "manifest.json":
data = path.read_bytes()
files.append({"path": str(path.relative_to(campaign_dir)),
"bytes": len(data), "sha256": sha256(data)})
return {
"experiment": "6-1", "campaign_id": summary.get("campaign_id"),
"generated_at": datetime.now(UTC).isoformat(),
"status": summary.get("status"),
"official_complete": summary.get("status") == "passed",
"files": files,
}
def choose_account(accounts: list[dict[str, Any]], requested: str | None) -> dict[str, Any]:
if requested:
match = next((account for account in accounts if account.get("id") == requested), None)
if not match:
raise ValueError("requested account ID was not returned by Unipile")
return match
candidates = [account for account in accounts if
"mail" in canonical_json(account.get("sources", [])).lower()
or account_email(account)]
if not candidates:
raise RuntimeError("Unipile returned no mail-capable account")
return candidates[0]
def run(args: argparse.Namespace) -> Path:
protocol = json.loads(PROTOCOL_PATH.read_text(encoding="utf-8"))
campaign_id = args.campaign_id or datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
campaign_dir = VALIDATION_ROOT / campaign_id
campaign_dir.mkdir(parents=True, exist_ok=False)
write_json(campaign_dir / "protocol.json", protocol)
docs = official_schema_receipts(protocol["official_schema_sources"])
dsn = os.getenv("UNIPILE_DSN", "")
token = os.getenv("UNIPILE_ACCESS_TOKEN", "")
summary: dict[str, Any] = {
"experiment": "6-1", "campaign_id": campaign_id,
"generated_at": datetime.now(UTC).isoformat(),
"provider": "unipile", "base_url_sha256": sha256(dsn) if dsn else None,
"official_schema_receipts": docs,
}
client: UnipileClient | None = None
try:
client = UnipileClient(dsn, token)
accounts = client.list_accounts()
if args.preflight_only:
summary.update({"status": "preflight_passed", "account_count": len(accounts),
"official_complete": False,
"account_receipts": [redacted(account) for account in accounts],
"api_calls": client.calls})
write_json(campaign_dir / "summary.json", summary)
return campaign_dir
listen_account = choose_account(accounts, args.listen_account_id)
sender_account = choose_account(accounts, args.sender_account_id) \
if args.sender_account_id else listen_account
calendar_account = choose_account(accounts, args.calendar_account_id) \
if args.calendar_account_id else listen_account
recipient = args.recipient or account_email(listen_account)
if not recipient:
raise RuntimeError("could not infer listener email; pass --recipient")
started = datetime.now(UTC) - timedelta(minutes=1)
seeds = seed_messages(client, str(sender_account["id"]), recipient, campaign_id)
emails = poll_campaign_emails(
client, str(listen_account["id"]), campaign_id, started,
timeout=args.poll_timeout, interval=args.poll_interval,
)
experiment = MailboxExperiment(
client, campaign_dir, str(listen_account["id"]), str(calendar_account["id"])
)
experiment.process_queue(emails)
acceptance = derive_acceptance(
experiment.events, experiment.workflows, client.calls, seeds,
credential_secret=token, dsn_secret=dsn,
)
summary.update({
"status": acceptance["status"], "listener": "unipile_mailbox_poll",
"official_complete": acceptance["status"] == "passed",
"account_receipts": {
"listener": redacted(listen_account), "sender": redacted(sender_account),
"calendar": redacted(calendar_account),
},
"seed_receipts": redacted(seeds), "events": experiment.events,
"workflows": experiment.workflows, "api_calls": client.calls,
"acceptance": acceptance,
})
except Exception as exc:
credential_block = isinstance(exc, UnipileAPIError) and exc.status == 401
if client and credential_block:
summary["alternate_auth_diagnostic"] = bearer_diagnostic(client)
summary.update({
"status": "blocked" if credential_block or not dsn or not token else "failed",
"official_complete": False,
"blocker_or_error": {"type": type(exc).__name__, "message": str(exc)},
"credentials_present": {"UNIPILE_DSN": bool(dsn),
"UNIPILE_ACCESS_TOKEN": bool(token)},
"api_calls": client.calls if client else [],
"acceptance": {"status": "blocked" if credential_block or not dsn or not token else "failed", "gates": {
"valid_unipile_credentials": False,
"real_mailbox_campaign_completed": False,
}},
})
finally:
if client:
client.close()
write_json(campaign_dir / "summary.json", summary)
write_json(campaign_dir / "manifest.json", build_manifest(campaign_dir, summary))
write_json(VALIDATION_ROOT / "latest.json", {
"experiment": "6-1", "campaign_id": campaign_id,
"status": summary.get("status"),
"official_complete": summary.get("status") == "passed",
"manifest": str((campaign_dir / "manifest.json").relative_to(HERE)),
"manifest_sha256": sha256((campaign_dir / "manifest.json").read_bytes()),
})
return campaign_dir
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--campaign-id")
parser.add_argument("--preflight-only", action="store_true")
parser.add_argument("--recipient",
help="Listener mailbox address; inferred from account when omitted")
parser.add_argument("--listen-account-id")
parser.add_argument("--sender-account-id")
parser.add_argument("--calendar-account-id")
parser.add_argument("--poll-timeout", type=float, default=180)
parser.add_argument("--poll-interval", type=float, default=5)
args = parser.parse_args()
path = run(args)
print(path)
if __name__ == "__main__":
main()
@@ -0,0 +1,15 @@
{
"generated_at": "2026-07-29T21:17:21.956016+00:00",
"files": [
{
"path": "protocol.json",
"bytes": 1924,
"sha256": "1826c3aa1c2ce0af96277e13adb237f4c37b005c8708a113159ed768c296fbc1"
},
{
"path": "summary.json",
"bytes": 3995,
"sha256": "3c8aa1272f9ddbb44ae9614af7183943c1bb39ea60d4ce0de21d923c3ee9cba3"
}
]
}
@@ -0,0 +1,55 @@
{
"experiment": "6-1",
"title": "Real event-driven mailbox workflow",
"authority": "book/chapter6.md:466",
"mail_provider": "Unipile Email API",
"listener": {
"mode": "polling",
"endpoint": "GET /api/v1/emails",
"event_channel": "unipile_mailbox_poll",
"queue": "FIFO by provider timestamp then email id"
},
"scenarios": [
{
"classification": "meeting_invitation",
"required_actions": [
"live_calendar_conflict_check",
"accept_or_decline_draft"
]
},
{
"classification": "customer_complaint",
"required_actions": [
"key_information_extraction",
"high_priority_notification"
]
},
{
"classification": "marketing",
"required_actions": [
"provider_archive_update",
"post_update_verification"
]
}
],
"official_schema_sources": [
"https://developer.unipile.com/reference/accountscontroller_listaccounts.md",
"https://developer.unipile.com/reference/mailscontroller_listmails.md",
"https://developer.unipile.com/reference/mailscontroller_getmail.md",
"https://developer.unipile.com/reference/mailscontroller_updatemail.md",
"https://developer.unipile.com/reference/folderscontroller_listfolders.md",
"https://developer.unipile.com/reference/calendarscontroller_listcalendars.md",
"https://developer.unipile.com/reference/calendarscontroller_listcalendareventsbycalendar.md",
"https://developer.unipile.com/docs/new-emails-webhook.md"
],
"acceptance": {
"no_local_or_mock_mailbox_substitute": true,
"three_real_inbound_email_objects": true,
"calendar_query_receipted": true,
"draft_artifact_hashed": true,
"high_priority_notification_delivered": true,
"marketing_email_archived_and_verified": true,
"identifiers_and_credentials_redacted": true,
"fail_closed_on_missing_or_invalid_credentials": true
}
}
@@ -0,0 +1,117 @@
{
"experiment": "6-1",
"campaign_id": "credential_probe_20260730T050900Z",
"generated_at": "2026-07-29T21:17:18.847328+00:00",
"provider": "unipile",
"base_url_sha256": "28b52f572e99947db7fa2192ba95435ed4d01404e53a40c2b4f6de2274513a9b",
"official_schema_receipts": [
{
"url": "https://developer.unipile.com/reference/accountscontroller_listaccounts.md",
"status": 200,
"bytes": 151069,
"sha256": "e63a180008549782b9b946734aea82bc2b91e50d12029f21617b1944e50368fd",
"retrieved_at": "2026-07-29T21:17:14.234772+00:00"
},
{
"url": "https://developer.unipile.com/reference/mailscontroller_listmails.md",
"status": 200,
"bytes": 102744,
"sha256": "6cefdb5ae883c8573b5c5d5b4cc3b6f2168909e224afed419209578ae7bf6b25",
"retrieved_at": "2026-07-29T21:17:14.881533+00:00"
},
{
"url": "https://developer.unipile.com/reference/mailscontroller_getmail.md",
"status": 200,
"bytes": 88581,
"sha256": "c3b5c98505a1dbb4433f67651796124dd3eb7b044a264581dc86cf31ae994d86",
"retrieved_at": "2026-07-29T21:17:15.567230+00:00"
},
{
"url": "https://developer.unipile.com/reference/mailscontroller_updatemail.md",
"status": 200,
"bytes": 16690,
"sha256": "26d1c3f2c6e0d75fb1445a220a90c701e95790ba33e31a0c89088c13ac393647",
"retrieved_at": "2026-07-29T21:17:16.643818+00:00"
},
{
"url": "https://developer.unipile.com/reference/folderscontroller_listfolders.md",
"status": 200,
"bytes": 22162,
"sha256": "d35d2042bd261314af6597ffdf9613d2e3df799e062e1f598708e07cdca1b957",
"retrieved_at": "2026-07-29T21:17:17.272871+00:00"
},
{
"url": "https://developer.unipile.com/reference/calendarscontroller_listcalendars.md",
"status": 200,
"bytes": 20671,
"sha256": "cf35a6a277b9390bf937dfe8300788c348999652903ee91ca07b2f9ff86cf8da",
"retrieved_at": "2026-07-29T21:17:17.902366+00:00"
},
{
"url": "https://developer.unipile.com/reference/calendarscontroller_listcalendareventsbycalendar.md",
"status": 200,
"bytes": 36444,
"sha256": "7f4b3414f9d4a78a23bf4a87aa3d82f05eea2e00b101063cc82694c8335d96e3",
"retrieved_at": "2026-07-29T21:17:18.521682+00:00"
},
{
"url": "https://developer.unipile.com/docs/new-emails-webhook.md",
"status": 200,
"bytes": 1729,
"sha256": "50c4808b875df5d71a3734ff4621b557d6fd40380e253cb690cc70d61148ee6a",
"retrieved_at": "2026-07-29T21:17:18.847210+00:00"
}
],
"alternate_auth_diagnostic": {
"method": "GET",
"path": "/api/v1/accounts",
"credential_scheme": "Authorization: Bearer <redacted>",
"status": 401,
"error_type": "errors/invalid_credentials",
"error_title": "Invalid credentials",
"response_sha256": "35feddea5acbb597bdd6395c36c393c077bf94090fb311652a85e10e464c6b59",
"latency_seconds": 1.57
},
"status": "blocked",
"blocker_or_error": {
"type": "UnipileAPIError",
"message": "GET /api/v1/accounts returned 401: errors/missing_credentials"
},
"credentials_present": {
"UNIPILE_DSN": true,
"UNIPILE_ACCESS_TOKEN": true
},
"api_calls": [
{
"method": "GET",
"path": "/api/v1/accounts",
"request": {
"params": {
"limit": 100
},
"json": null,
"multipart": null
},
"credential_scheme": "X-API-KEY",
"status": 401,
"success": false,
"latency_seconds": 1.522,
"response_sha256": "47c9318169e1bdccdcd71fb77189991e092db3344b06c1f59b8e1e426296babc",
"response_bytes": 80,
"response_shape": [
"status",
"title",
"type"
],
"error_type": "errors/missing_credentials",
"error_title": "Missing credentials"
}
],
"acceptance": {
"status": "failed",
"gates": {
"valid_unipile_credentials": false,
"real_mailbox_campaign_completed": false
}
}
}
@@ -0,0 +1,19 @@
{
"experiment": "6-1",
"campaign_id": "credential_probe_20260730T064500Z",
"generated_at": "2026-07-29T22:23:37.271474+00:00",
"status": "blocked",
"official_complete": false,
"files": [
{
"path": "protocol.json",
"bytes": 1924,
"sha256": "1826c3aa1c2ce0af96277e13adb237f4c37b005c8708a113159ed768c296fbc1"
},
{
"path": "summary.json",
"bytes": 4027,
"sha256": "413f8d00bf0d82fd4bf86913988f2b09907a9bc8b3bec2da2ffadab2f969f45c"
}
]
}
@@ -0,0 +1,55 @@
{
"experiment": "6-1",
"title": "Real event-driven mailbox workflow",
"authority": "book/chapter6.md:466",
"mail_provider": "Unipile Email API",
"listener": {
"mode": "polling",
"endpoint": "GET /api/v1/emails",
"event_channel": "unipile_mailbox_poll",
"queue": "FIFO by provider timestamp then email id"
},
"scenarios": [
{
"classification": "meeting_invitation",
"required_actions": [
"live_calendar_conflict_check",
"accept_or_decline_draft"
]
},
{
"classification": "customer_complaint",
"required_actions": [
"key_information_extraction",
"high_priority_notification"
]
},
{
"classification": "marketing",
"required_actions": [
"provider_archive_update",
"post_update_verification"
]
}
],
"official_schema_sources": [
"https://developer.unipile.com/reference/accountscontroller_listaccounts.md",
"https://developer.unipile.com/reference/mailscontroller_listmails.md",
"https://developer.unipile.com/reference/mailscontroller_getmail.md",
"https://developer.unipile.com/reference/mailscontroller_updatemail.md",
"https://developer.unipile.com/reference/folderscontroller_listfolders.md",
"https://developer.unipile.com/reference/calendarscontroller_listcalendars.md",
"https://developer.unipile.com/reference/calendarscontroller_listcalendareventsbycalendar.md",
"https://developer.unipile.com/docs/new-emails-webhook.md"
],
"acceptance": {
"no_local_or_mock_mailbox_substitute": true,
"three_real_inbound_email_objects": true,
"calendar_query_receipted": true,
"draft_artifact_hashed": true,
"high_priority_notification_delivered": true,
"marketing_email_archived_and_verified": true,
"identifiers_and_credentials_redacted": true,
"fail_closed_on_missing_or_invalid_credentials": true
}
}
@@ -0,0 +1,118 @@
{
"experiment": "6-1",
"campaign_id": "credential_probe_20260730T064500Z",
"generated_at": "2026-07-29T22:23:34.029339+00:00",
"provider": "unipile",
"base_url_sha256": "28b52f572e99947db7fa2192ba95435ed4d01404e53a40c2b4f6de2274513a9b",
"official_schema_receipts": [
{
"url": "https://developer.unipile.com/reference/accountscontroller_listaccounts.md",
"status": 200,
"bytes": 151069,
"sha256": "e63a180008549782b9b946734aea82bc2b91e50d12029f21617b1944e50368fd",
"retrieved_at": "2026-07-29T22:23:30.036928+00:00"
},
{
"url": "https://developer.unipile.com/reference/mailscontroller_listmails.md",
"status": 200,
"bytes": 102744,
"sha256": "6cefdb5ae883c8573b5c5d5b4cc3b6f2168909e224afed419209578ae7bf6b25",
"retrieved_at": "2026-07-29T22:23:30.697071+00:00"
},
{
"url": "https://developer.unipile.com/reference/mailscontroller_getmail.md",
"status": 200,
"bytes": 88581,
"sha256": "c3b5c98505a1dbb4433f67651796124dd3eb7b044a264581dc86cf31ae994d86",
"retrieved_at": "2026-07-29T22:23:31.325776+00:00"
},
{
"url": "https://developer.unipile.com/reference/mailscontroller_updatemail.md",
"status": 200,
"bytes": 16690,
"sha256": "26d1c3f2c6e0d75fb1445a220a90c701e95790ba33e31a0c89088c13ac393647",
"retrieved_at": "2026-07-29T22:23:31.902105+00:00"
},
{
"url": "https://developer.unipile.com/reference/folderscontroller_listfolders.md",
"status": 200,
"bytes": 22162,
"sha256": "d35d2042bd261314af6597ffdf9613d2e3df799e062e1f598708e07cdca1b957",
"retrieved_at": "2026-07-29T22:23:32.485289+00:00"
},
{
"url": "https://developer.unipile.com/reference/calendarscontroller_listcalendars.md",
"status": 200,
"bytes": 20671,
"sha256": "cf35a6a277b9390bf937dfe8300788c348999652903ee91ca07b2f9ff86cf8da",
"retrieved_at": "2026-07-29T22:23:33.169436+00:00"
},
{
"url": "https://developer.unipile.com/reference/calendarscontroller_listcalendareventsbycalendar.md",
"status": 200,
"bytes": 36444,
"sha256": "7f4b3414f9d4a78a23bf4a87aa3d82f05eea2e00b101063cc82694c8335d96e3",
"retrieved_at": "2026-07-29T22:23:33.710013+00:00"
},
{
"url": "https://developer.unipile.com/docs/new-emails-webhook.md",
"status": 200,
"bytes": 1729,
"sha256": "50c4808b875df5d71a3734ff4621b557d6fd40380e253cb690cc70d61148ee6a",
"retrieved_at": "2026-07-29T22:23:34.029079+00:00"
}
],
"alternate_auth_diagnostic": {
"method": "GET",
"path": "/api/v1/accounts",
"credential_scheme": "Authorization: Bearer <redacted>",
"status": 401,
"error_type": "errors/invalid_credentials",
"error_title": "Invalid credentials",
"response_sha256": "35feddea5acbb597bdd6395c36c393c077bf94090fb311652a85e10e464c6b59",
"latency_seconds": 1.568
},
"status": "blocked",
"official_complete": false,
"blocker_or_error": {
"type": "UnipileAPIError",
"message": "GET /api/v1/accounts returned 401: errors/missing_credentials"
},
"credentials_present": {
"UNIPILE_DSN": true,
"UNIPILE_ACCESS_TOKEN": true
},
"api_calls": [
{
"method": "GET",
"path": "/api/v1/accounts",
"request": {
"params": {
"limit": 100
},
"json": null,
"multipart": null
},
"credential_scheme": "X-API-KEY",
"status": 401,
"success": false,
"latency_seconds": 1.656,
"response_sha256": "47c9318169e1bdccdcd71fb77189991e092db3344b06c1f59b8e1e426296babc",
"response_bytes": 80,
"response_shape": [
"status",
"title",
"type"
],
"error_type": "errors/missing_credentials",
"error_title": "Missing credentials"
}
],
"acceptance": {
"status": "blocked",
"gates": {
"valid_unipile_credentials": false,
"real_mailbox_campaign_completed": false
}
}
}
@@ -0,0 +1,8 @@
{
"experiment": "6-1",
"campaign_id": "credential_probe_20260730T064500Z",
"status": "blocked",
"official_complete": false,
"manifest": "validation/experiment_6_1/credential_probe_20260730T064500Z/manifest.json",
"manifest_sha256": "3f689dfee915503f61ca30e9b590e24c8950496ca90fbf365def83805e877d0a"
}