Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
"""OpenAI-shape bridge shared by Hermes' ACP clients.
|
||||
|
||||
An ACP agent (``copilot --acp``, and the ACP CLIs that reach Hermes as
|
||||
providers) speaks the Agent Client Protocol, which has no OpenAI-style
|
||||
``tools``/``tool_calls`` channel: a prompt is text, and a response is text plus
|
||||
the agent's *own* tool notifications. Hermes' agentic surface — ``memory``,
|
||||
``todo``, ``skill_manage`` and friends — is dispatched from OpenAI-shaped
|
||||
``tool_calls``, so on an ACP provider it can only work if the schemas travel
|
||||
*into* the prompt as text and the calls are parsed back *out* of the response
|
||||
text.
|
||||
|
||||
``agent/copilot_acp_client.py`` already carried a private copy of that bridge.
|
||||
This module is that code, lifted verbatim into one place so every ACP client
|
||||
shares it instead of re-deriving the wire contract:
|
||||
|
||||
* :func:`render_tool_bridge_sections` — prompt sections describing the
|
||||
forwarded tools and the ``<tool_call>{...}</tool_call>`` contract.
|
||||
* :func:`extract_tool_calls_from_text` — parse those blocks back into
|
||||
``ChatCompletionMessageToolCall`` objects and return the response text with
|
||||
the blocks stripped.
|
||||
* :func:`completion_to_stream_chunks` — re-shape a one-shot ACP response as
|
||||
OpenAI stream chunks for callers that asked for ``stream=True`` (an ACP turn
|
||||
is inherently one-shot from Hermes' perspective).
|
||||
|
||||
The one axis clients differ on is *which* tools they forward, so
|
||||
``render_tool_bridge_sections`` takes an optional allowlist. A CLI with no tools
|
||||
of its own (Copilot) forwards everything Hermes offers; a CLI that is an
|
||||
autonomous agent with its own read/edit/execute tools must forward only Hermes'
|
||||
agent-level tools, because re-offering the overlapping ones makes Hermes re-run
|
||||
work the agent already finished.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# The contract sentence shared by every ACP client: how to emit a call.
|
||||
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):
|
||||
"""Stream chunks that can still carry response-level attributes.
|
||||
|
||||
Hermes reads provider-level extras off the object returned by
|
||||
``chat.completions.create`` (e.g. ``hermes_projected_messages``, consumed by
|
||||
``agent/provider_projection.py``). A plain list of chunks would silently drop
|
||||
them on the ``stream=True`` path, so ACP clients return this instead and copy
|
||||
the extras onto it.
|
||||
"""
|
||||
|
||||
|
||||
def completion_to_stream_chunks(completion: SimpleNamespace) -> StreamChunks:
|
||||
"""Convert a one-shot ACP response into OpenAI-style stream chunks.
|
||||
|
||||
Response-level attributes other than ``choices``/``usage``/``model`` are
|
||||
copied onto the returned object so nothing a caller reads off the completion
|
||||
is lost when it asked to stream.
|
||||
"""
|
||||
choice = completion.choices[0]
|
||||
message = choice.message
|
||||
tool_call_deltas = None
|
||||
if message.tool_calls:
|
||||
tool_call_deltas = []
|
||||
for index, tool_call in enumerate(message.tool_calls):
|
||||
tool_call_deltas.append(
|
||||
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),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
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. When ``allowlist`` is given, only tools whose
|
||||
name is in it survive — that is how a client forwards just Hermes'
|
||||
agent-level tools instead of the whole toolset.
|
||||
"""
|
||||
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 []:
|
||||
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
|
||||
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 that carry the forwarded tool schemas + choice hint.
|
||||
|
||||
Returns an empty list when no tool survives filtering and no choice hint was
|
||||
requested, so callers can splice the result into their section list
|
||||
unconditionally.
|
||||
"""
|
||||
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)`` where ``cleaned_text`` is the
|
||||
response with the consumed blocks removed, so the assistant message doesn't
|
||||
show raw JSON to the user.
|
||||
"""
|
||||
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
|
||||
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(
|
||||
build_openai_tool_call(
|
||||
call_id=call_id,
|
||||
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
|
||||
Reference in New Issue
Block a user