Files
OpenOPC/opc/layer2_organization/communication.py
T
LZH-YS1998 5aa57e69ee fix: release comms-blocking parks when replies arrive and give every role file authoring tools
Project 3333 forensics: env_engineer sent a blocking question to the CTO,
got a full reply 96s later, and still deadlocked the whole run — the
park half (`_park_for_blocking_comms`) had no wired consumer, so
WAITING_FOR_PEER work items could never be released.

Unpark path (root fix):
- The dispatcher loop now calls `_try_unpark_blocking_comms` each tick
  for parked, non-in-flight tasks; blocking replies land as durable
  inbox files, so the check is read-only until all replies are present.
- `_try_unpark_blocking_comms` accepts orphaned waits (peer_wait
  stripped by the legacy resolver while the work item stayed parked)
  and falls back to the park predicate itself: an empty unresolved
  blocking outbox releases the task, anything pending keeps it parked.
- `resolve_task_peer_wait` no longer touches comms_blocking waits (it
  flipped task.status without the work-item phase and stripped the
  peer_wait evidence); `_resume_peer_checkpoint` re-enters the company
  runtime for comms/orphaned waits and lets the dispatcher converge.

File tools (defaults changed at their declaration sites, honoring the
"empty tools = everything, explicit list = exactly that" contract):
- corporate builtin groups gain file_write/file_edit for coordination,
  QA, and data-acquisition roles.
- all shipped org YAML role tool lists gain the missing
  file_write/file_edit entries.
- coordination turn modes no longer strip file_write/file_edit at
  runtime — in-context content (briefs, matrices) must be persistable
  instead of getting trapped in blocking DM hand-offs.

Also includes the pending office_ui ws_handler change from the working
tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 21:06:39 +08:00

2483 lines
108 KiB
Python

