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