Remove plan mode, skill toolcall path, and inline netx tools.

Wave C subtraction: keep skills as prompt-only, route netx via MCP with a shared netx_http client for xlsx/context inject, and delete plan_agent_v2 from the gateway.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-11 01:17:39 +08:00
parent 628a9dffd2
commit efa72df362
40 changed files with 169 additions and 3033 deletions

View file

@ -56,7 +56,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background
### 可选:运维专家(network_ops)与 netx ### 可选:运维专家(network_ops)与 netx
若要在 ops 专家模式下调 **netx** 告警库,需单独启动 netx 服务,并在 Admin 安装 **netx MCP**(`server_id=netx`,env `NETX_API_URL`)。说明见 `docs/NETX_MCP_INTEGRATION.md`。迁移期可选 `OCLAW_NETX_BUILTIN_TOOLS=1` 启用旧 inline 工具。 若要在 ops 专家模式下调 **netx** 告警库,需单独启动 netx 服务,并在 Admin 安装 **netx MCP**(`server_id=netx`,env `NETX_API_URL`)。说明见 `docs/NETX_MCP_INTEGRATION.md`。
### 可选:股票分析专家(A股/港股,信号建议) ### 可选:股票分析专家(A股/港股,信号建议)

View file

@ -4,7 +4,7 @@ Wire **oclaw** (or any MCP host) to the standard **netx HTTP MCP** in `D:/projec
netx 侧通用安装/更新说明:`D:/project/chatgpt/netx/docs/MCP.md`。 netx 侧通用安装/更新说明:`D:/project/chatgpt/netx/docs/MCP.md`。
Default path: **stdio MCP → netx REST API** (`NETX_API_URL`). Legacy inline HTTP tools in oclaw are opt-in via `OCLAW_NETX_BUILTIN_TOOLS=1`. Default path: **stdio MCP → netx REST API** (`NETX_API_URL`). oclaw also uses the same REST base for ops context inject and `ume_alarm_xlsx_report` via shared `netx_http`.
## 1) Start netx API ## 1) Start netx API
@ -115,14 +115,16 @@ Then run **Health** → **Sync Tools**.
In Admin **MCP specialist binding**, include server **`netx`** for the ops workspace/specialist. In Admin **MCP specialist binding**, include server **`netx`** for the ops workspace/specialist.
## 4) Dual-track: builtin vs MCP ## 4) Shared REST helpers in oclaw
| Setting | Effect | oclaw keeps a small HTTP client (`runtime/tools/experts/network_ops/netx_http.py`) for:
|---------|--------|
| `OCLAW_NETX_BUILTIN_TOOLS=0` (default) | Only MCP tools (`mcp__netx__*`); no duplicate inline `netx_*` in catalog |
| `OCLAW_NETX_BUILTIN_TOOLS=1` | Registers legacy inline HTTP tools **and** MCP if installed — avoid binding both unless testing migration |
Runtime anchor inject (`OCLAW_OPS_NETX_CONTEXT_INJECT=1`) still works without builtin tools; it only needs netx API reachable at `NETX_API_URL` / `OCLAW_NETX_BASE_URL`. - ops system-context anchor inject (`OCLAW_OPS_NETX_CONTEXT_INJECT`)
- always-on `ume_alarm_xlsx_report` (Excel export shortcut)
Interactive alarm/NE tools are **MCP only** (`mcp__netx__*`). Inline `netx_*` expert tools were removed.
Runtime anchor inject still needs netx API reachable at `NETX_API_URL` / `OCLAW_NETX_BASE_URL`.
## 5) Cursor / Claude Desktop ## 5) Cursor / Claude Desktop

View file

@ -1,103 +0,0 @@
# Plan Agent V2 (Shadow) Design
## Goal
- Build a complete plan/agent pipeline in shadow mode first.
- Keep legacy runtime path unchanged until final one-shot cutover.
- Support instant rollback via a single feature switch.
## Scope
- Target only `interaction_mode=expert`.
- `interaction_mode=comprehensive` remains on legacy path.
- Current implementation is dry-run/shadow ready, not wired into gateway production flow.
## Runtime Components
- Package root: `runtime/plan_agent_v2/`
- `models.py`: state model (`PlanAgentStateV2`)
- `state_store.py`: session state persistence
- `manager.py`: plan lifecycle (`enter/confirm/exit`)
- `tool_policy.py`: plan-mode tool filtering policy
- `prompt_injector.py`: plan-mode and approved-plan prompt injection
- `tool_specs.py`: shadow plan tools (`enter_plan_mode_v2`, `exit_plan_mode_v2`)
- `switch.py`: feature switch and routing predicate
- `adapter.py`: expert-mode plan decision logic
- `gateway_adapter.py`: gateway-side shadow adapter
- `trace.py`: plan events trace helper
- `compat.py`: legacy result-shape compatibility helpers
## Legacy Compatibility
- Flat module paths are still available and now forward to package modules:
- `runtime/plan_agent_v2_*.py` -> `runtime/plan_agent_v2/*`
- This prevents existing imports from breaking during migration.
## Session State Contract
- Stored under key:
- `AIA_PLAN_AGENT_V2_STATE:<session_id>`
- Serialized JSON fields:
- `mode`: `normal|plan`
- `owner_specialist`
- `plan_id`
- `plan_path`
- `plan_content`
- `plan_confirmed`
- `entered_at_ms`
- `updated_at_ms`
## Feature Switches
- `AIA_EXPERT_PLAN_AGENT_V2_ENABLED`
- default: off
- effect: allow expert path to route to shadow v2 when wired
- `AIA_EXPERT_PLAN_FILE_DIR`
- optional plan file root override
- `AIA_EXPERT_PLAN_CONFIRM_STRATEGY`
- `strict` (default): confirmation in `plan` mode is blocked until user switches to `agent`
- `auto`: confirmation in `plan` mode auto-switches to execution
- `off`: disable confirmation-mode gate (same confirm behavior as `auto`)
## Admin API Mode Fields
- `GET /admin/api/chat/sessions/{session_id}/mode`
- now returns `confirm_strategy` together with `interaction_mode/specialist/memory_mode/execution_mode`.
- `POST /admin/api/chat/sessions/{session_id}/mode`
- accepts optional `confirm_strategy` (`strict|auto|off`)
- persists per-user and per-session mode settings
- mirrors to runtime key `AIA_EXPERT_PLAN_CONFIRM_STRATEGY` for immediate effect in expert v2 turns
## Routing Contract (Shadow)
- Predicate:
- `should_route_to_v2(store, interaction_mode, force_flag=False)`
- Rules:
- non-expert mode: always false
- expert + `force_flag=True`: true
- expert + feature on: true
- otherwise: false
## Adapter Outputs
- `evaluate_for_expert_mode(...)` returns:
- `action`: `enter_plan|stay_plan|run_agent`
- `reply_text`
- `plan_state`
- `system_prompt_override` (set on `run_agent`)
## Trace Events
- Emitted by `emit_plan_agent_v2_trace(...)`:
- `plan_mode_entered`
- `plan_mode_active`
- `plan_mode_confirmed`
## Tests
- Shadow core tests:
- `tests/test_plan_agent_v2_shadow.py`
- Gateway dry-run comparison tests:
- `tests/test_plan_agent_v2_gateway_dryrun.py`
## Cutover Plan (Later, Not Yet Applied)
- Add one gateway branch:
- if `should_route_to_v2(...)` then call `evaluate_gateway_expert_turn_shadow(...)`
- else keep legacy path
- Keep cutover in one commit for easy rollback.
## Rollback
- Runtime rollback:
- set `AIA_EXPERT_PLAN_AGENT_V2_ENABLED=false`
- Code rollback:
- revert only gateway branch commit; shadow modules can remain dormant.

View file

@ -33,7 +33,6 @@ from svc.files.session_export import export_session_json, export_session_markdow
from svc.persistence.sqlite_store import SqliteStore from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store from svc.persistence.assistant_store import get_assistant_store
from runtime.gateway import OclawGateway from runtime.gateway import OclawGateway
from runtime.plan_agent_v2.switch import v2_feature_enabled
from runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist from runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist
from runtime.chat.history_tool_result_compact import compact_tool_results_in_session_history from runtime.chat.history_tool_result_compact import compact_tool_results_in_session_history
from runtime.chat.persist_terminal_fallback import persist_assistant_text_if_turn_missing from runtime.chat.persist_terminal_fallback import persist_assistant_text_if_turn_missing
@ -1280,7 +1279,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
"execution_mode": s_em, "execution_mode": s_em,
"confirm_strategy": u_cs, "confirm_strategy": u_cs,
"plan_agent_version": u_pav, "plan_agent_version": u_pav,
"plan_agent_v2_globally_enabled": bool(v2_feature_enabled(store=store)), "plan_agent_v2_globally_enabled": False,
"global_menu": { "global_menu": {
"interaction_mode": u_im, "interaction_mode": u_im,
"specialist": u_sp, "specialist": u_sp,
@ -1325,7 +1324,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
"execution_mode": s_em, "execution_mode": s_em,
"confirm_strategy": u_cs, "confirm_strategy": u_cs,
"plan_agent_version": u_pav, "plan_agent_version": u_pav,
"plan_agent_v2_globally_enabled": bool(v2_feature_enabled(store=store)), "plan_agent_v2_globally_enabled": False,
"global_menu": { "global_menu": {
"interaction_mode": u_im, "interaction_mode": u_im,
"specialist": u_sp, "specialist": u_sp,
@ -1349,7 +1348,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
"specialist": u_sp, "specialist": u_sp,
"confirm_strategy": u_cs, "confirm_strategy": u_cs,
"plan_agent_version": u_pav, "plan_agent_version": u_pav,
"plan_agent_v2_globally_enabled": bool(v2_feature_enabled(store=store)), "plan_agent_v2_globally_enabled": False,
} }
@chat.post("/user-mode") @chat.post("/user-mode")
@ -1377,15 +1376,15 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
plan_agent_version=plan_agent_version, plan_agent_version=plan_agent_version,
) )
u_im, u_sp, u_cs, u_pav = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id) u_im, u_sp, u_cs, u_pav = _resolve_user_menu_chat_settings(store=store, tenant_id=tenant_id, user_id=user_id)
# Mirror Plan/Agent version to the gateway feature gate (⋯ menu is the control surface). # Plan mode removed; keep setting key cleared for leftover clients.
store.set_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED", "1" if str(u_pav or "").strip().lower() == "v2" else "0") store.set_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED", "0")
return { return {
"ok": True, "ok": True,
"interaction_mode": u_im, "interaction_mode": u_im,
"specialist": u_sp, "specialist": u_sp,
"confirm_strategy": u_cs, "confirm_strategy": u_cs,
"plan_agent_version": u_pav, "plan_agent_version": u_pav,
"plan_agent_v2_globally_enabled": bool(v2_feature_enabled(store=store)), "plan_agent_v2_globally_enabled": False,
} }
@chat.get("/admin/user-stats") @chat.get("/admin/user-stats")

View file

@ -98,14 +98,11 @@ def include_skill_routes(
ctx = resolve_auth(store, authorization) ctx = resolve_auth(store, authorization)
_require_admin(ctx) _require_admin(ctx)
raw_prompt = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower() raw_prompt = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower()
raw_toolcall = str(store.get_setting("AIA_SKILL_TOOLCALL_ENABLED") or "").strip().lower()
prompt_in_system = raw_prompt not in {"0", "false", "no", "off"} prompt_in_system = raw_prompt not in {"0", "false", "no", "off"}
toolcall_enabled = raw_toolcall in {"1", "true", "yes", "on"}
market_provider = normalize_skill_market_provider_setting(str(store.get_setting(_SKILL_MARKET_PROVIDER_KEY) or "")) market_provider = normalize_skill_market_provider_setting(str(store.get_setting(_SKILL_MARKET_PROVIDER_KEY) or ""))
return { return {
"ok": True, "ok": True,
"prompt_in_system": bool(prompt_in_system), "prompt_in_system": bool(prompt_in_system),
"toolcall_enabled": bool(toolcall_enabled),
"market_provider": market_provider, "market_provider": market_provider,
} }
@ -120,14 +117,10 @@ def include_skill_routes(
_require_admin(ctx) _require_admin(ctx)
if "prompt_in_system" in payload: if "prompt_in_system" in payload:
store.set_setting("AIA_SKILLS_PROMPT_IN_SYSTEM", "1" if bool(payload.get("prompt_in_system")) else "0") store.set_setting("AIA_SKILLS_PROMPT_IN_SYSTEM", "1" if bool(payload.get("prompt_in_system")) else "0")
if "toolcall_enabled" in payload:
store.set_setting("AIA_SKILL_TOOLCALL_ENABLED", "1" if bool(payload.get("toolcall_enabled")) else "0")
if "market_provider" in payload: if "market_provider" in payload:
store.set_setting(_SKILL_MARKET_PROVIDER_KEY, normalize_skill_market_provider_setting(str(payload.get("market_provider") or ""))) store.set_setting(_SKILL_MARKET_PROVIDER_KEY, normalize_skill_market_provider_setting(str(payload.get("market_provider") or "")))
raw_prompt = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower() raw_prompt = str(store.get_setting("AIA_SKILLS_PROMPT_IN_SYSTEM") or "").strip().lower()
raw_toolcall = str(store.get_setting("AIA_SKILL_TOOLCALL_ENABLED") or "").strip().lower()
prompt_in_system = raw_prompt not in {"0", "false", "no", "off"} prompt_in_system = raw_prompt not in {"0", "false", "no", "off"}
toolcall_enabled = raw_toolcall in {"1", "true", "yes", "on"}
market_provider = normalize_skill_market_provider_setting(str(store.get_setting(_SKILL_MARKET_PROVIDER_KEY) or "")) market_provider = normalize_skill_market_provider_setting(str(store.get_setting(_SKILL_MARKET_PROVIDER_KEY) or ""))
_audit( _audit(
store, store,
@ -137,14 +130,12 @@ def include_skill_routes(
status="ok", status="ok",
detail={ detail={
"prompt_in_system": bool(prompt_in_system), "prompt_in_system": bool(prompt_in_system),
"toolcall_enabled": bool(toolcall_enabled),
"market_provider": market_provider, "market_provider": market_provider,
}, },
) )
return { return {
"ok": True, "ok": True,
"prompt_in_system": bool(prompt_in_system), "prompt_in_system": bool(prompt_in_system),
"toolcall_enabled": bool(toolcall_enabled),
"market_provider": market_provider, "market_provider": market_provider,
} }
@ -817,7 +808,7 @@ def include_skill_routes(
raise HTTPException(status_code=500, detail="tool_registry_unavailable") raise HTTPException(status_code=500, detail="tool_registry_unavailable")
spec = tools.get(name) spec = tools.get(name)
if spec is None: if spec is None:
# Skill prompt mode: toolcall may be disabled, fallback to direct runtime execution from manifest. # Skill prompt mode: execute runtime entry from manifest when invoked via Admin/skills APIs.
mf = next((m for m in discover_workspace_skill_manifests() if str(m.name or "").strip() == name), None) mf = next((m for m in discover_workspace_skill_manifests() if str(m.name or "").strip() == name), None)
if mf is None: if mf is None:
raise HTTPException(status_code=404, detail="tool_not_found") raise HTTPException(status_code=404, detail="tool_not_found")

View file

@ -8235,7 +8235,6 @@ async function renderSkills() {
const marketStatus = el("div", { class: "muted", text: "" }); const marketStatus = el("div", { class: "muted", text: "" });
const skillModeStatus = el("div", { class: "muted", text: "" }); const skillModeStatus = el("div", { class: "muted", text: "" });
const skillPromptModeCb = el("input", { type: "checkbox" }); const skillPromptModeCb = el("input", { type: "checkbox" });
const skillToolcallModeCb = el("input", { type: "checkbox" });
const skillMarketProviderSelect = el("select", { class: "input", style: "min-width:160px;" }, [ const skillMarketProviderSelect = el("select", { class: "input", style: "min-width:160px;" }, [
el("option", { value: "clawhub", text: "clawhub (ClawHub)" }), el("option", { value: "clawhub", text: "clawhub (ClawHub)" }),
el("option", { value: "cocoloop", text: "cocoloop (CocoLoop)" }), el("option", { value: "cocoloop", text: "cocoloop (CocoLoop)" }),
@ -8250,7 +8249,6 @@ async function renderSkills() {
try { try {
const r = await apiGet("/admin/api/skills/mode"); const r = await apiGet("/admin/api/skills/mode");
skillPromptModeCb.checked = !!r.prompt_in_system; skillPromptModeCb.checked = !!r.prompt_in_system;
skillToolcallModeCb.checked = !!r.toolcall_enabled;
const mp = String(r.market_provider || "clawhub").trim().toLowerCase(); const mp = String(r.market_provider || "clawhub").trim().toLowerCase();
skillMarketProviderSelect.value = mp === "cocoloop" ? "cocoloop" : "clawhub"; skillMarketProviderSelect.value = mp === "cocoloop" ? "cocoloop" : "clawhub";
skillModeStatus.textContent = ""; skillModeStatus.textContent = "";
@ -8263,14 +8261,12 @@ async function renderSkills() {
try { try {
const r = await apiPost("/admin/api/skills/mode", { const r = await apiPost("/admin/api/skills/mode", {
prompt_in_system: !!skillPromptModeCb.checked, prompt_in_system: !!skillPromptModeCb.checked,
toolcall_enabled: !!skillToolcallModeCb.checked,
market_provider: String(skillMarketProviderSelect.value || "clawhub").trim(), market_provider: String(skillMarketProviderSelect.value || "clawhub").trim(),
}); });
skillPromptModeCb.checked = !!r.prompt_in_system; skillPromptModeCb.checked = !!r.prompt_in_system;
skillToolcallModeCb.checked = !!r.toolcall_enabled;
const mp = String(r.market_provider || "clawhub").trim().toLowerCase(); const mp = String(r.market_provider || "clawhub").trim().toLowerCase();
skillMarketProviderSelect.value = mp === "cocoloop" ? "cocoloop" : "clawhub"; skillMarketProviderSelect.value = mp === "cocoloop" ? "cocoloop" : "clawhub";
skillModeStatus.textContent = `saved: prompt=${String(!!r.prompt_in_system)} toolcall=${String(!!r.toolcall_enabled)} market=${String(skillMarketProviderSelect.value)}`; skillModeStatus.textContent = `saved: prompt=${String(!!r.prompt_in_system)} market=${String(skillMarketProviderSelect.value)}`;
} catch (e) { } catch (e) {
skillModeStatus.textContent = `mode: ${String(e && e.message ? e.message : e)}`; skillModeStatus.textContent = `mode: ${String(e && e.message ? e.message : e)}`;
} }
@ -9610,7 +9606,6 @@ async function renderSkills() {
el("div", { class: "card__title", text: t("title.skills") }), el("div", { class: "card__title", text: t("title.skills") }),
el("div", { class: "row", style: "gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;" }, [ el("div", { class: "row", style: "gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;" }, [
el("label", { class: "row", style: "gap:6px;align-items:center;" }, [skillPromptModeCb, el("span", { text: "Prompt mode (inject SKILL.md)" })]), el("label", { class: "row", style: "gap:6px;align-items:center;" }, [skillPromptModeCb, el("span", { text: "Prompt mode (inject SKILL.md)" })]),
el("label", { class: "row", style: "gap:6px;align-items:center;" }, [skillToolcallModeCb, el("span", { text: "Toolcall mode (runtime as tools)" })]),
el("label", { class: "row", style: "gap:6px;align-items:center;flex-wrap:wrap;" }, [ el("label", { class: "row", style: "gap:6px;align-items:center;flex-wrap:wrap;" }, [
el("span", { text: "Market (AIA_SKILL_MARKET_PROVIDER)" }), el("span", { text: "Market (AIA_SKILL_MARKET_PROVIDER)" }),
skillMarketProviderSelect, skillMarketProviderSelect,

View file

@ -3430,13 +3430,8 @@ async function renderChatUi() {
return normalizePlanAgentVersion(currentPlanAgentVersion); return normalizePlanAgentVersion(currentPlanAgentVersion);
}; };
const refreshExecUi = () => { const refreshExecUi = () => {
const modeVal = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(); // Plan mode removed; keep agent execution only.
const expert = modeVal !== MAIN_MODE_VALUE; execSelectWrap.style.display = "none";
const show =
expert &&
planAgentV2GloballyEnabled &&
normalizePlanAgentVersion(currentPlanAgentVersion) === PLAN_AGENT_V2;
execSelectWrap.style.display = show ? "inline-flex" : "none";
}; };
const modeOptionLabel = (v) => { const modeOptionLabel = (v) => {
const key = String(v || "").trim().toLowerCase(); const key = String(v || "").trim().toLowerCase();

View file

@ -1013,90 +1013,6 @@ class OclawGateway:
system_prompt_override = "" system_prompt_override = ""
tools_override = None tools_override = None
if interaction_mode == "expert":
from runtime.plan_agent_v2.gateway_adapter import evaluate_gateway_expert_turn_shadow
from runtime.plan_agent_v2.tool_specs import DEFAULT_SESSION_KEY, materialize_plan_mode_v2_tools
execution_mode = str(base_metadata.get("execution_mode") or "agent").strip().lower()
if execution_mode not in {"agent", "plan"}:
execution_mode = "agent"
try:
self.store.set_setting(DEFAULT_SESSION_KEY, str(msg.session_id or ""))
except Exception:
pass
# Respect store setting AIA_EXPERT_PLAN_AGENT_V2_ENABLED (default off); do not force cutover.
shadow = evaluate_gateway_expert_turn_shadow(
store=self.store,
msg=msg,
lang=lang,
interaction_mode=interaction_mode,
requested_specialist=requested_specialist,
execution_mode=execution_mode,
base_system_prompt=str(getattr(selected_executor, "system_prompt", "") or ""),
force_flag=False,
trace_id=trace_id,
parent_span_id=None,
)
if shadow.used_v2 and shadow.decision is not None:
action = str(shadow.decision.action or "")
if action in {"enter_plan", "stay_plan"}:
elapsed_ms = int((time.perf_counter() - t0) * 1000)
_trace_local(
event_type="response_sent",
payload={"ok": True, "elapsed_ms": elapsed_ms, "mode": "sync_direct", "plan_action": action},
started_at=t0,
)
_flush_trace_rows()
return OclawGatewayResult(
run_id=rid,
reply_text=str(shadow.decision.reply_text or ""),
trace_id=trace_id,
elapsed_ms=elapsed_ms,
mode="sync_direct",
selected_specialist=requested_specialist,
interaction_mode=interaction_mode,
dispatch_reason=f"plan_agent_v2:{action}",
manager_selected_specialist=requested_specialist,
requested_specialist=requested_specialist,
dynamic_agent_used=False,
dynamic_agent_name="",
relay_pointer_count=int(relay_stats.get("relay_pointer_count") or 0),
relay_envelope_present=bool(relay_stats.get("relay_envelope_present")),
relay_envelope_pointer_count=int(relay_stats.get("relay_envelope_pointer_count") or 0),
relay_ttl_turn_count=int(ttl_stats.get("turn") or 0),
relay_ttl_session_count=int(ttl_stats.get("session") or 0),
relay_ttl_keep_count=int(ttl_stats.get("keep") or 0),
turn_uuid="",
)
if action == "run_agent":
system_prompt_override = str(shadow.decision.system_prompt_override or "")
exec_tools = getattr(selected_executor, "tools", None)
if isinstance(exec_tools, ToolRegistry):
merged = ToolRegistry(exec_tools.list() + materialize_plan_mode_v2_tools(store=self.store))
tools_override = merged
_trace_local(
event_type="plan_mode_tools_augmented",
payload={"base_count": len(exec_tools.list()), "merged_count": len(merged.list())},
started_at=t0,
)
try:
plan_mode = str((shadow.decision.plan_state or {}).get("mode") or "").strip().lower()
except Exception:
plan_mode = ""
if plan_mode == "plan":
from runtime.plan_agent_v2.tool_policy import filter_tools_for_mode
if isinstance(tools_override, ToolRegistry):
filtered = filter_tools_for_mode(registry=tools_override, mode="plan")
tools_override = ToolRegistry(filtered)
_trace_local(
event_type="plan_mode_tools_filtered",
payload={
"before_count": len(merged.list()) if isinstance(exec_tools, ToolRegistry) else len(filtered),
"after_count": len(filtered),
},
started_at=t0,
)
route_mode = "sync_direct" route_mode = "sync_direct"
route_msg = StandardMessage( route_msg = StandardMessage(

View file

@ -1,42 +0,0 @@
from .adapter import PlanAgentV2Decision, evaluate_for_expert_mode
from .compat import build_shadow_gateway_result, legacy_gateway_result_keys
from .gateway_adapter import GatewayPlanV2AdapterOutput, evaluate_gateway_expert_turn_shadow
from .manager import PlanModeManagerV2
from .models import PLAN_MODE_NORMAL, PLAN_MODE_PLAN, PlanAgentStateV2
from .prompt_injector import build_plan_mode_prefix, inject_plan_context
from .state_store import PlanAgentStateStoreV2
from .switch import should_route_to_v2, v2_feature_enabled
from .tool_policy import filter_tools_for_mode, plan_mode_allowed_tool_names
from .tool_specs import (
enter_plan_mode_v2_tool,
exit_plan_mode_v2_tool,
is_plan_mode_v2_active,
materialize_plan_mode_v2_tools,
)
from .trace import emit_plan_agent_v2_trace
__all__ = [
"PLAN_MODE_NORMAL",
"PLAN_MODE_PLAN",
"PlanAgentStateV2",
"PlanAgentStateStoreV2",
"PlanModeManagerV2",
"build_plan_mode_prefix",
"inject_plan_context",
"filter_tools_for_mode",
"plan_mode_allowed_tool_names",
"enter_plan_mode_v2_tool",
"exit_plan_mode_v2_tool",
"materialize_plan_mode_v2_tools",
"is_plan_mode_v2_active",
"v2_feature_enabled",
"should_route_to_v2",
"PlanAgentV2Decision",
"evaluate_for_expert_mode",
"GatewayPlanV2AdapterOutput",
"evaluate_gateway_expert_turn_shadow",
"emit_plan_agent_v2_trace",
"legacy_gateway_result_keys",
"build_shadow_gateway_result",
]

View file

@ -1,281 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .manager import PlanModeManagerV2
from .models import PLAN_MODE_PLAN
from .prompt_injector import build_plan_mode_prefix, inject_plan_context
from .trace import emit_plan_agent_v2_trace
@dataclass(frozen=True)
class PlanAgentV2Decision:
action: str # enter_plan | stay_plan | run_agent
reply_text: str
plan_state: dict[str, Any]
system_prompt_override: str = ""
def _is_confirm_text(text: str) -> bool:
t = str(text or "").strip().lower()
return t in {"确认", "确认计划", "同意", "通过", "approve", "approved", "confirm", "yes"}
def _normalize_user_text(text: str) -> str:
return " ".join(str(text or "").strip().lower().split())
def _is_low_signal_continue(text_norm: str) -> bool:
t = str(text_norm or "").strip().lower()
return t in {
"继续",
"继续啊",
"继续吧",
"可以",
"好的",
"好",
"ok",
"okay",
"go on",
"continue",
}
def _confirm_strategy(store: Any) -> str:
try:
raw = str(store.get_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY") or "").strip().lower()
except Exception:
raw = ""
if raw in {"auto", "strict", "off"}:
return raw
return "strict"
def _last_user_text_norm_from_history(*, store: Any, session_id: str) -> str:
"""Most recent persisted user message (current turn is usually not persisted yet)."""
try:
msgs = store.get_messages(session_id=session_id, limit=120)
except Exception:
return ""
for m in reversed(msgs):
if str(getattr(m, "role", "") or "").strip().lower() == "user":
return _normalize_user_text(str(getattr(m, "content", "") or ""))
return ""
def _agent_conversation_stall_suffix(*, lang: str) -> str:
is_en = str(lang or "").startswith("en")
if is_en:
return (
"\n\n[Conversation stall guard — agent mode]\n"
"The user's latest message matches their previous user message in this session.\n"
"- Do not repeat your last assistant reply or restate \"I will now…\" boilerplate.\n"
"- Make substantive progress: execute the next concrete tool step, produce new actionable output, "
"or ask exactly one specific blocking question.\n"
)
return (
"\n\n【对话停滞防护 · agent 模式】\n"
"检测到用户本条输入与上一轮用户输入相同(会话已持久化部分)。\n"
"- 禁止复述上一轮助手回复或重复「接下来我将…」式独白。\n"
"- 必须给出实质进展:执行具体工具步骤、写出新的可执行结果,或只提一个关键追问。\n"
)
def evaluate_for_expert_mode(
*,
store: Any,
session_id: str,
lang: str,
requested_specialist: str,
user_text: str,
execution_mode: str = "agent",
base_system_prompt: str,
trace_id: str | None = None,
parent_span_id: str | None = None,
) -> PlanAgentV2Decision:
mgr = PlanModeManagerV2(store=store)
st = mgr.load_state(session_id=session_id)
txt = str(user_text or "").strip()
txt_norm = _normalize_user_text(txt)
exec_mode = str(execution_mode or "").strip().lower()
if exec_mode not in {"agent", "plan"}:
exec_mode = "plan"
confirm_strategy = _confirm_strategy(store)
if exec_mode == "agent" and st.mode != PLAN_MODE_PLAN:
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="plan_mode_bypassed",
payload={"requested_mode": "agent", "plan_mode_state": str(st.mode or "")},
)
last_user_norm = _last_user_text_norm_from_history(store=store, session_id=session_id)
stall = bool(txt_norm and last_user_norm and txt_norm == last_user_norm)
override = ""
if stall:
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="agent_mode_conversation_stall",
payload={"reason": "repeated_user_message"},
)
base = str(base_system_prompt or "").strip()
suffix = _agent_conversation_stall_suffix(lang=lang).strip()
override = f"{base}\n\n{suffix}".strip()
return PlanAgentV2Decision(
action="run_agent",
reply_text="",
plan_state=st.to_dict(),
system_prompt_override=override,
)
if st.mode != PLAN_MODE_PLAN:
entered = mgr.enter(session_id=session_id, owner_specialist=requested_specialist, force_new_plan=False)
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="plan_mode_entered",
payload={"owner_specialist": entered.owner_specialist, "plan_id": entered.plan_id},
)
prefix = build_plan_mode_prefix(state=entered, lang=lang)
return PlanAgentV2Decision(
action="run_agent",
reply_text="",
plan_state=entered.to_dict(),
system_prompt_override=f"{prefix}\n\n{str(base_system_prompt or '').strip()}".strip(),
)
st = mgr.refresh_plan_content(session_id=session_id)
st = mgr.update_loop_guard(session_id=session_id, user_text_norm=txt_norm)
if _is_confirm_text(txt):
if exec_mode != "agent" and confirm_strategy == "strict":
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="plan_mode_confirm_blocked",
payload={
"reason": "execution_mode_not_agent",
"requested_mode": exec_mode,
"confirm_strategy": confirm_strategy,
},
)
blocked_reply = (
"Plan is ready. Please switch to agent mode, then confirm to execute."
if str(lang or "").startswith("en")
else "计划已就绪。请先切换到 agent 模式,再回复“确认”开始执行。"
)
return PlanAgentV2Decision(
action="stay_plan",
reply_text=blocked_reply,
plan_state=st.to_dict(),
system_prompt_override="",
)
if exec_mode != "agent" and confirm_strategy == "auto":
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="plan_mode_confirm_auto_switched",
payload={"from_mode": exec_mode, "to_mode": "agent", "confirm_strategy": confirm_strategy},
)
confirmed = mgr.confirm(session_id=session_id)
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="plan_mode_confirmed",
payload={
"plan_id": confirmed.plan_id,
"plan_confirmed": bool(confirmed.plan_confirmed),
"confirm_strategy": confirm_strategy,
},
)
next_system = inject_plan_context(base_system=base_system_prompt, state=confirmed, lang=lang)
reply = mgr.build_approved_execution_message(state=confirmed, lang=lang)
return PlanAgentV2Decision(
action="run_agent",
reply_text=reply,
plan_state=confirmed.to_dict(),
system_prompt_override=next_system,
)
if _is_low_signal_continue(txt_norm):
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="plan_mode_active",
payload={"plan_id": st.plan_id, "plan_path": st.plan_path, "loop_guard": "low_signal_continue"},
)
low_signal_reply = (
"Plan mode detected a low-information continuation. "
"Please provide concrete plan adjustments, or switch to agent mode and reply 'confirm' to execute."
if str(lang or "").startswith("en")
else "检测到低信息续写(如“继续/可以”)。请给出具体计划修改点,或切换到 agent 模式后回复“确认”直接执行。"
)
return PlanAgentV2Decision(
action="stay_plan",
reply_text=low_signal_reply,
plan_state=st.to_dict(),
system_prompt_override="",
)
if int(st.plan_loop_count or 0) >= 2:
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="plan_mode_active",
payload={"plan_id": st.plan_id, "plan_path": st.plan_path, "loop_guard": "hard_block"},
)
anti_loop_reply = (
"I am in plan mode. I will only output a concise executable plan. "
"If you want me to execute, switch to agent mode and reply 'confirm'."
if str(lang or "").startswith("en")
else "当前为 plan 模式,我只输出可执行计划。若要开始执行,请切换到 agent 模式并回复“确认”。"
)
return PlanAgentV2Decision(
action="stay_plan",
reply_text=anti_loop_reply,
plan_state=st.to_dict(),
system_prompt_override="",
)
prefix = build_plan_mode_prefix(state=st, lang=lang)
anti_loop_suffix = (
"\n\n[Anti-loop guard]\n"
"- Do not repeat the previous response.\n"
"- If user asks similarly, refine with more concrete steps, checks, and fallback.\n"
"- Keep output as plan only; do not pretend execution is complete."
)
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_span_id,
event_type="plan_mode_active",
payload={"plan_id": st.plan_id, "plan_path": st.plan_path, "loop_count": int(st.plan_loop_count or 0)},
)
return PlanAgentV2Decision(
action="run_agent",
reply_text="",
plan_state=st.to_dict(),
system_prompt_override=f"{prefix}{anti_loop_suffix}\n\n{str(base_system_prompt or '').strip()}".strip(),
)
__all__ = ["PlanAgentV2Decision", "evaluate_for_expert_mode"]

View file

@ -1,46 +0,0 @@
from __future__ import annotations
from typing import Any
from .adapter import PlanAgentV2Decision
from runtime.gateway import OclawGatewayResult
def legacy_gateway_result_keys() -> set[str]:
return set(OclawGatewayResult.__dataclass_fields__.keys())
def build_shadow_gateway_result(
*,
decision: PlanAgentV2Decision,
run_id: str,
trace_id: str,
elapsed_ms: int,
requested_specialist: str,
) -> dict[str, Any]:
return {
"run_id": str(run_id),
"reply_text": str(decision.reply_text or ""),
"trace_id": str(trace_id),
"elapsed_ms": int(elapsed_ms),
"mode": "sync_direct",
"task_id": None,
"selected_specialist": str((decision.plan_state or {}).get("owner_specialist") or requested_specialist or "generalist"),
"interaction_mode": "expert",
"dispatch_reason": f"plan_agent_v2:{decision.action}",
"manager_selected_specialist": str((decision.plan_state or {}).get("owner_specialist") or requested_specialist or "generalist"),
"requested_specialist": str(requested_specialist or "generalist"),
"dynamic_agent_used": False,
"dynamic_agent_name": "",
"relay_pointer_count": 0,
"relay_envelope_present": False,
"relay_envelope_pointer_count": 0,
"relay_ttl_turn_count": 0,
"relay_ttl_session_count": 0,
"relay_ttl_keep_count": 0,
"turn_uuid": "",
}
__all__ = ["build_shadow_gateway_result", "legacy_gateway_result_keys"]

View file

@ -1,54 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .adapter import PlanAgentV2Decision, evaluate_for_expert_mode
from .switch import should_route_to_v2
from runtime.types import StandardMessage
@dataclass(frozen=True)
class GatewayPlanV2AdapterOutput:
used_v2: bool
decision: PlanAgentV2Decision | None
def evaluate_gateway_expert_turn_shadow(
*,
store: Any,
msg: StandardMessage,
lang: str,
interaction_mode: str,
requested_specialist: str,
execution_mode: str = "",
base_system_prompt: str,
force_flag: bool = False,
trace_id: str | None = None,
parent_span_id: str | None = None,
) -> GatewayPlanV2AdapterOutput:
if not should_route_to_v2(store=store, interaction_mode=interaction_mode, force_flag=force_flag):
return GatewayPlanV2AdapterOutput(used_v2=False, decision=None)
meta = msg.metadata if isinstance(msg.metadata, dict) else {}
if "plan_agent_version" in meta:
if str(meta.get("plan_agent_version") or "").strip().lower() != "v2":
return GatewayPlanV2AdapterOutput(used_v2=False, decision=None)
eff_mode = str(execution_mode or "").strip().lower()
if eff_mode not in {"agent", "plan"}:
eff_mode = "plan" if force_flag else "agent"
dec = evaluate_for_expert_mode(
store=store,
session_id=str(msg.session_id or ""),
lang=lang,
requested_specialist=requested_specialist,
user_text=str(msg.text or ""),
execution_mode=eff_mode,
base_system_prompt=base_system_prompt,
trace_id=trace_id,
parent_span_id=parent_span_id,
)
return GatewayPlanV2AdapterOutput(used_v2=True, decision=dec)
__all__ = ["GatewayPlanV2AdapterOutput", "evaluate_gateway_expert_turn_shadow"]

View file

@ -1,185 +0,0 @@
from __future__ import annotations
import time
import uuid
from pathlib import Path
from typing import Any
from .models import PLAN_MODE_NORMAL, PLAN_MODE_PLAN, PlanAgentStateV2
from .state_store import PlanAgentStateStoreV2
def _default_plan_dir() -> Path:
return Path(__file__).resolve().parents[2] / "data" / "plans"
def _resolve_plan_dir(store: Any) -> Path:
raw = str(store.get_setting("AIA_EXPERT_PLAN_FILE_DIR") or "").strip()
if raw:
p = Path(raw)
return p if p.is_absolute() else (Path(__file__).resolve().parents[2] / p)
return _default_plan_dir()
def _plan_template() -> str:
return (
"# Plan\n\n"
"## Goal\n"
"- \n\n"
"## Scope\n"
"- \n\n"
"## Steps\n"
"1. \n"
"2. \n"
"3. \n\n"
"## Risks\n"
"- \n\n"
"## Acceptance\n"
"- \n"
)
class PlanModeManagerV2:
def __init__(self, *, store: Any):
self._store = store
self._state_store = PlanAgentStateStoreV2(store)
def load_state(self, *, session_id: str) -> PlanAgentStateV2:
return self._state_store.load(session_id=session_id)
def enter(
self,
*,
session_id: str,
owner_specialist: str,
force_new_plan: bool = False,
) -> PlanAgentStateV2:
prev = self._state_store.load(session_id=session_id)
if prev.mode == PLAN_MODE_PLAN and not force_new_plan:
return prev
sid = str(session_id or "").strip()
if not sid:
return prev
plan_id = uuid.uuid4().hex
plan_root = _resolve_plan_dir(self._store) / sid
plan_root.mkdir(parents=True, exist_ok=True)
plan_path = plan_root / f"{plan_id}.md"
plan_content = _plan_template()
plan_path.write_text(plan_content, encoding="utf-8")
now_ms = int(time.time() * 1000)
next_state = PlanAgentStateV2(
mode=PLAN_MODE_PLAN,
owner_specialist=str(owner_specialist or "generalist").strip().lower() or "generalist",
plan_id=plan_id,
plan_path=str(plan_path),
plan_content=plan_content,
plan_confirmed=False,
entered_at_ms=now_ms,
updated_at_ms=now_ms,
last_user_text_norm="",
plan_loop_count=0,
)
return self._state_store.save(session_id=sid, state=next_state)
def refresh_plan_content(self, *, session_id: str) -> PlanAgentStateV2:
st = self._state_store.load(session_id=session_id)
p = Path(str(st.plan_path or "").strip())
if not p.exists() or not p.is_file():
return st
content = p.read_text(encoding="utf-8", errors="replace")
return self._state_store.save(
session_id=session_id,
state=PlanAgentStateV2(
mode=st.mode,
owner_specialist=st.owner_specialist,
plan_id=st.plan_id,
plan_path=st.plan_path,
plan_content=content,
plan_confirmed=st.plan_confirmed,
entered_at_ms=st.entered_at_ms,
updated_at_ms=st.updated_at_ms,
last_user_text_norm=st.last_user_text_norm,
plan_loop_count=st.plan_loop_count,
),
)
def update_loop_guard(self, *, session_id: str, user_text_norm: str) -> PlanAgentStateV2:
st = self._state_store.load(session_id=session_id)
nxt_count = int(st.plan_loop_count or 0) + 1 if user_text_norm and user_text_norm == st.last_user_text_norm else 0
return self._state_store.save(
session_id=session_id,
state=PlanAgentStateV2(
mode=st.mode,
owner_specialist=st.owner_specialist,
plan_id=st.plan_id,
plan_path=st.plan_path,
plan_content=st.plan_content,
plan_confirmed=st.plan_confirmed,
entered_at_ms=st.entered_at_ms,
updated_at_ms=st.updated_at_ms,
last_user_text_norm=user_text_norm,
plan_loop_count=nxt_count,
),
)
def confirm(self, *, session_id: str) -> PlanAgentStateV2:
st = self.refresh_plan_content(session_id=session_id)
return self._state_store.save(
session_id=session_id,
state=PlanAgentStateV2(
mode=PLAN_MODE_NORMAL,
owner_specialist=st.owner_specialist,
plan_id=st.plan_id,
plan_path=st.plan_path,
plan_content=st.plan_content,
plan_confirmed=True,
entered_at_ms=st.entered_at_ms,
updated_at_ms=st.updated_at_ms,
last_user_text_norm="",
plan_loop_count=0,
),
)
def build_approved_execution_message(self, *, state: PlanAgentStateV2, lang: str) -> str:
is_en = str(lang or "").startswith("en")
plan_path = str(state.plan_path or "").strip() or "unknown"
plan_content = str(state.plan_content or "").strip()
if plan_content:
if is_en:
return (
"User has approved your plan. You can now start implementation.\n\n"
f"Plan file: {plan_path}\n\n"
f"## Approved Plan\n{plan_content}"
)
return (
"用户已确认计划,你可以开始执行实现。\n\n"
f"计划文件:{plan_path}\n\n"
f"## 已确认计划\n{plan_content}"
)
return (
f"Plan approved. You can now start implementation. Plan file: {plan_path}"
if is_en
else f"计划已确认,你可以开始执行实现。计划文件:{plan_path}"
)
def exit_without_confirm(self, *, session_id: str) -> PlanAgentStateV2:
st = self._state_store.load(session_id=session_id)
return self._state_store.save(
session_id=session_id,
state=PlanAgentStateV2(
mode=PLAN_MODE_NORMAL,
owner_specialist=st.owner_specialist,
plan_id=st.plan_id,
plan_path=st.plan_path,
plan_content=st.plan_content,
plan_confirmed=False,
entered_at_ms=st.entered_at_ms,
updated_at_ms=st.updated_at_ms,
last_user_text_norm="",
plan_loop_count=0,
),
)
__all__ = ["PlanModeManagerV2"]

View file

@ -1,49 +0,0 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
PLAN_MODE_NORMAL = "normal"
PLAN_MODE_PLAN = "plan"
_VALID_MODES = {PLAN_MODE_NORMAL, PLAN_MODE_PLAN}
@dataclass(frozen=True)
class PlanAgentStateV2:
mode: str = PLAN_MODE_NORMAL
owner_specialist: str = "generalist"
plan_id: str = ""
plan_path: str = ""
plan_content: str = ""
plan_confirmed: bool = False
entered_at_ms: int = 0
updated_at_ms: int = 0
last_user_text_norm: str = ""
plan_loop_count: int = 0
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@staticmethod
def from_dict(raw: dict[str, Any] | None) -> "PlanAgentStateV2":
obj = raw if isinstance(raw, dict) else {}
mode = str(obj.get("mode") or PLAN_MODE_NORMAL).strip().lower()
if mode not in _VALID_MODES:
mode = PLAN_MODE_NORMAL
return PlanAgentStateV2(
mode=mode,
owner_specialist=str(obj.get("owner_specialist") or "generalist").strip().lower() or "generalist",
plan_id=str(obj.get("plan_id") or "").strip(),
plan_path=str(obj.get("plan_path") or "").strip(),
plan_content=str(obj.get("plan_content") or ""),
plan_confirmed=bool(obj.get("plan_confirmed")),
entered_at_ms=int(obj.get("entered_at_ms") or 0),
updated_at_ms=int(obj.get("updated_at_ms") or 0),
last_user_text_norm=str(obj.get("last_user_text_norm") or "").strip().lower(),
plan_loop_count=int(obj.get("plan_loop_count") or 0),
)
__all__ = ["PLAN_MODE_NORMAL", "PLAN_MODE_PLAN", "PlanAgentStateV2"]

View file

@ -1,120 +0,0 @@
from __future__ import annotations
from pathlib import Path
from .models import PLAN_MODE_PLAN, PlanAgentStateV2
def _plan_file_info(state: PlanAgentStateV2) -> str:
plan_path = str(state.plan_path or "").strip()
if not plan_path:
return "No plan file path is available yet."
p = Path(plan_path)
if p.exists():
return (
f"A plan file already exists at {plan_path}. "
"You can read it and make incremental edits."
)
return (
f"No plan file exists yet. You should create your plan at {plan_path}."
)
def build_plan_mode_prefix(*, state: PlanAgentStateV2, lang: str) -> str:
if state.mode != PLAN_MODE_PLAN:
return ""
is_en = str(lang or "").startswith("en")
file_info = _plan_file_info(state)
if is_en:
return (
"Plan mode is active. The user does not want execution yet.\n"
"You MUST NOT make real project edits, run non-readonly tools, or claim implementation is done.\n"
"## Execution discipline (critical)\n"
"- Do NOT narrate as if you will run scripts, migrate files, or touch disk *in this turn*. "
"Phrases like \"I'll write the script and run it\", \"starting migration now\", or "
"\"let me execute\" mislead the user—refuse that pattern.\n"
"- If the user needs real execution, say explicitly: switch to **agent mode** in the UI, "
"then confirm; you cannot perform execution while plan mode is active.\n"
"- Do NOT repeat the same \"next I will…\" monologue across turns. On vague follow-ups, "
"edit the plan file or ask **one** concrete question—do not restate boilerplate.\n"
"- Do not ask the user to \"approve the plan\" in chat when the product expects mode switch + "
"confirm; instead tell them the handoff: agent mode → confirm to execute.\n\n"
"## Plan File Info\n"
f"{file_info}\n"
"Only the plan file is allowed to be edited while in plan mode.\n\n"
"## Plan Workflow\n"
"### Phase 1: Initial Understanding\n"
"- Understand the request and inspect relevant codepaths.\n"
"- Reuse existing functions/utilities/patterns when possible.\n\n"
"### Phase 2: Design\n"
"- Propose a concrete implementation strategy with trade-offs.\n\n"
"### Phase 3: Review\n"
"- Validate alignment with user intent and constraints.\n"
"- Clarify unresolved requirements only when necessary.\n\n"
"### Phase 4: Final Plan\n"
"- Output sections: Context, Changes, Critical files, Verification.\n"
"- Prefer one recommended approach over listing many alternatives.\n\n"
"### Phase 5: Execution Handoff\n"
"- Ask user to switch to agent mode and confirm before execution.\n"
"- Do not execute while still in plan mode.\n\n"
"## Plan mode tools (lifecycle)\n"
"- Built-in tools `enter_plan_mode_v2` and `exit_plan_mode_v2` mirror cc-mini-style "
"Enter/Exit plan mode: they bind or release plan-mode state for this session.\n"
"- Prefer updating the plan file in place while staying in plan mode; only call "
"`enter_plan_mode_v2` with `force_new_plan: true` when the user explicitly wants a new plan document.\n"
"- When the written plan is ready for review, either keep plan mode and summarize next steps for the user, "
"or call `exit_plan_mode_v2`. Use `confirm: true` only when the user has explicitly approved executing "
"this plan; use `confirm: false` to leave plan mode without marking the plan approved for execution.\n"
"- If the user sends low-content prompts such as 'continue' or 'ok', do not repeat long boilerplate; "
"revise the plan file or ask one concrete clarification."
)
return (
"当前处于 plan 模式,用户暂不要求执行。\n"
"你必须不做真实项目改动、不调用非只读工具,也不要声称已经实现完成。\n"
"## 执行纪律(必须遵守)\n"
"- 禁止用「我现在写脚本并执行」「开始迁移/复制」「让我跑一下」等表述,假装本回合会动磁盘或执行命令。\n"
"- 若用户需要真实执行,必须明确说明:请在界面切换到 **agent 模式**,再按产品流程确认;"
"在 plan 模式下你无法代为执行。\n"
"- 禁止多轮重复同一套「接下来我将……」的独白;用户只说「继续/好的」时,应小幅改计划文件或只提一个具体问题,"
"不要复读长模板。\n"
"- 不要用闲聊式「你同意这个计划吗?」代替产品要求的 **切 agent + 确认**;应提示用户按界面切换到 agent 模式后再确认执行。\n\n"
"## 计划文件信息\n"
f"{file_info}\n"
"在 plan 模式下,只允许围绕计划文件进行编辑。\n\n"
"## 计划工作流\n"
"### 阶段1:理解问题\n"
"- 先理解需求并检查相关代码路径。\n"
"- 优先复用现有函数、工具和既有模式。\n\n"
"### 阶段2:方案设计\n"
"- 给出可落地的实现方案,并说明关键取舍。\n\n"
"### 阶段3:对齐复核\n"
"- 核对是否满足用户目标与约束。\n"
"- 仅在必要时提出澄清问题。\n\n"
"### 阶段4:最终计划\n"
"- 输出结构:背景、改动点、关键文件、验证方式。\n"
"- 推荐一个主方案,不要只堆备选项。\n\n"
"### 阶段5:执行切换\n"
"- 明确提示用户先切换到 agent 模式并确认后再执行。\n"
"- 在 plan 模式下不要执行实现。\n\n"
"## 计划模式工具(生命周期)\n"
"- 内置工具 `enter_plan_mode_v2` 与 `exit_plan_mode_v2` 对应 cc-mini 风格的进入/退出计划模式,用于绑定或释放本会话的 plan 状态。\n"
"- 优先在 plan 模式下就地更新计划文件;仅在用户明确要求新开计划文档时,才对 `enter_plan_mode_v2` 使用 `force_new_plan: true`。\n"
"- 计划文档写完后,可继续保持 plan 模式并给用户摘要;也可调用 `exit_plan_mode_v2`。仅在用户已明确同意按该计划执行时使用 "
"`confirm: true`;若只是结束规划、尚未批准执行,使用 `confirm: false`。\n"
"- 若用户输入信息量低的续写(如「继续」「好的」),不要重复大段套话,应小幅修订计划文件或提出一个具体问题。"
)
def inject_plan_context(*, base_system: str, state: PlanAgentStateV2, lang: str, max_chars: int = 3000) -> str:
plan_text = str(state.plan_content or "").strip()
if not plan_text:
return base_system
if len(plan_text) > max_chars:
plan_text = plan_text[:max_chars] + "\n...<plan_truncated>"
is_en = str(lang or "").startswith("en")
header = "Approved plan context:\n" if is_en else "已确认计划上下文:\n"
return f"{header}{plan_text}\n\n{str(base_system or '').strip()}".strip()
__all__ = ["build_plan_mode_prefix", "inject_plan_context"]

View file

@ -1,60 +0,0 @@
from __future__ import annotations
import json
import time
from typing import Any
from .models import PLAN_MODE_NORMAL, PlanAgentStateV2
def _state_key(session_id: str) -> str:
return f"AIA_PLAN_AGENT_V2_STATE:{str(session_id or '').strip()}"
class PlanAgentStateStoreV2:
def __init__(self, store: Any):
self._store = store
def load(self, *, session_id: str) -> PlanAgentStateV2:
sid = str(session_id or "").strip()
if not sid:
return PlanAgentStateV2()
raw = str(self._store.get_setting(_state_key(sid)) or "").strip()
if not raw:
return PlanAgentStateV2()
try:
obj = json.loads(raw)
except Exception:
return PlanAgentStateV2()
return PlanAgentStateV2.from_dict(obj if isinstance(obj, dict) else None)
def save(self, *, session_id: str, state: PlanAgentStateV2) -> PlanAgentStateV2:
sid = str(session_id or "").strip()
if not sid:
return state
now_ms = int(time.time() * 1000)
next_state = PlanAgentStateV2(
mode=state.mode,
owner_specialist=state.owner_specialist,
plan_id=state.plan_id,
plan_path=state.plan_path,
plan_content=state.plan_content,
plan_confirmed=bool(state.plan_confirmed),
entered_at_ms=int(state.entered_at_ms or 0),
updated_at_ms=now_ms,
last_user_text_norm=str(state.last_user_text_norm or "").strip().lower(),
plan_loop_count=int(state.plan_loop_count or 0),
)
self._store.set_setting(_state_key(sid), json.dumps(next_state.to_dict(), ensure_ascii=False))
return next_state
def reset(self, *, session_id: str) -> PlanAgentStateV2:
sid = str(session_id or "").strip()
if not sid:
return PlanAgentStateV2()
self._store.delete_setting(_state_key(sid))
return PlanAgentStateV2(mode=PLAN_MODE_NORMAL)
__all__ = ["PlanAgentStateStoreV2"]

View file

@ -1,35 +0,0 @@
from __future__ import annotations
import os
from typing import Any
def _is_truthy(raw: str | None) -> bool:
return str(raw or "").strip().lower() in {"1", "true", "yes", "on"}
def v2_feature_enabled(*, store: Any | None = None) -> bool:
# Default off for shadow path safety.
raw = ""
try:
if store is not None:
raw = str(store.get_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip()
except Exception:
raw = ""
if not raw:
raw = str(os.getenv("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip()
if not raw:
return False
return _is_truthy(raw)
def should_route_to_v2(*, store: Any | None, interaction_mode: str, force_flag: bool = False) -> bool:
if str(interaction_mode or "").strip().lower() != "expert":
return False
if force_flag:
return True
return v2_feature_enabled(store=store)
__all__ = ["should_route_to_v2", "v2_feature_enabled"]

View file

@ -1,52 +0,0 @@
from __future__ import annotations
from typing import Iterable
from .models import PLAN_MODE_PLAN
from runtime.tools.base import ToolRegistry, ToolSpec
_DEFAULT_PLAN_ALLOWLIST = frozenset(
{
"read_file",
"search_files",
"glob",
"list_directory",
"list_workspace_tree",
"search_files_context",
"system_time",
# Plan-mode control tools are non-read-only by design, but must stay callable.
"enter_plan_mode_v2",
"exit_plan_mode_v2",
}
)
def plan_mode_allowed_tool_names(extra_allowed: Iterable[str] | None = None) -> set[str]:
out = set(_DEFAULT_PLAN_ALLOWLIST)
for x in (extra_allowed or []):
n = str(x or "").strip()
if n:
out.add(n)
return out
def filter_tools_for_mode(
*,
registry: ToolRegistry,
mode: str,
extra_allowed: Iterable[str] | None = None,
) -> list[ToolSpec]:
tools = list(registry.list())
if str(mode or "").strip().lower() != PLAN_MODE_PLAN:
return tools
allow = plan_mode_allowed_tool_names(extra_allowed=extra_allowed)
out: list[ToolSpec] = []
for t in tools:
if t.name in allow or bool(t.is_read_only()):
out.append(t)
return out
__all__ = ["filter_tools_for_mode", "plan_mode_allowed_tool_names"]

View file

@ -1,161 +0,0 @@
from __future__ import annotations
from typing import Any
from .manager import PlanModeManagerV2
from .models import PLAN_MODE_PLAN
from .trace import emit_plan_agent_v2_trace
from runtime.tools.base import ToolSpec
DEFAULT_SESSION_KEY = "AIA_PLAN_AGENT_V2_DEFAULT_SESSION_ID"
def _emit_tool_trace(
*,
store: Any,
session_id: str,
args: dict[str, Any],
event_type: str,
payload: dict[str, Any] | None = None,
) -> None:
trace_id = str(args.get("trace_id") or "").strip()
if not trace_id:
return
parent_raw = str(args.get("parent_span_id") or "").strip()
emit_plan_agent_v2_trace(
store=store,
session_id=session_id,
trace_id=trace_id,
parent_span_id=parent_raw or None,
event_type=event_type,
payload=payload,
)
def _resolve_session_id(*, store: Any, args: dict[str, Any]) -> str:
sid = str(args.get("session_id") or "").strip()
if sid:
return sid
try:
return str(store.get_setting(DEFAULT_SESSION_KEY) or "").strip()
except Exception:
return ""
def enter_plan_mode_v2_tool(*, store: Any) -> ToolSpec:
mgr = PlanModeManagerV2(store=store)
def _handler(args: dict[str, Any]) -> dict[str, Any]:
session_id = _resolve_session_id(store=store, args=args)
if not session_id:
return {"ok": False, "error_code": "session_id_required", "error": "session_id_required"}
specialist = str(args.get("owner_specialist") or "generalist").strip().lower() or "generalist"
force_new = bool(args.get("force_new_plan"))
st = mgr.enter(session_id=session_id, owner_specialist=specialist, force_new_plan=force_new)
_emit_tool_trace(
store=store,
session_id=session_id,
args=args,
event_type="plan_mode_tool_enter",
payload={
"tool": "enter_plan_mode_v2",
"owner_specialist": specialist,
"force_new_plan": force_new,
"plan_id": st.plan_id,
},
)
return {"ok": True, "state": st.to_dict()}
return ToolSpec(
name="enter_plan_mode_v2",
description=(
"Enter plan mode for this session (shadow v2), cc-mini-style: binds a dedicated plan file path. "
"Use when the user wants structured planning. Set force_new_plan=true only when starting a brand-new "
"plan document; otherwise reuse the existing plan when possible. Optional trace_id / parent_span_id "
"attach observability to the current trace."
),
parameters={
"type": "object",
"properties": {
"session_id": {"type": "string"},
"owner_specialist": {"type": "string"},
"force_new_plan": {"type": "boolean", "default": False},
"trace_id": {"type": "string"},
"parent_span_id": {"type": "string"},
},
"required": [],
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"plan_mode", "shadow_v2", "read"}),
read_only=True,
risk_level="low",
)
def exit_plan_mode_v2_tool(*, store: Any) -> ToolSpec:
mgr = PlanModeManagerV2(store=store)
def _handler(args: dict[str, Any]) -> dict[str, Any]:
session_id = _resolve_session_id(store=store, args=args)
if not session_id:
return {"ok": False, "error_code": "session_id_required", "error": "session_id_required"}
confirm = bool(args.get("confirm"))
st = mgr.confirm(session_id=session_id) if confirm else mgr.exit_without_confirm(session_id=session_id)
_emit_tool_trace(
store=store,
session_id=session_id,
args=args,
event_type="plan_mode_tool_exit",
payload={
"tool": "exit_plan_mode_v2",
"confirmed": bool(confirm),
"plan_id": st.plan_id,
"plan_confirmed": bool(st.plan_confirmed),
},
)
return {"ok": True, "confirmed": bool(confirm), "state": st.to_dict()}
return ToolSpec(
name="exit_plan_mode_v2",
description=(
"Exit plan mode for this session (shadow v2). confirm=true marks the plan as approved for execution "
"(same intent as the user confirming in agent mode). confirm=false leaves plan mode without approving "
"execution—use when ending planning without a run approval yet. Optional trace_id / parent_span_id for "
"trace correlation."
),
parameters={
"type": "object",
"properties": {
"session_id": {"type": "string"},
"confirm": {"type": "boolean", "default": False},
"trace_id": {"type": "string"},
"parent_span_id": {"type": "string"},
},
"required": [],
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"plan_mode", "shadow_v2", "write"}),
read_only=False,
risk_level="high",
)
def materialize_plan_mode_v2_tools(*, store: Any) -> list[ToolSpec]:
return [enter_plan_mode_v2_tool(store=store), exit_plan_mode_v2_tool(store=store)]
def is_plan_mode_v2_active(*, store: Any, session_id: str) -> bool:
mgr = PlanModeManagerV2(store=store)
return mgr.load_state(session_id=session_id).mode == PLAN_MODE_PLAN
__all__ = [
"enter_plan_mode_v2_tool",
"exit_plan_mode_v2_tool",
"materialize_plan_mode_v2_tools",
"is_plan_mode_v2_active",
"DEFAULT_SESSION_KEY",
]

View file

@ -1,37 +0,0 @@
from __future__ import annotations
import time
from typing import Any
def emit_plan_agent_v2_trace(
*,
store: Any,
session_id: str,
trace_id: str | None,
parent_span_id: str | None,
event_type: str,
payload: dict[str, Any] | None = None,
) -> None:
if not str(trace_id or "").strip():
return
merged = dict(payload or {})
merged.setdefault("pipeline", "plan_agent_v2")
merged.setdefault("ts_ms", int(time.time() * 1000))
try:
from runtime.orchestration.trace import new_span_id
store.add_trace_event(
session_id=str(session_id or ""),
trace_id=str(trace_id),
span_id=new_span_id(),
parent_span_id=parent_span_id,
event_type=str(event_type or "plan_agent_v2"),
payload=merged,
)
except Exception:
pass
__all__ = ["emit_plan_agent_v2_trace"]

View file

@ -13,7 +13,6 @@ from runtime.tools.base import ToolRegistry, ToolSpec
from runtime.tools.expert_registry import materialize_tools_for_expert from runtime.tools.expert_registry import materialize_tools_for_expert
from runtime.tools.mcp.adapter import materialize_mcp_tools_for_specialist from runtime.tools.mcp.adapter import materialize_mcp_tools_for_specialist
from runtime.tools.public_registry import materialize_public_tools from runtime.tools.public_registry import materialize_public_tools
from runtime.tools.skills_runtime.materialize_skill_tools import materialize_executable_skill_tools
from runtime.skills import SkillSpec, materialize_skills_from_tool_specs from runtime.skills import SkillSpec, materialize_skills_from_tool_specs
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -48,21 +47,6 @@ def _plugin_tools_enabled(store: Any | None = None) -> bool:
return True return True
def _skill_toolcall_enabled(store: Any | None) -> bool:
try:
raw_env = str(os.getenv("AIA_SKILL_TOOLCALL_ENABLED") or "").strip()
if raw_env:
return _is_truthy(raw_env)
if store is not None:
raw = str(store.get_setting("AIA_SKILL_TOOLCALL_ENABLED") or "").strip()
if raw:
return _is_truthy(raw)
except Exception:
pass
# Default off: skill uses prompt-injection path, not toolcall path.
return False
def _apply_declared_tool_policy( def _apply_declared_tool_policy(
tools: list[ToolSpec], tools: list[ToolSpec],
*, *,
@ -206,19 +190,6 @@ def materialize_tool_specs(
except Exception as exc: except Exception as exc:
logger.warning("expert tool load skipped: %s", exc) logger.warning("expert tool load skipped: %s", exc)
# collect: skill runtime
if _skill_toolcall_enabled(store):
try:
for spec in materialize_executable_skill_tools(store=store):
if not isinstance(spec, ToolSpec):
continue
if _hidden_from_model(str(spec.name or "")):
logger.info("skill runtime tool hidden from model registry: %s", str(spec.name or ""))
continue
collected.append(("skill_runtime", spec))
except Exception as exc:
logger.warning("skill runtime tool load skipped: %s", exc)
# MCP tools are role-bound and should be materialized before model injection. # MCP tools are role-bound and should be materialized before model injection.
# Fine-grained penalty/visibility is still applied by wire policy in direct_loop. # Fine-grained penalty/visibility is still applied by wire policy in direct_loop.
mcp_enabled = True mcp_enabled = True

View file

@ -3,7 +3,6 @@ from __future__ import annotations
import importlib.util import importlib.util
import inspect import inspect
import logging import logging
import os
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -20,22 +19,12 @@ _DEPRECATED_TOOL_NAMES: set[str] = {
# Deprecated internal tool from legacy src/tools chain. # Deprecated internal tool from legacy src/tools chain.
"get_weather", "get_weather",
} }
# Helpers / non-tool modules under experts/ (no *_tool factories expected).
_SKIP_EXPERT_MODULES: frozenset[str] = frozenset({"netx_tools", "netx_http"})
def netx_builtin_tools_enabled() -> bool:
"""When false, skip ``network_ops/netx_tools.py`` factories (use MCP ``mcp__netx__*`` instead)."""
return str(os.getenv("OCLAW_NETX_BUILTIN_TOOLS") or "0").strip().lower() not in {
"0",
"false",
"no",
"off",
}
def _skip_expert_module(module_path: Path) -> bool: def _skip_expert_module(module_path: Path) -> bool:
if module_path.stem == "netx_tools" and not netx_builtin_tools_enabled(): return module_path.stem in _SKIP_EXPERT_MODULES
return True
return False
def _load_module_from_path(module_path: Path, module_name: str) -> Any | None: def _load_module_from_path(module_path: Path, module_name: str) -> Any | None:

View file

@ -0,0 +1,104 @@
"""Shared netx REST client for oclaw (context inject + ume_alarm_xlsx_report).
Aligns base URL with netx-mcp: ``NETX_API_URL`` then ``OCLAW_NETX_BASE_URL``.
"""
from __future__ import annotations
import contextvars
import os
from typing import Any
import httpx
# Set by ToolExecutor so responses match session language.
NETX_TOOL_LANG: contextvars.ContextVar[str] = contextvars.ContextVar("netx_tool_lang", default="zh")
_PROTOCOL_KEY_ZH_TO_EN: dict[str, str] = {
"其他": "Other",
"时钟": "Clock",
"OTN/光": "OTN/Optical",
"电源": "Power",
}
def _netx_base_url() -> str:
return (
os.getenv("NETX_API_URL") or os.getenv("OCLAW_NETX_BASE_URL") or "http://127.0.0.1:8890"
).strip().rstrip("/")
def _netx_headers() -> dict[str, str]:
h = {"accept": "application/json"}
tok = (os.getenv("OCLAW_NETX_API_TOKEN") or os.getenv("NETX_API_TOKEN") or "").strip()
if tok:
h["authorization"] = f"Bearer {tok}"
return h
def _netx_lang_query_params() -> dict[str, str]:
lang = str(NETX_TOOL_LANG.get() or "zh").strip().lower()
if lang.startswith("en"):
return {"lang": "en"}
return {}
def _localize_netx_payload(data: dict[str, Any], *, lang: str) -> dict[str, Any]:
"""Map legacy Chinese protocol bucket labels to English for en sessions."""
if not str(lang or "").strip().lower().startswith("en"):
return data
proto = data.get("protocol_summary")
if isinstance(proto, list):
for row in proto:
if isinstance(row, dict):
k = str(row.get("key") or "")
if k in _PROTOCOL_KEY_ZH_TO_EN:
row["key"] = _PROTOCOL_KEY_ZH_TO_EN[k]
return data
def _http_post_json(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]:
base = _netx_base_url()
url = f"{base}{path}"
try:
with httpx.Client(timeout=timeout, trust_env=False) as client:
resp = client.post(url, json=body, headers=_netx_headers())
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
if isinstance(data, dict):
data = _localize_netx_payload(data, lang=str(NETX_TOOL_LANG.get() or "zh"))
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def _http_json(method: str, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
base = _netx_base_url()
url = f"{base}{path}"
merged: dict[str, Any] = dict(_netx_lang_query_params())
if params:
merged.update(params)
try:
with httpx.Client(timeout=45.0, trust_env=False) as client:
resp = client.request(method, url, params=merged or None, headers=_netx_headers())
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
if isinstance(data, dict):
data = _localize_netx_payload(data, lang=str(NETX_TOOL_LANG.get() or "zh"))
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
__all__ = [
"NETX_TOOL_LANG",
"_http_json",
"_http_post_json",
"_localize_netx_payload",
"_netx_base_url",
"_netx_headers",
]

View file

@ -1,145 +1,40 @@
"""netx ops helpers: runtime context inject + optional legacy inline HTTP tools. """netx ops helpers: runtime context inject for ops specialist.
Default: tools are exposed via stdio MCP (``pip install -e packages/netx-mcp`` then ``python -m netx_mcp``). Alarm/NE tools are exposed only via stdio MCP (``mcp__netx__*``).
Set ``OCLAW_NETX_BUILTIN_TOOLS=1`` to re-register inline ``netx_*`` expert tools. HTTP helpers live in ``netx_http`` (shared with ``ume_alarm_xlsx_report``).
Configure via environment: Configure via environment:
- ``OCLAW_NETX_BASE_URL`` (default ``http://127.0.0.1:8890``) — runtime anchor HTTP probe - ``NETX_API_URL`` or ``OCLAW_NETX_BASE_URL`` (default ``http://127.0.0.1:8890``)
- ``OCLAW_NETX_API_TOKEN`` (optional) → sent as ``Authorization: Bearer …`` if set. - ``OCLAW_NETX_API_TOKEN`` / ``NETX_API_TOKEN`` (optional Bearer)
""" """
from __future__ import annotations from __future__ import annotations
import contextvars
import os import os
import threading import threading
import time import time
from typing import Any from typing import Any
import httpx from runtime.tools.experts.network_ops.netx_http import (
NETX_TOOL_LANG,
_http_json,
_http_post_json,
_localize_netx_payload,
_netx_base_url,
_netx_headers,
)
from runtime.tools.base import ToolSpec __all__ = [
"NETX_TOOL_LANG",
# Set by ToolExecutor for netx_* tools so responses match session language. "ops_netx_system_context_extension",
NETX_TOOL_LANG: contextvars.ContextVar[str] = contextvars.ContextVar("netx_tool_lang", default="zh") "_http_json",
"_http_post_json",
_PROTOCOL_KEY_ZH_TO_EN: dict[str, str] = { "_localize_netx_payload",
"其他": "Other", "_netx_base_url",
"时钟": "Clock", "_netx_headers",
"OTN/光": "OTN/Optical",
"电源": "Power",
}
_UME_RAW_GROUP_FIELDS = [
"alarm_alarm_key",
"alarm_host_name",
"alarm_ne_id",
"alarm_object_name",
"alarm_event_type",
"alarm_native_probable_cause",
"alarm_perceived_severity",
"alarm_is_cleared",
"alarm_time_created",
"alarm_root_cause_alarm_indication",
"ne_ne_id",
"ne_ne_name",
"ne_user_label",
"ne_ip_address",
"ne_ipv6_address",
"ne_ne_type",
"ne_device_level",
"ne_host_name",
"ne_location",
"ne_hardware_version",
"ne_loopback",
"ne_consistent_state",
"ne_interface_version",
"ne_mac",
"ne_admin_status",
"ne_address_type",
"ne_connection_status",
"ne_maintain_status",
"ne_net_mask",
"ne_create_time",
"ne_creator",
"ne_vendor",
"ne_source_type",
"ne_exists",
] ]
def _netx_base_url() -> str:
return (os.getenv("OCLAW_NETX_BASE_URL") or "http://127.0.0.1:8890").strip().rstrip("/")
def _netx_headers() -> dict[str, str]:
h = {"accept": "application/json"}
tok = (os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
if tok:
h["authorization"] = f"Bearer {tok}"
return h
def _netx_lang_query_params() -> dict[str, str]:
lang = str(NETX_TOOL_LANG.get() or "zh").strip().lower()
if lang.startswith("en"):
return {"lang": "en"}
return {}
def _localize_netx_payload(data: dict[str, Any], *, lang: str) -> dict[str, Any]:
"""Map legacy Chinese protocol bucket labels to English for en sessions."""
if not str(lang or "").strip().lower().startswith("en"):
return data
proto = data.get("protocol_summary")
if isinstance(proto, list):
for row in proto:
if isinstance(row, dict):
k = str(row.get("key") or "")
if k in _PROTOCOL_KEY_ZH_TO_EN:
row["key"] = _PROTOCOL_KEY_ZH_TO_EN[k]
return data
def _http_post_json(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]:
base = _netx_base_url()
url = f"{base}{path}"
try:
with httpx.Client(timeout=timeout, trust_env=False) as client:
resp = client.post(url, json=body, headers=_netx_headers())
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
if isinstance(data, dict):
data = _localize_netx_payload(data, lang=str(NETX_TOOL_LANG.get() or "zh"))
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def _http_json(method: str, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
base = _netx_base_url()
url = f"{base}{path}"
merged: dict[str, Any] = dict(_netx_lang_query_params())
if params:
merged.update(params)
try:
# Do not inherit system proxy settings for local netx calls.
with httpx.Client(timeout=45.0, trust_env=False) as client:
resp = client.request(method, url, params=merged or None, headers=_netx_headers())
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
if isinstance(data, dict):
data = _localize_netx_payload(data, lang=str(NETX_TOOL_LANG.get() or "zh"))
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def _resolve_ume_anchor() -> dict[str, Any]: def _resolve_ume_anchor() -> dict[str, Any]:
"""Resolve current UME alarm anchor from netx sync status.""" """Resolve current UME alarm anchor from netx sync status."""
r = _http_json("GET", "/v1/ume/sync/status", params={"page": 1, "page_size": 20}) r = _http_json("GET", "/v1/ume/sync/status", params={"page": 1, "page_size": 20})
@ -259,584 +154,3 @@ def ops_netx_system_context_extension(*, lang: str = "zh") -> str:
with _OPS_NETX_SYS_CTX_LOCK: with _OPS_NETX_SYS_CTX_LOCK:
_OPS_NETX_SYS_CTX_CACHE[lk] = (store_ts, text) _OPS_NETX_SYS_CTX_CACHE[lk] = (store_ts, text)
return text return text
def netx_query_ume_alarms_tool() -> ToolSpec:
"""Paginated UME current alarms from netx."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
# Guardrail: avoid accidental full scans by endless paging.
page = max(1, int(args.get("page") or 1))
if page > 2:
page = 2
page_size = min(500, max(1, int(args.get("page_size") or 50)))
params: dict[str, Any] = {"page": page, "page_size": page_size}
if str(args.get("severity") or "").strip():
params["severity"] = str(args.get("severity")).strip()
ne_name = str(args.get("ne_name") or "").strip()
keyword = str(args.get("keyword") or "").strip()
if keyword:
params["keyword"] = keyword
elif ne_name:
params["keyword"] = ne_name
if str(args.get("ne_id") or "").strip():
params["ne_id"] = str(args.get("ne_id")).strip()
return _http_json("GET", "/v1/ume/alarms", params=params)
return ToolSpec(
name="netx_query_ume_alarms",
description=(
"读取 netx UME 当前告警明细(实时表);每条含 host_name(网元主展示键,同步时已写入告警表)。"
"支持 severity/ne_id/keyword 与分页。"
),
parameters={
"type": "object",
"properties": {
"severity": {"type": "string"},
"ne_id": {"type": "string"},
"ne_name": {"type": "string", "description": "兼容参数,会映射到 keyword"},
"keyword": {"type": "string", "description": "按网元名/标签/IP/对象名等关键字检索"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "alarms", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_aggregate_ume_alarms_tool() -> ToolSpec:
"""Aggregate UME current alarms from netx."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
_ = args
return _http_json("GET", "/v1/ume/alarms/aggregate", params=None)
return ToolSpec(
name="netx_aggregate_ume_alarms",
description="读取 netx UME 当前告警聚合(by_severity/by_ne)。",
parameters={"type": "object", "properties": {}, "required": [], "additionalProperties": False},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "alarms", "aggregate", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_run_ume_diagnostics_tool() -> ToolSpec:
"""Diagnostics summary for UME current alarms."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
_ = args
return _http_json("GET", "/v1/ume/diagnostics", params=None)
return ToolSpec(
name="netx_run_ume_diagnostics",
description="读取 netx UME 告警诊断摘要(级别分布、Top 告警码、Top 网元、协议归类)。",
parameters={"type": "object", "properties": {}, "required": [], "additionalProperties": False},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "diagnostics", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_query_ume_ne_inventory_tool() -> ToolSpec:
"""Paged UME NE inventory synced in netx (PostgreSQL-backed)."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
params: dict[str, Any] = {"page": page, "page_size": page_size}
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
return _http_json("GET", "/v1/ume/inventory/ne", params=params)
return ToolSpec(
name="netx_query_ume_ne_inventory",
description=(
"查询 netx 已同步的 UME 网元清单(读 /v1/ume/inventory/ne,与 netx Web「网元清单」同源)。"
"keyword 可选:匹配 ne_id / ne_name / user_label / ip_address / host_name(主机名)包含。"
"返回 total、page、page_size、items(含 host_name、在线状态、地址、类型等)。"
),
parameters={
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "关键字过滤(可选)"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "inventory", "ne", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_get_ume_ne_tool() -> ToolSpec:
"""Single UME NE detail by ne_id from netx."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
from urllib.parse import quote
ne_id = str(args.get("ne_id") or "").strip()
if not ne_id:
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"}
safe = quote(ne_id, safe="")
return _http_json("GET", f"/v1/ume/inventory/ne/{safe}", params=None)
return ToolSpec(
name="netx_get_ume_ne",
description=(
"按网元 UUID(ne_id)读取 netx 中单条 UME 网元详情(GET /v1/ume/inventory/ne/{ne_id})。"
"含 vendor、source_type、raw_json 等;404 时上游返回 ume_ne_not_found。"
),
parameters={
"type": "object",
"properties": {
"ne_id": {"type": "string", "description": "网元 UUID(与清单中 ne_id 一致)"},
},
"required": ["ne_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "inventory", "ne", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_query_ume_alarms_raw_tool() -> ToolSpec:
"""Power query UME current alarms with full alarm+NE fields."""
presets: dict[str, list[str]] = {
"brief": [
"alarm_alarm_key",
"alarm_host_name",
"alarm_perceived_severity",
"alarm_event_type",
"alarm_last_seen_at",
"ne_host_name",
"ne_user_label",
"ne_ne_name",
"ne_ip_address",
"ne_exists",
],
"evidence": [
"alarm_alarm_key",
"alarm_host_name",
"alarm_object_name",
"alarm_event_type",
"alarm_native_probable_cause",
"alarm_perceived_severity",
"alarm_is_cleared",
"alarm_time_created",
"alarm_last_seen_at",
"ne_host_name",
"ne_user_label",
"ne_ne_name",
"ne_ip_address",
"ne_connection_status",
"ne_exists",
],
"ne_debug": [
"alarm_alarm_key",
"alarm_ne_id",
"alarm_perceived_severity",
"alarm_last_seen_at",
"ne_user_label",
"ne_ne_name",
"ne_ip_address",
"ne_ipv6_address",
"ne_device_level",
"ne_host_name",
"ne_connection_status",
"ne_admin_status",
"ne_address_type",
"ne_maintain_status",
"ne_exists",
],
}
def handler(args: dict[str, Any]) -> dict[str, Any]:
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
params: dict[str, Any] = {"page": page, "page_size": page_size}
for k in ("severity", "is_cleared", "ne_id", "event_type", "keyword", "time_from", "time_to", "order_by", "order"):
v = str(args.get(k) or "").strip()
if v:
params[k] = v
sf = args.get("select_fields")
fields: list[str] = []
if isinstance(sf, list):
fields = [str(x).strip() for x in sf if str(x).strip()]
if not fields:
preset = str(args.get("field_preset") or "").strip().lower()
fields = list(presets.get(preset) or [])
if fields:
params["select_fields"] = ",".join(fields)
return _http_json("GET", "/v1/ume/alarms/raw", params=params)
return ToolSpec(
name="netx_query_ume_alarms_raw",
description=(
"自由查询 netx UME 当前告警原始视图,返回 alarm_* + ne_* 全字段。"
"可按 severity/is_cleared/ne_id/event_type/keyword/time_from/time_to 过滤,支持排序分页。"
"select_fields 可按需指定返回字段,降低输出体积。"
"field_preset 可快速选用默认字段集(brief/evidence/ne_debug)。"
"建议先调用 netx_list_ume_alarm_fields 查看可用字段。"
),
parameters={
"type": "object",
"properties": {
"severity": {"type": "string"},
"is_cleared": {"type": "string"},
"ne_id": {"type": "string"},
"event_type": {"type": "string"},
"keyword": {"type": "string"},
"time_from": {"type": "string", "description": "ISO8601 时间下界(按 last_seen_at)"},
"time_to": {"type": "string", "description": "ISO8601 时间上界(按 last_seen_at)"},
"order_by": {"type": "string", "enum": ["last_seen_at", "time_created", "perceived_severity", "event_type", "ne_id"]},
"order": {"type": "string", "enum": ["asc", "desc"]},
"select_fields": {
"type": "array",
"items": {"type": "string"},
"description": "可选返回字段,如 alarm_alarm_key/ne_user_label/ne_exists",
},
"field_preset": {
"type": "string",
"enum": ["brief", "evidence", "ne_debug"],
"description": "字段集预设;当未传 select_fields 时生效",
},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "alarms", "power_query", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_list_ume_alarm_fields_tool() -> ToolSpec:
"""List field names for UME raw alarm query."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
_ = args
return _http_json("GET", "/v1/ume/alarms/fields", params=None)
return ToolSpec(
name="netx_list_ume_alarm_fields",
description="列出 UME 当前告警 raw 查询可用字段(alarm_fields/ne_fields/order_by_allowed)。",
parameters={"type": "object", "properties": {}, "required": [], "additionalProperties": False},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "alarms", "schema", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_sql_query_ume_tool() -> ToolSpec:
"""Execute read-only SQL on UME tables in netx."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
sql = str(args.get("sql") or "").strip()
limit = max(1, min(2000, int(args.get("limit") or 200)))
statement_timeout_ms = max(0, min(30000, int(args.get("statement_timeout_ms") or 0)))
if not sql:
return {"ok": False, "error": "sql_required"}
base = _netx_base_url()
url = f"{base}/v1/sql/ume_query"
try:
# Do not inherit system proxy settings for local netx calls.
with httpx.Client(timeout=60.0, trust_env=False) as client:
resp = client.post(
url,
json={"sql": sql, "limit": limit, "statement_timeout_ms": statement_timeout_ms},
headers=_netx_headers(),
)
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
return ToolSpec(
name="netx_sql_query_ume",
description=(
"在 netx 上执行 UME 只读 SQL(服务端强制 SELECT-only、单语句、限制表为 "
"ume_alarms_current/ume_inventory_ne,并强制 limit)。"
"推荐默认模板:设置 statement_timeout_ms=8000,且 SQL 带时间窗过滤(last_seen_at >= now() - interval '30 minutes')。"
),
parameters={
"type": "object",
"properties": {
"sql": {"type": "string", "description": "只读 SELECT SQL;仅允许 UME 当前告警与网元表"},
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 200},
"statement_timeout_ms": {
"type": "integer",
"minimum": 0,
"maximum": 30000,
"default": 0,
"description": "可选查询超时(ms);0 表示使用数据库默认超时",
},
},
"required": ["sql"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "sql", "power_query", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_aggregate_ume_alarms_raw_tool() -> ToolSpec:
"""Dynamic aggregation on UME raw fields."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
params: dict[str, Any] = {}
for k in (
"group_by",
"group_by2",
"severity",
"is_cleared",
"ne_id",
"event_type",
"keyword",
"time_from",
"time_to",
"limit",
):
v = args.get(k)
if v is None:
continue
sv = str(v).strip()
if sv:
params[k] = sv
return _http_json("GET", "/v1/ume/alarms/aggregate/raw", params=params)
return ToolSpec(
name="netx_aggregate_ume_alarms_raw",
description=(
"按 UME raw 字段做动态聚合(group_by/group_by2),支持与 raw 同口径过滤条件。"
"group_by 需使用 alarm_*/ne_* 字段,建议先 netx_list_ume_alarm_fields。"
),
parameters={
"type": "object",
"properties": {
"group_by": {
"type": "string",
"enum": _UME_RAW_GROUP_FIELDS,
"description": "主分组字段(网元主键优先 alarm_host_name 或 ne_host_name;勿用 alarm_ne_id/ne_ne_id)",
},
"group_by2": {"type": "string", "enum": _UME_RAW_GROUP_FIELDS, "description": "可选第二分组字段"},
"severity": {"type": "string"},
"is_cleared": {"type": "string"},
"ne_id": {"type": "string"},
"event_type": {"type": "string"},
"keyword": {"type": "string"},
"time_from": {"type": "string"},
"time_to": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 200},
},
"required": ["group_by"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "ume", "alarms", "aggregate", "power_query", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_list_managed_ne_tool() -> ToolSpec:
"""List netx managed NEs (inventory for CLI login targets)."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
params: dict[str, Any] = {"page": page, "page_size": page_size}
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
if str(args.get("vendor") or "").strip():
params["vendor"] = str(args.get("vendor")).strip()
if str(args.get("connect_status") or "").strip():
params["connect_status"] = str(args.get("connect_status")).strip()
return _http_json("GET", "/v1/managed-ne", params=params)
return ToolSpec(
name="netx_list_managed_ne",
description=(
"列出 netx「网元管理」中已纳管的设备(GET /v1/managed-ne)。"
"返回 id、name、ip、vendor、device_type、connect_status 等(不含密码)。"
"必须至少提供 keyword / vendor / connect_status 之一;keyword 可匹配名称/IP/用户名/标签,且至少 2 个字符。"
"登录查配置前先用本工具定位 ne_id,再 netx_get_managed_ne / netx_exec_managed_ne。"
),
parameters={
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "名称/IP/用户名/标签包含(可选,若提供至少 2 个字符)"},
"vendor": {"type": "string", "description": "厂商过滤(可选)"},
"connect_status": {
"type": "string",
"enum": ["unknown", "testing", "pass", "fail"],
"description": "连通性状态过滤(可选)",
},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "managed_ne", "inventory", "read_only"}),
risk_level="low",
read_only=True,
)
def netx_get_managed_ne_tool() -> ToolSpec:
"""Single managed NE metadata from netx."""
def handler(args: dict[str, Any]) -> dict[str, Any]:
ne_id = str(args.get("ne_id") or "").strip()
if not ne_id:
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"}
return _http_json("GET", f"/v1/managed-ne/{ne_id}", params=None)
return ToolSpec(
name="netx_get_managed_ne",
description=(
"读取 netx 单条纳管网元详情(GET /v1/managed-ne/{ne_id})。"
"含 connect_status、connect_message、connect_detail(连通测试日志)、跳板 hop_* 配置摘要。"
),
parameters={
"type": "object",
"properties": {
"ne_id": {"type": "string", "description": "网元 UUID(列表 items[].id)"},
},
"required": ["ne_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "managed_ne", "read_only"}),
risk_level="low",
read_only=True,
)
def _netx_exec_max_commands() -> int:
"""Mirror netx NETX_NE_EXEC_MAX_COMMANDS (default 5, hard cap 50)."""
try:
raw = int(os.getenv("NETX_NE_EXEC_MAX_COMMANDS") or 5)
except ValueError:
raw = 5
return max(1, min(50, raw))
def netx_exec_managed_ne_tool() -> ToolSpec:
"""Run read-only CLI on a managed NE via netx."""
max_cmds = _netx_exec_max_commands()
def handler(args: dict[str, Any]) -> dict[str, Any]:
ne_id = str(args.get("ne_id") or "").strip()
if not ne_id:
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"}
raw_cmds = args.get("commands")
if not isinstance(raw_cmds, list) or not raw_cmds:
return {"ok": False, "error": "commands_required", "error_code": "commands_required"}
commands = [str(c).strip() for c in raw_cmds if str(c).strip()]
if not commands:
return {"ok": False, "error": "commands_required", "error_code": "commands_required"}
if len(commands) > _netx_exec_max_commands():
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
body: dict[str, Any] = {"ne_id": ne_id, "commands": commands}
rts = args.get("read_timeout_sec")
if rts is not None:
body["read_timeout_sec"] = int(rts)
out = _http_post_json("/v1/managed-ne/exec", body, timeout=300.0)
if not out.get("ok"):
return out
data = out.get("data") or {}
if isinstance(data, dict) and data.get("ok") is False:
return {"ok": False, "data": data, "error": str(data.get("error") or "exec_failed")}
return {"ok": True, "data": data}
return ToolSpec(
name="netx_exec_managed_ne",
description=(
"经 netx 登录「网元管理」中的设备并执行只读 CLI(POST /v1/managed-ne/exec)。"
"每条命令须以 show / display / ping / ping6 / traceroute / tracert / trace / trace6 开头;"
f"允许白名单管道过滤;禁止分号及改配置类命令;单次最多 {max_cmds} 条"
"(NETX_NE_EXEC_MAX_COMMANDS,硬上限 50);默认读超时 60s。"
"返回合并输出(含命令回显);失败时含 error/detail。"
"先 netx_list_managed_ne 解析 ne_id;若 connect_status 非 pass 可先 netx_get_managed_ne 看 connect_detail。"
),
parameters={
"type": "object",
"properties": {
"ne_id": {"type": "string", "description": "纳管网元 UUID"},
"commands": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": max_cmds,
"description": "只读 CLI 列表,如 show version、display interface brief",
},
"read_timeout_sec": {
"type": "integer",
"minimum": 10,
"maximum": 120,
"description": "单条命令 Netmiko 读超时(秒),默认 60",
},
},
"required": ["ne_id", "commands"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"netx", "ops", "managed_ne", "cli", "exec"}),
risk_level="medium",
read_only=False,
)
__all__: list[str] = []
_LEGACY_EXPORTS = [
"netx_query_ume_alarms_tool",
"netx_aggregate_ume_alarms_tool",
"netx_run_ume_diagnostics_tool",
"netx_query_ume_ne_inventory_tool",
"netx_get_ume_ne_tool",
"netx_query_ume_alarms_raw_tool",
"netx_aggregate_ume_alarms_raw_tool",
"netx_list_ume_alarm_fields_tool",
"netx_sql_query_ume_tool",
"netx_list_managed_ne_tool",
"netx_get_managed_ne_tool",
"netx_exec_managed_ne_tool",
]
def _apply_legacy_exports() -> None:
import os as _os
if str(_os.getenv("OCLAW_NETX_BUILTIN_TOOLS") or "0").strip().lower() in {"0", "false", "no", "off"}:
return
globals()["__all__"] = list(_LEGACY_EXPORTS)
_apply_legacy_exports()

View file

@ -1,7 +1,7 @@
"""Always-on ops tool: query UME alarms → xlsx → optional channel deliverable in one call. """Always-on ops tool: query UME alarms → xlsx → optional channel deliverable in one call.
Independent of ``OCLAW_NETX_BUILTIN_TOOLS`` (MCP remains the primary query path; this Uses shared ``netx_http`` (same REST base as MCP). MCP remains the primary interactive
collapses the WhatsApp “export Excel” loop that previously needed 3–12 tool calls). query path; this collapses the WhatsApp “export Excel” loop into one tool call.
""" """
from __future__ import annotations from __future__ import annotations
@ -9,7 +9,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from runtime.tools.base import ToolSpec from runtime.tools.base import ToolSpec
from runtime.tools.experts.network_ops import netx_tools as nt from runtime.tools.experts.network_ops import netx_http as nt
from runtime.tools.public.write_xlsx_tool import write_xlsx_tool from runtime.tools.public.write_xlsx_tool import write_xlsx_tool
_LIST_FIELDS = [ _LIST_FIELDS = [

View file

@ -1,63 +0,0 @@
from __future__ import annotations
from typing import Any
from runtime.skills import discover_workspace_skill_manifests
from runtime.tools.base import ToolSpec
def materialize_executable_skill_tools(*, store: Any | None = None) -> list[ToolSpec]:
"""Convert installed skill manifests with runtime into ToolSpec.
Tool name equals skill name so the model can call it directly.
"""
_ = store
out: list[ToolSpec] = []
for m in discover_workspace_skill_manifests():
rt = dict(m.runtime or {}) if isinstance(m.runtime, dict) else {}
if not rt:
continue
tp = str(rt.get("type") or "").strip().lower()
entry = str(rt.get("entry") or "").strip()
if not tp or not entry:
continue
schema = rt.get("schema") if isinstance(rt.get("schema"), dict) else {"type": "object", "additionalProperties": True}
name = str(m.name or "").strip()
if not name:
continue
def _handler(args: dict[str, Any], *, _manifest=m, _rt=rt) -> dict[str, Any]:
from runtime.tools.skills_runtime.subprocess_exec import run_skill_runtime_entry
source_meta = {}
if isinstance(getattr(_manifest, "metadata_oclaw", None), dict):
source_raw = _manifest.metadata_oclaw.get("source")
if isinstance(source_raw, dict):
source_meta = {
"provider": str(source_raw.get("provider") or ""),
"version": str(source_raw.get("version") or ""),
"kind": str(source_raw.get("kind") or ""),
}
return run_skill_runtime_entry(
skill_name=str(_manifest.name or ""),
skill_dir=str(_manifest.skill_dir or ""),
runtime={**dict(_rt), "__source_meta": source_meta},
args=dict(args or {}),
)
out.append(
ToolSpec(
name=name,
description=str(m.description or ""),
parameters=dict(schema),
handler=_handler,
tags=frozenset({"skill", "oclaw", "runtime"}),
risk_level="high",
timeout_s=60.0,
)
)
return out
__all__ = ["materialize_executable_skill_tools"]

View file

@ -97,6 +97,5 @@ Each turn may append a **UME alarm runtime anchor** at the end of system context
## netx managed NE (device CLI) ## netx managed NE (device CLI)
- **MCP**: `mcp__netx__listManagedNe` / `mcp__netx__getManagedNe` / `mcp__netx__execManagedNe`. - **MCP**: `mcp__netx__listManagedNe` / `mcp__netx__getManagedNe` / `mcp__netx__execManagedNe`.
- **Legacy builtin** (`OCLAW_NETX_BUILTIN_TOOLS=1`): `netx_list_managed_ne`, `netx_get_managed_ne`, `netx_exec_managed_ne`.
netx API: MCP env `NETX_API_URL` (recommended); anchor probe also uses `OCLAW_NETX_BASE_URL`. Disable anchor inject: `OCLAW_OPS_NETX_CONTEXT_INJECT=0`. netx API: MCP env `NETX_API_URL` (recommended); anchor probe also uses `OCLAW_NETX_BASE_URL`. Disable anchor inject: `OCLAW_OPS_NETX_CONTEXT_INJECT=0`.

View file

@ -88,6 +88,5 @@
## netx 纳管网元(登录设备查 CLI) ## netx 纳管网元(登录设备查 CLI)
- **MCP**:`mcp__netx__listManagedNe` / `mcp__netx__getManagedNe` / `mcp__netx__execManagedNe`。 - **MCP**:`mcp__netx__listManagedNe` / `mcp__netx__getManagedNe` / `mcp__netx__execManagedNe`。
- **旧内置**(`OCLAW_NETX_BUILTIN_TOOLS=1`):`netx_list_managed_ne`、`netx_get_managed_ne`、`netx_exec_managed_ne`。
netx 服务地址:MCP env `NETX_API_URL`(推荐);锚点探测亦用 `OCLAW_NETX_BASE_URL`。关闭自动锚点:`OCLAW_OPS_NETX_CONTEXT_INJECT=0`。 netx 服务地址:MCP env `NETX_API_URL`(推荐);锚点探测亦用 `OCLAW_NETX_BASE_URL`。关闭自动锚点:`OCLAW_OPS_NETX_CONTEXT_INJECT=0`。

View file

@ -13,7 +13,7 @@ description: 面向 ops 专家的 netx 纳管网元(网元管理)作业手
## 工具选择顺序 ## 工具选择顺序
优先 **MCP**(`mcp__netx__*`)。legacy:`netx_list_managed_ne` 等(`OCLAW_NETX_BUILTIN_TOOLS=1`)。 使用 **MCP**(`mcp__netx__*`)。旧 inline `netx_*` 工具已移除。
1. **定位设备** 1. **定位设备**
- `mcp__netx__listManagedNe`:`keyword`、`connect_status=pass`(**首选定位**) - `mcp__netx__listManagedNe`:`keyword`、`connect_status=pass`(**首选定位**)

View file

@ -27,7 +27,7 @@ description: 面向 ops 专家的 netx UME 运维作业手册。覆盖告警查
| 拓扑路径 | `findTopologyPaths` | | 拓扑路径 | `findTopologyPaths` |
| 纳管/CLI(另册) | `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | | 纳管/CLI(另册) | `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` |
旧名 `netx_query_ume_alarms` 等仅在 `OCLAW_NETX_BUILTIN_TOOLS=1` 时可用;**优先 MCP**。 使用 **MCP** 名称(`mcp__netx__*`);勿使用已移除的旧 inline `netx_*` 工具名。
## 工具选择顺序 ## 工具选择顺序

View file

@ -172,8 +172,8 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
payload = json.loads(str(task.payload or "{}")) payload = json.loads(str(task.payload or "{}"))
self.assertEqual(str(payload.get("selected_specialist") or ""), "generalist") self.assertEqual(str(payload.get("selected_specialist") or ""), "generalist")
def test_user_mode_plan_version_sets_v2_feature_flag_in_store(self) -> None: def test_user_mode_always_clears_plan_agent_v2_flag(self) -> None:
"""POST /user-mode mirrors plan_agent_version to AIA_EXPERT_PLAN_AGENT_V2_ENABLED (v2→1, v1→0).""" """Plan mode removed: POST /user-mode always keeps AIA_EXPERT_PLAN_AGENT_V2_ENABLED=0."""
token = self._login() token = self._login()
headers = { headers = {
"authorization": f"Bearer {token}", "authorization": f"Bearer {token}",
@ -193,8 +193,8 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
self.assertEqual(r2.status_code, 200) self.assertEqual(r2.status_code, 200)
body2 = r2.json() body2 = r2.json()
self.assertTrue(body2.get("ok"), body2) self.assertTrue(body2.get("ok"), body2)
self.assertTrue(body2.get("plan_agent_v2_globally_enabled"), body2) self.assertFalse(body2.get("plan_agent_v2_globally_enabled"), body2)
self.assertEqual(str(self.store.get_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip(), "1") self.assertEqual(str(self.store.get_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip(), "0")
r1 = self.client.post( r1 = self.client.post(
"/admin/api/chat/user-mode", "/admin/api/chat/user-mode",

View file

@ -1,9 +1,7 @@
"""Expert registry: netx builtin tools gated by OCLAW_NETX_BUILTIN_TOOLS.""" """Expert registry: inline netx_* tools removed; MCP only."""
from __future__ import annotations from __future__ import annotations
import os
import pytest import pytest
from runtime.tools import expert_registry from runtime.tools import expert_registry
@ -18,18 +16,10 @@ def _clear_expert_cache():
expert_registry._CACHED_SPECS_BY_EXPERT = None expert_registry._CACHED_SPECS_BY_EXPERT = None
def test_netx_tools_skipped_when_builtin_disabled(monkeypatch): def test_netx_inline_tools_never_registered(monkeypatch):
monkeypatch.delenv("OCLAW_NETX_BUILTIN_TOOLS", raising=False)
factories = expert_registry.discover_expert_tool_factories()
network_ops = factories.get("network_ops") or []
names = {f().name for f in network_ops}
assert not any(n.startswith("netx_") for n in names)
def test_netx_tools_registered_when_builtin_enabled(monkeypatch):
monkeypatch.setenv("OCLAW_NETX_BUILTIN_TOOLS", "1") monkeypatch.setenv("OCLAW_NETX_BUILTIN_TOOLS", "1")
factories = expert_registry.discover_expert_tool_factories() factories = expert_registry.discover_expert_tool_factories()
network_ops = factories.get("network_ops") or [] network_ops = factories.get("network_ops") or []
names = {f().name for f in network_ops} names = {f().name for f in network_ops}
assert "netx_query_ume_alarms" in names assert not any(n.startswith("netx_") for n in names)
assert "netx_exec_managed_ne" in names assert "ume_alarm_xlsx_report" in names

View file

@ -1,49 +0,0 @@
from __future__ import annotations
from typing import Any
import pytest
def test_netx_list_managed_ne_forwards_params(monkeypatch: pytest.MonkeyPatch) -> None:
import runtime.tools.experts.network_ops.netx_tools as nt
calls: list[tuple[str, str, dict[str, Any] | None]] = []
def fake(method: str, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
calls.append((method, path, params))
return {"ok": True, "data": {"total": 0, "items": []}}
monkeypatch.setattr(nt, "_http_json", fake)
spec = nt.netx_list_managed_ne_tool()
out = spec.handler({"keyword": "192.168", "connect_status": "pass", "page": 1, "page_size": 20})
assert out.get("ok") is True
assert calls[0] == ("GET", "/v1/managed-ne", {"page": 1, "page_size": 20, "keyword": "192.168", "connect_status": "pass"})
def test_netx_exec_managed_ne_posts_body(monkeypatch: pytest.MonkeyPatch) -> None:
import runtime.tools.experts.network_ops.netx_tools as nt
bodies: list[dict[str, Any]] = []
def fake_post(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]:
bodies.append(body)
return {"ok": True, "data": {"ok": True, "output": "R2#show version\n..."}}
monkeypatch.setattr(nt, "_http_post_json", fake_post)
spec = nt.netx_exec_managed_ne_tool()
out = spec.handler({"ne_id": "abc", "commands": ["show version"], "read_timeout_sec": 90})
assert out.get("ok") is True
assert bodies[0]["ne_id"] == "abc"
assert bodies[0]["commands"] == ["show version"]
assert bodies[0]["read_timeout_sec"] == 90
def test_netx_exec_requires_commands(monkeypatch: pytest.MonkeyPatch) -> None:
import runtime.tools.experts.network_ops.netx_tools as nt
monkeypatch.setattr(nt, "_http_post_json", lambda *a, **k: {"ok": True, "data": {}})
spec = nt.netx_exec_managed_ne_tool()
out = spec.handler({"ne_id": "abc"})
assert out.get("ok") is False
assert out.get("error_code") == "commands_required"

View file

@ -1,6 +1,6 @@
import unittest import unittest
from runtime.tools.experts.network_ops import netx_tools as nt from runtime.tools.experts.network_ops import netx_http as nt
class NetxProtocolLocalizeTests(unittest.TestCase): class NetxProtocolLocalizeTests(unittest.TestCase):

View file

@ -1,50 +0,0 @@
from __future__ import annotations
from typing import Any
import pytest
def test_netx_query_ume_ne_inventory_forwards_params(monkeypatch: pytest.MonkeyPatch) -> None:
import runtime.tools.experts.network_ops.netx_tools as nt
calls: list[tuple[str, str, dict[str, Any] | None]] = []
def fake(method: str, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
calls.append((method, path, params))
return {"ok": True, "data": {"total": 0, "page": 1, "page_size": 50, "items": []}}
monkeypatch.setattr(nt, "_http_json", fake)
spec = nt.netx_query_ume_ne_inventory_tool()
out = spec.handler({"keyword": "10.0.0", "page": 2, "page_size": 100})
assert out.get("ok") is True
assert len(calls) == 1
assert calls[0][0] == "GET"
assert calls[0][1] == "/v1/ume/inventory/ne"
assert calls[0][2] == {"page": 2, "page_size": 100, "keyword": "10.0.0"}
def test_netx_get_ume_ne_requires_id(monkeypatch: pytest.MonkeyPatch) -> None:
import runtime.tools.experts.network_ops.netx_tools as nt
monkeypatch.setattr(nt, "_http_json", lambda *a, **k: {"ok": True, "data": {}})
spec = nt.netx_get_ume_ne_tool()
out = spec.handler({})
assert out.get("ok") is False
assert out.get("error_code") == "ne_id_required"
def test_netx_get_ume_ne_quotes_path(monkeypatch: pytest.MonkeyPatch) -> None:
import runtime.tools.experts.network_ops.netx_tools as nt
paths: list[str] = []
def fake(method: str, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
paths.append(path)
return {"ok": True, "data": {"ne_id": "x"}}
monkeypatch.setattr(nt, "_http_json", fake)
spec = nt.netx_get_ume_ne_tool()
nid = "550e8400-e29b-41d4-a716-446655440000"
spec.handler({"ne_id": nid})
assert paths == [f"/v1/ume/inventory/ne/{nid}"]

View file

@ -218,11 +218,9 @@ def test_gateway_expert_mode_uses_requested_specialist() -> None:
assert chosen.get("sid") == "ops" assert chosen.get("sid") == "ops"
def test_gateway_expert_plan_execution_mode_runs_v2_with_plan_prompt(monkeypatch: pytest.MonkeyPatch) -> None: def test_gateway_expert_plan_execution_mode_ignored_without_plan_agent(monkeypatch: pytest.MonkeyPatch) -> None:
class Store: class Store:
def get_setting(self, k: str) -> str: def get_setting(self, _k: str) -> str:
if str(k or "") == "AIA_EXPERT_PLAN_AGENT_V2_ENABLED":
return "1"
return "" return ""
def set_setting(self, _k: str, _v: str) -> None: def set_setting(self, _k: str, _v: str) -> None:
@ -234,9 +232,6 @@ def test_gateway_expert_plan_execution_mode_runs_v2_with_plan_prompt(monkeypatch
def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None: def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None:
return None return None
def set_setting(self, _k: str, _v: str) -> None:
return None
class _Exec: class _Exec:
model = object() model = object()
tools = object() tools = object()
@ -247,7 +242,7 @@ def test_gateway_expert_plan_execution_mode_runs_v2_with_plan_prompt(monkeypatch
def _run_agent_core_ok(**kwargs: object) -> object: def _run_agent_core_ok(**kwargs: object) -> object:
data = kwargs.get("data") data = kwargs.get("data")
captured["system_prompt"] = str(getattr(data, "system_prompt", "") or "") captured["system_prompt"] = str(getattr(data, "system_prompt", "") or "")
return SimpleNamespace(outcome=SimpleNamespace(final_text="plan_llm_reply", turn_uuid="turn-1")) return SimpleNamespace(outcome=SimpleNamespace(final_text="agent_reply", turn_uuid="turn-1"))
monkeypatch.setattr("runtime.gateway.run_agent_core", _run_agent_core_ok) monkeypatch.setattr("runtime.gateway.run_agent_core", _run_agent_core_ok)
@ -265,130 +260,10 @@ def test_gateway_expert_plan_execution_mode_runs_v2_with_plan_prompt(monkeypatch
out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec()) out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec())
assert out.interaction_mode == "expert" assert out.interaction_mode == "expert"
assert out.dispatch_reason == "expert_direct" assert out.dispatch_reason == "expert_direct"
assert str(out.reply_text or "") == "plan_llm_reply" assert str(out.reply_text or "") == "agent_reply"
prompt_text = str(captured.get("system_prompt") or "") prompt_text = str(captured.get("system_prompt") or "")
assert ("plan 模式" in prompt_text) or ("Plan mode is active" in prompt_text) assert "plan 模式" not in prompt_text
assert ("计划工作流" in prompt_text) or ("Plan Workflow" in prompt_text) assert "Plan mode is active" not in prompt_text
def test_gateway_expert_plan_mode_filters_non_readonly_tools(monkeypatch: pytest.MonkeyPatch) -> None:
class Store:
def get_setting(self, k: str) -> str:
if str(k or "") == "AIA_EXPERT_PLAN_AGENT_V2_ENABLED":
return "1"
return ""
def set_setting(self, _k: str, _v: str) -> None:
return None
def add_trace_event(self, **_kwargs: object) -> None:
return None
def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None:
return None
def _mk_tool(name: str, read_only: bool) -> ToolSpec:
return ToolSpec(
name=name,
description=name,
parameters={"type": "object", "properties": {}, "additionalProperties": True},
handler=lambda args: {"ok": True, "args": args},
read_only=read_only,
)
class _Exec:
model = object()
system_prompt = "base-system"
tools = ToolRegistry([_mk_tool("read_file", True), _mk_tool("edit_file", False)])
captured: dict[str, object] = {}
def _run_agent_core_ok(**kwargs: object) -> object:
data = kwargs.get("data")
tools = getattr(data, "tools", None)
captured["tool_names"] = [t.name for t in tools.list()] if hasattr(tools, "list") else []
return SimpleNamespace(outcome=SimpleNamespace(final_text="ok", turn_uuid="turn-1"))
monkeypatch.setattr("runtime.gateway.run_agent_core", _run_agent_core_ok)
gw = OclawGateway(store=Store())
msg = StandardMessage(
session_id="sid-plan-tools",
tenant_id="t1",
user_id="u1",
role="user",
channel="admin_chat",
text="先给我一个执行计划",
attachments=[],
metadata={"interaction_mode": "expert", "selected_specialist": "generalist", "execution_mode": "plan"},
)
out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec())
assert str(out.reply_text or "") == "ok"
names = list(captured.get("tool_names") or [])
assert "read_file" in names
assert "edit_file" not in names
def test_gateway_expert_agent_mode_injects_plan_control_tools(monkeypatch: pytest.MonkeyPatch) -> None:
class Store:
def __init__(self) -> None:
self.kv: dict[str, str] = {}
def get_setting(self, k: str) -> str:
return str(self.kv.get(k) or "")
def set_setting(self, k: str, v: str) -> None:
self.kv[k] = str(v or "")
def add_trace_event(self, **_kwargs: object) -> None:
return None
def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None:
return None
def _mk_tool(name: str, read_only: bool) -> ToolSpec:
return ToolSpec(
name=name,
description=name,
parameters={"type": "object", "properties": {}, "additionalProperties": True},
handler=lambda args: {"ok": True, "args": args},
read_only=read_only,
)
class _Exec:
model = object()
system_prompt = "base-system"
tools = ToolRegistry([_mk_tool("read_file", True)])
captured: dict[str, object] = {}
def _run_agent_core_ok(**kwargs: object) -> object:
data = kwargs.get("data")
tools = getattr(data, "tools", None)
captured["tool_names"] = [t.name for t in tools.list()] if hasattr(tools, "list") else []
return SimpleNamespace(outcome=SimpleNamespace(final_text="ok", turn_uuid="turn-1"))
monkeypatch.setattr("runtime.gateway.run_agent_core", _run_agent_core_ok)
store = Store()
store.kv["AIA_EXPERT_PLAN_AGENT_V2_ENABLED"] = "1"
gw = OclawGateway(store=store)
msg = StandardMessage(
session_id="sid-agent-tools",
tenant_id="t1",
user_id="u1",
role="user",
channel="admin_chat",
text="直接执行",
attachments=[],
metadata={"interaction_mode": "expert", "selected_specialist": "generalist", "execution_mode": "agent"},
)
out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec())
assert str(out.reply_text or "") == "ok"
names = list(captured.get("tool_names") or [])
assert "enter_plan_mode_v2" in names
assert "exit_plan_mode_v2" in names
assert store.get_setting("AIA_PLAN_AGENT_V2_DEFAULT_SESSION_ID") == "sid-agent-tools"
def test_gateway_comprehensive_mode_manager_first_selects_specialist(monkeypatch: pytest.MonkeyPatch) -> None: def test_gateway_comprehensive_mode_manager_first_selects_specialist(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -1,142 +0,0 @@
from __future__ import annotations
from pathlib import Path
from svc.persistence.sqlite_store import SqliteStore
from runtime.gateway import OclawGatewayResult
from runtime.plan_agent_v2 import (
build_shadow_gateway_result,
evaluate_gateway_expert_turn_shadow,
legacy_gateway_result_keys,
)
from runtime.types import StandardMessage
def _msg(text: str) -> StandardMessage:
return StandardMessage(
session_id="sess-dryrun",
tenant_id="tenant-1",
user_id="user-1",
role="user",
channel="chat",
text=text,
attachments=[],
metadata={},
)
def test_gateway_shadow_stays_off_without_force_or_flag(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
out = evaluate_gateway_expert_turn_shadow(
store=store,
msg=_msg("实现一个功能"),
lang="zh",
interaction_mode="expert",
requested_specialist="generalist",
base_system_prompt="base-system",
force_flag=False,
)
assert out.used_v2 is False
assert out.decision is None
def test_gateway_shadow_force_path_matches_legacy_shape(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
out = evaluate_gateway_expert_turn_shadow(
store=store,
msg=_msg("请先给计划"),
lang="zh",
interaction_mode="expert",
requested_specialist="generalist",
base_system_prompt="base-system",
force_flag=True,
)
assert out.used_v2 is True
assert out.decision is not None
shadow_row = build_shadow_gateway_result(
decision=out.decision,
run_id="run-1",
trace_id="trace-1",
elapsed_ms=9,
requested_specialist="generalist",
)
assert set(shadow_row.keys()) == legacy_gateway_result_keys()
baseline = OclawGatewayResult(run_id="run-1", reply_text="", trace_id="trace-1", elapsed_ms=9)
assert shadow_row["mode"] == baseline.mode
assert shadow_row["task_id"] == baseline.task_id
assert shadow_row["dynamic_agent_used"] == baseline.dynamic_agent_used
assert shadow_row["relay_pointer_count"] == baseline.relay_pointer_count
def test_gateway_shadow_confirm_path_builds_compatible_result(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
# Enter plan
first = evaluate_gateway_expert_turn_shadow(
store=store,
msg=_msg("我要改造一下"),
lang="zh",
interaction_mode="expert",
requested_specialist="generalist",
base_system_prompt="base-system",
force_flag=True,
)
assert first.used_v2 is True
assert first.decision is not None
assert first.decision.action == "run_agent"
assert "base-system" in str(first.decision.system_prompt_override or "")
# Confirm plan
second = evaluate_gateway_expert_turn_shadow(
store=store,
msg=_msg("确认"),
lang="zh",
interaction_mode="expert",
requested_specialist="generalist",
base_system_prompt="base-system",
force_flag=True,
)
assert second.used_v2 is True
assert second.decision is not None
assert second.decision.action == "stay_plan"
assert "切换到 agent 模式" in str(second.decision.reply_text or "")
row = build_shadow_gateway_result(
decision=second.decision,
run_id="run-2",
trace_id="trace-2",
elapsed_ms=12,
requested_specialist="generalist",
)
assert row["interaction_mode"] == "expert"
assert str(row["dispatch_reason"]).startswith("plan_agent_v2:")
def test_gateway_shadow_skips_v2_when_metadata_plan_agent_version_v1(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
out = evaluate_gateway_expert_turn_shadow(
store=store,
msg=StandardMessage(
session_id="sess-dryrun",
tenant_id="tenant-1",
user_id="user-1",
role="user",
channel="chat",
text="请先给计划",
attachments=[],
metadata={"plan_agent_version": "v1"},
),
lang="zh",
interaction_mode="expert",
requested_specialist="generalist",
base_system_prompt="base-system",
force_flag=True,
)
assert out.used_v2 is False
assert out.decision is None

View file

@ -1,450 +0,0 @@
from __future__ import annotations
from pathlib import Path
from svc.persistence.sqlite_store import SqliteStore
from runtime.plan_agent_v2.adapter import evaluate_for_expert_mode
from runtime.plan_agent_v2.compat import build_shadow_gateway_result, legacy_gateway_result_keys
from runtime.plan_agent_v2.gateway_adapter import evaluate_gateway_expert_turn_shadow
from runtime.plan_agent_v2.manager import PlanModeManagerV2
from runtime.plan_agent_v2.models import PLAN_MODE_PLAN, PlanAgentStateV2
from runtime.plan_agent_v2.prompt_injector import build_plan_mode_prefix
from runtime.plan_agent_v2.state_store import PlanAgentStateStoreV2
from runtime.plan_agent_v2.switch import should_route_to_v2, v2_feature_enabled
from runtime.plan_agent_v2.tool_specs import materialize_plan_mode_v2_tools
from runtime.plan_agent_v2.tool_policy import filter_tools_for_mode
from runtime.plan_agent_v2.trace import emit_plan_agent_v2_trace
from runtime.plan_agent_v2 import should_route_to_v2 as should_route_to_v2_pkg
from runtime.gateway import OclawGatewayResult
from runtime.tools.base import ToolRegistry, ToolSpec
from runtime.types import StandardMessage
def _dummy_tool(name: str, read_only: bool) -> ToolSpec:
def _handler(args):
return {"ok": True, "echo": args}
return ToolSpec(
name=name,
description=name,
parameters={"type": "object", "properties": {}, "additionalProperties": True},
handler=_handler,
read_only=read_only,
)
def test_state_store_roundtrip(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
ss = PlanAgentStateStoreV2(store)
st = ss.load(session_id="s1")
assert st.mode == "normal"
saved = ss.save(session_id="s1", state=st)
loaded = ss.load(session_id="s1")
assert loaded.mode == saved.mode
def test_manager_enter_and_confirm(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
plan_root = tmp_path / "plans"
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(plan_root))
mgr = PlanModeManagerV2(store=store)
st1 = mgr.enter(session_id="sess-1", owner_specialist="generalist")
assert st1.mode == PLAN_MODE_PLAN
assert st1.plan_path
assert Path(st1.plan_path).exists()
st2 = mgr.confirm(session_id="sess-1")
assert st2.mode == "normal"
assert st2.plan_confirmed is True
assert "## Goal" in str(st2.plan_content or "")
def test_tool_policy_filters_non_readonly_in_plan_mode() -> None:
reg = ToolRegistry([_dummy_tool("read_a", True), _dummy_tool("write_a", False)])
out = filter_tools_for_mode(registry=reg, mode="plan")
names = {t.name for t in out}
assert "read_a" in names
assert "write_a" not in names
def test_tool_policy_keeps_plan_mode_control_tools() -> None:
reg = ToolRegistry([_dummy_tool("exit_plan_mode_v2", False), _dummy_tool("write_a", False)])
out = filter_tools_for_mode(registry=reg, mode="plan")
names = {t.name for t in out}
assert "exit_plan_mode_v2" in names
assert "write_a" not in names
def test_adapter_agent_mode_repeated_user_injects_stall_guard(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
sid = store.create_session("stall-test").id
duplicate_line = "please handle this request"
store.add_message(session_id=sid, role="user", content=duplicate_line, event_type="user_text")
store.add_message(session_id=sid, role="assistant", content="I will analyze first…", event_type="assistant_text")
dec = evaluate_for_expert_mode(
store=store,
session_id=sid,
lang="en",
requested_specialist="generalist",
user_text=duplicate_line,
execution_mode="agent",
base_system_prompt="base",
)
assert dec.action == "run_agent"
assert "Conversation stall guard" in str(dec.system_prompt_override or "")
assert "base" in str(dec.system_prompt_override or "")
def test_adapter_plan_flow(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
d1 = evaluate_for_expert_mode(
store=store,
session_id="s1",
lang="zh",
requested_specialist="generalist",
user_text="帮我做一个功能",
execution_mode="plan",
base_system_prompt="base",
)
assert d1.action == "run_agent"
assert isinstance(d1.plan_state, dict)
assert str(d1.plan_state.get("mode") or "") == "plan"
assert "base" in str(d1.system_prompt_override or "")
d2 = evaluate_for_expert_mode(
store=store,
session_id="s1",
lang="zh",
requested_specialist="generalist",
user_text="确认",
execution_mode="agent",
base_system_prompt="base",
)
assert d2.action == "run_agent"
assert "base" in str(d2.system_prompt_override or "")
def test_adapter_confirm_blocked_until_agent_mode(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
_ = evaluate_for_expert_mode(
store=store,
session_id="s2",
lang="zh",
requested_specialist="generalist",
user_text="先给计划",
execution_mode="plan",
base_system_prompt="base",
)
d2 = evaluate_for_expert_mode(
store=store,
session_id="s2",
lang="zh",
requested_specialist="generalist",
user_text="确认",
execution_mode="plan",
base_system_prompt="base",
)
assert d2.action == "stay_plan"
assert "切换到 agent 模式" in str(d2.reply_text or "")
def test_adapter_confirm_strategy_auto_allows_confirm_in_plan_mode(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
store.set_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY", "auto")
_ = evaluate_for_expert_mode(
store=store,
session_id="s-auto",
lang="zh",
requested_specialist="generalist",
user_text="先给计划",
execution_mode="plan",
base_system_prompt="base",
)
d2 = evaluate_for_expert_mode(
store=store,
session_id="s-auto",
lang="zh",
requested_specialist="generalist",
user_text="确认",
execution_mode="plan",
base_system_prompt="base",
)
assert d2.action == "run_agent"
assert "已确认计划" in str(d2.reply_text or "")
def test_adapter_confirm_strategy_off_allows_confirm_in_plan_mode(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
store.set_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY", "off")
_ = evaluate_for_expert_mode(
store=store,
session_id="s-off",
lang="zh",
requested_specialist="generalist",
user_text="先给计划",
execution_mode="plan",
base_system_prompt="base",
)
d2 = evaluate_for_expert_mode(
store=store,
session_id="s-off",
lang="zh",
requested_specialist="generalist",
user_text="确认",
execution_mode="plan",
base_system_prompt="base",
)
assert d2.action == "run_agent"
assert "已确认计划" in str(d2.reply_text or "")
def test_adapter_plan_loop_guard_blocks_repeated_input(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
common = dict(
store=store,
session_id="s-loop",
lang="zh",
requested_specialist="generalist",
execution_mode="plan",
base_system_prompt="base",
)
_ = evaluate_for_expert_mode(user_text="继续", **common)
d2 = evaluate_for_expert_mode(user_text="继续", **common)
assert d2.action == "stay_plan"
assert "低信息续写" in str(d2.reply_text or "")
def test_adapter_plan_low_signal_continue_short_circuit(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
_ = evaluate_for_expert_mode(
store=store,
session_id="s-low",
lang="zh",
requested_specialist="generalist",
user_text="先给我一版计划",
execution_mode="plan",
base_system_prompt="base",
)
d2 = evaluate_for_expert_mode(
store=store,
session_id="s-low",
lang="zh",
requested_specialist="generalist",
user_text="继续",
execution_mode="plan",
base_system_prompt="base",
)
assert d2.action == "stay_plan"
assert "低信息续写" in str(d2.reply_text or "")
def test_prompt_prefix_uses_ccmini_like_phases(tmp_path: Path) -> None:
plan_file = tmp_path / "plan.md"
plan_file.write_text("# Plan\n", encoding="utf-8")
st = PlanAgentStateV2(mode="plan", plan_path=str(plan_file))
zh = build_plan_mode_prefix(state=st, lang="zh")
en = build_plan_mode_prefix(state=st, lang="en")
assert "阶段1:理解问题" in zh
assert "计划工作流" in zh
assert "计划模式工具" in zh
assert "执行纪律" in zh
assert "enter_plan_mode_v2" in zh
assert "Phase 1: Initial Understanding" in en
assert "Plan Workflow" in en
assert "Plan mode tools" in en
assert "Execution discipline" in en
assert "enter_plan_mode_v2" in en
def test_shadow_tool_specs_work(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
tools = materialize_plan_mode_v2_tools(store=store)
assert len(tools) == 2
enter = next(t for t in tools if t.name == "enter_plan_mode_v2")
exit_tool = next(t for t in tools if t.name == "exit_plan_mode_v2")
out1 = enter.handler({"session_id": "s-1", "owner_specialist": "generalist"})
assert out1.get("ok") is True
out2 = exit_tool.handler({"session_id": "s-1", "confirm": True})
assert out2.get("ok") is True
assert bool((out2.get("state") or {}).get("plan_confirmed")) is True
def test_shadow_plan_tools_emit_trace_when_trace_id(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
tools = materialize_plan_mode_v2_tools(store=store)
enter = next(t for t in tools if t.name == "enter_plan_mode_v2")
exit_tool = next(t for t in tools if t.name == "exit_plan_mode_v2")
enter.handler(
{
"session_id": "s-tr",
"owner_specialist": "generalist",
"trace_id": "tid-1",
"parent_span_id": "ps-9",
}
)
exit_tool.handler({"session_id": "s-tr", "confirm": False, "trace_id": "tid-1"})
rows = store.list_trace_events_for_trace(session_id="s-tr", trace_id="tid-1")
types = [r.get("event_type") for r in rows]
assert "plan_mode_tool_enter" in types
assert "plan_mode_tool_exit" in types
enter_ev = next(r for r in rows if r.get("event_type") == "plan_mode_tool_enter")
exit_ev = next(r for r in rows if r.get("event_type") == "plan_mode_tool_exit")
assert (enter_ev.get("payload") or {}).get("tool") == "enter_plan_mode_v2"
assert (exit_ev.get("payload") or {}).get("tool") == "exit_plan_mode_v2"
assert (exit_ev.get("payload") or {}).get("confirmed") is False
def test_shadow_tool_specs_can_use_default_session_key(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
store.set_setting("AIA_PLAN_AGENT_V2_DEFAULT_SESSION_ID", "s-default")
tools = materialize_plan_mode_v2_tools(store=store)
enter = next(t for t in tools if t.name == "enter_plan_mode_v2")
out = enter.handler({})
assert out.get("ok") is True
st = out.get("state") or {}
assert str(st.get("mode") or "") == "plan"
def test_switch_default_off_and_opt_in(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
assert v2_feature_enabled(store=store) is False
assert should_route_to_v2(store=store, interaction_mode="expert") is False
assert should_route_to_v2(store=store, interaction_mode="expert", force_flag=True) is True
store.set_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED", "1")
assert v2_feature_enabled(store=store) is True
assert should_route_to_v2(store=store, interaction_mode="expert") is True
assert should_route_to_v2(store=store, interaction_mode="comprehensive") is False
def test_gateway_adapter_shadow_force_flag(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
msg = StandardMessage(
session_id="s1",
tenant_id="t1",
user_id="u1",
role="user",
channel="chat",
text="帮我实现一个功能",
attachments=[],
metadata={},
)
out = evaluate_gateway_expert_turn_shadow(
store=store,
msg=msg,
lang="zh",
interaction_mode="expert",
requested_specialist="generalist",
base_system_prompt="base",
force_flag=True,
)
assert out.used_v2 is True
assert out.decision is not None
assert out.decision.action == "run_agent"
def test_shadow_compat_result_shape(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
dec = evaluate_for_expert_mode(
store=store,
session_id="s1",
lang="zh",
requested_specialist="generalist",
user_text="我要改造",
base_system_prompt="base",
)
row = build_shadow_gateway_result(
decision=dec,
run_id="r1",
trace_id="t1",
elapsed_ms=12,
requested_specialist="generalist",
)
assert set(row.keys()) == legacy_gateway_result_keys()
def test_trace_helper_no_crash() -> None:
events = []
class _S:
def add_trace_event(self, **kwargs):
events.append(kwargs)
emit_plan_agent_v2_trace(
store=_S(),
session_id="s1",
trace_id="t1",
parent_span_id=None,
event_type="plan_mode_entered",
payload={"x": 1},
)
assert len(events) == 1
assert events[0].get("event_type") == "plan_mode_entered"
def test_shadow_gateway_result_defaults_align_legacy_baseline(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "ops.sqlite"))
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
dec = evaluate_for_expert_mode(
store=store,
session_id="s1",
lang="zh",
requested_specialist="generalist",
user_text="继续",
base_system_prompt="base",
)
row = build_shadow_gateway_result(
decision=dec,
run_id="r1",
trace_id="t1",
elapsed_ms=1,
requested_specialist="generalist",
)
# Baseline invariant fields expected by legacy result dataclass.
baseline = OclawGatewayResult(run_id="r1", reply_text="", trace_id="t1", elapsed_ms=1)
assert row["mode"] == baseline.mode
assert row["task_id"] == baseline.task_id
assert row["dynamic_agent_used"] == baseline.dynamic_agent_used
assert row["dynamic_agent_name"] == baseline.dynamic_agent_name
assert row["relay_pointer_count"] == baseline.relay_pointer_count
assert row["relay_envelope_present"] == baseline.relay_envelope_present
assert row["relay_envelope_pointer_count"] == baseline.relay_envelope_pointer_count
assert row["relay_ttl_turn_count"] == baseline.relay_ttl_turn_count
assert row["relay_ttl_session_count"] == baseline.relay_ttl_session_count
assert row["relay_ttl_keep_count"] == baseline.relay_ttl_keep_count
# Package export should be wired and callable.
assert callable(should_route_to_v2_pkg)
def test_package_exports_stable_symbols() -> None:
import runtime.plan_agent_v2 as p
required = [
"PlanAgentStateV2",
"PlanAgentStateStoreV2",
"PlanModeManagerV2",
"PlanAgentV2Decision",
"GatewayPlanV2AdapterOutput",
"evaluate_for_expert_mode",
"evaluate_gateway_expert_turn_shadow",
"should_route_to_v2",
"v2_feature_enabled",
"emit_plan_agent_v2_trace",
"build_shadow_gateway_result",
"legacy_gateway_result_keys",
]
for name in required:
assert hasattr(p, name), name

View file

@ -6,7 +6,6 @@ import unittest
from pathlib import Path from pathlib import Path
from runtime.skills import discover_workspace_skill_manifests from runtime.skills import discover_workspace_skill_manifests
from runtime.tools.skills_runtime.materialize_skill_tools import materialize_executable_skill_tools
class SkillRuntimeMetadataAndToolsTests(unittest.TestCase): class SkillRuntimeMetadataAndToolsTests(unittest.TestCase):
@ -52,28 +51,6 @@ class SkillRuntimeMetadataAndToolsTests(unittest.TestCase):
assert m.runtime.get("entry") == "scripts/run.py" assert m.runtime.get("entry") == "scripts/run.py"
assert isinstance(m.runtime.get("schema"), dict) assert isinstance(m.runtime.get("schema"), dict)
def test_materialize_creates_tool_for_runtime_skill(self) -> None:
d = self.skills_root / "demo_runtime_tool"
d.mkdir(parents=True, exist_ok=True)
(d / "scripts").mkdir(parents=True, exist_ok=True)
(d / "scripts" / "run.py").write_text("print('{\"ok\": true}')\n", encoding="utf-8")
(d / "SKILL.md").write_text(
"---\n"
"name: demo_runtime_tool\n"
"description: demo\n"
"metadata:\n"
" oclaw:\n"
" runtime:\n"
" type: python\n"
" entry: scripts/run.py\n"
"---\n",
encoding="utf-8",
)
tools = materialize_executable_skill_tools(store=None)
names = {t.name for t in tools}
assert "demo_runtime_tool" in names
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -126,10 +126,9 @@ def test_filter_preset_and_row_helpers() -> None:
assert meta["total"] == 2 assert meta["total"] == 2
def test_ume_alarm_xlsx_report_registered_when_builtin_disabled(monkeypatch) -> None: def test_ume_alarm_xlsx_report_registered_without_inline_netx(monkeypatch) -> None:
from runtime.tools import expert_registry from runtime.tools import expert_registry
monkeypatch.delenv("OCLAW_NETX_BUILTIN_TOOLS", raising=False)
expert_registry.clear_expert_tool_cache() expert_registry.clear_expert_tool_cache()
factories = expert_registry.discover_expert_tool_factories() factories = expert_registry.discover_expert_tool_factories()
names = {f().name for f in (factories.get("network_ops") or [])} names = {f().name for f in (factories.get("network_ops") or [])}