mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 05:23:14 +08:00
初始化:独立 oclaw 仓库首提交
- 在 oclaw/ 下重新初始化 Git 仓库 - 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等) - 提交当前工程代码与配置 Made-with: Cursor
This commit is contained in:
commit
ba3836f00f
579 changed files with 83112 additions and 0 deletions
189
openclaw_runtime/agent_core_attempt.py
Normal file
189
openclaw_runtime/agent_core_attempt.py
Normal 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",
|
||||
]
|
||||
|
||||
357
openclaw_runtime/agent_core_run.py
Normal file
357
openclaw_runtime/agent_core_run.py
Normal 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",
|
||||
]
|
||||
|
||||
46
openclaw_runtime/command_parser.py
Normal file
46
openclaw_runtime/command_parser.py
Normal 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"]
|
||||
|
||||
617
openclaw_runtime/direct_loop.py
Normal file
617
openclaw_runtime/direct_loop.py
Normal 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
677
openclaw_runtime/gateway.py
Normal 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"]
|
||||
192
openclaw_runtime/hooks_runtime.py
Normal file
192
openclaw_runtime/hooks_runtime.py
Normal 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()
|
||||
206
openclaw_runtime/memory_stage.py
Normal file
206
openclaw_runtime/memory_stage.py
Normal 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",
|
||||
]
|
||||
|
||||
189
openclaw_runtime/project_context_prompt.py
Normal file
189
openclaw_runtime/project_context_prompt.py
Normal 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"]
|
||||
228
openclaw_runtime/relay_pointer.py
Normal file
228
openclaw_runtime/relay_pointer.py
Normal 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
134
openclaw_runtime/router.py
Normal 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"]
|
||||
|
||||
135
openclaw_runtime/skill_executor.py
Normal file
135
openclaw_runtime/skill_executor.py
Normal 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"]
|
||||
|
||||
401
openclaw_runtime/skill_installer.py
Normal file
401
openclaw_runtime/skill_installer.py
Normal 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",
|
||||
]
|
||||
129
openclaw_runtime/skill_role_binding.py
Normal file
129
openclaw_runtime/skill_role_binding.py
Normal 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
382
openclaw_runtime/skills.py
Normal 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",
|
||||
]
|
||||
167
openclaw_runtime/skills_prompt.py
Normal file
167
openclaw_runtime/skills_prompt.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
56
openclaw_runtime/system_prompt.py
Normal file
56
openclaw_runtime/system_prompt.py
Normal 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
246
openclaw_runtime/types.py
Normal 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
236
openclaw_runtime/worker.py
Normal 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"]
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue