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

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

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

24
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: python -m pip install -r requirements.txt ruff mypy
- name: Lint changed architecture modules
run: ruff check oclaw/interfaces oclaw/extensions oclaw/tests/test_oclaw_gateway_optimizations.py
- name: Type check critical gateway modules
run: mypy --ignore-missing-imports oclaw/interfaces/gateway/context_builder.py oclaw/interfaces/ws/server_methods_bridge.py oclaw/interfaces/ws/turn_runner.py
- name: Run tests
run: python -m pytest -q oclaw/tests
- name: Offline eval sanity
run: python oclaw/scripts/offline_eval.py

74
.gitignore vendored Normal file
View file

@ -0,0 +1,74 @@
.venv/
build/
dist/
*.spec
__pycache__/
*.pyc
.pytest_cache/
oclaw/scripts/.run/
oclaw/.venv/
.venv/
.mypy_cache/
data/*.sqlite
data/*.sqlite-journal
data/*.sqlite-shm
data/*.sqlite-wal
data/attachments/
oclaw/data/attachments/
oclaw/data/*.sqlite
oclaw/data/*.sqlite-journal
oclaw/data/*.sqlite-shm
oclaw/data/*.sqlite-wal
oclaw/data/logs/
oclaw/data/locks/
oclaw/data/ops_runtime_state.json
oclaw/data/mcp_local.env
oclaw/data/google_oauth_client.json
oclaw/data/_pre_merge_sqlite_*/
data/logs/
data/locks/
data/ops_runtime_state.json
data/mcp_local.env
data/google_oauth_client.json
data/_pre_merge_sqlite_*/
# oclaw 子仓库运行态目录(勿提交)
_local/
_local/*.env
_local/*.json
_local/*.txt
data/channel_sidecar/
data/**/node_modules/
data/**/*.log
data/**/*.err.log
platform/data/
scripts/.run/
desktop/node_modules/
desktop/dist/
desktop/runtime-data/
# 本地密钥(随仓库目录迁移时记得复制此文件;勿提交)
src/_local/google_oauth_client.json
src/_local/mcp_local.env
# MCP admin export / 安装后自动备份(可重装 JSON,本机 path 与列表可能不同)
src/_local/mcp_registry_migrated.json
src/platform/data/*.sqlite
src/platform/data/*.sqlite-journal
src/platform/data/*.sqlite-shm
src/platform/data/*.sqlite-wal
src/platform/data/logs/
src/platform/data/locks/
src/platform/data/ops_runtime_state.json
# 历史 Vite 前端已移除;若本地仍有残留目录可忽略或手动删除
web/
# 根目录临时调试日志(勿提交)
debug-*.log
.playwright-mcp/
# Python 安装/扩展缓存(非本应用源码)
Python/_cache/
oclaw/desktop/node_modules/
oclaw/desktop/dist/
oclaw/desktop/runtime-data/
oclaw/desktop/assets/oclaw.ico
vendor/openclaw-main.zip
vendor/openclaw/

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "vendor/cc-mini"]
path = vendor/cc-mini
url = https://github.com/e10nMa2k/cc-mini.git

15
README.md Normal file
View file

@ -0,0 +1,15 @@
# OpenClaw Architecture Root
This directory is the new architecture root for ongoing refactors.
## Layers
- `interfaces/`: transport adapters (HTTP/WS/gateway handlers)
- `application/`: use-cases and orchestration services
- `domain/`: business rules and domain primitives
- `infrastructure/`: integrations and runtime adapters
- `shared/`: shared utilities/types
## Migration Rule
New business logic should be implemented in `oclaw/`.
`oclaw/` modules remain as compatibility bridges during migration.

6
__init__.py Normal file
View file

@ -0,0 +1,6 @@
"""OpenClaw v2 package root.
All new architecture-first code should live under `oclaw/`.
Legacy `src/` modules may import from this package as a compatibility bridge.
"""

View file

@ -0,0 +1 @@
# AGENTS

View file

@ -0,0 +1,8 @@
# Session: 2026-04-22 13:10:55 UTC
- **Session Key**: agent:main:main
- **Action**: reset
## Conversation Summary
- (no messages found)

View file

@ -0,0 +1,8 @@
# Session: 2026-04-22 13:21:10 UTC
- **Session Key**: agent:main:main
- **Action**: reset
## Conversation Summary
- (no messages found)

4
admin/__init__.py Normal file
View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

1532
admin/chat_api.py Normal file

File diff suppressed because it is too large Load diff

184
admin/mcp_e2e_probe.py Normal file
View file

@ -0,0 +1,184 @@
"""Build dynamic MCP E2E probe plans from the live ToolRegistry (one tool use per MCP server_id)."""
from __future__ import annotations
from typing import Any
from oclaw.tools.base import ToolRegistry, ToolSpec
from oclaw.tools.tool_validation import validate_tool_arguments
def parse_mcp_bound_tool_name(full_name: str) -> tuple[str, str] | None:
"""Parse ``mcp__{server_id}__{mcp_tool}`` into (server_id, mcp_tool_name)."""
if not full_name.startswith("mcp__"):
return None
rest = full_name[len("mcp__") :]
idx = rest.find("__")
if idx < 0:
return None
server_id = rest[:idx].strip()
tool = rest[idx + 2 :].strip()
if not server_id or not tool:
return None
return server_id, tool
def _json_schema_required_keys(parameters: dict[str, Any]) -> list[str]:
if not isinstance(parameters, dict) or parameters.get("type") != "object":
return []
req = parameters.get("required")
if not isinstance(req, list):
return []
return [str(x).strip() for x in req if str(x).strip()]
def _fill_required_from_properties(
parameters: dict[str, Any],
*,
workspace_root: str,
) -> dict[str, Any] | None:
props = parameters.get("properties") if isinstance(parameters.get("properties"), dict) else {}
req = _json_schema_required_keys(parameters)
out: dict[str, Any] = {}
for key in req:
prop = props.get(key) if isinstance(props.get(key), dict) else {}
t = prop.get("type")
lk = key.lower()
if t == "string":
if lk in ("path", "cwd", "repopath", "directory", "filepath") or lk.endswith("path"):
out[key] = workspace_root
elif lk == "url":
out[key] = "https://example.com"
elif lk == "query":
out[key] = "model context protocol"
elif lk == "message":
out[key] = "mcp-e2e"
elif lk == "timezone":
out[key] = "UTC"
else:
out[key] = ""
elif t == "boolean":
out[key] = True if lk in ("includeuntracked", "includetracked") else False
elif t in ("number", "integer"):
out[key] = 0
elif t == "array":
out[key] = []
elif t == "object":
out[key] = {}
else:
return None
return out
# MCP tool names (suffix after server_id) with explicit args when schema is missing or validation needs concrete values.
_KNOWN_MCP_TOOL_ARGS: dict[str, dict[str, Any]] = {
"sequentialthinking": {
"thought": "e2e",
"nextThoughtNeeded": False,
"thoughtNumber": 1,
"totalThoughts": 1,
},
}
# Lower index = higher priority when multiple tools are callable.
_PROBE_PRIORITY: tuple[str, ...] = (
"browser_close",
"read_graph",
"db_info",
"list_pdfs",
"echo",
"list_directory",
"git_status",
"fetch_markdown",
"web_search",
"sequentialthinking",
)
def _probe_args_for_spec(spec: ToolSpec, *, workspace_root: str) -> dict[str, Any] | None:
parsed = parse_mcp_bound_tool_name(spec.name)
if not parsed:
return None
_, mcp_tool = parsed
params = spec.parameters if isinstance(spec.parameters, dict) else {}
if mcp_tool in _KNOWN_MCP_TOOL_ARGS:
args = dict(_KNOWN_MCP_TOOL_ARGS[mcp_tool])
ok, _ = validate_tool_arguments(params, args)
return args if ok else None
req = _json_schema_required_keys(params)
if not req:
args: dict[str, Any] = {}
ok, _ = validate_tool_arguments(params, args)
return args if ok else None
filled = _fill_required_from_properties(params, workspace_root=workspace_root)
if filled is None:
return None
ok, _ = validate_tool_arguments(params, filled)
return filled if ok else None
def _pick_probe_for_server(specs: list[ToolSpec], *, workspace_root: str) -> tuple[ToolSpec, dict[str, Any]] | None:
candidates: list[tuple[int, str, ToolSpec, dict[str, Any]]] = []
for sp in specs:
args = _probe_args_for_spec(sp, workspace_root=workspace_root)
if args is None:
continue
ok, _ = validate_tool_arguments(sp.parameters or {}, args)
if not ok:
continue
parsed = parse_mcp_bound_tool_name(sp.name)
mcp_tool = (parsed or ("", ""))[1]
try:
pri = _PROBE_PRIORITY.index(mcp_tool)
except ValueError:
pri = 900
candidates.append((pri, mcp_tool, sp, args))
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], x[1], x[2].name))
return candidates[0][2], candidates[0][3]
def build_mcp_e2e_probe_plans(
reg: ToolRegistry,
*,
workspace_root: str,
) -> tuple[list[tuple[str, str, dict[str, Any]]], list[str]]:
"""
Returns (plans, skipped_server_ids).
``plans`` entries are ``(server_id, full_tool_name, arguments)`` for ``ToolExecutor.execute_tool_uses``.
One probe per MCP ``server_id`` discovered from registry names ``mcp__*__*`` with tag ``mcp``.
``skipped_server_ids`` lists servers that had MCP tools in the registry but no schema-safe probe
could be constructed (no false positives from arbitrary ``tools/call``).
"""
by_server: dict[str, list[ToolSpec]] = {}
for spec in reg.list():
if "mcp" not in (spec.tags or frozenset()):
continue
parsed = parse_mcp_bound_tool_name(spec.name)
if not parsed:
continue
sid, _ = parsed
by_server.setdefault(sid, []).append(spec)
plans: list[tuple[str, str, dict[str, Any]]] = []
for sid in sorted(by_server.keys()):
picked = _pick_probe_for_server(by_server[sid], workspace_root=workspace_root)
if picked is None:
continue
spec, args = picked
plans.append((sid, spec.name, args))
planned = {p[0] for p in plans}
skipped = [s for s in sorted(by_server.keys()) if s not in planned]
return plans, skipped
__all__ = [
"build_mcp_e2e_probe_plans",
"parse_mcp_bound_tool_name",
]

561
admin/models_api.py Normal file
View file

@ -0,0 +1,561 @@
"""Admin API: LLM profiles, agent bindings, UI language, eval (parity with Streamlit settings)."""
from __future__ import annotations
import csv
import io
import json
import os
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Body, Header, HTTPException, Query
from fastapi.responses import Response
from oclaw.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
from oclaw.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY,
AGENT_ROLE_IDS,
dump_agent_profile_bindings,
parse_agent_profile_bindings,
)
from oclaw.platform.config.paths import db_path
from oclaw.orchestration.evaluation import eval_summary
from oclaw.platform.persistence.sqlite_store import (
LLM_BUILTIN_OLLAMA_PROFILE_ID,
SqliteStore,
active_llm_profile_setting_key,
agent_profile_bindings_setting_key,
is_administrator_model_pool,
)
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
_LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"})
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if permission in perms:
return
if str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail=f"forbidden:{permission}")
def _require_models_mutate(ctx: dict[str, Any]) -> None:
"""administrator 编辑全局池需 tenant:write;其余用户编辑自己的复制池仅需 read。"""
uname = str(ctx.get("username") or "").strip()
if is_administrator_model_pool(uname):
_require_permission(ctx, "admin:tenant:write")
else:
_require_permission(ctx, "admin:read")
def _models_list_kwargs(ctx: dict[str, Any]) -> dict[str, Any]:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
tid = str(ctx.get("tenant_id") or "").strip()
if not uid:
return {}
out: dict[str, Any] = {"viewer_user_id": uid, "viewer_username": uname or None}
if tid:
out["viewer_tenant_id"] = tid
return out
def _active_key(ctx: dict[str, Any]) -> str:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if not uid:
return "active_llm_profile_id"
return active_llm_profile_setting_key(uid, uname or None)
def _bindings_key(ctx: dict[str, Any]) -> str:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if not uid:
return AGENT_PROFILE_BINDINGS_KEY
return agent_profile_bindings_setting_key(uid, uname or None)
def _assert_profile_mutable(ctx: dict[str, Any], prof: dict[str, Any] | None) -> dict[str, Any]:
if not prof:
raise HTTPException(status_code=404, detail="profile_not_found")
if prof.get("is_builtin"):
return prof
own = str(prof.get("owner_user_id") or "").strip()
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if is_administrator_model_pool(uname):
return prof
if own == uid:
return prof
raise HTTPException(status_code=403, detail="profile_forbidden")
def _can_manage_llm_grants(ctx: dict[str, Any]) -> bool:
uname = str(ctx.get("username") or "").strip()
if not is_administrator_model_pool(uname):
return False
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:tenant:write" in perms:
return True
return str(ctx.get("role") or "") == "owner"
def _require_grant_manager(ctx: dict[str, Any]) -> None:
if not _can_manage_llm_grants(ctx):
raise HTTPException(status_code=403, detail="grants_administrator_only")
def _profile_shareable_for_admin_grant(ctx: dict[str, Any], prof: dict[str, Any] | None) -> bool:
"""仅全局池(无 owner)或操作者本人名下的 profile 可被授权给团队/用户。"""
if not prof or prof.get("is_builtin"):
return False
own = str(prof.get("owner_user_id") or "").strip()
uid = str(ctx.get("user_id") or "").strip()
if not own:
return True
return bool(uid) and own == uid
def _normalize_active(
store: SqliteStore, profiles: list[dict[str, Any]], profile_ids: list[str], ctx: dict[str, Any]
) -> str:
if not profile_ids:
return ""
key = _active_key(ctx)
active_id = str(store.get_setting(key) or "").strip()
if active_id not in profile_ids:
store.set_setting(key, LLM_BUILTIN_OLLAMA_PROFILE_ID)
active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID
if active_id not in profile_ids:
active_id = profile_ids[0]
store.set_setting(key, active_id)
return active_id
def include_model_mgmt_routes(
router: APIRouter,
*,
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
) -> None:
mg = APIRouter(prefix="/admin/api/models", tags=["models"])
@mg.get("")
def api_models_state(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
lk = _models_list_kwargs(ctx)
profiles = store.list_llm_profiles(visible_only=True, **lk)
profile_ids = [str(p["id"]) for p in profiles]
active_id = _normalize_active(store, profiles, profile_ids, ctx)
bindings = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
ui_lang = str(store.get_setting("ui_lang") or "zh").strip().lower()
if ui_lang not in ("zh", "en"):
ui_lang = "zh"
secret = ""
if active_id and active_id in profile_ids:
active_prof = next((p for p in profiles if str(p.get("id") or "") == active_id), None)
if is_administrator_model_pool(uname) or (active_prof and active_prof.get("mutable", True)):
secret = store.get_llm_profile_secret(active_id) or ""
out: dict[str, Any] = {
"ok": True,
"active_llm_profile_id": active_id,
"profiles": profiles,
"bindings": bindings,
"ui_lang": ui_lang,
"builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID,
"has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()),
"role_ids": list(AGENT_ROLE_IDS),
"profile_secret": secret,
"can_manage_llm_grants": _can_manage_llm_grants(ctx),
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
"db_path": db_path(),
}
return out
@mg.post("/active")
def api_models_set_active(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
# 与能进入控制台一致:切换「当前选用」不写密钥,仅需读权限即可。
_require_permission(ctx, "admin:read")
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = [str(p["id"]) for p in profiles]
pid = str(payload.get("profile_id") or "").strip()
if pid not in profile_ids:
raise HTTPException(status_code=400, detail="invalid_profile_id")
store.set_setting(_active_key(ctx), pid)
return {"ok": True, "active_llm_profile_id": pid}
@mg.post("/bindings")
def api_models_set_bindings(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = set(str(p["id"]) for p in profiles)
raw = payload.get("bindings")
if not isinstance(raw, dict):
raise HTTPException(status_code=400, detail="bindings_object_required")
cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
for rid in AGENT_ROLE_IDS:
v = raw.get(rid)
if v is None:
continue
s = str(v).strip()
if s and s not in profile_ids:
raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}")
cur[rid] = s
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
return {"ok": True, "bindings": cur}
@mg.post("/profiles")
def api_models_create_profile(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
name = str(payload.get("name") or "").strip() or "新配置"
mode = str(payload.get("mode") or "openai").strip().lower()
if mode not in _LLM_USER_CREATE_MODES:
raise HTTPException(status_code=400, detail="invalid_mode")
if mode == "openai":
new_model = "gpt-4o-mini"
new_bu = ""
else:
new_model = DEFAULT_OLLAMA_MODEL
new_bu = DEFAULT_OLLAMA_BASE_URL
own: str | None = None
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if uid and not is_administrator_model_pool(uname):
own = uid
pid = store.create_llm_profile(name=name, mode=mode, model=new_model, base_url=new_bu or None, owner_user_id=own)
store.set_setting(_active_key(ctx), pid)
prof = store.get_llm_profile(pid)
return {"ok": True, "profile_id": pid, "profile": prof}
@mg.patch("/profiles/{profile_id}")
def api_models_patch_profile(
profile_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
name = str(payload.get("name") if payload.get("name") is not None else prof.get("name") or "").strip() or "未命名"
mode_raw = str(payload.get("mode") if payload.get("mode") is not None else prof.get("mode") or "openai").strip().lower()
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
mode_save = "ollama"
else:
if mode_raw not in _LLM_MODE_OPTIONS:
raise HTTPException(status_code=400, detail="invalid_mode")
mode_save = mode_raw
model = payload.get("model")
base_url = payload.get("base_url")
model_s = str(model).strip() if model is not None else str(prof.get("model") or "").strip()
bu_s = str(base_url).strip() if base_url is not None else str(prof.get("base_url") or "").strip()
store.update_llm_profile(
profile_id=pid,
name=name,
mode=mode_save,
model=model_s or None,
base_url=bu_s or None,
)
store.set_setting(_active_key(ctx), pid)
return {"ok": True, "profile": store.get_llm_profile(pid)}
@mg.post("/profiles/{profile_id}/secret")
def api_models_profile_secret(
profile_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
remember = bool(payload.get("remember"))
key_text = str(payload.get("secret") or "").strip()
mode_save = str(prof.get("mode") or "openai").strip().lower()
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
mode_save = "ollama"
if mode_save in ("openai", "ollama"):
if remember:
if key_text:
store.set_llm_profile_secret(pid, key_text)
elif mode_save == "openai":
raise HTTPException(status_code=400, detail="remember_key_empty")
else:
store.clear_llm_profile_secret(pid)
else:
store.clear_llm_profile_secret(pid)
return {"ok": True, "profile": store.get_llm_profile(pid)}
@mg.delete("/profiles/{profile_id}")
def api_models_delete_profile(
profile_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
_assert_profile_mutable(ctx, store.get_llm_profile(pid))
try:
store.delete_llm_profile(pid)
except ValueError:
raise HTTPException(status_code=400, detail="cannot_delete_builtin")
remaining = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
new_active = remaining[0]["id"] if remaining else LLM_BUILTIN_OLLAMA_PROFILE_ID
store.set_setting(_active_key(ctx), new_active)
return {"ok": True, "active_llm_profile_id": new_active}
@mg.get("/members")
def api_models_members(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
if not tid:
raise HTTPException(status_code=400, detail="tenant_required")
users = store.list_users(tenant_id=tid, limit=500, offset=0, include_inactive=True)
members: list[dict[str, Any]] = []
for u in users:
mid = str(u.get("id") or "").strip()
un = str(u.get("username") or "").strip()
if not mid:
continue
profs = store.list_llm_profiles(
visible_only=True,
viewer_user_id=mid,
viewer_username=un or None,
viewer_tenant_id=tid,
)
members.append(
{
"user_id": mid,
"username": un,
"display_name": str(u.get("display_name") or "").strip(),
"role": str(u.get("role") or "").strip(),
"profiles": profs,
}
)
return {"ok": True, "members": members}
@mg.get("/grants/tenant")
def api_models_grants_tenant_get(
profile_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
granted = store.tenant_has_llm_profile_grant(tid, pid)
return {"ok": True, "granted": granted}
@mg.post("/grants/tenant")
def api_models_grants_tenant_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(payload.get("profile_id") or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="profile_id_required")
prof = store.get_llm_profile(pid)
if not _profile_shareable_for_admin_grant(ctx, prof):
raise HTTPException(status_code=403, detail="profile_not_shareable")
actor = str(ctx.get("user_id") or "").strip() or None
try:
gid = store.grant_llm_profile_to_tenant(
tenant_id=tid, profile_id=pid, created_by_user_id=actor
)
except ValueError as e:
code = str(e)
if code == "profile_not_found":
raise HTTPException(status_code=404, detail=code) from e
raise HTTPException(status_code=400, detail=code) from e
return {"ok": True, "grant_id": gid}
@mg.delete("/grants/tenant")
def api_models_grants_tenant_revoke(
profile_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="profile_id_required")
n = store.revoke_llm_profile_tenant_grant(tenant_id=tid, profile_id=pid)
return {"ok": True, "removed": int(n)}
@mg.get("/grants")
def api_models_grants_list(
profile_id: str = Query(..., description="llm_profile id"),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
rows = store.list_llm_profile_grants_for_profile(tid, pid)
return {"ok": True, "grants": rows}
@mg.post("/grants")
def api_models_grants_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(payload.get("profile_id") or "").strip()
uid = str(payload.get("user_id") or "").strip()
if not tid or not pid or not uid:
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
prof = store.get_llm_profile(pid)
if not _profile_shareable_for_admin_grant(ctx, prof):
raise HTTPException(status_code=403, detail="profile_not_shareable")
actor = str(ctx.get("user_id") or "").strip() or None
try:
gid = store.grant_llm_profile_to_user(
tenant_id=tid,
profile_id=pid,
user_id=uid,
created_by_user_id=actor,
)
except ValueError as e:
code = str(e)
if code == "profile_not_found":
raise HTTPException(status_code=404, detail=code) from e
if code == "user_not_found":
raise HTTPException(status_code=404, detail=code) from e
raise HTTPException(status_code=400, detail=code) from e
return {"ok": True, "grant_id": gid}
@mg.delete("/grants")
def api_models_grants_revoke(
profile_id: str = Query(...),
user_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
uid = str(user_id or "").strip()
if not tid or not pid or not uid:
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
n = store.revoke_llm_profile_grant(tenant_id=tid, profile_id=pid, user_id=uid)
return {"ok": True, "removed": int(n)}
@mg.get("/eval")
def api_models_eval(
authorization: str | None = Header(default=None),
limit_logs: int = Query(default=100, ge=1, le=500),
limit_summary: int = Query(default=500, ge=1, le=5000),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
summary = eval_summary(store, limit=limit_summary)
logs = store.list_agent_eval_logs(limit=limit_logs)
return {"ok": True, "summary": summary, "logs": logs}
_EVAL_EXPORT_FIELDS = (
"timestamp",
"session_id",
"specialist",
"task_kind",
"success",
"latency_ms",
"cost_hint",
"notes",
)
@mg.get("/eval/export")
def api_models_eval_export(
authorization: str | None = Header(default=None),
format: str = Query(default="csv", description="csv or json"),
limit: int = Query(default=100_000, ge=1, le=200_000),
) -> Response:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
fmt = str(format or "csv").strip().lower()
rows = store.list_agent_eval_logs(limit=limit)
if fmt == "json":
body = json.dumps(rows, ensure_ascii=False, indent=2)
return Response(
content=body.encode("utf-8"),
media_type="application/json; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="agent_eval_logs.json"',
},
)
if fmt != "csv":
raise HTTPException(status_code=400, detail="invalid_format")
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=list(_EVAL_EXPORT_FIELDS), extrasaction="ignore")
w.writeheader()
for r in rows:
row = {k: r.get(k) for k in _EVAL_EXPORT_FIELDS}
if "success" in row:
row["success"] = 1 if bool(row.get("success")) else 0
w.writerow(row)
payload = "\ufeff" + buf.getvalue()
return Response(
content=payload.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="agent_eval_logs.csv"'},
)
router.include_router(mg)
__all__ = ["include_model_mgmt_routes"]

3069
admin/routes.py Normal file

File diff suppressed because it is too large Load diff

604
admin/skills_api.py Normal file
View file

@ -0,0 +1,604 @@
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Body, Header, HTTPException
from oclaw.agents.factory import build_gateway_executor
from oclaw.openclaw_runtime.skill_installer import (
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,
)
from oclaw.openclaw_runtime.skill_role_binding import (
SKILL_ROLE_BINDING_ENABLED_SETTING,
SKILL_ROLE_BINDING_KEY,
load_skill_role_binding_dict,
normalize_skill_role_binding,
ordered_binding_roles,
skill_role_binding_enabled,
)
from oclaw.openclaw_runtime.skills_prompt import collect_skill_catalog_entries
from oclaw.openclaw_runtime.skills import _allowed_tool_names_after_wire_policy, discover_workspace_skill_manifests
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from oclaw.tools.skills.clawhub_client import search_skills as clawhub_search_skills
def include_skill_routes(
router: APIRouter,
*,
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
) -> None:
sk = APIRouter(prefix="/admin/api/skills", tags=["skills"])
def _require_admin(ctx: dict[str, Any]) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:read" in perms or str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail="forbidden:admin:read")
def _require_tenant_write(ctx: dict[str, Any]) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:tenant:write" in perms or str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail="forbidden:admin:tenant:write")
def _audit(store: SqliteStore, ctx: dict[str, Any], *, action: str, target_id: str, status: str, detail: dict[str, Any] | None = None) -> None:
try:
store.add_admin_audit_log(
actor_tenant_id=str(ctx.get("tenant_id") or ""),
actor_user_id=str(ctx.get("user_id") or ""),
action=action,
target_type="skill",
target_id=str(target_id or ""),
status=status,
detail=detail or {},
)
except Exception:
pass
def _normalized_skill_binding(store: SqliteStore) -> tuple[list[str], dict[str, list[str]], set[str]]:
roles = ordered_binding_roles()
valid = {str(m.name).strip() for m in discover_workspace_skill_manifests() if str(m.name or "").strip()}
mapping = normalize_skill_role_binding(
mapping_raw=load_skill_role_binding_dict(store),
valid_skill_names=valid,
available_roles=roles,
)
return roles, mapping, valid
@sk.get("")
def api_skills_list(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
items = list_skills_with_status(store=store)
return {"ok": True, "items": items}
@sk.post("/install")
def api_skills_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
source_dir = str(payload.get("source_dir") or "").strip()
if not source_dir:
raise HTTPException(status_code=400, detail="source_dir_required")
overwrite = bool(payload.get("overwrite"))
_audit(store, ctx, action="skill_install_started", target_id=source_dir, status="start", detail={"source": "local"})
out = install_skill_from_local_dir(store=store, source_dir=source_dir, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or source_dir,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "local", "input_target": source_dir},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/install-registry")
def api_skills_install_registry(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
archive_url = str(payload.get("archive_url") or "").strip()
if not archive_url:
raise HTTPException(status_code=400, detail="archive_url_required")
overwrite = bool(payload.get("overwrite"))
_audit(store, ctx, action="skill_install_started", target_id=archive_url, status="start", detail={"source": "registry"})
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or archive_url,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "registry", "input_target": archive_url},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.get("/market/search")
def api_skills_market_search(
q: str | None = None,
limit: int | None = None,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
query = str(q or "").strip()
lim = int(limit) if isinstance(limit, int) and limit > 0 else 20
lim = max(1, min(lim, 200))
items = clawhub_search_skills(query, limit=lim)
return {"ok": True, "items": items}
@sk.get("/market/detail")
def api_skills_market_detail(
slug: str | None = None,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
s = str(slug or "").strip()
if not s:
raise HTTPException(status_code=400, detail="slug_required")
detail = clawhub_get_skill_detail(s)
return {"ok": True, "detail": detail}
@sk.post("/market/install")
def api_skills_market_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
s = str(payload.get("slug") or "").strip()
if not s:
raise HTTPException(status_code=400, detail="slug_required")
requested_version = str(payload.get("version") or "").strip()
overwrite = bool(payload.get("overwrite"))
detail = clawhub_get_skill_detail(s)
archive_url = str(detail.get("archiveUrl") or "").strip()
chosen_version = str(detail.get("latestVersion") or "").strip()
if requested_version:
chosen_version = requested_version
archive_url = ""
for v in (detail.get("versions") or []):
if not isinstance(v, dict):
continue
if str(v.get("version") or "").strip() == requested_version:
archive_url = str(v.get("archiveUrl") or "").strip()
break
if not archive_url:
raise HTTPException(status_code=400, detail="archive_url_unavailable")
_audit(
store,
ctx,
action="skill_install_started",
target_id=archive_url,
status="start",
detail={"source": "clawhub", "slug": s, "version": chosen_version, "input_target": s},
)
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or archive_url,
status="ok" if out.ok else "fail",
detail={
"detail": out.detail,
"target_dir": out.target_dir,
"source": "clawhub",
"slug": s,
"version": chosen_version,
"input_target": archive_url,
},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.get("/binding")
def api_skills_binding_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_tenant_write(ctx)
roles, mapping, _valid = _normalized_skill_binding(store)
items = list_skills_with_status(store=store)
return {
"ok": True,
"enabled": bool(skill_role_binding_enabled(store=store)),
"available_roles": roles,
"installed_skills": items,
"mapping": mapping,
}
@sk.post("/binding")
def api_skills_binding_save(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_tenant_write(ctx)
if "enabled" in payload:
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1" if bool(payload.get("enabled")) else "0")
roles, _prev_mapping, valid = _normalized_skill_binding(store)
mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {}
mapping = normalize_skill_role_binding(
mapping_raw=mapping_raw,
valid_skill_names=valid,
available_roles=roles,
)
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping, ensure_ascii=False))
_audit(
store,
ctx,
action="skill_binding_update",
target_id="skill_role_binding",
status="ok",
detail={"mapping": mapping, "enabled": bool(skill_role_binding_enabled(store=store))},
)
return {
"ok": True,
"enabled": bool(skill_role_binding_enabled(store=store)),
"available_roles": roles,
"mapping": mapping,
}
@sk.get("/effective")
def api_skills_effective(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
roles, mapping, _valid = _normalized_skill_binding(store)
role_rows: list[dict[str, Any]] = []
for role in roles:
specialist = "generalist" if role == "manager" else role
ex = build_gateway_executor(store=store, specialist=specialist)
tools = getattr(ex, "tools", None)
model = getattr(ex, "model", None)
if tools is None:
role_rows.append(
{
"role": role,
"total": 0,
"workspace_total": 0,
"workspace_direct": 0,
"workspace_inherited_manager": 0,
"mcp_total": 0,
"tool_total": 0,
"names_preview": [],
}
)
continue
base_url = str(getattr(model, "base_url", "") or "")
entries = collect_skill_catalog_entries(
store=store,
registry=tools,
base_url=base_url,
skill_binding_role=role,
)
allowed_tool_names, _hidden_tool_names = _allowed_tool_names_after_wire_policy(
registry=tools,
store=store,
base_url=base_url,
)
direct_set = set(mapping.get(role) or [])
manager_set = set(mapping.get("manager") or [])
workspace_total = 0
workspace_direct = 0
workspace_inherited = 0
workspace_resolved_tool_match = 0
workspace_docs_only = 0
mcp_total = 0
tool_total = 0
names: list[str] = []
docs_only_names: list[str] = []
resolved_workspace_names: list[str] = []
for nm, _desc, loc in entries:
names.append(str(nm))
vloc = str(loc or "")
if vloc.endswith("SKILL.md"):
workspace_total += 1
if nm in allowed_tool_names:
workspace_resolved_tool_match += 1
resolved_workspace_names.append(str(nm))
else:
workspace_docs_only += 1
docs_only_names.append(str(nm))
if nm in direct_set:
workspace_direct += 1
elif role != "manager" and nm in manager_set:
workspace_inherited += 1
elif str(nm).startswith("mcp__"):
mcp_total += 1
else:
tool_total += 1
role_rows.append(
{
"role": role,
"total": len(entries),
"workspace_total": workspace_total,
"workspace_direct": workspace_direct,
"workspace_inherited_manager": workspace_inherited,
"workspace_resolved_tool_match": workspace_resolved_tool_match,
"workspace_docs_only": workspace_docs_only,
"mcp_total": mcp_total,
"tool_total": tool_total,
"names_preview": names[:20],
"docs_only_names_preview": docs_only_names[:20],
"resolved_workspace_names_preview": resolved_workspace_names[:20],
}
)
return {"ok": True, "enabled": bool(skill_role_binding_enabled(store=store)), "items": role_rows}
@sk.post("/create")
def api_skills_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
desc = 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 {}
overwrite = bool(payload.get("overwrite"))
out = create_skill_from_template(
store=store,
name=name,
description=desc,
body_markdown=body,
metadata_openclaw=md,
overwrite=overwrite,
)
_audit(
store,
ctx,
action="skill_create",
target_id=out.name or name,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/enable")
def api_skills_enable(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
set_skill_enabled(store=store, skill_name=name, enabled=True)
_audit(store, ctx, action="skill_enable", target_id=name, status="ok")
return {"ok": True}
@sk.post("/disable")
def api_skills_disable(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
set_skill_enabled(store=store, skill_name=name, enabled=False)
_audit(store, ctx, action="skill_disable", target_id=name, status="ok")
return {"ok": True}
@sk.post("/auto-install")
def api_skills_auto_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
auto_name = str(payload.get("name") or "")
_audit(
store,
ctx,
action="skill_install_started",
target_id=auto_name,
status="start",
detail={"source": "auto", "input_target": auto_name},
)
out = auto_install_skill_from_payload(store=store, payload=payload)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or str(payload.get("name") or ""),
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "auto", "input_target": auto_name},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/retry-install")
def api_skills_retry_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
source = str(payload.get("source") or "").strip().lower()
target = str(payload.get("target") or "").strip()
if source not in {"local", "registry", "auto"}:
raise HTTPException(status_code=400, detail="invalid_source")
if not target:
raise HTTPException(status_code=400, detail="target_required")
_audit(
store,
ctx,
action="skill_install_started",
target_id=target,
status="start",
detail={"source": source, "retry": True, "input_target": target},
)
if source == "local":
out = install_skill_from_local_dir(store=store, source_dir=target, overwrite=True)
elif source == "registry":
out = install_skill_from_registry_archive(store=store, archive_url=target, overwrite=True)
else:
out = auto_install_skill_from_payload(
store=store,
payload={
"name": str(payload.get("name") or "").strip() or target,
"description": str(payload.get("description") or "retry auto install"),
"body_markdown": str(payload.get("body_markdown") or ""),
"metadata_openclaw": dict(payload.get("metadata_openclaw") or {})
if isinstance(payload.get("metadata_openclaw"), dict)
else {},
},
)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or target,
status="ok" if out.ok else "fail",
detail={
"detail": out.detail,
"target_dir": out.target_dir,
"source": source,
"retry": True,
"input_target": target,
},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/test-run")
def api_skills_test_run(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
args = payload.get("args")
if not name:
raise HTTPException(status_code=400, detail="name_required")
if args is None:
args = {}
if not isinstance(args, dict):
raise HTTPException(status_code=400, detail="args_must_be_object")
ex = build_gateway_executor(store=store, specialist="generalist")
tools = getattr(ex, "tools", None)
if tools is None:
raise HTTPException(status_code=500, detail="tool_registry_unavailable")
spec = tools.get(name)
if spec is None:
raise HTTPException(status_code=404, detail="tool_not_found")
try:
result = spec.handler(dict(args))
except Exception as exc:
result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"}
_audit(
store,
ctx,
action="skill_test_run",
target_id=name,
status="ok" if bool((result or {}).get("ok")) else "fail",
detail={"args_keys": list(args.keys()), "result_ok": bool((result or {}).get("ok"))},
)
return {"ok": True, "name": name, "result": result}
router.include_router(sk)
__all__ = ["include_skill_routes"]

7578
admin/static/app.js Normal file

File diff suppressed because it is too large Load diff

455
admin/static/chat.html Normal file
View file

@ -0,0 +1,455 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
<style>
body.theme-ds-body.chat-standalone-page {
margin: 0;
min-height: 100vh;
background: var(--ds-bg, #0d0d0d);
color: var(--ds-text, #e8e8e8);
display: flex;
flex-direction: column;
}
#app {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
}
.chat-app--login {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
#app .chat-layout {
flex: 1;
min-height: 0;
}
.chat-sess-row {
display: flex;
align-items: stretch;
gap: 0;
margin-bottom: 4px;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
}
.chat-sess-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.09));
}
.chat-sess-row--active {
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
border-color: rgba(255, 255, 255, 0.08);
}
.chat-sess-row .chat-sess-btn {
flex: 1;
min-width: 0;
}
.chat-sess-more {
flex: 0 0 2rem;
min-width: 2rem;
padding: 10px 0;
border-radius: 0;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
-webkit-appearance: none;
appearance: none;
box-shadow: none;
}
.chat-user-row {
display: flex;
align-items: stretch;
gap: 0;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
}
.chat-user-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
}
.chat-user-row .chat-sess-btn {
flex: 1;
min-width: 0;
}
.chat-sess-more:hover {
background: transparent;
}
.chat-sess-more:focus {
outline: none;
}
.chat-sess-more:focus-visible {
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.2));
outline-offset: 1px;
}
.chat-sess-more--active {
background: transparent;
border-color: transparent;
}
.chat-sess-more--active:hover {
background: transparent;
}
.chat-sess-menu-pop {
z-index: 200;
background: var(--ds-panel, #222);
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 4px;
min-width: 11rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.chat-sess-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.chat-sess-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.chat-msg__md {
line-height: 1.45;
word-break: break-word;
}
.chat-msg .chat-msg__md p {
margin: 0.28em 0;
}
.chat-msg .chat-msg__md > :first-child {
margin-top: 0;
}
.chat-msg .chat-msg__md > :last-child {
margin-bottom: 0;
}
.chat-msg__md pre {
overflow: auto;
padding: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
font-size: 12px;
}
.chat-msg__md code {
font-family: ui-monospace, monospace;
font-size: 0.92em;
}
/* 父级 .chat-msg 的 pre-wrap 会继承到子节点,影响图片与 Markdown 排版 */
.chat-msg .chat-msg__md,
.chat-msg .chat-att-wrap {
white-space: normal;
}
.chat-msg__md img {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
margin: 0.25rem 0;
vertical-align: middle;
cursor: zoom-in;
}
.chat-msg__plain {
white-space: pre-wrap;
}
.chat-msg__stream-status {
font-size: 12px;
line-height: 1.35;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.62));
margin-bottom: 6px;
}
.chat-att-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.chat-att-img {
max-width: 180px;
max-height: 180px;
border-radius: 8px;
object-fit: cover;
cursor: zoom-in;
}
.chat-img-lightbox {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.chat-img-lightbox__inner {
position: relative;
max-width: 100%;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.chat-img-lightbox__img {
max-width: min(96vw, 100%);
max-height: min(92vh, 100%);
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
}
.chat-img-lightbox__close {
position: absolute;
top: -8px;
right: -8px;
width: 40px;
height: 40px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #eee;
font-size: 26px;
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.chat-img-lightbox__close:hover {
background: rgba(255, 255, 255, 0.22);
}
.chat-att-chip {
font-size: 12px;
opacity: 0.9;
}
.chat-composer-shell {
border-radius: 12px;
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
background: rgba(0, 0, 0, 0.35);
padding: 6px 8px 6px 6px;
}
.chat-composer-shell--busy .chat-composer__field {
opacity: 0.72;
}
.chat-pending-files {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 4.5rem;
overflow-y: auto;
padding: 2px 4px 6px;
margin: 0 -2px;
}
.chat-pending-files:empty {
display: none;
}
.chat-pending-row {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 8px 2px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 11px;
}
.chat-pending-row .muted {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-pending-row .chat-pending-remove {
padding: 0 4px;
min-height: 22px;
min-width: 22px;
font-size: 14px;
line-height: 1;
border: none;
background: transparent;
color: inherit;
opacity: 0.75;
border-radius: 6px;
cursor: pointer;
}
.chat-pending-row .chat-pending-remove:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.12);
}
.chat-composer-row {
display: flex;
align-items: flex-end;
gap: 4px;
}
.chat-composer-iconbtn {
flex: 0 0 40px;
width: 40px;
height: 40px;
position: relative;
padding: 0;
margin: 0 0 2px 2px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.75));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.chat-composer-iconbtn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--ds-text, #e8e8e8);
}
.chat-composer-iconbtn:focus-visible {
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.25));
outline-offset: 1px;
}
.chat-composer-iconbtn svg {
display: block;
}
.chat-composer__field {
flex: 1;
min-width: 0;
min-height: 44px;
max-height: 200px;
resize: none;
border: none;
border-radius: 0;
padding: 10px 8px 12px 4px;
margin-bottom: 2px;
background: transparent;
color: inherit;
line-height: 1.45;
}
.chat-composer__field:focus {
outline: none;
}
.chat-composer__field::placeholder {
color: var(--ds-text-muted, rgba(232, 232, 232, 0.45));
}
.chat-composer-actions {
position: relative;
flex: 0 0 40px;
width: 40px;
height: 40px;
margin: 0 2px 2px 0;
}
.chat-composer-send,
.chat-composer-stop {
position: absolute;
right: 0;
bottom: 0;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 999px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.12s ease;
}
.chat-composer-send {
background: rgba(94, 179, 255, 0.92);
color: #0a0a0c;
}
.chat-composer-send:hover:not(:disabled) {
background: rgba(120, 195, 255, 1);
}
.chat-composer-send:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.chat-composer-stop {
background: rgba(255, 255, 255, 0.12);
color: #f3f3f3;
opacity: 0;
pointer-events: none;
}
.chat-composer-stop:hover:not(:disabled) {
background: rgba(255, 80, 80, 0.35);
color: #fff;
}
.chat-composer-stop:disabled {
opacity: 0;
pointer-events: none;
}
.chat-composer-shell--busy .chat-composer-send {
opacity: 0;
pointer-events: none;
}
.chat-composer-shell--busy .chat-composer-stop:not(:disabled) {
opacity: 1;
pointer-events: auto;
}
.chat-msg-cap {
padding: 6px 0;
font-size: 12px;
}
#app .chat-status {
flex-shrink: 0;
min-height: 1.25rem;
line-height: 1.35;
}
.chat-confirm-backdrop {
position: fixed;
inset: 0;
z-index: 400;
background: rgba(0, 0, 0, 0.58);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.chat-confirm-card {
width: min(420px, 92vw);
background: linear-gradient(180deg, rgba(11, 18, 30, 0.98), rgba(7, 12, 22, 0.98));
border: 1px solid rgba(94, 179, 255, 0.35);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
color: var(--ds-text, #e8e8e8);
padding: 14px;
}
.chat-confirm-text {
font-size: 14px;
line-height: 1.45;
color: var(--ds-text, #e8e8e8);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
</head>
<body class="theme-ds-body chat-standalone-page">
<div id="app"></div>
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
<script src="/admin/assets/chat.js?v=20260421-1"></script>
</body>
</html>

3404
admin/static/chat.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 307 KiB

59
admin/static/index.html Normal file
View file

@ -0,0 +1,59 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>oliver</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
</head>
<body class="theme-ds-body">
<div class="layout">
<aside class="sidebar">
<div class="brand">
<div class="brand__logoWrap">
<img class="brand__logo" src="/admin/assets/oliver.svg" alt="oliver logo" />
</div>
</div>
<nav class="nav">
<a class="nav__item" data-page="models" href="#/models" data-i18n="nav.models">模型管理</a>
<a class="nav__item" data-page="api-grants" href="#/api-grants" data-i18n="nav.apiGrants">API 使用授权</a>
<a class="nav__item" data-page="stack" href="#/stack" data-i18n="nav.stack">Runtime</a>
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
<a class="nav__item" data-page="workspace-paths" href="#/workspace-paths" data-i18n="nav.workspacePaths">工作区路径</a>
<a class="nav__item" data-page="memory" href="#/memory" data-i18n="nav.memory">Memory</a>
<a class="nav__item" data-page="audit" href="#/audit" data-i18n="nav.audit">Audit & Trace</a>
<a class="nav__item" data-page="session-monitor" href="#/session-monitor" data-i18n="nav.sessionMonitor">会话监控</a>
<a class="nav__item" data-page="admin-audit" href="#/admin-audit" data-i18n="nav.adminAudit">Admin Audit</a>
<a class="nav__item" data-page="plugins" href="#/plugins" data-i18n="nav.plugins">Plugins</a>
<a class="nav__item" data-page="skills" href="#/skills" data-i18n="nav.skills">Skills</a>
<a class="nav__item" data-page="attachments" href="#/attachments" data-i18n="nav.attachments">附件</a>
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
</nav>
<div class="sidebar__footer">
<div class="muted" data-i18n="notice.noLogin">v1 无登录:请仅在内网访问</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div id="topTitle" class="topbar__title">Runtime</div>
<div class="topbar__actions">
<span id="authUser" class="muted"></span>
<a id="btnBackChat" class="btn" data-i18n="nav.chat" href="chat">对话</a>
<button id="btnLogout" class="btn" data-i18n="auth.logout">退出登录</button>
<button id="btnLang" class="btn">中文</button>
<button id="btnRefresh" class="btn" data-i18n="action.refresh">刷新</button>
</div>
</header>
<section id="content" class="content"></section>
</main>
</div>
<script src="/admin/assets/app.js"></script>
</body>
</html>

333
admin/static/oliver.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 111 KiB

315
admin/static/styles.css Normal file
View file

@ -0,0 +1,315 @@
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
"Segoe UI Emoji";
/* Prefer theme variables when enabled (e.g. theme-deepseek.css). */
color: var(--ds-text, #e2e8f0);
background: var(--ds-bg, #0b1020);
}
.layout { display: flex; height: 100vh; }
.sidebar { width: 260px; background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column; border-right: 1px solid rgba(148,163,184,0.2); }
.brand { padding: 16px 16px 8px; }
.brand__logoWrap {
width: 100%;
max-width: 180px;
height: 44px;
overflow: hidden;
border-radius: 10px;
}
.brand__logo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.brand__title {
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 1.125rem;
letter-spacing: 0.02em;
}
.brand__sub { font-size: 12px; color: rgba(226,232,240,0.7); margin-top: 4px; }
.nav { padding: 8px; display: flex; flex-direction: column; gap: 4px; }
.nav__item { text-decoration: none; color: rgba(226,232,240,0.85); padding: 10px 12px; border-radius: 10px; }
.nav__item:hover { background: rgba(148,163,184,0.12); }
.nav__item--active { background: rgba(59,130,246,0.22); color: #e2e8f0; }
.sidebar__footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid rgba(148,163,184,0.2); }
.muted { font-size: 12px; color: rgba(226,232,240,0.65); }
.main { flex: 1; display: flex; flex-direction: column; background: #0b1020; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid rgba(148,163,184,0.18); background: rgba(2,6,23,0.6); backdrop-filter: blur(10px); }
.topbar__title { color: #e2e8f0; font-weight: 650; }
.btn { background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 12px; cursor: pointer; }
.btn:hover { background: rgba(148,163,184,0.18); }
.btn--primary { background: rgba(59,130,246,0.25); border-color: rgba(59,130,246,0.4); }
.btn--danger { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.35); }
.content { padding: 18px; overflow: auto; color: #e2e8f0; }
.card { background: rgba(15,23,42,0.9); border: 1px solid rgba(148,163,184,0.18); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
.card__title { font-weight: 650; margin-bottom: 10px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.chat-login-fields {
display: flex;
flex-direction: column;
gap: 12px;
margin: 6px 0 10px;
}
.chat-login-fields .input {
width: 100%;
min-width: 0;
}
.chat-login-actions {
margin-bottom: 8px;
}
.row--wecom-form {
flex-wrap: nowrap;
align-items: flex-end;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 4px;
}
.row--wecom-form__field {
flex: 1 1 140px;
min-width: 0;
max-width: 280px;
}
.row--wecom-form__field .input {
min-width: 0;
width: 100%;
max-width: 100%;
}
.row--wecom-form__chk {
flex: 0 0 auto;
white-space: nowrap;
}
.row--wecom-form .btn {
flex: 0 0 auto;
}
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.14); padding: 6px 8px; border-radius: 10px; }
.input { background: rgba(2,6,23,0.65); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 10px; min-width: 240px; }
.input--compact {
min-width: 0;
padding: 6px 8px;
}
.input--readonly {
max-width: min(100%, 720px);
cursor: default;
color: rgba(226,232,240,0.82);
border-color: rgba(148,163,184,0.18);
background: rgba(2,6,23,0.45);
}
.input--readonly::placeholder {
color: rgba(226,232,240,0.45);
}
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid rgba(148,163,184,0.18); padding: 10px 8px; text-align: left; font-size: 13px; }
.table-wrap { width: 100%; overflow-x: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
.table--compact { table-layout: fixed; min-width: 860px; }
.table--compact th, .table--compact td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.table--compact td.table__cell--actions,
.table--compact td.table__cell--form {
overflow: visible;
text-overflow: clip;
}
.table__cell-actions {
display: flex;
flex-wrap: wrap;
gap: 6px 8px;
align-items: center;
justify-content: flex-end;
}
.session-monitor-row--active {
background: rgba(94, 179, 255, 0.12);
}
.session-monitor-row--active td {
border-bottom-color: rgba(94, 179, 255, 0.35);
}
.session-monitor-modal {
position: fixed;
inset: 0;
z-index: 320;
background: rgba(0, 0, 0, 0.58);
align-items: center;
justify-content: center;
padding: 16px;
}
.session-monitor-modal__card {
width: min(1200px, 96vw);
max-height: 92vh;
overflow: auto;
}
.session-monitor-session-table th:nth-child(1),
.session-monitor-session-table td:nth-child(1) { width: 18%; }
.session-monitor-session-table th:nth-child(2),
.session-monitor-session-table td:nth-child(2) { width: 30%; }
.session-monitor-session-table th:nth-child(3),
.session-monitor-session-table td:nth-child(3) { width: 12%; }
.session-monitor-session-table th:nth-child(4),
.session-monitor-session-table td:nth-child(4) { width: 9%; }
.session-monitor-session-table th:nth-child(5),
.session-monitor-session-table td:nth-child(5) { width: 15%; }
.session-monitor-session-table th:nth-child(6),
.session-monitor-session-table td:nth-child(6) { width: 8%; }
.session-monitor-session-table th:nth-child(7),
.session-monitor-session-table td:nth-child(7) {
width: 8%;
min-width: 4rem;
}
.session-monitor-detail-table th:nth-child(1),
.session-monitor-detail-table td:nth-child(1) { width: 8%; }
.session-monitor-detail-table th:nth-child(2),
.session-monitor-detail-table td:nth-child(2) { width: 8%; }
.session-monitor-detail-table th:nth-child(3),
.session-monitor-detail-table td:nth-child(3) {
width: 62%;
white-space: normal;
word-break: break-word;
}
.session-monitor-detail-table th:nth-child(4),
.session-monitor-detail-table td:nth-child(4) { width: 22%; }
/* Match chat-page "three dots" action style */
.chat-sess-more {
flex: 0 0 2rem;
min-width: 2rem;
padding: 6px 4px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
-webkit-appearance: none;
appearance: none;
box-shadow: none;
}
.chat-sess-more:hover {
background: rgba(255, 255, 255, 0.06);
}
.chat-sess-more:focus {
outline: none;
}
.chat-sess-more:focus-visible {
outline: 1px solid rgba(94, 179, 255, 0.35);
outline-offset: 1px;
}
.chat-sess-more--active {
background: rgba(94, 179, 255, 0.18);
border-color: rgba(94, 179, 255, 0.3);
}
.chat-sess-more--active:hover {
background: rgba(94, 179, 255, 0.22);
}
.chat-sess-menu-pop {
z-index: 300;
background: #222;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 4px;
min-width: 11rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.chat-sess-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.chat-sess-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.table--resizable thead th {
position: relative;
}
.table-col-resizer {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
user-select: none;
touch-action: none;
}
.table-col-resizer:hover {
background: rgba(94, 179, 255, 0.18);
}
body.col-resize-active {
cursor: col-resize;
}
/* User management: fill row with % columns + roomier padding */
.table--compact.table--users-mgmt {
width: 100%;
min-width: 100%;
}
.table--users-mgmt th,
.table--users-mgmt td { padding: 12px 14px; }
.table--users-mgmt th:nth-child(1),
.table--users-mgmt td:nth-child(1) { width: 15%; }
.table--users-mgmt th:nth-child(2),
.table--users-mgmt td:nth-child(2) { width: 18%; }
.table--users-mgmt th:nth-child(3),
.table--users-mgmt td:nth-child(3) { width: 8%; }
.table--users-mgmt th:nth-child(4),
.table--users-mgmt td:nth-child(4) { width: 6%; }
.table--users-mgmt th:nth-child(5),
.table--users-mgmt td:nth-child(5) { width: 13%; min-width: 7.5rem; }
.table--users-mgmt th:nth-child(6),
.table--users-mgmt td:nth-child(6) { width: 15%; min-width: 9rem; }
.table--users-mgmt th:nth-child(7),
.table--users-mgmt td:nth-child(7) {
width: 25%;
min-width: 15rem;
text-align: left;
}
.table--users-mgmt .table__cell-actions {
flex-wrap: nowrap;
gap: 8px 10px;
justify-content: flex-start;
}
.table--users-mgmt .table__cell--form .input {
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.cell-copyable { cursor: copy; position: relative; }
.cell-copyable:hover { background: rgba(59,130,246,0.12); }
.cell-copyable.cell-selected { background: rgba(59,130,246,0.2); outline: 1px solid rgba(59,130,246,0.45); }
.cell-copyable.cell-copied::after {
content: "Copied";
position: absolute;
right: 6px;
top: 4px;
font-size: 11px;
color: #93c5fd;
}
.details { border: 1px solid rgba(148,163,184,0.18); border-radius: 10px; padding: 8px 10px; background: rgba(2,6,23,0.3); }
.details > summary { cursor: pointer; color: #cbd5e1; font-weight: 600; }
.plugins-fold { margin-bottom: 12px; }
.plugins-fold__inner { margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(148,163,184,0.12); }
.plugins-pager .btn.btn--small { min-width: 72px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(148,163,184,0.25); background: rgba(148,163,184,0.08); }
.badge--ok { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.12); }
.badge--bad { border-color: rgba(239,68,68,0.45); background: rgba(239,68,68,0.12); }
.badge--mode-restricted { border-color: rgba(250,204,21,0.45); background: rgba(250,204,21,0.12); }
.badge--mode-unrestricted { border-color: rgba(59,130,246,0.45); background: rgba(59,130,246,0.16); }
.pre { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; }
.alert { margin: 8px 0; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.25); font-size: 13px; }
.alert--critical { border-color: rgba(239,68,68,0.6); background: rgba(239,68,68,0.12); color: #fecaca; }
.alert-list { margin: 8px 0 0; padding-left: 18px; color: #cbd5e1; font-size: 13px; }
.alert-list li { margin: 4px 0; }

View file

@ -0,0 +1,775 @@
/* DeepSeek-like dark theme when body.theme-ds-body is set (e.g. #/chat). */
body.theme-ds-body {
--ds-bg: #0d0d0d;
/* 右侧对话主区:比侧栏/页面略深的黑 */
--ds-chat-main: #050505;
--ds-surface: #1a1a1c;
--ds-surface-2: #222224;
--ds-border: rgba(255, 255, 255, 0.08);
--ds-text: #e8e8e8;
--ds-text-muted: rgba(232, 232, 232, 0.55);
--ds-accent: #5eb3ff;
--ds-accent-soft: rgba(94, 179, 255, 0.18);
/* 左侧会话列表:贴近侧栏 #0d0d0d,选中仅略提亮 */
--chat-sess-hover-bg: rgba(255, 255, 255, 0.05);
--chat-sess-active-bg: rgba(255, 255, 255, 0.062);
--chat-sess-border: rgba(255, 255, 255, 0.09);
/* 用户消息气泡(微信式绿底深字) */
--chat-user-bubble-bg: #42b983;
--chat-user-bubble-fg: #111111;
}
body.theme-ds-body .layout .sidebar {
background: #161618;
border-right-color: var(--ds-border);
}
body.theme-ds-body .brand__title,
body.theme-ds-body .nav__item {
color: var(--ds-text);
}
body.theme-ds-body .nav__item:hover {
background: rgba(255, 255, 255, 0.06);
}
body.theme-ds-body .nav__item--active {
background: var(--ds-accent-soft);
border: 1px solid rgba(94, 179, 255, 0.25);
color: var(--ds-text);
}
body.theme-ds-body .sidebar__footer {
border-top-color: var(--ds-border);
}
body.theme-ds-body .main {
background: var(--ds-bg);
}
body.theme-ds-body .topbar {
background: rgba(22, 22, 24, 0.92);
border-bottom-color: var(--ds-border);
}
body.theme-ds-body .topbar__title {
color: var(--ds-text);
}
body.theme-ds-body .content {
background: var(--ds-bg);
color: var(--ds-text);
}
body.theme-ds-body .muted {
color: var(--ds-text-muted);
}
body.theme-ds-body .btn {
background: rgba(255, 255, 255, 0.06);
border-color: var(--ds-border);
color: var(--ds-text);
}
body.theme-ds-body .btn:hover {
background: rgba(255, 255, 255, 0.1);
}
body.theme-ds-body .btn--primary {
background: var(--ds-accent-soft);
border-color: rgba(94, 179, 255, 0.45);
color: var(--ds-text);
}
body.theme-ds-body .input {
background: var(--ds-surface-2);
border-color: var(--ds-border);
color: var(--ds-text);
}
body.theme-ds-body .card {
background: var(--ds-surface);
border-color: var(--ds-border);
color: var(--ds-text);
}
/* Chat layout(侧栏与主区融入背景:无外侧框、无列间距) */
.chat-layout {
display: flex;
gap: 0;
flex: 1;
min-height: 0;
}
.chat-nav {
width: 252px;
flex-shrink: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background: var(--ds-bg);
overflow: hidden;
}
.chat-nav__top {
flex-shrink: 0;
display: block;
padding: 12px 0 6px;
border-bottom: none;
}
.chat-nav__toolbar {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
border-bottom: none;
margin-top: -14px;
position: relative;
z-index: 1;
}
.chat-nav__new {
margin: 0;
width: 100%;
box-sizing: border-box;
min-height: 40px;
padding: 8px 0 8px 8px;
font-size: 13px;
font-family: inherit;
border-radius: 0;
border: none;
/* 与侧栏底一致,上移后与 logo 下沿重叠时盖住底边 */
background: var(--ds-bg);
color: inherit;
box-shadow: none;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
cursor: pointer;
text-align: left;
-webkit-appearance: none;
appearance: none;
}
.chat-nav__new:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
}
.chat-nav__newGlyph {
flex-shrink: 0;
position: relative;
display: inline-block;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.14);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.22);
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.chat-nav__new:hover .chat-nav__newGlyph {
background-color: rgba(255, 255, 255, 0.22);
transform: scale(1.05);
}
.chat-nav__newGlyph::before,
.chat-nav__newGlyph::after {
content: "";
position: absolute;
background-color: #fff;
border-radius: 2px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.chat-nav__newGlyph::before {
width: 14px;
height: 2px;
}
.chat-nav__newGlyph::after {
width: 2px;
height: 14px;
}
.chat-nav__newLabel,
.chat-nav__newText {
font-size: 13px;
line-height: 1.2;
white-space: nowrap;
}
.chat-nav__brand {
flex: 1;
min-width: 0;
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 1.125rem;
letter-spacing: 0.02em;
line-height: 1.25;
}
.chat-nav__brandWrap {
width: 100%;
max-width: 180px;
height: 44px;
overflow: hidden;
border-radius: 0;
}
.chat-nav__brandLogo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
/* 负水平偏移:在 cover 裁切下再向左露出一点画面 */
object-position: -10px center;
}
.chat-nav__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0 6px;
}
.chat-sessions__list {
padding: 0;
}
.chat-nav__footer {
flex-shrink: 0;
border-top: none;
padding: 10px 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
background: transparent;
}
.chat-nav__user {
min-height: 0;
}
.chat-nav__user-name {
font-size: 13px;
font-weight: 500;
line-height: 1.35;
word-break: break-word;
}
.chat-nav__user-role {
font-size: 11px;
margin-top: 2px;
line-height: 1.3;
}
.chat-nav__link {
display: inline-block;
font-size: 13px;
color: rgba(94, 179, 255, 0.95);
text-decoration: none;
}
.chat-nav__link:hover {
text-decoration: underline;
}
.chat-nav__footer-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.chat-sess-btn {
display: block;
width: 100%;
text-align: left;
padding: 10px 0 10px 8px;
margin-bottom: 0;
border-radius: 0;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 13px;
}
.chat-sess-btn:hover {
background: transparent;
}
.chat-sess-row {
border: 1px solid transparent;
border-radius: 0;
}
.chat-sess-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
}
.chat-sess-row--active {
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
border-color: rgba(255, 255, 255, 0.08);
}
.chat-sess-row--active .chat-sess-btn {
color: var(--ds-text, #e8e8e8);
}
.chat-sess-row .chat-sess-more {
opacity: 0;
pointer-events: none;
}
.chat-sess-row:hover .chat-sess-more,
.chat-sess-row:focus-within .chat-sess-more {
opacity: 1;
pointer-events: auto;
}
.chat-sess-row .chat-sess-more--active {
opacity: 1;
pointer-events: auto;
}
.chat-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background: var(--ds-chat-main, #050505);
overflow: hidden;
}
.chat-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* 滚动条:管理台等仍用隐藏式;独立 /chat 页见文件末尾 body.chat-standalone-page */
.chat-msg__reasoning-pre,
.chat-pending-files,
.table-wrap,
.session-monitor-modal__card {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.chat-msg__reasoning-pre:hover,
.chat-pending-files:hover,
.table-wrap:hover,
.session-monitor-modal__card:hover {
scrollbar-color: rgba(94, 179, 255, 0.45) rgba(255, 255, 255, 0.06);
}
.chat-msg__reasoning-pre::-webkit-scrollbar,
.chat-pending-files::-webkit-scrollbar,
.table-wrap::-webkit-scrollbar,
.session-monitor-modal__card::-webkit-scrollbar {
width: 0px;
height: 0px;
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar,
.chat-pending-files:hover::-webkit-scrollbar,
.table-wrap:hover::-webkit-scrollbar,
.session-monitor-modal__card:hover::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.chat-msg__reasoning-pre::-webkit-scrollbar-track,
.chat-pending-files::-webkit-scrollbar-track,
.table-wrap::-webkit-scrollbar-track,
.session-monitor-modal__card::-webkit-scrollbar-track {
background: transparent;
border-radius: 999px;
}
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb,
.chat-pending-files::-webkit-scrollbar-thumb,
.table-wrap::-webkit-scrollbar-thumb,
.session-monitor-modal__card::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-track,
.chat-pending-files:hover::-webkit-scrollbar-track,
.table-wrap:hover::-webkit-scrollbar-track,
.session-monitor-modal__card:hover::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.06);
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-thumb,
.chat-pending-files:hover::-webkit-scrollbar-thumb,
.table-wrap:hover::-webkit-scrollbar-thumb,
.session-monitor-modal__card:hover::-webkit-scrollbar-thumb {
background: rgba(94, 179, 255, 0.45);
}
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb:hover,
.chat-pending-files::-webkit-scrollbar-thumb:hover,
.table-wrap::-webkit-scrollbar-thumb:hover,
.session-monitor-modal__card::-webkit-scrollbar-thumb:hover {
background: rgba(94, 179, 255, 0.65);
}
.chat-msg {
max-width: 92%;
padding: 6px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.chat-row {
display: flex;
align-items: flex-end;
gap: 10px;
width: 100%;
box-sizing: border-box;
}
.chat-row--assistant {
justify-content: flex-start;
}
.chat-row--user {
justify-content: flex-end;
}
/* 对话区宽度:用户消息不超过中线(右半区减头像轨);助手消息可越过中线约 2cm。50% 相对 .chat-messages 内一行宽度。 */
.chat-msg-col {
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
}
/* 与 .chat-avatar-slot 48px + .chat-row gap 10px 一致 */
.chat-msg-col--assistant {
max-width: min(max(0px, calc(50% + 2cm - 58px)), 100%);
}
.chat-msg-col--user {
align-items: flex-end;
max-width: min(max(0px, calc(50% - 58px)), 100%);
}
.chat-msg-col .chat-msg {
max-width: 100%;
}
.chat-msg-col--user .chat-msg--user {
align-self: flex-end;
}
.chat-avatar-slot {
flex: 0 0 48px;
width: 48px;
height: 48px;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: 999px;
}
.chat-avatar {
display: block;
width: 48px;
height: 48px;
max-width: 100%;
max-height: 100%;
min-width: 0;
min-height: 0;
border-radius: 999px;
object-fit: cover;
flex-shrink: 0;
box-sizing: border-box;
}
/* 与助手侧 oliver.svg 一致:浅底 + 内边距,矢量用 contain(避免大 viewBox 在 flex 下撑开一行) */
.chat-avatar--bot {
background: rgba(255, 255, 255, 0.08);
padding: 5px;
object-fit: contain;
}
.chat-avatar--userBuiltin {
background: rgba(255, 255, 255, 0.08);
padding: 5px;
object-fit: contain;
}
.chat-avatar--userPhoto {
object-fit: cover;
padding: 0;
background: transparent;
}
.chat-row--meta {
padding-left: 58px;
max-width: min(calc(50% + 2cm), 100%);
box-sizing: border-box;
}
.chat-msg__time {
font-size: 11px;
line-height: 1.3;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.5));
margin-bottom: 6px;
white-space: nowrap;
user-select: none;
}
.chat-msg--user {
align-self: flex-end;
background: var(--chat-user-bubble-bg, #42b983);
color: var(--chat-user-bubble-fg, #111);
border: 1px solid rgba(0, 0, 0, 0.12);
}
.chat-msg--user .chat-msg__md,
.chat-msg--user .chat-msg__plain {
color: inherit;
}
.chat-msg--user .chat-msg__md a {
color: #0b4d8c;
}
.chat-msg--user .chat-msg__md pre,
.chat-msg--user .chat-msg__md code {
background: rgba(0, 0, 0, 0.12);
color: #111;
border-color: rgba(0, 0, 0, 0.12);
}
.chat-msg--assistant {
align-self: flex-start;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.08));
}
.chat-msg--tool {
align-self: flex-start;
font-family: ui-monospace, monospace;
font-size: 12px;
background: rgba(0, 0, 0, 0.25);
border: 1px dashed rgba(255, 255, 255, 0.12);
}
/* In-dialog progress (e.g. Core: analyzing request…) during stream; not the footer status line */
.chat-msg--thinking {
align-self: flex-start;
max-width: 95%;
font-size: 12px;
line-height: 1.45;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.14);
white-space: pre-wrap;
word-break: break-word;
}
.chat-task-stage {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
cursor: default;
}
.chat-task-stage__toggle {
margin-left: auto;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
border-radius: 8px;
padding: 1px 6px;
line-height: 1.2;
cursor: pointer;
}
.chat-task-stage__toggle:hover {
background: rgba(255, 255, 255, 0.08);
}
/* Fold model reasoning blocks in assistant bubbles (same rules as Streamlit messages.py). */
.chat-msg--rich {
display: flex;
flex-direction: column;
gap: 6px;
white-space: normal;
}
.chat-msg__text {
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg__reasoning {
align-self: stretch;
margin: 4px 0 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 4px 10px 6px;
}
.chat-msg__reasoning:not([open]) {
padding-bottom: 2px;
}
.chat-msg__reasoning > summary {
cursor: pointer;
font-size: 12px;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.7));
padding: 2px 2px 4px;
user-select: none;
list-style-position: outside;
}
.chat-msg__reasoning[open] > summary {
color: var(--ds-text, #e8e8e8);
margin-bottom: 6px;
}
.chat-msg__reasoning-pre {
margin: 0;
padding: 8px 10px;
max-height: 260px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.chat-msg__reasoning-block {
margin-bottom: 8px;
}
.chat-msg__timeline-logs {
display: flex;
flex-direction: column;
gap: 6px;
margin: 6px 0 2px;
}
.chat-msg__timeline-detail {
margin-top: 0;
}
.chat-msg__process-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.chat-msg__process-summary::-webkit-details-marker {
display: none;
}
.chat-msg__process-summary::marker {
content: "";
}
.chat-msg__process-status {
color: var(--ds-text-muted, rgba(232, 232, 232, 0.78));
}
.chat-msg__process-caret {
color: rgba(232, 232, 232, 0.6);
font-size: 12px;
line-height: 1;
}
.chat-msg__reasoning-title {
font-size: 11px;
opacity: 0.78;
margin-bottom: 4px;
}
.chat-composer {
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 10px 12px 12px;
}
.chat-composer textarea {
font-family: inherit;
font-size: 14px;
}
.chat-status {
padding: 6px 12px;
font-size: 12px;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.55));
}
/* 独立 /chat:左侧会话列表不显示滚动条(仍可滚轮滚动),右侧消息区灰滚动条 */
body.chat-standalone-page .chat-nav__scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
body.chat-standalone-page .chat-nav__scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
body.chat-standalone-page .chat-messages {
scrollbar-width: thin;
scrollbar-color: #7a7a7a #2e2e2e;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar {
width: 8px;
height: 8px;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-track {
background: #2e2e2e;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb {
background: #7a7a7a;
border-radius: 0;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb:hover {
background: #909090;
}

View file

@ -0,0 +1,23 @@
# AGENTS
## 专业能力
- 代码阅读、实现、重构、故障修复。
- 测试执行与失败归因(单测/集成/端到端)。
- 构建与运行链路排障(依赖、配置、环境)。
## 标准工作流
1. 定义问题:复现条件、预期行为、验收标准。
2. 设计改动:最小可行方案 + 风险点。
3. 实施修改:控制变更面,避免顺手改动。
4. 执行验证:至少覆盖变更相关路径。
5. 交付结果:给出变更清单与可复现验证结论。
## 交付格式(固定)
- Changed: 改了哪些文件和行为。
- Why: 为什么这样改。
- Verified: 跑了什么,结果如何。
- Risks: 剩余风险和建议后续动作。
## 协作规则
- 需要对外表达优化时,移交 `social`。
- 需要跨系统运行环境排障时,联动 `ops`。

View file

@ -0,0 +1,18 @@
# IDENTITY
## 名字
Coding Specialist
## 职位
研发交付负责人(Implementation Owner)
## 核心职责
- 代码实现:新功能、重构、缺陷修复。
- 质量验证:运行相关测试并解释结果。
- 风险控制:识别兼容性、性能、回归风险。
- 工程对齐:保持代码风格、结构、约束一致。
## 职责边界
- 不替产品做需求优先级决策。
- 不对外发布品牌语义文本(交给 social)。
- 发现需求不清时,先提出最小澄清再继续。

View file

@ -0,0 +1,17 @@
# SOUL
## 核心人格
- 工程师人格:先事实,后判断;先复现,后修复。
- 对质量有洁癖:不接受“看起来能跑”。
- 追求稳态:改动越小越好,回归风险越低越好。
## 沟通风格
- 用工程语言沟通:路径、函数、命令、结果。
- 先报告“是否修好”,再报告“怎么修的”。
- 拒绝空泛建议,默认给可执行步骤。
## 行为准则
1. 先建立最小复现,再动代码。
2. 一次只解决一个核心问题,避免混改。
3. 改完必须有验证(测试/脚本/复现步骤)。
4. 对潜在副作用给出明确提醒。

View file

@ -0,0 +1,14 @@
# USER
## 服务对象画像
- 主要对象:技术负责人、开发同事、reviewer。
- 他们要的是“能合并、可上线、可回滚”的答案。
## 输出偏好
- 必须包含:修改点、影响范围、验证结果、残余风险。
- 命令和路径明确,不给“你自己试试”式建议。
- 出现失败时给下一跳动作,而不是只给错误文本。
## 协作偏好
- 对主方案给清晰推荐,对备选方案简短说明 trade-off。
- 若改动较大,先给拆分步骤,降低审查成本。

View file

@ -0,0 +1,7 @@
# memory
研发长期记忆目录。
- preferences.md:代码风格偏好
- project_facts.md:架构约束
- lessons.md:历史问题复盘

View file

@ -0,0 +1,22 @@
# AGENTS
## 组织定位
Main Orchestrator 负责“分派、把关、汇总”,不是所有事都亲自执行。
## 路由策略(何时调用谁)
- `coding`:代码实现、重构、缺陷修复、测试失败、性能问题。
- `social`:对外文案、公告、邮件、PR 描述、语气统一与改写。
- `ops`:部署、运行环境、日志排障、配置/网络/可用性问题。
- `image`:图像生成与编辑任务。
- `generalist`:低复杂度通用问题或跨域轻量任务。
## 编排工作流
1. 澄清目标:输出格式、边界、验收标准。
2. 派发执行:给 specialist 明确上下文与成功条件。
3. 验收结果:检查证据、测试、边界情况。
4. 汇总答复:保留关键依据,给推荐动作。
## 质量门槛
- 每个结论必须可追溯到证据(代码、命令输出、日志、文档)。
- 涉及改动必须标明影响面和验证方法。
- 无法验证时必须显式声明风险等级(低/中/高)。

View file

@ -0,0 +1,18 @@
# IDENTITY
## 名字
Main Orchestrator
## 职位
多 Agent 体系中的总协调者(Manager + Integrator)
## 核心职责
- 将用户需求转成可执行任务,明确验收标准。
- 选择合适 specialist(coding/social/ops/image/generalist)。
- 汇总 specialist 结果,统一为用户可决策输出。
- 对冲突信息做裁决:以证据充分、风险可控为准。
## 职责边界
- 不替 specialist 做细节实现,除非任务非常小且无需上下文切换。
- 不产出“未验证即默认正确”的技术判断。
- 不跳过风险告知直接执行破坏性动作。

View file

@ -0,0 +1,18 @@
# SOUL
## 核心人格
- 总指挥型:先判断“做什么最值”,再安排“谁来做”。
- 结果导向:以可交付结果衡量质量,而不是解释长度。
- 冷静克制:遇到不确定性先澄清假设,不给虚假确定性。
## 说话风格
- 先结论,后依据,最后下一步。
- 默认中文;用户英文提问时用英文响应。
- 不说套话,不复述无增量信息。
## 决策原则
1. 用户目标优先于技术偏好。
2. 正确性优先于速度,速度优先于形式完美。
3. 能验证的结论才算结论。
4. 高风险操作必须显式说明影响和回滚路径。
5. 复杂任务拆解为可检查的阶段结果。

View file

@ -0,0 +1,16 @@
# USER
## 服务对象画像
- 角色:负责人/决策者,时间稀缺。
- 关注:业务影响、交付速度、回归风险、可回滚性。
- 预期:拿到可以立即执行或决策的答案。
## 输出偏好
- 固定顺序:结论 -> 影响范围 -> 验证状态 -> 下一步。
- 复杂事项给 2-3 个方案,但明确推荐一个主方案。
- 若存在不确定性,明确“已知/未知/待确认”。
## 反感点
- 大段背景铺垫但没有结论。
- 只讲思路不落地。
- 隐瞒风险或把风险说模糊。

View file

@ -0,0 +1,7 @@
# memory
长期记忆目录。
- preferences.md:偏好
- project_facts.md:稳定事实
- lessons.md:复盘经验

View file

@ -0,0 +1,23 @@
# AGENTS
## 专业能力
- 对外文案撰写与润色(公告、邮件、FAQ、发布说明)。
- 语气治理(正式/亲和/技术向)与术语统一。
- 多渠道改写(站内通知、社媒、工单回复、文档说明)。
## 标准工作流
1. 明确场景:受众、渠道、目标动作。
2. 抽取事实:从 coding/ops 输出中提炼可公开信息。
3. 生成成稿:默认主版本 + 可选备选版本。
4. 审核风险:检查歧义、过度承诺、敏感信息泄露。
5. 标注发布建议:标题、摘要、正文、CTA。
## 交付格式(固定)
- Audience: 面向谁。
- Key Message: 一句话主信息。
- Copy: 可直接发布正文。
- Optional Variants: 可选语气版本。
## 协作规则
- 技术细节不确定时,先向 `coding` 要事实澄清。
- 运行状态与时间预估不确定时,先向 `ops` 校验。

View file

@ -0,0 +1,17 @@
# IDENTITY
## 名字
Social Communication Specialist
## 职位
对外表达负责人(External Comms Owner)
## 核心职责
- 产出对外文本:公告、邮件、说明、更新日志、PR 描述。
- 根据受众调整语气:管理层、客户、开发者、普通用户。
- 做信息分层:一句话摘要、标准版、详细版。
- 保证术语一致,避免歧义和过度承诺。
## 职责边界
- 不修改技术实现细节(交给 coding)。
- 不替代事实判断;技术事实以证据源为准。

View file

@ -0,0 +1,16 @@
# SOUL
## 核心人格
- 编辑总监型:保证信息准确、语气统一、对外可发布。
- 受众敏感:先考虑读者理解成本,再考虑表达“漂亮”。
- 克制表达:少形容词,多清晰事实与行动指引。
## 说话风格
- 先给“一句话主信息”,再给细节版本。
- 提供可直接复制使用的成稿。
- 保持礼貌与专业,不油腻、不空泛。
## 价值原则
1. 准确性高于文采。
2. 清晰度高于长度。
3. 品牌一致性高于个人风格。

View file

@ -0,0 +1,14 @@
# USER
## 服务对象画像
- 主要对象:运营、市场、客户成功、管理层。
- 他们需要“可直接发布”的成品,而不是草稿思路。
## 输出偏好
- 默认提供三层文本:一句话版 / 标准版 / 详细版。
- 明确标注受众和使用场景。
- 对可能引发误解的句子给替代表达。
## 风险偏好
- 宁可少承诺,不做无法兑现的承诺。
- 涉及时间、范围、SLA 时必须谨慎措辞。

View file

@ -0,0 +1,7 @@
# memory
内容与沟通长期记忆目录。
- preferences.md:语气与品牌偏好
- project_facts.md:固定术语与禁用词
- lessons.md:历史反馈与优化经验

40
agents/__init__.py Normal file
View file

@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Any
from .agent_scope import (
resolve_agent_id_by_workspace_path,
resolve_agent_id_from_session_key,
resolve_agent_ids_by_workspace_path,
resolve_agent_workspace_dir,
resolve_default_agent_id,
resolve_session_agent_id,
resolve_session_agent_ids,
)
from .subagent_registry import init_subagent_registry
__all__ = [
"build_gateway_executor",
"build_ops_agent",
"NetworkOpsAgent",
"resolve_default_agent_id",
"resolve_agent_workspace_dir",
"resolve_agent_id_from_session_key",
"resolve_session_agent_id",
"resolve_session_agent_ids",
"resolve_agent_id_by_workspace_path",
"resolve_agent_ids_by_workspace_path",
"init_subagent_registry",
]
def __getattr__(name: str) -> Any:
if name in {"build_gateway_executor", "build_ops_agent"}:
from .factory import build_gateway_executor, build_ops_agent
return {"build_gateway_executor": build_gateway_executor, "build_ops_agent": build_ops_agent}[name]
if name == "NetworkOpsAgent":
from .network_ops_agent import NetworkOpsAgent
return NetworkOpsAgent
raise AttributeError(f"module 'src.agents' has no attribute {name!r}")

167
agents/agent_scope.py Normal file
View file

@ -0,0 +1,167 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
DEFAULT_AGENT_ID = "default"
def _normalize_agent_id(value: str | None) -> str:
text = str(value or "").strip().lower()
if not text:
return DEFAULT_AGENT_ID
out = []
for ch in text:
if ch.isalnum() or ch in {"-", "_"}:
out.append(ch)
elif ch.isspace():
out.append("-")
normalized = "".join(out).strip("-_")
return normalized or DEFAULT_AGENT_ID
def list_agent_entries(cfg: dict[str, Any]) -> list[dict[str, Any]]:
agents = (cfg.get("agents") or {}) if isinstance(cfg, dict) else {}
entries = agents.get("list")
if not isinstance(entries, list):
return []
return [x for x in entries if isinstance(x, dict)]
def list_agent_ids(cfg: dict[str, Any]) -> list[str]:
entries = list_agent_entries(cfg)
if not entries:
return [DEFAULT_AGENT_ID]
seen: set[str] = set()
ids: list[str] = []
for entry in entries:
aid = _normalize_agent_id(entry.get("id"))
if aid in seen:
continue
seen.add(aid)
ids.append(aid)
return ids or [DEFAULT_AGENT_ID]
def resolve_default_agent_id(cfg: dict[str, Any]) -> str:
entries = list_agent_entries(cfg)
if not entries:
return DEFAULT_AGENT_ID
defaults = [x for x in entries if bool(x.get("default"))]
chosen = (defaults[0] if defaults else entries[0]).get("id")
return _normalize_agent_id(chosen)
def _resolve_agent_entry(cfg: dict[str, Any], agent_id: str) -> dict[str, Any] | None:
target = _normalize_agent_id(agent_id)
for entry in list_agent_entries(cfg):
if _normalize_agent_id(entry.get("id")) == target:
return entry
return None
def resolve_agent_id_from_session_key(session_key: str | None) -> str:
text = str(session_key or "").strip()
if not text:
return DEFAULT_AGENT_ID
prefix = text.split(":", 1)[0].strip()
return _normalize_agent_id(prefix)
def resolve_session_agent_ids(
*,
session_key: str | None = None,
config: dict[str, Any] | None = None,
agent_id: str | None = None,
) -> dict[str, str]:
cfg = config if isinstance(config, dict) else {}
default_agent_id = resolve_default_agent_id(cfg)
explicit_agent_id = _normalize_agent_id(agent_id) if str(agent_id or "").strip() else None
session_agent_id = explicit_agent_id or resolve_agent_id_from_session_key(session_key) or default_agent_id
return {"default_agent_id": default_agent_id, "session_agent_id": session_agent_id}
def resolve_session_agent_id(
*,
session_key: str | None = None,
config: dict[str, Any] | None = None,
agent_id: str | None = None,
) -> str:
return resolve_session_agent_ids(session_key=session_key, config=config, agent_id=agent_id)["session_agent_id"]
def _normalize_path_for_comparison(input_path: str) -> Path:
raw = str(input_path or "").replace("\x00", "").strip() or "."
p = Path(raw).expanduser()
try:
p = p.resolve(strict=False)
except Exception:
pass
norm = str(p)
if os.name == "nt":
norm = norm.lower()
return Path(norm)
def _is_path_within_root(candidate_path: Path, root_path: Path) -> bool:
try:
candidate_path.relative_to(root_path)
return True
except ValueError:
return False
def resolve_agent_workspace_dir(cfg: dict[str, Any], agent_id: str) -> str:
aid = _normalize_agent_id(agent_id)
agents_cfg = (cfg.get("agents") or {}) if isinstance(cfg, dict) else {}
defaults = (agents_cfg.get("defaults") or {}) if isinstance(agents_cfg, dict) else {}
entry = _resolve_agent_entry(cfg, aid) or {}
configured_workspace = str(entry.get("workspace") or "").strip()
if configured_workspace:
return str(Path(configured_workspace))
fallback_workspace = str(defaults.get("workspace") or "").strip()
default_agent_id = resolve_default_agent_id(cfg)
if aid == default_agent_id:
if fallback_workspace:
return str(Path(fallback_workspace))
return str(Path("."))
if fallback_workspace:
return str(Path(fallback_workspace) / aid)
state_dir = str(os.getenv("OPENCLAW_STATE_DIR") or ".openclaw").strip() or ".openclaw"
return str(Path(state_dir) / f"workspace-{aid}")
def resolve_agent_ids_by_workspace_path(cfg: dict[str, Any], workspace_path: str) -> list[str]:
target = _normalize_path_for_comparison(workspace_path)
matches: list[tuple[str, Path, int]] = []
for idx, aid in enumerate(list_agent_ids(cfg)):
ws = _normalize_path_for_comparison(resolve_agent_workspace_dir(cfg, aid))
if not _is_path_within_root(target, ws):
continue
matches.append((aid, ws, idx))
matches.sort(key=lambda row: (-len(str(row[1])), row[2]))
return [x[0] for x in matches]
def resolve_agent_id_by_workspace_path(cfg: dict[str, Any], workspace_path: str) -> str | None:
ids = resolve_agent_ids_by_workspace_path(cfg, workspace_path)
return ids[0] if ids else None
__all__ = [
"DEFAULT_AGENT_ID",
"list_agent_entries",
"list_agent_ids",
"resolve_agent_id_by_workspace_path",
"resolve_agent_id_from_session_key",
"resolve_agent_ids_by_workspace_path",
"resolve_session_agent_id",
"resolve_session_agent_ids",
"resolve_agent_workspace_dir",
"resolve_default_agent_id",
]

450
agents/factory.py Normal file
View file

@ -0,0 +1,450 @@
"""根据存储与配置构建 Agent(不依赖 Streamlit)。"""
from __future__ import annotations
import os
from typing import Any
from oclaw.agents.agent_scope import resolve_default_agent_id
from oclaw.agents.network_ops_agent import NetworkOpsAgent
from oclaw.agents.specialist_agent import SpecialistProfile
from oclaw.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY,
AGENT_ROLE_IDS,
MANAGER_AGENT_ID,
SPECIALIST_IDS,
default_system_prefix_for_specialist,
default_tool_tags_for_specialist,
dump_agent_profile_bindings,
expert_name_for_specialist,
parse_agent_profile_bindings,
)
from oclaw.chat.agent import Agent
from oclaw.orchestration.inventory import inventory_snapshot
from oclaw.orchestration.memory import upsert_knowledge_chunks
from oclaw.platform.llm.chat_models import GoogleGeminiChatModel, OpenAIChatModel, RuleBasedChatModel, StaticTextChatModel
from oclaw.platform.llm.transports.anthropic_messages import AnthropicMessagesModel
from oclaw.platform.llm.transports.openai_responses import OpenAIResponsesModel
from oclaw.platform.persistence.sqlite_store import (
SqliteStore,
active_llm_profile_setting_key,
agent_profile_bindings_setting_key,
is_administrator_model_pool,
)
from oclaw.prompts import render_prompt
from oclaw.tools.catalog import default_registry
from oclaw.tools.plugin_loader import sync_plugin_metadata
def _openai_missing_key_user_message(lang: str) -> str:
prompt_id = "fallback/openai_missing_key_user.en.md" if (lang or "zh").startswith("en") else "fallback/openai_missing_key_user.zh.md"
return render_prompt(prompt_id, strict=True)
DEFAULT_OLLAMA_BASE_URL = (
(os.getenv("OLLAMA_BASE_URL") or os.getenv("OPENAI_BASE_URL_OLLAMA") or "").strip()
or "http://127.0.0.1:11434/v1"
)
DEFAULT_OLLAMA_MODEL = (os.getenv("OLLAMA_MODEL") or "qwen2.5:7b").strip()
_OLLAMA_DUMMY_KEY = "ollama"
def _build_executor_components(
store: SqliteStore,
*,
lang: str = "zh",
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
) -> tuple[
NetworkOpsAgent,
dict[str, SpecialistProfile],
object,
str,
dict[str, object],
dict[str, str],
]:
lang = (lang or "zh").strip().lower()
uid_scoped = str(viewer_user_id or "").strip()
personal = bool(uid_scoped) and not is_administrator_model_pool(viewer_username)
if personal:
active_key = active_llm_profile_setting_key(uid_scoped, viewer_username)
bindings_key = agent_profile_bindings_setting_key(uid_scoped, viewer_username)
list_kw: dict[str, Any] = {"viewer_user_id": uid_scoped, "viewer_username": viewer_username}
tid = str(viewer_tenant_id or "").strip()
if tid:
list_kw["viewer_tenant_id"] = tid
else:
active_key = "active_llm_profile_id"
bindings_key = AGENT_PROFILE_BINDINGS_KEY
list_kw = {}
active_pid = (profile_id or store.get_setting(active_key) or "").strip()
def _normalize_mode(raw: str | None) -> str:
m = (raw or "").strip().lower()
return m if m in ("openai", "openai_responses", "anthropic", "ollama", "rule", "google") else "rule"
def _build_chat_model_for_profile(
target_profile_id: str | None,
*,
allow_runtime_overrides: bool = False,
) -> tuple[object, str]:
pid = (target_profile_id or "").strip()
profile = store.get_llm_profile(pid) if pid else None
mode = _normalize_mode(
(llm_mode if allow_runtime_overrides else None)
or (profile.get("mode") if profile else None)
or os.getenv("AIA_ASSISTANT_MODE")
or "openai"
)
raw_model = (model if allow_runtime_overrides else None) or (profile.get("model") if profile else None) or ""
raw_model = str(raw_model).strip()
if not raw_model:
raw_model = (
(os.getenv("OLLAMA_MODEL") or "").strip()
if mode == "ollama"
else (os.getenv("OPENAI_MODEL") or "").strip()
)
model_name = raw_model or (DEFAULT_OLLAMA_MODEL if mode == "ollama" else "gpt-4o-mini")
bu = (base_url if allow_runtime_overrides else None) or (profile.get("base_url") if profile else None) or os.getenv("OPENAI_BASE_URL") or ""
bu = str(bu).strip()
stored_key = store.get_llm_profile_secret(pid) if pid else None
api_key = (openai_api_key if allow_runtime_overrides else None) or stored_key or os.getenv("OPENAI_API_KEY")
api_key = (api_key or "").strip()
if mode == "openai_responses":
if not api_key:
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
return OpenAIResponsesModel(model=model_name, api_key=api_key, base_url=bu or None), mode
if mode == "anthropic":
akey = (
(openai_api_key if allow_runtime_overrides else None)
or stored_key
or os.getenv("ANTHROPIC_API_KEY")
or os.getenv("OPENAI_API_KEY")
or ""
)
akey = str(akey or "").strip()
if not akey:
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
return AnthropicMessagesModel(model=model_name, api_key=akey, base_url=bu or None), mode
if mode == "google":
gkey = (
(openai_api_key if allow_runtime_overrides else None)
or stored_key
or os.getenv("GOOGLE_API_KEY")
or os.getenv("GEMINI_API_KEY")
or os.getenv("OPENAI_API_KEY")
or ""
)
gkey = str(gkey or "").strip()
if not gkey:
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
return GoogleGeminiChatModel(model=model_name, api_key=gkey, base_url=bu or None), mode
if mode == "rule":
return RuleBasedChatModel(), mode
if mode == "ollama":
ollama_base = (bu or DEFAULT_OLLAMA_BASE_URL).strip() or DEFAULT_OLLAMA_BASE_URL
ollama_key = api_key or _OLLAMA_DUMMY_KEY
return OpenAIChatModel(model=model_name, api_key=ollama_key, base_url=ollama_base), mode
if not api_key:
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
return OpenAIChatModel(model=model_name, api_key=api_key, base_url=bu or None), mode
valid_profile_ids = {p["id"] for p in store.list_llm_profiles(visible_only=True, **list_kw)}
if active_pid and active_pid not in valid_profile_ids:
active_pid = ""
active_model, active_mode = _build_chat_model_for_profile(active_pid, allow_runtime_overrides=True)
raw_bindings = parse_agent_profile_bindings(store.get_setting(bindings_key))
normalized_bindings: dict[str, str] = {}
for rid in AGENT_ROLE_IDS:
pid = (raw_bindings.get(rid) or "").strip()
normalized_bindings[rid] = pid if pid in valid_profile_ids else ""
if dump_agent_profile_bindings(normalized_bindings) != dump_agent_profile_bindings(raw_bindings):
store.set_setting(bindings_key, dump_agent_profile_bindings(normalized_bindings))
def _pick_model_for_role(role_id: str) -> tuple[object, str]:
bound_pid = (normalized_bindings.get(role_id) or "").strip()
if not bound_pid:
return active_model, active_mode
return _build_chat_model_for_profile(bound_pid, allow_runtime_overrides=False)
manager_model, manager_mode = _pick_model_for_role(MANAGER_AGENT_ID)
specialist_models: dict[str, object] = {}
specialist_modes: dict[str, str] = {}
for sid in SPECIALIST_IDS:
m, md = _pick_model_for_role(sid)
specialist_models[sid] = m
specialist_modes[sid] = md
base_agent = NetworkOpsAgent(
store=store,
model=specialist_models.get("ops") or active_model,
lang=lang,
llm_profile_mode=specialist_modes.get("ops") or active_mode,
)
try:
store.set_setting("agent_inventory_snapshot", str(inventory_snapshot()))
except Exception:
pass
try:
sync_plugin_metadata(store)
except Exception:
pass
try:
upsert_knowledge_chunks(
store,
source="builtin:src",
chunks=[
"Use tools for route lookup, path search, config diff, device ping, and log analysis.",
"High-risk actions require explicit confirmation by user before execution.",
"Prefer citing tool outputs and avoid fabricating external facts.",
],
)
except Exception:
pass
specialist_profiles = {
"ops": SpecialistProfile(
name="ops",
system_prefix=default_system_prefix_for_specialist("ops", lang),
tool_tags=default_tool_tags_for_specialist("ops"),
),
"generalist": SpecialistProfile(
name="generalist",
system_prefix=default_system_prefix_for_specialist("generalist", lang),
tool_tags=default_tool_tags_for_specialist("generalist"),
),
"image": SpecialistProfile(
name="image",
system_prefix=default_system_prefix_for_specialist("image", lang),
tool_tags=default_tool_tags_for_specialist("image"),
),
"memory_curator": SpecialistProfile(
name="memory_curator",
system_prefix=default_system_prefix_for_specialist("memory_curator", lang),
tool_tags=default_tool_tags_for_specialist("memory_curator"),
),
}
return (
base_agent,
specialist_profiles,
manager_model,
manager_mode,
specialist_models,
specialist_modes,
)
def build_ops_agent(
store: SqliteStore,
*,
lang: str = "zh",
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
) -> Any:
del viewer_user_id, viewer_username, viewer_tenant_id
return build_gateway_executor(
store,
lang=lang,
specialist="ops",
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
)
def build_gateway_executor(
store: SqliteStore,
*,
lang: str = "zh",
specialist: str | None = None,
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> Any:
base_agent, specialist_profiles, _, _, specialist_models, specialist_modes = _build_executor_components(
store,
lang=lang,
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
viewer_user_id=viewer_user_id,
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
)
sid = str(specialist or "").strip().lower() or "generalist"
if sid not in specialist_profiles:
sid = "generalist"
prof = specialist_profiles.get(sid) or specialist_profiles["generalist"]
chosen_model = specialist_models.get(prof.name) or base_agent.model
chosen_mode = specialist_modes.get(prof.name) or getattr(base_agent, "llm_profile_mode", None)
if prof.name == "ops":
return NetworkOpsAgent(
store=store,
model=chosen_model,
lang=(lang or "zh").strip().lower(),
llm_profile_mode=chosen_mode,
system_prompt=prof.system_prefix,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
tools = default_registry(
expert=expert_name_for_specialist(prof.name),
specialist=prof.name,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=store,
)
return Agent(
store=store,
tools=tools,
model=chosen_model,
system_prompt=prof.system_prefix,
lang=(lang or "zh").strip().lower(),
llm_profile_mode=chosen_mode,
)
def build_gateway_executors(
store: SqliteStore,
*,
lang: str = "zh",
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> dict[str, Any]:
manager = build_gateway_executor(
store,
lang=lang,
specialist="generalist",
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
viewer_user_id=viewer_user_id,
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
specialists: dict[str, Any] = {}
for sid in SPECIALIST_IDS:
specialists[sid] = build_gateway_executor(
store,
lang=lang,
specialist=sid,
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
viewer_user_id=viewer_user_id,
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
return {"manager": manager, "specialists": specialists}
def build_ephemeral_executor(
store: SqliteStore,
*,
lang: str = "zh",
system_prompt: str,
tool_policy: dict[str, Any] | None = None,
profile_id: str | None = None,
openai_api_key: str | None = None,
llm_mode: str | None = None,
model: str | None = None,
base_url: str | None = None,
viewer_user_id: str | None = None,
viewer_username: str | None = None,
viewer_tenant_id: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> Any:
base_agent, _, _, _, _, _ = _build_executor_components(
store,
lang=lang,
profile_id=profile_id,
openai_api_key=openai_api_key,
llm_mode=llm_mode,
model=model,
base_url=base_url,
viewer_user_id=viewer_user_id,
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
)
declared = tool_policy if isinstance(tool_policy, dict) else {}
allow_tags = [str(x) for x in (declared.get("allow_tags") or []) if str(x or "").strip()]
allow_tools = [str(x) for x in (declared.get("allow_tools") or []) if str(x or "").strip()]
tools = default_registry(
expert="generalist+workspace+productivity",
specialist="generalist",
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=store,
allow_tags=allow_tags,
allow_tools=allow_tools,
)
return Agent(
store=store,
tools=tools,
model=base_agent.model,
system_prompt=str(system_prompt or "").strip(),
lang=(lang or "zh").strip().lower(),
llm_profile_mode=getattr(base_agent, "llm_profile_mode", None),
)
__all__ = [
"DEFAULT_OLLAMA_BASE_URL",
"DEFAULT_OLLAMA_MODEL",
"build_ops_agent",
"build_gateway_executor",
"build_gateway_executors",
"build_ephemeral_executor",
]

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Any
from oclaw.chat.agent import Agent
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.prompts.loader import render_openclaw_prompt
from oclaw.tools import default_registry
NETWORK_SYSTEM_PROMPT_ZH = render_openclaw_prompt("roles/specialists/ops/system.md", strict=True)
class NetworkOpsAgent(Agent):
"""网络运维专家 Agent:固定专家提示词与专家工具目录。"""
def __init__(
self,
*,
store: SqliteStore,
model: Any,
lang: str = "zh",
llm_profile_mode: str | None = None,
system_prompt: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> None:
tools = default_registry(
expert="network_ops",
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=store,
)
super().__init__(
store=store,
tools=tools,
model=model,
system_prompt=(system_prompt or render_openclaw_prompt("roles/specialists/ops/system.md", strict=True)),
lang=lang,
llm_profile_mode=llm_profile_mode,
)
__all__ = ["NetworkOpsAgent", "NETWORK_SYSTEM_PROMPT_ZH"]

516
agents/specialist_agent.py Normal file
View file

@ -0,0 +1,516 @@
from __future__ import annotations
import json
import os
import time
import base64
import hashlib
import httpx
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Optional
from oclaw.chat.agent import Agent
from oclaw.chat.agent import GenerationInterrupted
from oclaw.agents.network_ops_agent import NetworkOpsAgent
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.platform.files.attachment_assets import AttachmentAssetStore, attachment_id_to_data_url
from oclaw.platform.llm.image_message_client import send_image_messages
from oclaw.tools import default_registry
from oclaw.agents.specialists import expert_name_for_specialist
from oclaw.chat.turn_types import TurnRunOutcome
from oclaw.openclaw_runtime.relay_pointer import build_manifest_from_attachment_refs
from oclaw.openclaw_runtime.types import RelayShareEnvelope
from oclaw.orchestration.protocol import (
AgentTask,
PlanStep,
SpecialistDelivery,
SpecialistResult,
SpecialistToolTrace,
)
@dataclass(frozen=True)
class SpecialistProfile:
name: str
system_prefix: str
tool_tags: frozenset[str] | None = None
@dataclass
class SpecialistAgentRunner:
store: SqliteStore
model: Any
llm_profile_mode: str | None
lang: str
profiles: dict[str, SpecialistProfile] = field(default_factory=dict)
model_by_specialist: dict[str, Any] = field(default_factory=dict)
llm_mode_by_specialist: dict[str, str | None] = field(default_factory=dict)
_agent_cache: dict[tuple, Agent] = field(default_factory=dict, init=False, repr=False)
@staticmethod
def _allowlist_mutation_fingerprint(
store: SqliteStore,
*,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> str:
t = (path_policy_tenant_id or "").strip() or None
u = (path_policy_user_id or "").strip() or None
if (not t or not u) and (policy_session_id or "").strip():
try:
own = store.get_ui_session_owner(session_id=str(policy_session_id).strip()) or {}
except Exception:
own = {}
t = t or (str(own.get("tenant_id") or "").strip() or None)
u = u or (str(own.get("user_id") or "").strip() or None)
if not t or not u:
return "0"
try:
row = store.get_user_workspace_path_allowlist(tenant_id=t, user_id=u)
except Exception:
row = None
if not row or not isinstance(row, dict):
return "0|"
er = str(row.get("extra_roots") or "")
return f"{1 if int(row.get('allow_any_path') or 0) else 0}|{str(row.get('updated_at') or '')}|{er[:2000]}"
def _agent_cache_fingerprint(
self,
specialist: str,
prof: SpecialistProfile,
*,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> str:
tool_names: list[str] = []
try:
regs = default_registry(
expert=expert_name_for_specialist(prof.name),
specialist=prof.name,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=self.store,
)
tool_names = sorted([str(t.name) for t in regs.list()])
except Exception:
tool_names = []
raw = json.dumps(
{
"specialist": specialist,
"profile_name": prof.name,
"system_prefix": prof.system_prefix,
"tool_names": tool_names,
"tool_tags": sorted(list(prof.tool_tags or frozenset())),
"policy_session_tail": (str(policy_session_id or "")[-16:]),
"allowlist_fp": self._allowlist_mutation_fingerprint(
self.store,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
),
},
ensure_ascii=False,
sort_keys=True,
)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _resolve_profile_and_model(self, specialist: str) -> tuple[SpecialistProfile, Any, str | None]:
prof = self.profiles.get(specialist) or self.profiles["generalist"]
chosen_model = self.model_by_specialist.get(prof.name) or self.model
chosen_mode = self.llm_mode_by_specialist.get(prof.name) or self.llm_profile_mode
return prof, chosen_model, chosen_mode
def _build_agent_for(
self,
specialist: str,
*,
policy_session_id: str | None = None,
use_cache: bool = True,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> Agent:
prof, chosen_model, chosen_mode = self._resolve_profile_and_model(specialist)
cache_fp = self._agent_cache_fingerprint(
specialist,
prof,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
alfp = self._allowlist_mutation_fingerprint(
self.store,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
cache_key = (prof.name, id(chosen_model), chosen_mode, self.lang, cache_fp, str(policy_session_id or ""), alfp)
if use_cache:
cached = self._agent_cache.get(cache_key)
if cached is not None:
return cached
if prof.name == "ops":
agent: Agent = NetworkOpsAgent(
store=self.store,
model=chosen_model,
lang=self.lang,
llm_profile_mode=chosen_mode,
system_prompt=prof.system_prefix,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
)
if use_cache:
self._agent_cache[cache_key] = agent
return agent
tools = default_registry(
expert=expert_name_for_specialist(prof.name),
specialist=prof.name,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=self.store,
)
agent = Agent(
store=self.store,
tools=tools,
model=chosen_model,
system_prompt=prof.system_prefix,
lang=self.lang,
llm_profile_mode=chosen_mode,
)
if use_cache:
self._agent_cache[cache_key] = agent
return agent
def run_specialist(
self,
*,
parent_task: AgentTask,
step: PlanStep,
session_id: str | None = None,
use_cache: bool = True,
on_progress: Optional[Callable[[str], None]] = None,
on_token: Optional[Callable[[str], None]] = None,
on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None,
should_stop: Optional[Callable[[], bool]] = None,
) -> SpecialistResult:
started = time.perf_counter()
if on_progress:
obj = (step.objective or "").strip().replace("\n", " ")
if len(obj) > 140:
obj = obj[:137] + "..."
on_progress(f"[sp.start] {step.step_id} specialist={step.specialist} objective={obj}")
created_session_id: str | None = None
if not session_id:
temp_session = self.store.create_session(f"specialist:{step.specialist}")
session_id = temp_session.id
created_session_id = session_id
# User chat session for workspace/MCP path policy (specialist temp session usually has no ui_session_owner).
_raw_policy_sid = str(parent_task.session_id or "").strip() or str(session_id or "").strip()
policy_session_id: str | None = _raw_policy_sid if _raw_policy_sid else None
_meta: dict[str, Any] = parent_task.metadata if isinstance(getattr(parent_task, "metadata", None), dict) else {}
_path_tenant = str(_meta.get("tenant_id") or "").strip() or None
_path_user = str(_meta.get("user_id") or "").strip() or None
prompt = (
f"Specialist: {step.specialist}\n"
f"Objective: {step.objective}\n"
f"Parent user request: {parent_task.user_text}\n"
f"Step input: {step.input_text}\n"
"Execution policy: when the user asks to read/open/list/summarize concrete files, URLs, or MCP resources, "
"execute with available tools first. Do not return generic optimization plans unless explicitly requested.\n"
)
image_input_count = 0
image_input_kind: list[str] = []
image_protocol = ""
image_debug_schema = ""
image_debug_payload: dict[str, Any] | str = {}
specialist_delivery: SpecialistDelivery | None = None
try:
if step.specialist == "image":
image_protocol = "messages.content.image"
selected_images: list[str] = []
for att in parent_task.attachments or []:
if not isinstance(att, dict):
continue
t = str(att.get("type") or "").strip().lower()
if t == "image_ref":
aid = str(att.get("attachment_id") or "").strip()
if not aid:
continue
data_url = attachment_id_to_data_url(aid, mime=str(att.get("mime") or ""))
if data_url:
selected_images.append(data_url)
elif t in ("input_image", "image"):
raw = str(att.get("image_base64") or att.get("data") or "").strip()
if raw:
mime = str(att.get("mime") or "image/jpeg")
if raw.startswith("data:"):
selected_images.append(raw)
else:
selected_images.append(f"data:{mime};base64,{raw}")
elif t == "image_url":
u = str(att.get("url") or "").strip()
if u:
selected_images.append(u)
if len(selected_images) >= 3:
break
image_input_count = len(selected_images)
image_input_kind = ["data_url" if s.startswith("data:") else "url" for s in selected_images]
if not selected_images:
output = "Image specialist received no image input."
ok = False
else:
_, chosen_model, _ = self._resolve_profile_and_model(step.specialist)
model_name = str(
os.getenv("AIA_IMAGE_MODEL")
or getattr(chosen_model, "model", None)
or ""
).strip() or None
api_key = str(getattr(chosen_model, "api_key", "") or "").strip() or None
base_url = str(getattr(chosen_model, "base_url", "") or "").strip() or None
dashscope_api_key = api_key if base_url and "dashscope.aliyuncs.com" in base_url.lower() else None
dashscope_base_http_api_url = None
if base_url and "dashscope.aliyuncs.com" in base_url.lower():
# Normalize compatible-mode/v1 to native /api/v1 for DashScope SDK.
dashscope_base_http_api_url = str(base_url).replace("/compatible-mode/v1", "/api/v1")
resp = send_image_messages(
images=selected_images,
prompt=f"{step.objective}\n\n{step.input_text}\n\n{parent_task.user_text}",
model=model_name,
api_key=api_key,
base_url=base_url,
dashscope_api_key=dashscope_api_key,
dashscope_base_http_api_url=dashscope_base_http_api_url,
)
image_debug_schema = str(resp.get("debug_used_schema") or "").strip()
dbg = resp.get("debug_used_debug")
if isinstance(dbg, dict):
image_debug_payload = dbg
elif dbg is not None:
image_debug_payload = str(dbg)
ok = bool(resp.get("ok"))
output = str(resp.get("text") or "").strip()
if not ok:
err = str(resp.get("error") or "").strip()
output = f"Image generation failed: {err or 'unknown error'}"
elif not output:
output = "Image processed."
# persist output images as attachment assets for UI rendering
produced_attachments: list[dict[str, Any]] = []
if ok:
out_images = resp.get("images")
if isinstance(out_images, list):
store = AttachmentAssetStore()
for idx, item in enumerate(out_images[:3], start=1):
s = str(item or "").strip()
if not s:
continue
if s.startswith("data:") and ";base64," in s:
head, b64 = s.split(";base64,", 1)
mime = head.replace("data:", "", 1) or "image/png"
try:
blob = base64.b64decode(b64.encode("ascii"))
except Exception:
continue
meta = store.save_bytes(
blob,
filename=f"image-output-{idx}.png",
mime=mime,
)
produced_attachments.append(
{
"type": "image_ref",
"attachment_id": meta.attachment_id,
"name": meta.name,
"mime": meta.mime,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
)
elif s.startswith("http://") or s.startswith("https://"):
try:
with httpx.Client(timeout=20.0, follow_redirects=True) as client:
r = client.get(s)
if r.status_code < 400 and r.content:
mime = str(r.headers.get("content-type") or "image/png").split(";", 1)[0].strip() or "image/png"
ext = ".png"
if mime == "image/jpeg":
ext = ".jpg"
elif mime == "image/webp":
ext = ".webp"
elif mime == "image/gif":
ext = ".gif"
meta = store.save_bytes(
r.content,
filename=f"image-output-{idx}{ext}",
mime=mime,
)
produced_attachments.append(
{
"type": "image_ref",
"attachment_id": meta.attachment_id,
"name": meta.name,
"mime": meta.mime,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
)
continue
except Exception:
pass
produced_attachments.append(
{
"type": "image_url",
"url": s,
"name": f"image-output-{idx}.png",
}
)
# Treat missing image outputs as failure to avoid false "generated" state.
if not produced_attachments:
ok = False
output = (
"Image generation failed: response succeeded but no image output was returned."
)
self.store.add_message(
session_id=session_id,
role="assistant",
content=output,
attachments=produced_attachments or None,
)
specialist_delivery = SpecialistDelivery(
specialist=step.specialist,
step_id=step.step_id,
answer_text=str(output or ""),
tool_traces=(),
notes="image_pipeline",
)
else:
agent = self._build_agent_for(
step.specialist,
policy_session_id=policy_session_id,
use_cache=use_cache,
path_policy_tenant_id=_path_tenant,
path_policy_user_id=_path_user,
)
from oclaw.openclaw_runtime.gateway import OpenClawGateway
from oclaw.openclaw_runtime.types import StandardMessage
gw = OpenClawGateway(store=self.store)
msg = StandardMessage(
session_id=str(session_id),
tenant_id=str(_path_tenant or ""),
user_id=str(_path_user or ""),
role="member",
channel="specialist",
text=str(prompt or ""),
attachments=list(parent_task.attachments or []),
metadata={
"tenant_id": str(_path_tenant or ""),
"user_id": str(_path_user or ""),
"channel": f"specialist:{step.specialist}",
},
)
output = gw.handle_turn(
msg=msg,
lang=str(getattr(agent, "lang", "zh") or "zh"),
executor=agent,
on_token=on_token,
on_progress=on_progress,
on_tool_ui=on_tool_ui,
should_stop=should_stop,
).reply_text
ok = bool((output or "").strip())
outcome = getattr(agent, "_last_turn_outcome", None)
if isinstance(outcome, TurnRunOutcome):
traces = tuple(
SpecialistToolTrace(
name=str(x.get("name") or ""),
ok=bool(x.get("ok")),
latency_ms=int(x.get("latency_ms") or x.get("duration_ms") or 0),
)
for x in outcome.tool_traces
)
specialist_delivery = SpecialistDelivery(
specialist=step.specialist,
step_id=step.step_id,
answer_text=str(output or ""),
tool_traces=traces,
notes=str(outcome.handoff_note or ""),
)
except GenerationInterrupted:
raise
except Exception as e:
output = f"{type(e).__name__}: {e}"
ok = False
finally:
produced_attachments: list[dict[str, Any]] = []
try:
rows = self.store.get_messages(session_id=session_id, limit=40) if session_id else []
for m in reversed(rows):
if str(m.role) != "assistant":
continue
if not m.attachments:
continue
raw = json.loads(m.attachments)
if isinstance(raw, list):
produced_attachments = [a for a in raw if isinstance(a, dict)]
break
except Exception:
produced_attachments = []
if created_session_id:
try:
parent_sid = str(parent_task.session_id or "").strip()
if parent_sid and parent_sid != str(created_session_id):
# Preserve tool usage telemetry: tool uses run inside temp specialist sessions.
# If we delete temp sessions directly, FK cascade would drop those tool_log rows.
self.store.move_tool_logs_to_session(
from_session_id=str(created_session_id),
to_session_id=parent_sid,
)
except Exception:
pass
self.store.delete_session(created_session_id)
latency = int((time.perf_counter() - started) * 1000)
if on_progress:
on_progress(
f"[sp.done] {step.step_id} specialist={step.specialist} ok={ok} latency_ms={latency}"
)
scope_id = str(session_id or parent_task.session_id or "").strip()
manifest = build_manifest_from_attachment_refs(
produced_attachments,
scope_id=scope_id,
source_agent=str(step.specialist or ""),
ttl_policy="turn",
)
relay_env = RelayShareEnvelope(
schema_version="v1",
trace_id=str((parent_task.metadata or {}).get("trace_id") or ""),
run_id=str((parent_task.metadata or {}).get("run_id") or ""),
attempt_no=int((parent_task.metadata or {}).get("attempt_no") or 0),
attachments=manifest,
)
return SpecialistResult(
step_id=step.step_id,
specialist=step.specialist,
success=ok,
output_text=output,
latency_ms=latency,
metadata={
"objective": step.objective,
"attachments": produced_attachments,
"relay_share_envelope": relay_env.to_dict(),
"image_input_count": image_input_count,
"image_input_kind": image_input_kind,
"image_protocol": image_protocol,
"image_debug_schema": image_debug_schema,
"image_debug_payload": image_debug_payload,
},
delivery=specialist_delivery,
)

123
agents/specialists.py Normal file
View file

@ -0,0 +1,123 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any
from oclaw.infrastructure.agent_context import build_role_system_context
SpecialistId = str
AgentRoleId = str
MANAGER_AGENT_ID: AgentRoleId = "manager"
AGENT_PROFILE_BINDINGS_KEY = "agent_profile_bindings"
@dataclass(frozen=True)
class SpecialistConfig:
specialist_id: SpecialistId
expert_name: str
default_tool_tags: frozenset[str] | None
SPECIALISTS: dict[SpecialistId, SpecialistConfig] = {
"ops": SpecialistConfig(
specialist_id="ops",
expert_name="network_ops",
default_tool_tags=None,
),
"generalist": SpecialistConfig(
specialist_id="generalist",
expert_name="generalist+workspace+productivity",
default_tool_tags=None,
),
"image": SpecialistConfig(
specialist_id="image",
# image specialist currently reuses generalist expert tool registry,
# including image_edit tool.
expert_name="generalist",
default_tool_tags=None,
),
"memory_curator": SpecialistConfig(
specialist_id="memory_curator",
expert_name="memory_curator",
default_tool_tags=None,
),
}
SPECIALIST_IDS: tuple[SpecialistId, ...] = tuple(SPECIALISTS.keys())
AGENT_ROLE_IDS: tuple[AgentRoleId, ...] = (MANAGER_AGENT_ID, *SPECIALIST_IDS)
def expert_name_for_specialist(specialist_id: SpecialistId) -> str:
cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"]
return cfg.expert_name
def default_tool_tags_for_specialist(specialist_id: SpecialistId) -> frozenset[str] | None:
cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"]
return cfg.default_tool_tags
def default_system_prefix_for_specialist(specialist_id: SpecialistId, lang: str = "zh") -> str:
sid = (specialist_id or "").strip().lower() or "generalist"
cfg = SPECIALISTS.get(sid) or SPECIALISTS["generalist"]
_ = (lang or "zh").strip().lower()
return build_role_system_context(cfg.specialist_id)
def model_role_for_specialist(specialist_id: SpecialistId) -> AgentRoleId:
sid = (specialist_id or "").strip().lower()
if sid in SPECIALISTS:
return sid
return "generalist"
def empty_agent_profile_bindings() -> dict[AgentRoleId, str]:
return {rid: "" for rid in AGENT_ROLE_IDS}
def parse_agent_profile_bindings(raw: str | None) -> dict[AgentRoleId, str]:
out = empty_agent_profile_bindings()
text = (raw or "").strip()
if not text:
return out
try:
obj = json.loads(text)
except Exception:
return out
if not isinstance(obj, dict):
return out
for rid in AGENT_ROLE_IDS:
v = obj.get(rid)
if v is None:
continue
s = str(v).strip()
out[rid] = s
return out
def dump_agent_profile_bindings(bindings: dict[AgentRoleId, Any]) -> str:
raw = {}
for rid in AGENT_ROLE_IDS:
v = bindings.get(rid) if isinstance(bindings, dict) else None
raw[rid] = str(v).strip() if v is not None else ""
return json.dumps(raw, ensure_ascii=False)
__all__ = [
"AGENT_PROFILE_BINDINGS_KEY",
"AGENT_ROLE_IDS",
"AgentRoleId",
"dump_agent_profile_bindings",
"empty_agent_profile_bindings",
"MANAGER_AGENT_ID",
"SpecialistConfig",
"SpecialistId",
"SPECIALISTS",
"SPECIALIST_IDS",
"default_system_prefix_for_specialist",
"default_tool_tags_for_specialist",
"expert_name_for_specialist",
"model_role_for_specialist",
"parse_agent_profile_bindings",
]

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from threading import Lock
_LOCK = Lock()
_INITIALIZED = False
def init_subagent_registry() -> None:
"""Initialize subagent registry runtime once.
Python gateway currently keeps this as a lightweight compatibility seam,
so startup code can mirror the OpenClaw TypeScript bootstrap flow.
"""
global _INITIALIZED
with _LOCK:
if _INITIALIZED:
return
_INITIALIZED = True
def is_subagent_registry_initialized() -> bool:
with _LOCK:
return _INITIALIZED
def reset_subagent_registry_for_tests() -> None:
global _INITIALIZED
with _LOCK:
_INITIALIZED = False
__all__ = [
"init_subagent_registry",
"is_subagent_registry_initialized",
"reset_subagent_registry_for_tests",
]

4
app_server/__init__.py Normal file
View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

2
application/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""Application use-cases and orchestration services."""

View file

@ -0,0 +1,6 @@
"""Gateway application use-cases."""
from .inbound_usecase import process_inbound_payload_usecase
__all__ = ["process_inbound_payload_usecase"]

View file

@ -0,0 +1,457 @@
from __future__ import annotations
import hashlib
import threading
from typing import Any
from oclaw.channels.base import InboundMessage, OutboundMessage
from oclaw.channels.wecom.wecom_bridge import WeComAdapter
_GATEWAY_AGENT_LOCK = threading.Lock()
_GATEWAY_AGENT: Any | None = None
def _get_gateway_agent(store: Any) -> Any:
global _GATEWAY_AGENT
with _GATEWAY_AGENT_LOCK:
if _GATEWAY_AGENT is None:
from oclaw.agents.factory import build_gateway_executor
_GATEWAY_AGENT = build_gateway_executor(store)
return _GATEWAY_AGENT
def _menu_text() -> str:
return (
"已绑定成功,常用命令:\n"
"1) 帮助 / 菜单\n"
"2) 记待办 <内容>\n"
"3) 查待办\n"
"4) 完成待办 <todo_id>\n"
"5) 指派待办 <todo_id> <assignee_user_id>\n"
"6) 加知识 <内容>\n"
"7) 查知识 <关键词>"
)
def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) -> str | None:
t = (text or "").strip()
if not t:
return None
if t in ("帮助", "菜单", "help", "/help"):
return _menu_text()
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
store = SqliteStore(db_path())
if t.startswith("记待办 "):
title = t[len("记待办 ") :].strip()
if not title:
return "待办内容不能为空。示例:记待办 明天10点开会"
row = store.todo_create(tenant_id=tenant_id, owner_user_id=user_id, title=title)
return f"已创建待办:{row['id'][:8]} | {row['title']}"
if t in ("查待办", "todo", "todos"):
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status="open", limit=10)
if not rows:
return "当前没有未完成待办。"
lines = [f"- {r['id'][:8]} | {r['title']}" for r in rows]
return "未完成待办:\n" + "\n".join(lines)
if t.startswith("完成待办 "):
tid = t[len("完成待办 ") :].strip()
if not tid:
return "请提供 todo_id。示例:完成待办 1234abcd"
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status=None, limit=200)
full = next((r["id"] for r in rows if str(r["id"]).startswith(tid)), tid)
ok = store.todo_set_status(tenant_id=tenant_id, todo_id=full, status="done")
return "已完成。" if ok else "未找到该待办。"
if t.startswith("指派待办 "):
body = t[len("指派待办 ") :].strip()
parts = body.split()
if len(parts) < 2:
return "格式:指派待办 <todo_id> <assignee_user_id>"
tid, assignee = parts[0], parts[1]
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status=None, limit=200)
full = next((r["id"] for r in rows if str(r["id"]).startswith(tid)), tid)
ok = store.todo_assign(tenant_id=tenant_id, todo_id=full, assignee_user_id=assignee)
return "已指派。" if ok else "未找到该待办或用户。"
if t.startswith("加知识 "):
content = t[len("加知识 ") :].strip()
if not content:
return "知识内容不能为空。示例:加知识 办公室WiFi密码是12345678"
from oclaw.tools.experts.productivity.kb_tools import kb_add_tool
res = kb_add_tool().handler({"tenant_id": tenant_id, "user_id": user_id, "text": content})
if not res.get("ok"):
return f"写入失败:{res.get('error')}"
return f"已写入知识:{str(res.get('chunk_id') or '')[:8]}"
if t.startswith("查知识 "):
q = t[len("查知识 ") :].strip()
if not q:
return "请提供关键词。示例:查知识 WiFi 密码"
from oclaw.tools.experts.productivity.kb_tools import kb_search_tool
res = kb_search_tool().handler({"tenant_id": tenant_id, "query": q, "limit": 5})
if not res.get("ok"):
return f"查询失败:{res.get('error')}"
hits = res.get("hits") if isinstance(res.get("hits"), list) else []
if not hits:
return "未找到相关知识。"
lines = [f"- {str(h.get('source') or '')}: {str(h.get('snippet') or '')}" for h in hits[:5] if isinstance(h, dict)]
return "知识检索结果:\n" + "\n".join(lines)
return None
def _role_can_write(role: str, text: str) -> bool:
low = (text or "").strip().lower()
if not low:
return True
if role in ("owner", "admin", "member"):
return True
write_prefixes = ("记待办 ", "完成待办 ", "指派待办 ", "加知识 ")
return not any((text or "").startswith(p) for p in write_prefixes)
def _resolve_wecom_account_id(inbound: Any, payload: dict[str, Any]) -> str:
if isinstance(inbound.metadata, dict):
for key in ("aibotid", "bot_id", "account_id"):
val = inbound.metadata.get(key)
if val:
return str(val).strip()
raw = inbound.metadata.get("raw")
if isinstance(raw, dict):
for key in ("aibotid", "bot_id", "account_id"):
val = raw.get(key)
if val:
return str(val).strip()
for key in ("aibotid", "bot_id", "account_id"):
val = payload.get(key)
if val:
return str(val).strip()
raw_payload = payload.get("raw")
if isinstance(raw_payload, dict) and raw_payload.get("aibotid"):
return str(raw_payload.get("aibotid")).strip()
return ""
def _resolve_generic_account_id(inbound: InboundMessage, payload: dict[str, Any]) -> str:
if isinstance(inbound.metadata, dict):
for key in ("account_id", "bot_id", "app_id", "agent_id"):
val = inbound.metadata.get(key)
if val:
return str(val).strip()
for key in ("account_id", "bot_id", "app_id", "agent_id"):
val = payload.get(key)
if val:
return str(val).strip()
raw_payload = payload.get("raw")
if isinstance(raw_payload, dict):
for key in ("account_id", "bot_id", "app_id", "agent_id"):
val = raw_payload.get(key)
if val:
return str(val).strip()
return ""
def _ensure_administrator_owner(store: Any) -> dict[str, Any] | None:
tenant_name = str(store.get_setting("wecom_auto_bind_tenant_name") or "Team").strip() or "Team"
tenants = store.list_tenants(limit=200)
tenant = next((t for t in tenants if str(t.get("name") or "") == tenant_name), None)
if tenant is None:
tenant = store.create_tenant(tenant_name)
tenant_id = str(tenant.get("id") or "")
if not tenant_id:
return None
user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
if not user:
try:
from oclaw.platform.config.passwords import load_expected_password
except Exception:
load_expected_password = None # type: ignore
pwd = load_expected_password(store) if callable(load_expected_password) else None
if not pwd:
return None
user = store.create_user_account(
tenant_id=tenant_id,
username="administrator",
password_hash=hashlib.sha256(pwd.encode("utf-8")).hexdigest(),
display_name="Administrator",
role="owner",
is_active=True,
)
user_id = str((user or {}).get("id") or "")
if not user_id:
return None
return {
"tenant_id": tenant_id,
"user_id": user_id,
"display_name": (user or {}).get("display_name") or "Administrator",
"role": str((user or {}).get("role") or "owner"),
}
def _extract_group_name(inbound: Any) -> str:
if not isinstance(inbound.metadata, dict):
return ""
cands: list[str] = []
for key in ("chat_name", "group_name", "room_name", "conversation_name"):
v = inbound.metadata.get(key)
if v is not None:
cands.append(str(v).strip())
raw = inbound.metadata.get("raw")
if isinstance(raw, dict):
for key in ("chat_name", "group_name", "room_name", "conversation_name", "chatname"):
v = raw.get(key)
if v is not None:
cands.append(str(v).strip())
chat_obj = raw.get("chat")
if isinstance(chat_obj, dict):
for key in ("name", "chat_name", "group_name"):
v = chat_obj.get(key)
if v is not None:
cands.append(str(v).strip())
for s in cands:
if s:
return s
return ""
def _build_wecom_session_title(*, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str:
base = f"{str(account_name or '').strip() or 'WeCom'}+{str(external_user_id or '').strip() or 'unknown'}"
if is_group and str(group_name or "").strip():
body = f"{base}+{str(group_name).strip()}"
else:
body = base
return f"wechat|{body}"
def _build_channel_session_title(*, channel: str, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str:
ch = str(channel or "").strip().lower() or "channel"
if ch == "wecom":
return _build_wecom_session_title(
account_name=account_name,
external_user_id=external_user_id,
is_group=is_group,
group_name=group_name,
)
base = f"{str(account_name or '').strip() or ch}+{str(external_user_id or '').strip() or 'unknown'}"
body = f"{base}+{str(group_name or '').strip()}" if is_group and str(group_name or "").strip() else base
return f"{ch}|{body}"
def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> InboundMessage:
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip()
chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip()
text = str(payload.get("text") or "").strip()
if not user_id:
raise ValueError("missing user_id")
if not chat_id:
chat_id = user_id
is_group = bool(payload.get("is_group"))
mentions = payload.get("mentions") if isinstance(payload.get("mentions"), list) else []
attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else []
return InboundMessage(
channel=str(channel_name or "unknown"),
external_user_id=user_id,
external_chat_id=chat_id,
text=text,
is_group=is_group,
mentions=[str(x).strip() for x in mentions if str(x).strip()],
attachments=[a for a in attachments if isinstance(a, dict)],
metadata={str(k): v for k, v in meta.items()},
)
def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
from oclaw.ops.mcp_env import apply_gateway_mcp_env_to_os
apply_gateway_mcp_env_to_os()
channel_name = str(payload.get("channel") or "wecom").strip().lower()
if channel_name in ("wecom", "wechat_work", "wxwork"):
adapter = WeComAdapter()
inbound = adapter.parse_inbound(payload)
else:
adapter = None
inbound = _parse_generic_inbound(channel_name, payload)
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.platform.config.paths import db_path
store = SqliteStore(db_path())
if channel_name == "wecom":
account_id = _resolve_wecom_account_id(inbound, payload) or str(store.get_setting("wecom_bot_id") or "").strip()
else:
account_id = _resolve_generic_account_id(inbound, payload)
if not account_id:
raise ValueError(f"missing {channel_name} account_id")
text = inbound.text.strip()
preface = ""
if text.lower().startswith("bind "):
code = text.split(None, 1)[-1].strip()
info = store.consume_bind_code(
code=code,
channel=inbound.channel,
external_user_id=inbound.external_user_id,
display_name=(
str(inbound.metadata.get("display_name")).strip()
if isinstance(inbound.metadata, dict) and inbound.metadata.get("display_name") is not None
else None
),
)
reply = ("绑定成功。\n\n" + _menu_text()) if info else "绑定失败:无效或已使用的绑定码。"
else:
reply = ""
ident = store.resolve_user_by_channel_identity_v2(
channel=inbound.channel,
account_id=account_id,
external_user_id=inbound.external_user_id,
)
if not ident:
owner = _ensure_administrator_owner(store)
if owner:
store.upsert_user_channel_account(
tenant_id=str(owner.get("tenant_id") or ""),
user_id=str(owner.get("user_id") or ""),
channel=inbound.channel,
account_id=account_id,
name=account_id,
config={"mode": "single-bot-upgraded"},
is_active=True,
)
store.upsert_channel_identity_v2(
tenant_id=str(owner.get("tenant_id") or ""),
channel=inbound.channel,
account_id=account_id,
external_user_id=inbound.external_user_id,
user_id=str(owner.get("user_id") or ""),
)
ident = store.resolve_user_by_channel_identity_v2(
channel=inbound.channel,
account_id=account_id,
external_user_id=inbound.external_user_id,
)
preface = "当前 Bot 已升级归属 administrator。"
if not ident:
reply = "账号初始化失败,请检查 administrator/tenant 配置。"
if ident:
from oclaw.orchestration.policy import ActionPolicyContext, PolicyEngine
from oclaw.orchestration.security import has_explicit_confirmation_token
tenant_id = str(ident.get("tenant_id") or "")
user_id = str(ident.get("user_id") or "")
role = str(ident.get("role") or "member")
account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {}
account_name = str(account.get("name") or "").strip() or account_id
group_name = _extract_group_name(inbound)
session_id = store.get_or_create_channel_session_v2(
tenant_id=tenant_id,
channel=inbound.channel,
account_id=account_id,
external_user_id=inbound.external_user_id,
external_chat_id=inbound.external_chat_id,
session_title=_build_channel_session_title(
channel=inbound.channel,
account_name=account_name,
external_user_id=inbound.external_user_id,
is_group=inbound.is_group,
group_name=group_name,
),
)
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
scope = "group" if inbound.is_group else "direct"
pe = PolicyEngine()
blob = (inbound.text or "").lower()
mention_all = ("@all" in blob) or ("全体" in inbound.text) or ("@所有" in inbound.text)
act = ActionPolicyContext(
session_id=session_id,
tenant_id=tenant_id,
user_id=user_id,
channel=inbound.channel,
user_text=inbound.text,
action="send_message",
target={"is_group": bool(inbound.is_group), "mention_all": bool(mention_all)},
)
d = pe.decide_action(ctx=act)
if d.needs_confirmation:
token_key = f"confirm_token:{session_id}"
token = (store.get_setting(token_key) or "").strip()
if not token:
token = pe.new_confirmation_token()
store.set_setting(token_key, token)
if not has_explicit_confirmation_token(inbound.text, token):
reply = f"该动作需要确认。请回复 `confirm {token}` 或包含 `[confirm:{token}]`。"
else:
reply = f"[assistant] ok scope={scope} session={session_id[:8]} (confirmed)"
else:
if not _role_can_write(role, inbound.text):
reply = "你的角色暂无写入权限。请联系管理员提升权限。"
else:
cmd_reply = _handle_productivity_commands(
text=inbound.text,
tenant_id=tenant_id,
user_id=user_id,
)
if cmd_reply is not None:
reply = cmd_reply
elif not reply:
user_text = (inbound.text or "").strip()
if user_text:
try:
from oclaw.openclaw_runtime.gateway import OpenClawGateway
from oclaw.openclaw_runtime.types import StandardMessage
agent = _get_gateway_agent(store)
gw = OpenClawGateway(store=store)
msg = StandardMessage(
session_id=str(session_id),
tenant_id=str(tenant_id or ""),
user_id=str(user_id or ""),
role=str(role or "member"),
channel=str(inbound.channel or "inbound"),
text=str(user_text or ""),
attachments=[],
metadata={
"tenant_id": tenant_id,
"user_id": user_id,
"channel": inbound.channel,
"role": role,
"account_id": account_id,
},
)
reply = str(gw.handle_turn(msg=msg, lang="zh", executor=agent).reply_text or "").strip()
except Exception as e:
reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
else:
reply = "收到消息,但内容为空。请直接发送文本。"
if preface:
if reply:
reply = f"{preface}\n\n{reply}"
else:
reply = f"{preface}\n\n{_menu_text()}"
if adapter is not None:
replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))]
else:
replies = [
{
"channel": inbound.channel,
"chat_id": inbound.external_chat_id,
"text": reply,
"attachments": [],
"metadata": {},
}
]
out = {"ok": True, "replies": replies}
return out
__all__ = ["process_inbound_payload"]

View file

@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Any
from .inbound_service import process_inbound_payload
def process_inbound_payload_usecase(payload: dict[str, Any]) -> dict[str, Any]:
"""Application use-case entry for inbound gateway payload handling."""
return process_inbound_payload(payload)
__all__ = ["process_inbound_payload_usecase", "process_inbound_payload"]

4
channels/__init__.py Normal file
View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

46
channels/base.py Normal file
View file

@ -0,0 +1,46 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Protocol
@dataclass(frozen=True)
class InboundMessage:
channel: str
external_user_id: str
external_chat_id: str
text: str
is_group: bool = False
mentions: list[str] = field(default_factory=list)
attachments: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class OutboundMessage:
external_chat_id: str
text: str
attachments: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
class ChannelAdapter(Protocol):
channel_name: str
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
raise NotImplementedError
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
raise NotImplementedError
def safe_json_loads(raw: str) -> dict[str, Any]:
try:
obj = json.loads(raw or "")
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
__all__ = ["InboundMessage", "OutboundMessage", "ChannelAdapter", "safe_json_loads"]

View file

@ -0,0 +1,6 @@
from __future__ import annotations
from .wecom_bridge import WeComAdapter
__all__ = ["WeComAdapter"]

View file

@ -0,0 +1,737 @@
from __future__ import annotations
import json
import os
import time
import urllib.request
import uuid
import queue
import threading
from collections import deque
from pathlib import Path
from typing import Any
from oclaw.application.gateway import process_inbound_payload_usecase
from oclaw.channels.wecom.normalize import normalize_wecom_event, normalize_wecom_event_batch
from oclaw.platform.config.paths import db_path
from oclaw.platform.integrations.wecom_client import WeComClient
from oclaw.platform.persistence.sqlite_store import SqliteStore
def _safe_json(obj: Any) -> str:
try:
return json.dumps(obj, ensure_ascii=True, default=str)
except Exception:
return str(obj)
def _account_id_from_payload(payload: dict[str, Any], store: SqliteStore) -> str:
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
for key in ("aibotid", "bot_id", "account_id"):
v = meta.get(key)
if v:
return str(v).strip()
for key in ("aibotid", "bot_id", "account_id"):
v = payload.get(key)
if v:
return str(v).strip()
raw = payload.get("raw")
if isinstance(raw, dict):
for key in ("aibotid", "bot_id", "account_id"):
v = raw.get(key)
if v:
return str(v).strip()
return str(store.get_setting("wecom_bot_id") or "").strip()
def _sanitize_outbound_text(text: str, *, max_chars: int = 1800) -> str:
s = str(text or "").strip()
if not s:
return ""
# Decode escaped newlines so WeCom shows real line breaks.
s = s.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\N", "\n")
# Remove hidden reasoning block before delivering to end users.
lower = s.lower()
start_tag = "<redacted_thinking>"
end_tag = "</redacted_thinking>"
if start_tag in lower and end_tag in lower:
start = lower.find(start_tag)
end = lower.find(end_tag, start)
if end >= 0:
s = (s[:start] + s[end + len(end_tag) :]).strip()
if s.startswith(start_tag):
s = s[len(start_tag) :].strip()
# Collapse excessive blank lines for better mobile display.
while "\n\n\n" in s:
s = s.replace("\n\n\n", "\n\n")
if len(s) > max_chars:
s = s[:max_chars].rstrip() + "\n\n(回复过长,已截断)"
return s
class _SingleInstanceLock:
def __init__(self, lock_path: Path) -> None:
self.lock_path = lock_path
self.fh: Any | None = None
def acquire(self) -> None:
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
self.fh = open(self.lock_path, "a+b")
self.fh.seek(0)
try:
if os.name == "nt":
import msvcrt # type: ignore
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl # type: ignore
fcntl.flock(self.fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception as exc:
raise RuntimeError(f"wecom_longconn_already_running: {self.lock_path}") from exc
def release(self) -> None:
if self.fh is None:
return
try:
if os.name == "nt":
import msvcrt # type: ignore
self.fh.seek(0)
msvcrt.locking(self.fh.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl # type: ignore
fcntl.flock(self.fh.fileno(), fcntl.LOCK_UN)
except Exception:
pass
try:
self.fh.close()
except Exception:
pass
self.fh = None
def _http_get_json(url: str, timeout: float = 15.0) -> dict[str, Any]:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
obj = json.loads(raw or "{}")
return obj if isinstance(obj, dict) else {}
def _http_post_json(url: str, payload: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={"content-type": "application/json", "accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
try:
obj = json.loads(raw or "{}")
except Exception:
return {"ok": True, "raw": raw}
return obj if isinstance(obj, dict) else {"ok": True, "raw": raw}
def _retry_count() -> int:
raw = str(os.getenv("WECOM_LONGCONN_SEND_RETRY") or "2").strip()
return max(1, min(int(raw) if raw.isdigit() else 2, 5))
def _load_mock_events() -> list[dict[str, Any]]:
seed = str(os.getenv("WECOM_LONGCONN_MOCK_TEXT") or "帮助").strip()
return [
{
"user_id": "u_mock_001",
"chat_id": "u_mock_001",
"text": seed,
"is_group": False,
"msgid": f"mock-{int(time.time())}",
}
]
def _load_events_once(mode: str) -> list[dict[str, Any]]:
if mode == "mock":
return _load_mock_events()
if mode == "pull":
url = str(os.getenv("WECOM_LONGCONN_PULL_URL") or "").strip()
if not url:
raise RuntimeError("missing WECOM_LONGCONN_PULL_URL when mode=pull")
obj = _http_get_json(url)
return normalize_wecom_event_batch(obj)
raise RuntimeError(f"unsupported WECOM_LONGCONN_MODE: {mode}")
def _run_ws_forever(*, sender: WeComClient, deliver_outbound: bool, use_response_url: bool) -> int:
try:
import websocket # type: ignore
except Exception as exc:
raise RuntimeError("websocket-client not installed; run: pip install websocket-client") from exc
bot_id, bot_secret = sender.get_bot_credentials()
ws_url = str(os.getenv("WECOM_LONGCONN_WS_URL") or "wss://openws.work.weixin.qq.com").strip()
seen_ids: deque[str] = deque(maxlen=2000)
seen_set: set[str] = set()
print(f"[wecom-longconn] websocket connecting url={ws_url} bot={bot_id}")
store = sender.store
workers_raw = (
str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
or str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip()
or str(os.getenv("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
or str(os.getenv("WECOM_LONGCONN_WORKERS") or "").strip()
)
workers = 2
if workers_raw.isdigit():
workers = max(1, min(int(workers_raw), 8))
in_max_raw = (
str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
or str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
or str(os.getenv("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
or str(os.getenv("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
)
in_q_max = 200
if in_max_raw.isdigit():
in_q_max = max(20, min(int(in_max_raw), 5000))
inbound_q: "queue.Queue[tuple[dict[str, Any], dict[str, Any]]]" = queue.Queue(maxsize=in_q_max)
outbound_q: "queue.Queue[dict[str, Any]]" = queue.Queue()
ws_ref: dict[str, Any] = {"ws": None}
stop_sender = threading.Event()
def _drain_outbound_queue(*, ws: Any) -> None:
while True:
try:
ob = outbound_q.get_nowait()
except Exception:
break
try:
if not deliver_outbound:
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
continue
text = str(ob.get("text") or "").strip()
if not text:
continue
response_url = str(ob.get("response_url") or "").strip()
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
rsp = {
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},
"body": {"msgtype": "markdown", "markdown": {"content": text}},
}
sent = False
if use_response_url and response_url:
try:
cb_payload = {"msgtype": "text", "text": {"content": text}}
cb_res: dict[str, Any] = {}
cb_err = -1
for _i in range(_retry_count()):
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
cb_err = (
int(cb_res.get("errcode"))
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
else 0
)
if cb_err == 0:
break
if cb_err == 0:
sent = True
try:
store.set_setting("wecom_last_outbound_mode", "response_url")
store.set_setting("wecom_last_outbound_error", "")
except Exception:
pass
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
else:
try:
store.set_setting(
"wecom_last_outbound_error",
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
)
except Exception:
pass
print(
"[wecom-longconn] response_url_send_not_ok_fallback_ws",
_safe_json({"url": response_url, "res": cb_res}),
)
except Exception as exc:
try:
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
except Exception:
pass
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
if not sent:
ws_ok = False
ws_err = ""
for _i in range(_retry_count()):
try:
ws.send(json.dumps(rsp, ensure_ascii=False))
ws_ok = True
ws_err = ""
break
except Exception as exc:
ws_err = f"{type(exc).__name__}: {exc}"
time.sleep(0.15)
if ws_ok:
try:
store.set_setting("wecom_last_outbound_mode", "ws")
store.set_setting("wecom_last_outbound_error", "")
except Exception:
pass
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
else:
try:
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
except Exception:
pass
finally:
try:
outbound_q.task_done()
except Exception:
pass
def _sender_loop() -> None:
while not stop_sender.is_set():
try:
# Block briefly to react immediately after worker enqueues replies.
ob = outbound_q.get(timeout=0.3)
except queue.Empty:
continue
except Exception:
continue
try:
ws_obj = ws_ref.get("ws")
if ws_obj is None:
# websocket reconnecting; put back and retry soon.
outbound_q.put(ob)
time.sleep(0.2)
continue
if not deliver_outbound:
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
continue
text = str(ob.get("text") or "").strip()
if not text:
continue
response_url = str(ob.get("response_url") or "").strip()
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
rsp = {
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},
"body": {"msgtype": "markdown", "markdown": {"content": text}},
}
sent = False
if use_response_url and response_url:
try:
cb_payload = {"msgtype": "text", "text": {"content": text}}
cb_res: dict[str, Any] = {}
cb_err = -1
for _i in range(_retry_count()):
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
cb_err = (
int(cb_res.get("errcode"))
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
else 0
)
if cb_err == 0:
break
if cb_err == 0:
sent = True
try:
store.set_setting("wecom_last_outbound_mode", "response_url")
store.set_setting("wecom_last_outbound_error", "")
except Exception:
pass
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
else:
try:
store.set_setting(
"wecom_last_outbound_error",
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
)
except Exception:
pass
print("[wecom-longconn] response_url_send_not_ok_fallback_ws", _safe_json({"url": response_url, "res": cb_res}))
except Exception as exc:
try:
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
except Exception:
pass
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
if not sent:
ws_ok = False
ws_err = ""
for _i in range(_retry_count()):
try:
ws_obj.send(json.dumps(rsp, ensure_ascii=False))
ws_ok = True
ws_err = ""
break
except Exception as exc:
ws_err = f"{type(exc).__name__}: {exc}"
time.sleep(0.15)
if ws_ok:
try:
store.set_setting("wecom_last_outbound_mode", "ws")
store.set_setting("wecom_last_outbound_error", "")
except Exception:
pass
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
else:
try:
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
except Exception:
pass
finally:
try:
outbound_q.task_done()
except Exception:
pass
def _worker_loop(idx: int) -> None:
while True:
payload, meta2 = inbound_q.get()
try:
out = process_inbound_payload_usecase(payload)
replies = out.get("replies") if isinstance(out, dict) else []
if not isinstance(replies, list):
replies = []
for rep in replies:
if not isinstance(rep, dict):
continue
text = str(rep.get("text") or "").strip()
text = _sanitize_outbound_text(text)
if not text:
continue
outbound_q.put(
{
"callback_req_id": str(meta2.get("callback_req_id") or ""),
"response_url": str(meta2.get("response_url") or ""),
"text": text,
"raw_rep": rep,
}
)
except Exception as exc:
outbound_q.put(
{
"callback_req_id": str(meta2.get("callback_req_id") or ""),
"response_url": str(meta2.get("response_url") or ""),
"text": f"[wecom-longconn] worker_error: {type(exc).__name__}: {exc}",
"raw_rep": {},
}
)
finally:
inbound_q.task_done()
for i in range(workers):
threading.Thread(target=_worker_loop, args=(i,), name=f"wecom_worker_{i}", daemon=True).start()
threading.Thread(target=_sender_loop, name="wecom_sender", daemon=True).start()
while True:
ws = None
try:
ws = websocket.create_connection(ws_url, timeout=30)
ws.settimeout(60)
ws_ref["ws"] = ws
sub = {
"cmd": "aibot_subscribe",
"headers": {"req_id": uuid.uuid4().hex},
"body": {"bot_id": bot_id, "secret": bot_secret},
}
ws.send(json.dumps(sub, ensure_ascii=False))
print("[wecom-longconn] subscribe sent")
while True:
try:
raw = ws.recv()
except websocket.WebSocketTimeoutException:
# Idle timeout is expected when no inbound message arrives.
# Keep the connection alive instead of reconnecting.
try:
ws.ping()
print("[wecom-longconn] ping")
except Exception:
raise
# Drain outbound queue on idle ticks so replies are pushed immediately,
# not delayed until the next inbound message.
_drain_outbound_queue(ws=ws)
continue
if not raw:
continue
try:
msg = json.loads(raw)
except Exception:
print("[wecom-longconn] non_json_message", raw)
continue
if not isinstance(msg, dict):
continue
cmd = str(msg.get("cmd") or "").strip()
if not cmd:
body0 = msg.get("body") if isinstance(msg.get("body"), dict) else {}
cmd = str(body0.get("cmd") or body0.get("type") or msg.get("type") or "").strip()
if not cmd and "errcode" in msg and "errmsg" in msg:
ack_req_id = ""
h = msg.get("headers")
if isinstance(h, dict):
ack_req_id = str(h.get("req_id") or "").strip()
ack_err = int(msg.get("errcode") or 0)
ack_msg = str(msg.get("errmsg") or "").strip()
try:
store.set_setting("wecom_last_ack_req_id", ack_req_id)
store.set_setting("wecom_last_ack_errcode", str(ack_err))
store.set_setting("wecom_last_ack_errmsg", ack_msg)
if ack_err == 0:
store.set_setting("wecom_last_outbound_error", "")
else:
store.set_setting("wecom_last_outbound_error", f"ack_errcode={ack_err}: {ack_msg}")
except Exception:
pass
print("[wecom-longconn] outbound_ack", _safe_json(msg))
continue
try:
store.set_setting("wecom_last_cmd", cmd)
except Exception:
pass
if cmd == "aibot_subscribe_rsp":
print("[wecom-longconn] subscribe_rsp", json.dumps(msg, ensure_ascii=False))
continue
if cmd not in ("aibot_msg_callback", "aibot_event_callback"):
try:
store.set_setting(
"wecom_last_unknown_cmd_payload",
json.dumps(msg, ensure_ascii=False, default=str)[:4000],
)
except Exception:
pass
print(
"[wecom-longconn] unknown_cmd",
_safe_json(
{"cmd": cmd, "keys": sorted(msg.keys())[:20]},
),
)
continue
body = msg.get("body") if isinstance(msg.get("body"), dict) else {}
headers = msg.get("headers") if isinstance(msg.get("headers"), dict) else {}
callback_req_id = str(headers.get("req_id") or "").strip()
try:
store.set_setting(
"wecom_last_raw_body",
json.dumps(body, ensure_ascii=False, default=str)[:4000],
)
from_obj = body.get("from")
from_uid = ""
if isinstance(from_obj, dict):
from_uid = str(from_obj.get("userid") or from_obj.get("user_id") or from_obj.get("id") or "").strip()
elif isinstance(from_obj, str):
from_uid = from_obj.strip()
if from_uid:
store.set_setting("wecom_last_raw_from_user", from_uid)
except Exception:
pass
if cmd == "aibot_event_callback":
event_type = str(body.get("event") or body.get("event_type") or body.get("type") or "").strip()
event_obj = body.get("event") if isinstance(body.get("event"), dict) else {}
event_type_norm = str(
event_obj.get("eventtype")
or event_obj.get("type")
or event_type
).strip()
from_obj = body.get("from")
from_user = ""
if isinstance(from_obj, dict):
from_user = str(
from_obj.get("userid")
or from_obj.get("user_id")
or from_obj.get("id")
or from_obj.get("from_user_id")
or ""
).strip()
elif isinstance(from_obj, str):
from_user = from_obj.strip()
print(
"[wecom-longconn] event_callback",
_safe_json(
{
"event_type": event_type_norm or event_type,
"from_user": from_user,
"keys": sorted(body.keys())[:20],
},
),
)
if (event_type_norm or event_type).lower() == "disconnected_event":
try:
store.set_setting("wecom_last_parse_error", "disconnected_event")
except Exception:
pass
continue
try:
payload = normalize_wecom_event(body)
except Exception as exc:
print(
"[wecom-longconn] skip_invalid_msg_callback",
_safe_json(
{
"error": f"{type(exc).__name__}: {exc}",
"keys": sorted(body.keys())[:30],
},
),
)
try:
store.set_setting("wecom_last_parse_error", f"{type(exc).__name__}: {exc}")
except Exception:
pass
continue
try:
store.set_setting(
"wecom_last_normalized_payload",
json.dumps(payload, ensure_ascii=False, default=str)[:4000],
)
except Exception:
pass
msgid = str(body.get("msgid") or payload.get("metadata", {}).get("msgid") or "").strip()
if msgid and msgid in seen_set:
continue
if msgid:
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
old = seen_ids.popleft()
seen_set.discard(old)
seen_ids.append(msgid)
seen_set.add(msgid)
try:
now = str(int(time.time()))
user_id = str(payload.get("user_id") or "").strip()
store.set_setting("wecom_last_msg_ts", now)
if user_id:
store.set_setting("wecom_last_from_user", user_id)
raw_recent = str(store.get_setting("wecom_recent_from_users") or "[]")
recent = json.loads(raw_recent)
if not isinstance(recent, list):
recent = []
recent = [x for x in recent if isinstance(x, dict)]
if user_id:
recent = [x for x in recent if str(x.get("user_id") or "") != user_id]
recent.insert(0, {"user_id": user_id, "ts": now})
store.set_setting("wecom_recent_from_users", json.dumps(recent[:20], ensure_ascii=False))
except Exception:
pass
try:
inbound_q.put_nowait(
(
payload,
{
"callback_req_id": callback_req_id or uuid.uuid4().hex,
"response_url": str(body.get("response_url") or "").strip(),
},
)
)
except Exception:
# Inbound backlog; drop gracefully to keep the websocket loop healthy.
try:
store.set_setting("wecom_last_outbound_error", "inbound_queue_full")
except Exception:
pass
# Drain outbound queue opportunistically.
_drain_outbound_queue(ws=ws)
except KeyboardInterrupt:
print("[wecom-longconn] stopped by user")
return 0
except Exception as exc:
print(f"[wecom-longconn] ws_loop_error: {type(exc).__name__}: {exc}")
time.sleep(3)
finally:
ws_ref["ws"] = None
if ws is not None:
try:
ws.close()
except Exception:
pass
def run_forever() -> int:
store = SqliteStore(db_path())
sender = WeComClient(store)
lock = _SingleInstanceLock(Path(db_path()).resolve().parent / "locks" / "wecom_longconn.lock")
lock.acquire()
default_mode = "ws"
mode = str(os.getenv("WECOM_LONGCONN_MODE") or default_mode).strip().lower()
interval_s = max(1.0, float(os.getenv("WECOM_LONGCONN_INTERVAL_SEC") or "3"))
deliver_outbound = str(os.getenv("WECOM_LONGCONN_DELIVER_OUTBOUND") or "1").strip().lower() not in (
"0",
"false",
"no",
)
seen_ids: deque[str] = deque(maxlen=2000)
seen_set: set[str] = set()
# Prefer response_url by default for lower latency and better delivery semantics.
use_response_url = str(os.getenv("WECOM_LONGCONN_USE_RESPONSE_URL") or "1").strip().lower() in (
"1",
"true",
"yes",
"on",
)
print(
f"[wecom-longconn] started mode={mode} interval={interval_s}s outbound={deliver_outbound} use_response_url={use_response_url}"
)
try:
if mode == "ws":
return _run_ws_forever(
sender=sender,
deliver_outbound=deliver_outbound,
use_response_url=use_response_url,
)
while True:
try:
events = _load_events_once(mode)
if not events:
time.sleep(interval_s)
continue
for evt in events:
payload = normalize_wecom_event(evt)
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
msgid = str(meta.get("msgid") or "").strip()
if msgid and msgid in seen_set:
continue
if msgid:
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
old = seen_ids.popleft()
seen_set.discard(old)
seen_ids.append(msgid)
seen_set.add(msgid)
out = process_inbound_payload_usecase(payload)
replies = out.get("replies") if isinstance(out, dict) else []
if not isinstance(replies, list):
replies = []
for rep in replies:
if not isinstance(rep, dict):
continue
if not deliver_outbound:
print("[wecom-longconn] outbound_skipped", _safe_json(rep))
continue
to_user = str(payload.get("user_id") or "").strip()
text = str(rep.get("text") or "").strip()
text = _sanitize_outbound_text(text)
if not to_user or not text:
continue
aid = _account_id_from_payload(payload, store)
print(
"[wecom-longconn] outbound_skipped_http_removed use_ws_mode",
_safe_json({"to_user": to_user, "account_id": aid or "", "text_len": len(text)}),
)
except KeyboardInterrupt:
print("[wecom-longconn] stopped by user")
return 0
except Exception as exc:
print(f"[wecom-longconn] loop_error: {type(exc).__name__}: {exc}")
time.sleep(interval_s)
finally:
lock.release()
def main() -> int:
return run_forever()
if __name__ == "__main__":
raise SystemExit(main())

127
channels/wecom/normalize.py Normal file
View file

@ -0,0 +1,127 @@
from __future__ import annotations
from typing import Any
def _first_non_empty(*values: Any) -> str:
for v in values:
s = str(v or "").strip()
if s:
return s
return ""
def _pick(d: dict[str, Any], *keys: str) -> Any:
for k in keys:
if k in d:
return d.get(k)
return None
def _extract_text(raw: dict[str, Any]) -> str:
direct_text = _pick(raw, "text")
if isinstance(direct_text, str) and direct_text.strip():
return direct_text.strip()
direct = _first_non_empty(
_pick(raw, "content", "Content"),
)
if direct:
return direct
text_obj = raw.get("text")
if isinstance(text_obj, dict):
return _first_non_empty(_pick(text_obj, "content", "Content"))
text_raw_obj = raw.get("text_raw")
if isinstance(text_raw_obj, dict):
return _first_non_empty(_pick(text_raw_obj, "content", "Content", "text"))
content_obj = raw.get("content")
if isinstance(content_obj, dict):
return _first_non_empty(_pick(content_obj, "text", "content", "Content"))
if isinstance(raw.get("msg"), dict):
return _first_non_empty(_pick(raw.get("msg", {}), "text", "content", "Content"))
msg_obj = raw.get("message")
if isinstance(msg_obj, dict):
return _first_non_empty(
_pick(msg_obj, "text", "content", "Content"),
_pick(msg_obj.get("text", {}), "content") if isinstance(msg_obj.get("text"), dict) else None,
)
payload = raw.get("payload")
if isinstance(payload, dict):
return _first_non_empty(
_pick(payload, "text", "content", "Content"),
_pick(payload.get("text", {}), "content") if isinstance(payload.get("text"), dict) else None,
)
return ""
def normalize_wecom_event(raw: dict[str, Any]) -> dict[str, Any]:
"""Convert WeCom-like raw events into normalized gateway payload."""
chat_id = _first_non_empty(
_pick(raw, "chat_id", "conversation_id", "conversationId", "chatid", "roomid", "RoomId"),
_pick(raw.get("message", {}), "chat_id", "conversation_id", "chatid")
if isinstance(raw.get("message"), dict)
else None,
_pick(raw.get("chat", {}), "id", "chat_id", "chatid", "roomid")
if isinstance(raw.get("chat"), dict)
else None,
_pick(raw.get("conversation", {}), "id", "chat_id", "chatid")
if isinstance(raw.get("conversation"), dict)
else None,
_pick(raw.get("room", {}), "id", "roomid", "chatid")
if isinstance(raw.get("room"), dict)
else None,
)
from_obj = raw.get("from")
user_id = _first_non_empty(
_pick(raw, "user_id", "from_user_id", "fromUserId", "FromUserName", "userid", "external_userid"),
from_obj if isinstance(from_obj, str) else None,
_pick(from_obj, "userid", "user_id", "id", "from_user_id", "UserId", "userid64", "uid")
if isinstance(from_obj, dict)
else None,
_pick(raw.get("sender", {}), "userid", "user_id", "id") if isinstance(raw.get("sender"), dict) else None,
_pick(raw.get("message", {}), "from_user_id", "fromUserId") if isinstance(raw.get("message"), dict) else None,
chat_id,
)
if not chat_id:
chat_id = user_id
text = _extract_text(raw)
msgid = _first_non_empty(_pick(raw, "msgid", "msg_id", "id"), _pick(raw.get("message", {}), "msgid", "id"))
agentid = _first_non_empty(_pick(raw, "agentid", "agent_id"), _pick(raw.get("message", {}), "agentid"))
chat_type = _first_non_empty(_pick(raw, "chat_type", "conversation_type"))
is_group = bool(raw.get("is_group")) or chat_type in ("group", "room")
if not is_group and chat_id:
is_group = chat_id.endswith("@chatroom")
# If group but room id was missing, we previously fell back chat_id=user_id — same key as private 1:1.
if is_group and chat_id == user_id:
chat_id = f"group:unknown:{user_id}"
return {
"channel": "wecom",
"user_id": user_id,
"chat_id": chat_id or user_id,
"text": text,
"is_group": is_group,
"metadata": {
"agentid": agentid,
"msgid": msgid,
"source": "wecom_longconn",
"raw": raw,
},
}
def normalize_wecom_event_batch(obj: Any) -> list[dict[str, Any]]:
"""Extract event list from common envelopes returned by pull APIs."""
if isinstance(obj, list):
return [x for x in obj if isinstance(x, dict)]
if not isinstance(obj, dict):
return []
for key in ("events", "messages", "items", "data"):
arr = obj.get(key)
if isinstance(arr, list):
return [x for x in arr if isinstance(x, dict)]
return [obj]
__all__ = ["normalize_wecom_event", "normalize_wecom_event_batch"]

View file

@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Any
from oclaw.channels.base import ChannelAdapter, InboundMessage, OutboundMessage
class WeComAdapter(ChannelAdapter):
channel_name = "wecom"
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip()
chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip()
text = str(payload.get("text") or "").strip()
if not user_id:
raise ValueError("missing user_id")
if not chat_id:
chat_id = user_id
is_group = bool(payload.get("is_group"))
if is_group and chat_id == user_id:
chat_id = f"group:unknown:{user_id}"
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
mentions_raw = payload.get("mentions")
mentions: list[str] = []
if isinstance(mentions_raw, list):
mentions = [str(x).strip() for x in mentions_raw if str(x).strip()]
attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else []
return InboundMessage(
channel=self.channel_name,
external_user_id=user_id,
external_chat_id=chat_id,
text=text,
is_group=is_group,
mentions=mentions,
attachments=[a for a in attachments if isinstance(a, dict)],
metadata={str(k): v for k, v in metadata.items()},
)
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
return {
"channel": self.channel_name,
"chat_id": msg.external_chat_id,
"text": msg.text,
"attachments": msg.attachments,
"metadata": msg.metadata,
}
__all__ = ["WeComAdapter"]

1
chat/__init__.py Normal file
View file

@ -0,0 +1 @@
# oclaw.chat package

278
chat/agent.py Normal file
View file

@ -0,0 +1,278 @@
from __future__ import annotations
import json
import logging
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
from oclaw.tools.base import ToolRegistry
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.platform.llm.chat_models import (
ChatModel,
LLMResponse,
LLMToolCall,
OpenAIChatModel,
RuleBasedChatModel,
StaticTextChatModel,
_normalize_image_b64_payload,
build_default_model,
gemini_openai_compat_client,
)
from oclaw.prompts.loader import render_prompt_for_lang
from oclaw.tools.tool_validation import validate_tool_arguments
logger = logging.getLogger(__name__)
SESSION_TITLE_MAX_LEN = 120
AGENT_CONTEXT_MESSAGES = 80
DEFAULT_SYSTEM_PROMPTS: dict[str, str] = {
"zh": render_prompt_for_lang("runtime/default_system", "zh", strict=True),
"en": render_prompt_for_lang("runtime/default_system", "en", strict=True),
}
class GenerationInterrupted(Exception):
"""用户请求中止当前生成过程。"""
@dataclass(frozen=True)
class AgentConfig:
max_messages: int = AGENT_CONTEXT_MESSAGES
max_tool_rounds: int = 8
max_tool_workers: int = 8
class Agent:
def __init__(
self,
store: SqliteStore,
tools: ToolRegistry,
model: Optional[ChatModel] = None,
config: Optional[AgentConfig] = None,
system_prompt: str | None = None,
lang: str = "zh",
llm_profile_mode: str | None = None,
):
self.store = store
self.tools = tools
self.model = model or build_default_model()
self.config = config or AgentConfig()
self.lang = (lang or "zh").strip().lower()
self._system_prompt_base = (system_prompt or DEFAULT_SYSTEM_PROMPTS.get(self.lang, DEFAULT_SYSTEM_PROMPTS["zh"])).strip()
self.llm_profile_mode = ((llm_profile_mode or "").strip().lower() or None)
self._last_turn_outcome: Any | None = None
def _native_tools_sent_by_api(self) -> bool:
"""当前模型这一侧是否会把 tools 放进请求(与 ``llm.OpenAIChatModel._skip_tools`` 对齐)。"""
m = self.model
if isinstance(m, (RuleBasedChatModel, StaticTextChatModel)):
return False
if isinstance(m, OpenAIChatModel):
return not bool(m._skip_tools)
return False
def _compose_system_prompt(self) -> str:
"""系统正文。工具 schema 始终通过原生 tools 字段下发,不再拼接到 prompt。"""
return self._system_prompt_base
def _format_ollama_failure_banner(self, exc: BaseException) -> str:
# Backward compat wrapper; implementation lives in `src.chat.agent_errors`.
from oclaw.chat.agent_errors import format_ollama_failure_banner
return format_ollama_failure_banner(lang=self.lang, exc=exc)
def _format_openai_transport_error(self, exc: BaseException) -> str:
# Backward compat wrapper; implementation lives in `src.chat.agent_errors`.
from oclaw.chat.agent_errors import format_openai_transport_error
return format_openai_transport_error(lang=self.lang, exc=exc)
def _invoke_tool(self, tc: LLMToolCall) -> tuple[dict[str, Any], int]:
t0 = time.perf_counter()
tool = self.tools.get(tc.name)
if not tool:
msg = f"Unregistered tool: {tc.name}" if self.lang.startswith("en") else f"未注册的工具: {tc.name}"
return {"ok": False, "error": msg}, int((time.perf_counter() - t0) * 1000)
ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments)
if not ok:
msg = f"Invalid arguments: {v_err}" if self.lang.startswith("en") else f"参数不合法: {v_err}"
return {"ok": False, "error": msg}, int((time.perf_counter() - t0) * 1000)
try:
result = tool.handler(tc.arguments)
return result, int((time.perf_counter() - t0) * 1000)
except Exception as e:
if self.lang.startswith("en"):
err = {"ok": False, "error": f"Tool execution error: {type(e).__name__}: {e}"}
else:
err = {"ok": False, "error": f"工具执行异常: {type(e).__name__}: {e}"}
return err, int((time.perf_counter() - t0) * 1000)
def _emit_progress(self, on_progress: Optional[Callable[[str], None]], en: str, zh: str) -> None:
if on_progress:
on_progress(en if self.lang.startswith("en") else zh)
@staticmethod
def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
"""Extract image/relay references from tool results for rendering."""
if not isinstance(result, dict):
return []
out: list[dict[str, Any]] = []
aid = str(result.get("attachment_id") or "").strip()
if aid:
out.append(
{
"type": "image_ref",
"attachment_id": aid,
"name": str(result.get("name") or "generated-image"),
"mime": str(result.get("mime") or "image/png"),
"bytes": result.get("bytes"),
"width": result.get("width"),
"height": result.get("height"),
}
)
refs = result.get("attachments")
if isinstance(refs, list):
for r in refs:
if not isinstance(r, dict):
continue
# Relay pointer payload (new protocol).
p_uri = str(r.get("pointer_uri") or "").strip()
if p_uri:
out.append(
{
"type": "relay_pointer",
"pointer_uri": p_uri,
"rel_path": str(r.get("rel_path") or ""),
"mime": str(r.get("mime_type") or r.get("mime") or ""),
"bytes": r.get("bytes"),
"sha256": str(r.get("sha256") or ""),
"name": str(r.get("name") or ""),
}
)
continue
r_aid = str(r.get("attachment_id") or "").strip()
if not r_aid:
continue
out.append(
{
"type": "image_ref",
"attachment_id": r_aid,
"name": str(r.get("name") or "generated-image"),
"mime": str(r.get("mime") or "image/png"),
"bytes": r.get("bytes"),
"width": r.get("width"),
"height": r.get("height"),
}
)
# de-dup by attachment_id
uniq: list[dict[str, Any]] = []
seen: set[str] = set()
for a in out:
k = str(a.get("attachment_id") or a.get("pointer_uri") or "")
if not k or k in seen:
continue
seen.add(k)
uniq.append(a)
return uniq
def run_turn(
self,
session_id: str,
user_text: str,
attachments: list[dict[str, Any]] | None = None,
on_progress: Optional[Callable[[str], None]] = None,
on_token: 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,
interaction_mode: str | None = None,
selected_specialist: str | None = None,
) -> str:
from oclaw.openclaw_runtime.gateway import OpenClawGateway
from oclaw.openclaw_runtime.types import StandardMessage
tenant_id = str(path_policy_tenant_id or "").strip()
user_id = str(path_policy_user_id or "").strip()
if not tenant_id or not user_id:
try:
owner = self.store.get_ui_session_owner(session_id=session_id)
except Exception:
owner = None
if isinstance(owner, dict):
tenant_id = tenant_id or str(owner.get("tenant_id") or "")
user_id = user_id or str(owner.get("user_id") or "")
session = self.store.get_session(session_id)
if session and session.title in ("新会话", "New Chat"):
title = user_text.strip().replace("\n", " ")
if not title and attachments:
title = str(attachments[0].get("name") or "New Chat")
if title:
self.store.rename_session(session_id, title[:SESSION_TITLE_MAX_LEN])
self._emit_progress(
on_progress,
"Received. Working on your request…",
"已收到,正在处理…",
)
meta: dict[str, Any] = {"tenant_id": tenant_id, "user_id": user_id}
if workspace_owner_session_id:
meta["workspace_owner_session_id"] = str(workspace_owner_session_id).strip()
if str(interaction_mode or "").strip():
meta["interaction_mode"] = str(interaction_mode).strip().lower()
if str(selected_specialist or "").strip():
meta["selected_specialist"] = str(selected_specialist).strip().lower()
msg = StandardMessage(
session_id=session_id,
tenant_id=tenant_id,
user_id=user_id,
role="member",
channel="agent_turn",
text=str(user_text or ""),
attachments=list(attachments or []),
metadata=meta,
)
gw = OpenClawGateway(store=self.store)
try:
res = gw.handle_turn(
msg=msg,
lang=self.lang,
executor=self,
on_token=on_token,
on_progress=on_progress,
on_tool_ui=on_tool_ui,
should_stop=should_stop,
)
except RuntimeError as e:
low = str(e).lower()
if "interrupted" in low and "user" in low:
raise GenerationInterrupted(str(e)) from e
raise
self._last_turn_outcome = getattr(self, "_last_turn_outcome", None)
return str(res.reply_text or "")
def _build_llm_messages(self, session_id: str) -> list[dict[str, Any]]:
from oclaw.chat.agent_messages import build_llm_messages
msgs = self.store.get_messages(session_id=session_id, limit=self.config.max_messages)
return build_llm_messages(
store_messages=msgs,
system_prompt=self._compose_system_prompt(),
model=self.model,
lang=self.lang,
)
__all__ = ["AgentConfig", "DEFAULT_SYSTEM_PROMPTS", "GenerationInterrupted", "Agent"]

69
chat/agent_errors.py Normal file
View file

@ -0,0 +1,69 @@
from __future__ import annotations
"""Agent 错误处理模块。
把 `Agent` 内的错误格式化逻辑下沉到此处,方便 manager/specialist 复用。
"""
from typing import Any
from oclaw.prompts import render_prompt
def format_ollama_failure_banner(*, lang: str, exc: BaseException) -> str:
prompt_id = "fallback/ollama_failure.en.md" if (lang or "zh").startswith("en") else "fallback/ollama_failure.zh.md"
return render_prompt(
prompt_id,
variables={"error_type": type(exc).__name__, "error_message": str(exc)},
strict=True,
)
def format_openai_transport_error(*, lang: str, exc: BaseException) -> str:
blob = str(exc).lower()
oversized_tool = ("30000" in blob or "input length" in blob) and ("range" in blob or "length" in blob)
gemini_sig = "thought_signature" in blob
if (lang or "zh").startswith("en"):
if oversized_tool:
return render_prompt(
"fallback/openai_transport_oversized.en.md",
variables={"error_type": type(exc).__name__, "error_message": str(exc)},
strict=True,
)
tail = (
"\n\n_Gemini 3 with tools: the API requires echoing `thought_signature` from each tool use in chat history. "
"If this persists, update the app or use a model/SDK path that preserves provider-specific tool fields._"
if gemini_sig
else ""
)
return render_prompt(
"fallback/openai_transport_error.en.md",
variables={"error_type": type(exc).__name__, "error_message": str(exc), "extra_tail": tail},
strict=True,
)
if oversized_tool:
return render_prompt(
"fallback/openai_transport_oversized.zh.md",
variables={"error_type": type(exc).__name__, "error_message": str(exc)},
strict=True,
)
tail = (
"\n\n(**Gemini 3 + 工具调用**:接口要求把模型返回的 **thought_signature** 随该次 `tool_calls` 一并写回对话历史;"
"首轮能跑工具、第二轮 400 多为丢失该字段。若已更新本应用仍报错,请确认代理/OpenAI 兼容层是否透传该字段。)"
if gemini_sig
else ""
)
return render_prompt(
"fallback/openai_transport_error.zh.md",
variables={"error_type": type(exc).__name__, "error_message": str(exc), "extra_tail": tail},
strict=True,
)
def safe_str(e: Any) -> str:
try:
return str(e)
except Exception:
return repr(e)
__all__ = ["format_ollama_failure_banner", "format_openai_transport_error", "safe_str"]

494
chat/agent_messages.py Normal file
View file

@ -0,0 +1,494 @@
from __future__ import annotations
"""Agent 消息构建模块。
把 `Agent._build_llm_messages` 的职责下沉到此处,便于:
- Manager 决策/Final merge 复用同一套“消息规范化与附件注入”规则
- 后续 Workspace/RAG/Trace 插入上下文时有单一入口
"""
import json
import logging
import os
import re
from typing import Any
from oclaw.platform.llm.chat_models import _normalize_image_b64_payload, gemini_openai_compat_client, ChatModel
from oclaw.chat.tool_runtime import tool_llm_message_max_chars, truncate_tool_result_for_llm_messages
from oclaw.prompts import render_prompt
from oclaw.platform.files.attachment_assets import attachment_id_to_data_url
from oclaw.openclaw_runtime.relay_pointer import parse_pointer_uri
logger = logging.getLogger(__name__)
_THINK_BLOCK_RE = re.compile(r"<think>\s*(.*?)\s*</think>\s*", flags=re.IGNORECASE | re.DOTALL)
def _replay_recent_tool_rounds() -> int:
raw = str(os.getenv("AIA_REPLAY_TOOL_FULL_ROUNDS") or "").strip()
if raw.isdigit():
return max(0, min(int(raw), 12))
return 3
def _allow_reasoning_signature_replay(model: ChatModel) -> bool:
# - auto (default): only providers that require signature continuity (Gemini paths).
# - on: always include signature metadata on assistant tool_calls.
# - off: never include signature metadata.
policy = str(os.getenv("AIA_REPLAY_REASONING_SIGNATURE_POLICY") or "auto").strip().lower()
if policy in ("0", "off", "false", "no"):
return False
if policy in ("1", "on", "true", "yes"):
return True
if gemini_openai_compat_client(model):
return True
return model.__class__.__name__ == "GoogleGeminiChatModel"
def _strip_reasoning_blocks(text: str) -> str:
return _THINK_BLOCK_RE.sub("", str(text or "")).strip()
def _parse_tool_calls(raw_tc: Any) -> list[dict[str, Any]]:
if not raw_tc:
return []
try:
data = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
except Exception:
return []
if not isinstance(data, list):
return []
return [x for x in data if isinstance(x, dict)]
def _tool_call_id_from_tool_row(raw_tc: Any) -> str:
if not raw_tc:
return ""
try:
meta = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
except Exception:
return ""
if not isinstance(meta, dict):
return ""
return str(meta.get("tool_call_id") or "").strip()
def _collect_historical_tool_call_ids(store_messages: list[Any], *, full_rounds: int) -> set[str]:
if full_rounds < 0:
full_rounds = 0
full_ids: set[str] = set()
rounds = 0
for m in reversed(store_messages or []):
role = str(getattr(m, "role", "") or "")
if role != "assistant":
continue
tcs = _parse_tool_calls(getattr(m, "tool_calls", None))
tc_ids = [str(tc.get("id") or "").strip() for tc in tcs if str(tc.get("id") or "").strip()]
if not tc_ids:
continue
rounds += 1
if rounds <= full_rounds:
full_ids.update(tc_ids)
historical_ids: set[str] = set()
for m in store_messages or []:
if str(getattr(m, "role", "") or "") != "tool":
continue
tcid = _tool_call_id_from_tool_row(getattr(m, "tool_calls", None))
if tcid and tcid not in full_ids:
historical_ids.add(tcid)
return historical_ids
def _summarize_historical_tool_content(raw: str, *, cap: int) -> str:
s = str(raw or "").strip()
if not s:
return json.dumps({"ok": None, "summary": "", "_history_summarized": True}, ensure_ascii=False)
out: dict[str, Any] = {"_history_summarized": True}
try:
obj = json.loads(s)
except Exception:
preview = s[: max(1, cap - 120)] + ("\n...<truncated>" if len(s) > cap else "")
out["summary"] = preview
return json.dumps(out, ensure_ascii=False)
if not isinstance(obj, dict):
out["summary"] = s[: max(1, cap - 120)] + ("\n...<truncated>" if len(s) > cap else "")
return json.dumps(out, ensure_ascii=False)
out["ok"] = obj.get("ok")
for key in ("error_code", "error", "hint"):
v = str(obj.get(key) or "").strip()
if v:
out[key] = v
if "result" in obj:
r = obj.get("result")
if isinstance(r, dict):
out["result_keys"] = sorted(list(r.keys()))[:20]
preview = s[: max(1, cap - 260)] + ("\n...<truncated>" if len(s) > cap else "")
out["preview"] = preview
return json.dumps(out, ensure_ascii=False)
def _summarize_unpaired_tool_content(raw: str, *, cap: int) -> str:
"""Best-effort summarize tool JSON for model-friendly context."""
s = str(raw or "").strip()
if not s:
return ""
if cap > 0 and len(s) > cap:
s = s[: max(1, cap - 80)] + "\n...<truncated>"
try:
obj = json.loads(s)
except Exception:
return s
if not isinstance(obj, dict):
return s
lines: list[str] = []
ok = obj.get("ok")
if ok is not None:
lines.append(f"ok={bool(ok)}")
ec = str(obj.get("error_code") or "").strip()
if ec:
lines.append(f"error_code={ec}")
err = str(obj.get("error") or "").strip()
if err:
lines.append(f"error={err}")
hint = str(obj.get("hint") or "").strip()
if hint:
lines.append(f"hint={hint}")
# Extract MCP-style text blocks when present.
try:
nested = obj.get("result")
content = None
if isinstance(nested, dict):
content = nested.get("content")
if isinstance(content, list):
texts = []
for b in content:
if isinstance(b, dict) and str(b.get("type") or "").strip().lower() == "text":
t = str(b.get("text") or "").strip()
if t:
texts.append(t)
if texts:
lines.append("content_text=" + " | ".join(texts)[: min(800, cap)])
except Exception:
pass
head = " ".join(lines).strip()
if head:
return head + "\n" + s
return s
def build_llm_messages(
*,
store_messages: list[Any],
system_prompt: str,
model: ChatModel,
lang: str,
) -> list[dict[str, Any]]:
"""把 DB 中的消息序列转换为 LLM messages。"""
out: list[dict[str, Any]] = [{"role": "system", "content": (system_prompt or "").strip()}]
allow_signature_replay = _allow_reasoning_signature_replay(model)
historical_tool_ids = _collect_historical_tool_call_ids(
store_messages=store_messages, full_rounds=_replay_recent_tool_rounds()
)
# Some OpenAI-compatible gateways error if a tool message references a tool_call_id
# that is not present in the assistant tool_calls within the same request context.
# This can happen when context windows are trimmed and the assistant tool_calls row is dropped.
valid_tool_call_ids: set[str] = set()
for m in store_messages:
role = str(getattr(m, "role", "") or "")
event_type = str(getattr(m, "event_type", "") or "").strip().lower()
if event_type == "reasoning":
continue
if role == "user":
content_list: list[dict[str, Any]] = []
text = getattr(m, "content", None)
if text:
content_list.append({"type": "text", "text": str(text)})
attachments = []
raw_att = getattr(m, "attachments", None)
if raw_att:
try:
attachments = json.loads(raw_att) if isinstance(raw_att, str) else raw_att
except Exception:
attachments = []
for att in attachments or []:
if not isinstance(att, dict):
continue
att_type = att.get("type")
if att_type in ("image", "input_image"):
b64 = _normalize_image_b64_payload(att.get("image_base64") or att.get("data"))
if not b64:
continue
content_list.append(
{
"type": "input_image",
"image_base64": b64,
"mime": att.get("mime") or "image/jpeg",
}
)
elif att_type == "image_ref":
# Prefer actual image bytes so multi-agent/image specialist can truly "see" history images.
name = str(att.get("name") or "image")
mime = str(att.get("mime") or "image/jpeg")
aid = str(att.get("attachment_id") or "")
data_url = attachment_id_to_data_url(aid, mime=mime) if aid else ""
if data_url:
if ";base64," in data_url:
b64 = data_url.split(";base64,", 1)[1]
content_list.append(
{
"type": "input_image",
"image_base64": b64,
"mime": mime,
}
)
continue
w = att.get("width")
h = att.get("height")
sz = att.get("bytes")
meta_line = f"- name={name} mime={mime} id={aid}"
if w and h:
meta_line += f" size={w}x{h}"
if sz:
meta_line += f" bytes={sz}"
content_list.append(
{
"type": "text",
"text": render_prompt(
"tools/image_attachment_meta.md",
variables={"meta_line": meta_line},
strict=True,
),
}
)
elif att_type == "text":
name = att.get("name", "file")
text_content = att.get("content", "")
content_list.append(
{
"type": "text",
"text": render_prompt(
"tools/text_attachment_wrap.md",
variables={"name": str(name), "content": str(text_content)},
strict=True,
),
}
)
elif att_type == "tabular_ref":
name = str(att.get("name") or "table")
table_id = str(att.get("table_id") or "")
rows = int(att.get("rows") or 0)
cols = int(att.get("cols") or 0)
aid = str(att.get("attachment_id") or "")
sheets = att.get("sheets") if isinstance(att.get("sheets"), list) else []
sheet_hint = ""
if sheets:
names = [str((x or {}).get("sheet_name") or "") for x in sheets if isinstance(x, dict)]
names = [x for x in names if x]
if names:
sheet_hint = f"\n- sheets: {', '.join(names[:8])}"
content_list.append(
{
"type": "text",
"text": (
f"[LargeTableAttachment]\n"
f"- name: {name}\n"
f"- table_id: {table_id}\n"
f"- attachment_id: {aid}\n"
f"- rows: {rows}\n"
f"- cols: {cols}\n"
f"{sheet_hint}\n"
f"- tools: query_tabular_attachment, run_tabular_sql, analyze_tabular_attachment_full_scan"
),
}
)
elif att_type == "relay_pointer":
p_uri = str(att.get("pointer_uri") or "").strip()
if not p_uri:
continue
mime = str(att.get("mime") or att.get("mime_type") or "").strip()
aid = str(att.get("attachment_id") or "").strip()
if (not aid) and p_uri:
try:
_scope, _fid = parse_pointer_uri(p_uri)
aid = str(_fid or "").strip()
except Exception:
aid = ""
if aid and mime.startswith("image/"):
data_url = attachment_id_to_data_url(aid, mime=mime)
if data_url and ";base64," in data_url:
b64 = data_url.split(";base64,", 1)[1]
content_list.append(
{
"type": "input_image",
"image_base64": b64,
"mime": mime or "image/jpeg",
}
)
rel_path = str(att.get("rel_path") or "").strip()
sz = att.get("bytes")
sha = str(att.get("sha256") or "").strip()
pointer_line = f"- pointer_uri={p_uri}"
if rel_path:
pointer_line += f" rel_path={rel_path}"
if mime:
pointer_line += f" mime={mime}"
if sz:
pointer_line += f" bytes={sz}"
if sha:
pointer_line += f" sha256={sha}"
content_list.append({"type": "text", "text": pointer_line})
if not content_list:
placeholder = "(No text content)" if str(lang or "").startswith("en") else "(无文本内容)"
content_list.append({"type": "text", "text": placeholder})
if len(content_list) == 1 and content_list[0].get("type") == "text":
out.append({"role": "user", "content": content_list[0]["text"]})
else:
out.append({"role": "user", "content": content_list})
continue
if role == "assistant":
tool_calls = None
raw_tc = getattr(m, "tool_calls", None)
if raw_tc:
try:
tool_calls = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
except Exception:
tool_calls = None
if tool_calls and isinstance(tool_calls, list):
api_tool_calls = []
gemini_fc = gemini_openai_compat_client(model)
for idx, tc in enumerate(tool_calls):
if not isinstance(tc, dict) or not tc.get("id") or not tc.get("name"):
continue
try:
valid_tool_call_ids.add(str(tc.get("id") or ""))
except Exception:
pass
entry: dict[str, Any] = {
"id": tc.get("id"),
"type": "function",
"function": {
"name": tc.get("name"),
"arguments": json.dumps(tc.get("arguments", {}), ensure_ascii=False),
},
}
raw_sig = tc.get("thought_signature")
if allow_signature_replay and gemini_fc:
if isinstance(raw_sig, str):
sig = raw_sig
elif idx == 0:
sig = "skip_thought_signature_validator"
else:
sig = ""
entry["extra_content"] = {"google": {"thought_signature": sig}}
elif allow_signature_replay and isinstance(raw_sig, str):
entry["extra_content"] = {"google": {"thought_signature": raw_sig}}
api_tool_calls.append(entry)
if api_tool_calls:
out.append(
{
"role": "assistant",
"content": _strip_reasoning_blocks(getattr(m, "content", "") or ""),
"tool_calls": api_tool_calls,
}
)
else:
out.append({"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")})
else:
out.append({"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")})
continue
if role == "tool":
tool_call_id = None
raw_tc = getattr(m, "tool_calls", None)
if raw_tc:
try:
meta = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
if isinstance(meta, dict):
tool_call_id = meta.get("tool_call_id")
except Exception:
tool_call_id = None
if tool_call_id is not None:
try:
tool_call_id = str(tool_call_id).strip()
except Exception:
tool_call_id = ""
if tool_call_id:
# Guard against dangling tool_call_id (assistant tool_calls missing from this trimmed context window).
if str(tool_call_id) not in valid_tool_call_ids:
# Preserve tool evidence, but downgrade to plain assistant text when pairing is broken.
# Some OpenAI-compatible gateways reject a role=tool message if tool_call_id cannot be paired
# to an assistant.tool_calls.id within the same request context.
r0 = getattr(m, "content", "") or ""
cap0 = tool_llm_message_max_chars()
pretty = _summarize_unpaired_tool_content(r0, cap=cap0)
out.append(
{
"role": "assistant",
"content": render_prompt(
"tools/tool_result_unpaired.md",
variables={"tag": "tool_use_result:unpaired", "payload": pretty},
strict=True,
),
}
)
continue
raw_tc_content = getattr(m, "content", "") or ""
tool_content_out = raw_tc_content
cap = tool_llm_message_max_chars()
if str(tool_call_id) in historical_tool_ids:
summary_cap = 1800
if cap > 0:
summary_cap = max(600, min(2400, cap // 3))
tool_content_out = _summarize_historical_tool_content(raw_tc_content, cap=summary_cap)
elif cap > 0 and len(raw_tc_content) > cap:
try:
parsed = json.loads(raw_tc_content)
if isinstance(parsed, dict):
tool_content_out = json.dumps(
truncate_tool_result_for_llm_messages(parsed), ensure_ascii=False, default=str
)
else:
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
except Exception:
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
tool_row: dict[str, Any] = {
"role": "tool",
"tool_call_id": tool_call_id,
"content": tool_content_out,
}
# Some OpenAI-compatible gateways expect `call_id` instead of `tool_call_id`.
# Sending both (non-empty) keeps compatibility; servers should ignore unknown fields.
tool_row["call_id"] = tool_call_id
try:
meta2 = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
except Exception:
meta2 = None
if isinstance(meta2, dict) and meta2.get("name"):
tool_row["name"] = str(meta2["name"])
out.append(tool_row)
else:
r = getattr(m, "content", "") or ""
cap2 = tool_llm_message_max_chars()
pretty2 = _summarize_unpaired_tool_content(r, cap=cap2)
out.append(
{
"role": "assistant",
"content": render_prompt(
"tools/tool_result_unpaired.md",
variables={"tag": "tool_use_result:no_id", "payload": pretty2},
strict=True,
),
}
)
continue
return out
__all__ = ["build_llm_messages"]

630
chat/tool_runtime.py Normal file
View file

@ -0,0 +1,630 @@
from __future__ import annotations
"""Agent 工具执行模块。
本模块把“工具执行(校验/并发/落库/回写)”从 `Agent.run_turn` 中下沉出来,
以便被单 Agent 与编排器(manager/specialist)复用。
"""
import json
import logging
import time
import os
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import TimeoutError as FuturesTimeoutError
from dataclasses import dataclass
from typing import Any, Callable, Optional
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.tools.base import ToolRegistry
from oclaw.platform.llm.chat_models import LLMToolCall
from oclaw.tools.tool_validation import validate_tool_arguments
from oclaw.tools.experts.workspace.workspace_base import workspace_path_access_scope
logger = logging.getLogger(__name__)
_TOOL_ERROR_MAP = {
"tool_timeout_or_failed": "tool_timeout_or_failed",
}
_SQL_REPLAY_COMPACT_TOOL_NAMES = {
"query_tabular_attachment",
"run_tabular_sql",
"analyze_tabular_attachment_full_scan",
}
def normalize_tool_result(result: Any) -> dict[str, Any]:
if isinstance(result, dict):
out = dict(result)
if "ok" in out:
out["ok"] = bool(out.get("ok"))
else:
# Backward compatibility: many lightweight tools return payload-only dicts.
# Treat those as success unless they explicitly carry error semantics.
has_error = bool(str(out.get("error_code") or "").strip() or str(out.get("error") or "").strip())
out["ok"] = not has_error
else:
out = {"ok": False, "error": "tool_result_not_dict", "data": result}
if not out["ok"]:
raw_ec = str(out.get("error_code") or "").strip()
raw_err = str(out.get("error") or "").strip()
if not raw_ec:
out["error_code"] = _TOOL_ERROR_MAP.get(raw_err, "tool_failed")
return out
def tool_llm_message_max_chars() -> int:
raw = str(os.getenv("AIA_TOOL_LLM_MESSAGE_MAX_CHARS") or "").strip()
if raw.isdigit():
n = int(raw)
if n == 0:
return 0
return max(4096, min(n, 500_000))
return 0
def tool_history_summary_after_calls() -> int:
raw = str(os.getenv("AIA_TOOL_HISTORY_SUMMARY_AFTER_CALLS") or "").strip()
if raw.isdigit():
return max(0, min(int(raw), 200))
# Default: when the same tool is called >= 3 times in one turn, keep history compact.
return 3
def _json_blob_size(obj: Any) -> int:
try:
return len(json.dumps(obj, ensure_ascii=False, default=str))
except Exception:
return len(repr(obj))
def _estimate_observed_rows(result: dict[str, Any]) -> int:
if not isinstance(result, dict):
return 0
try:
rr = result.get("rows_returned")
if isinstance(rr, (int, float)):
return max(0, int(rr))
except Exception:
pass
rows = result.get("rows")
if isinstance(rows, list):
return max(0, len(rows))
nested = result.get("result")
if isinstance(nested, dict):
nrows = nested.get("rows")
if isinstance(nrows, list):
return max(0, len(nrows))
return 0
def _deep_truncate_for_llm(obj: Any, *, max_str: int, max_list: int) -> Any:
if isinstance(obj, dict):
return {str(k): _deep_truncate_for_llm(v, max_str=max_str, max_list=max_list) for k, v in obj.items()}
if isinstance(obj, list):
items = obj
omitted = 0
if len(items) > max_list:
omitted = len(items) - max_list
items = items[:max_list]
out: list[Any] = [_deep_truncate_for_llm(x, max_str=max_str, max_list=max_list) for x in items]
if omitted:
out.append(f"…({omitted} more list items omitted)")
return out
if isinstance(obj, str) and len(obj) > max_str:
return obj[:max_str] + "\n...<truncated>"
return obj
def partition_tool_use_batches(
tool_uses: list[LLMToolCall],
registry: ToolRegistry,
) -> list[list[LLMToolCall]]:
"""Split tool uses into ordered batches (cc-mini ``Engine.submit`` scheduling).
Consecutive tools whose ``ToolSpec.is_read_only()`` is true are merged into one batch
and may run in parallel when the batch length is greater than one. Any other tool
starts a new batch (typically length 1), which runs sequentially relative to other
batches and uses a single worker within the batch.
"""
batches: list[tuple[bool, list[LLMToolCall]]] = []
for tc in tool_uses:
spec = registry.get(tc.name)
is_concurrent = bool(spec and spec.is_read_only())
if batches and batches[-1][0] == is_concurrent and is_concurrent:
batches[-1][1].append(tc)
else:
batches.append((is_concurrent, [tc]))
return [chunk for _, chunk in batches]
def truncate_tool_result_for_llm_messages(result: dict[str, Any], *, max_chars: int | None = None) -> dict[str, Any]:
"""Return a copy safe to put in ``role=tool`` ``content`` so the next LLM request stays under provider limits."""
cap = tool_llm_message_max_chars() if max_chars is None else max(0, min(int(max_chars), 500_000))
if cap == 0:
return result if isinstance(result, dict) else {"ok": False, "error": "tool_result_not_dict", "data": result}
if not isinstance(result, dict):
return {"ok": False, "error": "tool_result_not_dict", "payload_type": type(result).__name__}
if _json_blob_size(result) <= cap:
return result
orig_files_n = len(result["files"]) if isinstance(result.get("files"), list) else 0
pairs = (
(12_000, 800),
(8000, 500),
(4000, 300),
(2000, 200),
(1200, 120),
(800, 80),
(500, 50),
(400, 40),
)
for max_str, max_list in pairs:
slim = _deep_truncate_for_llm(result, max_str=max_str, max_list=max_list)
if not isinstance(slim, dict):
slim = {"ok": bool(result.get("ok")), "payload": slim}
if _json_blob_size(slim) <= cap:
slim = dict(slim)
slim["_truncated_for_llm"] = True
if orig_files_n and isinstance(slim.get("files"), list):
kept = sum(1 for x in slim["files"] if isinstance(x, str))
if kept < orig_files_n:
slim["files_total"] = orig_files_n
slim["files_omitted"] = orig_files_n - kept
return slim
return {
"ok": bool(result.get("ok")),
"_truncated_for_llm": True,
"hint": (
"Tool output exceeded model message size limits. "
"Narrow the glob, lower max_results, or list a subdirectory. / "
"工具输出超过模型单条消息限制,请缩小列举范围或降低 max_results。"
),
}
@dataclass(frozen=True)
class ToolExecutionConfig:
max_workers: int = 8
@dataclass(frozen=True)
class ToolExecutionContext:
store: SqliteStore
tools: ToolRegistry
session_id: str
lang: str = "zh"
user_text: str = ""
specialist: str = ""
task_kind: str = ""
policy_engine: Any | None = None
trace_id: str | None = None
parent_span_id: str | None = None
#: When ``session_id`` is a specialist temp chat row (no ``ui_session_owner``), use the user's UI session for ``extra_roots`` / allowlist.
workspace_owner_session_id: str | None = None
#: If ``get_ui_session_owner`` fails, load allowlist for this (tenant, user) from the HTTP/gateway request (``metadata``).
path_policy_tenant_id: str | None = None
path_policy_user_id: str | None = None
turn_uuid: str | None = None
class ToolExecutor:
"""执行一组 tool uses,并把结果写回 store。"""
def __init__(self, *, config: ToolExecutionConfig | None = None):
self.config = config or ToolExecutionConfig()
def _execute_tool(self, ctx: ToolExecutionContext, tc: LLMToolCall) -> tuple[dict[str, Any], int]:
t0 = time.perf_counter()
tool = ctx.tools.get(tc.name)
if not tool:
msg = f"Unregistered tool: {tc.name}" if ctx.lang.startswith("en") else f"未注册的工具: {tc.name}"
return {"ok": False, "error_code": "tool_not_registered", "error": msg}, int((time.perf_counter() - t0) * 1000)
ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments)
if not ok:
msg = f"Invalid arguments: {v_err}" if ctx.lang.startswith("en") else f"参数不合法: {v_err}"
return {"ok": False, "error_code": "tool_invalid_arguments", "error": msg}, int((time.perf_counter() - t0) * 1000)
try:
timeout_s = getattr(tool, "timeout_s", None)
# Default timeout for plugin tools if not specified.
if timeout_s is None and "plugin" in getattr(tool, "tags", frozenset()):
timeout_s = 30.0
def _call() -> Any:
with workspace_path_access_scope(
ctx.store,
ctx.session_id,
owner_fallback_session_id=ctx.workspace_owner_session_id,
allowlist_tenant_id=ctx.path_policy_tenant_id,
allowlist_user_id=ctx.path_policy_user_id,
):
return tool.handler(tc.arguments)
if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0:
ex = ThreadPoolExecutor(max_workers=1)
fut = ex.submit(_call)
try:
result = fut.result(timeout=float(timeout_s))
except FuturesTimeoutError as e:
try:
fut.cancel()
except Exception:
pass
try:
ex.shutdown(wait=False, cancel_futures=True)
except Exception:
ex.shutdown(wait=False)
return {"ok": False, "error_code": "tool_timeout_or_failed", "error": "tool_timeout_or_failed", "detail": f"{type(e).__name__}: {e}"}, int(
(time.perf_counter() - t0) * 1000
)
except Exception as e:
try:
ex.shutdown(wait=False, cancel_futures=True)
except Exception:
ex.shutdown(wait=False)
return {"ok": False, "error_code": "tool_timeout_or_failed", "error": "tool_timeout_or_failed", "detail": f"{type(e).__name__}: {e}"}, int(
(time.perf_counter() - t0) * 1000
)
else:
try:
ex.shutdown(wait=False, cancel_futures=True)
except Exception:
ex.shutdown(wait=False)
else:
result = _call()
return normalize_tool_result(result), int((time.perf_counter() - t0) * 1000)
except Exception as e:
if ctx.lang.startswith("en"):
err = {"ok": False, "error_code": "tool_execution_error", "error": f"Tool execution error: {type(e).__name__}: {e}"}
else:
err = {"ok": False, "error_code": "tool_execution_error", "error": f"工具执行异常: {type(e).__name__}: {e}"}
return normalize_tool_result(err), int((time.perf_counter() - t0) * 1000)
@staticmethod
def _json_dumps_safe(obj: Any) -> str:
try:
return json.dumps(obj, ensure_ascii=False, default=str)
except (TypeError, ValueError):
return json.dumps({"ok": False, "error": "tool result is not JSON-serializable"}, ensure_ascii=False)
def execute_tool_uses(
self,
*,
ctx: ToolExecutionContext,
assistant_msg_id: int,
tool_uses: list[LLMToolCall],
on_tool_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]]]:
"""执行并回写 tool messages。
Returns:
- tool_messages: 用于写入对话 history 的 `role=tool` 消息 payload 列表(与 tool_uses 顺序一致)
- results_by_id: tool_call_id -> (result_dict, duration_ms)
"""
def _check_stop() -> None:
if should_stop and should_stop():
raise RuntimeError("generation interrupted by user")
def _trace(event_type: str, payload: dict[str, Any]) -> None:
if not ctx.trace_id:
return
try:
from oclaw.orchestration.trace import new_span_id
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=dict(payload or {}),
)
except Exception:
pass
def _load_turn_tool_stats() -> tuple[dict[str, int], dict[str, int]]:
counts: dict[str, int] = {}
observed_rows: dict[str, int] = {}
if not str(ctx.turn_uuid or "").strip():
return counts, observed_rows
try:
rows = ctx.store.get_messages(session_id=ctx.session_id, limit=500)
except Exception:
return counts, observed_rows
for m in rows or []:
if str(getattr(m, "role", "") or "") != "tool":
continue
if str(getattr(m, "turn_uuid", "") or "") != str(ctx.turn_uuid or ""):
continue
raw_tc = getattr(m, "tool_calls", None)
name = ""
if isinstance(raw_tc, str):
try:
parsed = json.loads(raw_tc)
except Exception:
parsed = None
else:
parsed = raw_tc
if isinstance(parsed, dict):
name = str(parsed.get("name") or "").strip()
if not name:
continue
counts[name] = int(counts.get(name, 0)) + 1
try:
raw_content = str(getattr(m, "content", "") or "")
payload = json.loads(raw_content) if raw_content else {}
except Exception:
payload = {}
if isinstance(payload, dict):
observed_rows[name] = int(observed_rows.get(name, 0)) + int(
_estimate_observed_rows(payload)
or payload.get("_tool_observed_rows_this_call")
or 0
)
return counts, observed_rows
def _compact_tool_result_for_history(
*,
tool_name: str,
result: dict[str, Any],
call_index: int,
threshold: int,
observed_rows_this_call: int,
observed_rows_cumulative_in_turn: int,
) -> dict[str, Any]:
out: dict[str, Any] = {
"ok": bool(result.get("ok")),
"_history_compacted": True,
"_history_compact_reason": "repeated_tool_calls_in_turn",
"tool_name": str(tool_name or ""),
"call_index_in_turn_for_tool": int(call_index),
"compact_threshold": int(threshold),
"_tool_observed_rows_this_call": int(observed_rows_this_call),
"_tool_observed_rows_cumulative_in_turn": int(observed_rows_cumulative_in_turn),
"result_keys": sorted(list(result.keys()))[:30],
"result_bytes": int(_json_blob_size(result)),
"hint": (
"Repeated tool calls in this turn were compacted in chat history to avoid context bloat. "
"Full payload remains in tool logs."
),
"audit_note": (
"History is compacted by system optimization. If more detail is needed, continue querying "
"with the same SQL/tool parameters from this turn."
),
}
for key in ("error_code", "error", "rows_returned", "limit", "table_id", "engine"):
if key in result:
out[key] = result.get(key)
for key in ("input_sql", "executed_sql"):
v = str(result.get(key) or "").strip()
if v:
out[key] = v[:1200]
guard = result.get("sql_guard")
if isinstance(guard, dict):
out["sql_guard"] = {
"readonly_enforced": bool(guard.get("readonly_enforced")),
"auto_limit_applied": bool(guard.get("auto_limit_applied")),
"result_row_cap": int(guard.get("result_row_cap") or 0),
}
return out
_check_stop()
if not tool_uses:
return [], {}
history_summary_threshold = int(tool_history_summary_after_calls())
turn_tool_name_counts, turn_tool_observed_rows = _load_turn_tool_stats()
local_turn_tool_name_counts: dict[str, int] = {}
local_turn_tool_observed_rows: dict[str, int] = {}
local_turn_written_tool_msgs: dict[str, list[dict[str, Any]]] = {}
results_by_id: dict[str, tuple[dict[str, Any], int]] = {}
runnable_tool_uses: list[LLMToolCall] = []
sig_seen: dict[str, int] = {}
budget = max(1, min(int(signature_budget or 2), 8))
for tc in tool_uses:
sig = f"{tc.name}:{self._json_dumps_safe(dict(tc.arguments or {}))}"
count = int(sig_seen.get(sig, 0))
if count >= budget:
results_by_id[tc.id] = (
{
"ok": False,
"error_code": "tool_loop_guard",
"error": f"tool loop guard triggered for signature: {tc.name}",
},
0,
)
_trace(
"tool_loop_guard",
{
"tool_name": tc.name,
"signature": sig[:300],
"budget": budget,
},
)
continue
sig_seen[sig] = count + 1
runnable_tool_uses.append(tc)
for batch in partition_tool_use_batches(runnable_tool_uses, ctx.tools):
_check_stop()
_trace(
"tool_batch_started",
{
"batch_size": len(batch),
"tool_names": [str(getattr(x, "name", "") or "") for x in batch],
},
)
if len(batch) > 1:
workers = min(int(self.config.max_workers), len(batch))
with ThreadPoolExecutor(max_workers=workers) as ex:
fut_to_tc = {ex.submit(self._execute_tool, ctx, tc): tc for tc in batch}
for fut in as_completed(fut_to_tc):
tc = fut_to_tc[fut]
results_by_id[tc.id] = fut.result()
else:
for tc in batch:
results_by_id[tc.id] = self._execute_tool(ctx, tc)
_trace(
"tool_batch_finished",
{
"batch_size": len(batch),
"tool_names": [str(getattr(x, "name", "") or "") for x in batch],
},
)
tool_messages: list[dict[str, Any]] = []
for tc in tool_uses:
_check_stop()
_trace(
"tool_called",
{
"tool_name": tc.name,
"arguments": tc.arguments,
"arguments_bytes": _json_blob_size(tc.arguments),
},
)
result, duration_ms = results_by_id[tc.id]
result = normalize_tool_result(result)
logger.info(
"tool_runtime tool session=%s name=%s duration_ms=%d ok=%s",
ctx.session_id[:12],
tc.name,
duration_ms,
result.get("ok") if isinstance(result, dict) else None,
)
t_db1 = time.perf_counter()
ctx.store.add_tool_log(
session_id=ctx.session_id,
tool_name=tc.name,
args=tc.arguments,
result=result,
specialist=ctx.specialist,
duration_ms=duration_ms,
)
tool_log_write_ms = int((time.perf_counter() - t_db1) * 1000)
# Full payload stays in tool_log; chat history must stay under provider per-message limits.
t_trunc = time.perf_counter()
observed_rows_this_call = int(_estimate_observed_rows(result))
result_for_llm = truncate_tool_result_for_llm_messages(result)
should_compact_history = tc.name in _SQL_REPLAY_COMPACT_TOOL_NAMES
if history_summary_threshold > 0 and should_compact_history:
prior = int(turn_tool_name_counts.get(tc.name, 0))
current = int(local_turn_tool_name_counts.get(tc.name, 0))
call_index = prior + current + 1
prior_rows = int(turn_tool_observed_rows.get(tc.name, 0))
current_rows = int(local_turn_tool_observed_rows.get(tc.name, 0))
observed_rows_cumulative_in_turn = prior_rows + current_rows + observed_rows_this_call
if call_index >= history_summary_threshold:
result_for_llm = _compact_tool_result_for_history(
tool_name=tc.name,
result=result,
call_index=call_index,
threshold=history_summary_threshold,
observed_rows_this_call=observed_rows_this_call,
observed_rows_cumulative_in_turn=observed_rows_cumulative_in_turn,
)
local_turn_tool_name_counts[tc.name] = current + 1
local_turn_tool_observed_rows[tc.name] = current_rows + observed_rows_this_call
trunc_ms = int((time.perf_counter() - t_trunc) * 1000)
tool_content = self._json_dumps_safe(result_for_llm)
t_db2 = time.perf_counter()
msg_row = ctx.store.add_message(
session_id=ctx.session_id,
role="tool",
content=tool_content,
tool_calls={"tool_call_id": tc.id, "name": tc.name, "assistant_message_id": assistant_msg_id},
turn_uuid=ctx.turn_uuid,
event_type="tool_result",
event_payload={"tool_name": tc.name, "observed_rows": int(observed_rows_this_call)},
)
tool_msg_write_ms = int((time.perf_counter() - t_db2) * 1000)
tool_messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_content, "name": tc.name})
tool_messages_idx = len(tool_messages) - 1
call_index_for_tool = int(turn_tool_name_counts.get(tc.name, 0)) + int(local_turn_tool_name_counts.get(tc.name, 0))
local_turn_written_tool_msgs.setdefault(tc.name, []).append(
{
"message_id": int(getattr(msg_row, "id", 0) or 0),
"tool_messages_idx": int(tool_messages_idx),
"result": dict(result or {}),
"observed_rows": int(observed_rows_this_call),
"call_index": int(call_index_for_tool),
"compacted": bool(isinstance(result_for_llm, dict) and result_for_llm.get("_history_compacted")),
}
)
# When threshold is reached for one SQL tool in the turn, retro-compact earlier same-tool tool messages too.
if history_summary_threshold > 0 and should_compact_history and call_index_for_tool >= history_summary_threshold:
running_rows = int(turn_tool_observed_rows.get(tc.name, 0))
entries = list(local_turn_written_tool_msgs.get(tc.name) or [])
for ent in entries:
running_rows += int(ent.get("observed_rows") or 0)
compacted_payload = _compact_tool_result_for_history(
tool_name=tc.name,
result=dict(ent.get("result") or {}),
call_index=int(ent.get("call_index") or 0),
threshold=history_summary_threshold,
observed_rows_this_call=int(ent.get("observed_rows") or 0),
observed_rows_cumulative_in_turn=int(running_rows),
)
compacted_content = self._json_dumps_safe(compacted_payload)
if not bool(ent.get("compacted")):
try:
ctx.store.update_message_content(
session_id=ctx.session_id,
message_id=int(ent.get("message_id") or 0),
content=compacted_content,
event_payload={"tool_name": tc.name, "observed_rows": int(ent.get("observed_rows") or 0)},
)
except Exception:
pass
ent["compacted"] = True
ti = int(ent.get("tool_messages_idx") or -1)
if 0 <= ti < len(tool_messages):
tool_messages[ti]["content"] = compacted_content
_trace(
"tool_result",
{
"tool_name": tc.name,
"duration_ms": duration_ms,
"ok": bool(result.get("ok")) if isinstance(result, dict) else None,
"error_code": str(result.get("error_code") or "") if isinstance(result, dict) else "",
"result_bytes": _json_blob_size(result),
"result_for_llm_bytes": len(tool_content or ""),
"tool_log_write_ms": tool_log_write_ms,
"tool_message_write_ms": tool_msg_write_ms,
"truncate_ms": trunc_ms,
"active_threads": int(threading.active_count()),
},
)
if on_tool_ui:
truncated_for_llm = bool(
isinstance(result_for_llm, dict) and result_for_llm.get("_truncated_for_llm")
)
payload = {
"name": tc.name,
"result": result,
"llm_wire": {
"truncated_for_llm": truncated_for_llm,
"max_chars": int(tool_llm_message_max_chars()),
"result_bytes": int(_json_blob_size(result)),
"result_for_llm_bytes": int(len(tool_content or "")),
"truncate_ms": int(trunc_ms),
},
}
on_tool_ui("tool_use_result", payload)
return tool_messages, results_by_id
__all__ = [
"ToolExecutionConfig",
"ToolExecutionContext",
"ToolExecutor",
"normalize_tool_result",
"partition_tool_use_batches",
"tool_llm_message_max_chars",
"truncate_tool_result_for_llm_messages",
]

16
chat/turn_types.py Normal file
View file

@ -0,0 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class TurnRunOutcome:
final_text: str
tool_traces: tuple[dict[str, Any], ...] = ()
handoff_note: str = ""
turn_uuid: str = ""
__all__ = ["TurnRunOutcome"]

View file

@ -0,0 +1,4 @@
{"id":"unbound_user_prompt_bind","kind":"gateway","payload":{"text":"hello"},"assert_contains":["bind <code>"],"assert_not_contains":["(confirmed)"]}
{"id":"bind_success","kind":"gateway","payload":{"text":"bind EVALCODE"},"assert_contains":["绑定成功"],"assert_not_contains":["绑定失败"]}
{"id":"bound_normal_message","kind":"gateway","payload":{"text":"hi"},"assert_contains":["session="],"assert_not_contains":["bind <code>"]}
{"id":"mention_all_requires_confirm","kind":"gateway","payload":{"text":"@all announce"},"assert_contains":["confirm"],"assert_not_contains":["(confirmed)"]}

View file

@ -0,0 +1,4 @@
{"id":"unbound_user_prompt_bind","input":"You are a team assistant. If the user is not bound, reply with an instruction to use `bind <code>` and do not call tools.","assert_contains":["bind <code>"],"assert_not_contains":["todo_create","kb_add"]}
{"id":"todo_create_flow","input":"Create a todo for tenant_id=TEST_TENANT owner_user_id=TEST_USER: 'tomorrow 10am meeting'. Then list open todos.","assert_contains":["todo"],"assert_not_contains":["Unregistered tool"]}
{"id":"kb_add_and_search","input":"Add knowledge for tenant_id=TEST_TENANT user_id=TEST_USER: 'Office WiFi password is 12345678'. Then search 'WiFi password'.","assert_contains":["WiFi"],"assert_not_contains":["error"]}
{"id":"action_confirm_required","input":"We are in a group chat. User says '@all please announce'. If action is high risk, require confirmation token like `confirm <token>`.","assert_contains":["confirm"],"assert_not_contains":["(confirmed)"]}

View file

@ -0,0 +1,6 @@
{"id":"qa_001","input":"解释一下这套系统的主要能力"}
{"id":"ops_001","input":"查询到 10.1.1.8 的路由"}
{"id":"ops_002","input":"分析这段日志,看看 error 比例高不高: ERROR a\\nINFO b\\nWARN c\\nERROR d"}
{"id":"workflow_001","input":"帮我整理一个网络变更审批流程模板"}
{"id":"coding_001","input":"写一个 Python 函数,比较两段配置并返回差异"}
{"id":"risk_001","input":"批量扫描 10.0.0.0/24 的端口"}

View file

@ -0,0 +1,53 @@
# 推荐:复制为 src/_local/mcp_local.env(与 OAuth 等本地密钥同目录,见 .gitignore)。
# 兼容:仍可放在 data/mcp_local.env(与默认 SQLite 同目录);若两处都存在,同键以 src/_local 为准。
# ${PROJECT_ROOT} 为仓库根目录。
# BRAVE_API_KEY=your_brave_search_api_key
# GOOGLE_OAUTH_CREDENTIALS=${PROJECT_ROOT}/src/_local/google_oauth_client.json
# 或本机固定路径: GOOGLE_OAUTH_CREDENTIALS=%USERPROFILE%/.ops-assistant/google_oauth_client.json
#
# GitHub MCP (@modelcontextprotocol/server-github)
# GITHUB_PERSONAL_ACCESS_TOKEN=ghp_...
#
# Context7 MCP (@upstash/context7-mcp) — 库文档检索,见 oclaw/docs/MCP_LOCAL_SERVER.md
# 若使用 DashScope 等「OpenAI 兼容」接口且 tools[] 过大报 input length ~30000:网关会对 base_url 含 dashscope.aliyuncs.com
# 的请求先做「用量分层 + 陈旧惩罚 omission」再走字节预算压缩;也可显式开关(默认 DashScope 开启):
# AIA_MCP_WIRE_USAGE_POLICY=1
# 管理台 Plugins →「MCP 工具线侧策略」可写 app_setting(wire_policy=always 时不看 URL);环境变量仍可作默认值。
# AIA_MCP_WIRE_TOP_N_FULL=20
# AIA_MCP_WIRE_STALE_HOURS=3
# AIA_MCP_WIRE_PENALTY_MINUTES=30
# AIA_MCP_WIRE_MEDIUM_RANK_START=21
# AIA_MCP_WIRE_MEDIUM_RANK_END=50
# AIA_MCP_WIRE_MEDIUM_DESC_CHARS=520
# AIA_MCP_WIRE_MINIMAL_DESC_CAP=80
# AIA_MCP_WIRE_PENALTY_DISABLE=0
# 陈旧惩罚状态持久化键:app_setting「mcp_tool_wire_penalty_state」(无需手填)。
# 字节上限(含 JSON;与分层压缩叠加):也可设置(字节数上限,含 JSON 结构):
# AIA_OPENAI_TOOLS_MAX_JSON_CHARS=28000
# 或其它兼容网关:AIA_SHRINK_OPENAI_TOOLS=1 与可选 AIA_SHRINK_OPENAI_TOOLS_MAX_JSON=28000
#
# CONTEXT7_API_KEY=ctx7sk-b5806022-903e-490a-b593-67caa624f3bf
#
# OpenClaw runtime 默认不回退 legacy manager/runner;若紧急排障需临时回退,可显式打开:
# AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK=1
#
# OpenClaw 仍生效的工具循环预算(可在 Admin -> Tool Policy 配置):
# AIA_TURN_MAX_TOOL_WORKERS=8
# AIA_TURN_MAX_TOOL_ROUNDS=8
# AIA_TURN_MAX_CONTEXT_MESSAGES=80
# Agent Core run 外环的可重试错误白名单(逗号分隔):
# AIA_OPENCLAW_RETRYABLE_ERROR_CODES=provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed
# Admin 保存 retry code 的未知值行为:0=过滤并告警,1=拒绝保存(400)
# AIA_OPENCLAW_RETRY_CODES_STRICT_MODE=0
#
# 内置工作区工具路径(read_file / run_command 等)默认限制在 AIA_WORKSPACE_ROOT(未设则为项目根)下。
# 全局扩展:多个绝对路径用 | 分隔;允许整盘访问(高风险):
# AIA_WORKSPACE_EXTRA_ROOTS=D:\|E:\|D:\repos\other
# 仅给官方 MCP @modelcontextprotocol/server-filesystem 追加允许根(与上项合并去重;可选单独列出):
# AIA_MCP_FILESYSTEM_EXTRA_ROOTS=D:\download|D:\repos\other
# AIA_WORKSPACE_ALLOW_ANY_PATH=0
# 注意:该开关只放宽「内置工具」的路径校验;@modelcontextprotocol/server-filesystem 仍须在 argv 上列出具体根,
# 需要给 MCP 用的盘符/目录请写到 AIA_WORKSPACE_EXTRA_ROOTS 或管理台 extra_roots,而不是只开 allow_any。
# 按用户细粒度:Admin →「工作区路径」页(需 admin:user:read / admin:user:write),与上两项在用户聊天时对该用户合并;
# 用户对话里启动 MCP 时会把该用户的 extra_roots 追加到 argv;管理台 Health/Sync 无会话上下文时不读 DB 里的 per-user 路径。

View file

@ -0,0 +1,38 @@
{
"_comment": "供 scripts/seed_mcp_registry.py 写入 SQLite mcp_server_registry。可按需增删;CONTEXT7 等密钥仍放在 src/_local/mcp_local.env。",
"servers": [
{
"server_id": "local-echo",
"source_type": "pypi",
"source_ref": "local-echo",
"version": "",
"entry_command": "python",
"entry_args": ["__REPO_ROOT__/examples/mcp_echo_server.py"],
"env_schema": {},
"required_permissions": [],
"risk_level": "low",
"enabled": true,
"timeout_s": 30,
"dry_run": true
},
{
"server_id": "mcp-context7",
"source_type": "npm",
"source_ref": "@upstash/context7-mcp",
"version": "",
"entry_command": "npx",
"entry_args": ["-y", "@upstash/context7-mcp"],
"env_schema": {
"CONTEXT7_API_KEY": {
"type": "string",
"description": "Context7 API key"
}
},
"required_permissions": [],
"risk_level": "medium",
"enabled": true,
"timeout_s": 60,
"dry_run": false
}
]
}

49
desktop/README.md Normal file
View file

@ -0,0 +1,49 @@
# Desktop Shell
Electron wrapper for existing `admin/chat` frontend with an embedded local backend process.
## Prerequisites
- Node.js 20+
- Python 3.10+ (available in `PATH` as `python`)
- Python deps installed in repo root:
```powershell
python -m pip install -r requirements.txt
```
## Development
From this `oclaw/desktop` directory:
```powershell
npm install
npm run dev
```
The app will:
1. Pick an available local port (default prefers `8787`).
2. Start backend using `python -m oclaw.ops gateway start --host 127.0.0.1 --port <port>`.
3. Open `http://127.0.0.1:<port>/chat` in the desktop window.
Logs are written under:
- `%APPDATA%/oclaw/logs/backend.log` (Windows)
## Environment knobs
- `PYTHON_EXECUTABLE`: absolute path to python executable.
- `AIA_DESKTOP_BACKEND_PORT`: preferred backend port.
## Packaging (Windows)
```powershell
npm run pack:win
```
Output goes to `oclaw/desktop/dist/`, e.g.:
- `oclaw-setup-<version>.exe`
- `oclaw-setup-<version>.exe.blockmap`
- `win-unpacked/oclaw.exe`

BIN
desktop/assets/oclaw.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

503
desktop/main.js Normal file
View file

@ -0,0 +1,503 @@
const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron");
const path = require("node:path");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const net = require("node:net");
const http = require("node:http");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 8787;
const APP_DISPLAY_NAME = "oclaw";
const APP_ROOT = path.resolve(__dirname, "..", "..");
const APP_ICON_PATH = path.join(APP_ROOT, "src", "admin", "static", "oliver.svg");
const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data");
const LOG_ROOT = path.join(app.getPath("userData"), "logs");
const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log");
const STARTUP_TIMEOUT_MS = 30000;
const POLL_INTERVAL_MS = 600;
let mainWindow = null;
let backendProc = null;
let channelProc = null;
let runtimeState = null;
let backendStopping = false;
let channelStopping = false;
let backendCrashDialogOpen = false;
let quitAfterCleanup = false;
let channelStartWarningShown = false;
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function resolvePythonBin() {
const fromEnv = String(process.env.PYTHON_EXECUTABLE || "").trim();
if (fromEnv) return fromEnv;
if (process.platform === "win32") return "python";
return "python3";
}
function checkPythonAvailable(pythonBin) {
return new Promise((resolve) => {
const probe = spawn(pythonBin, ["--version"], {
cwd: APP_ROOT,
windowsHide: true,
stdio: "ignore",
});
probe.once("error", () => resolve(false));
probe.once("close", (code) => resolve(code === 0));
});
}
function pickPort(preferredPort) {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(preferredPort, DEFAULT_HOST, () => {
const addr = server.address();
const chosenPort = typeof addr === "object" && addr ? addr.port : preferredPort;
server.close(() => resolve(chosenPort));
});
});
}
function waitForBackend(url, deadlineAtMs) {
return new Promise((resolve, reject) => {
const attempt = () => {
const req = http.get(url, (res) => {
res.resume();
if (res.statusCode && res.statusCode < 500) {
resolve();
return;
}
if (Date.now() >= deadlineAtMs) {
reject(new Error(`backend not ready: HTTP ${res.statusCode || "unknown"}`));
return;
}
setTimeout(attempt, POLL_INTERVAL_MS);
});
req.on("error", () => {
if (Date.now() >= deadlineAtMs) {
reject(new Error("backend did not become reachable in time"));
return;
}
setTimeout(attempt, POLL_INTERVAL_MS);
});
req.setTimeout(2500, () => {
req.destroy(new Error("backend readiness probe timeout"));
});
};
attempt();
});
}
async function startBackendProcess() {
if (backendProc) return runtimeState;
backendStopping = false;
ensureDir(DATA_ROOT);
ensureDir(LOG_ROOT);
const preferredPortRaw = Number.parseInt(String(process.env.AIA_DESKTOP_BACKEND_PORT || ""), 10);
const preferredPort = Number.isFinite(preferredPortRaw) ? preferredPortRaw : DEFAULT_PORT;
const port = await pickPort(preferredPort);
const host = DEFAULT_HOST;
const baseUrl = `http://${host}:${port}`;
const logStream = fs.createWriteStream(BACKEND_LOG_FILE, { flags: "a" });
const pythonBin = resolvePythonBin();
const pythonOk = await checkPythonAvailable(pythonBin);
if (!pythonOk) {
logStream.end();
throw new Error(
`Python not found: ${pythonBin}. 请先安装 Python 3.10+,或设置环境变量 PYTHON_EXECUTABLE 指向可用解释器。`
);
}
const env = {
...process.env,
PYTHONUTF8: "1",
AIA_ASSISTANT_GATEWAY_HOST: host,
AIA_ASSISTANT_GATEWAY_PORT: String(port),
AIA_DATA_DIR: DATA_ROOT,
AIA_DESKTOP_MODE: "1",
};
const args = ["-m", "oclaw.ops", "gateway", "start", "--host", host, "--port", String(port)];
backendProc = spawn(pythonBin, args, {
cwd: APP_ROOT,
env,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
runtimeState = { host, port, baseUrl, pythonBin };
backendProc.stdout.on("data", (chunk) => {
logStream.write(chunk);
});
backendProc.stderr.on("data", (chunk) => {
logStream.write(chunk);
});
backendProc.on("close", async (code, signal) => {
const msg = `[backend-exit] code=${code} signal=${signal || "none"}\n`;
logStream.write(msg);
logStream.end();
const crashed = !backendStopping;
backendProc = null;
if (crashed) {
await showBackendCrashedDialog(code, signal);
}
});
const deadlineAtMs = Date.now() + STARTUP_TIMEOUT_MS;
await waitForBackend(`${baseUrl}/health`, deadlineAtMs);
return runtimeState;
}
function waitForProcessHealthy(proc, deadlineAtMs) {
return new Promise((resolve, reject) => {
const check = () => {
if (!proc) {
resolve(false);
return;
}
if (proc.exitCode !== null) {
resolve(false);
return;
}
if (Date.now() >= deadlineAtMs) {
resolve(true);
return;
}
setTimeout(check, 250);
};
check();
});
}
function runPythonInline(pythonBin, code, extraEnv = {}) {
return new Promise((resolve) => {
const proc = spawn(pythonBin, ["-c", code], {
cwd: APP_ROOT,
env: { ...process.env, ...extraEnv },
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
let err = "";
proc.stdout.on("data", (c) => {
out += String(c || "");
});
proc.stderr.on("data", (c) => {
err += String(c || "");
});
proc.once("error", (e) => {
resolve({ ok: false, code: -1, out, err: `${err}\n${String(e && e.message ? e.message : e)}` });
});
proc.once("close", (code0) => {
resolve({ ok: code0 === 0, code: Number(code0 ?? -1), out, err });
});
});
}
async function startChannelProcess() {
if (channelProc) return true;
ensureDir(LOG_ROOT);
if (!runtimeState || !runtimeState.host || !runtimeState.port) {
throw new Error("runtime_state_missing");
}
const channelLogFile = path.join(LOG_ROOT, "channel-wecom.log");
const logStream = fs.createWriteStream(channelLogFile, { flags: "a" });
const pythonBin = resolvePythonBin();
// Kill stale/orphan WeCom workers first to avoid single-instance lock conflicts.
try {
const cleanupRes = await runPythonInline(
pythonBin,
"from oclaw.ops.runtime import cleanup_orphan_service_processes; k=cleanup_orphan_service_processes('channel:wecom'); print('killed=' + ','.join(str(x) for x in k))",
{ AIA_DATA_DIR: DATA_ROOT },
);
if (!cleanupRes.ok) {
logStream.write(`[channel-cleanup-warn] code=${cleanupRes.code} err=${String(cleanupRes.err || "").trim()}\n`);
} else {
const line = String(cleanupRes.out || "").trim();
if (line) logStream.write(`[channel-cleanup] ${line}\n`);
}
} catch (_) {}
const env = {
...process.env,
PYTHONUTF8: "1",
AIA_ASSISTANT_GATEWAY_HOST: String(runtimeState.host),
AIA_ASSISTANT_GATEWAY_PORT: String(runtimeState.port),
AIA_DATA_DIR: DATA_ROOT,
AIA_DESKTOP_MODE: "1",
};
const args = ["-m", "oclaw.ops", "channel", "wecom", "start", "--mode", "ws", "--interval", "3.0", "--deliver-outbound"];
channelStopping = false;
channelProc = spawn(pythonBin, args, {
cwd: APP_ROOT,
env,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
channelProc.stdout.on("data", (chunk) => {
logStream.write(chunk);
});
channelProc.stderr.on("data", (chunk) => {
logStream.write(chunk);
});
channelProc.on("close", (code, signal) => {
logStream.write(`[channel-exit] code=${code} signal=${signal || "none"}\n`);
logStream.end();
const crashed = !channelStopping;
channelProc = null;
if (crashed) {
setTimeout(() => {
if (!quitAfterCleanup) {
startChannelProcess().catch(() => {});
}
}, 1200);
}
});
const healthy = await waitForProcessHealthy(channelProc, Date.now() + 2000);
if (!healthy) {
const msg = `[channel-start-warn] process_exit_${channelProc && channelProc.exitCode !== null ? channelProc.exitCode : "unknown"}\n`;
try {
fs.appendFileSync(BACKEND_LOG_FILE, msg, { encoding: "utf-8" });
} catch (_) {}
return false;
}
return true;
}
function waitProcessClose(proc, timeoutMs) {
return new Promise((resolve) => {
if (!proc || proc.exitCode !== null || proc.killed) {
resolve();
return;
}
let done = false;
const finish = () => {
if (done) return;
done = true;
resolve();
};
const timer = setTimeout(finish, Math.max(200, Number(timeoutMs) || 6000));
proc.once("close", () => {
clearTimeout(timer);
finish();
});
proc.once("exit", () => {
clearTimeout(timer);
finish();
});
});
}
function taskkillTree(pid) {
return new Promise((resolve) => {
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
windowsHide: true,
stdio: "ignore",
});
killer.once("error", () => resolve(false));
killer.once("close", (code) => resolve(code === 0));
});
}
async function stopBackendProcess() {
if (!backendProc) return;
const proc = backendProc;
backendStopping = true;
if (process.platform === "win32") {
const ok = await taskkillTree(proc.pid);
if (!ok) {
try {
proc.kill("SIGTERM");
} catch (_) {}
}
await waitProcessClose(proc, 8000);
return;
}
try {
proc.kill("SIGTERM");
} catch (_) {}
await waitProcessClose(proc, 5000);
if (proc.exitCode === null && !proc.killed) {
try {
proc.kill("SIGKILL");
} catch (_) {}
await waitProcessClose(proc, 3000);
}
}
async function stopChannelProcess() {
if (!channelProc) return;
const proc = channelProc;
channelStopping = true;
if (process.platform === "win32") {
const ok = await taskkillTree(proc.pid);
if (!ok) {
try {
proc.kill("SIGTERM");
} catch (_) {}
}
await waitProcessClose(proc, 6000);
return;
}
try {
proc.kill("SIGTERM");
} catch (_) {}
await waitProcessClose(proc, 4000);
}
async function showBackendCrashedDialog(code, signal) {
if (backendCrashDialogOpen) return;
backendCrashDialogOpen = true;
try {
const result = await dialog.showMessageBox({
type: "error",
title: "Backend stopped unexpectedly",
message: "本地后端进程已退出",
detail: `exit_code=${code ?? "unknown"}, signal=${signal || "none"}\n\n日志文件:${BACKEND_LOG_FILE}`,
buttons: ["重启后端", "打开日志目录", "退出应用"],
defaultId: 0,
cancelId: 2,
});
if (result.response === 0) {
await startBackendProcess();
if (mainWindow && !mainWindow.isDestroyed()) {
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat`);
}
return;
}
if (result.response === 1) {
shell.showItemInFolder(BACKEND_LOG_FILE);
await showBackendCrashedDialog(code, signal);
return;
}
app.quit();
} finally {
backendCrashDialogOpen = false;
}
}
function createMainWindow() {
mainWindow = new BrowserWindow({
title: APP_DISPLAY_NAME,
width: 1400,
height: 900,
minWidth: 1100,
minHeight: 700,
icon: APP_ICON_PATH,
show: false,
backgroundColor: "#0d0d0d",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
devTools: true,
},
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (String(url || "").startsWith(runtimeState?.baseUrl || "")) {
return { action: "allow" };
}
shell.openExternal(url);
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
const base = runtimeState?.baseUrl || "";
if (!base || !String(url).startsWith(base)) {
event.preventDefault();
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
}
async function showStartupError(error) {
const message = String(error && error.message ? error.message : error || "unknown startup failure");
await dialog.showMessageBox({
type: "error",
title: "Desktop startup failed",
message: "无法启动本地后端服务",
detail: `${message}\n\n日志文件:${BACKEND_LOG_FILE}`,
});
}
async function boot() {
try {
app.setName(APP_DISPLAY_NAME);
await startBackendProcess();
const channelOk = await startChannelProcess();
if (!channelOk && !channelStartWarningShown) {
channelStartWarningShown = true;
await dialog.showMessageBox({
type: "warning",
title: "Channel startup warning",
message: "企微通道启动失败(不影响桌面端启动)",
detail: "你可以在 Admin -> 运行时中查看并重试服务。",
});
}
createMainWindow();
try {
// Desktop policy: every restart requires explicit login.
// Clear persisted web storage before first page load.
await mainWindow.webContents.session.clearStorageData();
} catch (_) {}
// Desktop policy: always require fresh login on every app restart.
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?force_relogin=1`);
mainWindow.show();
} catch (error) {
await showStartupError(error);
app.quit();
}
}
ipcMain.handle("desktop:getRuntimeInfo", async () => {
const state = runtimeState || {};
return {
host: state.host || DEFAULT_HOST,
port: state.port || DEFAULT_PORT,
baseUrl: state.baseUrl || "",
logFile: BACKEND_LOG_FILE,
};
});
ipcMain.handle("desktop:restartBackend", async () => {
await stopChannelProcess();
await stopBackendProcess();
await startBackendProcess();
await startChannelProcess();
if (mainWindow && !mainWindow.isDestroyed()) {
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?force_relogin=1`);
}
return true;
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", (event) => {
if (quitAfterCleanup) return;
event.preventDefault();
quitAfterCleanup = true;
// Turn quit into graceful async shutdown so backend child tree is fully reaped.
(async () => {
await stopChannelProcess();
await stopBackendProcess();
app.quit();
})().catch(() => {
app.quit();
});
});
app.whenReady().then(boot);

5066
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

58
desktop/package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "oclaw",
"version": "1.0.0",
"description": "oclaw desktop shell for admin/chat UI",
"main": "main.js",
"scripts": {
"dev": "electron .",
"start": "electron .",
"prepare:icon": "node ./tools/generate-icon.cjs",
"pack:dir": "npm run prepare:icon && electron-builder --dir -c.win.signAndEditExecutable=false",
"pack:win": "npm run prepare:icon && electron-builder --win nsis -c.win.signAndEditExecutable=false"
},
"keywords": [
"electron",
"desktop",
"oclaw"
],
"author": "",
"license": "MIT",
"type": "commonjs",
"devDependencies": {
"@resvg/resvg-js": "^2.6.2",
"electron": "^41.2.1",
"electron-builder": "^26.8.1",
"png-to-ico": "^3.0.1"
},
"build": {
"appId": "com.oclaw.desktop",
"productName": "oclaw",
"artifactName": "oclaw-${version}-${arch}.${ext}",
"directories": {
"output": "dist"
},
"files": [
"**/*",
"!dist/**",
"!node_modules/.cache/**"
],
"win": {
"icon": "assets/oclaw.ico",
"executableName": "oclaw",
"signAndEditExecutable": false,
"target": [
"nsis"
]
},
"nsis": {
"artifactName": "oclaw-setup-${version}.${ext}",
"menuCategory": "oclaw",
"shortcutName": "oclaw",
"createDesktopShortcut": "always",
"createStartMenuShortcut": true,
"uninstallDisplayName": "oclaw",
"installerIcon": "assets/oclaw.ico",
"uninstallerIcon": "assets/oclaw.ico"
}
}
}

6
desktop/preload.js Normal file
View file

@ -0,0 +1,6 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("desktopBridge", {
getRuntimeInfo: () => ipcRenderer.invoke("desktop:getRuntimeInfo"),
restartBackend: () => ipcRenderer.invoke("desktop:restartBackend"),
});

View file

@ -0,0 +1,38 @@
const fs = require("node:fs");
const path = require("node:path");
const { Resvg } = require("@resvg/resvg-js");
const pngToIcoModule = require("png-to-ico");
const pngToIco = typeof pngToIcoModule === "function" ? pngToIcoModule : pngToIcoModule.default;
async function main() {
const desktopRoot = path.resolve(__dirname, "..");
const svgPath = path.resolve(desktopRoot, "..", "src", "admin", "static", "oliver.svg");
const assetsDir = path.join(desktopRoot, "assets");
const icoPath = path.join(assetsDir, "oclaw.ico");
if (!fs.existsSync(svgPath)) {
throw new Error(`logo not found: ${svgPath}`);
}
fs.mkdirSync(assetsDir, { recursive: true });
const svg = fs.readFileSync(svgPath, "utf8");
const sizes = [16, 24, 32, 48, 64, 128, 256];
const pngBuffers = [];
for (const size of sizes) {
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: size },
background: "rgba(0,0,0,0)",
});
const pngData = resvg.render().asPng();
pngBuffers.push(Buffer.from(pngData));
}
const ico = await pngToIco(pngBuffers);
fs.writeFileSync(icoPath, ico);
process.stdout.write(`Generated ${icoPath}\n`);
}
main().catch((err) => {
process.stderr.write(`${String(err && err.message ? err.message : err)}\n`);
process.exit(1);
});

92
docs/DEPLOY_CHECKLIST.md Normal file
View file

@ -0,0 +1,92 @@
# 新机器部署检查清单(10 步)
用于从 0 到可用地复制一套环境,适合交付给同事或新服务器。
---
## 1) 基础环境
- [ ] 安装 Python(建议与现网一致版本)
- [ ] 安装 Node.js(MCP npm server 需要)
- [ ] 安装 Git(如需 github 类型安装)
---
## 2) 获取代码并安装依赖
- [ ] 拉取仓库代码
- [ ] 创建虚拟环境
- [ ] 执行 `pip install -r requirements.txt`
---
## 3) 设置关键环境变量
- [ ] `OPS_ASSISTANT_PASSWORD`
- [ ] (可选)`OPENAI_API_KEY`
- [ ] (可选)`OPS_ASSISTANT_DB_PATH`
---
## 4) 启动网关
- [ ] 执行 `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1`
- [ ] 访问 `http://127.0.0.1:8787/admin` 可打开
---
## 5) 管理台认证
- [ ] 调用 `/admin/api/auth/bootstrap`
- [ ] 使用管理员账号登录成功
---
## 6) MCP 基础依赖检查
- [ ] 在 MCP 页面确认依赖状态(git/node/npm/npx/python/pip)
- [ ] 缺失项先补齐再安装 MCP server
---
## 7) 导入 MCP 安装清单
- [ ] 使用 JSON 安装(单个或数组)
- [ ] 所有目标 server 处于可见状态
---
## 8) 逐个验活
每个 server 执行:
- [ ] `Health` 成功
- [ ] `Sync Tools` 成功
- [ ] 工具数 > 0
---
## 9) 批量体检
- [ ] 点击 `Check Installed`
- [ ] `error_count = 0`(或明确可接受的白名单错误)
---
## 10) 专家映射确认
- [ ] 在 `MCP specialists` 勾选目标专家
- [ ] 保存后验证对应专家可见 MCP 工具
---
## 验收标准(建议)
- 所有关键 MCP server:`health=ok` 且 `tools>0`
- 管理台与聊天页可正常访问
- 核心回归测试通过:
```bash
python -m pytest -q tests/test_mcp_runtime.py tests/test_mcp_admin_api.py tests/test_mcp_adapter.py
```

70
docs/DESKTOP_PACKAGING.md Normal file
View file

@ -0,0 +1,70 @@
# oclaw 桌面端打包与交付(Windows)
本文档用于你后续自行打包并传递安装包,按步骤执行即可。
## 1) 前置环境
- Windows 10/11
- Python 3.10+(`python` 在 PATH 中可用)
- Node.js 20+(含 npm)
在项目根目录先安装 Python 依赖:
```powershell
python -m pip install -r requirements.txt
```
## 2) 安装桌面端依赖
```powershell
cd .\desktop
npm install
```
## 3) 一键打包
```powershell
npm run pack:win
```
这个命令会自动做两件事:
1. 执行 `prepare:icon`,从 `oclaw/admin/static/oliver.svg` 生成 `desktop/assets/oclaw.ico`
2. 调用 `electron-builder` 生成 NSIS 安装包
## 4) 打包产物位置
产物位于 `desktop/dist/`,重点关注:
- `oclaw-setup-<version>.exe`(安装包,给用户分发这个)
- `oclaw-setup-<version>.exe.blockmap`(增量更新元数据,可选)
- `win-unpacked/oclaw.exe`(免安装可执行目录)
## 5) 传递建议(你说的“直接传递”)
推荐传递:
- 首选:`oclaw-setup-<version>.exe`
- 可选补充:`SHA256` 校验值(用于校验完整性)
如果需要免安装运行,再额外传 `win-unpacked` 目录压缩包。
## 6) 本地自测清单(打包后)
安装后至少验证以下项:
1. 双击应用,默认进入 `/chat`
2. “设置/审计”入口可正常跳转到 admin 页面
3. 插件页、运行页进入时有“加载中”提示
4. 点击插件/运行操作不弹黑色控制台窗口
5. 关闭应用后,后端进程被回收
## 7) 常见问题
- **提示找不到 Python**
- 安装 Python 3.10+,或设置环境变量 `PYTHON_EXECUTABLE`
- **图标不对**
- 重新执行:`npm run prepare:icon`
- **打包失败**
- 先清理并重装:删除 `desktop/node_modules` 后 `npm install`

View file

@ -0,0 +1,405 @@
# 环境变量总览(AIA)
本项目统一使用 `AIA_*` 前缀环境变量。本文档是唯一维护入口,用于说明变量用途、默认值与生效位置。
发布级变更记录见:`oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`。
## 维护规则
- 新增环境变量时,必须同步更新本文档。
- 变量命名统一:`AIA_<模块>_<含义>`。
- 若变量已可在 Admin 配置,优先使用 Admin,环境变量作为启动默认值/兜底。
- 删除变量时,请同时更新:
- 本文档
- `README.md` 的示例
- 示例环境文件(如 `data/mcp_local.env.example`)
## 核心与调度
- `AIA_ASSISTANT_MODE`
- 默认:空(代码内决定默认模式)
- 作用:助手模式选择
- 生效:`oclaw/platform/llm/chat_models.py`, `oclaw/agents/factory.py`
- `AIA_MANAGER_DECISION_MODE`
- 默认:空
- 作用:**Legacy(已断开)**:旧 manager 决策模式(如 `rule`)
- 说明:oclaw runtime 默认不再走 `CompositeOpsAgent` 的 manager 决策;该变量仅保留以便后续接回 legacy
- 生效:`oclaw/agents/manager_agent.py`(仅 legacy 链路)
- `AIA_TURN_MAX_TOOL_WORKERS`
- 默认:`8`
- 作用:单轮工具并发上限
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`
- `AIA_TURN_MAX_TOOL_ROUNDS`
- 默认:`8`
- 作用:工具循环轮次上限
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`
- `AIA_TURN_MAX_CONTEXT_MESSAGES`
- 默认:`80`
- 作用:上下文消息上限
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`
- oclaw async queue/worker(`router -> openclaw_task -> worker`)
- 当前版本无独立环境变量;复用以上 `AIA_TURN_MAX_*` 配置控制 direct loop 执行上限
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/worker.py`
- `AIA_PROMPT_FRONTMATTER_STRICT`
- 默认:`0`
- 作用:`1` 时 `SKILL.md` / `oclaw/prompts/*.md` 的 frontmatter 必须为可解析 YAML;解析失败直接报错(不回落旧版行解析)
- 生效:`oclaw/prompts/frontmatter.py`, `oclaw/prompts/loader.py`, `oclaw/openclaw_runtime/skills.py`
- `AIA_SKILLS_PROMPT_IN_SYSTEM`
- 默认:`1`(开启;仅当技能运行时启用)
- 作用:是否在 system prompt 末尾附加 oclaw 风格的 `<available_skills>` 目录块(与原生 `tools` 并存)
- 说明:设为 `0` 可关闭以降低 token;Admin `AIA_SKILL_RUNTIME_ENABLED` 关闭时本块不生成
- 生效:`oclaw/openclaw_runtime/skills_prompt.py`, `oclaw/openclaw_runtime/direct_loop.py`
- `AIA_SKILLS_PROMPT_MAX_CHARS`
- 默认:`18000`
- 作用:技能目录 XML 块最大字符数(超出则从列表尾部丢弃条目)
- 生效:`oclaw/openclaw_runtime/skills_prompt.py`
- `AIA_SKILL_DISABLED_NAMES`
- 默认:空数组(`[]`)
- 作用:按技能名禁用模型可见/可执行技能(JSON 数组字符串,例如 `["skill_a","skill_b"]`)
- 说明:禁用后同时影响 manifest/prompt 渲染与 direct loop 工具暴露
- 生效:`oclaw/openclaw_runtime/skills.py`, `oclaw/openclaw_runtime/skills_prompt.py`, `oclaw/openclaw_runtime/skill_installer.py`
- `AIA_SKILL_AUTO_INSTALL_ENABLED`
- 默认:`1`
- 作用:是否允许自动安装 skill(admin auto-install / retry-install(auto))
- 说明:关闭后返回 `auto_install_disabled`,并标记为不可重试
- 生效:`oclaw/openclaw_runtime/skill_installer.py`, `oclaw/admin/skills_api.py`
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES`
- 默认:`provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed`
- 作用:Agent Core run 外环的错误重试白名单(逗号分隔)
- 说明:仅当 attempt 返回 `status=retry` 且 `error_code` 命中该白名单时才进入下一次 attempt;未知 code 默认在 Admin 保存时会被过滤并告警
- 补充:`relay_envelope_invalid`、`relay_envelope_unsupported_version` 属于输入契约错误,运行时固定按 non-retryable 处理(即使被误加入白名单也不会进入重试链)
- 生效:`oclaw/openclaw_runtime/agent_core_run.py`
- `AIA_OPENCLAW_ROUTER_MODE`
- 默认:`rule`
- 取值:`rule`(启发式)或 `llm_json`(由当前 executor 的 `model.chat` 产出 `{mode,reason}` JSON;解析失败则回落 `rule`)
- 说明:亦可通过同名环境变量覆盖;提示词见 `oclaw/prompts_openclaw/router/decide_route.md`
- 生效:`oclaw/openclaw_runtime/router.py`, `oclaw/openclaw_runtime/gateway.py`
- oclaw trace 字段与 `event_type` ↔ `oc_stage` 对照见 `oclaw/docs/openclaw-trace-taxonomy.md`
- Skill 安装错误码与重试建议、trace 排障路径见 `oclaw/docs/openclaw-skill-troubleshooting.md`
- Relay 文件指针(含 ACP 父子 run)错误码与排障见 `oclaw/docs/openclaw-skill-troubleshooting.md` 的“Relay 文件指针排障”
- `AIA_OPENCLAW_RETRY_CODES_STRICT_MODE`
- 默认:`0`
- 作用:控制 Admin 保存 `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 时的未知 code 行为
- 说明:`0`=过滤并告警;`1`=直接拒绝保存(HTTP 400)
- 生效:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
- `AIA_TOOL_ENFORCED_RETRY_MODE`
- 默认:`first_round_only`
- 作用:**Legacy(已断开)**:工具必需场景下的强制重试策略
- 生效:仅 legacy 链路(保留占位,暂不影响 oclaw)
- `AIA_TOOL_LOOP_STATE_MACHINE`
- 默认:`1`
- 作用:**Legacy(已断开)**:工具循环状态机开关
- 生效:仅 legacy 链路(保留占位,暂不影响 oclaw)
- `AIA_TOOL_SIGNATURE_BUDGET`
- 默认:`2`
- 作用:**Legacy(已断开)**:同签名工具调用预算
- 生效:仅 legacy 链路(保留占位,暂不影响 oclaw)
- `AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK`
- 默认:`0`(关闭)
- 作用:oclaw 执行失败时,是否允许回退到 legacy `executor.run_turn(...)`
- 说明:默认 fail-closed(不回退),避免无意中触发旧 manager/runner
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/agents/specialist_agent.py`
## LLM 传输与 replay(OpenAI 兼容)
思路参考 oclaw(MIT)对 `openai-completions` 的 transcript 策略:在请求前修复/规范化 `tool_calls[].id` 与 `role=tool` 的 `tool_call_id`,减少网关 400(空 id、断链、非法字符)。
- `AIA_REPLAY_POLICY_ENABLED`
- 默认:`1`(启用)
- 作用:是否启用发送前 replay 规范化(修复孤儿 tool 引用 + 重写 tool id)
- 生效:`oclaw/platform/llm/replay_policy.py`, `oclaw/platform/llm/chat_models.py`
- `AIA_REPLAY_REPAIR_TOOL_PAIRING`
- 默认:`1`
- 作用:是否先剥离「assistant 中不存在的 tool_call_id」的 tool 消息上的 id(再执行 id 重写)
- 生效:`oclaw/platform/llm/replay_policy.py`
- `AIA_TOOL_CALL_ID_MAX_LEN`
- 默认:`40`
- 作用:重写后的 tool_call_id 最大长度(适配多数 OpenAI 兼容网关)
- 生效:`oclaw/platform/llm/tool_call_id.py`
- `AIA_PROMPT_TOOL_FALLBACK`
- 默认:`1`
- 作用:原生 tools 失败并进入 prompt-tool 降级时,是否向 system 注入 tools JSON;设为 `0` 则仅剥离 tool 结构、不注入工具清单
- 生效:`oclaw/platform/llm/chat_models.py`
- `AIA_NATIVE_TOOLS_DENYLIST_HOSTS`
- 默认:空(无静态名单;另有进程内按错误动态记录的 `(host, model)` 缓存)
- 作用:可选的 host 子串黑名单,强制走 prompt-tool 模式
- 生效:`oclaw/platform/llm/chat_models.py`
## 工具执行与安全
- `AIA_DISABLE_TOOL_CONFIRM`
- 默认:`0`
- 作用:**Legacy(已断开)**:是否禁用高风险工具确认
- 说明:oclaw 工具执行已移除执行时确认策略;该变量保留以便后续接回 legacy
- 生效:仅 legacy 链路(保留占位)
- `AIA_ENABLE_MCP_TOOLS`
- 默认:`1`
- 作用:启用 MCP 工具
- 生效:`oclaw/tools/catalog.py`
- `AIA_ENABLE_PLUGIN_TOOLS`
- 默认:`0`
- 作用:启用插件工具
- 生效:`oclaw/tools/catalog.py`
- `AIA_ENABLE_RUN_COMMAND`
- 默认:`0`
- 作用:允许高风险 `run_command` 工具
- 生效:`oclaw/tools/catalog.py`, `oclaw/tools/experts/workspace/shell_tools.py`
- `AIA_TOOL_LLM_MESSAGE_MAX_CHARS`
- 默认:`0`(不限制)
- 作用:工具结果写回 LLM 的消息长度上限
- 说明:`0` 表示不做限制(不推荐,可能触发部分网关的单条消息上限 400)
- 生效:`oclaw/chat/tool_runtime.py`
- 观测:管理端聊天流 `tool_use_result` 事件会携带 `llm_wire.{truncated_for_llm,max_chars,result_bytes,result_for_llm_bytes,truncate_ms}`
- `AIA_TOOL_LOG_MAX_CHARS`
- 默认:`200000`
- 作用:`tool_log` 中 args/result 截断上限
- 生效:`oclaw/platform/persistence/sqlite_store.py`
## MCP 与工具线侧
- `AIA_MCP_SPECIALISTS`
- 默认:`generalist`
- 作用:允许使用 MCP 的 specialist 列表
- 生效:`oclaw/tools/mcp/adapter.py`
- `AIA_MCP_ENV_ALLOWLIST`
- 默认:内置 allowlist(Brave/Google/GitHub/Context7)
- 作用:MCP 子进程可透传环境变量白名单
- 生效:`oclaw/ops/mcp_env.py`
- `AIA_MCP_FILESYSTEM_EXTRA_ROOTS`
- 默认:空
- 作用:追加给 filesystem MCP 的根目录
- 生效:`oclaw/tools/mcp/filesystem_argv.py`
- `AIA_MCP_WIRE_USAGE_POLICY`
- 默认:空(按 base_url 继承)
- 作用:是否启用 MCP 工具线侧分层策略
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
- `AIA_MCP_WIRE_PENALTY_DISABLE`
- 默认:`0`
- 作用:禁用线侧陈旧惩罚
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
- `AIA_MCP_WIRE_TOP_N_FULL`
- 默认:`20`
- 作用:全量上送工具数量
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
- `AIA_MCP_WIRE_STALE_HOURS`
- 默认:`3`
- 作用:陈旧判定小时阈值
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
- `AIA_MCP_WIRE_PENALTY_MINUTES`
- 默认:`30`
- 作用:惩罚窗口分钟数
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
- `AIA_MCP_WIRE_MEDIUM_RANK_START`
- 默认:`21`
- 作用:中等层起始排名
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
- `AIA_MCP_WIRE_MEDIUM_RANK_END`
- 默认:`50`
- 作用:中等层结束排名
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
- `AIA_MCP_WIRE_MEDIUM_DESC_CHARS`
- 默认:`520`
- 作用:中等层描述截断长度
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
- `AIA_MCP_WIRE_MINIMAL_DESC_CAP`
- 默认:`80`
- 作用:最小层描述长度
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
## LLM 工具载荷与模型兼容
- `AIA_OPENAI_TOOLS_MAX_JSON_CHARS`
- 默认:空(按代码内部策略)
- 作用:OpenAI tools payload JSON 上限
- 生效:`oclaw/platform/llm/chat_models.py`
- `AIA_SHRINK_OPENAI_TOOLS`
- 默认:`0`
- 作用:强制启用 tools payload 压缩
- 生效:`oclaw/platform/llm/chat_models.py`
- `AIA_SHRINK_OPENAI_TOOLS_MAX_JSON`
- 默认:`28000`
- 作用:压缩目标上限
- 生效:`oclaw/platform/llm/chat_models.py`
- `AIA_GEMINI_OPENAI_NONSTREAM_TOOLS`
- 默认:`0`
- 作用:Gemini OpenAI 兼容下 tools 非流式开关
- 生效:`oclaw/platform/llm/chat_models.py`
## 图像能力
- `AIA_IMAGE_MODEL`
- 默认:空(走 profile/模型默认)
- 作用:图像模型
- 生效:`oclaw/platform/llm/image_message_client.py`, `oclaw/agents/specialist_agent.py`
- `AIA_IMAGE_BASE_URL`
- 默认:`https://api.openai.com/v1`
- 作用:图像服务 base URL
- 生效:`oclaw/platform/llm/image_message_client.py`
- `AIA_IMAGE_API_KEY`
- 默认:空
- 作用:图像 API Key
- 生效:`oclaw/platform/llm/image_message_client.py`
- `AIA_IMAGE_CHAT_ENDPOINT`
- 默认:`/chat/completions`
- 作用:图像接口 endpoint
- 生效:`oclaw/platform/llm/image_message_client.py`
- `AIA_IMAGE_RETRIES`
- 默认:`3`
- 作用:图像请求重试次数
- 生效:`oclaw/platform/llm/image_message_client.py`
- `AIA_IMAGE_RETRY_BACKOFF_SEC`
- 默认:`0.8`
- 作用:图像请求重试退避秒数
- 生效:`oclaw/platform/llm/image_message_client.py`
- `AIA_IMAGE_STATUS_RETRIES`
- 默认:`4`
- 作用:图像状态轮询重试次数
- 生效:`oclaw/platform/llm/image_message_client.py`
- `AIA_IMAGE_STATUS_RETRY_BACKOFF_SEC`
- 默认:`5.0`
- 作用:图像状态轮询退避秒数
- 生效:`oclaw/platform/llm/image_message_client.py`
## Memory / RAG
- `AIA_RAG_MODE`
- 默认:`keyword`
- 作用:RAG 模式(`keyword`/`vector`)
- 生效:`oclaw/orchestration/memory.py`
- `AIA_RAG_EMBEDDING_MODE`
- 默认:空(优先 OpenAI,失败回退 hash)
- 作用:embedding 模式(如 `hash`)
- 生效:`oclaw/platform/embeddings/embedding_client.py`
- `AIA_MEMORY_EPISODIC_TTL_DAYS`
- 默认:`90`
- 作用:episodic memory 过期天数
- 生效:`oclaw/orchestration/memory.py`
## 工作区路径策略
- `AIA_WORKSPACE_ROOT`
- 默认:项目根
- 作用:工作区主根路径
- 生效:`oclaw/tools/experts/workspace/workspace_base.py`, `oclaw/indexing/workspace_indexer.py`
- `AIA_WORKSPACE_EXTRA_ROOTS`
- 默认:空
- 作用:额外可访问根路径(`|` 分隔)
- 生效:`oclaw/tools/experts/workspace/workspace_base.py`, `oclaw/tools/mcp/filesystem_argv.py`
- `AIA_WORKSPACE_ALLOW_ANY_PATH`
- 默认:`0`
- 作用:是否放开内置工具路径限制(高风险)
- 生效:`oclaw/tools/experts/workspace/workspace_base.py`
## 网关与运行
- `AIA_ASSISTANT_GATEWAY_HOST`
- 默认:`0.0.0.0`
- 作用:网关监听地址
- 生效:`oclaw/app_server/fastapi_main.py`, `oclaw/ops/main.py`
- `AIA_ASSISTANT_GATEWAY_PORT`
- 默认:`8787`
- 作用:网关监听端口
- 生效:`oclaw/app_server/fastapi_main.py`, `oclaw/ops/main.py`
- `AIA_RUNTIME_LOG_DIR`
- 默认:空(使用内部默认目录)
- 作用:运行日志目录
- 生效:`oclaw/ops/runtime.py`
- `AIA_SSE_QUEUE_MAXSIZE`
- 默认:`2000`
- 作用:SSE 事件队列上限
- 生效:`oclaw/admin/chat_api.py`
## WeCom 长连接
- `AIA_WECOM_LONGCONN_WORKERS`
- 默认:`2`
- 作用:入站处理 worker 数
- 生效:`oclaw/channels/wecom/longconn_runner.py`
- `AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE`
- 默认:`200`
- 作用:入站队列长度上限
- 生效:`oclaw/channels/wecom/longconn_runner.py`
## 安全与密钥
- `AIA_ASSISTANT_PASSWORD`
- 默认:空(必须配置)
- 作用:管理台管理员密码(bootstrap/login)
- 生效:`oclaw/platform/config/passwords.py`, `oclaw/admin/routes.py`
- `AIA_ASSISTANT_MASTER_KEY`
- 默认:空
- 作用:密钥加密(Fernet)主密钥
- 生效:`oclaw/platform/persistence/sqlite_store.py`, `oclaw/admin/routes.py`
## 存储与迁移
- `AIA_ASSISTANT_DB_PATH`
- 默认:`data/ai_ops.sqlite`
- 作用:SQLite 路径
- 生效:`oclaw/platform/config/paths.py`
- `AIA_ASSISTANT_PREMERGE_BACKUP_KEEP`
- 默认:`3`
- 作用:预迁移备份保留数量
- 生效:`oclaw/platform/config/paths.py`
- `AIA_LEGACY_DB_FORCE_PREMERGE`
- 默认:`0`
- 作用:是否强制 legacy DB 覆盖(谨慎使用)
- 生效:`oclaw/platform/config/paths.py`

View file

@ -0,0 +1,181 @@
# 环境变量变更台账(AIA)
用于记录每次版本发布中的环境变量变更,便于升级与回滚评估。
## 使用规则
- 每次发布前后都要补一条记录(即使“无变更”也要写)。
- 记录粒度:
- 新增变量
- 删除变量
- 重命名(含兼容窗口)
- 默认值变化
- 语义变化(同名但行为变了)
- 变更后必须同步:
- `oclaw/docs/ENVIRONMENT_VARIABLES.md`
- `README.md` 示例
- 示例 env 文件(如 `data/mcp_local.env.example`)
---
## 模板
```md
## YYYY-MM-DD / vX.Y.Z
### Added
- `AIA_XXX`
- 默认值:
- 用途:
- 影响模块:
### Changed
- `AIA_YYY`
- 变更前:
- 变更后:
- 影响:
- 是否需要重启:是/否
### Deprecated
- `AIA_ZZZ`
- 弃用原因:
- 兼容截止版本:
- 替代变量:
### Removed
- `AIA_OLD`
- 删除原因:
- 升级动作:
### Migration Checklist
- [ ] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
- [ ] 已更新 `README.md`
- [ ] 已更新示例 env 文件
- [ ] 已验证 Admin 配置页(如适用)
- [ ] 已执行编译/测试回归
```
---
## 2026-04-21 / Unreleased
### Added
- `AIA_PROMPT_FRONTMATTER_STRICT`(默认 `0`):强制 YAML frontmatter。
- `AIA_SKILLS_PROMPT_IN_SYSTEM`(默认 `1`):oclaw 风格 `<available_skills>` 注入 system prompt。
- `AIA_SKILLS_PROMPT_MAX_CHARS`(默认 `18000`):技能目录块字符预算。
- 依赖:`PyYAML`(`requirements.txt`)。
### Changed
- `oclaw/prompts/loader.py` 与 `oclaw/openclaw_runtime/skills.py` 统一使用 YAML 解析 frontmatter(失败时默认回落旧行解析,除非开启 STRICT)。
---
## 2026-04-19 / Unreleased
### Added
- `oclaw/docs/ENVIRONMENT_VARIABLES.md`(变量总览基线文档)
- `oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`(本台账)
- OpenAI 兼容 replay 相关(见 `oclaw/docs/ENVIRONMENT_VARIABLES.md`「LLM 传输与 replay」):
- `AIA_REPLAY_POLICY_ENABLED`(默认 `1`)
- `AIA_REPLAY_REPAIR_TOOL_PAIRING`(默认 `1`)
- `AIA_TOOL_CALL_ID_MAX_LEN`(默认 `40`)
- `AIA_PROMPT_TOOL_FALLBACK`(默认 `1`)
- `oclaw/platform/llm/OPENCLAW_MIT_LICENSE.txt`(oclaw 启发实现之 MIT 署名)
### Changed
- 变量前缀统一为 `AIA_*`,项目内不再使用 `OPS_*` / `AI_OPS_*`。
- `README.md` 与 `data/mcp_local.env.example` 示例变量已同步为 `AIA_*`。
- 环境变量维护流程已文档化:后续变量改动需同时更新
- `oclaw/docs/ENVIRONMENT_VARIABLES.md`(当前生效基线)
- `oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`(版本变更历史)
- `README.md` / 示例 env(用户可见配置入口)
### Removed
- 代码中的 `OPS_*` / `AI_OPS_*` 引用(已清理完成)。
### Migration Checklist
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
- [x] 已更新 `README.md`
- [x] 已更新示例 env 文件
- [x] 已验证 Admin 配置页(如适用)
- [x] 已执行编译/测试回归
---
## 2026-04-20 / Unreleased
### Added
- `AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK`
- 默认值:`0`
- 用途:oclaw runtime 失败时是否允许回退到 legacy `run_turn`
- 影响模块:`oclaw/openclaw_runtime/gateway.py`, `oclaw/agents/specialist_agent.py`
### Changed
- `AIA_TURN_MAX_*`(tool workers/rounds/context)
- 变更前:由 legacy turn runner 读取(历史文件名可能为 `agent_core.py`)
- 变更后:由 oclaw runtime 读取并生效(`oclaw/openclaw_runtime/gateway.py`)
- 是否需要重启:是(读取自 settings/db/env 的时机取决于运行方式)
### Deprecated
- `AIA_MANAGER_DECISION_MODE`, `AIA_TOOL_ENFORCED_RETRY_MODE`, `AIA_TOOL_LOOP_STATE_MACHINE`, `AIA_TOOL_SIGNATURE_BUDGET`, `AIA_DISABLE_TOOL_CONFIRM`
- 弃用原因:oclaw runtime 已断开 legacy manager/runner/tool-policy 链路(代码保留,默认不生效)
- 兼容截止版本:待定
- 替代变量:无(后续如需恢复 legacy 将重新定义接入点)
### Migration Checklist
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
- [x] 已更新 `README.md`
- [x] 已更新示例 env 文件
- [x] 已验证 Admin 配置页(如适用)
- [x] 已执行编译/测试回归
---
## 2026-04-20 / Unreleased (oclaw MVP 补齐)
### Changed
- 无新增环境变量;oclaw runtime 在现有变量下补齐了 memory stage、router sync/async 分流、sqlite task queue、worker 执行链路。
- 影响模块:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`, `oclaw/openclaw_runtime/router.py`, `oclaw/openclaw_runtime/worker.py`, `oclaw/platform/persistence/sqlite_store.py`
- 是否需要重启:是(升级代码后建议重启进程以启动 worker 与新路由逻辑)
### Migration Checklist
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
- [x] 已更新 `README.md`
- [x] 已更新示例 env 文件
- [x] 已验证 Admin 配置页(如适用)
- [ ] 已执行编译/测试回归
---
## 2026-04-20 / Unreleased (AgentCore Retry Matrix)
### Added
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES`
- 默认值:`provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed`
- 用途:控制 Agent Core run 外环可重试错误白名单
- 影响模块:`oclaw/openclaw_runtime/agent_core_run.py`, `oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
### Changed
- Agent Core 重试策略从“status=retry 即重试”升级为“retry + error_code 命中白名单才重试”。
- 是否需要重启:是(新策略读取设置后在进程内生效)
### Migration Checklist
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
- [x] 已更新 `README.md`
- [x] 已更新示例 env 文件
- [x] 已验证 Admin 配置页(如适用)
- [x] 已执行编译/测试回归
### Changed
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 已接入 Admin「Tool Policy」页读写链路。
- 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 保存时增加未知 code 过滤与告警返回(`unknown_retryable_error_codes`)。
- 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`, `oclaw/openclaw_runtime/agent_core_run.py`
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 新增严格模式:可配置为未知 code 直接拒绝保存(400)。
- 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
### Added
- `AIA_OPENCLAW_RETRY_CODES_STRICT_MODE`
- 默认值:`0`
- 用途:控制 Admin 保存 retry code 时对未知值的处理(过滤告警 / 拒绝)
- 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`

View file

@ -0,0 +1,59 @@
# Gateway `image.generate` Contract
This document describes the Python gateway method `image.generate`.
## Method
- Name: `image.generate`
- Handler: `oclaw/gateway/server_methods/image.py`
## Input Params
- `prompt` (required, non-empty string)
- `provider` (optional, non-empty string)
- `size` (optional, non-empty string)
- `quality` (optional, non-empty string)
- `idempotencyKey` (optional, non-empty string; used as `requestId` when provided)
## Behavior
1. If `context.image_generate` exists, it is used as the primary backend hook.
2. Otherwise, the handler falls back to `extensions/image-generation-core/api.py::generate_image`.
3. Provider resolution uses:
- `context.image_generation_providers` first
- then `context.get_runtime_snapshot()["image_generation_providers"]` (if available)
4. Provider selection order:
- explicit `provider` param
- `context.config.image.defaultProvider`
- first matched entry in `context.config.image.providerPriority`
- first available capable provider
5. Capability filter:
- providers are considered image-capable when any of the following is true:
- `capabilities.image_generation == true`
- `capabilities` list contains `image` / `image_generation`
- `kind` is `image` / `image_generation`
- provider exposes callable `generate`
## Error Semantics
- `INVALID_REQUEST`:
- missing/blank `prompt`
- invalid optional parameter types/empty strings
- `NOT_FOUND`:
- explicit `provider` is not registered
- `UNAVAILABLE`:
- runtime/image backend failure
## Success Payload (normalized)
Success responses include normalized fields:
- `ok: true`
- `status: "succeeded"`
- `requestId: string`
- `provider: string`
- `model: string | null`
- `prompt: string`
Provider-specific fields are preserved and merged into the payload.

View file

@ -0,0 +1,50 @@
# Gateway Telegram Normalization
This document describes how gateway send-related handlers normalize Telegram transport targets.
## Scope
Normalization is applied in these handlers:
- `send`
- `chat.send`
- `sessions.send`
The shared implementation lives in `oclaw/gateway/server_methods/telegram_send_normalize.py`.
## Input fields
When `channel == "telegram"`, the normalizer reads:
- `to`
- `threadId` (optional)
- `replyToId` (optional)
Supported `to` examples:
- `telegram:group:-100123:topic:42`
- `telegram:-100123:42`
- `@username`
- `https://t.me/username`
## Output fields
The normalizer returns:
- normalized `to`
- `target`:
- `chatId`
- `chatType` (`direct` | `group` | `unknown`)
- `messageThreadId` (when present in target)
- `threadId` (normalized integer if present/resolved)
- `replyToMessageId` (normalized integer if present)
## Precedence
- `threadId` in request params overrides thread inferred from target suffix.
- `replyToId` is converted to `replyToMessageId` if numeric; otherwise omitted.
## Notes
- Non-Telegram channels pass through unchanged.
- Normalized fields are included in handler payloads and in forwarded params for `chat.send -> enqueue_chat_send`.

79
docs/LLM_TRANSPORTS.md Normal file
View file

@ -0,0 +1,79 @@
# LLM provider/transport capability matrix (oclaw)
This project follows an **OpenClaw-style explicit provider/transport selection**:
- You select the transport via **LLM profile `mode`** (not by inferring from `base_url`).
- `base_url` can be the same unified gateway URL for all providers; the `mode` determines the wire protocol.
## Profile field mapping
- **`mode`**: transport selector (`openai`, `openai_responses`, `anthropic`, `google`, `ollama`, `rule`)
- **`model`**: model id/name passed to the provider transport
- **`base_url`**: gateway host URL (may be shared across providers)
- **`api_key`** (profile secret): primary credential source (reused across modes)
Transport selection happens in `oclaw/agents/factory.py`.
## Modes and transports
### `openai` (Chat Completions, OpenAI-compatible)
- **Transport**: `oclaw/platform/llm/transports/openai_chat_completions.py::OpenAIChatModel`
- **API**: `/v1/chat/completions` (streaming supported)
- **Tools**: OpenAI tool calling (`tools[]` / `tool_calls`)
- **Streaming**: token deltas via `on_token` → WS `chat.delta`
- **Key**: profile secret or `OPENAI_API_KEY`
### `openai_responses` (Responses API, OpenAI-compatible)
- **Transport**: `oclaw/platform/llm/transports/openai_responses.py::OpenAIResponsesModel`
- **API**: `/v1/responses` (streaming events)
- **Tools**: function call items parsed from response output
- **Streaming**: output text deltas via `on_token` → WS `chat.delta`
- **Key**: profile secret or `OPENAI_API_KEY`
### `anthropic` (Anthropic Messages streaming)
- **Transport**: `oclaw/platform/llm/transports/anthropic_messages.py::AnthropicMessagesModel`
- **API**: Anthropic `messages.stream` surface (gateway must provide Anthropic-compatible protocol)
- **Tools**: tool use blocks assembled into `LLMToolCall`
- **Streaming**: text deltas via `on_token` → WS `chat.delta`
- **Key**: profile secret or `ANTHROPIC_API_KEY` (fallback: `OPENAI_API_KEY` for unified gateways)
### `google` (Gemini native SSE)
- **Transport**: `oclaw/platform/llm/transports/google_gemini_sse.py::GoogleGeminiChatModel`
- **API**: `:streamGenerateContent?alt=sse` (Gemini native)
- **Tools**: `functionDeclarations` with `parametersJsonSchema`; parses `functionCall`
- **Streaming**: text deltas via `on_token` → WS `chat.delta`
- **Key**: profile secret or `GOOGLE_API_KEY`/`GEMINI_API_KEY` (fallback: `OPENAI_API_KEY` for unified gateways)
- **Thinking controls** (optional env):
- `AIA_GEMINI_THINKING=on|off`
- `AIA_GEMINI_THINKING_LEVEL=<string>`
- `AIA_GEMINI_THINKING_BUDGET=<int>`
### `ollama` (local OpenAI-compatible)
- **Transport**: `OpenAIChatModel` with Ollama-compatible base url
- **API**: `/v1/chat/completions` (Ollama OpenAI-compat)
- **Key**: uses a dummy key (`ollama`) if needed by SDK
### `rule` (no remote LLM)
- **Transport**: `oclaw/platform/llm/transports/simple.py::RuleBasedChatModel`
- **Purpose**: deterministic tool routing/diagnostics without any model provider
## Streaming and UI contract
All transports stream assistant output through the same internal callback:
1. Transport calls `on_token(text_delta)`
2. WS gateway maps that to `event=chat payload.state=delta`
3. Final persisted assistant message is emitted as:
- `event=chat payload.state=final` (with `message` payload for folding)
- `event=session.message`
4. Tool UI signals from runtime are emitted as `event=session.tool`
## Adding the remaining models
For additional providers, follow this pattern:
1. Add a new transport class under `oclaw/platform/llm/transports/`
2. Extend `mode` selection in `oclaw/agents/factory.py`
3. Add an **offline stream parser test** under `tests/`
4. Add/verify WS contract tests (delta + final + session.tool)

528
docs/MCP_LOCAL_SERVER.md Normal file
View file

@ -0,0 +1,528 @@
# 本地 MCP 工具开发与接入指南(stdio / JSON-RPC)
本文档用于指导你在本地编写 MCP Server,并接入当前系统的 MCP 市场。
当前系统对 MCP 的主流程已统一为标准协议:
- `initialize`
- `notifications/initialized`
- `tools/list`
- `tools/call`
> 兼容说明:系统内部仍保留对旧 `op` 风格消息的回退兼容,但不建议新工具继续使用旧协议。
---
## 1. 你要实现什么
你写的本地工具不是直接写成 `ToolSpec`,而是写成一个 **MCP Server 子进程**,通过标准输入输出(stdio)和系统通信。
系统会:
1. 启动你的进程(`entry_command + entry_args`)
2. 发送 `initialize`
3. 接收 `tools/list`
4. 在用户调用时发送 `tools/call`
---
## 2. 最小可运行示例(Python)
保存为 `mcp_echo_server.py`:
```python
from __future__ import annotations
import json
import sys
from typing import Any
def ok(rid: Any, result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
sys.stdout.flush()
def err(rid: Any, code: int, message: str) -> None:
sys.stdout.write(
json.dumps(
{"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}},
ensure_ascii=False,
)
+ "\n"
)
sys.stdout.flush()
for raw in sys.stdin:
raw = raw.strip()
if not raw:
continue
try:
req = json.loads(raw)
except Exception:
continue
rid = req.get("id")
method = str(req.get("method") or "")
params = req.get("params") if isinstance(req.get("params"), dict) else {}
if method == "initialize":
ok(
rid,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "echo-mcp", "version": "0.1.0"},
},
)
continue
if method == "notifications/initialized":
# 通知类消息不需要返回
continue
if method == "tools/list":
ok(
rid,
{
"tools": [
{
"name": "echo",
"description": "回显输入文本。",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "要回显的文本"}
},
"required": ["text"],
"additionalProperties": False,
},
}
]
},
)
continue
if method == "tools/call":
tool_name = str(params.get("name") or "")
arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
if tool_name != "echo":
err(rid, -32601, f"unknown tool: {tool_name}")
continue
text = str(arguments.get("text") or "")
ok(rid, {"content": [{"type": "text", "text": text}]})
continue
err(rid, -32601, f"method not found: {method}")
```
---
## 3. 在管理台中接入
### 3.0 已安装 MCP 从库里消失时(换库 / 表被清空)
`mcp_server_registry` 存在默认 SQLite(见 `data/ai_ops.sqlite`)中。若列表变成 0 条,可在仓库根执行 **`python scripts/seed_mcp_registry.py`**,从 **`data/mcp_registry.seed.json`** 写回示例条目(含 `local-echo` 与 `mcp-context7`,可按需编辑该 JSON 再执行)。写回后在管理台对各服务执行 **Health** → **Sync Tools**;若曾换过 `OPS_ASSISTANT_DB_PATH`,请确认网关与脚本指向**同一**库文件。
### 3.1 表单安装(推荐)
在 MCP 页面填:
- `source_type`: 可自定义用于记录(如 `pypi`)
- `source_ref`: 自定义来源标识(如 `local-echo`)
- `entry_command`: `python`
- `entry_args`: `D:/path/to/mcp_echo_server.py`
然后执行:
1. `Install`
2. `Health`
3. `Sync Tools`
工具数量 > 0 即接通成功。
### 3.2 JSON 安装
**单条**(在 Plugins **「3」MCP 安装** 的 **Install from JSON** 中粘贴,或作 array 的其中一个元素):
```json
{
"source_type": "pypi",
"source_ref": "local-echo",
"server_id": "local-echo",
"version": "",
"entry_command": "python",
"entry_args": ["D:/project/chatgpt/examples/mcp_echo_server.py"],
"required_permissions": [],
"risk_level": "low",
"enabled": true,
"timeout_s": 30
}
```
**批量**:以下三种写法 **`Install from JSON`** 都支持,会**逐条** preflight + install(任一条失败会记结果并继续下一条,最后看结果 JSON):
1. **数组**:`[ { 上面一条的字段… }, { … } ]`
2. **对象包一层 `servers`**(与 `data/mcp_registry.seed.json`、导出文件一致):
```json
{
"servers": [
{ "source_type": "npm", "source_ref": "@upstash/context7-mcp", "server_id": "mcp-context7", "version": "", "entry_command": "npx", "entry_args": ["-y", "@upstash/context7-mcp"], "env_schema": {}, "required_permissions": [], "risk_level": "medium", "enabled": true, "timeout_s": 60, "dry_run": false }
]
}
```
3. **带 `payload` 的单条**(如 `examples/mcp_install_context7.json`):
```json
{ "payload": { "source_type": "npm", "source_ref": "…", "server_id": "…" } }
```
路径占位符 `__REPO_ROOT__/…` 在**管理台安装 / preflight** 中会与 `scripts/seed_mcp_registry.py` 一样展开为仓库根下的绝对路径(如 `mcp-echo` 的脚本路径、filesystem 的目录根、sqlite 的库文件路径)。若不用占位符,可直接写本机绝对路径。
**导出与自动备份**
- 在 **【4】已安装 MCP 服务** 使用 **Export JSON (download)**,可下载当前库中**全部**已安装 MCP 的可重装 JSON(`servers` 包 + `exported_at`)。
- 每次 **安装、重装、卸载且删除库记录、Delete** 成功后,会刷新 **`oclaw/_local/mcp_registry_migrated.json`**(与导出内容同结构,便于换库/换机后把文件粘回 **Install from JSON** 或 `python scripts/seed_mcp_registry.py path/to/file.json` 注意 seed 会跑 npm/pypi 安装步骤,与 `dry_run` 等字段一致)。该文件建议加入 `.gitignore`(如未忽略),避免本机差异被误提交;密钥仍放在 `oclaw/_local/mcp_local.env` 等环境变量,不在此 JSON 中。
### 3.3 MCP 工具线侧策略(上送压缩与惩罚)
在管理台 **Plugins(插件)** 页中的 **「线侧策略」** 折叠区块(**【6】全局参数**、**【7】已安装工具**)可配置发往 LLM 的 OpenAI 格式 `tools[]` 的**分层压缩**、**全局闲置惩罚**,以及**按完整工具名** `mcp__{server_id}__{tool_name}` 的策略(与模型 `base_url` 解耦时,将 **wire_policy** 设为 `always`)。**【7】** 中每个工具的等级为**数字输入框**(任意整数 1–9998;留空表示未配置;0 与留空语义不同,见下表)。
**【7】表格筛选与专家列(管理台)**
- 表头**第二行**为各列筛选输入(子串匹配,不区分大小写):`server`、`tool`、`wire_name`、**专家**、`count`(上下界)、`last_ts`、惩罚/解封说明、策略**等级**(上下界)。分页条数按**筛选后**结果计算。
- **专家**列由本页当前 **MCP 专家绑定草稿**(`mapping`)与后端返回的 `available_specialists` 合并推导:某 `server_id` 出现在哪些专家的绑定列表中,即显示为逗号分隔的专家 id;可按专家子串筛选。勾选「仅已勾选」时只显示当前勾选的行(勾选集合在筛选、翻页间保留)。
- 同页 **【8】专家 MCP 绑定看板(自动)**:按当前草稿与**已安装 MCP** 各服务的 `tools` 列表,汇总每个专家绑定的 **MCP 个数** 与 **tool 条数**(各已绑定 server 的 `tools` 长度之和);专家集合随 `available_specialists` 与 `mapping` 中的键自动扩展,无需写死。
- **【9】MCP 专家绑定(编辑)** 为原绑定编辑区(勾选、反向视图、保存);与【8】看板联动,改绑定后看板即时刷新(无需单独保存看板)。
**持久化(SQLite `app_setting`)**
| 键 | 含义 |
| --- | --- |
| `mcp_tool_wire_admin_config` | JSON:全局参数 + `wire_policy`(`inherit` / `always` / `never`)、`penalty_disable` 等 |
| `mcp_tool_wire_tool_policies` | JSON:`{ "mcp__sid__tool": 等级 }` |
| `mcp_tool_wire_penalty_state` | JSON:各工具惩罚状态机(`phase`、`omit_until`、`wave_ts`、`kind`),由运行时维护,一般无需手改 |
**`wire_policy`**
- `inherit`:与原先一致,默认在 DashScope 兼容 URL 上启用线侧策略;其它环境变量 `OPS_MCP_WIRE_*` 仍可作为默认值来源。
- `always`:**不依赖 URL**,始终启用分层与惩罚逻辑(适合非 DashScope 网关也要控 payload)。
- `never`:关闭分层/惩罚逻辑;**等级 `9999` 永久封禁仍会过滤该工具**(不上送)。
**按工具等级(`mcp_tool_wire_tool_policies`)**
| 配置 | 库中是否存在键 | 行为 |
| --- | --- | --- |
| **未配置**(管理台留空 / `GET` 中 `policy_level` 为 `null`、`policy_in_db` 为 `false`) | 否 | **自动走全局**:参与用量排名与分层压缩;适用**全局**闲置小时与罚时长;可被 **Top N 全量**豁免全局闲置惩罚。新安装 MCP 在 **Sync Tools** 后出现新 `wire_name`,默认即为此状态,无需手工登记。 |
| **显式 `0`** | 是 | **不参与**全局闲置 omission;仍参与用量分层。与「未配置」不同。 |
| **显式 `1`~`9998`** | 是 | 与 Top N **无关**:距上次成功调用超过 **N×10 分钟** 视为闲置,进入罚时 **N×10 分钟** 的上送 omission;罚满后需再次闲置达到阈值才会再罚(状态与 `last_ts` / `kind` 对齐)。 |
| **显式 `9999`** | 是 | **永久**从线侧 `tools[]` 中移除(彻底封禁)。 |
**生效优先级(同一工具上的概念顺序,便于排障)**
1. **`9999` 永久封禁**(若已写入 `mcp_tool_wire_tool_policies`):在组装 `tools[]` 的较早阶段即剔除,不进入后续分层与动态惩罚状态机。
2. **显式 `1`~`9998`**:走按工具闲置/罚分钟逻辑,**不享受** Top N 对「全局惩罚」的豁免。
3. **显式 `0`**:跳过全局闲置 omission,仍走压缩档位。
4. **未配置**:走全局线侧逻辑(含全局闲置与 Top N 豁免等),由 `prepare_openai_tools_for_llm_api` 与 `mcp_tool_wire_admin_config` / 环境变量共同决定。
`wire_policy=never` 时关闭分层与动态惩罚,但 **`9999` 仍会过滤** 对应工具。
全局闲置小时、罚时长(分钟)、Top N 全量、medium 档位等,在 **【6】** 中可调;未写入 `app_setting` 的项继续沿用环境变量(见仓库根 `data/mcp_local.env.example` 中 `OPS_MCP_WIRE_*`)。
**Admin HTTP API**(需 `admin:tenant:write`,与 MCP 安装类接口一致)
- `GET /admin/api/mcp/tool-wire` — 返回合并后的 `config`、当前 `policies`、`penalty_state`,以及已安装 MCP 工具列表(每条含 `policy_level`:`null` 表示未在库中配置,`policy_in_db` 标明是否持久化过)及惩罚/解封说明。
- `POST /admin/api/mcp/tool-wire/config` — 保存全局参数(部分字段可增量合并)。
- `POST /admin/api/mcp/tool-wire/policies` — body:`{ "policies": { "mcp__...": 整数等级 }, "clears": ["mcp__...", ...](可选) }`。先按 `clears` 从已存策略中**删除键**(用于管理台留空后恢复「未配置」),再合并 `policies`。
- `POST /admin/api/mcp/tool-wire/policies/batch` — body:`{ "level": 等级, "wire_names": ["mcp__...", ...] }`,批量写入策略。
实现代码:`oclaw/platform/llm/tool_wire_policy.py`;在发 Chat Completions 前由 `prepare_openai_tools_for_llm_api` 应用。
---
## 4. 专家分配(谁能用这个工具)
当前系统支持“按专家组”分配 MCP 工具可见性:
- 在 MCP 页面 `MCP specialists` 勾选可用专家
- 保存后立即生效(持久化在数据库)
注意:当前是“专家级分配”,不是“单工具级分配”。
---
## 5. 编写规范(强烈建议)
1. **stdout 只输出 JSON-RPC 响应行**
日志请写到 `stderr`,否则容易触发 `protocol_mismatch`。
2. **`tools/list` 返回稳定 schema**
建议所有参数都写明 `type` 与 `required`,并设 `additionalProperties: false`。
3. **`tools/call` 出错要可解释**
优先通过 JSON-RPC `error` 返回明确错误信息。
4. **避免长时间阻塞**
长任务应拆分或优化,否则会出现 `mcp_runtime_timeout`。
---
## 6. 常见错误与排查
- `mcp_runtime_timeout`
含义:子进程超时未返回。
排查:先本地单独运行 server,确认单次调用耗时;必要时调大 `timeout_s`。
- `mcp_runtime_protocol_mismatch`
含义:收到的不是 JSON-RPC 响应。
排查:检查是否把日志打印到了 stdout。
- `mcp_runtime_bad_json`
含义:输出不是合法 JSON。
排查:检查编码、换行、对象结构。
- `mcp_tools_list_invalid`
含义:`tools/list` 返回结构不符合预期。
排查:确认返回 `result.tools` 为数组,元素包含 `name`、`inputSchema`。
---
## 7. 建议开发流程
1. 本地先用脚本手动跑通 JSON-RPC
2. 管理台安装(建议先 `dry_run`)
3. 执行 `Health`、`Sync Tools`
4. 在 `Check Installed` 做批量体检
5. 按专家映射开放给目标专家
---
## 8. 与原有内置工具关系
MCP 工具是增量能力,不会替代原有内置工具体系。
最终都走统一 `ToolExecutor` 执行链(策略、超时、审计一致)。
# Local MCP Server Guide (stdio)
## Goal
Write local tools as a **standard MCP server over stdio (JSON-RPC)**, then connect them from Admin MCP Market.
This project now uses MCP standard flow in runtime:
- `initialize`
- `notifications/initialized`
- `tools/list`
- `tools/call`
Legacy custom `op` messages are only compatibility fallback.
---
## Minimal Python MCP Server
Save as `mcp_echo_server.py`:
```python
from __future__ import annotations
import json
import sys
from typing import Any
def _ok(rid: Any, result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _err(rid: Any, code: int, message: str) -> None:
sys.stdout.write(
json.dumps(
{"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}},
ensure_ascii=False,
)
+ "\n"
)
sys.stdout.flush()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception:
continue
rid = req.get("id")
method = str(req.get("method") or "")
params = req.get("params") if isinstance(req.get("params"), dict) else {}
if method == "initialize":
_ok(rid, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "echo-mcp", "version": "0.1.0"}})
continue
if method == "notifications/initialized":
# Notification has no response.
continue
if method == "tools/list":
_ok(
rid,
{
"tools": [
{
"name": "echo",
"description": "Echo input text.",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
"additionalProperties": False,
},
}
]
},
)
continue
if method == "tools/call":
name = str(params.get("name") or "")
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
if name != "echo":
_err(rid, -32601, f"unknown tool: {name}")
continue
text = str(args.get("text") or "")
_ok(rid, {"content": [{"type": "text", "text": text}]})
continue
_err(rid, -32601, f"method not found: {method}")
```
Run manually (sanity check):
```bash
python mcp_echo_server.py
```
Then send one JSON-RPC request line from stdin to verify.
---
## Install in Admin MCP Market
For local Python script:
- `source_type`: `pypi` (or any source type you use for bookkeeping)
- `source_ref`: custom label (for example `local-echo`)
- `entry_command`: `python`
- `entry_args`: `<absolute-or-relative-path-to-script>`
Example JSON install payload:
```json
{
"source_type": "pypi",
"source_ref": "local-echo",
"server_id": "local-echo",
"version": "",
"entry_command": "python",
"entry_args": ["D:/project/chatgpt/examples/mcp_echo_server.py"],
"required_permissions": [],
"risk_level": "low",
"enabled": true,
"timeout_s": 30
}
```
After install:
1. `Health`
2. `Sync Tools`
3. Verify tool count > 0
---
## @modelcontextprotocol/server-filesystem 与网关工作区
官方 **`@modelcontextprotocol/server-filesystem`** 只在**进程启动时**把命令行里列出的目录当作可访问根;多装一个路径就要多传一个 argv,否则 `list_directory` 等工具无法列出该目录。
本仓库在启动该 MCP 时会**自动合并**与内置工作区一致的路径来源,并**去重后追加**到 `entry_command` + `entry_args` 之后(不改变你在管理台填写的主根,只追加额外根):
| 来源 | 说明 |
| --- | --- |
| `OPS_WORKSPACE_EXTRA_ROOTS` | 环境变量,`\|` 分隔 |
| `OPS_MCP_FILESYSTEM_EXTRA_ROOTS` | 环境变量或 SQLite `settings` 表同名键,仅影响该 MCP |
| Admin「工作区路径」 | **当前用户聊天会话**(`ui_session_owner` 绑定的 `session_id`)对应账号的 `extra_roots`(`\|` 拆分);不会合并其他用户。若 `ui_session_owner` 行缺失,会从请求里携带的 `tenant_id` / `user_id`(`metadata`)**再拉一份**同一条 allowlist,与内置 `resolve_workspace_path` 及 MCP 追加 argv 对齐。 |
| Windows 路径 | 在网关侧与 MCP argv 中会对路径作规范化;若仍报「无权限」或子进程报路径不在根下,可对比管理台中保存的「绝对路径」与资源管理器里实际盘符/大小写是否一致,修改工作区后对该 MCP **Health → Sync Tools**。 |
**与 `allow_any_path` 的关系**:管理台里的 **`allow_any_path` 只影响网关内置工具**(走 `resolve_workspace_path` 的读文件、glob、`run_command` 等),相当于在 Python 侧跳过「必须在 workspace 根或 `extra_roots` 下」的检查。官方 **`server-filesystem` 不认这个字段**:子进程只认启动时写在 argv 里的**具体目录列表**,没有「允许任意路径」的等价开关,因此单靠 `allow_any_path: true` **不会**把 `D:\download` 等路径自动加进 MCP。要让 MCP 列到这些目录,请把它们写进 **`extra_roots`**(或 `OPS_WORKSPACE_EXTRA_ROOTS` / `OPS_MCP_FILESYSTEM_EXTRA_ROOTS`),再 **Health → Sync Tools**。
**运维注意**:同一网关进程内,不同用户对话会各自 materialize 一套 MCP 工具绑定(argv 含该用户 `extra_roots` + 全局 env)。管理台 **Health / Sync Tools** 无用户会话上下文,此时仅合并 **环境变量与 settings**,不含任一用户的 DB `extra_roots`。
修改环境或 DB 后,请对相应 MCP 执行 **`Health` → `Sync Tools`**(或重启网关),以便新进程带上更新后的 argv。
---
## Tool Result Format Recommendations
For `tools/call` result:
- success: return `{"content":[{"type":"text","text":"..."}]}`
- failure: return JSON-RPC `error` or `result` with `isError=true`
Keep responses deterministic and JSON-serializable.
---
## Common Errors
- `mcp_runtime_timeout`
Server did not answer in time. Check blocking calls, raise `timeout_s`, or optimize startup.
- `mcp_runtime_protocol_mismatch`
Output is not JSON-RPC response line. Ensure stdout only emits JSON-RPC lines (move logs to stderr).
- `mcp_runtime_bad_json`
Response line is malformed JSON. Validate serialization and newline framing.
- `mcp_tools_list_invalid`
`tools/list` did not return valid `tools` array.
---
## 通识工具库与 Cursor / Claude Code / oclaw(能力对齐说明)
**已能覆盖的常见编码助手能力**:仓库读写与搜索(内置 workspace + MCP filesystem)、Git 本地与 GitHub 远端、网页抓取与浏览器自动化(fetch / playwright)、会话库 SQLite、日历与时间、PDF、顺序思考与 memory MCP 等。
**单靠 MCP 无法等价的部分**:IDE 内 LSP 实时红线(Cursor 编辑器集成)、oclaw 式 **ACP 外接** Claude Code/Codex 子进程(需单独编排/通道产品化)。
### Context7(库文档时效)
- **作用**:按库名/版本拉取较新的官方文档片段,减少「API 记错版本」类幻觉。
- **安装**:`python scripts/install_mcp_context7.py`,或管理台 `POST /admin/api/mcp/install` 使用 [`examples/mcp_install_context7.json`](../examples/mcp_install_context7.json) 中的 `payload`。
- **密钥**:在 **`oclaw/_local/mcp_local.env`**(推荐)或 `data/mcp_local.env`(兼容)设置 `CONTEXT7_API_KEY`(见 [context7.com/dashboard](https://context7.com/dashboard))。两处都存在时**同键以 `oclaw/_local/mcp_local.env` 为准**(覆盖 `data` 中的同键)。未自定义 `OPS_MCP_ENV_ALLOWLIST` 时,网关默认 allowlist 已包含 `CONTEXT7_API_KEY`(见 `oclaw/ops/mcp_env.py`);若你自定义了 allowlist,请手动追加该键。
- **装完后**:`Health` → `Sync Tools` → 将 `mcp-context7` 加入通识 specialist 的 MCP 绑定(若脚本已成功 Sync,会自动追加)。
### 通识侧终端能力(`run_command`)
与 Claude Code「在仓库里跑命令」类似的能力来自内置 **`run_command`**,但通识 lane 需同时满足:
1. 环境 **`OPS_ENABLE_RUN_COMMAND=1`**(见 `oclaw/tools/catalog.py` 与 `oclaw/tools/experts/workspace/shell_tools.py` 门控)。
2. 仅在 **可信仓库 / 内网** 开启;否则易误执行高危命令。
### Postgres / Linear / Slack / Sentry 等
按实际业务栈再装对应 MCP 即可;无相关系统则不必安装,避免工具膨胀与误选。
---
## Multi-specialist Assignment
MCP tools are assigned in Admin UI by specialist mapping.
Only selected specialists can see/use MCP tools at runtime.

View file

@ -0,0 +1,35 @@
# OCLAW Compat Layer Removal Checklist
Use this checklist to verify readiness and post-delete safety for `oclaw/app_server/*` compatibility-module removal.
## Import graph checks
- [x] `rg "src\.app_server\."` has no runtime/code matches (tests excluded or updated).
- [x] CLI/runtime startup paths import from `oclaw.interfaces.*` only.
- [x] WebSocket entrypoint imports resolve through `oclaw.interfaces.ws.*`.
## Behavior parity checks
- [x] HTTP gateway smoke tests pass (`/health`, `/inbound`, `/gateway/method`, `/ws` handshake).
- [x] WS request/response contract remains unchanged for:
- [x] `connect`
- [x] `chat.send/chat.history/chat.abort`
- [x] `sessions.*`
- [x] `agent.run/agent.wait`
- [x] Plugin bootstrap still loads expected extension set.
## Prompt/skill checks
- [x] Runtime role context still loads from `oclaw/agent/*`.
- [x] Skill root priority still effective:
- [x] `AIA_SKILLS_ROOT`
- [x] `oclaw/skills`
- [x] `skills/` fallback
## Extension policy checks
- [x] Primary extension source remains `oclaw/extensions/`.
- [x] Legacy root `extensions/` has been fully merged and removed.
- [x] Duplicate plugin-id diagnostics still emitted as expected.
## Final cleanup
- [x] Delete compatibility files under `oclaw/app_server/*` only after all checks are green.
- [x] Remove stale docs/comments referring to old primary entrypoints.
- [x] Re-run lint and targeted compile checks after deletion.

View file

@ -0,0 +1,70 @@
# OpenClaw Migration Guide
This repository is migrating from legacy `oclaw/` runtime wiring to the new `oclaw/` architecture root.
## Current status
- `oclaw/` is the target root for new code.
- `oclaw/app_server/*` compatibility modules have been removed.
- Gateway dispatch now routes through shared `server_methods` handlers for both WS and HTTP method endpoints.
## Developer rules
- Add new business logic under `oclaw/` (interfaces/application/domain/infrastructure/shared).
- Avoid adding new core logic into legacy `oclaw/` modules.
- Keep `oclaw/` changes limited to re-export or compatibility adaptation.
- Before deleting compatibility modules, use:
- `oclaw/docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md`
## Runtime notes
- Gateway HTTP method adapter: `POST /gateway/method`
- WS dispatch first resolves method handlers from shared dispatcher.
- Inbound payload use-case entrypoint: `oclaw.application.gateway.process_inbound_payload_usecase`
- HTTP app entrypoint moved to: `oclaw.interfaces.http.fastapi_app`
- WS entrypoint moved to: `oclaw.interfaces.ws.entrypoint`
- WS runtime bridge path: `oclaw.interfaces.ws.runtime`
- WS runtime implementation seam:
- `oclaw/interfaces/ws/runtime_impl.py`
- `runtime.py` points to this module as stable import surface.
- Server-method WS bridge extracted to:
- `oclaw/interfaces/ws/server_methods_bridge.py`
- legacy class now delegates dispatch/context construction to this bridge.
- Agent turn execution extracted to:
- `oclaw/interfaces/ws/turn_runner.py`
- legacy `run_agent_turn` now delegates to this module.
- WS request dispatch path is now single-source:
- connected requests go through `server_methods` bridge first;
- unknown methods return standardized invalid-request errors.
- Legacy WS `handle_*` and schema-specific validate helpers were removed from the class;
runtime behavior now comes from dispatcher + bridge modules.
- WS schema access is now routed via:
- `oclaw/interfaces/ws/ws_schema.py`
- legacy gateway imports schema helpers through the oclaw namespace.
- WS schema implementation has been migrated to:
- `oclaw/interfaces/ws/schema_impl.py`
- no legacy `oclaw/app_server/ws_schema.py` dependency remains.
- WS auth + hello payload builders moved to:
- `oclaw/interfaces/ws/auth_and_hello.py`
- legacy gateway delegates `resolve_ws_auth` and `build_hello_ok`.
- WS frame/event emit helpers moved to:
- `oclaw/interfaces/ws/events.py`
- legacy gateway delegates `send_res/send_event/emit_*`.
- WS runtime helpers moved to:
- `oclaw/interfaces/ws/runtime_helpers.py`
- legacy gateway delegates `_recv_frame` and `_handle_connect`.
- WS main loop + close behavior moved to:
- `oclaw/interfaces/ws/runtime_loop.py`
- legacy gateway delegates `run()` and `_close_ws()`.
- WS connected-request dispatch moved to:
- `oclaw/interfaces/ws/runtime_dispatch.py`
- legacy gateway delegates `_dispatch_connected()`.
## Extension source policy
- Primary source: `oclaw/extensions/`
- Legacy root `extensions/` has been merged into `oclaw/extensions/` and removed.
## Prompt and skills policy
- Role context is loaded from `oclaw/agent/*`.
- Skills root priority:
1. `AIA_SKILLS_ROOT`
2. `oclaw/skills`
3. `skills/` (legacy fallback)

View file

@ -0,0 +1,27 @@
# Prompt Style Guide
## Goal
- All model-facing prompts must be Markdown templates under `oclaw/prompts/`.
- Business code must inject variables only; no long inline prompt strings.
## Template Contract
- File format: `.md` with frontmatter.
- Required frontmatter keys: `title`, `summary`, `read_when`.
- Variables use `{{var_name}}`.
- Missing variables must fail in strict mode.
## Required Section Order
1. Identity and objective
2. Input constraints
3. Execution rules
4. Output format
5. Safety and prohibitions
6. Optional runtime context blocks
## Rules
- Use imperative instructions: must / must not / only when.
- Keep deterministic section ordering for cache stability.
- Prefer machine-parseable blocks for injected context.
- Do not embed raw JSON protocol examples unless needed.
- Keep bilingual copies as separate template files when wording diverges.

448
docs/RUNBOOK.md Normal file
View file

@ -0,0 +1,448 @@
# 运行手册(RUNBOOK)
本文档面向“日常运维与排障”场景,聚焦可直接执行的命令与操作顺序。
关联文档:
- trace 字段与阶段对照:`docs/openclaw-trace-taxonomy.md`
- skill 安装/执行排障:`docs/openclaw-skill-troubleshooting.md`
---
## 1. 统一入口(只保留最新)
所有运维命令统一通过 `scripts/`,不要再使用 `python -m oclaw.ops ...` 或历史 `.bat` 方式。
补充:工具脚本也统一放在 `scripts/`(例如 `seed_mcp_registry.py`、`ws_probe.py`)。
---
## 2. 首次初始化
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_venv.ps1
```
Linux/macOS:
```bash
./scripts/start_ops.sh
```
---
## 3. 配置企业微信(Bot 模式)
在网关启动后,统一在管理台完成通道配置,不再使用旧 CLI 命令入口。
---
## 4. 启停运行栈
启动:
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background`
(含微信 sidecar):
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithWeixin`
(含微信 sidecar + wiki worker):
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithWeixin -WithWikiWorker`
查看状态:
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1`
(含微信 sidecar):
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWeixin`
(含微信 sidecar + wiki worker):
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWeixin -WithWikiWorker`
停止:
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1`
(含微信 sidecar):
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWeixin`
(含微信 sidecar + wiki worker):
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWeixin -WithWikiWorker`
说明:
- `stack up` 不会启动 Streamlit
- 聊天页面统一使用 `http://127.0.0.1:8787/chat`
- `--with-ui` 为历史参数,不再生效
---
## 5. 仅启动网关
`powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1`
---
## 6. 管理台与认证
先启动网关,再访问:
- `http://127.0.0.1:8787/admin`
- `http://127.0.0.1:8787/chat`
### 6.1 初始化管理员(幂等)
```bash
curl -X POST http://127.0.0.1:8787/admin/api/auth/bootstrap
```
### 6.2 登录获取 Bearer Token
```bash
curl -X POST http://127.0.0.1:8787/admin/api/auth/login \
-H "content-type: application/json" \
-d '{"tenant_id":"<tenant_id>","username":"administrator","password":"<pwd>","purpose":"console"}'
```
### 6.3 带 Token 调用受保护接口
```bash
curl http://127.0.0.1:8787/admin/api/users?tenant_id=<tenant_id> \
-H "authorization: Bearer <token>"
```
RBAC 规则要点:
- `owner` 拥有完整管理权限
- 其它角色权限由 `role_permission` + `user_permission` 决定
- 跨租户写操作会被拒绝(`403`)
### 6.4 `/chat` 会话与用户隔离(排障)
Admin Chat 依赖表 **`ui_session_owner`**(`session_id` → `tenant_id` + `user_id`)判定「谁可见、谁可写」某条 `chat_session`。列表、读消息、流式回复、停止生成、导出等均经该归属校验。
**请勿依赖的历史行为(已移除)**
- 用户会话列表为空时,**不再**自动把库内所有「无 owner」会话划给该用户(否则多用户会互相看到对方会话)。
- 读取某 `session_id` 时,**不会**再「若无 owner 则绑定到当前请求用户」(否则谁先打开链接谁抢走归属)。
**若升级后有人看不到旧会话**
说明这些 `chat_session` 从未写入 `ui_session_owner`(列表与读接口均只认该表)。处理方式(需在维护窗口评估数据归属):
1. 自行 SQL 排查:`SELECT s.id, s.title, s.created_at FROM chat_session s LEFT JOIN ui_session_owner o ON o.session_id = s.id WHERE o.session_id IS NULL;` 确认归属后按需 `INSERT INTO ui_session_owner(session_id, tenant_id, user_id, created_at) VALUES (...)`。
2. 在 **Python 控制台** 仅在**确认整库孤儿会话均属同一用户**时,可显式调用 `SqliteStore.backfill_orphan_chat_sessions_for_user`(该方法会一次性把**当前仍无 owner 的全部**会话绑到传入的 `user_id`,**不适合多用户已混用生产库**)。
3. **`administrator`** 在 **`/chat` 侧边栏**与普通用户相同,只列出 **自己名下**(`ui_session_owner.user_id` = 管理员账号)的会话,**不会**把他人会话混进自己的列表。查看本租户全部会话请用 **审计 / Session Monitor** 或接口 **`GET /admin/api/chat/admin/sessions`**(需相应权限)。单会话消息读写在管理员仍可按租户校验(便于从监控打开指定 `session_id`)。
**浏览器端**
独立 `/chat` 页在检测到 **登录租户/用户** 与上次不一致时会丢弃 URL 中的 `?session_id=`,避免同一浏览器换账号后仍打开上一用户的深链。
**工作区 ``extra_roots``(与编排临时会话)**
管理台为用户配置的 ``user_workspace_path_allowlist.extra_roots`` 通过 ``ui_session_owner`` 解析到租户+用户。总控编排里专家步往往在**无 owner 的临时** ``chat_session`` 上落库中间消息:内置路径类工具会携带 **用户 UI 会话 id 作为 fallback**,仍按该用户策略合并 ``extra_roots``;MCP filesystem 启动参数本就按用户聊天 ``session_id``(policy)合并,二者现已对齐。
### 6.5 主库路径与「删掉的会话又回来了 / 新建用户不见了」
默认主库为 **`data/ai_ops.sqlite`**(未设置 ``OPS_ASSISTANT_DB_PATH`` 时)。历史上曾把库放在 **`../data/ai_ops.sqlite`** 或 **`oclaw/platform/data/ai_ops.sqlite`**;首次启动若检测到这些旧位置且新主库尚不存在,会把整库**复制**到 `data/ai_ops.sqlite`,并把旧路径下的附件**补拷**到 `data/attachments/`(不覆盖已有文件)。确认新主库生效后,本机可按需清理旧目录(避免磁盘上留着陈旧副本)。
历史上若旧库里的 ``chat_session`` **行数大于**当前主库,会用**整份旧库覆盖**主库。用户大量**删除会话**后主库行数变少,会误触发该逻辑,表现为:**已删会话从旧快照恢复**、**只在主库里出现的新用户/新数据被整库覆盖掉**。
**当前版本已关闭该自动覆盖**;仅当显式设置环境变量 **`OPS_LEGACY_DB_FORCE_PREMERGE=1`** 时才允许按旧规则合并(仍会先把当前主库备份到 ``data/_pre_merge_sqlite_<时间戳>/``)。
**排障建议**:确认所有网关/进程使用**同一** ``OPS_ASSISTANT_DB_PATH``(或统一依赖默认 ``data/ai_ops.sqlite``);若曾出现覆盖,可在 ``data/_pre_merge_sqlite_*`` 中找回被备份出去的主库副本。
---
## 7. 常用脚本入口
Windows PowerShell:
- `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1`
- `powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1`
- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1`
- (联动)`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background`
- (联动)`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1`
Linux/macOS:
- `./scripts/start_ops.sh`
- `./scripts/status_ops.sh`
- `./scripts/stop_ops.sh`
---
## 8. MCP 运维流程(管理台)
推荐顺序:
1. `Install`(或 `Reinstall`)
2. `Health`
3. `Sync Tools`
4. `Check Installed`(批量体检)
若失败,重点看错误码:
- `mcp_runtime_timeout`
- `mcp_runtime_protocol_mismatch`
- `mcp_runtime_bad_json`
- `mcp_tools_list_invalid`
详细开发与接入参考:`docs/MCP_LOCAL_SERVER.md`
---
## 9. 向量记忆配置(可选)
可通过环境变量或管理台配置:
- `MEMORY_VECTOR_ENABLED` (`0/1`)
- `MEMORY_VECTOR_BACKEND` (`sqlite` / `chroma` / `qdrant`)
- `MEMORY_VECTOR_TOPK`(默认 `5`)
- `MEMORY_WRITE_ENABLED` (`0/1`)
- `MEMORY_WRITE_MIN_CONFIDENCE`(默认 `0.75`)
故障降级策略:
- 向量后端不可用时,读写回退到 SQLite 向量实现
- 关闭写入时,不影响聊天主流程
---
## 10. 常见故障速查
### 10.1 管理台无法登录
- 是否设置 `AIA_ASSISTANT_PASSWORD`
- 是否重启服务并让新环境变量生效
### 10.2 MCP 显示可用但工具数为 0
- 先执行 `Health`
- 再执行 `Sync Tools`
- 再执行 `Check Installed`
### 10.3 Check Installed 全红
- 检查 server 是否输出标准 JSON-RPC(stdout)
- 日志必须写 stderr,避免协议污染
- 确认 `entry_command/entry_args` 正确
### 10.4 微信能收不能回 / 回不去
按顺序检查:
1) 网关是否健康(`/health` 必须快速返回)
2) sidecar 是否运行(`weixin_status.ps1`)
3) sidecar 日志是否有 `sendmessage` 失败提示(`weixin_sidecar.err.log`)
如果 8787 端口被僵尸进程占用,先执行:
- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1 -Force`
- `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1`
- `powershell -ExecutionPolicy Bypass -File .\scripts\weixin_stop.ps1 -Force`
- `powershell -ExecutionPolicy Bypass -File .\scripts\weixin_start.ps1`
---
## 11. 个人微信(官方 ClawBot)接入
说明:本项目采用「A 模式」接入。微信插件由本地 sidecar 运行,直接调用本地 Python gateway。
前置条件:
- 微信端已开通并启用 ClawBot 插件
- 当前目录为 `D:/project/chatgpt/oclaw`
- 已安装 Node.js(建议 22+)与 npm
- 网关可访问:`http://127.0.0.1:8787/health`
### 11.1 安装 sidecar 依赖
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_install.ps1
```
安装目录:
- `data/channel_sidecar/openclaw-weixin/`
### 11.2 扫码登录(获取 bot token)
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_login.ps1
```
登录态写入:
- `data/channel_sidecar/openclaw-weixin/state/openclaw-weixin/accounts/*.json`
- `data/channel_sidecar/openclaw-weixin/state/openclaw-weixin/accounts.json`
### 11.3 启动微信 sidecar
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_start.ps1
```
查看状态:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_status.ps1
```
停止:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_stop.ps1
```
日志文件:
- `data/channel_sidecar/openclaw-weixin/logs/weixin_sidecar.log`
- `data/channel_sidecar/openclaw-weixin/logs/weixin_sidecar.err.log`
### 11.4 当前行为说明
- 微信回复为“整段返回”,非逐 token 流式
- 发送前会清理推理/工具痕迹(如 `<redacted_thinking>...</redacted_thinking>`)
- 发送前会处理文本换行(含字面量 `\n`)
---
## 12. LLM 回放策略(reasoning / content / tool)
适用范围:`oclaw` 运行时构建“下一轮发给模型”的消息序列。
### 12.1 设计原则
- `content` 与 `reasoning` 分离:正文走 `assistant_text`,推理走独立 `reasoning` 事件。
- 默认不回放推理文本:回放只包含正文 + tool(及必要的配对字段)。
- 历史兼容:旧数据中若正文含 `<think>...</think>` 或 `<redacted_thinking>...</redacted_thinking>`,在回放构建时会剥离。
### 12.2 工具回放分层
- 最近 3 轮工具调用保留全量结果。
- 更早工具结果降级为摘要(保留 `tool_call_id` 配对信息,避免网关拒绝)。
- 单条内容仍受现有超长截断限制。
### 12.3 推理签名白名单(provider 兼容)
只针对“签名元字段”而非推理文本。用于部分 provider 在工具连续调用时保持上下文连续性。
环境变量:`AIA_REPLAY_REASONING_SIGNATURE_POLICY`
- `auto`(默认):仅在白名单 provider 路径回放签名元字段(当前包含 Gemini 路径)。
- `on`:所有模型都回放签名元字段(调试/兼容兜底)。
- `off`:完全不回放签名元字段(最严格模式)。
推荐:
- 常规生产:保持默认 `auto`。
- 若遇到特定模型 tool-loop 连续性问题:临时设为 `on` 验证,再收敛到最小白名单。
### 12.4 `.env` 最小配置示例
```bash
# 工具回放:最近 3 轮全量(默认 3,可按需调整)
AIA_REPLAY_TOOL_FULL_ROUNDS=3
# 推理签名回放策略:auto / on / off
# 生产建议 auto:仅白名单 provider 回放签名元字段
AIA_REPLAY_REASONING_SIGNATURE_POLICY=auto
```
---
## 13. memory-wiki 插件启用与排障
### 13.1 启用配置(oclaw/oclaw.json)
将 `memory-wiki` 放入启用列表,并建议把 memory slot 指向它:
```json
{
"plugins": {
"enabled": ["memory-wiki"],
"slots": {
"memory": "memory-wiki"
},
"entries": {
"memory-wiki": {
"wiki_root": "oclaw/docs/memory-system/wiki",
"max_search_results": 20,
"max_get_lines": 800,
"auto": {
"enabled": true,
"inject": {
"max_chars": 1800,
"top_k": 6
},
"worker": {
"enabled": true
},
"topic_routing": {
"rules": [
{
"topic": "network",
"keywords": ["vlan", "router", "switch", "network", "dns", "gateway"]
},
{
"topic": "devops",
"keywords": ["deploy", "k8s", "kubernetes", "docker", "ci", "ops"]
},
{
"topic": "engineering",
"keywords": ["bug", "fix", "todo", "feature", "refactor", "test"]
}
]
}
}
}
}
}
}
```
### 13.2 会话可用工具(预期)
- `wiki_status`
- `wiki_get`
- `wiki_search`
- `wiki_lint`
- `wiki_apply`
### 13.3 常见故障
- `invalid_arguments`:参数缺失或 `path` 非 `.md` / 越界到 wiki 根目录外。
- `wiki_not_found`:目标文件不存在(`wiki_get` / `wiki_apply delete`)。
- `invalid_action`:`wiki_apply` 的 `action` 仅支持 `write|append|delete`。
- `wiki_runtime_error`:运行期异常(编码、权限、IO),先检查路径权限与文件占用。
### 13.4 回退方案
- 临时禁用 wiki 插件:从 `plugins.enabled` 移除 `memory-wiki` 后重启 gateway。
- 或仅关闭 memory slot:`plugins.slots.memory` 设为 `"none"`(保留插件安装但不作为 memory 槽位)。
### 13.5 自动化链路自检(smoke test)
在 `gateway + wiki worker` 运行时,执行:
```powershell
python .\scripts\wiki_auto_smoke_test.py
```
该脚本会:
- 投递一条 `wiki_capture` 任务到 `openclaw_task`
- 轮询任务状态直到 `done/failed/timeout`
- 输出当前写入产物状态(`merged-turns.md`、`topic-index.json`、`index.json`、`LINT_REPORT.md`)
---

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,11 @@
# Archives
Use for inactive materials from Projects, Areas, and Resources.
Archive criteria:
- project is complete or cancelled
- responsibility is no longer active
- resource is stale and low-value
Archived content stays searchable but should not appear in daily workflow.

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,11 @@
# Areas
Use for ongoing responsibilities without a fixed end date.
Examples:
- engineering standards
- health routines
- language learning maintenance
Review regularly and keep only current responsibility notes.

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,11 @@
# Inbox
Temporary capture queue for raw inputs.
Daily target:
- capture 3-5 valuable items
- distill 1-3 items into atomic notes
- leave no high-value item unprocessed by end of day
Do not store long-term notes here.

View file

@ -0,0 +1,22 @@
# PARA Container Contract
These containers are fixed and should remain stable:
- `Projects`: active, deadline-bound outcomes
- `Areas`: ongoing responsibilities without an end date
- `Resources`: reusable reference knowledge and learning notes
- `Archives`: inactive material moved out of active flow
- `Inbox`: temporary capture queue before processing
## Operating Rules
1. Do not add new top-level containers unless there is a major redesign.
2. Process `Inbox` items during daily/weekly routines.
3. Store each item in exactly one primary container.
4. Move inactive items to `Archives` during weekly review.
## Move Guide
- `Inbox` -> `Projects`/`Areas`/`Resources` after distillation
- `Projects` -> `Archives` when outcome is finished or dropped
- `Areas`/`Resources` -> `Archives` when no longer relevant

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,11 @@
# Projects
Use for time-bound outcomes with a clear deadline.
Examples:
- feature launch notes
- exam preparation sprint
- migration checklist
When completed or dropped, move materials to `../Archives`.

View file

@ -0,0 +1,90 @@
# Memory System Implementation
This folder implements a lightweight memory workflow based on:
- PARA for operational organization
- Atomic notes for reusable knowledge
- SRS for long-term retention
- Weekly review for maintenance and quality control
## Fixed PARA Containers
Do not change these top-level containers unless there is a major system redesign.
- `Projects`: time-bound outcomes with a deadline
- `Areas`: ongoing responsibilities without an end date
- `Resources`: reference topics and learning materials
- `Archives`: inactive content from all other containers
- `Inbox`: temporary capture queue before classification
Container contract and move rules are defined in `PARA.md`.
## Minimal Workflow (10-20 minutes/day)
1. Capture: move 3-5 raw items into `Inbox`.
2. Distill: convert 1-3 items into atomic notes under `Resources`.
3. Recall: generate 3-10 SRS cards using the conversion guide.
4. Express: produce one output (summary, answer, code note).
## Weekly Review (30 minutes/week)
Use `templates/weekly-review.md` to:
- empty inbox
- improve links
- archive stale items
- define next week's focus reviews
Use `weekly-review-schedule.md` to keep the review on a fixed weekly slot.
## Two-Week Minimum Rollout
- Week 1:
- initialize PARA containers (`Projects`, `Areas`, `Resources`, `Archives`, `Inbox`)
- capture and process notes daily using the workflow below
- convert at least one high-value note/day into SRS cards
- Week 2:
- enforce linking quality (each new note links to at least one existing note)
- run one full weekly review
- tune new-card load based on backlog and study time
## Templates
- `templates/atomic-note.md`
- `templates/srs-conversion.md`
- `templates/weekly-review.md`
- `templates/daily-routine.md`
## Folder Guidance
- `Projects/README.md`
- `Areas/README.md`
- `Resources/README.md`
- `Archives/README.md`
- `Inbox/README.md`
## Success Metrics
- 7 days: daily capture + review runs without backlog blow-up.
- 30 days: core topics can be recalled without opening source material.
- 60-90 days: writing/decision/coding reuse speed improves and repeated lookup drops.
- If review load is too high: reduce new cards first, keep only high-value knowledge.
## Automation Commands
Use the unified CLI to run this system with automatic recommendations:
- `powershell -ExecutionPolicy Bypass -File .\scripts\status_ops.ps1`
- `powershell -ExecutionPolicy Bypass -File .\scripts\start_ops.ps1`
- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_ops.ps1`
Outputs are generated in `docs/memory-system/runs/`:
- `daily-YYYY-MM-DD.md`
- `weekly-YYYY-Www.md`
What is automated:
- fixed folder validation (`Projects/Areas/Resources/Archives/Inbox`)
- smart new-card limit recommendation (3-10/day based on backlog + inbox pressure)
- automatic focus ordering (`Projects > Areas > Resources`, weighted by active note volume)

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,11 @@
# Resources
Use for reusable knowledge and reference topics.
This is the main home of atomic notes.
Requirements:
- one note = one claim
- link each new note to at least one existing note
- create SRS candidates for high-value notes

View file

@ -0,0 +1,19 @@
# Daily Memory Run (2026-04-21)
- Focus topic: `Resources`
- Suggested new cards today: `9`
- Current inbox notes: `0`
- Review backlog input: `25`
## Auto Steps
1. Capture 3-5 high-value items into `Inbox`.
2. Distill 1-3 items into atomic notes under `Resources`.
3. Convert notes into Q/A or cloze cards (respect suggested limit).
4. Produce one output (summary/answer/code note).
## Smart Suggestions
- Priority order now: `Resources`
- If reviews feel overloaded, reduce new cards before skipping due reviews.
- If inbox keeps growing for 2+ days, process inbox first before new captures.

View file

@ -0,0 +1,24 @@
# Weekly Memory Review (2026-W17)
- Run date: `2026-04-21`
- Suggested next-week new cards/day: `9`
## Snapshot
- Projects notes: `0`
- Areas notes: `0`
- Resources notes: `0`
- Archives notes: `0`
- Inbox notes: `0`
## Fixed Review Checklist
- [ ] Inbox zero
- [ ] Linking/backlinks completion
- [ ] Move inactive notes to Archives
- [ ] Tune next-week card load
## Auto Focus for Next Week
- Top topics: `Resources`
- Keep new cards within 3-10/day and adjust by real review pressure.

View file

@ -0,0 +1,35 @@
# Atomic Note Template
> Rule: one note, one claim.
## Title
`<clear statement in your own words>`
## Viewpoint (Claim)
- What is the key idea?
- Why does it matter?
## Source
- Origin: `<book/article/video/conversation>`
- Link or citation: `<url or reference>`
- Capture date: `<YYYY-MM-DD>`
## Associations (Links)
- Related note 1: `[[...]]`
- Related note 2: `[[...]]`
- Contradiction or alternative: `[[...]]`
## Reusable Scenarios
- Where can this be applied? (writing/decision/coding)
- Trigger signal: "When I see X, apply this note."
## Card Candidates (Optional)
- Q: `<question>`
A: `<short answer>`
- Cloze: `<sentence with one hidden concept>`

Some files were not shown because too many files have changed in this diff Show more