204 lines
7.5 KiB
Python
204 lines
7.5 KiB
Python
"""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"<tool_call>\s*(\{.*?\})\s*</tool_call>", 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 <tool_call>{...}</tool_call> 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 ``<tool_call>`` 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
|