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
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:
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
+15
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+117
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+118
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user