Files
OpenOPC/opc/layer4_tools/collaboration_dispatch.py
2026-07-01 17:56:31 +08:00

600 lines
23 KiB
Python

"""Runtime dispatcher for the ``opc-collab`` CLI."""
from __future__ import annotations
import json
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Optional
from opc.core.company_tools import (
COLLAB_PROFILE_DISABLED,
resolve_allowed_collaboration_tools,
resolve_task_collaboration_tools,
)
from opc.core.events import EventBus
from opc.core.models import AgentMessage, MessageUrgency
from opc.database.store import OPCStore
from opc.layer2_organization.collaboration_service import (
CollaborationContext,
CollaborationService,
)
from opc.layer2_organization.communication import CommunicationManager
from opc.layer2_organization.work_item_links import set_linked_work_item_id
from opc.layer4_tools.collaboration import create_collaboration_tools
Handler = Callable[[dict[str, Any], Optional[Mapping[str, str]]], Awaitable[dict[str, Any]]]
BoundHandler = Callable[[dict[str, Any], "CollaborationRuntimeBinding"], Awaitable[dict[str, Any]]]
@dataclass
class CollaborationRuntimeBinding:
service: CollaborationService
context: CollaborationContext
store: OPCStore | None
manager: CommunicationManager
env: Mapping[str, str] | None = None
allowed_tools: set[str] | None = None
owns_store: bool = False
def _env(name: str, default: str = "", env: Mapping[str, str] | None = None) -> str:
source = env if env is not None else os.environ
return str(source.get(name, default))
def _parse_allowed_tools(raw: str) -> set[str]:
payload = str(raw or "").strip()
if not payload:
return set()
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
parsed = [item.strip() for item in payload.split(",") if item.strip()]
if isinstance(parsed, list):
return {str(item).strip() for item in parsed if str(item).strip()}
return set()
async def build_collaboration_runtime() -> tuple[
CollaborationService,
CollaborationContext,
OPCStore | None,
CommunicationManager,
]:
return await build_collaboration_runtime_from_env(None)
async def build_collaboration_runtime_from_env(
env: Mapping[str, str] | None = None,
) -> tuple[
CollaborationService,
CollaborationContext,
OPCStore | None,
CommunicationManager,
]:
"""Build the collaboration service/context from the current process env."""
store: OPCStore | None = None
task = None
db_path = _env("OPC_PROJECT_DB_PATH", env=env)
task_id = _env("OPC_TASK_ID", env=env) or _env("OPC_RUNTIME_TASK_ID", env=env)
if db_path:
store = OPCStore(db_path)
await store.initialize(run_startup_maintenance=False)
manager = CommunicationManager(store, EventBus())
if task_id:
task = await store.get_task(task_id)
else:
manager = CommunicationManager(None, EventBus())
from_role = _env("OPC_COMMS_FROM", env=env)
context = (
CollaborationContext.from_task(task, role_id=from_role)
if task is not None
else CollaborationContext.from_environment(
role_id=from_role,
project_id=_env("OPC_COMMS_PROJECT", "default", env=env),
session_id=_env("OPC_COMMS_SESSION", "default", env=env),
workspace_root=_env("OPC_WORKSPACE_ROOT", env=env) or _env("OPC_COMMS_ROOT", env=env) or os.getcwd(),
task_id=task_id,
)
)
work_item_id = _env("OPC_WORK_ITEM_ID", env=env)
if work_item_id and task is not None:
set_linked_work_item_id(task, work_item_id)
if work_item_id and "linked_work_item_id" not in context.metadata:
context.metadata["linked_work_item_id"] = work_item_id
service = CollaborationService(manager)
return service, context, store, manager
async def build_collaboration_runtime_binding_from_env(
env: Mapping[str, str] | None = None,
) -> CollaborationRuntimeBinding:
service, context, store, manager = await build_collaboration_runtime_from_env(env)
return CollaborationRuntimeBinding(
service=service,
context=context,
store=store,
manager=manager,
env=env,
allowed_tools=allowed_tool_names(task=context.task, context=context, manager=manager, env=env),
owns_store=store is not None,
)
def allowed_tool_names(
*,
task: Any,
context: CollaborationContext,
manager: CommunicationManager,
env: Mapping[str, str] | None = None,
) -> set[str]:
explicit = _parse_allowed_tools(_env("OPC_ALLOWED_COLLAB_TOOLS", env=env))
if explicit:
return explicit
role_cfg = None
org_engine = getattr(manager, "org_engine", None)
if org_engine is not None and context.role_id:
try:
role_cfg = org_engine.get_agent(context.role_id)
except Exception:
role_cfg = None
profile = str(_env("OPC_COLLAB_PROFILE", env=env) or "").strip() or resolve_task_collaboration_tools(
task,
role=context.role_id,
seat=str(getattr(task, "metadata", {}).get("delegation_seat_id", "") or "").strip()
if task is not None
else "",
runtime_state={
"manager_board_summary": (
dict(getattr(task, "context_snapshot", {}).get("manager_board_summary", {}) or {})
if task is not None
else {}
),
},
role_cfg=role_cfg,
debug_admin=str(_env("OPC_MAILBOX_MODE", env=env) or "").strip().lower() == "debug_admin",
)[0]
if profile == COLLAB_PROFILE_DISABLED:
return set()
return resolve_allowed_collaboration_tools(profile, task=task, runtime_state={})
def _simple_message_payload(message: dict[str, Any]) -> dict[str, Any]:
metadata = dict(message.get("metadata", {}) or {})
return {
"msg_id": str(message.get("msg_id", "") or message.get("message_id", "")).strip(),
"from_agent": str(message.get("from_agent", "") or message.get("from", "")).strip(),
"subject": str(message.get("subject", "")).strip(),
"body": str(message.get("body", "")).strip(),
"reply_needed": bool(message.get("reply_needed", False)),
"urgency": str(message.get("urgency", "") or "normal").strip() or "normal",
"transport_kind": str(message.get("transport_kind", "") or metadata.get("transport_kind", "")).strip(),
"semantic_type": str(message.get("semantic_type", "") or metadata.get("semantic_type", "")).strip(),
"status": str(message.get("status", "") or "").strip(),
"metadata": metadata,
}
async def _run_bound_handler(
handler: BoundHandler,
args: dict[str, Any],
env: Mapping[str, str] | None = None,
) -> dict[str, Any]:
binding = await build_collaboration_runtime_binding_from_env(env)
try:
return await handler(args, binding)
finally:
if binding.owns_store and binding.store is not None:
await binding.store.close()
def _env_handler(handler: BoundHandler) -> Handler:
async def _handler(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(handler, args, env)
return _handler
async def _handle_inbox_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
service = binding.service
context = binding.context
if context.task is None:
return {"error": "`inbox` requires an active task context."}
raw_ids = args.get("message_ids", [])
if isinstance(raw_ids, str):
message_ids = [raw_ids]
else:
message_ids = [str(item).strip() for item in list(raw_ids or []) if str(item).strip()]
return await service.inbox(
context,
agent_id=context.role_id,
task=context.task,
action=str(args.get("action", "status") or "status").strip(),
message_ids=message_ids,
limit=int(args.get("limit", 10) or 10),
)
async def _handle_inbox(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_inbox_bound, args, env)
async def _handle_send_dm_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
if "blocking" in args:
return {"error": "`send_dm` no longer accepts `blocking`; use `ask_peer_and_wait`."}
context = binding.context
manager = binding.manager
tool = next(tool for tool in create_collaboration_tools(manager) if tool.name == "send_dm")
result = await tool.func(task=context.task, **dict(args or {}))
return result if isinstance(result, dict) else {"result": result}
async def _handle_send_dm(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_send_dm_bound, args, env)
async def _handle_ask_peer_and_wait_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
service = binding.service
context = binding.context
if context.task is None:
return {"error": "`ask_peer_and_wait` requires an active task context."}
return await service.ask_peer_and_wait(
context,
task=context.task,
to_agent=str(args.get("to_agent", "")).strip(),
subject=str(args.get("subject", "")).strip(),
body=str(args.get("body", "")).strip(),
timeout_action=str(args.get("timeout_action", "")).strip(),
timeout_seconds=int(args.get("timeout_seconds", 300) or 300),
on_timeout=str(args.get("on_timeout", "continue") or "continue").strip(),
)
async def _handle_ask_peer_and_wait(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_ask_peer_and_wait_bound, args, env)
async def _handle_read_inbox_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
if "mark_read" in args:
return {"error": "`read_inbox` no longer accepts `mark_read`; reads always archive to seen."}
service = binding.service
context = binding.context
messages = await service.read_inbox(
context,
agent_id=context.role_id,
task=context.task,
task_id=context.task_id or None,
unread_only=True,
limit=int(args.get("limit", 10) or 10),
mark_read=True,
)
return {
"count": len(messages),
"messages": [_simple_message_payload(message) for message in messages],
}
async def _handle_read_inbox(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_read_inbox_bound, args, env)
async def _handle_reply_message_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
service = binding.service
context = binding.context
reply = await service.reply_message(
context,
original_msg_id=str(args.get("message_id", "")).strip(),
from_agent=context.role_id,
body=str(args.get("body", "")).strip(),
subject=str(args.get("subject", "")).strip(),
task_id=context.task_id or None,
)
return {"delivered": True, "message": _simple_message_payload(service.host._serialize_message(reply))}
async def _handle_reply_message(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_reply_message_bound, args, env)
async def _handle_broadcast_issue_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
service = binding.service
context = binding.context
task = context.task
message = AgentMessage(
msg_type="flag_issue",
from_agent=context.role_id,
to_agents=[str(item).strip() for item in list(args.get("to_agents", []) or []) if str(item).strip()],
subject=str(args.get("subject", "")).strip(),
body=str(args.get("body", "")).strip(),
context_ref=getattr(task, "id", None),
task_id=getattr(task, "id", None),
urgency=MessageUrgency.HIGH,
metadata={
"broadcast": True,
"async_mailbox": True,
"reply_requested": False,
},
)
delivered = await service.send_dm(context, message, task=task)
return {"delivered": True, "message": _simple_message_payload(service.host._serialize_message(delivered))}
async def _handle_broadcast_issue(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_broadcast_issue_bound, args, env)
async def _handle_list_colleagues_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
_ = args
return await binding.service.list_colleagues(binding.context)
async def _handle_list_colleagues(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_list_colleagues_bound, args, env)
async def _handle_start_meeting_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
service = binding.service
context = binding.context
if context.task is None:
return {"error": "`start_meeting` requires an active task context."}
return await service.open_meeting_wait(
context,
task=context.task,
topic=str(args.get("topic", "")).strip(),
participants=[str(item).strip() for item in list(args.get("participants", []) or []) if str(item).strip()],
agenda=[str(item).strip() for item in list(args.get("agenda", []) or []) if str(item).strip()],
shared_context=str(args.get("shared_context", "")).strip(),
decision_owner=str(args.get("decision_owner", "")).strip() or None,
decision_policy=str(args.get("decision_policy", "semantic_consensus_then_owner") or "semantic_consensus_then_owner").strip(),
timeout_seconds=int(args.get("timeout_seconds", 900) or 900),
risk_level=str(args.get("risk_level", "normal") or "normal").strip(),
)
async def _handle_start_meeting(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_start_meeting_bound, args, env)
async def _handle_respond_meeting_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
service = binding.service
context = binding.context
meeting = await service.respond_to_meeting(
context,
room_id=str(args.get("meeting_id", "")).strip(),
from_agent=context.role_id,
content=str(args.get("content", "")).strip(),
finalize=bool(args.get("finalize", False)),
task=context.task,
)
return {
"meeting": {
"room_id": meeting.room_id,
"status": meeting.status.value,
"outcome": meeting.outcome,
}
}
async def _handle_respond_meeting(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_respond_meeting_bound, args, env)
async def _handle_read_meeting_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
return await binding.service.read_meeting(binding.context, meeting_id=str(args.get("meeting_id", "")).strip())
async def _handle_read_meeting(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_read_meeting_bound, args, env)
async def _handle_propose_task_adjustment_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
return await binding.service.propose_task_adjustment(
binding.context,
summary=str(args.get("summary", "")).strip(),
changeset=dict(args.get("changeset", {}) or {}),
)
async def _handle_propose_task_adjustment(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
return await _run_bound_handler(_handle_propose_task_adjustment_bound, args, env)
def _native_handler_bound(tool_name: str) -> BoundHandler:
async def _handler(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
native_defs = {tool.name: tool for tool in create_collaboration_tools(binding.manager)}
tool = native_defs.get(tool_name)
if tool is None:
return {"error": f"unknown tool: {tool_name}"}
tool_args = dict(args or {})
if tool_name == "respond_meeting" and "meeting_id" in tool_args and "room_id" not in tool_args:
tool_args["room_id"] = tool_args.pop("meeting_id")
result = await tool.func(task=binding.context.task, **tool_args)
return result if isinstance(result, dict) else {"result": result}
return _handler
def _native_handler(tool_name: str) -> Handler:
return _env_handler(_native_handler_bound(tool_name))
BOUND_HANDLERS: dict[str, BoundHandler] = {
"inbox": _handle_inbox_bound,
"send_dm": _handle_send_dm_bound,
"ask_peer_and_wait": _handle_ask_peer_and_wait_bound,
"request_user_input": _native_handler_bound("request_user_input"),
"read_inbox": _handle_read_inbox_bound,
"reply_message": _handle_reply_message_bound,
"broadcast_issue": _handle_broadcast_issue_bound,
"list_colleagues": _handle_list_colleagues_bound,
"start_meeting": _handle_start_meeting_bound,
"respond_meeting": _handle_respond_meeting_bound,
"read_meeting": _handle_read_meeting_bound,
"propose_task_adjustment": _handle_propose_task_adjustment_bound,
"route_work": _native_handler_bound("route_work"),
"close_human_review": _native_handler_bound("close_human_review"),
"delegate_work": _native_handler_bound("delegate_work"),
"modify_work_item": _native_handler_bound("modify_work_item"),
"delete_work_item": _native_handler_bound("delete_work_item"),
"manager_board_read": _native_handler_bound("manager_board_read"),
}
HANDLERS: dict[str, Handler] = {
name: _env_handler(handler)
for name, handler in BOUND_HANDLERS.items()
}
def _is_infrastructure_error_text(value: Any) -> bool:
text = str(value or "").strip().lower()
if not text:
return False
markers = (
"disk i/o error",
"database is locked",
"readonly database",
"unable to open database file",
"collaboration broker rpc",
"broker rpc",
"sqlite",
)
return any(marker in text for marker in markers)
def infrastructure_error_payload(error: Any, *, tool_name: str = "") -> dict[str, Any]:
payload = {
"error": str(error or "collaboration infrastructure error"),
"error_type": "infrastructure",
"retryable": True,
}
if tool_name:
payload["tool_name"] = tool_name
return payload
async def _mailbox_notice_bound(binding: CollaborationRuntimeBinding) -> dict[str, Any] | None:
service = binding.service
context = binding.context
if context.task is None or not context.role_id:
return None
status = await service.inbox(
context,
agent_id=context.role_id,
task=context.task,
action="status",
limit=3,
)
if not bool(status.get("has_actionable_unread", False)):
return None
return {
"has_actionable_unread": True,
"unread_count": int(status.get("unread_count", 0) or 0),
"actionable_count": int(status.get("actionable_count", 0) or 0),
"blocking_count": int(status.get("blocking_count", 0) or 0),
"latest_unread_summary": list(status.get("latest_unread_summary", []) or [])[:3],
"hint": "Call `opc-collab inbox --args-stdin` with JSON {\"action\":\"peek\"} to inspect, then reply or ack handled messages.",
}
async def _mailbox_notice(env: Mapping[str, str] | None = None) -> dict[str, Any] | None:
binding = await build_collaboration_runtime_binding_from_env(env)
try:
return await _mailbox_notice_bound(binding)
finally:
if binding.owns_store and binding.store is not None:
await binding.store.close()
async def _attach_mailbox_notice_bound(payload: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
try:
notice = await _mailbox_notice_bound(binding)
except Exception:
notice = None
if notice:
payload.setdefault("mailbox_notice", notice)
return payload
async def _attach_mailbox_notice(payload: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
binding = await build_collaboration_runtime_binding_from_env(env)
try:
return await _attach_mailbox_notice_bound(payload, binding)
finally:
if binding.owns_store and binding.store is not None:
await binding.store.close()
async def dispatch_collaboration_tool(
tool_name: str,
args: dict[str, Any],
*,
env: Mapping[str, str] | None = None,
) -> tuple[dict[str, Any], bool]:
"""Call one collaboration tool and return ``(result, is_error)``."""
binding = await build_collaboration_runtime_binding_from_env(env)
try:
return await dispatch_collaboration_tool_bound(tool_name, args, binding)
finally:
if binding.owns_store and binding.store is not None:
await binding.store.close()
async def dispatch_collaboration_tool_bound(
tool_name: str,
args: dict[str, Any],
binding: CollaborationRuntimeBinding,
) -> tuple[dict[str, Any], bool]:
"""Call one collaboration tool using an already-bound runtime/store."""
name = str(tool_name or "").strip()
handler = BOUND_HANDLERS.get(name)
if handler is None:
return await _attach_mailbox_notice_bound({"error": f"unknown tool: {name}"}, binding), True
allowed = binding.allowed_tools
if allowed is None:
allowed = allowed_tool_names(
task=binding.context.task,
context=binding.context,
manager=binding.manager,
env=binding.env,
)
if name not in allowed:
return (
await _attach_mailbox_notice_bound(
{
"error": (
f"tool `{name}` is not available for this run. "
f"Allowed tools: {', '.join(sorted(allowed)) or '(none)'}."
)
},
binding,
),
True,
)
try:
result = await handler(dict(args or {}), binding)
except Exception as exc:
payload = (
infrastructure_error_payload(exc, tool_name=name)
if _is_infrastructure_error_text(exc)
else {"error": str(exc)}
)
return await _attach_mailbox_notice_bound(payload, binding), True
normalized = result if isinstance(result, dict) else {"result": result}
if "error" in normalized and _is_infrastructure_error_text(normalized.get("error")):
normalized = {**normalized, **infrastructure_error_payload(normalized.get("error"), tool_name=name)}
normalized = await _attach_mailbox_notice_bound(normalized, binding)
return normalized, bool("error" in normalized)
# Backward-compatible names for tests and small internal call sites that used
# the old module-local helper spellings.
_build_runtime = build_collaboration_runtime
_allowed_tool_names = allowed_tool_names