"""OpenAI-shape bridge shared by Hermes' ACP clients. ACP has no OpenAI-style ``tools``/``tool_calls`` channel, so Hermes' tool schemas travel INTO the prompt as text (:func:`render_tool_bridge_sections`) and calls are parsed back OUT of the response text (:func:`extract_tool_calls_from_text`). Clients differ only in WHICH tools they forward (``allowlist``): a CLI with no tools of its own forwards everything; an autonomous agent with its own read/edit/execute tools forwards only Hermes' agent-level tools, since re-offering overlapping ones makes Hermes redo finished work. """ from __future__ import annotations import json import re from types import SimpleNamespace from typing import Any, Iterable from openai.types.chat.chat_completion_message_tool_call import ( ChatCompletionMessageToolCall, Function, ) 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, ) TOOL_CALL_CONTRACT = ( "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." ) __all__ = [ "TOOL_CALL_BLOCK_RE", "TOOL_CALL_JSON_RE", "TOOL_CALL_CONTRACT", "StreamChunks", "build_openai_tool_call", "tool_specs_from_openai_tools", "render_tool_bridge_sections", "extract_tool_calls_from_text", "completion_to_stream_chunks", ] class StreamChunks(list): """Chunk list that also carries response-level attributes (e.g. ``hermes_projected_messages``) Hermes reads off the ``create`` result; a plain list would drop them on the stream path.""" def completion_to_stream_chunks(completion: SimpleNamespace) -> StreamChunks: """Re-shape a one-shot ACP response as OpenAI stream chunks (data chunk + usage chunk). Response-level attributes other than choices/usage/model are copied onto the result. """ choice = completion.choices[0] message = choice.message tool_call_deltas = None if message.tool_calls: tool_call_deltas = [ SimpleNamespace( index=index, id=getattr(tool_call, "id", None), type=getattr(tool_call, "type", "function"), function=SimpleNamespace( name=getattr(tool_call.function, "name", None), arguments=getattr(tool_call.function, "arguments", None), ), ) for index, tool_call in enumerate(message.tool_calls) ] delta = SimpleNamespace( role="assistant", content=message.content or None, tool_calls=tool_call_deltas, reasoning_content=getattr(message, "reasoning_content", None), reasoning=getattr(message, "reasoning", None), ) data_chunk = SimpleNamespace( choices=[SimpleNamespace(index=0, delta=delta, finish_reason=choice.finish_reason)], model=completion.model, usage=None, ) usage_chunk = SimpleNamespace(choices=[], model=completion.model, usage=completion.usage) chunks = StreamChunks([data_chunk, usage_chunk]) for key, value in vars(completion).items(): if key not in ("choices", "usage", "model"): setattr(chunks, key, value) return chunks def build_openai_tool_call(*, call_id: str, name: str, arguments: str) -> ChatCompletionMessageToolCall: """Build an OpenAI-compatible tool-call object for downstream handling.""" return ChatCompletionMessageToolCall( id=call_id, call_id=call_id, response_item_id=None, type="function", function=Function(name=name, arguments=arguments), ) def tool_specs_from_openai_tools( tools: list[dict[str, Any]] | None, *, allowlist: Iterable[str] | None = None, ) -> list[dict[str, Any]]: """Flatten OpenAI ``tools`` into ``{name, description, parameters}`` specs; malformed entries are skipped.""" allowed = {str(n).strip() for n in allowlist} if allowlist is not None else None specs: list[dict[str, Any]] = [] for t in tools or []: fn = t.get("function") or {} if isinstance(t, dict) else None if not isinstance(fn, dict): continue name = fn.get("name") if not isinstance(name, str) or not name.strip(): continue name = name.strip() if allowed is not None and name not in allowed: continue specs.append({"name": name, "description": fn.get("description", ""), "parameters": fn.get("parameters", {})}) return specs def render_tool_bridge_sections( tools: list[dict[str, Any]] | None, tool_choice: Any = None, *, allowlist: Iterable[str] | None = None, ) -> list[str]: """Prompt sections carrying the forwarded tool schemas + choice hint (empty list when neither applies).""" specs = tool_specs_from_openai_tools(tools, allowlist=allowlist) sections: list[str] = [] if specs: sections.append(TOOL_CALL_CONTRACT + "\n" + json.dumps(specs, ensure_ascii=False)) if tool_choice is not None: sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}") return sections def extract_tool_calls_from_text(text: str) -> tuple[list[ChatCompletionMessageToolCall], str]: """Pull ```` blocks out of an ACP response. Returns ``(tool_calls, cleaned_text)`` with the consumed blocks removed so the assistant message doesn't show raw JSON. Bare-JSON fallback runs only when no XML block parsed. """ if not isinstance(text, str) or not text.strip(): return [], "" extracted: list[ChatCompletionMessageToolCall] = [] consumed_spans: list[tuple[int, int]] = [] def _try_add_tool_call(raw_json: str) -> None: try: obj = json.loads(raw_json) except Exception: return fn = obj.get("function") if isinstance(obj, dict) else None 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(build_openai_tool_call(call_id=call_id, name=fn_name.strip(), arguments=fn_args)) for m in TOOL_CALL_BLOCK_RE.finditer(text): _try_add_tool_call(m.group(1)) consumed_spans.append((m.start(), m.end())) if not extracted: for m in TOOL_CALL_JSON_RE.finditer(text): _try_add_tool_call(m.group(0)) 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