600 lines
23 KiB
Python
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
|