830 lines
32 KiB
Python
830 lines
32 KiB
Python
"""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() + "…"
|