Skip to content

feat: dynamic team mode — LLM-driven runtime team creation - #209

Closed
Million-mo wants to merge 133 commits into
mainfrom
feat/dynamic-team-mode
Closed

feat: dynamic team mode — LLM-driven runtime team creation#209
Million-mo wants to merge 133 commits into
mainfrom
feat/dynamic-team-mode

Conversation

@Million-mo

Copy link
Copy Markdown
Owner

Dynamic Team Mode

AI agents can create, manage, and dissolve teams at runtime through tool calls — spawning teammate sessions, sending messages, sharing a task board with dependency tracking, and collaborating via a shared blackboard. Stale team data is cleaned up automatically via a TTL background task.

What's New

  • 14 team tools (9 universal + 5 lead-only):
    • Universal (all members): send_message, task_create, task_list, task_update, task_get, read_blackboard, write_blackboard, list_blackboard, team_status
    • Lead-only: team_create, team_delete, delete_blackboard, shutdown_request, team_add_member
  • Role-aware tool filtering: Lead agents see all 14 tools; non-lead members see 9 tools with send_message broadcast (to="*") stripped from the description and blocked via regex pattern constraint (^[^*]+$)
  • Protocol template injection: _DEFAULT_PROTOCOL_TEMPLATE renders per-member instructions with {member_name}, {team_name}, {role} placeholders, plus role-specific capability sections and an eligible agents list — injected via get_instructions() into the system prompt
  • File-based team state (FileTeamState): atomic writes, file locks, optimistic locking for blackboard, path traversal prevention
  • Per-session scoping: FileTeamState isolated by base_dir + team_id (stored in session metadata at team_create time)
  • Bounds enforcement: max_members, max_member_turns, inbox_max_bytes, blackboard max_size_mb
  • TTL cleanup: background task in AgentPool.__aenter__ that marks orphaned teams (active + TTL expired) and removes deleted teams (deleted + TTL expired), running every 10 minutes
  • Per-session capability instantiation: shared TeamCommCapability at compile time (returns None instructions, no tool filtering), replaced by per-session instance at runtime with actual session metadata (renders template, filters tools by role)
  • Watch mechanism: team_status and list_blackboard support blocking watch mode with configurable timeout and task-specific watching
  • Task reminder: after_run() checks for unfinished tasks after a member's run completes, sending <team-message type="task_reminder"> to prompt completion
  • Ephemeral members: team_add_member supports lifecycle="ephemeral" for auto-closing members when their run completes

How It Works

Two-Phase Capability Registration

  1. Compile time (factory.py _compile_agent_capabilities): Creates a shared TeamCommCapability(session_metadata=None)get_instructions() returns None, prepare_tools() returns all tools unfiltered
  2. Runtime (factory.py create_session_agent): Sets session.metadata["team_role"] and ["team_member_name"], creates a new TeamCommCapability(session.metadata), and replaces the shared instance in agent._extra_capabilities

Prompt Injection

TeamCommCapability.get_instructions() returns three layers when session metadata is present:

  1. Protocol template — rendered with team name, role, and member name
  2. Role-specific capabilities — lead gets broadcast/team management instructions; members get "broadcast not available" guidance
  3. Eligible agents list — agents available for team_create from member_eligible config

Tool Result Format

All tool returns use XML format for LLM parseability:

  • Tasks: <task id="..." status="..." owner="...">...</task>
  • Blackboard: <blackboard version="..." written_by="...">...</blackboard>
  • Messages: <team-message from="..." type="private|broadcast|task_notification|task_reminder">...</team-message>

Hook Integration

No team-specific hooks. All 4 standard hook points (pre_turn, post_turn, pre_tool_use, post_tool_use) fire through HookAwareTurn for team tools identically to any other tool. Tool execution is logged to the Journal via _log_tool_execution() for crash recovery idempotency.

Config Flow

