Initial commit
This commit is contained in:
@@ -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() + "…"
|
||||
Reference in New Issue
Block a user