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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-11 01:02:29 +08:00
parent e2fc72607e
commit 628a9dffd2
32 changed files with 127 additions and 870 deletions

View file

@ -6,27 +6,38 @@
- `runtime/`:运行时主域(agent、gateway 执行流、skills/hooks/extensions、operations)。 - `runtime/`:运行时主域(agent、gateway 执行流、skills/hooks/extensions、operations)。
- `interfaces/`:对外接口层(HTTP、WS、Admin、Gateway method bridge)。 - `interfaces/`:对外接口层(HTTP、WS、Admin、Gateway method bridge)。
- `platform/`:通用平台能力(配置、存储、LLM transport、文件层)。 - `svc/`:通用平台能力(配置、存储、LLM transport、文件层)。
- `runtime/workspaces/_system/`:内置系统提示词 Markdown 树(原顶层 `prompts/`,与按角色分区的 `workspaces/<role>/` 并列);`runtime/prompt_templates/` 为加载与 frontmatter 解析。 - `runtime/workspaces/_system/`:内置系统提示词 Markdown 树(与按角色分区的 `workspaces/<role>/` 并列);`runtime/prompt_templates/` 为加载与 frontmatter 解析。
- `tests/`:测试代码(按你的要求保持顶层)。 - `tests/`:测试代码(按你的要求保持顶层)。
- `docs/`:设计文档、运维说明、迁移记录。 - `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 内部建议边界
- `runtime/core/`:可复用执行内核(如 agent 执行管线聚合入口)。 - `runtime/core/`:可复用执行内核(如 agent 执行管线聚合入口)。
- `runtime/app/`:应用侧入口组织(面向外部流程的 runtime 编排)。 - `runtime/application/gateway/`:渠道入站用例(WhatsApp/Weixin/WeCom)。
- `runtime/agents|chat|orchestration|workers`:领域能力模块。 - `runtime/agents|chat|orchestration`:领域能力模块。
- `skills/`、`runtime/hooks`、`runtime/extensions`:可扩展能力载体(技能包在仓库根 `skills/`)。 - `skills/`、`runtime/hooks`、`runtime/extensions`:可扩展能力载体(技能包在仓库根 `skills/`)。
- `runtime/operations/scripts`:运维脚本与生成器。 - `runtime/operations/scripts`:运维脚本与生成器。
## 路径规范 ## 路径规范
- 运行时资源路径统一通过 `platform/config/runtime_paths.py` 获取。 - 运行时资源路径统一通过 `svc/config/runtime_paths.py` 获取。
- 禁止新增硬编码目录字符串(如直接拼 `oclaw/runtime/...`)。 - 禁止新增硬编码目录字符串(如直接拼 `oclaw/runtime/...`)。
## 依赖方向(原则) ## 依赖方向(原则)
- `interfaces -> runtime -> platform`(尽量单向)。 - `interfaces -> runtime -> svc`(尽量单向)。
- `runtime` 不反向依赖 `interfaces`(必要时通过协议/回调解耦)。 - `runtime` 不反向依赖 `interfaces`(必要时通过协议/回调解耦)。
- `docs/tests` 可依赖任意层,但不应反向影响运行时代码设计。 - `docs/tests` 可依赖任意层,但不应反向影响运行时代码设计。

View file

@ -18,18 +18,12 @@
- `AIA_ASSISTANT_MODE` - `AIA_ASSISTANT_MODE`
- 默认:空(代码内决定默认模式) - 默认:空(代码内决定默认模式)
- 作用:助手模式选择 - 作用:助手模式选择
- 生效:`oclaw/platform/llm/chat_models.py`, `oclaw/runtime/agents/factory.py` - 生效:`svc/llm/chat_models.py`, `runtime/agents/factory.py`
- `AIA_MANAGER_DECISION_MODE`
- 默认:空
- 作用:**Legacy(已断开)**:旧 manager 决策模式(如 `rule`)
- 说明:oclaw runtime 默认不再走 `CompositeOpsAgent` 的 manager 决策;该变量仅保留以便后续接回 legacy
- 生效:`oclaw/runtime/agents/manager_agent.py`(仅 legacy 链路)
- `AIA_TURN_MAX_TOOL_WORKERS` - `AIA_TURN_MAX_TOOL_WORKERS`
- 默认:`8` - 默认:`8`
- 作用:单轮工具并发上限 - 作用:单轮工具并发上限
- 生效:`oclaw/oclaw_runtime/gateway.py`, `oclaw/oclaw_runtime/direct_loop.py` - 生效:`runtime/gateway.py`, `runtime/direct_loop.py`
- `AIA_TURN_MAX_TOOL_ROUNDS` - `AIA_TURN_MAX_TOOL_ROUNDS`
- 默认:`100` - 默认:`100`
@ -95,28 +89,7 @@
- 默认:`0` - 默认:`0`
- 作用:控制 Admin 保存 `AIA_OCLAW_RETRYABLE_ERROR_CODES` 时的未知 code 行为 - 作用:控制 Admin 保存 `AIA_OCLAW_RETRYABLE_ERROR_CODES` 时的未知 code 行为
- 说明:`0`=过滤并告警;`1`=直接拒绝保存(HTTP 400) - 说明:`0`=过滤并告警;`1`=直接拒绝保存(HTTP 400)
- 生效:`oclaw/interfaces/admin/routes.py`, `oclaw/interfaces/admin/static/app.js` - 生效:`interfaces/admin/routes.py`, `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`
## LLM 传输与 replay(OpenAI 兼容) ## LLM 传输与 replay(OpenAI 兼容)
@ -162,26 +135,26 @@
## 工具执行与安全 ## 工具执行与安全
- `AIA_DISABLE_TOOL_CONFIRM`
- 默认:`0`
- 作用:**Legacy(已断开)**:是否禁用高风险工具确认
- 说明:oclaw 工具执行已移除执行时确认策略;该变量保留以便后续接回 legacy
- 生效:仅 legacy 链路(保留占位)
- `AIA_ENABLE_MCP_TOOLS` - `AIA_ENABLE_MCP_TOOLS`
- 默认:`1` - 默认:`1`
- 作用:启用 MCP 工具 - 作用:启用 MCP 工具
- 生效:`oclaw/tools/catalog.py` - 生效:`runtime/tools/catalog.py`
- `AIA_ENABLE_PLUGIN_TOOLS` - `AIA_ENABLE_PLUGIN_TOOLS`
- 默认:`0` - 默认:`1`(未设置时开启;Admin 可关)
- 作用:启用插件工具 - 作用:启用 Python 扩展插件工具
- 生效:`oclaw/tools/catalog.py` - 说明:与历史别名 `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` - `AIA_ENABLE_RUN_COMMAND`
- 默认:`0` - 默认:`0`
- 作用:允许高风险 `run_command` 工具 - 作用:允许高风险 `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` - `AIA_TOOL_LLM_MESSAGE_MAX_CHARS`
- 默认:`0`(不限制) - 默认:`0`(不限制)
@ -247,9 +220,9 @@
## MCP 与工具线侧 ## MCP 与工具线侧
- `AIA_MCP_SPECIALISTS` - `AIA_MCP_SPECIALISTS`
- 默认:`generalist` - 默认:`generalist,manager,ops`
- 作用:允许使用 MCP 的 specialist 列表 - 作用:未配置 `mcp_specialist_server_binding` 时,允许使用 MCP 的 specialist 列表
- 生效:`oclaw/tools/mcp/adapter.py` - 生效:`runtime/tools/mcp/adapter.py`
- `AIA_MCP_ENV_ALLOWLIST` - `AIA_MCP_ENV_ALLOWLIST`
- 默认:未设置时使用内置补充名单(仅用于**未**出现在 `mcp_local.env` 里、但要从宿主环境透传的变量名,见 `mcp_env._DEFAULT_ALLOWLIST`) - 默认:未设置时使用内置补充名单(仅用于**未**出现在 `mcp_local.env` 里、但要从宿主环境透传的变量名,见 `mcp_env._DEFAULT_ALLOWLIST`)

View file

@ -26,7 +26,7 @@
4. **有图**:调用 **`send_legacy_image_messages`**(`/chat/completions` 兼容路径,非 Responses API)。 4. **有图**:调用 **`send_legacy_image_messages`**(`/chat/completions` 兼容路径,非 Responses API)。
5. **输出解析**:**`legacy_image_turn_bundle`** 5. **输出解析**:**`legacy_image_turn_bundle`**
- 文本可为空;若有生成图则 **`materialize_legacy_response_output_attachments`** 写入本地 blob,产出 **`image_ref`**(或退化为 **`image_url`**)。 - 文本可为空;若有生成图则 **`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 行。 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 循环。 | | 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`**,避免分叉实现。 两处共用 **`platform/llm/image_legacy_client.py`**,避免分叉实现。
@ -78,7 +78,7 @@
## 8. 变更原则(避免波及其它链路) ## 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」通用能力。 2. **勿在** `openai_responses.py` **中为图片专家单独分支**,除非明确要做「非 legacy」通用能力。
3. 新增开关优先 **`AIA_IMAGE_*` / `AIA_IMAGE_SPECIALIST_*`**,勿复用 OCR 变量。 3. 新增开关优先 **`AIA_IMAGE_*` / `AIA_IMAGE_SPECIALIST_*`**,勿复用 OCR 变量。
4. UI 层附件渲染:**assistant_text 与 tool_result** 对称处理引用型附件,避免只修一端。 4. UI 层附件渲染:**assistant_text 与 tool_result** 对称处理引用型附件,避免只修一端。

View file

@ -27,7 +27,7 @@
5. **调用**:`send_video_generation_request` — `POST .../video-synthesis`(`X-DashScope-Async: enable`),再轮询 **`GET .../api/v1/tasks/{task_id}`** 直至 `SUCCEEDED` / 失败 / 超时。 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` 行(前端仍可尝试外链播放)。 6. **输出**:成功时从 `output.video_url` 下载为本地 blob,产出 **`video_ref`**;下载失败时退化为仅带 **`url`** 的 `video_ref` 行(前端仍可尝试外链播放)。
7. **占位文案**:`legacy_video_assistant_body_with_placeholder` 与图片专家对称(仅附件、无正文时插入中英文短句)。 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. 变更原则 ## 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`** 中为视频专家单独绕路,除非产品明确要求统一传输。 2. 勿在通用 **`openai_responses`** 中为视频专家单独绕路,除非产品明确要求统一传输。

