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 @@
+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")]