Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,880 @@
|
||||
"""Base class for external agent adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from opc.core.config import ExternalAgentConfig
|
||||
|
||||
from opc.core.models import AgentStatus, Task, TaskResult
|
||||
|
||||
|
||||
_APPROVAL_MARKERS = (
|
||||
"approve",
|
||||
"approval",
|
||||
"allow",
|
||||
"permission",
|
||||
"authorize",
|
||||
"confirm",
|
||||
"[y/n]",
|
||||
"(y/n)",
|
||||
)
|
||||
_SHELL_WRAPPER_FLAGS = {"-c", "-lc", "-command", "/c", "/command"}
|
||||
_POSIX_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "fish"}
|
||||
_POWERSHELL_SHELLS = {"pwsh", "powershell"}
|
||||
_SHELL_TOOL_NAMES = {
|
||||
"bash",
|
||||
"bashtoolcall",
|
||||
"command",
|
||||
"commandexecution",
|
||||
"commandtoolcall",
|
||||
"exec",
|
||||
"execcommand",
|
||||
"execcommandtoolcall",
|
||||
"runcommand",
|
||||
"shell",
|
||||
"shellcommand",
|
||||
"shelltoolcall",
|
||||
"terminal",
|
||||
"terminalcommand",
|
||||
"terminaltoolcall",
|
||||
}
|
||||
ExternalAgentStdinPolicy = Literal[
|
||||
"inherit",
|
||||
"devnull",
|
||||
"pipe_open",
|
||||
"pipe_prompt_then_close",
|
||||
]
|
||||
_FULL_PROMPT_CONTRACT = "description_is_full_prompt"
|
||||
_FILE_EDIT_TOOL_NAMES = {
|
||||
"applypatch",
|
||||
"applypatchtoolcall",
|
||||
"edit",
|
||||
"editfile",
|
||||
"edittoolcall",
|
||||
"multiedit",
|
||||
"multiedittoolcall",
|
||||
"replace",
|
||||
"rewrite",
|
||||
}
|
||||
_FILE_WRITE_TOOL_NAMES = {
|
||||
"createfile",
|
||||
"filewrite",
|
||||
"newfile",
|
||||
"write",
|
||||
"writefile",
|
||||
"writetoolcall",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalApprovalRequest:
|
||||
"""Normalized approval request emitted by an external agent CLI."""
|
||||
|
||||
approval_scope: str
|
||||
action_name: str
|
||||
prompt_text: str = ""
|
||||
arguments: dict[str, Any] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
raw_text: str = ""
|
||||
|
||||
|
||||
class ExternalAgentAdapter(abc.ABC):
|
||||
"""Unified abstract interface for all external agents (Cursor, Claude Code, Codex, etc.)."""
|
||||
|
||||
agent_type: str = ""
|
||||
default_command: str = ""
|
||||
|
||||
def __init__(self, config: ExternalAgentConfig | None = None) -> None:
|
||||
self.config = config or ExternalAgentConfig(command=self.default_command)
|
||||
|
||||
def configured_command(self) -> str:
|
||||
return self.config.command or self.default_command
|
||||
|
||||
def resolve_binary(self) -> str | None:
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
return shutil.which(self.configured_command())
|
||||
|
||||
def is_new_session(self) -> bool:
|
||||
return self.config.session_mode == "new"
|
||||
|
||||
def build_common_args(self) -> list[str]:
|
||||
args: list[str] = []
|
||||
if self.config.model and self.config.model_flag:
|
||||
args.extend([self.config.model_flag, self.config.model])
|
||||
|
||||
if self.config.session_mode == "new" and self.config.new_session_flag:
|
||||
args.append(self.config.new_session_flag)
|
||||
elif self.config.session_mode == "resume" and self.config.resume_session_flag:
|
||||
args.append(self.config.resume_session_flag)
|
||||
if self.config.session_id:
|
||||
args.append(self.config.session_id)
|
||||
|
||||
args.extend(self.config.extra_args)
|
||||
return args
|
||||
|
||||
def build_invocation_metadata(self, cmd: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"agent": self.agent_type,
|
||||
"command": shlex.join(cmd),
|
||||
"display_command": self._display_command(cmd),
|
||||
"binary": self.configured_command(),
|
||||
"model": self.config.model or "(cli default)",
|
||||
"model_flag": self.config.model_flag or "",
|
||||
"session_mode": self.config.session_mode,
|
||||
"session_id": self.config.session_id or "",
|
||||
"new_session": self.is_new_session(),
|
||||
"run_mode": self.config.run_mode,
|
||||
"approval_mode": self.config.approval_mode,
|
||||
"idle_timeout_seconds": self.config.idle_timeout_seconds,
|
||||
"status_heartbeat_seconds": self.config.status_heartbeat_seconds,
|
||||
"extra_args": list(self.config.extra_args),
|
||||
}
|
||||
|
||||
def describe(self) -> dict[str, Any]:
|
||||
return {
|
||||
"agent": self.agent_type,
|
||||
"enabled": self.config.enabled,
|
||||
"command": self.configured_command(),
|
||||
"model": self.config.model or "(cli default)",
|
||||
"model_flag": self.config.model_flag or "",
|
||||
"session_mode": self.config.session_mode,
|
||||
"session_id": self.config.session_id or "",
|
||||
"run_mode": self.config.run_mode,
|
||||
"approval_mode": self.config.approval_mode,
|
||||
"idle_timeout_seconds": self.config.idle_timeout_seconds,
|
||||
"status_heartbeat_seconds": self.config.status_heartbeat_seconds,
|
||||
"new_session_flag": self.config.new_session_flag or "",
|
||||
"resume_session_flag": self.config.resume_session_flag or "",
|
||||
"extra_args": list(self.config.extra_args),
|
||||
}
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return bool(str(self.config.resume_session_flag or "").strip())
|
||||
|
||||
def can_resume_without_session_id(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_live_inbox_delivery(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_resume_inbox_delivery(self) -> bool:
|
||||
return self.supports_session_resume() or self.supports_interactive()
|
||||
|
||||
def supports_approval_prompt_handling(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Whether OpenOPC should bridge this process' live approval prompts.
|
||||
|
||||
The broker can only answer a provider approval prompt when the child
|
||||
process has a live stdin transport that accepts the formatted response.
|
||||
Adapters with a different permission model can override this to avoid
|
||||
surfacing stale UI approval cards.
|
||||
"""
|
||||
return self.stdin_policy_for_process(cmd, metadata) == "pipe_open"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Skill-based collaboration surface.
|
||||
#
|
||||
# OpenOPC-spawned agents can use a dedicated home dir
|
||||
# (``<opc_home>/agent_homes/<slug>/``) so OpenOPC can install the
|
||||
# ``opc-collab`` skill + CLI shim there. Adapters may still choose to keep
|
||||
# native user config for authentication-sensitive CLIs.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def agent_isolation_home_slug(self) -> str | None:
|
||||
"""Short identifier for this agent's isolated home dir, or ``None``
|
||||
when this adapter cannot host the opc-collab CLI surface.
|
||||
|
||||
Returning e.g. ``"codex"`` tells the broker to (a) provision
|
||||
``<opc_home>/agent_homes/codex/``, (b) install the ``opc-collab``
|
||||
skill there, (c) merge :meth:`agent_home_env_vars` into the launch
|
||||
env.
|
||||
"""
|
||||
return None
|
||||
|
||||
def agent_home_env_vars(self, home: str) -> dict[str, str]:
|
||||
"""Env vars that point this agent at its isolated home dir.
|
||||
|
||||
Paired with :meth:`agent_isolation_home_slug`. For codex this is
|
||||
``{"CODEX_HOME": home}``; for opencode ``{"OPENCODE_CONFIG_DIR": home}``.
|
||||
Claude Code intentionally returns no config-dir override so it can use
|
||||
the user's existing authenticated login.
|
||||
"""
|
||||
_ = home
|
||||
return {}
|
||||
|
||||
def post_install_agent_home(self, home: str) -> None:
|
||||
"""Agent-specific finishing touches after the skill installer has
|
||||
provisioned ``home``. Default: no-op. Codex overrides to symlink
|
||||
the user's ``~/.codex/auth.json`` so the spawned process can log
|
||||
in without re-authenticating.
|
||||
"""
|
||||
_ = home
|
||||
return None
|
||||
|
||||
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
|
||||
_ = workspace_path
|
||||
return []
|
||||
|
||||
def build_task_prompt(self, task: Task) -> str:
|
||||
title = str(getattr(task, "title", "") or "").strip()
|
||||
description = str(getattr(task, "description", "") or "").strip()
|
||||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
if str(metadata.get("external_prompt_contract") or "").strip() == _FULL_PROMPT_CONTRACT:
|
||||
return description
|
||||
if not title:
|
||||
return description
|
||||
if not description:
|
||||
return title
|
||||
if title == description:
|
||||
return description
|
||||
if description.startswith(title):
|
||||
return description
|
||||
if self._description_starts_with_task_brief(description, title):
|
||||
return description
|
||||
return f"{title}\n\n{description}"
|
||||
|
||||
@staticmethod
|
||||
def _description_starts_with_task_brief(description: str, title: str) -> bool:
|
||||
if not description or not title or "Task Brief" not in description:
|
||||
return False
|
||||
pattern = rf"(?ms)^##+\s+Task Brief\s*\n\s*{re.escape(title)}(?:\s*$|\s*\n)"
|
||||
return re.search(pattern, description.strip()) is not None
|
||||
|
||||
@staticmethod
|
||||
def _display_command(cmd: list[str]) -> str:
|
||||
display_cmd = [str(part) for part in cmd]
|
||||
if display_cmd:
|
||||
last = display_cmd[-1]
|
||||
if "\n" in last or len(last) > 160:
|
||||
display_cmd[-1] = f"<prompt:{len(last)}-chars>"
|
||||
return shlex.join(display_cmd)
|
||||
|
||||
def extract_resume_session_id(self, output: str) -> str:
|
||||
candidates: list[str] = []
|
||||
for candidate in self._iter_json_object_candidates(output):
|
||||
candidates.extend(self._collect_session_id_candidates(candidate))
|
||||
return candidates[-1] if candidates else ""
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
request = self._parse_generic_json_approval_request(text, stream_name)
|
||||
if request:
|
||||
return request
|
||||
return self._parse_text_approval_request(text, stream_name)
|
||||
|
||||
def format_approval_response(
|
||||
self,
|
||||
request: ExternalApprovalRequest,
|
||||
approved: bool,
|
||||
decision: Any,
|
||||
) -> str:
|
||||
_ = request
|
||||
_ = decision
|
||||
return "y\n" if approved else "n\n"
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
return self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
"""Convert raw stdout into the user-facing task result text."""
|
||||
return output
|
||||
|
||||
def extract_structured_result_fields(self, output: str) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for candidate in self._iter_json_object_candidates(output):
|
||||
for key in (
|
||||
"work_item_runtime_plan",
|
||||
"runtime_plan",
|
||||
"work_item_artifact_index",
|
||||
"artifact_index",
|
||||
"verification_evidence",
|
||||
"verification",
|
||||
"structured_review_verdict",
|
||||
):
|
||||
if key in candidate and key not in payload:
|
||||
payload[key] = candidate[key]
|
||||
# Fix 4: a flat JSON envelope of the canonical shape
|
||||
# {"review_verdict":"reject","summary":"...","blocking_issues":[...],"followups":[...]}
|
||||
# used to degenerate into ``payload["review_verdict"] = "reject"``
|
||||
# (the inner string) because the old extractor pulled
|
||||
# ``candidate[key]`` instead of ``candidate`` when the key was
|
||||
# already present in the top-level dict. That silently dropped
|
||||
# the reviewer's actual blocking_issues/followups. Now we
|
||||
# preserve the whole candidate whenever review_verdict carries
|
||||
# a verdict label (string OR dict) AND there are sibling fields
|
||||
# that make the candidate a structured envelope.
|
||||
if "review_verdict" in candidate and "review_verdict" not in payload:
|
||||
inner = candidate["review_verdict"]
|
||||
has_sibling_fields = any(
|
||||
key in candidate
|
||||
for key in ("summary", "blocking_issues", "followups")
|
||||
)
|
||||
if isinstance(inner, dict):
|
||||
payload["review_verdict"] = inner
|
||||
elif has_sibling_fields:
|
||||
# Preserve the full envelope so downstream gets
|
||||
# ``{review_verdict, summary, blocking_issues, followups}``.
|
||||
payload["review_verdict"] = candidate
|
||||
else:
|
||||
payload["review_verdict"] = inner
|
||||
if "review_verdict" not in payload and any(
|
||||
key in candidate for key in ("verdict", "decision", "status")
|
||||
):
|
||||
payload["review_verdict"] = candidate
|
||||
return payload
|
||||
|
||||
def infer_review_verdict(self, output: str) -> dict[str, Any]:
|
||||
"""Extract an ``approve``/``reject`` verdict from a reviewer's output.
|
||||
|
||||
The runtime treats the reviewer agent as the authoritative judge
|
||||
and does NOT second-guess verdict shape or content. This helper
|
||||
is purely a JSON parser: when the reviewer emits a structured
|
||||
verdict (per the prompt's suggested schema), we extract its
|
||||
label and pass-through fields. When no parseable verdict is
|
||||
present, we return ``{}`` and let the runtime spawn a verdict-
|
||||
parse-retry attempt.
|
||||
|
||||
Returns a dict with at least ``label`` ∈ {"approve", "reject"}
|
||||
on success, or ``{}`` if no parseable verdict was found.
|
||||
"""
|
||||
structured = self.extract_structured_result_fields(output)
|
||||
explicit = structured.get("review_verdict") or structured.get("structured_review_verdict")
|
||||
if isinstance(explicit, str):
|
||||
normalized = explicit.strip().lower()
|
||||
if normalized in {"approve", "approved", "pass", "passed", "accept", "accepted"}:
|
||||
return {"label": "approve", "summary": explicit.strip()}
|
||||
if normalized in {"reject", "rejected", "fail", "failed", "rework"}:
|
||||
return {"label": "reject", "summary": explicit.strip()}
|
||||
return {}
|
||||
if isinstance(explicit, dict):
|
||||
raw = str(
|
||||
explicit.get("review_verdict")
|
||||
or explicit.get("verdict")
|
||||
or explicit.get("decision")
|
||||
or explicit.get("status")
|
||||
or explicit.get("label")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if raw in {"approved", "pass", "passed", "accept", "accepted"}:
|
||||
raw = "approve"
|
||||
elif raw in {"rejected", "fail", "failed", "rework"}:
|
||||
raw = "reject"
|
||||
if raw in {"approve", "reject"}:
|
||||
blocking = explicit.get("blocking_issues", [])
|
||||
followups = explicit.get("followups", [])
|
||||
return {
|
||||
"label": raw,
|
||||
"summary": str(explicit.get("summary", "") or "").strip(),
|
||||
"blocking_issues": [
|
||||
str(item).strip()
|
||||
for item in (blocking if isinstance(blocking, list) else [])
|
||||
if str(item).strip()
|
||||
][:8],
|
||||
"followups": [
|
||||
str(item).strip()
|
||||
for item in (followups if isinstance(followups, list) else [])
|
||||
if str(item).strip()
|
||||
][:8],
|
||||
}
|
||||
return {}
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
"""Convert a raw stream line into a user-facing progress update."""
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
return f"[External:{self.agent_type}:{stream_name}] {stripped[:500]}"
|
||||
|
||||
def detect_runtime_failure(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Return a fatal runtime failure reason if a stream line is unrecoverable."""
|
||||
_ = stream_name
|
||||
_ = metadata
|
||||
_ = text
|
||||
return None
|
||||
|
||||
async def start_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
workspace_path: str,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
task: Task | None = None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
_ = task
|
||||
_ = launch_metadata
|
||||
env = self.build_process_env(extra_env)
|
||||
launch_cmd = self._resolve_launch_command(
|
||||
cmd,
|
||||
extra_env=extra_env,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
stdin_policy = self.stdin_policy_for_process(launch_cmd, launch_metadata)
|
||||
stdin_target = self._stdin_target_for_policy(stdin_policy)
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*launch_cmd,
|
||||
stdin=stdin_target,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
env=env,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
|
||||
def keep_process_stdin_open(self, cmd: list[str]) -> bool:
|
||||
return self.stdin_policy_for_process(cmd) == "pipe_open"
|
||||
|
||||
def stdin_policy_for_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ExternalAgentStdinPolicy:
|
||||
_ = cmd
|
||||
_ = metadata
|
||||
return "devnull"
|
||||
|
||||
@staticmethod
|
||||
def _stdin_target_for_policy(policy: ExternalAgentStdinPolicy) -> Any:
|
||||
if policy == "inherit":
|
||||
return None
|
||||
if policy in {"pipe_open", "pipe_prompt_then_close"}:
|
||||
return asyncio.subprocess.PIPE
|
||||
return asyncio.subprocess.DEVNULL
|
||||
|
||||
@staticmethod
|
||||
def _record_stdin_policy_metadata(
|
||||
metadata: dict[str, Any],
|
||||
policy: ExternalAgentStdinPolicy,
|
||||
) -> None:
|
||||
metadata["stdin_policy"] = policy
|
||||
if "interactive_input_channel" in metadata:
|
||||
return
|
||||
if policy == "inherit":
|
||||
metadata["interactive_input_channel"] = "inherit"
|
||||
elif policy in {"pipe_open", "pipe_prompt_then_close"}:
|
||||
metadata["interactive_input_channel"] = "pipe"
|
||||
else:
|
||||
metadata["interactive_input_channel"] = "devnull"
|
||||
|
||||
def build_process_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str] | None:
|
||||
if not extra_env:
|
||||
return None
|
||||
return {**os.environ, **{str(k): str(v) for k, v in extra_env.items()}}
|
||||
|
||||
@staticmethod
|
||||
def _subprocess_group_kwargs() -> dict[str, Any]:
|
||||
if os.name == "posix":
|
||||
return {"start_new_session": True}
|
||||
if os.name == "nt":
|
||||
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_launch_command(
|
||||
cmd: list[str],
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> list[str]:
|
||||
if not cmd:
|
||||
return cmd
|
||||
executable = str(cmd[0] or "").strip()
|
||||
if not executable:
|
||||
return cmd
|
||||
path_value = ""
|
||||
if extra_env:
|
||||
merged = {**os.environ, **{str(k): str(v) for k, v in extra_env.items()}}
|
||||
path_value = merged.get("PATH") or merged.get("Path") or merged.get("path") or ""
|
||||
resolved = shutil.which(executable, path=path_value or None)
|
||||
if not resolved or resolved == executable:
|
||||
return cmd
|
||||
resolved_cmd = list(cmd)
|
||||
resolved_cmd[0] = resolved
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata.setdefault("configured_binary", executable)
|
||||
launch_metadata["resolved_binary"] = resolved
|
||||
return resolved_cmd
|
||||
|
||||
async def send_process_input(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
text: str,
|
||||
) -> bool:
|
||||
if not text:
|
||||
return True
|
||||
writer = getattr(proc, "stdin", None)
|
||||
if writer is None:
|
||||
return False
|
||||
if hasattr(writer, "is_closing") and writer.is_closing():
|
||||
return False
|
||||
try:
|
||||
writer.write(text.encode("utf-8"))
|
||||
await writer.drain()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def cleanup_process(self, proc: asyncio.subprocess.Process) -> None:
|
||||
writer = getattr(proc, "stdin", None)
|
||||
if writer is None:
|
||||
return
|
||||
if hasattr(writer, "is_closing") and writer.is_closing():
|
||||
return
|
||||
writer.close()
|
||||
wait_closed = getattr(writer, "wait_closed", None)
|
||||
if callable(wait_closed):
|
||||
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
||||
await wait_closed()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def is_available(self) -> bool:
|
||||
"""Check whether this agent is installed and configured."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
"""Execute task in the specified workspace."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
"""Build the CLI command and audit metadata for a task."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_status(self) -> AgentStatus:
|
||||
...
|
||||
|
||||
async def get_fallback(self) -> ExternalAgentAdapter | None:
|
||||
"""When this agent is unavailable, return the next preferred agent."""
|
||||
return None
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
"""Interrupt an in-progress task."""
|
||||
return False
|
||||
|
||||
def _parse_generic_json_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
event = self._parse_json_line(text)
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
|
||||
event_type = self._normalize_name(
|
||||
event.get("type") or event.get("event") or event.get("kind")
|
||||
)
|
||||
subtype = self._normalize_name(event.get("subtype") or event.get("status") or event.get("state"))
|
||||
explicit_event = any(
|
||||
marker in event_type or marker in subtype
|
||||
for marker in ("approval", "permission", "authorize", "confirm")
|
||||
)
|
||||
tool_name, tool_args = self._extract_structured_tool_call(event)
|
||||
prompt_text = self._extract_prompt_text(event) or text.strip()
|
||||
approval_metadata_present = any(
|
||||
key in event for key in ("approval", "approval_id", "available_decisions", "permission", "permission_id")
|
||||
)
|
||||
if not explicit_event and not (
|
||||
self._looks_like_textual_approval_prompt(prompt_text)
|
||||
and (bool(tool_name) or bool(tool_args) or approval_metadata_present)
|
||||
):
|
||||
return None
|
||||
|
||||
mapped = self._map_external_tool_request(tool_name, tool_args, prompt_text)
|
||||
metadata = {
|
||||
"stream": stream_name,
|
||||
"provider_event_type": str(event.get("type") or ""),
|
||||
"provider_event_subtype": str(event.get("subtype") or ""),
|
||||
"provider_tool_name": tool_name,
|
||||
"provider_tool_args": tool_args,
|
||||
"raw_event": event,
|
||||
}
|
||||
if mapped:
|
||||
action_name, arguments = mapped
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name=action_name,
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="external_agent",
|
||||
action_name=f"{self.agent_type}:prompt",
|
||||
prompt_text=prompt_text,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
def _parse_text_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
if not self._looks_like_textual_approval_prompt(stripped):
|
||||
return None
|
||||
|
||||
command = self._extract_command_from_text(stripped)
|
||||
metadata = {"stream": stream_name}
|
||||
if command:
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="shell_exec",
|
||||
prompt_text=stripped,
|
||||
arguments={"command": command},
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="external_agent",
|
||||
action_name=f"{self.agent_type}:prompt",
|
||||
prompt_text=stripped,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
def _looks_like_textual_approval_prompt(self, text: str) -> bool:
|
||||
stripped = str(text or "").strip()
|
||||
if not stripped:
|
||||
return False
|
||||
lowered = stripped.lower()
|
||||
if "[y/n]" in lowered or "(y/n)" in lowered:
|
||||
return True
|
||||
if re.match(r"^(approve|allow|authorize|confirm)\b", lowered):
|
||||
return True
|
||||
if re.search(r"\b(approve|allow|authorize|confirm)\b", lowered) and "?" in stripped:
|
||||
return True
|
||||
if "permission request" in lowered:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_structured_tool_call(self, event: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
tool_name = event.get("tool_name") or event.get("toolName") or event.get("name")
|
||||
tool_args = event.get("input") or event.get("arguments") or event.get("params") or event.get("tool_input")
|
||||
if tool_name:
|
||||
return str(tool_name), tool_args if isinstance(tool_args, dict) else {}
|
||||
|
||||
tool_call = event.get("tool_call")
|
||||
if isinstance(tool_call, dict):
|
||||
nested_name = tool_call.get("tool_name") or tool_call.get("toolName") or tool_call.get("name")
|
||||
nested_args = tool_call.get("input") or tool_call.get("arguments") or tool_call.get("params")
|
||||
if nested_name:
|
||||
return str(nested_name), nested_args if isinstance(nested_args, dict) else {}
|
||||
if len(tool_call) == 1:
|
||||
name, payload = next(iter(tool_call.items()))
|
||||
if isinstance(payload, dict):
|
||||
args = payload.get("args") if isinstance(payload.get("args"), dict) else payload
|
||||
return str(name), args if isinstance(args, dict) else {}
|
||||
|
||||
return "", {}
|
||||
|
||||
def _map_external_tool_request(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: dict[str, Any],
|
||||
prompt_text: str,
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
normalized_name = self._normalize_name(tool_name)
|
||||
command_value = (
|
||||
tool_args.get("command")
|
||||
or tool_args.get("cmd")
|
||||
or tool_args.get("argv")
|
||||
or tool_args.get("script")
|
||||
)
|
||||
if normalized_name in _SHELL_TOOL_NAMES or command_value:
|
||||
command = self.normalize_shell_command(command_value)
|
||||
if command:
|
||||
arguments: dict[str, Any] = {"command": command}
|
||||
working_directory = (
|
||||
tool_args.get("cwd")
|
||||
or tool_args.get("working_directory")
|
||||
or tool_args.get("workdir")
|
||||
or tool_args.get("directory")
|
||||
)
|
||||
if working_directory:
|
||||
arguments["working_directory"] = str(working_directory)
|
||||
return "shell_exec", arguments
|
||||
|
||||
path = tool_args.get("path") or tool_args.get("file_path") or tool_args.get("target") or tool_args.get("filepath")
|
||||
if normalized_name in _FILE_WRITE_TOOL_NAMES:
|
||||
arguments = {"path": str(path)} if path else {}
|
||||
return "file_write", arguments
|
||||
if normalized_name in _FILE_EDIT_TOOL_NAMES:
|
||||
arguments = {"path": str(path)} if path else {}
|
||||
return "file_edit", arguments
|
||||
|
||||
prompt_command = self._extract_command_from_text(prompt_text)
|
||||
if prompt_command:
|
||||
arguments = {"command": prompt_command}
|
||||
if path:
|
||||
arguments["target"] = str(path)
|
||||
return "shell_exec", arguments
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_line(text: str) -> dict[str, Any] | None:
|
||||
stripped = text.strip()
|
||||
if not stripped or not stripped.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_name(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower())
|
||||
|
||||
@classmethod
|
||||
def normalize_shell_command(cls, command: Any) -> str:
|
||||
if isinstance(command, (list, tuple)):
|
||||
tokens = [str(item).strip() for item in command if str(item).strip()]
|
||||
raw_text = shlex.join(tokens) if tokens else ""
|
||||
else:
|
||||
raw_text = str(command or "").strip()
|
||||
if not raw_text:
|
||||
return ""
|
||||
try:
|
||||
tokens = shlex.split(raw_text)
|
||||
except ValueError:
|
||||
tokens = raw_text.split()
|
||||
|
||||
if not tokens:
|
||||
return ""
|
||||
|
||||
head = tokens[0].lower()
|
||||
head_name = head.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
if head_name in _POSIX_SHELLS | _POWERSHELL_SHELLS:
|
||||
for index, token in enumerate(tokens[1:], start=1):
|
||||
lowered = token.lower()
|
||||
if lowered not in _SHELL_WRAPPER_FLAGS:
|
||||
continue
|
||||
if index + 1 >= len(tokens):
|
||||
break
|
||||
inner = str(tokens[index + 1]).strip()
|
||||
if not inner:
|
||||
break
|
||||
return inner
|
||||
return raw_text if raw_text else shlex.join(tokens)
|
||||
|
||||
def _extract_prompt_text(self, value: Any) -> str:
|
||||
fragments = self._collect_text_fragments(value)
|
||||
if not fragments:
|
||||
return ""
|
||||
return " | ".join(fragments[:6])[:1000]
|
||||
|
||||
def _collect_text_fragments(self, value: Any) -> list[str]:
|
||||
if isinstance(value, dict):
|
||||
fragments: list[str] = []
|
||||
for key in ("message", "prompt", "reason", "summary", "description", "text", "title"):
|
||||
item = value.get(key)
|
||||
if item:
|
||||
fragments.extend(self._collect_text_fragments(item))
|
||||
if not fragments:
|
||||
for item in value.values():
|
||||
fragments.extend(self._collect_text_fragments(item))
|
||||
return fragments
|
||||
if isinstance(value, list):
|
||||
fragments: list[str] = []
|
||||
for item in value:
|
||||
fragments.extend(self._collect_text_fragments(item))
|
||||
return fragments
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
return [text] if text else []
|
||||
return []
|
||||
|
||||
def _collect_session_id_candidates(self, value: Any) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
normalized = self._normalize_name(key)
|
||||
if normalized in {"sessionid", "conversationid", "threadid", "chatid"}:
|
||||
token = str(item or "").strip()
|
||||
if token:
|
||||
candidates.append(token)
|
||||
candidates.extend(self._collect_session_id_candidates(item))
|
||||
return candidates
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
candidates.extend(self._collect_session_id_candidates(item))
|
||||
return candidates
|
||||
|
||||
def _extract_command_from_text(self, text: str) -> str:
|
||||
backtick_match = re.search(r"`([^`]+)`", text)
|
||||
if backtick_match:
|
||||
return self.normalize_shell_command(backtick_match.group(1))
|
||||
|
||||
quoted_match = re.search(r'"([^"\n]+)"', text)
|
||||
if quoted_match and any(keyword in text.lower() for keyword in ("command", "bash", "shell", "terminal")):
|
||||
return self.normalize_shell_command(quoted_match.group(1))
|
||||
|
||||
command_match = re.search(
|
||||
r"(?:run|execute|command)\s*:\s*(.+?)(?:\?|$)",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if command_match:
|
||||
return self.normalize_shell_command(command_match.group(1).strip())
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _iter_json_object_candidates(text: str) -> list[dict[str, Any]]:
|
||||
stripped = str(text or "").strip()
|
||||
if not stripped:
|
||||
return []
|
||||
decoder = json.JSONDecoder()
|
||||
candidates: list[dict[str, Any]] = []
|
||||
start = stripped.find("{")
|
||||
while start != -1:
|
||||
try:
|
||||
value, consumed = decoder.raw_decode(stripped[start:])
|
||||
except json.JSONDecodeError:
|
||||
start = stripped.find("{", start + 1)
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
candidates.append(value)
|
||||
start = stripped.find("{", start + max(consumed, 1))
|
||||
return candidates
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Claude Code CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import AgentStatus, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.adapters.base import (
|
||||
ExternalAgentAdapter,
|
||||
ExternalAgentStdinPolicy,
|
||||
ExternalApprovalRequest,
|
||||
)
|
||||
from opc.layer3_agent.skill_installer import install_opc_collab_skill
|
||||
|
||||
|
||||
class ClaudeCodeAdapter(ExternalAgentAdapter):
|
||||
"""Invokes the Claude Code CLI (claude command) for task execution."""
|
||||
|
||||
agent_type = "claude_code"
|
||||
default_command = "claude"
|
||||
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
|
||||
# Lazily populated by ``_user_shell_proxy_env``. Cached at class level
|
||||
# so the (slow) ``zsh -i -c`` probe runs at most once per process.
|
||||
# ``None`` means "not probed yet"; ``{}`` means "probed, no proxy found".
|
||||
_user_shell_proxy_env_cache: dict[str, str] | None = None
|
||||
|
||||
def __init__(self, config=None) -> None:
|
||||
super().__init__(config=config)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
|
||||
async def start_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
workspace_path: str,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
task: Task | None = None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
|
||||
# Small prompts stay on argv. Keep stdin open only when Claude's
|
||||
# permission mode can emit live prompts; otherwise DEVNULL avoids
|
||||
# provider-side stdin probes on Windows wrappers.
|
||||
env = self.build_process_env(extra_env)
|
||||
env = self._merge_user_shell_proxy(env)
|
||||
launch_cmd = self._resolve_launch_command(
|
||||
cmd,
|
||||
extra_env=extra_env,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
stdin_policy = self.stdin_policy_for_process(launch_cmd, launch_metadata)
|
||||
stdin_target = self._stdin_target_for_policy(stdin_policy)
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*launch_cmd,
|
||||
stdin=stdin_target,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
env=env,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
if prompt_transport == "stdin":
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata["interactive_input_channel"] = "pipe"
|
||||
delivered = await self._seed_pipe_prompt(proc, prompt)
|
||||
if not delivered:
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata["prompt_delivery_failed"] = True
|
||||
logger.warning(
|
||||
"Large stdin prompt delivery to {} (pid={}) may be incomplete",
|
||||
self.agent_type,
|
||||
proc.pid,
|
||||
)
|
||||
return proc
|
||||
|
||||
@classmethod
|
||||
def _merge_user_shell_proxy(
|
||||
cls, env: dict[str, str] | None
|
||||
) -> dict[str, str] | None:
|
||||
"""Layer extracted user-shell proxy vars onto the spawn env as a
|
||||
fallback (only fills in vars that are not already set).
|
||||
|
||||
Returns ``None`` unchanged if there were no extracted vars and no
|
||||
``env`` was supplied — that preserves the "inherit os.environ"
|
||||
behavior expected by :func:`asyncio.create_subprocess_exec`.
|
||||
"""
|
||||
proxy = cls._user_shell_proxy_env()
|
||||
if not proxy:
|
||||
return env
|
||||
if env is None:
|
||||
import os
|
||||
env = {**os.environ}
|
||||
for key, value in proxy.items():
|
||||
env.setdefault(key, value)
|
||||
return env
|
||||
|
||||
@classmethod
|
||||
def _user_shell_proxy_env(cls) -> dict[str, str]:
|
||||
"""Probe the user's login shell for proxy env vars defined inside a
|
||||
``claude`` shell function.
|
||||
|
||||
Developers behind a national firewall commonly wrap ``claude`` in
|
||||
a shell function that injects ``HTTPS_PROXY`` per invocation::
|
||||
|
||||
claude() {
|
||||
HTTPS_PROXY=http://... command claude "$@"
|
||||
}
|
||||
|
||||
Those vars are only set when ``claude`` is invoked *through the
|
||||
shell*. OpenOPC spawns claude via
|
||||
:func:`asyncio.create_subprocess_exec`, which bypasses the shell
|
||||
and the function — so the proxy never reaches claude and the API
|
||||
call lands on a network gateway that returns ``403 Request not
|
||||
allowed``.
|
||||
|
||||
Run ``$SHELL -i -c 'declare -f claude'`` once at first call and
|
||||
parse any ``*_PROXY=`` assignments out of the function body. The
|
||||
result is cached at class level. Failures (no shell function,
|
||||
unusual shell, timeout, non-zero exit) silently degrade to an
|
||||
empty dict so the spawn path is unchanged.
|
||||
"""
|
||||
if cls._user_shell_proxy_env_cache is not None:
|
||||
return cls._user_shell_proxy_env_cache
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
shell = os.environ.get("SHELL", "").strip() or "/bin/zsh"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[shell, "-i", "-c", "declare -f claude 2>/dev/null || true"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.debug(
|
||||
"Skipping user-shell proxy probe ({}): {}", shell, exc
|
||||
)
|
||||
cls._user_shell_proxy_env_cache = {}
|
||||
return cls._user_shell_proxy_env_cache
|
||||
|
||||
text = result.stdout or ""
|
||||
extracted: dict[str, str] = {}
|
||||
# Match `VAR="value"` or `VAR='value'` for the standard proxy
|
||||
# env names. Unquoted assignments are intentionally skipped —
|
||||
# they may contain shell variable expansions we cannot resolve
|
||||
# without actually executing the function.
|
||||
pattern = re.compile(
|
||||
r"\b("
|
||||
r"HTTPS?_PROXY|https?_proxy|"
|
||||
r"NO_PROXY|no_proxy|"
|
||||
r"ALL_PROXY|all_proxy"
|
||||
r")="
|
||||
r"([\"'])([^\"']*)\2"
|
||||
)
|
||||
for var, _, val in pattern.findall(text):
|
||||
extracted[var] = val
|
||||
if extracted:
|
||||
logger.info(
|
||||
"Extracted {} proxy var(s) from user `claude` shell function: {}",
|
||||
len(extracted),
|
||||
sorted(extracted.keys()),
|
||||
)
|
||||
cls._user_shell_proxy_env_cache = extracted
|
||||
return cls._user_shell_proxy_env_cache
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return self.resolve_binary() is not None
|
||||
|
||||
async def get_status(self) -> AgentStatus:
|
||||
if self._process and self._process.returncode is None:
|
||||
return AgentStatus.RUNNING
|
||||
return AgentStatus.IDLE
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return True
|
||||
|
||||
def agent_isolation_home_slug(self) -> str:
|
||||
# Keep a broker-owned home so the shared ``opc-collab`` CLI shim is
|
||||
# installed and PATH is wired consistently. Claude Code authentication
|
||||
# intentionally remains global; setting CLAUDE_CONFIG_DIR makes Claude
|
||||
# ignore the user's normal login/keychain state and can leave company
|
||||
# mode stuck on stale isolated OAuth credentials.
|
||||
return "claude"
|
||||
|
||||
def agent_home_env_vars(self, home: str) -> dict[str, str]:
|
||||
_ = home
|
||||
return {}
|
||||
|
||||
def post_install_agent_home(self, home: str) -> None:
|
||||
# Claude Code discovers skills from the active user config directory.
|
||||
# Because we do not set CLAUDE_CONFIG_DIR, install the OpenOPC skill
|
||||
# into the user's normal Claude home and let the CLI use its existing
|
||||
# authenticated state.
|
||||
Path(home).mkdir(parents=True, exist_ok=True)
|
||||
user_home = Path.home() / ".claude"
|
||||
try:
|
||||
install_opc_collab_skill(user_home)
|
||||
except OSError as exc:
|
||||
logger.warning("Unable to install opc-collab into Claude user home: {}", exc)
|
||||
|
||||
def can_resume_without_session_id(self) -> bool:
|
||||
return True
|
||||
|
||||
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
|
||||
if not workspace_path:
|
||||
return []
|
||||
return ["--add-dir", workspace_path]
|
||||
|
||||
def _build_extra_dir_args(self, task: Task | None) -> list[str]:
|
||||
# Surface extra roots that Claude may need to edit outside the
|
||||
# primary workspace: collaboration files and durable memory.
|
||||
if task is None:
|
||||
return []
|
||||
extra: list[str] = []
|
||||
seen: set[str] = set()
|
||||
workspace = str((task.metadata or {}).get("target_output_dir") or "").strip()
|
||||
|
||||
def _add(path: str) -> None:
|
||||
normalized = str(path or "").strip()
|
||||
if normalized and normalized != workspace and normalized not in seen:
|
||||
seen.add(normalized)
|
||||
extra.extend(["--add-dir", normalized])
|
||||
|
||||
comms_root = str((task.metadata or {}).get("comms_workspace_root") or "").strip()
|
||||
_add(comms_root)
|
||||
try:
|
||||
from opc.core.config import get_opc_home
|
||||
|
||||
_add(str(get_opc_home() / "memory"))
|
||||
except Exception:
|
||||
pass
|
||||
return extra
|
||||
|
||||
def _build_argv_prompt_metadata(self, prompt: str) -> dict[str, object]:
|
||||
return {
|
||||
"prompt_transport": "argv",
|
||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
||||
}
|
||||
|
||||
def _build_stdin_prompt_metadata(self, prompt: str) -> dict[str, object]:
|
||||
return {
|
||||
"prompt_transport": "stdin",
|
||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
||||
"stdin_prompt_channel": "pipe",
|
||||
"interactive_input_limitation": (
|
||||
"large initial prompt is delivered through stdin; live approval replies "
|
||||
"are unavailable after stdin closes"
|
||||
),
|
||||
}
|
||||
|
||||
def _interactive_prompt_transport(self, prompt: str) -> str:
|
||||
prompt_bytes = len(prompt.encode("utf-8"))
|
||||
return "argv" if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES else "stdin"
|
||||
|
||||
@staticmethod
|
||||
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
|
||||
redacted = list(cmd)
|
||||
if redacted:
|
||||
redacted[-1] = f"<prompt:{len(prompt.encode('utf-8'))}-bytes>"
|
||||
return redacted
|
||||
|
||||
def _prompt_text_from_task(self, task: Task | None) -> str:
|
||||
if task is None:
|
||||
return ""
|
||||
return self.build_task_prompt(task)
|
||||
|
||||
@staticmethod
|
||||
async def _seed_pipe_prompt(
|
||||
proc: asyncio.subprocess.Process,
|
||||
prompt: str,
|
||||
) -> bool:
|
||||
writer = getattr(proc, "stdin", None)
|
||||
if writer is None:
|
||||
return False
|
||||
payload = prompt.encode("utf-8")
|
||||
delivered = True
|
||||
try:
|
||||
writer.write(payload)
|
||||
await writer.drain()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as exc:
|
||||
logger.warning(
|
||||
"Pipe prompt delivery failed ({} bytes planned): {}",
|
||||
len(payload),
|
||||
exc,
|
||||
)
|
||||
delivered = False
|
||||
writer.close()
|
||||
wait_closed = getattr(writer, "wait_closed", None)
|
||||
if callable(wait_closed):
|
||||
try:
|
||||
await wait_closed()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
delivered = False
|
||||
return delivered
|
||||
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
prompt = self.build_task_prompt(task)
|
||||
prompt_transport = self._interactive_prompt_transport(prompt)
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"--print",
|
||||
"--output-format", "text",
|
||||
*(["--input-format", "text"] if prompt_transport == "stdin" else []),
|
||||
*self.build_workspace_args(workspace_path),
|
||||
*self._build_extra_dir_args(task),
|
||||
*self._build_session_args(),
|
||||
*self.build_common_args(),
|
||||
]
|
||||
if prompt_transport == "argv":
|
||||
cmd.extend(["--", prompt])
|
||||
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
|
||||
metadata.update(self._build_argv_prompt_metadata(prompt))
|
||||
else:
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata.update(self._build_stdin_prompt_metadata(prompt))
|
||||
return cmd, metadata
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
prompt = self.build_task_prompt(task)
|
||||
prompt_transport = self._interactive_prompt_transport(prompt)
|
||||
# Claude Code 2.x rejects `--print --output-format stream-json` unless
|
||||
# `--verbose` is also passed (`Error: When using --print,
|
||||
# --output-format=stream-json requires --verbose`). Inject it when the
|
||||
# user has not already supplied it via `extra_args`.
|
||||
verbose_args: list[str] = []
|
||||
if not any(arg == "--verbose" for arg in self.config.extra_args):
|
||||
verbose_args = ["--verbose"]
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"--print",
|
||||
"--output-format", "stream-json",
|
||||
*(["--input-format", "text"] if prompt_transport == "stdin" else []),
|
||||
*verbose_args,
|
||||
"--include-partial-messages",
|
||||
*self.build_workspace_args(workspace_path),
|
||||
*self._build_extra_dir_args(task),
|
||||
*self._build_permission_args(),
|
||||
*self._build_session_args(),
|
||||
*self.build_common_args(),
|
||||
]
|
||||
if prompt_transport == "argv":
|
||||
cmd.extend(["--", prompt])
|
||||
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
|
||||
metadata.update(self._build_argv_prompt_metadata(prompt))
|
||||
else:
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata.update(self._build_stdin_prompt_metadata(prompt))
|
||||
return cmd, metadata
|
||||
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
if not await self.is_available():
|
||||
return TaskResult(status=TaskStatus.FAILED, content="Claude Code CLI not found")
|
||||
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
logger.info(f"Claude Code executing: {task.title}")
|
||||
|
||||
try:
|
||||
self._process = await self.start_process(
|
||||
cmd,
|
||||
workspace_path,
|
||||
task=task,
|
||||
launch_metadata=metadata,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
self._process.communicate(), timeout=600
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if self._process.returncode == 0:
|
||||
return TaskResult(
|
||||
status=TaskStatus.DONE,
|
||||
content=output,
|
||||
artifacts={**metadata, "stderr": errors} if errors else metadata,
|
||||
)
|
||||
else:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Claude Code exited with code {self._process.returncode}\n{errors}\n{output}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if self._process:
|
||||
self._process.kill()
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content="Claude Code timed out after 600s",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Claude Code error: {e}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
def supports_approval_prompt_handling(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
return self.stdin_policy_for_process(cmd, metadata) == "pipe_open"
|
||||
|
||||
def stdin_policy_for_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ExternalAgentStdinPolicy:
|
||||
if str((metadata or {}).get("prompt_transport") or "").strip().lower() == "stdin":
|
||||
return "pipe_prompt_then_close"
|
||||
for index, arg in enumerate(cmd):
|
||||
value = str(arg or "").strip()
|
||||
if value == "--dangerously-skip-permissions":
|
||||
return "devnull"
|
||||
if value == "--permission-mode":
|
||||
mode = str(cmd[index + 1] if index + 1 < len(cmd) else "").strip()
|
||||
return "devnull" if mode in {"bypassPermissions", "dontAsk"} else "pipe_open"
|
||||
if value.startswith("--permission-mode="):
|
||||
mode = value.split("=", 1)[1].strip()
|
||||
return "devnull" if mode in {"bypassPermissions", "dontAsk"} else "pipe_open"
|
||||
return "pipe_open"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# stream-json parsing for UI progress / transcript / approval suppression
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def _parse_runtime_event(cls, text: str) -> dict[str, Any] | None:
|
||||
envelope = cls._parse_json_line(text)
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
return envelope
|
||||
|
||||
@staticmethod
|
||||
def _trim_text(text: str, *, limit: int) -> str:
|
||||
stripped = str(text or "").strip()
|
||||
if len(stripped) <= limit:
|
||||
return stripped
|
||||
return stripped[: limit - 1].rstrip() + "…"
|
||||
|
||||
@classmethod
|
||||
def _summarize_tool_use(cls, block: dict[str, Any]) -> str:
|
||||
name = str(block.get("name") or "").strip() or "tool"
|
||||
inp = block.get("input") if isinstance(block.get("input"), dict) else {}
|
||||
if name == "Bash":
|
||||
command = str(inp.get("command") or "").strip()
|
||||
if command:
|
||||
return f"$ {cls._trim_text(command.replace(chr(10), ' '), limit=240)}"
|
||||
if name in {"Write", "Edit", "NotebookEdit"}:
|
||||
path = str(inp.get("file_path") or inp.get("notebook_path") or "").strip()
|
||||
if path:
|
||||
return f"{name} {path}"
|
||||
if name == "Read":
|
||||
path = str(inp.get("file_path") or "").strip()
|
||||
if path:
|
||||
return f"Read {path}"
|
||||
if name in {"Glob", "Grep"}:
|
||||
pat = str(inp.get("pattern") or "").strip()
|
||||
path = str(inp.get("path") or "").strip()
|
||||
tail = f" in {path}" if path else ""
|
||||
return f"{name} {pat}{tail}".strip()
|
||||
if name in {"WebFetch", "WebSearch"}:
|
||||
target = str(inp.get("url") or inp.get("query") or "").strip()
|
||||
if target:
|
||||
return f"{name} {target}"
|
||||
# Fallback: name + compact JSON of inputs
|
||||
try:
|
||||
import json as _json
|
||||
payload = _json.dumps(inp, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
payload = ""
|
||||
if payload and payload != "{}":
|
||||
return f"{name} {cls._trim_text(payload, limit=200)}"
|
||||
return name
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
# stderr lines: only surface real warnings/errors, drop noise.
|
||||
if stream_name != "stdout":
|
||||
stripped = str(text or "").strip()
|
||||
if not stripped:
|
||||
return None
|
||||
# Drop Claude's harmless stdin-probe message; OpenOPC owns initial
|
||||
# prompt transport explicitly via argv or stdin metadata.
|
||||
if "no stdin data received" in stripped.lower():
|
||||
return None
|
||||
return f"[External:{self.agent_type}:stderr] {self._trim_text(stripped, limit=400)}"
|
||||
|
||||
envelope = self._parse_runtime_event(text)
|
||||
if not envelope:
|
||||
return None
|
||||
|
||||
envelope_type = str(envelope.get("type") or "").strip()
|
||||
|
||||
# Suppress all the partial-streaming chatter — we surface the full
|
||||
# message once `assistant` arrives. Without this filter the UI gets
|
||||
# flooded with raw `content_block_delta` JSON.
|
||||
if envelope_type in {
|
||||
"stream_event",
|
||||
"user", # tool_result echoes — too verbose for the UI
|
||||
"rate_limit_event",
|
||||
}:
|
||||
return None
|
||||
|
||||
if envelope_type == "system" and str(envelope.get("subtype") or "") == "init":
|
||||
session_id = str(envelope.get("session_id") or "").strip()
|
||||
model = str(envelope.get("model") or "").strip()
|
||||
bits = [b for b in (model, f"session={session_id[:8]}" if session_id else "") if b]
|
||||
return f"[External:{self.agent_type}:init] {' '.join(bits) or 'started'}"
|
||||
|
||||
if envelope_type == "assistant":
|
||||
message = envelope.get("message") if isinstance(envelope.get("message"), dict) else None
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
content = message.get("content") if isinstance(message.get("content"), list) else []
|
||||
text_parts: list[str] = []
|
||||
tool_lines: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = str(block.get("type") or "").strip()
|
||||
if btype == "text":
|
||||
chunk = str(block.get("text") or "").strip()
|
||||
if chunk:
|
||||
text_parts.append(chunk)
|
||||
elif btype == "tool_use":
|
||||
tool_lines.append(self._summarize_tool_use(block))
|
||||
elif btype == "thinking":
|
||||
chunk = str(block.get("thinking") or "").strip()
|
||||
if chunk:
|
||||
text_parts.append(chunk)
|
||||
if tool_lines:
|
||||
# When the assistant turn ends with one or more tool calls,
|
||||
# surface the tool call(s) — that's the actionable signal.
|
||||
return f"[External:{self.agent_type}:tool] " + "\n".join(tool_lines)
|
||||
if text_parts:
|
||||
joined = "\n\n".join(text_parts)
|
||||
return f"[External:{self.agent_type}:thinking] {self._trim_text(joined, limit=2400)}"
|
||||
return None
|
||||
|
||||
if envelope_type == "result":
|
||||
subtype = str(envelope.get("subtype") or "").strip()
|
||||
result_text = str(envelope.get("result") or "").strip()
|
||||
if subtype and subtype != "success":
|
||||
return f"[External:{self.agent_type}:result] {subtype}: {self._trim_text(result_text, limit=600)}"
|
||||
if result_text:
|
||||
return f"[External:{self.agent_type}:thinking] {self._trim_text(result_text, limit=2400)}"
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
"""Extract the final assistant message from a stream-json transcript.
|
||||
|
||||
Falls back to the raw output if no `result` envelope is found, so
|
||||
downstream gate logic still has *something* to work with.
|
||||
"""
|
||||
last_result_text = ""
|
||||
last_assistant_text = ""
|
||||
for line in output.splitlines():
|
||||
envelope = self._parse_runtime_event(line)
|
||||
if not envelope:
|
||||
continue
|
||||
etype = str(envelope.get("type") or "").strip()
|
||||
if etype == "result":
|
||||
rt = str(envelope.get("result") or "").strip()
|
||||
if rt:
|
||||
last_result_text = rt
|
||||
elif etype == "assistant":
|
||||
message = envelope.get("message") if isinstance(envelope.get("message"), dict) else None
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
parts: list[str] = []
|
||||
for block in message.get("content") or []:
|
||||
if isinstance(block, dict) and str(block.get("type") or "") == "text":
|
||||
chunk = str(block.get("text") or "").strip()
|
||||
if chunk:
|
||||
parts.append(chunk)
|
||||
if parts:
|
||||
last_assistant_text = "\n\n".join(parts)
|
||||
return last_result_text or last_assistant_text or output
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
# Claude Code's stream-json transport should only surface
|
||||
# structured approval events here. We intentionally do *not*
|
||||
# fall back to the base class's free-text prompt parser because
|
||||
# ordinary assistant output that mentions "allow"/"approve"
|
||||
# would otherwise stall the broker waiting for human input.
|
||||
event = self._parse_runtime_event(text)
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
if str(event.get("type") or "").strip() == "permission_denial":
|
||||
return None
|
||||
return self._parse_generic_json_approval_request(text, stream_name)
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_permission_args(self) -> list[str]:
|
||||
common_args = self.build_common_args()
|
||||
if any(
|
||||
arg == "--permission-mode" or arg.startswith("--permission-mode=")
|
||||
for arg in common_args
|
||||
):
|
||||
return []
|
||||
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "user-settings":
|
||||
return []
|
||||
if mode == "full-auto":
|
||||
return ["--permission-mode", "bypassPermissions"]
|
||||
return ["--permission-mode", "auto"]
|
||||
|
||||
def _build_session_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
common_args = self.build_common_args()
|
||||
if any(
|
||||
arg in {"-c", "--continue", "-r", "--resume"} or arg.startswith("--resume=")
|
||||
for arg in [*extra_args, *common_args]
|
||||
):
|
||||
return []
|
||||
|
||||
mode = str(self.config.session_mode or "auto").strip().lower()
|
||||
if mode != "resume":
|
||||
return []
|
||||
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
if session_id:
|
||||
return ["--resume", session_id]
|
||||
return ["--continue"]
|
||||
@@ -0,0 +1,957 @@
|
||||
"""Codex CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import AgentStatus, ApprovalDecision, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.adapters.base import (
|
||||
ExternalAgentAdapter,
|
||||
ExternalAgentStdinPolicy,
|
||||
ExternalApprovalRequest,
|
||||
)
|
||||
|
||||
|
||||
class CodexAdapter(ExternalAgentAdapter):
|
||||
"""Invokes the OpenAI Codex CLI."""
|
||||
|
||||
agent_type = "codex"
|
||||
default_command = "codex"
|
||||
_COMMAND_OUTPUT_LIMIT = 2000
|
||||
_MIRRORED_USER_CONFIG_FILES = ("auth.json", "config.toml")
|
||||
_PARENT_CODEX_RUNTIME_ENV_VARS = {
|
||||
"CODEX_INTERNAL_ORIGINATOR_OVERRIDE",
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED",
|
||||
"CODEX_THREAD_ID",
|
||||
}
|
||||
_PROMPT_SENTINEL = "-"
|
||||
_TTY_EOF = b"\x04"
|
||||
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
|
||||
|
||||
def __init__(self, config=None) -> None:
|
||||
super().__init__(config=config)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._input_fds: dict[int, int] = {}
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return self.resolve_binary() is not None
|
||||
|
||||
async def get_status(self) -> AgentStatus:
|
||||
if self._process and self._process.returncode is None:
|
||||
return AgentStatus.RUNNING
|
||||
return AgentStatus.IDLE
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return True
|
||||
|
||||
def agent_isolation_home_slug(self) -> str:
|
||||
# Point spawned codex at ``<opc_home>/agent_homes/codex/``. The
|
||||
# broker installs the ``opc-collab`` skill under
|
||||
# ``skills/opc-collab/`` there; codex exec's native skill
|
||||
# discovery picks it up. The user's personal ``~/.codex/`` is
|
||||
# not inherited, so codex invoked directly by the user stays
|
||||
# separated from the OpenOPC collaboration surface.
|
||||
return "codex"
|
||||
|
||||
def agent_home_env_vars(self, home: str) -> dict[str, str]:
|
||||
return {"CODEX_HOME": home}
|
||||
|
||||
def post_install_agent_home(self, home: str) -> None:
|
||||
# Mirror the user's key Codex config files into the isolated
|
||||
# CODEX_HOME so the spawned process uses the same login and model
|
||||
# provider settings as the CLI the user runs directly. Prefer
|
||||
# symlinks so rotations are tracked; fall back to copying on
|
||||
# Windows/filesystems where symlink creation is blocked.
|
||||
from pathlib import Path
|
||||
|
||||
user_home = Path.home() / ".codex"
|
||||
target_home = Path(home)
|
||||
target_home.mkdir(parents=True, exist_ok=True)
|
||||
for file_name in self._MIRRORED_USER_CONFIG_FILES:
|
||||
self._mirror_user_config_file(user_home / file_name, target_home / file_name)
|
||||
|
||||
@staticmethod
|
||||
def _mirror_user_config_file(source: Path, target: Path) -> None:
|
||||
if not source.exists() or not source.is_file():
|
||||
return
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
if target.is_symlink() or target.exists():
|
||||
if target.is_symlink() and target.resolve() == source.resolve():
|
||||
return
|
||||
target.unlink()
|
||||
target.symlink_to(source)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
if not target.exists() or target.read_bytes() != source.read_bytes():
|
||||
target.write_bytes(source.read_bytes())
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Unable to mirror Codex config file {} into isolated home: {}",
|
||||
source.name,
|
||||
exc,
|
||||
)
|
||||
|
||||
def build_process_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str] | None:
|
||||
env = {
|
||||
str(key): str(value)
|
||||
for key, value in os.environ.items()
|
||||
if not self._is_parent_codex_runtime_env(str(key))
|
||||
}
|
||||
if extra_env:
|
||||
env.update({str(k): str(v) for k, v in extra_env.items()})
|
||||
return env
|
||||
|
||||
@classmethod
|
||||
def _is_parent_codex_runtime_env(cls, key: str) -> bool:
|
||||
normalized = key.upper()
|
||||
return normalized in cls._PARENT_CODEX_RUNTIME_ENV_VARS
|
||||
|
||||
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
|
||||
args: list[str] = []
|
||||
if workspace_path:
|
||||
args.extend(["-C", workspace_path, "--add-dir", workspace_path])
|
||||
return args
|
||||
|
||||
def _extra_writable_roots(self, task: Task | None) -> list[str]:
|
||||
# Surface to Codex's workspace-write sandbox every path outside
|
||||
# the main workspace that OpenOPC expects the agent (or a shell
|
||||
# it spawns) to be able to write to:
|
||||
#
|
||||
# * ``comms_workspace_root`` — sibling of the deliverable
|
||||
# folder; prompts tell the agent to write into
|
||||
# ``.opc-comms/...`` there.
|
||||
# * ``.opc/memory`` — durable global/project Markdown memory.
|
||||
# * the OPC home directory — the ``opc-collab`` CLI (shelled
|
||||
# out from inside the sandbox) writes to ``<opc_home>/
|
||||
# projects/<project>/tasks.db``. Without this entry, SQLite
|
||||
# returns SQLITE_READONLY because Codex mounts the DB path
|
||||
# read-only. (This manifested as "runtime database opened
|
||||
# as read-only" failures on every collab tool call.)
|
||||
if task is None:
|
||||
return []
|
||||
roots: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(path: str) -> None:
|
||||
if not path:
|
||||
return
|
||||
normalized = str(path).strip()
|
||||
if not normalized or normalized in seen:
|
||||
return
|
||||
seen.add(normalized)
|
||||
roots.append(normalized)
|
||||
|
||||
workspace = str((task.metadata or {}).get("target_output_dir") or "").strip()
|
||||
_add_except_workspace = lambda path: _add(path) if path and path != workspace else None # noqa: E731
|
||||
|
||||
comms_root = str((task.metadata or {}).get("comms_workspace_root") or "").strip()
|
||||
_add_except_workspace(comms_root)
|
||||
|
||||
# Compute the OPC home lazily so failures here (e.g. a test
|
||||
# environment without ``opc.core.config`` fully bootable) never
|
||||
# take down the adapter — the agent will still launch, just
|
||||
# without the DB writable path, and the broker will surface the
|
||||
# SQLITE_READONLY error if it matters.
|
||||
try:
|
||||
from opc.core.config import get_opc_home
|
||||
|
||||
opc_home_path = get_opc_home()
|
||||
memory_root = str(opc_home_path / "memory")
|
||||
opc_home = str(opc_home_path)
|
||||
except Exception:
|
||||
memory_root = ""
|
||||
opc_home = ""
|
||||
_add_except_workspace(memory_root)
|
||||
_add_except_workspace(opc_home)
|
||||
|
||||
return roots
|
||||
|
||||
def _build_extra_dir_args(self, task: Task | None) -> list[str]:
|
||||
extra: list[str] = []
|
||||
for root in self._extra_writable_roots(task):
|
||||
extra.extend(["--add-dir", root])
|
||||
return extra
|
||||
|
||||
def _build_writable_roots_config_args(self, task: Task | None) -> list[str]:
|
||||
roots = self._extra_writable_roots(task)
|
||||
if not roots:
|
||||
return []
|
||||
return [
|
||||
"-c",
|
||||
f"sandbox_workspace_write.writable_roots={json.dumps(roots, ensure_ascii=False)}",
|
||||
]
|
||||
|
||||
def _build_stdin_prompt_metadata(self, task: Task) -> dict[str, object]:
|
||||
prompt = self.build_task_prompt(task)
|
||||
return {
|
||||
"prompt_transport": "stdin",
|
||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
||||
}
|
||||
|
||||
def _build_argv_prompt_metadata(self, prompt: str) -> dict[str, object]:
|
||||
return {
|
||||
"prompt_transport": "argv",
|
||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
||||
}
|
||||
|
||||
def _interactive_prompt_transport(self, prompt: str) -> str:
|
||||
prompt_bytes = len(prompt.encode("utf-8"))
|
||||
if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES and self._windows_multiline_argv_is_unsafe(prompt):
|
||||
return "stdin"
|
||||
return "argv" if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES else "stdin"
|
||||
|
||||
def _windows_multiline_argv_is_unsafe(self, prompt: str) -> bool:
|
||||
if os.name != "nt":
|
||||
return False
|
||||
if "\n" not in prompt and "\r" not in prompt:
|
||||
return False
|
||||
command = self.configured_command()
|
||||
resolved = shutil.which(command) or command
|
||||
suffix = os.path.splitext(str(resolved or ""))[1].lower()
|
||||
return suffix in {"", ".cmd", ".bat", ".ps1", ".com", ".exe"}
|
||||
|
||||
@staticmethod
|
||||
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
|
||||
redacted = list(cmd)
|
||||
if redacted:
|
||||
redacted[-1] = f"<prompt:{len(prompt.encode('utf-8'))}-bytes>"
|
||||
return redacted
|
||||
|
||||
def _prompt_text_from_task(self, task: Task | None) -> str:
|
||||
if task is None:
|
||||
return ""
|
||||
return self.build_task_prompt(task)
|
||||
|
||||
@classmethod
|
||||
def _tty_prompt_payload(cls, prompt: str) -> bytes:
|
||||
payload = prompt.encode("utf-8")
|
||||
if payload and not payload.endswith(b"\n"):
|
||||
payload += b"\n"
|
||||
return payload + cls._TTY_EOF
|
||||
|
||||
async def _seed_tty_prompt(self, proc: asyncio.subprocess.Process, prompt: str) -> None:
|
||||
input_fd = self._input_fds.get(proc.pid)
|
||||
if input_fd is None:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
self._write_input_bytes,
|
||||
input_fd,
|
||||
self._tty_prompt_payload(prompt),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _seed_pipe_prompt(
|
||||
proc: asyncio.subprocess.Process,
|
||||
prompt: str,
|
||||
) -> bool:
|
||||
"""Write *prompt* to the subprocess stdin pipe and close it.
|
||||
|
||||
Returns ``True`` when the full payload was delivered, ``False``
|
||||
if the pipe broke before delivery completed (the child may have
|
||||
exited early or rejected the data).
|
||||
"""
|
||||
writer = getattr(proc, "stdin", None)
|
||||
if writer is None:
|
||||
return False
|
||||
payload = prompt.encode("utf-8")
|
||||
delivered = True
|
||||
try:
|
||||
writer.write(payload)
|
||||
await writer.drain()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as exc:
|
||||
logger.warning(
|
||||
"Pipe prompt delivery failed ({} bytes planned): {}",
|
||||
len(payload),
|
||||
exc,
|
||||
)
|
||||
delivered = False
|
||||
writer.close()
|
||||
wait_closed = getattr(writer, "wait_closed", None)
|
||||
if callable(wait_closed):
|
||||
try:
|
||||
await wait_closed()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
delivered = False
|
||||
return delivered
|
||||
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
if str(self.config.session_mode or "").strip().lower() == "resume" and session_id:
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"exec",
|
||||
"resume",
|
||||
"--skip-git-repo-check",
|
||||
*self._build_resume_approval_args(),
|
||||
*self._build_resume_model_args(),
|
||||
session_id,
|
||||
self._PROMPT_SENTINEL,
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"exec",
|
||||
*self.build_workspace_args(workspace_path),
|
||||
*self._build_extra_dir_args(task),
|
||||
*self._build_writable_roots_config_args(task),
|
||||
"--skip-git-repo-check",
|
||||
*self._build_approval_args(),
|
||||
*self.build_common_args(),
|
||||
self._PROMPT_SENTINEL,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata.update(self._build_stdin_prompt_metadata(task))
|
||||
self._record_stdin_policy_metadata(
|
||||
metadata,
|
||||
self.stdin_policy_for_process(cmd, metadata),
|
||||
)
|
||||
if (
|
||||
metadata.get("stdin_policy") == "pipe_open"
|
||||
and self._uses_interactive_input_channel(cmd)
|
||||
and self._supports_pty_input_channel()
|
||||
):
|
||||
metadata["interactive_input_channel"] = "pty"
|
||||
return cmd, metadata
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
prompt = self._prompt_text_from_task(task)
|
||||
prompt_transport = self._interactive_prompt_transport(prompt)
|
||||
prompt_arg = prompt if prompt_transport == "argv" else self._PROMPT_SENTINEL
|
||||
if str(self.config.session_mode or "").strip().lower() == "resume" and session_id:
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"exec",
|
||||
"resume",
|
||||
"--skip-git-repo-check",
|
||||
"--json",
|
||||
*self._build_resume_approval_args(),
|
||||
*self._build_resume_model_args(),
|
||||
session_id,
|
||||
prompt_arg,
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
self.configured_command(),
|
||||
"exec",
|
||||
*self.build_workspace_args(workspace_path),
|
||||
*self._build_extra_dir_args(task),
|
||||
*self._build_writable_roots_config_args(task),
|
||||
"--skip-git-repo-check",
|
||||
"--json",
|
||||
*self._build_approval_args(),
|
||||
*self.build_common_args(),
|
||||
prompt_arg,
|
||||
]
|
||||
# Small interactive prompts stay on argv because Codex CLI 0.130+
|
||||
# can exit before a PTY-backed `exec --json -` prompt is seeded. Large
|
||||
# prompts must not use argv: macOS/Linux command-line limits turn the
|
||||
# final-delivery assessment into an `Argument list too long` failure.
|
||||
if prompt_transport == "argv":
|
||||
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
|
||||
metadata.update(self._build_argv_prompt_metadata(prompt))
|
||||
else:
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata.update(self._build_stdin_prompt_metadata(task))
|
||||
metadata["stdin_prompt_channel"] = "pipe"
|
||||
metadata["prompt_transport_reason"] = (
|
||||
"prompt_too_large_for_argv"
|
||||
if len(prompt.encode("utf-8")) > self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES
|
||||
else "windows_multiline_argv_unsafe"
|
||||
)
|
||||
metadata["interactive_input_limitation"] = (
|
||||
"initial prompt is delivered through stdin; live approval replies "
|
||||
"may be unavailable after stdin closes"
|
||||
)
|
||||
self._record_stdin_policy_metadata(
|
||||
metadata,
|
||||
self.stdin_policy_for_process(cmd, metadata),
|
||||
)
|
||||
return cmd, metadata
|
||||
|
||||
def extract_resume_session_id(self, output: str) -> str:
|
||||
for line in output.splitlines():
|
||||
event = self._parse_runtime_event(line)
|
||||
if not event:
|
||||
continue
|
||||
if str(event.get("type") or "").strip() != "thread.started":
|
||||
continue
|
||||
thread_id = str(event.get("thread_id") or "").strip()
|
||||
if thread_id:
|
||||
return thread_id
|
||||
return super().extract_resume_session_id(output)
|
||||
|
||||
@classmethod
|
||||
def _parse_runtime_event(cls, text: str) -> dict[str, Any] | None:
|
||||
envelope = cls._parse_json_line(text)
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
event = envelope.get("msg") if isinstance(envelope.get("msg"), dict) else envelope
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
event_type = str(event.get("type") or "").strip()
|
||||
return event if event_type else None
|
||||
|
||||
@staticmethod
|
||||
def _trim_text(text: str, *, limit: int) -> str:
|
||||
stripped = str(text or "").strip()
|
||||
if len(stripped) <= limit:
|
||||
return stripped
|
||||
return stripped[: limit - 1].rstrip() + "…"
|
||||
|
||||
@classmethod
|
||||
def _command_summary(cls, item: dict[str, Any]) -> str:
|
||||
command = cls.normalize_shell_command(item.get("command"))
|
||||
if not command:
|
||||
command = str(item.get("command") or "").strip()
|
||||
if not command:
|
||||
return "command execution"
|
||||
return cls._trim_text(command.replace("\n", " "), limit=120)
|
||||
|
||||
@classmethod
|
||||
def _command_detail(cls, item: dict[str, Any], *, include_output: bool) -> str:
|
||||
parts: list[str] = []
|
||||
command = cls.normalize_shell_command(item.get("command"))
|
||||
if command:
|
||||
parts.append(f"$ {command}")
|
||||
raw_command = str(item.get("command") or "").strip()
|
||||
if not command and raw_command:
|
||||
parts.append(f"$ {raw_command}")
|
||||
|
||||
if include_output:
|
||||
output = str(item.get("aggregated_output") or "").strip()
|
||||
if output:
|
||||
parts.append(cls._trim_text(output, limit=cls._COMMAND_OUTPUT_LIMIT))
|
||||
status = str(item.get("status") or "").strip()
|
||||
exit_code = item.get("exit_code")
|
||||
status_bits: list[str] = []
|
||||
if status:
|
||||
status_bits.append(f"status={status}")
|
||||
if exit_code is not None:
|
||||
status_bits.append(f"exit_code={exit_code}")
|
||||
if status_bits:
|
||||
parts.append(", ".join(status_bits))
|
||||
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
@classmethod
|
||||
def normalize_transcript_text(cls, output: str) -> str:
|
||||
last_completed_agent_message = ""
|
||||
last_agent_message = ""
|
||||
|
||||
for line in output.splitlines():
|
||||
event = cls._parse_runtime_event(line)
|
||||
if not event:
|
||||
continue
|
||||
if str(event.get("type") or "").strip() not in {"item.started", "item.completed"}:
|
||||
continue
|
||||
item = event.get("item") if isinstance(event.get("item"), dict) else None
|
||||
if not isinstance(item, dict) or str(item.get("type") or "").strip() != "agent_message":
|
||||
continue
|
||||
text = str(item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
last_agent_message = text
|
||||
if str(event.get("type") or "").strip() == "item.completed":
|
||||
last_completed_agent_message = text
|
||||
|
||||
return last_completed_agent_message or last_agent_message or output
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
return self.normalize_transcript_text(output)
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
event = self._parse_runtime_event(text)
|
||||
if not event:
|
||||
return super().format_progress_update(text, stream_name)
|
||||
|
||||
event_type = str(event.get("type") or "").strip()
|
||||
if event_type in {"exec_approval_request", "apply_patch_approval_request"}:
|
||||
return None
|
||||
|
||||
if event_type not in {"item.started", "item.completed"}:
|
||||
return None
|
||||
|
||||
item = event.get("item") if isinstance(event.get("item"), dict) else None
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
item_type = str(item.get("type") or "").strip()
|
||||
if item_type == "agent_message" and event_type == "item.completed":
|
||||
message = self._trim_text(str(item.get("text") or ""), limit=2400)
|
||||
if not message:
|
||||
return None
|
||||
return f"[External:{self.agent_type}:thinking] {message}"
|
||||
|
||||
if item_type == "command_execution":
|
||||
detail = self._command_detail(item, include_output=event_type == "item.completed")
|
||||
if not detail:
|
||||
detail = self._command_summary(item)
|
||||
return f"[External:{self.agent_type}:tool] {detail}"
|
||||
|
||||
return None
|
||||
|
||||
def detect_runtime_failure(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
_ = stream_name
|
||||
if "Reading additional input from stdin..." not in str(text):
|
||||
return None
|
||||
policy = str((metadata or {}).get("stdin_policy") or "").strip()
|
||||
prompt_transport = str((metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
if prompt_transport == "stdin" or policy == "pipe_prompt_then_close":
|
||||
return None
|
||||
return (
|
||||
"Codex entered supplemental stdin intake mode (`Reading additional input from stdin...`) "
|
||||
"instead of executing the delegated task prompt."
|
||||
)
|
||||
|
||||
def _uses_interactive_input_channel(self, cmd: list[str]) -> bool:
|
||||
return "--json" in cmd
|
||||
|
||||
def stdin_policy_for_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ExternalAgentStdinPolicy:
|
||||
prompt_transport = str((metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
if prompt_transport == "stdin" or (cmd and str(cmd[-1]).strip() == self._PROMPT_SENTINEL):
|
||||
return "pipe_prompt_then_close"
|
||||
if self._uses_interactive_input_channel(cmd):
|
||||
return "pipe_open" if self._supports_pty_input_channel() else "inherit"
|
||||
return super().stdin_policy_for_process(cmd, metadata)
|
||||
|
||||
@staticmethod
|
||||
def _supports_pty_input_channel() -> bool:
|
||||
return callable(getattr(os, "openpty", None))
|
||||
|
||||
def _build_resume_model_args(self) -> list[str]:
|
||||
if not self.config.model:
|
||||
return []
|
||||
return [self.config.model_flag or "--model", self.config.model]
|
||||
|
||||
async def start_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
workspace_path: str,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
task: Task | None = None,
|
||||
launch_metadata: dict[str, Any] | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
|
||||
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
|
||||
env = self.build_process_env(extra_env)
|
||||
launch_cmd = self._resolve_launch_command(
|
||||
cmd,
|
||||
extra_env=extra_env,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
stdin_prompt_channel = str((launch_metadata or {}).get("stdin_prompt_channel") or "").strip().lower()
|
||||
stdin_policy = self.stdin_policy_for_process(launch_cmd, launch_metadata)
|
||||
|
||||
if not self._uses_interactive_input_channel(launch_cmd):
|
||||
proc = await super().start_process(
|
||||
launch_cmd,
|
||||
workspace_path,
|
||||
extra_env=extra_env,
|
||||
task=task,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
if prompt_transport == "stdin":
|
||||
delivered = await self._seed_pipe_prompt(proc, prompt)
|
||||
if not delivered:
|
||||
logger.warning(
|
||||
"Stdin prompt delivery to {} (pid={}) may be incomplete; "
|
||||
"the process might fail with an encoding error",
|
||||
self.agent_type,
|
||||
proc.pid,
|
||||
)
|
||||
return proc
|
||||
|
||||
if prompt_transport == "stdin" and stdin_prompt_channel == "pipe":
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
proc = await super().start_process(
|
||||
launch_cmd,
|
||||
workspace_path,
|
||||
extra_env=extra_env,
|
||||
task=task,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
delivered = await self._seed_pipe_prompt(proc, prompt)
|
||||
if not delivered:
|
||||
if isinstance(launch_metadata, dict):
|
||||
launch_metadata["prompt_delivery_failed"] = True
|
||||
logger.warning(
|
||||
"Large stdin prompt delivery to {} (pid={}) may be incomplete",
|
||||
self.agent_type,
|
||||
proc.pid,
|
||||
)
|
||||
return proc
|
||||
|
||||
if not self._supports_pty_input_channel():
|
||||
if prompt_transport == "stdin":
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
launch_metadata["interactive_input_limitation"] = (
|
||||
"initial stdin prompt is delivered through a pipe on PTY-less platforms; "
|
||||
"live approval replies require a PTY-capable platform"
|
||||
)
|
||||
logger.info(
|
||||
"PTY-backed Codex input is unavailable on this platform; using stdin prompt "
|
||||
"pipe delivery. Live approval replies require a PTY-capable platform."
|
||||
)
|
||||
proc = await super().start_process(
|
||||
launch_cmd,
|
||||
workspace_path,
|
||||
extra_env=extra_env,
|
||||
task=task,
|
||||
launch_metadata=launch_metadata,
|
||||
)
|
||||
delivered = await self._seed_pipe_prompt(proc, prompt)
|
||||
if not delivered:
|
||||
logger.warning(
|
||||
"Stdin prompt delivery to {} (pid={}) may be incomplete; "
|
||||
"the process might fail with an encoding error",
|
||||
self.agent_type,
|
||||
proc.pid,
|
||||
)
|
||||
return proc
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
launch_metadata["interactive_input_limitation"] = (
|
||||
"stdin is inherited for argv prompt delivery on PTY-less platforms; "
|
||||
"live approval replies require a PTY-capable platform"
|
||||
)
|
||||
logger.info(
|
||||
"PTY-backed Codex input is unavailable on this platform; using argv prompt "
|
||||
"delivery with inherited stdin. Live approval replies require a PTY-capable platform."
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*launch_cmd,
|
||||
stdin=None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
env=env,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
return proc
|
||||
|
||||
if isinstance(launch_metadata, dict):
|
||||
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
|
||||
launch_metadata["interactive_input_channel"] = "pty"
|
||||
master_fd, slave_fd = os.openpty()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*launch_cmd,
|
||||
stdin=slave_fd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
env=env,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
except Exception:
|
||||
os.close(master_fd)
|
||||
os.close(slave_fd)
|
||||
raise
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(slave_fd)
|
||||
|
||||
self._input_fds[proc.pid] = master_fd
|
||||
if prompt_transport == "stdin":
|
||||
try:
|
||||
await self._seed_tty_prompt(proc, prompt)
|
||||
except OSError:
|
||||
self._input_fds.pop(proc.pid, None)
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
raise
|
||||
return proc
|
||||
|
||||
async def send_process_input(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
text: str,
|
||||
) -> bool:
|
||||
if not text:
|
||||
return True
|
||||
input_fd = self._input_fds.get(proc.pid)
|
||||
if input_fd is None:
|
||||
return await super().send_process_input(proc, text)
|
||||
payload = text.encode("utf-8")
|
||||
try:
|
||||
await asyncio.to_thread(self._write_input_bytes, input_fd, payload)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def cleanup_process(self, proc: asyncio.subprocess.Process) -> None:
|
||||
input_fd = self._input_fds.pop(proc.pid, None)
|
||||
if input_fd is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(input_fd)
|
||||
await super().cleanup_process(proc)
|
||||
|
||||
@staticmethod
|
||||
def _write_input_bytes(input_fd: int, payload: bytes) -> None:
|
||||
written = 0
|
||||
while written < len(payload):
|
||||
written += os.write(input_fd, payload[written:])
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
envelope = self._parse_json_line(text)
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
|
||||
event = envelope.get("msg") if isinstance(envelope.get("msg"), dict) else envelope
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
|
||||
event_type = str(event.get("type") or "").strip()
|
||||
common_metadata = {
|
||||
"stream": stream_name,
|
||||
"provider_event_type": event_type,
|
||||
"approval_id": str(event.get("call_id") or event.get("id") or ""),
|
||||
"turn_id": str(event.get("turn_id") or ""),
|
||||
"raw_event": event,
|
||||
}
|
||||
|
||||
if event_type == "exec_approval_request":
|
||||
command = self.normalize_shell_command(event.get("command"))
|
||||
arguments: dict[str, object] = {}
|
||||
if command:
|
||||
arguments["command"] = command
|
||||
cwd = str(event.get("cwd") or "").strip()
|
||||
if cwd:
|
||||
arguments["working_directory"] = cwd
|
||||
prompt_text = str(event.get("reason") or "").strip()
|
||||
if not prompt_text and command:
|
||||
prompt_text = f"Allow Codex to run `{command}`?"
|
||||
metadata = {
|
||||
**common_metadata,
|
||||
"cwd": cwd,
|
||||
"command": command,
|
||||
"network_approval_context": event.get("network_approval_context"),
|
||||
"additional_permissions": event.get("additional_permissions"),
|
||||
"proposed_execpolicy_amendment": event.get("proposed_execpolicy_amendment"),
|
||||
"proposed_network_policy_amendments": event.get("proposed_network_policy_amendments"),
|
||||
"available_decisions": event.get("available_decisions"),
|
||||
}
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="shell_exec",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if event_type == "apply_patch_approval_request":
|
||||
changes = event.get("changes")
|
||||
paths = sorted(str(path) for path in changes.keys()) if isinstance(changes, dict) else []
|
||||
grant_root = str(event.get("grant_root") or "").strip()
|
||||
arguments: dict[str, object] = {}
|
||||
if grant_root:
|
||||
arguments["path"] = grant_root
|
||||
elif len(paths) == 1:
|
||||
arguments["path"] = paths[0]
|
||||
elif paths:
|
||||
arguments["target"] = paths[0]
|
||||
prompt_text = str(event.get("reason") or "").strip()
|
||||
if not prompt_text:
|
||||
prompt_text = "Allow Codex to apply file changes?"
|
||||
metadata = {
|
||||
**common_metadata,
|
||||
"grant_root": grant_root,
|
||||
"paths": paths,
|
||||
"available_decisions": event.get("available_decisions"),
|
||||
}
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_edit",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
# Codex interactive runs are launched with `--json`, so real approval
|
||||
# prompts arrive as structured approval_request events. Falling back to
|
||||
# the generic parser here causes ordinary command logs or sandbox error
|
||||
# text to be misclassified as approval prompts.
|
||||
return None
|
||||
|
||||
def format_approval_response(
|
||||
self,
|
||||
request: ExternalApprovalRequest,
|
||||
approved: bool,
|
||||
decision: ApprovalDecision,
|
||||
) -> str:
|
||||
event_type = str(request.metadata.get("provider_event_type") or "").strip()
|
||||
if event_type not in {"exec_approval_request", "apply_patch_approval_request"}:
|
||||
return super().format_approval_response(request, approved, decision)
|
||||
|
||||
approval_id = str(request.metadata.get("approval_id") or "").strip()
|
||||
if not approval_id:
|
||||
return super().format_approval_response(request, approved, decision)
|
||||
|
||||
op = {
|
||||
"type": "exec_approval" if event_type == "exec_approval_request" else "patch_approval",
|
||||
"id": approval_id,
|
||||
"decision": self._review_decision_payload(request, approved, decision),
|
||||
}
|
||||
turn_id = str(request.metadata.get("turn_id") or "").strip()
|
||||
if event_type == "exec_approval_request" and turn_id:
|
||||
op["turn_id"] = turn_id
|
||||
return json.dumps({"id": str(uuid.uuid4()), "op": op}, ensure_ascii=False) + "\n"
|
||||
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
if not await self.is_available():
|
||||
return TaskResult(status=TaskStatus.FAILED, content="Codex CLI not found")
|
||||
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
logger.info(f"Codex executing: {task.title}")
|
||||
|
||||
try:
|
||||
self._process = await self.start_process(
|
||||
cmd,
|
||||
workspace_path,
|
||||
task=task,
|
||||
launch_metadata=metadata,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
self._process.communicate(), timeout=600
|
||||
)
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if self._process.returncode == 0:
|
||||
return TaskResult(status=TaskStatus.DONE, content=output, artifacts=metadata)
|
||||
else:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Codex exited with code {self._process.returncode}\n{errors}\n{output}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if self._process:
|
||||
self._process.kill()
|
||||
return TaskResult(status=TaskStatus.FAILED, content="Codex timed out", artifacts=metadata)
|
||||
except Exception as e:
|
||||
return TaskResult(status=TaskStatus.FAILED, content=f"Codex error: {e}", artifacts=metadata)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_approval_args(self) -> list[str]:
|
||||
common_args = self.build_common_args()
|
||||
# Skip injection if the user has manually configured sandbox / approval
|
||||
# flags via `extra_args` so we don't trample explicit overrides.
|
||||
conflict_markers = {
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--full-auto",
|
||||
"-s",
|
||||
"--sandbox",
|
||||
}
|
||||
for arg in common_args:
|
||||
if arg in conflict_markers:
|
||||
return []
|
||||
if arg.startswith("--sandbox="):
|
||||
return []
|
||||
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "user-settings":
|
||||
return []
|
||||
if mode == "full-auto":
|
||||
return ["--dangerously-bypass-approvals-and-sandbox"]
|
||||
return ["--sandbox", "danger-full-access"]
|
||||
|
||||
def _build_resume_approval_args(self) -> list[str]:
|
||||
common_args = self.build_common_args()
|
||||
if any(arg in {"--dangerously-bypass-approvals-and-sandbox", "--full-auto"} for arg in common_args):
|
||||
return []
|
||||
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "user-settings":
|
||||
return []
|
||||
if mode == "full-auto":
|
||||
return ["--dangerously-bypass-approvals-and-sandbox"]
|
||||
|
||||
# `codex exec resume` does not accept the `--sandbox` flag that plain
|
||||
# `codex exec` supports, but it does accept config overrides.
|
||||
return ["-c", 'sandbox_mode="danger-full-access"']
|
||||
|
||||
def _review_decision_payload(
|
||||
self,
|
||||
request: ExternalApprovalRequest,
|
||||
approved: bool,
|
||||
decision: ApprovalDecision,
|
||||
) -> str | dict[str, object]:
|
||||
if not approved:
|
||||
return "denied"
|
||||
|
||||
human_reply = str((decision.metadata or {}).get("human_reply") or "").strip().lower()
|
||||
if human_reply not in {"always_project", "always_global"}:
|
||||
return "approved"
|
||||
|
||||
event_type = str(request.metadata.get("provider_event_type") or "").strip()
|
||||
if event_type == "exec_approval_request":
|
||||
proposed_execpolicy = request.metadata.get("proposed_execpolicy_amendment")
|
||||
if isinstance(proposed_execpolicy, dict):
|
||||
return {
|
||||
"approved_execpolicy_amendment": {
|
||||
"proposed_execpolicy_amendment": proposed_execpolicy,
|
||||
}
|
||||
}
|
||||
|
||||
amendments = request.metadata.get("proposed_network_policy_amendments")
|
||||
if isinstance(amendments, list):
|
||||
for amendment in amendments:
|
||||
if isinstance(amendment, dict) and str(amendment.get("action") or "").lower() == "allow":
|
||||
return {"network_policy_amendment": {"network_policy_amendment": amendment}}
|
||||
|
||||
if request.metadata.get("network_approval_context") or request.metadata.get("additional_permissions"):
|
||||
return "approved_for_session"
|
||||
|
||||
if event_type == "apply_patch_approval_request" and request.metadata.get("grant_root"):
|
||||
return "approved_for_session"
|
||||
return "approved"
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Cursor adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import AgentStatus, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.adapters.base import ExternalAgentAdapter, ExternalApprovalRequest
|
||||
|
||||
|
||||
class CursorAdapter(ExternalAgentAdapter):
|
||||
"""Invokes local Cursor for programming tasks via CLI."""
|
||||
|
||||
agent_type = "cursor"
|
||||
default_command = "cursor-agent"
|
||||
|
||||
def __init__(self, config=None) -> None:
|
||||
super().__init__(config=config)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._thinking_buffers: dict[str, list[str]] = {}
|
||||
|
||||
def resolve_binary(self) -> str | None:
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
for candidate in self._candidate_commands():
|
||||
resolved = shutil.which(candidate)
|
||||
if not resolved:
|
||||
continue
|
||||
if candidate == "cursor":
|
||||
# The editor CLI is not sufficient for headless agent execution.
|
||||
continue
|
||||
return resolved
|
||||
return None
|
||||
|
||||
def _runtime_command(self) -> str | None:
|
||||
for candidate in self._candidate_commands():
|
||||
if candidate == "cursor":
|
||||
continue
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _candidate_commands(self) -> list[str]:
|
||||
configured = str(self.configured_command() or "").strip()
|
||||
candidates: list[str] = []
|
||||
if configured:
|
||||
if configured == "cursor":
|
||||
candidates.append("cursor-agent")
|
||||
candidates.append(configured)
|
||||
else:
|
||||
candidates.extend(["cursor-agent", "cursor"])
|
||||
if "cursor-agent" not in candidates:
|
||||
candidates.insert(0, "cursor-agent")
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return self.resolve_binary() is not None
|
||||
|
||||
async def get_status(self) -> AgentStatus:
|
||||
if self._process and self._process.returncode is None:
|
||||
return AgentStatus.RUNNING
|
||||
return AgentStatus.IDLE
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return self._runtime_command() is not None
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_approval_prompt_handling(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Cursor's stream-json interaction events are not stdin prompts.
|
||||
|
||||
Cursor emits ``interaction_query`` request/response JSON for internal
|
||||
tool approvals, and the CLI answers those events itself. Treating that
|
||||
stream as a generic stdin approval prompt creates stale OpenOPC cards.
|
||||
"""
|
||||
_ = cmd
|
||||
_ = metadata
|
||||
return False
|
||||
|
||||
def agent_isolation_home_slug(self) -> str:
|
||||
return "cursor"
|
||||
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
_ = workspace_path
|
||||
prompt = self.build_task_prompt(task)
|
||||
command = self._runtime_command() or self.configured_command()
|
||||
cmd = [
|
||||
command,
|
||||
"-p",
|
||||
"--output-format",
|
||||
"text",
|
||||
*self._build_workspace_trust_args(),
|
||||
*self._build_approval_args(),
|
||||
*self._build_model_args(),
|
||||
*self._build_session_args(),
|
||||
*list(self.config.extra_args),
|
||||
prompt,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata["binary"] = command
|
||||
return cmd, metadata
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
_ = workspace_path
|
||||
prompt = self.build_task_prompt(task)
|
||||
command = self._runtime_command() or self.configured_command()
|
||||
cmd = [
|
||||
command,
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
*self._build_workspace_trust_args(),
|
||||
*self._build_approval_args(),
|
||||
*self._build_model_args(),
|
||||
*self._build_session_args(),
|
||||
*list(self.config.extra_args),
|
||||
prompt,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata["binary"] = command
|
||||
return cmd, metadata
|
||||
|
||||
def extract_resume_session_id(self, output: str) -> str:
|
||||
for line in output.splitlines():
|
||||
event = self._parse_json_line(line)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
token = self._session_id_from_event(event)
|
||||
if token:
|
||||
return token
|
||||
return super().extract_resume_session_id(output)
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
last_result = ""
|
||||
last_assistant = ""
|
||||
for line in output.splitlines():
|
||||
event = self._parse_json_line(line)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
if event_type == "result":
|
||||
text = self._event_text(event)
|
||||
if text:
|
||||
last_result = text
|
||||
elif self._event_role(event) == "assistant" or event_type in {"assistant", "assistant_message"}:
|
||||
text = self._event_text(event)
|
||||
if text:
|
||||
last_assistant = text
|
||||
return last_result or last_assistant or output
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
if stream_name != "stdout":
|
||||
stripped = str(text or "").strip()
|
||||
return f"[External:{self.agent_type}:stderr] {stripped[:500]}" if stripped else None
|
||||
|
||||
event = self._parse_json_line(text)
|
||||
if not isinstance(event, dict):
|
||||
return super().format_progress_update(text, stream_name)
|
||||
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
if event_type in {"system", "init", "session"}:
|
||||
session_id = self._session_id_from_event(event)
|
||||
return (
|
||||
f"[External:{self.agent_type}:init] session={session_id[:8]}"
|
||||
if session_id
|
||||
else None
|
||||
)
|
||||
if "approval" in event_type or "permission" in event_type:
|
||||
return None
|
||||
if "tool" in event_type or "command" in event_type:
|
||||
summary = self._tool_summary(event)
|
||||
return f"[External:{self.agent_type}:tool] {summary}" if summary else None
|
||||
if event_type == "thinking":
|
||||
return self._format_thinking_progress(event)
|
||||
if event_type == "result":
|
||||
result = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {result[:2400]}" if result else None
|
||||
if self._event_role(event) == "assistant" or event_type in {"assistant", "assistant_message"}:
|
||||
message = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
return None
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
event = self._parse_json_line(text)
|
||||
if isinstance(event, dict):
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
# Cursor stream-json uses these event types for normal execution.
|
||||
# Some payloads contain words such as "approved" or "allow" in web
|
||||
# content, so the generic parser must not infer an OpenOPC approval
|
||||
# card from them.
|
||||
if event_type in {
|
||||
"assistant",
|
||||
"assistant_message",
|
||||
"interaction_query",
|
||||
"result",
|
||||
"system",
|
||||
"thinking",
|
||||
"tool_call",
|
||||
"user",
|
||||
}:
|
||||
return None
|
||||
return super().parse_approval_request(text, stream_name)
|
||||
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
if not await self.is_available():
|
||||
return TaskResult(status=TaskStatus.FAILED, content="Cursor agent CLI not found")
|
||||
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
logger.info(f"Cursor executing: {task.title}")
|
||||
|
||||
try:
|
||||
stdin_policy = self.stdin_policy_for_process(cmd, metadata)
|
||||
self._record_stdin_policy_metadata(metadata, stdin_policy)
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=self._stdin_target_for_policy(stdin_policy),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
self._process.communicate(), timeout=600
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if self._process.returncode == 0:
|
||||
return TaskResult(status=TaskStatus.DONE, content=output, artifacts=metadata)
|
||||
else:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Cursor exited with code {self._process.returncode}\n{errors}\n{output}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if self._process:
|
||||
self._process.kill()
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content="Cursor timed out after 600s",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"Cursor error: {e}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_model_args(self) -> list[str]:
|
||||
if not self.config.model:
|
||||
return []
|
||||
extra_args = list(self.config.extra_args)
|
||||
if self.config.model_flag and any(
|
||||
arg == self.config.model_flag or arg.startswith(f"{self.config.model_flag}=")
|
||||
for arg in extra_args
|
||||
):
|
||||
return []
|
||||
flag = self.config.model_flag or "-m"
|
||||
return [flag, self.config.model]
|
||||
|
||||
def _build_session_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(
|
||||
arg == "--resume" or arg.startswith("--resume=")
|
||||
for arg in extra_args
|
||||
):
|
||||
return []
|
||||
|
||||
mode = str(self.config.session_mode or "auto").strip().lower()
|
||||
if mode == "resume":
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
if session_id:
|
||||
return ["--resume", session_id]
|
||||
return []
|
||||
if mode == "new" and self.config.new_session_flag:
|
||||
return [self.config.new_session_flag]
|
||||
return []
|
||||
|
||||
def _build_workspace_trust_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg in {"--trust", "--yolo", "-f", "--force"} for arg in extra_args):
|
||||
return []
|
||||
return ["--trust"]
|
||||
|
||||
def _build_approval_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg in {"-f", "--force"} for arg in extra_args):
|
||||
return []
|
||||
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "full-auto":
|
||||
return ["--force"]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _session_id_from_event(cls, event: dict[str, Any]) -> str:
|
||||
for key in ("session_id", "sessionId", "sessionID", "chat_id", "chatId", "conversation_id", "thread_id"):
|
||||
token = str(event.get(key) or "").strip()
|
||||
if token:
|
||||
return token
|
||||
for key in ("message", "data", "result"):
|
||||
nested = event.get(key)
|
||||
if isinstance(nested, dict):
|
||||
token = cls._session_id_from_event(nested)
|
||||
if token:
|
||||
return token
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _event_role(event: dict[str, Any]) -> str:
|
||||
role = str(event.get("role") or "").strip().lower()
|
||||
if role:
|
||||
return role
|
||||
message = event.get("message")
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("role") or "").strip().lower()
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _event_text(cls, event: Any) -> str:
|
||||
if isinstance(event, str):
|
||||
return event.strip()
|
||||
if isinstance(event, list):
|
||||
parts = [cls._event_text(item) for item in event]
|
||||
return "\n".join(part for part in parts if part).strip()
|
||||
if not isinstance(event, dict):
|
||||
return ""
|
||||
|
||||
for key in ("result", "text", "message", "content", "summary"):
|
||||
value = event.get(key)
|
||||
if key == "message" and isinstance(value, dict):
|
||||
nested = cls._event_text(value)
|
||||
if nested:
|
||||
return nested
|
||||
elif isinstance(value, (str, list, dict)):
|
||||
nested = cls._event_text(value)
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _tool_summary(cls, event: dict[str, Any]) -> str:
|
||||
source = event.get("tool_call") if isinstance(event.get("tool_call"), dict) else event
|
||||
nested_name, nested_payload = cls._nested_cursor_tool_payload(source)
|
||||
if nested_payload:
|
||||
return cls._nested_cursor_tool_summary(nested_name, nested_payload)
|
||||
|
||||
name = str(
|
||||
source.get("name")
|
||||
or source.get("tool_name")
|
||||
or source.get("toolName")
|
||||
or source.get("type")
|
||||
or "tool"
|
||||
).strip()
|
||||
args = source.get("input") or source.get("arguments") or source.get("params") or {}
|
||||
if isinstance(args, dict):
|
||||
command = cls.normalize_shell_command(
|
||||
args.get("command") or args.get("cmd") or args.get("argv")
|
||||
)
|
||||
if command:
|
||||
return f"$ {command[:240]}"
|
||||
path = str(args.get("path") or args.get("file_path") or args.get("target") or "").strip()
|
||||
if path:
|
||||
return f"{name} {path}"
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def _nested_cursor_tool_payload(source: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
for key, value in source.items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
normalized = key[:-8] if key.endswith("ToolCall") else key
|
||||
if key.endswith("ToolCall") or "args" in value or "result" in value:
|
||||
return normalized, value
|
||||
return "", {}
|
||||
|
||||
@classmethod
|
||||
def _nested_cursor_tool_summary(cls, name: str, payload: dict[str, Any]) -> str:
|
||||
args = payload.get("args") if isinstance(payload.get("args"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
lines: list[str] = []
|
||||
normalized_name = cls._normalize_name(name)
|
||||
readable_name = cls._readable_tool_name(name)
|
||||
command = cls.normalize_shell_command(
|
||||
args.get("command") or args.get("cmd") or args.get("argv")
|
||||
)
|
||||
if command:
|
||||
lines.append(f"$ {command[:240]}")
|
||||
elif normalized_name in {"websearch", "websearchrequest"}:
|
||||
query = str(args.get("searchTerm") or args.get("query") or args.get("q") or "").strip()
|
||||
lines.append(f"web search: {query}".strip())
|
||||
elif normalized_name in {"webfetch", "webfetchrequest"}:
|
||||
url = str(args.get("url") or args.get("uri") or args.get("target") or "").strip()
|
||||
lines.append(f"web fetch: {url}".strip())
|
||||
elif name:
|
||||
target = str(
|
||||
args.get("path")
|
||||
or args.get("filePath")
|
||||
or args.get("targetDirectory")
|
||||
or args.get("target")
|
||||
or args.get("globPattern")
|
||||
or ""
|
||||
).strip()
|
||||
lines.append(f"{readable_name} {target}".strip())
|
||||
|
||||
description = str(payload.get("description") or args.get("description") or "").strip()
|
||||
if description and (not lines or description not in lines[0]):
|
||||
lines.append(description[:240])
|
||||
|
||||
result_text = cls._cursor_result_text(result, tool_name=name)
|
||||
if result_text:
|
||||
lines.append(result_text[:1200])
|
||||
return "\n".join(line for line in lines if line).strip() or readable_name or "tool"
|
||||
|
||||
@classmethod
|
||||
def _cursor_result_text(cls, result: dict[str, Any], tool_name: str = "") -> str:
|
||||
if not result:
|
||||
return ""
|
||||
success = result.get("success") if isinstance(result.get("success"), dict) else None
|
||||
rejected = result.get("rejected") if isinstance(result.get("rejected"), dict) else None
|
||||
error = result.get("error") if isinstance(result.get("error"), dict) else None
|
||||
payload = success or rejected or error or result
|
||||
normalized_name = cls._normalize_name(tool_name)
|
||||
if rejected is not None:
|
||||
command = str(payload.get("command") or "").strip()
|
||||
reason = str(payload.get("reason") or "rejected").strip()
|
||||
return f"rejected: {command or reason}".strip()
|
||||
if normalized_name in {"websearch", "websearchrequest"}:
|
||||
return cls._summarize_web_search_result(payload)
|
||||
if normalized_name in {"webfetch", "webfetchrequest"}:
|
||||
return cls._summarize_web_fetch_result(payload)
|
||||
for key in ("stdout", "output", "text", "content", "message"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
files = payload.get("files")
|
||||
if isinstance(files, list):
|
||||
shown = [str(item) for item in files[:20]]
|
||||
total = payload.get("totalFiles")
|
||||
suffix = f" ({total} total)" if total is not None else ""
|
||||
return "\n".join(shown) + suffix
|
||||
if error is not None:
|
||||
return f"error: {payload}"
|
||||
return ""
|
||||
|
||||
def _format_thinking_progress(self, event: dict[str, Any]) -> str | None:
|
||||
subtype = str(event.get("subtype") or event.get("status") or "").strip().lower()
|
||||
message = self._event_text(event)
|
||||
key = self._thinking_buffer_key(event)
|
||||
if subtype == "delta":
|
||||
delta = self._thinking_delta_text(event)
|
||||
if delta:
|
||||
self._thinking_buffers.setdefault(key, []).append(delta)
|
||||
return None
|
||||
if subtype in {"completed", "complete", "done"}:
|
||||
buffered = "".join(self._thinking_buffers.pop(key, []))
|
||||
message = (message or buffered).strip()
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
|
||||
@staticmethod
|
||||
def _thinking_delta_text(event: dict[str, Any]) -> str:
|
||||
value = event.get("text")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return CursorAdapter._event_text(event)
|
||||
|
||||
@classmethod
|
||||
def _thinking_buffer_key(cls, event: dict[str, Any]) -> str:
|
||||
parts = [
|
||||
str(event.get(key) or "").strip()
|
||||
for key in ("session_id", "sessionId", "sessionID", "model_call_id", "request_id")
|
||||
if str(event.get(key) or "").strip()
|
||||
]
|
||||
return ":".join(parts) or "default"
|
||||
|
||||
@staticmethod
|
||||
def _readable_tool_name(name: str) -> str:
|
||||
raw = str(name or "tool").strip()
|
||||
raw = raw[:-8] if raw.endswith("ToolCall") else raw
|
||||
spaced = re.sub(r"(?<!^)(?=[A-Z])", " ", raw).strip().lower()
|
||||
return spaced or "tool"
|
||||
|
||||
@classmethod
|
||||
def _summarize_web_search_result(cls, payload: dict[str, Any]) -> str:
|
||||
references = payload.get("references")
|
||||
if not isinstance(references, list):
|
||||
return cls._summarize_markdown_text(payload.get("markdown") or payload.get("text") or "")
|
||||
|
||||
lines: list[str] = []
|
||||
for ref in references[:5]:
|
||||
if not isinstance(ref, dict):
|
||||
continue
|
||||
title = str(ref.get("title") or "").strip()
|
||||
url = str(ref.get("url") or "").strip()
|
||||
chunk = str(ref.get("chunk") or "").strip()
|
||||
if title and title.lower() != "web search results" and url:
|
||||
lines.append(f"- {title} — {url}")
|
||||
elif title and title.lower() != "web search results":
|
||||
lines.append(f"- {title}")
|
||||
elif url:
|
||||
lines.append(f"- {url}")
|
||||
else:
|
||||
lines.extend(cls._extract_markdown_links(chunk, limit=max(0, 5 - len(lines))))
|
||||
if len(lines) >= 5:
|
||||
break
|
||||
|
||||
if lines:
|
||||
return "\n".join(lines[:5])
|
||||
chunks = [
|
||||
cls._summarize_markdown_text(str(ref.get("chunk") or ""))
|
||||
for ref in references[:2]
|
||||
if isinstance(ref, dict)
|
||||
]
|
||||
return "\n".join(chunk for chunk in chunks if chunk).strip()
|
||||
|
||||
@classmethod
|
||||
def _summarize_web_fetch_result(cls, payload: dict[str, Any]) -> str:
|
||||
markdown = str(
|
||||
payload.get("markdown")
|
||||
or payload.get("content")
|
||||
or payload.get("text")
|
||||
or ""
|
||||
).strip()
|
||||
return cls._summarize_markdown_text(markdown)
|
||||
|
||||
@staticmethod
|
||||
def _extract_markdown_links(text: str, limit: int = 5) -> list[str]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
links: list[str] = []
|
||||
for title, url in re.findall(r"\[[^\]\n]*?([^\]\n]+)\]\((https?://[^)\s]+)\)", text):
|
||||
clean_title = str(title or "").strip()
|
||||
clean_url = str(url or "").strip()
|
||||
if clean_title and clean_url:
|
||||
links.append(f"- {clean_title} — {clean_url}")
|
||||
if len(links) >= limit:
|
||||
break
|
||||
return links
|
||||
|
||||
@staticmethod
|
||||
def _summarize_markdown_text(text: str, max_lines: int = 8) -> str:
|
||||
lines: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_line in str(text or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if len(line) > 180:
|
||||
line = line[:177].rstrip() + "..."
|
||||
fingerprint = line.lower()
|
||||
if fingerprint in seen:
|
||||
continue
|
||||
seen.add(fingerprint)
|
||||
lines.append(line)
|
||||
if len(lines) >= max_lines:
|
||||
break
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,829 @@
|
||||
"""OpenCode CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import AgentStatus, ApprovalDecision, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.adapters.base import (
|
||||
ExternalAgentAdapter,
|
||||
ExternalAgentStdinPolicy,
|
||||
ExternalApprovalRequest,
|
||||
)
|
||||
|
||||
|
||||
class OpenCodeAdapter(ExternalAgentAdapter):
|
||||
"""Invokes the OpenCode CLI via ``opencode run``."""
|
||||
|
||||
agent_type = "opencode"
|
||||
default_command = "opencode"
|
||||
_permission_handler_support_cache: dict[str, bool] = {}
|
||||
|
||||
def __init__(self, config=None) -> None:
|
||||
super().__init__(config=config)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
|
||||
def resolve_binary(self) -> str | None:
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
for candidate in self._candidate_commands():
|
||||
resolved = self._resolve_command_candidate(candidate)
|
||||
if resolved:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
def _runtime_command(self) -> str:
|
||||
return self.resolve_binary() or self.configured_command()
|
||||
|
||||
def _candidate_commands(self) -> list[str]:
|
||||
configured = str(self.configured_command() or "").strip()
|
||||
candidates: list[str] = []
|
||||
if configured:
|
||||
candidates.append(configured)
|
||||
env_binary = str(os.environ.get("OPENCODE_BIN") or "").strip()
|
||||
if env_binary:
|
||||
candidates.append(env_binary)
|
||||
candidates.extend([
|
||||
str(Path.home() / ".opencode" / "bin" / "opencode"),
|
||||
str(Path.home() / ".local" / "bin" / "opencode"),
|
||||
"opencode",
|
||||
])
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_command_candidate(candidate: str) -> str | None:
|
||||
raw = str(candidate or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
expanded = Path(raw).expanduser()
|
||||
if expanded.is_absolute() or os.sep in raw:
|
||||
return str(expanded) if expanded.is_file() and os.access(expanded, os.X_OK) else None
|
||||
return shutil.which(raw)
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return self.resolve_binary() is not None
|
||||
|
||||
async def get_status(self) -> AgentStatus:
|
||||
if self._process and self._process.returncode is None:
|
||||
return AgentStatus.RUNNING
|
||||
return AgentStatus.IDLE
|
||||
|
||||
def supports_interactive(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_session_resume(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_resume_without_session_id(self) -> bool:
|
||||
return True
|
||||
|
||||
def agent_isolation_home_slug(self) -> str:
|
||||
# OpenCode discovers project/user config, agents, commands, plugins,
|
||||
# and skills from OPENCODE_CONFIG_DIR. Point spawned OpenCode at an
|
||||
# isolated config dir so the opc-collab skill is discoverable without
|
||||
# mutating the user's normal OpenCode config directory.
|
||||
return "opencode"
|
||||
|
||||
def agent_home_env_vars(self, home: str) -> dict[str, str]:
|
||||
return {"OPENCODE_CONFIG_DIR": home}
|
||||
|
||||
def post_install_agent_home(self, home: str) -> None:
|
||||
target_home = Path(home)
|
||||
target_home.mkdir(parents=True, exist_ok=True)
|
||||
source_home = self._user_config_dir(target_home)
|
||||
if source_home is None:
|
||||
return
|
||||
|
||||
for file_name in ("opencode.json", "opencode.jsonc"):
|
||||
self._mirror_user_config_path(source_home / file_name, target_home / file_name)
|
||||
for dir_name in ("agent", "agents", "command", "commands", "plugin", "plugins"):
|
||||
self._mirror_user_config_path(source_home / dir_name, target_home / dir_name)
|
||||
|
||||
@staticmethod
|
||||
def _user_config_dir(target_home: Path) -> Path | None:
|
||||
candidates: list[Path] = []
|
||||
raw_env = str(os.environ.get("OPENCODE_CONFIG_DIR") or "").strip()
|
||||
if raw_env:
|
||||
candidates.append(Path(raw_env).expanduser())
|
||||
xdg_config_home = str(os.environ.get("XDG_CONFIG_HOME") or "").strip()
|
||||
if xdg_config_home:
|
||||
candidates.append(Path(xdg_config_home).expanduser() / "opencode")
|
||||
candidates.append(Path.home() / ".config" / "opencode")
|
||||
|
||||
target_resolved = target_home.expanduser().resolve()
|
||||
for candidate in candidates:
|
||||
try:
|
||||
resolved = candidate.expanduser().resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if resolved == target_resolved:
|
||||
continue
|
||||
if resolved.exists():
|
||||
return resolved
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _mirror_user_config_path(source: Path, target: Path) -> None:
|
||||
if not source.exists():
|
||||
return
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists() or target.is_symlink():
|
||||
return
|
||||
try:
|
||||
target.symlink_to(source, target_is_directory=source.is_dir())
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
if source.is_dir():
|
||||
import shutil as _shutil
|
||||
|
||||
_shutil.copytree(source, target, dirs_exist_ok=True)
|
||||
else:
|
||||
target.write_bytes(source.read_bytes())
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Unable to mirror OpenCode config path {} into isolated home: {}",
|
||||
source,
|
||||
exc,
|
||||
)
|
||||
|
||||
def build_process_env(self, extra_env: dict[str, str] | None = None) -> dict[str, str] | None:
|
||||
env = super().build_process_env(extra_env)
|
||||
config_dir = str((env or os.environ).get("OPENCODE_CONFIG_DIR") or "").strip()
|
||||
if config_dir:
|
||||
try:
|
||||
Path(config_dir).expanduser().mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Unable to create OPENCODE_CONFIG_DIR {}: {}", config_dir, exc)
|
||||
if str(self.config.approval_mode or "auto").strip().lower() != "full-auto":
|
||||
return env
|
||||
|
||||
merged = dict(os.environ if env is None else env)
|
||||
inline_config: dict[str, object] = {}
|
||||
raw_inline = str(merged.get("OPENCODE_CONFIG_CONTENT") or "").strip()
|
||||
if raw_inline:
|
||||
try:
|
||||
parsed = json.loads(raw_inline)
|
||||
if isinstance(parsed, dict):
|
||||
inline_config = dict(parsed)
|
||||
except Exception:
|
||||
inline_config = {}
|
||||
inline_config["permission"] = "allow"
|
||||
merged["OPENCODE_CONFIG_CONTENT"] = json.dumps(
|
||||
inline_config,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return merged
|
||||
|
||||
def stdin_policy_for_process(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ExternalAgentStdinPolicy:
|
||||
_ = metadata
|
||||
has_permission_handler = any(
|
||||
arg == "--permission-handler" or arg.startswith("--permission-handler=")
|
||||
for arg in cmd
|
||||
)
|
||||
return "pipe_open" if has_permission_handler else "devnull"
|
||||
|
||||
def supports_approval_prompt_handling(
|
||||
self,
|
||||
cmd: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
mode = str(
|
||||
(metadata or {}).get("approval_mode")
|
||||
or self.config.approval_mode
|
||||
or "auto"
|
||||
).strip().lower()
|
||||
if mode == "full-auto" or "--dangerously-skip-permissions" in cmd:
|
||||
return False
|
||||
return self.stdin_policy_for_process(cmd, metadata) == "pipe_open"
|
||||
|
||||
def build_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
_ = workspace_path
|
||||
prompt = self.build_task_prompt(task)
|
||||
command = self._runtime_command()
|
||||
cmd = [
|
||||
command,
|
||||
"run",
|
||||
"--format",
|
||||
"default",
|
||||
*self._build_approval_args(),
|
||||
*self._build_thinking_args(),
|
||||
*self._build_session_args(),
|
||||
*self._build_model_args(),
|
||||
*list(self.config.extra_args),
|
||||
prompt,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata["binary"] = command
|
||||
return cmd, metadata
|
||||
|
||||
def build_interactive_invocation(
|
||||
self,
|
||||
task: Task,
|
||||
workspace_path: str | None = None,
|
||||
) -> tuple[list[str], dict[str, object]]:
|
||||
_ = workspace_path
|
||||
prompt = self.build_task_prompt(task)
|
||||
command = self._runtime_command()
|
||||
cmd = [
|
||||
command,
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
*self._build_approval_args(),
|
||||
*self._build_permission_handler_args(),
|
||||
*self._build_thinking_args(),
|
||||
*self._build_session_args(),
|
||||
*self._build_model_args(),
|
||||
*list(self.config.extra_args),
|
||||
prompt,
|
||||
]
|
||||
metadata = self.build_invocation_metadata(cmd)
|
||||
metadata["binary"] = command
|
||||
return cmd, metadata
|
||||
|
||||
def normalize_result_output(self, output: str) -> str:
|
||||
last_result = ""
|
||||
last_assistant = ""
|
||||
tool_summaries: list[str] = []
|
||||
saw_json_event = False
|
||||
for line in output.splitlines():
|
||||
event = self._parse_json_line(line)
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
saw_json_event = True
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
if event_type in {"result", "session.result", "run.completed"}:
|
||||
text = self._event_text(event)
|
||||
if text:
|
||||
last_result = text
|
||||
elif (
|
||||
"tool" in event_type
|
||||
or "command" in event_type
|
||||
or event_type.startswith("item.")
|
||||
or self._event_part_type(event) == "tool"
|
||||
):
|
||||
summary = self._tool_summary(event)
|
||||
if summary:
|
||||
tool_summaries.append(summary)
|
||||
elif (
|
||||
self._event_role(event) == "assistant"
|
||||
or event_type in {"assistant", "assistant_message", "message", "text"}
|
||||
):
|
||||
text = self._event_text(event)
|
||||
if text:
|
||||
last_assistant = text
|
||||
if last_result or last_assistant:
|
||||
return last_result or last_assistant
|
||||
if saw_json_event:
|
||||
return self._tool_only_result_fallback(tool_summaries)
|
||||
return output
|
||||
|
||||
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
||||
if stream_name != "stdout":
|
||||
stripped = str(text or "").strip()
|
||||
return f"[External:{self.agent_type}:stderr] {stripped[:500]}" if stripped else None
|
||||
|
||||
event = self._parse_json_line(text)
|
||||
if not isinstance(event, dict):
|
||||
return super().format_progress_update(text, stream_name)
|
||||
|
||||
event_type = str(event.get("type") or event.get("event") or "").strip()
|
||||
part = event.get("part") if isinstance(event.get("part"), dict) else {}
|
||||
part_type = str(part.get("type") or "").strip()
|
||||
if event_type == "approval_request":
|
||||
return None
|
||||
if event_type in {"session", "session.started", "init", "step_start", "step-start"}:
|
||||
session_id = self._session_id_from_event(event)
|
||||
return (
|
||||
f"[External:{self.agent_type}:init] session={session_id[:8]}"
|
||||
if session_id
|
||||
else None
|
||||
)
|
||||
if (
|
||||
"tool" in event_type
|
||||
or "command" in event_type
|
||||
or event_type.startswith("item.")
|
||||
or part_type == "tool"
|
||||
):
|
||||
summary = self._tool_summary(event)
|
||||
return f"[External:{self.agent_type}:tool] {summary}" if summary else None
|
||||
if event_type in {"thinking", "reasoning"} or part_type in {"thinking", "reasoning"}:
|
||||
message = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
if event_type in {"result", "session.result", "run.completed"}:
|
||||
result = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {result[:2400]}" if result else None
|
||||
if (
|
||||
self._event_role(event) == "assistant"
|
||||
or event_type in {"assistant", "assistant_message", "message", "text"}
|
||||
):
|
||||
message = self._event_text(event)
|
||||
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
||||
return None
|
||||
|
||||
def detect_runtime_failure(self, text: str, stream_name: str) -> str | None:
|
||||
_ = stream_name
|
||||
lowered = str(text or "").lower()
|
||||
if "permission-handler" in lowered and ("unknown" in lowered or "invalid" in lowered):
|
||||
return "OpenCode rejected the configured stdio permission handler flag."
|
||||
return None
|
||||
|
||||
def parse_approval_request(
|
||||
self,
|
||||
text: str,
|
||||
stream_name: str,
|
||||
) -> ExternalApprovalRequest | None:
|
||||
event = self._parse_json_line(text)
|
||||
if not isinstance(event, dict) or str(event.get("type") or "").strip() != "approval_request":
|
||||
return super().parse_approval_request(text, stream_name)
|
||||
|
||||
permission = event.get("permission")
|
||||
if not isinstance(permission, dict):
|
||||
return super().parse_approval_request(text, stream_name)
|
||||
|
||||
permission_name = str(permission.get("permission") or "").strip()
|
||||
patterns = [str(item).strip() for item in permission.get("patterns") or [] if str(item).strip()]
|
||||
metadata = permission.get("metadata") if isinstance(permission.get("metadata"), dict) else {}
|
||||
common_metadata = {
|
||||
"stream": stream_name,
|
||||
"provider_event_type": "approval_request",
|
||||
"approval_id": str(permission.get("id") or "").strip(),
|
||||
"permission_name": permission_name,
|
||||
"permission_patterns": patterns,
|
||||
"permission_always": list(permission.get("always") or []),
|
||||
"provider_metadata": metadata,
|
||||
"raw_event": event,
|
||||
}
|
||||
|
||||
normalized = self._normalize_name(permission_name)
|
||||
if normalized == "bash":
|
||||
command = self.normalize_shell_command(
|
||||
metadata.get("command") or metadata.get("cmd") or (patterns[0] if patterns else "")
|
||||
)
|
||||
arguments: dict[str, object] = {"command": command} if command else {}
|
||||
working_directory = (
|
||||
metadata.get("workdir") or metadata.get("cwd") or metadata.get("working_directory")
|
||||
)
|
||||
if working_directory:
|
||||
arguments["working_directory"] = str(working_directory)
|
||||
prompt_text = f"Allow OpenCode to run `{command}`?" if command else "Allow OpenCode to run a shell command?"
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="shell_exec",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
path_value = (
|
||||
metadata.get("filepath")
|
||||
or metadata.get("filePath")
|
||||
or metadata.get("path")
|
||||
or (patterns[0] if patterns else "")
|
||||
)
|
||||
if normalized == "edit":
|
||||
arguments = {"path": str(path_value)} if path_value else {}
|
||||
prompt_text = (
|
||||
f"Allow OpenCode to edit `{path_value}`?" if path_value else "Allow OpenCode to edit files?"
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_edit",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "read":
|
||||
arguments = {"path": str(path_value)} if path_value else {}
|
||||
prompt_text = (
|
||||
f"Allow OpenCode to read `{path_value}`?" if path_value else "Allow OpenCode to read files?"
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_read",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "list":
|
||||
arguments = {"path": str(path_value)} if path_value else {}
|
||||
prompt_text = (
|
||||
f"Allow OpenCode to list `{path_value}`?" if path_value else "Allow OpenCode to list files?"
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_list",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "glob":
|
||||
arguments: dict[str, object] = {}
|
||||
if path_value:
|
||||
arguments["path"] = str(path_value)
|
||||
pattern = str(metadata.get("pattern") or (patterns[0] if patterns else "")).strip()
|
||||
if pattern:
|
||||
arguments["query"] = pattern
|
||||
prompt_text = f"Allow OpenCode to glob `{pattern}`?" if pattern else "Allow OpenCode to glob files?"
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_glob",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "grep":
|
||||
arguments = {}
|
||||
if path_value:
|
||||
arguments["path"] = str(path_value)
|
||||
query = str(metadata.get("pattern") or (patterns[0] if patterns else "")).strip()
|
||||
if query:
|
||||
arguments["query"] = query
|
||||
prompt_text = f"Allow OpenCode to grep `{query}`?" if query else "Allow OpenCode to grep files?"
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="tool",
|
||||
action_name="file_search",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
if normalized == "externaldirectory":
|
||||
arguments = {"path": str(patterns[0])} if patterns else {}
|
||||
prompt_text = (
|
||||
f"Allow OpenCode to access `{patterns[0]}` outside the workspace?"
|
||||
if patterns
|
||||
else "Allow OpenCode to access directories outside the workspace?"
|
||||
)
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="external_agent",
|
||||
action_name=f"{self.agent_type}:external_directory",
|
||||
prompt_text=prompt_text,
|
||||
arguments=arguments,
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
prompt_text = permission_name or "permission request"
|
||||
if patterns:
|
||||
prompt_text = f"{prompt_text}: {', '.join(patterns[:3])}"
|
||||
return ExternalApprovalRequest(
|
||||
approval_scope="external_agent",
|
||||
action_name=f"{self.agent_type}:{permission_name or 'permission'}",
|
||||
prompt_text=prompt_text,
|
||||
arguments={"target": patterns[0]} if patterns else {},
|
||||
metadata=common_metadata,
|
||||
raw_text=text,
|
||||
)
|
||||
|
||||
def format_approval_response(
|
||||
self,
|
||||
request: ExternalApprovalRequest,
|
||||
approved: bool,
|
||||
decision: ApprovalDecision,
|
||||
) -> str:
|
||||
approval_id = str(request.metadata.get("approval_id") or "").strip()
|
||||
if not approval_id:
|
||||
return super().format_approval_response(request, approved, decision)
|
||||
|
||||
human_reply = str((decision.metadata or {}).get("human_reply") or "").strip().lower()
|
||||
reply = "reject"
|
||||
if approved:
|
||||
reply = "always" if human_reply in {"always_project", "always_global"} else "once"
|
||||
|
||||
payload = {
|
||||
"type": "approval_response",
|
||||
"permission_id": approval_id,
|
||||
"reply": reply,
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False) + "\n"
|
||||
|
||||
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
||||
if not await self.is_available():
|
||||
return TaskResult(status=TaskStatus.FAILED, content="OpenCode CLI not found")
|
||||
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
||||
|
||||
logger.info(f"OpenCode executing: {task.title}")
|
||||
|
||||
try:
|
||||
stdin_policy = self.stdin_policy_for_process(cmd, metadata)
|
||||
self._record_stdin_policy_metadata(metadata, stdin_policy)
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=self._stdin_target_for_policy(stdin_policy),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=workspace_path,
|
||||
**self._subprocess_group_kwargs(),
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(self._process.communicate(), timeout=600)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if self._process.returncode == 0:
|
||||
return TaskResult(
|
||||
status=TaskStatus.DONE,
|
||||
content=output,
|
||||
artifacts={**metadata, "stderr": errors} if errors else metadata,
|
||||
)
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"OpenCode exited with code {self._process.returncode}\n{errors}\n{output}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if self._process:
|
||||
self._process.kill()
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content="OpenCode timed out after 600s",
|
||||
artifacts=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return TaskResult(
|
||||
status=TaskStatus.FAILED,
|
||||
content=f"OpenCode error: {e}",
|
||||
artifacts=metadata,
|
||||
)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_model_args(self) -> list[str]:
|
||||
if not self.config.model:
|
||||
return []
|
||||
extra_args = list(self.config.extra_args)
|
||||
if self.config.model_flag and any(
|
||||
arg == self.config.model_flag or arg.startswith(f"{self.config.model_flag}=")
|
||||
for arg in extra_args
|
||||
):
|
||||
return []
|
||||
flag = self.config.model_flag or "--model"
|
||||
return [flag, self.config.model]
|
||||
|
||||
def _build_approval_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg == "--dangerously-skip-permissions" for arg in extra_args):
|
||||
return []
|
||||
mode = str(self.config.approval_mode or "auto").strip().lower()
|
||||
if mode == "full-auto":
|
||||
return ["--dangerously-skip-permissions"]
|
||||
return []
|
||||
|
||||
def _build_permission_handler_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(
|
||||
arg == "--permission-handler" or arg.startswith("--permission-handler=")
|
||||
for arg in extra_args
|
||||
):
|
||||
return []
|
||||
if self._supports_stdio_permission_handler():
|
||||
return ["--permission-handler", "stdio-json"]
|
||||
return []
|
||||
|
||||
def _supports_stdio_permission_handler(self) -> bool:
|
||||
command = self.resolve_binary() or shutil.which(self.configured_command()) or self.configured_command()
|
||||
if not command:
|
||||
return False
|
||||
cached = self._permission_handler_support_cache.get(command)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[command, "run", "--help"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
help_text = f"{proc.stdout}\n{proc.stderr}"
|
||||
supported = "--permission-handler" in help_text
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
supported = False
|
||||
self._permission_handler_support_cache[command] = supported
|
||||
return supported
|
||||
|
||||
def _build_thinking_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg == "--thinking" for arg in extra_args):
|
||||
return []
|
||||
if bool(getattr(self.config, "show_thinking", False)):
|
||||
return ["--thinking"]
|
||||
return []
|
||||
|
||||
def _build_session_args(self) -> list[str]:
|
||||
extra_args = list(self.config.extra_args)
|
||||
if any(arg in {"--continue", "--session"} or arg.startswith("--session=") for arg in extra_args):
|
||||
return []
|
||||
|
||||
mode = str(self.config.session_mode or "auto").strip().lower()
|
||||
if mode == "resume":
|
||||
session_id = str(self.config.session_id or "").strip()
|
||||
if session_id:
|
||||
return ["--session", session_id]
|
||||
return ["--continue"]
|
||||
if mode == "new" and self.config.new_session_flag:
|
||||
return [self.config.new_session_flag]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _session_id_from_event(cls, event: dict[str, Any]) -> str:
|
||||
for key in ("sessionID", "sessionId", "session_id", "id"):
|
||||
token = str(event.get(key) or "").strip()
|
||||
if token:
|
||||
return token
|
||||
for key in ("session", "message", "data", "result"):
|
||||
nested = event.get(key)
|
||||
if isinstance(nested, dict):
|
||||
token = cls._session_id_from_event(nested)
|
||||
if token:
|
||||
return token
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _event_role(event: dict[str, Any]) -> str:
|
||||
role = str(event.get("role") or "").strip().lower()
|
||||
if role:
|
||||
return role
|
||||
message = event.get("message")
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("role") or "").strip().lower()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _event_part_type(event: dict[str, Any]) -> str:
|
||||
part = event.get("part")
|
||||
if isinstance(part, dict):
|
||||
return str(part.get("type") or "").strip().lower()
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _event_text(cls, event: Any) -> str:
|
||||
if isinstance(event, str):
|
||||
return event.strip()
|
||||
if isinstance(event, list):
|
||||
parts = [cls._event_text(item) for item in event]
|
||||
return "\n".join(part for part in parts if part).strip()
|
||||
if not isinstance(event, dict):
|
||||
return ""
|
||||
|
||||
for key in ("result", "message", "part", "text", "content", "summary", "output"):
|
||||
value = event.get(key)
|
||||
if key in {"message", "part"} and isinstance(value, dict):
|
||||
nested = cls._event_text(value)
|
||||
if nested:
|
||||
return nested
|
||||
elif isinstance(value, (str, list, dict)):
|
||||
nested = cls._event_text(value)
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _tool_summary(cls, event: dict[str, Any]) -> str:
|
||||
item = event.get("item") if isinstance(event.get("item"), dict) else event
|
||||
part = item.get("part") if isinstance(item.get("part"), dict) else {}
|
||||
if part:
|
||||
tool_name = str(part.get("tool") or part.get("name") or "tool").strip()
|
||||
normalized_tool = cls._normalize_name(tool_name)
|
||||
state = part.get("state") if isinstance(part.get("state"), dict) else {}
|
||||
tool_input = state.get("input") if isinstance(state.get("input"), dict) else {}
|
||||
command = cls.normalize_shell_command(
|
||||
tool_input.get("command") or tool_input.get("cmd") or tool_input.get("argv")
|
||||
)
|
||||
title = str(
|
||||
state.get("title")
|
||||
or tool_input.get("description")
|
||||
or state.get("description")
|
||||
or ""
|
||||
).strip()
|
||||
metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
|
||||
raw_output = str(state.get("output") or metadata.get("output") or "").strip()
|
||||
status = str(state.get("status") or "").strip()
|
||||
lines: list[str] = []
|
||||
if command:
|
||||
lines.append(f"$ {command[:240]}")
|
||||
else:
|
||||
target = str(
|
||||
tool_input.get("path")
|
||||
or tool_input.get("file")
|
||||
or tool_input.get("target")
|
||||
or tool_input.get("pattern")
|
||||
or tool_input.get("query")
|
||||
or tool_input.get("searchTerm")
|
||||
or ""
|
||||
).strip()
|
||||
label = "web search" if normalized_tool in {"websearch", "web_search"} else tool_name
|
||||
lines.append(f"{label}: {target}".strip(": "))
|
||||
if title and title not in lines[0]:
|
||||
lines.append(title[:240])
|
||||
output = cls._summarize_tool_output(tool_name, raw_output)
|
||||
if output:
|
||||
lines.append(output)
|
||||
elif status:
|
||||
lines.append(f"status={status}")
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
source = item.get("tool_call") if isinstance(item.get("tool_call"), dict) else item
|
||||
name = str(
|
||||
source.get("name")
|
||||
or source.get("tool_name")
|
||||
or source.get("toolName")
|
||||
or source.get("type")
|
||||
or "tool"
|
||||
).strip()
|
||||
command = cls.normalize_shell_command(source.get("command"))
|
||||
if command:
|
||||
return f"$ {command[:240]}"
|
||||
args = source.get("input") or source.get("arguments") or source.get("params") or {}
|
||||
if isinstance(args, dict):
|
||||
command = cls.normalize_shell_command(
|
||||
args.get("command") or args.get("cmd") or args.get("argv")
|
||||
)
|
||||
if command:
|
||||
return f"$ {command[:240]}"
|
||||
path = str(args.get("path") or args.get("file_path") or args.get("target") or "").strip()
|
||||
if path:
|
||||
return f"{name} {path}"
|
||||
output = str(source.get("aggregated_output") or source.get("output") or "").strip()
|
||||
if output and name != "tool":
|
||||
return f"{name}: {output[:200]}"
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
def _tool_only_result_fallback(cls, tool_summaries: list[str]) -> str:
|
||||
lines = [
|
||||
"OpenCode completed but did not emit a final assistant message.",
|
||||
"The raw JSON stream was parsed and suppressed from the user-facing reply.",
|
||||
]
|
||||
cleaned = [cls._compact_multiline(item, limit=500) for item in tool_summaries if item]
|
||||
if cleaned:
|
||||
lines.append("")
|
||||
lines.append("Tool activity:")
|
||||
lines.extend(f"- {item}" for item in cleaned[-6:])
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
@classmethod
|
||||
def _summarize_tool_output(cls, tool_name: str, output: str) -> str:
|
||||
normalized_tool = cls._normalize_name(tool_name)
|
||||
if not output:
|
||||
return ""
|
||||
if normalized_tool in {"bash", "shell", "command", "exec"}:
|
||||
return output[:1200]
|
||||
parsed = cls._parse_json_line(output)
|
||||
if isinstance(parsed, dict):
|
||||
results = parsed.get("results")
|
||||
if isinstance(results, list):
|
||||
titles: list[str] = []
|
||||
for item in results[:5]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title") or item.get("url") or "").strip()
|
||||
if title:
|
||||
titles.append(title)
|
||||
if titles:
|
||||
return "results: " + "; ".join(titles)
|
||||
if "url" in parsed or "title" in parsed:
|
||||
return str(parsed.get("title") or parsed.get("url") or "").strip()[:500]
|
||||
return cls._compact_multiline(output, limit=500)
|
||||
|
||||
@staticmethod
|
||||
def _compact_multiline(text: str, *, limit: int) -> str:
|
||||
compact = " ".join(str(text or "").split())
|
||||
if len(compact) <= limit:
|
||||
return compact
|
||||
return compact[: max(0, limit - 1)].rstrip() + "…"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""External agent adapter registry — manages available external agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.config import AgentsConfig
|
||||
from opc.layer3_agent.adapters.base import ExternalAgentAdapter
|
||||
from opc.layer3_agent.adapters.claude_code import ClaudeCodeAdapter
|
||||
from opc.layer3_agent.adapters.cursor_adapter import CursorAdapter
|
||||
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter
|
||||
from opc.layer3_agent.adapters.opencode_adapter import OpenCodeAdapter
|
||||
|
||||
|
||||
ADAPTER_CLASSES: dict[str, type[ExternalAgentAdapter]] = {
|
||||
"claude_code": ClaudeCodeAdapter,
|
||||
"cursor": CursorAdapter,
|
||||
"codex": CodexAdapter,
|
||||
"opencode": OpenCodeAdapter,
|
||||
}
|
||||
|
||||
|
||||
class AdapterRegistry:
|
||||
"""Manages external agent adapters and preferred order."""
|
||||
|
||||
def __init__(self, config: AgentsConfig) -> None:
|
||||
self.config = config
|
||||
self._adapters: dict[str, ExternalAgentAdapter] = {}
|
||||
self._available: dict[str, bool] = {}
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Discover and initialize available external agents."""
|
||||
self._adapters = {}
|
||||
self._available = {}
|
||||
for agent_type, adapter_cls in ADAPTER_CLASSES.items():
|
||||
agent_config = self.config.agents.get(agent_type)
|
||||
adapter = adapter_cls(config=agent_config)
|
||||
self._adapters[agent_type] = adapter
|
||||
if agent_config and not agent_config.enabled:
|
||||
self._available[agent_type] = False
|
||||
logger.info(f"External agent {agent_type}: disabled in config")
|
||||
continue
|
||||
|
||||
available = await adapter.is_available()
|
||||
self._available[agent_type] = available
|
||||
status = "available" if available else "not found"
|
||||
logger.info(f"External agent {agent_type}: {status}")
|
||||
|
||||
def get(self, agent_type: str) -> ExternalAgentAdapter | None:
|
||||
if agent_type in self._adapters and self._available.get(agent_type):
|
||||
return self._adapters[agent_type]
|
||||
return None
|
||||
|
||||
def get_preferred(self) -> ExternalAgentAdapter | None:
|
||||
"""Get the first available adapter from the preferred order."""
|
||||
for agent_type in self.config.preferred_order:
|
||||
adapter = self.get(agent_type)
|
||||
if adapter:
|
||||
return adapter
|
||||
return None
|
||||
|
||||
def get_ordered_available(self) -> list[tuple[str, ExternalAgentAdapter]]:
|
||||
ordered: list[tuple[str, ExternalAgentAdapter]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for agent_type in self.config.preferred_order:
|
||||
adapter = self.get(agent_type)
|
||||
if adapter:
|
||||
ordered.append((agent_type, adapter))
|
||||
seen.add(agent_type)
|
||||
|
||||
for agent_type, adapter in self._adapters.items():
|
||||
if agent_type not in seen and self._available.get(agent_type):
|
||||
ordered.append((agent_type, adapter))
|
||||
|
||||
return ordered
|
||||
|
||||
def list_available(self) -> list[str]:
|
||||
return [k for k, v in self._available.items() if v]
|
||||
|
||||
def list_all(self) -> dict[str, bool]:
|
||||
return dict(self._available)
|
||||
|
||||
def describe_all(self) -> list[dict[str, object]]:
|
||||
profiles: list[dict[str, object]] = []
|
||||
for agent_type, adapter in self._adapters.items():
|
||||
profile = adapter.describe()
|
||||
profile["available"] = self._available.get(agent_type, False)
|
||||
profiles.append(profile)
|
||||
return profiles
|
||||
|
||||
def describe_available(self) -> list[dict[str, object]]:
|
||||
return [p for p in self.describe_all() if p.get("available")]
|
||||
Reference in New Issue
Block a user