5aa57e69ee
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>
2483 lines
108 KiB
Python
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
|