1485 lines
64 KiB
Python
1485 lines
64 KiB
Python
"""Shared collaboration service for native and external agent flows."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from opc.core.models import (
|
|
AgentMessage,
|
|
CommsSemanticType,
|
|
HandoffRecord,
|
|
MeetingRoom,
|
|
MeetingStatus,
|
|
MessageStatus,
|
|
MessageUrgency,
|
|
OPCEvent,
|
|
Task,
|
|
TaskStatus,
|
|
)
|
|
from opc.core.worker_envelope import classify_worker_message, worker_message_is_actionable
|
|
from opc.layer2_organization import comms as file_comms
|
|
from opc.layer2_organization.collaboration_policy import effective_contact_roles
|
|
from opc.layer2_organization.work_item_identity import work_item_identity_payload_for_task
|
|
|
|
|
|
class CommunicationDeliveryError(RuntimeError):
|
|
"""Raised when file-based collaboration delivery fails."""
|
|
|
|
def __init__(self, message: str, *, payload: dict[str, Any] | None = None) -> None:
|
|
super().__init__(message)
|
|
self.payload = dict(payload or {})
|
|
|
|
|
|
@dataclass
|
|
class CollaborationContext:
|
|
"""Resolved collaboration identity and workspace scope."""
|
|
|
|
role_id: str = ""
|
|
task: Task | None = None
|
|
task_id: str = ""
|
|
project_id: str = "default"
|
|
session_id: str = "default"
|
|
workspace_root: str = ""
|
|
output_root: str = ""
|
|
comms_root: str = ""
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def from_task(
|
|
cls,
|
|
task: Task | None,
|
|
*,
|
|
role_id: str = "",
|
|
) -> "CollaborationContext":
|
|
if task is None:
|
|
return cls(role_id=role_id)
|
|
metadata = dict(task.metadata or {})
|
|
workspace_root = (
|
|
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()
|
|
)
|
|
output_root = (
|
|
str(metadata.get("output_root", "") or "").strip()
|
|
or str(metadata.get("target_output_dir", "") or "").strip()
|
|
or workspace_root
|
|
)
|
|
comms_root = str(metadata.get("comms_root", "") or "").strip()
|
|
if not comms_root and workspace_root:
|
|
try:
|
|
comms_root = str(Path(workspace_root).expanduser().resolve() / ".opc-comms")
|
|
except Exception:
|
|
comms_root = ""
|
|
return cls(
|
|
role_id=str(
|
|
role_id
|
|
or task.assigned_to
|
|
or metadata.get("work_item_role_id", "")
|
|
or ""
|
|
).strip(),
|
|
task=task,
|
|
task_id=str(task.id or "").strip(),
|
|
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"
|
|
),
|
|
workspace_root=workspace_root,
|
|
output_root=output_root,
|
|
comms_root=comms_root,
|
|
metadata=metadata,
|
|
)
|
|
|
|
@classmethod
|
|
def from_environment(
|
|
cls,
|
|
*,
|
|
role_id: str,
|
|
project_id: str,
|
|
session_id: str,
|
|
workspace_root: str,
|
|
comms_root: str = "",
|
|
task: Task | None = None,
|
|
task_id: str = "",
|
|
) -> "CollaborationContext":
|
|
resolved_comms_root = str(comms_root or "").strip()
|
|
if not resolved_comms_root and workspace_root:
|
|
try:
|
|
resolved_comms_root = str(Path(workspace_root).expanduser().resolve() / ".opc-comms")
|
|
except Exception:
|
|
resolved_comms_root = ""
|
|
return cls(
|
|
role_id=str(role_id or "").strip(),
|
|
task=task,
|
|
task_id=str(task_id or getattr(task, "id", "") or "").strip(),
|
|
project_id=str(project_id or "default").strip() or "default",
|
|
session_id=str(session_id or "default").strip() or "default",
|
|
workspace_root=str(workspace_root or "").strip(),
|
|
output_root=str(workspace_root or "").strip(),
|
|
comms_root=resolved_comms_root,
|
|
metadata=dict(getattr(task, "metadata", {}) or {}),
|
|
)
|
|
|
|
|
|
class CollaborationService:
|
|
"""Shared collaboration semantics over the file-based comms substrate."""
|
|
|
|
def __init__(self, host: Any | None = None) -> None:
|
|
self.host = host
|
|
self.store = getattr(host, "store", None)
|
|
self.event_bus = getattr(host, "event_bus", None)
|
|
self.org_engine = getattr(host, "org_engine", None)
|
|
|
|
async def resolve_task(self, context: CollaborationContext) -> Task | None:
|
|
if context.task is not None:
|
|
return context.task
|
|
if not self.store or not context.task_id or not hasattr(self.store, "get_task"):
|
|
return None
|
|
task = await self.store.get_task(context.task_id)
|
|
if task is not None:
|
|
context.task = task
|
|
enriched = CollaborationContext.from_task(task, role_id=context.role_id)
|
|
context.role_id = context.role_id or enriched.role_id
|
|
context.project_id = enriched.project_id
|
|
context.session_id = enriched.session_id
|
|
context.workspace_root = enriched.workspace_root
|
|
context.output_root = enriched.output_root
|
|
context.comms_root = enriched.comms_root
|
|
context.metadata = dict(enriched.metadata)
|
|
return context.task
|
|
|
|
def _require_host(self) -> Any:
|
|
if self.host is None:
|
|
raise RuntimeError("CollaborationService host is required for this operation")
|
|
return self.host
|
|
|
|
def _layout(self, context: CollaborationContext, task: Task | None = None):
|
|
active_task = task or context.task
|
|
if active_task is not None and self.host is not None:
|
|
layout = self.host._task_comms_layout(active_task)
|
|
if layout is not None:
|
|
return layout
|
|
workspace_root = str(
|
|
context.workspace_root
|
|
or context.metadata.get("workspace_root", "")
|
|
or context.metadata.get("comms_workspace_root", "")
|
|
or context.metadata.get("output_root", "")
|
|
or context.metadata.get("target_output_dir", "")
|
|
or ""
|
|
).strip()
|
|
if not workspace_root:
|
|
return None
|
|
try:
|
|
return file_comms.resolve_layout(
|
|
workspace_root,
|
|
str(context.project_id or "default").strip() or "default",
|
|
str(context.session_id or "default").strip() or "default",
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
async def _record_failure(
|
|
self,
|
|
task: Task | None,
|
|
*,
|
|
operation: str,
|
|
from_role: str,
|
|
to_role: str,
|
|
reason: str,
|
|
attempted_path: str = "",
|
|
attempted_command: str = "",
|
|
) -> dict[str, Any]:
|
|
if self.host is not None and hasattr(self.host, "_record_comms_failure"):
|
|
return await self.host._record_comms_failure(
|
|
task,
|
|
operation=operation,
|
|
from_role=from_role,
|
|
to_role=to_role,
|
|
reason=reason,
|
|
attempted_path=attempted_path,
|
|
attempted_command=attempted_command,
|
|
)
|
|
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 not None:
|
|
task.metadata = dict(task.metadata or {})
|
|
task.context_snapshot = dict(task.context_snapshot or {})
|
|
failures = list(task.context_snapshot.get("comms_failures", []) or [])
|
|
failures.append(dict(payload))
|
|
task.context_snapshot["comms_failures"] = failures[-12:]
|
|
task.context_snapshot["latest_comms_failure"] = dict(payload)
|
|
task.metadata["comms_health"] = "degraded"
|
|
if self.store and hasattr(self.store, "save_task"):
|
|
await self.store.save_task(task)
|
|
return payload
|
|
|
|
async def _publish_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
if self.event_bus is None or not hasattr(self.event_bus, "publish"):
|
|
return
|
|
await self.event_bus.publish(OPCEvent(event_type=event_type, payload=payload))
|
|
|
|
def _validate_recipients(self, sender: str, recipients: list[str], task: Task | None = None) -> None:
|
|
if not sender:
|
|
return
|
|
allowed = set(effective_contact_roles(sender, task=task, org_engine=self.org_engine))
|
|
if not allowed:
|
|
return
|
|
invalid = [recipient for recipient in recipients if recipient and recipient != sender and recipient not in allowed]
|
|
if invalid:
|
|
raise ValueError(f"Role `{sender}` cannot message recipient(s): {', '.join(invalid)}")
|
|
|
|
def _canonicalize_message(self, message: AgentMessage, task: Task | None = None) -> AgentMessage:
|
|
host = self._require_host()
|
|
return host._canonicalize_message(message, task=task)
|
|
|
|
def _serialize_message(self, message: AgentMessage) -> dict[str, Any]:
|
|
host = self._require_host()
|
|
return host._serialize_message(message)
|
|
|
|
def _message_frontmatter(self, message: AgentMessage, task: Task | None = None) -> dict[str, Any]:
|
|
host = self._require_host()
|
|
return host._message_frontmatter(message, task=task)
|
|
|
|
async def send_dm(
|
|
self,
|
|
context: CollaborationContext,
|
|
message: AgentMessage,
|
|
*,
|
|
task: Task | None = None,
|
|
allow_policy_bypass: bool = False,
|
|
) -> AgentMessage:
|
|
host = self._require_host()
|
|
active_task = task or context.task
|
|
if active_task is None and message.task_id and self.store and hasattr(self.store, "get_task"):
|
|
active_task = await self.store.get_task(str(message.task_id))
|
|
message = self._canonicalize_message(message, task=active_task)
|
|
if (
|
|
str(getattr(message.transport_kind, "value", message.transport_kind) or "").strip().lower() != "system"
|
|
and not allow_policy_bypass
|
|
):
|
|
self._validate_recipients(message.from_agent, message.to_agents, task=active_task)
|
|
|
|
subject = (message.subject or "").strip()
|
|
body = (message.body or "").strip()
|
|
if not subject and not body:
|
|
failure = await self._record_failure(
|
|
active_task,
|
|
operation="send_dm",
|
|
from_role=message.from_agent,
|
|
to_role=",".join(message.to_agents),
|
|
reason="empty_message_rejected (subject and body are blank)",
|
|
)
|
|
raise CommunicationDeliveryError(
|
|
"Refusing to send a blank message. Include a concrete request, decision, update, or reply before sending.",
|
|
payload=failure,
|
|
)
|
|
|
|
layout = self._layout(context, active_task)
|
|
if layout is None:
|
|
delivered: list[AgentMessage] = []
|
|
for recipient in list(message.to_agents):
|
|
projected = AgentMessage(
|
|
msg_id=message.msg_id,
|
|
msg_type=message.msg_type,
|
|
from_agent=message.from_agent,
|
|
to_agents=[recipient],
|
|
subject=message.subject,
|
|
body=message.body,
|
|
context_ref=message.context_ref,
|
|
urgency=message.urgency,
|
|
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=MessageStatus.DELIVERED,
|
|
timestamp=message.timestamp,
|
|
transport_kind=message.transport_kind,
|
|
semantic_type=message.semantic_type,
|
|
comms_state=message.comms_state,
|
|
correlation_id=message.correlation_id,
|
|
refs=dict(message.refs),
|
|
metadata={
|
|
**dict(message.metadata or {}),
|
|
"projection_source": "store_fallback",
|
|
},
|
|
)
|
|
projected = self._canonicalize_message(projected, task=active_task)
|
|
if self.store and hasattr(self.store, "save_message"):
|
|
await self.store.save_message(projected)
|
|
if hasattr(host, "_get_queue"):
|
|
host._get_queue(recipient).put_nowait(projected)
|
|
delivered.append(projected)
|
|
if delivered:
|
|
primary = delivered[0]
|
|
primary.metadata = dict(primary.metadata)
|
|
primary.metadata["delivered_message_ids"] = [item.msg_id for item in delivered]
|
|
message = primary
|
|
await self._publish_event(
|
|
"agent_message_sent",
|
|
{
|
|
"msg_id": message.msg_id,
|
|
"from": message.from_agent,
|
|
"to": message.to_agents,
|
|
"type": message.msg_type,
|
|
"subject": message.subject,
|
|
"urgency": message.urgency.value,
|
|
"task_id": message.task_id,
|
|
"delivery": "store_fallback",
|
|
},
|
|
)
|
|
# No main-key LLM impersonation here. If the recipient is on
|
|
# standby (task DONE), CommsReactivationSweeper will re-open
|
|
# that task so the recipient's own agent reads the inbox and
|
|
# replies through the runtime mailbox. Senders that invoked
|
|
# `ask_peer_and_wait` unblock on the real reply (or timeout).
|
|
return message
|
|
try:
|
|
file_comms.ensure_layout(layout, [message.from_agent, *list(message.to_agents)])
|
|
except Exception as exc:
|
|
failure = await self._record_failure(
|
|
active_task,
|
|
operation="send_dm",
|
|
from_role=message.from_agent,
|
|
to_role=",".join(message.to_agents),
|
|
reason=str(exc),
|
|
attempted_path=str(layout.root),
|
|
)
|
|
raise CommunicationDeliveryError(str(exc), payload=failure) from exc
|
|
|
|
delivered: list[AgentMessage] = []
|
|
blocking = bool(message.reply_needed) and str((message.metadata or {}).get("wait_mode", "") or "").strip() == "ask_peer_and_wait"
|
|
message.metadata = dict(message.metadata)
|
|
message.metadata.setdefault("reply_origin", "live_request")
|
|
|
|
import hashlib as _hashlib
|
|
|
|
content_fp = _hashlib.sha1(
|
|
f"{message.from_agent}|{subject}|{body}".encode("utf-8", errors="replace")
|
|
).hexdigest()[:16]
|
|
|
|
for recipient in list(message.to_agents):
|
|
try:
|
|
frontmatter = {
|
|
**self._message_frontmatter(message, task=active_task),
|
|
"to_endpoint_type": host._infer_endpoint_type(recipient, message.metadata).value,
|
|
}
|
|
delivered_path = file_comms.send_message(
|
|
layout,
|
|
from_role=message.from_agent,
|
|
to_role=recipient,
|
|
subject=message.subject,
|
|
body=message.body,
|
|
blocking=blocking,
|
|
reply_to=message.reply_to_msg_id,
|
|
priority=message.urgency.value,
|
|
sent_at=message.timestamp.isoformat(),
|
|
idempotency_key=f"{recipient}:{content_fp}",
|
|
extra_frontmatter={
|
|
"task_id": message.task_id or "",
|
|
"context_ref": message.context_ref or "",
|
|
"requires_ack": bool(message.requires_ack),
|
|
"timeout_action": str(message.timeout_action or "").strip(),
|
|
"projection_source": "file_comms",
|
|
"content_fp": content_fp,
|
|
**frontmatter,
|
|
},
|
|
)
|
|
header = file_comms.read_header(delivered_path)
|
|
if header is None:
|
|
raise RuntimeError(f"Failed to read comms header for `{delivered_path}`")
|
|
projected = AgentMessage(
|
|
msg_id=header.message_id,
|
|
msg_type=message.msg_type,
|
|
from_agent=message.from_agent,
|
|
to_agents=[recipient],
|
|
subject=message.subject,
|
|
body=message.body,
|
|
context_ref=message.context_ref,
|
|
urgency=message.urgency,
|
|
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=MessageStatus.DELIVERED,
|
|
timestamp=message.timestamp,
|
|
transport_kind=message.transport_kind,
|
|
semantic_type=message.semantic_type,
|
|
comms_state=message.comms_state,
|
|
correlation_id=message.correlation_id,
|
|
refs=dict(message.refs),
|
|
metadata={
|
|
**dict(message.metadata),
|
|
"projection_source": "file_comms",
|
|
"comms_path": str(delivered_path),
|
|
},
|
|
)
|
|
projected = self._canonicalize_message(projected, task=active_task)
|
|
if self.store and hasattr(self.store, "save_message"):
|
|
await self.store.save_message(projected)
|
|
if hasattr(host, "_get_queue"):
|
|
host._get_queue(recipient).put_nowait(projected)
|
|
delivered.append(projected)
|
|
except Exception as exc:
|
|
failure = await self._record_failure(
|
|
active_task,
|
|
operation="send_dm",
|
|
from_role=message.from_agent,
|
|
to_role=recipient,
|
|
reason=str(exc),
|
|
attempted_path=str(layout.role_new_dir(recipient)),
|
|
)
|
|
raise CommunicationDeliveryError(str(exc), payload=failure) from exc
|
|
|
|
if delivered:
|
|
primary = delivered[0]
|
|
primary.metadata = dict(primary.metadata)
|
|
primary.metadata["delivered_message_ids"] = [item.msg_id for item in delivered]
|
|
message = primary
|
|
await self._publish_event(
|
|
"agent_message_sent",
|
|
{
|
|
"msg_id": message.msg_id,
|
|
"from": message.from_agent,
|
|
"to": message.to_agents,
|
|
"type": message.msg_type,
|
|
"subject": message.subject,
|
|
"urgency": message.urgency.value,
|
|
"task_id": message.task_id,
|
|
},
|
|
)
|
|
# No main-key LLM impersonation here either. Blocking DMs whose
|
|
# recipient is on standby wake up via CommsReactivationSweeper
|
|
# so the real agent can reply from its own session.
|
|
return message
|
|
|
|
async def ask_peer_and_wait(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
task: Task,
|
|
to_agent: str,
|
|
subject: str,
|
|
body: str,
|
|
timeout_action: str = "",
|
|
timeout_seconds: int = 300,
|
|
on_timeout: str = "continue",
|
|
) -> dict[str, Any]:
|
|
target = str(to_agent or "").strip()
|
|
if not target:
|
|
raise ValueError("ask_peer_and_wait requires a recipient")
|
|
host = self._require_host()
|
|
await host._validate_blocking_recipients_ready(task, [target])
|
|
message = AgentMessage(
|
|
msg_type="question",
|
|
from_agent=task.assigned_to,
|
|
to_agents=[target],
|
|
subject=subject,
|
|
body=body,
|
|
context_ref=task.id,
|
|
task_id=task.id,
|
|
urgency=MessageUrgency.BLOCKING,
|
|
reply_needed=True,
|
|
semantic_type=CommsSemanticType.BLOCKED_ON_DECISION,
|
|
metadata={
|
|
**work_item_identity_payload_for_task(task),
|
|
"async_mailbox": False,
|
|
"reply_requested": True,
|
|
"wait_mode": "ask_peer_and_wait",
|
|
"reply_origin": "live_request",
|
|
"timeout_seconds": timeout_seconds,
|
|
"execution_task_ids": list(task.metadata.get("execution_task_ids", [])),
|
|
},
|
|
)
|
|
message = await self.send_dm(context, message, task=task)
|
|
timeout_mode = str(on_timeout or "continue").strip().lower() or "continue"
|
|
task.status = TaskStatus.AWAITING_PEER
|
|
task.metadata = dict(task.metadata)
|
|
task.metadata["peer_wait"] = {
|
|
"kind": "peer_message",
|
|
"message_id": message.msg_id,
|
|
"waiting_on_agents": [target],
|
|
"timeout_action": timeout_action,
|
|
"timeout_at": (datetime.now() + timedelta(seconds=max(1, int(timeout_seconds or 1)))).isoformat(),
|
|
"on_timeout": timeout_mode,
|
|
"live_required": True,
|
|
"reply_origin_required": "live_reply",
|
|
}
|
|
task.context_snapshot = dict(task.context_snapshot)
|
|
task.context_snapshot["pending_peer_message"] = self._serialize_message(message)
|
|
if self.store and hasattr(self.store, "save_task"):
|
|
await self.store.save_task(task)
|
|
return {
|
|
"requires_peer_wait": True,
|
|
"reason": f"Waiting for a lightweight peer reply from `{target}`.",
|
|
"message": self._serialize_message(message),
|
|
"peer_wait": dict(task.metadata["peer_wait"]),
|
|
}
|
|
|
|
async def _generic_read_inbox(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
role_id: str,
|
|
limit: int,
|
|
archive: bool,
|
|
) -> list[dict[str, Any]]:
|
|
layout = self._layout(context, context.task)
|
|
if layout is None:
|
|
return []
|
|
active_seat_id = str((getattr(context.task, "metadata", {}) or {}).get("delegation_seat_id", "") or "").strip()
|
|
headers = file_comms.list_unread(layout, role_id, limit=limit)
|
|
messages: list[dict[str, Any]] = []
|
|
paths_to_mark: list[Path] = []
|
|
for header in headers:
|
|
target_seat_id = str((header.raw_frontmatter or {}).get("target_seat_id", "") or "").strip()
|
|
if active_seat_id and target_seat_id and target_seat_id != active_seat_id:
|
|
continue
|
|
_, body = file_comms.read_message(header.path)
|
|
item = classify_worker_message(
|
|
{
|
|
"msg_id": str(header.message_id or "").strip(),
|
|
"message_id": str(header.message_id or "").strip(),
|
|
"from_agent": str(header.from_role or "").strip(),
|
|
"to_agent": str(header.to_role or "").strip(),
|
|
"from": str(header.from_role or "").strip(),
|
|
"subject": str(header.subject or "").strip(),
|
|
"body": str(body or "").strip(),
|
|
"reply_needed": bool(header.blocking),
|
|
"requires_ack": bool((header.raw_frontmatter or {}).get("requires_ack", False)),
|
|
"urgency": str(header.priority or "").strip() or "normal",
|
|
"transport_kind": str(header.raw_frontmatter.get("transport_kind", "") or "").strip(),
|
|
"semantic_type": str(
|
|
header.raw_frontmatter.get("semantic_type")
|
|
or header.raw_frontmatter.get("kind")
|
|
or ""
|
|
).strip(),
|
|
"metadata": {
|
|
**dict(header.raw_frontmatter or {}),
|
|
"comms_path": str(header.path),
|
|
},
|
|
"status": MessageStatus.DELIVERED.value,
|
|
}
|
|
)
|
|
messages.append(item)
|
|
paths_to_mark.append(header.path)
|
|
if archive and paths_to_mark:
|
|
moved = file_comms.mark_seen(layout, role_id, paths_to_mark)
|
|
moved_names = {path.name for path in moved}
|
|
for item in messages:
|
|
path = str((item.get("metadata", {}) or {}).get("comms_path", "") or "").strip()
|
|
if not path:
|
|
continue
|
|
if Path(path).name in moved_names:
|
|
item["status"] = MessageStatus.READ.value
|
|
return messages
|
|
|
|
async def read_inbox(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
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]]:
|
|
active_task = task or context.task
|
|
scope_ids = []
|
|
host = self.host
|
|
if host is not None and hasattr(host, "_task_scope_ids"):
|
|
scope_ids = host._task_scope_ids(task=task, task_id=task_id, task_ids=task_ids)
|
|
if active_task is None and task_id and self.store and hasattr(self.store, "get_task"):
|
|
active_task = await self.store.get_task(task_id)
|
|
if active_task is None and scope_ids and self.store and hasattr(self.store, "get_task"):
|
|
for scope_id in scope_ids:
|
|
active_task = await self.store.get_task(scope_id)
|
|
if active_task is not None:
|
|
break
|
|
if active_task is not None:
|
|
context.task = active_task
|
|
context = CollaborationContext.from_task(active_task, role_id=context.role_id or agent_id)
|
|
layout = self._layout(context, active_task)
|
|
if host is not None and layout is not None and active_task is not None and hasattr(host, "_project_comms_messages"):
|
|
await host.rebuild_comms_projection(task=active_task, layout=layout)
|
|
messages = await host._project_comms_messages(
|
|
layout,
|
|
role_id=agent_id,
|
|
task=active_task,
|
|
unread_only=unread_only,
|
|
limit=limit,
|
|
mark_read=mark_read,
|
|
)
|
|
if mark_read:
|
|
for item in messages:
|
|
handoff_id = str((item.get("metadata", {}) or {}).get("handoff_id", "") or "").strip()
|
|
if handoff_id and bool(item.get("requires_ack", False)) and self.store and hasattr(self.store, "update_handoff_record"):
|
|
await self.store.update_handoff_record(
|
|
handoff_id,
|
|
status="received",
|
|
received_at=datetime.now(),
|
|
)
|
|
if self.store and hasattr(self.store, "get_handoff_records"):
|
|
sent_handoffs = await self.store.get_handoff_records(
|
|
project_id=str(active_task.project_id or "default").strip() or "default",
|
|
task_id=active_task.id,
|
|
status="sent",
|
|
limit=50,
|
|
)
|
|
for record in sent_handoffs:
|
|
if str(record.to_role or "").strip() != str(agent_id or "").strip():
|
|
continue
|
|
if not bool(record.requires_ack):
|
|
continue
|
|
await self.store.update_handoff_record(
|
|
record.handoff_id,
|
|
status="received",
|
|
received_at=datetime.now(),
|
|
)
|
|
return messages
|
|
if layout is not None and unread_only:
|
|
return await self._generic_read_inbox(
|
|
context,
|
|
role_id=agent_id,
|
|
limit=limit,
|
|
archive=mark_read,
|
|
)
|
|
if self.store and hasattr(self.store, "get_messages_for_agent"):
|
|
messages = await self.store.get_messages_for_agent(
|
|
agent_id=agent_id,
|
|
task_id=task_id,
|
|
task_ids=scope_ids,
|
|
unread_only=unread_only,
|
|
limit=limit,
|
|
)
|
|
if mark_read:
|
|
for message in messages:
|
|
if message.status in {MessageStatus.SENT, MessageStatus.DELIVERED}:
|
|
message.status = MessageStatus.READ
|
|
message.processed_at = datetime.now()
|
|
if hasattr(self.store, "save_message"):
|
|
await self.store.save_message(message)
|
|
if active_task is not None and hasattr(self.store, "get_handoff_records"):
|
|
sent_handoffs = await self.store.get_handoff_records(
|
|
project_id=str(active_task.project_id or "default").strip() or "default",
|
|
task_id=active_task.id,
|
|
status="sent",
|
|
limit=50,
|
|
)
|
|
for record in sent_handoffs:
|
|
if str(record.to_role or "").strip() != str(agent_id or "").strip():
|
|
continue
|
|
if not bool(record.requires_ack):
|
|
continue
|
|
await self.store.update_handoff_record(
|
|
record.handoff_id,
|
|
status="received",
|
|
received_at=datetime.now(),
|
|
)
|
|
return [self._serialize_message(message) for message in messages]
|
|
return []
|
|
|
|
@staticmethod
|
|
def _message_id(item: dict[str, Any]) -> str:
|
|
return str(item.get("msg_id", "") or item.get("message_id", "") or "").strip()
|
|
|
|
@staticmethod
|
|
def _inbox_summary_payload(message: dict[str, Any]) -> dict[str, Any]:
|
|
metadata = dict(message.get("metadata", {}) or {})
|
|
return {
|
|
"msg_id": CollaborationService._message_id(message),
|
|
"from_agent": str(message.get("from_agent", "") or message.get("from", "")).strip(),
|
|
"subject": str(message.get("subject", "") or "").strip(),
|
|
"reply_needed": bool(message.get("reply_needed", False)),
|
|
"requires_ack": bool(message.get("requires_ack", False)),
|
|
"urgency": str(message.get("urgency", "") or "normal").strip() or "normal",
|
|
"message_class": str(message.get("message_class", "") or metadata.get("message_class", "") or "").strip(),
|
|
"protocol_type": str(message.get("protocol_type", "") or metadata.get("protocol_type", "") or "").strip(),
|
|
"notification_kind": str(message.get("notification_kind", "") or metadata.get("notification_kind", "") or "").strip(),
|
|
"transport_kind": str(message.get("transport_kind", "") or metadata.get("transport_kind", "") or "").strip(),
|
|
"semantic_type": str(message.get("semantic_type", "") or metadata.get("semantic_type", "") or "").strip(),
|
|
}
|
|
|
|
@staticmethod
|
|
def _inbox_message_payload(message: dict[str, Any]) -> dict[str, Any]:
|
|
payload = CollaborationService._inbox_summary_payload(message)
|
|
payload["body"] = str(message.get("body", "") or "").strip()
|
|
payload["status"] = str(message.get("status", "") or "").strip()
|
|
payload["metadata"] = dict(message.get("metadata", {}) or {})
|
|
return payload
|
|
|
|
async def ack_inbox_messages(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
agent_id: str,
|
|
message_ids: list[str],
|
|
task: Task | None = None,
|
|
ignore_active_seat: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Explicitly mark selected messages as handled for one role."""
|
|
active_task = task or context.task
|
|
if active_task is not None:
|
|
context = CollaborationContext.from_task(active_task, role_id=context.role_id or agent_id)
|
|
target_ids = {
|
|
str(item).strip()
|
|
for item in list(message_ids or [])
|
|
if str(item).strip()
|
|
}
|
|
if not target_ids:
|
|
return {"acked": [], "already_seen": [], "missing": [], "count": 0}
|
|
|
|
acked: set[str] = set()
|
|
already_seen: set[str] = set()
|
|
layout = self._layout(context, active_task)
|
|
if layout is not None:
|
|
active_seat_id = str((getattr(active_task, "metadata", {}) or {}).get("delegation_seat_id", "") or "").strip()
|
|
unread_headers = file_comms.list_unread(layout, agent_id)
|
|
paths_to_mark: list[Path] = []
|
|
header_by_name: dict[str, Any] = {}
|
|
for header in unread_headers:
|
|
msg_id = str(header.message_id or "").strip()
|
|
if msg_id not in target_ids:
|
|
continue
|
|
target_seat_id = str((header.raw_frontmatter or {}).get("target_seat_id", "") or "").strip()
|
|
if not ignore_active_seat and active_seat_id and target_seat_id and target_seat_id != active_seat_id:
|
|
continue
|
|
paths_to_mark.append(header.path)
|
|
header_by_name[header.path.name] = header
|
|
moved = file_comms.mark_seen(layout, agent_id, paths_to_mark) if paths_to_mark else []
|
|
moved_names = {path.name for path in moved}
|
|
for name in moved_names:
|
|
header = header_by_name.get(name)
|
|
if header is not None:
|
|
acked.add(str(header.message_id or "").strip())
|
|
|
|
seen_headers = file_comms.list_role_messages(
|
|
layout,
|
|
agent_id,
|
|
include_new=False,
|
|
include_seen=True,
|
|
include_outbox=False,
|
|
)
|
|
for header in seen_headers:
|
|
msg_id = str(header.message_id or "").strip()
|
|
if msg_id in target_ids and msg_id not in acked:
|
|
already_seen.add(msg_id)
|
|
|
|
if self.store and hasattr(self.store, "get_message"):
|
|
for msg_id in sorted(target_ids):
|
|
try:
|
|
message = await self.store.get_message(msg_id)
|
|
except Exception:
|
|
message = None
|
|
if message is None:
|
|
continue
|
|
recipients = {str(item).strip() for item in list(message.to_agents or []) if str(item).strip()}
|
|
if str(agent_id or "").strip() not in recipients:
|
|
continue
|
|
if message.status in {MessageStatus.SENT, MessageStatus.DELIVERED}:
|
|
message.status = MessageStatus.READ
|
|
message.processed_at = datetime.now()
|
|
if hasattr(self.store, "save_message"):
|
|
await self.store.save_message(message)
|
|
acked.add(msg_id)
|
|
elif message.status in {MessageStatus.READ, MessageStatus.REPLIED}:
|
|
if msg_id not in acked:
|
|
already_seen.add(msg_id)
|
|
|
|
missing = sorted(target_ids - acked - already_seen)
|
|
return {
|
|
"acked": sorted(acked),
|
|
"already_seen": sorted(already_seen),
|
|
"missing": missing,
|
|
"count": len(acked),
|
|
}
|
|
|
|
@staticmethod
|
|
def _frontmatter_ref_values(frontmatter: dict[str, Any]) -> set[str]:
|
|
refs: set[str] = set()
|
|
for source in (
|
|
frontmatter,
|
|
dict(frontmatter.get("refs", {}) or {}),
|
|
dict(frontmatter.get("metadata", {}) or {}),
|
|
):
|
|
for key in (
|
|
"work_item_id",
|
|
"source_work_item_id",
|
|
"target_work_item_id",
|
|
"parent_work_item_id",
|
|
"projection_id",
|
|
"origin_projection_id",
|
|
"source_projection_id",
|
|
"target_projection_id",
|
|
"task_id",
|
|
"origin_task_id",
|
|
"context_ref",
|
|
):
|
|
value = str(source.get(key, "") or "").strip()
|
|
if value:
|
|
refs.add(value)
|
|
return refs
|
|
|
|
@staticmethod
|
|
def _frontmatter_semantic_type(frontmatter: dict[str, Any]) -> str:
|
|
return str(
|
|
frontmatter.get("semantic_type")
|
|
or frontmatter.get("kind")
|
|
or frontmatter.get("protocol_type")
|
|
or frontmatter.get("notification_kind")
|
|
or ""
|
|
).strip().lower()
|
|
|
|
async def ack_inbox_messages_by_refs(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
agent_id: str,
|
|
work_item_ids: list[str] | None = None,
|
|
projection_ids: list[str] | None = None,
|
|
task_ids: list[str] | None = None,
|
|
semantic_types: list[str] | None = None,
|
|
task: Task | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Mark lifecycle messages handled by role + work/projection/task refs.
|
|
|
|
This is an internal lifecycle cleanup path. It intentionally bypasses
|
|
the active-seat filter used by normal role tools, but it never leaves
|
|
the current role's inbox and only moves messages whose frontmatter refs
|
|
match the completed review/report work.
|
|
"""
|
|
active_task = task or context.task
|
|
if active_task is not None:
|
|
context = CollaborationContext.from_task(active_task, role_id=context.role_id or agent_id)
|
|
target_refs = {
|
|
str(item).strip()
|
|
for items in (work_item_ids or [], projection_ids or [], task_ids or [])
|
|
for item in list(items or [])
|
|
if str(item).strip()
|
|
}
|
|
allowed_semantics = {
|
|
str(item).strip().lower()
|
|
for item in list(semantic_types or [])
|
|
if str(item).strip()
|
|
}
|
|
if not target_refs:
|
|
return {"acked": [], "already_seen": [], "missing": [], "count": 0}
|
|
|
|
acked: set[str] = set()
|
|
layout = self._layout(context, active_task)
|
|
if layout is not None:
|
|
headers = file_comms.list_unread(layout, agent_id)
|
|
paths_to_mark: list[Path] = []
|
|
header_by_name: dict[str, Any] = {}
|
|
for header in headers:
|
|
frontmatter = dict(header.raw_frontmatter or {})
|
|
if allowed_semantics:
|
|
semantic = self._frontmatter_semantic_type(frontmatter)
|
|
protocol = str(frontmatter.get("protocol_type", "") or "").strip().lower()
|
|
notification = str(frontmatter.get("notification_kind", "") or "").strip().lower()
|
|
if not ({semantic, protocol, notification} & allowed_semantics):
|
|
continue
|
|
if not (self._frontmatter_ref_values(frontmatter) & target_refs):
|
|
continue
|
|
paths_to_mark.append(header.path)
|
|
header_by_name[header.path.name] = header
|
|
moved = file_comms.mark_seen(layout, agent_id, paths_to_mark) if paths_to_mark else []
|
|
for path in moved:
|
|
header = header_by_name.get(path.name)
|
|
if header is not None:
|
|
acked.add(str(header.message_id or "").strip())
|
|
|
|
if self.store and hasattr(self.store, "get_messages_for_agent") and hasattr(self.store, "save_message"):
|
|
try:
|
|
messages = await self.store.get_messages_for_agent(
|
|
agent_id=agent_id,
|
|
unread_only=True,
|
|
limit=200,
|
|
)
|
|
except Exception:
|
|
messages = []
|
|
for message in messages:
|
|
metadata = dict(getattr(message, "metadata", {}) or {})
|
|
message_refs = self._frontmatter_ref_values({
|
|
**metadata,
|
|
"task_id": getattr(message, "task_id", "") or "",
|
|
"context_ref": getattr(message, "context_ref", "") or "",
|
|
})
|
|
if not (message_refs & target_refs):
|
|
continue
|
|
semantic = str(getattr(getattr(message, "semantic_type", ""), "value", getattr(message, "semantic_type", "")) or "").strip().lower()
|
|
if allowed_semantics and semantic not in allowed_semantics and str(metadata.get("protocol_type", "") or "").strip().lower() not in allowed_semantics:
|
|
continue
|
|
if message.status in {MessageStatus.SENT, MessageStatus.DELIVERED}:
|
|
message.status = MessageStatus.READ
|
|
message.processed_at = datetime.now()
|
|
await self.store.save_message(message)
|
|
acked.add(str(message.msg_id or "").strip())
|
|
|
|
return {
|
|
"acked": sorted(item for item in acked if item),
|
|
"already_seen": [],
|
|
"missing": [],
|
|
"count": len([item for item in acked if item]),
|
|
}
|
|
|
|
async def inbox(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
agent_id: str,
|
|
action: str = "status",
|
|
message_ids: list[str] | None = None,
|
|
limit: int = 10,
|
|
task: Task | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Compact role mailbox surface: status, peek, or explicit ack."""
|
|
normalized_action = str(action or "status").strip().lower() or "status"
|
|
active_task = task or context.task
|
|
if active_task is not None:
|
|
context = CollaborationContext.from_task(active_task, role_id=context.role_id or agent_id)
|
|
if normalized_action == "ack":
|
|
ack = await self.ack_inbox_messages(
|
|
context,
|
|
agent_id=agent_id,
|
|
message_ids=list(message_ids or []),
|
|
task=active_task,
|
|
)
|
|
status = await self.inbox(
|
|
context,
|
|
agent_id=agent_id,
|
|
action="status",
|
|
limit=limit,
|
|
task=active_task,
|
|
)
|
|
return {"action": "ack", **ack, "status": status}
|
|
if normalized_action not in {"status", "peek"}:
|
|
raise ValueError("inbox action must be one of: status, peek, ack")
|
|
|
|
read_limit = max(1, int(limit or 10))
|
|
messages = await self.read_inbox(
|
|
context,
|
|
agent_id=agent_id,
|
|
task=active_task,
|
|
task_id=context.task_id or None,
|
|
unread_only=True,
|
|
limit=max(read_limit, 50 if normalized_action == "status" else read_limit),
|
|
mark_read=False,
|
|
)
|
|
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)]
|
|
blocking = [
|
|
item for item in actionable
|
|
if bool(item.get("reply_needed", False))
|
|
or str(item.get("urgency", "") or "").strip().lower() == MessageUrgency.BLOCKING.value
|
|
]
|
|
if normalized_action == "peek":
|
|
shown = actionable[:read_limit]
|
|
return {
|
|
"action": "peek",
|
|
"count": len(shown),
|
|
"unread_count": len(classified),
|
|
"actionable_count": len(actionable),
|
|
"blocking_count": len(blocking),
|
|
"messages": [self._inbox_message_payload(item) for item in shown],
|
|
}
|
|
summaries = [self._inbox_summary_payload(item) for item in actionable[: min(3, read_limit)]]
|
|
return {
|
|
"action": "status",
|
|
"unread_count": len(classified),
|
|
"actionable_count": len(actionable),
|
|
"blocking_count": len(blocking),
|
|
"has_actionable_unread": bool(actionable),
|
|
"latest_unread_summary": summaries,
|
|
}
|
|
|
|
async def prepare_inbox_for_resume(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
seen_ids: set[str],
|
|
limit: int = 6,
|
|
backlog_key: str = "broker_pending_inbox",
|
|
) -> list[dict[str, Any]]:
|
|
task = context.task or await self.resolve_task(context)
|
|
if task is None:
|
|
return []
|
|
role_id = str(
|
|
context.role_id
|
|
or task.assigned_to
|
|
or task.metadata.get("work_item_role_id", "")
|
|
or ""
|
|
).strip()
|
|
if not role_id:
|
|
return []
|
|
messages = await self.read_inbox(
|
|
CollaborationContext.from_task(task, role_id=role_id),
|
|
agent_id=role_id,
|
|
task=task,
|
|
unread_only=True,
|
|
limit=limit,
|
|
mark_read=False,
|
|
)
|
|
fresh = [
|
|
dict(item)
|
|
for item in messages
|
|
if str(item.get("msg_id", "")).strip()
|
|
and str(item.get("msg_id", "")).strip() not in seen_ids
|
|
]
|
|
if not fresh:
|
|
return []
|
|
for item in fresh:
|
|
seen_ids.add(str(item.get("msg_id", "")).strip())
|
|
task.context_snapshot = dict(task.context_snapshot)
|
|
backlog = [
|
|
dict(item)
|
|
for item in list(task.context_snapshot.get(backlog_key, []) or [])
|
|
if isinstance(item, dict)
|
|
]
|
|
backlog.extend(fresh)
|
|
task.context_snapshot[backlog_key] = backlog[-12:]
|
|
task.context_snapshot["latest_broker_inbox"] = fresh[-1]
|
|
if self.store and hasattr(self.store, "save_task"):
|
|
await self.store.save_task(task)
|
|
return fresh
|
|
|
|
def _find_inbox_message(
|
|
self,
|
|
context: CollaborationContext,
|
|
role_id: str,
|
|
message_id: str,
|
|
) -> tuple[Any | None, str]:
|
|
layout = self._layout(context, context.task)
|
|
if layout is None:
|
|
return None, ""
|
|
headers = file_comms.list_unread(layout, role_id)
|
|
headers += file_comms.list_role_messages(layout, role_id, include_new=False, include_seen=True)
|
|
for header in headers:
|
|
if str(header.message_id or "").strip() != str(message_id or "").strip():
|
|
continue
|
|
return file_comms.read_message(header.path)
|
|
return None, ""
|
|
|
|
async def reply_message(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
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:
|
|
original = None
|
|
if self.store and hasattr(self.store, "get_message"):
|
|
original = await self.store.get_message(original_msg_id)
|
|
header = None
|
|
original_body = ""
|
|
if original is None:
|
|
header, original_body = self._find_inbox_message(context, from_agent, original_msg_id)
|
|
if header is None:
|
|
raise ValueError(f"Original message `{original_msg_id}` not found")
|
|
if original is not None:
|
|
recipients = {str(item).strip() for item in list(original.to_agents or []) if str(item).strip()}
|
|
if from_agent not in recipients:
|
|
raise ValueError(f"Role `{from_agent}` cannot reply to message `{original_msg_id}`")
|
|
original.metadata = dict(original.metadata)
|
|
if original_metadata_updates:
|
|
original.metadata.update(dict(original_metadata_updates))
|
|
original_from = original.from_agent
|
|
original_subject = original.subject
|
|
original_context_ref = original.context_ref or original.task_id
|
|
original_task_id = task_id or original.task_id
|
|
reply_semantic_type = dict(original.metadata or {}).get("reply_semantic_type")
|
|
else:
|
|
if str(header.to_role or "").strip() != from_agent:
|
|
raise ValueError(f"Role `{from_agent}` cannot reply to message `{original_msg_id}`")
|
|
original_from = str(header.from_role or "").strip()
|
|
original_subject = str(header.subject or "").strip()
|
|
original_context_ref = context.task_id or None
|
|
original_task_id = task_id or context.task_id or None
|
|
reply_semantic_type = dict(header.raw_frontmatter or {}).get("reply_semantic_type")
|
|
reply_metadata = {"in_reply_to_subject": original_subject}
|
|
if metadata:
|
|
reply_metadata.update(dict(metadata))
|
|
reply_metadata.setdefault("reply_origin", "live_reply")
|
|
if original_body and "original_body" not in reply_metadata:
|
|
reply_metadata["original_body"] = original_body
|
|
host = self._require_host()
|
|
reply = AgentMessage(
|
|
msg_type="answer" if bool(getattr(original, "reply_needed", header.blocking if header else False)) else "inform",
|
|
from_agent=from_agent,
|
|
to_agents=[original_from],
|
|
subject=subject or f"Re: {original_subject}",
|
|
body=body,
|
|
context_ref=original_context_ref,
|
|
task_id=original_task_id,
|
|
urgency=MessageUrgency.NORMAL,
|
|
reply_to_msg_id=original_msg_id,
|
|
semantic_type=host._coerce_semantic_type(
|
|
reply_semantic_type,
|
|
fallback=CommsSemanticType.WORK_UPDATE,
|
|
),
|
|
metadata=reply_metadata,
|
|
)
|
|
reply = await self.send_dm(
|
|
context,
|
|
reply,
|
|
task=context.task,
|
|
allow_policy_bypass=True,
|
|
)
|
|
if original is not None:
|
|
original.status = MessageStatus.REPLIED
|
|
original.processed_at = datetime.now()
|
|
if self.store and hasattr(self.store, "save_message"):
|
|
await self.store.save_message(original)
|
|
await self.ack_inbox_messages(
|
|
context,
|
|
agent_id=from_agent,
|
|
message_ids=[original_msg_id],
|
|
task=context.task,
|
|
)
|
|
await self._publish_event(
|
|
"agent_message_replied",
|
|
{
|
|
"msg_id": original_msg_id,
|
|
"reply_msg_id": reply.msg_id,
|
|
"task_id": reply.task_id,
|
|
},
|
|
)
|
|
return reply
|
|
|
|
async def create_meeting(
|
|
self,
|
|
context: CollaborationContext,
|
|
meeting: MeetingRoom,
|
|
) -> MeetingRoom:
|
|
task_record = context.task
|
|
if task_record is None and meeting.task_id and self.store and hasattr(self.store, "get_task"):
|
|
task_record = await self.store.get_task(meeting.task_id)
|
|
layout = self._layout(context, task_record)
|
|
try:
|
|
if layout is not None:
|
|
file_comms.ensure_layout(layout, [meeting.decision_owner, *list(meeting.participants)])
|
|
state = file_comms.start_meeting(
|
|
layout,
|
|
meeting_id=meeting.room_id,
|
|
topic=meeting.topic,
|
|
organizer=meeting.decision_owner,
|
|
participants=meeting.participants,
|
|
extra={"task_id": meeting.task_id or ""},
|
|
)
|
|
meeting.room_id = state.meeting_id
|
|
except Exception as exc:
|
|
failure = await self._record_failure(
|
|
task_record,
|
|
operation="start_meeting",
|
|
from_role=meeting.decision_owner,
|
|
to_role=",".join(meeting.participants),
|
|
reason=str(exc),
|
|
attempted_path=str(layout.meeting_dir(meeting.room_id)) if layout is not None else "",
|
|
)
|
|
raise CommunicationDeliveryError(str(exc), payload=failure) from exc
|
|
meeting.status = MeetingStatus.OPEN
|
|
meeting.current_round = max(1, int(meeting.current_round or 1))
|
|
meeting.pending_participants = list(meeting.pending_participants or meeting.participants)
|
|
meeting.last_activity_at = datetime.now()
|
|
meeting.updated_at = datetime.now()
|
|
timeout_seconds = max(60, int(meeting.metadata.get("timeout_seconds", 900) or 900))
|
|
if meeting.deadline_at is None:
|
|
meeting.deadline_at = datetime.now() + timedelta(seconds=timeout_seconds)
|
|
if self.host is not None and hasattr(self.host, "_meetings"):
|
|
self.host._meetings[meeting.room_id] = meeting
|
|
if self.store and hasattr(self.store, "save_meeting"):
|
|
await self.store.save_meeting(meeting)
|
|
invite_body = (
|
|
f"{meeting.shared_context}\n\n"
|
|
f"Meeting room: {meeting.room_id}\n"
|
|
f"Topic: {meeting.topic}\n"
|
|
"Agenda:\n"
|
|
+ "\n".join(f"- {item}" for item in meeting.agenda)
|
|
+ "\n\nUse `respond_meeting` with a structured JSON stance when you are ready."
|
|
).strip()
|
|
invite_context = CollaborationContext.from_task(task_record, role_id=meeting.decision_owner)
|
|
for participant in meeting.participants:
|
|
invite = AgentMessage(
|
|
msg_type="decision_needed",
|
|
from_agent=meeting.decision_owner,
|
|
to_agents=[participant],
|
|
subject=f"Meeting Invite: {meeting.topic}",
|
|
body=invite_body,
|
|
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,
|
|
"decision_policy": str(meeting.metadata.get("decision_policy", "") or "semantic_consensus_then_owner"),
|
|
},
|
|
)
|
|
await self.send_dm(invite_context, invite, task=task_record)
|
|
await self._publish_event(
|
|
"meeting_started",
|
|
{
|
|
"room_id": meeting.room_id,
|
|
"task_id": meeting.task_id,
|
|
"topic": meeting.topic,
|
|
"participants": meeting.participants,
|
|
},
|
|
)
|
|
return meeting
|
|
|
|
async def respond_to_meeting(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
room_id: str,
|
|
from_agent: str,
|
|
content: str,
|
|
finalize: bool = False,
|
|
task: Task | None = None,
|
|
) -> MeetingRoom:
|
|
if self.store is None or not hasattr(self.store, "get_meeting"):
|
|
raise RuntimeError("Meetings require a task store")
|
|
host = self._require_host()
|
|
meeting = await self.store.get_meeting(room_id)
|
|
if not meeting:
|
|
raise ValueError(f"Meeting `{room_id}` not found")
|
|
task_record = task or (await self.store.get_task(meeting.task_id) if meeting.task_id and hasattr(self.store, "get_task") else None)
|
|
layout = self._layout(CollaborationContext.from_task(task_record, role_id=from_agent), task_record)
|
|
if finalize:
|
|
if str(from_agent).strip() != str(meeting.decision_owner).strip():
|
|
raise ValueError("Only the meeting decision owner can finalize a meeting directly.")
|
|
manual_outcome = host._coerce_meeting_outcome(
|
|
content,
|
|
decision_method="manual_owner_finalize",
|
|
consensus=meeting.consensus,
|
|
)
|
|
if not manual_outcome.get("decision") and not manual_outcome.get("requires_human_input", False):
|
|
manual_outcome["decision"] = str(content or "").strip()
|
|
meeting.metadata = dict(meeting.metadata)
|
|
meeting.metadata["outcome_origin"] = "live_meeting"
|
|
if layout is not None:
|
|
try:
|
|
file_comms.close_meeting(
|
|
layout,
|
|
meeting_id=meeting.room_id,
|
|
decision=str(manual_outcome.get("decision", "") or str(content or "").strip()),
|
|
closed_by=from_agent,
|
|
)
|
|
except Exception as exc:
|
|
failure = await self._record_failure(
|
|
task_record,
|
|
operation="close_meeting",
|
|
from_role=from_agent,
|
|
to_role=",".join(meeting.participants),
|
|
reason=str(exc),
|
|
attempted_path=str(layout.meeting_dir(meeting.room_id)),
|
|
)
|
|
raise CommunicationDeliveryError(str(exc), payload=failure) from exc
|
|
return await host._finalize_meeting(
|
|
meeting,
|
|
outcome=manual_outcome,
|
|
transcript_note=str(manual_outcome.get("decision", "") or manual_outcome.get("reasoning", "")).strip(),
|
|
)
|
|
if layout is not None:
|
|
try:
|
|
file_comms.append_to_transcript(
|
|
layout,
|
|
meeting_id=meeting.room_id,
|
|
author=from_agent,
|
|
content=content,
|
|
)
|
|
except Exception as exc:
|
|
failure = await self._record_failure(
|
|
task_record,
|
|
operation="respond_meeting",
|
|
from_role=from_agent,
|
|
to_role=meeting.decision_owner,
|
|
reason=str(exc),
|
|
attempted_path=str(layout.meeting_dir(meeting.room_id) / "transcript.md"),
|
|
)
|
|
raise CommunicationDeliveryError(str(exc), payload=failure) from exc
|
|
host._ensure_meeting_round_state(meeting)
|
|
host._append_meeting_participant_turn(
|
|
meeting,
|
|
from_agent=from_agent,
|
|
content=content,
|
|
source="live_agent",
|
|
round_no=meeting.current_round,
|
|
)
|
|
await self.store.save_meeting(meeting)
|
|
progressed, _ = await host._advance_meeting(
|
|
meeting,
|
|
run_missing_participants=False,
|
|
)
|
|
return progressed
|
|
|
|
async def open_meeting_wait(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
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]:
|
|
meeting = await self.create_meeting(
|
|
CollaborationContext.from_task(task, role_id=context.role_id),
|
|
MeetingRoom(
|
|
task_id=task.id,
|
|
topic=topic,
|
|
participants=participants,
|
|
shared_context=shared_context,
|
|
agenda=agenda,
|
|
decision_owner=decision_owner or task.assigned_to or task.metadata.get("work_item_role_id", "coordinator"),
|
|
metadata={
|
|
"decision_policy": decision_policy,
|
|
"timeout_seconds": max(60, int(timeout_seconds or 60)),
|
|
"risk_level": str(risk_level or "normal").strip() or "normal",
|
|
},
|
|
),
|
|
)
|
|
task.status = TaskStatus.AWAITING_PEER
|
|
task.metadata = dict(task.metadata)
|
|
task.metadata["peer_wait"] = {
|
|
"kind": "meeting",
|
|
"meeting_room_id": meeting.room_id,
|
|
"waiting_on_agents": list(participants),
|
|
}
|
|
task.context_snapshot = dict(task.context_snapshot)
|
|
task.context_snapshot["active_meeting"] = {
|
|
"room_id": meeting.room_id,
|
|
"topic": meeting.topic,
|
|
"agenda": list(meeting.agenda),
|
|
"decision_owner": meeting.decision_owner,
|
|
"decision_policy": meeting.metadata.get("decision_policy", decision_policy),
|
|
}
|
|
if self.store and hasattr(self.store, "save_task"):
|
|
await self.store.save_task(task)
|
|
return {
|
|
"requires_peer_wait": True,
|
|
"reason": f"Waiting for meeting `{meeting.topic}` outcome.",
|
|
"meeting_room_id": meeting.room_id,
|
|
"peer_wait": dict(task.metadata["peer_wait"]),
|
|
}
|
|
|
|
async def propose_task_adjustment(
|
|
self,
|
|
context: CollaborationContext,
|
|
*,
|
|
summary: str,
|
|
changeset: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
task = context.task or await self.resolve_task(context)
|
|
role_id = str(
|
|
context.role_id
|
|
or getattr(task, "assigned_to", "")
|
|
or (task.metadata.get("work_item_role_id", "") if task else "")
|
|
or ""
|
|
).strip()
|
|
if task is None or not role_id:
|
|
raise ValueError("propose_task_adjustment requires an active assigned task")
|
|
suggester = getattr(self.host, "task_adjustment_suggester", None) if self.host is not None else None
|
|
if suggester is None:
|
|
raise RuntimeError("Runtime replan support is not configured")
|
|
result = await suggester(
|
|
project_id=task.project_id,
|
|
source_role_id=role_id,
|
|
summary=summary,
|
|
changeset=changeset,
|
|
session_id=task.parent_session_id or task.session_id,
|
|
task_id=task.id,
|
|
)
|
|
proposal = result["proposal"]
|
|
if result.get("auto_applied"):
|
|
task.metadata = dict(task.metadata)
|
|
task.metadata["reorg_proposal_id"] = proposal.proposal_id
|
|
task.metadata.pop("pending_reorg_proposal_id", None)
|
|
task.metadata.pop("pending_reorg_scope", None)
|
|
if self.store and hasattr(self.store, "save_task"):
|
|
await self.store.save_task(task)
|
|
return {
|
|
"proposal_id": proposal.proposal_id,
|
|
"scope": proposal.scope.value,
|
|
"status": proposal.status.value,
|
|
"auto_applied": True,
|
|
"result": result.get("result", {}),
|
|
}
|
|
task.metadata = dict(task.metadata)
|
|
task.metadata["pending_reorg_proposal_id"] = proposal.proposal_id
|
|
task.metadata["pending_reorg_scope"] = proposal.scope.value
|
|
if self.store and hasattr(self.store, "save_task"):
|
|
await self.store.save_task(task)
|
|
return {
|
|
"proposal_id": proposal.proposal_id,
|
|
"scope": proposal.scope.value,
|
|
"status": proposal.status.value,
|
|
"auto_applied": False,
|
|
"requires_user_input": True,
|
|
"reason": (
|
|
f"Proposed runtime adjustment `{proposal.proposal_id}`. "
|
|
"Review the replan details and reply `approve` or `deny` to continue."
|
|
),
|
|
}
|
|
|
|
async def list_colleagues(self, context: CollaborationContext) -> dict[str, Any]:
|
|
task = context.task or await self.resolve_task(context)
|
|
if self.org_engine and context.role_id and hasattr(self.org_engine, "build_contact_directory"):
|
|
try:
|
|
contacts = self.org_engine.build_contact_directory(context.role_id, task=task)
|
|
except TypeError:
|
|
contacts = self.org_engine.build_contact_directory(context.role_id)
|
|
roles = [str(item.get("role_id", "") or "").strip() for item in contacts if str(item.get("role_id", "") or "").strip()]
|
|
return {"roles": roles, "self": context.role_id}
|
|
layout = self._layout(context, task)
|
|
if layout is None:
|
|
return {"roles": [], "self": context.role_id}
|
|
roles = [role for role in file_comms.list_roles(layout) if role != context.role_id]
|
|
return {"roles": roles, "self": context.role_id}
|
|
|
|
async def read_meeting(self, context: CollaborationContext, *, meeting_id: str) -> dict[str, Any]:
|
|
task = context.task or await self.resolve_task(context)
|
|
layout = self._layout(context, task)
|
|
if layout is None:
|
|
return {"error": "meeting layout unavailable"}
|
|
state = file_comms.read_meeting_state(layout, meeting_id)
|
|
if state is None:
|
|
return {"error": f"meeting {meeting_id} not found"}
|
|
entries = file_comms.read_transcript(layout, meeting_id)
|
|
return {
|
|
"meeting_id": state.meeting_id,
|
|
"topic": state.topic,
|
|
"status": state.status,
|
|
"participants": state.participants,
|
|
"entries": [
|
|
{"author": entry.author, "posted_at": entry.posted_at, "content": entry.content}
|
|
for entry in entries
|
|
],
|
|
}
|