Skip to content

Commit 6842bfb

Browse files
RoyLinRoyLin
authored andcommitted
fix: Add Python SDK bindings for SubAgentHandle.events()
- Export SubAgentEventStream from orchestrator module - Make SubAgentEventStream fields pub(crate) for internal access - Add PySubAgentEventStream wrapper with recv() method - Add events() method to PySubAgentHandle - Update test scripts to use correct Agent.create() and Orchestrator.create() APIs - Add event streaming and permissive_deny test scripts This completes the Python SDK support for Issue #5 features.
1 parent 7003323 commit 6842bfb

6 files changed

Lines changed: 654 additions & 11 deletions

File tree

core/src/orchestrator/agent.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,8 @@ impl std::fmt::Debug for AgentOrchestrator {
460460

461461
/// SubAgent 事件流(过滤特定 SubAgent 的事件)
462462
pub struct SubAgentEventStream {
463-
rx: broadcast::Receiver<OrchestratorEvent>,
464-
filter_id: String,
463+
pub(crate) rx: broadcast::Receiver<OrchestratorEvent>,
464+
pub(crate) filter_id: String,
465465
}
466466

467467
impl SubAgentEventStream {

core/src/orchestrator/handle.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
//! SubAgent 句柄
22
33
use crate::error::Result;
4-
use crate::orchestrator::{ControlSignal, SubAgentActivity, SubAgentConfig, SubAgentState};
4+
use crate::orchestrator::{
5+
agent::SubAgentEventStream, ControlSignal, OrchestratorEvent, SubAgentActivity,
6+
SubAgentConfig, SubAgentState,
7+
};
58
use std::sync::Arc;
69
use tokio::sync::RwLock;
710

@@ -178,9 +181,9 @@ impl SubAgentHandle {
178181
/// Subscribe to events for this SubAgent.
179182
///
180183
/// Returns a filtered event stream that only includes events for this SubAgent.
181-
pub fn events(&self) -> crate::orchestrator::SubAgentEventStream {
184+
pub fn events(&self) -> SubAgentEventStream {
182185
let rx = self.event_tx.subscribe();
183-
crate::orchestrator::SubAgentEventStream {
186+
SubAgentEventStream {
184187
rx,
185188
filter_id: self.id.clone(),
186189
}

core/src/orchestrator/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ mod wrapper;
6666
mod tests;
6767

6868
pub use crate::agent_teams::TeamRole;
69-
pub use agent::AgentOrchestrator;
69+
pub use agent::{AgentOrchestrator, SubAgentEventStream};
7070
pub use config::{AgentSlot, OrchestratorConfig, SubAgentActivity, SubAgentConfig, SubAgentInfo};
7171
pub use control::ControlSignal;
7272
pub use events::{OrchestratorEvent, SubAgentEventPayload};
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Test Issue #5 Fix: Sub-agent Event Streaming
4+
5+
Tests the new event streaming functionality:
6+
1. SubAgentHandle.events() method
7+
2. TextDelta event forwarding
8+
3. TurnStart event forwarding
9+
4. Tool execution events with arguments
10+
11+
Run:
12+
export MOONSHOT_API_KEY=sk-...
13+
python examples/test_issue5_event_streaming.py
14+
"""
15+
16+
import os
17+
import sys
18+
import time
19+
from pathlib import Path
20+
21+
from a3s_code import Agent, Orchestrator, SubAgentConfig
22+
23+
24+
def find_config() -> str:
25+
"""Locate config: system ~/.a3s/config.hcl or local agent_kimi.hcl."""
26+
system = Path.home() / ".a3s" / "config.hcl"
27+
if system.exists():
28+
return str(system)
29+
here = Path(__file__).parent
30+
local = here / "agent_kimi.hcl"
31+
if local.exists():
32+
return str(local)
33+
raise FileNotFoundError("No config found: ~/.a3s/config.hcl or agent_kimi.hcl")
34+
35+
36+
def test_event_streaming():
37+
"""Test sub-agent event streaming with Kimi model."""
38+
print("\n" + "=" * 70)
39+
print("Test: Sub-agent Event Streaming (Issue #5)")
40+
print("=" * 70)
41+
42+
# Check API key
43+
if not os.getenv("MOONSHOT_API_KEY"):
44+
print("❌ MOONSHOT_API_KEY not set. Skipping test.")
45+
return False
46+
47+
try:
48+
config_path = find_config()
49+
print(f"✓ Using config: {config_path}")
50+
except FileNotFoundError as e:
51+
print(f"❌ {e}")
52+
return False
53+
54+
# Create agent and orchestrator
55+
print("\n[1] Creating Agent and Orchestrator...")
56+
agent = Agent.create(config_path)
57+
orch = Orchestrator.create()
58+
print("✓ Orchestrator created")
59+
60+
# Spawn sub-agent with a simple task
61+
print("\n[2] Spawning sub-agent...")
62+
config = SubAgentConfig(
63+
agent_type="general",
64+
prompt="Use bash to echo 'Hello from sub-agent!' and then explain what you did.",
65+
description="Event streaming test",
66+
permissive=True,
67+
max_steps=5,
68+
)
69+
70+
handle = orch.spawn_subagent(config)
71+
print(f"✓ Sub-agent spawned: {handle.id}")
72+
73+
# Subscribe to events
74+
print("\n[3] Subscribing to sub-agent events...")
75+
print("-" * 70)
76+
77+
event_counts = {
78+
"text_delta": 0,
79+
"turn_start": 0,
80+
"tool_start": 0,
81+
"tool_end": 0,
82+
"subagent_internal_event": 0,
83+
"other": 0,
84+
}
85+
86+
text_output = []
87+
tool_calls = []
88+
89+
# Use handle.events() to subscribe to this sub-agent's events
90+
print("Monitoring events (timeout: 30s)...")
91+
start_time = time.time()
92+
timeout = 30
93+
94+
try:
95+
# Subscribe to events for this sub-agent
96+
events = handle.events()
97+
98+
while time.time() - start_time < timeout:
99+
try:
100+
event = events.recv(timeout_ms=1000)
101+
if event is None:
102+
continue
103+
104+
event_type = event.get("event_type", "unknown")
105+
106+
# Count events
107+
if event_type == "subagent_internal_event":
108+
event_counts["subagent_internal_event"] += 1
109+
inner_event = event.get("event", {})
110+
inner_type = inner_event.get("type", "")
111+
112+
if inner_type == "text_delta":
113+
event_counts["text_delta"] += 1
114+
text = inner_event.get("text", "")
115+
text_output.append(text)
116+
print(f" 📝 TextDelta: {repr(text[:50])}")
117+
elif inner_type == "turn_start":
118+
event_counts["turn_start"] += 1
119+
turn = inner_event.get("turn", 0)
120+
print(f" 🔄 TurnStart: turn={turn}")
121+
122+
elif event_type == "tool_execution_started":
123+
event_counts["tool_start"] += 1
124+
tool_name = event.get("tool_name", "")
125+
tool_id = event.get("tool_id", "")
126+
args = event.get("args", {})
127+
print(f" 🔧 ToolStart: {tool_name} (id={tool_id})")
128+
if args and args != {}:
129+
print(f" Args: {args}")
130+
tool_calls.append({"name": tool_name, "id": tool_id, "args": args})
131+
132+
elif event_type == "tool_execution_completed":
133+
event_counts["tool_end"] += 1
134+
tool_name = event.get("tool_name", "")
135+
result = event.get("result", "")
136+
exit_code = event.get("exit_code", 0)
137+
print(f" ✅ ToolEnd: {tool_name} (exit={exit_code})")
138+
print(f" Result: {result[:100]}")
139+
140+
elif event_type == "subagent_completed":
141+
print(f" 🏁 SubAgent completed")
142+
break
143+
144+
else:
145+
event_counts["other"] += 1
146+
print(f" ℹ️ {event_type}")
147+
148+
except Exception as e:
149+
if "timeout" not in str(e).lower():
150+
print(f" ⚠️ Event error: {e}")
151+
continue
152+
153+
except Exception as e:
154+
print(f"❌ Error subscribing to events: {e}")
155+
return False
156+
157+
# Wait for completion
158+
print("\n[4] Waiting for sub-agent to complete...")
159+
try:
160+
result = handle.wait()
161+
print(f"✓ Sub-agent completed")
162+
print(f" Result: {result[:200]}")
163+
except Exception as e:
164+
print(f"⚠️ Wait error: {e}")
165+
166+
# Print summary
167+
print("\n" + "=" * 70)
168+
print("Event Summary:")
169+
print("=" * 70)
170+
for event_type, count in event_counts.items():
171+
print(f" {event_type:30s}: {count:3d}")
172+
173+
print(f"\n Total text deltas: {len(text_output)}")
174+
print(f" Total tool calls: {len(tool_calls)}")
175+
176+
if text_output:
177+
full_text = "".join(text_output)
178+
print(f"\n Streamed text ({len(full_text)} chars):")
179+
print(f" {repr(full_text[:200])}")
180+
181+
# Validation
182+
print("\n" + "=" * 70)
183+
print("Validation:")
184+
print("=" * 70)
185+
186+
success = True
187+
188+
if event_counts["text_delta"] > 0:
189+
print(" ✅ TextDelta events received")
190+
else:
191+
print(" ⚠️ No TextDelta events (might be expected for some models)")
192+
193+
if event_counts["turn_start"] > 0:
194+
print(" ✅ TurnStart events received")
195+
else:
196+
print(" ❌ No TurnStart events received")
197+
success = False
198+
199+
if event_counts["tool_start"] > 0:
200+
print(" ✅ ToolStart events received")
201+
else:
202+
print(" ⚠️ No ToolStart events")
203+
204+
if event_counts["tool_end"] > 0:
205+
print(" ✅ ToolEnd events received")
206+
else:
207+
print(" ⚠️ No ToolEnd events")
208+
209+
if event_counts["subagent_internal_event"] > 0:
210+
print(" ✅ SubAgentInternalEvent forwarding works")
211+
else:
212+
print(" ❌ No SubAgentInternalEvent received")
213+
success = False
214+
215+
return success
216+
217+
218+
if __name__ == "__main__":
219+
try:
220+
success = test_event_streaming()
221+
if success:
222+
print("\n✅ Test PASSED")
223+
sys.exit(0)
224+
else:
225+
print("\n⚠️ Test completed with warnings")
226+
sys.exit(0)
227+
except Exception as e:
228+
print(f"\n❌ Test FAILED: {e}")
229+
import traceback
230+
traceback.print_exc()
231+
sys.exit(1)

0 commit comments

Comments
 (0)