oclaw/runtime/gateway.py
oliver 4a23b715a2 重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。

Made-with: Cursor
2026-04-25 01:24:23 +08:00

676 lines
30 KiB
Python

from __future__ import annotations
import json
import os
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
from oclaw.runtime.agents.factory import build_ephemeral_executor
from oclaw.runtime.agent_context import build_role_system_context
from oclaw.runtime.hooks_runtime import (
get_active_hooks_config,
initialize_hooks_runtime,
trigger_hook_event,
)
from oclaw.runtime.relay_pointer import summarize_relay_ttl
from oclaw.runtime.skills import build_skill_manifest
from oclaw.runtime.types import (
OclawSessionContext,
StandardMessage,
normalize_interaction_mode,
normalize_requested_specialist,
)
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.prompts import render_prompt
from oclaw.prompts.loader import render_runtime_prompt
from oclaw.runtime.command_parser import parse_internal_command
from oclaw.runtime.core.agent_execution import AgentCoreRunInput, build_memory_context, run_agent_core
from oclaw.runtime.router import decide_route
from oclaw.runtime.worker import ensure_worker_started
from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id
_OC_STAGE_BY_EVENT: dict[str, str] = {
"gateway_received": "ingress",
"gateway_normalized": "normalize",
"skill_manifest": "skills_manifest",
"memory_retrieval_started": "memory_start",
"memory_retrieval_finished": "memory_done",
"router_decision": "route",
"task_enqueued": "async_enqueue",
"runtime_config": "runtime_config",
"response_sent": "response",
}
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
_DEFAULT_TABULAR_PREVIEW_ROWS = 20
_DEFAULT_TABULAR_ROWS_READ = 5000
@dataclass(frozen=True)
class OclawGatewayResult:
run_id: str
reply_text: str
trace_id: str
elapsed_ms: int
mode: str = "sync_direct"
task_id: str | None = None
selected_specialist: str = "generalist"
interaction_mode: str = "comprehensive"
dispatch_reason: str = ""
manager_selected_specialist: str = "generalist"
requested_specialist: str = "generalist"
dynamic_agent_used: bool = False
dynamic_agent_name: str = ""
relay_pointer_count: int = 0
relay_envelope_present: bool = False
relay_envelope_pointer_count: int = 0
relay_ttl_turn_count: int = 0
relay_ttl_session_count: int = 0
relay_ttl_keep_count: int = 0
class OclawGateway:
def __init__(self, *, store: Any):
self.store = store
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any] | None:
t = str(text or "").strip()
start = t.find("{")
if start < 0:
return None
try:
obj, _end = json.JSONDecoder().raw_decode(t[start:])
except Exception:
return None
return obj if isinstance(obj, dict) else None
@staticmethod
def _sanitize_dynamic_system_prompt(raw: Any) -> str:
s = str(raw or "").strip()
if not s:
return ""
s = s[:3000]
banned = ("<tool_call>", "</tool_call>", "assistant_response:", "function_call:")
low = s.lower()
if any(b in low for b in banned):
return ""
return s
@staticmethod
def _parse_dynamic_agent(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
name = str(raw.get("name") or "").strip()
system_prompt = OclawGateway._sanitize_dynamic_system_prompt(raw.get("system_prompt"))
reason = str(raw.get("reason") or "").strip()
tp = raw.get("tool_policy")
tool_policy = tp if isinstance(tp, dict) else {}
allow_tags = [str(x).strip() for x in (tool_policy.get("allow_tags") or []) if str(x).strip()]
allow_tools = [str(x).strip() for x in (tool_policy.get("allow_tools") or []) if str(x).strip()]
if not system_prompt:
return None
return {
"name": name or "dynamic_ephemeral",
"system_prompt": system_prompt,
"tool_policy": {"allow_tags": allow_tags, "allow_tools": allow_tools},
"reason": reason or "dynamic_agent_selected",
}
def _manager_select_specialist(
self,
*,
msg: StandardMessage,
lang: str,
executor: Any,
memory_curator_enabled: bool,
skill_names_preview: list[str] | None = None,
) -> tuple[str, str, dict[str, Any] | None]:
model = getattr(executor, "model", None)
if model is None or not callable(getattr(model, "chat", None)):
return ("generalist", "manager_model_missing", None)
try:
manager_context = build_role_system_context("generalist")
allowed_fixed = ["ops", "generalist", "image"]
if memory_curator_enabled:
allowed_fixed.append("memory_curator")
allowed_fixed_csv = ",".join(allowed_fixed)
allowed_fixed_quoted = ", ".join([f'"{x}"' for x in allowed_fixed])
user_block = render_runtime_prompt(
"manager/decision.md",
variables={"agent_registry": f"specialists: {allowed_fixed_csv}"},
strict=True,
)
messages = [
{
"role": "system",
"content": (
f"{manager_context}\n\n"
"Return exactly one compact JSON object with route.specialist and route.reason. "
f"Allowed fixed specialists: {allowed_fixed_quoted}. "
"If none fits, you may set route.specialist to a custom id and include dynamic_agent with "
"name/system_prompt/tool_policy(allow_tags/allow_tools)/reason."
),
},
{
"role": "user",
"content": (
f"{user_block}\n\n"
f"Visible skills preview: {', '.join(skill_names_preview or [])}\n\n"
f"User request:\n{str(msg.text or '').strip()}"
),
},
]
resp = model.chat(messages, [], on_token=None)
obj = self._parse_json_object(str(getattr(resp, "content", "") or ""))
route = obj.get("route") if isinstance(obj, dict) else None
if not isinstance(route, dict):
return ("generalist", "manager_route_missing", None)
raw_specialist = str(route.get("specialist") or "").strip().lower()
fixed_set = {"ops", "generalist", "image"}
if memory_curator_enabled:
fixed_set.add("memory_curator")
fixed = raw_specialist in fixed_set
specialist = normalize_requested_specialist(raw_specialist) if fixed else raw_specialist
reason = str(route.get("reason") or "").strip() or "manager_selected"
dynamic_agent = self._parse_dynamic_agent(obj.get("dynamic_agent") if isinstance(obj, dict) else None)
if specialist == "memory_curator" and not memory_curator_enabled:
return ("generalist", "memory_curator_disabled_fallback", None)
if not fixed and dynamic_agent is None:
return ("generalist", "dynamic_agent_invalid_fallback", None)
return (specialist, reason, dynamic_agent)
except Exception:
return ("generalist", "manager_select_failed", None)
def _memory_curator_enabled(self) -> bool:
raw = str(self.store.get_setting(_SPECIALIST_FLAGS_SETTING_KEY) or "").strip()
if not raw:
return True
try:
obj = json.loads(raw)
except Exception:
return True
if not isinstance(obj, dict):
return True
return bool(obj.get("memory_curator", True))
@staticmethod
def _has_tabular_ref_attachments(msg: StandardMessage) -> bool:
atts = msg.attachments if isinstance(msg.attachments, list) else []
for a in atts:
if isinstance(a, dict) and str(a.get("type") or "").strip().lower() == "tabular_ref":
return True
return False
@staticmethod
def _tabular_query_system_hint(lang: str) -> str:
limits = OclawGateway._tabular_limits_from_config()
preview_rows = int(limits.get("large_table_preview_rows") or _DEFAULT_TABULAR_PREVIEW_ROWS)
max_rows_read = int(limits.get("max_rows_read") or _DEFAULT_TABULAR_ROWS_READ)
if str(lang or "").startswith("en"):
return (
f"For large table attachments: only the first {preview_rows} preview rows are included in context. "
f"A single read is capped at {max_rows_read} rows. "
"If you need more rows/details, use database tools (`query_tabular_attachment` / `run_tabular_sql`) with table_id."
)
return (
f"对于大表附件:当前上下文只提供前{preview_rows}行预览。"
f"单次读取上限为{max_rows_read}行。"
"如果需要更多行或更细节,请通过数据库工具(`query_tabular_attachment` / `run_tabular_sql`)结合 table_id 查询。"
)
@staticmethod
def _tabular_limits_from_config() -> dict[str, int]:
cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
cfg_path = Path(cfg_path_raw).expanduser() if cfg_path_raw else (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json")
if not cfg_path.is_absolute():
cfg_path = (Path(PROJECT_ROOT) / cfg_path).resolve()
try:
obj = json.loads(cfg_path.read_text(encoding="utf-8"))
tabular = (
(((obj or {}).get("plugins") or {}).get("entries") or {})
.get("memory-wiki", {})
.get("auto", {})
.get("attachments", {})
.get("tabular", {})
)
if not isinstance(tabular, dict):
tabular = {}
raw_preview = int(tabular.get("large_table_preview_rows") or _DEFAULT_TABULAR_PREVIEW_ROWS)
raw_rows_read = int(tabular.get("max_rows_read") or _DEFAULT_TABULAR_ROWS_READ)
preview = min(max(raw_preview, 1), 500)
rows_read = min(max(raw_rows_read, 1), 2_000_000)
return {
"large_table_preview_rows": preview,
"max_rows_read": rows_read,
}
except Exception:
return {
"large_table_preview_rows": _DEFAULT_TABULAR_PREVIEW_ROWS,
"max_rows_read": _DEFAULT_TABULAR_ROWS_READ,
}
@staticmethod
def _relay_pointer_stats(msg: StandardMessage) -> dict[str, Any]:
atts = msg.attachments if isinstance(msg.attachments, list) else []
att_ptr_count = 0
for a in atts:
if isinstance(a, dict) and str(a.get("pointer_uri") or "").strip():
att_ptr_count += 1
md = msg.metadata if isinstance(msg.metadata, dict) else {}
env = md.get("relay_share_envelope")
env_ptr_count = 0
if isinstance(env, dict):
ad = env.get("attachments")
if isinstance(ad, dict):
ps = ad.get("pointers")
if isinstance(ps, list):
env_ptr_count = len([x for x in ps if isinstance(x, dict)])
return {
"relay_pointer_count": int(att_ptr_count),
"relay_envelope_present": bool(isinstance(env, dict)),
"relay_envelope_pointer_count": int(env_ptr_count),
}
@staticmethod
def _resolve_workspace_dir(msg: StandardMessage) -> str:
if isinstance(msg.metadata, dict):
ws = str(msg.metadata.get("workspaceDir") or msg.metadata.get("workspace_dir") or "").strip()
if ws:
return ws
return str(os.getenv("OCLAW_WORKSPACE") or "").strip()
@staticmethod
def _resolve_command_source(msg: StandardMessage) -> str:
if isinstance(msg.metadata, dict):
src = str(msg.metadata.get("commandSource") or msg.metadata.get("source") or "").strip()
if src:
return src
return str(msg.channel or "unknown")
@staticmethod
def _build_command_hook_context(*, msg: StandardMessage, workspace_dir: str) -> dict[str, Any]:
md = msg.metadata if isinstance(msg.metadata, dict) else {}
cfg = get_active_hooks_config()
return {
"commandSource": OclawGateway._resolve_command_source(msg),
"senderId": str(msg.user_id or "unknown"),
"workspaceDir": str(workspace_dir or ""),
"sessionEntry": {
"sessionId": str(msg.session_id or ""),
"tenantId": str(msg.tenant_id or ""),
"userId": str(msg.user_id or ""),
"channel": str(msg.channel or ""),
"role": str(msg.role or ""),
},
"cfg": cfg,
"metadata": dict(md),
}
def _trace(
self,
*,
ctx: OclawSessionContext,
event_type: str,
payload: dict[str, Any],
started_at: float | None = None,
) -> None:
merged: dict[str, Any] = dict(payload or {})
merged.setdefault("pipeline", "oclaw_gateway")
merged.setdefault("trace_id", ctx.trace_id)
merged.setdefault("lang", str(ctx.lang or ""))
merged["oc_stage"] = _OC_STAGE_BY_EVENT.get(event_type, event_type)
if started_at is not None:
merged["elapsed_ms_since_gateway_start"] = int((time.perf_counter() - started_at) * 1000)
try:
self.store.add_trace_event(
session_id=ctx.session_id,
trace_id=ctx.trace_id,
span_id=new_span_id(),
parent_span_id=ctx.parent_span_id,
event_type=event_type,
payload=merged,
)
except Exception:
pass
def handle_turn(
self,
*,
msg: StandardMessage,
lang: str,
executor: Any,
run_id: str | None = None,
on_token: Optional[Callable[[str], None]] = None,
on_progress: Optional[Callable[[str], None]] = None,
on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None,
should_stop: Optional[Callable[[], bool]] = None,
specialist_executor_factory: Optional[Callable[[str], Any]] = None,
) -> OclawGatewayResult:
t0 = time.perf_counter()
trace_id = new_trace_id()
rid = str(run_id or "").strip() or str(uuid.uuid4())
ctx = OclawSessionContext(
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
role=msg.role,
channel=msg.channel,
lang=lang,
trace_id=trace_id,
parent_span_id=None,
)
relay_stats = self._relay_pointer_stats(msg)
ttl_stats = summarize_relay_ttl(msg.metadata.get("relay_share_envelope") if isinstance(msg.metadata, dict) else None)
workspace_dir = self._resolve_workspace_dir(msg)
if workspace_dir:
initialize_hooks_runtime(cfg=None, workspace_dir=workspace_dir)
try:
if isinstance(msg.metadata, dict) and "workspaceDir" not in msg.metadata and "workspace_dir" not in msg.metadata:
msg.metadata["workspaceDir"] = workspace_dir
except Exception:
pass
parsed_cmd = parse_internal_command(str(msg.text or ""))
if parsed_cmd and parsed_cmd.action == "new":
trigger_hook_event(
event_type="command",
action="new",
session_key=str(msg.session_id or "unknown"),
context=self._build_command_hook_context(msg=msg, workspace_dir=workspace_dir),
)
elif parsed_cmd and parsed_cmd.action == "reset":
trigger_hook_event(
event_type="command",
action="reset",
session_key=str(msg.session_id or "unknown"),
context=self._build_command_hook_context(msg=msg, workspace_dir=workspace_dir),
)
self._trace(
ctx=ctx,
event_type="gateway_received",
payload={"channel": msg.channel, "has_attachments": bool(msg.attachments), **relay_stats, **ttl_stats},
started_at=t0,
)
self._trace(
ctx=ctx,
event_type="gateway_normalized",
payload={"text_chars": len(msg.text or ""), "metadata_keys": sorted(list(msg.metadata.keys()))[:20]},
started_at=t0,
)
skill_stats: dict[str, Any] = {}
try:
reg = getattr(executor, "tools", None)
base_url = str(getattr(getattr(executor, "model", None), "base_url", "") or "")
if reg is not None:
_, stats = build_skill_manifest(registry=reg, store=self.store, base_url=base_url)
skill_stats = dict(stats or {})
self._trace(ctx=ctx, event_type="skill_manifest", payload={"base_url": base_url, **skill_stats}, started_at=t0)
except Exception:
pass
self._trace(ctx=ctx, event_type="memory_retrieval_started", payload={"session_id": msg.session_id}, started_at=t0)
memory_context = build_memory_context(
store=self.store,
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
query_text=msg.text,
)
self._trace(
ctx=ctx,
event_type="memory_retrieval_finished",
payload={
"short_term_count": len(memory_context.short_term),
"semantic_hit_count": len(memory_context.semantic_hits),
"enabled": bool(memory_context.enabled),
},
started_at=t0,
)
base_metadata = dict(msg.metadata or {})
memory_curator_enabled = self._memory_curator_enabled()
interaction_mode = normalize_interaction_mode(base_metadata.get("interaction_mode"))
requested_specialist = normalize_requested_specialist(base_metadata.get("selected_specialist"))
if requested_specialist == "memory_curator" and not memory_curator_enabled:
requested_specialist = "generalist"
manager_specialist = requested_specialist
dispatch_reason = "expert_direct"
selected_executor = executor
dynamic_agent: dict[str, Any] | None = None
if interaction_mode == "expert" and callable(specialist_executor_factory):
try:
selected_executor = specialist_executor_factory(requested_specialist)
except Exception:
selected_executor = executor
dispatch_reason = "expert_factory_failed"
if interaction_mode == "comprehensive":
manager_specialist, dispatch_reason, dynamic_agent = self._manager_select_specialist(
msg=msg,
lang=lang,
executor=executor,
memory_curator_enabled=memory_curator_enabled,
skill_names_preview=list(skill_stats.get("visible_names_preview") or []),
)
if manager_specialist in {"ops", "generalist", "image", "memory_curator"}:
if callable(specialist_executor_factory):
try:
selected_executor = specialist_executor_factory(manager_specialist)
except Exception:
selected_executor = executor
dispatch_reason = "manager_factory_failed"
elif dynamic_agent:
try:
selected_executor = build_ephemeral_executor(
self.store,
lang=lang,
system_prompt=str(dynamic_agent.get("system_prompt") or ""),
tool_policy=dict(dynamic_agent.get("tool_policy") or {}),
viewer_user_id=msg.user_id,
viewer_tenant_id=msg.tenant_id,
policy_session_id=msg.session_id,
path_policy_tenant_id=msg.tenant_id,
path_policy_user_id=msg.user_id,
)
manager_specialist = str(dynamic_agent.get("name") or "dynamic_ephemeral")
dispatch_reason = str(dynamic_agent.get("reason") or "dynamic_agent_selected")
except Exception:
manager_specialist = "generalist"
dispatch_reason = "dynamic_agent_build_failed"
route_msg = StandardMessage(
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
role=msg.role,
channel=msg.channel,
text=msg.text,
attachments=list(msg.attachments or []),
metadata={
**base_metadata,
"skills_total": int(skill_stats.get("skills_total") or 0),
"interaction_mode": interaction_mode,
"requested_specialist": requested_specialist,
"manager_selected_specialist": manager_specialist,
},
)
route = decide_route(route_msg, store=self.store, model=getattr(selected_executor, "model", None))
self._trace(
ctx=ctx,
event_type="router_decision",
payload={
"mode": route.mode,
"reason": route.reason,
"interaction_mode": interaction_mode,
"requested_specialist": requested_specialist,
"manager_selected_specialist": manager_specialist,
"dispatch_reason": dispatch_reason,
},
started_at=t0,
)
if on_progress:
on_progress("oclaw: running…")
if route.mode == "async_task":
worker_id = ensure_worker_started(store=self.store)
task = self.store.oclaw_task_create(
tenant_id=msg.tenant_id,
session_id=msg.session_id,
task_type="async_turn",
payload={
"trace_id": trace_id,
"run_id": rid,
"session_id": msg.session_id,
"tenant_id": msg.tenant_id,
"user_id": msg.user_id,
"role": msg.role,
"channel": msg.channel,
"lang": lang,
"text": msg.text,
"attachments": msg.attachments,
"metadata": dict(msg.metadata or {}),
"relay_share_envelope": (dict(msg.metadata.get("relay_share_envelope")) if isinstance(msg.metadata, dict) and isinstance(msg.metadata.get("relay_share_envelope"), dict) else None),
"acp_parent_run_id": (str(msg.metadata.get("acp_parent_run_id") or "") if isinstance(msg.metadata, dict) else ""),
"acp_child_run_id": (str(msg.metadata.get("acp_child_run_id") or "") if isinstance(msg.metadata, dict) else ""),
"relay_pointer_count": int(relay_stats.get("relay_pointer_count") or 0),
"relay_envelope_present": bool(relay_stats.get("relay_envelope_present")),
"relay_envelope_pointer_count": int(relay_stats.get("relay_envelope_pointer_count") or 0),
"relay_ttl_turn_count": int(ttl_stats.get("turn") or 0),
"relay_ttl_session_count": int(ttl_stats.get("session") or 0),
"relay_ttl_keep_count": int(ttl_stats.get("keep") or 0),
"interaction_mode": interaction_mode,
"requested_specialist": requested_specialist,
"selected_specialist": manager_specialist,
"manager_selected_specialist": manager_specialist,
"dispatch_reason": dispatch_reason,
"memory_mode": str((msg.metadata or {}).get("memory_mode") or ""),
"dynamic_agent_used": bool(dynamic_agent is not None),
"dynamic_agent": dynamic_agent,
},
)
self._trace(
ctx=ctx,
event_type="task_enqueued",
payload={"task_id": task.id, "task_type": task.task_type, "worker_id": worker_id, "status": task.status},
started_at=t0,
)
elapsed_ms = int((time.perf_counter() - t0) * 1000)
reply = render_prompt(
"fallback/task_queued.en.md" if str(lang or "").startswith("en") else "fallback/task_queued.zh.md",
variables={"task_id": str(task.id)},
strict=True,
)
self._trace(
ctx=ctx,
event_type="response_sent",
payload={"ok": True, "elapsed_ms": elapsed_ms, "mode": "async_task", "task_id": str(task.id)},
started_at=t0,
)
return OclawGatewayResult(
run_id=rid,
reply_text=reply,
trace_id=trace_id,
elapsed_ms=elapsed_ms,
mode="async_task",
task_id=task.id,
selected_specialist=manager_specialist,
interaction_mode=interaction_mode,
dispatch_reason=dispatch_reason,
manager_selected_specialist=manager_specialist,
requested_specialist=requested_specialist,
dynamic_agent_used=bool(dynamic_agent is not None),
dynamic_agent_name=str((dynamic_agent or {}).get("name") or ""),
relay_pointer_count=int(relay_stats.get("relay_pointer_count") or 0),
relay_envelope_present=bool(relay_stats.get("relay_envelope_present")),
relay_envelope_pointer_count=int(relay_stats.get("relay_envelope_pointer_count") or 0),
relay_ttl_turn_count=int(ttl_stats.get("turn") or 0),
relay_ttl_session_count=int(ttl_stats.get("session") or 0),
relay_ttl_keep_count=int(ttl_stats.get("keep") or 0),
)
try:
model = getattr(selected_executor, "model", None)
tools = getattr(selected_executor, "tools", None)
if model is None or tools is None:
raise RuntimeError("executor missing model/tools")
sys_prompt = str(getattr(selected_executor, "system_prompt", "") or "")
if self._has_tabular_ref_attachments(msg):
sys_prompt = f"{sys_prompt}\n\n{self._tabular_query_system_hint(lang)}".strip()
def _get_int_setting(key: str, default: int, lo: int, hi: int) -> int:
try:
raw = str(self.store.get_setting(key) or "").strip()
if raw.isdigit():
return max(lo, min(int(raw), hi))
except Exception:
pass
return max(lo, min(int(default), hi))
core_out = run_agent_core(
store=self.store,
data=AgentCoreRunInput(
msg=msg,
lang=lang,
system_prompt=sys_prompt,
model=model,
tools=tools,
trace_id=trace_id,
parent_span_id=None,
run_id=rid,
max_messages=_get_int_setting("AIA_TURN_MAX_CONTEXT_MESSAGES", 80, 10, 400),
max_tool_rounds=_get_int_setting("AIA_TURN_MAX_TOOL_ROUNDS", 8, 1, 30),
max_tool_workers=_get_int_setting("AIA_TURN_MAX_TOOL_WORKERS", 8, 1, 32),
max_attempts=_get_int_setting("AIA_OCLAW_MAX_ATTEMPTS", 2, 1, 5),
memory_context=memory_context,
on_token=on_token,
on_progress=on_progress,
on_tool_ui=on_tool_ui,
should_stop=should_stop,
skill_binding_role=str(manager_specialist or "generalist"),
wire_policy_role="manager" if interaction_mode == "comprehensive" else str(requested_specialist),
),
)
reply = core_out.outcome.final_text
except Exception as exc:
base = render_prompt(
"fallback/runtime_error.en.md" if str(lang or "").startswith("en") else "fallback/runtime_error.zh.md",
strict=True,
)
detail = f"{type(exc).__name__}: {str(exc or '')}".strip().replace("\n", " ")[:400]
reply = f"{base}\n(detail: {detail})" if detail else base
elapsed_ms = int((time.perf_counter() - t0) * 1000)
self._trace(
ctx=ctx,
event_type="response_sent",
payload={"ok": bool(str(reply or "").strip()), "elapsed_ms": elapsed_ms, "mode": "sync_direct"},
started_at=t0,
)
return OclawGatewayResult(
run_id=rid,
reply_text=str(reply or ""),
trace_id=trace_id,
elapsed_ms=elapsed_ms,
mode="sync_direct",
selected_specialist=manager_specialist,
interaction_mode=interaction_mode,
dispatch_reason=dispatch_reason,
manager_selected_specialist=manager_specialist,
requested_specialist=requested_specialist,
dynamic_agent_used=bool(dynamic_agent is not None),
dynamic_agent_name=str((dynamic_agent or {}).get("name") or ""),
relay_pointer_count=int(relay_stats.get("relay_pointer_count") or 0),
relay_envelope_present=bool(relay_stats.get("relay_envelope_present")),
relay_envelope_pointer_count=int(relay_stats.get("relay_envelope_pointer_count") or 0),
relay_ttl_turn_count=int(ttl_stats.get("turn") or 0),
relay_ttl_session_count=int(ttl_stats.get("session") or 0),
relay_ttl_keep_count=int(ttl_stats.get("keep") or 0),
)
__all__ = ["OclawGateway", "OclawGatewayResult"]