YAML team_mode: → AgentsManifest.team_mode (global)
                → BaseAgentConfig.team_mode (per-agent override)
                        │
                        ▼
              resolve_team_mode(global, agent) → TeamModeConfig
                        │
                ┌───────┴───────┐
                ▼               ▼
     factory.py compile      factory.py runtime
     shared capability       per-session replacement

Note: AgentContext.team_mode_config is declared but not set by the RunLoop's _inject_agent_context() in production (always None). The system operates through session metadata (team_id, team_base_dir) set by team_create, with team_mode_config as a fallback only.

Files Changed

New source files (3):

  • src/agentpool_config/team_mode.pyTeamModeConfig Pydantic model with TeamBounds, BlackboardConfig, MemberSpec, _DEFAULT_PROTOCOL_TEMPLATE
  • src/agentpool/capabilities/file_team_state.py — File-based persistence with atomic writes, file locks, optimistic locking, TTL cleanup
  • src/agentpool/capabilities/team_comm_capability.pyTeamCommCapability(FunctionToolsetCapability) with 14 tools, role-aware filtering, protocol template injection, after_run() task reminders

Modified files (6):

  • src/agentpool/capabilities/agent_context.py — added team_mode_config field
  • src/agentpool/orchestrator/run.pyAgentContext construction (field defaults to None in production; session metadata is the primary config carrier)
  • src/agentpool/models/manifest.py — added team_mode field to AgentsManifest
  • src/agentpool_config/nodes.py — added team_mode field to BaseAgentConfig
  • src/agentpool/host/factory.pyTeamCommCapability two-phase registration (compile-time shared + runtime per-session replacement)
  • src/agentpool/delegation/pool.py — TTL cleanup task lifecycle (__aenter__ start, __aexit__ cancel)

Dependency: filelock>=3.13 added to pyproject.toml

Test Coverage

111 tests across 5 files (all pass):

  • tests/test_team_mode_config.py — 10 unit tests (config model validation)
  • tests/capabilities/test_file_team_state.py — 29 unit tests (file persistence, blackboard, tasks, cleanup)
  • tests/capabilities/test_team_comm_capability.py — 59 unit tests (all 14 tools, bounds, role filtering)
  • tests/integration/test_team_auto_init.py — 3 integration tests (auto_init lifecycle)
  • tests/integration/test_team_mode.py — 10 integration tests (e2e lifecycle, coexistence)

Quality Gates

  • ruff check — all pass
  • ruff format --check — all pass
  • mypy — 0 errors in new files
  • ✅ No getattr/hasattr in new code
  • ✅ No TODO/FIXME/HACK in new code
  • ✅ RFC-0055 status updated to IMPLEMENTED

Documentation

  • AGENTS.md — Team Mode section with config YAML, 14 tools table, protocol template explanation
  • site/examples/team-translation/config.yml — translation team with auto_init
  • site/examples/team-sales/config.yml — sales team with auto_init + blackboard
  • site/examples/team-dev-squad/config.yml — dev squad with manual team_create
  • docs/rfcs/draft/RFC-0055-dynamic-team-mode.md — RFC (status: IMPLEMENTED)

Design Decisions

  1. Direct SessionPool access (no wrapper) — follows RunLoopDelegationService pattern
  2. File-based persistence in tempdir — ephemeral, isolated by UUID, atomic writes
  3. Binary priority — only DeliveryMode.STEER and DeliveryMode.QUEUE
  4. Session metadata as primary config carrierteam_id and team_base_dir stored in session.metadata at team_create time; AgentContext.team_mode_config is a fallback only (not set by RunLoop in production)
  5. Per-agent team_mode field — all agent types inherit via BaseAgentConfig
  6. Per-session TeamCommCapability — shared at compile time (no instructions, no filtering), replaced at runtime with per-session instance (renders template, filters tools by role)
  7. No team-specific hooks — standard HookAwareTurn applies to all team tools identically
  8. Role-aware tool filtering at prepare_tools() — lead-only tools removed from non-lead members; send_message broadcast capability restricted via description + regex pattern

备份迁移自 SRC-PR#168 · 作者 @Leoyzen · 创建于 2026-07-16T11:24:44Z · head=feat/dynamic-team-mode base=main
源状态: merged · merge_commit_sha=ff1a6a7438eea053a9a03442282a96872a5c1a8e

Leoyzen added 30 commits July 16, 2026 10:12
…trumentation)

Fixes orphan traces in subagent sessions by adding logfire span
instrumentation to RunLoop, Turn, delegation, capabilities, lifecycle,
graph, and ACP layers.

P0 (span breakage fix):
- SubagentCapability.spawn_subagent(): delegation.subagent span
- RunLoopDelegationService.spawn_subagent(): fix double-iteration bug + span
- RunHandle.start(): safe_span for async generator
- RunHandle._execute_turn(): safe_span for async generator
- NativeTurn.execute(): safe_span for async generator
- ACPTurn.execute(): safe_span for async generator
- Create safe_span() helper to suppress OTel context detach ValueError

P1 (coverage expansion):
- SessionController: @logfire.instrument on receive_request, _start_run_handle, _consume_run
- SessionPool: @logfire.instrument on steer, followup
- RunHandle: @logfire.instrument on steer, followup
- BaseAgent.run_stream(): safe_span
- BaseTeam: @logfire.instrument on _execute_parallel, _execute_sequential
- subagent_tools: safe_span for background task
- DurableJournal/SnapshotStore: @logfire.instrument
- Graph adapter + signal adapter: @logfire.instrument
- ACP cross-process: TraceContextTextMapPropagator inject/extract

Tests:
- 4 span hierarchy tests (delegation, team parallel/sequential, bg task)
- 3 ACP traceparent tests (injection, roundtrip, no-span skip)
- Fixed deprecation test for EventBus subscription pattern

OpenSpec change: openspec/changes/fix-span-instrumentation/
- SessionPool.send_message(): @logfire.instrument('session.send_message')
- SessionController._route_message(): @logfire.instrument('session.route_message')
- ACP acp_agent.prompt(): replace raw OTel span with logfire.span() for
  consistent context propagation, use attach/detach for W3C trace context

These fill the gap between acp.agent.handle_prompt and _start_run_handle,
ensuring all child spans nest under the root entry-point span.
…n background task

- Remove {session_id} from all @logfire.instrument format strings in
  session_controller.py and session_pool.py. Logfire span names are
  always the template string (not rendered), so {session_id} showed
  literally in SigNoz. Session_id is still captured as a span attribute
  via logfire's extract_args default.

- Replace @logfire.instrument decorator on _consume_run() with manual
  with logfire.span(...) inside the method body. The decorator may not
  properly establish parent-child span relationship when the coroutine
  runs in a copied contextvars Context (asyncio.create_task). Manual
  span creation ensures the span is created in the correct context.

- Update test mock assertions to match new parameter passing for
  send_message and receive_request calls.
LogfireSpan.__exit__ is decorated with @handle_internal_errors which
catches ValueError from _detach() and suppresses it — but this also
prevents _end() from being called, since the exception fires in
_detach() before _end() is reached. An unended span is never exported
by the OTel exporter, causing 'Missing Span' in SigNoz.

This affected ALL safe_span callers in async generators:
- session.consume_run
- orchestration.run_handle.start
- orchestration.run_handle.execute_turn
- turn.native / turn.acp
- agent.run_stream
- delegation.subagent (runloop_delegation)

Fix: call _detach() and _end() separately in safe_span's finally block,
each wrapped in suppress(Exception). This ensures _end() always runs
even when _detach() fails due to contextvars Context mismatch.
session.consume_run in session_controller.py and delegation.subagent in
subagent_capability.py were using raw 'with logfire.span(...)' instead
of safe_span. Logfire's @handle_internal_errors on LogfireSpan.__exit__
catches ValueError from _detach() and suppresses it, but this also
prevents _end() from being called — the span is never ended and never
exported, causing 'Missing Span' in SigNoz.

safe_span calls _detach() and _end() separately, ensuring the span is
always ended even if context detach fails.

This fixes the remaining Missing Span entries that were not addressed
by the previous safe_span fix (commit b2b1cfd).
When RunHandle.start() is closed via aclose(), the nested async
generators _execute_turn() and turn.execute() were NOT automatically
closed. Their safe_span finally blocks never ran, so spans were never
ended, never exported, causing Missing Span in SigNoz.

Fix: wrap each async-for-over-subgenerator with contextlib.aclosing(),
which calls aclose() on __aexit__, cascading GeneratorExit through all
nested safe_span context managers.

Changes:
- run.py start(): aclosing(self._execute_turn(...))
- run.py _execute_turn(): aclosing(turn.execute())
- test_span_hierarchy.py: add red test (xfail) + green test
Add 8 universal team tools to TeamCommCapability:
- send_message: deliver messages to teammate inboxes via SessionPool
- task_create: create tasks on shared task board with dependency tracking
- task_list: list all tasks as JSON
- task_update: update task status/owner
- read_blackboard: read key with version metadata
- write_blackboard: write with optimistic locking
- list_blackboard: list all blackboard keys
- team_status: formatted team status with member info

All tools extract AgentContext from RunContext.deps, use FileTeamState
for persistence, and handle error cases (no team_id, no session_pool,
member not found, etc.). 19 new unit tests, 35 total pass.
…/dynamic-team-mode

# Conflicts:
#	src/agentpool/agents/base_agent.py
#	src/agentpool/agents/native_agent/turn.py
#	src/agentpool/capabilities/runloop_delegation.py
#	src/agentpool/observability/spans.py
#	src/agentpool/orchestrator/run.py
#	src/agentpool/orchestrator/session_controller.py
#	tests/test_span_hierarchy.py
…am_create

- Remove _maybe_auto_init() and all 12 call sites
- team_create now uses delegation.create_child_session() which emits SpawnSessionStart
- team_create supports config default members from auto_init config
- Update tests: remove auto_init tests, add config default member tests
- Rename AutoInitConfig → TeamDefaultsConfig, auto_init field → defaults
- Update all source, tests, examples, RFC, AGENTS.md
- Examples now explain that team tools are auto-wired by factory
- Rename test_team_auto_init.py → test_team_defaults.py
…ep skipped

- Factory create_session_agent() only replaced existing TeamCommCapability
  instances, but when compile() wasn't called, _extra_capabilities was
  empty and the per-session instance was silently dropped
- Fix: append team_cap if no existing TeamCommCapability found
- Fix: validator now checks member.agent (agent name) not member.name
  (display name) against member_eligible
- Update test data to match corrected validator behavior
- 109 tests pass
PydanticAI uses type annotations to determine which parameters are
auto-injected vs exposed to the LLM. ctx: Any was treated as a regular
parameter, causing the LLM to pass the string 'ctx' instead of the
framework auto-injecting RunContext.

Changed all 12 tool functions + _resolve_agent_context helper from
ctx: Any to ctx: RunContext[Any]. Import RunContext from pydantic_ai.tools
at runtime (not TYPE_CHECKING) so PydanticAI can resolve the annotation.

109 tests pass, ruff + mypy clean.
Team tools were failing with 'AgentContext object has no attribute session'
because _resolve_agent_context was casting ctx.deps directly to
capabilities.agent_context.AgentContext, but ctx.deps is actually
agents.context.AgentContext (PydanticAI runtime context). Our
AgentContext is stored at ctx.deps.data, set by NativeTurn.
Protocol servers don't set team_role in session metadata, so team tools
couldn't determine if the agent was a lead or member. Factory now sets
team_role='lead' for lead_eligible agents and 'member' for others,
plus team_member_name, before creating TeamCommCapability.
Leoyzen added 27 commits July 24, 2026 23:12
…ring

Session ID and created_at_ns were captured at different times
(generate_session_id() vs SessionState construction), causing
millisecond gaps that flip ordering for rapidly-created sessions.

Add extract_timestamp_ms() to decode the timestamp embedded in
ascending/descending IDs. After SessionState creation, override
created_at_ns and last_active_at_ns with the session ID's timestamp,
ensuring time.created order always matches session ID lexicographic
order.
Enables EnqueuedMessagesEvent support (available since v2.12.0).
Fix ToolReturn import from pydantic_ai.messages instead of
pydantic_ai.tools (re-export removed in newer versions).
Move RunContext/ToolDefinition into TYPE_CHECKING block.
PydanticAI's UsageLimits defaults request_limit to 50, which is too
low for agents with many tool calls. When no usage_limits are
explicitly configured, default to request_limit=None (unlimited).
Ensures each member session gets a distinct time.created (millisecond
precision) when team_create creates multiple members in a tight loop.
SQLite WAL store operations complete in sub-millisecond time, so
without this delay all members get the same time.created, causing
non-deterministic sort order in OpenCode TUI subagent numbering.
session_data_to_opencode() now uses extract_timestamp_ms(session_id)
to derive time.created when the session ID is ses_ format. This fixes
old sessions persisted before the created_at_ns sync fix — their
stored created_at came from get_now() (separate wall-clock call),
which can differ from the session ID's embedded timestamp by enough
to cause sort mismatches in OpenCode TUI subagent numbering.
… timestamps

Move delay from team_create loop into _create_member_session() to
cover all paths (team_create and team_add_member). 10ms ensures
each session gets a distinct time.created (millisecond precision),
preventing sort mismatches in OpenCode TUI subagent numbering.
Using cumulative values caused the TUI sidebar to show extremely large
token counts that grew quickly on each step, because each LLM request
re-sends the entire system prompt + conversation history.

Switch to per-step delta so the sidebar shows the actual cost of each
step (e.g., input=55460, output=1517) instead of the running
cumulative total (e.g., input=324969).
…serialization

Replace the 15-50ms random sleep with an asyncio.Lock around
create_child_session() calls. Concurrent tool invocations from
PydanticAI could fire multiple _create_member_session() in parallel,
making both delays start simultaneously — the generate_session_id()
calls still landed in the same millisecond. A lock guarantees true
serialization.
- Remove 50 unused # noqa: BLE001 directives (RUF100)
- Move None to end of type union in 4 locations (RUF036)
- Parenthesize implicit string concatenation in collections (ISC004)
…handoff, batch, progress

Implements the team-mode-collab-flow OpenSpec change with 9 design decisions:

- MemberSpec.instructions: per-member role text injected as ## Your Assignment
- Protocol template rewritten with explicit channel boundaries (Tasks/Blackboard/Messages)
- task_update note → technical_note (disambiguate from communication)
- Task handoff: handoff_to + handoff_context_keys for one-step handoff with notifications
- Enhanced dependency notifications: <team-message type=dependency_resolved> + self-skip
- send_message persist_to_blackboard: eliminate message-vs-blackboard dilemma
- task_create_batch: atomic batch creation with #N and symbolic id references
- Progress tracking: progress_current/progress_total with auto-complete on status=completed
- Owner visibility: mine_only filter, owner summary, actionable ownership errors

15 tools total (was 14): 9 universal + 6 lead-only (added task_create_batch)

298 tests pass (91 new). ruff check + format clean. RFC-0055 updated.
Team-mode display enrichment (tasks 6-9 of enhance-opencode-display):

- team_comm_capability.py: SpawnSessionStart.metadata includes team context
- team_comm_capability.py: team_create sets team_role='lead' on lead session
- opencode_message_bridge.py: Team members get 'Team ·' prefix in subagent_type
- opencode_event_bridge.py: Team member session titles include team name and role
- 8 new unit tests for team display enrichment

All 306 team-mode tests pass.
…e_parent_toolpart_error

The update/error methods were overwriting ToolPart state with source_name
instead of display_name, causing the card to revert from 'Lead' to
'Coordinator' when the subagent completed or errored.
ensure_session() does not update the title when the session already
exists (fast path or store-first path). The child session is created
by SessionPool.create_child_session() before SpawnSessionStart is
emitted, so _ensure_child_session_visible's title parameter was
silently ignored.

Now we explicitly update session.title and broadcast SessionUpdatedEvent
when the title differs from the desired value.
…opagation

- team_comm_capability: pass tool_call_id from ctx to SpawnSessionStart
  instead of hardcoded empty string (fixes ACP/OpenCode tool call correlation)
- team_comm_capability: change spawn_mechanism from 'spawn' to 'task'
  (team members are async via DeliveryMode.QUEUE)
- opencode_message_bridge: set background=True in ToolPart metadata when
  spawn_mechanism=='task' (TUI shows '(background)' suffix)
- opencode_message_bridge: simplify ToolPart card description to
  '{role} in '{team_name}'' (remove redundant name repetition)
- opencode_event_bridge: use source_name (ASCII) in @xxx subagent pattern
  instead of display_name (may be non-ASCII, breaks TUI regex)
- opencode_event_bridge: set mode to team_member_name for assistant footer
- tests: update assertions for new spawn_mechanism, background flag,
  session title pattern, and simplified card description
TUI renders '↳ {Locale.titlecase(tool)} {title}', but title was
'Running {tool_name}' / 'Completed {tool_name}', causing duplicate
tool name display (e.g. 'Segment_scan Completed segment_scan').

Changed title to just 'Running' / 'Completed' in converters.py
and event_processor.py (4 call sites).
… blackboard namespace keys

- Add _safe_publish() helper on RunHandle to catch ProtocolChannel closed
  RuntimeError during session shutdown, preventing cascade errors
- Remove 5 unused # type: ignore[assignment] comments in team_comm_capability.py
- Relax benchmark threshold from 3x to 5x for CI runner variance
- Set session_state.metadata = {} in test_mode_consistency mocks
- Fix list_blackboard glob→rglob to return namespace keys with '/' separators
- Add test_list_blackboard_returns_namespace_keys unit test
… blackboard cleanup, per-instance session lock

- format_task_xml: move parts list init outside if block to prevent
  UnboundLocalError when progress fields are None
- register_member: wrap state.json read-modify-write in FileLock to
  prevent data loss under concurrent registration
- write_blackboard: delete oversized file when max_size_mb exceeded so
  subsequent reads return 'Key not found' instead of stale data
- _create_session_lock: move from module-level global to per-instance
  asyncio.Lock so different teams don't serialize session creation

All 271 unit tests pass, ruff lint+format clean.
@Million-mo Million-mo closed this Aug 22, 2026
@Million-mo

Copy link
Copy Markdown
Owner Author

评论 by @Leoyzen 于 2026-07-16T12:58:58Z(备份迁移)

架构问题记录

在 review 过程中发现三个架构层面的问题,记录在此供后续讨论和跟踪。


1. team_role metadata 未自动设置 — auto_init 在生产环境无法触发

问题_maybe_auto_init() 检查 session.metadata.get("team_role") == "lead",但在 ACP/OpenCode 的 session 创建路径中,没有任何地方自动设置 team_role

追踪

  • Protocol server 创建 lead session → SessionPool.create_session(session_id, agent_name="coordinator", **metadata) → metadata 不含 team_role
  • Factory create_session_agent() 创建 TeamCommCapability(config, agent_name, session.metadata) → 不修改 metadata
  • 结果:team_role 永远是空字符串 → role != "lead" 永远为 True → auto_init 永远跳过

测试中 team_role 是手动塞进 metadata 的,所以测试通过但生产环境不工作。

修复方案:在 factory create_session_agent() 中,当 agent 在 lead_eligible 列表里时自动设置:

if resolved_tm.enabled and agent_name in resolved_tm.lead_eligible:
    session_state.metadata.setdefault("team_role", "lead")
    session_state.metadata.setdefault("team_member_name", agent_name)

2. auto_init 创建的 member session 未发布 SpawnSessionStart — protocol server 无法发现

问题:auto_init 和 team_create 通过 session_pool.create_session() + session_pool.send_message() 创建 member session,但没有发布 SpawnSessionStart 事件。Protocol server(ACP/OpenCode)的 _on_spawn_session_start() 钩子无法触发,不会为 member session 启动 event consumer。

影响

  • Member agent 确实会执行(RunHandle 启动 RunLoop,LLM 正常调用)
  • 但 member session 的所有事件(stream tokens、tool calls 等)流到 EventBus 后没有消费者,事件被丢弃
  • ACP/OpenCode 客户端看不到 member agent 的任何活动

对比:正常的 subagent delegation 路径通过 AgentContext.create_child_session() 创建子 session,会自动发布 SpawnSessionStart,protocol server 能正确发现并启动子消费者。

修复方案:在 create_session() 之后、send_message() 之前,发布 SpawnSessionStart 事件:

from agentpool.agents.events.events import SpawnSessionStart

spawn_event = SpawnSessionStart(
    child_session_id=member_session_id,
    parent_session_id=lead_session_id,
    tool_call_id="",
    spawn_mechanism="spawn",
    source_name=member.agent,
    source_type="agent",
    depth=1,
    description=f"Team member: {member.name}",
)
await session_pool.event_bus.publish(lead_session_id, spawn_event)

3. auto_init 可能是过度设计 — 考虑移除

问题:auto_init 的设计意图是"让 lead agent 不需要显式调用 team_create",但:

  1. auto_init 仍然依赖 LLM tool call — 它在每个 team tool 函数开头触发,但 tool call 本身就是 LLM 的决策。LLM 既然在调 tool,完全可以先调 team_create
  2. ~175 LOC 重复代码_maybe_auto_init 几乎复制了 team_create 的全部逻辑
  3. 12 个 tool 函数被污染 — 每个函数开头都要加 init_result = await self._maybe_auto_init(ctx)
  4. 隐式副作用 — LLM 调用 send_message,team 被悄悄创建,LLM 不知道发生了什么
  5. 结合问题 [PR #15] feat(file_edit): implement streaming file I/O with async operations, … #1 — auto_init 在生产环境根本触发不了

建议:移除 auto_init,改为在 team_create 中支持从 config 读取默认 member(当 members 参数为空时从 auto_init.members 读取)。这样:

  • 减少 ~175 LOC
  • 去掉 12 个 tool 函数中的 _maybe_auto_init 调用
  • 行为对 LLM 完全透明(显式调用 team_create
  • 保留"从 YAML 配置读取 team 组成"的便利性

优先级

@Million-mo

Copy link
Copy Markdown
Owner Author

评论 by @Leoyzen 于 2026-07-24T04:19:57Z(备份迁移)

Steer 消息丢失问题分析

在 team 模式测试中发现,member agent 经常卡住不执行 — Conductor 通过 send_message (STEER mode) 发消息给 member,消息看似投递成功(返回 message_id),但实际丢失,member 永远收不到,需要手动激活。

根因

RunHandle.steer()active_agent_run is None 时,将消息写入 run_ctx.queued_steer_messages 列表,但该列表在整个代码库中从未被消费(只写不读)。消息随 RunHandle 销毁而永久丢失。

消息丢失的时间线

T0  NativeTurn while 循环到达 End 节点
T1  finally 块清除 active_agent_run = None  (turn.py:443)
T2  NativeTurn 继续构建 final_message, yield StreamCompleteEvent
    === 竞态窗口开始 ===
T3  Conductor 发 STEER → _route_message() 查 current_run_id → 找到 run(尚未清理)
T4  _route_message() 调用 run.steer(content)
T5  steer() 发现 active_agent_run is None → 写入 queued_steer_messages
    === 竞态窗口结束 ===
T6  _consume_run() 清理 _runs + current_run_id
T7  prompt_queue 为空 → session idle
T8  queued_steer_messages 随 RunHandle 丢弃 → 消息永久丢失

为什么 stale-run detection 没捕获

_route_message() 的 stale-run detection(session_controller_runs.py:535-538)检查 existing_run.complete_event.is_set(),但 complete_event_consume_run() 后面的 _cleanup_run() 中才设置。在竞态窗口内,run 看起来还是"活跃"的。

关键代码位置

文件 行号 问题
agents/context.py 172 queued_steer_messages 定义 — 只写不读的死列表
orchestrator/run.py 646-650 steer() fallback 写入 queued_steer_messages
orchestrator/run.py 359-361 注释声称 _execute_turn() 会 drain,但实际没有
agents/native_agent/turn.py 443 finally 块清除 active_agent_run(竞态窗口起点)
orchestrator/session_controller_runs.py 535-538 stale-run detection 检查 complete_event,但时机太晚
orchestrator/session_controller_runs.py 241-263 _consume_run() 只检查 prompt_queue,不检查 feedback_queue

三个丢失场景

  1. Turn 结束后的竞态窗口(最常见)active_agent_runfinally 块清除,但 current_run_id_runs 还没清理。_route_message() 认为成员"忙",调用 steer(),消息进入死列表。

  2. start() 中 feedback_queue drain 时机错误RunHandle.start()(run.py:362-370)在 _execute_turn() 之前 drain feedback_queue,此时 active_agent_run 还没设置 → steer() 把消息写入 queued_steer_messages → 丢失。

  3. 新 RunHandle 创建后、active_agent_run 设置前_route_message() 创建新 RunHandle 后,第二个 STEER 到达 → steer() 看到 active_agent_run is None → 写入 queued_steer_messages → 丢失。

已有的测试 test_run_handle.py:1503 明确记录了这个已知缺陷:

"""Empty prompt drains feedback_queue but queued_steer_messages are never processed.
Known limitation: ... The steer messages are technically in
queued_steer_messages but no turn processes them.
"""

修复方案

方案 A: steer() fallback 改为写入 session.feedback_queue(推荐)

# run.py steer() lines 646-650, 替换:
# self.run_ctx.queued_steer_messages.append(fb.content_blocks)
# self.run_ctx.queued_steer_messages.append(fb.content)

# 改为:
if self.session is not None:
    self.session.feedback_queue.put_nowait(fb)

feedback_queueSessionState 级别的队列,跨 RunHandle 存活。新 RunHandle 的 start() 已经会 drain feedback_queue(run.py:362-370)。

但场景 2 的问题仍需修复 — start() drain feedback_queueactive_agent_run 还没设置,消息会再次进入 queued_steer_messages。所以还需要方案 B 配合。

方案 B: NativeTurn.execute() 中 drain queued_steer_messages(配合 A)

# turn.py line 244 之后,active_agent_run 设置后立即添加:
if self._run_ctx._run_handle is not None:
    self._run_ctx._run_handle.active_agent_run = agent_run
    # Drain queued steer messages that arrived before active_agent_run was set.
    queued = self._run_ctx.queued_steer_messages
    if queued:
        self._run_ctx.queued_steer_messages = []
        for msg in queued:
            if isinstance(msg, list):
                agent_run.enqueue(*msg, priority="asap")
            else:
                agent_run.enqueue(msg, priority="asap")

这是 run.py:359 注释声称但从未实现的逻辑。

方案 C: _route_message() 增强 stale-run detection(可选加固)

# session_controller_runs.py:535-538, 在 complete_event 检查之外额外检查:
if session.current_run_id is not None:
    existing_run = self._runs.get(session.current_run_id)
    if existing_run is None or existing_run.complete_event.is_set() or existing_run.active_agent_run is None:
        session.set_current_run_id(None)

如果 active_agent_run is None,说明 turn 已结束但还没清理 — 视为 stale,走 idle 路径创建新 run。

推荐组合

方案 A + B:A 解决消息存活问题(feedback_queue 跨 RunHandle 存活),B 解决 start() drain 后消息再次进入死列表的问题。

可选加 C:进一步减少竞态窗口,让 _route_message() 更早检测到 stale run。

修复后应更新 test_run_handle.py:1503 的已知缺陷测试,验证消息不再丢失。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants