From 628a9dffd2d92ec8930bbe8ee4d27c9e4eeccef4 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 11 Aug 2026 01:02:29 +0800 Subject: [PATCH] Subtract dead agent husks and unify the gateway executor path. Remove SpecialistAgentRunner, plan_agent_v2 re-export shims, empty runtime husks, and disconnected Admin knobs. Fold ops into build_gateway_executor, align AIA_ENABLE_PLUGIN_TOOLS with catalog, and allow ops on the default MCP specialist list. Co-authored-by: Cursor --- docs/ARCHITECTURE_OVERVIEW.md | 25 +- docs/ENVIRONMENT_VARIABLES.md | 61 +-- docs/IMAGE_SPECIALIST_LANE.md | 6 +- docs/VIDEO_SPECIALIST_LANE.md | 4 +- docs/plan_agent_v2_gateway_cutover_patch.md | 36 -- interfaces/admin/routes.py | 33 +- interfaces/admin/static/app.js | 23 +- runtime/agents/factory.py | 24 +- runtime/agents/network_ops_agent.py | 16 +- runtime/agents/specialist_agent.py | 478 ------------------ runtime/agents/specialists.py | 10 + runtime/app/__init__.py | 2 - runtime/orchestration/tool_adapter.py | 34 -- runtime/plan_agent_v2_adapter.py | 2 - runtime/plan_agent_v2_compat.py | 2 - runtime/plan_agent_v2_gateway_adapter.py | 2 - runtime/plan_agent_v2_gateway_cutover.py | 86 ---- runtime/plan_agent_v2_manager.py | 2 - runtime/plan_agent_v2_models.py | 2 - runtime/plan_agent_v2_prompt_injector.py | 2 - runtime/plan_agent_v2_state_store.py | 2 - runtime/plan_agent_v2_switch.py | 2 - runtime/plan_agent_v2_tool_policy.py | 2 - runtime/plan_agent_v2_tool_specs.py | 2 - runtime/plan_agent_v2_trace.py | 2 - runtime/tools/__init__.py | 2 - runtime/tools/catalog.py | 27 +- runtime/tools/mcp/adapter.py | 2 +- svc/llm/image_legacy_client.py | 3 +- tests/test_memory_wiki_plugin.py | 1 + ...est_plan_agent_v2_gateway_cutover_draft.py | 80 --- tests/test_plan_agent_v2_shadow.py | 22 +- 32 files changed, 127 insertions(+), 870 deletions(-) delete mode 100644 docs/plan_agent_v2_gateway_cutover_patch.md delete mode 100644 runtime/agents/specialist_agent.py delete mode 100644 runtime/app/__init__.py delete mode 100644 runtime/orchestration/tool_adapter.py delete mode 100644 runtime/plan_agent_v2_adapter.py delete mode 100644 runtime/plan_agent_v2_compat.py delete mode 100644 runtime/plan_agent_v2_gateway_adapter.py delete mode 100644 runtime/plan_agent_v2_gateway_cutover.py delete mode 100644 runtime/plan_agent_v2_manager.py delete mode 100644 runtime/plan_agent_v2_models.py delete mode 100644 runtime/plan_agent_v2_prompt_injector.py delete mode 100644 runtime/plan_agent_v2_state_store.py delete mode 100644 runtime/plan_agent_v2_switch.py delete mode 100644 runtime/plan_agent_v2_tool_policy.py delete mode 100644 runtime/plan_agent_v2_tool_specs.py delete mode 100644 runtime/plan_agent_v2_trace.py delete mode 100644 tests/test_plan_agent_v2_gateway_cutover_draft.py diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 2b6b0228..1d814f4d 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -6,27 +6,38 @@ - `runtime/`:运行时主域(agent、gateway 执行流、skills/hooks/extensions、operations)。 - `interfaces/`:对外接口层(HTTP、WS、Admin、Gateway method bridge)。 -- `platform/`:通用平台能力(配置、存储、LLM transport、文件层)。 -- `runtime/workspaces/_system/`:内置系统提示词 Markdown 树(原顶层 `prompts/`,与按角色分区的 `workspaces//` 并列);`runtime/prompt_templates/` 为加载与 frontmatter 解析。 +- `svc/`:通用平台能力(配置、存储、LLM transport、文件层)。 +- `runtime/workspaces/_system/`:内置系统提示词 Markdown 树(与按角色分区的 `workspaces//` 并列);`runtime/prompt_templates/` 为加载与 frontmatter 解析。 - `tests/`:测试代码(按你的要求保持顶层)。 - `docs/`:设计文档、运维说明、迁移记录。 +## 现行 Agent 脊梁 + +```text +interfaces (HTTP/WS/Admin/channel) + → OclawGateway.handle_turn + → run_agent_core → run_direct_loop + → SkillExecutor / ToolExecutor → ToolRegistry (MCP/public/expert/plugin) + → SqliteStore → outbound +``` + +工厂入口:`runtime/agents/factory.py::build_gateway_executor`(按 specialist 构建 `Agent`)。 + ## runtime 内部建议边界 - `runtime/core/`:可复用执行内核(如 agent 执行管线聚合入口)。 -- `runtime/app/`:应用侧入口组织(面向外部流程的 runtime 编排)。 -- `runtime/agents|chat|orchestration|workers`:领域能力模块。 +- `runtime/application/gateway/`:渠道入站用例(WhatsApp/Weixin/WeCom)。 +- `runtime/agents|chat|orchestration`:领域能力模块。 - `skills/`、`runtime/hooks`、`runtime/extensions`:可扩展能力载体(技能包在仓库根 `skills/`)。 - `runtime/operations/scripts`:运维脚本与生成器。 ## 路径规范 -- 运行时资源路径统一通过 `platform/config/runtime_paths.py` 获取。 +- 运行时资源路径统一通过 `svc/config/runtime_paths.py` 获取。 - 禁止新增硬编码目录字符串(如直接拼 `oclaw/runtime/...`)。 ## 依赖方向(原则) -- `interfaces -> runtime -> platform`(尽量单向)。 +- `interfaces -> runtime -> svc`(尽量单向)。 - `runtime` 不反向依赖 `interfaces`(必要时通过协议/回调解耦)。 - `docs/tests` 可依赖任意层,但不应反向影响运行时代码设计。 - diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 4b42a84d..78b4debb 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -18,18 +18,12 @@ - `AIA_ASSISTANT_MODE` - 默认:空(代码内决定默认模式) - 作用:助手模式选择 - - 生效:`oclaw/platform/llm/chat_models.py`, `oclaw/runtime/agents/factory.py` - -- `AIA_MANAGER_DECISION_MODE` - - 默认:空 - - 作用:**Legacy(已断开)**:旧 manager 决策模式(如 `rule`) - - 说明:oclaw runtime 默认不再走 `CompositeOpsAgent` 的 manager 决策;该变量仅保留以便后续接回 legacy - - 生效:`oclaw/runtime/agents/manager_agent.py`(仅 legacy 链路) + - 生效:`svc/llm/chat_models.py`, `runtime/agents/factory.py` - `AIA_TURN_MAX_TOOL_WORKERS` - 默认:`8` - 作用:单轮工具并发上限 - - 生效:`oclaw/oclaw_runtime/gateway.py`, `oclaw/oclaw_runtime/direct_loop.py` + - 生效:`runtime/gateway.py`, `runtime/direct_loop.py` - `AIA_TURN_MAX_TOOL_ROUNDS` - 默认:`100` @@ -95,28 +89,7 @@ - 默认:`0` - 作用:控制 Admin 保存 `AIA_OCLAW_RETRYABLE_ERROR_CODES` 时的未知 code 行为 - 说明:`0`=过滤并告警;`1`=直接拒绝保存(HTTP 400) - - 生效:`oclaw/interfaces/admin/routes.py`, `oclaw/interfaces/admin/static/app.js` - -- `AIA_TOOL_ENFORCED_RETRY_MODE` - - 默认:`first_round_only` - - 作用:**Legacy(已断开)**:工具必需场景下的强制重试策略 - - 生效:仅 legacy 链路(保留占位,暂不影响 oclaw) - -- `AIA_TOOL_LOOP_STATE_MACHINE` - - 默认:`1` - - 作用:**Legacy(已断开)**:工具循环状态机开关 - - 生效:仅 legacy 链路(保留占位,暂不影响 oclaw) - -- `AIA_TOOL_SIGNATURE_BUDGET` - - 默认:`2` - - 作用:**Legacy(已断开)**:同签名工具调用预算 - - 生效:仅 legacy 链路(保留占位,暂不影响 oclaw) - -- `AIA_OCLAW_ALLOW_LEGACY_FALLBACK` - - 默认:`0`(关闭) - - 作用:oclaw 执行失败时,是否允许回退到 legacy `executor.run_turn(...)` - - 说明:默认 fail-closed(不回退),避免无意中触发旧 manager/runner - - 生效:`oclaw/oclaw_runtime/gateway.py`, `oclaw/runtime/agents/specialist_agent.py` + - 生效:`interfaces/admin/routes.py`, `interfaces/admin/static/app.js` ## LLM 传输与 replay(OpenAI 兼容) @@ -162,26 +135,26 @@ ## 工具执行与安全 -- `AIA_DISABLE_TOOL_CONFIRM` - - 默认:`0` - - 作用:**Legacy(已断开)**:是否禁用高风险工具确认 - - 说明:oclaw 工具执行已移除执行时确认策略;该变量保留以便后续接回 legacy - - 生效:仅 legacy 链路(保留占位) - - `AIA_ENABLE_MCP_TOOLS` - 默认:`1` - 作用:启用 MCP 工具 - - 生效:`oclaw/tools/catalog.py` + - 生效:`runtime/tools/catalog.py` - `AIA_ENABLE_PLUGIN_TOOLS` - - 默认:`0` - - 作用:启用插件工具 - - 生效:`oclaw/tools/catalog.py` + - 默认:`1`(未设置时开启;Admin 可关) + - 作用:启用 Python 扩展插件工具 + - 说明:与历史别名 `AIA_PLUGIN_TOOLS_ENABLED` 等价;Admin DB 设置优先 + - 生效:`runtime/tools/catalog.py`, `interfaces/admin/routes.py` + +- `AIA_PLUGIN_TOOLS_ENABLED` + - 默认:同 `AIA_ENABLE_PLUGIN_TOOLS` + - 作用:**别名**(兼容旧 env);新代码请用 `AIA_ENABLE_PLUGIN_TOOLS` + - 生效:`runtime/tools/catalog.py` - `AIA_ENABLE_RUN_COMMAND` - 默认:`0` - 作用:允许高风险 `run_command` 工具 - - 生效:`oclaw/tools/catalog.py`, `oclaw/tools/experts/workspace/shell_tools.py` + - 生效:`runtime/tools/catalog.py`, `runtime/tools/experts/workspace/shell_tools.py` - `AIA_TOOL_LLM_MESSAGE_MAX_CHARS` - 默认:`0`(不限制) @@ -247,9 +220,9 @@ ## MCP 与工具线侧 - `AIA_MCP_SPECIALISTS` - - 默认:`generalist` - - 作用:允许使用 MCP 的 specialist 列表 - - 生效:`oclaw/tools/mcp/adapter.py` + - 默认:`generalist,manager,ops` + - 作用:未配置 `mcp_specialist_server_binding` 时,允许使用 MCP 的 specialist 列表 + - 生效:`runtime/tools/mcp/adapter.py` - `AIA_MCP_ENV_ALLOWLIST` - 默认:未设置时使用内置补充名单(仅用于**未**出现在 `mcp_local.env` 里、但要从宿主环境透传的变量名,见 `mcp_env._DEFAULT_ALLOWLIST`) diff --git a/docs/IMAGE_SPECIALIST_LANE.md b/docs/IMAGE_SPECIALIST_LANE.md index efc84927..863e90bf 100644 --- a/docs/IMAGE_SPECIALIST_LANE.md +++ b/docs/IMAGE_SPECIALIST_LANE.md @@ -26,7 +26,7 @@ 4. **有图**:调用 **`send_legacy_image_messages`**(`/chat/completions` 兼容路径,非 Responses API)。 5. **输出解析**:**`legacy_image_turn_bundle`** - 文本可为空;若有生成图则 **`materialize_legacy_response_output_attachments`** 写入本地 blob,产出 **`image_ref`**(或退化为 **`image_url`**)。 -6. **占位文案**:成功但只有图、无模型正文时,由 **`legacy_image_assistant_body_with_placeholder`**(`image_legacy_client`)写入中英文占位句;网关与 `specialist_agent` 共用,避免两处字符串分叉。 +6. **占位文案**:成功但只有图、无模型正文时,由 **`legacy_image_assistant_body_with_placeholder`**(`image_legacy_client`)写入中英文占位句;与 `direct_loop` early-exit 共用。 7. **持久化**:**`store.add_message(role=assistant, event_type=assistant_text, attachments=…)`** —— 附件以 JSON 形式挂在助手消息上,而非 tool 行。 --- @@ -36,7 +36,7 @@ | 场景 | 模块 | 说明 | |------|------|------| | Chat 网关 + 图片专家 | `direct_loop._maybe_image_specialist_legacy_gateway_turn` | 上文主路径;Early Return,不进主 LLM 循环。 | -| Specialist 编排临时会话 | `runtime/agents/specialist_agent.py` | 同样调用 `send_legacy_image_messages` / `legacy_image_turn_bundle`,逻辑对齐但不经过同一 Early Return。 | +| Gateway early-exit | `runtime/direct_loop.py` | Image specialist 走 legacy HTTP lane,不经过 Responses 协议。 | 两处共用 **`platform/llm/image_legacy_client.py`**,避免分叉实现。 @@ -78,7 +78,7 @@ ## 8. 变更原则(避免波及其它链路) -1. **默认改动范围**:`image_legacy_client.py`、`image_http_common.py`、`direct_loop` 中 **`_maybe_image_specialist_*` 函数体**、`specialist_agent` 中与 legacy image 调用相邻代码、`turn_runner` / `chat.js` 中与 **assistant + attachments** 展示相邻逻辑。 +1. **默认改动范围**:`image_legacy_client.py`、`image_http_common.py`、`direct_loop` 中 **`_maybe_image_specialist_*` 函数体**、`turn_runner` / `chat.js` 中与 **assistant + attachments** 展示相邻逻辑。 2. **勿在** `openai_responses.py` **中为图片专家单独分支**,除非明确要做「非 legacy」通用能力。 3. 新增开关优先 **`AIA_IMAGE_*` / `AIA_IMAGE_SPECIALIST_*`**,勿复用 OCR 变量。 4. UI 层附件渲染:**assistant_text 与 tool_result** 对称处理引用型附件,避免只修一端。 diff --git a/docs/VIDEO_SPECIALIST_LANE.md b/docs/VIDEO_SPECIALIST_LANE.md index 0a04f153..2cc5ff8b 100644 --- a/docs/VIDEO_SPECIALIST_LANE.md +++ b/docs/VIDEO_SPECIALIST_LANE.md @@ -27,7 +27,7 @@ 5. **调用**:`send_video_generation_request` — `POST .../video-synthesis`(`X-DashScope-Async: enable`),再轮询 **`GET .../api/v1/tasks/{task_id}`** 直至 `SUCCEEDED` / 失败 / 超时。 6. **输出**:成功时从 `output.video_url` 下载为本地 blob,产出 **`video_ref`**;下载失败时退化为仅带 **`url`** 的 `video_ref` 行(前端仍可尝试外链播放)。 7. **占位文案**:`legacy_video_assistant_body_with_placeholder` 与图片专家对称(仅附件、无正文时插入中英文短句)。 -8. **编排**:`runtime/agents/specialist_agent.py` 在 `step.specialist == "video"` 时调用同一客户端(按父任务附件 + 父会话历史解析首帧),保证综合模式子专家与专家模式行为一致。 +8. **编排**:video specialist 在 `direct_loop` early-exit 中调用同一客户端;综合模式经 gateway 选中 video specialist 后走同一路径。 --- @@ -64,5 +64,5 @@ ## 8. 变更原则 -1. 默认只改 **`video_generation_client.py`**、`direct_loop` 的 **`_maybe_video_specialist_*`**、`specialist_agent` 视频分支、`factory` / `gateway` 白名单、**`chat.js`** 附件展示、本文与 **`ENVIRONMENT_VARIABLES.md`**。 +1. 默认只改 **`video_generation_client.py`**、`direct_loop` 的 **`_maybe_video_specialist_*`**、`factory` / `gateway` 白名单、**`chat.js`** 附件展示、本文与 **`ENVIRONMENT_VARIABLES.md`**。 2. 勿在通用 **`openai_responses`** 中为视频专家单独绕路,除非产品明确要求统一传输。 diff --git a/docs/plan_agent_v2_gateway_cutover_patch.md b/docs/plan_agent_v2_gateway_cutover_patch.md deleted file mode 100644 index 4ee42199..00000000 --- a/docs/plan_agent_v2_gateway_cutover_patch.md +++ /dev/null @@ -1,36 +0,0 @@ -# Plan Agent V2 Gateway Cutover Draft - -## Purpose -- Provide a minimal, reviewable gateway cutover sketch without changing production routing yet. -- Keep existing `runtime/gateway.py` behavior unchanged until explicit cutover approval. - -## Draft Helper -- New module: - - `runtime/plan_agent_v2_gateway_cutover.py` -- Entrypoint: - - `maybe_handle_expert_turn_v2_draft(...)` - -## Draft Behavior -- If v2 shadow is not selected: - - returns `handled=False`, gateway should continue legacy flow. -- If decision is `enter_plan` or `stay_plan`: - - returns `handled=True` with an `OclawGatewayResult` built from v2 shadow compatibility mapper. -- If decision is `run_agent`: - - returns `handled=False` and provides `system_prompt_override`. - - gateway would continue legacy execution path but with injected approved-plan context. - -## Why This Is Safe -- No import or call-site changes in `runtime/gateway.py` yet. -- Feature remains effectively dormant unless future cutover patch wires this helper. -- Existing tests continue to validate legacy and shadow independently. - -## Future Minimal Cutover (single commit) -- In `OclawGateway.handle_turn(...)` expert path, add one early branch: - 1) call `maybe_handle_expert_turn_v2_draft(...)` - 2) if `handled=True`, return result immediately - 3) else continue existing flow; if `system_prompt_override` exists, use it as specialist system prompt - -## Rollback -- Revert only the gateway wiring commit. -- Keep shadow modules and tests as dormant assets. - diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 9675261e..b85046a7 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -2760,7 +2760,7 @@ def build_admin_router() -> APIRouter: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") - # Oclaw takeover: legacy tool-policy switches are disconnected (kept in DB for later). + # Oclaw takeover: legacy tool-policy switches removed (fixed display values for API compat). disabled = False retry_mode = "first_round_only" loop_state_machine = True @@ -2771,9 +2771,7 @@ def build_admin_router() -> APIRouter: turn_max_tool_workers = max(1, min(int(tmw_raw), 32)) if tmw_raw.isdigit() else 8 turn_max_tool_rounds = max(1, min(int(tmr_raw), 300)) if tmr_raw.isdigit() else 100 turn_max_context_messages = max(10, min(int(tmc_raw), 400)) if tmc_raw.isdigit() else 80 - # Oclaw takeover: legacy runner switches are disconnected (kept in DB for later). turn_runner_impl = "oclaw" - # Oclaw takeover: legacy manager decision mode is disconnected (kept in DB for later). manager_decision_mode = "" sse_raw = str(store.get_setting("AIA_SSE_QUEUE_MAXSIZE") or "").strip() sse_queue_maxsize = max(200, min(int(sse_raw), 50_000)) if sse_raw.isdigit() else 2000 @@ -2781,8 +2779,20 @@ def build_admin_router() -> APIRouter: tool_log_max_chars = max(20_000, min(int(tl_raw), 2_000_000)) if tl_raw.isdigit() else 64_000 emcp_raw = str(store.get_setting("AIA_ENABLE_MCP_TOOLS") or "").strip().lower() enable_mcp_tools = emcp_raw not in ("0", "false", "no", "off") - epl_raw = str(store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") or "").strip().lower() - enable_plugin_tools = epl_raw in ("1", "true", "yes", "on") + # Align with catalog._plugin_tools_enabled: unset → ON (env may override). + epl_sv = store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") + if epl_sv is None or str(epl_sv).strip() == "": + import os as _os + + epl_env = str( + _os.getenv("AIA_ENABLE_PLUGIN_TOOLS") + or _os.getenv("AIA_PLUGIN_TOOLS_ENABLED") + or "1" + ).strip().lower() + enable_plugin_tools = epl_env not in ("0", "false", "no", "off") + else: + epl_raw = str(epl_sv).strip().lower() + enable_plugin_tools = epl_raw in ("1", "true", "yes", "on") # Absent DB row must read as OFF: matches LocalAdapter.run_command (no row → env only; no env → disabled). # Previously `str(None or "")` made the UI show ON while execution stayed disabled (confusing on new machines). erc_sv = store.get_setting("AIA_ENABLE_RUN_COMMAND") @@ -2859,7 +2869,7 @@ def build_admin_router() -> APIRouter: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") - # Oclaw takeover: legacy tool-policy switches are disconnected (kept in DB for later). + # Legacy tool-policy switches removed from product surface (fixed values for API compat). disable_confirm = False retry_mode = "first_round_only" sm = True @@ -2892,7 +2902,7 @@ def build_admin_router() -> APIRouter: except Exception: tl_cap_n = 64_000 emcp = bool(payload.get("enable_mcp_tools", True)) - epl = bool(payload.get("enable_plugin_tools", False)) + epl = bool(payload.get("enable_plugin_tools", True)) erc = bool(payload.get("enable_run_command", True)) tctx = bool(payload.get("tool_context_truncate_enabled", True)) ttft_debug = bool(payload.get("chat_show_ttft_debug", False)) @@ -2952,19 +2962,14 @@ def build_admin_router() -> APIRouter: except Exception: wc_in_q_n = 200 # Canonical professional prefix. - # Legacy-only (disconnected): do not write the following settings: - # - AIA_DISABLE_TOOL_CONFIRM - # - AIA_TOOL_ENFORCED_RETRY_MODE - # - AIA_TOOL_LOOP_STATE_MACHINE - # - AIA_TOOL_SIGNATURE_BUDGET store.set_setting("AIA_TURN_MAX_TOOL_WORKERS", str(tmw_n)) store.set_setting("AIA_TURN_MAX_TOOL_ROUNDS", str(tmr_n)) store.set_setting("AIA_TURN_MAX_CONTEXT_MESSAGES", str(tmc_n)) - # Legacy-only (disconnected): do not write AIA_MANAGER_DECISION_MODE here. store.set_setting("AIA_SSE_QUEUE_MAXSIZE", str(sse_q_n)) store.set_setting("AIA_TOOL_LOG_MAX_CHARS", str(tl_cap_n)) store.set_setting("AIA_ENABLE_MCP_TOOLS", "1" if emcp else "0") store.set_setting("AIA_ENABLE_PLUGIN_TOOLS", "1" if epl else "0") + os.environ["AIA_ENABLE_PLUGIN_TOOLS"] = "1" if epl else "0" store.set_setting("AIA_ENABLE_RUN_COMMAND", "1" if erc else "0") # Keep runtime gate aligned with Admin toggle immediately. os.environ["AIA_ENABLE_RUN_COMMAND"] = "1" if erc else "0" @@ -3474,7 +3479,7 @@ def build_admin_router() -> APIRouter: ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") available = _ordered_mcp_roles() - raw = str(store.get_setting("mcp_allowed_specialists") or "").strip() or "generalist,manager" + raw = str(store.get_setting("mcp_allowed_specialists") or "").strip() or "generalist,manager,ops" allowed = [x.strip().lower() for x in raw.split(",") if x.strip()] allowed_set = set(allowed) ordered = [x for x in available if x in allowed_set] diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 3e8adff6..90afdfb9 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -5168,10 +5168,6 @@ async function renderPlugins() { const installStatus = el("div", { class: "muted", text: "" }); const preflightFixWrap = el("div"); const toolPolicyStatus = el("div", { class: "muted", text: "" }); - const legacyToolPolicyNote = el("div", { - class: "muted", - text: "Legacy tool-policy switches (confirm/retry/state-machine/signature budget) are disconnected under oclaw.", - }); const turnMaxWorkersInput = el("input", { class: "input", type: "number", @@ -5196,14 +5192,6 @@ async function renderPlugins() { value: String(Number(toolPolicy.turn_max_context_messages || 80)), style: "max-width:120px", }); - const turnRunnerImplNote = el("div", { - class: "muted", - text: "Turn runner: oclaw (legacy runners disconnected)", - }); - const managerDecisionModeNote = el("div", { - class: "muted", - text: "Manager decision mode: (legacy disconnected)", - }); const sseQueueMaxsizeInput = el("input", { class: "input", type: "number", @@ -6638,8 +6626,7 @@ async function renderPlugins() { applyWireRoleSelectorState(); applyWireRoleModeUiState(); const foldToolPolicy = pluginsFold(`【1】工具策略与已注册插件(${pluginCatalog.length})`, [ - el("div", { class: "muted", text: "Tool policy(确认 / 重试)与 Python 工具插件表" }), - legacyToolPolicyNote, + el("div", { class: "muted", text: "Tool policy(并发 / 轮次 / MCP·插件开关)与 Python 工具插件表" }), el("div", { class: "row" }, [ el("label", { text: "Turn max tool workers (1-32)" }), turnMaxWorkersInput, @@ -6652,14 +6639,6 @@ async function renderPlugins() { el("label", { text: "Turn max context messages (10-400)" }), turnMaxCtxInput, ]), - el("div", { class: "row" }, [ - el("label", { text: "Turn runner implementation" }), - turnRunnerImplNote, - ]), - el("div", { class: "row" }, [ - el("label", { text: "Manager decision mode" }), - managerDecisionModeNote, - ]), el("div", { class: "row" }, [ el("label", { text: "SSE queue maxsize (200-50000)" }), sseQueueMaxsizeInput, diff --git a/runtime/agents/factory.py b/runtime/agents/factory.py index d6d04e0d..b6096ba8 100644 --- a/runtime/agents/factory.py +++ b/runtime/agents/factory.py @@ -6,10 +6,9 @@ import os from typing import Any from runtime.agents.agent_scope import resolve_default_agent_id -from runtime.agents.network_ops_agent import NetworkOpsAgent -from runtime.agents.specialist_agent import SpecialistProfile from runtime.agents.specialists import ( AGENT_PROFILE_BINDINGS_KEY, + SpecialistProfile, normalize_specialist_id, agent_role_ids, MANAGER_AGENT_ID, @@ -63,7 +62,7 @@ def _build_executor_components( viewer_username: str | None = None, viewer_tenant_id: str | None = None, ) -> tuple[ - NetworkOpsAgent, + Agent, dict[str, SpecialistProfile], object, str, @@ -205,9 +204,15 @@ def _build_executor_components( specialist_models[sid] = m specialist_modes[sid] = md - base_agent = NetworkOpsAgent( + base_agent = Agent( store=store, + tools=default_registry( + expert=expert_name_for_specialist("ops"), + specialist="ops", + store=store, + ), model=specialist_models.get("ops") or active_model, + system_prompt=default_system_prefix_for_specialist("ops", lang), lang=lang, llm_profile_mode=specialist_modes.get("ops") or active_mode, ) @@ -315,17 +320,6 @@ def build_gateway_executor( prof = specialist_profiles.get(sid) or specialist_profiles["generalist"] chosen_model = specialist_models.get(prof.name) or base_agent.model chosen_mode = specialist_modes.get(prof.name) or getattr(base_agent, "llm_profile_mode", None) - if prof.name == "ops": - return NetworkOpsAgent( - store=store, - model=chosen_model, - lang=(lang or "zh").strip().lower(), - llm_profile_mode=chosen_mode, - system_prompt=prof.system_prefix, - policy_session_id=policy_session_id, - path_policy_tenant_id=path_policy_tenant_id, - path_policy_user_id=path_policy_user_id, - ) reg_kw: dict[str, Any] = { "expert": expert_name_for_specialist(prof.name), "specialist": prof.name, diff --git a/runtime/agents/network_ops_agent.py b/runtime/agents/network_ops_agent.py index a08a7e46..a72bc068 100644 --- a/runtime/agents/network_ops_agent.py +++ b/runtime/agents/network_ops_agent.py @@ -1,9 +1,15 @@ from __future__ import annotations +"""Ops specialist helpers. + +Prefer ``build_gateway_executor(specialist=\"ops\")``. This module keeps the +legacy ``NetworkOpsAgent`` name as a thin ``Agent`` factory for older imports. +""" + from typing import Any -from runtime.chat.agent import Agent from runtime.agent_context import build_role_system_context +from runtime.chat.agent import Agent from svc.persistence.sqlite_store import SqliteStore from runtime.tools import default_registry @@ -12,7 +18,10 @@ NETWORK_SYSTEM_PROMPT_ZH = build_role_system_context("ops") class NetworkOpsAgent(Agent): - """网络运维专家 Agent:固定专家提示词与专家工具目录。""" + """Compatibility alias: ops specialist with network_ops(+memory) tool catalog. + + New code should use ``runtime.agents.factory.build_gateway_executor(specialist=\"ops\")``. + """ def __init__( self, @@ -27,7 +36,8 @@ class NetworkOpsAgent(Agent): path_policy_user_id: str | None = None, ) -> None: tools = default_registry( - expert="network_ops", + expert="network_ops+memory", + specialist="ops", policy_session_id=policy_session_id, path_policy_tenant_id=path_policy_tenant_id, path_policy_user_id=path_policy_user_id, diff --git a/runtime/agents/specialist_agent.py b/runtime/agents/specialist_agent.py deleted file mode 100644 index 23e307e2..00000000 --- a/runtime/agents/specialist_agent.py +++ /dev/null @@ -1,478 +0,0 @@ -from __future__ import annotations - -import json -import os -import sys -import time -import hashlib -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any, Optional - -from runtime.chat.agent import Agent -from runtime.chat.agent import GenerationInterrupted -from runtime.agents.network_ops_agent import NetworkOpsAgent -from svc.persistence.sqlite_store import SqliteStore -from svc.llm.image_legacy_client import ( - IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH, - collect_legacy_lane_images_from_attachments, - collect_legacy_lane_images_with_session_fallback, - legacy_image_assistant_body_with_placeholder, - legacy_image_turn_bundle, - send_legacy_image_messages, -) -from svc.llm.video_generation_client import ( - VIDEO_SPECIALIST_DEFAULT_PROMPT_ZH, - legacy_video_assistant_body_with_placeholder, - legacy_video_turn_bundle, - send_video_generation_request, -) -from runtime.tools import default_registry -from runtime.agents.specialists import expert_name_for_specialist - -from runtime.chat.turn_types import TurnRunOutcome -from runtime.relay_pointer import build_manifest_from_attachment_refs -from runtime.types import RelayShareEnvelope -from runtime.orchestration.protocol import ( - AgentTask, - PlanStep, - SpecialistDelivery, - SpecialistResult, - SpecialistToolTrace, -) - - -@dataclass(frozen=True) -class SpecialistProfile: - name: str - system_prefix: str - tool_tags: frozenset[str] | None = None - - -@dataclass -class SpecialistAgentRunner: - store: SqliteStore - model: Any - llm_profile_mode: str | None - lang: str - profiles: dict[str, SpecialistProfile] = field(default_factory=dict) - model_by_specialist: dict[str, Any] = field(default_factory=dict) - llm_mode_by_specialist: dict[str, str | None] = field(default_factory=dict) - _agent_cache: dict[tuple, Agent] = field(default_factory=dict, init=False, repr=False) - - @staticmethod - def _allowlist_mutation_fingerprint( - store: SqliteStore, - *, - policy_session_id: str | None = None, - path_policy_tenant_id: str | None = None, - path_policy_user_id: str | None = None, - ) -> str: - t = (path_policy_tenant_id or "").strip() or None - u = (path_policy_user_id or "").strip() or None - if (not t or not u) and (policy_session_id or "").strip(): - try: - own = store.get_ui_session_owner(session_id=str(policy_session_id).strip()) or {} - except Exception: - own = {} - t = t or (str(own.get("tenant_id") or "").strip() or None) - u = u or (str(own.get("user_id") or "").strip() or None) - if not t or not u: - return "0" - try: - row = store.get_user_workspace_path_allowlist(tenant_id=t, user_id=u) - except Exception: - row = None - if not row or not isinstance(row, dict): - return "0|" - er = str(row.get("extra_roots") or "") - return f"{1 if int(row.get('allow_any_path') or 0) else 0}|{str(row.get('updated_at') or '')}|{er[:2000]}" - - def _agent_cache_fingerprint( - self, - specialist: str, - prof: SpecialistProfile, - *, - policy_session_id: str | None = None, - path_policy_tenant_id: str | None = None, - path_policy_user_id: str | None = None, - ) -> str: - tool_names: list[str] = [] - try: - regs = default_registry( - expert=expert_name_for_specialist(prof.name), - specialist=prof.name, - policy_session_id=policy_session_id, - path_policy_tenant_id=path_policy_tenant_id, - path_policy_user_id=path_policy_user_id, - store=self.store, - ) - tool_names = sorted([str(t.name) for t in regs.list()]) - except Exception: - tool_names = [] - raw = json.dumps( - { - "specialist": specialist, - "profile_name": prof.name, - "system_prefix": prof.system_prefix, - "tool_names": tool_names, - "tool_tags": sorted(list(prof.tool_tags or frozenset())), - "policy_session_tail": (str(policy_session_id or "")[-16:]), - "allowlist_fp": self._allowlist_mutation_fingerprint( - self.store, - policy_session_id=policy_session_id, - path_policy_tenant_id=path_policy_tenant_id, - path_policy_user_id=path_policy_user_id, - ), - }, - ensure_ascii=False, - sort_keys=True, - ) - return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] - - def _resolve_profile_and_model(self, specialist: str) -> tuple[SpecialistProfile, Any, str | None]: - prof = self.profiles.get(specialist) or self.profiles["generalist"] - chosen_model = self.model_by_specialist.get(prof.name) or self.model - chosen_mode = self.llm_mode_by_specialist.get(prof.name) or self.llm_profile_mode - return prof, chosen_model, chosen_mode - - def _build_agent_for( - self, - specialist: str, - *, - policy_session_id: str | None = None, - use_cache: bool = True, - path_policy_tenant_id: str | None = None, - path_policy_user_id: str | None = None, - ) -> Agent: - prof, chosen_model, chosen_mode = self._resolve_profile_and_model(specialist) - cache_fp = self._agent_cache_fingerprint( - specialist, - prof, - policy_session_id=policy_session_id, - path_policy_tenant_id=path_policy_tenant_id, - path_policy_user_id=path_policy_user_id, - ) - alfp = self._allowlist_mutation_fingerprint( - self.store, - policy_session_id=policy_session_id, - path_policy_tenant_id=path_policy_tenant_id, - path_policy_user_id=path_policy_user_id, - ) - cache_key = (prof.name, id(chosen_model), chosen_mode, self.lang, cache_fp, str(policy_session_id or ""), alfp) - if use_cache: - cached = self._agent_cache.get(cache_key) - if cached is not None: - return cached - if prof.name == "ops": - agent: Agent = NetworkOpsAgent( - store=self.store, - model=chosen_model, - lang=self.lang, - llm_profile_mode=chosen_mode, - system_prompt=prof.system_prefix, - policy_session_id=policy_session_id, - path_policy_tenant_id=path_policy_tenant_id, - path_policy_user_id=path_policy_user_id, - ) - if use_cache: - self._agent_cache[cache_key] = agent - return agent - tools = default_registry( - expert=expert_name_for_specialist(prof.name), - specialist=prof.name, - policy_session_id=policy_session_id, - path_policy_tenant_id=path_policy_tenant_id, - path_policy_user_id=path_policy_user_id, - store=self.store, - ) - agent = Agent( - store=self.store, - tools=tools, - model=chosen_model, - system_prompt=prof.system_prefix, - lang=self.lang, - llm_profile_mode=chosen_mode, - ) - if use_cache: - self._agent_cache[cache_key] = agent - return agent - - def run_specialist( - self, - *, - parent_task: AgentTask, - step: PlanStep, - session_id: str | None = None, - use_cache: bool = True, - on_progress: Optional[Callable[[str], None]] = None, - on_token: Optional[Callable[[str], None]] = None, - on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None, - should_stop: Optional[Callable[[], bool]] = None, - ) -> SpecialistResult: - started = time.perf_counter() - if on_progress: - obj = (step.objective or "").strip().replace("\n", " ") - if len(obj) > 140: - obj = obj[:137] + "..." - on_progress(f"[sp.start] {step.step_id} specialist={step.specialist} objective={obj}") - created_session_id: str | None = None - if not session_id: - temp_session = self.store.create_session(f"specialist:{step.specialist}") - session_id = temp_session.id - created_session_id = session_id - # User chat session for workspace/MCP path policy (specialist temp session usually has no ui_session_owner). - _raw_policy_sid = str(parent_task.session_id or "").strip() or str(session_id or "").strip() - policy_session_id: str | None = _raw_policy_sid if _raw_policy_sid else None - _meta: dict[str, Any] = parent_task.metadata if isinstance(getattr(parent_task, "metadata", None), dict) else {} - _path_tenant = str(_meta.get("tenant_id") or "").strip() or None - _path_user = str(_meta.get("user_id") or "").strip() or None - prompt = ( - f"Specialist: {step.specialist}\n" - f"Objective: {step.objective}\n" - f"Parent user request: {parent_task.user_text}\n" - f"Step input: {step.input_text}\n" - "Execution policy: when the user asks to read/open/list/summarize concrete files, URLs, or MCP resources, " - "execute with available tools first. Do not return generic optimization plans unless explicitly requested.\n" - ) - image_input_count = 0 - image_input_kind: list[str] = [] - image_protocol = "" - image_debug_schema = "" - image_debug_payload: dict[str, Any] | str = {} - specialist_delivery: SpecialistDelivery | None = None - try: - if step.specialist == "image": - image_protocol = "messages.content.image" - selected_images = collect_legacy_lane_images_from_attachments( - list(parent_task.attachments or []), - max_images=3, - ) - image_input_count = len(selected_images) - image_input_kind = ["data_url" if s.startswith("data:") else "url" for s in selected_images] - if not selected_images: - output = "Image specialist received no image input." - ok = False - else: - # Use the user's chosen model/session profile (same as specialist routing UI). Wrong model ⇒ upstream HTTP error as-is (no OCR lane, no alternate payload). - _, chosen_model, _ = self._resolve_profile_and_model(step.specialist) - text_parts = [ - str(x).strip() - for x in (step.objective, step.input_text, parent_task.user_text) - if str(x or "").strip() - ] - user_text = "\n".join(text_parts) if text_parts else IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH - if str(os.getenv("AIA_IMAGE_EXPERT_DEBUG_PRINT_PAYLOAD") or "").strip().lower() in ( - "1", - "true", - "yes", - "on", - ): - try: - sys.stderr.write( - "[oclaw specialist:image] lane=legacy_http → send_legacy_image_messages " - "(NOT OpenAIResponsesModel).\n" - ) - sys.stderr.flush() - except Exception: - pass - resp = send_legacy_image_messages( - images=selected_images, - prompt=user_text, - model=str(getattr(chosen_model, "model", "") or "").strip() or None, - api_key=str(getattr(chosen_model, "api_key", "") or "").strip() or None, - base_url=str(getattr(chosen_model, "base_url", "") or "").strip() or None, - ) - image_debug_schema = str(resp.get("debug_used_schema") or "").strip() - dbg = resp.get("debug_used_debug") - if isinstance(dbg, dict): - image_debug_payload = dbg - elif dbg is not None: - image_debug_payload = str(dbg) - ok, output, produced_attachments = legacy_image_turn_bundle(resp) - output = legacy_image_assistant_body_with_placeholder( - lang=self.lang, - body_text=output, - produced=produced_attachments if ok else None, - ) - self.store.add_message( - session_id=session_id, - role="assistant", - content=output, - attachments=produced_attachments or None, - ) - specialist_delivery = SpecialistDelivery( - specialist=step.specialist, - step_id=step.step_id, - answer_text=str(output or ""), - tool_traces=(), - notes="image_pipeline", - ) - elif step.specialist == "video": - image_protocol = "video_generation.http" - _, chosen_model, _ = self._resolve_profile_and_model(step.specialist) - text_parts = [ - str(x).strip() - for x in (step.objective, step.input_text, parent_task.user_text) - if str(x or "").strip() - ] - user_text_v = "\n".join(text_parts) if text_parts else VIDEO_SPECIALIST_DEFAULT_PROMPT_ZH - policy_sid = str(policy_session_id or parent_task.session_id or session_id or "").strip() - v_frames, _v_src = collect_legacy_lane_images_with_session_fallback( - store=self.store, - session_id=policy_sid, - attachments=list(parent_task.attachments or []), - max_images=1, - ) - v_frame = str(v_frames[0]).strip() if v_frames else None - resp_v = send_video_generation_request( - prompt=user_text_v, - model=str(getattr(chosen_model, "model", "") or "").strip() or None, - api_key=str(getattr(chosen_model, "api_key", "") or "").strip() or None, - base_url=str(getattr(chosen_model, "base_url", "") or "").strip() or None, - img_url=v_frame, - on_progress=on_progress, - should_stop=should_stop, - ) - ok, output, produced_attachments = legacy_video_turn_bundle(resp_v) - output = legacy_video_assistant_body_with_placeholder( - lang=self.lang, - body_text=output, - produced=produced_attachments if ok else None, - ) - self.store.add_message( - session_id=session_id, - role="assistant", - content=output, - attachments=(produced_attachments or None) if ok else None, - ) - specialist_delivery = SpecialistDelivery( - specialist=step.specialist, - step_id=step.step_id, - answer_text=str(output or ""), - tool_traces=(), - notes="video_pipeline", - ) - else: - agent = self._build_agent_for( - step.specialist, - policy_session_id=policy_session_id, - use_cache=use_cache, - path_policy_tenant_id=_path_tenant, - path_policy_user_id=_path_user, - ) - from runtime.gateway import OclawGateway - from runtime.types import StandardMessage - - gw = OclawGateway(store=self.store) - msg = StandardMessage( - session_id=str(session_id), - tenant_id=str(_path_tenant or ""), - user_id=str(_path_user or ""), - role="member", - channel="specialist", - text=str(prompt or ""), - attachments=list(parent_task.attachments or []), - metadata={ - "tenant_id": str(_path_tenant or ""), - "user_id": str(_path_user or ""), - "channel": f"specialist:{step.specialist}", - }, - ) - output = gw.handle_turn( - msg=msg, - lang=str(getattr(agent, "lang", "zh") or "zh"), - executor=agent, - on_token=on_token, - on_progress=on_progress, - on_tool_ui=on_tool_ui, - should_stop=should_stop, - ).reply_text - ok = bool((output or "").strip()) - outcome = getattr(agent, "_last_turn_outcome", None) - if isinstance(outcome, TurnRunOutcome): - traces = tuple( - SpecialistToolTrace( - name=str(x.get("name") or ""), - ok=bool(x.get("ok")), - latency_ms=int(x.get("latency_ms") or x.get("duration_ms") or 0), - ) - for x in outcome.tool_traces - ) - specialist_delivery = SpecialistDelivery( - specialist=step.specialist, - step_id=step.step_id, - answer_text=str(output or ""), - tool_traces=traces, - notes=str(outcome.handoff_note or ""), - ) - except GenerationInterrupted: - raise - except Exception as e: - output = f"{type(e).__name__}: {e}" - ok = False - finally: - produced_attachments: list[dict[str, Any]] = [] - try: - rows = self.store.get_messages(session_id=session_id, limit=40) if session_id else [] - for m in reversed(rows): - if str(m.role) != "assistant": - continue - if not m.attachments: - continue - raw = json.loads(m.attachments) - if isinstance(raw, list): - produced_attachments = [a for a in raw if isinstance(a, dict)] - break - except Exception: - produced_attachments = [] - if created_session_id: - try: - parent_sid = str(parent_task.session_id or "").strip() - if parent_sid and parent_sid != str(created_session_id): - # Preserve tool usage telemetry: tool uses run inside temp specialist sessions. - # If we delete temp sessions directly, FK cascade would drop those tool_log rows. - self.store.move_tool_logs_to_session( - from_session_id=str(created_session_id), - to_session_id=parent_sid, - ) - except Exception: - pass - self.store.delete_session(created_session_id) - latency = int((time.perf_counter() - started) * 1000) - if on_progress: - on_progress( - f"[sp.done] {step.step_id} specialist={step.specialist} ok={ok} latency_ms={latency}" - ) - scope_id = str(session_id or parent_task.session_id or "").strip() - manifest = build_manifest_from_attachment_refs( - produced_attachments, - scope_id=scope_id, - source_agent=str(step.specialist or ""), - ttl_policy="turn", - ) - relay_env = RelayShareEnvelope( - schema_version="v1", - trace_id=str((parent_task.metadata or {}).get("trace_id") or ""), - run_id=str((parent_task.metadata or {}).get("run_id") or ""), - attempt_no=int((parent_task.metadata or {}).get("attempt_no") or 0), - attachments=manifest, - ) - return SpecialistResult( - step_id=step.step_id, - specialist=step.specialist, - success=ok, - output_text=output, - latency_ms=latency, - metadata={ - "objective": step.objective, - "attachments": produced_attachments, - "relay_share_envelope": relay_env.to_dict(), - "image_input_count": image_input_count, - "image_input_kind": image_input_kind, - "image_protocol": image_protocol, - "image_debug_schema": image_debug_schema, - "image_debug_payload": image_debug_payload, - }, - delivery=specialist_delivery, - ) diff --git a/runtime/agents/specialists.py b/runtime/agents/specialists.py index 8a83fa7c..0f304371 100644 --- a/runtime/agents/specialists.py +++ b/runtime/agents/specialists.py @@ -21,6 +21,15 @@ class SpecialistConfig: default_tool_tags: frozenset[str] | None +@dataclass(frozen=True) +class SpecialistProfile: + """Prompt/tool surface for a specialist id (gateway executor factory).""" + + name: str + system_prefix: str + tool_tags: frozenset[str] | None = None + + SPECIALISTS: dict[SpecialistId, SpecialistConfig] = { "ops": SpecialistConfig( specialist_id="ops", @@ -140,6 +149,7 @@ __all__ = [ "MANAGER_AGENT_ID", "SpecialistConfig", "SpecialistId", + "SpecialistProfile", "SPECIALISTS", "specialist_ids", "default_system_prefix_for_specialist", diff --git a/runtime/app/__init__.py b/runtime/app/__init__.py deleted file mode 100644 index 7f2d7f87..00000000 --- a/runtime/app/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Application-facing runtime entrypoints.""" - diff --git a/runtime/orchestration/tool_adapter.py b/runtime/orchestration/tool_adapter.py deleted file mode 100644 index 700787d8..00000000 --- a/runtime/orchestration/tool_adapter.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import time -from typing import Any - -from svc.persistence.sqlite_store import SqliteStore - - -class ToolAuditAdapter: - def __init__(self, store: SqliteStore): - self.store = store - - def log_dispatch( - self, - *, - session_id: str, - specialist: str, - task_kind: str, - action: str, - payload: dict[str, Any], - status: str = "ok", - reason: str = "", - ) -> None: - started = time.perf_counter() - self.store.add_agent_audit_log( - session_id=session_id, - specialist=specialist, - task_kind=task_kind, - action=action, - payload=payload, - status=status, - reason=reason, - duration_ms=max(0, int((time.perf_counter() - started) * 1000)), - ) diff --git a/runtime/plan_agent_v2_adapter.py b/runtime/plan_agent_v2_adapter.py deleted file mode 100644 index 34cea22d..00000000 --- a/runtime/plan_agent_v2_adapter.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.adapter import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_compat.py b/runtime/plan_agent_v2_compat.py deleted file mode 100644 index 798b4475..00000000 --- a/runtime/plan_agent_v2_compat.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.compat import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_gateway_adapter.py b/runtime/plan_agent_v2_gateway_adapter.py deleted file mode 100644 index 0a63f058..00000000 --- a/runtime/plan_agent_v2_gateway_adapter.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.gateway_adapter import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_gateway_cutover.py b/runtime/plan_agent_v2_gateway_cutover.py deleted file mode 100644 index 1cf606b3..00000000 --- a/runtime/plan_agent_v2_gateway_cutover.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import time -import uuid -from dataclasses import dataclass -from typing import Any - -from runtime.gateway import OclawGatewayResult -from runtime.plan_agent_v2 import ( - build_shadow_gateway_result, - evaluate_gateway_expert_turn_shadow, -) -from runtime.types import StandardMessage - - -@dataclass(frozen=True) -class GatewayCutoverDraftOutput: - handled: bool - result: OclawGatewayResult | None - system_prompt_override: str = "" - decision_action: str = "" - - -def maybe_handle_expert_turn_v2_draft( - *, - store: Any, - msg: StandardMessage, - lang: str, - interaction_mode: str, - requested_specialist: str, - base_system_prompt: str, - force_flag: bool = False, -) -> GatewayCutoverDraftOutput: - """Draft-only helper for future gateway cutover. - - Important: - - This module is intentionally NOT wired into `runtime/gateway.py`. - - It documents and validates the minimal cutover behavior in isolation. - """ - t0 = time.perf_counter() - trace_id = str(uuid.uuid4()) - run_id = str(uuid.uuid4()) - - shadow = evaluate_gateway_expert_turn_shadow( - store=store, - msg=msg, - lang=lang, - interaction_mode=interaction_mode, - requested_specialist=requested_specialist, - base_system_prompt=base_system_prompt, - force_flag=force_flag, - trace_id=trace_id, - parent_span_id=None, - ) - if not shadow.used_v2 or shadow.decision is None: - return GatewayCutoverDraftOutput(handled=False, result=None) - - action = str(shadow.decision.action or "") - elapsed_ms = int((time.perf_counter() - t0) * 1000) - if action in {"enter_plan", "stay_plan"}: - row = build_shadow_gateway_result( - decision=shadow.decision, - run_id=run_id, - trace_id=trace_id, - elapsed_ms=elapsed_ms, - requested_specialist=requested_specialist, - ) - result = OclawGatewayResult(**row) - return GatewayCutoverDraftOutput( - handled=True, - result=result, - decision_action=action, - system_prompt_override="", - ) - - # run_agent: draft suggests continuing legacy execution with injected prompt. - return GatewayCutoverDraftOutput( - handled=False, - result=None, - decision_action=action, - system_prompt_override=str(shadow.decision.system_prompt_override or ""), - ) - - -__all__ = ["GatewayCutoverDraftOutput", "maybe_handle_expert_turn_v2_draft"] - diff --git a/runtime/plan_agent_v2_manager.py b/runtime/plan_agent_v2_manager.py deleted file mode 100644 index 656684ac..00000000 --- a/runtime/plan_agent_v2_manager.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.manager import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_models.py b/runtime/plan_agent_v2_models.py deleted file mode 100644 index 7b3eafbd..00000000 --- a/runtime/plan_agent_v2_models.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.models import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_prompt_injector.py b/runtime/plan_agent_v2_prompt_injector.py deleted file mode 100644 index fd3ba06c..00000000 --- a/runtime/plan_agent_v2_prompt_injector.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.prompt_injector import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_state_store.py b/runtime/plan_agent_v2_state_store.py deleted file mode 100644 index cdb916de..00000000 --- a/runtime/plan_agent_v2_state_store.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.state_store import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_switch.py b/runtime/plan_agent_v2_switch.py deleted file mode 100644 index 32dbd56a..00000000 --- a/runtime/plan_agent_v2_switch.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.switch import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_tool_policy.py b/runtime/plan_agent_v2_tool_policy.py deleted file mode 100644 index 1f080589..00000000 --- a/runtime/plan_agent_v2_tool_policy.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.tool_policy import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_tool_specs.py b/runtime/plan_agent_v2_tool_specs.py deleted file mode 100644 index 21858d7e..00000000 --- a/runtime/plan_agent_v2_tool_specs.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.tool_specs import * # noqa: F403 - diff --git a/runtime/plan_agent_v2_trace.py b/runtime/plan_agent_v2_trace.py deleted file mode 100644 index 744591bb..00000000 --- a/runtime/plan_agent_v2_trace.py +++ /dev/null @@ -1,2 +0,0 @@ -from runtime.plan_agent_v2.trace import * # noqa: F403 - diff --git a/runtime/tools/__init__.py b/runtime/tools/__init__.py index 3c9babae..4fb56910 100644 --- a/runtime/tools/__init__.py +++ b/runtime/tools/__init__.py @@ -3,14 +3,12 @@ from __future__ import annotations from runtime.tools.catalog import ( - TOOL_FACTORIES, default_registry, materialize_tool_specs, tool_inventory, ) __all__ = [ - "TOOL_FACTORIES", "default_registry", "materialize_tool_specs", "tool_inventory", diff --git a/runtime/tools/catalog.py b/runtime/tools/catalog.py index a8756a57..5238ae37 100644 --- a/runtime/tools/catalog.py +++ b/runtime/tools/catalog.py @@ -25,15 +25,30 @@ _MODEL_TOOLS_DENYLIST = frozenset( } ) -# Legacy export: some modules still import TOOL_FACTORIES. Tools are now intentionally -# restricted to a single safe builtin (`system_time`), so this is left empty. -TOOL_FACTORIES: tuple[object, ...] = () def _is_truthy(v: str | None) -> bool: return str(v or "").strip().lower() in ("1", "true", "yes", "on") -def _skill_toolcall_enabled(store: SqliteStore | None) -> bool: +def _plugin_tools_enabled(store: Any | None = None) -> bool: + """Admin setting ``AIA_ENABLE_PLUGIN_TOOLS`` wins; env aliases accepted. + + Default ON when unset (matches historical ``AIA_PLUGIN_TOOLS_ENABLED=1``). + """ + if store is not None: + try: + raw = store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") + if raw is not None and str(raw).strip() != "": + return _is_truthy(str(raw)) + except Exception: + pass + for key in ("AIA_ENABLE_PLUGIN_TOOLS", "AIA_PLUGIN_TOOLS_ENABLED"): + if key in os.environ: + return _is_truthy(os.getenv(key)) + 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: @@ -232,8 +247,8 @@ def materialize_tool_specs( except Exception as exc: logger.warning("mcp tool load skipped: %s", exc) - # collect: plugin - if not _is_truthy(os.getenv("AIA_PLUGIN_TOOLS_ENABLED", "1")): + # collect: plugin (Admin AIA_ENABLE_PLUGIN_TOOLS / env alias AIA_PLUGIN_TOOLS_ENABLED) + if not _plugin_tools_enabled(store): return _resolve_tool_conflicts(collected) try: diff --git a/runtime/tools/mcp/adapter.py b/runtime/tools/mcp/adapter.py index 6c3bf226..3c89561c 100644 --- a/runtime/tools/mcp/adapter.py +++ b/runtime/tools/mcp/adapter.py @@ -262,7 +262,7 @@ def materialize_mcp_tools_for_specialist( except Exception: raw_allowed = "" if not raw_allowed: - raw_allowed = str(os.getenv("AIA_MCP_SPECIALISTS") or "generalist,manager").strip() + raw_allowed = str(os.getenv("AIA_MCP_SPECIALISTS") or "generalist,manager,ops").strip() allowed = {x.strip().lower() for x in raw_allowed.split(",") if x.strip()} if binding_server_ids is None and sp and sp not in allowed: return [] diff --git a/svc/llm/image_legacy_client.py b/svc/llm/image_legacy_client.py index fd511dc0..d42142a8 100644 --- a/svc/llm/image_legacy_client.py +++ b/svc/llm/image_legacy_client.py @@ -3,7 +3,6 @@ Uses ``{"image":...}/{"text":...}`` or typed compatible-mode blocks on ``/chat/completions`` only. The **image specialist** uses this module from: -- :mod:`~runtime.agents.specialist_agent` (orchestration temp sessions) - :mod:`~runtime.direct_loop` when ``skill_binding_role=="image"`` (**gateway /chat UI**), so vision turns never hit :class:`~svc.llm.transports.openai_responses.OpenAIResponsesModel` unless explicitly disabled via env. @@ -359,7 +358,7 @@ def legacy_image_assistant_body_with_placeholder( ) -> str: """If the model returned images but no visible text, use the standard chat placeholder (ZH/EN). - Shared by ``direct_loop`` (gateway /chat) and ``specialist_agent`` (temp sessions). + Shared by ``direct_loop`` (gateway /chat) for the image specialist early-exit lane. """ if str(body_text or "").strip(): return str(body_text or "") diff --git a/tests/test_memory_wiki_plugin.py b/tests/test_memory_wiki_plugin.py index dc48c529..c213ec44 100644 --- a/tests/test_memory_wiki_plugin.py +++ b/tests/test_memory_wiki_plugin.py @@ -63,6 +63,7 @@ def test_memory_wiki_plugin_registry_and_catalog_wiring(tmp_path: Path, monkeypa assert {"wiki_status", "wiki_lint", "wiki_apply", "wiki_search", "wiki_get"} <= names assert all(callable(t.get("handler")) for t in tools if str(t.get("name") or "").startswith("wiki_")) + monkeypatch.setenv("AIA_ENABLE_PLUGIN_TOOLS", "1") monkeypatch.setenv("AIA_PLUGIN_TOOLS_ENABLED", "1") monkeypatch.setenv("AIA_PLUGIN_TOOL_IDS", "memory-wiki") specs = materialize_tool_specs() diff --git a/tests/test_plan_agent_v2_gateway_cutover_draft.py b/tests/test_plan_agent_v2_gateway_cutover_draft.py deleted file mode 100644 index e84c5a90..00000000 --- a/tests/test_plan_agent_v2_gateway_cutover_draft.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from svc.persistence.sqlite_store import SqliteStore -from runtime.plan_agent_v2_gateway_cutover import maybe_handle_expert_turn_v2_draft -from runtime.types import StandardMessage - - -def _msg(text: str) -> StandardMessage: - return StandardMessage( - session_id="cutover-s1", - tenant_id="t1", - user_id="u1", - role="user", - channel="chat", - text=text, - attachments=[], - metadata={}, - ) - - -def test_cutover_draft_off_by_default(tmp_path: Path) -> None: - store = SqliteStore(str(tmp_path / "ops.sqlite")) - out = maybe_handle_expert_turn_v2_draft( - store=store, - msg=_msg("hello"), - lang="zh", - interaction_mode="expert", - requested_specialist="generalist", - base_system_prompt="base", - force_flag=False, - ) - assert out.handled is False - assert out.result is None - - -def test_cutover_draft_plan_reply_when_forced(tmp_path: Path) -> None: - store = SqliteStore(str(tmp_path / "ops.sqlite")) - store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) - out = maybe_handle_expert_turn_v2_draft( - store=store, - msg=_msg("我要做改造"), - lang="zh", - interaction_mode="expert", - requested_specialist="generalist", - base_system_prompt="base", - force_flag=True, - ) - assert out.handled is False - assert out.result is None - assert out.decision_action == "run_agent" - assert "base" in str(out.system_prompt_override or "") - - -def test_cutover_draft_run_agent_returns_prompt_override(tmp_path: Path) -> None: - store = SqliteStore(str(tmp_path / "ops.sqlite")) - store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans")) - _ = maybe_handle_expert_turn_v2_draft( - store=store, - msg=_msg("先给个计划"), - lang="zh", - interaction_mode="expert", - requested_specialist="generalist", - base_system_prompt="base", - force_flag=True, - ) - out = maybe_handle_expert_turn_v2_draft( - store=store, - msg=_msg("确认"), - lang="zh", - interaction_mode="expert", - requested_specialist="generalist", - base_system_prompt="base", - force_flag=True, - ) - assert out.decision_action == "stay_plan" - assert out.handled is True - assert out.result is not None - diff --git a/tests/test_plan_agent_v2_shadow.py b/tests/test_plan_agent_v2_shadow.py index a607cadd..ae359067 100644 --- a/tests/test_plan_agent_v2_shadow.py +++ b/tests/test_plan_agent_v2_shadow.py @@ -3,17 +3,17 @@ 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.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