diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index a673e059c..235fd9a1a 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import os import queue +import re import shlex import subprocess import threading @@ -23,6 +24,9 @@ from typing import Any ACP_MARKER_BASE_URL = "acp://copilot" _DEFAULT_TIMEOUT_SECONDS = 900.0 +_TOOL_CALL_BLOCK_RE = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL) +_TOOL_CALL_JSON_RE = re.compile(r"\{\s*\"id\"\s*:\s*\"[^\"]+\"\s*,\s*\"type\"\s*:\s*\"function\"\s*,\s*\"function\"\s*:\s*\{.*?\}\s*\}", re.DOTALL) + def _resolve_command() -> str: return ( @@ -50,15 +54,50 @@ def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]: } -def _format_messages_as_prompt(messages: list[dict[str, Any]], model: str | None = None) -> str: +def _format_messages_as_prompt( + messages: list[dict[str, Any]], + model: str | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, +) -> str: sections: list[str] = [ "You are being used as the active ACP agent backend for Hermes.", - "Use your own ACP capabilities and respond directly in natural language.", - "Do not emit OpenAI tool-call JSON.", + "Use ACP capabilities to complete tasks.", + "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.", + "If no tool is needed, answer normally.", ] if model: sections.append(f"Hermes requested model hint: {model}") + if isinstance(tools, list) and tools: + tool_specs: list[dict[str, Any]] = [] + for t in tools: + if not isinstance(t, dict): + continue + fn = t.get("function") or {} + if not isinstance(fn, dict): + continue + name = fn.get("name") + if not isinstance(name, str) or not name.strip(): + continue + tool_specs.append( + { + "name": name.strip(), + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + } + ) + if tool_specs: + sections.append( + "Available tools (OpenAI function schema). " + "When using a tool, emit ONLY {...} with one JSON object " + "containing id/type/function{name,arguments}. arguments must be a JSON string.\n" + + json.dumps(tool_specs, ensure_ascii=False) + ) + + if tool_choice is not None: + sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}") + transcript: list[str] = [] for message in messages: if not isinstance(message, dict): @@ -114,6 +153,80 @@ def _render_message_content(content: Any) -> str: return str(content).strip() +def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]: + if not isinstance(text, str) or not text.strip(): + return [], "" + + extracted: list[SimpleNamespace] = [] + consumed_spans: list[tuple[int, int]] = [] + + def _try_add_tool_call(raw_json: str) -> None: + try: + obj = json.loads(raw_json) + except Exception: + return + if not isinstance(obj, dict): + return + fn = obj.get("function") + if not isinstance(fn, dict): + return + fn_name = fn.get("name") + if not isinstance(fn_name, str) or not fn_name.strip(): + return + fn_args = fn.get("arguments", "{}") + if not isinstance(fn_args, str): + fn_args = json.dumps(fn_args, ensure_ascii=False) + call_id = obj.get("id") + if not isinstance(call_id, str) or not call_id.strip(): + call_id = f"acp_call_{len(extracted)+1}" + + extracted.append( + SimpleNamespace( + id=call_id, + call_id=call_id, + response_item_id=None, + type="function", + function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args), + ) + ) + + for m in _TOOL_CALL_BLOCK_RE.finditer(text): + raw = m.group(1) + _try_add_tool_call(raw) + consumed_spans.append((m.start(), m.end())) + + # Only try bare-JSON fallback when no XML blocks were found. + if not extracted: + for m in _TOOL_CALL_JSON_RE.finditer(text): + raw = m.group(0) + _try_add_tool_call(raw) + consumed_spans.append((m.start(), m.end())) + + if not consumed_spans: + return extracted, text.strip() + + consumed_spans.sort() + merged: list[tuple[int, int]] = [] + for start, end in consumed_spans: + if not merged or start > merged[-1][1]: + merged.append((start, end)) + else: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + + parts: list[str] = [] + cursor = 0 + for start, end in merged: + if cursor < start: + parts.append(text[cursor:start]) + cursor = max(cursor, end) + if cursor < len(text): + parts.append(text[cursor:]) + + cleaned = "\n".join(p.strip() for p in parts if p and p.strip()).strip() + return extracted, cleaned + + + def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path: candidate = Path(path_text) if not candidate.is_absolute(): @@ -190,14 +303,23 @@ class CopilotACPClient: model: str | None = None, messages: list[dict[str, Any]] | None = None, timeout: float | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, **_: Any, ) -> Any: - prompt_text = _format_messages_as_prompt(messages or [], model=model) + prompt_text = _format_messages_as_prompt( + messages or [], + model=model, + tools=tools, + tool_choice=tool_choice, + ) response_text, reasoning_text = self._run_prompt( prompt_text, timeout_seconds=float(timeout or _DEFAULT_TIMEOUT_SECONDS), ) + tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text) + usage = SimpleNamespace( prompt_tokens=0, completion_tokens=0, @@ -205,13 +327,14 @@ class CopilotACPClient: prompt_tokens_details=SimpleNamespace(cached_tokens=0), ) assistant_message = SimpleNamespace( - content=response_text, - tool_calls=[], + content=cleaned_text, + tool_calls=tool_calls, reasoning=reasoning_text or None, reasoning_content=reasoning_text or None, reasoning_details=None, ) - choice = SimpleNamespace(message=assistant_message, finish_reason="stop") + finish_reason = "tool_calls" if tool_calls else "stop" + choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason) return SimpleNamespace( choices=[choice], usage=usage,