Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,880 @@
|
||||
"""Base class for external agent adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from opc.core.config import ExternalAgentConfig
|
||||
|
||||
from opc.core.models import AgentStatus, Task, TaskResult
|
||||
|
||||
|
||||
_APPROVAL_MARKERS = (
|
||||
"approve",
|
||||
"approval",
|
||||
"allow",
|
||||
"permission",
|
||||
"authorize",
|
||||
"confirm",
|
||||
"[y/n]",
|
||||
"(y/n)",
|
||||
)
|
||||
_SHELL_WRAPPER_FLAGS = {"-c", "-lc", "-command", "/c", "/command"}
|
||||
_POSIX_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "fish"}
|
||||
_POWERSHELL_SHELLS = {"pwsh", "powershell"}
|
||||
_SHELL_TOOL_NAMES = {
|
||||
"bash",
|
||||
"bashtoolcall",
|
||||
"command",
|
||||
"commandexecution",
|
||||
"commandtoolcall",
|
||||
"exec",
|
||||
"execcommand",
|
||||
"execcommandtoolcall",
|
||||
"runcommand",
|
||||
"shell",
|
||||
"shellcommand",
|
||||
"shelltoolcall",
|
||||
"terminal",
|
||||
"terminalcommand",
|
||||
"terminaltoolcall",
|
||||
}
|
||||
ExternalAgentStdinPolicy = Literal[
|
||||
"inherit",
|
||||
"devnull",
|
||||
"pipe_open",
|
||||
"pipe_prompt_then_close",
|
||||
]
|
||||
_FULL_PROMPT_CONTRACT = "description_is_full_prompt"
|
||||
_FILE_EDIT_TOOL_NAMES = {
|
||||
"applypatch",
|
||||
"applypatchtoolcall",
|
||||
"edit",
|
||||
"editfile",
|
||||
"edittoolcall",
|
||||
"multiedit",
|
||||
"multiedittoolcall",
|
||||
"replace",
|
||||
"rewrite",
|
||||
}
|
||||
_FILE_WRITE_TOOL_NAMES = {
|
||||
"createfile",
|
||||
"filewrite",
|
||||
"newfile",
|
||||
"write",
|
||||
"writefile",
|
||||
"writetoolcall",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalApprovalRequest:
|
||||
"""Normalized approval request emitted by an external agent CLI."""
|
||||
|
||||
approval_scope: str
|
||||
action_name: str
|
||||
prompt_text: str = ""
|
||||
arguments: dict[str, Any] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
raw_text: str = ""
|
||||
|
||||
|
||||
class ExternalAgentAdapter(abc.ABC):
|
||||
"""Unified abstract interface for all external agents (Cursor, Claude Code, Codex, etc.)."""
|
||||
|
||||
agent_type: str = ""
|
||||
default_command: str = ""
|
||||
|
||||
def __init__(self, config: ExternalAgentConfig | None = None) -> None:
|
||||
self.config = config or ExternalAgentConfig(command=self.default_command)
|
||||
|
||||
def configured_command(self) -> str:
|
||||
return self.config.command or self.default_command
|
||||
|
||||
def resolve_binary(self) -> str | None:
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
return shutil.which(self.configured_command())
|
||||
|
||||
def is_new_session(self) -> bool:
|
||||
return self.config.session_mode == "new"
|
||||
|
||||
def build_common_args(self) -> list[str]:
|
||||
args: list[str] = []
|
||||
if self.config.model and self.config.model_flag:
|
||||
args.extend([self.config.model_flag, self.config.model])
|
||||
|
||||
if self.config.session_mode == "new" and self.config.new_session_flag:
|
||||
args.append(self.config.new_session_flag)
|
||||
elif self.config.session_mode == "resume" and self.config.resume_session_flag:
|
||||
args.append(self.config.resume_session_flag)
|
||||
if self.config.session_id:
|
||||
args.append(self.config.session_id)
|
||||
|
||||
args.extend(self.config.extra_args)
|
||||
return args
|
||||
|
||||
def build_invocation_metadata(self, cmd: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"agent": self.agent_type,
|
||||
"command": shlex.join(cmd),
|
||||
"display_command": self._display_command(cmd),
|
||||
"binary": self.configured_command(),
|
||||
"model": self.config.model or "(cli default)",
|
||||
"model_flag": self.config.model_flag or "",
|
||||
"session_mode": self.config.session_mode,
|
||||
"session_id": self.config.session_id or "",
|
||||
"new_session": self.is_new_session(),
|
||||
"run_mode": self.config.run_mode,
|
||||
"approval_mode": self.config.approval_mode,
|
||||
"idle_timeout_seconds": self.config.idle_timeout_seconds,
|
||||
"status_heartbeat_seconds": self.config.status_heartbeat_seconds,
|
||||
"extra_args": list(self.config.extra_args),
|
||||
}
|
||||
|
||||
def describe(self) -> dict[str, Any]:
|
||||
return {
|
||||
"agent": self.agent_type,
|
||||
"enabled": self.config.enabled,
|
||||
"command": self.configured_command(),
|
||||
"model": self.config.model or "(cli default)",
|
||||
"model_flag": self.config.model_flag or "",
|
||||
"session_mode": self.config.session_mode,
|
||||
"session_id": self.config.session_id or "",
|
||||
"run_mode": self.config.run_mode,
|
||||
"approval_mode": self.config.approval_mode,
|
||||
"idle_timeout_seconds": self.config.idle_timeout_seconds,
|
||||
"status_heartbeat_seconds": self.config.status_heartbeat_seconds,
|
||||
"new_session_flag": self.config.new_session_flag or "",
|
||||
"resume_session_flag": self.config.resume_session_flag or "",
|
||||
"extra_args": list(self.config.extra_args),
|
||||
}
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return bool(str(self.config.resume_session_flag or "").strip())
|
||||
|
||||
def can_resume_without_session_id(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_live_inbox_delivery(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_resume_inbox_delivery(self) -> bool:
|
||||
return self.supports_session_resume() or self.supports_interactive()
|
||||
|
||||
def supports_approval_prompt_handling(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Whether OpenOPC should bridge this process' live approval prompts.
|
||||
|
||||
The broker can only answer a provider approval prompt when the child
|
||||
process has a live stdin transport that accepts the formatted response.
|
||||
Adapters with a different permission model can override this to avoid
|
||||
surfacing stale UI approval cards.
|
||||
"""
|
||||
return self.stdin_policy_for_process(cmd, metadata) == "pipe_open"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Skill-based collaboration surface.
|
||||
#
|
||||
# OpenOPC-spawned agents can use a dedicated home dir
|
||||
# (``<opc_home>/agent_homes/<slug>/``) so OpenOPC can install the
|
||||
# ``opc-collab`` skill + CLI shim there. Adapters may still choose to keep
|
||||
# native user config for authentication-sensitive CLIs.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def agent_isolation_home_slug(self) -> str | None:
|
||||
"""Short identifier for this agent's isolated home dir, or ``None``
|
||||
when this adapter cannot host the opc-collab CLI surface.
|
||||
|
||||
Returning e.g. ``"codex"`` tells the broker to (a) provision
|
||||
``<opc_home>/agent_homes/codex/``, (b) install the ``opc-collab``
|
||||
skill there, (c) merge :meth:`agent_home_env_vars` into the launch
|
||||
env.
|
||||
"""
|
||||
return None
|
||||
|
||||
def agent_home_env_vars(self, home: str) -> dict[str, str]:
|
||||
"""Env vars that point this agent at its isolated home dir.
|
||||
|
||||
Paired with :meth:`agent_isolation_home_slug`. For codex this is
|
||||
``{"CODEX_HOME": home}``; for opencode ``{"OPENCODE_CONFIG_DIR": home}``.
|
||||
Claude Code intentionally returns no config-dir override so it can use
|
||||
the user's existing authenticated login.
|
||||
"""
|
||||
_ = home
|
||||
return {}
|
||||
|
||||
def post_install_agent_home(self, home: str) -> None:
|
||||
"""Agent-specific finishing touches after the skill installer has
|
||||
provisioned ``home``. Default: no-op. Codex overrides to symlink
|
||||
the user's ``~/.codex/auth.json`` so the spawned process can log
|
||||
in without re-authenticating.
|
||||
"""
|
||||
_ = home
|
||||
return None
|
||||
|
||||
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
|
||||
_ = workspace_path
|
||||
return []
|
||||
|
||||
def build_task_prompt(self, task: Task) -> str:
|
||||
title = str(getattr(task, "title", "") or "").strip()
|
||||
description = str(getattr(task, "description", "") or "").strip()
|
||||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
if str(metadata.get("external_prompt_contract") or "").strip() == _FULL_PROMPT_CONTRACT:
|
||||
return description
|
||||
if not title:
|
||||
return description
|
||||
if not description:
|
||||
return title
|
||||
if title == description:
|
||||
return description
|
||||
if description.startswith(title):
|
||||
return description
|
||||
if self._description_starts_with_task_brief(description, title):
|
||||
return description
|
||||
return f"{title}\n\n{description}"
|
||||
|
||||
@staticmethod
|
||||
def _description_starts_with_task_brief(description: str, title: str) -> bool:
|
||||
if not description or not title or "Task Brief" not in description:
|
||||
return False
|
||||
pattern = rf"(?ms)^##+\s+Task Brief\s*\n\s*{re.escape(title)}(?:\s*$|\s*\n)"
|
||||
return re.search(pattern, description.strip()) is not None
|
||||
|
||||
@staticmethod
|
||||
def _display_command(cmd: list[str]) -> str:
|
||||
display_cmd = [str(part) for part in cmd]
|
||||
if display_cmd:
|
||||
last = display_cmd[-1]
|
||||
if "\n" in last or len(last) > 160:
|
||||
display_cmd[-1] = f"<prompt:{len(last)}-chars>"
|
||||
return shlex.join(display_cmd)
|
||||
|
||||
def extract_resume_session_id(self, output: str) -> str:
|
||||
candidates: list[str] = []
|
||||
for candidate in self._iter_json_object_candidates(output):
|
||||
candidates.extend(self._collect_session_id_candidates(candidate))
|
||||
return candidates[-1] if candidates else ""
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
request = self._parse_generic_json_approval_request(text, stream_name)
|
||||
if request:
|
||||
return request
|
||||
return self._parse_text_approval_request(text, stream_name)
|
||||
|
||||
def format_approval_response(
|
||||
self,
|
||||
request: ExternalApprovalRequest,
|
||||
approved: bool,
|
||||
decision: Any,
|
||||
) -> str:
|
||||
_ = request
|
||||
_ = decision
|
||||
return "y\n" if approved else "n\n"
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
return self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
"""Convert raw stdout into the user-facing task result text."""
|
||||
return output
|
||||
|
||||
def extract_structured_result_fields(self, output: str) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for candidate in self._iter_json_object_candidates(output):
|
||||
for key in (
|
||||
"work_item_runtime_plan",
|
||||
"runtime_plan",
|
||||
"work_item_artifact_index",
|
||||
"artifact_index",
|
||||
"verification_evidence",
|
||||
"verification",
|
||||
"structured_review_verdict",
|
||||
):
|
||||
if key in candidate and key not in payload:
|
||||
payload[key] = candidate[key]
|
||||
# Fix 4: a flat JSON envelope of the canonical shape
|
||||
# {"review_verdict":"reject","summary":"...","blocking_issues":[...],"followups":[...]}
|
||||
# used to degenerate into ``payload["review_verdict"] = "reject"``
|
||||
# (the inner string) because the old extractor pulled
|
||||
# ``candidate[key]`` instead of ``candidate`` when the key was
|
||||
# already present in the top-level dict. That silently dropped
|
||||
# the reviewer's actual blocking_issues/followups. Now we
|
||||
# preserve the whole candidate whenever review_verdict carries
|
||||
# a verdict label (string OR dict) AND there are sibling fields
|
||||
# that make the candidate a structured envelope.
|
||||
if "review_verdict" in candidate and "review_verdict" not in payload:
|
||||
inner = candidate["review_verdict"]
|
||||
has_sibling_fields = any(
|
||||
key in candidate
|
||||
for key in ("summary", "blocking_issues", "followups")
|
||||
)
|
||||
if isinstance(inner, dict):
|
||||
payload["review_verdict"] = inner
|
||||
elif has_sibling_fields:
|
||||
# Preserve the full envelope so downstream gets
|
||||
# ``{review_verdict, summary, blocking_issues, followups}``.
|
||||
payload["review_verdict"] = candidate
|
||||
else:
|
||||
payload["review_verdict"] = inner
|
||||
if "review_verdict" not in payload and any(
|
||||
key in candidate for key in ("verdict", "decision", "status")
|
||||
):
|
||||
payload["review_verdict"] = candidate
|
||||
return payload
|
||||
|
||||
def infer_review_verdict(self, output: str) -> dict[str, Any]:
|
||||
"""Extract an ``approve``/``reject`` verdict from a reviewer's output.
|
||||
|
||||
The runtime treats the reviewer agent as the authoritative judge
|
||||
and does NOT second-guess verdict shape or content. This helper
|
||||
is purely a JSON parser: when the reviewer emits a structured
|
||||
verdict (per the prompt's suggested schema), we extract its
|
||||
label and pass-through fields. When no parseable verdict is
|
||||
present, we return ``{}`` and let the runtime spawn a verdict-
|
||||
parse-retry attempt.
|
||||
|
||||
Returns a dict with at least ``label`` ∈ {"approve", "reject"}
|
||||
on success, or ``{}`` if no parseable verdict was found.
|
||||
"""
|
||||
structured = self.extract_structured_result_fields(output)
|
||||
explicit = structured.get("review_verdict") or structured.get("structured_review_verdict")
|
||||
if isinstance(explicit, str):
|
||||
normalized = explicit.strip().lower()
|
||||
if normalized in {"approve", "approved", "pass", "passed", "accept", "accepted"}:
|
||||
return {"label": "approve", "summary": explicit.strip()}
|
||||
if normalized in {"reject", "rejected", "fail", "failed", "rework"}:
|
||||
return {"label": "reject", "summary": explicit.strip()}
|
||||
return {}
|
||||
if isinstance(explicit, dict):
|
||||
raw = str(
|
||||
explicit.get("review_verdict")
|
||||
or explicit.get("verdict")
|
||||
or explicit.get("decision")
|
||||
or explicit.get("status")
|
||||
or explicit.get("label")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if raw in {"approved", "pass", "passed", "accept", "accepted"}:
|
||||
raw = "approve"
|
||||
elif raw in {"rejected", "fail", "failed", "rework"}:
|
||||
raw = "reject"
|
||||
if raw in {"approve", "reject"}:
|
||||
blocking = explicit.get("blocking_issues", [])
|
||||
followups = explicit.get("followups", [])
|
||||
return {
|
||||
"label": raw,
|
||||
"summary": str(explicit.get("summary", "") or "").strip(),
|
||||
"blocking_issues": [
|
||||
str(item).strip()
|
||||
for item in (blocking if isinstance(blocking, list) else [])
|
||||
if str(item).strip()
|
||||
][:8],
|
||||
"followups": [
|
||||
str(item).strip()
|
||||
for item in (followups if isinstance(followups, list) else [])
|
||||
if str(item).strip()
|
||||
][:8],
|
||||
}
|
||||
return {}
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
"""Convert a raw stream line into a user-facing progress update."""
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
return f"[External:{self.agent_type}:{stream_name}] {stripped[:500]}"
|
||||
|
||||
def detect_runtime_failure(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Return a fatal runtime failure reason if a stream line is unrecoverable."""
|
||||
_ = stream_name
|
||||
_ = metadata
|
||||
_ = text
|
||||
return None
|
||||
|
||||
async def start_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
workspace_path: str,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
task: Task | None = None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
_ = task
|
||||
_ = launch_metadata
|
||||
env = self.build_process_env(extra_env)
|
||||
launch_cmd = self._resolve_launch_command(
|
||||
cmd,
|
||||
extra_env=extra_env,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
stdin_policy = self.stdin_policy_for_process(launch_cmd, launch_metadata)
|
||||
stdin_target = self._stdin_target_for_policy(stdin_policy)
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*launch_cmd,
|
||||
stdin=stdin_target,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
env=env,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
|
||||
def keep_process_stdin_open(self, cmd: list[str]) -> bool:
|
||||
return self.stdin_policy_for_process(cmd) == "pipe_open"
|
||||
|
||||
def stdin_policy_for_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ExternalAgentStdinPolicy:
|
||||
_ = cmd
|
||||
_ = metadata
|
||||
return "devnull"
|
||||
|
||||
@staticmethod
|
||||
def _stdin_target_for_policy(policy: ExternalAgentStdinPolicy) -> Any:
|
||||
if policy == "inherit":
|
||||
return None
|
||||
if policy in {"pipe_open", "pipe_prompt_then_close"}:
|
||||
return asyncio.subprocess.PIPE
|
||||
return asyncio.subprocess.DEVNULL
|
||||
|
||||
@staticmethod
|
||||
def _record_stdin_policy_metadata(
|
||||
metadata: dict[str, Any],
|
||||
policy: ExternalAgentStdinPolicy,
|
||||
) -> None:
|
||||
metadata["stdin_policy"] = policy
|
||||
if "interactive_input_channel" in metadata:
|
||||
return
|
||||
if policy == "inherit":
|
||||
metadata["interactive_input_channel"] = "inherit"
|
||||
elif policy in {"pipe_open", "pipe_prompt_then_close"}:
|
||||
metadata["interactive_input_channel"] = "pipe"
|
||||
else:
|
||||
metadata["interactive_input_channel"] = "devnull"
|
||||
|
||||
def build_process_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str] | None:
|
||||
if not extra_env:
|
||||
return None
|
||||
return {**os.environ, **{str(k): str(v) for k, v in extra_env.items()}}
|
||||
|
||||
@staticmethod
|
||||
def _subprocess_group_kwargs() -> dict[str, Any]:
|
||||
if os.name == "posix":
|
||||
return {"start_new_session": True}
|
||||
if os.name == "nt":
|
||||
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_launch_command(
|
||||
cmd: list[str],
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> list[str]:
|
||||
if not cmd:
|
||||
return cmd
|
||||
executable = str(cmd[0] or "").strip()
|
||||
if not executable:
|
||||
return cmd
|
||||
path_value = ""
|
||||
if extra_env:
|
||||
merged = {**os.environ, **{str(k): str(v) for k, v in extra_env.items()}}
|
||||
path_value = merged.get("PATH") or merged.get("Path") or merged.get("path") or ""
|
||||
resolved = shutil.which(executable, path=path_value or None)
|
||||
if not resolved or resolved == executable:
|
||||
return cmd
|
||||
resolved_cmd = list(cmd)
|
||||
resolved_cmd[0] = resolved
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata.setdefault("configured_binary", executable)
|
||||
launch_metadata["resolved_binary"] = resolved
|
||||
return resolved_cmd
|
||||
|
||||
async def send_process_input(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
text: str,
|
||||
) -> bool:
|
||||
if not text:
|
||||
return True
|
||||
writer = getattr(proc, "stdin", None)
|
||||
if writer is None:
|
||||
return False
|
||||
if hasattr(writer, "is_closing") and writer.is_closing():
|
||||
return False
|
||||
try:
|
||||
writer.write(text.encode("utf-8"))
|
||||
await writer.drain()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def cleanup_process(self, proc: asyncio.subprocess.Process) -> None:
|
||||
writer = getattr(proc, "stdin", None)
|
||||
if writer is None:
|
||||
return
|
||||
if hasattr(writer, "is_closing") and writer.is_closing():
|
||||
return
|
||||
writer.close()
|
||||
wait_closed = getattr(writer, "wait_closed", None)
|
||||
if callable(wait_closed):
|
||||
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
||||
await wait_closed()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def is_available(self) -> bool:
|
||||
"""Check whether this agent is installed and configured."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
"""Execute task in the specified workspace."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
"""Build the CLI command and audit metadata for a task."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_status(self) -> AgentStatus:
|
||||
...
|
||||
|
||||
async def get_fallback(self) -> ExternalAgentAdapter | None:
|
||||
"""When this agent is unavailable, return the next preferred agent."""
|
||||
return None
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
"""Interrupt an in-progress task."""
|
||||
return False
|
||||
|
||||
def _parse_generic_json_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
event = self._parse_json_line(text)
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
|
||||
event_type = self._normalize_name(
|
||||
event.get("type") or event.get("event") or event.get("kind")
|
||||
)
|
||||
subtype = self._normalize_name(event.get("subtype") or event.get("status") or event.get("state"))
|
||||
explicit_event = any(
|
||||
marker in event_type or marker in subtype
|
||||
for marker in ("approval", "permission", "authorize", "confirm")
|
||||
)
|
||||
tool_name, tool_args = self._extract_structured_tool_call(event)
|
||||
prompt_text = self._extract_prompt_text(event) or text.strip()
|
||||
approval_metadata_present = any(
|
||||
key in event for key in ("approval", "approval_id", "available_decisions", "permission", "permission_id")
|
||||
)
|
||||
if not explicit_event and not (
|
||||
self._looks_like_textual_approval_prompt(prompt_text)
|
||||
and (bool(tool_name) or bool(tool_args) or approval_metadata_present)
|
||||
):
|
||||
return None
|
||||
|
||||
mapped = self._map_external_tool_request(tool_name, tool_args, prompt_text)
|
||||
metadata = {
|
||||
"stream": stream_name,
|
||||
"provider_event_type": str(event.get("type") or ""),
|
||||
"provider_event_subtype": str(event.get("subtype") or ""),
|
||||
"provider_tool_name": tool_name,
|
||||
"provider_tool_args": tool_args,
|
||||
"raw_event": event,
|
||||
}
|
||||
if mapped:
|
||||
action_name, arguments = mapped
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name=action_name,
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="external_agent",
|
||||
action_name=f"{self.agent_type}:prompt",
|
||||
prompt_text=prompt_text,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
def _parse_text_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
if not self._looks_like_textual_approval_prompt(stripped):
|
||||
return None
|
||||
|
||||
command = self._extract_command_from_text(stripped)
|
||||
metadata = {"stream": stream_name}
|
||||
if command:
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="shell_exec",
|
||||
prompt_text=stripped,
|
||||
arguments={"command": command},
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="external_agent",
|
||||
action_name=f"{self.agent_type}:prompt",
|
||||
prompt_text=stripped,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
def _looks_like_textual_approval_prompt(self, text: str) -> bool:
|
||||
stripped = str(text or "").strip()
|
||||
if not stripped:
|
||||
return False
|
||||
lowered = stripped.lower()
|
||||
if "[y/n]" in lowered or "(y/n)" in lowered:
|
||||
return True
|
||||
if re.match(r"^(approve|allow|authorize|confirm)\b", lowered):
|
||||
return True
|
||||
if re.search(r"\b(approve|allow|authorize|confirm)\b", lowered) and "?" in stripped:
|
||||
return True
|
||||
if "permission request" in lowered:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_structured_tool_call(self, event: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
tool_name = event.get("tool_name") or event.get("toolName") or event.get("name")
|
||||
tool_args = event.get("input") or event.get("arguments") or event.get("params") or event.get("tool_input")
|
||||
if tool_name:
|
||||
return str(tool_name), tool_args if isinstance(tool_args, dict) else {}
|
||||
|
||||
tool_call = event.get("tool_call")
|
||||
if isinstance(tool_call, dict):
|
||||
nested_name = tool_call.get("tool_name") or tool_call.get("toolName") or tool_call.get("name")
|
||||
nested_args = tool_call.get("input") or tool_call.get("arguments") or tool_call.get("params")
|
||||
if nested_name:
|
||||
return str(nested_name), nested_args if isinstance(nested_args, dict) else {}
|
||||
if len(tool_call) == 1:
|
||||
name, payload = next(iter(tool_call.items()))
|
||||
if isinstance(payload, dict):
|
||||
args = payload.get("args") if isinstance(payload.get("args"), dict) else payload
|
||||
return str(name), args if isinstance(args, dict) else {}
|
||||
|
||||
return "", {}
|
||||
|
||||
def _map_external_tool_request(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: dict[str, Any],
|
||||
prompt_text: str,
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
normalized_name = self._normalize_name(tool_name)
|
||||
command_value = (
|
||||
tool_args.get("command")
|
||||
or tool_args.get("cmd")
|
||||
or tool_args.get("argv")
|
||||
or tool_args.get("script")
|
||||
)
|
||||
if normalized_name in _SHELL_TOOL_NAMES or command_value:
|
||||
command = self.normalize_shell_command(command_value)
|
||||
if command:
|
||||
arguments: dict[str, Any] = {"command": command}
|
||||
working_directory = (
|
||||
tool_args.get("cwd")
|
||||
or tool_args.get("working_directory")
|
||||
or tool_args.get("workdir")
|
||||
or tool_args.get("directory")
|
||||
)
|
||||
if working_directory:
|
||||
arguments["working_directory"] = str(working_directory)
|
||||
return "shell_exec", arguments
|
||||
|
||||
path = tool_args.get("path") or tool_args.get("file_path") or tool_args.get("target") or tool_args.get("filepath")
|
||||
if normalized_name in _FILE_WRITE_TOOL_NAMES:
|
||||
arguments = {"path": str(path)} if path else {}
|
||||
return "file_write", arguments
|
||||
if normalized_name in _FILE_EDIT_TOOL_NAMES:
|
||||
arguments = {"path": str(path)} if path else {}
|
||||
return "file_edit", arguments
|
||||
|
||||
prompt_command = self._extract_command_from_text(prompt_text)
|
||||
if prompt_command:
|
||||
arguments = {"command": prompt_command}
|
||||
if path:
|
||||
arguments["target"] = str(path)
|
||||
return "shell_exec", arguments
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_line(text: str) -> dict[str, Any] | None:
|
||||
stripped = text.strip()
|
||||
if not stripped or not stripped.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_name(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower())
|
||||
|
||||
@classmethod
|
||||
def normalize_shell_command(cls, command: Any) -> str:
|
||||
if isinstance(command, (list, tuple)):
|
||||
tokens = [str(item).strip() for item in command if str(item).strip()]
|
||||
raw_text = shlex.join(tokens) if tokens else ""
|
||||
else:
|
||||
raw_text = str(command or "").strip()
|
||||
if not raw_text:
|
||||
return ""
|
||||
try:
|
||||
tokens = shlex.split(raw_text)
|
||||
except ValueError:
|
||||
tokens = raw_text.split()
|
||||
|
||||
if not tokens:
|
||||
return ""
|
||||
|
||||
head = tokens[0].lower()
|
||||
head_name = head.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
if head_name in _POSIX_SHELLS | _POWERSHELL_SHELLS:
|
||||
for index, token in enumerate(tokens[1:], start=1):
|
||||
lowered = token.lower()
|
||||
if lowered not in _SHELL_WRAPPER_FLAGS:
|
||||
continue
|
||||
if index + 1 >= len(tokens):
|
||||
break
|
||||
inner = str(tokens[index + 1]).strip()
|
||||
if not inner:
|
||||
break
|
||||
return inner
|
||||
return raw_text if raw_text else shlex.join(tokens)
|
||||
|
||||
def _extract_prompt_text(self, value: Any) -> str:
|
||||
fragments = self._collect_text_fragments(value)
|
||||
if not fragments:
|
||||
return ""
|
||||
return " | ".join(fragments[:6])[:1000]
|
||||
|
||||
def _collect_text_fragments(self, value: Any) -> list[str]:
|
||||
if isinstance(value, dict):
|
||||
fragments: list[str] = []
|
||||
for key in ("message", "prompt", "reason", "summary", "description", "text", "title"):
|
||||
item = value.get(key)
|
||||
if item:
|
||||
fragments.extend(self._collect_text_fragments(item))
|
||||
if not fragments:
|
||||
for item in value.values():
|
||||
fragments.extend(self._collect_text_fragments(item))
|
||||
return fragments
|
||||
if isinstance(value, list):
|
||||
fragments: list[str] = []
|
||||
for item in value:
|
||||
fragments.extend(self._collect_text_fragments(item))
|
||||
return fragments
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
return [text] if text else []
|
||||
return []
|
||||
|
||||
def _collect_session_id_candidates(self, value: Any) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
normalized = self._normalize_name(key)
|
||||
if normalized in {"sessionid", "conversationid", "threadid", "chatid"}:
|
||||
token = str(item or "").strip()
|
||||
if token:
|
||||
candidates.append(token)
|
||||
candidates.extend(self._collect_session_id_candidates(item))
|
||||
return candidates
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
candidates.extend(self._collect_session_id_candidates(item))
|
||||
return candidates
|
||||
|
||||
def _extract_command_from_text(self, text: str) -> str:
|
||||
backtick_match = re.search(r"`([^`]+)`", text)
|
||||
if backtick_match:
|
||||
return self.normalize_shell_command(backtick_match.group(1))
|
||||
|
||||
quoted_match = re.search(r'"([^"\n]+)"', text)
|
||||
if quoted_match and any(keyword in text.lower() for keyword in ("command", "bash", "shell", "terminal")):
|
||||
return self.normalize_shell_command(quoted_match.group(1))
|
||||
|
||||
command_match = re.search(
|
||||
r"(?:run|execute|command)\s*:\s*(.+?)(?:\?|$)",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if command_match:
|
||||
return self.normalize_shell_command(command_match.group(1).strip())
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _iter_json_object_candidates(text: str) -> list[dict[str, Any]]:
|
||||
stripped = str(text or "").strip()
|
||||
if not stripped:
|
||||
return []
|
||||
decoder = json.JSONDecoder()
|
||||
candidates: list[dict[str, Any]] = []
|
||||
start = stripped.find("{")
|
||||
while start != -1:
|
||||
try:
|
||||
value, consumed = decoder.raw_decode(stripped[start:])
|
||||
except json.JSONDecodeError:
|
||||
start = stripped.find("{", start + 1)
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
candidates.append(value)
|
||||
start = stripped.find("{", start + max(consumed, 1))
|
||||
return candidates
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Claude Code CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import AgentStatus, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.adapters.base import (
|
||||
ExternalAgentAdapter,
|
||||
ExternalAgentStdinPolicy,
|
||||
ExternalApprovalRequest,
|
||||
)
|
||||
from opc.layer3_agent.skill_installer import install_opc_collab_skill
|
||||
|
||||
|
||||
class ClaudeCodeAdapter(ExternalAgentAdapter):
|
||||
"""Invokes the Claude Code CLI (claude command) for task execution."""
|
||||
|
||||
agent_type = "claude_code"
|
||||
default_command = "claude"
|
||||
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
|
||||
# Lazily populated by ``_user_shell_proxy_env``. Cached at class level
|
||||
# so the (slow) ``zsh -i -c`` probe runs at most once per process.
|
||||
# ``None`` means "not probed yet"; ``{}`` means "probed, no proxy found".
|
||||
_user_shell_proxy_env_cache: dict[str, str] | None = None
|
||||
|
||||
def __init__(self, config=None) -> None:
|
||||
super().__init__(config=config)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
|
||||
async def start_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
workspace_path: str,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
task: Task | None = None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
|
||||
# Small prompts stay on argv. Keep stdin open only when Claude's
|
||||
# permission mode can emit live prompts; otherwise DEVNULL avoids
|
||||
# provider-side stdin probes on Windows wrappers.
|
||||
env = self.build_process_env(extra_env)
|
||||
env = self._merge_user_shell_proxy(env)
|
||||
launch_cmd = self._resolve_launch_command(
|
||||
cmd,
|
||||
extra_env=extra_env,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
stdin_policy = self.stdin_policy_for_process(launch_cmd, launch_metadata)
|
||||
stdin_target = self._stdin_target_for_policy(stdin_policy)
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*launch_cmd,
|
||||
stdin=stdin_target,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
env=env,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
if prompt_transport == "stdin":
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata["interactive_input_channel"] = "pipe"
|
||||
delivered = await self._seed_pipe_prompt(proc, prompt)
|
||||
if not delivered:
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata["prompt_delivery_failed"] = True
|
||||
logger.warning(
|
||||
"Large stdin prompt delivery to {} (pid={}) may be incomplete",
|
||||
self.agent_type,
|
||||
proc.pid,
|
||||
)
|
||||
return proc
|
||||
|
||||
@classmethod
|
||||
def _merge_user_shell_proxy(
|
||||
cls, env: dict[str, str] | None
|
||||
) -> dict[str, str] | None:
|
||||
"""Layer extracted user-shell proxy vars onto the spawn env as a
|
||||
fallback (only fills in vars that are not already set).
|
||||
|
||||
Returns ``None`` unchanged if there were no extracted vars and no
|
||||
``env`` was supplied — that preserves the "inherit os.environ"
|
||||
behavior expected by :func:`asyncio.create_subprocess_exec`.
|
||||
"""
|
||||
proxy = cls._user_shell_proxy_env()
|
||||
if not proxy:
|
||||
return env
|
||||
if env is None:
|
||||
import os
|
||||
env = {**os.environ}
|
||||
for key, value in proxy.items():
|
||||
env.setdefault(key, value)
|
||||
return env
|
||||
|
||||
@classmethod
|
||||
def _user_shell_proxy_env(cls) -> dict[str, str]:
|
||||
"""Probe the user's login shell for proxy env vars defined inside a
|
||||
``claude`` shell function.
|
||||
|
||||
Developers behind a national firewall commonly wrap ``claude`` in
|
||||
a shell function that injects ``HTTPS_PROXY`` per invocation::
|
||||
|
||||
claude() {
|
||||
HTTPS_PROXY=http://... command claude "$@"
|
||||
}
|
||||
|
||||
Those vars are only set when ``claude`` is invoked *through the
|
||||
shell*. OpenOPC spawns claude via
|
||||
:func:`asyncio.create_subprocess_exec`, which bypasses the shell
|
||||
and the function — so the proxy never reaches claude and the API
|
||||
call lands on a network gateway that returns ``403 Request not
|
||||
allowed``.
|
||||
|
||||
Run ``$SHELL -i -c 'declare -f claude'`` once at first call and
|
||||
parse any ``*_PROXY=`` assignments out of the function body. The
|
||||
result is cached at class level. Failures (no shell function,
|
||||
unusual shell, timeout, non-zero exit) silently degrade to an
|
||||
empty dict so the spawn path is unchanged.
|
||||
"""
|
||||
if cls._user_shell_proxy_env_cache is not None:
|
||||
return cls._user_shell_proxy_env_cache
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
shell = os.environ.get("SHELL", "").strip() or "/bin/zsh"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[shell, "-i", "-c", "declare -f claude 2>/dev/null || true"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.debug(
|
||||
"Skipping user-shell proxy probe ({}): {}", shell, exc
|
||||
)
|
||||
cls._user_shell_proxy_env_cache = {}
|
||||
return cls._user_shell_proxy_env_cache
|
||||
|
||||
text = result.stdout or ""
|
||||
extracted: dict[str, str] = {}
|
||||
# Match `VAR="value"` or `VAR='value'` for the standard proxy
|
||||
# env names. Unquoted assignments are intentionally skipped —
|
||||
# they may contain shell variable expansions we cannot resolve
|
||||
# without actually executing the function.
|
||||
pattern = re.compile(
|
||||
r"\b("
|
||||
r"HTTPS?_PROXY|https?_proxy|"
|
||||
r"NO_PROXY|no_proxy|"
|
||||
r"ALL_PROXY|all_proxy"
|
||||
r")="
|
||||
r"([\"'])([^\"']*)\2"
|
||||
)
|
||||
for var, _, val in pattern.findall(text):
|
||||
extracted[var] = val
|
||||
if extracted:
|
||||
logger.info(
|
||||
"Extracted {} proxy var(s) from user `claude` shell function: {}",
|
||||
len(extracted),
|
||||
sorted(extracted.keys()),
|
||||
)
|
||||
cls._user_shell_proxy_env_cache = extracted
|
||||
return cls._user_shell_proxy_env_cache
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return self.resolve_binary() is not None
|
||||
|
||||
async def get_status(self) -> AgentStatus:
|
||||
if self._process and self._process.returncode is None:
|
||||
return AgentStatus.RUNNING
|
||||
return AgentStatus.IDLE
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return True
|
||||
|
||||
def agent_isolation_home_slug(self) -> str:
|
||||
# Keep a broker-owned home so the shared ``opc-collab`` CLI shim is
|
||||
# installed and PATH is wired consistently. Claude Code authentication
|
||||
# intentionally remains global; setting CLAUDE_CONFIG_DIR makes Claude
|
||||
# ignore the user's normal login/keychain state and can leave company
|
||||
# mode stuck on stale isolated OAuth credentials.
|
||||
return "claude"
|
||||
|
||||
def agent_home_env_vars(self, home: str) -> dict[str, str]:
|
||||
_ = home
|
||||
return {}
|
||||
|
||||
def post_install_agent_home(self, home: str) -> None:
|
||||
# Claude Code discovers skills from the active user config directory.
|
||||
# Because we do not set CLAUDE_CONFIG_DIR, install the OpenOPC skill
|
||||
# into the user's normal Claude home and let the CLI use its existing
|
||||
# authenticated state.
|
||||
Path(home).mkdir(parents=True, exist_ok=True)
|
||||
user_home = Path.home() / ".claude"
|
||||
try:
|
||||
install_opc_collab_skill(user_home)
|
||||
except OSError as exc:
|
||||
logger.warning("Unable to install opc-collab into Claude user home: {}", exc)
|
||||
|
||||
def can_resume_without_session_id(self) -> bool:
|
||||
return True
|
||||
|
||||
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
|
||||
if not workspace_path:
|
||||
return []
|
||||
return ["--add-dir", workspace_path]
|
||||
|
||||
def _build_extra_dir_args(self, task: Task | None) -> list[str]:
|
||||
# Surface extra roots that Claude may need to edit outside the
|
||||
# primary workspace: collaboration files and durable memory.
|
||||
if task is None:
|
||||
return []
|
||||
extra: list[str] = []
|
||||
seen: set[str] = set()
|
||||
workspace = str((task.metadata or {}).get("target_output_dir") or "").strip()
|
||||
|
||||
def _add(path: str) -> None:
|
||||
normalized = str(path or "").strip()
|
||||
if normalized and normalized != workspace and normalized not in seen:
|
||||
seen.add(normalized)
|
||||
extra.extend(["--add-dir", normalized])
|
||||
|
||||
comms_root = str((task.metadata or {}).get("comms_workspace_root") or "").strip()
|
||||
_add(comms_root)
|
||||
try:
|
||||
from opc.core.config import get_opc_home
|
||||
|
||||
_add(str(get_opc_home() / "memory"))
|
||||
except Exception:
|
||||
pass
|
||||
return extra
|
||||
|
||||
def _build_argv_prompt_metadata(self, prompt: str) -> dict[str, object]:
|
||||
return {
|
||||
"prompt_transport": "argv",
|
||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
||||
}
|
||||
|
||||
def _build_stdin_prompt_metadata(self, prompt: str) -> dict[str, object]:
|
||||
return {
|
||||
"prompt_transport": "stdin",
|
||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
||||
"stdin_prompt_channel": "pipe",
|
||||
"interactive_input_limitation": (
|
||||
"large initial prompt is delivered through stdin; live approval replies "
|
||||
"are unavailable after stdin closes"
|
||||
),
|
||||
}
|
||||
|
||||
def _interactive_prompt_transport(self, prompt: str) -> str:
|
||||
prompt_bytes = len(prompt.encode("utf-8"))
|
||||
return "argv" if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES else "stdin"
|
||||
|
||||
@staticmethod
|
||||
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
|
||||
redacted = list(cmd)
|
||||
if redacted:
|
||||
redacted[-1] = f"<prompt:{len(prompt.encode('utf-8'))}-bytes>"
|
||||
return redacted
|
||||
|
||||
def _prompt_text_from_task(self, task: Task | None) -> str:
|
||||
if task is None:
|
||||
return ""
|
||||
return self.build_task_prompt(task)
|
||||
|
||||
@staticmethod
|
||||
async def _seed_pipe_prompt(
|
||||
proc: asyncio.subprocess.Process,
|
||||
prompt: str,
|
||||
) -> bool:
|
||||
writer = getattr(proc, "stdin", None)
|
||||
if writer is None:
|
||||
return False
|
||||
payload = prompt.encode("utf-8")
|
||||
delivered = True
|
||||
try:
|
||||
writer.write(payload)
|
||||
await writer.drain()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as exc:
|
||||
logger.warning(
|
||||
"Pipe prompt delivery failed ({} bytes planned): {}",
|
||||
len(payload),
|
||||
exc,
|
||||
)
|
||||
delivered = False
|
||||
writer.close()
|
||||
wait_closed = getattr(writer, "wait_closed", None)
|
||||
if callable(wait_closed):
|
||||
try:
|
||||
await wait_closed()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
delivered = False
|
||||
return delivered
|
||||
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
prompt = self.build_task_prompt(task)
|
||||
prompt_transport = self._interactive_prompt_transport(prompt)
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"--print",
|
||||
"--output-format", "text",
|
||||
*(["--input-format", "text"] if prompt_transport == "stdin" else []),
|
||||
*self.build_workspace_args(workspace_path),
|
||||
*self._build_extra_dir_args(task),
|
||||
*self._build_session_args(),
|
||||
*self.build_common_args(),
|
||||
]
|
||||
if prompt_transport == "argv":
|
||||
cmd.extend(["--", prompt])
|
||||
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
|
||||
metadata.update(self._build_argv_prompt_metadata(prompt))
|
||||
else:
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata.update(self._build_stdin_prompt_metadata(prompt))
|
||||
return cmd, metadata
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
prompt = self.build_task_prompt(task)
|
||||
prompt_transport = self._interactive_prompt_transport(prompt)
|
||||
# Claude Code 2.x rejects `--print --output-format stream-json` unless
|
||||
# `--verbose` is also passed (`Error: When using --print,
|
||||
# --output-format=stream-json requires --verbose`). Inject it when the
|
||||
# user has not already supplied it via `extra_args`.
|
||||
verbose_args: list[str] = []
|
||||
if not any(arg == "--verbose" for arg in self.config.extra_args):
|
||||
verbose_args = ["--verbose"]
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"--print",
|
||||
"--output-format", "stream-json",
|
||||
*(["--input-format", "text"] if prompt_transport == "stdin" else []),
|
||||
*verbose_args,
|
||||
"--include-partial-messages",
|
||||
*self.build_workspace_args(workspace_path),
|
||||
*self._build_extra_dir_args(task),
|
||||
*self._build_permission_args(),
|
||||
*self._build_session_args(),
|
||||
*self.build_common_args(),
|
||||
]
|
||||
if prompt_transport == "argv":
|
||||
cmd.extend(["--", prompt])
|
||||
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
|
||||
metadata.update(self._build_argv_prompt_metadata(prompt))
|
||||
else:
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata.update(self._build_stdin_prompt_metadata(prompt))
|
||||
return cmd, metadata
|
||||
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
if not await self.is_available():
|
||||
return TaskResult(status=TaskStatus.FAILED, content="Claude Code CLI not found")
|
||||
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
logger.info(f"Claude Code executing: {task.title}")
|
||||
|
||||
try:
|
||||
self._process = await self.start_process(
|
||||
cmd,
|
||||
workspace_path,
|
||||
task=task,
|
||||
launch_metadata=metadata,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
self._process.communicate(), timeout=600
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if self._process.returncode == 0:
|
||||
return TaskResult(
|
||||
status=TaskStatus.DONE,
|
||||
content=output,
|
||||
artifacts={**metadata, "stderr": errors} if errors else metadata,
|
||||
)
|
||||
else:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Claude Code exited with code {self._process.returncode}\n{errors}\n{output}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if self._process:
|
||||
self._process.kill()
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content="Claude Code timed out after 600s",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Claude Code error: {e}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
def supports_approval_prompt_handling(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
return self.stdin_policy_for_process(cmd, metadata) == "pipe_open"
|
||||
|
||||
def stdin_policy_for_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ExternalAgentStdinPolicy:
|
||||
if str((metadata or {}).get("prompt_transport") or "").strip().lower() == "stdin":
|
||||
return "pipe_prompt_then_close"
|
||||
for index, arg in enumerate(cmd):
|
||||
value = str(arg or "").strip()
|
||||
if value == "--dangerously-skip-permissions":
|
||||
return "devnull"
|
||||
if value == "--permission-mode":
|
||||
mode = str(cmd[index + 1] if index + 1 < len(cmd) else "").strip()
|
||||
return "devnull" if mode in {"bypassPermissions", "dontAsk"} else "pipe_open"
|
||||
if value.startswith("--permission-mode="):
|
||||
mode = value.split("=", 1)[1].strip()
|
||||
return "devnull" if mode in {"bypassPermissions", "dontAsk"} else "pipe_open"
|
||||
return "pipe_open"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# stream-json parsing for UI progress / transcript / approval suppression
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def _parse_runtime_event(cls, text: str) -> dict[str, Any] | None:
|
||||
envelope = cls._parse_json_line(text)
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
return envelope
|
||||
|
||||
@staticmethod
|
||||
def _trim_text(text: str, *, limit: int) -> str:
|
||||
stripped = str(text or "").strip()
|
||||
if len(stripped) <= limit:
|
||||
return stripped
|
||||
return stripped[: limit - 1].rstrip() + "…"
|
||||
|
||||
@classmethod
|
||||
def _summarize_tool_use(cls, block: dict[str, Any]) -> str:
|
||||
name = str(block.get("name") or "").strip() or "tool"
|
||||
inp = block.get("input") if isinstance(block.get("input"), dict) else {}
|
||||
if name == "Bash":
|
||||
command = str(inp.get("command") or "").strip()
|
||||
if command:
|
||||
return f"$ {cls._trim_text(command.replace(chr(10), ' '), limit=240)}"
|
||||
if name in {"Write", "Edit", "NotebookEdit"}:
|
||||
path = str(inp.get("file_path") or inp.get("notebook_path") or "").strip()
|
||||
if path:
|
||||
return f"{name} {path}"
|
||||
if name == "Read":
|
||||
path = str(inp.get("file_path") or "").strip()
|
||||
if path:
|
||||
return f"Read {path}"
|
||||
if name in {"Glob", "Grep"}:
|
||||
pat = str(inp.get("pattern") or "").strip()
|
||||
path = str(inp.get("path") or "").strip()
|
||||
tail = f" in {path}" if path else ""
|
||||
return f"{name} {pat}{tail}".strip()
|
||||
if name in {"WebFetch", "WebSearch"}:
|
||||
target = str(inp.get("url") or inp.get("query") or "").strip()
|
||||
if target:
|
||||
return f"{name} {target}"
|
||||
# Fallback: name + compact JSON of inputs
|
||||
try:
|
||||
import json as _json
|
||||
payload = _json.dumps(inp, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
payload = ""
|
||||
if payload and payload != "{}":
|
||||
return f"{name} {cls._trim_text(payload, limit=200)}"
|
||||
return name
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
# stderr lines: only surface real warnings/errors, drop noise.
|
||||
if stream_name != "stdout":
|
||||
stripped = str(text or "").strip()
|
||||
if not stripped:
|
||||
return None
|
||||
# Drop Claude's harmless stdin-probe message; OpenOPC owns initial
|
||||
# prompt transport explicitly via argv or stdin metadata.
|
||||
if "no stdin data received" in stripped.lower():
|
||||
return None
|
||||
return f"[External:{self.agent_type}:stderr] {self._trim_text(stripped, limit=400)}"
|
||||
|
||||
envelope = self._parse_runtime_event(text)
|
||||
if not envelope:
|
||||
return None
|
||||
|
||||
envelope_type = str(envelope.get("type") or "").strip()
|
||||
|
||||
# Suppress all the partial-streaming chatter — we surface the full
|
||||
# message once `assistant` arrives. Without this filter the UI gets
|
||||
# flooded with raw `content_block_delta` JSON.
|
||||
if envelope_type in {
|
||||
"stream_event",
|
||||
"user", # tool_result echoes — too verbose for the UI
|
||||
"rate_limit_event",
|
||||
}:
|
||||
return None
|
||||
|
||||
if envelope_type == "system" and str(envelope.get("subtype") or "") == "init":
|
||||
session_id = str(envelope.get("session_id") or "").strip()
|
||||
model = str(envelope.get("model") or "").strip()
|
||||
bits = [b for b in (model, f"session={session_id[:8]}" if session_id else "") if b]
|
||||
return f"[External:{self.agent_type}:init] {' '.join(bits) or 'started'}"
|
||||
|
||||
if envelope_type == "assistant":
|
||||
message = envelope.get("message") if isinstance(envelope.get("message"), dict) else None
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
content = message.get("content") if isinstance(message.get("content"), list) else []
|
||||
text_parts: list[str] = []
|
||||
tool_lines: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = str(block.get("type") or "").strip()
|
||||
if btype == "text":
|
||||
chunk = str(block.get("text") or "").strip()
|
||||
if chunk:
|
||||
text_parts.append(chunk)
|
||||
elif btype == "tool_use":
|
||||
tool_lines.append(self._summarize_tool_use(block))
|
||||
elif btype == "thinking":
|
||||
chunk = str(block.get("thinking") or "").strip()
|
||||
if chunk:
|
||||
text_parts.append(chunk)
|
||||
if tool_lines:
|
||||
# When the assistant turn ends with one or more tool calls,
|
||||
# surface the tool call(s) — that's the actionable signal.
|
||||
return f"[External:{self.agent_type}:tool] " + "\n".join(tool_lines)
|
||||
if text_parts:
|
||||
joined = "\n\n".join(text_parts)
|
||||
return f"[External:{self.agent_type}:thinking] {self._trim_text(joined, limit=2400)}"
|
||||
return None
|
||||
|
||||
if envelope_type == "result":
|
||||
subtype = str(envelope.get("subtype") or "").strip()
|
||||
result_text = str(envelope.get("result") or "").strip()
|
||||
if subtype and subtype != "success":
|
||||
return f"[External:{self.agent_type}:result] {subtype}: {self._trim_text(result_text, limit=600)}"
|
||||
if result_text:
|
||||
return f"[External:{self.agent_type}:thinking] {self._trim_text(result_text, limit=2400)}"
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
"""Extract the final assistant message from a stream-json transcript.
|
||||
|
||||
Falls back to the raw output if no `result` envelope is found, so
|
||||
downstream gate logic still has *something* to work with.
|
||||
"""
|
||||
last_result_text = ""
|
||||
last_assistant_text = ""
|
||||
for line in output.splitlines():
|
||||
envelope = self._parse_runtime_event(line)
|
||||
if not envelope:
|
||||
continue
|
||||
etype = str(envelope.get("type") or "").strip()
|
||||
if etype == "result":
|
||||
rt = str(envelope.get("result") or "").strip()
|
||||
if rt:
|
||||
last_result_text = rt
|
||||
elif etype == "assistant":
|
||||
message = envelope.get("message") if isinstance(envelope.get("message"), dict) else None
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
parts: list[str] = []
|
||||
for block in message.get("content") or []:
|
||||
if isinstance(block, dict) and str(block.get("type") or "") == "text":
|
||||
chunk = str(block.get("text") or "").strip()
|
||||
if chunk:
|
||||
parts.append(chunk)
|
||||
if parts:
|
||||
last_assistant_text = "\n\n".join(parts)
|
||||
return last_result_text or last_assistant_text or output
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
# Claude Code's stream-json transport should only surface
|
||||
# structured approval events here. We intentionally do *not*
|
||||
# fall back to the base class's free-text prompt parser because
|
||||
# ordinary assistant output that mentions "allow"/"approve"
|
||||
# would otherwise stall the broker waiting for human input.
|
||||
event = self._parse_runtime_event(text)
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
if str(event.get("type") or "").strip() == "permission_denial":
|
||||
return None
|
||||
return self._parse_generic_json_approval_request(text, stream_name)
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_permission_args(self) -> list[str]:
|
||||
common_args = self.build_common_args()
|
||||
if any(
|
||||
arg == "--permission-mode" or arg.startswith("--permission-mode=")
|
||||
for arg in common_args
|
||||
):
|
||||
return []
|
||||
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "user-settings":
|
||||
return []
|
||||
if mode == "full-auto":
|
||||
return ["--permission-mode", "bypassPermissions"]
|
||||
return ["--permission-mode", "auto"]
|
||||
|
||||
def _build_session_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
common_args = self.build_common_args()
|
||||
if any(
|
||||
arg in {"-c", "--continue", "-r", "--resume"} or arg.startswith("--resume=")
|
||||
for arg in [*extra_args, *common_args]
|
||||
):
|
||||
return []
|
||||
|
||||
mode = str(self.config.session_mode or "auto").strip().lower()
|
||||
if mode != "resume":
|
||||
return []
|
||||
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
if session_id:
|
||||
return ["--resume", session_id]
|
||||
return ["--continue"]
|
||||
@@ -0,0 +1,957 @@
|
||||
"""Codex CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import AgentStatus, ApprovalDecision, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.adapters.base import (
|
||||
ExternalAgentAdapter,
|
||||
ExternalAgentStdinPolicy,
|
||||
ExternalApprovalRequest,
|
||||
)
|
||||
|
||||
|
||||
class CodexAdapter(ExternalAgentAdapter):
|
||||
"""Invokes the OpenAI Codex CLI."""
|
||||
|
||||
agent_type = "codex"
|
||||
default_command = "codex"
|
||||
_COMMAND_OUTPUT_LIMIT = 2000
|
||||
_MIRRORED_USER_CONFIG_FILES = ("auth.json", "config.toml")
|
||||
_PARENT_CODEX_RUNTIME_ENV_VARS = {
|
||||
"CODEX_INTERNAL_ORIGINATOR_OVERRIDE",
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED",
|
||||
"CODEX_THREAD_ID",
|
||||
}
|
||||
_PROMPT_SENTINEL = "-"
|
||||
_TTY_EOF = b"\x04"
|
||||
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
|
||||
|
||||
def __init__(self, config=None) -> None:
|
||||
super().__init__(config=config)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._input_fds: dict[int, int] = {}
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return self.resolve_binary() is not None
|
||||
|
||||
async def get_status(self) -> AgentStatus:
|
||||
if self._process and self._process.returncode is None:
|
||||
return AgentStatus.RUNNING
|
||||
return AgentStatus.IDLE
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return True
|
||||
|
||||
def agent_isolation_home_slug(self) -> str:
|
||||
# Point spawned codex at ``<opc_home>/agent_homes/codex/``. The
|
||||
# broker installs the ``opc-collab`` skill under
|
||||
# ``skills/opc-collab/`` there; codex exec's native skill
|
||||
# discovery picks it up. The user's personal ``~/.codex/`` is
|
||||
# not inherited, so codex invoked directly by the user stays
|
||||
# separated from the OpenOPC collaboration surface.
|
||||
return "codex"
|
||||
|
||||
def agent_home_env_vars(self, home: str) -> dict[str, str]:
|
||||
return {"CODEX_HOME": home}
|
||||
|
||||
def post_install_agent_home(self, home: str) -> None:
|
||||
# Mirror the user's key Codex config files into the isolated
|
||||
# CODEX_HOME so the spawned process uses the same login and model
|
||||
# provider settings as the CLI the user runs directly. Prefer
|
||||
# symlinks so rotations are tracked; fall back to copying on
|
||||
# Windows/filesystems where symlink creation is blocked.
|
||||
from pathlib import Path
|
||||
|
||||
user_home = Path.home() / ".codex"
|
||||
target_home = Path(home)
|
||||
target_home.mkdir(parents=True, exist_ok=True)
|
||||
for file_name in self._MIRRORED_USER_CONFIG_FILES:
|
||||
self._mirror_user_config_file(user_home / file_name, target_home / file_name)
|
||||
|
||||
@staticmethod
|
||||
def _mirror_user_config_file(source: Path, target: Path) -> None:
|
||||
if not source.exists() or not source.is_file():
|
||||
return
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
if target.is_symlink() or target.exists():
|
||||
if target.is_symlink() and target.resolve() == source.resolve():
|
||||
return
|
||||
target.unlink()
|
||||
target.symlink_to(source)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
if not target.exists() or target.read_bytes() != source.read_bytes():
|
||||
target.write_bytes(source.read_bytes())
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Unable to mirror Codex config file {} into isolated home: {}",
|
||||
source.name,
|
||||
exc,
|
||||
)
|
||||
|
||||
def build_process_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str] | None:
|
||||
env = {
|
||||
str(key): str(value)
|
||||
for key, value in os.environ.items()
|
||||
if not self._is_parent_codex_runtime_env(str(key))
|
||||
}
|
||||
if extra_env:
|
||||
env.update({str(k): str(v) for k, v in extra_env.items()})
|
||||
return env
|
||||
|
||||
@classmethod
|
||||
def _is_parent_codex_runtime_env(cls, key: str) -> bool:
|
||||
normalized = key.upper()
|
||||
return normalized in cls._PARENT_CODEX_RUNTIME_ENV_VARS
|
||||
|
||||
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
|
||||
args: list[str] = []
|
||||
if workspace_path:
|
||||
args.extend(["-C", workspace_path, "--add-dir", workspace_path])
|
||||
return args
|
||||
|
||||
def _extra_writable_roots(self, task: Task | None) -> list[str]:
|
||||
# Surface to Codex's workspace-write sandbox every path outside
|
||||
# the main workspace that OpenOPC expects the agent (or a shell
|
||||
# it spawns) to be able to write to:
|
||||
#
|
||||
# * ``comms_workspace_root`` — sibling of the deliverable
|
||||
# folder; prompts tell the agent to write into
|
||||
# ``.opc-comms/...`` there.
|
||||
# * ``.opc/memory`` — durable global/project Markdown memory.
|
||||
# * the OPC home directory — the ``opc-collab`` CLI (shelled
|
||||
# out from inside the sandbox) writes to ``<opc_home>/
|
||||
# projects/<project>/tasks.db``. Without this entry, SQLite
|
||||
# returns SQLITE_READONLY because Codex mounts the DB path
|
||||
# read-only. (This manifested as "runtime database opened
|
||||
# as read-only" failures on every collab tool call.)
|
||||
if task is None:
|
||||
return []
|
||||
roots: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(path: str) -> None:
|
||||
if not path:
|
||||
return
|
||||
normalized = str(path).strip()
|
||||
if not normalized or normalized in seen:
|
||||
return
|
||||
seen.add(normalized)
|
||||
roots.append(normalized)
|
||||
|
||||
workspace = str((task.metadata or {}).get("target_output_dir") or "").strip()
|
||||
_add_except_workspace = lambda path: _add(path) if path and path != workspace else None # noqa: E731
|
||||
|
||||
comms_root = str((task.metadata or {}).get("comms_workspace_root") or "").strip()
|
||||
_add_except_workspace(comms_root)
|
||||
|
||||
# Compute the OPC home lazily so failures here (e.g. a test
|
||||
# environment without ``opc.core.config`` fully bootable) never
|
||||
# take down the adapter — the agent will still launch, just
|
||||
# without the DB writable path, and the broker will surface the
|
||||
# SQLITE_READONLY error if it matters.
|
||||
try:
|
||||
from opc.core.config import get_opc_home
|
||||
|
||||
opc_home_path = get_opc_home()
|
||||
memory_root = str(opc_home_path / "memory")
|
||||
opc_home = str(opc_home_path)
|
||||
except Exception:
|
||||
memory_root = ""
|
||||
opc_home = ""
|
||||
_add_except_workspace(memory_root)
|
||||
_add_except_workspace(opc_home)
|
||||
|
||||
return roots
|
||||
|
||||
def _build_extra_dir_args(self, task: Task | None) -> list[str]:
|
||||
extra: list[str] = []
|
||||
for root in self._extra_writable_roots(task):
|
||||
extra.extend(["--add-dir", root])
|
||||
return extra
|
||||
|
||||
def _build_writable_roots_config_args(self, task: Task | None) -> list[str]:
|
||||
roots = self._extra_writable_roots(task)
|
||||
if not roots:
|
||||
return []
|
||||
return [
|
||||
"-c",
|
||||
f"sandbox_workspace_write.writable_roots={json.dumps(roots, ensure_ascii=False)}",
|
||||
]
|
||||
|
||||
def _build_stdin_prompt_metadata(self, task: Task) -> dict[str, object]:
|
||||
prompt = self.build_task_prompt(task)
|
||||
return {
|
||||
"prompt_transport": "stdin",
|
||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
||||
}
|
||||
|
||||
def _build_argv_prompt_metadata(self, prompt: str) -> dict[str, object]:
|
||||
return {
|
||||
"prompt_transport": "argv",
|
||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
||||
}
|
||||
|
||||
def _interactive_prompt_transport(self, prompt: str) -> str:
|
||||
prompt_bytes = len(prompt.encode("utf-8"))
|
||||
if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES and self._windows_multiline_argv_is_unsafe(prompt):
|
||||
return "stdin"
|
||||
return "argv" if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES else "stdin"
|
||||
|
||||
def _windows_multiline_argv_is_unsafe(self, prompt: str) -> bool:
|
||||
if os.name != "nt":
|
||||
return False
|
||||
if "\n" not in prompt and "\r" not in prompt:
|
||||
return False
|
||||
command = self.configured_command()
|
||||
resolved = shutil.which(command) or command
|
||||
suffix = os.path.splitext(str(resolved or ""))[1].lower()
|
||||
return suffix in {"", ".cmd", ".bat", ".ps1", ".com", ".exe"}
|
||||
|
||||
@staticmethod
|
||||
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
|
||||
redacted = list(cmd)
|
||||
if redacted:
|
||||
redacted[-1] = f"<prompt:{len(prompt.encode('utf-8'))}-bytes>"
|
||||
return redacted
|
||||
|
||||
def _prompt_text_from_task(self, task: Task | None) -> str:
|
||||
if task is None:
|
||||
return ""
|
||||
return self.build_task_prompt(task)
|
||||
|
||||
@classmethod
|
||||
def _tty_prompt_payload(cls, prompt: str) -> bytes:
|
||||
payload = prompt.encode("utf-8")
|
||||
if payload and not payload.endswith(b"\n"):
|
||||
payload += b"\n"
|
||||
return payload + cls._TTY_EOF
|
||||
|
||||
async def _seed_tty_prompt(self, proc: asyncio.subprocess.Process, prompt: str) -> None:
|
||||
input_fd = self._input_fds.get(proc.pid)
|
||||
if input_fd is None:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
self._write_input_bytes,
|
||||
input_fd,
|
||||
self._tty_prompt_payload(prompt),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _seed_pipe_prompt(
|
||||
proc: asyncio.subprocess.Process,
|
||||
prompt: str,
|
||||
) -> bool:
|
||||
"""Write *prompt* to the subprocess stdin pipe and close it.
|
||||
|
||||
Returns ``True`` when the full payload was delivered, ``False``
|
||||
if the pipe broke before delivery completed (the child may have
|
||||
exited early or rejected the data).
|
||||
"""
|
||||
writer = getattr(proc, "stdin", None)
|
||||
if writer is None:
|
||||
return False
|
||||
payload = prompt.encode("utf-8")
|
||||
delivered = True
|
||||
try:
|
||||
writer.write(payload)
|
||||
await writer.drain()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as exc:
|
||||
logger.warning(
|
||||
"Pipe prompt delivery failed ({} bytes planned): {}",
|
||||
len(payload),
|
||||
exc,
|
||||
)
|
||||
delivered = False
|
||||
writer.close()
|
||||
wait_closed = getattr(writer, "wait_closed", None)
|
||||
if callable(wait_closed):
|
||||
try:
|
||||
await wait_closed()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
delivered = False
|
||||
return delivered
|
||||
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
if str(self.config.session_mode or "").strip().lower() == "resume" and session_id:
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"exec",
|
||||
"resume",
|
||||
"--skip-git-repo-check",
|
||||
*self._build_resume_approval_args(),
|
||||
*self._build_resume_model_args(),
|
||||
session_id,
|
||||
self._PROMPT_SENTINEL,
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"exec",
|
||||
*self.build_workspace_args(workspace_path),
|
||||
*self._build_extra_dir_args(task),
|
||||
*self._build_writable_roots_config_args(task),
|
||||
"--skip-git-repo-check",
|
||||
*self._build_approval_args(),
|
||||
*self.build_common_args(),
|
||||
self._PROMPT_SENTINEL,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata.update(self._build_stdin_prompt_metadata(task))
|
||||
self._record_stdin_policy_metadata(
|
||||
metadata,
|
||||
self.stdin_policy_for_process(cmd, metadata),
|
||||
)
|
||||
if (
|
||||
metadata.get("stdin_policy") == "pipe_open"
|
||||
and self._uses_interactive_input_channel(cmd)
|
||||
and self._supports_pty_input_channel()
|
||||
):
|
||||
metadata["interactive_input_channel"] = "pty"
|
||||
return cmd, metadata
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
prompt = self._prompt_text_from_task(task)
|
||||
prompt_transport = self._interactive_prompt_transport(prompt)
|
||||
prompt_arg = prompt if prompt_transport == "argv" else self._PROMPT_SENTINEL
|
||||
if str(self.config.session_mode or "").strip().lower() == "resume" and session_id:
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"exec",
|
||||
"resume",
|
||||
"--skip-git-repo-check",
|
||||
"--json",
|
||||
*self._build_resume_approval_args(),
|
||||
*self._build_resume_model_args(),
|
||||
session_id,
|
||||
prompt_arg,
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"exec",
|
||||
*self.build_workspace_args(workspace_path),
|
||||
*self._build_extra_dir_args(task),
|
||||
*self._build_writable_roots_config_args(task),
|
||||
"--skip-git-repo-check",
|
||||
"--json",
|
||||
*self._build_approval_args(),
|
||||
*self.build_common_args(),
|
||||
prompt_arg,
|
||||
]
|
||||
# Small interactive prompts stay on argv because Codex CLI 0.130+
|
||||
# can exit before a PTY-backed `exec --json -` prompt is seeded. Large
|
||||
# prompts must not use argv: macOS/Linux command-line limits turn the
|
||||
# final-delivery assessment into an `Argument list too long` failure.
|
||||
if prompt_transport == "argv":
|
||||
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
|
||||
metadata.update(self._build_argv_prompt_metadata(prompt))
|
||||
else:
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata.update(self._build_stdin_prompt_metadata(task))
|
||||
metadata["stdin_prompt_channel"] = "pipe"
|
||||
metadata["prompt_transport_reason"] = (
|
||||
"prompt_too_large_for_argv"
|
||||
if len(prompt.encode("utf-8")) > self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES
|
||||
else "windows_multiline_argv_unsafe"
|
||||
)
|
||||
metadata["interactive_input_limitation"] = (
|
||||
"initial prompt is delivered through stdin; live approval replies "
|
||||
"may be unavailable after stdin closes"
|
||||
)
|
||||
self._record_stdin_policy_metadata(
|
||||
metadata,
|
||||
self.stdin_policy_for_process(cmd, metadata),
|
||||
)
|
||||
return cmd, metadata
|
||||
|
||||
def extract_resume_session_id(self, output: str) -> str:
|
||||
for line in output.splitlines():
|
||||
event = self._parse_runtime_event(line)
|
||||
if not event:
|
||||
continue
|
||||
if str(event.get("type") or "").strip() != "thread.started":
|
||||
continue
|
||||
thread_id = str(event.get("thread_id") or "").strip()
|
||||
if thread_id:
|
||||
return thread_id
|
||||
return super().extract_resume_session_id(output)
|
||||
|
||||
@classmethod
|
||||
def _parse_runtime_event(cls, text: str) -> dict[str, Any] | None:
|
||||
envelope = cls._parse_json_line(text)
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
event = envelope.get("msg") if isinstance(envelope.get("msg"), dict) else envelope
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
event_type = str(event.get("type") or "").strip()
|
||||
return event if event_type else None
|
||||
|
||||
@staticmethod
|
||||
def _trim_text(text: str, *, limit: int) -> str:
|
||||
stripped = str(text or "").strip()
|
||||
if len(stripped) <= limit:
|
||||
return stripped
|
||||
return stripped[: limit - 1].rstrip() + "…"
|
||||
|
||||
@classmethod
|
||||
def _command_summary(cls, item: dict[str, Any]) -> str:
|
||||
command = cls.normalize_shell_command(item.get("command"))
|
||||
if not command:
|
||||
command = str(item.get("command") or "").strip()
|
||||
if not command:
|
||||
return "command execution"
|
||||
return cls._trim_text(command.replace("\n", " "), limit=120)
|
||||
|
||||
@classmethod
|
||||
def _command_detail(cls, item: dict[str, Any], *, include_output: bool) -> str:
|
||||
parts: list[str] = []
|
||||
command = cls.normalize_shell_command(item.get("command"))
|
||||
if command:
|
||||
parts.append(f"$ {command}")
|
||||
raw_command = str(item.get("command") or "").strip()
|
||||
if not command and raw_command:
|
||||
parts.append(f"$ {raw_command}")
|
||||
|
||||
if include_output:
|
||||
output = str(item.get("aggregated_output") or "").strip()
|
||||
if output:
|
||||
parts.append(cls._trim_text(output, limit=cls._COMMAND_OUTPUT_LIMIT))
|
||||
status = str(item.get("status") or "").strip()
|
||||
exit_code = item.get("exit_code")
|
||||
status_bits: list[str] = []
|
||||
if status:
|
||||
status_bits.append(f"status={status}")
|
||||
if exit_code is not None:
|
||||
status_bits.append(f"exit_code={exit_code}")
|
||||
if status_bits:
|
||||
parts.append(", ".join(status_bits))
|
||||
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
@classmethod
|
||||
def normalize_transcript_text(cls, output: str) -> str:
|
||||
last_completed_agent_message = ""
|
||||
last_agent_message = ""
|
||||
|
||||
for line in output.splitlines():
|
||||
event = cls._parse_runtime_event(line)
|
||||
if not event:
|
||||
continue
|
||||
if str(event.get("type") or "").strip() not in {"item.started", "item.completed"}:
|
||||
continue
|
||||
item = event.get("item") if isinstance(event.get("item"), dict) else None
|
||||
if not isinstance(item, dict) or str(item.get("type") or "").strip() != "agent_message":
|
||||
continue
|
||||
text = str(item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
last_agent_message = text
|
||||
if str(event.get("type") or "").strip() == "item.completed":
|
||||
last_completed_agent_message = text
|
||||
|
||||
return last_completed_agent_message or last_agent_message or output
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
return self.normalize_transcript_text(output)
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
event = self._parse_runtime_event(text)
|
||||
if not event:
|
||||
return super().format_progress_update(text, stream_name)
|
||||
|
||||
event_type = str(event.get("type") or "").strip()
|
||||
if event_type in {"exec_approval_request", "apply_patch_approval_request"}:
|
||||
return None
|
||||
|
||||
if event_type not in {"item.started", "item.completed"}:
|
||||
return None
|
||||
|
||||
item = event.get("item") if isinstance(event.get("item"), dict) else None
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
item_type = str(item.get("type") or "").strip()
|
||||
if item_type == "agent_message" and event_type == "item.completed":
|
||||
message = self._trim_text(str(item.get("text") or ""), limit=2400)
|
||||
if not message:
|
||||
return None
|
||||
return f"[External:{self.agent_type}:thinking] {message}"
|
||||
|
||||
if item_type == "command_execution":
|
||||
detail = self._command_detail(item, include_output=event_type == "item.completed")
|
||||
if not detail:
|
||||
detail = self._command_summary(item)
|
||||
return f"[External:{self.agent_type}:tool] {detail}"
|
||||
|
||||
return None
|
||||
|
||||
def detect_runtime_failure(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
_ = stream_name
|
||||
if "Reading additional input from stdin..." not in str(text):
|
||||
return None
|
||||
policy = str((metadata or {}).get("stdin_policy") or "").strip()
|
||||
prompt_transport = str((metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
if prompt_transport == "stdin" or policy == "pipe_prompt_then_close":
|
||||
return None
|
||||
return (
|
||||
"Codex entered supplemental stdin intake mode (`Reading additional input from stdin...`) "
|
||||
"instead of executing the delegated task prompt."
|
||||
)
|
||||
|
||||
def _uses_interactive_input_channel(self, cmd: list[str]) -> bool:
|
||||
return "--json" in cmd
|
||||
|
||||
def stdin_policy_for_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ExternalAgentStdinPolicy:
|
||||
prompt_transport = str((metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
if prompt_transport == "stdin" or (cmd and str(cmd[-1]).strip() == self._PROMPT_SENTINEL):
|
||||
return "pipe_prompt_then_close"
|
||||
if self._uses_interactive_input_channel(cmd):
|
||||
return "pipe_open" if self._supports_pty_input_channel() else "inherit"
|
||||
return super().stdin_policy_for_process(cmd, metadata)
|
||||
|
||||
@staticmethod
|
||||
def _supports_pty_input_channel() -> bool:
|
||||
return callable(getattr(os, "openpty", None))
|
||||
|
||||
def _build_resume_model_args(self) -> list[str]:
|
||||
if not self.config.model:
|
||||
return []
|
||||
return [self.config.model_flag or "--model", self.config.model]
|
||||
|
||||
async def start_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
workspace_path: str,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
task: Task | None = None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
|
||||
env = self.build_process_env(extra_env)
|
||||
launch_cmd = self._resolve_launch_command(
|
||||
cmd,
|
||||
extra_env=extra_env,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
stdin_prompt_channel = str((launch_metadata or {}).get("stdin_prompt_channel") or "").strip().lower()
|
||||
stdin_policy = self.stdin_policy_for_process(launch_cmd, launch_metadata)
|
||||
|
||||
if not self._uses_interactive_input_channel(launch_cmd):
|
||||
proc = await super().start_process(
|
||||
launch_cmd,
|
||||
workspace_path,
|
||||
extra_env=extra_env,
|
||||
task=task,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
if prompt_transport == "stdin":
|
||||
delivered = await self._seed_pipe_prompt(proc, prompt)
|
||||
if not delivered:
|
||||
logger.warning(
|
||||
"Stdin prompt delivery to {} (pid={}) may be incomplete; "
|
||||
"the process might fail with an encoding error",
|
||||
self.agent_type,
|
||||
proc.pid,
|
||||
)
|
||||
return proc
|
||||
|
||||
if prompt_transport == "stdin" and stdin_prompt_channel == "pipe":
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
proc = await super().start_process(
|
||||
launch_cmd,
|
||||
workspace_path,
|
||||
extra_env=extra_env,
|
||||
task=task,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
delivered = await self._seed_pipe_prompt(proc, prompt)
|
||||
if not delivered:
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata["prompt_delivery_failed"] = True
|
||||
logger.warning(
|
||||
"Large stdin prompt delivery to {} (pid={}) may be incomplete",
|
||||
self.agent_type,
|
||||
proc.pid,
|
||||
)
|
||||
return proc
|
||||
|
||||
if not self._supports_pty_input_channel():
|
||||
if prompt_transport == "stdin":
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
launch_metadata["interactive_input_limitation"] = (
|
||||
"initial stdin prompt is delivered through a pipe on PTY-less platforms; "
|
||||
"live approval replies require a PTY-capable platform"
|
||||
)
|
||||
logger.info(
|
||||
"PTY-backed Codex input is unavailable on this platform; using stdin prompt "
|
||||
"pipe delivery. Live approval replies require a PTY-capable platform."
|
||||
)
|
||||
proc = await super().start_process(
|
||||
launch_cmd,
|
||||
workspace_path,
|
||||
extra_env=extra_env,
|
||||
task=task,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
delivered = await self._seed_pipe_prompt(proc, prompt)
|
||||
if not delivered:
|
||||
logger.warning(
|
||||
"Stdin prompt delivery to {} (pid={}) may be incomplete; "
|
||||
"the process might fail with an encoding error",
|
||||
self.agent_type,
|
||||
proc.pid,
|
||||
)
|
||||
return proc
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
launch_metadata["interactive_input_limitation"] = (
|
||||
"stdin is inherited for argv prompt delivery on PTY-less platforms; "
|
||||
"live approval replies require a PTY-capable platform"
|
||||
)
|
||||
logger.info(
|
||||
"PTY-backed Codex input is unavailable on this platform; using argv prompt "
|
||||
"delivery with inherited stdin. Live approval replies require a PTY-capable platform."
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*launch_cmd,
|
||||
stdin=None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
env=env,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
return proc
|
||||
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
launch_metadata["interactive_input_channel"] = "pty"
|
||||
master_fd, slave_fd = os.openpty()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*launch_cmd,
|
||||
stdin=slave_fd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
env=env,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
except Exception:
|
||||
os.close(master_fd)
|
||||
os.close(slave_fd)
|
||||
raise
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(slave_fd)
|
||||
|
||||
self._input_fds[proc.pid] = master_fd
|
||||
if prompt_transport == "stdin":
|
||||
try:
|
||||
await self._seed_tty_prompt(proc, prompt)
|
||||
except OSError:
|
||||
self._input_fds.pop(proc.pid, None)
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
raise
|
||||
return proc
|
||||
|
||||
async def send_process_input(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
text: str,
|
||||
) -> bool:
|
||||
if not text:
|
||||
return True
|
||||
input_fd = self._input_fds.get(proc.pid)
|
||||
if input_fd is None:
|
||||
return await super().send_process_input(proc, text)
|
||||
payload = text.encode("utf-8")
|
||||
try:
|
||||
await asyncio.to_thread(self._write_input_bytes, input_fd, payload)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def cleanup_process(self, proc: asyncio.subprocess.Process) -> None:
|
||||
input_fd = self._input_fds.pop(proc.pid, None)
|
||||
if input_fd is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(input_fd)
|
||||
await super().cleanup_process(proc)
|
||||
|
||||
@staticmethod
|
||||
def _write_input_bytes(input_fd: int, payload: bytes) -> None:
|
||||
written = 0
|
||||
while written < len(payload):
|
||||
written += os.write(input_fd, payload[written:])
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
envelope = self._parse_json_line(text)
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
|
||||
event = envelope.get("msg") if isinstance(envelope.get("msg"), dict) else envelope
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
|
||||
event_type = str(event.get("type") or "").strip()
|
||||
common_metadata = {
|
||||
"stream": stream_name,
|
||||
"provider_event_type": event_type,
|
||||
"approval_id": str(event.get("call_id") or event.get("id") or ""),
|
||||
"turn_id": str(event.get("turn_id") or ""),
|
||||
"raw_event": event,
|
||||
}
|
||||
|
||||
if event_type == "exec_approval_request":
|
||||
command = self.normalize_shell_command(event.get("command"))
|
||||
arguments: dict[str, object] = {}
|
||||
if command:
|
||||
arguments["command"] = command
|
||||
cwd = str(event.get("cwd") or "").strip()
|
||||
if cwd:
|
||||
arguments["working_directory"] = cwd
|
||||
prompt_text = str(event.get("reason") or "").strip()
|
||||
if not prompt_text and command:
|
||||
prompt_text = f"Allow Codex to run `{command}`?"
|
||||
metadata = {
|
||||
**common_metadata,
|
||||
"cwd": cwd,
|
||||
"command": command,
|
||||
"network_approval_context": event.get("network_approval_context"),
|
||||
"additional_permissions": event.get("additional_permissions"),
|
||||
"proposed_execpolicy_amendment": event.get("proposed_execpolicy_amendment"),
|
||||
"proposed_network_policy_amendments": event.get("proposed_network_policy_amendments"),
|
||||
"available_decisions": event.get("available_decisions"),
|
||||
}
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="shell_exec",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if event_type == "apply_patch_approval_request":
|
||||
changes = event.get("changes")
|
||||
paths = sorted(str(path) for path in changes.keys()) if isinstance(changes, dict) else []
|
||||
grant_root = str(event.get("grant_root") or "").strip()
|
||||
arguments: dict[str, object] = {}
|
||||
if grant_root:
|
||||
arguments["path"] = grant_root
|
||||
elif len(paths) == 1:
|
||||
arguments["path"] = paths[0]
|
||||
elif paths:
|
||||
arguments["target"] = paths[0]
|
||||
prompt_text = str(event.get("reason") or "").strip()
|
||||
if not prompt_text:
|
||||
prompt_text = "Allow Codex to apply file changes?"
|
||||
metadata = {
|
||||
**common_metadata,
|
||||
"grant_root": grant_root,
|
||||
"paths": paths,
|
||||
"available_decisions": event.get("available_decisions"),
|
||||
}
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_edit",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
# Codex interactive runs are launched with `--json`, so real approval
|
||||
# prompts arrive as structured approval_request events. Falling back to
|
||||
# the generic parser here causes ordinary command logs or sandbox error
|
||||
# text to be misclassified as approval prompts.
|
||||
return None
|
||||
|
||||
def format_approval_response(
|
||||
self,
|
||||
request: ExternalApprovalRequest,
|
||||
approved: bool,
|
||||
decision: ApprovalDecision,
|
||||
) -> str:
|
||||
event_type = str(request.metadata.get("provider_event_type") or "").strip()
|
||||
if event_type not in {"exec_approval_request", "apply_patch_approval_request"}:
|
||||
return super().format_approval_response(request, approved, decision)
|
||||
|
||||
approval_id = str(request.metadata.get("approval_id") or "").strip()
|
||||
if not approval_id:
|
||||
return super().format_approval_response(request, approved, decision)
|
||||
|
||||
op = {
|
||||
"type": "exec_approval" if event_type == "exec_approval_request" else "patch_approval",
|
||||
"id": approval_id,
|
||||
"decision": self._review_decision_payload(request, approved, decision),
|
||||
}
|
||||
turn_id = str(request.metadata.get("turn_id") or "").strip()
|
||||
if event_type == "exec_approval_request" and turn_id:
|
||||
op["turn_id"] = turn_id
|
||||
return json.dumps({"id": str(uuid.uuid4()), "op": op}, ensure_ascii=False) + "\n"
|
||||
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
if not await self.is_available():
|
||||
return TaskResult(status=TaskStatus.FAILED, content="Codex CLI not found")
|
||||
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
logger.info(f"Codex executing: {task.title}")
|
||||
|
||||
try:
|
||||
self._process = await self.start_process(
|
||||
cmd,
|
||||
workspace_path,
|
||||
task=task,
|
||||
launch_metadata=metadata,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
self._process.communicate(), timeout=600
|
||||
)
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if self._process.returncode == 0:
|
||||
return TaskResult(status=TaskStatus.DONE, content=output, artifacts=metadata)
|
||||
else:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Codex exited with code {self._process.returncode}\n{errors}\n{output}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if self._process:
|
||||
self._process.kill()
|
||||
return TaskResult(status=TaskStatus.FAILED, content="Codex timed out", artifacts=metadata)
|
||||
except Exception as e:
|
||||
return TaskResult(status=TaskStatus.FAILED, content=f"Codex error: {e}", artifacts=metadata)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_approval_args(self) -> list[str]:
|
||||
common_args = self.build_common_args()
|
||||
# Skip injection if the user has manually configured sandbox / approval
|
||||
# flags via `extra_args` so we don't trample explicit overrides.
|
||||
conflict_markers = {
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--full-auto",
|
||||
"-s",
|
||||
"--sandbox",
|
||||
}
|
||||
for arg in common_args:
|
||||
if arg in conflict_markers:
|
||||
return []
|
||||
if arg.startswith("--sandbox="):
|
||||
return []
|
||||
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "user-settings":
|
||||
return []
|
||||
if mode == "full-auto":
|
||||
return ["--dangerously-bypass-approvals-and-sandbox"]
|
||||
return ["--sandbox", "danger-full-access"]
|
||||
|
||||
def _build_resume_approval_args(self) -> list[str]:
|
||||
common_args = self.build_common_args()
|
||||
if any(arg in {"--dangerously-bypass-approvals-and-sandbox", "--full-auto"} for arg in common_args):
|
||||
return []
|
||||
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "user-settings":
|
||||
return []
|
||||
if mode == "full-auto":
|
||||
return ["--dangerously-bypass-approvals-and-sandbox"]
|
||||
|
||||
# `codex exec resume` does not accept the `--sandbox` flag that plain
|
||||
# `codex exec` supports, but it does accept config overrides.
|
||||
return ["-c", 'sandbox_mode="danger-full-access"']
|
||||
|
||||
def _review_decision_payload(
|
||||
self,
|
||||
request: ExternalApprovalRequest,
|
||||
approved: bool,
|
||||
decision: ApprovalDecision,
|
||||
) -> str | dict[str, object]:
|
||||
if not approved:
|
||||
return "denied"
|
||||
|
||||
human_reply = str((decision.metadata or {}).get("human_reply") or "").strip().lower()
|
||||
if human_reply not in {"always_project", "always_global"}:
|
||||
return "approved"
|
||||
|
||||
event_type = str(request.metadata.get("provider_event_type") or "").strip()
|
||||
if event_type == "exec_approval_request":
|
||||
proposed_execpolicy = request.metadata.get("proposed_execpolicy_amendment")
|
||||
if isinstance(proposed_execpolicy, dict):
|
||||
return {
|
||||
"approved_execpolicy_amendment": {
|
||||
"proposed_execpolicy_amendment": proposed_execpolicy,
|
||||
}
|
||||
}
|
||||
|
||||
amendments = request.metadata.get("proposed_network_policy_amendments")
|
||||
if isinstance(amendments, list):
|
||||
for amendment in amendments:
|
||||
if isinstance(amendment, dict) and str(amendment.get("action") or "").lower() == "allow":
|
||||
return {"network_policy_amendment": {"network_policy_amendment": amendment}}
|
||||
|
||||
if request.metadata.get("network_approval_context") or request.metadata.get("additional_permissions"):
|
||||
return "approved_for_session"
|
||||
|
||||
if event_type == "apply_patch_approval_request" and request.metadata.get("grant_root"):
|
||||
return "approved_for_session"
|
||||
return "approved"
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Cursor adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import AgentStatus, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.adapters.base import ExternalAgentAdapter, ExternalApprovalRequest
|
||||
|
||||
|
||||
class CursorAdapter(ExternalAgentAdapter):
|
||||
"""Invokes local Cursor for programming tasks via CLI."""
|
||||
|
||||
agent_type = "cursor"
|
||||
default_command = "cursor-agent"
|
||||
|
||||
def __init__(self, config=None) -> None:
|
||||
super().__init__(config=config)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._thinking_buffers: dict[str, list[str]] = {}
|
||||
|
||||
def resolve_binary(self) -> str | None:
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
for candidate in self._candidate_commands():
|
||||
resolved = shutil.which(candidate)
|
||||
if not resolved:
|
||||
continue
|
||||
if candidate == "cursor":
|
||||
# The editor CLI is not sufficient for headless agent execution.
|
||||
continue
|
||||
return resolved
|
||||
return None
|
||||
|
||||
def _runtime_command(self) -> str | None:
|
||||
for candidate in self._candidate_commands():
|
||||
if candidate == "cursor":
|
||||
continue
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _candidate_commands(self) -> list[str]:
|
||||
configured = str(self.configured_command() or "").strip()
|
||||
candidates: list[str] = []
|
||||
if configured:
|
||||
if configured == "cursor":
|
||||
candidates.append("cursor-agent")
|
||||
candidates.append(configured)
|
||||
else:
|
||||
candidates.extend(["cursor-agent", "cursor"])
|
||||
if "cursor-agent" not in candidates:
|
||||
candidates.insert(0, "cursor-agent")
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return self.resolve_binary() is not None
|
||||
|
||||
async def get_status(self) -> AgentStatus:
|
||||
if self._process and self._process.returncode is None:
|
||||
return AgentStatus.RUNNING
|
||||
return AgentStatus.IDLE
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return self._runtime_command() is not None
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_approval_prompt_handling(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Cursor's stream-json interaction events are not stdin prompts.
|
||||
|
||||
Cursor emits ``interaction_query`` request/response JSON for internal
|
||||
tool approvals, and the CLI answers those events itself. Treating that
|
||||
stream as a generic stdin approval prompt creates stale OpenOPC cards.
|
||||
"""
|
||||
_ = cmd
|
||||
_ = metadata
|
||||
return False
|
||||
|
||||
def agent_isolation_home_slug(self) -> str:
|
||||
return "cursor"
|
||||
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
_ = workspace_path
|
||||
prompt = self.build_task_prompt(task)
|
||||
command = self._runtime_command() or self.configured_command()
|
||||
cmd = [
|
||||
command,
|
||||
"-p",
|
||||
"--output-format",
|
||||
"text",
|
||||
*self._build_workspace_trust_args(),
|
||||
*self._build_approval_args(),
|
||||
*self._build_model_args(),
|
||||
*self._build_session_args(),
|
||||
*list(self.config.extra_args),
|
||||
prompt,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata["binary"] = command
|
||||
return cmd, metadata
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
_ = workspace_path
|
||||
prompt = self.build_task_prompt(task)
|
||||
command = self._runtime_command() or self.configured_command()
|
||||
cmd = [
|
||||
command,
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
*self._build_workspace_trust_args(),
|
||||
*self._build_approval_args(),
|
||||
*self._build_model_args(),
|
||||
*self._build_session_args(),
|
||||
*list(self.config.extra_args),
|
||||
prompt,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata["binary"] = command
|
||||
return cmd, metadata
|
||||
|
||||
def extract_resume_session_id(self, output: str) -> str:
|
||||
for line in output.splitlines():
|
||||
event = self._parse_json_line(line)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
token = self._session_id_from_event(event)
|
||||
if token:
|
||||
return token
|
||||
return super().extract_resume_session_id(output)
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
last_result = ""
|
||||
last_assistant = ""
|
||||
for line in output.splitlines():
|
||||
event = self._parse_json_line(line)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
if event_type == "result":
|
||||
text = self._event_text(event)
|
||||
if text:
|
||||
last_result = text
|
||||
elif self._event_role(event) == "assistant" or event_type in {"assistant", "assistant_message"}:
|
||||
text = self._event_text(event)
|
||||
if text:
|
||||
last_assistant = text
|
||||
return last_result or last_assistant or output
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
if stream_name != "stdout":
|
||||
stripped = str(text or "").strip()
|
||||
return f"[External:{self.agent_type}:stderr] {stripped[:500]}" if stripped else None
|
||||
|
||||
event = self._parse_json_line(text)
|
||||
if not isinstance(event, dict):
|
||||
return super().format_progress_update(text, stream_name)
|
||||
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
if event_type in {"system", "init", "session"}:
|
||||
session_id = self._session_id_from_event(event)
|
||||
return (
|
||||
f"[External:{self.agent_type}:init] session={session_id[:8]}"
|
||||
if session_id
|
||||
else None
|
||||
)
|
||||
if "approval" in event_type or "permission" in event_type:
|
||||
return None
|
||||
if "tool" in event_type or "command" in event_type:
|
||||
summary = self._tool_summary(event)
|
||||
return f"[External:{self.agent_type}:tool] {summary}" if summary else None
|
||||
if event_type == "thinking":
|
||||
return self._format_thinking_progress(event)
|
||||
if event_type == "result":
|
||||
result = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {result[:2400]}" if result else None
|
||||
if self._event_role(event) == "assistant" or event_type in {"assistant", "assistant_message"}:
|
||||
message = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
return None
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
event = self._parse_json_line(text)
|
||||
if isinstance(event, dict):
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
# Cursor stream-json uses these event types for normal execution.
|
||||
# Some payloads contain words such as "approved" or "allow" in web
|
||||
# content, so the generic parser must not infer an OpenOPC approval
|
||||
# card from them.
|
||||
if event_type in {
|
||||
"assistant",
|
||||
"assistant_message",
|
||||
"interaction_query",
|
||||
"result",
|
||||
"system",
|
||||
"thinking",
|
||||
"tool_call",
|
||||
"user",
|
||||
}:
|
||||
return None
|
||||
return super().parse_approval_request(text, stream_name)
|
||||
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
if not await self.is_available():
|
||||
return TaskResult(status=TaskStatus.FAILED, content="Cursor agent CLI not found")
|
||||
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
logger.info(f"Cursor executing: {task.title}")
|
||||
|
||||
try:
|
||||
stdin_policy = self.stdin_policy_for_process(cmd, metadata)
|
||||
self._record_stdin_policy_metadata(metadata, stdin_policy)
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=self._stdin_target_for_policy(stdin_policy),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
self._process.communicate(), timeout=600
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if self._process.returncode == 0:
|
||||
return TaskResult(status=TaskStatus.DONE, content=output, artifacts=metadata)
|
||||
else:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Cursor exited with code {self._process.returncode}\n{errors}\n{output}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if self._process:
|
||||
self._process.kill()
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content="Cursor timed out after 600s",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Cursor error: {e}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_model_args(self) -> list[str]:
|
||||
if not self.config.model:
|
||||
return []
|
||||
extra_args = list(self.config.extra_args)
|
||||
if self.config.model_flag and any(
|
||||
arg == self.config.model_flag or arg.startswith(f"{self.config.model_flag}=")
|
||||
for arg in extra_args
|
||||
):
|
||||
return []
|
||||
flag = self.config.model_flag or "-m"
|
||||
return [flag, self.config.model]
|
||||
|
||||
def _build_session_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(
|
||||
arg == "--resume" or arg.startswith("--resume=")
|
||||
for arg in extra_args
|
||||
):
|
||||
return []
|
||||
|
||||
mode = str(self.config.session_mode or "auto").strip().lower()
|
||||
if mode == "resume":
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
if session_id:
|
||||
return ["--resume", session_id]
|
||||
return []
|
||||
if mode == "new" and self.config.new_session_flag:
|
||||
return [self.config.new_session_flag]
|
||||
return []
|
||||
|
||||
def _build_workspace_trust_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg in {"--trust", "--yolo", "-f", "--force"} for arg in extra_args):
|
||||
return []
|
||||
return ["--trust"]
|
||||
|
||||
def _build_approval_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg in {"-f", "--force"} for arg in extra_args):
|
||||
return []
|
||||
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "full-auto":
|
||||
return ["--force"]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _session_id_from_event(cls, event: dict[str, Any]) -> str:
|
||||
for key in ("session_id", "sessionId", "sessionID", "chat_id", "chatId", "conversation_id", "thread_id"):
|
||||
token = str(event.get(key) or "").strip()
|
||||
if token:
|
||||
return token
|
||||
for key in ("message", "data", "result"):
|
||||
nested = event.get(key)
|
||||
if isinstance(nested, dict):
|
||||
token = cls._session_id_from_event(nested)
|
||||
if token:
|
||||
return token
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _event_role(event: dict[str, Any]) -> str:
|
||||
role = str(event.get("role") or "").strip().lower()
|
||||
if role:
|
||||
return role
|
||||
message = event.get("message")
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("role") or "").strip().lower()
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _event_text(cls, event: Any) -> str:
|
||||
if isinstance(event, str):
|
||||
return event.strip()
|
||||
if isinstance(event, list):
|
||||
parts = [cls._event_text(item) for item in event]
|
||||
return "\n".join(part for part in parts if part).strip()
|
||||
if not isinstance(event, dict):
|
||||
return ""
|
||||
|
||||
for key in ("result", "text", "message", "content", "summary"):
|
||||
value = event.get(key)
|
||||
if key == "message" and isinstance(value, dict):
|
||||
nested = cls._event_text(value)
|
||||
if nested:
|
||||
return nested
|
||||
elif isinstance(value, (str, list, dict)):
|
||||
nested = cls._event_text(value)
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _tool_summary(cls, event: dict[str, Any]) -> str:
|
||||
source = event.get("tool_call") if isinstance(event.get("tool_call"), dict) else event
|
||||
nested_name, nested_payload = cls._nested_cursor_tool_payload(source)
|
||||
if nested_payload:
|
||||
return cls._nested_cursor_tool_summary(nested_name, nested_payload)
|
||||
|
||||
name = str(
|
||||
source.get("name")
|
||||
or source.get("tool_name")
|
||||
or source.get("toolName")
|
||||
or source.get("type")
|
||||
or "tool"
|
||||
).strip()
|
||||
args = source.get("input") or source.get("arguments") or source.get("params") or {}
|
||||
if isinstance(args, dict):
|
||||
command = cls.normalize_shell_command(
|
||||
args.get("command") or args.get("cmd") or args.get("argv")
|
||||
)
|
||||
if command:
|
||||
return f"$ {command[:240]}"
|
||||
path = str(args.get("path") or args.get("file_path") or args.get("target") or "").strip()
|
||||
if path:
|
||||
return f"{name} {path}"
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def _nested_cursor_tool_payload(source: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
for key, value in source.items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
normalized = key[:-8] if key.endswith("ToolCall") else key
|
||||
if key.endswith("ToolCall") or "args" in value or "result" in value:
|
||||
return normalized, value
|
||||
return "", {}
|
||||
|
||||
@classmethod
|
||||
def _nested_cursor_tool_summary(cls, name: str, payload: dict[str, Any]) -> str:
|
||||
args = payload.get("args") if isinstance(payload.get("args"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
lines: list[str] = []
|
||||
normalized_name = cls._normalize_name(name)
|
||||
readable_name = cls._readable_tool_name(name)
|
||||
command = cls.normalize_shell_command(
|
||||
args.get("command") or args.get("cmd") or args.get("argv")
|
||||
)
|
||||
if command:
|
||||
lines.append(f"$ {command[:240]}")
|
||||
elif normalized_name in {"websearch", "websearchrequest"}:
|
||||
query = str(args.get("searchTerm") or args.get("query") or args.get("q") or "").strip()
|
||||
lines.append(f"web search: {query}".strip())
|
||||
elif normalized_name in {"webfetch", "webfetchrequest"}:
|
||||
url = str(args.get("url") or args.get("uri") or args.get("target") or "").strip()
|
||||
lines.append(f"web fetch: {url}".strip())
|
||||
elif name:
|
||||
target = str(
|
||||
args.get("path")
|
||||
or args.get("filePath")
|
||||
or args.get("targetDirectory")
|
||||
or args.get("target")
|
||||
or args.get("globPattern")
|
||||
or ""
|
||||
).strip()
|
||||
lines.append(f"{readable_name} {target}".strip())
|
||||
|
||||
description = str(payload.get("description") or args.get("description") or "").strip()
|
||||
if description and (not lines or description not in lines[0]):
|
||||
lines.append(description[:240])
|
||||
|
||||
result_text = cls._cursor_result_text(result, tool_name=name)
|
||||
if result_text:
|
||||
lines.append(result_text[:1200])
|
||||
return "\n".join(line for line in lines if line).strip() or readable_name or "tool"
|
||||
|
||||
@classmethod
|
||||
def _cursor_result_text(cls, result: dict[str, Any], tool_name: str = "") -> str:
|
||||
if not result:
|
||||
return ""
|
||||
success = result.get("success") if isinstance(result.get("success"), dict) else None
|
||||
rejected = result.get("rejected") if isinstance(result.get("rejected"), dict) else None
|
||||
error = result.get("error") if isinstance(result.get("error"), dict) else None
|
||||
payload = success or rejected or error or result
|
||||
normalized_name = cls._normalize_name(tool_name)
|
||||
if rejected is not None:
|
||||
command = str(payload.get("command") or "").strip()
|
||||
reason = str(payload.get("reason") or "rejected").strip()
|
||||
return f"rejected: {command or reason}".strip()
|
||||
if normalized_name in {"websearch", "websearchrequest"}:
|
||||
return cls._summarize_web_search_result(payload)
|
||||
if normalized_name in {"webfetch", "webfetchrequest"}:
|
||||
return cls._summarize_web_fetch_result(payload)
|
||||
for key in ("stdout", "output", "text", "content", "message"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
files = payload.get("files")
|
||||
if isinstance(files, list):
|
||||
shown = [str(item) for item in files[:20]]
|
||||
total = payload.get("totalFiles")
|
||||
suffix = f" ({total} total)" if total is not None else ""
|
||||
return "\n".join(shown) + suffix
|
||||
if error is not None:
|
||||
return f"error: {payload}"
|
||||
return ""
|
||||
|
||||
def _format_thinking_progress(self, event: dict[str, Any]) -> str | None:
|
||||
subtype = str(event.get("subtype") or event.get("status") or "").strip().lower()
|
||||
message = self._event_text(event)
|
||||
key = self._thinking_buffer_key(event)
|
||||
if subtype == "delta":
|
||||
delta = self._thinking_delta_text(event)
|
||||
if delta:
|
||||
self._thinking_buffers.setdefault(key, []).append(delta)
|
||||
return None
|
||||
if subtype in {"completed", "complete", "done"}:
|
||||
buffered = "".join(self._thinking_buffers.pop(key, []))
|
||||
message = (message or buffered).strip()
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
|
||||
@staticmethod
|
||||
def _thinking_delta_text(event: dict[str, Any]) -> str:
|
||||
value = event.get("text")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return CursorAdapter._event_text(event)
|
||||
|
||||
@classmethod
|
||||
def _thinking_buffer_key(cls, event: dict[str, Any]) -> str:
|
||||
parts = [
|
||||
str(event.get(key) or "").strip()
|
||||
for key in ("session_id", "sessionId", "sessionID", "model_call_id", "request_id")
|
||||
if str(event.get(key) or "").strip()
|
||||
]
|
||||
return ":".join(parts) or "default"
|
||||
|
||||
@staticmethod
|
||||
def _readable_tool_name(name: str) -> str:
|
||||
raw = str(name or "tool").strip()
|
||||
raw = raw[:-8] if raw.endswith("ToolCall") else raw
|
||||
spaced = re.sub(r"(?<!^)(?=[A-Z])", " ", raw).strip().lower()
|
||||
return spaced or "tool"
|
||||
|
||||
@classmethod
|
||||
def _summarize_web_search_result(cls, payload: dict[str, Any]) -> str:
|
||||
references = payload.get("references")
|
||||
if not isinstance(references, list):
|
||||
return cls._summarize_markdown_text(payload.get("markdown") or payload.get("text") or "")
|
||||
|
||||
lines: list[str] = []
|
||||
for ref in references[:5]:
|
||||
if not isinstance(ref, dict):
|
||||
continue
|
||||
title = str(ref.get("title") or "").strip()
|
||||
url = str(ref.get("url") or "").strip()
|
||||
chunk = str(ref.get("chunk") or "").strip()
|
||||
if title and title.lower() != "web search results" and url:
|
||||
lines.append(f"- {title} — {url}")
|
||||
elif title and title.lower() != "web search results":
|
||||
lines.append(f"- {title}")
|
||||
elif url:
|
||||
lines.append(f"- {url}")
|
||||
else:
|
||||
lines.extend(cls._extract_markdown_links(chunk, limit=max(0, 5 - len(lines))))
|
||||
if len(lines) >= 5:
|
||||
break
|
||||
|
||||
if lines:
|
||||
return "\n".join(lines[:5])
|
||||
chunks = [
|
||||
cls._summarize_markdown_text(str(ref.get("chunk") or ""))
|
||||
for ref in references[:2]
|
||||
if isinstance(ref, dict)
|
||||
]
|
||||
return "\n".join(chunk for chunk in chunks if chunk).strip()
|
||||
|
||||
@classmethod
|
||||
def _summarize_web_fetch_result(cls, payload: dict[str, Any]) -> str:
|
||||
markdown = str(
|
||||
payload.get("markdown")
|
||||
or payload.get("content")
|
||||
or payload.get("text")
|
||||
or ""
|
||||
).strip()
|
||||
return cls._summarize_markdown_text(markdown)
|
||||
|
||||
@staticmethod
|
||||
def _extract_markdown_links(text: str, limit: int = 5) -> list[str]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
links: list[str] = []
|
||||
for title, url in re.findall(r"\[[^\]\n]*?([^\]\n]+)\]\((https?://[^)\s]+)\)", text):
|
||||
clean_title = str(title or "").strip()
|
||||
clean_url = str(url or "").strip()
|
||||
if clean_title and clean_url:
|
||||
links.append(f"- {clean_title} — {clean_url}")
|
||||
if len(links) >= limit:
|
||||
break
|
||||
return links
|
||||
|
||||
@staticmethod
|
||||
def _summarize_markdown_text(text: str, max_lines: int = 8) -> str:
|
||||
lines: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_line in str(text or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if len(line) > 180:
|
||||
line = line[:177].rstrip() + "..."
|
||||
fingerprint = line.lower()
|
||||
if fingerprint in seen:
|
||||
continue
|
||||
seen.add(fingerprint)
|
||||
lines.append(line)
|
||||
if len(lines) >= max_lines:
|
||||
break
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,829 @@
|
||||
"""OpenCode CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import AgentStatus, ApprovalDecision, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.adapters.base import (
|
||||
ExternalAgentAdapter,
|
||||
ExternalAgentStdinPolicy,
|
||||
ExternalApprovalRequest,
|
||||
)
|
||||
|
||||
|
||||
class OpenCodeAdapter(ExternalAgentAdapter):
|
||||
"""Invokes the OpenCode CLI via ``opencode run``."""
|
||||
|
||||
agent_type = "opencode"
|
||||
default_command = "opencode"
|
||||
_permission_handler_support_cache: dict[str, bool] = {}
|
||||
|
||||
def __init__(self, config=None) -> None:
|
||||
super().__init__(config=config)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
|
||||
def resolve_binary(self) -> str | None:
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
for candidate in self._candidate_commands():
|
||||
resolved = self._resolve_command_candidate(candidate)
|
||||
if resolved:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
def _runtime_command(self) -> str:
|
||||
return self.resolve_binary() or self.configured_command()
|
||||
|
||||
def _candidate_commands(self) -> list[str]:
|
||||
configured = str(self.configured_command() or "").strip()
|
||||
candidates: list[str] = []
|
||||
if configured:
|
||||
candidates.append(configured)
|
||||
env_binary = str(os.environ.get("OPENCODE_BIN") or "").strip()
|
||||
if env_binary:
|
||||
candidates.append(env_binary)
|
||||
candidates.extend([
|
||||
str(Path.home() / ".opencode" / "bin" / "opencode"),
|
||||
str(Path.home() / ".local" / "bin" / "opencode"),
|
||||
"opencode",
|
||||
])
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_command_candidate(candidate: str) -> str | None:
|
||||
raw = str(candidate or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
expanded = Path(raw).expanduser()
|
||||
if expanded.is_absolute() or os.sep in raw:
|
||||
return str(expanded) if expanded.is_file() and os.access(expanded, os.X_OK) else None
|
||||
return shutil.which(raw)
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return self.resolve_binary() is not None
|
||||
|
||||
async def get_status(self) -> AgentStatus:
|
||||
if self._process and self._process.returncode is None:
|
||||
return AgentStatus.RUNNING
|
||||
return AgentStatus.IDLE
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_resume_without_session_id(self) -> bool:
|
||||
return True
|
||||
|
||||
def agent_isolation_home_slug(self) -> str:
|
||||
# OpenCode discovers project/user config, agents, commands, plugins,
|
||||
# and skills from OPENCODE_CONFIG_DIR. Point spawned OpenCode at an
|
||||
# isolated config dir so the opc-collab skill is discoverable without
|
||||
# mutating the user's normal OpenCode config directory.
|
||||
return "opencode"
|
||||
|
||||
def agent_home_env_vars(self, home: str) -> dict[str, str]:
|
||||
return {"OPENCODE_CONFIG_DIR": home}
|
||||
|
||||
def post_install_agent_home(self, home: str) -> None:
|
||||
target_home = Path(home)
|
||||
target_home.mkdir(parents=True, exist_ok=True)
|
||||
source_home = self._user_config_dir(target_home)
|
||||
if source_home is None:
|
||||
return
|
||||
|
||||
for file_name in ("opencode.json", "opencode.jsonc"):
|
||||
self._mirror_user_config_path(source_home / file_name, target_home / file_name)
|
||||
for dir_name in ("agent", "agents", "command", "commands", "plugin", "plugins"):
|
||||
self._mirror_user_config_path(source_home / dir_name, target_home / dir_name)
|
||||
|
||||
@staticmethod
|
||||
def _user_config_dir(target_home: Path) -> Path | None:
|
||||
candidates: list[Path] = []
|
||||
raw_env = str(os.environ.get("OPENCODE_CONFIG_DIR") or "").strip()
|
||||
if raw_env:
|
||||
candidates.append(Path(raw_env).expanduser())
|
||||
xdg_config_home = str(os.environ.get("XDG_CONFIG_HOME") or "").strip()
|
||||
if xdg_config_home:
|
||||
candidates.append(Path(xdg_config_home).expanduser() / "opencode")
|
||||
candidates.append(Path.home() / ".config" / "opencode")
|
||||
|
||||
target_resolved = target_home.expanduser().resolve()
|
||||
for candidate in candidates:
|
||||
try:
|
||||
resolved = candidate.expanduser().resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if resolved == target_resolved:
|
||||
continue
|
||||
if resolved.exists():
|
||||
return resolved
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _mirror_user_config_path(source: Path, target: Path) -> None:
|
||||
if not source.exists():
|
||||
return
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists() or target.is_symlink():
|
||||
return
|
||||
try:
|
||||
target.symlink_to(source, target_is_directory=source.is_dir())
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
if source.is_dir():
|
||||
import shutil as _shutil
|
||||
|
||||
_shutil.copytree(source, target, dirs_exist_ok=True)
|
||||
else:
|
||||
target.write_bytes(source.read_bytes())
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Unable to mirror OpenCode config path {} into isolated home: {}",
|
||||
source,
|
||||
exc,
|
||||
)
|
||||
|
||||
def build_process_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str] | None:
|
||||
env = super().build_process_env(extra_env)
|
||||
config_dir = str((env or os.environ).get("OPENCODE_CONFIG_DIR") or "").strip()
|
||||
if config_dir:
|
||||
try:
|
||||
Path(config_dir).expanduser().mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Unable to create OPENCODE_CONFIG_DIR {}: {}", config_dir, exc)
|
||||
if str(self.config.approval_mode or "auto").strip().lower() != "full-auto":
|
||||
return env
|
||||
|
||||
merged = dict(os.environ if env is None else env)
|
||||
inline_config: dict[str, object] = {}
|
||||
raw_inline = str(merged.get("OPENCODE_CONFIG_CONTENT") or "").strip()
|
||||
if raw_inline:
|
||||
try:
|
||||
parsed = json.loads(raw_inline)
|
||||
if isinstance(parsed, dict):
|
||||
inline_config = dict(parsed)
|
||||
except Exception:
|
||||
inline_config = {}
|
||||
inline_config["permission"] = "allow"
|
||||
merged["OPENCODE_CONFIG_CONTENT"] = json.dumps(
|
||||
inline_config,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return merged
|
||||
|
||||
def stdin_policy_for_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ExternalAgentStdinPolicy:
|
||||
_ = metadata
|
||||
has_permission_handler = any(
|
||||
arg == "--permission-handler" or arg.startswith("--permission-handler=")
|
||||
for arg in cmd
|
||||
)
|
||||
return "pipe_open" if has_permission_handler else "devnull"
|
||||
|
||||
def supports_approval_prompt_handling(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
mode = str(
|
||||
(metadata or {}).get("approval_mode")
|
||||
or self.config.approval_mode
|
||||
or "auto"
|
||||
).strip().lower()
|
||||
if mode == "full-auto" or "--dangerously-skip-permissions" in cmd:
|
||||
return False
|
||||
return self.stdin_policy_for_process(cmd, metadata) == "pipe_open"
|
||||
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
_ = workspace_path
|
||||
prompt = self.build_task_prompt(task)
|
||||
command = self._runtime_command()
|
||||
cmd = [
|
||||
command,
|
||||
"run",
|
||||
"--format",
|
||||
"default",
|
||||
*self._build_approval_args(),
|
||||
*self._build_thinking_args(),
|
||||
*self._build_session_args(),
|
||||
*self._build_model_args(),
|
||||
*list(self.config.extra_args),
|
||||
prompt,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata["binary"] = command
|
||||
return cmd, metadata
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
_ = workspace_path
|
||||
prompt = self.build_task_prompt(task)
|
||||
command = self._runtime_command()
|
||||
cmd = [
|
||||
command,
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
*self._build_approval_args(),
|
||||
*self._build_permission_handler_args(),
|
||||
*self._build_thinking_args(),
|
||||
*self._build_session_args(),
|
||||
*self._build_model_args(),
|
||||
*list(self.config.extra_args),
|
||||
prompt,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata["binary"] = command
|
||||
return cmd, metadata
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
last_result = ""
|
||||
last_assistant = ""
|
||||
tool_summaries: list[str] = []
|
||||
saw_json_event = False
|
||||
for line in output.splitlines():
|
||||
event = self._parse_json_line(line)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
saw_json_event = True
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
if event_type in {"result", "session.result", "run.completed"}:
|
||||
text = self._event_text(event)
|
||||
if text:
|
||||
last_result = text
|
||||
elif (
|
||||
"tool" in event_type
|
||||
or "command" in event_type
|
||||
or event_type.startswith("item.")
|
||||
or self._event_part_type(event) == "tool"
|
||||
):
|
||||
summary = self._tool_summary(event)
|
||||
if summary:
|
||||
tool_summaries.append(summary)
|
||||
elif (
|
||||
self._event_role(event) == "assistant"
|
||||
or event_type in {"assistant", "assistant_message", "message", "text"}
|
||||
):
|
||||
text = self._event_text(event)
|
||||
if text:
|
||||
last_assistant = text
|
||||
if last_result or last_assistant:
|
||||
return last_result or last_assistant
|
||||
if saw_json_event:
|
||||
return self._tool_only_result_fallback(tool_summaries)
|
||||
return output
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
if stream_name != "stdout":
|
||||
stripped = str(text or "").strip()
|
||||
return f"[External:{self.agent_type}:stderr] {stripped[:500]}" if stripped else None
|
||||
|
||||
event = self._parse_json_line(text)
|
||||
if not isinstance(event, dict):
|
||||
return super().format_progress_update(text, stream_name)
|
||||
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
part = event.get("part") if isinstance(event.get("part"), dict) else {}
|
||||
part_type = str(part.get("type") or "").strip()
|
||||
if event_type == "approval_request":
|
||||
return None
|
||||
if event_type in {"session", "session.started", "init", "step_start", "step-start"}:
|
||||
session_id = self._session_id_from_event(event)
|
||||
return (
|
||||
f"[External:{self.agent_type}:init] session={session_id[:8]}"
|
||||
if session_id
|
||||
else None
|
||||
)
|
||||
if (
|
||||
"tool" in event_type
|
||||
or "command" in event_type
|
||||
or event_type.startswith("item.")
|
||||
or part_type == "tool"
|
||||
):
|
||||
summary = self._tool_summary(event)
|
||||
return f"[External:{self.agent_type}:tool] {summary}" if summary else None
|
||||
if event_type in {"thinking", "reasoning"} or part_type in {"thinking", "reasoning"}:
|
||||
message = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
if event_type in {"result", "session.result", "run.completed"}:
|
||||
result = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {result[:2400]}" if result else None
|
||||
if (
|
||||
self._event_role(event) == "assistant"
|
||||
or event_type in {"assistant", "assistant_message", "message", "text"}
|
||||
):
|
||||
message = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
return None
|
||||
|
||||
def detect_runtime_failure(self, text: str, stream_name: str) -> str | None:
|
||||
_ = stream_name
|
||||
lowered = str(text or "").lower()
|
||||
if "permission-handler" in lowered and ("unknown" in lowered or "invalid" in lowered):
|
||||
return "OpenCode rejected the configured stdio permission handler flag."
|
||||
return None
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
event = self._parse_json_line(text)
|
||||
if not isinstance(event, dict) or str(event.get("type") or "").strip() != "approval_request":
|
||||
return super().parse_approval_request(text, stream_name)
|
||||
|
||||
permission = event.get("permission")
|
||||
if not isinstance(permission, dict):
|
||||
return super().parse_approval_request(text, stream_name)
|
||||
|
||||
permission_name = str(permission.get("permission") or "").strip()
|
||||
patterns = [str(item).strip() for item in permission.get("patterns") or [] if str(item).strip()]
|
||||
metadata = permission.get("metadata") if isinstance(permission.get("metadata"), dict) else {}
|
||||
common_metadata = {
|
||||
"stream": stream_name,
|
||||
"provider_event_type": "approval_request",
|
||||
"approval_id": str(permission.get("id") or "").strip(),
|
||||
"permission_name": permission_name,
|
||||
"permission_patterns": patterns,
|
||||
"permission_always": list(permission.get("always") or []),
|
||||
"provider_metadata": metadata,
|
||||
"raw_event": event,
|
||||
}
|
||||
|
||||
normalized = self._normalize_name(permission_name)
|
||||
if normalized == "bash":
|
||||
command = self.normalize_shell_command(
|
||||
metadata.get("command") or metadata.get("cmd") or (patterns[0] if patterns else "")
|
||||
)
|
||||
arguments: dict[str, object] = {"command": command} if command else {}
|
||||
working_directory = (
|
||||
metadata.get("workdir") or metadata.get("cwd") or metadata.get("working_directory")
|
||||
)
|
||||
if working_directory:
|
||||
arguments["working_directory"] = str(working_directory)
|
||||
prompt_text = f"Allow OpenCode to run `{command}`?" if command else "Allow OpenCode to run a shell command?"
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="shell_exec",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
path_value = (
|
||||
metadata.get("filepath")
|
||||
or metadata.get("filePath")
|
||||
or metadata.get("path")
|
||||
or (patterns[0] if patterns else "")
|
||||
)
|
||||
if normalized == "edit":
|
||||
arguments = {"path": str(path_value)} if path_value else {}
|
||||
prompt_text = (
|
||||
f"Allow OpenCode to edit `{path_value}`?" if path_value else "Allow OpenCode to edit files?"
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_edit",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "read":
|
||||
arguments = {"path": str(path_value)} if path_value else {}
|
||||
prompt_text = (
|
||||
f"Allow OpenCode to read `{path_value}`?" if path_value else "Allow OpenCode to read files?"
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_read",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "list":
|
||||
arguments = {"path": str(path_value)} if path_value else {}
|
||||
prompt_text = (
|
||||
f"Allow OpenCode to list `{path_value}`?" if path_value else "Allow OpenCode to list files?"
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_list",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "glob":
|
||||
arguments: dict[str, object] = {}
|
||||
if path_value:
|
||||
arguments["path"] = str(path_value)
|
||||
pattern = str(metadata.get("pattern") or (patterns[0] if patterns else "")).strip()
|
||||
if pattern:
|
||||
arguments["query"] = pattern
|
||||
prompt_text = f"Allow OpenCode to glob `{pattern}`?" if pattern else "Allow OpenCode to glob files?"
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_glob",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "grep":
|
||||
arguments = {}
|
||||
if path_value:
|
||||
arguments["path"] = str(path_value)
|
||||
query = str(metadata.get("pattern") or (patterns[0] if patterns else "")).strip()
|
||||
if query:
|
||||
arguments["query"] = query
|
||||
prompt_text = f"Allow OpenCode to grep `{query}`?" if query else "Allow OpenCode to grep files?"
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_search",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "externaldirectory":
|
||||
arguments = {"path": str(patterns[0])} if patterns else {}
|
||||
prompt_text = (
|
||||
f"Allow OpenCode to access `{patterns[0]}` outside the workspace?"
|
||||
if patterns
|
||||
else "Allow OpenCode to access directories outside the workspace?"
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="external_agent",
|
||||
action_name=f"{self.agent_type}:external_directory",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
prompt_text = permission_name or "permission request"
|
||||
if patterns:
|
||||
prompt_text = f"{prompt_text}: {', '.join(patterns[:3])}"
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="external_agent",
|
||||
action_name=f"{self.agent_type}:{permission_name or 'permission'}",
|
||||
prompt_text=prompt_text,
|
||||
arguments={"target": patterns[0]} if patterns else {},
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
def format_approval_response(
|
||||
self,
|
||||
request: ExternalApprovalRequest,
|
||||
approved: bool,
|
||||
decision: ApprovalDecision,
|
||||
) -> str:
|
||||
approval_id = str(request.metadata.get("approval_id") or "").strip()
|
||||
if not approval_id:
|
||||
return super().format_approval_response(request, approved, decision)
|
||||
|
||||
human_reply = str((decision.metadata or {}).get("human_reply") or "").strip().lower()
|
||||
reply = "reject"
|
||||
if approved:
|
||||
reply = "always" if human_reply in {"always_project", "always_global"} else "once"
|
||||
|
||||
payload = {
|
||||
"type": "approval_response",
|
||||
"permission_id": approval_id,
|
||||
"reply": reply,
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False) + "\n"
|
||||
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
if not await self.is_available():
|
||||
return TaskResult(status=TaskStatus.FAILED, content="OpenCode CLI not found")
|
||||
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
logger.info(f"OpenCode executing: {task.title}")
|
||||
|
||||
try:
|
||||
stdin_policy = self.stdin_policy_for_process(cmd, metadata)
|
||||
self._record_stdin_policy_metadata(metadata, stdin_policy)
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=self._stdin_target_for_policy(stdin_policy),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(self._process.communicate(), timeout=600)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if self._process.returncode == 0:
|
||||
return TaskResult(
|
||||
status=TaskStatus.DONE,
|
||||
content=output,
|
||||
artifacts={**metadata, "stderr": errors} if errors else metadata,
|
||||
)
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"OpenCode exited with code {self._process.returncode}\n{errors}\n{output}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if self._process:
|
||||
self._process.kill()
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content="OpenCode timed out after 600s",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"OpenCode error: {e}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_model_args(self) -> list[str]:
|
||||
if not self.config.model:
|
||||
return []
|
||||
extra_args = list(self.config.extra_args)
|
||||
if self.config.model_flag and any(
|
||||
arg == self.config.model_flag or arg.startswith(f"{self.config.model_flag}=")
|
||||
for arg in extra_args
|
||||
):
|
||||
return []
|
||||
flag = self.config.model_flag or "--model"
|
||||
return [flag, self.config.model]
|
||||
|
||||
def _build_approval_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg == "--dangerously-skip-permissions" for arg in extra_args):
|
||||
return []
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "full-auto":
|
||||
return ["--dangerously-skip-permissions"]
|
||||
return []
|
||||
|
||||
def _build_permission_handler_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(
|
||||
arg == "--permission-handler" or arg.startswith("--permission-handler=")
|
||||
for arg in extra_args
|
||||
):
|
||||
return []
|
||||
if self._supports_stdio_permission_handler():
|
||||
return ["--permission-handler", "stdio-json"]
|
||||
return []
|
||||
|
||||
def _supports_stdio_permission_handler(self) -> bool:
|
||||
command = self.resolve_binary() or shutil.which(self.configured_command()) or self.configured_command()
|
||||
if not command:
|
||||
return False
|
||||
cached = self._permission_handler_support_cache.get(command)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[command, "run", "--help"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
help_text = f"{proc.stdout}\n{proc.stderr}"
|
||||
supported = "--permission-handler" in help_text
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
supported = False
|
||||
self._permission_handler_support_cache[command] = supported
|
||||
return supported
|
||||
|
||||
def _build_thinking_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg == "--thinking" for arg in extra_args):
|
||||
return []
|
||||
if bool(getattr(self.config, "show_thinking", False)):
|
||||
return ["--thinking"]
|
||||
return []
|
||||
|
||||
def _build_session_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg in {"--continue", "--session"} or arg.startswith("--session=") for arg in extra_args):
|
||||
return []
|
||||
|
||||
mode = str(self.config.session_mode or "auto").strip().lower()
|
||||
if mode == "resume":
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
if session_id:
|
||||
return ["--session", session_id]
|
||||
return ["--continue"]
|
||||
if mode == "new" and self.config.new_session_flag:
|
||||
return [self.config.new_session_flag]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _session_id_from_event(cls, event: dict[str, Any]) -> str:
|
||||
for key in ("sessionID", "sessionId", "session_id", "id"):
|
||||
token = str(event.get(key) or "").strip()
|
||||
if token:
|
||||
return token
|
||||
for key in ("session", "message", "data", "result"):
|
||||
nested = event.get(key)
|
||||
if isinstance(nested, dict):
|
||||
token = cls._session_id_from_event(nested)
|
||||
if token:
|
||||
return token
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _event_role(event: dict[str, Any]) -> str:
|
||||
role = str(event.get("role") or "").strip().lower()
|
||||
if role:
|
||||
return role
|
||||
message = event.get("message")
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("role") or "").strip().lower()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _event_part_type(event: dict[str, Any]) -> str:
|
||||
part = event.get("part")
|
||||
if isinstance(part, dict):
|
||||
return str(part.get("type") or "").strip().lower()
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _event_text(cls, event: Any) -> str:
|
||||
if isinstance(event, str):
|
||||
return event.strip()
|
||||
if isinstance(event, list):
|
||||
parts = [cls._event_text(item) for item in event]
|
||||
return "\n".join(part for part in parts if part).strip()
|
||||
if not isinstance(event, dict):
|
||||
return ""
|
||||
|
||||
for key in ("result", "message", "part", "text", "content", "summary", "output"):
|
||||
value = event.get(key)
|
||||
if key in {"message", "part"} and isinstance(value, dict):
|
||||
nested = cls._event_text(value)
|
||||
if nested:
|
||||
return nested
|
||||
elif isinstance(value, (str, list, dict)):
|
||||
nested = cls._event_text(value)
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _tool_summary(cls, event: dict[str, Any]) -> str:
|
||||
item = event.get("item") if isinstance(event.get("item"), dict) else event
|
||||
part = item.get("part") if isinstance(item.get("part"), dict) else {}
|
||||
if part:
|
||||
tool_name = str(part.get("tool") or part.get("name") or "tool").strip()
|
||||
normalized_tool = cls._normalize_name(tool_name)
|
||||
state = part.get("state") if isinstance(part.get("state"), dict) else {}
|
||||
tool_input = state.get("input") if isinstance(state.get("input"), dict) else {}
|
||||
command = cls.normalize_shell_command(
|
||||
tool_input.get("command") or tool_input.get("cmd") or tool_input.get("argv")
|
||||
)
|
||||
title = str(
|
||||
state.get("title")
|
||||
or tool_input.get("description")
|
||||
or state.get("description")
|
||||
or ""
|
||||
).strip()
|
||||
metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
|
||||
raw_output = str(state.get("output") or metadata.get("output") or "").strip()
|
||||
status = str(state.get("status") or "").strip()
|
||||
lines: list[str] = []
|
||||
if command:
|
||||
lines.append(f"$ {command[:240]}")
|
||||
else:
|
||||
target = str(
|
||||
tool_input.get("path")
|
||||
or tool_input.get("file")
|
||||
or tool_input.get("target")
|
||||
or tool_input.get("pattern")
|
||||
or tool_input.get("query")
|
||||
or tool_input.get("searchTerm")
|
||||
or ""
|
||||
).strip()
|
||||
label = "web search" if normalized_tool in {"websearch", "web_search"} else tool_name
|
||||
lines.append(f"{label}: {target}".strip(": "))
|
||||
if title and title not in lines[0]:
|
||||
lines.append(title[:240])
|
||||
output = cls._summarize_tool_output(tool_name, raw_output)
|
||||
if output:
|
||||
lines.append(output)
|
||||
elif status:
|
||||
lines.append(f"status={status}")
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
source = item.get("tool_call") if isinstance(item.get("tool_call"), dict) else item
|
||||
name = str(
|
||||
source.get("name")
|
||||
or source.get("tool_name")
|
||||
or source.get("toolName")
|
||||
or source.get("type")
|
||||
or "tool"
|
||||
).strip()
|
||||
command = cls.normalize_shell_command(source.get("command"))
|
||||
if command:
|
||||
return f"$ {command[:240]}"
|
||||
args = source.get("input") or source.get("arguments") or source.get("params") or {}
|
||||
if isinstance(args, dict):
|
||||
command = cls.normalize_shell_command(
|
||||
args.get("command") or args.get("cmd") or args.get("argv")
|
||||
)
|
||||
if command:
|
||||
return f"$ {command[:240]}"
|
||||
path = str(args.get("path") or args.get("file_path") or args.get("target") or "").strip()
|
||||
if path:
|
||||
return f"{name} {path}"
|
||||
output = str(source.get("aggregated_output") or source.get("output") or "").strip()
|
||||
if output and name != "tool":
|
||||
return f"{name}: {output[:200]}"
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
def _tool_only_result_fallback(cls, tool_summaries: list[str]) -> str:
|
||||
lines = [
|
||||
"OpenCode completed but did not emit a final assistant message.",
|
||||
"The raw JSON stream was parsed and suppressed from the user-facing reply.",
|
||||
]
|
||||
cleaned = [cls._compact_multiline(item, limit=500) for item in tool_summaries if item]
|
||||
if cleaned:
|
||||
lines.append("")
|
||||
lines.append("Tool activity:")
|
||||
lines.extend(f"- {item}" for item in cleaned[-6:])
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
@classmethod
|
||||
def _summarize_tool_output(cls, tool_name: str, output: str) -> str:
|
||||
normalized_tool = cls._normalize_name(tool_name)
|
||||
if not output:
|
||||
return ""
|
||||
if normalized_tool in {"bash", "shell", "command", "exec"}:
|
||||
return output[:1200]
|
||||
parsed = cls._parse_json_line(output)
|
||||
if isinstance(parsed, dict):
|
||||
results = parsed.get("results")
|
||||
if isinstance(results, list):
|
||||
titles: list[str] = []
|
||||
for item in results[:5]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title") or item.get("url") or "").strip()
|
||||
if title:
|
||||
titles.append(title)
|
||||
if titles:
|
||||
return "results: " + "; ".join(titles)
|
||||
if "url" in parsed or "title" in parsed:
|
||||
return str(parsed.get("title") or parsed.get("url") or "").strip()[:500]
|
||||
return cls._compact_multiline(output, limit=500)
|
||||
|
||||
@staticmethod
|
||||
def _compact_multiline(text: str, *, limit: int) -> str:
|
||||
compact = " ".join(str(text or "").split())
|
||||
if len(compact) <= limit:
|
||||
return compact
|
||||
return compact[: max(0, limit - 1)].rstrip() + "…"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""External agent adapter registry — manages available external agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.config import AgentsConfig
|
||||
from opc.layer3_agent.adapters.base import ExternalAgentAdapter
|
||||
from opc.layer3_agent.adapters.claude_code import ClaudeCodeAdapter
|
||||
from opc.layer3_agent.adapters.cursor_adapter import CursorAdapter
|
||||
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter
|
||||
from opc.layer3_agent.adapters.opencode_adapter import OpenCodeAdapter
|
||||
|
||||
|
||||
ADAPTER_CLASSES: dict[str, type[ExternalAgentAdapter]] = {
|
||||
"claude_code": ClaudeCodeAdapter,
|
||||
"cursor": CursorAdapter,
|
||||
"codex": CodexAdapter,
|
||||
"opencode": OpenCodeAdapter,
|
||||
}
|
||||
|
||||
|
||||
class AdapterRegistry:
|
||||
"""Manages external agent adapters and preferred order."""
|
||||
|
||||
def __init__(self, config: AgentsConfig) -> None:
|
||||
self.config = config
|
||||
self._adapters: dict[str, ExternalAgentAdapter] = {}
|
||||
self._available: dict[str, bool] = {}
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Discover and initialize available external agents."""
|
||||
self._adapters = {}
|
||||
self._available = {}
|
||||
for agent_type, adapter_cls in ADAPTER_CLASSES.items():
|
||||
agent_config = self.config.agents.get(agent_type)
|
||||
adapter = adapter_cls(config=agent_config)
|
||||
self._adapters[agent_type] = adapter
|
||||
if agent_config and not agent_config.enabled:
|
||||
self._available[agent_type] = False
|
||||
logger.info(f"External agent {agent_type}: disabled in config")
|
||||
continue
|
||||
|
||||
available = await adapter.is_available()
|
||||
self._available[agent_type] = available
|
||||
status = "available" if available else "not found"
|
||||
logger.info(f"External agent {agent_type}: {status}")
|
||||
|
||||
def get(self, agent_type: str) -> ExternalAgentAdapter | None:
|
||||
if agent_type in self._adapters and self._available.get(agent_type):
|
||||
return self._adapters[agent_type]
|
||||
return None
|
||||
|
||||
def get_preferred(self) -> ExternalAgentAdapter | None:
|
||||
"""Get the first available adapter from the preferred order."""
|
||||
for agent_type in self.config.preferred_order:
|
||||
adapter = self.get(agent_type)
|
||||
if adapter:
|
||||
return adapter
|
||||
return None
|
||||
|
||||
def get_ordered_available(self) -> list[tuple[str, ExternalAgentAdapter]]:
|
||||
ordered: list[tuple[str, ExternalAgentAdapter]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for agent_type in self.config.preferred_order:
|
||||
adapter = self.get(agent_type)
|
||||
if adapter:
|
||||
ordered.append((agent_type, adapter))
|
||||
seen.add(agent_type)
|
||||
|
||||
for agent_type, adapter in self._adapters.items():
|
||||
if agent_type not in seen and self._available.get(agent_type):
|
||||
ordered.append((agent_type, adapter))
|
||||
|
||||
return ordered
|
||||
|
||||
def list_available(self) -> list[str]:
|
||||
return [k for k, v in self._available.items() if v]
|
||||
|
||||
def list_all(self) -> dict[str, bool]:
|
||||
return dict(self._available)
|
||||
|
||||
def describe_all(self) -> list[dict[str, object]]:
|
||||
profiles: list[dict[str, object]] = []
|
||||
for agent_type, adapter in self._adapters.items():
|
||||
profile = adapter.describe()
|
||||
profile["available"] = self._available.get(agent_type, False)
|
||||
profiles.append(profile)
|
||||
return profiles
|
||||
|
||||
def describe_available(self) -> list[dict[str, object]]:
|
||||
return [p for p in self.describe_all() if p.get("available")]
|
||||
@@ -0,0 +1,712 @@
|
||||
"""Shared company/runtime contract builders for native and external agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from opc.core.company_tools import resolve_company_turn_mode
|
||||
from opc.core.models import Task
|
||||
from opc.layer2_organization.prompt_contract import render_target_prompt_contract
|
||||
from opc.layer2_organization.work_item_identity import turn_type_for_task
|
||||
from opc.layer2_organization.work_item_links import linked_work_item_id_for_task
|
||||
from opc.layer4_tools.output_budget import clip_text
|
||||
|
||||
ContractAudience = Literal["native", "external"]
|
||||
|
||||
_COMPANY_WORK_ITEM_GUIDELINES = """
|
||||
## Company Work Item Contract
|
||||
- You are executing one projected work item inside company mode, not the whole project alone.
|
||||
- Stay inside the current work-item boundary and use upstream handoffs, annotations, and inbox context before re-solving prior work.
|
||||
- Prefer asynchronous collaboration tools for cross-role clarification. Use meetings only for genuine cross-role decisions or conflicts.
|
||||
- Keep downstream handoffs crisp: preserve decisions, risks, open questions, artifacts, and verification status when they matter.
|
||||
- Team collaboration MUST flow through the collaboration tools and inbox context. Plain assistant prose does not count as inter-member coordination.
|
||||
- External executors such as Codex / OpenCode / Claude Code are temporary workers you may call; they are not organization members and do not replace your role ownership.
|
||||
- Respect the work-item ownership and artifact contracts in task metadata. If you cannot satisfy them, surface that explicitly instead of silently widening scope.
|
||||
|
||||
## Proactive Collaboration Requirements
|
||||
- BEFORE completing your work item, review the injected inbox context for messages from peers, managers, or downstream consumers; reply to pending questions or acknowledge handled messages with `inbox(action="ack")`.
|
||||
- If you handle an approval/review request through the manager board or task verdict, acknowledge the matching inbox message unless `reply_message` already acknowledged it.
|
||||
- If your work depends on or overlaps with a parallel peer's output, use `send_dm` or `ask_peer_and_wait` to confirm alignment BEFORE finalizing.
|
||||
- If you discover a gap, conflict, or dependency that affects another role, use `send_dm` or `broadcast_issue` immediately - do NOT silently deliver incomplete work.
|
||||
- If you cannot complete your deliverables because a peer hasn't finished, send them a message and continue with what you can. Use `ask_peer_and_wait` with `on_timeout="continue"` so you resume automatically if no reply arrives within the timeout window.
|
||||
- Delivering incomplete work without attempting coordination is a policy violation.
|
||||
- Waiting forever without a timeout fallback is also a policy violation - always set a reasonable `on_timeout` (prefer `continue` or `manager`).
|
||||
"""
|
||||
|
||||
_MULTI_TEAM_ORG_GUIDELINES = """
|
||||
## Organization Runtime Contract
|
||||
- You are one seat inside company mode; work inside your assigned WorkItem.
|
||||
- The runtime has prepared your assignment, mailbox snapshot, board context, and workspace roots for this turn.
|
||||
- The WorkItem is the collaboration source of truth. Use WorkItem IDs for collaboration; never use runtime Task IDs as WorkItem IDs.
|
||||
- `workspace_root` and `comms_workspace_root` are guaranteed. `output_root` may be blank; if needed, choose a suitable subfolder under `workspace_root` and communicate it in handoffs or delegation briefs.
|
||||
- Kanban state is advanced by the runtime from completion reports and review verdicts. Do not manually flip board states.
|
||||
- Use mailbox tools only for coordination, questions, blockers, or handoffs.
|
||||
- Cross-team collaboration is request-based; only direct managers delegate executable work.
|
||||
- If you are the root final decider, only your finished turn is the authoritative owner-facing result.
|
||||
"""
|
||||
|
||||
_MANAGER_RUNTIME_CONTRACT = """
|
||||
## Manager Runtime Contract
|
||||
- You have direct reports or an allowed delegation surface; prefer delegation and monitoring before local execution.
|
||||
- Use `delegate_work` only to CREATE child WorkItems for direct reports.
|
||||
- Use `modify_work_item` to revise an existing child WorkItem when the owner is still right but the brief, deliverables, acceptance criteria, or dependencies changed.
|
||||
- Use `delete_work_item` to cancel/hide an obsolete or wrong child WorkItem so it no longer blocks the parent board.
|
||||
- Use `manager_board_read` only to READ child-board state. For your current board, omit `parent_work_item_id` or use the current WorkItem ID; never use the runtime Task ID.
|
||||
- Do not send executable orders outside your direct reports; coordinate with peers or other teams by request.
|
||||
"""
|
||||
|
||||
_COMPANY_PLAN_WORK_ITEM_GUIDELINES = """
|
||||
## Company Work Item Turn: Plan / Intake / Dispatch
|
||||
- Investigate first, then convert findings into a concrete work-item plan with sequencing, assumptions, and validation targets.
|
||||
- Prefer read-only `agent_spawn(profile='explore')` exploration when the codebase slice is broad.
|
||||
- Avoid implementation-level edits unless the task explicitly assigns execution to this work item.
|
||||
- During intake or initial dispatch, do NOT use `ask_peer_and_wait` on a role that does not already have an active work package. Create or delegate the work first, then use `send_dm` for non-blocking coordination.
|
||||
- If you need another role's view before delegation exists, send a non-blocking message or include the question in the delegated brief; do not park the whole project startup.
|
||||
"""
|
||||
|
||||
_COMPANY_EXECUTE_WORK_ITEM_GUIDELINES = """
|
||||
## Company Work Item Turn: Execute
|
||||
- Prefer direct execution for the assigned slice once the approach is clear.
|
||||
- Use `agent_spawn(profile='explore')` for read-only exploration when it reduces context noise.
|
||||
- If work-item swarm tools are available, use the shared microtask board to break the assigned slice into tactical work items before spawning burst workers.
|
||||
- Before handing off, leave evidence that a reviewer can verify quickly: changed areas, artifact pointers, and any remaining risks.
|
||||
- Treat write scope as constrained by the ownership contract. Do not edit outside that scope unless the task metadata explicitly expands it.
|
||||
- Your completion bar is higher than "it works on my turn": leave a handoff that satisfies summary, artifact index, decisions, risks, open questions, and verification status.
|
||||
"""
|
||||
|
||||
_COMPANY_REVIEW_WORK_ITEM_GUIDELINES = """
|
||||
## Company Work Item Turn: Review
|
||||
|
||||
You are reviewing a subordinate's deliverable. The runtime applies your verdict mechanically — approve sends the work to done, reject sends it back to the worker with your summary + blocking_issues as rework feedback. The runtime does NOT second-guess the shape or content of your verdict; you are responsible for the call.
|
||||
|
||||
### How to judge
|
||||
- You have read access to the workspace. Use your tools (file_read, bash, git_*, web_search, etc.) to verify the worker's claims against the actual current state. Don't trust the handoff blindly and don't reject blindly either.
|
||||
- Current workspace evidence is the truth. Previous review notes and old memory are leads to re-check, not facts.
|
||||
- Reject only for gaps that still exist now. If an earlier finding was fixed, don't repeat it.
|
||||
- Compare the deliverable against the original brief and acceptance criteria in plain English: did the assignee produce the requested output, or did they only provide analysis/planning? If the brief asked for an artifact and the worker shipped a plan, reject.
|
||||
|
||||
### Verdict (suggested JSON shape)
|
||||
End your turn with one JSON object on its own line:
|
||||
|
||||
Approve: `{"review_verdict":"approve","summary":"<concrete reason it meets the bar>"}`
|
||||
Reject: `{"review_verdict":"reject","summary":"<overall reason>","blocking_issues":["<specific change needed>"],"followups":["<non-blocking improvement>"]}`
|
||||
|
||||
If you cannot be parsed into approve or reject, the runtime will spawn one more review attempt and ask you again; after that it will escalate to a human. So please emit a clear label.
|
||||
|
||||
You are trusted to choose the level of detail. A short summary on a clear approve is fine. For rejections, name specific files / tests / artifacts in `blocking_issues` so the worker can act.
|
||||
"""
|
||||
|
||||
_COMPANY_AGGREGATE_WORK_ITEM_GUIDELINES = """
|
||||
## Company Work Item Turn: Aggregate / Deliver
|
||||
- Synthesize upstream outputs into a decision-ready summary instead of repeating every intermediate detail.
|
||||
- Preserve artifact pointers, unresolved risks, and owner-facing next actions.
|
||||
- Keep the final surface area small enough that the next work item can act without replaying the whole run.
|
||||
- Aggregation does not erase accountability: preserve which role produced which artifact or review conclusion when it matters for follow-up.
|
||||
- When possible, end with a compact JSON object like `{"delivery_package":{"executive_summary":"...","delivered_items":[],"artifact_manifest":[],"risks":[],"open_issues":[],"next_steps":[]}}` so the final delivery stays structured.
|
||||
"""
|
||||
|
||||
|
||||
_COMPANY_REPORT_GENERATION_HEADER = """
|
||||
## Work Item Turn: Report Generation
|
||||
|
||||
You have just finished executing the assigned work. This turn is dedicated to writing a self-contained handoff report for your reviewer. Do NOT do any new execution work in this turn — your execution is already done. Do NOT delegate, do not message peers, do not run the original task again.
|
||||
|
||||
Use your own session context plus the runtime-injected execute-turn summary/evidence below. If your session memory is unavailable but the injected execute-turn summary, output, artifacts, or verification evidence are present, use those injected facts as the handoff source. Do not claim you lack context merely because this is a report-only turn.
|
||||
|
||||
### Suggested report shape (JSON, not strictly required)
|
||||
End your turn with EXACTLY one JSON object on its own line:
|
||||
```
|
||||
{
|
||||
"summary": "<2-3 sentence overall outcome>",
|
||||
"deliverables": [
|
||||
{"name": "<deliverable name>", "path": "<path or pointer>", "status": "complete" | "partial" | "blocked"}
|
||||
],
|
||||
"acceptance_status": [
|
||||
{"criterion": "<original acceptance criterion>", "met": true | false, "evidence": "<file path / command / proof>"}
|
||||
],
|
||||
"risks": ["<known risk or caveat>"],
|
||||
"next_actions": ["<what reviewer or downstream should do next>"]
|
||||
}
|
||||
```
|
||||
|
||||
If a structured shape doesn't fit your situation, write a clear narrative report instead — the runtime will pass your prose to the reviewer as-is. Do NOT make up content to fill the schema; leave fields out or fall back to narrative.
|
||||
|
||||
### Why this matters
|
||||
The reviewer will receive this report PLUS the original brief and will independently verify your claims with their own tools (file_read, bash, etc.). Be honest about partial work and open issues — silent gaps will be caught by the reviewer and counted against this delivery.
|
||||
""".strip()
|
||||
|
||||
|
||||
_REVIEW_PENDING_HEADER = """
|
||||
## Review Requirement
|
||||
- One or more of your direct reports has submitted a completed work item for your review. You MUST clear the review queue before dispatching new children, monitoring, or executing local work.
|
||||
- For each pending item: compare the deliverable against the original acceptance criteria and non_overlap_guard. Inspect artifacts, completion reports, and any cross-team coordination notes.
|
||||
- Also compare the result against the original work item brief in plain English: did the assignee produce the requested output, or did they only provide analysis/planning/search notes? If the brief asked for actual production, reject planning-only submissions and request concrete rework.
|
||||
- Treat current files and command results as the truth. Previous review findings are only leads; verify them against the latest workspace before repeating them.
|
||||
|
||||
### Verdict (suggested JSON shape, one per item)
|
||||
Approve: `{"review_verdict":"approve","summary":"<concrete reason>"}`
|
||||
Reject: `{"review_verdict":"reject","summary":"<reason>","blocking_issues":["<specific fix>"],"followups":["<nice-to-have>"]}`
|
||||
|
||||
The runtime applies the verdict mechanically. For rejections, name specific files / tests / artifacts in `blocking_issues` so the worker can act on your feedback.
|
||||
|
||||
- If a review depends on information you lack (e.g., evidence from another team), send a targeted `send_dm` or `ask_peer_and_wait` message rather than approving blindly.
|
||||
- Do NOT approve simply to unblock the pipeline; reject with specific, actionable feedback if acceptance criteria are not met.
|
||||
""".strip()
|
||||
|
||||
|
||||
_REVIEW_EXECUTE_HEADER = """
|
||||
## Kanban Review Turn
|
||||
|
||||
This turn is a dedicated review of one child work item. Do not dispatch new work, do not rewrite scope, and do not message peers unless your review depends on information you cannot get yourself.
|
||||
|
||||
### Inputs you have
|
||||
- The original brief (target description) below.
|
||||
- The worker's handoff report below.
|
||||
- Your own session memory of any prior review on this item.
|
||||
- Read access to the workspace via your tools (file_read, bash, git_*, web_search, etc.).
|
||||
|
||||
### How to judge
|
||||
- Use your tools to verify the worker's claims directly against the workspace. Don't trust the handoff blindly and don't reject blindly. Current workspace evidence is the truth.
|
||||
- Treat the original brief as the contract. Approve only if the submitted result actually satisfies the requested production output; if the worker shipped only a plan or concept memo when the brief required an artifact or implementation, reject and request rework.
|
||||
- Before rejecting, re-check the cited paths from the latest report; do not reuse stale line numbers or already-fixed findings.
|
||||
|
||||
### Verdict (suggested JSON shape)
|
||||
End your turn with one JSON object on its own line:
|
||||
|
||||
Approve: `{"review_verdict":"approve","summary":"<concrete reason it meets the bar>"}`
|
||||
Reject: `{"review_verdict":"reject","summary":"<overall reason>","blocking_issues":["<specific change needed>"],"followups":["<non-blocking improvement>"]}`
|
||||
|
||||
The runtime applies your verdict mechanically — approve moves the child to done, reject sends it back to the worker with your summary + blocking_issues as rework feedback. The runtime does NOT second-guess the shape or content of your verdict. You are responsible for the call.
|
||||
|
||||
If your output cannot be parsed into approve or reject, the runtime will give you one more review attempt with a parse-failure hint, and after that it will escalate to a human reviewer. So please emit a clear label.
|
||||
""".strip()
|
||||
|
||||
|
||||
def _format_pending_review_items(items: list[Any]) -> str:
|
||||
lines: list[str] = []
|
||||
for entry in items:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
work_item_id = str(entry.get("work_item_id") or "").strip()
|
||||
if not work_item_id:
|
||||
continue
|
||||
title = str(entry.get("title") or "").strip() or "(untitled)"
|
||||
role_id = str(entry.get("role_id") or "").strip() or "unknown"
|
||||
deliverable_summary = str(entry.get("deliverable_summary") or "").strip()
|
||||
phase = str(entry.get("phase") or "").strip() or "awaiting_manager_review"
|
||||
line = f"- work_item_id=`{work_item_id}` role=`{role_id}` title=`{title}` phase=`{phase}`"
|
||||
if deliverable_summary:
|
||||
preview = clip_text(
|
||||
deliverable_summary.replace("\n", " "),
|
||||
limit=200,
|
||||
marker="deliverable summary preview truncated",
|
||||
prefer_newline=False,
|
||||
).text
|
||||
line += f" deliverable_preview=`{preview}`"
|
||||
review_evidence = dict(entry.get("review_evidence", {}) or {})
|
||||
output_paths = [
|
||||
str(item).strip()
|
||||
for item in list(review_evidence.get("output_paths", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
verification_status = dict(review_evidence.get("verification_results", {}) or {}).get("status", {})
|
||||
verification_label = str(verification_status.get("label", "") or "").strip()
|
||||
if verification_label:
|
||||
line += f" verification=`{verification_label}`"
|
||||
if output_paths:
|
||||
line += f" outputs=`{', '.join(output_paths[:3])}`"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _review_pending_block(task: Task) -> str:
|
||||
runtime_model = str(task.metadata.get("runtime_model", "") or "").strip()
|
||||
if runtime_model != "multi_team_org":
|
||||
return ""
|
||||
if resolve_company_turn_mode(task) != "review_pending":
|
||||
return ""
|
||||
pending_items = list(task.metadata.get("pending_review_items", []) or [])
|
||||
if not pending_items:
|
||||
pending_items = list(task.context_snapshot.get("pending_review_items", []) or [])
|
||||
rendered = _format_pending_review_items(pending_items)
|
||||
if not rendered:
|
||||
return _REVIEW_PENDING_HEADER
|
||||
return f"{_REVIEW_PENDING_HEADER}\n\n### Pending Review Queue\n{rendered}"
|
||||
|
||||
|
||||
def _review_execute_block(task: Task) -> str:
|
||||
runtime_model = str(task.metadata.get("runtime_model", "") or "").strip()
|
||||
if runtime_model != "multi_team_org":
|
||||
return ""
|
||||
turn_mode = resolve_company_turn_mode(task)
|
||||
work_item_turn_type = turn_type_for_task(task, fallback="")
|
||||
explicit_review_turn = bool(
|
||||
task.metadata.get("review_execution_work_item", False)
|
||||
or task.metadata.get("review_task", False)
|
||||
or str(task.metadata.get("review_target_work_item_id", "") or "").strip()
|
||||
)
|
||||
if not explicit_review_turn:
|
||||
return ""
|
||||
if turn_mode != "review_execute" and work_item_turn_type != "review":
|
||||
return ""
|
||||
target_work_item_id = str(
|
||||
task.metadata.get("review_target_work_item_id")
|
||||
or linked_work_item_id_for_task(task)
|
||||
or ""
|
||||
).strip()
|
||||
if not target_work_item_id:
|
||||
return _REVIEW_EXECUTE_HEADER
|
||||
title = str(task.metadata.get("review_target_title", "") or "").strip()
|
||||
worker_role_id = str(task.metadata.get("review_target_worker_role_id", "") or "").strip()
|
||||
completion_report = str(task.metadata.get("review_completion_report", "") or "").strip()
|
||||
review_evidence = dict(task.metadata.get("review_evidence", {}) or {})
|
||||
prompt_contract = dict(task.metadata.get("prompt_contract", {}) or {})
|
||||
target_contract = dict(task.metadata.get("review_target_prompt_contract", {}) or prompt_contract.get("target_contract", {}) or {})
|
||||
lines = [
|
||||
_REVIEW_EXECUTE_HEADER,
|
||||
"",
|
||||
"### Target Child Work Item",
|
||||
f"- work_item_id=`{target_work_item_id}`",
|
||||
]
|
||||
if title:
|
||||
lines.append(f"- title=`{title}`")
|
||||
if worker_role_id:
|
||||
lines.append(f"- worker_role=`{worker_role_id}`")
|
||||
rendered_target_contract = render_target_prompt_contract(target_contract)
|
||||
if rendered_target_contract:
|
||||
lines.append("")
|
||||
lines.append(rendered_target_contract)
|
||||
if completion_report:
|
||||
lines.append("")
|
||||
lines.append("### Completion Report")
|
||||
lines.append(clip_text(
|
||||
completion_report,
|
||||
limit=2000,
|
||||
marker="completion report preview truncated",
|
||||
).text)
|
||||
artifact_manifest = [
|
||||
dict(item)
|
||||
for item in list(review_evidence.get("artifact_manifest", []) or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
changed_areas = [
|
||||
str(item).strip()
|
||||
for item in list(review_evidence.get("changed_areas", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
verification_results = dict(review_evidence.get("verification_results", {}) or {})
|
||||
verification_status = dict(verification_results.get("status", {}) or {})
|
||||
verification_checks = [
|
||||
dict(item)
|
||||
for item in list(verification_results.get("checks", []) or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
key_commands = [
|
||||
str(item).strip()
|
||||
for item in list(review_evidence.get("key_commands", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
output_paths = [
|
||||
str(item).strip()
|
||||
for item in list(review_evidence.get("output_paths", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
open_risks = [
|
||||
str(item).strip()
|
||||
for item in list(review_evidence.get("open_risks", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
if artifact_manifest:
|
||||
lines.append("")
|
||||
lines.append("### Artifact Manifest")
|
||||
for item in artifact_manifest[:10]:
|
||||
label = str(item.get("label", "") or item.get("kind", "") or "artifact").strip()
|
||||
value = str(item.get("value", "") or "").strip()
|
||||
lines.append(f"- {label}: `{value}`" if value else f"- {label}")
|
||||
if changed_areas:
|
||||
lines.append("")
|
||||
lines.append("### Changed Areas")
|
||||
lines.extend(f"- `{item}`" for item in changed_areas[:10])
|
||||
if verification_status or verification_checks:
|
||||
lines.append("")
|
||||
lines.append("### Verification")
|
||||
if verification_status:
|
||||
label = str(verification_status.get("label", "") or "").strip()
|
||||
summary = str(verification_status.get("summary", "") or "").strip()
|
||||
if label:
|
||||
lines.append(f"- status=`{label}`")
|
||||
if summary:
|
||||
lines.append(f"- summary={clip_text(summary, limit=300, marker='verification summary preview truncated').text}")
|
||||
for item in verification_checks[:6]:
|
||||
command = str(item.get("command", "") or "").strip()
|
||||
status = str(item.get("status", "") or "").strip()
|
||||
summary = str(item.get("summary", "") or "").strip()
|
||||
rendered = f"- `{command}` -> `{status}`" if command else f"- `{status}`"
|
||||
if summary:
|
||||
rendered += f" :: {summary[:220]}"
|
||||
lines.append(rendered)
|
||||
if key_commands:
|
||||
lines.append("")
|
||||
lines.append("### Key Commands")
|
||||
lines.extend(f"- `{item}`" for item in key_commands[:8])
|
||||
if output_paths:
|
||||
lines.append("")
|
||||
lines.append("### Output Paths")
|
||||
lines.extend(f"- `{item}`" for item in output_paths[:10])
|
||||
if open_risks:
|
||||
lines.append("")
|
||||
lines.append("### Open Risks")
|
||||
lines.extend(f"- {item}" for item in open_risks[:8])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_report_source_evidence(task: Task) -> str:
|
||||
evidence = dict(task.metadata.get("report_source_evidence", {}) or {})
|
||||
summary = str(task.metadata.get("report_source_summary", "") or "").strip()
|
||||
result_content = str(task.metadata.get("report_source_result_content", "") or "").strip()
|
||||
lines: list[str] = []
|
||||
|
||||
if summary:
|
||||
lines.append("### Last Execute-Turn Summary")
|
||||
lines.append(clip_text(summary, limit=2000, marker="execute summary preview truncated").text)
|
||||
if result_content and result_content != summary:
|
||||
lines.append("")
|
||||
lines.append("### Last Execute-Turn Output")
|
||||
lines.append(clip_text(result_content, limit=2000, marker="execute output preview truncated").text)
|
||||
|
||||
artifact_manifest = [
|
||||
dict(item)
|
||||
for item in list(evidence.get("artifact_manifest", []) or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
changed_areas = [
|
||||
str(item).strip()
|
||||
for item in list(evidence.get("changed_areas", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
verification_results = dict(evidence.get("verification_results", {}) or {})
|
||||
verification_status = dict(verification_results.get("status", {}) or {})
|
||||
verification_checks = [
|
||||
dict(item)
|
||||
for item in list(verification_results.get("checks", []) or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
key_commands = [
|
||||
str(item).strip()
|
||||
for item in list(evidence.get("key_commands", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
output_paths = [
|
||||
str(item).strip()
|
||||
for item in list(evidence.get("output_paths", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
open_risks = [
|
||||
str(item).strip()
|
||||
for item in list(evidence.get("open_risks", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
|
||||
if artifact_manifest:
|
||||
lines.append("")
|
||||
lines.append("### Known Artifacts From Execute Turn")
|
||||
for item in artifact_manifest[:10]:
|
||||
label = str(item.get("label", "") or item.get("kind", "") or "artifact").strip()
|
||||
value = str(item.get("value", "") or "").strip()
|
||||
lines.append(f"- {label}: `{value}`" if value else f"- {label}")
|
||||
if changed_areas:
|
||||
lines.append("")
|
||||
lines.append("### Changed Areas")
|
||||
lines.extend(f"- `{item}`" for item in changed_areas[:10])
|
||||
if verification_status or verification_checks:
|
||||
lines.append("")
|
||||
lines.append("### Verification Evidence From Execute Turn")
|
||||
if verification_status:
|
||||
label = str(verification_status.get("label", "") or "").strip()
|
||||
summary_text = str(verification_status.get("summary", "") or "").strip()
|
||||
if label:
|
||||
lines.append(f"- status=`{label}`")
|
||||
if summary_text:
|
||||
lines.append(f"- summary={clip_text(summary_text, limit=300, marker='verification summary preview truncated').text}")
|
||||
for item in verification_checks[:6]:
|
||||
command = str(item.get("command", "") or "").strip()
|
||||
status = str(item.get("status", "") or "").strip()
|
||||
summary_text = str(item.get("summary", "") or "").strip()
|
||||
rendered = f"- `{command}` -> `{status}`" if command else f"- `{status}`"
|
||||
if summary_text:
|
||||
rendered += f" :: {summary_text[:220]}"
|
||||
lines.append(rendered)
|
||||
if key_commands:
|
||||
lines.append("")
|
||||
lines.append("### Key Commands")
|
||||
lines.extend(f"- `{item}`" for item in key_commands[:8])
|
||||
if output_paths:
|
||||
lines.append("")
|
||||
lines.append("### Output Paths")
|
||||
lines.extend(f"- `{item}`" for item in output_paths[:10])
|
||||
if open_risks:
|
||||
lines.append("")
|
||||
lines.append("### Open Risks")
|
||||
lines.extend(f"- {item}" for item in open_risks[:8])
|
||||
|
||||
if not lines:
|
||||
return ""
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def _report_execute_block(task: Task) -> str:
|
||||
"""Return the report-generation prompt block when this turn is the
|
||||
hidden auxiliary report card spawned after a worker DONE.
|
||||
|
||||
Two-turn worker→review handoff: instead of the worker's last execute
|
||||
turn prose being treated as the report, the runtime spawns a separate
|
||||
report card that resumes the same worker session and asks for an
|
||||
explicit structured handoff. This block is the prompt for that turn.
|
||||
"""
|
||||
runtime_model = str(task.metadata.get("runtime_model", "") or "").strip()
|
||||
if runtime_model != "multi_team_org":
|
||||
return ""
|
||||
turn_mode = resolve_company_turn_mode(task)
|
||||
work_item_turn_type = turn_type_for_task(task, fallback="")
|
||||
is_report_turn = (
|
||||
turn_mode == "report_required"
|
||||
or work_item_turn_type == "report"
|
||||
or bool(task.metadata.get("report_execution_work_item", False))
|
||||
)
|
||||
if not is_report_turn:
|
||||
return ""
|
||||
prompt_contract = dict(task.metadata.get("prompt_contract", {}) or {})
|
||||
target_contract = dict(task.metadata.get("report_target_prompt_contract", {}) or prompt_contract.get("target_contract", {}) or {})
|
||||
rendered_target_contract = render_target_prompt_contract(
|
||||
target_contract,
|
||||
heading="### Work Item Contract To Report Against",
|
||||
)
|
||||
source_evidence = _render_report_source_evidence(task)
|
||||
parts = [_COMPANY_REPORT_GENERATION_HEADER]
|
||||
if rendered_target_contract:
|
||||
parts.append(rendered_target_contract)
|
||||
if source_evidence:
|
||||
parts.append(source_evidence)
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _multi_team_manager_capable(task: Task) -> bool:
|
||||
"""Return whether this seat has a management/delegation surface.
|
||||
|
||||
This deliberately keys off role capability, not the current turn mode:
|
||||
a middle manager can be in an execute/integrate/review turn and still
|
||||
need manager guidance, while a leaf worker should not receive delegation
|
||||
planning rules.
|
||||
"""
|
||||
metadata = dict(task.metadata or {})
|
||||
for key in ("direct_report_seat_ids", "allowed_delegate_role_ids", "direct_report_role_ids"):
|
||||
if [str(item).strip() for item in list(metadata.get(key, []) or []) if str(item).strip()]:
|
||||
return True
|
||||
if str(metadata.get("managed_team_id", "") or "").strip():
|
||||
return True
|
||||
|
||||
topology = dict(metadata.get("runtime_topology", {}) or {})
|
||||
seats = [
|
||||
dict(item)
|
||||
for item in list(topology.get("seats", []) or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
current_seat_id = str(metadata.get("delegation_seat_id", "") or metadata.get("seat_id", "") or "").strip()
|
||||
current_role_id = str(task.assigned_to or metadata.get("work_item_role_id", "") or "").strip()
|
||||
current_seat: dict[str, Any] = {}
|
||||
for seat in seats:
|
||||
if current_seat_id and str(seat.get("seat_id", "") or "").strip() == current_seat_id:
|
||||
current_seat = seat
|
||||
break
|
||||
if not current_seat and current_role_id:
|
||||
for seat in seats:
|
||||
if str(seat.get("role_id", "") or "").strip() == current_role_id:
|
||||
current_seat = seat
|
||||
break
|
||||
if not current_seat:
|
||||
return False
|
||||
for key in ("direct_report_seat_ids", "allowed_delegate_role_ids", "direct_report_role_ids"):
|
||||
if [str(item).strip() for item in list(current_seat.get(key, []) or []) if str(item).strip()]:
|
||||
return True
|
||||
return bool(str(current_seat.get("managed_team_id", "") or "").strip())
|
||||
|
||||
|
||||
def _dispatch_requirement_block(task: Task) -> str:
|
||||
runtime_model = str(task.metadata.get("runtime_model", "") or "").strip()
|
||||
if runtime_model != "multi_team_org":
|
||||
return ""
|
||||
if resolve_company_turn_mode(task) != "dispatch_required":
|
||||
return ""
|
||||
if not _multi_team_manager_capable(task):
|
||||
return ""
|
||||
lines = [
|
||||
"""
|
||||
## Dispatch Planning Contract
|
||||
- This turn is currently in `dispatch_required`.
|
||||
- Scope first: preserve the upstream goal, requested deliverable form, required paths, constraints, and hard dependencies.
|
||||
- Delegate outcome-based child WorkItems; planning or checklist work must not replace requested production work.
|
||||
- Separate hard blockers from startable preparation, and split phases only when they have distinct outputs, blockers, or handoff points.
|
||||
- If production cannot happen in this environment, dispatch or escalate the blocker instead of substituting a plan.
|
||||
- After that, do exactly one of the following:
|
||||
1. Use `delegate_work` to create downstream child work for at least one direct report.
|
||||
2. If local execution is truly required because no downstream seat is a fit, include exactly one line in your final response:
|
||||
`NO_DELEGATION_JUSTIFICATION: <specific reason>`
|
||||
- If you execute locally without delegation or the justification line, the runtime will reject the turn and ask you to dispatch first.
|
||||
- If this is a follow-up over an existing board, inspect it with `manager_board_read` and use `modify_work_item` / `delete_work_item` for wrong existing items before creating additional work.
|
||||
""".strip()
|
||||
]
|
||||
metadata = dict(task.metadata or {})
|
||||
snapshot = dict(task.context_snapshot or {})
|
||||
followup_text = str(snapshot.get("user_supplied_input", "") or "").strip()
|
||||
is_final_decider_followup = bool(metadata.get("followup_routed_to_final_decider", False)) or bool(followup_text)
|
||||
if is_final_decider_followup:
|
||||
if followup_text:
|
||||
followup_preview = clip_text(followup_text, limit=800, marker="follow-up truncated").text
|
||||
lines.append(
|
||||
"\n".join(
|
||||
[
|
||||
"## User Follow-up Board Reconciliation",
|
||||
f"User follow-up: {followup_preview}",
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append("## User Follow-up Board Reconciliation")
|
||||
lines.append(
|
||||
"\n".join(
|
||||
[
|
||||
"- You are resuming this same role session with a fresh owner directive; rely on the session history already available to you.",
|
||||
"- Decide the next step from the current state and available collaboration tools: answer directly, close review when appropriate, inspect or revise the board, delegate more work, or take another supported runtime action.",
|
||||
"- When changing an existing board, inspect it with `manager_board_read` and prefer `modify_work_item` / `delete_work_item` for stale or wrong child work before adding more work.",
|
||||
"- If you create replacement child work, also resolve obsolete siblings so old work does not keep running or blocking completion.",
|
||||
"- Keep your final response focused on what you decided or changed.",
|
||||
]
|
||||
)
|
||||
)
|
||||
mutation_action = str(metadata.get("manager_mutation_action", "") or "").strip()
|
||||
mutation_reason = str(metadata.get("manager_mutation_reason", "") or "").strip()
|
||||
mutation_user_input = str(
|
||||
metadata.get("latest_user_directive", "")
|
||||
or metadata.get("manager_mutation_user_input", "")
|
||||
or ""
|
||||
).strip()
|
||||
if mutation_action == "modify" or mutation_user_input:
|
||||
mutation_lines = ["## Upstream Work Item Mutation Reconciliation"]
|
||||
if mutation_user_input:
|
||||
mutation_lines.append(
|
||||
f"Latest upstream user instruction: {clip_text(mutation_user_input, limit=800, marker='upstream user input truncated').text}"
|
||||
)
|
||||
if mutation_reason:
|
||||
mutation_lines.append(
|
||||
f"Manager mutation reason: {clip_text(mutation_reason, limit=500, marker='mutation reason truncated').text}"
|
||||
)
|
||||
mutation_lines.extend(
|
||||
[
|
||||
"- Your current WorkItem must follow this latest upstream instruction. Before creating replacement child work, inspect your existing child board with `manager_board_read`.",
|
||||
"- For each existing child, decide whether it still supports the revised parent brief. Use `modify_work_item` for a child that should continue under the new scope.",
|
||||
"- Use `delete_work_item` for stale children that still describe the old direction, especially suspended/running children left over from before Stop.",
|
||||
"- If you delegate a replacement child, also resolve the obsolete child so old work does not keep running or remain visible on the board.",
|
||||
]
|
||||
)
|
||||
lines.append("\n".join(mutation_lines))
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
_EXTERNAL_TOOL_WORDING_REPLACEMENTS = {
|
||||
"Prefer read-only `agent_spawn(profile='explore')` exploration when the codebase slice is broad.": (
|
||||
"Prefer read-only exploration using your external agent's own search, "
|
||||
"inspection, or context-isolation capabilities when the workspace slice is broad."
|
||||
),
|
||||
"Use `agent_spawn(profile='explore')` for read-only exploration when it reduces context noise.": (
|
||||
"Use your external agent's own search, inspection, or context-isolation "
|
||||
"capabilities for read-only exploration when it reduces context noise."
|
||||
),
|
||||
"You have read access to the workspace. Use your tools (file_read, bash, git_*, web_search, etc.) to verify the worker's claims against the actual current state. Don't trust the handoff blindly and don't reject blindly either.": (
|
||||
"You have read access to the workspace. Use your external agent's "
|
||||
"available inspection, search, shell, version-control, browser, and "
|
||||
"verification capabilities to verify the worker's claims against the "
|
||||
"actual current state. Don't trust the handoff blindly and don't reject blindly either."
|
||||
),
|
||||
"The reviewer will receive this report PLUS the original brief and will independently verify your claims with their own tools (file_read, bash, etc.). Be honest about partial work and open issues — silent gaps will be caught by the reviewer and counted against this delivery.": (
|
||||
"The reviewer will receive this report PLUS the original brief and will "
|
||||
"independently verify your claims with their own workspace inspection "
|
||||
"and verification capabilities. Be honest about partial work and open "
|
||||
"issues — silent gaps will be caught by the reviewer and counted against this delivery."
|
||||
),
|
||||
"- Read access to the workspace via your tools (file_read, bash, git_*, web_search, etc.).": (
|
||||
"- Read access to the workspace via your external agent's available "
|
||||
"inspection, search, shell, version-control, browser, and verification capabilities."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_contract_audience(audience: str | None) -> ContractAudience:
|
||||
return "external" if str(audience or "").strip().lower() == "external" else "native"
|
||||
|
||||
|
||||
def _contract_text_for_audience(text: str, audience: ContractAudience) -> str:
|
||||
if audience != "external":
|
||||
return text
|
||||
rendered = text
|
||||
for old, new in _EXTERNAL_TOOL_WORDING_REPLACEMENTS.items():
|
||||
rendered = rendered.replace(old, new)
|
||||
return rendered
|
||||
|
||||
|
||||
def build_company_work_item_contract(
|
||||
task: Task,
|
||||
*,
|
||||
audience: str | None = "native",
|
||||
) -> str:
|
||||
"""Return the shared company/runtime contract for a task."""
|
||||
resolved_audience = _normalize_contract_audience(audience)
|
||||
if str(task.metadata.get("runtime_model", "") or "").strip() == "multi_team_org":
|
||||
# Hidden auxiliary cards are single-purpose and take precedence over
|
||||
# the generic multi-team guidelines: the seat is here for exactly
|
||||
# one job (write the report, or apply the verdict) and nothing else.
|
||||
report_execute_block = _report_execute_block(task)
|
||||
if report_execute_block:
|
||||
return _contract_text_for_audience(report_execute_block, resolved_audience)
|
||||
review_execute_block = _review_execute_block(task)
|
||||
if review_execute_block:
|
||||
return _contract_text_for_audience(review_execute_block, resolved_audience)
|
||||
parts = [_MULTI_TEAM_ORG_GUIDELINES.strip()]
|
||||
if _multi_team_manager_capable(task):
|
||||
parts.append(_MANAGER_RUNTIME_CONTRACT.strip())
|
||||
review_block = _review_pending_block(task)
|
||||
if review_block:
|
||||
parts.append(review_block)
|
||||
dispatch_block = _dispatch_requirement_block(task)
|
||||
if dispatch_block:
|
||||
parts.append(dispatch_block)
|
||||
return _contract_text_for_audience("\n\n".join(parts), resolved_audience)
|
||||
|
||||
turn_type = turn_type_for_task(task, fallback="execute")
|
||||
work_item_name = str(task.title or "").strip()
|
||||
orchestration = str(task.metadata.get("work_item_orchestration_profile", "") or "").strip()
|
||||
verification_required = bool(task.metadata.get("work_item_verification_required", False))
|
||||
header = [
|
||||
_COMPANY_WORK_ITEM_GUIDELINES.strip(),
|
||||
f"Current work item: `{work_item_name or 'projected work item'}`",
|
||||
f"Work item turn type: `{turn_type}`",
|
||||
]
|
||||
if orchestration:
|
||||
header.append(f"Orchestration profile: `{orchestration}`")
|
||||
header.append(
|
||||
"Work item verification requirement: "
|
||||
+ ("required before completion." if verification_required else "not automatically required for this work item.")
|
||||
)
|
||||
if turn_type in {"intake", "plan", "dispatch"}:
|
||||
header.append(_COMPANY_PLAN_WORK_ITEM_GUIDELINES.strip())
|
||||
elif turn_type == "review":
|
||||
header.append(_COMPANY_REVIEW_WORK_ITEM_GUIDELINES.strip())
|
||||
elif turn_type in {"aggregate", "deliver"}:
|
||||
header.append(_COMPANY_AGGREGATE_WORK_ITEM_GUIDELINES.strip())
|
||||
else:
|
||||
header.append(_COMPANY_EXECUTE_WORK_ITEM_GUIDELINES.strip())
|
||||
return _contract_text_for_audience("\n\n".join(part for part in header if part), resolved_audience)
|
||||
|
||||
|
||||
def build_external_company_work_item_contract(task: Task) -> str:
|
||||
"""Return the company/runtime contract with external-agent tool wording."""
|
||||
return build_company_work_item_contract(task, audience="external")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,744 @@
|
||||
"""OPC Native Agent — the primary agent implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.company_tools import (
|
||||
COMPANY_ALL_COLLABORATION_TOOL_NAMES,
|
||||
MULTI_TEAM_COORDINATION_TURN_MODES,
|
||||
company_collaboration_enabled_for_task,
|
||||
resolve_company_turn_mode,
|
||||
resolve_task_collaboration_tools,
|
||||
)
|
||||
from opc.core.config import OPCConfig
|
||||
from opc.core.models import AgentInfo, AgentStatus, ExecutionMode, Task, TaskResult, TaskStatus
|
||||
from opc.core.events import EventBus
|
||||
from opc.core.models import OPCEvent
|
||||
from opc.core.worker_envelope import classify_worker_message
|
||||
from opc.llm.provider import LLMProvider
|
||||
from opc.layer1_perception.context_assembler import ContextAssembler
|
||||
from opc.layer3_agent.company_runtime_contract import build_company_work_item_contract
|
||||
from opc.layer3_agent.runtime_v2 import NativeRuntimeV2
|
||||
from opc.layer3_agent.prompt_harness import PromptHarnessBuilder
|
||||
from opc.layer3_agent.prompt_harness.builder import _final_decider_role_id, _memory_skill_user_facing
|
||||
from opc.layer4_tools.output_budget import clip_text
|
||||
from opc.layer4_tools.registry import ToolRegistry
|
||||
from opc.layer5_memory.memory_manager import MemoryManager
|
||||
from opc.layer5_memory.preference import PreferenceManager
|
||||
from opc.layer5_memory.skill_library import SkillLibrary
|
||||
from opc.layer6_observability.cost_tracker import CostTracker
|
||||
from opc.layer3_agent.prompt_harness.sections import (
|
||||
HONEST_REPORTING_CONTRACT,
|
||||
LONG_RUNNING_SESSION_CONTRACT,
|
||||
MEMORY_TRUST_CONTRACT,
|
||||
SAFE_ACTIONS_CONTRACT,
|
||||
SUBAGENT_HARNESS_CONTRACT,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role-aware system prompt components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CORE_HEADER = (
|
||||
"You are {role_name}, an AI agent in the OPC (One-Person Company) system.\n"
|
||||
"Role: {responsibility}\n\n"
|
||||
"You accomplish tasks by using the tools available to your role."
|
||||
)
|
||||
|
||||
_TASK_MODE_CORE_HEADER = (
|
||||
"You are {role_name}, an OpenOPC task execution agent.\n"
|
||||
"Role: {responsibility}\n\n"
|
||||
"You accomplish standalone user tasks by using the tools available to your role."
|
||||
)
|
||||
|
||||
_CORE_OPERATING_PRINCIPLES = """
|
||||
## Core Operating Principles
|
||||
- Use available context and tools before asking the user for missing information.
|
||||
- Own the user's goal within the explicit scope and keep moving with the best
|
||||
evidence available.
|
||||
- Be honest about uncertainty, failed attempts, unavailable tools, and
|
||||
unverified results.
|
||||
- Follow the runtime safety, reporting, memory, and subagent contracts when
|
||||
actions become risky or stateful.
|
||||
- Use the current tool strategy and tool schemas as the source of truth for
|
||||
choosing tools and exact arguments.
|
||||
"""
|
||||
|
||||
|
||||
_NATIVE_WORKING_CONTRACT = """
|
||||
## Native Working Contract
|
||||
- Use the task brief, runtime context, available tools, and explicit runtime
|
||||
addenda to choose the right working posture for this turn.
|
||||
- Treat planning, execution, review, verification, and synthesis as flexible
|
||||
working postures, not as prompt profiles selected by metadata.
|
||||
- Prefer concrete, evidence-backed progress over describing hypothetical work.
|
||||
- Keep implementation changes scoped to the request and consistent with the
|
||||
project.
|
||||
|
||||
## Planning And Review Practice
|
||||
- For planning, produce decision-complete steps with clear inputs, outputs,
|
||||
handoffs, risks, and validation targets.
|
||||
- For review, inspect the current workspace and evidence directly. Do not
|
||||
approve, reject, or repeat old findings without checking the current state.
|
||||
- When a runtime addendum requires a structured verdict, dispatch, report, or
|
||||
handoff shape, follow that addendum exactly.
|
||||
|
||||
## Native Self-Verification Contract
|
||||
- Before final delivery, check the user's goal against the actual changes,
|
||||
artifacts, and paths you touched.
|
||||
- When you change code, files, UI behavior, commands, or generated artifacts,
|
||||
prefer executable evidence: targeted tests, lint/type checks, smoke commands,
|
||||
browser checks, or direct artifact inspection.
|
||||
- If you cannot run a relevant verification step, say so plainly in one
|
||||
sentence and explain the constraint.
|
||||
- Include a short verification status in the final reply when you changed
|
||||
something or when the runtime asks for one.
|
||||
- If verification reveals a blocking issue, fix it before finishing when
|
||||
possible. If it cannot be fixed in this turn, report the blocker honestly
|
||||
instead of presenting the work as complete.
|
||||
"""
|
||||
|
||||
_USER_INPUT_GUIDELINES = """
|
||||
## User Input Recovery
|
||||
- If the latest user reply resolves the blocker, continue instead of asking again.
|
||||
- If it is incomplete or ambiguous, ask only for the exact remaining gap.
|
||||
- Never repeat the same broad question or ask for what the user already provided.
|
||||
"""
|
||||
|
||||
_TASK_MODE_ORCHESTRATION = """
|
||||
## Task-Mode Orchestration
|
||||
- You are the user's primary task-mode execution agent for this session.
|
||||
- Execute as a single full-capability agent; do not model task mode as a
|
||||
company organization, recruiting flow, employee persona, or staff assignment.
|
||||
- Treat the `task_generalist` role id as routing and logging metadata only, not
|
||||
as a persona source.
|
||||
- Prefer direct execution over narrating what you would do.
|
||||
- Use `agent_spawn`, `agent_wait`, and `agent_send` only for bounded parallel
|
||||
work or context isolation when that improves the result.
|
||||
"""
|
||||
|
||||
_MULTI_TEAM_COORDINATION_NATIVE_TOOL_BLOCKLIST = {
|
||||
"shell_exec",
|
||||
"file_write",
|
||||
"file_edit",
|
||||
"apply_patch",
|
||||
"python_exec",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"browser_navigate",
|
||||
"browser_navigate_back",
|
||||
"browser_click",
|
||||
"browser_snapshot",
|
||||
"browser_type",
|
||||
"browser_wait_for",
|
||||
"browser_scroll",
|
||||
"browser_select_option",
|
||||
"browser_take_screenshot",
|
||||
"browser_close",
|
||||
"git_status",
|
||||
"git_commit",
|
||||
"git_diff",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
}
|
||||
|
||||
_PROMPT_PROFILE_COMMUNICATION = """
|
||||
## Communication Contract
|
||||
- Before the first meaningful tool action, briefly state the immediate plan.
|
||||
- During longer work, give short progress updates when you find a root cause,
|
||||
change direction, or complete a meaningful milestone.
|
||||
- Final delivery must be outcome-first and include an explicit verification
|
||||
status when the runtime asks for one.
|
||||
"""
|
||||
|
||||
_PROMPT_PROFILE_HARNESS = """
|
||||
## Runtime Harness Reminder
|
||||
- The runtime may compact history, summarize older turns, and re-inject structured runtime artifacts.
|
||||
- Preserve important state in task tools and artifacts rather than only in free-form prose.
|
||||
- When resuming work, trust the reinjected runtime state before re-solving old steps.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class NativePromptBundle:
|
||||
"""Layered prompt payload for the native runtime."""
|
||||
|
||||
profile_name: str
|
||||
stable_system_prompt: str
|
||||
runtime_policy_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
class PromptProfileManager:
|
||||
"""Build unified native prompts with stable static sections."""
|
||||
|
||||
UNIFIED_PROFILE = "unified"
|
||||
|
||||
def __init__(self, role: AgentInfo, config: OPCConfig) -> None:
|
||||
self.role = role
|
||||
self.config = config
|
||||
|
||||
def resolve_profile(self, task: Task) -> str:
|
||||
_ = task
|
||||
# Compatibility/observability label only. Prompt profiles are no longer
|
||||
# selected from YAML; the native prompt is intentionally unified.
|
||||
return self.UNIFIED_PROFILE
|
||||
|
||||
def build_stable_system_prompt(self, task: Task) -> tuple[str, str]:
|
||||
profile = self.resolve_profile(task)
|
||||
header = _TASK_MODE_CORE_HEADER if self._is_task_mode_task(task) else _CORE_HEADER
|
||||
parts: list[str] = [
|
||||
header.format(
|
||||
role_name=self.role.name,
|
||||
responsibility=self.role.responsibility,
|
||||
),
|
||||
_CORE_OPERATING_PRINCIPLES,
|
||||
SAFE_ACTIONS_CONTRACT,
|
||||
HONEST_REPORTING_CONTRACT,
|
||||
MEMORY_TRUST_CONTRACT,
|
||||
SUBAGENT_HARNESS_CONTRACT,
|
||||
_NATIVE_WORKING_CONTRACT,
|
||||
_USER_INPUT_GUIDELINES,
|
||||
_PROMPT_PROFILE_COMMUNICATION,
|
||||
_PROMPT_PROFILE_HARNESS,
|
||||
LONG_RUNNING_SESSION_CONTRACT,
|
||||
]
|
||||
return profile, "\n\n".join(part for part in parts if part)
|
||||
|
||||
def build_runtime_policy_messages(self, task: Task) -> list[dict[str, Any]]:
|
||||
parts: list[str] = []
|
||||
|
||||
if self._is_company_mode_task(task):
|
||||
parts.append(self._build_company_work_item_contract(task))
|
||||
|
||||
if self._is_task_mode_task(task):
|
||||
parts.append(_TASK_MODE_ORCHESTRATION)
|
||||
if self.role.prompt_refs and not self._is_task_generalist_role(task):
|
||||
parts.append("## Role Operating Instructions\n" + "\n\n".join(self.role.prompt_refs))
|
||||
runtime_prompt_addendum = str(task.metadata.get("_subagent_profile_prompt", "") or "").strip()
|
||||
if runtime_prompt_addendum:
|
||||
parts.append(f"## Runtime Profile Override\n{runtime_prompt_addendum}")
|
||||
return [
|
||||
{"role": "system", "content": part}
|
||||
for part in parts
|
||||
if str(part or "").strip()
|
||||
]
|
||||
|
||||
def build_prompt_bundle(self, task: Task) -> NativePromptBundle:
|
||||
profile, stable_prompt = self.build_stable_system_prompt(task)
|
||||
return NativePromptBundle(
|
||||
profile_name=profile,
|
||||
stable_system_prompt=stable_prompt,
|
||||
runtime_policy_messages=self.build_runtime_policy_messages(task),
|
||||
)
|
||||
|
||||
def build_prompt(self, task: Task) -> tuple[str, str]:
|
||||
bundle = self.build_prompt_bundle(task)
|
||||
parts = [
|
||||
bundle.stable_system_prompt,
|
||||
*[
|
||||
str(message.get("content", "") or "").strip()
|
||||
for message in bundle.runtime_policy_messages
|
||||
],
|
||||
]
|
||||
return bundle.profile_name, "\n\n".join(part for part in parts if part)
|
||||
|
||||
@staticmethod
|
||||
def _is_task_mode_task(task: Task) -> bool:
|
||||
mode = str(task.metadata.get("mode") or "").strip().lower()
|
||||
execution_mode = str(task.metadata.get("execution_mode") or "").strip()
|
||||
return mode in {"project", "task"} or execution_mode == ExecutionMode.TASK_MODE.value
|
||||
|
||||
@staticmethod
|
||||
def _is_company_mode_task(task: Task) -> bool:
|
||||
execution_mode = str(task.metadata.get("execution_mode") or "").strip()
|
||||
return execution_mode == ExecutionMode.COMPANY_MODE.value
|
||||
|
||||
def _is_task_generalist_role(self, task: Task) -> bool:
|
||||
role_id = str(getattr(self.role, "role_id", "") or "").strip()
|
||||
return role_id == "task_generalist" and self._is_task_mode_task(task)
|
||||
|
||||
def _build_company_work_item_contract(self, task: Task) -> str:
|
||||
return build_company_work_item_contract(task)
|
||||
|
||||
|
||||
class NativeAgent:
|
||||
"""OPC Native Agent — wraps NativeRuntimeV2 with memory, skills, and preferences."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role: AgentInfo,
|
||||
llm: LLMProvider,
|
||||
tool_registry: ToolRegistry,
|
||||
context_assembler: ContextAssembler,
|
||||
memory: MemoryManager,
|
||||
preferences: PreferenceManager,
|
||||
skills: SkillLibrary,
|
||||
event_bus: EventBus,
|
||||
cost_tracker: CostTracker | None = None,
|
||||
config: OPCConfig | None = None,
|
||||
communication: Any | None = None,
|
||||
approval_callback: Any = None,
|
||||
) -> None:
|
||||
self.role = role
|
||||
self.llm = llm
|
||||
self.tool_registry = tool_registry
|
||||
self.context_assembler = context_assembler
|
||||
self.memory = memory
|
||||
self.preferences = preferences
|
||||
self.skills = skills
|
||||
self.event_bus = event_bus
|
||||
self.cost_tracker = cost_tracker
|
||||
self.config = config or OPCConfig()
|
||||
self.communication = communication
|
||||
self.approval_callback = approval_callback
|
||||
self.prompt_profiles = PromptProfileManager(role, self.config)
|
||||
max_iter = self.config.system.max_agent_iterations
|
||||
comp_threshold = self.config.system.context_compression_threshold
|
||||
self.loop = NativeRuntimeV2(
|
||||
llm=llm,
|
||||
tool_registry=tool_registry,
|
||||
event_bus=event_bus,
|
||||
cost_tracker=cost_tracker,
|
||||
memory_manager=memory,
|
||||
history_compactor=getattr(memory, "history_compactor", None),
|
||||
max_iterations=max_iter,
|
||||
compression_threshold=comp_threshold,
|
||||
config=self.config,
|
||||
child_agent_factory=self._create_child_agent,
|
||||
approval_callback=approval_callback,
|
||||
prefetch_provider=self._build_runtime_prefetch_payload,
|
||||
)
|
||||
|
||||
def _is_task_mode_task(self, task: Task) -> bool:
|
||||
mode = str(task.metadata.get("mode") or "").strip().lower()
|
||||
execution_mode = str(task.metadata.get("execution_mode") or "").strip()
|
||||
if mode in {"project", "task"}:
|
||||
return True
|
||||
return execution_mode == ExecutionMode.TASK_MODE.value
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
task: Task,
|
||||
on_progress: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
||||
) -> TaskResult:
|
||||
"""Execute a task end-to-end."""
|
||||
self.role.status = AgentStatus.RUNNING
|
||||
self.role.current_task_id = task.id
|
||||
|
||||
await self.event_bus.publish(OPCEvent(
|
||||
event_type="agent_status_changed",
|
||||
payload={"role_id": self.role.role_id, "status": "running", "task_id": task.id},
|
||||
))
|
||||
|
||||
is_task_mode = self._is_task_mode_task(task)
|
||||
allowed = self._resolve_allowed_tools(task)
|
||||
inbox_interrupt_provider = None
|
||||
if (
|
||||
self.communication
|
||||
and task.metadata.get("execution_mode") == ExecutionMode.COMPANY_MODE.value
|
||||
and not bool(task.metadata.get("_disable_live_inbox_interrupts", False))
|
||||
):
|
||||
inbox_interrupt_provider = self._create_inbox_interrupt_provider()
|
||||
runtime_inbox_queue = getattr(task, "_runtime_inbox_queue", None)
|
||||
if runtime_inbox_queue is not None:
|
||||
inbox_interrupt_provider = self._create_runtime_inbox_provider(runtime_inbox_queue, task)
|
||||
|
||||
try:
|
||||
system_prompt = await self._build_system_prompt(task)
|
||||
user_message = await self._build_user_message(task)
|
||||
context_messages = await self._build_context_messages(task)
|
||||
|
||||
result = await self.loop.run(
|
||||
system_prompt=system_prompt,
|
||||
user_message=user_message,
|
||||
context_messages=context_messages,
|
||||
attachment_refs=list(task.metadata.get("attachment_refs", []) or []),
|
||||
task=task,
|
||||
allowed_tools=allowed,
|
||||
on_progress=on_progress,
|
||||
inbox_interrupt_provider=inbox_interrupt_provider,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Agent {self.role.role_id} failed on task {task.id}: {e}")
|
||||
return TaskResult(status=TaskStatus.FAILED, content=str(e))
|
||||
|
||||
finally:
|
||||
self.role.status = AgentStatus.IDLE
|
||||
self.role.current_task_id = None
|
||||
await self.event_bus.publish(OPCEvent(
|
||||
event_type="agent_status_changed",
|
||||
payload={"role_id": self.role.role_id, "status": "idle"},
|
||||
))
|
||||
|
||||
async def _build_native_prompt_bundle(self, task: Task) -> NativePromptBundle:
|
||||
override = str(task.metadata.get("_runtime_system_prompt_override", "") or "").strip()
|
||||
if override:
|
||||
task.metadata["runtime_prompt_profile"] = "override"
|
||||
return NativePromptBundle(
|
||||
profile_name="override",
|
||||
stable_system_prompt=override,
|
||||
runtime_policy_messages=[],
|
||||
)
|
||||
bundle = self.prompt_profiles.build_prompt_bundle(task)
|
||||
task.metadata["runtime_prompt_profile"] = bundle.profile_name
|
||||
return bundle
|
||||
|
||||
async def _build_system_prompt(self, task: Task) -> str:
|
||||
bundle = await self._build_native_prompt_bundle(task)
|
||||
return bundle.stable_system_prompt
|
||||
|
||||
async def _build_user_message(self, task: Task) -> str:
|
||||
return self.context_assembler.build_task_brief(task)
|
||||
|
||||
async def _build_context_messages(self, task: Task) -> list[dict[str, Any]]:
|
||||
fork_messages = list(task.metadata.get("_fork_context_messages", []) or [])
|
||||
if fork_messages:
|
||||
return fork_messages
|
||||
|
||||
harness_output = await self._build_prompt_harness(task)
|
||||
dynamic_messages = [
|
||||
*harness_output.runtime_policy_messages,
|
||||
*harness_output.workspace_context_messages,
|
||||
*harness_output.artifact_messages,
|
||||
]
|
||||
session_id = getattr(task, "session_id", None)
|
||||
if not session_id:
|
||||
return dynamic_messages
|
||||
context_snapshot = task.context_snapshot if isinstance(task.context_snapshot, dict) else {}
|
||||
raw_runtime_resume = context_snapshot.get("runtime_resume")
|
||||
has_runtime_resume = isinstance(raw_runtime_resume, dict) and bool(raw_runtime_resume)
|
||||
legacy_skip_session_history = raw_runtime_resume is True
|
||||
if bool(context_snapshot.get("skip_session_history", False)) or has_runtime_resume or legacy_skip_session_history:
|
||||
return dynamic_messages
|
||||
return [
|
||||
*dynamic_messages,
|
||||
*(
|
||||
await self.memory.build_session_history_tail_messages(
|
||||
session_id,
|
||||
include_latest_user_turn=False,
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
async def _build_prompt_harness(self, task: Task) -> Any:
|
||||
allowed_tools = self._resolve_allowed_tools(task)
|
||||
prompt_bundle = await self._build_native_prompt_bundle(task)
|
||||
harness = PromptHarnessBuilder(
|
||||
task=task,
|
||||
role_id=self.role.role_id,
|
||||
config=self.config,
|
||||
context_assembler=self.context_assembler,
|
||||
preferences=self.preferences,
|
||||
skills=self.skills,
|
||||
)
|
||||
output = await harness.build(
|
||||
system_prompt=prompt_bundle.stable_system_prompt,
|
||||
allowed_tools=allowed_tools,
|
||||
runtime_policy_messages=prompt_bundle.runtime_policy_messages,
|
||||
)
|
||||
task.metadata["prompt_harness"] = {
|
||||
"static_section_ids": list(output.static_section_ids),
|
||||
"dynamic_section_ids": list(output.dynamic_section_ids),
|
||||
"artifact_manifest": list(output.artifact_manifest),
|
||||
"artifact_hashes": dict(output.artifact_hashes),
|
||||
}
|
||||
task.metadata["_prompt_harness_boot_artifacts"] = list(output.artifact_manifest)
|
||||
return output
|
||||
|
||||
def _registered_general_tool_names(self) -> set[str]:
|
||||
return {
|
||||
str(tool.name or "").strip()
|
||||
for tool in self.tool_registry.list_tools()
|
||||
if str(tool.name or "").strip()
|
||||
and str(tool.name or "").strip() not in COMPANY_ALL_COLLABORATION_TOOL_NAMES
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _configured_general_tool_names(tools: list[str] | tuple[str, ...]) -> set[str]:
|
||||
return {
|
||||
str(tool or "").strip()
|
||||
for tool in list(tools or [])
|
||||
if str(tool or "").strip()
|
||||
and str(tool or "").strip() not in COMPANY_ALL_COLLABORATION_TOOL_NAMES
|
||||
}
|
||||
|
||||
def _resolve_allowed_tools(self, task: Task) -> list[str] | None:
|
||||
turn_mode = resolve_company_turn_mode(task, runtime_state={})
|
||||
inherited = list(task.metadata.get("_fork_allowed_tools", []) or [])
|
||||
if inherited:
|
||||
if not company_collaboration_enabled_for_task(task):
|
||||
inherited = [
|
||||
tool for tool in inherited
|
||||
if tool not in COMPANY_ALL_COLLABORATION_TOOL_NAMES
|
||||
]
|
||||
else:
|
||||
_, allowed_collab = resolve_task_collaboration_tools(
|
||||
task,
|
||||
role=self.role.role_id,
|
||||
seat=str(task.metadata.get("delegation_seat_id", "") or "").strip(),
|
||||
runtime_state={},
|
||||
role_cfg=self.role,
|
||||
)
|
||||
inherited = [
|
||||
tool for tool in inherited
|
||||
if tool not in COMPANY_ALL_COLLABORATION_TOOL_NAMES or tool in allowed_collab
|
||||
]
|
||||
if turn_mode in MULTI_TEAM_COORDINATION_TURN_MODES:
|
||||
inherited = [
|
||||
tool for tool in inherited
|
||||
if tool not in _MULTI_TEAM_COORDINATION_NATIVE_TOOL_BLOCKLIST
|
||||
]
|
||||
return inherited
|
||||
|
||||
configured_general = self._configured_general_tool_names(list(self.role.tools or []))
|
||||
company_mode = company_collaboration_enabled_for_task(task)
|
||||
if company_mode:
|
||||
allowed = set(configured_general) if configured_general else self._registered_general_tool_names()
|
||||
_, allowed_collab = resolve_task_collaboration_tools(
|
||||
task,
|
||||
role=self.role.role_id,
|
||||
seat=str(task.metadata.get("delegation_seat_id", "") or "").strip(),
|
||||
runtime_state={},
|
||||
role_cfg=self.role,
|
||||
)
|
||||
allowed.update(allowed_collab)
|
||||
elif configured_general:
|
||||
allowed = set(configured_general)
|
||||
else:
|
||||
return None
|
||||
|
||||
if turn_mode in MULTI_TEAM_COORDINATION_TURN_MODES:
|
||||
allowed.difference_update(_MULTI_TEAM_COORDINATION_NATIVE_TOOL_BLOCKLIST)
|
||||
return sorted(allowed)
|
||||
|
||||
async def _build_runtime_prefetch_payload(
|
||||
self,
|
||||
task: Task,
|
||||
query: str,
|
||||
_messages: list[dict[str, Any]],
|
||||
) -> dict[str, str]:
|
||||
prefetch_cfg = self.config.system.native_runtime.prefetch
|
||||
if not prefetch_cfg.enabled:
|
||||
return {}
|
||||
payload: dict[str, str] = {}
|
||||
max_chars = max(400, int(prefetch_cfg.max_chars or 4000))
|
||||
session_id = getattr(task, "session_id", None)
|
||||
include_project_knowledge = bool(task.metadata.get("include_project_knowledge", False))
|
||||
if prefetch_cfg.session_memory and session_id:
|
||||
session_memory = (await self.memory.build_session_memory_context(session_id)).strip()
|
||||
if session_memory:
|
||||
payload["session_memory"] = clip_text(
|
||||
session_memory,
|
||||
limit=max_chars,
|
||||
marker="session memory prefetch truncated",
|
||||
).text
|
||||
if prefetch_cfg.focused_memory:
|
||||
focused = (
|
||||
await self.memory.build_focused_memory_context(
|
||||
query=query,
|
||||
project_id=task.project_id,
|
||||
session_id=session_id,
|
||||
include_project_knowledge=include_project_knowledge,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
).strip()
|
||||
if focused:
|
||||
payload["focused_memory"] = clip_text(
|
||||
focused,
|
||||
limit=max_chars,
|
||||
marker="focused memory prefetch truncated",
|
||||
).text
|
||||
if prefetch_cfg.project_memory_candidates:
|
||||
project_memory = (
|
||||
await self.memory.build_project_memory_context(
|
||||
project_id=task.project_id,
|
||||
include_project_knowledge=include_project_knowledge,
|
||||
)
|
||||
).strip()
|
||||
if project_memory:
|
||||
payload["project_memory_candidates"] = clip_text(
|
||||
project_memory,
|
||||
limit=max_chars,
|
||||
marker="project memory prefetch truncated",
|
||||
).text
|
||||
harness_cfg = self.config.system.native_runtime.prompt_harness
|
||||
skills_in_prompt_harness = bool(harness_cfg.enabled and harness_cfg.artifact_messages_enabled)
|
||||
if prefetch_cfg.skills_summary and not skills_in_prompt_harness:
|
||||
execution_mode = str(task.metadata.get("execution_mode", "") or "").strip() or None
|
||||
skills_summary = str(
|
||||
self.skills.build_skills_summary(
|
||||
task.project_id,
|
||||
execution_mode=execution_mode,
|
||||
role_id=self.role.role_id,
|
||||
user_facing=_memory_skill_user_facing(task, self.role.role_id),
|
||||
final_decider_role_id=_final_decider_role_id(task),
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if skills_summary:
|
||||
payload["skills_summary"] = clip_text(
|
||||
skills_summary,
|
||||
limit=max_chars,
|
||||
marker="skills summary prefetch truncated",
|
||||
).text
|
||||
return payload
|
||||
|
||||
def _create_inbox_interrupt_provider(self) -> Any:
|
||||
communication = self.communication
|
||||
agent_role_id = self.role.role_id
|
||||
|
||||
async def _provide(task: Task) -> list[dict[str, Any]]:
|
||||
if communication is None:
|
||||
return []
|
||||
return await communication.consume_live_inbox_messages(task, agent_id=agent_role_id)
|
||||
|
||||
return _provide
|
||||
|
||||
def _create_runtime_inbox_provider(self, inbox_queue: Any, task: Task) -> Any:
|
||||
async def _provide(_task: Task) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
while True:
|
||||
try:
|
||||
message = inbox_queue.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
if not message:
|
||||
continue
|
||||
if isinstance(message, dict):
|
||||
normalized = dict(message)
|
||||
normalized.setdefault("from", str(normalized.get("from_agent", "runtime_subagent_parent") or "runtime_subagent_parent"))
|
||||
normalized.setdefault("body", str(normalized.get("body", normalized.get("message", "")) or ""))
|
||||
items.append(normalized)
|
||||
continue
|
||||
items.append({"from": "runtime_subagent_parent", "body": str(message)})
|
||||
endpoint_id = str(task.metadata.get("_comms_endpoint_id", "") or "").strip()
|
||||
if endpoint_id:
|
||||
workspace_root = (
|
||||
str(task.metadata.get("comms_workspace_root", "") or "").strip()
|
||||
or str(task.metadata.get("workspace_root", "") or "").strip()
|
||||
or str(task.metadata.get("target_output_dir", "") or "").strip()
|
||||
)
|
||||
if workspace_root:
|
||||
try:
|
||||
from opc.layer2_organization import comms as _comms
|
||||
|
||||
layout = _comms.resolve_layout(
|
||||
workspace_root,
|
||||
str(task.project_id or "default").strip() or "default",
|
||||
str(task.parent_session_id or task.session_id or "default").strip() or "default",
|
||||
)
|
||||
unread = _comms.list_unread(layout, endpoint_id, limit=6)
|
||||
injected_ids = {
|
||||
str(item).strip()
|
||||
for item in list(task.context_snapshot.get("runtime_inbox_injected_message_ids", []) or [])
|
||||
if str(item).strip()
|
||||
}
|
||||
for header in unread:
|
||||
msg_id = str(header.message_id or "").strip()
|
||||
if msg_id and msg_id in injected_ids:
|
||||
continue
|
||||
_, body = _comms.read_message(header.path)
|
||||
if body.strip():
|
||||
items.append(classify_worker_message(
|
||||
{
|
||||
"from": str(header.from_role or "runtime_subagent_parent").strip() or "runtime_subagent_parent",
|
||||
"from_agent": str(header.from_role or "runtime_subagent_parent").strip() or "runtime_subagent_parent",
|
||||
"subject": str(header.subject or "").strip(),
|
||||
"message_id": str(header.message_id or "").strip(),
|
||||
"msg_id": str(header.message_id or "").strip(),
|
||||
"body": body.strip(),
|
||||
"reply_needed": bool(header.blocking),
|
||||
"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 {}),
|
||||
}
|
||||
))
|
||||
if msg_id:
|
||||
injected_ids.add(msg_id)
|
||||
if injected_ids:
|
||||
task.context_snapshot = dict(task.context_snapshot)
|
||||
task.context_snapshot["runtime_inbox_injected_message_ids"] = sorted(injected_ids)[-50:]
|
||||
except Exception:
|
||||
pass
|
||||
return items
|
||||
|
||||
return _provide
|
||||
|
||||
def _create_child_agent(
|
||||
self,
|
||||
profile: str,
|
||||
allowed_tools: list[str],
|
||||
prompt_addendum: str,
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> "NativeAgent":
|
||||
overrides = dict(overrides or {})
|
||||
role_name = str(overrides.get("name") or f"{self.role.name} [{profile}]").strip() or f"{self.role.name} [{profile}]"
|
||||
role = AgentInfo(
|
||||
role_id=f"{self.role.role_id}:{profile}",
|
||||
name=role_name,
|
||||
responsibility=self.role.responsibility,
|
||||
status=AgentStatus.IDLE,
|
||||
current_task_id=None,
|
||||
reports_to=self.role.reports_to,
|
||||
icon=self.role.icon,
|
||||
can_spawn=list(self.role.can_spawn),
|
||||
tools=list(allowed_tools),
|
||||
preferred_external_agent=self.role.preferred_external_agent,
|
||||
prompt_refs=[*self.role.prompt_refs],
|
||||
skill_refs=[*self.role.skill_refs],
|
||||
handoff_template_ref=self.role.handoff_template_ref,
|
||||
memory_policy_ref=self.role.memory_policy_ref,
|
||||
artifact_contract_ref=self.role.artifact_contract_ref,
|
||||
runtime_policy=dict(self.role.runtime_policy),
|
||||
org_id=self.role.org_id,
|
||||
budget_monthly_cents=self.role.budget_monthly_cents,
|
||||
spent_monthly_cents=self.role.spent_monthly_cents,
|
||||
heartbeat_enabled=self.role.heartbeat_enabled,
|
||||
heartbeat_interval_sec=self.role.heartbeat_interval_sec,
|
||||
last_heartbeat_at=self.role.last_heartbeat_at,
|
||||
capabilities=self.role.capabilities,
|
||||
)
|
||||
if prompt_addendum:
|
||||
role.prompt_refs.append(prompt_addendum)
|
||||
if overrides.get("description"):
|
||||
role.prompt_refs.append(f"Subagent task summary: {str(overrides['description']).strip()}")
|
||||
if overrides.get("mode"):
|
||||
role.prompt_refs.append(f"Runtime spawn mode: {str(overrides['mode']).strip()}")
|
||||
|
||||
child_llm = self.llm
|
||||
model_override = str(overrides.get("model") or "").strip()
|
||||
if model_override:
|
||||
llm_config = self.llm.config.model_copy(deep=True)
|
||||
llm_config.default_model = model_override
|
||||
child_llm = LLMProvider(llm_config, opc_home=getattr(self.llm, "opc_home", None))
|
||||
|
||||
child_config = self.config
|
||||
max_iterations = overrides.get("max_iterations")
|
||||
if self.config is not None and max_iterations:
|
||||
child_config = self.config.model_copy(deep=True)
|
||||
child_config.system.max_agent_iterations = max(1, int(max_iterations))
|
||||
|
||||
return NativeAgent(
|
||||
role=role,
|
||||
llm=child_llm,
|
||||
tool_registry=self.tool_registry,
|
||||
context_assembler=self.context_assembler,
|
||||
memory=self.memory,
|
||||
preferences=self.preferences,
|
||||
skills=self.skills,
|
||||
event_bus=self.event_bus,
|
||||
cost_tracker=self.cost_tracker,
|
||||
config=child_config,
|
||||
communication=self.communication,
|
||||
approval_callback=self.approval_callback,
|
||||
)
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Preflight checks for OpenOPC external-agent integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import OPCConfig
|
||||
from opc.core.models import Task
|
||||
from opc.layer3_agent.adapters.base import ExternalAgentAdapter
|
||||
from opc.layer3_agent.adapters.registry import ADAPTER_CLASSES
|
||||
from opc.layer3_agent.skill_installer import (
|
||||
install_collab_surface,
|
||||
opc_collab_executable,
|
||||
)
|
||||
|
||||
|
||||
_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
|
||||
"codex": ("exec", "--help"),
|
||||
"claude_code": ("--help",),
|
||||
"cursor": ("--help",),
|
||||
"opencode": ("run", "--help"),
|
||||
}
|
||||
|
||||
|
||||
_VERSION_COMMANDS: dict[str, tuple[str, ...]] = {
|
||||
"codex": ("--version",),
|
||||
"claude_code": ("--version",),
|
||||
"cursor": ("--version",),
|
||||
"opencode": ("--version",),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PathProbeResult:
|
||||
name: str
|
||||
path: str
|
||||
ok: bool
|
||||
error: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"path": self.path,
|
||||
"ok": self.ok,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalAgentPreflightResult:
|
||||
agent: str
|
||||
enabled: bool
|
||||
command: str
|
||||
available: bool
|
||||
binary: str = ""
|
||||
version: str = ""
|
||||
launch_command: str = ""
|
||||
stdin_policy: str = ""
|
||||
collaboration_rpc_transport: str = ""
|
||||
isolated_home: str = ""
|
||||
collab_cli: str = ""
|
||||
issues: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
write_checks: list[PathProbeResult] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return bool(self.enabled and self.available and not self.issues)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"agent": self.agent,
|
||||
"enabled": self.enabled,
|
||||
"command": self.command,
|
||||
"available": self.available,
|
||||
"binary": self.binary,
|
||||
"version": self.version,
|
||||
"launch_command": self.launch_command,
|
||||
"stdin_policy": self.stdin_policy,
|
||||
"collaboration_rpc_transport": self.collaboration_rpc_transport,
|
||||
"isolated_home": self.isolated_home,
|
||||
"collab_cli": self.collab_cli,
|
||||
"issues": list(self.issues),
|
||||
"warnings": list(self.warnings),
|
||||
"write_checks": [check.as_dict() for check in self.write_checks],
|
||||
"ok": self.ok,
|
||||
}
|
||||
|
||||
|
||||
class ExternalAgentPreflightError(RuntimeError):
|
||||
"""Raised when the workspace permission contract is not writable."""
|
||||
|
||||
def __init__(self, checks: list[PathProbeResult]) -> None:
|
||||
self.checks = checks
|
||||
failures = [check for check in checks if not check.ok]
|
||||
detail = "; ".join(f"{item.name}={item.path}: {item.error}" for item in failures)
|
||||
super().__init__(f"External agent workspace permission preflight failed: {detail}")
|
||||
|
||||
|
||||
def ensure_external_agent_surfaces(
|
||||
config: OPCConfig,
|
||||
*,
|
||||
opc_home: Path | None = None,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Provision isolated agent homes and the shared opc-collab shim.
|
||||
|
||||
This is intentionally safe to run from ``opc init`` and ``opc status``:
|
||||
surfaces are idempotent and user config is mirrored into OpenOPC-owned
|
||||
isolated homes instead of mutating the user's real agent configuration.
|
||||
"""
|
||||
base_home = Path(opc_home) if opc_home else _get_opc_home()
|
||||
surfaces: dict[str, dict[str, str]] = {}
|
||||
for agent_name, adapter_cls in ADAPTER_CLASSES.items():
|
||||
agent_config = config.agents.agents.get(agent_name)
|
||||
adapter = adapter_cls(config=agent_config)
|
||||
if agent_config and not agent_config.enabled:
|
||||
continue
|
||||
slug = adapter.agent_isolation_home_slug()
|
||||
if not slug:
|
||||
continue
|
||||
home, bin_dir = install_collab_surface(slug, opc_home=base_home)
|
||||
adapter.post_install_agent_home(str(home))
|
||||
surfaces[agent_name] = {
|
||||
"home": str(home),
|
||||
"bin_dir": str(bin_dir),
|
||||
"collab_cli": str(opc_collab_executable(bin_dir)),
|
||||
}
|
||||
return surfaces
|
||||
|
||||
|
||||
def probe_external_agent_write_contract(
|
||||
*,
|
||||
workspace_path: str | Path,
|
||||
opc_home: Path | None = None,
|
||||
task: Task | None = None,
|
||||
project_db_path: str | Path | None = None,
|
||||
) -> list[PathProbeResult]:
|
||||
"""Check every path external agents must be able to write before launch."""
|
||||
base_home = Path(opc_home) if opc_home else _get_opc_home()
|
||||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
probes: list[tuple[str, Path, bool]] = [
|
||||
("workspace", Path(workspace_path).expanduser(), False),
|
||||
("opc_home", base_home.expanduser(), False),
|
||||
("opc_memory", (base_home / "memory").expanduser(), False),
|
||||
]
|
||||
|
||||
for name, key in (
|
||||
("collab_workspace", "comms_workspace_root"),
|
||||
("collab_root", "comms_root"),
|
||||
("output_root", "output_root"),
|
||||
("target_output_dir", "target_output_dir"),
|
||||
):
|
||||
raw = str(metadata.get(key) or "").strip()
|
||||
if raw:
|
||||
probes.append((name, Path(raw).expanduser(), False))
|
||||
|
||||
if project_db_path:
|
||||
probes.append(("project_db", Path(project_db_path).expanduser(), True))
|
||||
|
||||
results: list[PathProbeResult] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for name, path, is_file in probes:
|
||||
key = (name, str(path))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
results.append(_probe_writable_path(name, path, is_file=is_file))
|
||||
return results
|
||||
|
||||
|
||||
def assert_external_agent_write_contract(
|
||||
*,
|
||||
workspace_path: str | Path,
|
||||
opc_home: Path | None = None,
|
||||
task: Task | None = None,
|
||||
project_db_path: str | Path | None = None,
|
||||
) -> list[PathProbeResult]:
|
||||
checks = probe_external_agent_write_contract(
|
||||
workspace_path=workspace_path,
|
||||
opc_home=opc_home,
|
||||
task=task,
|
||||
project_db_path=project_db_path,
|
||||
)
|
||||
if any(not check.ok for check in checks):
|
||||
raise ExternalAgentPreflightError(checks)
|
||||
return checks
|
||||
|
||||
|
||||
def run_external_agent_preflight(
|
||||
config: OPCConfig,
|
||||
*,
|
||||
project_id: str = "default",
|
||||
workspace_path: str | Path | None = None,
|
||||
opc_home: Path | None = None,
|
||||
probe_commands: bool = True,
|
||||
prepare_surfaces: bool = True,
|
||||
) -> list[ExternalAgentPreflightResult]:
|
||||
base_home = Path(opc_home) if opc_home else _get_opc_home()
|
||||
if workspace_path is None:
|
||||
workspace = _get_project_workplace(project_id)
|
||||
else:
|
||||
workspace = Path(workspace_path)
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
surfaces = (
|
||||
ensure_external_agent_surfaces(config, opc_home=base_home)
|
||||
if prepare_surfaces
|
||||
else {}
|
||||
)
|
||||
sample_task = _sample_preflight_task(project_id, workspace)
|
||||
project_db = base_home / "projects" / project_id / "tasks.db"
|
||||
write_checks = probe_external_agent_write_contract(
|
||||
workspace_path=workspace,
|
||||
opc_home=base_home,
|
||||
task=sample_task,
|
||||
project_db_path=project_db,
|
||||
)
|
||||
rpc_transport, rpc_issue = _describe_collaboration_rpc_transport()
|
||||
|
||||
results: list[ExternalAgentPreflightResult] = []
|
||||
for agent_name, adapter_cls in ADAPTER_CLASSES.items():
|
||||
agent_config = config.agents.agents.get(agent_name)
|
||||
adapter = adapter_cls(config=agent_config)
|
||||
command = adapter.configured_command()
|
||||
enabled = bool(adapter.config.enabled)
|
||||
binary = adapter.resolve_binary() if enabled else None
|
||||
result = ExternalAgentPreflightResult(
|
||||
agent=agent_name,
|
||||
enabled=enabled,
|
||||
command=command,
|
||||
available=bool(binary),
|
||||
binary=str(binary or ""),
|
||||
write_checks=list(write_checks),
|
||||
collaboration_rpc_transport=rpc_transport,
|
||||
)
|
||||
surface = surfaces.get(agent_name, {})
|
||||
result.isolated_home = surface.get("home", "")
|
||||
result.collab_cli = surface.get("collab_cli", "")
|
||||
|
||||
if not enabled:
|
||||
result.warnings.append("disabled in config")
|
||||
results.append(result)
|
||||
continue
|
||||
|
||||
cmd: list[str] = []
|
||||
try:
|
||||
cmd, metadata = _build_sample_invocation(adapter, sample_task, str(workspace))
|
||||
result.launch_command = str(
|
||||
metadata.get("display_command") or metadata.get("command") or shlex.join(cmd)
|
||||
)
|
||||
result.stdin_policy = _describe_stdin_policy(adapter, cmd, metadata)
|
||||
except Exception as exc:
|
||||
result.warnings.append(f"sample invocation unavailable: {exc}")
|
||||
|
||||
if not binary:
|
||||
result.issues.append(_missing_agent_issue(agent_name, command))
|
||||
if rpc_issue:
|
||||
result.issues.append(rpc_issue)
|
||||
results.append(result)
|
||||
continue
|
||||
|
||||
_add_windows_wrapper_warnings(command, str(binary), result.warnings)
|
||||
if rpc_issue:
|
||||
result.issues.append(rpc_issue)
|
||||
if probe_commands:
|
||||
result.version = _probe_version(agent_name, str(binary), result.warnings)
|
||||
if cmd:
|
||||
_probe_help_flags(agent_name, str(binary), cmd, result)
|
||||
_probe_isolated_home(agent_name, result)
|
||||
|
||||
for check in write_checks:
|
||||
if not check.ok:
|
||||
result.issues.append(f"{check.name} is not writable: {check.path} ({check.error})")
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
def _describe_collaboration_rpc_transport() -> tuple[str, str]:
|
||||
from opc.layer4_tools.collaboration_rpc import (
|
||||
OPC_COLLAB_RPC_TRANSPORT,
|
||||
default_collaboration_rpc_transport,
|
||||
resolve_collaboration_rpc_transport,
|
||||
)
|
||||
|
||||
requested = str(os.environ.get(OPC_COLLAB_RPC_TRANSPORT, "")).strip().lower()
|
||||
try:
|
||||
resolved = resolve_collaboration_rpc_transport(requested or "auto")
|
||||
except Exception as exc:
|
||||
return (f"{requested or 'auto'}(unavailable)", str(exc))
|
||||
if resolved == "tcp":
|
||||
return ("tcp(loopback)", "")
|
||||
if requested and requested != resolved:
|
||||
return (f"{resolved}({requested})", "")
|
||||
return (resolved, "")
|
||||
|
||||
|
||||
def _describe_stdin_policy(
|
||||
adapter: ExternalAgentAdapter,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any],
|
||||
) -> str:
|
||||
try:
|
||||
return adapter.stdin_policy_for_process(cmd, metadata)
|
||||
except Exception:
|
||||
explicit_policy = str(metadata.get("stdin_policy") or "").strip()
|
||||
return explicit_policy or "devnull"
|
||||
|
||||
|
||||
def _missing_agent_issue(agent_name: str, command: str) -> str:
|
||||
if agent_name == "cursor":
|
||||
editor = shutil.which("cursor")
|
||||
agent = shutil.which("cursor-agent")
|
||||
if editor and not agent:
|
||||
return "Cursor editor found, cursor-agent missing; install Cursor Agent CLI for headless execution"
|
||||
return f"command not found on PATH: {command}"
|
||||
|
||||
|
||||
def _add_windows_wrapper_warnings(command: str, binary: str, warnings: list[str]) -> None:
|
||||
if os.name != "nt":
|
||||
return
|
||||
path = Path(binary)
|
||||
if path.suffix.lower() == ".ps1":
|
||||
warnings.append(
|
||||
"PowerShell execution policy may block this .ps1 wrapper; use the matching .cmd command manually"
|
||||
)
|
||||
return
|
||||
if path.suffix.lower() == ".cmd":
|
||||
ps1 = path.with_suffix(".ps1")
|
||||
if ps1.exists() and Path(command).suffix == "":
|
||||
warnings.append(
|
||||
f"PowerShell may prefer {ps1.name}; use `{path.name}` manually if script execution is blocked"
|
||||
)
|
||||
|
||||
|
||||
def _sample_preflight_task(project_id: str, workspace: Path) -> Task:
|
||||
metadata = {
|
||||
"workspace_root": str(workspace),
|
||||
"comms_workspace_root": str(workspace),
|
||||
"comms_root": str(workspace / ".opc-comms"),
|
||||
"target_output_dir": str(workspace),
|
||||
}
|
||||
return Task(
|
||||
title="OpenOPC external agent preflight",
|
||||
description="Reply with OK. This task is only used to build a launch command.",
|
||||
assigned_to="owner",
|
||||
project_id=project_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _get_opc_home() -> Path:
|
||||
from opc.core.config import get_opc_home
|
||||
|
||||
return get_opc_home()
|
||||
|
||||
|
||||
def _get_project_workplace(project_id: str) -> Path:
|
||||
from opc.core.config import get_project_workplace
|
||||
|
||||
return get_project_workplace(project_id)
|
||||
|
||||
|
||||
def _build_sample_invocation(
|
||||
adapter: ExternalAgentAdapter,
|
||||
task: Task,
|
||||
workspace: str,
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
if adapter.config.run_mode == "interactive" and adapter.supports_interactive():
|
||||
return adapter.build_interactive_invocation(task, workspace_path=workspace)
|
||||
return adapter.build_invocation(task, workspace_path=workspace)
|
||||
|
||||
|
||||
def _probe_writable_path(name: str, path: Path, *, is_file: bool) -> PathProbeResult:
|
||||
try:
|
||||
if is_file:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists():
|
||||
with path.open("ab"):
|
||||
pass
|
||||
else:
|
||||
_write_delete_probe(path.parent)
|
||||
else:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
_write_delete_probe(path)
|
||||
resolved = path.resolve(strict=False)
|
||||
return PathProbeResult(name=name, path=str(resolved), ok=True)
|
||||
except Exception as exc:
|
||||
return PathProbeResult(name=name, path=str(path), ok=False, error=str(exc))
|
||||
|
||||
|
||||
def _write_delete_probe(directory: Path) -> None:
|
||||
probe_path = directory / f".opc-write-probe-{uuid.uuid4().hex}.tmp"
|
||||
probe_path.write_text("ok", encoding="utf-8")
|
||||
probe_path.unlink()
|
||||
|
||||
|
||||
def _probe_version(agent_name: str, binary: str, warnings: list[str]) -> str:
|
||||
args = _VERSION_COMMANDS.get(agent_name)
|
||||
if not args:
|
||||
return ""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[binary, *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
warnings.append(f"version probe failed: {exc}")
|
||||
return ""
|
||||
text = (proc.stdout or proc.stderr or "").strip().splitlines()
|
||||
if not text and proc.returncode != 0:
|
||||
warnings.append(f"version probe exited {proc.returncode}")
|
||||
return text[0][:200] if text else ""
|
||||
|
||||
|
||||
def _probe_help_flags(
|
||||
agent_name: str,
|
||||
binary: str,
|
||||
launch_cmd: list[str],
|
||||
result: ExternalAgentPreflightResult,
|
||||
) -> None:
|
||||
args = _HELP_COMMANDS.get(agent_name, ("--help",))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[binary, *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3,
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
result.warnings.append(f"help probe failed: {exc}")
|
||||
return
|
||||
help_text = f"{proc.stdout}\n{proc.stderr}"
|
||||
if proc.returncode not in {0, 1, 2} and not help_text.strip():
|
||||
result.warnings.append(f"help probe exited {proc.returncode}")
|
||||
return
|
||||
|
||||
for flag in _launch_flags(launch_cmd):
|
||||
if flag in {"--", "-"}:
|
||||
continue
|
||||
if flag not in help_text:
|
||||
result.warnings.append(f"launch flag not advertised by help output: {flag}")
|
||||
|
||||
|
||||
def _launch_flags(cmd: list[str]) -> list[str]:
|
||||
flags: list[str] = []
|
||||
for item in cmd[1:]:
|
||||
value = str(item or "").strip()
|
||||
if not value.startswith("-"):
|
||||
continue
|
||||
flag = value.split("=", 1)[0]
|
||||
flags.append(flag)
|
||||
return list(dict.fromkeys(flags))
|
||||
|
||||
|
||||
def _probe_isolated_home(agent_name: str, result: ExternalAgentPreflightResult) -> None:
|
||||
if not result.isolated_home:
|
||||
return
|
||||
home = Path(result.isolated_home)
|
||||
if not home.exists():
|
||||
result.issues.append(f"isolated agent home was not created: {home}")
|
||||
skill = home / "skills" / "opc-collab" / "SKILL.md"
|
||||
if not skill.exists():
|
||||
result.issues.append(f"opc-collab skill missing from isolated home: {skill}")
|
||||
if result.collab_cli and not Path(result.collab_cli).exists():
|
||||
result.issues.append(f"opc-collab executable missing: {result.collab_cli}")
|
||||
|
||||
for source, target in _known_user_config_mirrors(agent_name, home):
|
||||
if source.exists() and not target.exists():
|
||||
result.warnings.append(f"user config exists but was not mirrored: {source}")
|
||||
|
||||
|
||||
def _known_user_config_mirrors(agent_name: str, home: Path) -> list[tuple[Path, Path]]:
|
||||
if agent_name == "codex":
|
||||
user_home = Path.home() / ".codex"
|
||||
return [
|
||||
(user_home / "auth.json", home / "auth.json"),
|
||||
(user_home / "config.toml", home / "config.toml"),
|
||||
]
|
||||
if agent_name == "claude_code":
|
||||
user_home = Path.home() / ".claude"
|
||||
return [
|
||||
(user_home / ".credentials.json", home / ".credentials.json"),
|
||||
(user_home / "settings.json", home / "settings.json"),
|
||||
(user_home / "settings.local.json", home / "settings.local.json"),
|
||||
(user_home / "CLAUDE.md", home / "CLAUDE.md"),
|
||||
]
|
||||
if agent_name == "opencode":
|
||||
candidates: list[tuple[Path, Path]] = []
|
||||
raw_env = str(os.environ.get("OPENCODE_CONFIG_DIR") or "").strip()
|
||||
if raw_env:
|
||||
source_home = Path(raw_env).expanduser()
|
||||
else:
|
||||
xdg = str(os.environ.get("XDG_CONFIG_HOME") or "").strip()
|
||||
source_home = Path(xdg).expanduser() / "opencode" if xdg else Path.home() / ".config" / "opencode"
|
||||
for name in ("opencode.json", "opencode.jsonc"):
|
||||
candidates.append((source_home / name, home / name))
|
||||
return candidates
|
||||
return []
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Prompt harness helpers for Native Runtime V2."""
|
||||
|
||||
from .artifacts import (
|
||||
RUNTIME_ARTIFACT_DELTA_HEADER,
|
||||
RUNTIME_ARTIFACT_HEADER,
|
||||
is_runtime_artifact_message,
|
||||
render_runtime_artifact_messages,
|
||||
strip_runtime_artifact_messages,
|
||||
)
|
||||
from .builder import PromptHarnessBuilder
|
||||
from .tool_strategy import NativeToolStrategyBuilder
|
||||
from .types import PromptHarnessOutput, RuntimeArtifact
|
||||
|
||||
__all__ = [
|
||||
"PromptHarnessBuilder",
|
||||
"NativeToolStrategyBuilder",
|
||||
"PromptHarnessOutput",
|
||||
"RuntimeArtifact",
|
||||
"RUNTIME_ARTIFACT_HEADER",
|
||||
"RUNTIME_ARTIFACT_DELTA_HEADER",
|
||||
"is_runtime_artifact_message",
|
||||
"render_runtime_artifact_messages",
|
||||
"strip_runtime_artifact_messages",
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Runtime artifact rendering helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .deltas import changed_artifacts
|
||||
from .types import RuntimeArtifact
|
||||
|
||||
|
||||
RUNTIME_ARTIFACT_HEADER = "## Runtime Artifact:"
|
||||
RUNTIME_ARTIFACT_DELTA_HEADER = "## Runtime Artifact Delta:"
|
||||
|
||||
|
||||
def artifact_content_hash(content: str, metadata: dict[str, Any] | None = None) -> str:
|
||||
raw = json.dumps(
|
||||
{
|
||||
"content": str(content or ""),
|
||||
"metadata": dict(metadata or {}),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def build_runtime_artifact_record(
|
||||
artifact_type: str,
|
||||
title: str,
|
||||
content: str,
|
||||
*,
|
||||
scope: str = "runtime",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
content_hash = artifact_content_hash(content, metadata)
|
||||
return RuntimeArtifact(
|
||||
artifact_type=artifact_type,
|
||||
title=title,
|
||||
content=str(content or "").strip(),
|
||||
scope=scope,
|
||||
metadata={"content_hash": content_hash, **dict(metadata or {})},
|
||||
).to_record(content_hash=content_hash)
|
||||
|
||||
|
||||
def build_runtime_artifact_manifest(artifacts: Iterable[RuntimeArtifact]) -> tuple[list[dict[str, Any]], dict[str, str]]:
|
||||
manifest: list[dict[str, Any]] = []
|
||||
hashes: dict[str, str] = {}
|
||||
for artifact in artifacts:
|
||||
content_hash = artifact_content_hash(artifact.content, artifact.metadata)
|
||||
record = artifact.to_record(content_hash=content_hash)
|
||||
manifest.append(record)
|
||||
hashes[artifact.artifact_type] = content_hash
|
||||
return manifest, hashes
|
||||
|
||||
|
||||
def render_runtime_artifact_messages(
|
||||
artifacts: Iterable[RuntimeArtifact],
|
||||
*,
|
||||
previous_hashes: dict[str, str] | None = None,
|
||||
emit_delta_messages: bool = True,
|
||||
) -> list[dict[str, str]]:
|
||||
messages: list[dict[str, str]] = []
|
||||
for artifact, is_delta in changed_artifacts(artifacts, previous_hashes):
|
||||
header = RUNTIME_ARTIFACT_DELTA_HEADER if emit_delta_messages and is_delta else RUNTIME_ARTIFACT_HEADER
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": f"{header} {artifact.title}\n{artifact.content}".strip(),
|
||||
})
|
||||
return messages
|
||||
|
||||
|
||||
def is_runtime_artifact_message(message: dict[str, Any]) -> bool:
|
||||
if str(message.get("role", "") or "") != "system":
|
||||
return False
|
||||
content = str(message.get("content", "") or "")
|
||||
return content.startswith(RUNTIME_ARTIFACT_HEADER) or content.startswith(RUNTIME_ARTIFACT_DELTA_HEADER)
|
||||
|
||||
|
||||
def strip_runtime_artifact_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [message for message in messages if not is_runtime_artifact_message(message)]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Prompt harness builder for NativeAgent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from opc.layer2_organization.prompt_contract import is_report_prompt_turn
|
||||
from opc.layer2_organization.session_scoping import is_top_level_company_session
|
||||
|
||||
from .artifacts import build_runtime_artifact_manifest, render_runtime_artifact_messages
|
||||
from .tool_strategy import NativeToolStrategyBuilder
|
||||
from .types import PromptHarnessOutput, RuntimeArtifact
|
||||
|
||||
|
||||
def _final_decider_role_id(task: Any) -> str:
|
||||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
final_role = str(metadata.get("final_decider_role_id", "") or "").strip()
|
||||
if final_role:
|
||||
return final_role
|
||||
top_level = [str(item).strip() for item in list(metadata.get("top_level_role_ids", []) or []) if str(item).strip()]
|
||||
if len(top_level) == 1:
|
||||
return top_level[0]
|
||||
return ""
|
||||
|
||||
|
||||
def _memory_skill_user_facing(task: Any, role_id: str) -> bool:
|
||||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
execution_mode = str(metadata.get("execution_mode", "") or "").strip()
|
||||
if execution_mode != "company_mode":
|
||||
return True
|
||||
if is_report_prompt_turn(metadata):
|
||||
return False
|
||||
current_role = str(role_id or getattr(task, "assigned_to", "") or metadata.get("work_item_role_id", "") or "").strip()
|
||||
if not current_role or current_role != _final_decider_role_id(task):
|
||||
return False
|
||||
return bool(metadata.get("user_visible", False) or is_top_level_company_session(task))
|
||||
|
||||
|
||||
def _execution_mode(task: Any) -> str | None:
|
||||
return str(getattr(task, "metadata", {}).get("execution_mode", "") or "").strip() or None
|
||||
|
||||
|
||||
def _resume_content(runtime_resume: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
f"- Runtime session: {str(runtime_resume.get('runtime_session_id', '') or '').strip()}",
|
||||
f"- Resume cursor: {runtime_resume.get('resume_cursor', '')}",
|
||||
f"- Active subagents: {len(runtime_resume.get('active_subagents', []) or [])}",
|
||||
f"- Permission requests: {len(runtime_resume.get('permission_requests', []) or [])}",
|
||||
f"- Task ledger items: {len(runtime_resume.get('task_ledger', []) or [])}",
|
||||
]
|
||||
worktree_path = str(runtime_resume.get("worktree_path", "") or "").strip()
|
||||
if worktree_path:
|
||||
lines.append(f"- Worktree path: {worktree_path}")
|
||||
verification_verdict = str(runtime_resume.get("verification_verdict", "") or "").strip()
|
||||
if verification_verdict:
|
||||
lines.append(f"- Last verification: {verification_verdict}")
|
||||
return "Resume envelope:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _runtime_resume_payload(task: Any) -> dict[str, Any]:
|
||||
context_snapshot = getattr(task, "context_snapshot", {}) or {}
|
||||
if not isinstance(context_snapshot, dict):
|
||||
return {}
|
||||
raw_resume = context_snapshot.get("runtime_resume", {})
|
||||
return dict(raw_resume) if isinstance(raw_resume, dict) else {}
|
||||
|
||||
|
||||
class PromptHarnessBuilder:
|
||||
"""Build dynamic sections and boot artifact messages for NativeAgent."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
task: Any,
|
||||
role_id: str,
|
||||
config: Any,
|
||||
context_assembler: Any,
|
||||
preferences: Any,
|
||||
skills: Any,
|
||||
) -> None:
|
||||
self.task = task
|
||||
self.role_id = role_id
|
||||
self.config = config
|
||||
self.context_assembler = context_assembler
|
||||
self.preferences = preferences
|
||||
self.skills = skills
|
||||
|
||||
async def build(
|
||||
self,
|
||||
*,
|
||||
system_prompt: str,
|
||||
allowed_tools: list[str] | None = None,
|
||||
runtime_policy_messages: list[dict[str, Any]] | None = None,
|
||||
) -> PromptHarnessOutput:
|
||||
runtime_policy_messages = list(runtime_policy_messages or [])
|
||||
cfg = self.config.system.native_runtime.prompt_harness
|
||||
if not cfg.enabled:
|
||||
return PromptHarnessOutput(
|
||||
system_prompt=system_prompt,
|
||||
runtime_policy_messages=runtime_policy_messages,
|
||||
)
|
||||
|
||||
workspace_context_messages: list[dict[str, str]] = []
|
||||
dynamic_section_ids: list[str] = []
|
||||
if cfg.split_static_dynamic:
|
||||
assembled_ctx = await self.context_assembler.build_system_context(self.task, role_id=self.role_id)
|
||||
if assembled_ctx:
|
||||
workspace_context_messages.append({"role": "system", "content": assembled_ctx})
|
||||
dynamic_section_ids.append("assembled_context")
|
||||
# employee_delta_context is already rendered inside the
|
||||
# unified Self section produced by
|
||||
# ``ContextAssembler._build_self_section`` (which is
|
||||
# included in ``assembled_ctx`` above). Emitting it a
|
||||
# second time here would duplicate the delta profile in
|
||||
# the prompt, so we rely solely on the assembled context.
|
||||
|
||||
artifacts: list[RuntimeArtifact] = []
|
||||
if cfg.artifact_messages_enabled:
|
||||
execution_mode = _execution_mode(self.task)
|
||||
tool_surface = NativeToolStrategyBuilder(
|
||||
list(allowed_tools or []),
|
||||
company_mode=execution_mode == "company_mode",
|
||||
).render()
|
||||
artifacts.append(RuntimeArtifact(
|
||||
artifact_type="tool_surface_delta",
|
||||
title="Tool Strategy",
|
||||
content=tool_surface,
|
||||
metadata={"allowed_tools": sorted(list(allowed_tools or []))},
|
||||
))
|
||||
skills_summary = str(
|
||||
self.skills.build_skills_summary(
|
||||
self.task.project_id,
|
||||
execution_mode=execution_mode,
|
||||
role_id=self.role_id,
|
||||
user_facing=_memory_skill_user_facing(self.task, self.role_id),
|
||||
final_decider_role_id=_final_decider_role_id(self.task),
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if skills_summary:
|
||||
artifacts.append(RuntimeArtifact(
|
||||
artifact_type="skills_delta",
|
||||
title="Skills",
|
||||
content=skills_summary,
|
||||
metadata={"project_id": self.task.project_id or "default"},
|
||||
))
|
||||
resident_assignment = dict(self.task.context_snapshot.get("resident_assignment", {}) or self.task.metadata.get("resident_assignment", {}) or {})
|
||||
team_memory_digest = str(resident_assignment.get("team_memory_digest", "") or "").strip()
|
||||
if team_memory_digest:
|
||||
artifacts.append(RuntimeArtifact(
|
||||
artifact_type="team_memory_delta",
|
||||
title="Team Memory",
|
||||
content=team_memory_digest,
|
||||
metadata={"assignment_id": resident_assignment.get("assignment_id", "")},
|
||||
))
|
||||
runtime_resume = _runtime_resume_payload(self.task)
|
||||
if runtime_resume:
|
||||
artifacts.append(RuntimeArtifact(
|
||||
artifact_type="resume_state",
|
||||
title="Resume State",
|
||||
content=_resume_content(runtime_resume),
|
||||
metadata={"runtime_session_id": runtime_resume.get("runtime_session_id", "")},
|
||||
))
|
||||
|
||||
previous_hashes = dict((self.task.metadata.get("prompt_harness", {}) or {}).get("artifact_hashes", {}) or {})
|
||||
artifact_messages = render_runtime_artifact_messages(
|
||||
artifacts,
|
||||
previous_hashes=previous_hashes,
|
||||
emit_delta_messages=cfg.emit_delta_messages,
|
||||
)
|
||||
artifact_manifest, artifact_hashes = build_runtime_artifact_manifest(artifacts)
|
||||
return PromptHarnessOutput(
|
||||
system_prompt=system_prompt,
|
||||
runtime_policy_messages=runtime_policy_messages,
|
||||
workspace_context_messages=workspace_context_messages,
|
||||
dynamic_messages=workspace_context_messages,
|
||||
artifact_messages=artifact_messages,
|
||||
static_section_ids=["system_prompt"],
|
||||
dynamic_section_ids=dynamic_section_ids,
|
||||
artifact_manifest=artifact_manifest,
|
||||
artifact_hashes=artifact_hashes,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Artifact delta helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from .types import RuntimeArtifact
|
||||
|
||||
|
||||
def changed_artifacts(
|
||||
artifacts: Iterable[RuntimeArtifact],
|
||||
previous_hashes: dict[str, str] | None = None,
|
||||
) -> list[tuple[RuntimeArtifact, bool]]:
|
||||
previous = dict(previous_hashes or {})
|
||||
changed: list[tuple[RuntimeArtifact, bool]] = []
|
||||
for artifact in artifacts:
|
||||
content_hash = str(artifact.metadata.get("content_hash", "") or "")
|
||||
is_delta = bool(previous.get(artifact.artifact_type)) and previous.get(artifact.artifact_type) != content_hash
|
||||
changed.append((artifact, is_delta))
|
||||
return changed
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Static prompt harness sections."""
|
||||
|
||||
DEDICATED_TOOL_DISCIPLINE = """
|
||||
## Dedicated Tool Discipline
|
||||
- Prefer dedicated file/search/browser tools over shell commands when both can accomplish the same task.
|
||||
- Use shell execution for commands, builds, tests, and process control. Do not use it as a substitute for file reading or editing when dedicated tools exist.
|
||||
- If a dedicated tool fails for environmental reasons, explain that briefly and then fall back to the next-best tool.
|
||||
"""
|
||||
|
||||
SAFE_ACTIONS_CONTRACT = """
|
||||
## Safe Actions Contract
|
||||
- Reversible local actions such as reading files, editing code in the workspace, and running tests are normally acceptable.
|
||||
- Destructive or shared-state actions require a higher bar: deleting data, force-pushing, changing CI/CD, altering database schema, sending outbound messages, or touching infrastructure should trigger approval or an explicit user decision.
|
||||
- Do not use destructive operations to bypass an obstacle. Investigate first, then fix the cause.
|
||||
"""
|
||||
|
||||
HONEST_REPORTING_CONTRACT = """
|
||||
## Honest Reporting Contract
|
||||
- Never claim a command, test, or validation step succeeded unless you actually ran it and saw the output.
|
||||
- Never hide failing checks or rewrite their meaning to sound successful.
|
||||
- If you could not verify something, say that directly and explain why in one sentence.
|
||||
"""
|
||||
|
||||
MEMORY_TRUST_CONTRACT = """
|
||||
## Memory Trust Contract
|
||||
- Memory is guidance, not ground truth.
|
||||
- If memory names a repo fact, file path, behavior, or convention that could have changed, verify it against the current workspace before relying on it.
|
||||
- If current evidence conflicts with memory, trust the current evidence and update the memory later instead of forcing the old assumption.
|
||||
"""
|
||||
|
||||
SUBAGENT_HARNESS_CONTRACT = """
|
||||
## Subagent Harness Contract
|
||||
- Use fresh subagents when you need isolation or a different write scope.
|
||||
- Use fork-style inheritance when the child clearly benefits from the current context and tool surface.
|
||||
- Do not duplicate work already delegated.
|
||||
- When delegating implementation or verification, make the prompt self-contained and explicit about scope, expected output, and constraints.
|
||||
"""
|
||||
|
||||
LONG_RUNNING_SESSION_CONTRACT = """
|
||||
## Long-Running Session Contract
|
||||
- This runtime may summarize history, compact context, and re-inject structured state.
|
||||
- Preserve important state in structured tools and artifacts, not only in assistant prose.
|
||||
- When continuing after a long task, rely on the current runtime state, task ledger, and reinjected artifacts before re-solving old work.
|
||||
"""
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Native runtime tool strategy rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_FILE_READ_TOOLS = {"file_read", "list_dir", "glob", "grep", "file_search"}
|
||||
_FILE_EDIT_TOOLS = {"file_write", "file_edit", "apply_patch"}
|
||||
_SHELL_TOOLS = {"shell_exec"}
|
||||
_PYTHON_TOOLS = {"python_exec"}
|
||||
_WEB_TOOLS = {"web_search", "web_fetch"}
|
||||
_BROWSER_TOOLS = {
|
||||
"browser_navigate",
|
||||
"browser_navigate_back",
|
||||
"browser_click",
|
||||
"browser_snapshot",
|
||||
"browser_type",
|
||||
"browser_wait_for",
|
||||
"browser_scroll",
|
||||
"browser_select_option",
|
||||
"browser_take_screenshot",
|
||||
"browser_close",
|
||||
}
|
||||
_TODO_TOOLS = {"todo_write", "todo_read"}
|
||||
_SUBAGENT_TOOLS = {"agent_spawn", "agent_wait", "agent_send", "agent_list"}
|
||||
_COMPANY_COLLABORATION_TOOLS = {
|
||||
"inbox",
|
||||
"send_dm",
|
||||
"ask_peer_and_wait",
|
||||
"reply_message",
|
||||
"broadcast_issue",
|
||||
"delegate_work",
|
||||
"modify_work_item",
|
||||
"delete_work_item",
|
||||
"manager_board_read",
|
||||
"manager_board_update",
|
||||
"manager_board_release",
|
||||
"manager_board_rollup",
|
||||
"start_meeting",
|
||||
"respond_meeting",
|
||||
"propose_runtime_replan",
|
||||
"propose_task_adjustment",
|
||||
"route_work",
|
||||
"find_and_ask_expert",
|
||||
"read_inbox",
|
||||
}
|
||||
|
||||
|
||||
class NativeToolStrategyBuilder:
|
||||
"""Render concise tool-selection guidance for the current native tool surface."""
|
||||
|
||||
def __init__(self, allowed_tools: list[str] | None, *, company_mode: bool = False) -> None:
|
||||
self.allowed_tools = {
|
||||
str(item or "").strip()
|
||||
for item in list(allowed_tools or [])
|
||||
if str(item or "").strip()
|
||||
}
|
||||
self.company_mode = bool(company_mode)
|
||||
|
||||
def render(self) -> str:
|
||||
if not self.allowed_tools:
|
||||
return "No explicit tool surface was supplied for this runtime."
|
||||
|
||||
lines = [
|
||||
"Use the current tool schema as the source of truth for exact arguments.",
|
||||
f"Available tool count: {len(self.allowed_tools)}",
|
||||
f"Available tools: {self._preview_tools()}",
|
||||
"",
|
||||
"Selection strategy:",
|
||||
]
|
||||
rules = self._rules()
|
||||
if rules:
|
||||
lines.extend(f"- {rule}" for rule in rules)
|
||||
else:
|
||||
lines.append("- Use the provided tools directly when they advance the task.")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
def _preview_tools(self) -> str:
|
||||
ordered = sorted(self.allowed_tools)
|
||||
preview = ", ".join(ordered[:24])
|
||||
suffix = "" if len(ordered) <= 24 else f", +{len(ordered) - 24} more"
|
||||
return f"{preview}{suffix}"
|
||||
|
||||
def _rules(self) -> list[str]:
|
||||
rules: list[str] = []
|
||||
tools = self.allowed_tools
|
||||
has_file_read = bool(tools & _FILE_READ_TOOLS)
|
||||
has_file_edit = bool(tools & _FILE_EDIT_TOOLS)
|
||||
if has_file_read:
|
||||
rules.append("Use dedicated read/search/list tools for workspace inspection instead of shell text commands.")
|
||||
if has_file_edit:
|
||||
rules.append("Use dedicated edit/write/patch tools for file changes; verify the resulting diff or file content when useful.")
|
||||
if tools & _SHELL_TOOLS:
|
||||
rules.append("Use shell execution for commands, builds, tests, package scripts, and process control.")
|
||||
if tools & _PYTHON_TOOLS:
|
||||
rules.append("Use Python execution for calculations, data processing, and focused local experiments.")
|
||||
if tools & (_WEB_TOOLS | _BROWSER_TOOLS):
|
||||
rules.append("Use web/browser tools only when current external information or direct page interaction is needed.")
|
||||
if tools & _TODO_TOOLS:
|
||||
rules.append("Use the task ledger for multi-step work; keep one item in progress and update it as work changes.")
|
||||
if tools & _SUBAGENT_TOOLS:
|
||||
rules.append("Use subagents only for bounded parallel work, isolation, verification, or clearly separate scopes.")
|
||||
if self.company_mode and tools & _COMPANY_COLLABORATION_TOOLS:
|
||||
rules.append("Use company collaboration tools only for the active work-item coordination surface and only when available this turn.")
|
||||
if self._has_parallel_read_surface():
|
||||
rules.append("Run independent read/search/context-gathering tool calls in parallel when there is no dependency between them.")
|
||||
return rules
|
||||
|
||||
def _has_parallel_read_surface(self) -> bool:
|
||||
read_like_count = len(self.allowed_tools & (_FILE_READ_TOOLS | _WEB_TOOLS | _BROWSER_TOOLS))
|
||||
return read_like_count >= 2
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Typed prompt harness objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeArtifact:
|
||||
artifact_type: str
|
||||
title: str
|
||||
content: str
|
||||
scope: str = "runtime"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_record(self, *, content_hash: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": self.artifact_type,
|
||||
"title": self.title,
|
||||
"content": self.content,
|
||||
"scope": self.scope,
|
||||
"content_hash": content_hash,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptHarnessOutput:
|
||||
system_prompt: str
|
||||
runtime_policy_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
workspace_context_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
dynamic_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
artifact_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
static_section_ids: list[str] = field(default_factory=list)
|
||||
dynamic_section_ids: list[str] = field(default_factory=list)
|
||||
artifact_manifest: list[dict[str, Any]] = field(default_factory=list)
|
||||
artifact_hashes: dict[str, str] = field(default_factory=dict)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Native Runtime V2 exports."""
|
||||
|
||||
from .runtime import NativeRuntimeV2
|
||||
|
||||
__all__ = ["NativeRuntimeV2"]
|
||||
@@ -0,0 +1,823 @@
|
||||
"""Permission helpers for Native Runtime V2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import PermissionsV2Config, get_opc_home
|
||||
from opc.core.models import PermissionResolution, PermissionScope, RiskLevel, RuntimePermissionDecision
|
||||
from opc.llm.retry import LLMRetryError, call_llm_json_with_retry
|
||||
from opc.layer2_organization.data_acquisition_policy import (
|
||||
ACQUISITION_SHELL_PREFIXES,
|
||||
is_projection_scoped_acquisition_shell_command,
|
||||
)
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
_DEFAULT_PATH_KEYS = (
|
||||
"path",
|
||||
"file_path",
|
||||
"directory",
|
||||
"working_directory",
|
||||
"target_output_dir",
|
||||
"workspace_path",
|
||||
)
|
||||
_DEFAULT_COMMAND_KEYS = ("command", "cmd")
|
||||
_DEFAULT_URL_KEYS = ("url",)
|
||||
_READ_ONLY_PREFIXES = {
|
||||
"cat",
|
||||
"echo",
|
||||
"find",
|
||||
"git diff",
|
||||
"git log",
|
||||
"git show",
|
||||
"git status",
|
||||
"head",
|
||||
"ls",
|
||||
"node -v",
|
||||
"npm -v",
|
||||
"pwd",
|
||||
"python -V",
|
||||
"python3 -V",
|
||||
"rg",
|
||||
"tail",
|
||||
"wc",
|
||||
}
|
||||
_RISKY_SHELL_KEYWORDS = (
|
||||
"curl ",
|
||||
"wget ",
|
||||
"invoke-webrequest",
|
||||
"invoke-restmethod",
|
||||
"mv ",
|
||||
"cp ",
|
||||
"rm ",
|
||||
"del ",
|
||||
"remove-item",
|
||||
"git commit",
|
||||
"git push",
|
||||
"npm install",
|
||||
"pip install",
|
||||
"pnpm install",
|
||||
"cargo test",
|
||||
"pytest",
|
||||
"tee ",
|
||||
"sed -i",
|
||||
">",
|
||||
">>",
|
||||
)
|
||||
_ACQUISITION_SHELL_PREFIXES = {str(item) for item in ACQUISITION_SHELL_PREFIXES}
|
||||
_ANY_GRANT_VALUE = "*"
|
||||
|
||||
|
||||
class ToolPermissionResolver:
|
||||
"""Runtime permission gate with persisted session/project/global grants."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: PermissionsV2Config | None = None,
|
||||
*,
|
||||
store: Any = None,
|
||||
runtime_session_id: str = "",
|
||||
project_id: str = "default",
|
||||
llm: Any | None = None,
|
||||
) -> None:
|
||||
self.config = config or PermissionsV2Config()
|
||||
self.store = store
|
||||
self.runtime_session_id = runtime_session_id
|
||||
self.project_id = project_id or "default"
|
||||
self.llm = llm
|
||||
self._loaded = False
|
||||
self._session_grants: set[tuple[str, str, str, str, str]] = set()
|
||||
self._project_grants: set[tuple[str, str, str, str, str]] = set()
|
||||
self._global_grants: set[tuple[str, str, str, str, str]] = set()
|
||||
self._denial_counts: dict[str, int] = {}
|
||||
|
||||
async def warmup(self) -> None:
|
||||
if self._loaded or not self.store or not hasattr(self.store, "list_runtime_permission_grants"):
|
||||
self._loaded = True
|
||||
return
|
||||
session_rows = await self.store.list_runtime_permission_grants(
|
||||
runtime_session_id=self.runtime_session_id or None,
|
||||
scopes=["session"],
|
||||
)
|
||||
project_rows = await self.store.list_runtime_permission_grants(
|
||||
project_id=self.project_id,
|
||||
scopes=["project"],
|
||||
)
|
||||
global_rows = await self.store.list_runtime_permission_grants(scopes=["global"])
|
||||
self._session_grants = {self._grant_key_from_row(row) for row in session_rows}
|
||||
self._project_grants = {self._grant_key_from_row(row) for row in project_rows}
|
||||
self._global_grants = {self._grant_key_from_row(row) for row in global_rows}
|
||||
self._loaded = True
|
||||
|
||||
def _candidate_extractors(self) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
|
||||
keys = [str(item or "").strip() for item in self.config.candidate_extractors if str(item or "").strip()]
|
||||
if not keys:
|
||||
keys = [*_DEFAULT_PATH_KEYS, *_DEFAULT_COMMAND_KEYS, *_DEFAULT_URL_KEYS]
|
||||
path_keys = tuple(item for item in keys if item in _DEFAULT_PATH_KEYS)
|
||||
command_keys = tuple(item for item in keys if item in _DEFAULT_COMMAND_KEYS)
|
||||
url_keys = tuple(item for item in keys if item in _DEFAULT_URL_KEYS)
|
||||
return (
|
||||
path_keys or _DEFAULT_PATH_KEYS,
|
||||
command_keys or _DEFAULT_COMMAND_KEYS,
|
||||
url_keys or _DEFAULT_URL_KEYS,
|
||||
)
|
||||
|
||||
def _grant_key_from_row(self, row: dict[str, Any]) -> tuple[str, str, str, str, str]:
|
||||
tool_name = str(row.get("tool_name", "") or "").strip()
|
||||
candidate = str(row.get("candidate", "") or "").strip()
|
||||
metadata = dict(row.get("metadata", {}) or {})
|
||||
sandbox_mode = str(metadata.get("sandbox_mode", "") or "").strip() or _ANY_GRANT_VALUE
|
||||
allow_network = str(metadata.get("allow_network", "") or "").strip().lower() or _ANY_GRANT_VALUE
|
||||
workspace_class = str(metadata.get("workspace_class", "") or "").strip() or _ANY_GRANT_VALUE
|
||||
return self._grant_key(
|
||||
tool_name,
|
||||
candidate,
|
||||
sandbox_mode=sandbox_mode,
|
||||
allow_network=allow_network,
|
||||
workspace_class=workspace_class,
|
||||
)
|
||||
|
||||
def _grant_key(
|
||||
self,
|
||||
tool_name: str,
|
||||
candidate: str,
|
||||
*,
|
||||
sandbox_mode: str,
|
||||
allow_network: str,
|
||||
workspace_class: str,
|
||||
) -> tuple[str, str, str, str, str]:
|
||||
normalized = candidate.strip() or _ANY_GRANT_VALUE
|
||||
return (
|
||||
tool_name,
|
||||
normalized,
|
||||
sandbox_mode.strip() or _ANY_GRANT_VALUE,
|
||||
allow_network.strip().lower() or _ANY_GRANT_VALUE,
|
||||
workspace_class.strip() or _ANY_GRANT_VALUE,
|
||||
)
|
||||
|
||||
def _candidate(self, arguments: dict[str, Any] | None = None) -> str:
|
||||
if not arguments:
|
||||
return _ANY_GRANT_VALUE
|
||||
path_keys, command_keys, url_keys = self._candidate_extractors()
|
||||
for key in (*path_keys, *command_keys, *url_keys):
|
||||
value = str(arguments.get(key, "") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return _ANY_GRANT_VALUE
|
||||
|
||||
def _grant_context(self, task: Any = None) -> tuple[str, str, str]:
|
||||
sandbox_mode = _ANY_GRANT_VALUE
|
||||
allow_network = _ANY_GRANT_VALUE
|
||||
workspace_class = _ANY_GRANT_VALUE
|
||||
if task is None:
|
||||
return sandbox_mode, allow_network, workspace_class
|
||||
metadata = getattr(task, "metadata", {}) or {}
|
||||
execution_context = dict(metadata.get("_execution_context", {}) or {})
|
||||
sandbox = dict(execution_context.get("sandbox", {}) or {})
|
||||
sandbox_mode = str(sandbox.get("mode", "") or "").strip() or _ANY_GRANT_VALUE
|
||||
allow_network = str(bool(sandbox.get("allow_network", True))).lower()
|
||||
workspace_root = (
|
||||
str(execution_context.get("workspace_root", "") or "").strip()
|
||||
or str(metadata.get("workspace_root", "") or "").strip()
|
||||
or str(metadata.get("comms_workspace_root", "") or "").strip()
|
||||
or str(metadata.get("target_output_dir", "") or "").strip()
|
||||
)
|
||||
workspace_class = "workspace" if workspace_root else "default"
|
||||
return sandbox_mode, allow_network, workspace_class
|
||||
|
||||
@staticmethod
|
||||
def _risk(value: Any, default: RiskLevel) -> RiskLevel:
|
||||
try:
|
||||
return RiskLevel(str(value or default.value))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_shell_tool(tool_name: str) -> bool:
|
||||
return tool_name in {"shell_exec", "python_exec", "git_commit"}
|
||||
|
||||
def _normalized_tool_set(self, values: list[str]) -> set[str]:
|
||||
return {str(item or "").strip() for item in values if str(item or "").strip()}
|
||||
|
||||
def _matches_path_rule(self, candidate: str, rules: list[str]) -> bool:
|
||||
if not candidate or candidate == "*":
|
||||
return False
|
||||
raw = str(candidate).strip()
|
||||
for rule in rules:
|
||||
token = str(rule or "").strip()
|
||||
if not token:
|
||||
continue
|
||||
if token == "*" or raw == token:
|
||||
return True
|
||||
try:
|
||||
rule_path = Path(token).resolve()
|
||||
candidate_path = Path(raw).resolve()
|
||||
except Exception:
|
||||
if raw.startswith(token.rstrip("\\/")):
|
||||
return True
|
||||
continue
|
||||
if candidate_path == rule_path or rule_path in candidate_path.parents:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _workspace_paths(self, task: Any = None) -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
metadata = getattr(task, "metadata", {}) or {} if task else {}
|
||||
for raw in (
|
||||
str(metadata.get("workspace_root", "") or "").strip(),
|
||||
str(metadata.get("comms_workspace_root", "") or "").strip(),
|
||||
str(metadata.get("output_root", "") or "").strip(),
|
||||
str(metadata.get("target_output_dir", "") or "").strip(),
|
||||
):
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
path = Path(raw).resolve()
|
||||
except Exception:
|
||||
continue
|
||||
if path not in roots:
|
||||
roots.append(path)
|
||||
try:
|
||||
memory_root = (Path(get_opc_home()) / "memory").resolve()
|
||||
if memory_root not in roots:
|
||||
roots.append(memory_root)
|
||||
except Exception:
|
||||
pass
|
||||
if not roots:
|
||||
try:
|
||||
roots.append(Path.cwd().resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return roots
|
||||
|
||||
def _path_decision(
|
||||
self,
|
||||
tool: ToolDefinition,
|
||||
arguments: dict[str, Any] | None,
|
||||
task: Any = None,
|
||||
) -> RuntimePermissionDecision | None:
|
||||
if not arguments:
|
||||
return None
|
||||
path_keys, _, _ = self._candidate_extractors()
|
||||
candidate = ""
|
||||
for key in path_keys:
|
||||
value = str(arguments.get(key, "") or "").strip()
|
||||
if value:
|
||||
candidate = value
|
||||
break
|
||||
if not candidate:
|
||||
return None
|
||||
if self._matches_path_rule(candidate, self.config.denied_paths):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale="Target path matches a denied runtime permission rule.",
|
||||
source="permission_rules",
|
||||
)
|
||||
if self._matches_path_rule(candidate, self.config.allowed_paths):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.PROJECT,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Target path matches an explicit runtime allow rule.",
|
||||
source="permission_rules",
|
||||
)
|
||||
try:
|
||||
resolved = Path(candidate).resolve()
|
||||
except Exception:
|
||||
return None
|
||||
if tool.read_only:
|
||||
return None
|
||||
for root in self._workspace_paths(task):
|
||||
if resolved == root or root in resolved.parents:
|
||||
return None
|
||||
risk = RiskLevel.HIGH if self.config.sandbox_policy.treat_external_paths_as_high_risk else RiskLevel.MEDIUM
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK if self.config.fail_closed else PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=risk,
|
||||
rationale="Target path is outside the current runtime workspace roots.",
|
||||
source="path_guard",
|
||||
metadata={"candidate": candidate},
|
||||
)
|
||||
|
||||
def _split_command_prefix(self, command: str) -> str:
|
||||
text = str(command or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
parts = shlex.split(text, posix=os.name != "nt")
|
||||
except Exception:
|
||||
parts = text.split()
|
||||
if not parts:
|
||||
return ""
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]} {parts[1]}".strip()
|
||||
return parts[0]
|
||||
|
||||
def _matches_command_prefix(self, command: str, prefixes: list[str]) -> bool:
|
||||
raw = str(command or "").strip()
|
||||
prefix = self._split_command_prefix(raw)
|
||||
candidates = {raw, prefix}
|
||||
for item in prefixes:
|
||||
token = str(item or "").strip()
|
||||
if not token:
|
||||
continue
|
||||
if raw == token or prefix == token:
|
||||
return True
|
||||
if raw.startswith(f"{token} ") or prefix.startswith(f"{token} "):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _split_shell_command_segments(self, command: str) -> list[list[str]]:
|
||||
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
lexer = shlex.shlex(text, posix=os.name != "nt", punctuation_chars=";&|")
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
tokens = list(lexer)
|
||||
except Exception:
|
||||
try:
|
||||
tokens = shlex.split(text, posix=os.name != "nt")
|
||||
except Exception:
|
||||
tokens = text.split()
|
||||
|
||||
segments: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for token in tokens:
|
||||
if token in {"&&", "||", ";", "|", "&"}:
|
||||
if current:
|
||||
segments.append(current)
|
||||
current = []
|
||||
continue
|
||||
current.append(token)
|
||||
if current:
|
||||
segments.append(current)
|
||||
return segments
|
||||
|
||||
def _command_has_redirection(self, command: str) -> bool:
|
||||
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
lexer = shlex.shlex(text, posix=os.name != "nt", punctuation_chars=";&|<>")
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
tokens = list(lexer)
|
||||
except Exception:
|
||||
return any(marker in text for marker in (">", "<"))
|
||||
return any(token in {">", ">>", "<", "<<"} for token in tokens)
|
||||
|
||||
def _matches_safe_shell_prefix(self, command: str, prefixes: list[str]) -> bool:
|
||||
cleaned = " ".join(str(command or "").split()).strip()
|
||||
if not cleaned or self._command_has_redirection(cleaned):
|
||||
return False
|
||||
segments = self._split_shell_command_segments(cleaned)
|
||||
if len(segments) != 1:
|
||||
return False
|
||||
return self._matches_command_prefix(" ".join(segments[0]).strip(), prefixes)
|
||||
|
||||
def _shell_ast_reason(self, command: str) -> tuple[RiskLevel, str] | None:
|
||||
lowered = str(command or "").strip().lower()
|
||||
if not lowered:
|
||||
return None
|
||||
if lowered in _READ_ONLY_PREFIXES or self._matches_command_prefix(lowered, list(_READ_ONLY_PREFIXES)):
|
||||
return RiskLevel.LOW, "Command matches a read-only shell prefix."
|
||||
for keyword in _RISKY_SHELL_KEYWORDS:
|
||||
if keyword in lowered:
|
||||
risk = RiskLevel.HIGH
|
||||
if keyword in {"curl ", "wget ", "invoke-webrequest", "invoke-restmethod"} and self.config.sandbox_policy.treat_network_as_risky:
|
||||
risk = RiskLevel.CRITICAL
|
||||
return risk, f"Command contains risky shell operation `{keyword.strip()}`."
|
||||
return RiskLevel.MEDIUM, "Shell AST classifier could not prove the command is read-only."
|
||||
|
||||
def _shell_decision(
|
||||
self,
|
||||
tool: ToolDefinition,
|
||||
arguments: dict[str, Any] | None,
|
||||
*,
|
||||
task: Any = None,
|
||||
) -> RuntimePermissionDecision | None:
|
||||
if not self._looks_like_shell_tool(tool.name) or not arguments:
|
||||
return None
|
||||
_, command_keys, _ = self._candidate_extractors()
|
||||
command = ""
|
||||
for key in command_keys:
|
||||
value = str(arguments.get(key, "") or "").strip()
|
||||
if value:
|
||||
command = value
|
||||
break
|
||||
if not command:
|
||||
return None
|
||||
projection_scoped_low_risk = is_projection_scoped_acquisition_shell_command(
|
||||
command=command,
|
||||
task=task,
|
||||
working_directory=str(arguments.get("working_directory", "") or arguments.get("workdir", "") or "").strip(),
|
||||
target_output_dir=str(getattr(task, "metadata", {}).get("target_output_dir", "") or "").strip() if task else "",
|
||||
)
|
||||
if projection_scoped_low_risk:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Command matches a work-item-scoped acquisition prefix inside the assigned workspace.",
|
||||
source="shell_prefix",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
for pattern in self.config.dangerous_shell_patterns:
|
||||
if pattern and re.search(pattern, command, flags=re.IGNORECASE):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.CRITICAL,
|
||||
rationale=f"Command matched dangerous shell pattern `{pattern}`.",
|
||||
source="shell_pattern",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
filtered_safe_prefixes = [
|
||||
item for item in self.config.safe_shell_prefixes
|
||||
if str(item or "").strip() not in _ACQUISITION_SHELL_PREFIXES
|
||||
]
|
||||
if self._matches_safe_shell_prefix(command, filtered_safe_prefixes):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Command matches a safe shell prefix.",
|
||||
source="shell_prefix",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
if self._matches_command_prefix(command, self.config.ask_shell_prefixes):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.MEDIUM,
|
||||
rationale="Command matches an ask-first shell prefix.",
|
||||
source="shell_prefix",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
if self.config.shell_ast_validation:
|
||||
risk, rationale = self._shell_ast_reason(command) or (RiskLevel.MEDIUM, "Shell command requires manual review.")
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW if risk == RiskLevel.LOW else PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=risk,
|
||||
rationale=rationale,
|
||||
source="shell_ast",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
if tool.requires_confirmation or self.config.fail_closed:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH if not tool.read_only else RiskLevel.MEDIUM,
|
||||
rationale="Shell command requires explicit approval under runtime_v2.",
|
||||
source="shell_guard",
|
||||
metadata={"candidate": command},
|
||||
)
|
||||
return None
|
||||
|
||||
def _candidate_matches(self, candidate: str, granted_candidate: str) -> bool:
|
||||
if granted_candidate == _ANY_GRANT_VALUE:
|
||||
return True
|
||||
if candidate == granted_candidate:
|
||||
return True
|
||||
return candidate.startswith(granted_candidate.rstrip("\\/"))
|
||||
|
||||
@staticmethod
|
||||
def _sandbox_rank(mode: str) -> int:
|
||||
return {
|
||||
"workspace-write": 1,
|
||||
"elevated": 2,
|
||||
"off": 3,
|
||||
}.get(str(mode or "").strip().lower(), 0)
|
||||
|
||||
def _match_grant(self, grants: set[tuple[str, str, str, str, str]], tool_name: str, candidate: str, *, task: Any = None) -> bool:
|
||||
if not grants:
|
||||
return False
|
||||
sandbox_mode, allow_network, workspace_class = self._grant_context(task)
|
||||
for grant_tool, grant_candidate, grant_sandbox_mode, grant_allow_network, grant_workspace_class in grants:
|
||||
if grant_tool != tool_name:
|
||||
continue
|
||||
if not self._candidate_matches(candidate, grant_candidate):
|
||||
continue
|
||||
if grant_sandbox_mode not in {_ANY_GRANT_VALUE, sandbox_mode}:
|
||||
if not (
|
||||
self.config.guardian.cache_upgrade_context
|
||||
and self._sandbox_rank(sandbox_mode) >= self._sandbox_rank(grant_sandbox_mode)
|
||||
):
|
||||
continue
|
||||
if grant_allow_network not in {_ANY_GRANT_VALUE, allow_network}:
|
||||
continue
|
||||
if grant_workspace_class not in {_ANY_GRANT_VALUE, workspace_class}:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
def _denial_memory_key(self, tool_name: str, arguments: dict[str, Any] | None) -> str:
|
||||
return f"{tool_name}:{self._candidate(arguments)}"
|
||||
|
||||
def record_denial(self, tool_name: str, arguments: dict[str, Any] | None) -> None:
|
||||
if not self.config.denial_memory.enabled:
|
||||
return
|
||||
key = self._denial_memory_key(tool_name, arguments)
|
||||
self._denial_counts[key] = self._denial_counts.get(key, 0) + 1
|
||||
|
||||
def _repeat_denial_decision(self, tool_name: str, arguments: dict[str, Any] | None) -> RuntimePermissionDecision | None:
|
||||
if not self.config.denial_memory.enabled:
|
||||
return None
|
||||
key = self._denial_memory_key(tool_name, arguments)
|
||||
repeats = self._denial_counts.get(key, 0)
|
||||
if repeats < max(1, self.config.denial_memory.repeat_threshold):
|
||||
return None
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale="Repeated denial memory indicates this action should stop and ask for a new plan.",
|
||||
source="denial_memory",
|
||||
metadata={"repeated_denials": repeats},
|
||||
)
|
||||
|
||||
def build_blocked_result(
|
||||
self,
|
||||
decision: RuntimePermissionDecision,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
action = "reject" if decision.resolution == PermissionResolution.DENY else "require_input"
|
||||
candidate = self._candidate(arguments)
|
||||
return {
|
||||
"error": decision.rationale or f"Runtime permission blocked `{tool_name}`.",
|
||||
"success": False,
|
||||
"approval": {
|
||||
"action": action,
|
||||
"risk_level": decision.risk_level.value,
|
||||
"policy_source": decision.source,
|
||||
"scope": decision.scope.value,
|
||||
"candidate": candidate,
|
||||
"explanation": decision.rationale,
|
||||
"metadata": dict(decision.metadata or {}),
|
||||
},
|
||||
"permission_context": {
|
||||
"tool_name": tool_name,
|
||||
"candidate": candidate,
|
||||
"resolution": decision.resolution.value,
|
||||
"risk_level": decision.risk_level.value,
|
||||
"source": decision.source,
|
||||
},
|
||||
}
|
||||
|
||||
def predicted_decision(
|
||||
self,
|
||||
tool: ToolDefinition | None,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
task: Any = None,
|
||||
) -> RuntimePermissionDecision:
|
||||
if tool is None:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK if self.config.fail_closed else PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale="Unknown tool requires manual review.",
|
||||
source="runtime_prediction",
|
||||
)
|
||||
tool_name = tool.name
|
||||
candidate = self._candidate(arguments)
|
||||
repeated_denial = self._repeat_denial_decision(tool_name, arguments)
|
||||
if repeated_denial is not None:
|
||||
return repeated_denial
|
||||
if tool_name in self._normalized_tool_set(self.config.deny_tools):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale="Tool is explicitly denied by runtime permission rules.",
|
||||
source="permission_rules",
|
||||
)
|
||||
if self._match_grant(self._session_grants, tool_name, candidate, task=task):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.SESSION,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Allowed by runtime session grant.",
|
||||
source="runtime_session_grant",
|
||||
)
|
||||
if self._match_grant(self._project_grants, tool_name, candidate, task=task):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.PROJECT,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Allowed by persisted project grant.",
|
||||
source="runtime_project_grant",
|
||||
)
|
||||
if self._match_grant(self._global_grants, tool_name, candidate, task=task):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.GLOBAL,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Allowed by persisted global grant.",
|
||||
source="runtime_global_grant",
|
||||
)
|
||||
if tool_name in self._normalized_tool_set(self.config.allow_tools):
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.PROJECT,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Tool is explicitly allowed by runtime permission rules.",
|
||||
source="permission_rules",
|
||||
)
|
||||
path_decision = self._path_decision(tool, arguments, task=task)
|
||||
if path_decision is not None:
|
||||
return path_decision
|
||||
shell_decision = self._shell_decision(tool, arguments, task=task)
|
||||
if shell_decision is not None:
|
||||
return shell_decision
|
||||
if tool.requires_confirmation:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.MEDIUM,
|
||||
rationale="Tool is marked as requiring confirmation.",
|
||||
source="runtime_prediction",
|
||||
metadata={"candidate": candidate},
|
||||
)
|
||||
if self.config.guardian.enabled and self.config.guardian.auto_allow_read_only and tool.read_only:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Guardian pre-check marked the tool as deterministic read-only.",
|
||||
source="guardian",
|
||||
metadata={"candidate": candidate},
|
||||
)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="No runtime permission warning triggered.",
|
||||
source="runtime_prediction",
|
||||
metadata={"candidate": candidate},
|
||||
)
|
||||
|
||||
async def refine_decision(
|
||||
self,
|
||||
decision: RuntimePermissionDecision,
|
||||
*,
|
||||
tool: ToolDefinition | None,
|
||||
arguments: dict[str, Any] | None,
|
||||
task: Any = None,
|
||||
) -> RuntimePermissionDecision:
|
||||
if decision.resolution != PermissionResolution.ASK:
|
||||
return decision
|
||||
if not self.config.classifier_enabled or not self.config.llm_classifier_model or self.llm is None or tool is None:
|
||||
return decision
|
||||
payload = {
|
||||
"tool_name": tool.name,
|
||||
"arguments": arguments or {},
|
||||
"candidate": self._candidate(arguments),
|
||||
"project_id": getattr(task, "project_id", self.project_id),
|
||||
"heuristic_rationale": decision.rationale,
|
||||
}
|
||||
def _validate_classifier(parsed: Any) -> str | None:
|
||||
if not isinstance(parsed, dict):
|
||||
return "Top-level response must be a JSON object."
|
||||
try:
|
||||
score_val = float(parsed.get("score", 0.5) or 0.5)
|
||||
except (TypeError, ValueError):
|
||||
return "`score` must be a number between 0 and 1."
|
||||
if score_val < 0 or score_val > 1:
|
||||
return "`score` must be a number between 0 and 1."
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = await call_llm_json_with_retry(
|
||||
self.llm,
|
||||
system=(
|
||||
"You are a runtime permission classifier.\n"
|
||||
"Return strict JSON with keys `score` and `reason`.\n"
|
||||
"`score` is a float between 0 and 1 where 0 is clearly safe and 1 is clearly unsafe.\n"
|
||||
"Classify file mutation, shell execution, path escape risk, and network side effects conservatively."
|
||||
),
|
||||
payload=payload,
|
||||
task_type="quick_tasks",
|
||||
validator=_validate_classifier,
|
||||
label="runtime_permission_classifier",
|
||||
)
|
||||
except LLMRetryError:
|
||||
return decision
|
||||
score = float(parsed.get("score", 0.5) or 0.5)
|
||||
reason = str(parsed.get("reason", "") or decision.rationale)
|
||||
thresholds = self.config.classifier_thresholds
|
||||
if score <= thresholds.allow:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=decision.scope,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale=reason or "Classifier marked the action safe.",
|
||||
source="llm_classifier",
|
||||
metadata={**dict(decision.metadata or {}), "classifier_score": score},
|
||||
)
|
||||
if score >= thresholds.deny:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=decision.scope,
|
||||
risk_level=RiskLevel.HIGH,
|
||||
rationale=reason or "Classifier marked the action unsafe.",
|
||||
source="llm_classifier",
|
||||
metadata={**dict(decision.metadata or {}), "classifier_score": score},
|
||||
)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=decision.scope,
|
||||
risk_level=RiskLevel.MEDIUM if score < thresholds.ask else RiskLevel.HIGH,
|
||||
rationale=reason or decision.rationale,
|
||||
source="llm_classifier",
|
||||
metadata={**dict(decision.metadata or {}), "classifier_score": score},
|
||||
)
|
||||
|
||||
def decision_from_result(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
result: dict[str, Any],
|
||||
) -> RuntimePermissionDecision:
|
||||
approval = dict(result.get("approval", {}) or {})
|
||||
action = str(approval.get("action", "") or "").strip().lower()
|
||||
if action in {"require_input", "escalate"}:
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ASK,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=self._risk(approval.get("risk_level"), RiskLevel.MEDIUM),
|
||||
rationale=str(result.get("error", "") or "Awaiting explicit permission."),
|
||||
source="approval_engine",
|
||||
metadata=approval,
|
||||
)
|
||||
if action == "reject":
|
||||
self.record_denial(tool_name, arguments)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.DENY,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=self._risk(approval.get("risk_level"), RiskLevel.HIGH),
|
||||
rationale=str(result.get("error", "") or "Permission denied."),
|
||||
source="approval_engine",
|
||||
metadata=approval,
|
||||
)
|
||||
human_reply = str(approval.get("human_reply") or result.get("human_reply") or "").strip().lower()
|
||||
candidate = self._candidate(arguments)
|
||||
grant = self._grant_key(
|
||||
tool_name,
|
||||
candidate,
|
||||
sandbox_mode=_ANY_GRANT_VALUE,
|
||||
allow_network=_ANY_GRANT_VALUE,
|
||||
workspace_class=_ANY_GRANT_VALUE,
|
||||
)
|
||||
if human_reply == "approve_session":
|
||||
self._session_grants.add(grant)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.SESSION,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Approved for this runtime session.",
|
||||
source="human_escalation",
|
||||
metadata=approval,
|
||||
)
|
||||
if human_reply == "always_project":
|
||||
self._project_grants.add(grant)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.PROJECT,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Approved for this project.",
|
||||
source="human_escalation",
|
||||
metadata=approval,
|
||||
)
|
||||
if human_reply == "always_global":
|
||||
self._global_grants.add(grant)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.GLOBAL,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Approved globally.",
|
||||
source="human_escalation",
|
||||
metadata=approval,
|
||||
)
|
||||
return RuntimePermissionDecision(
|
||||
resolution=PermissionResolution.ALLOW,
|
||||
scope=PermissionScope.ONCE,
|
||||
risk_level=RiskLevel.LOW,
|
||||
rationale="Tool execution allowed.",
|
||||
source="approval_engine",
|
||||
metadata=approval,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
"""Streaming-friendly tool executor for Native Runtime V2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from opc.core.models import PermissionResolution
|
||||
from opc.layer3_agent.runtime_v2.permissions import ToolPermissionResolver
|
||||
from opc.layer3_agent.runtime_v2.tool_hooks import RuntimeToolHookBus, RuntimeToolHookContext
|
||||
from opc.layer3_agent.runtime_v2.tool_planner import ToolBatch, ToolPlanner
|
||||
from opc.layer4_tools.registry import ToolRegistry
|
||||
|
||||
|
||||
RuntimeToolHandler = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
|
||||
RuntimeEventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||
_HEARTBEAT_INTERVAL_SECONDS = 1.0
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _result_summary(result: dict[str, Any], *, limit: int = 240) -> str:
|
||||
if not result:
|
||||
return ""
|
||||
if result.get("error"):
|
||||
summary = str(result.get("error", "") or "").strip()
|
||||
else:
|
||||
payload = result.get("result", {})
|
||||
summary = ""
|
||||
if isinstance(payload, dict):
|
||||
for key in ("summary", "rendered", "stdout", "stderr", "content", "message"):
|
||||
value = payload.get(key)
|
||||
if value:
|
||||
summary = str(value).strip()
|
||||
break
|
||||
if not summary:
|
||||
summary = json.dumps(result, ensure_ascii=False, default=str)
|
||||
if len(summary) <= limit:
|
||||
return summary
|
||||
return summary[:limit].rstrip() + "..."
|
||||
|
||||
|
||||
class StreamingToolExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry: ToolRegistry,
|
||||
planner: ToolPlanner,
|
||||
permission_resolver: ToolPermissionResolver,
|
||||
hook_bus: RuntimeToolHookBus | None = None,
|
||||
runtime_tool_handler: RuntimeToolHandler | None = None,
|
||||
emit_event: RuntimeEventCallback | None = None,
|
||||
max_parallel_read_tools: int = 6,
|
||||
converge_on_parallel_failure: bool = True,
|
||||
) -> None:
|
||||
self.registry = registry
|
||||
self.planner = planner
|
||||
self.permission_resolver = permission_resolver
|
||||
self.hook_bus = hook_bus
|
||||
self.runtime_tool_handler = runtime_tool_handler
|
||||
self.emit_event = emit_event
|
||||
self.max_parallel_read_tools = max(1, int(max_parallel_read_tools or 1))
|
||||
self.converge_on_parallel_failure = bool(converge_on_parallel_failure)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
*,
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
ordered_results: list[dict[str, Any]] = []
|
||||
for batch in self.planner.partition(tool_calls):
|
||||
batch_id = f"tb_{uuid.uuid4().hex[:12]}"
|
||||
batch_started_at_ms = _now_ms()
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_batch_started",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"started_at_ms": batch_started_at_ms,
|
||||
"concurrency_safe": batch.concurrency_safe,
|
||||
"tool_names": [str(call.get("function", "") or "") for call in batch.calls],
|
||||
"tool_call_ids": [str(call.get("id", "") or "") for call in batch.calls],
|
||||
},
|
||||
)
|
||||
if batch.concurrency_safe:
|
||||
batch_results = await self._run_parallel(batch, task=task, on_progress=on_progress, batch_id=batch_id)
|
||||
else:
|
||||
batch_results: list[dict[str, Any]] = []
|
||||
for call in batch.calls:
|
||||
batch_results.append(await self._run_one(call, task=task, on_progress=on_progress, batch_id=batch_id))
|
||||
ordered_results.extend(batch_results)
|
||||
if self.emit_event:
|
||||
batch_completed_at_ms = _now_ms()
|
||||
await self.emit_event(
|
||||
"tool_batch_completed",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"started_at_ms": batch_started_at_ms,
|
||||
"completed_at_ms": batch_completed_at_ms,
|
||||
"elapsed_ms": max(0, batch_completed_at_ms - batch_started_at_ms),
|
||||
"concurrency_safe": batch.concurrency_safe,
|
||||
"success": all(bool(item.get("result", {}).get("success", True)) for item in batch_results),
|
||||
"tool_count": len(batch_results),
|
||||
},
|
||||
)
|
||||
return ordered_results
|
||||
|
||||
async def _run_parallel(
|
||||
self,
|
||||
batch: ToolBatch,
|
||||
*,
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
batch_id: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
semaphore = asyncio.Semaphore(self.max_parallel_read_tools)
|
||||
batch_state: dict[str, Any] = {
|
||||
"cascade_event": asyncio.Event(),
|
||||
"failed_call_id": "",
|
||||
"failed_tool_name": "",
|
||||
}
|
||||
|
||||
async def _wrapped(call: dict[str, Any]) -> dict[str, Any]:
|
||||
async with semaphore:
|
||||
if self.converge_on_parallel_failure and batch_state["cascade_event"].is_set():
|
||||
return await self._build_converged_result(call, batch_state, batch_id=batch_id)
|
||||
result = await self._run_one(call, task=task, on_progress=on_progress, batch_state=batch_state, batch_id=batch_id)
|
||||
if self.converge_on_parallel_failure and self._should_converge_batch(result):
|
||||
batch_state["failed_call_id"] = str(call.get("id", "") or "")
|
||||
batch_state["failed_tool_name"] = str(call.get("function", "") or "")
|
||||
batch_state["cascade_event"].set()
|
||||
return result
|
||||
|
||||
return list(await asyncio.gather(*[_wrapped(call) for call in batch.calls]))
|
||||
|
||||
async def _run_one(
|
||||
self,
|
||||
call: dict[str, Any],
|
||||
*,
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
batch_state: dict[str, Any] | None = None,
|
||||
batch_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
tool_name = str(call.get("function", "") or "")
|
||||
arguments = dict(call.get("arguments", {}) or {})
|
||||
tool = self.registry.get(tool_name)
|
||||
predicted = self.permission_resolver.predicted_decision(tool, arguments, task=task)
|
||||
started_at_ms = _now_ms()
|
||||
started_at_monotonic = time.monotonic()
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"permission_predicted",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"resolution": predicted.resolution.value,
|
||||
"scope": predicted.scope.value,
|
||||
"risk_level": predicted.risk_level.value,
|
||||
"rationale": predicted.rationale,
|
||||
"source": predicted.source,
|
||||
"started_at_ms": started_at_ms,
|
||||
},
|
||||
)
|
||||
if self.emit_event and predicted.resolution != PermissionResolution.ALLOW:
|
||||
await self.emit_event(
|
||||
"permission_requested",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"resolution": predicted.resolution.value,
|
||||
"scope": predicted.scope.value,
|
||||
"risk_level": predicted.risk_level.value,
|
||||
"rationale": predicted.rationale,
|
||||
"source": predicted.source,
|
||||
},
|
||||
)
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_started",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"predicted_permission": predicted.resolution.value,
|
||||
"started_at_ms": started_at_ms,
|
||||
},
|
||||
)
|
||||
|
||||
if batch_state is not None and self.converge_on_parallel_failure and batch_state["cascade_event"].is_set():
|
||||
return await self._build_converged_result(call, batch_state, batch_id=batch_id)
|
||||
|
||||
hook_context = RuntimeToolHookContext(
|
||||
phase="pre",
|
||||
tool_name=tool_name,
|
||||
call=call,
|
||||
task=task,
|
||||
tool=tool,
|
||||
arguments=dict(arguments),
|
||||
predicted_permission=predicted,
|
||||
)
|
||||
if self.hook_bus is not None:
|
||||
hook_context = await self.hook_bus.run_pre_hooks(hook_context)
|
||||
arguments = dict(hook_context.arguments)
|
||||
elif predicted.resolution == PermissionResolution.DENY:
|
||||
hook_context.result = self.permission_resolver.build_blocked_result(
|
||||
predicted,
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
)
|
||||
hook_context.state["stop_batch_on_failure"] = True
|
||||
|
||||
if hook_context.result is not None:
|
||||
result = dict(hook_context.result)
|
||||
decision = self.permission_resolver.decision_from_result(tool_name, arguments, result)
|
||||
elif call.get("arguments_parse_error"):
|
||||
result = {
|
||||
"error": str(call.get("arguments_parse_error", "")),
|
||||
"invalid_arguments": True,
|
||||
"success": False,
|
||||
"raw_arguments": str(call.get("arguments_raw", "")),
|
||||
}
|
||||
decision = self.permission_resolver.decision_from_result(tool_name, arguments, result)
|
||||
else:
|
||||
last_progress: dict[str, str] = {"stream": "", "text": ""}
|
||||
last_progress_at = {"value": time.monotonic()}
|
||||
heartbeat_active = {"value": True}
|
||||
|
||||
async def _heartbeat() -> None:
|
||||
while heartbeat_active["value"]:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_SECONDS)
|
||||
if not heartbeat_active["value"]:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - last_progress_at["value"] < _HEARTBEAT_INTERVAL_SECONDS:
|
||||
continue
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_progress",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"phase": "running",
|
||||
"message": f"{tool_name} still running",
|
||||
"heartbeat": True,
|
||||
"elapsed_ms": int((now - started_at_monotonic) * 1000),
|
||||
},
|
||||
)
|
||||
|
||||
async def _tool_progress(progress: Any, **progress_kw: Any) -> None:
|
||||
if isinstance(progress, dict):
|
||||
payload = dict(progress)
|
||||
text = str(payload.get("text", "") or payload.get("message", "") or "").strip()
|
||||
stream_name = str(payload.get("stream", "") or "").strip()
|
||||
else:
|
||||
text = str(progress or "").strip()
|
||||
stream_name = str(progress_kw.get("stream", "") or "").strip()
|
||||
payload = {
|
||||
"text": text,
|
||||
"stream": stream_name,
|
||||
}
|
||||
if not text:
|
||||
return
|
||||
if last_progress["text"] == text and last_progress["stream"] == stream_name:
|
||||
return
|
||||
last_progress["text"] = text
|
||||
last_progress["stream"] = stream_name
|
||||
last_progress_at["value"] = time.monotonic()
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_progress",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"stream": stream_name,
|
||||
"elapsed_ms": int((last_progress_at["value"] - started_at_monotonic) * 1000),
|
||||
**payload,
|
||||
},
|
||||
)
|
||||
if on_progress:
|
||||
try:
|
||||
await on_progress(text, task_id=getattr(task, "id", None))
|
||||
except TypeError:
|
||||
await on_progress(text)
|
||||
|
||||
heartbeat_task = asyncio.create_task(_heartbeat())
|
||||
try:
|
||||
if tool is not None and tool.runtime_managed and self.runtime_tool_handler is not None:
|
||||
result = await self.runtime_tool_handler(tool_name, arguments)
|
||||
else:
|
||||
result = await self.registry.execute(
|
||||
tool_name,
|
||||
arguments,
|
||||
task=task,
|
||||
on_progress=_tool_progress,
|
||||
skip_approval=True,
|
||||
)
|
||||
result = await self._maybe_retry_with_escalated_sandbox(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
task=task,
|
||||
result=result,
|
||||
on_progress=_tool_progress,
|
||||
batch_id=batch_id,
|
||||
call=call,
|
||||
)
|
||||
finally:
|
||||
heartbeat_active["value"] = False
|
||||
heartbeat_task.cancel()
|
||||
await asyncio.gather(heartbeat_task, return_exceptions=True)
|
||||
hook_context.phase = "post"
|
||||
hook_context.arguments = dict(arguments)
|
||||
hook_context.result = dict(result)
|
||||
if self.hook_bus is not None:
|
||||
hook_context = await self.hook_bus.run_post_hooks(hook_context)
|
||||
result = dict(hook_context.result or result)
|
||||
if not bool(result.get("success", True)):
|
||||
hook_context.phase = "failure"
|
||||
hook_context.result = dict(result)
|
||||
hook_context = await self.hook_bus.run_failure_hooks(hook_context)
|
||||
result = dict(hook_context.result or result)
|
||||
decision = self.permission_resolver.decision_from_result(tool_name, arguments, result)
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"permission_resolved",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"resolution": decision.resolution.value,
|
||||
"scope": decision.scope.value,
|
||||
"rationale": decision.rationale,
|
||||
},
|
||||
)
|
||||
await self.emit_event(
|
||||
"tool_completed",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"started_at_ms": started_at_ms,
|
||||
"completed_at_ms": _now_ms(),
|
||||
"elapsed_ms": int((time.monotonic() - started_at_monotonic) * 1000),
|
||||
"success": bool(result.get("success", True)),
|
||||
"result_summary": _result_summary(result),
|
||||
"result_preview": json.dumps(result, ensure_ascii=False, default=str)[:800],
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"tool_call": call,
|
||||
"result": result,
|
||||
"permission_decision": decision,
|
||||
"stop_batch_on_failure": bool(hook_context.state.get("stop_batch_on_failure")),
|
||||
"hook_metadata": {"batch_id": batch_id, **dict(hook_context.state.get("metadata", {}))},
|
||||
}
|
||||
|
||||
async def _maybe_retry_with_escalated_sandbox(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
task: Any,
|
||||
result: dict[str, Any],
|
||||
on_progress: Any,
|
||||
batch_id: str,
|
||||
call: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
guardian = getattr(self.permission_resolver.config, "guardian", None)
|
||||
if not guardian or not bool(getattr(guardian, "auto_retry_sandbox", False)):
|
||||
return result
|
||||
payload = result.get("result", {})
|
||||
if not isinstance(payload, dict):
|
||||
return result
|
||||
exit_code = payload.get("exit_code")
|
||||
if bool(result.get("success", True)) and exit_code in (None, 0):
|
||||
return result
|
||||
if tool_name not in {"shell_exec", "python_exec"}:
|
||||
return result
|
||||
sandbox_meta = dict(payload.get("sandbox", {}) or {})
|
||||
error_text = str(result.get("error", "") or payload.get("error", "") or "").lower()
|
||||
if not sandbox_meta and "sandbox" not in error_text:
|
||||
return result
|
||||
if task is None:
|
||||
return result
|
||||
execution_context = dict((getattr(task, "metadata", {}) or {}).get("_execution_context", {}) or {})
|
||||
sandbox_context = dict(execution_context.get("sandbox", {}) or {})
|
||||
current_mode = str(sandbox_context.get("mode", "") or "").strip().lower() or "off"
|
||||
next_mode = self._next_sandbox_mode(current_mode)
|
||||
if not next_mode:
|
||||
return result
|
||||
original_context = dict(execution_context)
|
||||
original_sandbox = dict(sandbox_context)
|
||||
retry_started_at_ms = _now_ms()
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"sandbox_retry_requested",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"from_mode": current_mode,
|
||||
"to_mode": next_mode,
|
||||
"started_at_ms": retry_started_at_ms,
|
||||
},
|
||||
)
|
||||
sandbox_context["mode"] = next_mode
|
||||
execution_context["sandbox"] = sandbox_context
|
||||
task.metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
task.metadata["_execution_context"] = execution_context
|
||||
try:
|
||||
if self.registry.get(tool_name) is not None and getattr(self.registry.get(tool_name), "runtime_managed", False) and self.runtime_tool_handler is not None:
|
||||
retry_result = await self.runtime_tool_handler(tool_name, arguments)
|
||||
else:
|
||||
retry_result = await self.registry.execute(
|
||||
tool_name,
|
||||
arguments,
|
||||
task=task,
|
||||
on_progress=on_progress,
|
||||
skip_approval=True,
|
||||
)
|
||||
finally:
|
||||
original_context["sandbox"] = original_sandbox
|
||||
task.metadata["_execution_context"] = original_context
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"sandbox_retry_completed",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"from_mode": current_mode,
|
||||
"to_mode": next_mode,
|
||||
"started_at_ms": retry_started_at_ms,
|
||||
"completed_at_ms": _now_ms(),
|
||||
"success": bool(retry_result.get("success", True)),
|
||||
"result_summary": _result_summary(retry_result),
|
||||
},
|
||||
)
|
||||
return retry_result
|
||||
|
||||
@staticmethod
|
||||
def _next_sandbox_mode(current_mode: str) -> str:
|
||||
normalized = str(current_mode or "").strip().lower()
|
||||
if normalized == "workspace-write":
|
||||
return "elevated"
|
||||
if normalized == "elevated":
|
||||
return "off"
|
||||
return ""
|
||||
|
||||
async def _build_converged_result(
|
||||
self,
|
||||
call: dict[str, Any],
|
||||
batch_state: dict[str, Any],
|
||||
*,
|
||||
batch_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
tool_name = str(call.get("function", "") or "")
|
||||
result = {
|
||||
"error": (
|
||||
"Skipped because a concurrent sibling tool failed and the runtime converged the batch. "
|
||||
f"Source: {batch_state.get('failed_tool_name', '') or 'unknown'}"
|
||||
),
|
||||
"success": False,
|
||||
"converged": True,
|
||||
"converged_from_tool": batch_state.get("failed_tool_name", ""),
|
||||
"converged_from_call_id": batch_state.get("failed_call_id", ""),
|
||||
}
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_skipped",
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"tool_name": tool_name,
|
||||
"reason": "parallel_batch_converged",
|
||||
"source_tool_name": batch_state.get("failed_tool_name", ""),
|
||||
"source_call_id": batch_state.get("failed_call_id", ""),
|
||||
},
|
||||
)
|
||||
decision = self.permission_resolver.decision_from_result(tool_name, dict(call.get("arguments", {}) or {}), result)
|
||||
return {
|
||||
"tool_call": call,
|
||||
"result": result,
|
||||
"permission_decision": decision,
|
||||
"stop_batch_on_failure": False,
|
||||
"hook_metadata": {"converged": True, "batch_id": batch_id},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _should_converge_batch(result: dict[str, Any]) -> bool:
|
||||
if bool(result.get("stop_batch_on_failure")):
|
||||
return True
|
||||
payload = result.get("result", {})
|
||||
if isinstance(payload, dict) and payload.get("prevent_continuation"):
|
||||
return True
|
||||
if isinstance(payload, dict):
|
||||
return not bool(payload.get("success", True))
|
||||
return False
|
||||
@@ -0,0 +1,766 @@
|
||||
"""Runtime-managed native subagents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Awaitable
|
||||
|
||||
from opc.core.config import OPCConfig, NativeSubagentProfileConfig
|
||||
from opc.core.models import OPCEvent, Task, TaskResult, TaskStatus
|
||||
from opc.layer2_organization.work_item_identity import projection_id_for_task, work_item_identity_payload_for_task
|
||||
from opc.layer3_agent.runtime_v2.worktree import cleanup_worktree, create_worktree
|
||||
|
||||
|
||||
ChildAgentFactory = Callable[..., Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubagentState:
|
||||
agent_id: str
|
||||
profile: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
model: str = ""
|
||||
mode: str = "default"
|
||||
isolation: str = "shared"
|
||||
max_iterations: int = 24
|
||||
status: str = "running"
|
||||
background: bool = False
|
||||
resident: bool = False
|
||||
fork_mode: bool = False
|
||||
created_at: float = field(default_factory=time.time)
|
||||
latest_result: str = ""
|
||||
pending_messages_count: int = 0
|
||||
last_notification_kind: str = ""
|
||||
worktree: dict[str, Any] | None = None
|
||||
fork_system_prompt: str = ""
|
||||
fork_context_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
fork_allowed_tools: list[str] = field(default_factory=list)
|
||||
runtime_task: asyncio.Task[Any] | None = None
|
||||
inbox: asyncio.Queue[dict[str, Any]] = field(default_factory=asyncio.Queue)
|
||||
completion: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
update_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
task_result: TaskResult | None = None
|
||||
|
||||
|
||||
class SubagentManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
parent_task: Task | None,
|
||||
config: OPCConfig | None,
|
||||
child_agent_factory: ChildAgentFactory | None,
|
||||
event_bus: Any = None,
|
||||
store: Any = None,
|
||||
runtime_session_id: str = "",
|
||||
) -> None:
|
||||
self.parent_task = parent_task
|
||||
self.config = config or OPCConfig()
|
||||
self.child_agent_factory = child_agent_factory
|
||||
self.event_bus = event_bus
|
||||
self.store = store
|
||||
self.runtime_session_id = runtime_session_id
|
||||
self.states: dict[str, SubagentState] = {}
|
||||
self.agent_names: dict[str, str] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
prompt: str,
|
||||
background: bool | None = None,
|
||||
isolation: str | None = None,
|
||||
description: str = "",
|
||||
name: str = "",
|
||||
model: str = "",
|
||||
mode: str = "default",
|
||||
fork_context_messages: list[dict[str, Any]] | None = None,
|
||||
fork_system_prompt: str = "",
|
||||
fork_allowed_tools: list[str] | None = None,
|
||||
fork_mode: bool = False,
|
||||
resident: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if self.child_agent_factory is None:
|
||||
return {"error": "Native subagent factory is not configured", "success": False}
|
||||
parent_depth = int(getattr(self.parent_task, "metadata", {}).get("_native_runtime_depth", 0) or 0)
|
||||
max_depth = int(self.config.system.native_runtime.subagent_max_depth or 3)
|
||||
if parent_depth >= max_depth:
|
||||
return {
|
||||
"error": f"Maximum native subagent depth ({max_depth}) reached",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
profile_cfg = self._profile_config(profile)
|
||||
effective_mode = str(mode or "default").strip() or "default"
|
||||
default_isolation = profile_cfg.default_isolation
|
||||
if not default_isolation:
|
||||
default_isolation = "shared" if profile in {"explore", "plan"} else "worktree"
|
||||
effective_isolation = str(isolation or default_isolation or "shared").strip().lower() or "shared"
|
||||
if effective_mode == "plan":
|
||||
effective_isolation = "shared"
|
||||
if effective_isolation not in {"shared", "worktree"}:
|
||||
effective_isolation = str(profile_cfg.default_isolation or "shared").strip().lower() or "shared"
|
||||
effective_background = profile_cfg.background if background is None else bool(background)
|
||||
effective_resident = bool(resident) and bool(effective_background)
|
||||
effective_model = str(model or profile_cfg.model or "").strip()
|
||||
effective_description = str(description or prompt or "").strip()
|
||||
effective_name = str(name or "").strip()
|
||||
if effective_name and effective_name in self.agent_names:
|
||||
existing_state = self.states.get(self.agent_names[effective_name])
|
||||
if existing_state is not None and not existing_state.completion.is_set():
|
||||
return {"error": f"Subagent name `{effective_name}` is already in use", "success": False}
|
||||
|
||||
agent_id = f"na_{uuid.uuid4().hex[:10]}"
|
||||
worktree = None
|
||||
if effective_isolation == "worktree":
|
||||
base_path = str(getattr(self.parent_task, "metadata", {}).get("target_output_dir", "") or "").strip()
|
||||
worktree = await create_worktree(base_path or None, config=self.config)
|
||||
state = SubagentState(
|
||||
agent_id=agent_id,
|
||||
profile=profile,
|
||||
name=effective_name,
|
||||
description=effective_description,
|
||||
model=effective_model,
|
||||
mode=effective_mode,
|
||||
isolation=effective_isolation,
|
||||
max_iterations=max(1, int(profile_cfg.max_iterations or 24)),
|
||||
background=effective_background,
|
||||
resident=effective_resident,
|
||||
fork_mode=bool(fork_mode),
|
||||
worktree=worktree,
|
||||
fork_system_prompt=str(fork_system_prompt or ""),
|
||||
fork_context_messages=list(fork_context_messages or []),
|
||||
fork_allowed_tools=list(fork_allowed_tools or []),
|
||||
)
|
||||
self.states[agent_id] = state
|
||||
if effective_name:
|
||||
self.agent_names[effective_name] = agent_id
|
||||
self._ensure_comms_endpoint(state)
|
||||
await self._save_state(state, "running")
|
||||
if state.worktree and self.store and hasattr(self.store, "save_runtime_worktree_session"):
|
||||
await self.store.save_runtime_worktree_session(
|
||||
worktree_session_id=f"wt_{agent_id}",
|
||||
runtime_session_id=self.runtime_session_id,
|
||||
task_id=self.parent_task.id if self.parent_task else None,
|
||||
path=str(state.worktree.get("path", "") or ""),
|
||||
status="active",
|
||||
metadata=dict(state.worktree or {}),
|
||||
)
|
||||
await self._emit(
|
||||
"subagent_started",
|
||||
state,
|
||||
{
|
||||
"prompt": prompt,
|
||||
"description": effective_description,
|
||||
"name": effective_name,
|
||||
"isolation": effective_isolation,
|
||||
"mode": effective_mode,
|
||||
"model": effective_model,
|
||||
"fork_mode": state.fork_mode,
|
||||
"resident": state.resident,
|
||||
},
|
||||
)
|
||||
|
||||
async def _execute_turn(turn_prompt: str) -> None:
|
||||
try:
|
||||
state.status = "running"
|
||||
state.update_event.set()
|
||||
await self._save_state(state, state.status)
|
||||
child = self._build_child_task(state, turn_prompt)
|
||||
child.metadata["_permission_bridge_runtime_session_id"] = self.runtime_session_id
|
||||
setattr(child, "_runtime_permission_bridge", self._build_permission_bridge(state))
|
||||
child_agent = self._build_child_agent(
|
||||
profile=profile,
|
||||
allowed_tools=list(state.fork_allowed_tools) or self._resolve_allowed_tools(profile, mode=effective_mode),
|
||||
prompt_addendum=self._profile_prompt(profile, mode=effective_mode),
|
||||
state=state,
|
||||
)
|
||||
setattr(child, "_runtime_inbox_queue", state.inbox)
|
||||
result = await child_agent.execute(child)
|
||||
state.task_result = result
|
||||
state.latest_result = result.content
|
||||
terminal_status = result.status.value
|
||||
state.last_notification_kind = self._resident_notification_kind(result)
|
||||
state.status = "idle" if state.resident else terminal_status
|
||||
await self._save_state(state, state.status, {
|
||||
"turn_status": terminal_status,
|
||||
"notification_kind": state.last_notification_kind,
|
||||
})
|
||||
await self._emit(
|
||||
"subagent_completed",
|
||||
state,
|
||||
{
|
||||
"status": terminal_status,
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident or not state.completion.is_set()),
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"content_preview": result.content[:500],
|
||||
},
|
||||
)
|
||||
if state.resident:
|
||||
await self._emit_worker_notification(
|
||||
state,
|
||||
notification_kind=state.last_notification_kind or "idle",
|
||||
summary=result.content or f"{state.name or state.agent_id} is idle",
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
state.latest_result = str(exc)
|
||||
state.last_notification_kind = "error"
|
||||
state.task_result = TaskResult(status=TaskStatus.FAILED, content=str(exc))
|
||||
state.status = "idle" if state.resident else TaskStatus.FAILED.value
|
||||
await self._save_state(state, state.status, {"error": str(exc), "notification_kind": state.last_notification_kind})
|
||||
await self._emit(
|
||||
"subagent_completed",
|
||||
state,
|
||||
{
|
||||
"status": TaskStatus.FAILED.value,
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident),
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"content_preview": str(exc)[:500],
|
||||
},
|
||||
)
|
||||
if state.resident:
|
||||
await self._emit_worker_notification(
|
||||
state,
|
||||
notification_kind="error",
|
||||
summary=str(exc),
|
||||
)
|
||||
finally:
|
||||
state.update_event.set()
|
||||
|
||||
async def _runner() -> None:
|
||||
current_prompt = prompt
|
||||
try:
|
||||
while True:
|
||||
await _execute_turn(current_prompt)
|
||||
if not state.resident:
|
||||
return
|
||||
state.status = "idle"
|
||||
state.update_event.set()
|
||||
await self._save_state(state, state.status, {"notification_kind": state.last_notification_kind or "idle"})
|
||||
next_message = await state.inbox.get()
|
||||
state.pending_messages_count = max(0, state.pending_messages_count - 1)
|
||||
current_prompt = str(next_message.get("body", "") or "").strip()
|
||||
if not current_prompt:
|
||||
current_prompt = str(next_message.get("message", "") or "").strip()
|
||||
if not current_prompt:
|
||||
current_prompt = str(next_message)
|
||||
finally:
|
||||
state.completion.set()
|
||||
state.update_event.set()
|
||||
if state.worktree and self.store and hasattr(self.store, "save_runtime_worktree_session"):
|
||||
await self.store.save_runtime_worktree_session(
|
||||
worktree_session_id=f"wt_{agent_id}",
|
||||
runtime_session_id=self.runtime_session_id,
|
||||
task_id=self.parent_task.id if self.parent_task else None,
|
||||
path=str(state.worktree.get("path", "") or ""),
|
||||
status="closed",
|
||||
metadata=dict(state.worktree or {}),
|
||||
)
|
||||
await cleanup_worktree(state.worktree)
|
||||
|
||||
if effective_background:
|
||||
state.runtime_task = asyncio.create_task(_runner())
|
||||
return {
|
||||
"success": True,
|
||||
"agent_id": agent_id,
|
||||
"name": effective_name,
|
||||
"status": "running",
|
||||
"background": True,
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident),
|
||||
"worktree_path": (state.worktree or {}).get("path", ""),
|
||||
}
|
||||
|
||||
await _runner()
|
||||
return self._result_payload(state)
|
||||
|
||||
async def wait(self, agent_id: str, timeout_seconds: int = 300) -> dict[str, Any]:
|
||||
resolved_id = self._resolve_agent_id(agent_id)
|
||||
state = self.states.get(resolved_id)
|
||||
if state is None:
|
||||
return {"error": f"Unknown subagent: {agent_id}", "success": False}
|
||||
if state.resident and state.status != "running":
|
||||
return self._result_payload(state)
|
||||
deadline = time.time() + max(1, int(timeout_seconds or 1))
|
||||
while True:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
return self._result_payload(state)
|
||||
if state.completion.is_set():
|
||||
return self._result_payload(state)
|
||||
if state.resident and state.status != "running":
|
||||
return self._result_payload(state)
|
||||
try:
|
||||
await asyncio.wait_for(state.update_event.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
return self._result_payload(state)
|
||||
state.update_event.clear()
|
||||
return self._result_payload(state)
|
||||
|
||||
async def send(self, agent_id: str, message: str) -> dict[str, Any]:
|
||||
resolved_id = self._resolve_agent_id(agent_id)
|
||||
state = self.states.get(resolved_id)
|
||||
if state is None:
|
||||
return {"error": f"Unknown subagent: {agent_id}", "success": False}
|
||||
if state.completion.is_set():
|
||||
return {"error": f"Subagent {agent_id} has already completed", "success": False}
|
||||
rendered = str(message or "").strip()
|
||||
self._persist_follow_up_message(state, rendered)
|
||||
await state.inbox.put(
|
||||
{
|
||||
"body": rendered,
|
||||
"message_class": "chat",
|
||||
"actionable": True,
|
||||
"worker_id": state.agent_id,
|
||||
"origin_task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"origin_session_id": str(getattr(self.parent_task, "session_id", "") or "").strip(),
|
||||
}
|
||||
)
|
||||
state.pending_messages_count += 1
|
||||
state.update_event.set()
|
||||
await self._save_state(state, state.status, {"queued_message": rendered[:500]})
|
||||
await self._emit(
|
||||
"subagent_updated",
|
||||
state,
|
||||
{
|
||||
"message": rendered[:500],
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
},
|
||||
)
|
||||
return {"success": True, "agent_id": state.agent_id, "name": state.name, "status": state.status}
|
||||
|
||||
def list_agents(self) -> dict[str, Any]:
|
||||
return {
|
||||
"success": True,
|
||||
"agents": [self._result_payload(state) for state in self.states.values()],
|
||||
}
|
||||
|
||||
def _build_child_task(self, state: SubagentState, prompt: str) -> Task:
|
||||
parent = self.parent_task or Task()
|
||||
metadata = dict(parent.metadata or {})
|
||||
metadata.pop("_fork_allowed_tools", None)
|
||||
metadata["_native_runtime_depth"] = int(metadata.get("_native_runtime_depth", 0) or 0) + 1
|
||||
metadata["subagent_profile"] = state.profile
|
||||
metadata["_subagent_name"] = state.name
|
||||
metadata["_subagent_description"] = state.description
|
||||
metadata["_subagent_model"] = state.model
|
||||
metadata["_subagent_mode"] = state.mode
|
||||
metadata["_subagent_max_iterations"] = state.max_iterations
|
||||
metadata["_fork_mode"] = state.fork_mode
|
||||
if state.profile == "verify":
|
||||
metadata[self.config.system.native_runtime.verification_policy.skip_metadata_key] = True
|
||||
metadata["work_item_verification_required"] = False
|
||||
if state.worktree and state.worktree.get("path"):
|
||||
metadata["target_output_dir"] = state.worktree["path"]
|
||||
execution_context = dict((state.worktree or {}).get("execution_context", {}) or {})
|
||||
if execution_context:
|
||||
metadata["_execution_context"] = execution_context
|
||||
if state.fork_system_prompt:
|
||||
metadata["_runtime_system_prompt_override"] = state.fork_system_prompt
|
||||
if state.fork_context_messages:
|
||||
metadata["_fork_context_messages"] = list(state.fork_context_messages)
|
||||
if state.fork_allowed_tools:
|
||||
metadata["_fork_allowed_tools"] = list(state.fork_allowed_tools)
|
||||
metadata["_comms_endpoint_id"] = state.agent_id
|
||||
metadata["_comms_parent_endpoint_id"] = self._parent_endpoint_id()
|
||||
metadata["_subagent_resident"] = state.resident
|
||||
return Task(
|
||||
title=state.name or state.description or f"{state.profile} subagent",
|
||||
description=prompt,
|
||||
assigned_to=parent.assigned_to,
|
||||
project_id=parent.project_id,
|
||||
session_id=f"{parent.session_id or 'session'}:{state.agent_id}",
|
||||
parent_session_id=parent.session_id,
|
||||
parent_id=parent.id,
|
||||
tags=list(parent.tags),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _resolve_allowed_tools(self, profile: str, mode: str = "default") -> list[str]:
|
||||
profiles = self.config.agents.native_subagents or {}
|
||||
profile_cfg: NativeSubagentProfileConfig = profiles.get(profile) or profiles.get("general") or NativeSubagentProfileConfig()
|
||||
if profile_cfg.allowed_tools:
|
||||
return self._apply_mode_tool_filter(list(profile_cfg.allowed_tools), mode)
|
||||
read_only = [
|
||||
"file_read",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"todo_read",
|
||||
"todo_write",
|
||||
"request_user_input",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
]
|
||||
implement = [
|
||||
"shell_exec",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"file_edit",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"python_exec",
|
||||
"todo_read",
|
||||
"todo_write",
|
||||
"request_user_input",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
]
|
||||
verify = [
|
||||
"shell_exec",
|
||||
"file_read",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"python_exec",
|
||||
"browser_navigate",
|
||||
"browser_snapshot",
|
||||
"browser_click",
|
||||
"browser_type",
|
||||
"browser_wait_for",
|
||||
"browser_scroll",
|
||||
"browser_take_screenshot",
|
||||
"browser_close",
|
||||
"todo_read",
|
||||
"todo_write",
|
||||
"request_user_input",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
]
|
||||
mapping = {
|
||||
"general": implement,
|
||||
"explore": read_only,
|
||||
"plan": read_only,
|
||||
"implement": implement,
|
||||
"verify": verify,
|
||||
}
|
||||
return self._apply_mode_tool_filter(mapping.get(profile, implement), mode)
|
||||
|
||||
def _profile_prompt(self, profile: str, mode: str = "default") -> str:
|
||||
prompts = {
|
||||
"general": "Complete the task directly and report only the essential outcome.",
|
||||
"explore": "Read-only exploration only. Do not modify files or system state.",
|
||||
"plan": "Read-only planning only. Produce a concise implementation plan with critical files.",
|
||||
"implement": "Implement directly in the assigned workspace, then verify your changes with commands.",
|
||||
"verify": (
|
||||
"Try to break the implementation. Prefer executable checks over code reading. "
|
||||
"For every check you actually ran, emit `Check:`, `Command:`, `Observed Output:`, and `Result:` lines. "
|
||||
"End with exactly one line `VERDICT: PASS`, `VERDICT: FAIL`, or `VERDICT: PARTIAL`. "
|
||||
"You may start the final summary with `VERIFIED:` if the work is acceptable, or `ISSUES:` if blocking problems remain."
|
||||
),
|
||||
}
|
||||
base = prompts.get(profile, prompts["general"])
|
||||
normalized_mode = str(mode or "default").strip().lower()
|
||||
if normalized_mode == "plan":
|
||||
return base + " Operate in plan mode: do not make filesystem or shell changes."
|
||||
if normalized_mode in {"accept_edits", "bypass_permissions", "dont_ask", "acceptedits", "bypasspermissions", "dontask"}:
|
||||
return base + f" Runtime spawn mode hint: {mode}."
|
||||
return base
|
||||
|
||||
def _profile_config(self, profile: str) -> NativeSubagentProfileConfig:
|
||||
profiles = self.config.agents.native_subagents or {}
|
||||
return profiles.get(profile) or profiles.get("general") or NativeSubagentProfileConfig()
|
||||
|
||||
def _apply_mode_tool_filter(self, tools: list[str], mode: str) -> list[str]:
|
||||
if str(mode or "default").strip().lower() != "plan":
|
||||
return list(tools)
|
||||
plan_safe = {
|
||||
"file_read",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"todo_read",
|
||||
"todo_write",
|
||||
"request_user_input",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
"agent_list",
|
||||
}
|
||||
return [tool for tool in tools if tool in plan_safe]
|
||||
|
||||
def _build_child_agent(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
allowed_tools: list[str],
|
||||
prompt_addendum: str,
|
||||
state: SubagentState,
|
||||
) -> Any:
|
||||
overrides = {
|
||||
"name": state.name,
|
||||
"description": state.description,
|
||||
"model": state.model,
|
||||
"mode": state.mode,
|
||||
"max_iterations": state.max_iterations,
|
||||
}
|
||||
signature = inspect.signature(self.child_agent_factory)
|
||||
accepts_overrides = len(signature.parameters) >= 4 or any(
|
||||
parameter.kind == inspect.Parameter.VAR_POSITIONAL
|
||||
for parameter in signature.parameters.values()
|
||||
)
|
||||
if accepts_overrides:
|
||||
return self.child_agent_factory(profile, allowed_tools, prompt_addendum, overrides)
|
||||
return self.child_agent_factory(profile, allowed_tools, prompt_addendum)
|
||||
|
||||
def _build_permission_bridge(self, state: SubagentState) -> Callable[..., Awaitable[tuple[bool, Any]]]:
|
||||
async def _bridge(
|
||||
*,
|
||||
tool: Any,
|
||||
arguments: dict[str, Any],
|
||||
approval_engine: Any,
|
||||
on_progress: Any = None,
|
||||
) -> tuple[bool, Any]:
|
||||
parent_task = self.parent_task or Task(project_id="default")
|
||||
metadata = {
|
||||
"category": getattr(tool, "category", "general"),
|
||||
"requires_confirmation": getattr(tool, "requires_confirmation", False),
|
||||
"description": getattr(tool, "description", ""),
|
||||
"subagent_id": state.agent_id,
|
||||
"subagent_profile": state.profile,
|
||||
"subagent_name": state.name,
|
||||
"subagent_mode": state.mode,
|
||||
"bridged_runtime_session_id": self.runtime_session_id,
|
||||
}
|
||||
return await approval_engine.authorize_tool_call(
|
||||
task=parent_task,
|
||||
tool_name=getattr(tool, "name", ""),
|
||||
arguments=arguments,
|
||||
metadata=metadata,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
return _bridge
|
||||
|
||||
def _resolve_agent_id(self, agent_id: str) -> str:
|
||||
raw = str(agent_id or "").strip()
|
||||
if raw in self.states:
|
||||
return raw
|
||||
return self.agent_names.get(raw, raw)
|
||||
|
||||
def _parent_endpoint_id(self) -> str:
|
||||
if self.parent_task is None:
|
||||
return "runtime-parent"
|
||||
session_id = str(getattr(self.parent_task, "session_id", "") or "").strip()
|
||||
task_id = str(getattr(self.parent_task, "id", "") or "").strip()
|
||||
return f"task::{session_id or task_id or 'runtime-parent'}"
|
||||
|
||||
def _comms_layout(self):
|
||||
if self.parent_task is None:
|
||||
return None
|
||||
workspace_root = (
|
||||
str(getattr(self.parent_task, "metadata", {}).get("comms_workspace_root", "") or "").strip()
|
||||
or str(getattr(self.parent_task, "metadata", {}).get("workspace_root", "") or "").strip()
|
||||
or str(getattr(self.parent_task, "metadata", {}).get("target_output_dir", "") or "").strip()
|
||||
)
|
||||
if not workspace_root:
|
||||
return None
|
||||
try:
|
||||
from opc.layer2_organization import comms as _comms
|
||||
|
||||
return _comms.resolve_layout(
|
||||
workspace_root,
|
||||
str(getattr(self.parent_task, "project_id", "") or "default").strip() or "default",
|
||||
str(getattr(self.parent_task, "parent_session_id", "") or getattr(self.parent_task, "session_id", "") or "default").strip() or "default",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _ensure_comms_endpoint(self, state: SubagentState) -> None:
|
||||
layout = self._comms_layout()
|
||||
if layout is None:
|
||||
return
|
||||
try:
|
||||
from opc.layer2_organization import comms as _comms
|
||||
|
||||
_comms.ensure_layout(layout, [self._parent_endpoint_id(), state.agent_id])
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _persist_follow_up_message(self, state: SubagentState, message: str) -> None:
|
||||
if not message:
|
||||
return
|
||||
layout = self._comms_layout()
|
||||
if layout is None:
|
||||
return
|
||||
try:
|
||||
from opc.layer2_organization import comms as _comms
|
||||
|
||||
_comms.send_message(
|
||||
layout,
|
||||
from_role=self._parent_endpoint_id(),
|
||||
to_role=state.agent_id,
|
||||
subject=f"Follow-up for {state.name or state.agent_id}",
|
||||
body=message,
|
||||
priority="normal",
|
||||
extra_frontmatter={
|
||||
"transport_kind": "system",
|
||||
"semantic_type": "work_update",
|
||||
"message_class": "chat",
|
||||
"actionable": True,
|
||||
"worker_id": state.agent_id,
|
||||
"origin_task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"origin_session_id": str(getattr(self.parent_task, "session_id", "") or "").strip(),
|
||||
"comms_state": "open",
|
||||
"from_endpoint_type": "native_subagent",
|
||||
"to_endpoint_type": "native_subagent",
|
||||
"refs": {
|
||||
"task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"runtime_session_id": self.runtime_session_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
async def _emit(self, event_type: str, state: SubagentState, payload: dict[str, Any]) -> None:
|
||||
if not self.event_bus:
|
||||
return
|
||||
await self.event_bus.publish(OPCEvent(
|
||||
event_type="runtime_event",
|
||||
payload={
|
||||
"type": event_type,
|
||||
"agent_id": state.agent_id,
|
||||
"profile": state.profile,
|
||||
"task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"session_id": str(getattr(self.parent_task, "session_id", "") or "").strip(),
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
**payload,
|
||||
},
|
||||
))
|
||||
|
||||
async def _save_state(self, state: SubagentState, status: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
if not self.store or not hasattr(self.store, "save_runtime_subagent_run"):
|
||||
return
|
||||
merged_metadata = {
|
||||
"name": state.name,
|
||||
"description": state.description,
|
||||
"model": state.model,
|
||||
"mode": state.mode,
|
||||
"isolation": state.isolation,
|
||||
"max_iterations": state.max_iterations,
|
||||
"fork_mode": state.fork_mode,
|
||||
"fork_context_messages": len(state.fork_context_messages),
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident and not state.completion.is_set()),
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"last_notification_kind": state.last_notification_kind,
|
||||
}
|
||||
merged_metadata.update(metadata or {})
|
||||
await self.store.save_runtime_subagent_run(
|
||||
subagent_run_id=state.agent_id,
|
||||
runtime_session_id=self.runtime_session_id,
|
||||
task_id=self.parent_task.id if self.parent_task else None,
|
||||
agent_id=state.agent_id,
|
||||
profile=state.profile,
|
||||
status=status,
|
||||
worktree_path=str((state.worktree or {}).get("path", "") or ""),
|
||||
metadata=merged_metadata,
|
||||
)
|
||||
|
||||
async def _emit_worker_notification(
|
||||
self,
|
||||
state: SubagentState,
|
||||
*,
|
||||
notification_kind: str,
|
||||
summary: str,
|
||||
actionable: bool = False,
|
||||
) -> None:
|
||||
payload = {
|
||||
"worker_id": state.agent_id,
|
||||
"worker_type": "native_subagent",
|
||||
"notification_kind": str(notification_kind or "idle").strip() or "idle",
|
||||
"summary": str(summary or "").strip(),
|
||||
"task_id": str(getattr(self.parent_task, "id", "") or "").strip(),
|
||||
"session_id": str(getattr(self.parent_task, "session_id", "") or "").strip(),
|
||||
**work_item_identity_payload_for_task(self.parent_task),
|
||||
"projection_id": projection_id_for_task(self.parent_task) if self.parent_task is not None else "",
|
||||
"details_ref": state.agent_id,
|
||||
"actionable": bool(actionable),
|
||||
"resident_status": state.status,
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"name": state.name,
|
||||
}
|
||||
await self._emit("worker_notification", state, payload)
|
||||
|
||||
@staticmethod
|
||||
def _resident_notification_kind(result: TaskResult) -> str:
|
||||
if result.status in {TaskStatus.AWAITING_HUMAN, TaskStatus.AWAITING_REVIEW}:
|
||||
return "permission_needed"
|
||||
if result.status == TaskStatus.AWAITING_PEER:
|
||||
return "blocked"
|
||||
if result.status == TaskStatus.FAILED:
|
||||
return "error"
|
||||
if result.status == TaskStatus.DONE:
|
||||
return "task_complete"
|
||||
return "idle"
|
||||
|
||||
def _result_payload(self, state: SubagentState) -> dict[str, Any]:
|
||||
payload = {
|
||||
"success": state.task_result is not None and state.task_result.status == TaskStatus.DONE,
|
||||
"agent_id": state.agent_id,
|
||||
"profile": state.profile,
|
||||
"name": state.name,
|
||||
"description": state.description,
|
||||
"model": state.model,
|
||||
"mode": state.mode,
|
||||
"isolation": state.isolation,
|
||||
"max_iterations": state.max_iterations,
|
||||
"status": state.status,
|
||||
"background": state.background,
|
||||
"fork_mode": state.fork_mode,
|
||||
"resident": state.resident,
|
||||
"resident_status": state.status,
|
||||
"accepts_followups": bool(state.resident and not state.completion.is_set()),
|
||||
"pending_messages_count": state.pending_messages_count,
|
||||
"last_notification_kind": state.last_notification_kind,
|
||||
"result": state.latest_result,
|
||||
"worktree_path": (state.worktree or {}).get("path", ""),
|
||||
"venv_path": (state.worktree or {}).get("venv_path", ""),
|
||||
"python_executable": (state.worktree or {}).get("python_executable", ""),
|
||||
}
|
||||
if state.task_result and state.task_result.status in {TaskStatus.AWAITING_HUMAN, TaskStatus.AWAITING_REVIEW}:
|
||||
artifacts = dict(state.task_result.artifacts or {})
|
||||
payload.update(
|
||||
{
|
||||
"requires_user_input": True,
|
||||
"reason": state.task_result.content,
|
||||
"approval": dict(artifacts.get("approval", {}) or {}),
|
||||
"permission_requests": list(artifacts.get("permission_requests", []) or []),
|
||||
}
|
||||
)
|
||||
if state.task_result and state.task_result.status == TaskStatus.AWAITING_PEER:
|
||||
artifacts = dict(state.task_result.artifacts or {})
|
||||
payload.update(
|
||||
{
|
||||
"requires_peer_wait": True,
|
||||
"reason": state.task_result.content,
|
||||
"permission_requests": list(artifacts.get("permission_requests", []) or []),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Runtime-managed tool hook bus for Native Runtime V2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeToolHookContext:
|
||||
phase: str
|
||||
tool_name: str
|
||||
call: dict[str, Any]
|
||||
task: Any = None
|
||||
tool: Any = None
|
||||
arguments: dict[str, Any] = field(default_factory=dict)
|
||||
predicted_permission: Any = None
|
||||
result: dict[str, Any] | None = None
|
||||
state: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
RuntimeToolHook = Callable[[RuntimeToolHookContext], Awaitable[Optional[dict[str, Any]]]]
|
||||
RuntimeHookEmitter = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
class RuntimeToolHookBus:
|
||||
"""Composable pre/post/failure hook bus for runtime-managed tool execution."""
|
||||
|
||||
def __init__(self, *, emit_event: RuntimeHookEmitter | None = None) -> None:
|
||||
self.emit_event = emit_event
|
||||
self._pre_hooks: list[tuple[str, RuntimeToolHook]] = []
|
||||
self._post_hooks: list[tuple[str, RuntimeToolHook]] = []
|
||||
self._failure_hooks: list[tuple[str, RuntimeToolHook]] = []
|
||||
|
||||
def register_pre_hook(self, name: str, hook: RuntimeToolHook) -> None:
|
||||
self._pre_hooks.append((name, hook))
|
||||
|
||||
def register_post_hook(self, name: str, hook: RuntimeToolHook) -> None:
|
||||
self._post_hooks.append((name, hook))
|
||||
|
||||
def register_failure_hook(self, name: str, hook: RuntimeToolHook) -> None:
|
||||
self._failure_hooks.append((name, hook))
|
||||
|
||||
async def run_pre_hooks(self, context: RuntimeToolHookContext) -> RuntimeToolHookContext:
|
||||
return await self._run_hooks(self._pre_hooks, context)
|
||||
|
||||
async def run_post_hooks(self, context: RuntimeToolHookContext) -> RuntimeToolHookContext:
|
||||
return await self._run_hooks(self._post_hooks, context)
|
||||
|
||||
async def run_failure_hooks(self, context: RuntimeToolHookContext) -> RuntimeToolHookContext:
|
||||
return await self._run_hooks(self._failure_hooks, context)
|
||||
|
||||
async def _run_hooks(
|
||||
self,
|
||||
hooks: list[tuple[str, RuntimeToolHook]],
|
||||
context: RuntimeToolHookContext,
|
||||
) -> RuntimeToolHookContext:
|
||||
for hook_name, hook in hooks:
|
||||
patch = await hook(context) or {}
|
||||
self._apply_patch(context, patch)
|
||||
if self.emit_event:
|
||||
await self.emit_event(
|
||||
"tool_hook",
|
||||
{
|
||||
"phase": context.phase,
|
||||
"tool_name": context.tool_name,
|
||||
"tool_call_id": context.call.get("id", ""),
|
||||
"hook_name": hook_name,
|
||||
"stopped": bool(context.state.get("stop_execution")),
|
||||
"result_overridden": context.result is not None,
|
||||
},
|
||||
)
|
||||
if context.state.get("stop_execution"):
|
||||
break
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _apply_patch(context: RuntimeToolHookContext, patch: dict[str, Any]) -> None:
|
||||
if not patch:
|
||||
return
|
||||
if isinstance(patch.get("arguments"), dict):
|
||||
context.arguments = dict(patch["arguments"])
|
||||
if isinstance(patch.get("result"), dict):
|
||||
context.result = dict(patch["result"])
|
||||
if isinstance(patch.get("metadata"), dict):
|
||||
context.state.setdefault("metadata", {}).update(dict(patch["metadata"]))
|
||||
if isinstance(patch.get("approval"), dict):
|
||||
context.state.setdefault("approval", {}).update(dict(patch["approval"]))
|
||||
if "stop_execution" in patch:
|
||||
context.state["stop_execution"] = bool(patch["stop_execution"])
|
||||
if "stop_batch_on_failure" in patch:
|
||||
context.state["stop_batch_on_failure"] = bool(patch["stop_batch_on_failure"])
|
||||
if "prevent_continuation" in patch:
|
||||
context.state["prevent_continuation"] = bool(patch["prevent_continuation"])
|
||||
if patch.get("stop_reason"):
|
||||
context.state["stop_reason"] = str(patch["stop_reason"])
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tool planning helpers for Native Runtime V2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from opc.layer4_tools.registry import ToolDefinition, ToolRegistry
|
||||
|
||||
|
||||
_READ_ONLY_TOOL_NAMES = {
|
||||
"file_read",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"grep",
|
||||
"glob",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"todo_read",
|
||||
"agent_list",
|
||||
"agent_wait",
|
||||
}
|
||||
|
||||
_NON_CONCURRENT_TOOL_NAMES = {
|
||||
"shell_exec",
|
||||
"file_write",
|
||||
"file_edit",
|
||||
"apply_patch",
|
||||
"python_exec",
|
||||
"git_commit",
|
||||
"agent_spawn",
|
||||
"agent_wait",
|
||||
"agent_send",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolBatch:
|
||||
concurrency_safe: bool
|
||||
calls: list[dict[str, Any]]
|
||||
|
||||
|
||||
class ToolPlanner:
|
||||
"""Determine tool execution ordering and concurrency."""
|
||||
|
||||
def __init__(self, registry: ToolRegistry, max_parallel_read_tools: int = 6) -> None:
|
||||
self.registry = registry
|
||||
self.max_parallel_read_tools = max(1, int(max_parallel_read_tools or 1))
|
||||
|
||||
def is_read_only(self, tool: ToolDefinition | None) -> bool:
|
||||
if tool is None:
|
||||
return False
|
||||
if tool.read_only is not None:
|
||||
return bool(tool.read_only)
|
||||
if tool.name in _READ_ONLY_TOOL_NAMES:
|
||||
return True
|
||||
return tool.category in {"search", "read"} or tool.name.endswith("_read")
|
||||
|
||||
def is_concurrency_safe(self, tool: ToolDefinition | None) -> bool:
|
||||
if tool is None:
|
||||
return False
|
||||
if tool.concurrency_safe is not None:
|
||||
return bool(tool.concurrency_safe)
|
||||
if tool.name in _NON_CONCURRENT_TOOL_NAMES:
|
||||
return False
|
||||
return self.is_read_only(tool)
|
||||
|
||||
def partition(self, tool_calls: list[dict[str, Any]]) -> list[ToolBatch]:
|
||||
batches: list[ToolBatch] = []
|
||||
for call in tool_calls:
|
||||
tool = self.registry.get(str(call.get("function", "") or ""))
|
||||
concurrency_safe = self.is_concurrency_safe(tool)
|
||||
if concurrency_safe and batches and batches[-1].concurrency_safe:
|
||||
batches[-1].calls.append(call)
|
||||
else:
|
||||
batches.append(ToolBatch(concurrency_safe=concurrency_safe, calls=[call]))
|
||||
return batches
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Local worktree helpers for runtime-managed subagents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import OPCConfig
|
||||
from opc.layer4_tools.execution_context import build_task_execution_context, venv_python_path
|
||||
|
||||
|
||||
async def create_worktree(
|
||||
base_path: str | None,
|
||||
*,
|
||||
config: OPCConfig | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
source = Path(base_path or os.getcwd()).resolve()
|
||||
root = await _find_git_root(source)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="opc-native-v2-"))
|
||||
|
||||
if root is not None:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
str(root),
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
str(temp_dir),
|
||||
"HEAD",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
info = {
|
||||
"path": str(temp_dir),
|
||||
"git_root": str(root),
|
||||
"mode": "git_worktree",
|
||||
"stdout": stdout.decode("utf-8", errors="replace"),
|
||||
}
|
||||
return await _prepare_execution_environment(info, config=config, on_progress=on_progress)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="opc-native-v2-copy-"))
|
||||
|
||||
shutil.copytree(
|
||||
source,
|
||||
temp_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns(".git", ".venv", "__pycache__", ".pytest_cache"),
|
||||
)
|
||||
info = {
|
||||
"path": str(temp_dir),
|
||||
"git_root": str(root) if root else "",
|
||||
"mode": "copy",
|
||||
}
|
||||
return await _prepare_execution_environment(info, config=config, on_progress=on_progress)
|
||||
|
||||
|
||||
async def cleanup_worktree(info: dict[str, Any] | None) -> None:
|
||||
if not info:
|
||||
return
|
||||
path = Path(str(info.get("path", "") or "")).resolve()
|
||||
mode = str(info.get("mode", "") or "")
|
||||
git_root = str(info.get("git_root", "") or "")
|
||||
if not path.exists():
|
||||
return
|
||||
if mode == "git_worktree" and git_root:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
git_root,
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
str(path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
return
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
async def _find_git_root(path: Path) -> Path | None:
|
||||
current = path
|
||||
if current.is_file():
|
||||
current = current.parent
|
||||
for candidate in [current, *current.parents]:
|
||||
if (candidate / ".git").exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
async def _prepare_execution_environment(
|
||||
info: dict[str, Any],
|
||||
*,
|
||||
config: OPCConfig | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
workspace = Path(str(info.get("path", "") or "")).resolve()
|
||||
info["execution_context"] = build_task_execution_context(workspace_root=workspace, config=config)
|
||||
if config is None:
|
||||
return info
|
||||
venv_cfg = config.system.native_runtime.execution_environment.worktree_venv
|
||||
if not venv_cfg.enabled:
|
||||
return info
|
||||
|
||||
provider = _resolve_venv_provider(venv_cfg.provider)
|
||||
venv_path = workspace / str(venv_cfg.venv_dir or ".opc-venv")
|
||||
python_path = venv_python_path(venv_path)
|
||||
try:
|
||||
if not python_path.exists():
|
||||
create_args = _venv_create_args(provider, venv_path, system_site_packages=venv_cfg.system_site_packages)
|
||||
await _run_process(create_args, cwd=workspace, on_progress=on_progress)
|
||||
sync_commands = _build_sync_commands(
|
||||
workspace=workspace,
|
||||
python_path=python_path,
|
||||
provider=provider,
|
||||
editable_project=venv_cfg.editable_project,
|
||||
requirements_files=_detect_requirements_files(
|
||||
workspace=workspace,
|
||||
configured=venv_cfg.requirements_files,
|
||||
auto_detect=venv_cfg.auto_detect_requirements,
|
||||
),
|
||||
)
|
||||
for command in sync_commands:
|
||||
await _run_process(command, cwd=workspace, on_progress=on_progress)
|
||||
info.update(
|
||||
{
|
||||
"venv_path": str(venv_path),
|
||||
"python_executable": str(python_path),
|
||||
"venv_provider": provider,
|
||||
"environment_prepared": True,
|
||||
"environment_sync_commands": len(sync_commands),
|
||||
}
|
||||
)
|
||||
info["execution_context"] = build_task_execution_context(
|
||||
workspace_root=workspace,
|
||||
config=config,
|
||||
venv_path=venv_path,
|
||||
python_executable=python_path,
|
||||
venv_provider=provider,
|
||||
)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
info.update(
|
||||
{
|
||||
"venv_path": str(venv_path),
|
||||
"python_executable": str(python_path),
|
||||
"venv_provider": provider,
|
||||
"environment_prepared": False,
|
||||
"environment_error": error,
|
||||
}
|
||||
)
|
||||
info["execution_context"] = build_task_execution_context(
|
||||
workspace_root=workspace,
|
||||
config=config,
|
||||
venv_path=venv_path,
|
||||
python_executable=python_path,
|
||||
venv_provider=provider,
|
||||
preparation_error=error,
|
||||
)
|
||||
if venv_cfg.fail_if_prepare_fails:
|
||||
raise
|
||||
return info
|
||||
|
||||
|
||||
def _resolve_venv_provider(provider: str) -> str:
|
||||
normalized = str(provider or "auto").strip().lower() or "auto"
|
||||
if normalized == "uv":
|
||||
return "uv"
|
||||
if normalized == "venv":
|
||||
return "venv"
|
||||
return "uv" if shutil.which("uv") else "venv"
|
||||
|
||||
|
||||
def _venv_create_args(provider: str, venv_path: Path, *, system_site_packages: bool) -> list[str]:
|
||||
if provider == "uv":
|
||||
return ["uv", "venv", str(venv_path), "--python", sys.executable]
|
||||
args = [sys.executable, "-m", "venv", str(venv_path)]
|
||||
if system_site_packages:
|
||||
args.append("--system-site-packages")
|
||||
return args
|
||||
|
||||
|
||||
def _detect_requirements_files(
|
||||
*,
|
||||
workspace: Path,
|
||||
configured: list[str],
|
||||
auto_detect: bool,
|
||||
) -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
for entry in configured:
|
||||
text = str(entry or "").strip()
|
||||
if text:
|
||||
candidates.append((workspace / text).resolve())
|
||||
if auto_detect:
|
||||
for name in ("requirements.txt", "requirements-dev.txt", "requirements-test.txt", "requirements-ci.txt"):
|
||||
candidate = (workspace / name).resolve()
|
||||
if candidate.exists():
|
||||
candidates.append(candidate)
|
||||
seen: set[str] = set()
|
||||
resolved: list[Path] = []
|
||||
for item in candidates:
|
||||
key = str(item)
|
||||
if key in seen or not item.exists():
|
||||
continue
|
||||
seen.add(key)
|
||||
resolved.append(item)
|
||||
return resolved
|
||||
|
||||
|
||||
def _build_sync_commands(
|
||||
*,
|
||||
workspace: Path,
|
||||
python_path: Path,
|
||||
provider: str,
|
||||
editable_project: bool,
|
||||
requirements_files: list[Path],
|
||||
) -> list[list[str]]:
|
||||
commands: list[list[str]] = []
|
||||
if editable_project and (workspace / "pyproject.toml").exists():
|
||||
if provider == "uv" and shutil.which("uv"):
|
||||
commands.append(["uv", "pip", "install", "--python", str(python_path), "-e", str(workspace)])
|
||||
else:
|
||||
commands.append([str(python_path), "-m", "pip", "install", "-e", str(workspace)])
|
||||
for item in requirements_files:
|
||||
if provider == "uv" and shutil.which("uv"):
|
||||
commands.append(["uv", "pip", "install", "--python", str(python_path), "-r", str(item)])
|
||||
else:
|
||||
commands.append([str(python_path), "-m", "pip", "install", "-r", str(item)])
|
||||
return commands
|
||||
|
||||
|
||||
async def _run_process(args: list[str], *, cwd: Path, on_progress: Any = None) -> None:
|
||||
if on_progress:
|
||||
try:
|
||||
await on_progress(f"[worktree-env] {' '.join(args[:4])}")
|
||||
except TypeError:
|
||||
await on_progress(f"[worktree-env] {' '.join(args[:4])}")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
cwd=str(cwd),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
||||
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
||||
detail = stderr_text or stdout_text or f"exit code {proc.returncode}"
|
||||
raise RuntimeError(f"{' '.join(args[:4])} failed: {detail}")
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Install the ``opc-collab`` skill + CLI shim for external agents.
|
||||
|
||||
Most OpenOPC-spawned external agents run with a dedicated HOME-style directory
|
||||
(``$CODEX_HOME``, ``$OPENCODE_CONFIG_DIR``) under
|
||||
``<opc_home>/agent_homes/<agent>/``. Claude Code keeps the user's normal config
|
||||
directory so it can reuse the already authenticated CLI login. Before each
|
||||
launch the broker calls the functions in this module to:
|
||||
|
||||
* symlink the packaged ``SKILL.md`` into ``<agent_home>/skills/opc-collab/``
|
||||
so the agent's native skill discovery finds the instructions;
|
||||
* drop an executable ``opc-collab`` shim into ``<opc_home>/bin/`` and prepend
|
||||
that directory to ``PATH`` so the agent can call the CLI from the shell.
|
||||
|
||||
Everything is idempotent — repeated calls reconcile symlink targets and
|
||||
overwrite the shim only when its content would otherwise drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.config import get_opc_home
|
||||
|
||||
|
||||
SKILL_NAME = "opc-collab"
|
||||
_SKILL_SOURCE = Path(__file__).resolve().parent.parent / "skills_assets" / "opc_collab"
|
||||
_SKILL_FILES: tuple[str, ...] = ("SKILL.md",)
|
||||
|
||||
|
||||
def opc_bin_dir(opc_home: Path | None = None) -> Path:
|
||||
"""Return the shared ``<opc_home>/bin/`` directory (created on demand).
|
||||
|
||||
Kept outside each agent home so a single shim serves every agent.
|
||||
"""
|
||||
base = Path(opc_home) if opc_home else get_opc_home()
|
||||
bin_dir = base / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
return bin_dir
|
||||
|
||||
|
||||
def agent_home_dir(agent_slug: str, opc_home: Path | None = None) -> Path:
|
||||
"""Return ``<opc_home>/agent_homes/<slug>/`` (created on demand).
|
||||
|
||||
``agent_slug`` is a short identifier like ``codex`` / ``claude`` /
|
||||
``opencode`` — one directory per external agent so each has its own
|
||||
native config space without colliding or polluting the user's
|
||||
``~/.codex`` / ``~/.claude`` / ``~/.config/opencode``.
|
||||
"""
|
||||
base = Path(opc_home) if opc_home else get_opc_home()
|
||||
home = base / "agent_homes" / agent_slug
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
return home
|
||||
|
||||
|
||||
def _write_opc_collab_shim(shim_path: Path) -> None:
|
||||
"""Write (or refresh) the ``opc-collab`` executable shim.
|
||||
|
||||
The shim pins ``sys.executable`` at install time so spawned agents use
|
||||
the same Python that's running OpenOPC, regardless of what Python they
|
||||
find on their own ``PATH``.
|
||||
"""
|
||||
python = sys.executable or "python3"
|
||||
content = (
|
||||
"#!/bin/sh\n"
|
||||
"# Auto-generated by OpenOPC. Dispatches to `opc.cli_collab`.\n"
|
||||
"# Do not edit by hand; the skill installer rewrites this file.\n"
|
||||
f'exec "{python}" -m opc.cli_collab "$@"\n'
|
||||
)
|
||||
try:
|
||||
existing = shim_path.read_text()
|
||||
except FileNotFoundError:
|
||||
existing = ""
|
||||
if existing != content:
|
||||
shim_path.write_text(content)
|
||||
mode = shim_path.stat().st_mode
|
||||
executable = mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
if mode != executable:
|
||||
shim_path.chmod(executable)
|
||||
|
||||
|
||||
def _write_opc_collab_cmd_shim(shim_path: Path) -> None:
|
||||
"""Write the Windows ``opc-collab.cmd`` shim.
|
||||
|
||||
Windows does not execute extensionless POSIX shell shims via normal
|
||||
``CreateProcess`` / shell lookup. Keeping a ``.cmd`` sibling lets
|
||||
spawned agents call the collaboration CLI from PowerShell/CMD and via
|
||||
PATHEXT lookup.
|
||||
"""
|
||||
python = sys.executable or "python"
|
||||
content = (
|
||||
"@echo off\r\n"
|
||||
"REM Auto-generated by OpenOPC. Dispatches to opc.cli_collab.\r\n"
|
||||
"REM Do not edit by hand; the skill installer rewrites this file.\r\n"
|
||||
f'"{python}" -m opc.cli_collab %*\r\n'
|
||||
)
|
||||
try:
|
||||
existing = shim_path.read_text()
|
||||
except FileNotFoundError:
|
||||
existing = ""
|
||||
if existing != content:
|
||||
shim_path.write_text(content)
|
||||
|
||||
|
||||
def ensure_opc_collab_bin(opc_home: Path | None = None) -> Path:
|
||||
"""Ensure ``<opc_home>/bin/opc-collab`` exists and is executable. Returns
|
||||
the bin directory (so callers can prepend it to ``PATH``)."""
|
||||
bin_dir = opc_bin_dir(opc_home)
|
||||
_write_opc_collab_shim(bin_dir / "opc-collab")
|
||||
if os.name == "nt":
|
||||
_write_opc_collab_cmd_shim(bin_dir / "opc-collab.cmd")
|
||||
return bin_dir
|
||||
|
||||
|
||||
def opc_collab_executable(bin_dir: Path) -> Path:
|
||||
"""Return the platform-native collaboration CLI path."""
|
||||
return Path(bin_dir) / ("opc-collab.cmd" if os.name == "nt" else "opc-collab")
|
||||
|
||||
|
||||
def _ensure_symlink(source: Path, target: Path) -> None:
|
||||
"""Create or reconcile ``target`` → ``source`` as a symlink.
|
||||
|
||||
Silently removes a stale target pointing elsewhere (this file is owned
|
||||
by the skill installer — the user has no business editing it). If
|
||||
symlinks are unsupported on this filesystem, falls back to copying the
|
||||
file content. The fallback is rare (Windows without developer mode).
|
||||
"""
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"skill source missing: {source}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
current = target.readlink() if target.is_symlink() else None
|
||||
except OSError:
|
||||
current = None
|
||||
if current is not None and Path(current).resolve() == source.resolve():
|
||||
return
|
||||
if target.is_symlink() or target.exists():
|
||||
target.unlink()
|
||||
try:
|
||||
target.symlink_to(source)
|
||||
except (OSError, NotImplementedError):
|
||||
target.write_bytes(source.read_bytes())
|
||||
|
||||
|
||||
def install_opc_collab_skill(agent_home: Path) -> Path:
|
||||
"""Install the ``opc-collab`` skill bundle into ``<agent_home>/skills/opc-collab/``.
|
||||
|
||||
Returns the installed skill directory. Safe to call on every launch —
|
||||
the implementation reconciles existing symlinks instead of rewriting.
|
||||
"""
|
||||
skill_dir = Path(agent_home) / "skills" / SKILL_NAME
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
for file_name in _SKILL_FILES:
|
||||
src = _SKILL_SOURCE / file_name
|
||||
if not src.exists():
|
||||
logger.warning(
|
||||
f"install_opc_collab_skill: packaged asset missing: {src}; skipping"
|
||||
)
|
||||
continue
|
||||
_ensure_symlink(src, skill_dir / file_name)
|
||||
return skill_dir
|
||||
|
||||
|
||||
def install_collab_surface(
|
||||
agent_slug: str,
|
||||
opc_home: Path | None = None,
|
||||
) -> tuple[Path, Path]:
|
||||
"""One-shot: ensure the agent home, the skill bundle, and the bin shim
|
||||
all exist. Returns ``(agent_home, bin_dir)`` so the caller can wire
|
||||
env vars.
|
||||
"""
|
||||
home = agent_home_dir(agent_slug, opc_home=opc_home)
|
||||
install_opc_collab_skill(home)
|
||||
bin_dir = ensure_opc_collab_bin(opc_home=opc_home)
|
||||
return home, bin_dir
|
||||
|
||||
|
||||
def prepend_to_path(existing_path: str, bin_dir: Path) -> str:
|
||||
"""Return a ``PATH`` value with ``bin_dir`` at the front.
|
||||
|
||||
Kept as a pure function so the broker can compose env maps without
|
||||
mutating ``os.environ``. ``bin_dir`` is idempotent — repeated calls do
|
||||
not duplicate the entry.
|
||||
"""
|
||||
bin_str = str(bin_dir)
|
||||
parts = [bin_str]
|
||||
for part in (existing_path or "").split(os.pathsep):
|
||||
if part and part != bin_str and part not in parts:
|
||||
parts.append(part)
|
||||
return os.pathsep.join(parts)
|
||||
Reference in New Issue
Block a user