Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+880
View File
@@ -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
+659
View File
@@ -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"]
+957
View File
@@ -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"
+591
View File
@@ -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() + ""
+93
View File
@@ -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
+744
View File
@@ -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,
)
+511
View File
@@ -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)]
+182
View File
@@ -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,
)
+20
View File
@@ -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
+38
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
"""Native Runtime V2 exports."""
from .runtime import NativeRuntimeV2
__all__ = ["NativeRuntimeV2"]
+823
View File
@@ -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
+766
View File
@@ -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
+96
View File
@@ -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
+260
View File
@@ -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}")
+194
View File
@@ -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)