View file

@ -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.

View file

@ -2760,7 +2760,7 @@ def build_admin_router() -> APIRouter:
store = get_assistant_store() store = get_assistant_store()
ctx = _resolve_auth(store, authorization) ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:tenant:write") _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 disabled = False
retry_mode = "first_round_only" retry_mode = "first_round_only"
loop_state_machine = True 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_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_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 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" turn_runner_impl = "oclaw"
# Oclaw takeover: legacy manager decision mode is disconnected (kept in DB for later).
manager_decision_mode = "" manager_decision_mode = ""
sse_raw = str(store.get_setting("AIA_SSE_QUEUE_MAXSIZE") or "").strip() 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 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 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() emcp_raw = str(store.get_setting("AIA_ENABLE_MCP_TOOLS") or "").strip().lower()
enable_mcp_tools = emcp_raw not in ("0", "false", "no", "off") enable_mcp_tools = emcp_raw not in ("0", "false", "no", "off")
epl_raw = str(store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") or "").strip().lower() # Align with catalog._plugin_tools_enabled: unset → ON (env may override).
enable_plugin_tools = epl_raw in ("1", "true", "yes", "on") 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). # 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). # 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") erc_sv = store.get_setting("AIA_ENABLE_RUN_COMMAND")
@ -2859,7 +2869,7 @@ def build_admin_router() -> APIRouter:
store = get_assistant_store() store = get_assistant_store()
ctx = _resolve_auth(store, authorization) ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:tenant:write") _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 disable_confirm = False
retry_mode = "first_round_only" retry_mode = "first_round_only"
sm = True sm = True
@ -2892,7 +2902,7 @@ def build_admin_router() -> APIRouter:
except Exception: except Exception:
tl_cap_n = 64_000 tl_cap_n = 64_000
emcp = bool(payload.get("enable_mcp_tools", True)) 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)) erc = bool(payload.get("enable_run_command", True))
tctx = bool(payload.get("tool_context_truncate_enabled", True)) tctx = bool(payload.get("tool_context_truncate_enabled", True))
ttft_debug = bool(payload.get("chat_show_ttft_debug", False)) ttft_debug = bool(payload.get("chat_show_ttft_debug", False))
@ -2952,19 +2962,14 @@ def build_admin_router() -> APIRouter:
except Exception: except Exception:
wc_in_q_n = 200 wc_in_q_n = 200
# Canonical professional prefix. # 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_WORKERS", str(tmw_n))
store.set_setting("AIA_TURN_MAX_TOOL_ROUNDS", str(tmr_n)) store.set_setting("AIA_TURN_MAX_TOOL_ROUNDS", str(tmr_n))
store.set_setting("AIA_TURN_MAX_CONTEXT_MESSAGES", str(tmc_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_SSE_QUEUE_MAXSIZE", str(sse_q_n))
store.set_setting("AIA_TOOL_LOG_MAX_CHARS", str(tl_cap_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_MCP_TOOLS", "1" if emcp else "0")
store.set_setting("AIA_ENABLE_PLUGIN_TOOLS", "1" if epl 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") store.set_setting("AIA_ENABLE_RUN_COMMAND", "1" if erc else "0")
# Keep runtime gate aligned with Admin toggle immediately. # Keep runtime gate aligned with Admin toggle immediately.
os.environ["AIA_ENABLE_RUN_COMMAND"] = "1" if erc else "0" 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) ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:tenant:write") _require_permission(ctx, "admin:tenant:write")
available = _ordered_mcp_roles() 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 = [x.strip().lower() for x in raw.split(",") if x.strip()]
allowed_set = set(allowed) allowed_set = set(allowed)
ordered = [x for x in available if x in allowed_set] ordered = [x for x in available if x in allowed_set]

View file

@ -5168,10 +5168,6 @@ async function renderPlugins() {
const installStatus = el("div", { class: "muted", text: "" }); const installStatus = el("div", { class: "muted", text: "" });
const preflightFixWrap = el("div"); const preflightFixWrap = el("div");
const toolPolicyStatus = el("div", { class: "muted", text: "" }); 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", { const turnMaxWorkersInput = el("input", {
class: "input", class: "input",
type: "number", type: "number",
@ -5196,14 +5192,6 @@ async function renderPlugins() {
value: String(Number(toolPolicy.turn_max_context_messages || 80)), value: String(Number(toolPolicy.turn_max_context_messages || 80)),
style: "max-width:120px", 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", { const sseQueueMaxsizeInput = el("input", {
class: "input", class: "input",
type: "number", type: "number",
@ -6638,8 +6626,7 @@ async function renderPlugins() {
applyWireRoleSelectorState(); applyWireRoleSelectorState();
applyWireRoleModeUiState(); applyWireRoleModeUiState();
const foldToolPolicy = pluginsFold(`【1】工具策略与已注册插件(${pluginCatalog.length})`, [ const foldToolPolicy = pluginsFold(`【1】工具策略与已注册插件(${pluginCatalog.length})`, [
el("div", { class: "muted", text: "Tool policy(确认 / 重试)与 Python 工具插件表" }), el("div", { class: "muted", text: "Tool policy(并发 / 轮次 / MCP·插件开关)与 Python 工具插件表" }),
legacyToolPolicyNote,
el("div", { class: "row" }, [ el("div", { class: "row" }, [
el("label", { text: "Turn max tool workers (1-32)" }), el("label", { text: "Turn max tool workers (1-32)" }),
turnMaxWorkersInput, turnMaxWorkersInput,
@ -6652,14 +6639,6 @@ async function renderPlugins() {
el("label", { text: "Turn max context messages (10-400)" }), el("label", { text: "Turn max context messages (10-400)" }),
turnMaxCtxInput, 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("div", { class: "row" }, [
el("label", { text: "SSE queue maxsize (200-50000)" }), el("label", { text: "SSE queue maxsize (200-50000)" }),
sseQueueMaxsizeInput, sseQueueMaxsizeInput,

View file

@ -6,10 +6,9 @@ import os
from typing import Any from typing import Any
from runtime.agents.agent_scope import resolve_default_agent_id 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 ( from runtime.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY, AGENT_PROFILE_BINDINGS_KEY,
SpecialistProfile,
normalize_specialist_id, normalize_specialist_id,
agent_role_ids, agent_role_ids,
MANAGER_AGENT_ID, MANAGER_AGENT_ID,
@ -63,7 +62,7 @@ def _build_executor_components(
viewer_username: str | None = None, viewer_username: str | None = None,
viewer_tenant_id: str | None = None, viewer_tenant_id: str | None = None,
) -> tuple[ ) -> tuple[
NetworkOpsAgent, Agent,
dict[str, SpecialistProfile], dict[str, SpecialistProfile],
object, object,
str, str,
@ -205,9 +204,15 @@ def _build_executor_components(
specialist_models[sid] = m specialist_models[sid] = m
specialist_modes[sid] = md specialist_modes[sid] = md
base_agent = NetworkOpsAgent( base_agent = Agent(
store=store, store=store,
tools=default_registry(
expert=expert_name_for_specialist("ops"),
specialist="ops",
store=store,
),
model=specialist_models.get("ops") or active_model, model=specialist_models.get("ops") or active_model,
system_prompt=default_system_prefix_for_specialist("ops", lang),
lang=lang, lang=lang,
llm_profile_mode=specialist_modes.get("ops") or active_mode, 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"] prof = specialist_profiles.get(sid) or specialist_profiles["generalist"]
chosen_model = specialist_models.get(prof.name) or base_agent.model 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) 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] = { reg_kw: dict[str, Any] = {
"expert": expert_name_for_specialist(prof.name), "expert": expert_name_for_specialist(prof.name),
"specialist": prof.name, "specialist": prof.name,

View file

@ -1,9 +1,15 @@
from __future__ import annotations 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 typing import Any
from runtime.chat.agent import Agent
from runtime.agent_context import build_role_system_context from runtime.agent_context import build_role_system_context
from runtime.chat.agent import Agent
from svc.persistence.sqlite_store import SqliteStore from svc.persistence.sqlite_store import SqliteStore
from runtime.tools import default_registry from runtime.tools import default_registry
@ -12,7 +18,10 @@ NETWORK_SYSTEM_PROMPT_ZH = build_role_system_context("ops")
class NetworkOpsAgent(Agent): 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__( def __init__(
self, self,
@ -27,7 +36,8 @@ class NetworkOpsAgent(Agent):
path_policy_user_id: str | None = None, path_policy_user_id: str | None = None,
) -> None: ) -> None:
tools = default_registry( tools = default_registry(
expert="network_ops", expert="network_ops+memory",
specialist="ops",
policy_session_id=policy_session_id, policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id, path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id, path_policy_user_id=path_policy_user_id,

View file

@ -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,
)

View file

@ -21,6 +21,15 @@ class SpecialistConfig:
default_tool_tags: frozenset[str] | None 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] = { SPECIALISTS: dict[SpecialistId, SpecialistConfig] = {
"ops": SpecialistConfig( "ops": SpecialistConfig(
specialist_id="ops", specialist_id="ops",
@ -140,6 +149,7 @@ __all__ = [
"MANAGER_AGENT_ID", "MANAGER_AGENT_ID",
"SpecialistConfig", "SpecialistConfig",
"SpecialistId", "SpecialistId",
"SpecialistProfile",
"SPECIALISTS", "SPECIALISTS",
"specialist_ids", "specialist_ids",
"default_system_prefix_for_specialist", "default_system_prefix_for_specialist",

View file

@ -1,2 +0,0 @@
"""Application-facing runtime entrypoints."""

View file

@ -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)),
)

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.adapter import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.compat import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.gateway_adapter import * # noqa: F403

View file

@ -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"]

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.manager import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.models import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.prompt_injector import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.state_store import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.switch import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.tool_policy import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.tool_specs import * # noqa: F403

View file

@ -1,2 +0,0 @@
from runtime.plan_agent_v2.trace import * # noqa: F403

View file

@ -3,14 +3,12 @@
from __future__ import annotations from __future__ import annotations
from runtime.tools.catalog import ( from runtime.tools.catalog import (
TOOL_FACTORIES,
default_registry, default_registry,
materialize_tool_specs, materialize_tool_specs,
tool_inventory, tool_inventory,
) )
__all__ = [ __all__ = [
"TOOL_FACTORIES",
"default_registry", "default_registry",
"materialize_tool_specs", "materialize_tool_specs",
"tool_inventory", "tool_inventory",

View file

@ -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: def _is_truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in ("1", "true", "yes", "on") 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: try:
raw_env = str(os.getenv("AIA_SKILL_TOOLCALL_ENABLED") or "").strip() raw_env = str(os.getenv("AIA_SKILL_TOOLCALL_ENABLED") or "").strip()
if raw_env: if raw_env:
@ -232,8 +247,8 @@ def materialize_tool_specs(
except Exception as exc: except Exception as exc:
logger.warning("mcp tool load skipped: %s", exc) logger.warning("mcp tool load skipped: %s", exc)
# collect: plugin # collect: plugin (Admin AIA_ENABLE_PLUGIN_TOOLS / env alias AIA_PLUGIN_TOOLS_ENABLED)
if not _is_truthy(os.getenv("AIA_PLUGIN_TOOLS_ENABLED", "1")): if not _plugin_tools_enabled(store):
return _resolve_tool_conflicts(collected) return _resolve_tool_conflicts(collected)
try: try:

View file

@ -262,7 +262,7 @@ def materialize_mcp_tools_for_specialist(
except Exception: except Exception:
raw_allowed = "" raw_allowed = ""
if not 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()} 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: if binding_server_ids is None and sp and sp not in allowed:
return [] return []

View file

@ -3,7 +3,6 @@
Uses ``{"image":...}/{"text":...}`` or typed compatible-mode blocks on ``/chat/completions`` only. Uses ``{"image":...}/{"text":...}`` or typed compatible-mode blocks on ``/chat/completions`` only.
The **image specialist** uses this module from: 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 - :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. 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: ) -> str:
"""If the model returned images but no visible text, use the standard chat placeholder (ZH/EN). """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(): if str(body_text or "").strip():
return str(body_text or "") return str(body_text or "")

View file

@ -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 {"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_")) 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_TOOLS_ENABLED", "1")
monkeypatch.setenv("AIA_PLUGIN_TOOL_IDS", "memory-wiki") monkeypatch.setenv("AIA_PLUGIN_TOOL_IDS", "memory-wiki")
specs = materialize_tool_specs() specs = materialize_tool_specs()

View file

@ -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

View file

@ -3,17 +3,17 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from svc.persistence.sqlite_store import SqliteStore from svc.persistence.sqlite_store import SqliteStore
from runtime.plan_agent_v2_adapter import evaluate_for_expert_mode 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.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.gateway_adapter import evaluate_gateway_expert_turn_shadow
from runtime.plan_agent_v2_manager import PlanModeManagerV2 from runtime.plan_agent_v2.manager import PlanModeManagerV2
from runtime.plan_agent_v2_models import PLAN_MODE_PLAN, PlanAgentStateV2 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.prompt_injector import build_plan_mode_prefix
from runtime.plan_agent_v2_state_store import PlanAgentStateStoreV2 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.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_specs import materialize_plan_mode_v2_tools
from runtime.plan_agent_v2_tool_policy import filter_tools_for_mode 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.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.plan_agent_v2 import should_route_to_v2 as should_route_to_v2_pkg
from runtime.gateway import OclawGatewayResult from runtime.gateway import OclawGatewayResult
from runtime.tools.base import ToolRegistry, ToolSpec from runtime.tools.base import ToolRegistry, ToolSpec