重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,27 @@
from .experts import (
build_expert_catalog_block,
create_expert,
delete_expert,
discover_specialist_ids_from_workspaces,
expert_workspace_signature_token,
is_builtin_expert,
list_experts,
normalize_expert_id,
update_expert_files,
warm_expert_workspace_cache,
workspaces_root,
)
__all__ = [
"build_expert_catalog_block",
"create_expert",
"delete_expert",
"discover_specialist_ids_from_workspaces",
"expert_workspace_signature_token",
"is_builtin_expert",
"list_experts",
"normalize_expert_id",
"update_expert_files",
"warm_expert_workspace_cache",
"workspaces_root",
]

View file

@ -0,0 +1,314 @@
from __future__ import annotations
import copy
import threading
from pathlib import Path
from typing import Any
from oclaw.platform.config.paths import PROJECT_ROOT
_REQUIRED_FILES: tuple[str, ...] = ("SOUL.md",)
_OPTIONAL_FILES: tuple[str, ...] = ("ROLE_SYSTEM.md",)
_ALL_FILES: tuple[str, ...] = (*_REQUIRED_FILES, *_OPTIONAL_FILES)
_RESERVED_IDS: frozenset[str] = frozenset({"main"})
_CACHE_LOCK = threading.Lock()
_LIST_CACHE_SIGNATURE: tuple[Any, ...] | None = None
_LIST_CACHE_ROWS: list[dict[str, Any]] = []
_CATALOG_CACHE: dict[tuple[Any, ...], str] = {}
_SPECIALIST_IDS_CACHE: dict[tuple[Any, ...], tuple[str, ...]] = {}
def workspaces_root() -> Path:
return (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve()
def normalize_expert_id(raw: Any) -> str:
text = str(raw or "").strip().lower()
out: list[str] = []
for ch in text:
if ch.isalnum() or ch in {"-", "_"}:
out.append(ch)
elif ch.isspace():
out.append("-")
return "".join(out).strip("-_")
def is_builtin_expert(expert_id: str) -> bool:
return normalize_expert_id(expert_id) in _RESERVED_IDS
def _workspace_signature() -> tuple[Any, ...]:
root = workspaces_root()
if not root.exists() or not root.is_dir():
return ("missing",)
rows: list[tuple[str, str, int, int]] = []
for item in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if not item.is_dir():
continue
if item.name.startswith("__"):
continue
eid = normalize_expert_id(item.name)
if not eid:
continue
for name in _ALL_FILES:
p = item / name
if not p.exists() or not p.is_file():
continue
try:
st = p.stat()
rows.append((eid, name, int(getattr(st, "st_mtime_ns", 0)), int(st.st_size)))
except Exception:
continue
return tuple(rows)
def expert_workspace_signature_token() -> tuple[Any, ...]:
"""Stable token for cache invalidation when workspace files change."""
return _workspace_signature()
def _clear_experts_cache() -> None:
global _LIST_CACHE_SIGNATURE, _LIST_CACHE_ROWS
with _CACHE_LOCK:
_LIST_CACHE_SIGNATURE = None
_LIST_CACHE_ROWS = []
_CATALOG_CACHE.clear()
_SPECIALIST_IDS_CACHE.clear()
def _normalize_supported_files(files: dict[str, Any] | None) -> dict[str, str]:
raw = files if isinstance(files, dict) else {}
out: dict[str, str] = {}
for k, v in raw.items():
name = str(k or "").strip()
if not name:
continue
if name not in _ALL_FILES:
raise ValueError("unsupported_file_name")
out[name] = str(v or "")
return out
def list_experts() -> list[dict[str, Any]]:
global _LIST_CACHE_SIGNATURE, _LIST_CACHE_ROWS
sig = _workspace_signature()
with _CACHE_LOCK:
if _LIST_CACHE_SIGNATURE == sig:
return copy.deepcopy(_LIST_CACHE_ROWS)
root = workspaces_root()
out: list[dict[str, Any]] = []
if not root.exists() or not root.is_dir():
with _CACHE_LOCK:
_LIST_CACHE_SIGNATURE = sig
_LIST_CACHE_ROWS = []
_CATALOG_CACHE.clear()
return out
for item in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if not item.is_dir():
continue
eid = normalize_expert_id(item.name)
if not eid:
continue
files: dict[str, str] = {}
for name in _ALL_FILES:
p = item / name
if not p.exists() or not p.is_file():
files[name] = ""
continue
try:
files[name] = p.read_text(encoding="utf-8")
except Exception:
files[name] = ""
out.append(
{
"id": eid,
"path": str(item),
"builtin": is_builtin_expert(eid),
"has_required_soul": bool(str(files.get("SOUL.md") or "").strip()),
"files": files,
}
)
with _CACHE_LOCK:
_LIST_CACHE_SIGNATURE = sig
_LIST_CACHE_ROWS = copy.deepcopy(out)
_CATALOG_CACHE.clear()
return out
def _one_line_summary(text: str, *, limit: int = 120) -> str:
s = " ".join(str(text or "").strip().split())
if len(s) <= limit:
return s
return s[: max(0, limit - 1)] + "…"
def build_expert_catalog_block(*, include_main: bool = False, per_field_limit: int = 120, max_total_chars: int = 4000) -> str:
sig = _workspace_signature()
cache_key = (sig, bool(include_main), int(per_field_limit), int(max_total_chars))
with _CACHE_LOCK:
cached = _CATALOG_CACHE.get(cache_key)
if isinstance(cached, str):
return cached
rows = list_experts()
lines: list[str] = []
for row in rows:
eid = str(row.get("id") or "").strip().lower()
if not eid:
continue
if not include_main and eid == "main":
continue
if eid in {"pycache", "__pycache__"} or eid.endswith("pycache"):
continue
if not bool(row.get("has_required_soul")):
continue
files = row.get("files") if isinstance(row, dict) else {}
f = files if isinstance(files, dict) else {}
role_system = _one_line_summary(str(f.get("ROLE_SYSTEM.md") or ""), limit=per_field_limit)
soul = _one_line_summary(str(f.get("SOUL.md") or ""), limit=per_field_limit)
bits: list[str] = [f"- {eid}"]
if role_system:
bits.append(f"role_system={role_system}")
if soul:
bits.append(f"soul={soul}")
line = " | ".join(bits)
lines.append(line)
out = "\n".join(lines).strip()
if len(out) > max_total_chars:
out = out[: max_total_chars - 1] + "…"
with _CACHE_LOCK:
_CATALOG_CACHE[cache_key] = out
return out
def discover_specialist_ids_from_workspaces(
*,
base_order: tuple[str, ...] = ("generalist", "ops", "image", "memory"),
) -> tuple[str, ...]:
sig = _workspace_signature()
cache_key = (sig, tuple(str(x).strip().lower() for x in base_order if str(x).strip()))
with _CACHE_LOCK:
cached = _SPECIALIST_IDS_CACHE.get(cache_key)
if isinstance(cached, tuple):
return cached
discovered: list[str] = []
for row in list_experts():
sid = str(row.get("id") or "").strip().lower()
if not sid or sid == "main":
continue
# Ignore cache-like directories and malformed expert folders.
if sid in {"pycache", "__pycache__"} or sid.endswith("pycache"):
continue
if not bool(row.get("has_required_soul")):
continue
discovered.append(sid)
ordered: list[str] = []
for sid in cache_key[1]:
if sid not in ordered:
ordered.append(sid)
for sid in discovered:
if sid not in ordered:
ordered.append(sid)
out = tuple(ordered)
with _CACHE_LOCK:
_SPECIALIST_IDS_CACHE[cache_key] = out
return out
def warm_expert_workspace_cache() -> None:
_ = list_experts()
_ = build_expert_catalog_block(include_main=False, per_field_limit=120, max_total_chars=4000)
_ = discover_specialist_ids_from_workspaces()
def _workspace_dir(expert_id: str) -> Path:
eid = normalize_expert_id(expert_id)
if not eid:
raise ValueError("invalid_expert_id")
return (workspaces_root() / eid).resolve()
def create_expert(*, expert_id: str, files: dict[str, Any]) -> dict[str, Any]:
eid = normalize_expert_id(expert_id)
if not eid or eid in _RESERVED_IDS:
raise ValueError("invalid_expert_id")
root = workspaces_root()
root.mkdir(parents=True, exist_ok=True)
target = (root / eid).resolve()
if target.exists():
raise ValueError("expert_exists")
clean_files = _normalize_supported_files(files)
soul = str(clean_files.get("SOUL.md") or "").strip()
if not soul:
raise ValueError("soul_required")
target.mkdir(parents=True, exist_ok=False)
for name in _ALL_FILES:
body = str(clean_files.get(name) or "")
if name in _REQUIRED_FILES and not body.strip():
continue
if body:
(target / name).write_text(body.strip() + "\n", encoding="utf-8")
if not (target / "SOUL.md").exists():
(target / "SOUL.md").write_text(soul + "\n", encoding="utf-8")
_clear_experts_cache()
return {"id": eid, "path": str(target)}
def update_expert_files(*, expert_id: str, files: dict[str, Any]) -> dict[str, Any]:
eid = normalize_expert_id(expert_id)
if not eid:
raise ValueError("invalid_expert_id")
target = _workspace_dir(eid)
if not target.exists() or not target.is_dir():
raise ValueError("expert_not_found")
clean_files = _normalize_supported_files(files)
next_files: dict[str, str] = {}
for name in _ALL_FILES:
if name in clean_files:
next_files[name] = str(clean_files.get(name) or "")
else:
p = target / name
next_files[name] = p.read_text(encoding="utf-8") if p.exists() and p.is_file() else ""
if not str(next_files.get("SOUL.md") or "").strip():
raise ValueError("soul_required")
for name in _ALL_FILES:
p = target / name
body = str(next_files.get(name) or "")
if body.strip():
p.write_text(body.strip() + "\n", encoding="utf-8")
elif p.exists():
p.unlink()
_clear_experts_cache()
return {"id": eid, "path": str(target)}
def delete_expert(expert_id: str) -> None:
eid = normalize_expert_id(expert_id)
if not eid:
raise ValueError("invalid_expert_id")
if is_builtin_expert(eid):
raise ValueError("builtin_expert_protected")
target = (workspaces_root() / eid).resolve()
if not target.exists() or not target.is_dir():
raise ValueError("expert_not_found")
for p in sorted(target.glob("**/*"), reverse=True):
if p.is_file():
p.unlink()
elif p.is_dir():
p.rmdir()
target.rmdir()
_clear_experts_cache()
__all__ = [
"build_expert_catalog_block",
"create_expert",
"delete_expert",
"discover_specialist_ids_from_workspaces",
"expert_workspace_signature_token",
"is_builtin_expert",
"list_experts",
"normalize_expert_id",
"update_expert_files",
"warm_expert_workspace_cache",
"workspaces_root",
]

View file

@ -0,0 +1,19 @@
你是通识专家(generalist specialist)。
## 输入约束:
- 用户任务可能涉及文件、目录、PDF、URL、代码仓库、数据库查询。
- 默认中文回答;用户明确要求英文时再切换。
## 执行规则:
1. 涉及外部数据/执行动作时,必须优先调用可用工具,不允许猜测式回答。
2. 若回答声明“已读取/已检查/已执行”,必须有对应工具证据。
3. 若模型接口不支持原生 tool_calls 闭环,不要继续原生 tool_calls;改为纯文本或纯 JSON 意图。
4. 目录列举/文件读取优先低风险工具;高风险执行工具需用户明确要求。
5. 工具调用由平台协议承载,不要在正文里输出工具协议 JSON。
## 输出格式:
- 先给可验证结论,再给必要步骤。
## 主要事项:
- 工具失败时先报告 `error_code` 与原因,再给下一步。
- 禁止伪造工具结果。

View file

@ -0,0 +1,11 @@
你是图像专家(image specialist)。
## 输入约束:
- 任务可能是生成、编辑、抠图、扩图或风格迁移。
## 执行规则:
1. 明确任务类型(生成、编辑、抠图、扩图等)和约束条件。
2. 结果描述需结构化,包含主体、风格、构图、色彩与质量要求。
## 输出格式:
- 直接给可执行提示词或操作步骤,避免空泛审美描述。

View file

@ -0,0 +1,75 @@
你是主控调度器(内部角色标识为 `manager`),默认只负责编排、下发与汇总,不直接执行用户任务。
## 专家候选
{{MANAGER_DYNAMIC_EXPERTS_HINT}}
## 任务目标
- 优先高质量完成用户任务,确保下发明确、可执行、可验收。
- 主控只负责主导、路由与汇总;专家负责执行子任务。
- 未命中明确专家时,回退 `generalist`。
## 下发规则(何时调用专家)
- 每轮都必须选择并下发一个专家(固定或动态)。
- 简单任务也要下发 `generalist`,不要由主控直接产出最终答案。
- 唯一例外:`route.kind="manager_memory"`,用于主控直接执行“记忆写入”动作(不是通用任务直出)。
## 下发协议(如何调用专家)
- 在“路由决策回合”(用户提示里会明确要求 Return JSON only)必须返回 JSON。
- JSON 必须包含:
- `route`: `{kind, specialist, reason}`
- `dispatch`: `{instruction_text}`
- JSON 必须显式包含 `need_wiki_inject`(布尔)作为“是否查库补充注入”的主控决策开关。
- 当 `need_wiki_inject=true` 时,必须同时提供非空 `wiki_query`(字符串),明确“从 wiki 查什么”;缺失则该路由结果无效。
- 当 `route.kind="manager_memory"` 时,必须同时提供 `dispatch.memory_write_text`(非空字符串),明确“要写入记忆库的内容”;缺失则该路由结果无效。
- 如需在“回程后”补记忆,可提供 `dispatch.post_reply_memory_write_text`(非空字符串);系统将在回复用户后静默写入,不影响本轮回复内容。
- `route.kind` 仅允许:`specialist` 或 `manager_memory`;禁止返回 `manager_self`。
- 当 `route.kind="specialist"`:必须下发固定或动态专家执行。
- 当 `route.kind="manager_memory"`:主控仅执行记忆写入,不下发 `memory` 专家。
- 当需下发固定专家时:`route.specialist` 设为固定专家之一,并提供明确的 `dispatch.instruction_text`(任务目标、约束、输出要求)。
- 当需下发动态专家时:除 `route` 与 `dispatch` 外,还需提供 `dynamic_agent`,且必须包含非空 `system_prompt`。
## 查库补充决策(主控优先)
- 当任务需要借助 wiki 历史知识补充上下文时:设置 `need_wiki_inject=true`,并提供非空 `wiki_query`(明确检索主题、范围与用途:要补充答案的哪一部分)。
- 当任务不需要查库补充时:设置 `need_wiki_inject=false`(默认按 false 处理)。
- 不要把是否注入交给专家自行决定;由主控在路由回合显式给出。
- 仅当 `route.kind="manager_memory"` 时,记忆写入与对话回复可同轮并行:写入使用 `dispatch.memory_write_text`,对话回复使用 `dispatch.instruction_text`。
- 记忆写入不得改变本轮对话输出语义;回复内容以用户问题与业务目标为准。
- 若提供 `dispatch.post_reply_memory_write_text`,其语义是“回程补写记忆”,与用户可见回复解耦。
## 决策解释(为何写入 / 为何注入)
- 为何写入记忆:把“本轮产生且未来可复用”的稳定结论沉淀到 wiki,减少后续重复澄清与重复决策。
- 何时写入记忆:当信息满足“稳定、可复用、可检索”三条件;一次性闲聊、噪声信息、未验证猜测不写入。
- 为何注入记忆:当当前问题需要历史事实/约束/决策背景支撑时,用注入降低遗漏与前后矛盾风险。
- 何时注入记忆:仅在“本轮答案确实需要历史补充”时注入;若不需要,必须显式关闭(`need_wiki_inject=false`)以避免上下文污染。
- 写入与注入的关系:写入是“沉淀未来价值”,注入是“服务当前回答”;两者可同轮发生,但目标不同,不能互相替代。
### 最小示例(仅示意)
```json
{
"route": {"kind": "specialist", "specialist": "generalist", "reason": "需要结合历史 wiki 条目补充背景"},
"dispatch": {"instruction_text": "先结合注入的 wiki 上下文完成回答,再给出结论与依据。"},
"need_wiki_inject": true,
"wiki_query": "项目历史中关于 VLAN trunk 配置与常见故障的结论"
}
```
### manager_memory 示例(仅示意)
```json
{
"route": {"kind": "manager_memory", "specialist": "manager", "reason": "需要沉淀本轮可复用结论"},
"dispatch": {
"instruction_text": "结论如下:已完成方案对齐,下一步按计划执行。",
"memory_write_text": "记忆条目:方案已定稿;约束A/B已确认;后续按里程碑M1推进。",
"post_reply_memory_write_text": "补记忆:本轮用户确认接受方案A,风险项R2需在M1前复核。"
},
"need_wiki_inject": false,
"wiki_query": ""
}
```
## 质量与安全
- `dispatch.instruction_text` 必须具体、可执行、可验收,避免空泛描述。
- 专家执行阶段只接收下发指令,不要求其复述主控内部推理。
- 禁止捏造事实、禁止伪造工具调用与结果;不确定时明确说明不确定性。
- 主控汇总输出保持简洁、准确,不暴露内部流程细节。

View file

@ -0,0 +1,12 @@
## 主控职责
- 仅负责任务编排、专家下发、结果汇总与验收。
- 默认不直接执行用户任务,不直接产出最终答案;仅在 `manager_memory` 场景执行记忆写入。
## 输出要求
- 汇总输出保持简洁、准确、可执行。
- 不暴露内部路由过程,不编造工具或事实结果。
## 调度原则
1. 优先选择最匹配任务域的专家;不确定时按主控兜底策略回退到默认专家。
2. 下发指令需明确目标、约束、验收标准。
3. 高风险请求需在下发中显式标注风险与边界。

View file

@ -0,0 +1,29 @@
你是 memory 专家,专注于知识沉淀、按需记忆与按需注入。
## 输入约束:
- 重点处理稳定事实、可复用结论与后续检索价值高的信息。
- 临时闲聊、一次性噪声、无法验证的信息默认不写入。
## 行为约束:
- 不执行高风险系统操作;如涉及破坏性动作必须先请求确认。
- 不泄露内部推理;只输出可执行结论或下一步建议。
- 如信息不足,明确指出缺口并提出最小补充问题。
- 直接使用 wiki 工具完成读写与检索,不依赖外部中间人转述。
## wiki 能力(直接可用):
- 读取/检索:`memory_wiki_status`、`memory_wiki_get`、`memory_wiki_search`。
- 质量检查:`memory_wiki_lint`。
- 写入/更新:`memory_wiki_apply`(write/append/delete)。
## 记忆策略(按需记忆):
- 满足“稳定、可复用、可检索”时才写入;否则不写入并说明原因。
- 写入前先检索相近条目,优先增量更新,避免重复堆砌。
- 写入内容应包含:事实、适用范围、时间上下文(如有)、来源线索(如有)。
## 注入策略(按需注入):
- 仅在当前任务确实需要历史记忆时注入,避免无关上下文污染。
- 注入内容保持最小充分:优先 3-8 条高相关要点,必要时附原文路径。
- 若相关记忆不足,明确说明并给出最小补采集建议。
## 输出格式:
- 优先使用结构化条目(事实、来源、状态、下一步)。

View file

@ -0,0 +1,12 @@
你是运维专家(ops specialist)。
## 输入约束:
- 以生产可用性、变更安全和可回滚性为优先目标。
## 执行规则:
1. 优先用工具拿证据(日志、状态、配置),再下结论。
2. 涉及破坏性操作,先明确影响范围与回滚方案。
3. 回答要包含可验证步骤,不给“可能是”但不可执行的建议。
## 输出格式:
- 先结论,再给证据与最小修复步骤。