mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-15 20:50:44 +08:00
初始化:独立 oclaw 仓库首提交
- 在 oclaw/ 下重新初始化 Git 仓库 - 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等) - 提交当前工程代码与配置 Made-with: Cursor
This commit is contained in:
commit
ba3836f00f
579 changed files with 83112 additions and 0 deletions
7
interfaces/ws/__init__.py
Normal file
7
interfaces/ws/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""WebSocket interface adapters."""
|
||||
|
||||
from .entrypoint import ws_gateway_loop
|
||||
from .runtime import OclawWsGatewayConnection
|
||||
|
||||
__all__ = ["ws_gateway_loop", "OclawWsGatewayConnection"]
|
||||
|
||||
70
interfaces/ws/auth_and_hello.py
Normal file
70
interfaces/ws/auth_and_hello.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def build_hello_ok_payload(
|
||||
*,
|
||||
conn_id: str,
|
||||
started_at_ms: int,
|
||||
role: str,
|
||||
scopes: list[str],
|
||||
protocol_version: int,
|
||||
methods: list[str],
|
||||
max_payload_bytes: int,
|
||||
max_buffered_bytes: int,
|
||||
tick_interval_ms: int,
|
||||
now_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "hello-ok",
|
||||
"protocol": int(protocol_version),
|
||||
"server": {"version": str(os.getenv("OCLAW_VERSION") or "0.1"), "connId": str(conn_id)},
|
||||
"features": {
|
||||
"methods": list(methods),
|
||||
"events": ["connect.challenge", "chat", "session.message", "session.tool", "sessions.changed", "agent.event", "tick", "shutdown"],
|
||||
},
|
||||
"snapshot": {
|
||||
"presence": [],
|
||||
"health": {},
|
||||
"stateVersion": {"presence": 0, "health": 0},
|
||||
"uptimeMs": max(0, int(now_ms) - int(started_at_ms or now_ms)),
|
||||
},
|
||||
"policy": {"maxPayload": int(max_payload_bytes), "maxBufferedBytes": int(max_buffered_bytes), "tickIntervalMs": int(tick_interval_ms)},
|
||||
"auth": {"role": str(role or "operator"), "scopes": list(scopes or [])},
|
||||
}
|
||||
|
||||
|
||||
def resolve_ws_auth(connect_params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
p = connect_params if isinstance(connect_params, dict) else {}
|
||||
auth = p.get("auth") if isinstance(p.get("auth"), dict) else {}
|
||||
token = str(auth.get("token") or "").strip()
|
||||
device_token = str(auth.get("deviceToken") or "").strip()
|
||||
bootstrap_token = str(auth.get("bootstrapToken") or "").strip()
|
||||
if not token and (device_token or bootstrap_token):
|
||||
token = device_token or bootstrap_token
|
||||
if not token:
|
||||
return {}
|
||||
store = SqliteStore(db_path())
|
||||
token_hash = hashlib.sha256(token.encode("utf-8", errors="ignore")).hexdigest()
|
||||
session = store.get_auth_session(session_token_hash=token_hash)
|
||||
if not session or session.get("revoked_at"):
|
||||
return {}
|
||||
user = store.get_user_by_id(tenant_id=str(session.get("tenant_id") or ""), user_id=str(session.get("user_id") or ""))
|
||||
if not user or not bool(user.get("is_active")):
|
||||
return {}
|
||||
return {
|
||||
"tenant_id": str(user.get("tenant_id") or ""),
|
||||
"user_id": str(user.get("id") or ""),
|
||||
"username": str(user.get("username") or ""),
|
||||
"role": str(user.get("role") or "member"),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["build_hello_ok_payload", "resolve_ws_auth"]
|
||||
|
||||
81
interfaces/ws/common.py
Normal file
81
interfaces/ws/common.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.files.file_attachments import process_file_data
|
||||
|
||||
PROTOCOL_VERSION = 3
|
||||
MAX_PAYLOAD_BYTES = 26_214_400
|
||||
MAX_BUFFERED_BYTES = 52_428_800
|
||||
TICK_INTERVAL_MS = 15_000
|
||||
PREAUTH_HANDSHAKE_TIMEOUT_MS = 15_000
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def error_shape(code: str, message: str, *, details: Any | None = None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"code": str(code or "INVALID_REQUEST"), "message": str(message or "invalid_request")}
|
||||
if details is not None:
|
||||
out["details"] = details
|
||||
return out
|
||||
|
||||
|
||||
def decode_base64_payload_ws(s: str | None) -> bytes | None:
|
||||
raw = str(s or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("data:") and "," in raw:
|
||||
raw = raw.split(",", 1)[1].strip()
|
||||
raw = raw.replace("-", "+").replace("_", "/")
|
||||
pad = (-len(raw)) % 4
|
||||
if pad:
|
||||
raw += "=" * pad
|
||||
try:
|
||||
return base64.b64decode(raw, validate=False)
|
||||
except Exception:
|
||||
try:
|
||||
return base64.standard_b64decode(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def normalize_ws_attachments(raw: Any) -> list[dict[str, Any]]:
|
||||
if not raw:
|
||||
return []
|
||||
items = raw if isinstance(raw, list) else []
|
||||
out: list[dict[str, Any]] = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
if "type" in it:
|
||||
out.append(it)
|
||||
continue
|
||||
name = str(it.get("name") or "file").strip() or "file"
|
||||
b64 = it.get("data_base64") if "data_base64" in it else it.get("data")
|
||||
if not isinstance(b64, str) or not b64.strip():
|
||||
continue
|
||||
data = decode_base64_payload_ws(b64)
|
||||
if not data:
|
||||
continue
|
||||
got = process_file_data(name, data)
|
||||
if got:
|
||||
out.extend(got)
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROTOCOL_VERSION",
|
||||
"MAX_PAYLOAD_BYTES",
|
||||
"MAX_BUFFERED_BYTES",
|
||||
"TICK_INTERVAL_MS",
|
||||
"PREAUTH_HANDSHAKE_TIMEOUT_MS",
|
||||
"now_ms",
|
||||
"error_shape",
|
||||
"decode_base64_payload_ws",
|
||||
"normalize_ws_attachments",
|
||||
]
|
||||
|
||||
14
interfaces/ws/entrypoint.py
Normal file
14
interfaces/ws/entrypoint.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from .runtime import ws_gateway_loop as _runtime_ws_gateway_loop
|
||||
|
||||
|
||||
async def ws_gateway_loop(ws: WebSocket) -> None:
|
||||
"""WS gateway entrypoint under oclaw interfaces."""
|
||||
await _runtime_ws_gateway_loop(ws)
|
||||
|
||||
|
||||
__all__ = ["ws_gateway_loop"]
|
||||
|
||||
58
interfaces/ws/events.py
Normal file
58
interfaces/ws/events.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def send_res(conn: Any, req_id: str, *, ok: bool, payload: Any | None = None, error: Any | None = None) -> None:
|
||||
frame: dict[str, Any] = {"type": "res", "id": str(req_id or "invalid"), "ok": bool(ok)}
|
||||
if payload is not None:
|
||||
frame["payload"] = payload
|
||||
if error is not None:
|
||||
frame["error"] = error
|
||||
await conn.ws.send_text(json.dumps(frame, ensure_ascii=False))
|
||||
|
||||
|
||||
async def send_event(conn: Any, event: str, payload: Any | None = None) -> None:
|
||||
conn.seq += 1
|
||||
frame: dict[str, Any] = {"type": "event", "event": str(event or "event"), "seq": int(conn.seq)}
|
||||
if payload is not None:
|
||||
frame["payload"] = payload
|
||||
await conn.ws.send_text(json.dumps(frame, ensure_ascii=False))
|
||||
|
||||
|
||||
async def emit_agent_event(conn: Any, *, run_id: str, stream: str, data: dict[str, Any], now_ms: int) -> None:
|
||||
payload = {"runId": str(run_id), "seq": int(conn.seq + 1), "stream": str(stream), "ts": int(now_ms), "data": dict(data or {})}
|
||||
await send_event(conn, "agent.event", payload)
|
||||
|
||||
|
||||
async def emit_chat_event(
|
||||
conn: Any,
|
||||
*,
|
||||
run_id: str,
|
||||
state: str,
|
||||
delta: str = "",
|
||||
reply: str = "",
|
||||
error: str = "",
|
||||
message: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
seq: int | None = None,
|
||||
) -> None:
|
||||
payload: dict[str, Any] = {"runId": str(run_id), "state": str(state or "")}
|
||||
if session_key:
|
||||
payload["sessionKey"] = str(session_key)
|
||||
if seq is not None:
|
||||
payload["seq"] = int(seq)
|
||||
if delta:
|
||||
payload["message"] = {"role": "assistant", "content": [{"type": "text", "text": str(delta)}]}
|
||||
elif reply and message is None:
|
||||
payload["message"] = {"role": "assistant", "content": [{"type": "text", "text": str(reply)}]}
|
||||
if error:
|
||||
payload["errorMessage"] = str(error)
|
||||
if message is not None:
|
||||
payload["message"] = message
|
||||
await send_event(conn, "chat", payload)
|
||||
|
||||
|
||||
__all__ = ["send_res", "send_event", "emit_agent_event", "emit_chat_event"]
|
||||
|
||||
6
interfaces/ws/runtime.py
Normal file
6
interfaces/ws/runtime.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""WS runtime bridge under oclaw namespace."""
|
||||
|
||||
from .runtime_impl import OclawWsGatewayConnection, ws_gateway_loop
|
||||
|
||||
__all__ = ["OclawWsGatewayConnection", "ws_gateway_loop"]
|
||||
|
||||
13
interfaces/ws/runtime_dispatch.py
Normal file
13
interfaces/ws/runtime_dispatch.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def dispatch_connected(conn: Any, req_id: str, method: str, params: Any) -> None:
|
||||
if await conn._dispatch_via_server_methods(req_id=req_id, method=method, params=params):
|
||||
return
|
||||
await conn.send_res(req_id, ok=False, error=conn._error_shape("INVALID_REQUEST", f"unknown method: {method}"))
|
||||
|
||||
|
||||
__all__ = ["dispatch_connected"]
|
||||
|
||||
137
interfaces/ws/runtime_helpers.py
Normal file
137
interfaces/ws/runtime_helpers.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
||||
async def recv_frame(
|
||||
*,
|
||||
conn: Any,
|
||||
preauth: bool,
|
||||
handshake_timeout_ms: int,
|
||||
validate_or_errors: Any,
|
||||
format_validation_errors: Any,
|
||||
error_shape: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
try:
|
||||
if preauth:
|
||||
raw = await asyncio.wait_for(conn.ws.receive_text(), timeout=handshake_timeout_ms / 1000.0)
|
||||
else:
|
||||
raw = await conn.ws.receive_text()
|
||||
except asyncio.TimeoutError:
|
||||
conn.handshake_failed = True
|
||||
return None
|
||||
except WebSocketDisconnect:
|
||||
return None
|
||||
try:
|
||||
frame = json.loads(raw)
|
||||
except Exception:
|
||||
await conn.send_res("invalid", ok=False, error=error_shape("INVALID_REQUEST", "invalid json"))
|
||||
if preauth:
|
||||
conn.handshake_failed = True
|
||||
return {}
|
||||
frame_errs = validate_or_errors(conn.schemas.frame, frame)
|
||||
if frame_errs:
|
||||
fid = str(frame.get("id") or "invalid") if isinstance(frame, dict) else "invalid"
|
||||
await conn.send_res(
|
||||
fid,
|
||||
ok=False,
|
||||
error=error_shape("INVALID_REQUEST", f"invalid frame: {format_validation_errors(frame_errs)}"),
|
||||
)
|
||||
if preauth:
|
||||
conn.handshake_failed = True
|
||||
return {}
|
||||
return frame if isinstance(frame, dict) else {}
|
||||
|
||||
|
||||
async def handle_connect(
|
||||
*,
|
||||
conn: Any,
|
||||
req_id: str,
|
||||
method: str,
|
||||
params: Any,
|
||||
protocol_version: int,
|
||||
validate_or_errors: Any,
|
||||
format_validation_errors: Any,
|
||||
error_shape: Any,
|
||||
) -> None:
|
||||
if method != "connect":
|
||||
await conn.send_res(req_id, ok=False, error=error_shape("INVALID_REQUEST", "first request must be connect"))
|
||||
conn.handshake_failed = True
|
||||
return
|
||||
p_errs = validate_or_errors(conn.schemas.connect_params, params)
|
||||
if p_errs:
|
||||
await conn.send_res(
|
||||
req_id,
|
||||
ok=False,
|
||||
error=error_shape("INVALID_REQUEST", f"invalid connect params: {format_validation_errors(p_errs)}"),
|
||||
)
|
||||
conn.handshake_failed = True
|
||||
return
|
||||
p = dict(params or {})
|
||||
device = p.get("device") if isinstance(p.get("device"), dict) else None
|
||||
if device is not None:
|
||||
nonce = str(device.get("nonce") or "").strip()
|
||||
if not nonce or nonce != conn.connect_nonce:
|
||||
await conn.send_res(
|
||||
req_id,
|
||||
ok=False,
|
||||
error=error_shape("INVALID_REQUEST", "device nonce mismatch", details={"code": "DEVICE_NONCE_MISMATCH"}),
|
||||
)
|
||||
conn.handshake_failed = True
|
||||
return
|
||||
min_protocol = int(p.get("minProtocol") or 0)
|
||||
max_protocol = int(p.get("maxProtocol") or 0)
|
||||
if max_protocol < protocol_version or min_protocol > protocol_version:
|
||||
await conn.send_res(
|
||||
req_id,
|
||||
ok=False,
|
||||
error=error_shape("INVALID_REQUEST", "protocol mismatch", details={"expectedProtocol": protocol_version}),
|
||||
)
|
||||
conn.handshake_failed = True
|
||||
return
|
||||
hello = conn.build_hello_ok(params)
|
||||
hello_errs = validate_or_errors(conn.schemas.hello_ok, hello)
|
||||
if hello_errs:
|
||||
await conn.send_res(
|
||||
req_id,
|
||||
ok=False,
|
||||
error=error_shape("UNAVAILABLE", f"server hello-ok schema mismatch: {format_validation_errors(hello_errs)}"),
|
||||
)
|
||||
conn.handshake_failed = True
|
||||
return
|
||||
conn.connected = True
|
||||
conn.client_meta = dict(params or {})
|
||||
try:
|
||||
c = (conn.client_meta or {}).get("client") if isinstance((conn.client_meta or {}).get("client"), dict) else {}
|
||||
mode = str(c.get("mode") or "").strip().lower()
|
||||
cid = str(c.get("id") or "").strip().lower()
|
||||
conn._is_webchat_client = mode == "webchat" or cid in ("webchat-ui", "webchat", "control-ui-webchat")
|
||||
except Exception:
|
||||
conn._is_webchat_client = False
|
||||
conn.role = str((params or {}).get("role") or "operator")
|
||||
conn.scopes = list((params or {}).get("scopes") or [])
|
||||
conn.auth_ctx = conn.resolve_ws_auth(params)
|
||||
auth = p.get("auth") if isinstance(p.get("auth"), dict) else {}
|
||||
auth_provided = bool(
|
||||
str(auth.get("token") or "").strip()
|
||||
or str(auth.get("password") or "").strip()
|
||||
or str(auth.get("bootstrapToken") or "").strip()
|
||||
or str(auth.get("deviceToken") or "").strip()
|
||||
)
|
||||
if auth_provided and not conn.auth_ctx:
|
||||
await conn.send_res(
|
||||
req_id,
|
||||
ok=False,
|
||||
error=error_shape("INVALID_REQUEST", "unauthorized", details={"code": "AUTH_UNAUTHORIZED"}),
|
||||
)
|
||||
conn.handshake_failed = True
|
||||
return
|
||||
await conn.send_res(req_id, ok=True, payload=hello)
|
||||
|
||||
|
||||
__all__ = ["recv_frame", "handle_connect"]
|
||||
|
||||
190
interfaces/ws/runtime_impl.py
Normal file
190
interfaces/ws/runtime_impl.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""WS runtime implementation under oclaw namespace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
import threading
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from oclaw.interfaces.gateway.dispatcher import build_gateway_method_handlers, method_names
|
||||
from oclaw.interfaces.ws.auth_and_hello import build_hello_ok_payload, resolve_ws_auth as resolve_ws_auth_payload
|
||||
from oclaw.interfaces.ws.common import (
|
||||
MAX_BUFFERED_BYTES,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
PREAUTH_HANDSHAKE_TIMEOUT_MS,
|
||||
PROTOCOL_VERSION,
|
||||
TICK_INTERVAL_MS,
|
||||
error_shape as _error_shape,
|
||||
normalize_ws_attachments as _normalize_ws_attachments,
|
||||
now_ms as _now_ms,
|
||||
)
|
||||
from oclaw.interfaces.ws.events import (
|
||||
emit_agent_event as emit_agent_event_impl,
|
||||
emit_chat_event as emit_chat_event_impl,
|
||||
send_event as send_event_impl,
|
||||
send_res as send_res_impl,
|
||||
)
|
||||
from oclaw.interfaces.ws.runtime_dispatch import dispatch_connected as dispatch_connected_impl
|
||||
from oclaw.interfaces.ws.runtime_helpers import handle_connect as handle_connect_impl, recv_frame as recv_frame_impl
|
||||
from oclaw.interfaces.ws.runtime_loop import close_ws as close_ws_impl, run_connection_loop
|
||||
from oclaw.interfaces.ws.server_methods_bridge import build_gateway_context, dispatch_via_server_methods
|
||||
from oclaw.interfaces.ws.turn_runner import run_agent_turn_via_bridge
|
||||
from oclaw.interfaces.ws.ws_schema import format_validation_errors, get_ws_schemas, validate_or_errors
|
||||
from oclaw.openclaw_runtime.relay_pointer import validate_relay_share_envelope
|
||||
|
||||
|
||||
class OclawWsGatewayConnection:
|
||||
def __init__(self, ws: WebSocket):
|
||||
self.ws = ws
|
||||
self.schemas = get_ws_schemas()
|
||||
self.connected = False
|
||||
self.conn_id = uuid.uuid4().hex[:12]
|
||||
self.seq = 0
|
||||
self.started_at_ms = _now_ms()
|
||||
self.client_meta: dict[str, Any] | None = None
|
||||
self._is_webchat_client = False
|
||||
self.role = "operator"
|
||||
self.scopes: list[str] = []
|
||||
self.auth_ctx: dict[str, Any] | None = None
|
||||
self.connect_nonce = uuid.uuid4().hex
|
||||
self.handshake_failed = False
|
||||
self._subscribed_sessions_changed = False
|
||||
self._subscribed_message_keys: set[str] = set()
|
||||
self._abort_lock = threading.Lock()
|
||||
self._aborted_run_ids: set[str] = set()
|
||||
self._active_run_session: dict[str, str] = {}
|
||||
self._gateway_handlers = build_gateway_method_handlers()
|
||||
self._now_ms = _now_ms
|
||||
self._error_shape = _error_shape
|
||||
|
||||
async def run(self) -> None:
|
||||
await run_connection_loop(self)
|
||||
|
||||
async def _recv_frame(self, *, preauth: bool = False) -> dict[str, Any] | None:
|
||||
return await recv_frame_impl(
|
||||
conn=self,
|
||||
preauth=preauth,
|
||||
handshake_timeout_ms=PREAUTH_HANDSHAKE_TIMEOUT_MS,
|
||||
validate_or_errors=validate_or_errors,
|
||||
format_validation_errors=format_validation_errors,
|
||||
error_shape=_error_shape,
|
||||
)
|
||||
|
||||
async def _handle_connect(self, req_id: str, method: str, params: Any) -> None:
|
||||
await handle_connect_impl(
|
||||
conn=self,
|
||||
req_id=req_id,
|
||||
method=method,
|
||||
params=params,
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
validate_or_errors=validate_or_errors,
|
||||
format_validation_errors=format_validation_errors,
|
||||
error_shape=_error_shape,
|
||||
)
|
||||
|
||||
async def _close_ws(self, code: int = 1000, reason: str = "done") -> None:
|
||||
await close_ws_impl(self, code=code, reason=reason)
|
||||
|
||||
async def _dispatch_connected(self, req_id: str, method: str, params: Any) -> None:
|
||||
await dispatch_connected_impl(self, req_id=req_id, method=method, params=params)
|
||||
|
||||
async def _dispatch_via_server_methods(self, *, req_id: str, method: str, params: Any) -> bool:
|
||||
return await dispatch_via_server_methods(
|
||||
req_id=req_id,
|
||||
method=method,
|
||||
params=params,
|
||||
conn_id=self.conn_id,
|
||||
is_webchat_client=bool(self._is_webchat_client),
|
||||
handlers=self._gateway_handlers,
|
||||
context=self._build_gateway_context(),
|
||||
send_res=self.send_res,
|
||||
error_shape=_error_shape,
|
||||
)
|
||||
|
||||
def _build_gateway_context(self) -> dict[str, Any]:
|
||||
return build_gateway_context(
|
||||
conn_id=self.conn_id,
|
||||
subscribed_sessions_changed=bool(self._subscribed_sessions_changed),
|
||||
subscribed_message_keys=set(self._subscribed_message_keys),
|
||||
abort_lock=self._abort_lock,
|
||||
active_run_session=self._active_run_session,
|
||||
aborted_run_ids=self._aborted_run_ids,
|
||||
run_agent_turn=self.run_agent_turn,
|
||||
normalize_ws_attachments=_normalize_ws_attachments,
|
||||
validate_relay_share_envelope=validate_relay_share_envelope,
|
||||
now_ms=_now_ms,
|
||||
)
|
||||
|
||||
def build_hello_ok(self, _connect_params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
methods = ["connect", *method_names()]
|
||||
return build_hello_ok_payload(
|
||||
conn_id=self.conn_id,
|
||||
started_at_ms=int(self.started_at_ms or _now_ms()),
|
||||
role=str(self.role or "operator"),
|
||||
scopes=list(self.scopes or []),
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
methods=methods,
|
||||
max_payload_bytes=MAX_PAYLOAD_BYTES,
|
||||
max_buffered_bytes=MAX_BUFFERED_BYTES,
|
||||
tick_interval_ms=TICK_INTERVAL_MS,
|
||||
now_ms=_now_ms(),
|
||||
)
|
||||
|
||||
async def send_res(self, req_id: str, *, ok: bool, payload: Any | None = None, error: Any | None = None) -> None:
|
||||
await send_res_impl(self, req_id, ok=ok, payload=payload, error=error)
|
||||
|
||||
async def send_event(self, event: str, payload: Any | None = None) -> None:
|
||||
await send_event_impl(self, event, payload)
|
||||
|
||||
async def emit_agent_event(self, *, run_id: str, stream: str, data: dict[str, Any]) -> None:
|
||||
await emit_agent_event_impl(self, run_id=run_id, stream=stream, data=data, now_ms=_now_ms())
|
||||
|
||||
async def emit_chat_event(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
state: str,
|
||||
delta: str = "",
|
||||
reply: str = "",
|
||||
error: str = "",
|
||||
message: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
seq: int | None = None,
|
||||
) -> None:
|
||||
await emit_chat_event_impl(
|
||||
self,
|
||||
run_id=run_id,
|
||||
state=state,
|
||||
delta=delta,
|
||||
reply=reply,
|
||||
error=error,
|
||||
message=message,
|
||||
session_key=session_key,
|
||||
seq=seq,
|
||||
)
|
||||
|
||||
def resolve_ws_auth(self, connect_params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return resolve_ws_auth_payload(connect_params)
|
||||
|
||||
async def run_agent_turn(self, req_id: str, p: dict[str, Any], *, session_id: str, send_response: bool = True) -> None:
|
||||
await run_agent_turn_via_bridge(
|
||||
conn=self,
|
||||
req_id=req_id,
|
||||
p=p,
|
||||
session_id=session_id,
|
||||
send_response=send_response,
|
||||
normalize_ws_attachments=_normalize_ws_attachments,
|
||||
validate_relay_share_envelope=validate_relay_share_envelope,
|
||||
now_ms=_now_ms,
|
||||
error_shape=_error_shape,
|
||||
)
|
||||
|
||||
|
||||
async def ws_gateway_loop(ws: WebSocket) -> None:
|
||||
await OclawWsGatewayConnection(ws).run()
|
||||
|
||||
|
||||
__all__ = ["OclawWsGatewayConnection", "ws_gateway_loop"]
|
||||
|
||||
43
interfaces/ws/runtime_loop.py
Normal file
43
interfaces/ws/runtime_loop.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def close_ws(conn: Any, code: int = 1000, reason: str = "done") -> None:
|
||||
try:
|
||||
await conn.ws.close(code=code, reason=reason)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def run_connection_loop(conn: Any) -> None:
|
||||
await conn.ws.accept()
|
||||
await conn.send_event("connect.challenge", {"nonce": conn.connect_nonce, "ts": conn._now_ms()})
|
||||
while True:
|
||||
frame = await conn._recv_frame(preauth=not conn.connected)
|
||||
if frame is None:
|
||||
return
|
||||
if frame.get("type") != "req":
|
||||
await conn.send_res(
|
||||
str(frame.get("id") or "invalid"),
|
||||
ok=False,
|
||||
error=conn._error_shape("INVALID_REQUEST", "expected req frame"),
|
||||
)
|
||||
if not conn.connected:
|
||||
await close_ws(conn, 1008, "invalid handshake")
|
||||
return
|
||||
continue
|
||||
req_id = str(frame.get("id") or "invalid")
|
||||
method = str(frame.get("method") or "")
|
||||
params = frame.get("params")
|
||||
if not conn.connected:
|
||||
await conn._handle_connect(req_id, method, params)
|
||||
if conn.handshake_failed:
|
||||
await close_ws(conn, 1008, "invalid handshake")
|
||||
return
|
||||
continue
|
||||
await conn._dispatch_connected(req_id, method, params)
|
||||
|
||||
|
||||
__all__ = ["run_connection_loop", "close_ws"]
|
||||
|
||||
79
interfaces/ws/schema_impl.py
Normal file
79
interfaces/ws/schema_impl.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||
|
||||
_SCHEMA_DIR = Path(PROJECT_ROOT) / "oclaw" / "openclaw_protocol_schemas"
|
||||
|
||||
|
||||
def _load_schema(name: str) -> dict[str, Any]:
|
||||
p = _SCHEMA_DIR / name
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WsSchemas:
|
||||
frame: Draft202012Validator
|
||||
connect_params: Draft202012Validator
|
||||
hello_ok: Draft202012Validator
|
||||
agent_params: Draft202012Validator
|
||||
agent_wait_params: Draft202012Validator
|
||||
sessions_schema: dict[str, Any]
|
||||
chat_schema: dict[str, Any]
|
||||
|
||||
|
||||
_CACHED: WsSchemas | None = None
|
||||
|
||||
|
||||
def get_ws_schemas() -> WsSchemas:
|
||||
global _CACHED
|
||||
if _CACHED is not None:
|
||||
return _CACHED
|
||||
|
||||
frame_schema = _load_schema("frames.json")
|
||||
connect_schema = _load_schema("connect.json")
|
||||
hello_ok_schema = _load_schema("hello_ok.json")
|
||||
agent_schema = _load_schema("agent.json")
|
||||
agent_wait_schema = _load_schema("agent_wait.json")
|
||||
sessions_schema = _load_schema("sessions.json")
|
||||
chat_schema = _load_schema("chat.json")
|
||||
|
||||
_CACHED = WsSchemas(
|
||||
frame=Draft202012Validator(frame_schema),
|
||||
connect_params=Draft202012Validator(connect_schema),
|
||||
hello_ok=Draft202012Validator(hello_ok_schema),
|
||||
agent_params=Draft202012Validator(agent_schema),
|
||||
agent_wait_params=Draft202012Validator(agent_wait_schema),
|
||||
sessions_schema=sessions_schema,
|
||||
chat_schema=chat_schema,
|
||||
)
|
||||
return _CACHED
|
||||
|
||||
|
||||
def format_validation_errors(errors: list[Any]) -> str:
|
||||
out: list[str] = []
|
||||
for e in errors[:6]:
|
||||
try:
|
||||
path = "/".join(str(x) for x in list(e.path))
|
||||
except Exception:
|
||||
path = ""
|
||||
msg = str(getattr(e, "message", "") or "")
|
||||
if path:
|
||||
out.append(f"{path}: {msg}")
|
||||
else:
|
||||
out.append(msg)
|
||||
return "; ".join([x for x in out if x]) or "invalid_payload"
|
||||
|
||||
|
||||
def validate_or_errors(validator: Draft202012Validator, obj: Any) -> list[Any]:
|
||||
return list(validator.iter_errors(obj))
|
||||
|
||||
|
||||
__all__ = ["WsSchemas", "get_ws_schemas", "format_validation_errors", "validate_or_errors"]
|
||||
|
||||
192
interfaces/ws/server_methods_bridge.py
Normal file
192
interfaces/ws/server_methods_bridge.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, Callable
|
||||
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.interfaces.gateway.context_builder import build_common_gateway_context
|
||||
|
||||
|
||||
def build_gateway_context(
|
||||
*,
|
||||
conn_id: str,
|
||||
subscribed_sessions_changed: bool,
|
||||
subscribed_message_keys: set[str],
|
||||
abort_lock: Any,
|
||||
active_run_session: dict[str, str],
|
||||
aborted_run_ids: set[str],
|
||||
run_agent_turn: Callable[..., Any],
|
||||
normalize_ws_attachments: Callable[[Any], list[dict[str, Any]]],
|
||||
validate_relay_share_envelope: Callable[[dict[str, Any]], tuple[bool, str, dict[str, Any]]],
|
||||
now_ms: Callable[[], int],
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
|
||||
def _abort_chat_run(run_id: str) -> bool:
|
||||
rid = str(run_id or "").strip()
|
||||
if not rid:
|
||||
return False
|
||||
with abort_lock:
|
||||
if rid not in active_run_session:
|
||||
return False
|
||||
aborted_run_ids.add(rid)
|
||||
return True
|
||||
|
||||
def _enqueue_chat_send(session_key: str, message: str, run_id: str | None, params: dict[str, Any]) -> bool:
|
||||
sid = str(session_key or "").strip()
|
||||
txt = str(message or "").strip()
|
||||
if not sid or not txt:
|
||||
return False
|
||||
rid = str(run_id or "").strip() or uuid.uuid4().hex
|
||||
with abort_lock:
|
||||
active_run_session[rid] = sid
|
||||
p = dict(params or {})
|
||||
interaction_mode = str(p.get("interaction_mode") or "comprehensive").strip().lower() or "comprehensive"
|
||||
specialist = str(p.get("specialist") or "generalist").strip().lower() or "generalist"
|
||||
if interaction_mode != "expert":
|
||||
specialist = "generalist"
|
||||
raw_env = dict(p.get("relay_share_envelope") or {}) if isinstance(p.get("relay_share_envelope"), dict) else {}
|
||||
ok_env, _err_env, norm_env = validate_relay_share_envelope(raw_env) if raw_env else (False, "", {})
|
||||
agent_params = {
|
||||
"message": txt,
|
||||
"sessionId": sid,
|
||||
"attachments": normalize_ws_attachments(p.get("attachments")),
|
||||
"idempotencyKey": str(p.get("idempotencyKey") or uuid.uuid4().hex),
|
||||
"interaction_mode": interaction_mode,
|
||||
"specialist": specialist,
|
||||
"relay_share_envelope": norm_env if ok_env else {},
|
||||
"acp_parent_run_id": str(p.get("acp_parent_run_id") or ""),
|
||||
"acp_child_run_id": str(p.get("acp_child_run_id") or ""),
|
||||
"runId": rid,
|
||||
}
|
||||
asyncio.create_task(run_agent_turn("server_methods.chat.send", agent_params, session_id=sid, send_response=False))
|
||||
return True
|
||||
|
||||
def _run_agent(params: dict[str, Any]) -> dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
session_id = str(p.get("sessionId") or p.get("sessionKey") or "").strip()
|
||||
run_id = str(p.get("idempotencyKey") or "").strip() or uuid.uuid4().hex
|
||||
message = str(p.get("message") or "").strip()
|
||||
interaction_mode = str(p.get("interaction_mode") or "comprehensive").strip().lower() or "comprehensive"
|
||||
specialist = str(p.get("specialist") or "generalist").strip().lower() or "generalist"
|
||||
if interaction_mode != "expert":
|
||||
specialist = "generalist"
|
||||
raw_env = dict(p.get("relay_share_envelope") or {}) if isinstance(p.get("relay_share_envelope"), dict) else {}
|
||||
ok_env, _err_env, norm_env = validate_relay_share_envelope(raw_env) if raw_env else (False, "", {})
|
||||
agent_params = {
|
||||
"message": message,
|
||||
"sessionId": session_id,
|
||||
"attachments": normalize_ws_attachments(p.get("attachments")),
|
||||
"idempotencyKey": run_id,
|
||||
"interaction_mode": interaction_mode,
|
||||
"specialist": specialist,
|
||||
"relay_share_envelope": norm_env if ok_env else {},
|
||||
"acp_parent_run_id": str(p.get("acp_parent_run_id") or ""),
|
||||
"acp_child_run_id": str(p.get("acp_child_run_id") or ""),
|
||||
"runId": run_id,
|
||||
}
|
||||
with abort_lock:
|
||||
active_run_session[str(run_id)] = str(session_id)
|
||||
asyncio.create_task(run_agent_turn("server_methods.agent", agent_params, session_id=session_id, send_response=False))
|
||||
return {"runId": run_id, "status": "started"}
|
||||
|
||||
def _enqueue_session_send(session_key: str, message: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
sid = str(session_key or "").strip()
|
||||
txt = str(message or "").strip()
|
||||
if not sid or not txt:
|
||||
return {"sent": False}
|
||||
run_id = str((params or {}).get("idempotencyKey") or "").strip() or uuid.uuid4().hex
|
||||
queued = _enqueue_chat_send(sid, txt, run_id, dict(params or {}))
|
||||
return {"queued": bool(queued), "runId": run_id, "sent": bool(queued)}
|
||||
|
||||
def _wait_for_agent_job(run_id: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
rid = str(run_id or "").strip()
|
||||
if not rid:
|
||||
return {"status": "error", "runId": rid, "summary": "runId required"}
|
||||
run = store.openclaw_run_get(run_id=rid)
|
||||
if not run:
|
||||
return {"status": "pending", "runId": rid, "summary": "run not finished", "pollAfterMs": 250}
|
||||
status = str(run.status or "").strip().lower()
|
||||
if status == "success":
|
||||
return {"status": "ok", "runId": rid}
|
||||
if status == "failed":
|
||||
return {"status": "error", "runId": rid}
|
||||
return {"status": "pending", "runId": rid, "summary": "run still processing", "pollAfterMs": 250}
|
||||
|
||||
context = build_common_gateway_context(store=store)
|
||||
context.update({
|
||||
"abort_chat_run": _abort_chat_run,
|
||||
"enqueue_chat_send": _enqueue_chat_send,
|
||||
"run_agent": _run_agent,
|
||||
"enqueue_session_send": _enqueue_session_send,
|
||||
"wait_for_agent_job": _wait_for_agent_job,
|
||||
"session_event_subscribers": {conn_id} if subscribed_sessions_changed else set(),
|
||||
"session_message_subscribers": {k: {conn_id} for k in subscribed_message_keys},
|
||||
})
|
||||
return context
|
||||
|
||||
|
||||
async def dispatch_via_server_methods(
|
||||
*,
|
||||
req_id: str,
|
||||
method: str,
|
||||
params: Any,
|
||||
conn_id: str,
|
||||
is_webchat_client: bool,
|
||||
handlers: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
send_res: Callable[..., Any],
|
||||
error_shape: Callable[[str, str], dict[str, Any]],
|
||||
) -> bool:
|
||||
handler = handlers.get(str(method or ""))
|
||||
if not callable(handler):
|
||||
return False
|
||||
|
||||
responded = False
|
||||
response_packet: tuple[bool, Any | None, Any | None] | None = None
|
||||
sync_response = str(method or "") == "chat.send"
|
||||
|
||||
def _respond(ok: bool, payload: Any | None, error: Any | None, _meta: dict[str, Any] | None) -> None:
|
||||
nonlocal responded, response_packet
|
||||
responded = True
|
||||
if sync_response:
|
||||
response_packet = (bool(ok), payload, error)
|
||||
return
|
||||
|
||||
async def _send() -> None:
|
||||
await send_res(req_id, ok=bool(ok), payload=payload, error=error)
|
||||
|
||||
asyncio.create_task(_send())
|
||||
|
||||
opts = {
|
||||
"req": {"id": req_id, "method": method, "params": params},
|
||||
"params": dict(params) if isinstance(params, dict) else {},
|
||||
"client": {"conn_id": conn_id, "internal": {}},
|
||||
"respond": _respond,
|
||||
"context": context,
|
||||
"is_webchat_connect": lambda _p: bool(is_webchat_client),
|
||||
}
|
||||
try:
|
||||
handler(opts)
|
||||
except Exception as exc:
|
||||
await send_res(
|
||||
req_id,
|
||||
ok=False,
|
||||
error=error_shape("UNAVAILABLE", f"server method failed: {type(exc).__name__}: {exc}"),
|
||||
)
|
||||
return True
|
||||
if sync_response and responded and response_packet is not None:
|
||||
ok, payload, error = response_packet
|
||||
await send_res(req_id, ok=ok, payload=payload, error=error)
|
||||
return True
|
||||
if responded:
|
||||
return True
|
||||
if not responded:
|
||||
await send_res(req_id, ok=False, error=error_shape("UNAVAILABLE", f"server method did not respond: {method}"))
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["build_gateway_context", "dispatch_via_server_methods"]
|
||||
|
||||
328
interfaces/ws/turn_runner.py
Normal file
328
interfaces/ws/turn_runner.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Callable
|
||||
|
||||
from oclaw.agents.factory import build_gateway_executor
|
||||
from oclaw.openclaw_runtime.gateway import OpenClawGateway
|
||||
from oclaw.openclaw_runtime.types import StandardMessage
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
async def run_agent_turn_via_bridge(
|
||||
*,
|
||||
conn: Any,
|
||||
req_id: str,
|
||||
p: dict[str, Any],
|
||||
session_id: str,
|
||||
send_response: bool,
|
||||
normalize_ws_attachments: Callable[[Any], list[dict[str, Any]]],
|
||||
validate_relay_share_envelope: Callable[[dict[str, Any]], tuple[bool, str, dict[str, Any]]],
|
||||
now_ms: Callable[[], int],
|
||||
error_shape: Callable[[str, str], dict[str, Any]],
|
||||
) -> None:
|
||||
msg_text = str(p.get("message") or "").strip()
|
||||
attachments = list(p.get("attachments") or [])
|
||||
store = SqliteStore(db_path())
|
||||
gw = OpenClawGateway(store=store)
|
||||
|
||||
ctx = conn.auth_ctx or {}
|
||||
tenant_id = str(ctx.get("tenant_id") or "")
|
||||
user_id = str(ctx.get("user_id") or "")
|
||||
lang = "zh"
|
||||
|
||||
manager_agent = build_gateway_executor(
|
||||
store,
|
||||
lang=lang,
|
||||
specialist="generalist",
|
||||
viewer_user_id=user_id or None,
|
||||
viewer_username=str(ctx.get("username") or "") or None,
|
||||
viewer_tenant_id=tenant_id or None,
|
||||
policy_session_id=session_id,
|
||||
path_policy_tenant_id=tenant_id or None,
|
||||
path_policy_user_id=user_id or None,
|
||||
)
|
||||
specialist_factory = lambda sid: build_gateway_executor(
|
||||
store,
|
||||
lang=lang,
|
||||
specialist=sid,
|
||||
viewer_user_id=user_id or None,
|
||||
viewer_username=str(ctx.get("username") or "") or None,
|
||||
viewer_tenant_id=tenant_id or None,
|
||||
policy_session_id=session_id,
|
||||
path_policy_tenant_id=tenant_id or None,
|
||||
path_policy_user_id=user_id or None,
|
||||
)
|
||||
|
||||
run_id_holder: dict[str, str] = {"run_id": str(p.get("runId") or uuid.uuid4().hex)}
|
||||
loop = asyncio.get_running_loop()
|
||||
run_id = str(run_id_holder.get("run_id") or "")
|
||||
if run_id:
|
||||
with conn._abort_lock:
|
||||
conn._active_run_session[run_id] = str(session_id)
|
||||
|
||||
buf_lock = threading.Lock()
|
||||
token_chunks: list[str] = []
|
||||
marker_turn_count = 0
|
||||
marker_session_count = 0
|
||||
marker_keep_count = 0
|
||||
|
||||
def _schedule(coro: Any) -> None:
|
||||
try:
|
||||
loop.call_soon_threadsafe(lambda: asyncio.create_task(coro))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_token(tok: str) -> None:
|
||||
token_text = str(tok)
|
||||
rid = run_id_holder.get("run_id") or ""
|
||||
if rid:
|
||||
with conn._abort_lock:
|
||||
if rid in conn._aborted_run_ids:
|
||||
return
|
||||
if rid and not conn._is_webchat_client:
|
||||
_schedule(conn.emit_agent_event(run_id=rid, stream="assistant", data={"event": "delta", "delta": token_text}))
|
||||
with buf_lock:
|
||||
token_chunks.append(token_text)
|
||||
_schedule(
|
||||
conn.emit_chat_event(
|
||||
run_id=rid,
|
||||
state="delta",
|
||||
delta=token_text,
|
||||
session_key=str(session_id),
|
||||
)
|
||||
)
|
||||
|
||||
def on_progress(text: str) -> None:
|
||||
rid = run_id_holder.get("run_id") or ""
|
||||
if rid:
|
||||
with conn._abort_lock:
|
||||
if rid in conn._aborted_run_ids:
|
||||
return
|
||||
_schedule(conn.emit_agent_event(run_id=rid, stream="lifecycle", data={"phase": "running", "message": str(text)}))
|
||||
|
||||
def on_tool_ui(name: str, payload: dict[str, Any]) -> None:
|
||||
rid = run_id_holder.get("run_id") or ""
|
||||
if rid:
|
||||
with conn._abort_lock:
|
||||
if rid in conn._aborted_run_ids:
|
||||
return
|
||||
pl = {"runId": rid, "sessionKey": str(session_id), "name": str(name), "payload": dict(payload or {}), "ts": now_ms()}
|
||||
_schedule(conn.send_event("session.tool", pl))
|
||||
|
||||
msg = StandardMessage(
|
||||
session_id=session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
role=str(p.get("role") or "member"),
|
||||
channel=str(p.get("channel") or "ws"),
|
||||
text=msg_text,
|
||||
attachments=attachments,
|
||||
metadata={
|
||||
"interaction_mode": str(p.get("interaction_mode") or "comprehensive"),
|
||||
"selected_specialist": str(p.get("specialist") or "generalist"),
|
||||
"relay_share_envelope": dict(p.get("relay_share_envelope") or {})
|
||||
if isinstance(p.get("relay_share_envelope"), dict)
|
||||
else {},
|
||||
"acp_parent_run_id": str(p.get("acp_parent_run_id") or ""),
|
||||
"acp_child_run_id": str(p.get("acp_child_run_id") or ""),
|
||||
},
|
||||
)
|
||||
|
||||
await conn.emit_agent_event(run_id=run_id_holder["run_id"], stream="lifecycle", data={"phase": "start", "status": "accepted"})
|
||||
try:
|
||||
relay_env = p.get("relay_share_envelope") if isinstance(p.get("relay_share_envelope"), dict) else {}
|
||||
relay_pointers = 0
|
||||
relay_turn = 0
|
||||
relay_session = 0
|
||||
relay_keep = 0
|
||||
if isinstance(relay_env, dict):
|
||||
ad = relay_env.get("attachments") if isinstance(relay_env.get("attachments"), dict) else {}
|
||||
ps = ad.get("pointers") if isinstance(ad.get("pointers"), list) else []
|
||||
relay_pointers = len(ps)
|
||||
for item in ps:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
ttl = str(item.get("ttl_policy") or "").strip().lower()
|
||||
if ttl == "turn":
|
||||
relay_turn += 1
|
||||
elif ttl == "session":
|
||||
relay_session += 1
|
||||
elif ttl == "keep":
|
||||
relay_keep += 1
|
||||
marker_turn_count = int(relay_turn)
|
||||
marker_session_count = int(relay_session)
|
||||
marker_keep_count = int(relay_keep)
|
||||
await conn.send_event(
|
||||
"session.marker",
|
||||
{
|
||||
"runId": run_id_holder["run_id"],
|
||||
"sessionKey": str(session_id),
|
||||
"action": "ingress",
|
||||
"relayPointerCount": int(relay_pointers),
|
||||
"relayEnvelopePresent": bool(isinstance(relay_env, dict) and bool(relay_env)),
|
||||
"relayEnvelopePointerCount": int(relay_pointers),
|
||||
"relayTtlTurnCount": int(relay_turn),
|
||||
"relayTtlSessionCount": int(relay_session),
|
||||
"relayTtlKeepCount": int(relay_keep),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _run_turn_sync() -> Any:
|
||||
return gw.handle_turn(
|
||||
msg=msg,
|
||||
lang=lang,
|
||||
executor=manager_agent,
|
||||
specialist_executor_factory=specialist_factory,
|
||||
run_id=run_id_holder.get("run_id") or None,
|
||||
on_token=on_token,
|
||||
on_progress=on_progress,
|
||||
on_tool_ui=on_tool_ui,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_run_turn_sync)
|
||||
except Exception as exc:
|
||||
if send_response:
|
||||
await conn.send_res(req_id, ok=False, error=error_shape("UNAVAILABLE", str(exc or "agent_failed")))
|
||||
await conn.emit_agent_event(
|
||||
run_id=run_id_holder.get("run_id") or "",
|
||||
stream="lifecycle",
|
||||
data={"phase": "error", "status": "error", "error": str(exc or "agent_failed")},
|
||||
)
|
||||
await conn.emit_chat_event(run_id=run_id_holder.get("run_id") or "", state="error", error=str(exc or "agent_failed"))
|
||||
try:
|
||||
await conn.send_event(
|
||||
"session.marker",
|
||||
{
|
||||
"runId": run_id_holder.get("run_id") or "",
|
||||
"sessionKey": str(session_id),
|
||||
"action": "turn_reclaimed",
|
||||
"reclaimedTurnPointers": int(marker_turn_count),
|
||||
"relayTtlTurnCount": int(marker_turn_count),
|
||||
"relayTtlSessionCount": int(marker_session_count),
|
||||
"relayTtlKeepCount": int(marker_keep_count),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
finally:
|
||||
_rid0 = str(run_id_holder.get("run_id") or "")
|
||||
if _rid0:
|
||||
with conn._abort_lock:
|
||||
conn._active_run_session.pop(_rid0, None)
|
||||
conn._aborted_run_ids.discard(_rid0)
|
||||
|
||||
rid = str(getattr(result, "run_id", "") or run_id_holder.get("run_id") or "")
|
||||
with conn._abort_lock:
|
||||
if rid and rid in conn._aborted_run_ids:
|
||||
try:
|
||||
await conn.send_event(
|
||||
"session.marker",
|
||||
{
|
||||
"runId": rid,
|
||||
"sessionKey": str(session_id),
|
||||
"action": "turn_reclaimed",
|
||||
"reclaimedTurnPointers": int(marker_turn_count),
|
||||
"relayTtlTurnCount": int(marker_turn_count),
|
||||
"relayTtlSessionCount": int(marker_session_count),
|
||||
"relayTtlKeepCount": int(marker_keep_count),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if send_response:
|
||||
await conn.send_res(
|
||||
req_id,
|
||||
ok=True,
|
||||
payload={
|
||||
"runId": rid,
|
||||
"acceptedAt": now_ms(),
|
||||
"mode": str(getattr(result, "mode", "sync_direct") or "sync_direct"),
|
||||
"taskId": str(getattr(result, "task_id", "") or ""),
|
||||
"traceId": str(getattr(result, "trace_id", "") or ""),
|
||||
"reply": str(getattr(result, "reply_text", "") or ""),
|
||||
"selectedSpecialist": str(getattr(result, "selected_specialist", "generalist") or "generalist"),
|
||||
"interactionMode": str(getattr(result, "interaction_mode", "comprehensive") or "comprehensive"),
|
||||
"dispatchReason": str(getattr(result, "dispatch_reason", "") or ""),
|
||||
"managerSelectedSpecialist": str(getattr(result, "manager_selected_specialist", "generalist") or "generalist"),
|
||||
"requestedSpecialist": str(getattr(result, "requested_specialist", "generalist") or "generalist"),
|
||||
"dynamicAgentUsed": bool(getattr(result, "dynamic_agent_used", False) or False),
|
||||
"dynamicAgentName": str(getattr(result, "dynamic_agent_name", "") or ""),
|
||||
"relayPointerCount": int(getattr(result, "relay_pointer_count", 0) or 0),
|
||||
"relayEnvelopePresent": bool(getattr(result, "relay_envelope_present", False) or False),
|
||||
"relayEnvelopePointerCount": int(getattr(result, "relay_envelope_pointer_count", 0) or 0),
|
||||
"relayTtlTurnCount": int(getattr(result, "relay_ttl_turn_count", 0) or 0),
|
||||
"relayTtlSessionCount": int(getattr(result, "relay_ttl_session_count", 0) or 0),
|
||||
"relayTtlKeepCount": int(getattr(result, "relay_ttl_keep_count", 0) or 0),
|
||||
},
|
||||
)
|
||||
await conn.emit_agent_event(
|
||||
run_id=rid,
|
||||
stream="lifecycle",
|
||||
data={
|
||||
"phase": "end",
|
||||
"status": "ok",
|
||||
"reply": str(getattr(result, "reply_text", "") or ""),
|
||||
"elapsedMs": int(getattr(result, "elapsed_ms", 0) or 0),
|
||||
"mode": str(getattr(result, "mode", "sync_direct") or "sync_direct"),
|
||||
"taskId": str(getattr(result, "task_id", "") or ""),
|
||||
"traceId": str(getattr(result, "trace_id", "") or ""),
|
||||
},
|
||||
)
|
||||
final_text = str(getattr(result, "reply_text", "") or "")
|
||||
if not final_text:
|
||||
with buf_lock:
|
||||
final_text = "".join(token_chunks)
|
||||
final_msg: dict[str, Any] = {"role": "assistant", "content": final_text, "timestamp": now_ms()}
|
||||
try:
|
||||
persisted = store.get_messages(session_id=session_id, limit=12)
|
||||
for m in reversed(list(persisted or [])):
|
||||
if str(getattr(m, "role", "") or "").lower() != "assistant":
|
||||
continue
|
||||
content = str(getattr(m, "content", "") or "")
|
||||
if not content.strip():
|
||||
continue
|
||||
final_text = content
|
||||
final_msg = {
|
||||
"id": int(getattr(m, "id", 0) or 0),
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"timestamp": str(getattr(m, "timestamp", "") or ""),
|
||||
"tool_calls": getattr(m, "tool_calls", None),
|
||||
"attachments": getattr(m, "attachments", None),
|
||||
}
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await conn.emit_chat_event(run_id=rid, state="final", reply=str(final_text or ""), message=final_msg, session_key=str(session_id))
|
||||
try:
|
||||
await conn.send_event(
|
||||
"session.marker",
|
||||
{
|
||||
"runId": rid,
|
||||
"sessionKey": str(session_id),
|
||||
"action": "turn_reclaimed",
|
||||
"reclaimedTurnPointers": int(marker_turn_count),
|
||||
"relayTtlTurnCount": int(marker_turn_count),
|
||||
"relayTtlSessionCount": int(marker_session_count),
|
||||
"relayTtlKeepCount": int(marker_keep_count),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await conn.send_event("session.message", {"sessionKey": str(session_id), "message": final_msg})
|
||||
if conn._subscribed_sessions_changed:
|
||||
await conn.send_event("sessions.changed", {"sessionKey": str(session_id), "reason": "send", "ts": now_ms()})
|
||||
|
||||
|
||||
__all__ = ["run_agent_turn_via_bridge"]
|
||||
|
||||
6
interfaces/ws/ws_schema.py
Normal file
6
interfaces/ws/ws_schema.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""WS schema exports under oclaw namespace."""
|
||||
|
||||
from .schema_impl import WsSchemas, format_validation_errors, get_ws_schemas, validate_or_errors
|
||||
|
||||
__all__ = ["WsSchemas", "format_validation_errors", "get_ws_schemas", "validate_or_errors"]
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue