@cotal-ai/connector-hermes 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/bin/install.mjs +99 -0
- package/dist/bridge.d.ts +7 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +199 -0
- package/dist/bridge.js.map +1 -0
- package/dist/extension.d.ts +13 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +49 -0
- package/dist/extension.js.map +1 -0
- package/dist/hermes-hooks.d.ts +15 -0
- package/dist/hermes-hooks.d.ts.map +1 -0
- package/dist/hermes-hooks.js +51 -0
- package/dist/hermes-hooks.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +2 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +149 -0
- package/dist/launch.js.map +1 -0
- package/dist/sidecar.d.ts +10 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +60 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/standalone.d.ts +2 -0
- package/dist/standalone.d.ts.map +1 -0
- package/dist/standalone.js +31 -0
- package/dist/standalone.js.map +1 -0
- package/dist/tool-schema.d.ts +10 -0
- package/dist/tool-schema.d.ts.map +1 -0
- package/dist/tool-schema.js +34 -0
- package/dist/tool-schema.js.map +1 -0
- package/package.json +51 -0
- package/plugin/cotal/__init__.py +148 -0
- package/plugin/cotal/_sidecar/standalone.cjs +33188 -0
- package/plugin/cotal/adapter.py +114 -0
- package/plugin/cotal/bridge_client.py +153 -0
- package/plugin/cotal/hooks.py +74 -0
- package/plugin/cotal/plugin.yaml +10 -0
- package/plugin/cotal/tools.py +62 -0
- package/pyproject.toml +19 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Cotal gateway platform adapter.
|
|
2
|
+
|
|
3
|
+
Inbound: the sidecar pushes mesh messages over the bridge; the adapter builds a ``MessageEvent``
|
|
4
|
+
and calls ``handle_message`` — which wakes an idle session or **queues + interrupts a running one**
|
|
5
|
+
(the gateway's own busy handling), so a peer can DRIVE a live turn, not just leave a message.
|
|
6
|
+
Outbound: the gateway hands a turn's reply to ``send()``, which the adapter routes back to that
|
|
7
|
+
message's mesh origin (the channel it came in on, or a DM to the sender).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from gateway.platforms.base import (
|
|
16
|
+
BasePlatformAdapter,
|
|
17
|
+
MessageEvent,
|
|
18
|
+
MessageType,
|
|
19
|
+
SendResult,
|
|
20
|
+
)
|
|
21
|
+
from gateway.config import Platform, PlatformConfig
|
|
22
|
+
|
|
23
|
+
from . import hooks
|
|
24
|
+
from .bridge_client import get_client
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _target_for(chat_id: str) -> dict:
|
|
28
|
+
"""Reverse the chat_id minted on inbound back into a mesh reply target."""
|
|
29
|
+
if chat_id.startswith("channel:"):
|
|
30
|
+
return {"channel": chat_id[len("channel:"):]}
|
|
31
|
+
if chat_id.startswith("dm:"):
|
|
32
|
+
return {"peerId": chat_id[len("dm:"):]}
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CotalAdapter(BasePlatformAdapter):
|
|
37
|
+
def __init__(self, config: PlatformConfig) -> None:
|
|
38
|
+
super().__init__(config, Platform("cotal"))
|
|
39
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
40
|
+
self._client = get_client()
|
|
41
|
+
|
|
42
|
+
async def connect(self) -> bool:
|
|
43
|
+
self._loop = asyncio.get_running_loop()
|
|
44
|
+
self._client.start(self._on_incoming) # reader thread → _on_incoming
|
|
45
|
+
self._mark_connected()
|
|
46
|
+
hooks.relay("gateway_startup") # present + free
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
async def disconnect(self) -> None:
|
|
50
|
+
hooks.relay("gateway_shutdown")
|
|
51
|
+
self._client.close()
|
|
52
|
+
self._mark_disconnected()
|
|
53
|
+
|
|
54
|
+
async def send(
|
|
55
|
+
self, chat_id: str, content: str, reply_to: Any = None, metadata: Any = None
|
|
56
|
+
) -> SendResult:
|
|
57
|
+
# The gateway delivers a turn's reply here → route it back to the message's mesh origin.
|
|
58
|
+
self._client.reply(_target_for(chat_id), content)
|
|
59
|
+
return SendResult(success=True, message_id=uuid.uuid4().hex)
|
|
60
|
+
|
|
61
|
+
async def get_chat_info(self, chat_id: str) -> dict:
|
|
62
|
+
if chat_id.startswith("channel:"):
|
|
63
|
+
return {"name": "#" + chat_id[len("channel:"):], "type": "group"}
|
|
64
|
+
return {"name": chat_id, "type": "dm"}
|
|
65
|
+
|
|
66
|
+
# ---- inbound (bridge reader thread → gateway loop) -----------------------
|
|
67
|
+
|
|
68
|
+
def _on_incoming(self, msg: dict) -> None:
|
|
69
|
+
"""Called off-loop by the bridge reader; hop onto the gateway loop to inject the turn."""
|
|
70
|
+
loop = self._loop
|
|
71
|
+
if loop is None:
|
|
72
|
+
return
|
|
73
|
+
fut = asyncio.run_coroutine_threadsafe(self._inject(msg), loop)
|
|
74
|
+
fut.add_done_callback(lambda f: self._maybe_ack(msg, f))
|
|
75
|
+
|
|
76
|
+
def _maybe_ack(self, msg: dict, fut: Any) -> None:
|
|
77
|
+
"""Ack a message on the mesh stream once it has been surfaced into a turn.
|
|
78
|
+
|
|
79
|
+
VALIDATION GATE (open confirmation #4): ``handle_message`` returning means the event was
|
|
80
|
+
*queued*, not necessarily *consumed into a turn*. On the pinned Hermes line, confirm the
|
|
81
|
+
completion point and, if needed, move this ack to a real processing-complete hook/wrapper —
|
|
82
|
+
do NOT relax it to ack-on-queue (a mesh message that never reaches the model must redeliver
|
|
83
|
+
after a crash). Until proven, this acks on the inject coroutine completing without error.
|
|
84
|
+
"""
|
|
85
|
+
mid = msg.get("id")
|
|
86
|
+
if mid and not fut.cancelled() and fut.exception() is None:
|
|
87
|
+
self._client.delivered(mid)
|
|
88
|
+
|
|
89
|
+
async def _inject(self, msg: dict) -> None:
|
|
90
|
+
kind = msg.get("kind")
|
|
91
|
+
sender = msg.get("fromName") or "peer"
|
|
92
|
+
role = msg.get("fromRole")
|
|
93
|
+
tag = f"[{kind} from {sender}{f' / {role}' if role else ''}] "
|
|
94
|
+
|
|
95
|
+
if kind == "channel":
|
|
96
|
+
ch = msg.get("channel") or "general"
|
|
97
|
+
chat_id, chat_type, chat_name = f"channel:{ch}", "group", f"#{ch}"
|
|
98
|
+
else: # dm / anycast → a turn whose reply goes straight back to the sender
|
|
99
|
+
chat_id, chat_type, chat_name = f"dm:{msg.get('fromId')}", "dm", sender
|
|
100
|
+
|
|
101
|
+
source = self.build_source(
|
|
102
|
+
chat_id=chat_id,
|
|
103
|
+
chat_name=chat_name,
|
|
104
|
+
chat_type=chat_type,
|
|
105
|
+
user_id=msg.get("fromId"),
|
|
106
|
+
user_name=sender,
|
|
107
|
+
)
|
|
108
|
+
event = MessageEvent(
|
|
109
|
+
text=tag + (msg.get("text") or ""),
|
|
110
|
+
message_type=MessageType.TEXT,
|
|
111
|
+
source=source,
|
|
112
|
+
message_id=msg.get("id"),
|
|
113
|
+
)
|
|
114
|
+
await self.handle_message(event)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Persistent client to the Cotal sidecar's bridge socket.
|
|
2
|
+
|
|
3
|
+
The sidecar (``src/sidecar.ts``) owns the mesh endpoint and exposes a unix-socket bridge; this is
|
|
4
|
+
the in-gateway half. One background thread owns a blocking ``AF_UNIX`` connection and dispatches
|
|
5
|
+
frames — inbound mesh messages go to the adapter's callback, tool results resolve pending calls.
|
|
6
|
+
Writes are newline-delimited JSON under a lock; it reconnects with backoff so a sidecar restart
|
|
7
|
+
self-heals (re-subscribing on every (re)connect). Wire format mirrors ``src/bridge.ts``.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
_BACKOFF_S = 2.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BridgeClient:
|
|
23
|
+
def __init__(self, socket_path: str) -> None:
|
|
24
|
+
self._path = socket_path
|
|
25
|
+
self._sock: Optional[socket.socket] = None
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self._pending: dict[str, tuple[threading.Event, dict]] = {}
|
|
28
|
+
self._on_incoming: Optional[Callable[[dict], None]] = None
|
|
29
|
+
self._stop = threading.Event()
|
|
30
|
+
self._reader: Optional[threading.Thread] = None
|
|
31
|
+
|
|
32
|
+
def start(self, on_incoming: Callable[[dict], None]) -> None:
|
|
33
|
+
"""Begin the reader thread. ``on_incoming`` is called (off-loop) for each mesh message."""
|
|
34
|
+
self._on_incoming = on_incoming
|
|
35
|
+
if self._reader is None:
|
|
36
|
+
self._reader = threading.Thread(target=self._run, name="cotal-bridge", daemon=True)
|
|
37
|
+
self._reader.start()
|
|
38
|
+
|
|
39
|
+
# ---- reader thread -------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def _run(self) -> None:
|
|
42
|
+
buf = b""
|
|
43
|
+
while not self._stop.is_set():
|
|
44
|
+
if self._sock is None:
|
|
45
|
+
self._connect()
|
|
46
|
+
if self._sock is None:
|
|
47
|
+
continue
|
|
48
|
+
self._send({"t": "subscribe"}) # (re)subscribe after every (re)connect
|
|
49
|
+
try:
|
|
50
|
+
data = self._sock.recv(65536)
|
|
51
|
+
except OSError:
|
|
52
|
+
data = b""
|
|
53
|
+
if not data:
|
|
54
|
+
with self._lock:
|
|
55
|
+
self._sock = None
|
|
56
|
+
continue
|
|
57
|
+
buf += data
|
|
58
|
+
while b"\n" in buf:
|
|
59
|
+
line, buf = buf.split(b"\n", 1)
|
|
60
|
+
if line.strip():
|
|
61
|
+
self._dispatch(line)
|
|
62
|
+
|
|
63
|
+
def _connect(self) -> None:
|
|
64
|
+
while not self._stop.is_set():
|
|
65
|
+
try:
|
|
66
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
67
|
+
s.connect(self._path)
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._sock = s
|
|
70
|
+
return
|
|
71
|
+
except OSError:
|
|
72
|
+
time.sleep(_BACKOFF_S)
|
|
73
|
+
|
|
74
|
+
def _dispatch(self, line: bytes) -> None:
|
|
75
|
+
try:
|
|
76
|
+
frame = json.loads(line)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return
|
|
79
|
+
t = frame.get("t")
|
|
80
|
+
if t == "incoming":
|
|
81
|
+
cb = self._on_incoming
|
|
82
|
+
if cb:
|
|
83
|
+
cb(frame.get("msg") or {})
|
|
84
|
+
elif t == "tool_result":
|
|
85
|
+
entry = self._pending.get(frame.get("id"))
|
|
86
|
+
if entry:
|
|
87
|
+
entry[1].update(frame)
|
|
88
|
+
entry[0].set()
|
|
89
|
+
|
|
90
|
+
# ---- writes --------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _send(self, frame: dict) -> None:
|
|
93
|
+
data = (json.dumps(frame) + "\n").encode()
|
|
94
|
+
with self._lock:
|
|
95
|
+
if self._sock is None:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
self._sock.sendall(data)
|
|
99
|
+
except OSError:
|
|
100
|
+
self._sock = None
|
|
101
|
+
|
|
102
|
+
def delivered(self, msg_id: str) -> None:
|
|
103
|
+
"""Ack a message on the stream — call only once it has been surfaced into a turn."""
|
|
104
|
+
self._send({"t": "delivered", "id": msg_id})
|
|
105
|
+
|
|
106
|
+
def reply(self, target: dict, text: str) -> None:
|
|
107
|
+
"""Route a turn's reply back to its mesh origin (channel broadcast or DM to the sender)."""
|
|
108
|
+
self._send({"t": "reply", "target": target, "text": text})
|
|
109
|
+
|
|
110
|
+
def call_tool(self, name: str, args: dict, timeout: float = 30.0) -> str:
|
|
111
|
+
"""Invoke a cotal_* tool on the sidecar and block for its text result (raises on transport
|
|
112
|
+
error/timeout). The sidecar runs the shared spec, so the text is already model-ready; an
|
|
113
|
+
in-tool logical error comes back flagged and is prefixed for the model."""
|
|
114
|
+
rid = uuid.uuid4().hex
|
|
115
|
+
ev = threading.Event()
|
|
116
|
+
box: dict = {}
|
|
117
|
+
self._pending[rid] = (ev, box)
|
|
118
|
+
try:
|
|
119
|
+
self._send({"t": "tool", "id": rid, "name": name, "args": args})
|
|
120
|
+
if not ev.wait(timeout):
|
|
121
|
+
raise TimeoutError(f"cotal tool '{name}' timed out")
|
|
122
|
+
if not box.get("ok"):
|
|
123
|
+
raise RuntimeError(box.get("error") or "tool failed")
|
|
124
|
+
text = box.get("text") or ""
|
|
125
|
+
return f"⚠ {text}" if box.get("isError") else text
|
|
126
|
+
finally:
|
|
127
|
+
self._pending.pop(rid, None)
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
self._stop.set()
|
|
131
|
+
with self._lock:
|
|
132
|
+
if self._sock is not None:
|
|
133
|
+
try:
|
|
134
|
+
self._sock.close()
|
|
135
|
+
except OSError:
|
|
136
|
+
pass
|
|
137
|
+
self._sock = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_client: Optional[BridgeClient] = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_client() -> BridgeClient:
|
|
144
|
+
"""Process-wide singleton, bound to ``COTAL_BRIDGE_SOCKET`` (set by the launcher/bootstrap)."""
|
|
145
|
+
global _client
|
|
146
|
+
if _client is None:
|
|
147
|
+
path = os.environ.get("COTAL_BRIDGE_SOCKET")
|
|
148
|
+
if not path:
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
"COTAL_BRIDGE_SOCKET not set — the Cotal launcher or standalone bootstrap must set it"
|
|
151
|
+
)
|
|
152
|
+
_client = BridgeClient(path)
|
|
153
|
+
return _client
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Hermes lifecycle hooks → Cotal presence (the relay.ts pattern, in Python).
|
|
2
|
+
|
|
3
|
+
Each hook makes a one-shot connection to connector-core's control socket
|
|
4
|
+
(``COTAL_CONTROL_SOCKET``), sends ``{"hook_event_name": ...}``, and ignores the reply — the TS
|
|
5
|
+
``hermesHookHandle`` turns it into a presence change. Hooks must never block the gateway, so the
|
|
6
|
+
connection has a short timeout and every error is swallowed.
|
|
7
|
+
|
|
8
|
+
Hermes hook callback signatures vary by version; these take ``*args, **kwargs`` and best-effort
|
|
9
|
+
extract what they need, so a signature change degrades to "no detail" rather than an exception.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import socket
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_TIMEOUT_S = 2.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def relay(event_name: str, **fields: Any) -> None:
|
|
22
|
+
"""Forward one lifecycle event to the connector's control socket; fire-and-forget."""
|
|
23
|
+
path = os.environ.get("COTAL_CONTROL_SOCKET")
|
|
24
|
+
if not path:
|
|
25
|
+
return
|
|
26
|
+
payload = {"hook_event_name": event_name, **fields}
|
|
27
|
+
try:
|
|
28
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
29
|
+
s.settimeout(_TIMEOUT_S)
|
|
30
|
+
s.connect(path)
|
|
31
|
+
s.sendall((json.dumps(payload) + "\n").encode())
|
|
32
|
+
try:
|
|
33
|
+
s.recv(65536) # read + discard the reply
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
s.close()
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_tool(args: tuple, kwargs: dict) -> tuple[str, Any]:
|
|
42
|
+
"""Best-effort tool name + input from whatever Hermes passes the pre_tool_call hook."""
|
|
43
|
+
ctx: dict = {}
|
|
44
|
+
for a in args:
|
|
45
|
+
if isinstance(a, dict):
|
|
46
|
+
ctx = a
|
|
47
|
+
break
|
|
48
|
+
ctx = {**ctx, **kwargs}
|
|
49
|
+
name = ctx.get("tool_name") or ctx.get("name") or ctx.get("tool") or ""
|
|
50
|
+
inp = ctx.get("tool_input") or ctx.get("arguments") or ctx.get("input") or ctx.get("args")
|
|
51
|
+
return str(name), inp
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---- hook callbacks (registered in __init__.register) -----------------------
|
|
55
|
+
|
|
56
|
+
def on_session_start(*args: Any, **kwargs: Any) -> None:
|
|
57
|
+
relay("on_session_start")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pre_llm_call(*args: Any, **kwargs: Any) -> None:
|
|
61
|
+
relay("pre_llm_call")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pre_tool_call(*args: Any, **kwargs: Any) -> None:
|
|
65
|
+
name, inp = _extract_tool(args, kwargs)
|
|
66
|
+
relay("pre_tool_call", tool_name=name, tool_input=inp)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def post_llm_call(*args: Any, **kwargs: Any) -> None:
|
|
70
|
+
relay("post_llm_call")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def on_session_end(*args: Any, **kwargs: Any) -> None:
|
|
74
|
+
relay("on_session_end")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
name: cotal
|
|
2
|
+
label: Cotal
|
|
3
|
+
kind: platform
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
description: Join the Cotal mesh — coordinate with other AI agents as lateral peers over NATS.
|
|
6
|
+
author: Cotal
|
|
7
|
+
# COTAL_BRIDGE_SOCKET is intentionally NOT declared as a required/prompted env var: in managed
|
|
8
|
+
# mode the Cotal launcher always presets it, and in standalone mode (a user's own `hermes`) the
|
|
9
|
+
# plugin spawns the bundled sidecar itself and derives it — see __init__.register(). The platform's
|
|
10
|
+
# check_fn gates enablement instead, so an unrelated gateway never gets prompted for it.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""cotal_* tools — the deliberate, proactive mesh actions, exposed to the Hermes agent.
|
|
2
|
+
|
|
3
|
+
A turn's *reply* is delivered automatically (the adapter routes it back to whoever messaged), so
|
|
4
|
+
these tools are for reaching OTHER peers/channels, seeing who's around, reporting status, and
|
|
5
|
+
growing the team. We do NOT hand-write the list: the TS sidecar renders it once from the shared
|
|
6
|
+
``cotalToolSpecs`` and writes the descriptors to ``COTAL_TOOLS_FILE``; this reads that file and
|
|
7
|
+
registers each as a Hermes plugin tool whose handler forwards the call (by name) over the bridge
|
|
8
|
+
and returns the sidecar's already-formatted text result.
|
|
9
|
+
|
|
10
|
+
The exact ``ctx.register_tool`` schema/handler contract is the Hermes 0.16 plugin API; adjust the
|
|
11
|
+
``_spec`` shape if a pinned version differs.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from .bridge_client import get_client
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _spec(descriptor: dict) -> dict:
|
|
23
|
+
"""A Hermes tool spec from a sidecar descriptor ({name, description, parameters})."""
|
|
24
|
+
params = descriptor.get("parameters") or {"type": "object", "properties": {}, "required": []}
|
|
25
|
+
return {
|
|
26
|
+
"name": descriptor["name"],
|
|
27
|
+
"description": descriptor.get("description", ""),
|
|
28
|
+
"parameters": params,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _handler(name: str) -> Callable[[dict], str]:
|
|
33
|
+
"""Forward a tool call to the sidecar; the sidecar runs the shared spec and returns the text."""
|
|
34
|
+
def run(args: dict) -> str:
|
|
35
|
+
try:
|
|
36
|
+
return get_client().call_tool(name, args or {})
|
|
37
|
+
except Exception as e: # surfaced back to the model as the tool result
|
|
38
|
+
return f"cotal error: {e}"
|
|
39
|
+
|
|
40
|
+
return run
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_descriptors() -> list[dict]:
|
|
44
|
+
path = os.environ.get("COTAL_TOOLS_FILE")
|
|
45
|
+
if not path:
|
|
46
|
+
raise RuntimeError("COTAL_TOOLS_FILE not set — the sidecar must publish the tool descriptors")
|
|
47
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
48
|
+
data = json.load(f)
|
|
49
|
+
if not isinstance(data, list):
|
|
50
|
+
raise RuntimeError(f"COTAL_TOOLS_FILE {path} did not contain a tool list")
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register_tools(ctx: Any) -> None:
|
|
55
|
+
for descriptor in _load_descriptors():
|
|
56
|
+
name = descriptor["name"]
|
|
57
|
+
ctx.register_tool(
|
|
58
|
+
name=name,
|
|
59
|
+
toolset="cotal",
|
|
60
|
+
schema=_spec(descriptor),
|
|
61
|
+
handler=_handler(name),
|
|
62
|
+
)
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# The managed launcher runs the gateway via `uv run --project <this dir> hermes gateway run`,
|
|
2
|
+
# so uv provisions an env that provides the `hermes` CLI. This is NOT a Python package itself
|
|
3
|
+
# (the plugin dir is copied into HERMES_HOME at launch) — `package = false` keeps uv from
|
|
4
|
+
# building it.
|
|
5
|
+
#
|
|
6
|
+
# Pin: the connector is written against the Hermes 0.16 plugin/platform API. The TS sidecar
|
|
7
|
+
# asserts the installed version is on that line at startup and fails loudly on a mismatch
|
|
8
|
+
# (no silent degrade) — keep this pin and the assertion in src/launch.ts in sync.
|
|
9
|
+
[project]
|
|
10
|
+
name = "cotal-connector-hermes"
|
|
11
|
+
version = "0.1.0"
|
|
12
|
+
description = "Run the Hermes (Nous Research) agent as a Cotal mesh peer."
|
|
13
|
+
requires-python = ">=3.11,<3.14"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"hermes-agent>=0.16,<0.17",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.uv]
|
|
19
|
+
package = false
|