"""Inter-agent communication protocol — DM, broadcast, meeting room, async annotation."""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Awaitable, Callable
from loguru import logger
from opc.core.events import EventBus
from opc.core.models import (
AgentEndpointType,
AgentMessage,
CommsSemanticType,
CommsState,
CommsTransportKind,
MeetingRoom,
MeetingStatus,
MessageStatus,
MessageUrgency,
OPCEvent,
SessionMessageRecord,
SessionPartRecord,
Task,
TaskStatus,
WorkItemDecisionRecord,
)
from opc.core.worker_envelope import (
classify_worker_message,
normalize_worker_envelope_metadata,
worker_message_is_actionable,
)
from opc.layer2_organization.work_item_links import linked_work_item_id_for_task
from opc.database.store import OPCStore
from opc.layer2_organization.collaboration_service import (
CollaborationContext,
CollaborationService,
CommunicationDeliveryError,
)
from opc.layer2_organization.collaboration_policy import (
effective_contact_roles,
)
from opc.layer2_organization.work_item_runtime import is_work_item_runtime_metadata
from opc.layer2_organization.work_item_identity import (
projection_id_for_task,
turn_type_for_task,
work_item_identity_payload_for_task,
)
from opc.llm.provider import LLMProvider
from opc.llm.retry import LLMRetryError, call_llm_json_with_retry
class CommunicationManager:
"""Manages direct messages, broadcasts, meetings, annotations, and peer waits."""
_MAILBOX_METADATA_KEYS: tuple[str, ...] = (
"work_item_id",
"source_work_item_id",
"target_work_item_id",
"parent_work_item_id",
"source_message_id",
"manager_role_id",
"manager_seat_id",
"origin_team_instance_id",
"target_team_instance_id",
"target_role_id",
"target_seat_id",
"source_role_id",
"source_seat_id",
"parent_board_scope",
"upstream_visibility",
"release_policy",
"release_on_semantic_type",
"action_hint",
)
_ENVELOPE_METADATA_KEYS: tuple[str, ...] = (
"message_class",
"protocol_type",
"notification_kind",
"actionable",
"worker_id",
"origin_task_id",
"origin_projection_id",
"origin_session_id",
)
def __init__(
self,
store: OPCStore,
event_bus: EventBus,
llm: LLMProvider | None = None,
org_engine: Any | None = None,
meeting_turn_runner: Callable[[MeetingRoom, str, dict[str, Any]], Awaitable[str]] | None = None,
) -> None:
self.store = store
self.event_bus = event_bus
self.llm = llm
self.org_engine = org_engine
self.meeting_turn_runner = meeting_turn_runner
self._message_queues: dict[str, asyncio.Queue[AgentMessage]] = {}
self._meetings: dict[str, MeetingRoom] = {}
self.task_adjustment_suggester: Any | None = None
# Kanban-push hook: runtime state transitions (work item moves,
# review verdict application, dependency releases) call this to
# push a fresh UI snapshot mid-turn instead of waiting for the
# turn boundary. CompanyWorkItemExecutor wires it up at
# construction time.
self.on_kanban_changed: Callable[[], Awaitable[None]] | None = None
# Work-items-created dispatch-wake hook: fired synchronously
# after `delegate_work` (or the runtime's auto-rework path)
# persists new TODO items so the company-mode main loop can
# claim+spawn them without waiting for the parent turn to drain.
# Set by CompanyWorkItemExecutor.
self.on_work_items_created: Callable[[], None] | None = None
def _collaboration_service(self) -> CollaborationService:
return CollaborationService(self)
def set_meeting_turn_runner(
self,
runner: Callable[[MeetingRoom, str, dict[str, Any]], Awaitable[str]] | None,
) -> None:
self.meeting_turn_runner = runner
def _get_queue(self, agent_id: str) -> asyncio.Queue[AgentMessage]:
return self._message_queues.setdefault(agent_id, asyncio.Queue())
async def rehydrate_queues(self) -> int:
"""Reload unprocessed messages from the store into in-memory queues.
Call once during engine startup to recover from process restarts."""
if not self.store:
return 0
messages = await self.store.get_unprocessed_messages()
count = 0
for msg in messages:
for recipient in msg.to_agents:
self._get_queue(recipient).put_nowait(msg)
count += 1
if count:
logger.info("Rehydrated {} pending messages into agent queues", count)
return count
def _validate_recipients(self, sender: str, recipients: list[str], task: Task | None = None) -> None:
if not self.org_engine or not sender:
return
allowed = set(effective_contact_roles(sender, task=task, org_engine=self.org_engine))
if not allowed:
return
invalid: list[str] = []
for recipient in recipients:
if recipient in allowed or recipient == sender:
continue
employee = self.org_engine.get_employee(recipient)
if employee and employee.role_id in allowed:
continue
role_agent = self.org_engine.get_agent(recipient)
if role_agent:
continue
invalid.append(recipient)
if invalid:
raise ValueError(f"Role `{sender}` cannot message recipient(s): {', '.join(invalid)}")
def _serialize_message(self, message: AgentMessage) -> dict[str, Any]:
raw_metadata = dict(message.metadata or {})
envelope_meta = normalize_worker_envelope_metadata(
raw_metadata,
msg_type=message.msg_type,
semantic_type=message.semantic_type.value,
transport_kind=message.transport_kind.value,
from_agent=message.from_agent,
reply_needed=message.reply_needed,
task_id=str(message.task_id or ""),
projection_id=str(dict(message.refs or {}).get("projection_id", "") or ""),
session_id=str(dict(message.refs or {}).get("session_id", "") or ""),
)
return {
"msg_id": message.msg_id,
"msg_type": message.msg_type,
"from_agent": message.from_agent,
"to_agents": list(message.to_agents),
"subject": message.subject,
"body": message.body,
"context_ref": message.context_ref,
"urgency": message.urgency.value,
"reply_needed": message.reply_needed,
"requires_ack": message.requires_ack,
"timeout_action": message.timeout_action,
"reply_to_msg_id": message.reply_to_msg_id,
"task_id": message.task_id,
"status": message.status.value,
"timestamp": message.timestamp.isoformat(),
"processed_at": message.processed_at.isoformat() if message.processed_at else None,
"transport_kind": message.transport_kind.value,
"semantic_type": message.semantic_type.value,
"comms_state": message.comms_state.value,
"correlation_id": message.correlation_id,
"refs": dict(message.refs),
"message_class": envelope_meta.get("message_class"),
"protocol_type": envelope_meta.get("protocol_type"),
"notification_kind": envelope_meta.get("notification_kind"),
"actionable": bool(envelope_meta.get("actionable", True)),
"worker_id": envelope_meta.get("worker_id"),
"origin_task_id": envelope_meta.get("origin_task_id"),
"origin_projection_id": envelope_meta.get("origin_projection_id"),
"origin_session_id": envelope_meta.get("origin_session_id"),
**{
key: raw_metadata.get(key)
for key in self._MAILBOX_METADATA_KEYS
if raw_metadata.get(key) not in (None, "", [])
},
"metadata": {
**envelope_meta,
**{
key: raw_metadata.get(key)
for key in self._MAILBOX_METADATA_KEYS
if raw_metadata.get(key) not in (None, "", [])
},
},
}
@staticmethod
def _enum_value(value: Any) -> str:
return str(getattr(value, "value", value) or "").strip()
@staticmethod
def _infer_endpoint_type(agent_id: str, metadata: dict[str, Any] | None = None) -> AgentEndpointType:
raw = str(agent_id or "").strip()
meta = dict(metadata or {})
explicit = str(
meta.get("endpoint_type")
or meta.get("from_endpoint_type")
or meta.get("to_endpoint_type")
or ""
).strip().lower()
if explicit in {item.value for item in AgentEndpointType}:
return AgentEndpointType(explicit)
if raw.startswith("na_") or raw.startswith("subagent::"):
return AgentEndpointType.NATIVE_SUBAGENT
if raw.startswith("external::") or bool(meta.get("external_agent", False)):
return AgentEndpointType.EXTERNAL_AGENT
return AgentEndpointType.COMPANY_ROLE
@staticmethod
def _coerce_semantic_type(value: Any, *, fallback: CommsSemanticType = CommsSemanticType.WORK_UPDATE) -> CommsSemanticType:
raw = CommunicationManager._enum_value(value).lower()
for item in CommsSemanticType:
if item.value == raw:
return item
return fallback
@staticmethod
def _coerce_transport_kind(value: Any, *, fallback: CommsTransportKind = CommsTransportKind.DM) -> CommsTransportKind:
raw = CommunicationManager._enum_value(value).lower()
for item in CommsTransportKind:
if item.value == raw:
return item
return fallback
@staticmethod
def _coerce_comms_state(value: Any, *, fallback: CommsState = CommsState.OPEN) -> CommsState:
raw = CommunicationManager._enum_value(value).lower()
for item in CommsState:
if item.value == raw:
return item
return fallback
def _manager_mailroom_target(self, role_id: str, task: Task | None = None) -> str:
role = str(role_id or "").strip()
if not role:
return "ceo"
if self.org_engine and hasattr(self.org_engine, "get_agent"):
agent = self.org_engine.get_agent(role)
manager_role = str(getattr(agent, "reports_to", "") or "").strip()
if manager_role and manager_role != role:
return manager_role
if task is not None:
session_state = dict(task.metadata.get("member_session_state", {}) or task.context_snapshot.get("member_session", {}) or {})
manager_role = str(session_state.get("manager_role_id", "") or "").strip()
if manager_role and manager_role != role:
return manager_role
if self.org_engine and hasattr(self.org_engine, "get_coordinator"):
coordinator = self.org_engine.get_coordinator()
coordinator_role = str(getattr(coordinator, "role_id", "") or "").strip()
if coordinator_role and coordinator_role != role:
return coordinator_role
return "ceo"
def _recipient_seat_id_for_role(self, role_id: str, task: Task | None = None) -> str:
role = str(role_id or "").strip()
if not role or task is None:
return ""
metadata = dict(getattr(task, "metadata", {}) or {})
for role_key, seat_key in (
("review_owner_role_id", "review_owner_seat_id"),
("manager_role_id", "manager_seat_id"),
):
if str(metadata.get(role_key, "") or "").strip() == role:
seat_id = str(metadata.get(seat_key, "") or "").strip()
if seat_id:
return seat_id
context_snapshot = dict(getattr(task, "context_snapshot", {}) or {})
session_state = dict(
metadata.get("member_session_state", {})
or context_snapshot.get("member_session", {})
or {}
)
if str(session_state.get("manager_role_id", "") or "").strip() == role:
seat_id = str(session_state.get("manager_seat_id", "") or "").strip()
if seat_id:
return seat_id
runtime_topology = dict(metadata.get("runtime_topology", {}) or {})
from_seat_id = str(metadata.get("delegation_seat_id", "") or "").strip()
if self.org_engine and hasattr(self.org_engine, "resolve_runtime_target_seat"):
try:
resolved = self.org_engine.resolve_runtime_target_seat(
runtime_topology,
from_seat_id=from_seat_id,
target_role_id=role,
)
except Exception:
resolved = None
if isinstance(resolved, dict):
seat_id = str(resolved.get("seat_id", "") or "").strip()
if seat_id:
return seat_id
for seat in list(runtime_topology.get("seats", []) or []):
if str(seat.get("role_id", "") or "").strip() == role:
seat_id = str(seat.get("seat_id", "") or "").strip()
if seat_id:
return seat_id
if role == str(task.assigned_to or metadata.get("work_item_role_id", "") or "").strip():
return from_seat_id
return ""
def _canonicalize_message(self, message: AgentMessage, task: Task | None = None) -> AgentMessage:
message.metadata = dict(message.metadata or {})
refs = {
**dict(message.metadata.get("refs", {}) or {}),
**dict(message.refs or {}),
}
if task is not None:
refs.setdefault("task_id", str(task.id or "").strip())
refs.setdefault("projection_id", projection_id_for_task(task))
refs.setdefault("session_id", str(task.session_id or task.parent_session_id or "").strip())
if message.task_id:
refs.setdefault("task_id", str(message.task_id).strip())
if message.reply_to_msg_id:
refs.setdefault("reply_to_msg_id", str(message.reply_to_msg_id).strip())
semantic_fallback = (
CommsSemanticType.BLOCKED_ON_DECISION
if message.reply_needed or message.msg_type == "decision_needed"
else CommsSemanticType.WORK_UPDATE
)
message.transport_kind = self._coerce_transport_kind(
message.transport_kind,
fallback=CommsTransportKind.BROADCAST if len(message.to_agents) > 1 else CommsTransportKind.DM,
)
message.semantic_type = self._coerce_semantic_type(
message.semantic_type or message.metadata.get("semantic_type"),
fallback=semantic_fallback,
)
message.comms_state = self._coerce_comms_state(
message.comms_state or message.metadata.get("comms_state"),
fallback=CommsState.OPEN,
)
if not message.correlation_id:
message.correlation_id = str(message.metadata.get("correlation_id", "") or message.msg_id).strip()
message.refs = refs
envelope_meta = normalize_worker_envelope_metadata(
message.metadata,
msg_type=message.msg_type,
semantic_type=message.semantic_type.value,
transport_kind=message.transport_kind.value,
from_agent=message.from_agent,
reply_needed=message.reply_needed,
task_id=refs.get("task_id", ""),
projection_id=refs.get("projection_id", ""),
session_id=refs.get("session_id", ""),
worker_id=str(message.metadata.get("worker_id", "") or message.from_agent).strip(),
)
message.metadata.update(
{
**envelope_meta,
"transport_kind": message.transport_kind.value,
"semantic_type": message.semantic_type.value,
"comms_state": message.comms_state.value,
"correlation_id": message.correlation_id,
"refs": dict(refs),
"from_endpoint_type": self._infer_endpoint_type(message.from_agent, message.metadata).value,
"to_endpoint_types": [
self._infer_endpoint_type(target, message.metadata).value
for target in list(message.to_agents)
],
}
)
return message
def _message_frontmatter(self, message: AgentMessage, task: Task | None = None) -> dict[str, Any]:
normalized = self._canonicalize_message(message, task=task)
refs = dict(normalized.refs or {})
frontmatter = {
"transport_kind": normalized.transport_kind.value,
"semantic_type": normalized.semantic_type.value,
"comms_state": normalized.comms_state.value,
"correlation_id": normalized.correlation_id,
"from_endpoint_type": self._infer_endpoint_type(normalized.from_agent, normalized.metadata).value,
"to_endpoint_types": [
self._infer_endpoint_type(target, normalized.metadata).value
for target in list(normalized.to_agents)
],
"refs": refs,
"kind": normalized.semantic_type.value,
"task_id": refs.get("task_id", ""),
"projection_id": refs.get("projection_id", ""),
}
metadata = dict(normalized.metadata or {})
for key in (*self._ENVELOPE_METADATA_KEYS, *self._MAILBOX_METADATA_KEYS):
value = metadata.get(key)
if value not in (None, "", []):
frontmatter[key] = value
return frontmatter
@staticmethod
def _is_high_risk_work_item(task: Task) -> bool:
gate = dict(task.metadata.get("work_item_gate", {}) or {})
if bool(gate.get("requires_human", False)):
return True
if bool(task.metadata.get("work_item_verification_required", False)):
return True
turn_type = turn_type_for_task(task, fallback="")
return turn_type in {"execute", "review"}
async def _record_non_live_collaboration(
self,
task: Task,
*,
origin: str,
summary: str,
) -> None:
task.context_snapshot = dict(task.context_snapshot)
provenance = list(task.context_snapshot.get("collaboration_provenance", []))
provenance.append(
{
"origin": origin,
"summary": summary,
"recorded_at": datetime.now().isoformat(),
}
)
task.context_snapshot["collaboration_provenance"] = provenance[-8:]
task.context_snapshot["latest_collaboration_origin"] = origin
if self._is_high_risk_work_item(task):
warnings = list(task.metadata.get("progress_log", []))
note = f"Non-live collaboration artifact observed: {origin}. {summary}"
if note not in warnings:
warnings.append(note)
task.metadata["progress_log"] = warnings
async def send_manager_notification(
self,
*,
from_agent: str,
task: Task | None,
semantic_type: CommsSemanticType,
subject: str,
body: str,
metadata: dict[str, Any] | None = None,
reply_needed: bool = False,
requires_ack: bool = False,
) -> AgentMessage | None:
manager_role = self._manager_mailroom_target(from_agent, task=task)
if not manager_role or manager_role == from_agent:
return None
msg_type = (
"decision_needed"
if semantic_type == CommsSemanticType.BLOCKED_ON_DECISION
else "inform"
)
work_item_id = linked_work_item_id_for_task(task)
metadata_payload = dict(metadata or {})
target_seat_id = (
self._recipient_seat_id_for_role(manager_role, task)
or str(metadata_payload.get("target_seat_id", "") or "").strip()
or str((getattr(task, "metadata", {}) or {}).get("manager_seat_id", "") or "").strip()
)
message = AgentMessage(
msg_type=msg_type,
from_agent=from_agent,
to_agents=[manager_role],
subject=subject,
body=body,
context_ref=task.id if task else None,
urgency=MessageUrgency.BLOCKING if reply_needed else MessageUrgency.NORMAL,
reply_needed=reply_needed,
requires_ack=requires_ack,
task_id=task.id if task else None,
transport_kind=CommsTransportKind.SYSTEM,
semantic_type=semantic_type,
metadata={
**metadata_payload,
"work_item_id": work_item_id,
"source_work_item_id": work_item_id,
"target_work_item_id": str(
dict(metadata or {}).get("target_work_item_id")
or dict(metadata or {}).get("work_item_id")
or work_item_id
or ""
).strip(),
"parent_work_item_id": str(
(getattr(task, "metadata", {}) or {}).get("delegation_parent_work_item_id", "")
or dict(
(getattr(task, "metadata", {}) or {}).get("derived_work_item_projection", {})
or {}
).get("parent_work_item_id", "")
or work_item_id
or ""
).strip(),
"manager_role_id": manager_role,
"from_seat_id": str((getattr(task, "metadata", {}) or {}).get("delegation_seat_id", "") or "").strip(),
"source_role_id": from_agent,
"source_seat_id": str((getattr(task, "metadata", {}) or {}).get("delegation_seat_id", "") or "").strip(),
"target_role_id": manager_role,
"target_seat_id": target_seat_id,
"team_id": str((getattr(task, "metadata", {}) or {}).get("delegation_team_id", "") or "").strip(),
},
)
return await self.send_dm(message, task=task)
@staticmethod
def _task_workspace_root(task: Task | None) -> str:
if task is None:
return ""
metadata = dict(task.metadata or {})
return (
str(metadata.get("workspace_root", "") or "").strip()
or str(metadata.get("comms_workspace_root", "") or "").strip()
or str(metadata.get("output_root", "") or "").strip()
or str(metadata.get("target_output_dir", "") or "").strip()
)
@staticmethod
def _task_output_root(task: Task | None) -> str:
if task is None:
return ""
metadata = dict(task.metadata or {})
return (
str(metadata.get("output_root", "") or "").strip()
or str(metadata.get("target_output_dir", "") or "").strip()
or CommunicationManager._task_workspace_root(task)
)
@staticmethod
def _task_comms_root(task: Task | None) -> str:
if task is None:
return ""
metadata = dict(task.metadata or {})
explicit = str(metadata.get("comms_root", "") or "").strip()
if explicit:
return explicit
workspace_root = CommunicationManager._task_workspace_root(task)
if not workspace_root:
return ""
try:
return str(Path(workspace_root).expanduser().resolve() / ".opc-comms")
except Exception:
return ""
def _task_comms_layout(self, task: Task | None):
if task is None:
return None
workspace_root = self._task_workspace_root(task)
if not workspace_root:
return None
try:
from opc.layer2_organization import comms as _comms
except Exception:
return None
project_id = str(task.project_id or "default").strip() or "default"
session_id = (
str(task.parent_session_id or "").strip()
or str(task.session_id or "").strip()
or "default"
)
try:
return _comms.resolve_layout(workspace_root, project_id, session_id)
except Exception:
return None
async def _record_session_notice(
self,
task: Task,
*,
text: str,
metadata: dict[str, Any] | None = None,
agent_id: str | None = None,
) -> None:
if not task.session_id:
return
message = SessionMessageRecord(
session_id=task.session_id,
role="assistant",
task_id=task.id,
agent_id=agent_id,
metadata=dict(metadata or {}),
)
await self.store.save_session_message(message)
await self.store.save_session_part(
SessionPartRecord(
message_id=message.message_id,
session_id=task.session_id,
part_type="text",
payload={"text": text},
)
)
async def _record_comms_failure(
self,
task: Task | None,
*,
operation: str,
from_role: str,
to_role: str,
reason: str,
attempted_path: str = "",
attempted_command: str = "",
) -> dict[str, Any]:
payload = {
"operation": str(operation or "").strip(),
"from_role": str(from_role or "").strip(),
"to_role": str(to_role or "").strip(),
"reason": str(reason or "").strip() or "unknown_error",
"attempted_path": str(attempted_path or "").strip(),
"attempted_command": str(attempted_command or "").strip(),
"recorded_at": datetime.now().isoformat(),
"can_retry": True,
}
if task is None:
return payload
task.metadata = dict(task.metadata or {})
task.context_snapshot = dict(task.context_snapshot or {})
failures = list(task.context_snapshot.get("comms_failures", []) or [])
payload["attempt_count"] = len(failures) + 1
failures.append(dict(payload))
task.context_snapshot["comms_failures"] = failures[-12:]
task.context_snapshot["latest_comms_failure"] = dict(payload)
task.metadata["comms_health"] = "degraded"
progress = list(task.metadata.get("progress_log", []) or [])
progress.append(
f"Comms failure [{payload['operation']}] {payload['from_role']} -> {payload['to_role']}: {payload['reason']}"
)
task.metadata["progress_log"] = progress[-40:]
if task.status in {TaskStatus.DONE, TaskStatus.IDLE, TaskStatus.AWAITING_PEER}:
task.status = TaskStatus.PENDING
await self.store.save_task(task)
notice = (
"通信发送失败,需要你下一轮自行处理:\n"
f"- operation: {payload['operation']}\n"
f"- from: {payload['from_role']}\n"
f"- to: {payload['to_role']}\n"
f"- reason: {payload['reason']}"
)
if payload["attempted_path"]:
notice += f"\n- path: {payload['attempted_path']}"
if payload["attempted_command"]:
notice += f"\n- command: {payload['attempted_command']}"
notice += "\n请结合错误信息决定是否重发、改写、改走 meeting,或先继续可并行部分。"
await self._record_session_notice(
task,
text=notice,
metadata={
"kind": "comms_failure",
"comms_failure": dict(payload),
},
agent_id=payload["from_role"] or None,
)
await self.event_bus.publish(
OPCEvent(
event_type="comms_delivery_failed",
payload={
"task_id": task.id,
"session_id": task.session_id,
**payload,
},
)
)
return payload
@staticmethod
def _status_from_message_bucket(path: Path) -> MessageStatus:
parent = path.parent.name
if parent == "seen":
return MessageStatus.READ
return MessageStatus.DELIVERED
async def _project_comms_messages(
self,
layout: Any,
*,
role_id: str,
task: Task | None,
unread_only: bool,
limit: int,
mark_read: bool,
) -> list[dict[str, Any]]:
from opc.layer2_organization import comms as _comms
headers = (
_comms.list_unread(layout, role_id, limit=limit)
if unread_only
else _comms.list_role_messages(
layout,
role_id,
include_new=True,
include_seen=True,
include_outbox=False,
)[-limit:]
)
serialized: list[dict[str, Any]] = []
consumed_paths: list[Path] = []
active_seat_id = str((getattr(task, "metadata", {}) or {}).get("delegation_seat_id", "") or "").strip()
for header in headers:
full_header, body = _comms.read_message(header.path)
if full_header is None:
continue
frontmatter = dict(full_header.raw_frontmatter or {})
target_seat_id = str(frontmatter.get("target_seat_id", "") or "").strip()
if active_seat_id and target_seat_id and target_seat_id != active_seat_id:
continue
refs = dict(frontmatter.get("refs", {}) or {})
transport_kind = self._coerce_transport_kind(frontmatter.get("transport_kind"))
semantic_type = self._coerce_semantic_type(frontmatter.get("semantic_type") or frontmatter.get("kind"))
comms_state = self._coerce_comms_state(frontmatter.get("comms_state"))
projection = AgentMessage(
msg_id=full_header.message_id,
msg_type=str(frontmatter.get("msg_type", "") or "question"),
from_agent=full_header.from_role,
to_agents=[full_header.to_role],
subject=full_header.subject,
body=body,
context_ref=task.id if task else None,
urgency=MessageUrgency.BLOCKING if full_header.blocking else MessageUrgency.NORMAL,
reply_needed=bool(full_header.blocking),
requires_ack=bool(frontmatter.get("requires_ack", False)),
reply_to_msg_id=full_header.reply_to,
task_id=(task.id if task else str(frontmatter.get("task_id", "") or refs.get("task_id", "") or "").strip()) or None,
status=self._status_from_message_bucket(header.path),
transport_kind=transport_kind,
semantic_type=semantic_type,
comms_state=comms_state,
correlation_id=str(frontmatter.get("correlation_id", "") or full_header.message_id).strip(),
refs=refs,
metadata={
"projection_source": "file_comms",
"comms_path": str(header.path),
"transport_kind": transport_kind.value,
"semantic_type": semantic_type.value,
"comms_state": comms_state.value,
"correlation_id": str(frontmatter.get("correlation_id", "") or full_header.message_id).strip(),
"refs": refs,
"from_endpoint_type": str(frontmatter.get("from_endpoint_type", "") or "").strip(),
"to_endpoint_types": list(frontmatter.get("to_endpoint_types", []) or []),
**{
key: frontmatter.get(key)
for key in (*self._ENVELOPE_METADATA_KEYS, *self._MAILBOX_METADATA_KEYS)
if frontmatter.get(key) not in (None, "", [])
},
},
)
projection = self._canonicalize_message(projection, task=task)
await self.store.save_message(projection)
serialized.append(classify_worker_message(self._serialize_message(projection)))
if mark_read and header.path.parent.name == "new":
consumed_paths.append(header.path)
if consumed_paths:
moved = _comms.mark_seen(layout, role_id, consumed_paths)
moved_names = {path.name for path in moved}
for item in serialized:
metadata = dict(item.get("metadata", {}) or {})
path = str(metadata.get("comms_path", "") or "").strip()
if path and Path(path).name in moved_names:
item["status"] = MessageStatus.READ.value
return list(reversed(serialized))
async def _project_comms_meetings(self, layout: Any, *, task: Task | None) -> None:
from opc.layer2_organization import comms as _comms
if not layout.meetings_root.is_dir():
return
for child in sorted(layout.meetings_root.iterdir(), key=lambda item: item.name):
if not child.is_dir():
continue
state = _comms.read_meeting_state(layout, child.name)
if state is None:
continue
transcript = [
{
"entry_id": entry.entry_id,
"agent": entry.author,
"content": entry.content,
"posted_at": entry.posted_at,
}
for entry in _comms.read_transcript(layout, state.meeting_id)
]
status = MeetingStatus.CLOSED if state.status == "closed" else MeetingStatus.OPEN
await self.store.save_meeting(
MeetingRoom(
room_id=state.meeting_id,
task_id=task.id if task else None,
topic=state.topic,
participants=list(state.participants),
shared_context="",
agenda=[],
decision_owner=state.organizer,
status=status,
outcome={"decision": state.decision} if state.decision else None,
transcript=transcript,
metadata={
"projection_source": "file_comms",
"transcript_path": str(state.transcript_path),
},
)
)
async def rebuild_comms_projection(
self,
*,
task: Task | None = None,
layout: Any | None = None,
) -> dict[str, Any]:
active_task = task
resolved_layout = layout or self._task_comms_layout(active_task)
if active_task is None or resolved_layout is None:
return {"available": False}
await self._project_comms_meetings(resolved_layout, task=active_task)
return {
"available": True,
"workspace_root": self._task_workspace_root(active_task),
"output_root": self._task_output_root(active_task),
"comms_root": self._task_comms_root(active_task),
}
@staticmethod
def _coerce_string_list(value: Any, *, limit: int = 8) -> list[str]:
if isinstance(value, str):
return [value.strip()] if value.strip() else []
items: list[str] = []
for raw in list(value or []):
text = str(raw).strip()
if text:
items.append(text)
return items[:limit]
@staticmethod
def _extract_json_object(content: str) -> dict[str, Any] | None:
text = str(content or "").strip()
if not text:
return None
if text.startswith("```"):
lines = [line for line in text.splitlines() if not line.strip().startswith("```")]
text = "\n".join(lines).strip()
try:
parsed = json.loads(text)
except Exception:
parsed = None
if isinstance(parsed, dict):
return parsed
decoder = json.JSONDecoder()
for idx, char in enumerate(text):
if char != "{":
continue
try:
candidate, _ = decoder.raw_decode(text[idx:])
except Exception:
continue
if isinstance(candidate, dict):
return candidate
return None
@staticmethod
def _normalize_meeting_stance(value: Any) -> str:
text = str(value or "").strip().lower()
if text in {"agree", "aligned", "support", "yes"}:
return "agree"
if text in {"disagree", "oppose", "no"}:
return "disagree"
if text in {"conditional", "partial", "mixed"}:
return "conditional"
if text in {"abstain", "neutral"}:
return "abstain"
return "conditional" if text else "abstain"
@staticmethod
def _normalize_meeting_vote(value: Any, stance: str = "") -> str:
text = str(value or "").strip().lower()
if text in {"support", "yes", "approve", "agree"}:
return "support"
if text in {"oppose", "no", "reject", "disagree"}:
return "oppose"
if text in {"abstain", "neutral"}:
return "abstain"
normalized_stance = CommunicationManager._normalize_meeting_stance(stance)
if normalized_stance == "agree":
return "support"
if normalized_stance == "disagree":
return "oppose"
return "abstain"
def _coerce_meeting_turn_payload(self, content: str) -> dict[str, Any]:
parsed = self._extract_json_object(content)
if not isinstance(parsed, dict):
parsed = {}
parsed_from_json = bool(parsed)
lines = [
line.strip(" -*\t")
for line in str(content or "").splitlines()
if line.strip(" -*\t")
]
reasoning = str(parsed.get("reasoning", "") or "").strip()
if not reasoning and lines:
reasoning = lines[0]
proposal = str(parsed.get("proposal", "") or "").strip()
if not proposal and lines:
proposal = lines[0]
blocking_issues = self._coerce_string_list(parsed.get("blocking_issues"), limit=6)
assumptions = self._coerce_string_list(parsed.get("assumptions"), limit=6)
questions = self._coerce_string_list(parsed.get("questions_for_others"), limit=6)
if not blocking_issues and not parsed_from_json:
blocking_issues = [line for line in lines if any(token in line.lower() for token in ("block", "risk", "concern", "issue"))][:4]
stance = self._normalize_meeting_stance(parsed.get("stance"))
if stance == "abstain" and content:
lowered = str(content).lower()
if "disagree" in lowered or "oppose" in lowered:
stance = "disagree"
elif "agree" in lowered or "support" in lowered or "aligned" in lowered:
stance = "agree"
support_level = parsed.get("support_level")
try:
support_level_num = float(support_level)
except (TypeError, ValueError):
support_level_num = 0.8 if stance == "agree" else (0.2 if stance == "disagree" else 0.5)
return {
"stance": stance,
"proposal": proposal,
"support_level": max(0.0, min(1.0, support_level_num)),
"vote": self._normalize_meeting_vote(parsed.get("vote"), stance=stance),
"reasoning": reasoning,
"blocking_issues": blocking_issues,
"assumptions": assumptions,
"questions_for_others": questions,
}
def _coerce_meeting_outcome(
self,
content: str,
*,
decision_method: str,
consensus: dict[str, Any] | None = None,
) -> dict[str, Any]:
parsed = self._extract_json_object(content)
if not isinstance(parsed, dict):
parsed = {}
decision = str(parsed.get("decision", "") or "").strip()
action_items = self._coerce_string_list(parsed.get("action_items"), limit=10)
reasoning = str(parsed.get("reasoning", "") or "").strip() or str(content or "").strip()
follow_up_questions = self._coerce_string_list(parsed.get("follow_up_questions"), limit=8)
outcome = {
"decision": decision,
"action_items": action_items,
"reasoning": reasoning,
"decision_method": decision_method,
"requires_human_input": bool(parsed.get("requires_human_input", False)),
"follow_up_questions": follow_up_questions,
}
if consensus:
outcome["consensus_summary"] = dict(consensus)
outcome["aligned_points"] = list(consensus.get("aligned_points", []))
outcome["unresolved_items"] = list(consensus.get("blocking_conflicts", []))
outcome["vote_summary"] = dict(consensus.get("vote_summary", {}))
if not outcome["decision"] and consensus:
outcome["decision"] = str(consensus.get("dominant_proposal", "") or "").strip()
return outcome
@staticmethod
def _normalize_proposal_key(value: str) -> str:
text = str(value or "").strip().lower()
return " ".join(text.split())
def _fallback_consensus_analysis(self, entries: list[dict[str, Any]], meeting: MeetingRoom) -> dict[str, Any]:
structured_entries = []
proposal_counts: dict[str, int] = {}
vote_summary = {"support": 0, "oppose": 0, "abstain": 0}
blocking_conflicts: list[str] = []
open_questions: list[str] = []
for entry in entries:
structured = dict(entry.get("structured", {}) or {})
structured_entries.append(structured)
proposal = str(structured.get("proposal", "") or "").strip()
proposal_key = self._normalize_proposal_key(proposal)
if proposal_key:
proposal_counts[proposal_key] = proposal_counts.get(proposal_key, 0) + 1
vote = self._normalize_meeting_vote(structured.get("vote"), stance=str(structured.get("stance", "") or ""))
vote_summary[vote] = vote_summary.get(vote, 0) + 1
blocking_conflicts.extend(self._coerce_string_list(structured.get("blocking_issues"), limit=6))
open_questions.extend(self._coerce_string_list(structured.get("questions_for_others"), limit=4))
dominant_proposal_key = max(proposal_counts, key=proposal_counts.get, default="")
proposal_lookup = {
self._normalize_proposal_key(str((entry.get("structured", {}) or {}).get("proposal", "") or "").strip()):
str((entry.get("structured", {}) or {}).get("proposal", "") or "").strip()
for entry in entries
}
dominant_proposal = proposal_lookup.get(dominant_proposal_key, "")
all_supportive = all(
self._normalize_meeting_stance((entry.get("structured", {}) or {}).get("stance")) in {"agree", "conditional"}
for entry in entries
)
consensus_reached = bool(
entries
and dominant_proposal_key
and proposal_counts.get(dominant_proposal_key, 0) == len(entries)
and all_supportive
and not blocking_conflicts
)
aligned_points = [f"All participants converged on `{dominant_proposal}`."] if consensus_reached and dominant_proposal else []
suggested_next = [
str(entry.get("agent", "")).strip()
for entry in entries
if self._normalize_meeting_stance((entry.get("structured", {}) or {}).get("stance")) == "disagree"
]
if not suggested_next:
suggested_next = [participant for participant in meeting.participants if participant]
return {
"consensus_reached": consensus_reached,
"aligned_points": aligned_points,
"open_questions": list(dict.fromkeys(open_questions))[:8],
"blocking_conflicts": list(dict.fromkeys(blocking_conflicts))[:8],
"suggested_next_speakers": suggested_next[: max(1, len(suggested_next))],
"dominant_proposal": dominant_proposal,
"vote_summary": vote_summary,
"recommended_decision_method": "semantic_consensus" if consensus_reached else "owner_override",
}
def _render_recent_meeting_transcript(self, meeting: MeetingRoom, *, limit: int = 12) -> str:
lines: list[str] = []
for entry in meeting.transcript[-limit:]:
agent = str(entry.get("agent", "")).strip() or "unknown"
round_no = entry.get("round")
entry_type = str(entry.get("entry_type", "participant_turn") or "participant_turn").strip()
prefix = f"[Round {round_no}] " if isinstance(round_no, int) and round_no > 0 else ""
if entry_type == "judge_analysis":
prefix += "[Judge] "
elif entry_type == "final_decision":
prefix += "[Decision] "
lines.append(f"{prefix}{agent}: {str(entry.get('content', '')).strip()}")
return "\n".join(lines)
async def _judge_meeting_consensus(
self,
meeting: MeetingRoom,
round_entries: list[dict[str, Any]],
) -> dict[str, Any]:
fallback = self._fallback_consensus_analysis(round_entries, meeting)
if not self.llm:
return fallback
prompt = (
"You are the semantic consensus judge for a company meeting.\n"
"Analyze whether the participants actually converged on the same decision.\n"
"Return ONLY valid JSON with this schema:\n"
'{"consensus_reached": true|false, "aligned_points": ["..."], '
'"open_questions": ["..."], "blocking_conflicts": ["..."], '
'"suggested_next_speakers": ["role_id"], "dominant_proposal": "...", '
'"vote_summary": {"support": 0, "oppose": 0, "abstain": 0}, '
'"recommended_decision_method": "semantic_consensus|owner_override|majority_vote|human_escalation"}\n\n'
f"Topic: {meeting.topic}\n"
f"Agenda: {json.dumps(meeting.agenda, ensure_ascii=False)}\n"
f"Current round: {meeting.current_round}\n"
f"Recent transcript:\n{self._render_recent_meeting_transcript(meeting) or '(none)'}\n\n"
f"Structured round entries:\n{json.dumps([entry.get('structured', {}) for entry in round_entries], ensure_ascii=False)}"
)
try:
parsed = await call_llm_json_with_retry(
self.llm,
system="",
payload=prompt,
task_type="quick_tasks",
label="meeting_consensus_judge",
)
except LLMRetryError:
return fallback
if not isinstance(parsed, dict):
return fallback
analysis = {
"consensus_reached": bool(parsed.get("consensus_reached", False)),
"aligned_points": self._coerce_string_list(parsed.get("aligned_points"), limit=10),
"open_questions": self._coerce_string_list(parsed.get("open_questions"), limit=10),
"blocking_conflicts": self._coerce_string_list(parsed.get("blocking_conflicts"), limit=10),
"suggested_next_speakers": self._coerce_string_list(parsed.get("suggested_next_speakers"), limit=max(1, len(meeting.participants))),
"dominant_proposal": str(parsed.get("dominant_proposal", "") or "").strip(),
"vote_summary": dict(parsed.get("vote_summary", {}) or fallback.get("vote_summary", {})),
"recommended_decision_method": str(parsed.get("recommended_decision_method", "") or fallback.get("recommended_decision_method", "owner_override")).strip() or "owner_override",
}
if not analysis["dominant_proposal"]:
analysis["dominant_proposal"] = str(fallback.get("dominant_proposal", "") or "").strip()
if not analysis["suggested_next_speakers"]:
analysis["suggested_next_speakers"] = list(fallback.get("suggested_next_speakers", []))
return analysis
async def _run_meeting_turn(
self,
meeting: MeetingRoom,
participant: str,
*,
mode: str,
consensus: dict[str, Any] | None = None,
) -> str:
waiting_task = await self.store.get_task(str(meeting.task_id)) if meeting.task_id else None
transcript = self._render_recent_meeting_transcript(meeting) or "(no prior discussion)"
role = self.org_engine.get_agent(participant) if self.org_engine else None
role_name = str(getattr(role, "name", "") or participant).strip() or participant
responsibility = str(getattr(role, "responsibility", "") or "").strip()
output_schema = (
'{"stance":"agree|disagree|conditional|abstain",'
'"proposal":"...",'
'"support_level":0.0,'
'"vote":"support|oppose|abstain",'
'"reasoning":"...",'
'"blocking_issues":["..."],'
'"assumptions":["..."],'
'"questions_for_others":["..."]}'
)
if mode == "decision_owner":
task_brief = (
f"Meeting decision required for topic `{meeting.topic}`.\n"
f"You are the decision owner: {participant}.\n"
"Review the meeting transcript and the latest consensus analysis, then make the decision.\n"
"Return ONLY valid JSON with this schema:\n"
'{"decision":"...",'
'"action_items":["..."],'
'"reasoning":"...",'
'"requires_human_input":false,'
'"follow_up_questions":["..."]}'
)
prompt = (
f"You are {role_name} ({participant}).\n"
+ (f"Responsibility: {responsibility}\n" if responsibility else "")
+ task_brief
+ "\n\nMeeting agenda:\n"
+ "\n".join(f"- {item}" for item in meeting.agenda)
+ f"\n\nRecent transcript:\n{transcript}"
+ f"\n\nConsensus analysis:\n{json.dumps(consensus or {}, ensure_ascii=False)}"
+ "\n\nIf evidence is insufficient or risk is too high, set `requires_human_input` to true."
)
else:
task_brief = (
f"Participate in meeting round {meeting.current_round} for topic `{meeting.topic}` as role `{participant}`.\n"
"Think independently from your role's perspective.\n"
"Return ONLY valid JSON with this schema:\n"
f"{output_schema}\n"
"Do not produce prose before or after the JSON."
)
prompt = (
f"You are {role_name} ({participant}).\n"
+ (f"Responsibility: {responsibility}\n" if responsibility else "")
+ task_brief
+ "\n\nMeeting background:\n"
+ str(meeting.shared_context or "").strip()
+ "\n\nAgenda:\n"
+ "\n".join(f"- {item}" for item in meeting.agenda)
+ f"\n\nRecent transcript:\n{transcript}"
+ f"\n\nLatest consensus analysis:\n{json.dumps(consensus or {}, ensure_ascii=False)}"
+ "\n\nIf you disagree, name the blocking issue explicitly."
)
request = {
"mode": mode,
"participant": participant,
"round": int(meeting.current_round or 1),
"task_id": meeting.task_id,
"project_id": str(getattr(waiting_task, "project_id", "") or ""),
"task_brief": task_brief,
"prompt": prompt,
"system_addendum": (
"## Meeting Turn Contract\n"
"You are producing one structured meeting turn.\n"
"Stay within the meeting topic.\n"
"Use available read-only context/tools only when needed.\n"
"Do not edit files, do not request user input, and do not widen scope.\n"
"End your turn with one JSON object only."
),
"meeting_context": {
"room_id": meeting.room_id,
"topic": meeting.topic,
"agenda": list(meeting.agenda),
"shared_context": meeting.shared_context,
"current_round": int(meeting.current_round or 1),
"decision_owner": meeting.decision_owner,
"consensus": dict(consensus or {}),
"recent_transcript": meeting.transcript[-12:],
},
"execution_scope_ids": list((waiting_task.metadata if waiting_task else {}).get("execution_task_ids", []))
or ([str(meeting.task_id).strip()] if str(meeting.task_id or "").strip() else []),
}
if self.meeting_turn_runner:
return await self.meeting_turn_runner(meeting, participant, request)
# A missing runner means the engine wiring is broken — engine.py
# sets ``set_meeting_turn_runner`` during initialization, and every
# test that exercises meetings must inject its own fake runner.
# Fail loud instead of silently returning a JSON stub (which would
# pretend a consensus was reached) or calling the main LLM (which
# would contradict the external-agent-voice contract).
raise RuntimeError(
f"meeting_turn_runner is not registered (meeting={meeting.room_id}, "
f"participant={participant}, mode={mode}); engine wiring is incomplete."
)
def _ensure_meeting_round_state(self, meeting: MeetingRoom) -> None:
if meeting.current_round <= 0:
meeting.current_round = 1
if not meeting.pending_participants:
meeting.pending_participants = list(meeting.participants)
def _meeting_round_entries(self, meeting: MeetingRoom, round_no: int) -> list[dict[str, Any]]:
return [
dict(entry)
for entry in meeting.transcript
if str(entry.get("entry_type", "") or "participant_turn") == "participant_turn"
and int(entry.get("round") or 0) == int(round_no)
]
def _append_meeting_participant_turn(
self,
meeting: MeetingRoom,
*,
from_agent: str,
content: str,
source: str,
round_no: int | None = None,
) -> None:
active_round = int(round_no or meeting.current_round or 1)
meeting.transcript = [
entry
for entry in meeting.transcript
if not (
str(entry.get("entry_type", "") or "participant_turn") == "participant_turn"
and int(entry.get("round") or 0) == active_round
and str(entry.get("agent", "")).strip() == str(from_agent).strip()
)
]
meeting.transcript.append(
{
"agent": from_agent,
"content": content,
"timestamp": datetime.now().isoformat(),
"round": active_round,
"source": source,
"entry_type": "participant_turn",
"structured": self._coerce_meeting_turn_payload(content),
}
)
meeting.pending_participants = [
participant
for participant in list(meeting.pending_participants or [])
if str(participant).strip() != str(from_agent).strip()
]
meeting.status = MeetingStatus.IN_PROGRESS
meeting.updated_at = datetime.now()
meeting.last_activity_at = datetime.now()
async def _dispatch_meeting_round_requests(
self,
meeting: MeetingRoom,
*,
participants: list[str],
consensus: dict[str, Any],
) -> None:
unresolved = self._coerce_string_list(consensus.get("blocking_conflicts"), limit=6)
open_questions = self._coerce_string_list(consensus.get("open_questions"), limit=6)
body_lines = [
f"Meeting follow-up for room `{meeting.room_id}`.",
f"Round: {meeting.current_round}",
f"Topic: {meeting.topic}",
"Agenda:",
*[f"- {item}" for item in meeting.agenda],
]
if unresolved:
body_lines.append("Unresolved conflicts:")
body_lines.extend(f"- {item}" for item in unresolved)
if open_questions:
body_lines.append("Open questions:")
body_lines.extend(f"- {item}" for item in open_questions)
body_lines.append("Reply with `respond_meeting` using a structured JSON stance.")
for participant in participants:
follow_up = AgentMessage(
msg_type="decision_needed",
from_agent=meeting.decision_owner,
to_agents=[participant],
subject=f"Meeting Round {meeting.current_round}: {meeting.topic}",
body="\n".join(body_lines),
context_ref=meeting.room_id,
task_id=meeting.task_id,
urgency=MessageUrgency.HIGH,
metadata={
"meeting_room_id": meeting.room_id,
"agenda": list(meeting.agenda),
"meeting_round": meeting.current_round,
"consensus_summary": dict(consensus),
},
)
await self.send_dm(follow_up)
async def _finalize_meeting(
self,
meeting: MeetingRoom,
*,
outcome: dict[str, Any],
transcript_note: str = "",
) -> MeetingRoom:
meeting.outcome = dict(outcome)
meeting.decision_method = str(outcome.get("decision_method", "") or meeting.decision_method or "").strip()
meeting.pending_participants = []
meeting.status = MeetingStatus.DECIDED
meeting.updated_at = datetime.now()
meeting.last_activity_at = datetime.now()
if transcript_note:
meeting.transcript.append(
{
"agent": meeting.decision_owner or "meeting_system",
"content": transcript_note,
"timestamp": datetime.now().isoformat(),
"round": int(meeting.current_round or 0),
"entry_type": "final_decision",
"source": "meeting_system",
}
)
await self.store.save_meeting(meeting)
if meeting.task_id:
task = await self.store.get_task(str(meeting.task_id))
if task:
await self.store.record_work_item_decision(
WorkItemDecisionRecord(
project_id=str(task.project_id or "default"),
task_id=task.id,
role_id=meeting.decision_owner,
projection_id=projection_id_for_task(task),
category="meeting",
summary=str(outcome.get("decision", "") or meeting.topic).strip(),
details={
"meeting_room_id": meeting.room_id,
"decision_method": meeting.decision_method,
"consensus": dict(meeting.consensus or {}),
"action_items": list(outcome.get("action_items", []) or []),
"requires_human_input": bool(outcome.get("requires_human_input", False)),
},
)
)
await self.event_bus.publish(
OPCEvent(
event_type="meeting_ended",
payload={
"room_id": meeting.room_id,
"task_id": meeting.task_id,
"outcome": meeting.outcome,
"decision_method": meeting.decision_method,
},
)
)
return meeting
def _majority_vote_outcome(self, meeting: MeetingRoom, consensus: dict[str, Any]) -> dict[str, Any] | None:
vote_summary = dict(consensus.get("vote_summary", {}) or {})
support = int(vote_summary.get("support", 0) or 0)
oppose = int(vote_summary.get("oppose", 0) or 0)
dominant = str(consensus.get("dominant_proposal", "") or "").strip()
if support <= oppose or not dominant:
return None
return {
"decision": dominant,
"action_items": [
"Proceed with the majority-backed proposal.",
"Record the unresolved objections for downstream reviewers.",
],
"reasoning": "Consensus was not unanimous, so the meeting used a majority vote fallback.",
"decision_method": "majority_vote",
"requires_human_input": False,
"follow_up_questions": [],
"consensus_summary": dict(consensus),
"aligned_points": list(consensus.get("aligned_points", [])),
"unresolved_items": list(consensus.get("blocking_conflicts", [])),
"vote_summary": vote_summary,
}
def _human_escalation_outcome(self, meeting: MeetingRoom, consensus: dict[str, Any]) -> dict[str, Any]:
return {
"decision": "",
"action_items": [
"Escalate this decision to a human reviewer.",
"Resolve the listed blocking conflicts before resuming execution.",
],
"reasoning": (
"The meeting exhausted its semantic consensus path and no safe automatic owner/majority decision "
"was available."
),
"decision_method": "human_escalation",
"requires_human_input": True,
"follow_up_questions": list(consensus.get("open_questions", [])),
"consensus_summary": dict(consensus),
"aligned_points": list(consensus.get("aligned_points", [])),
"unresolved_items": list(consensus.get("blocking_conflicts", [])),
"vote_summary": dict(consensus.get("vote_summary", {})),
}
async def _resolve_meeting_without_consensus(
self,
meeting: MeetingRoom,
*,
consensus: dict[str, Any],
) -> MeetingRoom:
decision_policy = str(meeting.metadata.get("decision_policy", "") or "semantic_consensus_then_owner").strip()
if meeting.decision_owner and decision_policy in {
"semantic_consensus_then_owner",
"owner_override",
"owner_then_human",
"semantic_consensus_then_owner_then_vote",
}:
owner_response = await self._run_meeting_turn(
meeting,
meeting.decision_owner,
mode="decision_owner",
consensus=consensus,
)
owner_outcome = self._coerce_meeting_outcome(
owner_response,
decision_method="owner_override",
consensus=consensus,
)
if owner_outcome.get("decision"):
return await self._finalize_meeting(
meeting,
outcome=owner_outcome,
transcript_note=str(owner_outcome.get("decision", "") or owner_outcome.get("reasoning", "")).strip(),
)
if decision_policy in {
"semantic_consensus_then_owner_then_vote",
"semantic_consensus_then_vote",
"majority_vote",
}:
majority = self._majority_vote_outcome(meeting, consensus)
if majority is not None:
return await self._finalize_meeting(
meeting,
outcome=majority,
transcript_note=str(majority.get("decision", "") or majority.get("reasoning", "")).strip(),
)
escalation = self._human_escalation_outcome(meeting, consensus)
return await self._finalize_meeting(
meeting,
outcome=escalation,
transcript_note=str(escalation.get("reasoning", "")).strip(),
)
async def _advance_meeting(
self,
meeting: MeetingRoom,
*,
run_missing_participants: bool,
) -> tuple[MeetingRoom, bool]:
if meeting.status in {MeetingStatus.DECIDED, MeetingStatus.CLOSED, MeetingStatus.CANCELLED}:
return meeting, False
self._ensure_meeting_round_state(meeting)
changed = False
iteration_budget = max(1, int(meeting.max_rounds or 1)) + 1
while iteration_budget > 0:
iteration_budget -= 1
round_entries = self._meeting_round_entries(meeting, meeting.current_round)
responded = {
str(entry.get("agent", "")).strip()
for entry in round_entries
if str(entry.get("agent", "")).strip()
}
pending = [
participant
for participant in list(meeting.participants or [])
if str(participant).strip() and str(participant).strip() not in responded
]
if pending != list(meeting.pending_participants or []):
meeting.pending_participants = list(pending)
changed = True
if pending:
if not run_missing_participants:
meeting.updated_at = datetime.now()
await self.store.save_meeting(meeting)
return meeting, changed
for participant in pending:
content = await self._run_meeting_turn(
meeting,
participant,
mode="participant",
consensus=meeting.consensus,
)
self._append_meeting_participant_turn(
meeting,
from_agent=participant,
content=content,
source="meeting_runner",
round_no=meeting.current_round,
)
changed = True
round_entries = self._meeting_round_entries(meeting, meeting.current_round)
if not round_entries:
meeting.updated_at = datetime.now()
await self.store.save_meeting(meeting)
return meeting, changed
consensus = await self._judge_meeting_consensus(meeting, round_entries)
meeting.consensus = dict(consensus)
meeting.updated_at = datetime.now()
meeting.last_activity_at = datetime.now()
meeting.transcript.append(
{
"agent": "meeting_judge",
"content": json.dumps(consensus, ensure_ascii=False),
"timestamp": datetime.now().isoformat(),
"round": int(meeting.current_round or 0),
"entry_type": "judge_analysis",
"source": "meeting_judge",
}
)
changed = True
if bool(consensus.get("consensus_reached", False)):
semantic_outcome = {
"decision": str(consensus.get("dominant_proposal", "") or "").strip(),
"action_items": [
"Follow the meeting decision in downstream work.",
"Preserve the aligned rationale in the handoff.",
],
"reasoning": (
"Semantic consensus was reached across the participant positions and blocking conflicts were resolved."
),
"decision_method": "semantic_consensus",
"requires_human_input": False,
"follow_up_questions": list(consensus.get("open_questions", [])),
"consensus_summary": dict(consensus),
"aligned_points": list(consensus.get("aligned_points", [])),
"unresolved_items": list(consensus.get("blocking_conflicts", [])),
"vote_summary": dict(consensus.get("vote_summary", {})),
}
return await self._finalize_meeting(
meeting,
outcome=semantic_outcome,
transcript_note=str(semantic_outcome.get("decision", "")).strip(),
), True
deadline_reached = bool(meeting.deadline_at and datetime.now() >= meeting.deadline_at)
if meeting.current_round >= max(1, int(meeting.max_rounds or 1)) or deadline_reached:
return await self._resolve_meeting_without_consensus(meeting, consensus=consensus), True
next_speakers = [
participant
for participant in self._coerce_string_list(consensus.get("suggested_next_speakers"), limit=max(1, len(meeting.participants)))
if participant in meeting.participants
]
meeting.current_round += 1
meeting.pending_participants = next_speakers or list(meeting.participants)
meeting.status = MeetingStatus.IN_PROGRESS
meeting.updated_at = datetime.now()
meeting.last_activity_at = datetime.now()
await self.store.save_meeting(meeting)
if not run_missing_participants:
await self._dispatch_meeting_round_requests(
meeting,
participants=list(meeting.pending_participants),
consensus=consensus,
)
return meeting, True
await self.store.save_meeting(meeting)
return meeting, changed
await self.store.save_task(task)
def _mark_peer_wait(self, task: Task, message: AgentMessage, timeout_seconds: int = 300) -> dict[str, Any]:
task.status = TaskStatus.AWAITING_PEER
task.metadata = dict(task.metadata)
wait = {
"message_id": message.msg_id,
"waiting_on_agents": list(message.to_agents),
"timeout_action": message.timeout_action or "",
"timeout_at": (datetime.now() + timedelta(seconds=timeout_seconds)).isoformat(),
"kind": "peer_message",
}
task.metadata["peer_wait"] = wait
task.context_snapshot = dict(task.context_snapshot)
task.context_snapshot["pending_peer_message"] = self._serialize_message(message)
return wait
@staticmethod
def _task_dependency_keys(task: Task) -> set[str]:
keys = {str(task.id).strip()}
projection_id = projection_id_for_task(task)
if projection_id:
keys.add(projection_id)
return keys
@staticmethod
def _normalize_scope_ids(raw_ids: list[str] | None) -> list[str]:
return list(dict.fromkeys(str(item).strip() for item in list(raw_ids or []) if str(item).strip()))
def _task_scope_ids(self, task: Task | None = None, task_id: str | None = None, task_ids: list[str] | None = None) -> list[str]:
explicit = self._normalize_scope_ids(task_ids)
if explicit:
return explicit
if task is not None:
runtime_task_ids = self._normalize_scope_ids(task.metadata.get("execution_task_ids", []))
if runtime_task_ids:
return runtime_task_ids
if str(task.id).strip():
return [str(task.id).strip()]
return []
if task_id:
return [str(task_id).strip()]
return []
@staticmethod
def _role_id_for_task(task: Task) -> str:
return str(task.assigned_to or task.metadata.get("work_item_role_id", "") or "").strip()
@staticmethod
def _is_task_runnable(task: Task, task_by_key: dict[str, Task]) -> bool:
deps = [str(dep).strip() for dep in task.dependencies if str(dep).strip()]
if not deps:
return True
return all(
(dep_task := task_by_key.get(dep)) is not None and dep_task.status == TaskStatus.DONE
for dep in deps
)
# Standby-profile helpers (_build_standby_task_summary, _render_standby_profile,
# _build_role_standby_profile, _collect_standby_profiles) were removed along
# with the main-key "impersonation reply" path in a prior cleanup. Standby
# roles now wake up via CommsReactivationSweeper and reply through their
# own agents via the runtime mailbox, so no side-channel "rendered
# standby context" is needed.
async def _resolve_message_project_id(self, message: AgentMessage) -> str:
metadata_project = str(message.metadata.get("project_id", "") or "").strip()
if metadata_project:
return metadata_project
for ref in (message.task_id, message.context_ref):
ref_id = str(ref or "").strip()
if not ref_id:
continue
task = await self.store.get_task(ref_id)
if task and str(task.project_id or "").strip():
return str(task.project_id).strip()
return ""
# NOTE: ``_reply_from_standby_profiles`` and ``_maybe_auto_reply_to_message``
# used to synthesize a main-LLM "impersonation reply" whenever a blocking
# DM landed in a standby role's inbox. They are removed: standby roles now
# wake up via ``CommsReactivationSweeper`` (layer2_organization/
# reactivation_sweeper.py) and reply through their own external/native
# agent via the runtime mailbox. The role's own voice is authoritative,
# and the main API key is not burned on impersonation.
async def _validate_blocking_recipients_ready(self, task: Task, to_agents: list[str]) -> None:
"""Reject blocking waits on recipients that cannot reply yet.
This prevents a work item from pausing on a downstream role whose
task still depends on the current work item (or otherwise is not
runnable), which would deadlock the company runtime.
"""
project_id = str(getattr(task, "project_id", "") or "").strip()
recipients = {str(agent).strip() for agent in to_agents if str(agent).strip()}
work_item_turn_type = turn_type_for_task(task, fallback="")
work_item_runtime = is_work_item_runtime_metadata(task.metadata or {})
if not project_id or not recipients:
return
tasks = await self.store.get_tasks(project_id=project_id)
dependency_keys = self._task_dependency_keys(task)
task_by_key: dict[str, Task] = {}
tasks_by_role: dict[str, list[Task]] = {}
for candidate in tasks:
task_id = str(candidate.id).strip()
if task_id:
task_by_key[task_id] = candidate
projection_id = projection_id_for_task(candidate)
if projection_id:
task_by_key.setdefault(projection_id, candidate)
role_id = str(candidate.assigned_to or candidate.metadata.get("work_item_role_id", "") or "").strip()
if role_id:
tasks_by_role.setdefault(role_id, []).append(candidate)
blocked_reasons: list[str] = []
for role_id in sorted(recipients):
candidate_tasks = [
candidate
for candidate in tasks_by_role.get(role_id, [])
if str(candidate.id).strip() != str(task.id).strip()
]
if not candidate_tasks:
blocked_reasons.append(
f"{role_id} has no active work package yet"
+ (" during intake/dispatch startup" if work_item_turn_type in {"intake", "dispatch", "plan"} or work_item_runtime else "")
)
continue
role_is_ready = False
role_reasons: list[str] = []
for candidate in candidate_tasks:
deps = {str(dep).strip() for dep in candidate.dependencies if str(dep).strip()}
unmet = [
dep
for dep in deps
if (dep_task := task_by_key.get(dep)) is None or dep_task.status != TaskStatus.DONE
]
work_item_label = projection_id_for_task(candidate) or str(candidate.title or candidate.id).strip()
if dependency_keys & deps:
role_reasons.append(f"{role_id} ({work_item_label}) depends on the current work item")
continue
if candidate.status in {TaskStatus.PENDING, TaskStatus.BLOCKED} and unmet:
role_reasons.append(f"{role_id} ({work_item_label}) is not runnable yet")
continue
role_is_ready = True
break
if not role_is_ready and role_reasons:
blocked_reasons.append(role_reasons[0])
if blocked_reasons:
raise ValueError(
"Cannot use blocking peer wait on recipient(s) that cannot reply yet: "
+ "; ".join(blocked_reasons)
+ ". Use `blocking=false` or finish the current work item first."
)
# --- Mode 1: Direct Message ---
async def send_dm(self, message: AgentMessage, task: Task | None = None) -> AgentMessage:
active_task = task
if active_task is None and message.task_id:
active_task = await self.store.get_task(str(message.task_id))
context = CollaborationContext.from_task(active_task, role_id=message.from_agent)
return await self._collaboration_service().send_dm(context, message, task=active_task)
async def open_peer_wait(
self,
task: Task,
to_agents: list[str],
subject: str,
body: str,
timeout_action: str = "",
timeout_seconds: int = 300,
msg_type: str = "question",
) -> dict[str, Any]:
message = AgentMessage(
msg_type=msg_type,
from_agent=task.assigned_to,
to_agents=to_agents,
subject=subject,
body=body,
context_ref=task.id,
task_id=task.id,
urgency=MessageUrgency.BLOCKING,
reply_needed=True,
timeout_action=timeout_action,
metadata={
**work_item_identity_payload_for_task(task),
"async_mailbox": True,
"reply_requested": True,
"legacy_blocking_request": True,
"timeout_seconds": timeout_seconds,
"execution_task_ids": list(task.metadata.get("execution_task_ids", [])),
},
)
message = await self.send_dm(message, task=task)
return {
"requires_peer_wait": False,
"reason": (
f"Delivered async mailbox request to {', '.join(to_agents)}. "
"The sender continues working without pausing."
),
"message": self._serialize_message(message),
"delivery_mode": "async_mailbox",
"blocking_deprecated": True,
}
async def ask_peer_and_wait(
self,
task: Task,
to_agent: str,
subject: str,
body: str,
timeout_action: str = "",
timeout_seconds: int = 300,
on_timeout: str = "continue",
) -> dict[str, Any]:
context = CollaborationContext.from_task(task)
return await self._collaboration_service().ask_peer_and_wait(
context,
task=task,
to_agent=to_agent,
subject=subject,
body=body,
timeout_action=timeout_action,
timeout_seconds=timeout_seconds,
on_timeout=on_timeout,
)
async def wait_for_reply(
self,
agent_id: str,
original_msg_id: str,
timeout: float = 60.0,
) -> AgentMessage | None:
"""Wait for an in-memory reply to a specific message."""
queue = self._get_queue(agent_id)
deadline = asyncio.get_event_loop().time() + timeout
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return None
try:
msg = await asyncio.wait_for(queue.get(), timeout=remaining)
if msg.reply_to_msg_id == original_msg_id or msg.context_ref == original_msg_id:
return msg
queue.put_nowait(msg)
except asyncio.TimeoutError:
return None
async def read_inbox(
self,
agent_id: str,
task_id: str | None = None,
task_ids: list[str] | None = None,
task: Task | None = None,
unread_only: bool = True,
limit: int = 10,
mark_read: bool = True,
) -> list[dict[str, Any]]:
context = CollaborationContext.from_task(task, role_id=agent_id)
return await self._collaboration_service().read_inbox(
context,
agent_id=agent_id,
task_id=task_id,
task_ids=task_ids,
task=task,
unread_only=unread_only,
limit=limit,
mark_read=mark_read,
)
async def inbox(
self,
*,
agent_id: str,
task: Task | None = None,
action: str = "status",
message_ids: list[str] | None = None,
limit: int = 10,
) -> dict[str, Any]:
context = CollaborationContext.from_task(task, role_id=agent_id)
return await self._collaboration_service().inbox(
context,
agent_id=agent_id,
action=action,
message_ids=list(message_ids or []),
limit=limit,
task=task,
)
def format_live_inbox_interrupt(self, messages: list[dict[str, Any]]) -> str:
if not messages:
return ""
lines = [
"## Live Inbox Update",
"New direct messages arrived while you were working. They do not pause your task.",
"You may reply now with `reply_message`, acknowledge handled messages with `inbox(action=\"ack\")`, or continue working and respond later.",
"If you reply, use the exact `message_id` shown below.",
]
for item in messages[:6]:
msg_id = str(item.get("msg_id", "")).strip()
from_agent = str(item.get("from_agent", "")).strip()
subject = str(item.get("subject", "")).strip()
body = str(item.get("body", "")).strip()
reply_needed = bool(item.get("reply_needed", False))
urgency = str(item.get("urgency", "")).strip()
message_class = str(item.get("message_class", "") or dict(item.get("metadata", {}) or {}).get("message_class", "")).strip()
protocol_type = str(item.get("protocol_type", "") or dict(item.get("metadata", {}) or {}).get("protocol_type", "")).strip()
lines.append(
f"- message_id={msg_id} | from={from_agent} | urgency={urgency or 'normal'} | "
f"reply_needed={'yes' if reply_needed else 'no'}"
+ (f" | class={message_class}" if message_class else "")
+ (f" | protocol={protocol_type}" if protocol_type else "")
)
if subject:
lines.append(f" Subject: {subject}")
if body:
lines.append(f" Body: {body}")
return "\n".join(lines)
async def consume_live_inbox_messages(
self,
task: Task,
*,
agent_id: str | None = None,
limit: int = 6,
) -> list[dict[str, Any]]:
session_state = dict(task.metadata.get("member_session_state", {}) or task.context_snapshot.get("member_session", {}) or {})
if str(session_state.get("status", "") or "").strip().lower() == "cold":
return []
active_agent = str(agent_id or task.assigned_to or task.metadata.get("work_item_role_id", "") or "").strip()
if not active_agent:
return []
messages = await self.read_inbox(
agent_id=active_agent,
task=task,
unread_only=True,
limit=limit,
mark_read=False,
)
if not messages:
return []
classified = [classify_worker_message(dict(item)) for item in messages if isinstance(item, dict)]
actionable = [item for item in classified if worker_message_is_actionable(item)]
injected_ids = {
str(item).strip()
for item in list(task.context_snapshot.get("live_inbox_injected_message_ids", []) or [])
if str(item).strip()
}
fresh_actionable = [
item for item in actionable
if str(item.get("msg_id", "") or item.get("message_id", "") or "").strip()
and str(item.get("msg_id", "") or item.get("message_id", "") or "").strip() not in injected_ids
]
notifications = [item for item in classified if item.get("message_class") == "notification"]
task.context_snapshot = dict(task.context_snapshot)
live_history = list(task.context_snapshot.get("live_inbox_messages", []))
live_history.extend(fresh_actionable)
task.context_snapshot["live_inbox_messages"] = live_history[-10:]
if fresh_actionable:
task.context_snapshot["latest_live_inbox"] = fresh_actionable[-1]
injected_ids.update(
str(item.get("msg_id", "") or item.get("message_id", "") or "").strip()
for item in fresh_actionable
if str(item.get("msg_id", "") or item.get("message_id", "") or "").strip()
)
task.context_snapshot["live_inbox_injected_message_ids"] = sorted(injected_ids)[-50:]
if notifications:
task.context_snapshot["latest_live_notification"] = notifications[-1]
await self.store.save_task(task)
return fresh_actionable
async def reply_to_message(
self,
original_msg_id: str,
from_agent: str,
body: str,
subject: str = "",
task_id: str | None = None,
metadata: dict[str, Any] | None = None,
original_metadata_updates: dict[str, Any] | None = None,
) -> AgentMessage:
resolved_task_id = str(task_id or "").strip()
if not resolved_task_id and self.store and hasattr(self.store, "get_message"):
original = await self.store.get_message(original_msg_id)
if original is not None:
resolved_task_id = str(original.task_id or "").strip()
active_task = await self.store.get_task(resolved_task_id) if resolved_task_id else None
return await self._collaboration_service().reply_message(
CollaborationContext.from_task(active_task, role_id=from_agent),
original_msg_id=original_msg_id,
from_agent=from_agent,
body=body,
subject=subject,
task_id=resolved_task_id or task_id,
metadata=metadata,
original_metadata_updates=original_metadata_updates,
)
async def resolve_task_peer_wait(self, task: Task) -> bool:
wait = dict(task.metadata.get("peer_wait", {}))
if not wait:
return False
if str(wait.get("kind") or "") == "comms_blocking":
# File-comms blocking waits are owned by the company
# dispatcher's per-tick unpark; resolving them here would flip
# task.status without the work-item phase and strand the run.
return False
if wait.get("kind") != "meeting":
msg_id = wait.get("message_id")
reply = await self.store.get_latest_reply(msg_id) if msg_id else None
live_required = bool(wait.get("live_required", False))
if reply:
task.status = TaskStatus.PENDING
task.metadata = dict(task.metadata)
task.metadata.pop("peer_wait", None)
task.context_snapshot = dict(task.context_snapshot)
replies = list(task.context_snapshot.get("peer_replies", []))
replies.append(self._serialize_message(reply))
task.context_snapshot["peer_replies"] = replies[-10:]
task.context_snapshot["latest_peer_reply"] = self._serialize_message(reply)
reply_origin = str((reply.metadata or {}).get("reply_origin", "") or "live_reply").strip()
task.context_snapshot["peer_wait_released"] = {
"kind": "peer_message",
"released_at": datetime.now().isoformat(),
"reason": "Peer wait resolved by an available reply.",
"reply_origin": reply_origin,
}
await self.store.save_task(task)
if reply_origin != "live_reply":
await self._record_non_live_collaboration(
task,
origin=reply_origin,
summary=f"Peer wait on `{msg_id}` resumed with a non-live reply.",
)
return True
if not live_required:
task.status = TaskStatus.PENDING
task.metadata = dict(task.metadata)
task.metadata.pop("peer_wait", None)
task.context_snapshot = dict(task.context_snapshot)
task.context_snapshot["peer_wait_released"] = {
"kind": "peer_message",
"released_at": datetime.now().isoformat(),
"reason": "Peer messages are now asynchronous mailbox delivery and no longer pause tasks.",
}
await self.store.save_task(task)
return True
msg_id = wait.get("message_id")
if not msg_id:
return False
reply = await self.store.get_latest_reply(msg_id)
if reply:
task.status = TaskStatus.PENDING
task.metadata = dict(task.metadata)
task.metadata.pop("peer_wait", None)
task.context_snapshot = dict(task.context_snapshot)
replies = list(task.context_snapshot.get("peer_replies", []))
replies.append(self._serialize_message(reply))
task.context_snapshot["peer_replies"] = replies[-10:]
task.context_snapshot["latest_peer_reply"] = self._serialize_message(reply)
await self.store.save_task(task)
reply_origin = str((reply.metadata or {}).get("reply_origin", "") or "live_reply").strip()
if reply_origin != "live_reply":
await self._record_non_live_collaboration(
task,
origin=reply_origin,
summary=f"Meeting wait on `{msg_id}` resumed with a non-live reply.",
)
return True
# Previously this branch called ``_synthesize_reply_from_completed_agents``
# to burn the main LLM on an impersonation reply when all targets
# were DONE. That path is gone: ``CommsReactivationSweeper`` now
# re-opens the recipient tasks so their real agents reply via the
# runtime mailbox. The next tick of this resolver picks up the
# genuine reply via ``get_latest_reply`` above. If no reply ever
# arrives, the waiting task stays in AWAITING_PEER until the peer
# wait's configured timeout fires (handled elsewhere).
timeout_at = wait.get("timeout_at")
if timeout_at and datetime.now() >= datetime.fromisoformat(timeout_at):
timeout_mode = str(wait.get("on_timeout", "") or "continue").strip().lower() or "continue"
if timeout_mode == "manager":
return await self._escalate_peer_wait_to_manager(task, wait)
if timeout_mode == "meeting":
await self._escalate_peer_wait_to_meeting(task, wait)
return False
task.status = TaskStatus.PENDING
task.metadata = dict(task.metadata)
task.context_snapshot = dict(task.context_snapshot)
task.metadata.pop("peer_wait", None)
task.context_snapshot["peer_timeout_action"] = wait.get("timeout_action", "")
original = await self.store.get_message(msg_id)
if original:
original.status = MessageStatus.TIMED_OUT
original.processed_at = datetime.now()
await self.store.save_message(original)
await self.store.save_task(task)
return True
return False
async def _escalate_peer_wait_to_manager(self, task: Task, wait: dict[str, Any]) -> bool:
manager_role = str(task.metadata.get("manager_role_id", "") or "").strip()
if not manager_role and self.org_engine:
agent = self.org_engine.get_agent(str(task.assigned_to or task.metadata.get("work_item_role_id", "") or "").strip())
manager_role = str(getattr(agent, "reports_to", "") or "").strip()
if not manager_role:
task.status = TaskStatus.PENDING
task.metadata = dict(task.metadata)
task.metadata.pop("peer_wait", None)
task.context_snapshot = dict(task.context_snapshot)
task.context_snapshot["peer_timeout_action"] = wait.get("timeout_action", "")
task.context_snapshot["peer_timeout_escalation"] = {
"mode": "manager_unavailable",
"reason": "No manager role was available for escalation.",
}
await self.store.save_task(task)
return True
original_msg_id = str(wait.get("message_id", "") or "").strip()
summary = (
f"Peer wait timed out for work item `{projection_id_for_task(task)}`. "
f"Waiting on: {', '.join(wait.get('waiting_on_agents', [])) or '(unknown)'}. "
f"Default timeout action: {str(wait.get('timeout_action', '') or '(none)').strip()}."
)
escalation_message = AgentMessage(
msg_type="decision_needed",
from_agent=str(task.assigned_to or task.metadata.get("work_item_role_id", "") or "").strip(),
to_agents=[manager_role],
subject=f"Peer timeout escalation: {task.title}",
body=summary,
context_ref=task.id,
task_id=task.id,
urgency=MessageUrgency.HIGH,
metadata={
"peer_timeout_escalation": True,
"timed_out_message_id": original_msg_id,
"reply_origin": "live_request",
},
)
escalation_message = await self.send_dm(escalation_message, task=task)
task.status = TaskStatus.PENDING
task.metadata = dict(task.metadata)
task.metadata.pop("peer_wait", None)
task.context_snapshot = dict(task.context_snapshot)
task.context_snapshot["peer_timeout_action"] = wait.get("timeout_action", "")
task.context_snapshot["peer_timeout_escalation"] = {
"mode": "manager",
"manager_role_id": manager_role,
"message_id": escalation_message.msg_id,
"reason": summary,
}
await self.store.save_task(task)
return True
async def _escalate_peer_wait_to_meeting(self, task: Task, wait: dict[str, Any]) -> None:
manager_role = str(task.metadata.get("manager_role_id", "") or "").strip()
if not manager_role and self.org_engine:
agent = self.org_engine.get_agent(str(task.assigned_to or task.metadata.get("work_item_role_id", "") or "").strip())
manager_role = str(getattr(agent, "reports_to", "") or "").strip()
participants = [str(item).strip() for item in list(wait.get("waiting_on_agents", []) or []) if str(item).strip()]
if manager_role and manager_role not in participants and manager_role != str(task.assigned_to or "").strip():
participants.append(manager_role)
meeting = await self.create_meeting(
MeetingRoom(
task_id=task.id,
topic=f"Escalated peer wait: {task.title}",
participants=participants,
shared_context=(
f"Peer wait timed out. Previous timeout action: {str(wait.get('timeout_action', '') or '(none)').strip()}.\n"
f"Original waiting agents: {', '.join(wait.get('waiting_on_agents', [])) or '(unknown)'}"
),
agenda=[
"Resolve the blocked peer clarification",
"Decide whether the default timeout action is acceptable",
],
decision_owner=str(task.assigned_to or task.metadata.get("work_item_role_id", "coordinator") or "coordinator").strip(),
)
)
task.metadata = dict(task.metadata)
task.metadata["peer_wait"] = {
"kind": "meeting",
"meeting_room_id": meeting.room_id,
"waiting_on_agents": participants,
"escalated_from": dict(wait),
}
task.context_snapshot = dict(task.context_snapshot)
task.context_snapshot["peer_timeout_escalation"] = {
"mode": "meeting",
"meeting_room_id": meeting.room_id,
"participants": participants,
}
await self.store.save_task(task)
# The former DM auto-resolve helpers (``_check_targets_done`` and
# ``_synthesize_reply_from_completed_agents``) were removed along with
# ``_reply_from_standby_profiles``. Blocking peer waits now resolve only
# when a real agent replies — the sweeper in ``reactivation_sweeper.py``
# guarantees that standby recipients are re-activated in time.
async def refresh_waiting_tasks(self, tasks: list[Task]) -> list[Task]:
resumed: list[Task] = []
for task in tasks:
if task.status != TaskStatus.AWAITING_PEER:
continue
wait = dict(task.metadata.get("peer_wait", {}))
resolved = False
if wait.get("kind") == "meeting":
resolved = await self.resolve_task_meeting_wait(task)
else:
resolved = await self.resolve_task_peer_wait(task)
if resolved:
resumed.append(task)
return resumed
# --- Mode 2: Targeted Broadcast ---
async def broadcast(self, message: AgentMessage) -> AgentMessage:
"""Send a targeted broadcast to multiple agents."""
message.metadata = dict(message.metadata)
message.metadata["broadcast"] = True
return await self.send_dm(message)
# --- Mode 3: Meeting Room ---
async def create_meeting(self, meeting: MeetingRoom) -> MeetingRoom:
task_record = await self.store.get_task(meeting.task_id) if meeting.task_id else None
context = CollaborationContext.from_task(task_record, role_id=meeting.decision_owner)
return await self._collaboration_service().create_meeting(context, meeting)
async def respond_to_meeting(
self,
room_id: str,
from_agent: str,
content: str,
finalize: bool = False,
task: Task | None = None,
) -> MeetingRoom:
context = CollaborationContext.from_task(task, role_id=from_agent)
return await self._collaboration_service().respond_to_meeting(
context,
room_id=room_id,
from_agent=from_agent,
content=content,
finalize=finalize,
task=task,
)
async def open_meeting_wait(
self,
task: Task,
topic: str,
participants: list[str],
agenda: list[str],
shared_context: str = "",
decision_owner: str | None = None,
decision_policy: str = "semantic_consensus_then_owner",
timeout_seconds: int = 900,
risk_level: str = "normal",
) -> dict[str, Any]:
context = CollaborationContext.from_task(task)
return await self._collaboration_service().open_meeting_wait(
context,
task=task,
topic=topic,
participants=participants,
agenda=agenda,
shared_context=shared_context,
decision_owner=decision_owner,
decision_policy=decision_policy,
timeout_seconds=timeout_seconds,
risk_level=risk_level,
)
async def resolve_task_meeting_wait(self, task: Task) -> bool:
wait = dict(task.metadata.get("peer_wait", {}))
if wait.get("kind") != "meeting":
return False
room_id = wait.get("meeting_room_id")
if not room_id:
return False
meeting = await self.store.get_meeting(room_id)
if not meeting or meeting.status not in {MeetingStatus.DECIDED, MeetingStatus.CLOSED}:
return False
requires_human = bool((meeting.outcome or {}).get("requires_human_input", False)) or meeting.decision_method == "human_escalation"
task.status = TaskStatus.AWAITING_HUMAN if requires_human else TaskStatus.PENDING
task.metadata = dict(task.metadata)
task.metadata.pop("peer_wait", None)
task.context_snapshot = dict(task.context_snapshot)
task.context_snapshot["meeting_outcome"] = meeting.outcome or {}
task.context_snapshot["meeting_decision_method"] = meeting.decision_method
task.context_snapshot["meeting_consensus"] = dict(meeting.consensus or {})
outcome_origin = str((meeting.metadata or {}).get("outcome_origin", "") or "live_meeting").strip()
task.context_snapshot["meeting_outcome_origin"] = outcome_origin
if requires_human:
task.context_snapshot["meeting_requires_human_review"] = {
"room_id": meeting.room_id,
"reason": str((meeting.outcome or {}).get("reasoning", "") or "Human review is required before this work item can resume.").strip(),
}
await self.store.save_task(task)
if outcome_origin != "live_meeting":
await self._record_non_live_collaboration(
task,
origin=outcome_origin,
summary=f"Meeting `{room_id}` was resolved without a fully live discussion.",
)
return True
async def run_meeting(
self,
meeting: MeetingRoom,
agent_respond: Any = None,
) -> MeetingRoom:
"""Advance a meeting using independent participant turns."""
meeting.metadata = dict(meeting.metadata)
meeting.metadata.setdefault("outcome_origin", "autonomous_meeting")
progressed, _ = await self._advance_meeting(
meeting,
run_missing_participants=True,
)
return progressed
async def auto_resolve_stale_meetings(
self,
tasks: list[Task],
) -> list[str]:
"""Advance stalled meetings using independent participant turns."""
progressed_rooms: list[str] = []
for task in tasks:
if task.status != TaskStatus.AWAITING_PEER:
continue
wait = dict(task.metadata.get("peer_wait", {}))
if wait.get("kind") != "meeting":
continue
room_id = wait.get("meeting_room_id")
if not room_id:
continue
meeting = await self.store.get_meeting(room_id)
if not meeting or meeting.status in {MeetingStatus.DECIDED, MeetingStatus.CLOSED}:
continue
logger.info("Advancing meeting {} with autonomous participant turns", room_id)
before_status = meeting.status
before_round = meeting.current_round
before_outcome = dict(meeting.outcome or {})
updated = await self.run_meeting(meeting)
if updated.status in {MeetingStatus.DECIDED, MeetingStatus.CLOSED}:
await self.resolve_task_meeting_wait(task)
if (
updated.status != before_status
or updated.current_round != before_round
or dict(updated.outcome or {}) != before_outcome
):
progressed_rooms.append(room_id)
return progressed_rooms
async def propose_task_adjustment(
self,
*,
task: Task,
summary: str,
changeset: dict[str, Any],
) -> dict[str, Any]:
context = CollaborationContext.from_task(task)
return await self._collaboration_service().propose_task_adjustment(
context,
summary=summary,
changeset=changeset,
)
async def build_agent_context(self, agent_id: str, task: Task | None = None, task_id: str | None = None) -> dict[str, Any]:
active_task = task
if active_task is None and task_id:
active_task = await self.store.get_task(task_id)
if active_task is not None:
await self.rebuild_comms_projection(task=active_task)
inbox = await self.read_inbox(
agent_id=agent_id,
task=active_task,
task_id=task_id,
unread_only=False,
mark_read=False,
limit=10,
)
task_comments: list[dict[str, Any]] = []
if active_task:
task_comments = list(active_task.comments)
allowed_contacts: list[dict[str, Any]] = []
if self.org_engine and agent_id and hasattr(self.org_engine, "build_contact_directory"):
try:
allowed_contacts = self.org_engine.build_contact_directory(agent_id, task=active_task)
except TypeError:
allowed_contacts = self.org_engine.build_contact_directory(agent_id)
pending_handoffs: list[dict[str, Any]] = []
if active_task:
records = await self.store.get_handoff_records(
project_id=active_task.project_id,
task_id=active_task.id,
limit=10,
)
pending_handoffs = [
{
"handoff_id": record.handoff_id,
"from_role": record.from_role,
"summary": record.summary,
"status": record.status,
"requires_ack": record.requires_ack,
}
for record in records
if record.status not in {"accepted", "rejected"} and record.requires_ack
]
return {
"inbox": inbox,
"annotations": task_comments[-10:],
"allowed_contacts": allowed_contacts,
"pending_handoffs": pending_handoffs,
}
# --- Deadlock Detection ---
async def detect_deadlocks(self, project_id: str) -> list[tuple[str, str]]:
"""Detect circular waiting patterns across ALL peer wait types.
Covers meetings, DMs, comms-blocking, and dependency-based cycles
(not just meetings as before).
"""
tasks = await self.store.get_tasks(project_id=project_id)
waiting_for: dict[str, set[str]] = {}
# Build wait graph from all AWAITING_PEER tasks (all wait kinds)
for task in tasks:
if task.status != TaskStatus.AWAITING_PEER:
continue
role = task.assigned_to or task.metadata.get("work_item_role_id", "")
wait = dict(task.metadata.get("peer_wait", {}))
targets = set(wait.get("waiting_on_agents", []))
if role and targets:
waiting_for.setdefault(role, set()).update(targets)
# Also detect dependency-based deadlocks: two PENDING tasks whose deps
# form a cycle (shouldn't happen with correct DAG, but catch it)
task_by_id = {t.id: t for t in tasks}
for task in tasks:
if task.status != TaskStatus.PENDING:
continue
role = task.assigned_to or task.metadata.get("work_item_role_id", "")
if not role:
continue
for dep_id in (task.dependencies or []):
dep_task = task_by_id.get(dep_id)
if dep_task and dep_task.status == TaskStatus.PENDING:
dep_role = dep_task.assigned_to or dep_task.metadata.get("work_item_role_id", "")
if dep_role:
waiting_for.setdefault(role, set()).add(dep_role)
# DFS cycle detection (supports cycles of any length, not just pairs)
return self._find_wait_cycles(waiting_for)
@staticmethod
def _find_wait_cycles(graph: dict[str, set[str]]) -> list[tuple[str, str]]:
"""Find cycles in the wait-for graph using iterative DFS."""
visited: set[str] = set()
in_stack: set[str] = set()
cycles: list[tuple[str, str]] = []
seen_pairs: set[tuple[str, str]] = set()
for start in graph:
if start in visited:
continue
stack: list[tuple[str, list[str]]] = [(start, list(graph.get(start, set())))]
in_stack.add(start)
path: list[str] = [start]
while stack:
node, neighbors = stack[-1]
if neighbors:
nxt = neighbors.pop()
if nxt in in_stack:
# Found a cycle — record pairwise edges in the cycle
cycle_start_idx = path.index(nxt) if nxt in path else -1
if cycle_start_idx >= 0:
cycle_nodes = path[cycle_start_idx:]
for i, cn in enumerate(cycle_nodes):
nn = cycle_nodes[(i + 1) % len(cycle_nodes)]
pair = tuple(sorted((cn, nn)))
if pair not in seen_pairs:
seen_pairs.add(pair)
cycles.append(pair)
elif nxt not in visited and nxt in graph:
in_stack.add(nxt)
path.append(nxt)
stack.append((nxt, list(graph.get(nxt, set()))))
else:
stack.pop()
in_stack.discard(node)
if path and path[-1] == node:
path.pop()
visited.add(node)
return cycles