mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
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:
parent
e2fc72607e
commit
628a9dffd2
32 changed files with 127 additions and 870 deletions
|
|
@ -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` 可依赖任意层,但不应反向影响运行时代码设计。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
||||||
|
|
|
||||||
|
|
@ -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** 对称处理引用型附件,避免只修一端。
|
||||||
|
|
|
||||||
|
|
@ -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`** 中为视频专家单独绕路,除非产品明确要求统一传输。
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
"""Application-facing runtime entrypoints."""
|
|
||||||
|
|
||||||
|
|
@ -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)),
|
|
||||||
)
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.adapter import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.compat import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.gateway_adapter import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.manager import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.models import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.prompt_injector import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.state_store import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.switch import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.tool_policy import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.tool_specs import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from runtime.plan_agent_v2.trace import * # noqa: F403
|
|
||||||
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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 "")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue