Skip to content

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

Merged
Million-mo merged 133 commits into
mainfrom
feat/dynamic-team-mode
Jul 28, 2026
Merged

feat: dynamic team mode — LLM-driven runtime team creation#168
Million-mo merged 133 commits into
mainfrom
feat/dynamic-team-mode

Conversation

@Leoyzen

@Leoyzen Leoyzen commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

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

Leoyzen added 20 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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a dynamic team mode in AgentPool, allowing LLM agents to dynamically form, coordinate, and dissolve teams at runtime, supported by a file-based state store for inboxes, tasks, and blackboard entries. It also adds extensive Logfire span instrumentation to fix orphan traces in distributed tracing. The review feedback identifies several key issues to address: fixing Python < 3.11 compatibility by replacing 'datetime.UTC' with 'datetime.timezone.utc', ensuring parent directories are created for nested lock files to prevent 'FileNotFoundError', implementing proper cleanup of orphaned sessions on initialization or creation failures, and offloading blocking synchronous file I/O and lock operations to background threads via 'asyncio.to_thread' to avoid blocking the asyncio event loop.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/agentpool/capabilities/file_team_state.py
Comment thread src/agentpool/capabilities/file_team_state.py
Comment thread src/agentpool/capabilities/team_comm_capability.py Outdated
Comment thread src/agentpool/capabilities/team_comm_capability.py Outdated
Comment thread src/agentpool/capabilities/team_comm_capability.py
Comment thread src/agentpool/capabilities/file_team_state.py
Leoyzen added 3 commits July 16, 2026 20:27
…/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
@Leoyzen

Leoyzen commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

架构问题记录

在 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. 结合问题 feat: add dynamic instructions from ResourceProviders for context… #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 组成"的便利性

优先级

Leoyzen added 4 commits July 16, 2026 21:52
…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
Leoyzen added 18 commits July 25, 2026 19:50
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
@Leoyzen
Leoyzen changed the base branch from refactor/agentwolf_v1 to main July 28, 2026 01:57
@Million-mo
Million-mo self-requested a review July 28, 2026 02:16
… 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.
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