mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 05:50:44 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
15
runtime/orchestration/__init__.py
Normal file
15
runtime/orchestration/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from .evaluation import eval_summary, log_eval_event
|
||||
from .inventory import inventory_snapshot
|
||||
from .protocol import AgentTask, FinalDecision, ManagerPlan, PlanStep, RoutingDecision, SpecialistResult
|
||||
|
||||
__all__ = [
|
||||
"AgentTask",
|
||||
"FinalDecision",
|
||||
"ManagerPlan",
|
||||
"PlanStep",
|
||||
"RoutingDecision",
|
||||
"SpecialistResult",
|
||||
"eval_summary",
|
||||
"inventory_snapshot",
|
||||
"log_eval_event",
|
||||
]
|
||||
62
runtime/orchestration/evaluation.py
Normal file
62
runtime/orchestration/evaluation.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def log_eval_event(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
session_id: str,
|
||||
specialist: str,
|
||||
task_kind: str,
|
||||
success: bool,
|
||||
latency_ms: int,
|
||||
cost_hint: float = 0.0,
|
||||
notes: str = "",
|
||||
) -> None:
|
||||
store.add_agent_eval_log(
|
||||
session_id=session_id,
|
||||
specialist=specialist,
|
||||
task_kind=task_kind,
|
||||
success=success,
|
||||
latency_ms=latency_ms,
|
||||
cost_hint=cost_hint,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def eval_summary(store: SqliteStore, *, limit: int = 200) -> dict[str, Any]:
|
||||
rows = store.list_agent_eval_logs(limit=limit)
|
||||
if not rows:
|
||||
return {"total": 0, "success_rate": 0.0, "p95_latency_ms": 0}
|
||||
success_cnt = sum(1 for r in rows if bool(r.get("success")))
|
||||
lats = sorted(int(r.get("latency_ms") or 0) for r in rows)
|
||||
idx = max(0, int(len(lats) * 0.95) - 1)
|
||||
by_specialist: dict[str, dict[str, Any]] = defaultdict(lambda: {"total": 0, "ok": 0, "lat": []})
|
||||
plan_rows = 0
|
||||
for r in rows:
|
||||
sp = str(r.get("specialist") or "unknown")
|
||||
by_specialist[sp]["total"] += 1
|
||||
by_specialist[sp]["ok"] += 1 if bool(r.get("success")) else 0
|
||||
by_specialist[sp]["lat"].append(int(r.get("latency_ms") or 0))
|
||||
if "manager_plan_generated" in str(r.get("notes") or ""):
|
||||
plan_rows += 1
|
||||
specialist_metrics: dict[str, dict[str, Any]] = {}
|
||||
for sp, m in by_specialist.items():
|
||||
l = sorted(m["lat"])
|
||||
p95_idx = max(0, int(len(l) * 0.95) - 1)
|
||||
specialist_metrics[sp] = {
|
||||
"total": m["total"],
|
||||
"success_rate": round((m["ok"] / m["total"]) if m["total"] else 0.0, 4),
|
||||
"p95_latency_ms": l[p95_idx] if l else 0,
|
||||
}
|
||||
return {
|
||||
"total": len(rows),
|
||||
"success_rate": round(success_cnt / len(rows), 4),
|
||||
"p95_latency_ms": lats[idx],
|
||||
"plan_events": plan_rows,
|
||||
"by_specialist": specialist_metrics,
|
||||
}
|
||||
20
runtime/orchestration/inventory.py
Normal file
20
runtime/orchestration/inventory.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.tools import tool_inventory
|
||||
|
||||
|
||||
def inventory_snapshot() -> dict[str, Any]:
|
||||
tools = tool_inventory()
|
||||
data_sources = [
|
||||
{"name": "sqlite", "scope": "chat_session/chat_message/tool_log/app_setting/llm_profile"},
|
||||
{"name": "attachments", "scope": "user uploaded files parsed as text/image blocks"},
|
||||
{"name": "external_api", "scope": "tool handlers (weather/geocode/http)"},
|
||||
]
|
||||
permissions = {
|
||||
"tool_groups": ["ops", "system"],
|
||||
"high_risk_actions": ["port_scan", "network write-like operations", "batch changes"],
|
||||
"default_policy": "allow-read, review-before-high-risk",
|
||||
}
|
||||
return {"tools": tools, "data_sources": data_sources, "permissions": permissions}
|
||||
275
runtime/orchestration/memory.py
Normal file
275
runtime/orchestration/memory.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.platform.embeddings.embedding_client import build_default_embedding_client
|
||||
from oclaw.runtime.orchestration.vector_store import (
|
||||
MemoryVectorItem,
|
||||
read_vector_memory_runtime,
|
||||
semantic_search,
|
||||
build_vector_store,
|
||||
)
|
||||
|
||||
|
||||
def _chunk_id(source: str, text: str) -> str:
|
||||
raw = f"{source}\n{text}".encode("utf-8", errors="ignore")
|
||||
return hashlib.sha1(raw).hexdigest()
|
||||
|
||||
|
||||
def upsert_knowledge_chunks(store: SqliteStore, source: str, chunks: list[str]) -> int:
|
||||
inserted = 0
|
||||
for chunk in chunks:
|
||||
text = (chunk or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
store.upsert_knowledge_chunk(
|
||||
chunk_id=_chunk_id(source, text),
|
||||
source=source,
|
||||
content=text,
|
||||
metadata={"source": source},
|
||||
)
|
||||
inserted += 1
|
||||
return inserted
|
||||
|
||||
|
||||
def retrieve_context(store: SqliteStore, query: str, *, limit: int = 3) -> list[dict[str, Any]]:
|
||||
mode = (store.get_setting("rag_mode") or "").strip().lower()
|
||||
if not mode:
|
||||
import os
|
||||
|
||||
mode = (os.getenv("AIA_RAG_MODE") or "").strip().lower()
|
||||
if mode == "vector":
|
||||
try:
|
||||
return retrieve_context_vector(store, query, limit=limit)
|
||||
except Exception:
|
||||
return store.search_knowledge(query=query, limit=limit)
|
||||
return store.search_knowledge(query=query, limit=limit)
|
||||
|
||||
|
||||
def _dot(a: list[float], b: list[float]) -> float:
|
||||
n = min(len(a), len(b))
|
||||
return float(sum(float(a[i]) * float(b[i]) for i in range(n)))
|
||||
|
||||
|
||||
def _norm(a: list[float]) -> float:
|
||||
return float(sum(float(x) * float(x) for x in a)) ** 0.5
|
||||
|
||||
|
||||
def _cosine(a: list[float], b: list[float]) -> float:
|
||||
na = _norm(a)
|
||||
nb = _norm(b)
|
||||
if na <= 1e-9 or nb <= 1e-9:
|
||||
return 0.0
|
||||
return _dot(a, b) / (na * nb)
|
||||
|
||||
|
||||
def retrieve_context_vector(store: SqliteStore, query: str, *, limit: int = 3) -> list[dict[str, Any]]:
|
||||
token = (query or "").strip()
|
||||
if not token:
|
||||
return []
|
||||
client = build_default_embedding_client()
|
||||
q = client.embed(token)
|
||||
emb_rows = store.list_knowledge_embeddings(model=q.model, limit=8000)
|
||||
scored: list[tuple[float, str]] = []
|
||||
for r in emb_rows:
|
||||
vec = r.get("vector")
|
||||
if not isinstance(vec, list):
|
||||
continue
|
||||
try:
|
||||
score = _cosine(q.vector, [float(x) for x in vec])
|
||||
except Exception:
|
||||
continue
|
||||
scored.append((score, str(r.get("chunk_id") or "")))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
top_scored = [(float(s), cid) for s, cid in scored[: max(1, int(limit))] if cid]
|
||||
top_ids = [cid for _s, cid in top_scored]
|
||||
if not top_ids:
|
||||
return []
|
||||
chunks = store.get_knowledge_chunks(chunk_ids=top_ids)
|
||||
# keep order stable by score
|
||||
by_id = {c.get("chunk_id"): c for c in chunks}
|
||||
out: list[dict[str, Any]] = []
|
||||
score_by_id = {cid: float(s) for s, cid in top_scored}
|
||||
for cid in top_ids:
|
||||
c = by_id.get(cid)
|
||||
if c:
|
||||
out.append(
|
||||
{
|
||||
"source": str(c.get("source") or "kb"),
|
||||
"content": c.get("content") or "",
|
||||
"metadata": c.get("metadata") if isinstance(c.get("metadata"), dict) else {},
|
||||
"updated_at": c.get("updated_at") or "",
|
||||
"score": float(score_by_id.get(cid) or 0.0),
|
||||
"chunk_id": str(cid),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def session_memory_digest(store: SqliteStore, session_id: str, *, max_items: int = 6) -> list[str]:
|
||||
msgs = store.get_messages(session_id=session_id, limit=max_items)
|
||||
out: list[str] = []
|
||||
for m in msgs:
|
||||
content = (m.content or "").strip()
|
||||
if content:
|
||||
out.append(f"{m.role}: {content[:180]}")
|
||||
return out[-max_items:]
|
||||
|
||||
|
||||
def semantic_retrieve(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
query: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
session_id: str | None = None,
|
||||
top_k: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
runtime = read_vector_memory_runtime(store)
|
||||
if not runtime.enabled:
|
||||
return []
|
||||
token = (query or "").strip()
|
||||
if not token or not tenant_id or not user_id:
|
||||
return []
|
||||
client = build_default_embedding_client()
|
||||
try:
|
||||
hits = semantic_search(
|
||||
store=store,
|
||||
embedder=client,
|
||||
query=token,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
top_k=max(1, min(int(top_k), 20)),
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for h in hits:
|
||||
try:
|
||||
store.add_memory_hit_log(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
memory_id=h.memory_id,
|
||||
query_text=token,
|
||||
score=float(h.score),
|
||||
source=str(h.source or "vector"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
out.append(
|
||||
{
|
||||
"memory_id": h.memory_id,
|
||||
"score": float(h.score),
|
||||
"source": str(h.source or "vector"),
|
||||
"content": h.content,
|
||||
"tenant_id": h.tenant_id,
|
||||
"user_id": h.user_id,
|
||||
"session_id": h.session_id,
|
||||
"memory_type": h.memory_type,
|
||||
"confidence": float(h.confidence),
|
||||
"created_at": h.created_at,
|
||||
"metadata": h.metadata or {},
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_memory_text(text: str) -> str:
|
||||
s = re.sub(r"\s+", " ", str(text or "").strip())
|
||||
return s[:600]
|
||||
|
||||
|
||||
def maybe_write_turn_memory(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
) -> dict[str, Any]:
|
||||
runtime = read_vector_memory_runtime(store)
|
||||
if not runtime.writer_enabled:
|
||||
return {"ok": True, "written": 0, "reason": "writer_disabled"}
|
||||
user_norm = _normalize_memory_text(user_text)
|
||||
assistant_norm = _normalize_memory_text(assistant_text)
|
||||
if not user_norm or not assistant_norm:
|
||||
return {"ok": True, "written": 0, "reason": "empty_turn"}
|
||||
# High-value heuristic in v1: preference/fact/decision-like statements.
|
||||
low = user_norm.lower()
|
||||
is_high_value = any(
|
||||
key in low
|
||||
for key in ("我喜欢", "偏好", "记住", "以后", "每次", "always", "prefer", "my name", "习惯", "决定")
|
||||
) or len(user_norm) >= 24
|
||||
if not is_high_value:
|
||||
return {"ok": True, "written": 0, "reason": "below_value_threshold"}
|
||||
confidence = 0.8 if len(user_norm) >= 24 else 0.7
|
||||
if confidence < runtime.write_min_confidence:
|
||||
return {"ok": True, "written": 0, "reason": "below_confidence_threshold"}
|
||||
|
||||
dedupe_key = hashlib.sha1(f"{tenant_id}:{user_id}:{user_norm}".encode("utf-8", errors="ignore")).hexdigest()
|
||||
existing = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=100)
|
||||
if any(str(x.get("metadata", {}).get("dedupe_key") or "") == dedupe_key for x in existing):
|
||||
return {"ok": True, "written": 0, "reason": "deduped"}
|
||||
|
||||
content = f"User: {user_norm}\nAssistant: {assistant_norm[:320]}"
|
||||
now = store.get_setting("memory_now_override") or None
|
||||
# TTL (best-effort): allow expiring episodic memories by default to reduce long-term risk.
|
||||
expires_at: str | None = None
|
||||
try:
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
days_raw = (
|
||||
store.get_setting("AIA_MEMORY_EPISODIC_TTL_DAYS")
|
||||
or store.get_setting("MEMORY_EPISODIC_TTL_DAYS")
|
||||
or os.getenv("AIA_MEMORY_EPISODIC_TTL_DAYS")
|
||||
or os.getenv("MEMORY_EPISODIC_TTL_DAYS")
|
||||
or "90"
|
||||
).strip()
|
||||
days = int(float(days_raw))
|
||||
if days > 0:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
||||
except Exception:
|
||||
expires_at = None
|
||||
item = MemoryVectorItem(
|
||||
memory_id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
memory_type="episodic_memory",
|
||||
content=content,
|
||||
confidence=confidence,
|
||||
created_at=now or "",
|
||||
updated_at=now or "",
|
||||
expires_at=expires_at,
|
||||
metadata={"dedupe_key": dedupe_key, "write_policy": "high_value_v1"},
|
||||
)
|
||||
client = build_default_embedding_client()
|
||||
emb = client.embed(content)
|
||||
store.upsert_memory_item(
|
||||
memory_id=item.memory_id,
|
||||
tenant_id=item.tenant_id,
|
||||
user_id=item.user_id,
|
||||
session_id=item.session_id,
|
||||
memory_type=item.memory_type,
|
||||
content=item.content,
|
||||
confidence=item.confidence,
|
||||
source="memory:write_pipeline",
|
||||
metadata=item.metadata or {},
|
||||
created_at=item.created_at or None,
|
||||
updated_at=item.updated_at or None,
|
||||
expires_at=item.expires_at,
|
||||
)
|
||||
try:
|
||||
vs = build_vector_store(store)
|
||||
vs.upsert(item, emb.vector, model=emb.model)
|
||||
except Exception:
|
||||
# Keep sqlite metadata even if vector backend fails.
|
||||
store.upsert_memory_vector(memory_id=item.memory_id, model=emb.model, vector=emb.vector)
|
||||
return {"ok": True, "written": 1, "memory_id": item.memory_id, "confidence": confidence}
|
||||
72
runtime/orchestration/policy.py
Normal file
72
runtime/orchestration/policy.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
allowed: bool
|
||||
needs_confirmation: bool
|
||||
reason: str
|
||||
confirm_token: str | None = None
|
||||
redactions: dict[str, str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPolicyContext:
|
||||
session_id: str
|
||||
user_text: str
|
||||
specialist: str = ""
|
||||
task_kind: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionPolicyContext:
|
||||
session_id: str
|
||||
tenant_id: str = ""
|
||||
user_id: str = ""
|
||||
channel: str = ""
|
||||
user_text: str = ""
|
||||
action: str = ""
|
||||
target: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
_HIGH_RISK_TOOLS = {"port_scan", "run_command", "write_file", "apply_patch", "git_commit", "git_push"}
|
||||
_HIGH_RISK_ACTIONS = {"send_broadcast", "send_mention_all", "home_control", "payment_transfer"}
|
||||
|
||||
def is_high_risk_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> bool:
|
||||
name = (tool_name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
if name in self._HIGH_RISK_TOOLS:
|
||||
return True
|
||||
blob = f"{name}\n{arguments or {}}".lower()
|
||||
return any(k in blob for k in ("delete", "remove", "rm ", "drop", "reset", "format", "shutdown", "reboot"))
|
||||
|
||||
def new_confirmation_token(self) -> str:
|
||||
return secrets.token_urlsafe(8)
|
||||
|
||||
def decide_tool(self, *, tool_name: str, arguments: dict[str, Any], ctx: ToolPolicyContext) -> PolicyDecision:
|
||||
if self.is_high_risk_tool(tool_name, arguments):
|
||||
return PolicyDecision(allowed=True, needs_confirmation=True, reason="high_risk_tool_requires_confirmation")
|
||||
return PolicyDecision(allowed=True, needs_confirmation=False, reason="allowed")
|
||||
|
||||
def decide_action(self, *, ctx: ActionPolicyContext) -> PolicyDecision:
|
||||
action = (ctx.action or "").strip().lower()
|
||||
if not action:
|
||||
return PolicyDecision(allowed=True, needs_confirmation=False, reason="allowed")
|
||||
if action in self._HIGH_RISK_ACTIONS:
|
||||
return PolicyDecision(allowed=True, needs_confirmation=True, reason=f"high_risk_action:{action}")
|
||||
target = ctx.target or {}
|
||||
if action in {"send_message", "send"}:
|
||||
if bool(target.get("mention_all")):
|
||||
return PolicyDecision(allowed=True, needs_confirmation=True, reason="high_risk_action:mention_all")
|
||||
if bool(target.get("is_group")) and int(target.get("member_count") or 0) >= 50:
|
||||
return PolicyDecision(allowed=True, needs_confirmation=True, reason="high_risk_action:large_group")
|
||||
return PolicyDecision(allowed=True, needs_confirmation=False, reason="allowed")
|
||||
|
||||
|
||||
__all__ = ["PolicyDecision", "ToolPolicyContext", "ActionPolicyContext", "PolicyEngine"]
|
||||
119
runtime/orchestration/protocol.py
Normal file
119
runtime/orchestration/protocol.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
TaskKind = str
|
||||
RiskLevel = str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTask:
|
||||
session_id: str
|
||||
user_text: str
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
kind: TaskKind = "generalist"
|
||||
risk_level: RiskLevel = "low"
|
||||
specialist: str = "generalist"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingDecision:
|
||||
kind: TaskKind
|
||||
specialist: str
|
||||
risk_level: RiskLevel
|
||||
reason: str
|
||||
|
||||
|
||||
# 规范说明:
|
||||
# - specialist: 路由/计划层使用的专家标识(例如 ops / generalist)
|
||||
# - expert: 工具目录名(例如 network_ops / generalist)
|
||||
# - tool_tags: 运行期开关过滤(例如 ops / system)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanStep:
|
||||
step_id: str
|
||||
specialist: str
|
||||
objective: str
|
||||
input_text: str
|
||||
depends_on: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagerPlan:
|
||||
plan_id: str
|
||||
strategy: str
|
||||
steps: list[PlanStep] = field(default_factory=list)
|
||||
raw_text: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialistToolTrace:
|
||||
"""单条工具调用摘要(专家会话内执行,用于交付给总控的可追溯信息)。"""
|
||||
|
||||
name: str
|
||||
ok: bool
|
||||
latency_ms: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialistDelivery:
|
||||
"""
|
||||
专家 → 总控(Core)的结构化交付。
|
||||
|
||||
- answer_text:面向用户的专家结论(已结合工具结果,与 output_text 对齐)。
|
||||
- tool_traces:本步内在专家侧实际执行的工具摘要(非总控直接执行)。
|
||||
"""
|
||||
|
||||
version: int = 1
|
||||
specialist: str = ""
|
||||
step_id: str = ""
|
||||
answer_text: str = ""
|
||||
tool_traces: tuple[SpecialistToolTrace, ...] = ()
|
||||
notes: str = ""
|
||||
|
||||
|
||||
def format_specialist_handoff_for_core(res: "SpecialistResult") -> str:
|
||||
"""将专家结果格式化为总控合并提示中的单步条目(可读 + 明示 handoff 边界)。"""
|
||||
if res.delivery and res.delivery.answer_text.strip():
|
||||
d = res.delivery
|
||||
lines = [
|
||||
f"### handoff v{d.version} step={d.step_id} specialist={d.specialist}",
|
||||
"role=specialist_completed",
|
||||
f"answer_for_user:\n{d.answer_text.strip()}",
|
||||
]
|
||||
if d.tool_traces:
|
||||
parts = []
|
||||
for t in d.tool_traces:
|
||||
parts.append(f"{t.name}(ok={t.ok}, {t.latency_ms}ms)")
|
||||
lines.append(f"tools_executed_inside_specialist: " + "; ".join(parts))
|
||||
else:
|
||||
lines.append("tools_executed_inside_specialist: (none)")
|
||||
if (d.notes or "").strip():
|
||||
lines.append(f"notes: {d.notes.strip()}")
|
||||
lines.append(
|
||||
"instruction_for_core: 专家已基于工具输出整理 answer_for_user;请仅做合并、润色与一致性检查,勿编造与工具矛盾的事实。"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
return f"[{res.specialist}/{res.step_id}] {res.output_text}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialistResult:
|
||||
step_id: str
|
||||
specialist: str
|
||||
success: bool
|
||||
output_text: str
|
||||
latency_ms: int
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
delivery: SpecialistDelivery | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FinalDecision:
|
||||
plan_id: str
|
||||
summary: str
|
||||
confidence: float
|
||||
references: list[str] = field(default_factory=list)
|
||||
54
runtime/orchestration/security.py
Normal file
54
runtime/orchestration/security.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from oclaw.runtime.orchestration.protocol import AgentTask
|
||||
|
||||
_HIGH_RISK_ACTIONS = ("删除", "drop", "重启", "批量", "扫描", "写入", "变更")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuardrailResult:
|
||||
allowed: bool
|
||||
needs_confirmation: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def evaluate_risk(task: AgentTask) -> GuardrailResult:
|
||||
text = (task.user_text or "").lower()
|
||||
high_risk = task.risk_level == "high" or any(k in text for k in _HIGH_RISK_ACTIONS)
|
||||
if high_risk:
|
||||
return GuardrailResult(
|
||||
allowed=True,
|
||||
needs_confirmation=True,
|
||||
reason="High-risk action detected, require explicit confirmation token.",
|
||||
)
|
||||
return GuardrailResult(allowed=True, needs_confirmation=False, reason="Low-risk request")
|
||||
|
||||
|
||||
def has_explicit_confirmation(user_text: str) -> bool:
|
||||
return has_explicit_confirmation_token(user_text, token=None)
|
||||
|
||||
|
||||
def has_explicit_confirmation_token(user_text: str, token: str | None) -> bool:
|
||||
text = (user_text or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
low = text.lower()
|
||||
if low.startswith("confirm "):
|
||||
if token:
|
||||
parts = low.split()
|
||||
return len(parts) >= 2 and parts[1].strip() == token.lower()
|
||||
return True
|
||||
if "[confirm]" in low or "确认执行" in low:
|
||||
return True
|
||||
if token:
|
||||
t = token.strip()
|
||||
if not t:
|
||||
return False
|
||||
if f"[confirm:{t}]".lower() in low or f"confirm:{t}".lower() in low:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["GuardrailResult", "evaluate_risk", "has_explicit_confirmation", "has_explicit_confirmation_token"]
|
||||
41
runtime/orchestration/session_mapping.py
Normal file
41
runtime/orchestration/session_mapping.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionMapResult:
|
||||
session_id: str
|
||||
scope: str # "group" | "dm"
|
||||
|
||||
|
||||
def get_or_create_session_for_wecom(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
external_user_id: str,
|
||||
external_chat_id: str,
|
||||
is_group: bool,
|
||||
) -> SessionMapResult:
|
||||
"""Group: session per chat_id; DM: session per user_id.
|
||||
|
||||
We store mapping under (tenant, channel, external_chat_id, external_user_id) to keep it simple.
|
||||
"""
|
||||
scope = "group" if bool(is_group) else "dm"
|
||||
# For DM, external_chat_id is often contact id; we still keep it as provided.
|
||||
title = f"wecom:{scope}:{external_chat_id[:8]}"
|
||||
sid = store.get_or_create_channel_session(
|
||||
tenant_id=tenant_id,
|
||||
channel="wecom",
|
||||
external_chat_id=external_chat_id,
|
||||
external_user_id=external_user_id,
|
||||
session_title=title,
|
||||
)
|
||||
return SessionMapResult(session_id=sid, scope=scope)
|
||||
|
||||
|
||||
__all__ = ["SessionMapResult", "get_or_create_session_for_wecom"]
|
||||
|
||||
34
runtime/orchestration/tool_adapter.py
Normal file
34
runtime/orchestration/tool_adapter.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class ToolAuditAdapter:
|
||||
def __init__(self, store: SqliteStore):
|
||||
self.store = store
|
||||
|
||||
def log_dispatch(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
specialist: str,
|
||||
task_kind: str,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
status: str = "ok",
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
started = time.perf_counter()
|
||||
self.store.add_agent_audit_log(
|
||||
session_id=session_id,
|
||||
specialist=specialist,
|
||||
task_kind=task_kind,
|
||||
action=action,
|
||||
payload=payload,
|
||||
status=status,
|
||||
reason=reason,
|
||||
duration_ms=max(0, int((time.perf_counter() - started) * 1000)),
|
||||
)
|
||||
56
runtime/orchestration/trace.py
Normal file
56
runtime/orchestration/trace.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def new_trace_id() -> str:
|
||||
return f"tr_{uuid.uuid4().hex[:20]}"
|
||||
|
||||
|
||||
def new_span_id() -> str:
|
||||
return f"sp_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
# rough heuristic: ~4 chars per token for English-ish; Chinese is denser but ok for hint.
|
||||
s = (text or "").strip()
|
||||
return max(0, int(len(s) / 4))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceContext:
|
||||
trace_id: str
|
||||
span_id: str
|
||||
parent_span_id: str | None = None
|
||||
|
||||
|
||||
class TraceEmitter:
|
||||
def __init__(self, store: Any):
|
||||
self.store = store
|
||||
|
||||
def emit(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
ctx: TraceContext,
|
||||
event_type: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
self.store.add_trace_event(
|
||||
session_id=session_id,
|
||||
trace_id=ctx.trace_id,
|
||||
span_id=ctx.span_id,
|
||||
parent_span_id=ctx.parent_span_id,
|
||||
event_type=event_type,
|
||||
payload=payload or {},
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
__all__ = ["TraceContext", "TraceEmitter", "new_trace_id", "new_span_id", "estimate_tokens"]
|
||||
|
||||
275
runtime/orchestration/vector_store.py
Normal file
275
runtime/orchestration/vector_store.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
|
||||
from oclaw.platform.embeddings.embedding_client import EmbeddingClient
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
s = value.strip().lower()
|
||||
if s in {"1", "true", "yes", "y", "on"}:
|
||||
return True
|
||||
if s in {"0", "false", "no", "n", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryVectorItem:
|
||||
memory_id: str
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
session_id: str
|
||||
memory_type: str
|
||||
content: str
|
||||
confidence: float
|
||||
created_at: str
|
||||
updated_at: str
|
||||
expires_at: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryVectorHit:
|
||||
memory_id: str
|
||||
score: float
|
||||
source: str
|
||||
content: str
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
session_id: str
|
||||
memory_type: str
|
||||
confidence: float
|
||||
created_at: str
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class VectorStore(Protocol):
|
||||
def upsert(self, item: MemoryVectorItem, vector: list[float], *, model: str) -> None: ...
|
||||
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
query_vector: list[float],
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
top_k: int,
|
||||
model: str,
|
||||
) -> list[MemoryVectorHit]: ...
|
||||
|
||||
|
||||
class SqliteVectorStore:
|
||||
def __init__(self, store: SqliteStore):
|
||||
self.store = store
|
||||
self.store.ensure_memory_tables()
|
||||
|
||||
def upsert(self, item: MemoryVectorItem, vector: list[float], *, model: str) -> None:
|
||||
self.store.upsert_memory_item(
|
||||
memory_id=item.memory_id,
|
||||
tenant_id=item.tenant_id,
|
||||
user_id=item.user_id,
|
||||
session_id=item.session_id,
|
||||
memory_type=item.memory_type,
|
||||
content=item.content,
|
||||
confidence=float(item.confidence),
|
||||
source="vector:sqlite",
|
||||
metadata=item.metadata or {},
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
expires_at=item.expires_at,
|
||||
)
|
||||
self.store.upsert_memory_vector(
|
||||
memory_id=item.memory_id,
|
||||
model=model,
|
||||
vector=[float(x) for x in (vector or [])],
|
||||
updated_at=item.updated_at or _utc_now_iso(),
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
query_vector: list[float],
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
top_k: int,
|
||||
model: str,
|
||||
) -> list[MemoryVectorHit]:
|
||||
rows = self.store.search_memory_vectors(
|
||||
query_vector=query_vector,
|
||||
model=model,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
limit=top_k,
|
||||
)
|
||||
out: list[MemoryVectorHit] = []
|
||||
for row in rows:
|
||||
out.append(
|
||||
MemoryVectorHit(
|
||||
memory_id=str(row.get("memory_id") or ""),
|
||||
score=float(row.get("score") or 0.0),
|
||||
source=str(row.get("source") or "vector:sqlite"),
|
||||
content=str(row.get("content") or ""),
|
||||
tenant_id=str(row.get("tenant_id") or ""),
|
||||
user_id=str(row.get("user_id") or ""),
|
||||
session_id=str(row.get("session_id") or ""),
|
||||
memory_type=str(row.get("memory_type") or "semantic"),
|
||||
confidence=float(row.get("confidence") or 0.0),
|
||||
created_at=str(row.get("created_at") or ""),
|
||||
metadata=row.get("metadata") if isinstance(row.get("metadata"), dict) else {},
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class ChromaVectorStore(SqliteVectorStore):
|
||||
"""Best-effort adapter: delegates to SQLite if Chroma client is unavailable."""
|
||||
|
||||
def __init__(self, store: SqliteStore):
|
||||
super().__init__(store)
|
||||
self._available = False
|
||||
try:
|
||||
import chromadb # noqa: F401
|
||||
|
||||
self._available = True
|
||||
except Exception:
|
||||
self._available = False
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
|
||||
class QdrantVectorStore(SqliteVectorStore):
|
||||
"""Best-effort adapter: delegates to SQLite if Qdrant client is unavailable."""
|
||||
|
||||
def __init__(self, store: SqliteStore):
|
||||
super().__init__(store)
|
||||
self._available = False
|
||||
try:
|
||||
import qdrant_client # noqa: F401
|
||||
|
||||
self._available = True
|
||||
except Exception:
|
||||
self._available = False
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VectorMemoryRuntime:
|
||||
enabled: bool
|
||||
backend: str
|
||||
top_k: int
|
||||
writer_enabled: bool
|
||||
write_min_confidence: float
|
||||
|
||||
|
||||
def read_vector_memory_runtime(store: SqliteStore) -> VectorMemoryRuntime:
|
||||
def _get(name: str, default: str) -> str:
|
||||
v = store.get_setting(name)
|
||||
if v is not None and str(v).strip() != "":
|
||||
return str(v).strip()
|
||||
return str(os.getenv(name) or default).strip()
|
||||
|
||||
enabled = _parse_bool(_get("MEMORY_VECTOR_ENABLED", "0"), default=False)
|
||||
backend = (_get("MEMORY_VECTOR_BACKEND", "sqlite") or "sqlite").strip().lower()
|
||||
if backend not in {"sqlite", "chroma", "qdrant"}:
|
||||
backend = "sqlite"
|
||||
try:
|
||||
top_k = max(1, min(20, int(_get("MEMORY_VECTOR_TOPK", "5"))))
|
||||
except Exception:
|
||||
top_k = 5
|
||||
writer_enabled = _parse_bool(_get("MEMORY_WRITE_ENABLED", "0"), default=False)
|
||||
try:
|
||||
write_min_confidence = float(_get("MEMORY_WRITE_MIN_CONFIDENCE", "0.75"))
|
||||
except Exception:
|
||||
write_min_confidence = 0.75
|
||||
return VectorMemoryRuntime(
|
||||
enabled=enabled,
|
||||
backend=backend,
|
||||
top_k=top_k,
|
||||
writer_enabled=writer_enabled,
|
||||
write_min_confidence=max(0.0, min(1.0, write_min_confidence)),
|
||||
)
|
||||
|
||||
|
||||
def build_vector_store(store: SqliteStore) -> VectorStore:
|
||||
runtime = read_vector_memory_runtime(store)
|
||||
if runtime.backend == "chroma":
|
||||
adapter = ChromaVectorStore(store)
|
||||
if adapter.available:
|
||||
return adapter
|
||||
if runtime.backend == "qdrant":
|
||||
adapter = QdrantVectorStore(store)
|
||||
if adapter.available:
|
||||
return adapter
|
||||
return SqliteVectorStore(store)
|
||||
|
||||
|
||||
def semantic_search(
|
||||
*,
|
||||
store: SqliteStore,
|
||||
embedder: EmbeddingClient,
|
||||
query: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
top_k: int,
|
||||
) -> list[MemoryVectorHit]:
|
||||
token = (query or "").strip()
|
||||
if not token or not tenant_id or not user_id:
|
||||
return []
|
||||
emb = embedder.embed(token)
|
||||
vs = build_vector_store(store)
|
||||
return vs.search(
|
||||
query_vector=emb.vector,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
top_k=max(1, int(top_k)),
|
||||
model=emb.model,
|
||||
)
|
||||
|
||||
|
||||
def dump_hit_json(hit: MemoryVectorHit) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"memory_id": hit.memory_id,
|
||||
"score": hit.score,
|
||||
"source": hit.source,
|
||||
"tenant_id": hit.tenant_id,
|
||||
"user_id": hit.user_id,
|
||||
"session_id": hit.session_id,
|
||||
"memory_type": hit.memory_type,
|
||||
"confidence": hit.confidence,
|
||||
"created_at": hit.created_at,
|
||||
"metadata": hit.metadata or {},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MemoryVectorHit",
|
||||
"MemoryVectorItem",
|
||||
"VectorMemoryRuntime",
|
||||
"VectorStore",
|
||||
"build_vector_store",
|
||||
"dump_hit_json",
|
||||
"read_vector_memory_runtime",
|
||||
"semantic_search",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue