初始化:独立 oclaw 仓库首提交

- 在 oclaw/ 下重新初始化 Git 仓库
- 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等)
- 提交当前工程代码与配置

Made-with: Cursor
This commit is contained in:
oliver 2026-04-24 22:31:22 +08:00
commit ba3836f00f
579 changed files with 83112 additions and 0 deletions

View file

@ -0,0 +1,189 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
from oclaw.chat.turn_types import TurnRunOutcome
from oclaw.openclaw_runtime.direct_loop import run_openclaw_direct_loop
from oclaw.openclaw_runtime.memory_stage import after_turn_memory
from oclaw.openclaw_runtime.types import AttemptState, OpenClawMemoryContext, StandardMessage
from oclaw.orchestration.trace import new_span_id
def _workspace_owner_session_id_from_msg(msg: StandardMessage) -> str | None:
md = msg.metadata if isinstance(msg.metadata, dict) else {}
w = str(md.get("workspace_owner_session_id") or "").strip()
return w or None
ALL_ATTEMPT_ERROR_CODES = (
"relay_envelope_invalid",
"relay_envelope_unsupported_version",
"control_interrupted",
"auth_invalid_credentials",
"input_invalid_request",
"context_overflow",
"tool_loop_guard",
"tool_execution_failed",
"provider_timeout",
"provider_rate_limited",
"provider_temporary_error",
"provider_unavailable",
"runtime_unknown_error",
)
@dataclass(frozen=True)
class AttemptRunnerInput:
attempt_no: int
msg: StandardMessage
lang: str
system_prompt: str
model: Any
tools: Any
trace_id: str | None
parent_span_id: str | None
max_messages: int
max_tool_rounds: int
max_tool_workers: int
memory_context: OpenClawMemoryContext | None
persist_user_message: bool = True
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
run_id: str | None = None
workspace_dir: str | None = None
skill_binding_role: str | None = None
wire_policy_role: str | None = None
@dataclass(frozen=True)
class AttemptRunnerOutput:
state: AttemptState
outcome: TurnRunOutcome
def _classify_attempt_error(exc: Exception) -> tuple[str, str, bool]:
raw = f"{type(exc).__name__}:{exc}"
low = raw.lower()
if "relay_envelope_unsupported_version" in low:
return ("relay_envelope_unsupported_version", raw[:500], False)
if "relay_envelope_invalid" in low:
return ("relay_envelope_invalid", raw[:500], False)
if "interrupted" in low or "cancel" in low or "stopped" in low:
return ("control_interrupted", raw[:500], False)
if "api_key" in low or "invalid api key" in low or "unauthorized" in low or "401" in low or "forbidden" in low or "403" in low:
return ("auth_invalid_credentials", raw[:500], False)
if "invalid_request" in low or "bad_request" in low or "400" in low:
return ("input_invalid_request", raw[:500], False)
if "context_length" in low or "token limit" in low or "max context" in low:
return ("context_overflow", raw[:500], True)
if "tool_loop_guard" in low:
return ("tool_loop_guard", raw[:500], False)
if "tool_timeout_or_failed" in low or "tool_execution_error" in low:
return ("tool_execution_failed", raw[:500], True)
if "timeout" in low:
return ("provider_timeout", raw[:500], True)
if "rate" in low and "limit" in low:
return ("provider_rate_limited", raw[:500], True)
if "temporary" in low or "temporar" in low:
return ("provider_temporary_error", raw[:500], True)
if "connection" in low or "network" in low or "503" in low or "502" in low:
return ("provider_unavailable", raw[:500], True)
return ("runtime_unknown_error", raw[:500], False)
def run_attempt(*, store: Any, data: AttemptRunnerInput) -> AttemptRunnerOutput:
try:
outcome = run_openclaw_direct_loop(
store=store,
session_id=data.msg.session_id,
lang=data.lang,
system_prompt=data.system_prompt,
model=data.model,
tools=data.tools,
user_text=data.msg.text,
attachments=data.msg.attachments,
trace_id=data.trace_id,
parent_span_id=data.parent_span_id,
run_id=data.run_id,
attempt_no=data.attempt_no,
max_messages=data.max_messages,
max_tool_rounds=data.max_tool_rounds,
max_tool_workers=data.max_tool_workers,
on_token=data.on_token,
on_progress=data.on_progress,
on_tool_ui=data.on_tool_ui,
should_stop=data.should_stop,
workspace_owner_session_id=_workspace_owner_session_id_from_msg(data.msg),
path_policy_tenant_id=str(data.msg.metadata.get("tenant_id") or "") if isinstance(data.msg.metadata, dict) else None,
path_policy_user_id=str(data.msg.metadata.get("user_id") or "") if isinstance(data.msg.metadata, dict) else None,
workspace_dir=data.workspace_dir,
memory_context=data.memory_context,
persist_user_message=bool(data.persist_user_message),
skill_binding_role=data.skill_binding_role,
wire_policy_role=data.wire_policy_role,
)
after_turn_memory(
store=store,
session_id=data.msg.session_id,
tenant_id=data.msg.tenant_id,
user_id=data.msg.user_id,
user_text=data.msg.text,
assistant_text=outcome.final_text,
turn_uuid=outcome.turn_uuid,
)
if data.trace_id:
try:
store.add_trace_event(
session_id=data.msg.session_id,
trace_id=str(data.trace_id),
span_id=new_span_id(),
parent_span_id=data.parent_span_id,
event_type="after_turn_memory",
payload={
"pipeline": "openclaw_agent_core",
"oc_stage": "memory_done",
"run_id": str(data.run_id or ""),
"attempt_no": int(data.attempt_no),
},
)
except Exception:
pass
st = AttemptState(
attempt_no=int(data.attempt_no),
status="success",
reason="completed",
error_code="",
tool_trace_count=len(outcome.tool_traces),
compact_triggered=False,
)
return AttemptRunnerOutput(state=st, outcome=outcome)
except Exception as exc:
err_code, reason, retryable = _classify_attempt_error(exc)
state = AttemptState(
attempt_no=int(data.attempt_no),
status="retry" if retryable else "failed",
reason=reason,
error_code=err_code,
tool_trace_count=0,
compact_triggered=False,
)
return AttemptRunnerOutput(
state=state,
outcome=TurnRunOutcome(
final_text="",
tool_traces=tuple(),
handoff_note=f"{err_code}:{reason}",
turn_uuid="",
),
)
__all__ = [
"ALL_ATTEMPT_ERROR_CODES",
"AttemptRunnerInput",
"AttemptRunnerOutput",
"run_attempt",
]

View file

@ -0,0 +1,357 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any, Callable, Optional
from oclaw.chat.turn_types import TurnRunOutcome
from oclaw.openclaw_runtime.agent_core_attempt import ALL_ATTEMPT_ERROR_CODES
from oclaw.openclaw_runtime.agent_core_attempt import AttemptRunnerInput, run_attempt
from oclaw.openclaw_runtime.memory_stage import compact_memory_context
from oclaw.openclaw_runtime.types import AttemptState, OpenClawMemoryContext, RunState, StandardMessage
from oclaw.orchestration.trace import new_span_id
_AGENT_CORE_OC_STAGE: dict[str, str] = {
"run_started": "run_start",
"attempt_started": "attempt",
"attempt_finished": "attempt_done",
"run_finished": "run_done",
"run_compact": "compact",
"run_retry": "retry",
}
@dataclass(frozen=True)
class AgentCoreRunInput:
msg: StandardMessage
lang: str
system_prompt: str
model: Any
tools: Any
trace_id: str | None
parent_span_id: str | None
run_id: str | None = None
max_messages: int = 80
max_tool_rounds: int = 8
max_tool_workers: int = 8
max_attempts: int = 2
memory_context: OpenClawMemoryContext | 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
openclaw_task_id: str | None = None
openclaw_worker_id: str | None = None
skill_binding_role: str | None = None
wire_policy_role: str | None = None
@dataclass(frozen=True)
class AgentCoreRunOutput:
run_id: str
run_state: RunState
outcome: TurnRunOutcome
DEFAULT_RETRYABLE_ERROR_CODES = (
"provider_timeout",
"provider_rate_limited",
"provider_temporary_error",
"provider_unavailable",
"context_overflow",
"tool_execution_failed",
)
def resolve_retryable_error_codes(*, store: Any) -> set[str]:
raw = ""
try:
raw = str(store.get_setting("AIA_OPENCLAW_RETRYABLE_ERROR_CODES") or "").strip()
except Exception:
raw = ""
if not raw:
return set(DEFAULT_RETRYABLE_ERROR_CODES)
allowed = set(ALL_ATTEMPT_ERROR_CODES)
out = {x.strip().lower() for x in raw.split(",") if x and x.strip()}
out = {x for x in out if x in allowed}
return out or set(DEFAULT_RETRYABLE_ERROR_CODES)
def _trace(
store: Any,
*,
data: AgentCoreRunInput,
run_id: str,
attempt_no: int | None,
event_type: str,
payload: dict[str, Any],
) -> None:
if not data.trace_id:
return
merged: dict[str, Any] = dict(payload or {})
merged.setdefault("pipeline", "openclaw_agent_core")
merged.setdefault("trace_id", str(data.trace_id))
merged.setdefault("lang", str(data.lang or ""))
merged.setdefault("run_id", run_id)
if attempt_no is not None:
merged.setdefault("attempt_no", int(attempt_no))
merged["oc_stage"] = _AGENT_CORE_OC_STAGE.get(event_type, event_type)
tid = str(data.openclaw_task_id or "").strip()
if tid:
merged.setdefault("openclaw_task_id", tid)
wid = str(data.openclaw_worker_id or "").strip()
if wid:
merged.setdefault("openclaw_worker_id", wid)
try:
store.add_trace_event(
session_id=data.msg.session_id,
trace_id=str(data.trace_id),
span_id=new_span_id(),
parent_span_id=data.parent_span_id,
event_type=event_type,
payload=merged,
)
except Exception:
pass
def _persist_run(store: Any, *, run_id: str, msg: StandardMessage, status: str, payload: dict[str, Any]) -> None:
try:
store.openclaw_run_upsert(
run_id=run_id,
tenant_id=msg.tenant_id,
session_id=msg.session_id,
status=status,
payload=payload,
)
except Exception:
pass
def _relay_envelope_stats(msg: StandardMessage) -> dict[str, Any]:
md = msg.metadata if isinstance(msg.metadata, dict) else {}
env = md.get("relay_share_envelope")
present = isinstance(env, dict)
ptr_count = 0
if present:
ad = env.get("attachments")
if isinstance(ad, dict):
ps = ad.get("pointers")
if isinstance(ps, list):
ptr_count = len([x for x in ps if isinstance(x, dict)])
return {
"relay_envelope_present": bool(present),
"relay_envelope_pointer_count": int(ptr_count),
}
def run_agent_core(*, store: Any, data: AgentCoreRunInput) -> AgentCoreRunOutput:
run_id = str(data.run_id or "").strip() or str(uuid.uuid4())
attempts: list[AttemptState] = []
compact_count = 0
outcome = TurnRunOutcome(final_text="", tool_traces=tuple(), handoff_note="", turn_uuid="")
retryable_error_codes = resolve_retryable_error_codes(store=store)
_trace(
store,
data=data,
run_id=run_id,
attempt_no=None,
event_type="run_started",
payload={
"max_attempts": int(max(1, data.max_attempts)),
"retryable_error_codes": sorted(retryable_error_codes),
**_relay_envelope_stats(data.msg),
},
)
_persist_run(
store,
run_id=run_id,
msg=data.msg,
status="running",
payload={"max_attempts": int(max(1, data.max_attempts)), "retryable_error_codes": sorted(retryable_error_codes)},
)
mem_ctx = data.memory_context
for idx in range(1, max(1, int(data.max_attempts or 1)) + 1):
_trace(
store,
data=data,
run_id=run_id,
attempt_no=idx,
event_type="attempt_started",
payload={**_relay_envelope_stats(data.msg)},
)
out = run_attempt(
store=store,
data=AttemptRunnerInput(
attempt_no=idx,
msg=data.msg,
lang=data.lang,
system_prompt=data.system_prompt,
model=data.model,
tools=data.tools,
trace_id=data.trace_id,
parent_span_id=data.parent_span_id,
max_messages=data.max_messages,
max_tool_rounds=data.max_tool_rounds,
max_tool_workers=data.max_tool_workers,
memory_context=mem_ctx,
persist_user_message=(idx == 1),
on_token=data.on_token,
on_progress=data.on_progress,
on_tool_ui=data.on_tool_ui,
should_stop=data.should_stop,
run_id=run_id,
workspace_dir=(
str(data.msg.metadata.get("workspaceDir") or data.msg.metadata.get("workspace_dir") or "").strip()
if isinstance(data.msg.metadata, dict)
else None
),
skill_binding_role=data.skill_binding_role,
wire_policy_role=data.wire_policy_role,
),
)
attempts.append(out.state)
outcome = out.outcome
try:
store.openclaw_attempt_append(
run_id=run_id,
tenant_id=data.msg.tenant_id,
session_id=data.msg.session_id,
attempt_no=idx,
status=out.state.status,
reason=out.state.reason,
payload={"tool_trace_count": int(out.state.tool_trace_count), "error_code": str(out.state.error_code or "")},
)
except Exception:
pass
_trace(
store,
data=data,
run_id=run_id,
attempt_no=idx,
event_type="attempt_finished",
payload={
"status": out.state.status,
"reason": out.state.reason,
"error_code": out.state.error_code,
"tool_trace_count": int(out.state.tool_trace_count),
},
)
if out.state.status == "success":
rs = RunState(
run_id=run_id,
session_id=data.msg.session_id,
status="success",
attempts=tuple(attempts),
compact_count=compact_count,
stop_reason="success",
)
_persist_run(store, run_id=run_id, msg=data.msg, status="success", payload={"stop_reason": "success", "attempts": len(attempts)})
_trace(
store,
data=data,
run_id=run_id,
attempt_no=None,
event_type="run_finished",
payload={"status": "success", "attempts": len(attempts)},
)
return AgentCoreRunOutput(run_id=run_id, run_state=rs, outcome=outcome)
retryable = out.state.status == "retry" and str(out.state.error_code or "") in retryable_error_codes
if not retryable:
rs_fail = RunState(
run_id=run_id,
session_id=data.msg.session_id,
status="failed",
attempts=tuple(attempts),
compact_count=compact_count,
last_error_code=str(out.state.error_code or "attempt_failed"),
stop_reason="non_retryable_error" if out.state.status != "retry" else "retry_matrix_blocked",
)
_persist_run(
store,
run_id=run_id,
msg=data.msg,
status="failed",
payload={
"stop_reason": "non_retryable_error" if out.state.status != "retry" else "retry_matrix_blocked",
"attempts": len(attempts),
"last_error_code": str(out.state.error_code or "attempt_failed"),
},
)
_trace(
store,
data=data,
run_id=run_id,
attempt_no=None,
event_type="run_finished",
payload={
"status": "failed",
"attempts": len(attempts),
"stop_reason": "non_retryable_error" if out.state.status != "retry" else "retry_matrix_blocked",
"last_error_code": str(out.state.error_code or "attempt_failed"),
},
)
return AgentCoreRunOutput(run_id=run_id, run_state=rs_fail, outcome=outcome)
# retry path with memory compaction
compact_count += 1
mem_ctx = compact_memory_context(
store=store,
session_id=data.msg.session_id,
tenant_id=data.msg.tenant_id,
user_id=data.msg.user_id,
)
_trace(
store,
data=data,
run_id=run_id,
attempt_no=idx,
event_type="run_compact",
payload={"compact_count": compact_count},
)
_trace(
store,
data=data,
run_id=run_id,
attempt_no=None,
event_type="run_retry",
payload={"next_attempt_no": idx + 1},
)
rs = RunState(
run_id=run_id,
session_id=data.msg.session_id,
status="failed",
attempts=tuple(attempts),
compact_count=compact_count,
last_error_code="attempt_failed",
stop_reason="max_attempts_reached",
)
_persist_run(
store,
run_id=run_id,
msg=data.msg,
status="failed",
payload={"stop_reason": "max_attempts_reached", "attempts": len(attempts), "last_error_code": "attempt_failed"},
)
_trace(
store,
data=data,
run_id=run_id,
attempt_no=None,
event_type="run_finished",
payload={"status": "failed", "attempts": len(attempts), "stop_reason": "max_attempts_reached"},
)
return AgentCoreRunOutput(run_id=run_id, run_state=rs, outcome=outcome)
__all__ = [
"AgentCoreRunInput",
"AgentCoreRunOutput",
"DEFAULT_RETRYABLE_ERROR_CODES",
"resolve_retryable_error_codes",
"run_agent_core",
]

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
CommandAction = Literal["new", "reset"]
@dataclass(frozen=True)
class ParsedCommand:
action: CommandAction
raw: str
command: str
args: tuple[str, ...]
def parse_internal_command(text: str) -> ParsedCommand | None:
"""
Parse top-level slash/plain reset commands.
Supported:
- /new, new
- /reset, reset
"""
t = str(text or "").strip()
if not t:
return None
parts = [x for x in t.split() if str(x or "").strip()]
if not parts:
return None
first_raw = parts[0].strip()
first_norm = first_raw.lower()
if first_norm.startswith(("/", "/")):
first_norm = first_norm[1:]
# English aliases + localized aliases
new_aliases = {"new", "n", "新建", "重开"}
reset_aliases = {"reset", "r", "重置", "清空"}
if first_norm in new_aliases:
return ParsedCommand(action="new", raw=t, command=first_norm, args=tuple(parts[1:]))
if first_norm in reset_aliases:
return ParsedCommand(action="reset", raw=t, command=first_norm, args=tuple(parts[1:]))
return None
__all__ = ["ParsedCommand", "parse_internal_command"]

View file

@ -0,0 +1,617 @@
from __future__ import annotations
import json
import re
import time
import uuid
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Callable, Optional
from oclaw.chat.agent_messages import build_llm_messages
from oclaw.chat.tool_runtime import ToolExecutionConfig
from oclaw.chat.turn_types import TurnRunOutcome
from oclaw.openclaw_runtime.skill_executor import SkillExecutionContext, SkillExecutor
from oclaw.openclaw_runtime.skills import build_skill_manifest
from oclaw.platform.llm.chat_models import ChatModel
from oclaw.openclaw_runtime.system_prompt import build_openclaw_executor_system_prompt
from oclaw.openclaw_runtime.types import OpenClawMemoryContext
from oclaw.orchestration.trace import new_span_id
from oclaw.tools.base import ToolRegistry
_OPENCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000
_DIRECT_LOOP_OC_STAGE: dict[str, str] = {
"tool_wire_filter": "wire_filter",
"tool_result_context_guard": "tool_context_guard",
}
_THINK_BLOCK_RE = re.compile(r"<(think|redacted_thinking)>\s*(.*?)\s*</\1>\s*", flags=re.IGNORECASE | re.DOTALL)
def _emit_direct_loop_trace(
*,
store: Any,
session_id: str,
trace_id: str | None,
parent_span_id: str | None,
event_type: str,
payload: dict[str, Any],
run_id: str | None,
attempt_no: int | None,
lang: str,
) -> None:
if not trace_id:
return
merged: dict[str, Any] = dict(payload or {})
merged.setdefault("pipeline", "openclaw_direct_loop")
merged.setdefault("trace_id", str(trace_id))
merged.setdefault("lang", str(lang or ""))
merged["oc_stage"] = _DIRECT_LOOP_OC_STAGE.get(event_type, event_type)
rid = str(run_id or "").strip()
if rid:
merged.setdefault("run_id", rid)
if attempt_no is not None:
merged.setdefault("attempt_no", int(attempt_no))
try:
store.add_trace_event(
session_id=session_id,
trace_id=str(trace_id),
span_id=new_span_id(),
parent_span_id=parent_span_id,
event_type=event_type,
payload=merged,
)
except Exception:
pass
@dataclass(frozen=True)
class _LoopStepResult:
assistant_text: str
llm_tool_calls: list[Any]
assistant_msg_id: int
def _json_dumps_safe(obj: Any) -> str:
try:
return json.dumps(obj, ensure_ascii=False, default=str)
except Exception:
return json.dumps({"ok": False, "error": "not_json_serializable"}, ensure_ascii=False)
def _split_reasoning_and_body(text: str, *, explicit_reasoning: str | None = None) -> tuple[list[str], str]:
explicit = str(explicit_reasoning or "").strip()
raw = str(text or "")
if not raw:
return ([explicit] if explicit else []), ""
if explicit:
body = _THINK_BLOCK_RE.sub("", raw).strip()
return [explicit], body
chunks: list[str] = []
for m in _THINK_BLOCK_RE.finditer(raw):
t = str(m.group(2) or "").strip()
if t:
chunks.append(t)
body = _THINK_BLOCK_RE.sub("", raw).strip()
return chunks, body
def _guard_tool_results_for_llm_context(
*,
store: Any,
session_id: str,
store_messages: list[Any],
trace_id: str | None,
parent_span_id: str | None,
hard_cap_chars: int,
run_id: str | None = None,
attempt_no: int | None = None,
lang: str = "",
) -> list[Any]:
"""Hard-guard overlarge `role=tool` message contents before sending to model.
This does NOT rewrite DB history (tool_log / chat_message). It only guards the
in-flight LLM context to prevent provider context overflow spirals.
"""
cap = max(4096, min(int(hard_cap_chars or _OPENCLAW_TOOL_RESULT_HARD_CAP_CHARS), 500_000))
out: list[Any] = []
for m in store_messages or []:
role = str(getattr(m, "role", "") or "")
if role != "tool":
out.append(m)
continue
raw = str(getattr(m, "content", "") or "")
if len(raw) <= cap:
out.append(m)
continue
# Best-effort parse tool JSON for a minimal summary.
ok = None
error_code = ""
error = ""
try:
obj = json.loads(raw)
if isinstance(obj, dict):
ok = obj.get("ok")
error_code = str(obj.get("error_code") or "").strip()
error = str(obj.get("error") or "").strip()
except Exception:
obj = None
preview = raw[: max(1, min(4000, cap - 400))] + "\n...<tool_result_guard_truncated>"
guarded_obj = {
"ok": bool(ok) if ok is not None else None,
"error_code": error_code,
"error": error,
"_tool_result_guarded": True,
"original_chars": len(raw),
"guard_cap_chars": cap,
"preview": preview,
"hint": (
"Tool output was too large for safe context replay; it was truncated for the model context. "
"Use narrower queries (e.g., smaller glob/max_results) or adjust AIA_TOOL_LLM_MESSAGE_MAX_CHARS. / "
"工具输出过大,已在发给模型的上下文中强制截断;请缩小范围或配置 AIA_TOOL_LLM_MESSAGE_MAX_CHARS。"
),
}
guarded = _json_dumps_safe(guarded_obj)
out.append(
SimpleNamespace(
id=getattr(m, "id", 0),
session_id=getattr(m, "session_id", session_id),
role="tool",
content=guarded,
tool_calls=getattr(m, "tool_calls", None),
timestamp=getattr(m, "timestamp", ""),
attachments=getattr(m, "attachments", None),
)
)
if trace_id:
_emit_direct_loop_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="tool_result_context_guard",
payload={
"message_id": int(getattr(m, "id", 0) or 0),
"original_chars": int(len(raw)),
"guarded_chars": int(len(guarded)),
"guard_cap_chars": int(cap),
},
run_id=run_id,
attempt_no=attempt_no,
lang=lang,
)
return out
def _check_stop(should_stop: Optional[Callable[[], bool]]) -> None:
if should_stop and should_stop():
raise RuntimeError("generation interrupted by user")
def _build_model_context(
*,
store: Any,
session_id: str,
max_messages: int,
system_prompt: str,
model: ChatModel,
lang: str,
memory_context: OpenClawMemoryContext | None,
trace_id: str | None,
parent_span_id: str | None,
tools: ToolRegistry | None = None,
base_url: str = "",
run_id: str | None = None,
attempt_no: int | None = None,
workspace_dir: str | None = None,
skill_binding_role: str | None = None,
) -> list[dict[str, Any]]:
rows = store.get_messages(session_id=session_id, limit=int(max_messages))
rows = _guard_tool_results_for_llm_context(
store=store,
session_id=session_id,
store_messages=rows,
trace_id=trace_id,
parent_span_id=parent_span_id,
hard_cap_chars=_OPENCLAW_TOOL_RESULT_HARD_CAP_CHARS,
run_id=run_id,
attempt_no=attempt_no,
lang=lang,
)
final_system = build_openclaw_executor_system_prompt(
store=store,
tools=tools,
base_url=str(base_url or ""),
base_system=str(system_prompt or ""),
memory_context=memory_context,
lang=lang,
workspace_dir=workspace_dir,
skill_binding_role=skill_binding_role,
)
return build_llm_messages(store_messages=rows, system_prompt=final_system, model=model, lang=lang)
def _prepare_llm_tools(
*,
store: Any,
tools: ToolRegistry,
base_url: str,
session_id: str,
trace_id: str | None,
parent_span_id: str | None,
run_id: str | None = None,
attempt_no: int | None = None,
lang: str = "",
wire_policy_role: str | None = None,
) -> list[dict[str, Any]]:
runtime_enabled = True
try:
raw_flag = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower()
if raw_flag:
runtime_enabled = raw_flag in {"1", "true", "yes", "on"}
except Exception:
runtime_enabled = True
if runtime_enabled:
skill_specs, _ = build_skill_manifest(registry=tools, store=store, base_url=base_url)
raw_llm_tools = [s.as_openai_tool() for s in skill_specs]
else:
raw_llm_tools = tools.as_openai_tools()
from oclaw.tools.exposure_plan import build_llm_tools_plan
plan = build_llm_tools_plan(
store=store,
role=str(wire_policy_role or "").strip().lower() or "generalist",
base_url=base_url or None,
max_json_bytes=None,
include_mcp=False,
preview_internal=False,
raw_openai_tools_override=raw_llm_tools,
)
llm_tools = plan.tools_wired
if trace_id:
try:
import os
raw_names = {
str(((t.get("function") or {}) if isinstance(t, dict) else {}).get("name") or "")
for t in (raw_llm_tools or [])
if isinstance(t, dict)
}
raw_names.discard("")
hidden = list(plan.removed_names)
hidden_mcp = list(plan.removed_mcp_names)
_emit_direct_loop_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="tool_wire_filter",
payload={
"runner": "openclaw_direct",
"base_url": base_url,
"wire_policy_role": str(wire_policy_role or ""),
"tools_before": len(raw_names),
"tools_after": int(len(_tool_names_for_trace(llm_tools))),
"hidden_total": int(len(hidden)),
"hidden_mcp_total": int(len(hidden_mcp)),
"hidden_mcp_preview": list(hidden_mcp)[:20],
"role_mode": str(plan.role_mode or ""),
"wire_policy_effective": bool(plan.wire_policy_effective),
"max_json_bytes": plan.max_json_bytes,
"changed_total": int(len(plan.changed_names)),
},
run_id=run_id,
attempt_no=attempt_no,
lang=lang,
)
# Optional richer snapshot for debugging (may be large).
trace_plan_enabled = False
try:
raw = str(store.get_setting("AIA_TRACE_TOOL_EXPOSURE_PLAN") or "").strip()
if raw:
trace_plan_enabled = raw.lower() in {"1", "true", "yes", "on"}
else:
trace_plan_enabled = str(os.getenv("AIA_TRACE_TOOL_EXPOSURE_PLAN") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
except Exception:
trace_plan_enabled = str(os.getenv("AIA_TRACE_TOOL_EXPOSURE_PLAN") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
if trace_plan_enabled:
_emit_direct_loop_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="tool_exposure_plan",
payload={
"runner": "openclaw_direct",
"base_url": base_url,
"wire_policy_role": str(wire_policy_role or ""),
"role_mode": str(plan.role_mode or ""),
"wire_policy_effective": bool(plan.wire_policy_effective),
"max_json_bytes": plan.max_json_bytes,
"raw_names": sorted(list(raw_names))[:300],
"wired_names": sorted(list(set(_tool_names_for_trace(llm_tools))))[:300],
"removed_names": list(plan.removed_names)[:300],
"removed_mcp_names": list(plan.removed_mcp_names)[:300],
"added_names": list(plan.added_names)[:300],
"changed_names": list(plan.changed_names)[:300],
},
run_id=run_id,
attempt_no=attempt_no,
lang=lang,
)
except Exception:
pass
return llm_tools
def _tool_names_for_trace(tools: list[dict[str, Any]]) -> list[str]:
out: list[str] = []
for t in tools or []:
if not isinstance(t, dict):
continue
fn = t.get("function")
if not isinstance(fn, dict):
continue
nm = str(fn.get("name") or "").strip()
if nm:
out.append(nm)
return out
def _persist_assistant_step(
*,
store: Any,
session_id: str,
turn_uuid: str,
assistant_text: str,
reasoning_text: str,
llm_tool_calls: list[Any],
) -> _LoopStepResult:
stored_tool_calls = []
for tc in llm_tool_calls:
stored_tool_calls.append(
{
"id": str(getattr(tc, "id", "") or ""),
"name": str(getattr(tc, "name", "") or ""),
"arguments": dict(getattr(tc, "arguments", {}) or {}),
"thought_signature": getattr(tc, "thought_signature", None),
}
)
reasoning_chunks, assistant_body = _split_reasoning_and_body(
assistant_text,
explicit_reasoning=reasoning_text,
)
for idx, chunk in enumerate(reasoning_chunks):
store.add_message(
session_id=session_id,
role="assistant",
content=chunk,
turn_uuid=turn_uuid,
event_type="reasoning",
event_payload={"chunk_index": int(idx), "chunk_count": len(reasoning_chunks)},
)
assistant_row = store.add_message(
session_id=session_id,
role="assistant",
content=assistant_body,
tool_calls=stored_tool_calls or None,
turn_uuid=turn_uuid,
event_type="tool_call" if stored_tool_calls else "assistant_text",
)
return _LoopStepResult(
assistant_text=assistant_body,
llm_tool_calls=llm_tool_calls,
assistant_msg_id=int(getattr(assistant_row, "id", 0) or 0),
)
def _execute_tool_step(
*,
skill_exec: SkillExecutor,
store: Any,
tools: ToolRegistry,
session_id: str,
lang: str,
user_text: str,
trace_id: str | None,
parent_span_id: str | None,
workspace_owner_session_id: str | None,
path_policy_tenant_id: str | None,
path_policy_user_id: str | None,
assistant_msg_id: int,
llm_tool_calls: list[Any],
on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]],
should_stop: Optional[Callable[[], bool]],
signature_budget: int,
run_id: str | None = None,
attempt_no: int | None = None,
turn_uuid: str | None = None,
) -> tuple[int, dict[str, tuple[dict[str, Any], int]]]:
t0 = time.perf_counter()
_tool_messages, results_by_id = skill_exec.execute_skill_uses(
ctx=SkillExecutionContext(
store=store,
tools=tools,
session_id=session_id,
lang=lang,
user_text=user_text,
specialist="openclaw",
trace_id=trace_id,
parent_span_id=parent_span_id,
workspace_owner_session_id=workspace_owner_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
run_id=run_id,
attempt_no=attempt_no,
turn_uuid=turn_uuid,
),
assistant_msg_id=assistant_msg_id,
skill_uses=llm_tool_calls,
on_tool_ui=None,
on_skill_ui=on_tool_ui,
should_stop=should_stop,
signature_budget=signature_budget,
)
return int((time.perf_counter() - t0) * 1000), results_by_id
def run_openclaw_direct_loop(
*,
store: Any,
session_id: str,
lang: str,
system_prompt: str,
model: ChatModel,
tools: ToolRegistry,
user_text: str,
attachments: list[dict[str, Any]] | None = None,
trace_id: str | None = None,
parent_span_id: str | None = None,
run_id: str | None = None,
attempt_no: int | None = None,
max_messages: int = 80,
max_tool_rounds: int = 8,
max_tool_workers: int = 8,
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,
workspace_owner_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
workspace_dir: str | None = None,
memory_context: OpenClawMemoryContext | None = None,
persist_user_message: bool = True,
tool_signature_budget: int = 2,
skill_binding_role: str | None = None,
wire_policy_role: str | None = None,
) -> TurnRunOutcome:
"""A minimal oclaw-style loop: model -> tool_uses -> execute -> tool_results -> continue."""
_check_stop(should_stop)
turn_uuid = str(uuid.uuid4())
if persist_user_message:
store.add_message(
session_id=session_id,
role="user",
content=str(user_text or ""),
attachments=attachments,
turn_uuid=turn_uuid,
event_type="user_text",
)
skill_exec = SkillExecutor(config=ToolExecutionConfig(max_workers=max(1, min(int(max_tool_workers or 8), 32))))
tool_traces: list[dict[str, Any]] = []
final_text = ""
base_url = str(getattr(model, "base_url", "") or "")
for round_idx in range(max(1, int(max_tool_rounds or 1))):
_check_stop(should_stop)
if on_progress:
on_progress(f"oclaw: think ({round_idx + 1})…")
msgs = _build_model_context(
store=store,
session_id=session_id,
max_messages=max_messages,
system_prompt=system_prompt,
model=model,
lang=lang,
memory_context=memory_context,
trace_id=trace_id,
parent_span_id=parent_span_id,
tools=tools,
base_url=base_url,
run_id=run_id,
attempt_no=attempt_no,
workspace_dir=workspace_dir,
skill_binding_role=skill_binding_role,
)
llm_tools = _prepare_llm_tools(
store=store,
tools=tools,
base_url=base_url,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
run_id=run_id,
attempt_no=attempt_no,
lang=lang,
wire_policy_role=wire_policy_role,
)
resp = model.chat(msgs, llm_tools, on_token=on_token)
assistant_text = str(getattr(resp, "content", "") or "")
reasoning_text = str(getattr(resp, "reasoning_content", "") or "")
llm_tool_calls = list(getattr(resp, "tool_calls", []) or [])
step = _persist_assistant_step(
store=store,
session_id=session_id,
turn_uuid=turn_uuid,
assistant_text=assistant_text,
reasoning_text=reasoning_text,
llm_tool_calls=llm_tool_calls,
)
final_text = step.assistant_text
if not step.llm_tool_calls:
break
elapsed_ms, results_by_id = _execute_tool_step(
skill_exec=skill_exec,
store=store,
tools=tools,
session_id=session_id,
lang=lang,
user_text=str(user_text or ""),
trace_id=trace_id,
parent_span_id=parent_span_id,
workspace_owner_session_id=workspace_owner_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
assistant_msg_id=step.assistant_msg_id,
llm_tool_calls=step.llm_tool_calls,
on_tool_ui=on_tool_ui,
should_stop=should_stop,
signature_budget=tool_signature_budget,
run_id=run_id,
attempt_no=attempt_no,
turn_uuid=turn_uuid,
)
for tc in step.llm_tool_calls:
result, dur = results_by_id.get(str(getattr(tc, "id", "") or ""), ({}, 0))
tool_traces.append(
{
"name": str(getattr(tc, "name", "") or ""),
"tool_call_id": str(getattr(tc, "id", "") or ""),
"ok": bool((result or {}).get("ok")) if isinstance(result, dict) else None,
"duration_ms": int(dur),
"round": int(round_idx + 1),
}
)
if on_progress:
on_progress(f"oclaw: tools done ({elapsed_ms}ms)")
return TurnRunOutcome(
final_text=str(final_text or ""),
tool_traces=tuple(tool_traces),
handoff_note="",
turn_uuid=turn_uuid,
)
__all__ = ["run_openclaw_direct_loop"]

677
openclaw_runtime/gateway.py Normal file
View file

@ -0,0 +1,677 @@
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.agents.factory import build_ephemeral_executor
from oclaw.infrastructure.agent_context import build_role_system_context
from oclaw.openclaw_runtime.hooks_runtime import (
get_active_hooks_config,
initialize_hooks_runtime,
trigger_hook_event,
)
from oclaw.openclaw_runtime.relay_pointer import summarize_relay_ttl
from oclaw.openclaw_runtime.skills import build_skill_manifest
from oclaw.openclaw_runtime.types import (
OpenClawSessionContext,
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_openclaw_prompt
from oclaw.openclaw_runtime.agent_core_run import AgentCoreRunInput, run_agent_core
from oclaw.openclaw_runtime.command_parser import parse_internal_command
from oclaw.openclaw_runtime.memory_stage import build_memory_context
from oclaw.openclaw_runtime.router import decide_route
from oclaw.openclaw_runtime.worker import ensure_worker_started
from oclaw.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 OpenClawGatewayResult:
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 OpenClawGateway:
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 = OpenClawGateway._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_openclaw_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 = OpenClawGateway._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("OPENCLAW_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": OpenClawGateway._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: OpenClawSessionContext,
event_type: str,
payload: dict[str, Any],
started_at: float | None = None,
) -> None:
merged: dict[str, Any] = dict(payload or {})
merged.setdefault("pipeline", "openclaw_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,
) -> OpenClawGatewayResult:
t0 = time.perf_counter()
trace_id = new_trace_id()
rid = str(run_id or "").strip() or str(uuid.uuid4())
ctx = OpenClawSessionContext(
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.openclaw_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 OpenClawGatewayResult(
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_OPENCLAW_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/openclaw_runtime_error.en.md" if str(lang or "").startswith("en") else "fallback/openclaw_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 OpenClawGatewayResult(
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__ = ["OpenClawGateway", "OpenClawGatewayResult"]

View file

@ -0,0 +1,192 @@
from __future__ import annotations
import asyncio
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.openclaw_runtime.skills import discover_workspace_skill_manifests
from oclaw.platform.config.paths import PROJECT_ROOT
@dataclass
class _HooksState:
initialized: bool = False
loaded_count: int = 0
last_error: str = ""
hooks_mod: Any = None
resolved_config: dict[str, Any] | None = None
_STATE = _HooksState()
def _ensure_openclaw_path() -> Path:
openclaw_dir = (Path(PROJECT_ROOT) / "oclaw").resolve()
if str(openclaw_dir) not in sys.path:
sys.path.insert(0, str(openclaw_dir))
return openclaw_dir
def _default_runtime_config() -> dict[str, Any]:
return {"hooks": {"internal": {"enabled": True}}}
def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = dict(base)
for k, v in (patch or {}).items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = _deep_merge_dict(dict(out.get(k) or {}), v)
else:
out[k] = v
return out
def _read_json_file(path: Path) -> dict[str, Any] | None:
try:
if not path.exists() or not path.is_file():
return None
raw = path.read_text(encoding="utf-8")
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except Exception:
return None
def resolve_runtime_config() -> dict[str, Any]:
cfg = _default_runtime_config()
env_json = str(os.getenv("OPENCLAW_RUNTIME_CONFIG_JSON") or "").strip()
if env_json:
try:
obj = json.loads(env_json)
if isinstance(obj, dict):
cfg = _deep_merge_dict(cfg, obj)
return cfg
except Exception:
pass
raw_path = str(os.getenv("OPENCLAW_CONFIG_PATH") or "").strip()
if raw_path:
p = Path(raw_path).expanduser()
if not p.is_absolute():
p = (Path(PROJECT_ROOT) / p).resolve()
file_cfg = _read_json_file(p)
if isinstance(file_cfg, dict):
cfg = _deep_merge_dict(cfg, file_cfg)
return cfg
default_cfg_path = (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve()
file_cfg = _read_json_file(default_cfg_path)
if isinstance(file_cfg, dict):
cfg = _deep_merge_dict(cfg, file_cfg)
return cfg
def initialize_hooks_runtime(*, cfg: dict[str, Any] | None, workspace_dir: str) -> int:
if _STATE.initialized:
return int(_STATE.loaded_count or 0)
try:
openclaw_dir = _ensure_openclaw_path()
import hooks as hooks_mod # type: ignore
bundled_dir = openclaw_dir / "hooks" / "bundled"
resolved_cfg = dict(cfg or resolve_runtime_config() or {})
extra_dirs: list[str] = []
try:
for m in discover_workspace_skill_manifests():
d = (Path(str(m.skill_dir or "")) / "hooks").resolve()
if d.exists() and d.is_dir():
extra_dirs.append(str(d))
except Exception:
extra_dirs = []
if extra_dirs:
hooks_cfg = dict((resolved_cfg.get("hooks") or {})) if isinstance(resolved_cfg.get("hooks"), dict) else {}
internal = dict((hooks_cfg.get("internal") or {})) if isinstance(hooks_cfg.get("internal"), dict) else {}
load = dict((internal.get("load") or {})) if isinstance(internal.get("load"), dict) else {}
prev = load.get("extraDirs")
merged = []
if isinstance(prev, list):
merged.extend([str(x) for x in prev if str(x).strip()])
merged.extend([x for x in extra_dirs if x and x not in set(merged)])
load["extraDirs"] = merged
internal["load"] = load
hooks_cfg["internal"] = internal
resolved_cfg["hooks"] = hooks_cfg
loaded = int(
hooks_mod.load_internal_hooks(
resolved_cfg,
workspace_dir=str(workspace_dir or "."),
bundled_hooks_dir=str(bundled_dir),
)
or 0
)
_STATE.initialized = True
_STATE.loaded_count = loaded
_STATE.hooks_mod = hooks_mod
_STATE.resolved_config = resolved_cfg
_STATE.last_error = ""
return loaded
except Exception as exc:
_STATE.initialized = True
_STATE.loaded_count = 0
_STATE.hooks_mod = None
_STATE.resolved_config = None
_STATE.last_error = f"{type(exc).__name__}: {exc}"
return 0
def hooks_status() -> dict[str, Any]:
return {
"initialized": bool(_STATE.initialized),
"loaded_count": int(_STATE.loaded_count or 0),
"last_error": str(_STATE.last_error or ""),
"has_config": isinstance(_STATE.resolved_config, dict),
}
def _trigger_async_or_sync(coro: Any) -> None:
if not asyncio.iscoroutine(coro):
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
loop.create_task(coro)
else:
asyncio.run(coro)
def trigger_hook_event(
*,
event_type: str,
action: str,
session_key: str,
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
hooks_mod = _STATE.hooks_mod
if hooks_mod is None:
return {}
try:
mutable_context = dict(context or {})
ev = hooks_mod.create_hook_event(
str(event_type or ""),
str(action or ""),
str(session_key or "system"),
context=mutable_context,
)
_trigger_async_or_sync(hooks_mod.trigger_hook(ev))
if isinstance(getattr(ev, "context", None), dict):
return dict(getattr(ev, "context"))
return mutable_context
except Exception:
return dict(context or {})
def get_active_hooks_config() -> dict[str, Any]:
if isinstance(_STATE.resolved_config, dict):
return dict(_STATE.resolved_config)
return resolve_runtime_config()

View file

@ -0,0 +1,206 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from oclaw.openclaw_runtime.types import OpenClawMemoryContext
from oclaw.orchestration.memory import semantic_retrieve, session_memory_digest
from oclaw.prompts.loader import render_openclaw_prompt
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
def build_memory_context(
*,
store: Any,
session_id: str,
tenant_id: str,
user_id: str,
query_text: str,
short_limit: int = 6,
semantic_top_k: int = 4,
) -> OpenClawMemoryContext:
short_term: tuple[str, ...] = ()
semantic_hits: tuple[dict[str, Any], ...] = ()
try:
short = session_memory_digest(store, session_id, max_items=max(1, min(int(short_limit), 20)))
short_term = tuple([str(x) for x in (short or []) if str(x).strip()])
except Exception:
short_term = ()
t = str(tenant_id or "").strip()
u = str(user_id or "").strip()
q = str(query_text or "").strip()
if t and u and q:
try:
hits = semantic_retrieve(
store,
query=q,
tenant_id=t,
user_id=u,
session_id=session_id,
top_k=max(1, min(int(semantic_top_k), 12)),
)
semantic_hits = tuple([h for h in (hits or []) if isinstance(h, dict)])
except Exception:
semantic_hits = ()
return OpenClawMemoryContext(short_term=short_term, semantic_hits=semantic_hits)
def render_memory_context_block(ctx: OpenClawMemoryContext) -> str:
if not ctx.enabled:
return ""
short_lines: list[str] = []
sem_lines: list[str] = []
if ctx.short_term:
for i, x in enumerate(ctx.short_term, start=1):
short_lines.append(f"{i}. {x}")
if ctx.semantic_hits:
for i, h in enumerate(ctx.semantic_hits, start=1):
content = str(h.get("content") or "").strip()
score = h.get("score")
score_text = ""
try:
score_text = f" score={float(score):.4f}" if score is not None else ""
except Exception:
score_text = ""
sem_lines.append(f"{i}.{score_text} {content[:500]}")
return render_openclaw_prompt(
"runtime/memory_context_block.md",
variables={
"short_term_block": "\n".join(short_lines).strip(),
"semantic_hits_block": "\n".join(sem_lines).strip(),
},
strict=True,
)
def assemble_memory_context(
*,
store: Any,
session_id: str,
tenant_id: str,
user_id: str,
query_text: str,
) -> OpenClawMemoryContext:
return build_memory_context(
store=store,
session_id=session_id,
tenant_id=tenant_id,
user_id=user_id,
query_text=query_text,
)
def ingest_after_turn(
*,
store: Any,
session_id: str,
tenant_id: str,
user_id: str,
user_text: str,
assistant_text: str,
) -> None:
# MVP lifecycle hook: current runtime already persists messages in chat_message;
# long-term memory write can be layered here later without changing orchestrator contracts.
_ = (store, session_id, tenant_id, user_id, user_text, assistant_text)
def compact_memory_context(
*,
store: Any,
session_id: str,
tenant_id: str,
user_id: str,
) -> OpenClawMemoryContext:
# MVP compaction path: return fresh assembled short+semantic context.
return assemble_memory_context(
store=store,
session_id=session_id,
tenant_id=tenant_id,
user_id=user_id,
query_text="",
)
def after_turn_memory(
*,
store: Any,
session_id: str,
tenant_id: str,
user_id: str,
user_text: str,
assistant_text: str,
turn_uuid: str = "",
) -> None:
ingest_after_turn(
store=store,
session_id=session_id,
tenant_id=tenant_id,
user_id=user_id,
user_text=user_text,
assistant_text=assistant_text,
)
try:
from oclaw.orchestration.memory import maybe_write_turn_memory
from oclaw.platform.persistence.sqlite_store import SqliteStore
if isinstance(store, SqliteStore):
maybe_write_turn_memory(
store,
tenant_id=str(tenant_id or ""),
user_id=str(user_id or ""),
session_id=str(session_id or ""),
user_text=str(user_text or ""),
assistant_text=str(assistant_text or ""),
)
except Exception:
pass
try:
raw_flags = str(store.get_setting(_SPECIALIST_FLAGS_SETTING_KEY) or "").strip() if hasattr(store, "get_setting") else ""
if raw_flags:
obj = json.loads(raw_flags)
if isinstance(obj, dict) and not bool(obj.get("memory_curator", True)):
return
except Exception:
pass
try:
cfg_path = Path(__file__).resolve().parents[2] / "oclaw" / "oclaw.json"
cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {}
plugins = cfg.get("plugins") if isinstance(cfg, dict) else {}
entries = plugins.get("entries") if isinstance(plugins, dict) else {}
wiki_entry = entries.get("memory-wiki") if isinstance(entries, dict) else {}
auto = wiki_entry.get("auto") if isinstance(wiki_entry, dict) else {}
if bool(auto.get("enabled", False)):
payload = {
"kind": "captureAfterTurn",
"session_id": str(session_id or ""),
"tenant_id": str(tenant_id or ""),
"user_id": str(user_id or ""),
"turn_uuid": str(turn_uuid or ""),
"user_text": str(user_text or "")[:8000],
"assistant_text": str(assistant_text or "")[:12000],
}
if hasattr(store, "openclaw_task_create"):
store.openclaw_task_create(
tenant_id=str(tenant_id or "default"),
session_id=str(session_id or ""),
task_type="wiki_capture",
payload=payload,
)
except Exception:
pass
__all__ = [
"build_memory_context",
"render_memory_context_block",
"assemble_memory_context",
"ingest_after_turn",
"compact_memory_context",
"after_turn_memory",
]

View file

@ -0,0 +1,189 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from oclaw.agents.agent_scope import resolve_agent_id_by_workspace_path
from oclaw.openclaw_runtime.hooks_runtime import get_active_hooks_config, trigger_hook_event
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.prompts.loader import render_openclaw_prompt
_PROJECT_CONTEXT_FILES: tuple[str, ...] = (
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
)
def _project_context_roots() -> tuple[Path, ...]:
roots: list[Path] = []
raw_ws = str(os.getenv("OPENCLAW_WORKSPACE") or "").strip()
if raw_ws:
roots.append(Path(raw_ws).expanduser())
roots.append(Path(PROJECT_ROOT) / "oclaw" / "agent" / "workspace-main")
roots.append(Path(PROJECT_ROOT) / "oclaw" / "workspace-main")
roots.append(Path(PROJECT_ROOT) / "oclaw" / "workspace")
roots.append(Path(PROJECT_ROOT))
out: list[Path] = []
seen: set[str] = set()
for p in roots:
key = str(p.resolve()) if p.exists() else str(p)
if key in seen:
continue
seen.add(key)
out.append(p)
return tuple(out)
def _max_file_chars(store: Any) -> int:
try:
raw = str(store.get_setting("AIA_PROJECT_CONTEXT_MAX_FILE_CHARS") or "").strip()
if raw.isdigit():
return max(200, min(int(raw), 200_000))
except Exception:
pass
return 12_000
def _max_total_chars(store: Any) -> int:
try:
raw = str(store.get_setting("AIA_PROJECT_CONTEXT_MAX_TOTAL_CHARS") or "").strip()
if raw.isdigit():
return max(1_000, min(int(raw), 1_000_000))
except Exception:
pass
return 60_000
def build_project_context_block(*, store: Any, workspace_dir: str | None = None) -> str:
per_file_cap = _max_file_chars(store)
total_cap = _max_total_chars(store)
total = 0
lines: list[str] = []
cfg = get_active_hooks_config()
roots: list[Path] = []
# When workspace_dir is provided, isolate project context to that workspace
# plus the shared PROJECT_ROOT fallback (avoid cross-agent pollution).
if isinstance(workspace_dir, str) and workspace_dir.strip():
roots.append(Path(workspace_dir).expanduser())
roots.append(Path(PROJECT_ROOT))
else:
roots = list(_project_context_roots())
# Include configured agent workspaces as additional roots (multi-agent support).
try:
agents = cfg.get("agents") if isinstance(cfg, dict) else None
entries = agents.get("list") if isinstance(agents, dict) else None
if isinstance(entries, list):
for e in entries:
if not isinstance(e, dict):
continue
ws = str(e.get("workspace") or "").strip()
if ws:
roots.append(Path(ws).expanduser())
except Exception:
pass
# De-dupe roots while preserving order.
deduped: list[Path] = []
seen: set[str] = set()
for p in roots:
key = str(p.resolve()) if p.exists() else str(p)
if key in seen:
continue
seen.add(key)
deduped.append(p)
roots = deduped
bootstrap_files: list[dict[str, str]] = []
root_bootstrap_files: list[tuple[str, list[dict[str, str]]]] = []
for root in roots:
one_root_files: list[dict[str, str]] = []
for name in _PROJECT_CONTEXT_FILES:
cand = (root / name).resolve()
if cand.exists() and cand.is_file():
one_root_files.append({"path": str(cand), "name": name})
root_bootstrap_files.append((str(root), one_root_files))
# Trigger bootstrap hooks for each discovered workspace root so multi-agent
# workspaces can contribute extra bootstrap files independently.
cfg = cfg
for idx, (workspace_dir, one_root_files) in enumerate(root_bootstrap_files):
agent_id = None
try:
agent_id = resolve_agent_id_by_workspace_path(cfg, workspace_dir)
except Exception:
agent_id = None
agent_part = str(agent_id or "unknown").strip() or "unknown"
# keep session keys log-friendly; avoid long absolute paths.
ws_part = Path(workspace_dir).name or "workspace"
trigger_hook_event(
event_type="agent",
action="bootstrap",
session_key=f"system:agent:bootstrap:{agent_part}:{ws_part}:{idx}",
context={
"workspaceDir": workspace_dir,
"bootstrapFiles": one_root_files,
"cfg": cfg,
"agentId": agent_id,
},
)
bootstrap_files.extend(one_root_files)
seen_paths: set[str] = set()
candidate_paths: list[tuple[str, str]] = []
for item in bootstrap_files:
if not isinstance(item, dict):
continue
p = str(item.get("path") or "").strip()
n = str(item.get("name") or "").strip() or Path(p).name
if not p or p in seen_paths:
continue
seen_paths.add(p)
candidate_paths.append((p, n))
for name in _PROJECT_CONTEXT_FILES:
p: Path | None = None
for root in roots:
cand = (root / name).resolve()
if cand.exists() and cand.is_file():
p = cand
break
if p is None:
continue
if str(p) not in seen_paths:
seen_paths.add(str(p))
candidate_paths.append((str(p), name))
for raw_path, name in candidate_paths:
p = Path(raw_path)
if not p.exists() or not p.is_file():
continue
try:
raw = p.read_text(encoding="utf-8")
except Exception:
continue
txt = str(raw or "").strip()
if not txt:
continue
clipped = txt[:per_file_cap]
remain = max(0, total_cap - total)
if remain <= 0:
break
chunk = clipped[:remain]
if not chunk:
continue
total += len(chunk)
lines.append(f"[{name}]")
lines.append(chunk)
lines.append("")
if not lines:
return ""
return render_openclaw_prompt(
"runtime/project_context_block.md",
variables={"project_context": "\n".join(lines).strip()},
strict=True,
)
__all__ = ["build_project_context_block"]

View file

@ -0,0 +1,228 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.openclaw_runtime.types import SharedAttachmentManifest, SharedFilePointer
_SAFE_FILE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
_SAFE_SCOPE_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
_SAFE_FILE_ID = re.compile(r"^[a-f0-9]{8,64}$")
class RelayPointerError(ValueError):
"""Raised when relay pointer payload or path is invalid."""
@dataclass(frozen=True)
class RelayPointerLimits:
max_files: int = 32
max_file_bytes: int = 8 * 1024 * 1024
max_total_bytes: int = 64 * 1024 * 1024
def validate_file_name(name: str) -> str:
s = str(name or "").strip()
if not s:
raise RelayPointerError("file_name_required")
if s in {".", ".."}:
raise RelayPointerError("file_name_invalid")
if "/" in s or "\\" in s:
raise RelayPointerError("file_name_invalid")
if not _SAFE_FILE_NAME.match(s):
raise RelayPointerError("file_name_invalid")
return s
def normalize_rel_path(rel_path: str) -> str:
raw = str(rel_path or "").strip()
if not raw:
raise RelayPointerError("rel_path_required")
p = Path(raw.replace("\\", "/"))
if p.is_absolute():
raise RelayPointerError("rel_path_absolute")
parts = [x for x in p.parts if str(x or "").strip()]
if not parts:
raise RelayPointerError("rel_path_required")
if any(x in {".", ".."} for x in parts):
raise RelayPointerError("rel_path_traversal")
return "/".join(parts)
def safe_join_under_root(root: Path, rel_path: str) -> Path:
root_resolved = Path(root).resolve()
normalized = normalize_rel_path(rel_path)
out = (root_resolved / normalized).resolve()
try:
out.relative_to(root_resolved)
except Exception as exc:
raise RelayPointerError("path_outside_root") from exc
return out
def decode_base64_payload(payload: str, *, max_bytes: int) -> bytes:
raw = str(payload or "").strip()
if not raw:
raise RelayPointerError("payload_required")
try:
data = base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError) as exc:
raise RelayPointerError("payload_base64_invalid") from exc
if len(data) > int(max_bytes):
raise RelayPointerError("payload_too_large")
return data
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def build_pointer_uri(scope_id: str, file_id: str) -> str:
s = str(scope_id or "").strip()
f = str(file_id or "").strip().lower()
if not _SAFE_SCOPE_ID.match(s):
raise RelayPointerError("scope_id_invalid")
if not _SAFE_FILE_ID.match(f):
raise RelayPointerError("file_id_invalid")
return f"relay://attachments/{s}/{f}"
def parse_pointer_uri(pointer_uri: str) -> tuple[str, str]:
s = str(pointer_uri or "").strip()
prefix = "relay://attachments/"
if not s.startswith(prefix):
raise RelayPointerError("pointer_uri_invalid")
rest = s[len(prefix) :]
chunks = rest.split("/")
if len(chunks) != 2:
raise RelayPointerError("pointer_uri_invalid")
scope_id, file_id = chunks[0].strip(), chunks[1].strip().lower()
if not _SAFE_SCOPE_ID.match(scope_id):
raise RelayPointerError("scope_id_invalid")
if not _SAFE_FILE_ID.match(file_id):
raise RelayPointerError("file_id_invalid")
return scope_id, file_id
def build_manifest_from_attachment_refs(
attachments: list[dict[str, Any]] | None,
*,
scope_id: str,
source_agent: str,
created_at: str = "",
ttl_policy: str = "turn",
) -> SharedAttachmentManifest:
items = attachments if isinstance(attachments, list) else []
pointers: list[SharedFilePointer] = []
total = 0
for a in items:
if not isinstance(a, dict):
continue
aid = str(a.get("attachment_id") or "").strip().lower()
if not aid:
continue
try:
uri = build_pointer_uri(scope_id, aid)
except RelayPointerError:
continue
b = int(a.get("bytes") or 0)
total += max(0, b)
rel = str(a.get("rel_path") or f"attachments/{aid}")
pointers.append(
SharedFilePointer(
schema_version="v1",
pointer_uri=uri,
rel_path=rel,
mime_type=str(a.get("mime") or ""),
bytes=max(0, b),
sha256=str(a.get("sha256") or aid),
source_agent=str(source_agent or ""),
created_at=str(created_at or ""),
ttl_policy=str(ttl_policy or "turn"),
extra={
"attachment_id": aid,
"name": str(a.get("name") or ""),
},
)
)
return SharedAttachmentManifest(
scope_id=str(scope_id or ""),
count=len(pointers),
total_bytes=total,
pointers=tuple(pointers),
)
def validate_relay_share_envelope(raw: dict[str, Any] | None) -> tuple[bool, str, dict[str, Any]]:
if not isinstance(raw, dict):
return (False, "relay_envelope_invalid", {})
env = dict(raw)
schema = str(env.get("schema_version") or "").strip().lower()
if schema != "v1":
return (False, "relay_envelope_unsupported_version", {})
attachments = env.get("attachments")
if not isinstance(attachments, dict):
return (False, "relay_envelope_invalid", {})
manifest = SharedAttachmentManifest.from_dict(attachments)
return (True, "", {"schema_version": "v1", "attachments": manifest.to_dict()})
def summarize_relay_ttl(relay_envelope: dict[str, Any] | None) -> dict[str, int]:
out = {"total": 0, "turn": 0, "session": 0, "keep": 0, "unknown": 0}
ok, _err, norm = validate_relay_share_envelope(relay_envelope)
if not ok:
return out
ad = norm.get("attachments") if isinstance(norm, dict) else {}
ps = ad.get("pointers") if isinstance(ad, dict) else []
for p in ps if isinstance(ps, list) else []:
if not isinstance(p, dict):
continue
out["total"] += 1
ttl = str(p.get("ttl_policy") or "").strip().lower()
if ttl in ("turn", "session", "keep"):
out[ttl] += 1
else:
out["unknown"] += 1
return out
def build_acp_relay_result(
*,
parent_run_id: str,
child_run_id: str,
relay_envelope: dict[str, Any] | None,
) -> dict[str, Any]:
ok, err, norm = validate_relay_share_envelope(relay_envelope)
out: dict[str, Any] = {
"acp_parent_run_id": str(parent_run_id or ""),
"acp_child_run_id": str(child_run_id or ""),
"relay_ok": bool(ok),
"relay_error_code": str(err or ""),
"relay_share_envelope": norm if ok else {},
}
ad = norm.get("attachments") if isinstance(norm, dict) else {}
ps = ad.get("pointers") if isinstance(ad, dict) else []
out["relay_pointer_count"] = len(ps) if isinstance(ps, list) else 0
return out
__all__ = [
"RelayPointerError",
"RelayPointerLimits",
"validate_file_name",
"normalize_rel_path",
"safe_join_under_root",
"decode_base64_payload",
"sha256_hex",
"build_pointer_uri",
"parse_pointer_uri",
"build_manifest_from_attachment_refs",
"validate_relay_share_envelope",
"summarize_relay_ttl",
"build_acp_relay_result",
]

134
openclaw_runtime/router.py Normal file
View file

@ -0,0 +1,134 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any
from oclaw.openclaw_runtime.types import StandardMessage
from oclaw.openclaw_runtime.types import normalize_interaction_mode, normalize_requested_specialist
from oclaw.prompts.loader import render_openclaw_prompt
@dataclass(frozen=True)
class RouterDecision:
mode: str # sync_direct | async_task
reason: str
skill_signal: str = ""
interaction_mode: str = "comprehensive"
requested_specialist: str = "generalist"
_ASYNC_HINTS = (
"总结并发送",
"总结后发送",
"发到",
"发送到",
"send to",
"summarize and send",
"总结一下并发",
)
def _router_mode_from_store(store: Any | None) -> str:
raw = ""
if store is not None:
try:
raw = str(store.get_setting("AIA_OPENCLAW_ROUTER_MODE") or "").strip().lower()
except Exception:
raw = ""
if not raw:
raw = str(os.getenv("AIA_OPENCLAW_ROUTER_MODE", "") or "").strip().lower()
return raw or "rule"
def _decide_rule(msg: StandardMessage, *, skill_count: int = 0) -> RouterDecision:
text = str(msg.text or "").strip().lower()
has_attachments = bool(msg.attachments)
if any(h in text for h in _ASYNC_HINTS):
return RouterDecision(mode="async_task", reason="multi_step_send_flow", skill_signal=f"skills={int(skill_count)}")
if has_attachments and len(text) >= 120:
return RouterDecision(mode="async_task", reason="long_with_attachments", skill_signal=f"skills={int(skill_count)}")
return RouterDecision(mode="sync_direct", reason="default_sync", skill_signal=f"skills={int(skill_count)}")
def _parse_router_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
def _decide_llm_json(msg: StandardMessage, *, model: Any | None) -> RouterDecision | None:
if model is None or not callable(getattr(model, "chat", None)):
return None
try:
user_block = render_openclaw_prompt(
"router/decide_route.md",
variables={
"user_text": str(msg.text or "").strip(),
"has_attachments": "yes" if bool(msg.attachments) else "no",
},
strict=True,
)
messages = [
{
"role": "system",
"content": (
"You are a routing classifier. Output exactly one JSON object on one line: "
'{"mode":"sync_direct"|"async_task","reason":"..."}. No markdown, no extra text.'
),
},
{"role": "user", "content": user_block},
]
resp = model.chat(messages, [], on_token=None)
raw = str(getattr(resp, "content", "") or "")
obj = _parse_router_json_object(raw)
if not obj:
return None
mode = str(obj.get("mode") or "").strip().lower()
reason = str(obj.get("reason") or "").strip() or "llm_router"
if mode not in {"sync_direct", "async_task"}:
return None
return RouterDecision(mode=mode, reason=reason)
except Exception:
return None
def decide_route(msg: StandardMessage, *, store: Any | None = None, model: Any | None = None) -> RouterDecision:
md = msg.metadata if isinstance(msg.metadata, dict) else {}
interaction_mode = normalize_interaction_mode(md.get("interaction_mode"))
requested_specialist = normalize_requested_specialist(md.get("selected_specialist"))
skill_count = 0
try:
skill_count = int(md.get("skills_total") or 0)
except Exception:
skill_count = 0
mode = _router_mode_from_store(store)
if mode == "llm_json":
d = _decide_llm_json(msg, model=model)
if d is not None:
return RouterDecision(
mode=d.mode,
reason=d.reason,
skill_signal=f"skills={int(skill_count)}",
interaction_mode=interaction_mode,
requested_specialist=requested_specialist,
)
d2 = _decide_rule(msg, skill_count=skill_count)
return RouterDecision(
mode=d2.mode,
reason=d2.reason,
skill_signal=d2.skill_signal,
interaction_mode=interaction_mode,
requested_specialist=requested_specialist,
)
__all__ = ["RouterDecision", "decide_route"]

View file

@ -0,0 +1,135 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
from oclaw.chat.tool_runtime import (
ToolExecutionConfig,
ToolExecutionContext,
ToolExecutor,
)
from oclaw.orchestration.trace import new_span_id
from oclaw.platform.llm.chat_models import LLMToolCall
from oclaw.tools.base import ToolRegistry
@dataclass(frozen=True)
class SkillExecutionContext:
store: Any
tools: ToolRegistry
session_id: str
lang: str = "zh"
user_text: str = ""
specialist: str = "openclaw"
trace_id: str | None = None
parent_span_id: str | None = None
workspace_owner_session_id: str | None = None
path_policy_tenant_id: str | None = None
path_policy_user_id: str | None = None
run_id: str | None = None
attempt_no: int | None = None
turn_uuid: str | None = None
class SkillExecutor:
"""Skill-oriented execution bridge.
Phase-1 delegates execution to ToolExecutor while emitting skill_ui events.
"""
def __init__(self, *, config: ToolExecutionConfig | None = None):
self._tool_exec = ToolExecutor(config=config or ToolExecutionConfig())
@staticmethod
def _trace(ctx: SkillExecutionContext, *, event_type: str, payload: dict[str, Any]) -> None:
if not str(ctx.trace_id or "").strip():
return
try:
ctx.store.add_trace_event(
session_id=ctx.session_id,
trace_id=str(ctx.trace_id),
span_id=new_span_id(),
parent_span_id=ctx.parent_span_id,
event_type=str(event_type),
payload={
"pipeline": "openclaw_skill_executor",
"trace_id": str(ctx.trace_id or ""),
"run_id": str(ctx.run_id or ""),
"attempt_no": int(ctx.attempt_no) if ctx.attempt_no is not None else None,
**dict(payload or {}),
},
)
except Exception:
pass
def execute_skill_uses(
self,
*,
ctx: SkillExecutionContext,
assistant_msg_id: int,
skill_uses: list[LLMToolCall],
on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None,
on_skill_ui: Optional[Callable[[str, dict[str, Any]], None]] = None,
should_stop: Optional[Callable[[], bool]] = None,
signature_budget: int = 2,
) -> tuple[list[dict[str, Any]], dict[str, tuple[dict[str, Any], int]]]:
for su in skill_uses or []:
self._trace(
ctx,
event_type="skill_selected",
payload={
"skill_name": str(getattr(su, "name", "") or ""),
"skill_call_id": str(getattr(su, "id", "") or ""),
},
)
def _emit(event: str, payload: dict[str, Any]) -> None:
if on_tool_ui:
on_tool_ui(event, payload)
if on_skill_ui and on_skill_ui is not on_tool_ui:
ev = str(event or "")
if ev.startswith("tool_"):
ev = "skill_" + ev[len("tool_") :]
on_skill_ui(ev, payload)
tool_messages, results_by_id = self._tool_exec.execute_tool_uses(
ctx=ToolExecutionContext(
store=ctx.store,
tools=ctx.tools,
session_id=ctx.session_id,
lang=ctx.lang,
user_text=ctx.user_text,
specialist=ctx.specialist,
task_kind="turn",
policy_engine=None,
trace_id=ctx.trace_id,
parent_span_id=ctx.parent_span_id,
workspace_owner_session_id=ctx.workspace_owner_session_id,
path_policy_tenant_id=ctx.path_policy_tenant_id,
path_policy_user_id=ctx.path_policy_user_id,
turn_uuid=ctx.turn_uuid,
),
assistant_msg_id=assistant_msg_id,
tool_uses=skill_uses,
on_tool_ui=_emit,
should_stop=should_stop,
signature_budget=signature_budget,
)
for su in skill_uses or []:
result, dur = results_by_id.get(str(getattr(su, "id", "") or ""), ({}, 0))
self._trace(
ctx,
event_type="skill_executed",
payload={
"skill_name": str(getattr(su, "name", "") or ""),
"skill_call_id": str(getattr(su, "id", "") or ""),
"ok": bool((result or {}).get("ok")) if isinstance(result, dict) else None,
"duration_ms": int(dur or 0),
"error_code": str((result or {}).get("error_code") or "") if isinstance(result, dict) else "",
},
)
return tool_messages, results_by_id
__all__ = ["SkillExecutionContext", "SkillExecutor"]

View file

@ -0,0 +1,401 @@
from __future__ import annotations
import json
import shutil
import tarfile
import tempfile
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.openclaw_runtime.skills import (
default_skills_root,
discover_workspace_skill_manifests,
load_skill_manifest,
)
_DISABLED_SKILLS_KEY = "AIA_SKILL_DISABLED_NAMES"
_AUTO_INSTALL_KEY = "AIA_SKILL_AUTO_INSTALL_ENABLED"
@dataclass(frozen=True)
class SkillInstallResult:
ok: bool
name: str
target_dir: str
detail: str = ""
error_code: str = ""
retryable: bool = False
def _classify_install_detail(detail: str) -> tuple[str, bool]:
d = str(detail or "").strip().lower()
if not d:
return ("unknown", False)
if d.startswith("download_failed") or d.startswith("extract_failed"):
return ("transport_or_extract_error", True)
if d in {"archive_too_large", "empty_archive", "unsupported_archive_type", "archive_url_required", "unsupported_url_scheme"}:
return ("invalid_archive", False)
if d in {"already_exists"}:
return ("already_exists", False)
if d.startswith("unsafe_") or d.startswith("skill_md_missing") or d.startswith("manifest_parse_failed"):
return ("invalid_skill_package", False)
if d.startswith("rollback_after_error"):
return ("runtime_error", True)
if d == "installed" or d == "created":
return ("ok", False)
return ("unknown", False)
def _truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in {"1", "true", "yes", "on"}
def skill_auto_install_enabled(store: Any) -> bool:
try:
raw = str(store.get_setting(_AUTO_INSTALL_KEY) or "").strip()
except Exception:
raw = ""
if not raw:
return True
return _truthy(raw)
def _get_disabled_names(store: Any) -> set[str]:
try:
raw = str(store.get_setting(_DISABLED_SKILLS_KEY) or "").strip()
except Exception:
raw = ""
if not raw:
return set()
try:
arr = json.loads(raw)
if isinstance(arr, list):
return {str(x).strip() for x in arr if str(x).strip()}
except Exception:
pass
return set()
def _set_disabled_names(store: Any, names: set[str]) -> None:
vals = sorted({str(x).strip() for x in names if str(x).strip()})
store.set_setting(_DISABLED_SKILLS_KEY, json.dumps(vals, ensure_ascii=False))
def list_skills_with_status(*, store: Any, skills_root: str | Path | None = None) -> list[dict[str, Any]]:
disabled = _get_disabled_names(store)
out: list[dict[str, Any]] = []
for m in discover_workspace_skill_manifests(skills_root):
runtime = dict(m.runtime or {}) if isinstance(m.runtime, dict) else {}
out.append(
{
"name": m.name,
"description": m.description,
"skill_dir": m.skill_dir,
"skill_file": m.skill_file,
"enabled": m.name not in disabled,
"install_specs": [dict(id=s.id, kind=s.kind, payload=s.payload) for s in m.install],
"metadata_openclaw": dict(m.metadata_openclaw),
"runtime": runtime,
"executable": bool(runtime),
}
)
return out
def set_skill_enabled(*, store: Any, skill_name: str, enabled: bool) -> None:
name = str(skill_name or "").strip()
if not name:
return
disabled = _get_disabled_names(store)
if enabled:
disabled.discard(name)
else:
disabled.add(name)
_set_disabled_names(store, disabled)
def scan_skill_source_dir(src_dir: str | Path) -> tuple[bool, str]:
src = Path(src_dir).resolve()
if not src.exists() or not src.is_dir():
return False, "source_dir_not_found"
skill_md = src / "SKILL.md"
if not skill_md.exists() or not skill_md.is_file():
return False, "skill_md_missing"
for p in src.rglob("*"):
if not p.is_file():
continue
nm = p.name.lower()
if nm.endswith((".exe", ".dll", ".bat", ".cmd", ".ps1")):
return False, f"unsafe_file_detected:{p.name}"
return True, "ok"
def _safe_archive_members(paths: list[str]) -> bool:
for p in paths:
v = str(p or "").replace("\\", "/")
if not v or v.startswith("/") or ".." in v.split("/"):
return False
return True
def _extract_archive_to_temp(archive_path: Path) -> tuple[bool, str, Path | None]:
temp_dir = Path(tempfile.mkdtemp(prefix="skill_archive_"))
try:
if zipfile.is_zipfile(archive_path):
with zipfile.ZipFile(archive_path, "r") as zf:
members = [str(x.filename or "") for x in zf.infolist()]
if not _safe_archive_members(members):
return False, "unsafe_archive_path", None
zf.extractall(temp_dir)
return True, "ok", temp_dir
if tarfile.is_tarfile(archive_path):
with tarfile.open(archive_path, "r:*") as tf:
members = [str(x.name or "") for x in tf.getmembers()]
if not _safe_archive_members(members):
return False, "unsafe_archive_path", None
tf.extractall(temp_dir)
return True, "ok", temp_dir
shutil.rmtree(temp_dir, ignore_errors=True)
return False, "unsupported_archive_type", None
except Exception as exc:
shutil.rmtree(temp_dir, ignore_errors=True)
return False, f"extract_failed:{type(exc).__name__}", None
def _resolve_registry_archive_url(url: str) -> str:
raw = str(url or "").strip()
if not raw:
return raw
parsed = urllib.parse.urlparse(raw)
if parsed.scheme not in {"http", "https"}:
return raw
host = str(parsed.netloc or "").strip().lower()
if host not in {"clawhub.ai", "www.clawhub.ai"}:
return raw
segs = [s for s in str(parsed.path or "").split("/") if s]
if not segs:
return raw
candidates: list[str] = []
if len(segs) >= 1:
candidates.append(segs[-1])
if len(segs) >= 2:
candidates.append(f"{segs[0]}/{segs[1]}")
try:
from oclaw.tools.skills.clawhub_client import get_skill_detail
for slug in candidates:
detail = get_skill_detail(slug)
archive_url = str(detail.get("archiveUrl") or "").strip() if isinstance(detail, dict) else ""
if archive_url:
return archive_url
return raw
except Exception:
return raw
def install_skill_from_local_dir(
*,
store: Any,
source_dir: str | Path,
overwrite: bool = False,
skills_root: str | Path | None = None,
) -> SkillInstallResult:
src = Path(source_dir).resolve()
ok, detail = scan_skill_source_dir(src)
if not ok:
ec, rt = _classify_install_detail(detail)
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
manifest = load_skill_manifest(src)
if not manifest:
ec, rt = _classify_install_detail("manifest_parse_failed")
return SkillInstallResult(ok=False, name="", target_dir="", detail="manifest_parse_failed", error_code=ec, retryable=rt)
root = Path(skills_root).resolve() if skills_root else default_skills_root()
root.mkdir(parents=True, exist_ok=True)
safe_name = str(manifest.name).strip()
target = root / safe_name
if target.exists():
if not overwrite:
ec, rt = _classify_install_detail("already_exists")
return SkillInstallResult(ok=False, name=manifest.name, target_dir=str(target), detail="already_exists", error_code=ec, retryable=rt)
shutil.rmtree(target)
shutil.copytree(src, target)
set_skill_enabled(store=store, skill_name=manifest.name, enabled=True)
ec, rt = _classify_install_detail("installed")
return SkillInstallResult(ok=True, name=manifest.name, target_dir=str(target), detail="installed", error_code=ec, retryable=rt)
def install_skill_from_registry_archive(
*,
store: Any,
archive_url: str,
overwrite: bool = False,
skills_root: str | Path | None = None,
) -> SkillInstallResult:
url = str(archive_url or "").strip()
if not url:
ec, rt = _classify_install_detail("archive_url_required")
return SkillInstallResult(ok=False, name="", target_dir="", detail="archive_url_required", error_code=ec, retryable=rt)
url = _resolve_registry_archive_url(url)
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"https", "http", "file"}:
ec, rt = _classify_install_detail("unsupported_url_scheme")
return SkillInstallResult(ok=False, name="", target_dir="", detail="unsupported_url_scheme", error_code=ec, retryable=rt)
tmp_file = Path(tempfile.mkstemp(prefix="skill_pkg_", suffix=".bin")[1])
try:
req = urllib.request.Request(
url,
headers={
"User-Agent": "OpenClaw-SkillInstaller/1.0 (+https://clawhub.ai)",
"Accept": "*/*",
},
)
with urllib.request.urlopen(req, timeout=20) as resp:
data = resp.read()
if not data:
ec, rt = _classify_install_detail("empty_archive")
return SkillInstallResult(ok=False, name="", target_dir="", detail="empty_archive", error_code=ec, retryable=rt)
if len(data) > 50 * 1024 * 1024:
ec, rt = _classify_install_detail("archive_too_large")
return SkillInstallResult(ok=False, name="", target_dir="", detail="archive_too_large", error_code=ec, retryable=rt)
tmp_file.write_bytes(data)
ok, detail, extracted = _extract_archive_to_temp(tmp_file)
if not ok or extracted is None:
ec, rt = _classify_install_detail(detail)
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
candidates: list[Path] = []
if (extracted / "SKILL.md").exists():
candidates.append(extracted)
for p in extracted.rglob("SKILL.md"):
if p.is_file():
candidates.append(p.parent)
if not candidates:
ec, rt = _classify_install_detail("skill_md_missing")
return SkillInstallResult(ok=False, name="", target_dir="", detail="skill_md_missing", error_code=ec, retryable=rt)
chosen = sorted(candidates, key=lambda x: len(x.parts))[0]
return install_skill_from_local_dir(store=store, source_dir=chosen, overwrite=overwrite, skills_root=skills_root)
except Exception as exc:
detail = f"download_failed:{type(exc).__name__}"
code = getattr(exc, "code", None)
if isinstance(code, int):
detail = f"download_failed:http_{code}"
ec, rt = _classify_install_detail(detail)
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
finally:
try:
if tmp_file.exists():
tmp_file.unlink()
except Exception:
pass
try:
if "extracted" in locals() and isinstance(extracted, Path) and extracted.exists():
shutil.rmtree(extracted, ignore_errors=True)
except Exception:
pass
def create_skill_from_template(
*,
store: Any,
name: str,
description: str,
body_markdown: str = "",
metadata_openclaw: dict[str, Any] | None = None,
skills_root: str | Path | None = None,
overwrite: bool = False,
) -> SkillInstallResult:
nm = str(name or "").strip()
if not nm:
ec, rt = _classify_install_detail("name_required")
return SkillInstallResult(ok=False, name="", target_dir="", detail="name_required", error_code=ec, retryable=rt)
root = Path(skills_root).resolve() if skills_root else default_skills_root()
root.mkdir(parents=True, exist_ok=True)
target = root / nm
if target.exists() and not overwrite:
ec, rt = _classify_install_detail("already_exists")
return SkillInstallResult(ok=False, name=nm, target_dir=str(target), detail="already_exists", error_code=ec, retryable=rt)
target.mkdir(parents=True, exist_ok=True)
oc = dict(metadata_openclaw or {})
front = {
"name": nm,
"description": str(description or f"{nm} skill"),
"user-invocable": "true",
"disable-model-invocation": "false",
"metadata": {"openclaw": oc},
}
text = (
"---\n"
f"name: {front['name']}\n"
f"description: {front['description']}\n"
f"user-invocable: {front['user-invocable']}\n"
f"disable-model-invocation: {front['disable-model-invocation']}\n"
f"metadata: {json.dumps(front['metadata'], ensure_ascii=False)}\n"
"---\n\n"
f"{str(body_markdown or '').strip()}\n"
)
(target / "SKILL.md").write_text(text, encoding="utf-8")
set_skill_enabled(store=store, skill_name=nm, enabled=True)
ec, rt = _classify_install_detail("created")
return SkillInstallResult(ok=True, name=nm, target_dir=str(target), detail="created", error_code=ec, retryable=rt)
def auto_install_skill_from_payload(
*,
store: Any,
payload: dict[str, Any],
skills_root: str | Path | None = None,
) -> SkillInstallResult:
if not skill_auto_install_enabled(store):
ec, rt = _classify_install_detail("auto_install_disabled")
return SkillInstallResult(ok=False, name="", target_dir="", detail="auto_install_disabled", error_code=ec, retryable=rt)
name = str(payload.get("name") or "").strip()
description = str(payload.get("description") or "").strip()
body = str(payload.get("body_markdown") or "").strip()
md = payload.get("metadata_openclaw")
md = dict(md) if isinstance(md, dict) else {}
root = Path(skills_root).resolve() if skills_root else default_skills_root()
target = root / name
before_disabled = _get_disabled_names(store)
try:
out = create_skill_from_template(
store=store,
name=name,
description=description,
body_markdown=body,
metadata_openclaw=md,
skills_root=skills_root,
overwrite=False,
)
if bool(payload.get("force_error_for_test")):
raise RuntimeError("forced_error_for_test")
if not out.ok:
return out
return out
except Exception as exc:
try:
if target.exists() and target.is_dir():
shutil.rmtree(target)
except Exception:
pass
try:
_set_disabled_names(store, before_disabled)
except Exception:
pass
detail = f"rollback_after_error:{type(exc).__name__}"
ec, rt = _classify_install_detail(detail)
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail=detail, error_code=ec, retryable=rt)
__all__ = [
"SkillInstallResult",
"auto_install_skill_from_payload",
"create_skill_from_template",
"install_skill_from_local_dir",
"install_skill_from_registry_archive",
"list_skills_with_status",
"set_skill_enabled",
"skill_auto_install_enabled",
]

View file

@ -0,0 +1,129 @@
from __future__ import annotations
import json
import os
from typing import Any
from oclaw.agents.specialists import SPECIALISTS
SKILL_ROLE_BINDING_KEY = "skill_role_binding"
SKILL_ROLE_BINDING_ENABLED_SETTING = "AIA_SKILL_ROLE_BINDING_ENABLED"
def _truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in {"1", "true", "yes", "on"}
def ordered_specialist_ids() -> list[str]:
base = [str(k).strip().lower() for k in SPECIALISTS.keys() if str(k).strip()]
preferred = [x for x in ("generalist", "ops", "image") if x in set(base)]
return preferred + [x for x in base if x not in set(preferred)]
def ordered_binding_roles() -> list[str]:
return ["manager", *ordered_specialist_ids()]
def skill_role_binding_enabled(*, store: Any) -> bool:
raw_env = str(os.getenv(SKILL_ROLE_BINDING_ENABLED_SETTING) or "").strip()
if raw_env:
return _truthy(raw_env)
try:
raw = str(store.get_setting(SKILL_ROLE_BINDING_ENABLED_SETTING) or "").strip()
except Exception:
raw = ""
return _truthy(raw) if raw else False
def load_skill_role_binding_dict(store: Any) -> dict[str, Any]:
try:
raw = str(store.get_setting(SKILL_ROLE_BINDING_KEY) or "").strip()
except Exception:
raw = ""
if not raw:
return {}
try:
obj = json.loads(raw)
except Exception:
return {}
return obj if isinstance(obj, dict) else {}
def mapping_has_any_skill_names(mapping: dict[str, list[str]]) -> bool:
for vals in mapping.values():
if vals and any(str(x).strip() for x in vals):
return True
return False
def normalize_skill_role_binding(
*,
mapping_raw: dict[str, Any],
valid_skill_names: set[str],
available_roles: list[str] | None = None,
) -> dict[str, list[str]]:
roles = available_roles if available_roles is not None else ordered_binding_roles()
role_set = set(roles)
out: dict[str, list[str]] = {r: [] for r in roles}
for k, v in (mapping_raw or {}).items():
rk = str(k or "").strip().lower()
if rk not in role_set:
continue
items = v if isinstance(v, list) else []
seen: set[str] = set()
for x in items:
nm = str(x or "").strip()
if not nm or nm not in valid_skill_names or nm in seen:
continue
seen.add(nm)
out[rk].append(nm)
return out
def allowed_workspace_skill_names_for_role(*, store: Any, role: str) -> set[str]:
"""Union of manager-bound skills and skills bound to the given specialist role."""
r = str(role or "").strip().lower()
if not r:
return set()
mapping = normalize_skill_role_binding(
mapping_raw=load_skill_role_binding_dict(store),
valid_skill_names=_all_installed_skill_names(store),
)
mgr = {str(x).strip() for x in (mapping.get("manager") or []) if str(x).strip()}
sp = {str(x).strip() for x in (mapping.get(r) or []) if str(x).strip()}
return mgr | sp
def _all_installed_skill_names(store: Any) -> set[str]:
from oclaw.openclaw_runtime.skills import discover_workspace_skill_manifests
return {str(m.name).strip() for m in discover_workspace_skill_manifests() if str(m.name or "").strip()}
def should_apply_workspace_role_filter(*, store: Any, skill_binding_role: str | None) -> bool:
if not str(skill_binding_role or "").strip():
return False
if not skill_role_binding_enabled(store=store):
return False
raw = load_skill_role_binding_dict(store)
normalized = normalize_skill_role_binding(
mapping_raw=raw,
valid_skill_names=_all_installed_skill_names(store),
)
if not mapping_has_any_skill_names(normalized):
return False
return True
__all__ = [
"SKILL_ROLE_BINDING_KEY",
"SKILL_ROLE_BINDING_ENABLED_SETTING",
"allowed_workspace_skill_names_for_role",
"load_skill_role_binding_dict",
"mapping_has_any_skill_names",
"normalize_skill_role_binding",
"ordered_binding_roles",
"ordered_specialist_ids",
"should_apply_workspace_role_filter",
"skill_role_binding_enabled",
]

382
openclaw_runtime/skills.py Normal file
View file

@ -0,0 +1,382 @@
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.prompts.frontmatter import parse_frontmatter_dict, split_markdown_frontmatter
if TYPE_CHECKING:
from oclaw.tools.base import ToolRegistry, ToolSpec
@dataclass(frozen=True)
class SkillSpec:
name: str
description: str
input_schema: dict[str, Any]
read_only: bool = False
tags: tuple[str, ...] = ()
origin: str = "builtin"
location: str = ""
install_spec_count: int = 0
model_invocable: bool = True
def as_openai_tool(self) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.input_schema,
},
}
@dataclass(frozen=True)
class SkillInstallSpec:
id: str
kind: str
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SkillManifest:
name: str
description: str
skill_dir: str
skill_file: str
user_invocable: bool = True
disable_model_invocation: bool = False
metadata_openclaw: dict[str, Any] = field(default_factory=dict)
runtime: dict[str, Any] = field(default_factory=dict)
install: tuple[SkillInstallSpec, ...] = ()
body: str = ""
class SkillRegistry:
def __init__(self, skills: tuple[SkillSpec, ...] | list[SkillSpec] | None = None):
self._skills: dict[str, SkillSpec] = {}
if skills:
for s in skills:
self.register(s)
def register(self, skill: SkillSpec) -> None:
self._skills[str(skill.name)] = skill
def get(self, name: str) -> SkillSpec | None:
return self._skills.get(str(name))
def list(self) -> tuple[SkillSpec, ...]:
return tuple(self._skills.values())
def as_openai_tools(self) -> list[dict[str, Any]]:
return [s.as_openai_tool() for s in self.list()]
def default_skills_root() -> Path:
raw = str(os.getenv("AIA_SKILLS_ROOT") or "").strip()
if raw:
return Path(raw).resolve()
preferred = (PROJECT_ROOT / "oclaw" / "skills").resolve()
if preferred.exists() and preferred.is_dir():
return preferred
typo_legacy = (PROJECT_ROOT / "oclaw" / "sills").resolve()
if typo_legacy.exists() and typo_legacy.is_dir():
return typo_legacy
legacy = (PROJECT_ROOT / "skills").resolve()
if legacy.exists() and legacy.is_dir():
return legacy
return preferred
def _normalize_skill_manifest_frontmatter(fm: dict[str, Any]) -> dict[str, Any]:
out = dict(fm)
md = out.get("metadata")
if isinstance(md, str) and md.strip():
try:
out["metadata"] = json.loads(md)
except Exception:
out["metadata"] = {}
elif not isinstance(md, dict):
out["metadata"] = {}
u = out.get("user-invocable", True)
if isinstance(u, bool):
out["user-invocable"] = u
else:
uinv = str(u or "true").strip().lower()
out["user-invocable"] = uinv in {"1", "true", "yes", "on"}
d = out.get("disable-model-invocation", False)
if isinstance(d, bool):
out["disable-model-invocation"] = d
else:
dmi = str(d or "false").strip().lower()
out["disable-model-invocation"] = dmi in {"1", "true", "yes", "on"}
return out
def _parse_skill_frontmatter_block(frontmatter_text: str) -> dict[str, Any]:
raw_fm = str(frontmatter_text or "").strip()
if not raw_fm:
return {}
fm = parse_frontmatter_dict(raw_fm, source="skill")
return _normalize_skill_manifest_frontmatter(fm)
def _parse_install_specs(metadata_openclaw: dict[str, Any]) -> tuple[SkillInstallSpec, ...]:
raw = metadata_openclaw.get("install")
if not isinstance(raw, list):
return ()
out: list[SkillInstallSpec] = []
for idx, it in enumerate(raw):
if not isinstance(it, dict):
continue
sid = str(it.get("id") or f"install_{idx + 1}").strip() or f"install_{idx + 1}"
kind = str(it.get("kind") or "").strip().lower()
if not kind:
continue
payload = dict(it)
out.append(SkillInstallSpec(id=sid, kind=kind, payload=payload))
return tuple(out)
def _parse_runtime_spec(metadata_openclaw: dict[str, Any]) -> dict[str, Any]:
raw = metadata_openclaw.get("runtime")
if not isinstance(raw, dict):
return {}
out: dict[str, Any] = {}
tp = str(raw.get("type") or "").strip().lower()
if tp not in {"shell", "python", "node", "hook"}:
return {}
entry = str(raw.get("entry") or "").strip().replace("\\", "/")
if not entry or entry.startswith("/") or ".." in entry.split("/"):
return {}
out["type"] = tp
out["entry"] = entry
schema = raw.get("schema")
if isinstance(schema, dict):
out["schema"] = schema
perms = raw.get("permissions")
if isinstance(perms, dict):
out["permissions"] = perms
return out
def load_skill_manifest(skill_dir: str | Path) -> SkillManifest | None:
root = Path(skill_dir).resolve()
f = root / "SKILL.md"
if not f.exists() or not f.is_file():
return None
raw = f.read_text(encoding="utf-8", errors="ignore")
fm_text, body = split_markdown_frontmatter(raw)
fm = _parse_skill_frontmatter_block(fm_text)
name = str(fm.get("name") or root.name).strip() or root.name
desc = str(fm.get("description") or "").strip() or f"{name} skill"
md = fm.get("metadata")
md = dict(md) if isinstance(md, dict) else {}
oc = md.get("openclaw")
oc = dict(oc) if isinstance(oc, dict) else {}
return SkillManifest(
name=name,
description=desc,
skill_dir=str(root),
skill_file=str(f),
user_invocable=bool(fm.get("user-invocable", True)),
disable_model_invocation=bool(fm.get("disable-model-invocation", False)),
metadata_openclaw=oc,
runtime=_parse_runtime_spec(oc),
install=_parse_install_specs(oc),
body=str(body or ""),
)
def discover_workspace_skill_manifests(skills_root: str | Path | None = None) -> tuple[SkillManifest, ...]:
base = Path(skills_root).resolve() if skills_root else default_skills_root()
if not base.exists() or not base.is_dir():
return ()
out: list[SkillManifest] = []
if (base / "SKILL.md").exists():
one = load_skill_manifest(base)
if one:
out.append(one)
for d in sorted(base.iterdir(), key=lambda p: p.name.lower()):
if not d.is_dir():
continue
m = load_skill_manifest(d)
if m:
out.append(m)
return tuple(out)
def _tool_origin(tool: "ToolSpec") -> str:
nm = str(getattr(tool, "name", "") or "")
if nm.startswith("mcp__"):
return "mcp"
tags = set(getattr(tool, "tags", frozenset()) or frozenset())
if "plugin" in tags:
return "plugin"
return "builtin"
def _allowed_tool_names_after_wire_policy(
*,
registry: "ToolRegistry",
store: Any,
base_url: str,
) -> tuple[set[str], list[str]]:
raw = registry.as_openai_tools()
try:
from oclaw.platform.llm.tool_wire_policy import prepare_openai_tools_for_llm_api
prepared = prepare_openai_tools_for_llm_api(
raw,
base_url=base_url,
store=store,
max_json_bytes=None,
role=None,
)
except Exception:
prepared = raw
allowed: set[str] = set()
for e in prepared or []:
if not isinstance(e, dict) or str(e.get("type") or "") != "function":
continue
fn = e.get("function")
if not isinstance(fn, dict):
continue
nm = str(fn.get("name") or "").strip()
if nm:
allowed.add(nm)
all_names = {str((t.get("function") or {}).get("name") or "") for t in (raw or []) if isinstance(t, dict)}
all_names.discard("")
hidden = sorted([n for n in all_names if n and n not in allowed])
return allowed or all_names, hidden
def build_skill_manifest(
*,
registry: "ToolRegistry",
store: Any,
base_url: str = "",
) -> tuple[tuple[SkillSpec, ...], dict[str, Any]]:
allowed, hidden = _allowed_tool_names_after_wire_policy(registry=registry, store=store, base_url=base_url)
disabled_names: set[str] = set()
try:
raw_disabled = str(store.get_setting("AIA_SKILL_DISABLED_NAMES") or "").strip()
if raw_disabled:
arr = json.loads(raw_disabled)
if isinstance(arr, list):
disabled_names = {str(x).strip() for x in arr if str(x).strip()}
except Exception:
disabled_names = set()
manifest_by_name = {m.name: m for m in discover_workspace_skill_manifests()}
skills: list[SkillSpec] = []
for t in sorted((registry.list() or []), key=lambda x: str(getattr(x, "name", "") or "").lower()):
name = str(getattr(t, "name", "") or "").strip()
if not name or name not in allowed or name in disabled_names:
continue
desc = str(getattr(t, "description", "") or "")
params = getattr(t, "parameters", None)
schema = dict(params) if isinstance(params, dict) else {"type": "object", "additionalProperties": True}
tags = tuple(sorted({str(x) for x in (getattr(t, "tags", frozenset()) or frozenset()) if str(x)}))
m = manifest_by_name.get(name)
location = str(m.skill_file) if m is not None else f"tool:{name}"
install_spec_count = int(len(m.install)) if m is not None else 0
model_invocable = not bool(m.disable_model_invocation) if m is not None else True
skills.append(
SkillSpec(
name=name,
description=desc,
input_schema=schema,
read_only=bool(getattr(t, "read_only", False) or getattr(t, "is_read_only", lambda: False)()),
tags=tags,
origin=_tool_origin(t),
location=location,
install_spec_count=install_spec_count,
model_invocable=model_invocable,
)
)
skills = sorted(skills, key=lambda x: x.name.lower())
hidden_mcp = [n for n in hidden if n.startswith("mcp__")]
stats = {
"skills_total": len(skills),
"hidden_total": len(hidden),
"hidden_mcp_total": len(hidden_mcp),
"hidden_mcp_preview": hidden_mcp[:20],
"disabled_total": len(disabled_names),
"visible_names_preview": [s.name for s in skills[:30]],
}
try:
json.dumps(stats, ensure_ascii=False, default=str)
except Exception:
stats = {"skills_total": len(skills)}
return tuple(skills), stats
def build_skill_registry(
*,
registry: ToolRegistry,
store: Any,
base_url: str = "",
) -> tuple[SkillRegistry, dict[str, Any]]:
skills, stats = build_skill_manifest(registry=registry, store=store, base_url=base_url)
return SkillRegistry(skills), stats
def materialize_skills_from_tool_specs(
tools: list["ToolSpec"],
) -> tuple[SkillSpec, ...]:
out: list[SkillSpec] = []
for t in tools or []:
name = str(getattr(t, "name", "") or "").strip()
if not name:
continue
desc = str(getattr(t, "description", "") or "")
params = getattr(t, "parameters", None)
schema = dict(params) if isinstance(params, dict) else {"type": "object", "additionalProperties": True}
tags = tuple(sorted({str(x) for x in (getattr(t, "tags", frozenset()) or frozenset()) if str(x)}))
out.append(
SkillSpec(
name=name,
description=desc,
input_schema=schema,
read_only=bool(getattr(t, "read_only", False) or getattr(t, "is_read_only", lambda: False)()),
tags=tags,
origin=_tool_origin(t),
)
)
return tuple(out)
def skill_runtime_diagnostics() -> dict[str, Any]:
root = default_skills_root()
manifests = discover_workspace_skill_manifests(root)
return {
"skills_root": str(root),
"skills_total": len(manifests),
"skills_names": [m.name for m in manifests],
"skills_files": [m.skill_file for m in manifests],
}
__all__ = [
"SkillSpec",
"SkillInstallSpec",
"SkillManifest",
"SkillRegistry",
"build_skill_manifest",
"build_skill_registry",
"default_skills_root",
"discover_workspace_skill_manifests",
"load_skill_manifest",
"materialize_skills_from_tool_specs",
"skill_runtime_diagnostics",
]

View file

@ -0,0 +1,167 @@
from __future__ import annotations
import json
import os
from typing import Any
from oclaw.openclaw_runtime.skill_role_binding import (
allowed_workspace_skill_names_for_role,
should_apply_workspace_role_filter,
)
from oclaw.openclaw_runtime.skills import (
_allowed_tool_names_after_wire_policy,
build_skill_manifest,
discover_workspace_skill_manifests,
)
from oclaw.tools.base import ToolRegistry
def _skill_runtime_enabled(store: Any) -> bool:
try:
raw_flag = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower()
if raw_flag:
return raw_flag in {"1", "true", "yes", "on"}
except Exception:
pass
return True
def _skills_prompt_in_system_enabled(store: Any) -> bool:
raw = str(os.getenv("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower()
if raw in {"0", "false", "no", "off"}:
return False
try:
s = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower()
if s in {"0", "false", "no", "off"}:
return False
except Exception:
pass
return True
def _disabled_skill_names(store: Any) -> set[str]:
try:
raw_disabled = str(store.get_setting("AIA_SKILL_DISABLED_NAMES") or "").strip()
if raw_disabled:
arr = json.loads(raw_disabled)
if isinstance(arr, list):
return {str(x).strip() for x in arr if str(x).strip()}
except Exception:
pass
return set()
def _max_skills_prompt_chars() -> int:
try:
return max(0, min(int(os.getenv("AIA_SKILLS_PROMPT_MAX_CHARS", "18000")), 500_000))
except Exception:
return 18_000
def escape_xml(s: str) -> str:
return (
str(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
def collect_skill_catalog_entries(
*,
store: Any,
registry: ToolRegistry,
base_url: str,
skill_binding_role: str | None = None,
) -> list[tuple[str, str, str]]:
"""Return (name, description, location) for model-visible skills (wire + disable filtered)."""
allowed, _hidden = _allowed_tool_names_after_wire_policy(registry=registry, store=store, base_url=base_url)
disabled = _disabled_skill_names(store)
skill_specs, _stats = build_skill_manifest(registry=registry, store=store, base_url=base_url)
seen: set[str] = set()
out: list[tuple[str, str, str]] = []
role_filter = should_apply_workspace_role_filter(store=store, skill_binding_role=skill_binding_role)
role_allow: set[str] = set()
if role_filter:
role_allow = allowed_workspace_skill_names_for_role(store=store, role=str(skill_binding_role or ""))
for m in sorted(discover_workspace_skill_manifests(), key=lambda x: x.name.lower()):
if m.disable_model_invocation or m.name in disabled:
continue
if role_filter:
if m.name not in role_allow:
continue
elif m.name not in allowed:
continue
out.append((m.name, (m.description or "").strip() or m.name, m.skill_file))
seen.add(m.name)
for s in sorted(skill_specs, key=lambda x: x.name.lower()):
if s.name in seen or s.name in disabled:
continue
out.append((s.name, (s.description or "").strip() or s.name, str(s.location or f"tool:{s.name}")))
seen.add(s.name)
return sorted(out, key=lambda x: x[0].lower())
def format_skills_for_prompt(entries: list[tuple[str, str, str]], *, max_chars: int) -> str:
"""oclaw-aligned `<available_skills>` XML block with a hard character budget."""
if not entries or max_chars <= 0:
return ""
header = (
"\n\nThe following skills describe callable capabilities and optional workspace packages.\n"
"When you need full instructions for a workspace skill, use `read_file` (or equivalent) on the path in `<location>`.\n"
"For execution, use native tool/function calls provided by the host.\n"
"When a skill references a relative path, resolve it against that skill's directory (parent of SKILL.md).\n"
)
tail = "\n</available_skills>"
def _render(subset: list[tuple[str, str, str]]) -> str:
lines = [header.rstrip(), "", "<available_skills>"]
for name, desc, loc in subset:
lines.append(" <skill>")
lines.append(f" <name>{escape_xml(name)}</name>")
lines.append(f" <description>{escape_xml(desc)}</description>")
lines.append(f" <location>{escape_xml(loc)}</location>")
lines.append(" </skill>")
lines.append("</available_skills>")
return "\n".join(lines)
# Drop from the end until under budget (keep workspace-first order).
subset = list(entries)
while subset:
blob = _render(subset)
if len(blob) <= max_chars:
return blob
subset = subset[:-1]
return ""
def build_skills_catalog_block(
*,
store: Any,
registry: ToolRegistry,
base_url: str,
skill_binding_role: str | None = None,
) -> str:
if not _skill_runtime_enabled(store) or not _skills_prompt_in_system_enabled(store):
return ""
entries = collect_skill_catalog_entries(
store=store,
registry=registry,
base_url=base_url,
skill_binding_role=skill_binding_role,
)
return format_skills_for_prompt(entries, max_chars=_max_skills_prompt_chars())
__all__ = [
"build_skills_catalog_block",
"collect_skill_catalog_entries",
"escape_xml",
"format_skills_for_prompt",
]

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from typing import Any
from oclaw.openclaw_runtime.memory_stage import render_memory_context_block
from oclaw.openclaw_runtime.project_context_prompt import build_project_context_block
from oclaw.openclaw_runtime.skills_prompt import build_skills_catalog_block
from oclaw.openclaw_runtime.types import OpenClawMemoryContext
from oclaw.prompts.loader import render_openclaw_prompt
from oclaw.tools.base import ToolRegistry
def build_openclaw_executor_system_prompt(
*,
store: Any,
tools: ToolRegistry | None,
base_url: str,
base_system: str,
memory_context: OpenClawMemoryContext | None,
lang: str,
workspace_dir: str | None = None,
skill_binding_role: str | None = None,
) -> str:
"""Build the final system string for the oclaw executor (memory block + skills catalog).
`lang` is reserved for future localized system fragments; current templates are bilingual/static.
"""
_ = lang # reserved for i18n extensions
final_system = str(base_system or "").strip()
project_block = build_project_context_block(store=store, workspace_dir=workspace_dir)
if project_block:
final_system = f"{final_system}\n\n{project_block}".strip()
mem_block = render_memory_context_block(memory_context or OpenClawMemoryContext())
if mem_block:
final_system = render_openclaw_prompt(
"runtime/system_with_memory.md",
variables={"system_prompt": final_system, "memory_context": mem_block},
strict=True,
)
if tools is not None:
cat = build_skills_catalog_block(
store=store,
registry=tools,
base_url=str(base_url or ""),
skill_binding_role=skill_binding_role,
)
if cat.strip():
final_system = render_openclaw_prompt(
"runtime/system_with_skills.md",
variables={"system_body": final_system, "skills_catalog": cat},
strict=True,
)
return final_system
__all__ = ["build_openclaw_executor_system_prompt"]

246
openclaw_runtime/types.py Normal file
View file

@ -0,0 +1,246 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
InteractionMode = Literal["comprehensive", "expert"]
SpecialistId = Literal["generalist", "ops", "image", "memory_curator"]
ChannelId = Literal[
"admin_chat",
"wecom",
"feishu",
"discord",
"api",
"eval",
"specialist",
"agent_turn",
"unknown",
]
@dataclass(frozen=True)
class StandardMessage:
"""oclaw-style normalized inbound message for runtime stages."""
session_id: str
tenant_id: str
user_id: str
role: str
channel: ChannelId
text: str
attachments: list[dict[str, Any]]
metadata: dict[str, Any]
def normalize_interaction_mode(raw: Any) -> InteractionMode:
mode = str(raw or "").strip().lower()
if mode in {"expert", "specialist"}:
return "expert"
return "comprehensive"
def normalize_requested_specialist(raw: Any) -> SpecialistId:
specialist = str(raw or "").strip().lower()
if specialist == "ops":
return "ops"
if specialist == "image":
return "image"
if specialist in {"memory_curator", "memory-curator", "memory curator"}:
return "memory_curator"
return "generalist"
@dataclass(frozen=True)
class OpenClawSessionContext:
session_id: str
tenant_id: str
user_id: str
role: str
channel: ChannelId
lang: str
trace_id: str
parent_span_id: str | None = None
@dataclass(frozen=True)
class OpenClawMemoryContext:
short_term: tuple[str, ...] = ()
semantic_hits: tuple[dict[str, Any], ...] = ()
@property
def enabled(self) -> bool:
return bool(self.short_term or self.semantic_hits)
@dataclass(frozen=True)
class AttemptState:
attempt_no: int
status: str
reason: str = ""
error_code: str = ""
tool_trace_count: int = 0
compact_triggered: bool = False
@dataclass(frozen=True)
class RunState:
run_id: str
session_id: str
status: str
attempts: tuple[AttemptState, ...] = ()
compact_count: int = 0
last_error_code: str = ""
stop_reason: str = ""
@dataclass(frozen=True)
class SharedFilePointer:
schema_version: str = "v1"
pointer_uri: str = ""
rel_path: str = ""
mime_type: str = ""
bytes: int = 0
sha256: str = ""
source_agent: str = ""
created_at: str = ""
ttl_policy: str = "turn"
extra: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"schema_version": str(self.schema_version or "v1"),
"pointer_uri": str(self.pointer_uri or ""),
"rel_path": str(self.rel_path or ""),
"mime_type": str(self.mime_type or ""),
"bytes": int(self.bytes or 0),
"sha256": str(self.sha256 or ""),
"source_agent": str(self.source_agent or ""),
"created_at": str(self.created_at or ""),
"ttl_policy": str(self.ttl_policy or "turn"),
}
if isinstance(self.extra, dict):
for k, v in self.extra.items():
if str(k or "") not in out:
out[str(k)] = v
return out
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> "SharedFilePointer":
d = raw if isinstance(raw, dict) else {}
known = {
"schema_version",
"pointer_uri",
"rel_path",
"mime_type",
"bytes",
"sha256",
"source_agent",
"created_at",
"ttl_policy",
}
extra = {str(k): v for k, v in d.items() if str(k) not in known}
return cls(
schema_version=str(d.get("schema_version") or "v1"),
pointer_uri=str(d.get("pointer_uri") or ""),
rel_path=str(d.get("rel_path") or ""),
mime_type=str(d.get("mime_type") or ""),
bytes=int(d.get("bytes") or 0),
sha256=str(d.get("sha256") or ""),
source_agent=str(d.get("source_agent") or ""),
created_at=str(d.get("created_at") or ""),
ttl_policy=str(d.get("ttl_policy") or "turn"),
extra=extra,
)
@dataclass(frozen=True)
class SharedAttachmentManifest:
scope_id: str = ""
count: int = 0
total_bytes: int = 0
pointers: tuple[SharedFilePointer, ...] = ()
extra: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"scope_id": str(self.scope_id or ""),
"count": int(self.count or 0),
"total_bytes": int(self.total_bytes or 0),
"pointers": [p.to_dict() for p in self.pointers],
}
if isinstance(self.extra, dict):
for k, v in self.extra.items():
if str(k or "") not in out:
out[str(k)] = v
return out
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> "SharedAttachmentManifest":
d = raw if isinstance(raw, dict) else {}
known = {"scope_id", "count", "total_bytes", "pointers"}
ptrs_raw = d.get("pointers")
ptrs = ptrs_raw if isinstance(ptrs_raw, list) else []
extra = {str(k): v for k, v in d.items() if str(k) not in known}
return cls(
scope_id=str(d.get("scope_id") or ""),
count=int(d.get("count") or 0),
total_bytes=int(d.get("total_bytes") or 0),
pointers=tuple(SharedFilePointer.from_dict(x) for x in ptrs if isinstance(x, dict)),
extra=extra,
)
@dataclass(frozen=True)
class RelayShareEnvelope:
schema_version: str = "v1"
trace_id: str = ""
run_id: str = ""
attempt_no: int = 0
attachments: SharedAttachmentManifest = field(default_factory=SharedAttachmentManifest)
extra: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"schema_version": str(self.schema_version or "v1"),
"trace_id": str(self.trace_id or ""),
"run_id": str(self.run_id or ""),
"attempt_no": int(self.attempt_no or 0),
"attachments": self.attachments.to_dict(),
}
if isinstance(self.extra, dict):
for k, v in self.extra.items():
if str(k or "") not in out:
out[str(k)] = v
return out
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> "RelayShareEnvelope":
d = raw if isinstance(raw, dict) else {}
known = {"schema_version", "trace_id", "run_id", "attempt_no", "attachments"}
extra = {str(k): v for k, v in d.items() if str(k) not in known}
return cls(
schema_version=str(d.get("schema_version") or "v1"),
trace_id=str(d.get("trace_id") or ""),
run_id=str(d.get("run_id") or ""),
attempt_no=int(d.get("attempt_no") or 0),
attachments=SharedAttachmentManifest.from_dict(d.get("attachments") if isinstance(d.get("attachments"), dict) else {}),
extra=extra,
)
__all__ = [
"InteractionMode",
"SpecialistId",
"ChannelId",
"StandardMessage",
"normalize_interaction_mode",
"normalize_requested_specialist",
"OpenClawSessionContext",
"OpenClawMemoryContext",
"AttemptState",
"RunState",
"SharedFilePointer",
"SharedAttachmentManifest",
"RelayShareEnvelope",
]

236
openclaw_runtime/worker.py Normal file
View file

@ -0,0 +1,236 @@
from __future__ import annotations
import json
import threading
import time
import uuid
from typing import Any
from oclaw.agents.factory import build_gateway_executor
from oclaw.openclaw_runtime.agent_core_run import AgentCoreRunInput, run_agent_core
from oclaw.openclaw_runtime.memory_stage import build_memory_context
from oclaw.openclaw_runtime.relay_pointer import build_acp_relay_result, validate_relay_share_envelope
from oclaw.openclaw_runtime.types import StandardMessage
_LOCK = threading.Lock()
_THREAD: threading.Thread | None = None
def ensure_worker_started(*, store: Any, poll_interval_s: float = 1.0) -> str:
global _THREAD
with _LOCK:
if _THREAD and _THREAD.is_alive():
return _THREAD.name
wid = f"openclaw-worker-{uuid.uuid4().hex[:8]}"
t = threading.Thread(
target=_worker_loop,
name=wid,
kwargs={"store": store, "worker_id": wid, "poll_interval_s": max(0.3, float(poll_interval_s or 1.0))},
daemon=True,
)
t.start()
_THREAD = t
return wid
def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
while True:
task = None
try:
task = store.openclaw_task_claim(worker_id=worker_id, lease_seconds=90)
except Exception:
task = None
if not task:
time.sleep(poll_interval_s)
continue
payload: dict[str, Any] = {}
try:
payload = json.loads(task.payload or "{}")
if not isinstance(payload, dict):
payload = {}
except Exception:
payload = {}
trace_id = str(payload.get("trace_id") or "")
run_id = str(payload.get("run_id") or "").strip() or None
session_id = str(task.session_id or "")
try:
if trace_id:
store.add_trace_event(
session_id=session_id,
trace_id=trace_id,
span_id=str(uuid.uuid4()),
parent_span_id=None,
event_type="task_claimed",
payload={"task_id": task.id, "worker_id": worker_id, "attempt_count": int(task.attempt_count or 0)},
)
except Exception:
pass
try:
lang = str(payload.get("lang") or "zh")
session_id = str(payload.get("session_id") or task.session_id or "")
user_text = str(payload.get("text") or "")
attachments = payload.get("attachments") or []
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
relay_share_envelope = payload.get("relay_share_envelope") if isinstance(payload.get("relay_share_envelope"), dict) else {}
if relay_share_envelope and "relay_share_envelope" not in metadata:
metadata["relay_share_envelope"] = relay_share_envelope
acp_parent_run_id = str(payload.get("acp_parent_run_id") or "").strip()
acp_child_run_id = str(payload.get("acp_child_run_id") or "").strip()
if acp_parent_run_id or acp_child_run_id:
ok_env, env_err, env_norm = validate_relay_share_envelope(relay_share_envelope)
if not ok_env:
fail_result = {
"ok": False,
"error_code": str(env_err or "relay_envelope_invalid"),
"retryable": False,
"acp_parent_run_id": acp_parent_run_id,
"acp_child_run_id": acp_child_run_id,
}
store.openclaw_task_fail(task_id=task.id, error=str(env_err or "relay_envelope_invalid"), result=fail_result)
if trace_id:
store.add_trace_event(
session_id=session_id,
trace_id=trace_id,
span_id=str(uuid.uuid4()),
parent_span_id=None,
event_type="task_failed",
payload={
"task_id": task.id,
"ok": False,
"error": str(env_err or "relay_envelope_invalid"),
"error_code": str(env_err or "relay_envelope_invalid"),
"retryable": False,
"acp_parent_run_id": acp_parent_run_id,
"acp_child_run_id": acp_child_run_id,
},
)
continue
relay_share_envelope = env_norm
metadata["relay_share_envelope"] = relay_share_envelope
tenant_id = str(payload.get("tenant_id") or "")
user_id = str(payload.get("user_id") or "")
viewer_username = str(payload.get("viewer_username") or "")
model_profile_id = str(payload.get("model_profile_id") or "") or None
selected_specialist = str(payload.get("selected_specialist") or "") or str(metadata.get("selected_specialist") or "")
memory_ctx = build_memory_context(
store=store,
session_id=session_id,
tenant_id=tenant_id,
user_id=user_id,
query_text=user_text,
)
executor = build_gateway_executor(
store,
lang=lang,
specialist=selected_specialist or "generalist",
profile_id=model_profile_id,
viewer_user_id=user_id or None,
viewer_username=viewer_username or None,
viewer_tenant_id=tenant_id or None,
policy_session_id=session_id or None,
path_policy_tenant_id=tenant_id or None,
path_policy_user_id=user_id or None,
)
system_prompt = ""
if hasattr(executor, "_compose_system_prompt"):
try:
system_prompt = str(executor._compose_system_prompt() or "")
except Exception:
system_prompt = ""
if not system_prompt:
system_prompt = str(getattr(executor, "system_prompt", "") or "")
max_messages = int(store.get_setting("AIA_TURN_MAX_CONTEXT_MESSAGES") or 80)
max_tool_rounds = int(store.get_setting("AIA_TURN_MAX_TOOL_ROUNDS") or 8)
max_tool_workers = int(store.get_setting("AIA_TURN_MAX_TOOL_WORKERS") or 8)
msg = StandardMessage(
session_id=session_id,
tenant_id=tenant_id,
user_id=user_id,
role=str(payload.get("role") or "member"),
channel=str(payload.get("channel") or "admin_chat"), # type: ignore[arg-type]
text=user_text,
attachments=list(attachments or []),
metadata=dict(metadata or {}),
)
run_out = run_agent_core(
store=store,
data=AgentCoreRunInput(
msg=msg,
lang=lang,
system_prompt=system_prompt,
model=executor.model,
tools=executor.tools,
trace_id=trace_id or None,
parent_span_id=None,
run_id=run_id,
max_messages=max(10, min(max_messages, 400)),
max_tool_rounds=max(1, min(max_tool_rounds, 30)),
max_tool_workers=max(1, min(max_tool_workers, 32)),
max_attempts=2,
memory_context=memory_ctx,
openclaw_task_id=str(task.id),
openclaw_worker_id=worker_id,
),
)
base_result = {
"run_id": str(run_out.run_id or ""),
"reply_text": run_out.outcome.final_text,
"tool_trace_count": len(run_out.outcome.tool_traces),
"relay_pointer_count": int(payload.get("relay_pointer_count") or 0),
"relay_envelope_present": bool(isinstance(relay_share_envelope, dict) and bool(relay_share_envelope)),
}
if acp_parent_run_id or acp_child_run_id:
base_result.update(
build_acp_relay_result(
parent_run_id=acp_parent_run_id,
child_run_id=acp_child_run_id,
relay_envelope=relay_share_envelope,
)
)
store.openclaw_task_finish(task_id=task.id, result=base_result)
if trace_id:
trace_payload = {
"task_id": task.id,
"ok": True,
"tool_trace_count": len(run_out.outcome.tool_traces),
"relay_pointer_count": int(payload.get("relay_pointer_count") or 0),
"relay_envelope_present": bool(isinstance(relay_share_envelope, dict) and bool(relay_share_envelope)),
}
if acp_parent_run_id or acp_child_run_id:
trace_payload.update(
{
"acp_parent_run_id": acp_parent_run_id,
"acp_child_run_id": acp_child_run_id,
}
)
store.add_trace_event(
session_id=session_id,
trace_id=trace_id,
span_id=str(uuid.uuid4()),
parent_span_id=None,
event_type="task_finished",
payload=trace_payload,
)
except Exception as exc:
store.openclaw_task_fail(task_id=task.id, error=str(exc), result={"ok": False})
try:
if trace_id:
store.add_trace_event(
session_id=session_id,
trace_id=trace_id,
span_id=str(uuid.uuid4()),
parent_span_id=None,
event_type="task_failed",
payload={"task_id": task.id, "ok": False, "error": str(exc)[:500]},
)
except Exception:
pass
__all__ = ["ensure_worker_started"]