opencomputer 0.1.0__py3-none-any.whl
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.
- opencomputer/__init__.py +3 -0
- opencomputer/agent/__init__.py +1 -0
- opencomputer/agent/compaction.py +245 -0
- opencomputer/agent/config.py +108 -0
- opencomputer/agent/config_store.py +210 -0
- opencomputer/agent/injection.py +60 -0
- opencomputer/agent/loop.py +326 -0
- opencomputer/agent/memory.py +132 -0
- opencomputer/agent/prompt_builder.py +66 -0
- opencomputer/agent/prompts/base.j2 +23 -0
- opencomputer/agent/state.py +251 -0
- opencomputer/agent/step.py +31 -0
- opencomputer/cli.py +483 -0
- opencomputer/doctor.py +216 -0
- opencomputer/gateway/__init__.py +1 -0
- opencomputer/gateway/dispatch.py +89 -0
- opencomputer/gateway/protocol.py +84 -0
- opencomputer/gateway/server.py +77 -0
- opencomputer/gateway/wire_server.py +256 -0
- opencomputer/hooks/__init__.py +1 -0
- opencomputer/hooks/engine.py +79 -0
- opencomputer/hooks/runner.py +42 -0
- opencomputer/mcp/__init__.py +1 -0
- opencomputer/mcp/client.py +208 -0
- opencomputer/plugins/__init__.py +1 -0
- opencomputer/plugins/discovery.py +107 -0
- opencomputer/plugins/loader.py +155 -0
- opencomputer/plugins/registry.py +56 -0
- opencomputer/setup_wizard.py +235 -0
- opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
- opencomputer/tools/__init__.py +1 -0
- opencomputer/tools/bash.py +78 -0
- opencomputer/tools/delegate.py +98 -0
- opencomputer/tools/glob.py +70 -0
- opencomputer/tools/grep.py +117 -0
- opencomputer/tools/read.py +81 -0
- opencomputer/tools/registry.py +69 -0
- opencomputer/tools/skill_manage.py +265 -0
- opencomputer/tools/write.py +58 -0
- opencomputer-0.1.0.dist-info/METADATA +190 -0
- opencomputer-0.1.0.dist-info/RECORD +51 -0
- opencomputer-0.1.0.dist-info/WHEEL +4 -0
- opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
- plugin_sdk/__init__.py +66 -0
- plugin_sdk/channel_contract.py +74 -0
- plugin_sdk/core.py +129 -0
- plugin_sdk/hooks.py +80 -0
- plugin_sdk/injection.py +60 -0
- plugin_sdk/provider_contract.py +95 -0
- plugin_sdk/runtime_context.py +39 -0
- plugin_sdk/tool_contract.py +67 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gateway dispatch — route inbound MessageEvents to the agent loop.
|
|
3
|
+
|
|
4
|
+
This is the glue between channel adapters (Telegram, Discord, etc.)
|
|
5
|
+
and the AgentLoop. Each adapter calls `Dispatch.handle_message(event)`;
|
|
6
|
+
we map chat_id → session_id and invoke the loop.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import hashlib
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from opencomputer.agent.loop import AgentLoop
|
|
16
|
+
from plugin_sdk.core import MessageEvent
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("opencomputer.gateway.dispatch")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Dispatch:
|
|
22
|
+
"""Map channel messages to agent-loop runs, keeping per-chat sessions separate."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, loop: AgentLoop) -> None:
|
|
25
|
+
self.loop = loop
|
|
26
|
+
# One lock per chat_id — prevents interleaved turns from the same chat
|
|
27
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
28
|
+
# Adapter reference (set by Gateway) so we can send typing indicators
|
|
29
|
+
self._adapters_by_platform: dict = {}
|
|
30
|
+
|
|
31
|
+
def register_adapter(self, platform: str, adapter) -> None:
|
|
32
|
+
self._adapters_by_platform[platform] = adapter
|
|
33
|
+
|
|
34
|
+
def _session_id_for(self, event: MessageEvent) -> str:
|
|
35
|
+
"""Stable session id: hash(platform + chat_id). Keeps history per chat."""
|
|
36
|
+
h = hashlib.sha256(f"{event.platform.value}:{event.chat_id}".encode())
|
|
37
|
+
return h.hexdigest()[:32]
|
|
38
|
+
|
|
39
|
+
async def handle_message(self, event: MessageEvent) -> str | None:
|
|
40
|
+
"""
|
|
41
|
+
Handle one inbound message. Runs the agent loop and returns the
|
|
42
|
+
final assistant text for the adapter to send back.
|
|
43
|
+
|
|
44
|
+
Also starts a periodic typing-indicator heartbeat on the source
|
|
45
|
+
channel so the user sees "..." while the agent thinks.
|
|
46
|
+
"""
|
|
47
|
+
if not event.text.strip():
|
|
48
|
+
return None
|
|
49
|
+
session_id = self._session_id_for(event)
|
|
50
|
+
lock = self._locks.setdefault(session_id, asyncio.Lock())
|
|
51
|
+
async with lock:
|
|
52
|
+
# Start a typing heartbeat (Telegram's typing state expires after
|
|
53
|
+
# ~5s, so we re-send every 4s until the turn completes).
|
|
54
|
+
heartbeat = asyncio.create_task(
|
|
55
|
+
self._typing_heartbeat(event.platform.value, event.chat_id)
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
result = await self.loop.run_conversation(
|
|
59
|
+
user_message=event.text,
|
|
60
|
+
session_id=session_id,
|
|
61
|
+
)
|
|
62
|
+
return result.final_message.content or None
|
|
63
|
+
except Exception as e: # noqa: BLE001
|
|
64
|
+
logger.exception("dispatch error for %s: %s", event.platform, e)
|
|
65
|
+
return f"[error: {type(e).__name__}: {e}]"
|
|
66
|
+
finally:
|
|
67
|
+
heartbeat.cancel()
|
|
68
|
+
try:
|
|
69
|
+
await heartbeat
|
|
70
|
+
except (asyncio.CancelledError, Exception):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
async def _typing_heartbeat(self, platform: str, chat_id: str) -> None:
|
|
74
|
+
"""Send typing indicator every 4s until cancelled."""
|
|
75
|
+
adapter = self._adapters_by_platform.get(platform)
|
|
76
|
+
if adapter is None:
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
while True:
|
|
80
|
+
try:
|
|
81
|
+
await adapter.send_typing(chat_id)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass # typing is best-effort
|
|
84
|
+
await asyncio.sleep(4.0)
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["Dispatch"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gateway wire protocol — JSON over WebSocket.
|
|
3
|
+
|
|
4
|
+
Clients connect to the gateway and exchange messages in this format.
|
|
5
|
+
Openclaw-style: typed schemas, request/response/event messages.
|
|
6
|
+
|
|
7
|
+
Three message shapes:
|
|
8
|
+
req — client → gateway request {type:"req", id, method, params}
|
|
9
|
+
res — gateway → client response {type:"res", id, ok, payload?, error?}
|
|
10
|
+
event — gateway → client server-push event {type:"event", event, payload}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
# ─── Request ────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WireRequest(BaseModel):
|
|
23
|
+
type: Literal["req"] = "req"
|
|
24
|
+
id: str
|
|
25
|
+
method: str
|
|
26
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ─── Response ───────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WireResponse(BaseModel):
|
|
33
|
+
type: Literal["res"] = "res"
|
|
34
|
+
id: str
|
|
35
|
+
ok: bool
|
|
36
|
+
payload: dict[str, Any] | None = None
|
|
37
|
+
error: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ─── Events (server-push) ───────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WireEvent(BaseModel):
|
|
44
|
+
type: Literal["event"] = "event"
|
|
45
|
+
event: str # e.g. "turn.begin", "tool.call", "assistant.delta", "turn.end"
|
|
46
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ─── Method names (client → gateway) ────────────────────────────
|
|
50
|
+
|
|
51
|
+
# These are the RPC methods clients can call.
|
|
52
|
+
METHOD_HELLO = "hello" # handshake, exchange capabilities
|
|
53
|
+
METHOD_CHAT = "chat" # send a user message, get assistant response
|
|
54
|
+
METHOD_SESSION_LIST = "sessions.list"
|
|
55
|
+
METHOD_SEARCH = "search"
|
|
56
|
+
METHOD_SKILLS_LIST = "skills.list"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ─── Event names (gateway → client) ─────────────────────────────
|
|
60
|
+
|
|
61
|
+
EVENT_TURN_BEGIN = "turn.begin"
|
|
62
|
+
EVENT_TURN_END = "turn.end"
|
|
63
|
+
EVENT_TOOL_CALL = "tool.call"
|
|
64
|
+
EVENT_TOOL_RESULT = "tool.result"
|
|
65
|
+
EVENT_ASSISTANT_MESSAGE = "assistant.message"
|
|
66
|
+
EVENT_ERROR = "error"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"WireRequest",
|
|
71
|
+
"WireResponse",
|
|
72
|
+
"WireEvent",
|
|
73
|
+
"METHOD_HELLO",
|
|
74
|
+
"METHOD_CHAT",
|
|
75
|
+
"METHOD_SESSION_LIST",
|
|
76
|
+
"METHOD_SEARCH",
|
|
77
|
+
"METHOD_SKILLS_LIST",
|
|
78
|
+
"EVENT_TURN_BEGIN",
|
|
79
|
+
"EVENT_TURN_END",
|
|
80
|
+
"EVENT_TOOL_CALL",
|
|
81
|
+
"EVENT_TOOL_RESULT",
|
|
82
|
+
"EVENT_ASSISTANT_MESSAGE",
|
|
83
|
+
"EVENT_ERROR",
|
|
84
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gateway daemon — runs channel adapters + optional WebSocket server.
|
|
3
|
+
|
|
4
|
+
Two modes:
|
|
5
|
+
1. Channel mode: start configured channel adapters (Telegram, Discord, ...).
|
|
6
|
+
Messages arrive via platform SDKs → Dispatch → AgentLoop → back out.
|
|
7
|
+
2. Wire mode (optional): also start a WebSocket server on a local port,
|
|
8
|
+
letting additional clients (TUI, web, mobile) use the same agent via
|
|
9
|
+
the typed protocol.
|
|
10
|
+
|
|
11
|
+
Phase 2 focuses on channel mode. Wire mode is scaffolded but minimal.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from opencomputer.agent.loop import AgentLoop
|
|
20
|
+
from opencomputer.gateway.dispatch import Dispatch
|
|
21
|
+
from plugin_sdk.channel_contract import BaseChannelAdapter
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("opencomputer.gateway.server")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Gateway:
|
|
27
|
+
"""The gateway daemon."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, loop: AgentLoop) -> None:
|
|
30
|
+
self.loop = loop
|
|
31
|
+
self.dispatch = Dispatch(loop)
|
|
32
|
+
self._adapters: list[BaseChannelAdapter] = []
|
|
33
|
+
|
|
34
|
+
def register_adapter(self, adapter: BaseChannelAdapter) -> None:
|
|
35
|
+
"""Register a channel adapter (usually from a loaded plugin)."""
|
|
36
|
+
adapter.set_message_handler(self.dispatch.handle_message)
|
|
37
|
+
self._adapters.append(adapter)
|
|
38
|
+
# Give Dispatch a handle so it can send typing indicators back out.
|
|
39
|
+
self.dispatch.register_adapter(adapter.platform.value, adapter)
|
|
40
|
+
|
|
41
|
+
async def start(self) -> None:
|
|
42
|
+
"""Connect all adapters. Returns once they're all running."""
|
|
43
|
+
logger.info("gateway: starting %d adapters", len(self._adapters))
|
|
44
|
+
results = await asyncio.gather(
|
|
45
|
+
*(a.connect() for a in self._adapters), return_exceptions=True
|
|
46
|
+
)
|
|
47
|
+
for adapter, res in zip(self._adapters, results, strict=False):
|
|
48
|
+
if isinstance(res, Exception):
|
|
49
|
+
logger.error(
|
|
50
|
+
"gateway: adapter %s failed to connect: %s", adapter.platform, res
|
|
51
|
+
)
|
|
52
|
+
elif res is False:
|
|
53
|
+
logger.error("gateway: adapter %s returned False from connect()", adapter.platform)
|
|
54
|
+
|
|
55
|
+
async def stop(self) -> None:
|
|
56
|
+
logger.info("gateway: stopping")
|
|
57
|
+
await asyncio.gather(
|
|
58
|
+
*(a.disconnect() for a in self._adapters), return_exceptions=True
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def serve_forever(self) -> None:
|
|
62
|
+
"""Connect adapters and block until interrupted."""
|
|
63
|
+
await self.start()
|
|
64
|
+
try:
|
|
65
|
+
while True:
|
|
66
|
+
await asyncio.sleep(3600)
|
|
67
|
+
except asyncio.CancelledError:
|
|
68
|
+
pass
|
|
69
|
+
finally:
|
|
70
|
+
await self.stop()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def adapters(self) -> list[BaseChannelAdapter]:
|
|
74
|
+
return list(self._adapters)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["Gateway"]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wire server — WebSocket JSON-RPC server for TUI / IDE / web clients.
|
|
3
|
+
|
|
4
|
+
Listens on 127.0.0.1:<port> by default. Each connection is independent —
|
|
5
|
+
clients send WireRequests, receive WireResponses (and server-pushed
|
|
6
|
+
WireEvents during long-running calls like chat).
|
|
7
|
+
|
|
8
|
+
Supported methods:
|
|
9
|
+
hello — handshake, returns server capabilities
|
|
10
|
+
chat — send a user message, stream assistant response
|
|
11
|
+
sessions.list — list recent sessions
|
|
12
|
+
search — FTS5 search across session history
|
|
13
|
+
skills.list — list available skills
|
|
14
|
+
|
|
15
|
+
New methods can be added by plugins in a future phase.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import websockets
|
|
27
|
+
|
|
28
|
+
from opencomputer.agent.loop import AgentLoop
|
|
29
|
+
from opencomputer.gateway.protocol import (
|
|
30
|
+
EVENT_ASSISTANT_MESSAGE,
|
|
31
|
+
EVENT_ERROR,
|
|
32
|
+
EVENT_TURN_BEGIN,
|
|
33
|
+
EVENT_TURN_END,
|
|
34
|
+
METHOD_CHAT,
|
|
35
|
+
METHOD_HELLO,
|
|
36
|
+
METHOD_SEARCH,
|
|
37
|
+
METHOD_SESSION_LIST,
|
|
38
|
+
METHOD_SKILLS_LIST,
|
|
39
|
+
WireEvent,
|
|
40
|
+
WireRequest,
|
|
41
|
+
WireResponse,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger("opencomputer.gateway.wire_server")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WireServer:
|
|
48
|
+
"""Minimal JSON-RPC-over-WebSocket server for local clients."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
loop: AgentLoop,
|
|
53
|
+
host: str = "127.0.0.1",
|
|
54
|
+
port: int = 18789,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.loop = loop
|
|
57
|
+
self.host = host
|
|
58
|
+
self.port = port
|
|
59
|
+
self._server: websockets.WebSocketServer | None = None
|
|
60
|
+
|
|
61
|
+
async def start(self) -> None:
|
|
62
|
+
self._server = await websockets.serve(
|
|
63
|
+
self._handle_client, self.host, self.port
|
|
64
|
+
)
|
|
65
|
+
logger.info("wire: listening on ws://%s:%s", self.host, self.port)
|
|
66
|
+
|
|
67
|
+
async def stop(self) -> None:
|
|
68
|
+
if self._server is not None:
|
|
69
|
+
self._server.close()
|
|
70
|
+
await self._server.wait_closed()
|
|
71
|
+
|
|
72
|
+
async def _handle_client(
|
|
73
|
+
self, ws: websockets.WebSocketServerProtocol
|
|
74
|
+
) -> None:
|
|
75
|
+
client_id = str(uuid.uuid4())[:8]
|
|
76
|
+
logger.info("wire: client %s connected", client_id)
|
|
77
|
+
try:
|
|
78
|
+
async for raw in ws:
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(raw)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
await self._send_response(
|
|
83
|
+
ws, "", False, error="invalid JSON"
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if data.get("type") != "req":
|
|
88
|
+
await self._send_response(
|
|
89
|
+
ws, data.get("id", ""), False, error="expected type=req"
|
|
90
|
+
)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
req = WireRequest(**data)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
await self._send_response(
|
|
97
|
+
ws,
|
|
98
|
+
data.get("id", ""),
|
|
99
|
+
False,
|
|
100
|
+
error=f"invalid request: {e}",
|
|
101
|
+
)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
await self._dispatch(ws, req)
|
|
106
|
+
except Exception as e: # noqa: BLE001
|
|
107
|
+
logger.exception("wire dispatch error for method %s", req.method)
|
|
108
|
+
await self._send_response(
|
|
109
|
+
ws, req.id, False, error=f"{type(e).__name__}: {e}"
|
|
110
|
+
)
|
|
111
|
+
except websockets.ConnectionClosed:
|
|
112
|
+
pass
|
|
113
|
+
finally:
|
|
114
|
+
logger.info("wire: client %s disconnected", client_id)
|
|
115
|
+
|
|
116
|
+
async def _dispatch(
|
|
117
|
+
self, ws: websockets.WebSocketServerProtocol, req: WireRequest
|
|
118
|
+
) -> None:
|
|
119
|
+
if req.method == METHOD_HELLO:
|
|
120
|
+
await self._send_response(
|
|
121
|
+
ws,
|
|
122
|
+
req.id,
|
|
123
|
+
True,
|
|
124
|
+
payload={
|
|
125
|
+
"server": "opencomputer",
|
|
126
|
+
"version": "0.0.1",
|
|
127
|
+
"methods": [
|
|
128
|
+
METHOD_HELLO,
|
|
129
|
+
METHOD_CHAT,
|
|
130
|
+
METHOD_SESSION_LIST,
|
|
131
|
+
METHOD_SEARCH,
|
|
132
|
+
METHOD_SKILLS_LIST,
|
|
133
|
+
],
|
|
134
|
+
"events": [
|
|
135
|
+
EVENT_TURN_BEGIN,
|
|
136
|
+
EVENT_TURN_END,
|
|
137
|
+
EVENT_ASSISTANT_MESSAGE,
|
|
138
|
+
EVENT_ERROR,
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
elif req.method == METHOD_CHAT:
|
|
143
|
+
await self._handle_chat(ws, req)
|
|
144
|
+
elif req.method == METHOD_SESSION_LIST:
|
|
145
|
+
limit = int(req.params.get("limit", 20))
|
|
146
|
+
rows = self.loop.db.list_sessions(limit=limit)
|
|
147
|
+
await self._send_response(ws, req.id, True, payload={"sessions": rows})
|
|
148
|
+
elif req.method == METHOD_SEARCH:
|
|
149
|
+
query = str(req.params.get("query", ""))
|
|
150
|
+
limit = int(req.params.get("limit", 20))
|
|
151
|
+
hits = self.loop.db.search(query, limit=limit)
|
|
152
|
+
await self._send_response(ws, req.id, True, payload={"hits": hits})
|
|
153
|
+
elif req.method == METHOD_SKILLS_LIST:
|
|
154
|
+
skills = self.loop.memory.list_skills()
|
|
155
|
+
payload = {
|
|
156
|
+
"skills": [
|
|
157
|
+
{
|
|
158
|
+
"id": s.id,
|
|
159
|
+
"name": s.name,
|
|
160
|
+
"description": s.description,
|
|
161
|
+
"version": s.version,
|
|
162
|
+
}
|
|
163
|
+
for s in skills
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
await self._send_response(ws, req.id, True, payload=payload)
|
|
167
|
+
else:
|
|
168
|
+
await self._send_response(
|
|
169
|
+
ws, req.id, False, error=f"unknown method: {req.method}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def _handle_chat(
|
|
173
|
+
self, ws: websockets.WebSocketServerProtocol, req: WireRequest
|
|
174
|
+
) -> None:
|
|
175
|
+
user_message = str(req.params.get("message", "")).strip()
|
|
176
|
+
session_id = req.params.get("session_id") or None
|
|
177
|
+
if not user_message:
|
|
178
|
+
await self._send_response(
|
|
179
|
+
ws, req.id, False, error="empty message"
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# Announce turn begin
|
|
184
|
+
await self._send_event(
|
|
185
|
+
ws, EVENT_TURN_BEGIN, {"request_id": req.id}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Stream text deltas to the client as assistant messages
|
|
189
|
+
async def _on_chunk(text: str) -> None:
|
|
190
|
+
await self._send_event(
|
|
191
|
+
ws,
|
|
192
|
+
EVENT_ASSISTANT_MESSAGE,
|
|
193
|
+
{"delta": text, "request_id": req.id},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
result = await self.loop.run_conversation(
|
|
198
|
+
user_message=user_message,
|
|
199
|
+
session_id=session_id,
|
|
200
|
+
stream_callback=lambda t: asyncio.create_task(_on_chunk(t)),
|
|
201
|
+
)
|
|
202
|
+
except Exception as e: # noqa: BLE001
|
|
203
|
+
await self._send_event(
|
|
204
|
+
ws,
|
|
205
|
+
EVENT_ERROR,
|
|
206
|
+
{"request_id": req.id, "error": f"{type(e).__name__}: {e}"},
|
|
207
|
+
)
|
|
208
|
+
await self._send_response(
|
|
209
|
+
ws, req.id, False, error=f"{type(e).__name__}: {e}"
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
await self._send_event(
|
|
214
|
+
ws,
|
|
215
|
+
EVENT_TURN_END,
|
|
216
|
+
{
|
|
217
|
+
"request_id": req.id,
|
|
218
|
+
"iterations": result.iterations,
|
|
219
|
+
"input_tokens": result.input_tokens,
|
|
220
|
+
"output_tokens": result.output_tokens,
|
|
221
|
+
"session_id": result.session_id,
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
await self._send_response(
|
|
225
|
+
ws,
|
|
226
|
+
req.id,
|
|
227
|
+
True,
|
|
228
|
+
payload={
|
|
229
|
+
"text": result.final_message.content,
|
|
230
|
+
"session_id": result.session_id,
|
|
231
|
+
"iterations": result.iterations,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def _send_response(
|
|
236
|
+
self,
|
|
237
|
+
ws: websockets.WebSocketServerProtocol,
|
|
238
|
+
req_id: str,
|
|
239
|
+
ok: bool,
|
|
240
|
+
payload: dict[str, Any] | None = None,
|
|
241
|
+
error: str | None = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
res = WireResponse(id=req_id, ok=ok, payload=payload, error=error)
|
|
244
|
+
await ws.send(res.model_dump_json())
|
|
245
|
+
|
|
246
|
+
async def _send_event(
|
|
247
|
+
self,
|
|
248
|
+
ws: websockets.WebSocketServerProtocol,
|
|
249
|
+
event_name: str,
|
|
250
|
+
payload: dict[str, Any],
|
|
251
|
+
) -> None:
|
|
252
|
+
ev = WireEvent(event=event_name, payload=payload)
|
|
253
|
+
await ws.send(ev.model_dump_json())
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
__all__ = ["WireServer"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hook engine — lifecycle event intercepts."""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hook engine — dispatches lifecycle events to registered handlers.
|
|
3
|
+
|
|
4
|
+
Registration pattern mirrors the tool registry. Plugins call
|
|
5
|
+
`engine.register(HookSpec(...))` at load time. At runtime the agent
|
|
6
|
+
loop emits events via `engine.fire(HookEvent.X, ctx)`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
|
|
15
|
+
from opencomputer.hooks.runner import fire_and_forget
|
|
16
|
+
from plugin_sdk.hooks import HookContext, HookDecision, HookEvent, HookSpec
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("opencomputer.hooks")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HookEngine:
|
|
22
|
+
"""Central dispatcher for lifecycle events."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._hooks: dict[HookEvent, list[HookSpec]] = defaultdict(list)
|
|
26
|
+
|
|
27
|
+
def register(self, spec: HookSpec) -> None:
|
|
28
|
+
self._hooks[spec.event].append(spec)
|
|
29
|
+
|
|
30
|
+
def unregister_all(self, event: HookEvent | None = None) -> None:
|
|
31
|
+
if event is None:
|
|
32
|
+
self._hooks.clear()
|
|
33
|
+
else:
|
|
34
|
+
self._hooks[event] = []
|
|
35
|
+
|
|
36
|
+
def _matches(self, spec: HookSpec, ctx: HookContext) -> bool:
|
|
37
|
+
if spec.matcher is None:
|
|
38
|
+
return True
|
|
39
|
+
# Matcher is a regex over tool name (for PreToolUse / PostToolUse)
|
|
40
|
+
tool_name = ""
|
|
41
|
+
if ctx.tool_call:
|
|
42
|
+
tool_name = ctx.tool_call.name
|
|
43
|
+
elif ctx.tool_result:
|
|
44
|
+
tool_name = ctx.tool_result.tool_call_id # fallback — not ideal
|
|
45
|
+
try:
|
|
46
|
+
return re.search(spec.matcher, tool_name) is not None
|
|
47
|
+
except re.error:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
async def fire_blocking(self, ctx: HookContext) -> HookDecision | None:
|
|
51
|
+
"""Fire a hook event and WAIT for decisions (for PreToolUse approvals).
|
|
52
|
+
|
|
53
|
+
Returns the first non-pass decision, or None if all hooks passed.
|
|
54
|
+
"""
|
|
55
|
+
for spec in self._hooks.get(ctx.event, []):
|
|
56
|
+
if not self._matches(spec, ctx):
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
decision = await spec.handler(ctx)
|
|
60
|
+
except Exception: # noqa: BLE001
|
|
61
|
+
logger.exception("blocking hook raised")
|
|
62
|
+
continue
|
|
63
|
+
if decision is None or decision.decision == "pass":
|
|
64
|
+
continue
|
|
65
|
+
return decision
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def fire_and_forget(self, ctx: HookContext) -> None:
|
|
69
|
+
"""Fire a hook event without waiting. Used for PostToolUse logging etc."""
|
|
70
|
+
for spec in self._hooks.get(ctx.event, []):
|
|
71
|
+
if not self._matches(spec, ctx):
|
|
72
|
+
continue
|
|
73
|
+
fire_and_forget(spec.handler(ctx))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
engine = HookEngine()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["HookEngine", "engine"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fire-and-forget async runner.
|
|
3
|
+
|
|
4
|
+
Adapted from kimi-cli's pattern: post-action hooks must NEVER block
|
|
5
|
+
the main loop. We schedule them as independent tasks and log any
|
|
6
|
+
exceptions (never re-raise).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Coroutine
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("opencomputer.hooks.runner")
|
|
17
|
+
|
|
18
|
+
_pending: set[asyncio.Task[Any]] = set()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fire_and_forget(coro: Coroutine[Any, Any, Any]) -> None:
|
|
22
|
+
"""Schedule `coro` to run independently. Exceptions are logged, never raised."""
|
|
23
|
+
try:
|
|
24
|
+
task = asyncio.create_task(coro)
|
|
25
|
+
except RuntimeError:
|
|
26
|
+
# No running event loop — run synchronously on the current thread as a last resort.
|
|
27
|
+
asyncio.run(coro)
|
|
28
|
+
return
|
|
29
|
+
_pending.add(task)
|
|
30
|
+
task.add_done_callback(_on_done)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _on_done(task: asyncio.Task[Any]) -> None:
|
|
34
|
+
_pending.discard(task)
|
|
35
|
+
if task.cancelled():
|
|
36
|
+
return
|
|
37
|
+
exc = task.exception()
|
|
38
|
+
if exc is not None:
|
|
39
|
+
logger.warning("fire-and-forget hook raised: %s", exc)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["fire_and_forget"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) integration — connect to external tool servers."""
|