From 96c3bf1dee81087bcdef179a41c94e0a3cb87f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E7=82=8E?= <635735027@qq.com> Date: Tue, 7 Apr 2026 16:17:17 +0800 Subject: [PATCH] feat: route staged scene skills through runtime Add registry-driven scene routing and multi-root skill loading so fault-details and 95598 scene skills can be triggered from natural language while still running through the browser-backed runtime. Co-Authored-By: Claude Sonnet 4.6 --- ...-04-06-scene-skill-runtime-routing-plan.md | 455 ++++++++++ ...4-06-scene-skill-runtime-routing-design.md | 291 +++++++ resources/rules.json | 2 + src/agent/task_runner.rs | 33 +- src/compat/browser_script_skill_tool.rs | 218 +++-- src/compat/config_adapter.rs | 48 +- src/compat/runtime.rs | 2 +- src/compat/workflow_executor.rs | 287 ++++++- src/config/settings.rs | 84 +- src/runtime/engine.rs | 107 ++- src/runtime/mod.rs | 5 + src/runtime/scene_registry.rs | 242 ++++++ tests/browser_script_skill_tool_test.rs | 123 ++- tests/compat_config_test.rs | 55 +- tests/compat_cron_test.rs | 2 +- tests/compat_memory_test.rs | 2 +- tests/compat_runtime_test.rs | 783 +++++++++++++++++- tests/deepseek_provider_test.rs | 4 +- tests/runtime_profile_test.rs | 118 ++- tests/scene_registry_test.rs | 223 +++++ tests/task_runner_test.rs | 2 + 21 files changed, 2846 insertions(+), 240 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md create mode 100644 docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md create mode 100644 src/runtime/scene_registry.rs create mode 100644 tests/scene_registry_test.rs diff --git a/docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md b/docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md new file mode 100644 index 0000000..90b5bc8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md @@ -0,0 +1,455 @@ +# Scene Skill Runtime Routing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a first scene-routing slice that recognizes staged business scenes from natural language and dispatches them through browser-backed execution, with `fault-details-report` using direct browser execution and `95598-repair-city-dispatch` using agent-mediated browser execution. + +**Architecture:** Introduce a small registry module that loads the first staged `scene.json` contracts plus runtime dispatch policy from the external `skill_staging` root. Route matched scenes through one of two paths: `direct_browser` scenes execute through compat orchestration without the model choosing tools, while `agent_browser` scenes stay in the existing agent flow but get scene-specific browser-first prompt injection. Both modes must still execute through the existing `BrowserScriptSkillTool` / browser backend path so the final business action uses browser-internal methods. + +**Tech Stack:** Rust, serde/JSON metadata loading, existing compat orchestration/runtime/workflow layers, browser-backed skill tools, focused `cargo test` coverage. + +--- + +## File Map + +**Create:** +- `src/runtime/scene_registry.rs` — load staged scene metadata, attach runtime dispatch policy, expose matching helpers for the first slice. +- `tests/scene_registry_test.rs` — focused tests for registry loading, matching, and policy behavior. + +**Modify:** +- `src/runtime/mod.rs` — export the new scene registry module/types used by runtime and compat layers. +- `src/compat/config_adapter.rs` — verify the moved external `skill_staging` root resolves to the staged `skills` child, and only change path resolution if a targeted regression proves it is insufficient. +- `src/runtime/engine.rs` — inject scene-specific browser-first contracts for `agent_browser` scenes and keep existing Zhihu prompt behavior intact. +- `src/compat/workflow_executor.rs` — extend route detection and direct execution support for `fault-details-report` using the browser-backed skill path. +- `src/compat/orchestration.rs` — let primary orchestration prefer direct execution for `direct_browser` scenes while leaving `agent_browser` scenes in the agent path. +- `src/compat/browser_script_skill_tool.rs` — expose the thinnest reusable browser-backed execution helper needed so direct scene execution can reuse the same `browser_script` semantics instead of drifting into a duplicate local path. +- `src/compat/runtime.rs` — ensure runtime sees the staged skills root and continues exposing browser-backed scene tools. +- `tests/compat_config_test.rs` — add path-resolution coverage for the staged external root. +- `tests/runtime_profile_test.rs` — add scene-specific instruction contract assertions. +- `tests/browser_script_skill_tool_test.rs` — add coverage for any new reusable direct-execution helper introduced in the browser-script layer. +- `tests/compat_runtime_test.rs` — add orchestration/direct-route coverage for the new scene behavior. + +**Reference:** +- `docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md` +- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\fault-details-report\scene.json` +- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\95598-repair-city-dispatch\scene.json` +- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\fault-details-report\SKILL.toml` +- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-repair-city-dispatch\SKILL.toml` + +### Task 1: Add Scene Registry And Matching + +**Files:** +- Create: `src/runtime/scene_registry.rs` +- Modify: `src/runtime/mod.rs` +- Test: `tests/scene_registry_test.rs` + +- [ ] **Step 1: Write the failing registry tests** + +Add tests that prove the first-slice registry can: +- load `fault-details-report` with `dispatch_mode = direct_browser` +- load `95598-repair-city-dispatch` with `dispatch_mode = agent_browser` +- match natural-language phrases like `导出故障明细` and `95598抢修市指监测` +- ignore missing/broken scene files without panicking + +Example assertions to include: + +```rust +assert_eq!(entry.scene_id, "fault-details-report"); +assert_eq!(entry.dispatch_mode, DispatchMode::DirectBrowser); +assert_eq!(entry.tool_name(), "fault-details-report.collect_fault_details"); +assert_eq!(entry.expected_domain, "__scene_fault_details__"); +``` + +```rust +assert_eq!(matched.scene_id, "95598-repair-city-dispatch"); +assert_eq!(matched.dispatch_mode, DispatchMode::AgentBrowser); +``` + +- [ ] **Step 2: Run the new registry tests to verify they fail** + +Run: +```bash +cargo test scene_registry --test scene_registry_test -- --nocapture +``` + +Expected: FAIL because `src/runtime/scene_registry.rs` and the exported registry APIs do not exist yet. + +- [ ] **Step 3: Implement the minimal scene registry module** + +Create `src/runtime/scene_registry.rs` with: +- a small deserialized scene metadata struct for `scene.json` +- a `DispatchMode` enum +- a single runtime registry-entry struct combining scene metadata plus runtime policy +- first-slice hardcoded runtime policy for the two initial scenes +- helper methods like: + +```rust +pub fn load_first_slice_scene_registry() -> Vec +pub fn match_scene_instruction(instruction: &str) -> Option +``` + +Use deterministic keyword/alias matching only. Do not add embeddings, fuzzy search, or generic scoring infrastructure beyond what the spec requires. + +- [ ] **Step 4: Export the registry from `src/runtime/mod.rs`** + +Expose the new types/helpers needed by runtime and compat layers, for example: + +```rust +mod scene_registry; + +pub use scene_registry::{ + load_first_slice_scene_registry, + match_scene_instruction, + DispatchMode, + SceneRegistryEntry, +}; +``` + +- [ ] **Step 5: Run the registry tests to verify they pass** + +Run: +```bash +cargo test scene_registry --test scene_registry_test -- --nocapture +``` + +Expected: PASS + +- [ ] **Step 6: Commit Task 1** + +```bash +git add src/runtime/scene_registry.rs src/runtime/mod.rs tests/scene_registry_test.rs +git commit -m "feat: add staged scene registry matching" +``` + +### Task 2: Verify Staged Skills Root Resolution + +**Files:** +- Modify if needed: `src/compat/config_adapter.rs:94` +- Modify if needed: `src/compat/runtime.rs:152` +- Test: `tests/compat_config_test.rs` + +- [ ] **Step 1: Write the targeted staged-root path test** + +Add a focused test that proves an external configured `skill_staging` root resolves to its `skills` child and preserves current nested-skills behavior. + +Add a test shape like: + +```rust +let staged_root = root.join("external/skill_staging"); +fs::create_dir_all(staged_root.join("skills")).unwrap(); +fs::create_dir_all(staged_root.join("scenes")).unwrap(); + +let settings = DeepSeekSettings { + api_key: "key".to_string(), + base_url: "https://api.deepseek.com".to_string(), + model: "deepseek-chat".to_string(), + skills_dir: Some(staged_root.clone()), +}; + +assert_eq!(resolve_skills_dir(&root, &settings), staged_root.join("skills")); +``` + +- [ ] **Step 2: Run the focused config test and record the actual result** + +Run: +```bash +cargo test --test compat_config_test resolve_skills_dir_ -- --nocapture +``` + +Expected: either +- PASS immediately, proving current path resolution already supports the staged-root contract, or +- FAIL with a concrete staged-root regression that justifies a minimal config fix. + +- [ ] **Step 3: Only if the staged-root test fails, implement the narrowest config fix** + +If the test fails, update `src/compat/config_adapter.rs` so configured external staged roots resolve to the staged skill package directory used by runtime skill loading. Keep the change narrow: +- preserve current behavior for normal `skills` roots +- add the smallest extra branch needed for the failing staged-root case +- do not create a broad path-discovery system + +- [ ] **Step 4: Verify runtime alignment with the resolved staged skills root** + +Confirm `src/compat/runtime.rs` still uses the resolved `skills_dir` as-is. If no runtime code change is needed after the test outcome, leave the file untouched and rely on test coverage. + +- [ ] **Step 5: Run the focused config tests to verify they pass** + +Run: +```bash +cargo test --test compat_config_test resolve_skills_dir_ -- --nocapture +``` + +Expected: PASS + +- [ ] **Step 6: Commit Task 2** + +```bash +git add src/compat/config_adapter.rs src/compat/runtime.rs tests/compat_config_test.rs +git commit -m "test: verify staged scene skill root resolution" +``` + +### Task 3: Inject Agent-Browser Scene Contract For 95598 + +**Files:** +- Modify: `src/runtime/engine.rs:135` +- Test: `tests/runtime_profile_test.rs` + +- [ ] **Step 1: Write the failing instruction-contract tests** + +Add focused tests proving that when the instruction matches `95598-repair-city-dispatch`, `RuntimeEngine::build_instruction(...)` includes a scene-specific browser contract requiring the tool `95598-repair-city-dispatch.collect_repair_orders` first. + +Example assertion pattern: + +```rust +let instruction = engine.build_instruction( + "请做95598抢修市指监测", + Some("https://example.invalid/dispatch"), + Some("95598抢修-市指"), + true, +); + +assert!(instruction.contains("95598-repair-city-dispatch.collect_repair_orders")); +assert!(instruction.contains("browser workflow, not a text-only task")); +assert!(instruction.contains("generic browser probing only after")); +``` + +Also add a negative control showing unrelated tasks do not receive this scene contract. + +- [ ] **Step 2: Run the focused runtime-profile tests to verify they fail** + +Run: +```bash +cargo test --test runtime_profile_test 95598 -- --nocapture +``` + +Expected: FAIL because no scene-specific contract is injected yet. + +- [ ] **Step 3: Implement minimal scene-aware prompt injection** + +Update `src/runtime/engine.rs` to: +- query the new scene matcher +- when the matched scene is `agent_browser`, append a scene execution contract section +- preserve existing Zhihu prompt sections unchanged + +Keep the contract explicit and narrow, for example: + +```text +Scene execution contract: +- Matched scene: 95598-repair-city-dispatch +- Required tool: 95598-repair-city-dispatch.collect_repair_orders +- This is a browser workflow, not a text-only task. +- Business data must come from the matched browser-backed scene tool. +- Only use generic browser probing after the matched scene tool fails. +``` + +Do not add hard allowed-tool narrowing in this task; slice one only promises instruction-level enforcement. + +- [ ] **Step 4: Run the focused runtime-profile tests to verify they pass** + +Run: +```bash +cargo test --test runtime_profile_test 95598 -- --nocapture +``` + +Expected: PASS + +- [ ] **Step 5: Commit Task 3** + +```bash +git add src/runtime/engine.rs tests/runtime_profile_test.rs +git commit -m "feat: inject 95598 scene browser contract" +``` + +### Task 4: Add Direct Browser Route For Fault Details + +**Files:** +- Modify: `src/compat/workflow_executor.rs:58` +- Modify: `src/compat/orchestration.rs:9` +- Modify: `src/compat/browser_script_skill_tool.rs:101` +- Test: `tests/browser_script_skill_tool_test.rs` +- Test: `tests/compat_runtime_test.rs` + +- [ ] **Step 1: Write the failing route-detection tests** + +Add focused tests that prove: +- natural language like `导出故障明细` is detected as a direct scene route +- primary orchestration is selected for that scene +- missing scene metadata leaves unrelated routing unchanged + +Target the existing routing seams with test shapes like: + +```rust +assert!(sgclaw::compat::orchestration::should_use_primary_orchestration( + "导出故障明细", + Some("https://example.invalid/fault"), + Some("故障明细"), +)); +``` + +and a focused route assertion using the new route enum variant. + +- [ ] **Step 2: Run the focused route tests to verify they fail** + +Run: +```bash +cargo test --test compat_runtime_test fault_details -- --nocapture +``` + +Expected: FAIL because no direct scene route exists yet. + +- [ ] **Step 3: Write the failing browser-script helper tests** + +Add focused tests in `tests/browser_script_skill_tool_test.rs` for the thinnest reusable helper needed by direct scene execution. The tests should prove that the helper: +- reads the packaged script from the skill root +- wraps args exactly like `BrowserScriptSkillTool` +- invokes browser `Eval` +- returns normalized serialized output +- fails clearly when required fields like `expected_domain` are missing + +- [ ] **Step 4: Run the focused browser-script helper tests to verify they fail** + +Run: +```bash +cargo test --test browser_script_skill_tool_test -- --nocapture +``` + +Expected: FAIL because the reusable helper does not exist yet. + +- [ ] **Step 5: Implement the reusable browser-backed execution helper** + +Update `src/compat/browser_script_skill_tool.rs` with the smallest reusable helper that direct scene execution can call while preserving the same `browser_script` semantics as normal skill execution. Keep it narrow: +- reuse the same script loading and wrapping rules +- require explicit `expected_domain` +- return normalized serialized output +- do not introduce a second browser-script execution model + +- [ ] **Step 6: Implement the direct fault-details route on top of that helper** + +Update `src/compat/workflow_executor.rs` to: +- introduce a new direct route variant for `fault-details-report` +- extend `detect_route(...)` to return it when the scene matcher says `direct_browser` +- build required args from scene runtime policy +- call the reusable browser-script execution helper +- return normalized serialized tool output + +If required scene args cannot be derived safely, return a clear failure instead of guessing. + +- [ ] **Step 7: Wire primary orchestration to prefer the new direct scene route** + +Update `src/compat/orchestration.rs` so `should_use_primary_orchestration(...)` and the direct execution branch treat the new `fault-details-report` route like the existing direct Zhihu routes. + +- [ ] **Step 8: Run the focused direct-route and helper tests to verify they pass** + +Run: +```bash +cargo test --test browser_script_skill_tool_test -- --nocapture && cargo test --test compat_runtime_test fault_details -- --nocapture +``` + +Expected: PASS + +- [ ] **Step 9: Commit Task 4** + +```bash +git add src/compat/browser_script_skill_tool.rs src/compat/workflow_executor.rs src/compat/orchestration.rs tests/browser_script_skill_tool_test.rs tests/compat_runtime_test.rs +git commit -m "feat: add direct fault-details scene routing" +``` + +### Task 5: Verify Tool Exposure, Browser-Surface Fallback, And Mixed Routing Together + +**Files:** +- Modify if needed: `src/compat/runtime.rs:142` +- Test: `tests/compat_runtime_test.rs` +- Test: `tests/runtime_profile_test.rs` +- Test: `tests/scene_registry_test.rs` + +- [ ] **Step 1: Write the failing integration-shape tests** + +Add focused assertions that prove the mixed-mode design works together: +- staged browser-backed tool names are exposed +- `fault-details-report` uses direct routing +- `95598-repair-city-dispatch` stays in the agent path but gets scene-specific browser-first instruction injection +- browser-surface-disabled turns do not gain scene browser contracts +- browser-surface-disabled turns do not trigger `direct_browser` scene execution +- missing scene metadata preserves unchanged runtime behavior for unrelated tasks +- unrelated Zhihu behavior still works the same way + +Use existing test seams instead of broad integration scaffolding. + +- [ ] **Step 2: Run the focused mixed-routing tests to verify they fail** + +Run: +```bash +cargo test --test scene_registry_test -- --nocapture && cargo test --test compat_runtime_test scene_ -- --nocapture && cargo test --test runtime_profile_test scene_ -- --nocapture +``` + +Expected: FAIL until the mixed-routing assertions are implemented. + +- [ ] **Step 3: Make the minimum runtime adjustments needed** + +Only if required by the tests, adjust `src/compat/runtime.rs` so the loaded staged skills from the resolved external root are visible in the same way as existing browser-backed skills. Keep the shape of `build_browser_script_skill_tools(...)` and runtime tool assembly intact. + +- [ ] **Step 4: Run the focused mixed-routing tests to verify they pass** + +Run: +```bash +cargo test --test scene_registry_test -- --nocapture && cargo test --test compat_runtime_test scene_ -- --nocapture && cargo test --test runtime_profile_test scene_ -- --nocapture +``` + +Expected: PASS + +- [ ] **Step 5: Run the broader targeted verification sweep** + +Run: +```bash +cargo test --test browser_script_skill_tool_test -- --nocapture && cargo test --test scene_registry_test -- --nocapture && cargo test --test compat_config_test resolve_skills_dir_ -- --nocapture && cargo test --test runtime_profile_test -- --nocapture && cargo test --test compat_runtime_test fault_details -- --nocapture +``` + +Expected: PASS + +- [ ] **Step 6: Commit Task 5** + +```bash +git add src/compat/runtime.rs tests/scene_registry_test.rs tests/compat_runtime_test.rs tests/runtime_profile_test.rs +git commit -m "feat: wire staged scene mixed routing" +``` + +### Task 6: Final Verification And Handoff + +**Files:** +- Verify: `src/runtime/scene_registry.rs` +- Verify: `src/compat/config_adapter.rs` +- Verify: `src/runtime/engine.rs` +- Verify: `src/compat/workflow_executor.rs` +- Verify: `src/compat/orchestration.rs` +- Verify: `tests/scene_registry_test.rs` +- Verify: `tests/compat_config_test.rs` +- Verify: `tests/runtime_profile_test.rs` +- Verify: `tests/compat_runtime_test.rs` + +- [ ] **Step 1: Run the full focused verification set** + +Run: +```bash +cargo test --test scene_registry_test -- --nocapture && cargo test --test compat_config_test -- --nocapture && cargo test --test runtime_profile_test -- --nocapture && cargo test --test compat_runtime_test -- --nocapture +``` + +Expected: PASS + +- [ ] **Step 2: If any test fails, fix only the minimal root cause and re-run the same command** + +Do not broaden scope. Keep fixes limited to scene registry, path resolution, prompt injection, or direct routing. + +- [ ] **Step 3: Review the resulting diff against the spec** + +Manually verify: +- `fault-details-report` is direct-browser +- `95598-repair-city-dispatch` is agent-browser +- both still use browser-backed execution semantics +- no broad Zhihu refactor slipped in +- the new scene-routing abstraction stays registry-driven + +- [ ] **Step 4: Commit the final verification pass** + +```bash +git add src/runtime/scene_registry.rs src/runtime/mod.rs src/compat/config_adapter.rs src/runtime/engine.rs src/compat/workflow_executor.rs src/compat/orchestration.rs src/compat/runtime.rs tests/scene_registry_test.rs tests/compat_config_test.rs tests/runtime_profile_test.rs tests/compat_runtime_test.rs +git commit -m "test: verify scene skill runtime routing" +``` diff --git a/docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md b/docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md new file mode 100644 index 0000000..91cc880 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md @@ -0,0 +1,291 @@ +# Scene Skill Runtime Routing Design + +**Goal:** Add a minimal, extensible scene-routing layer so staged business scenes can be triggered from natural language while still executing through the existing browser-backed skill path. + +**Architecture:** Introduce a registry-driven scene contract loader that reads staged `scene.json` metadata, matches user instructions to a scene, and chooses one of two dispatch modes: direct browser execution or agent-mediated browser execution. Both modes must reuse the same browser-backed skill tool path so scene skills continue to execute through browser-internal methods rather than text-only responses or local fake execution. + +**Tech Stack:** Rust, serde/JSON scene metadata loading, existing `BrowserScriptSkillTool`, existing compat runtime / runtime engine / workflow executor layers, focused Rust unit tests. + +--- + +## Problem Statement + +The codebase already supports two useful but separate ideas: + +1. **Zhihu special-case runtime routing** + - `src/compat/workflow_executor.rs` detects a narrow set of Zhihu tasks and can execute them directly without relying on the model to choose tools. + - This is stable, but not extensible for a growing set of business scenes. + +2. **Browser-backed skills** + - `src/compat/runtime.rs` loads skills and exposes `browser_script` tools through `BrowserScriptSkillTool`. + - `src/compat/browser_script_skill_tool.rs` executes those tools by calling the browser backend with `Action::Eval`, so actual execution already happens through browser-internal methods. + - This is extensible, but tool choice currently depends too heavily on generic agent behavior. + +The staged business scenes under `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging` already provide most of the metadata needed to bridge these two ideas. We need a first integration slice that uses scene metadata to improve routing without turning every scene into a hardcoded Zhihu-style exception. + +## Design Goals + +- Support natural-language triggering for staged scenes. +- Preserve the current browser-backed execution contract: both scene modes must end in browser-internal execution via the existing browser tool path. +- Support both dispatch styles discussed with the user: + - one scene that can execute without the model + - one scene that still uses the model for orchestration +- Keep the first slice small, covering only: + - `fault-details-report` + - `95598-repair-city-dispatch` +- Keep the design extensible so more scene skills can be added in the same directory later without more ad hoc routing branches. +- Avoid broad refactors or a new generic workflow platform in this slice. + +## Non-Goals + +- Do not build a scene editor, scene UI, or registry authoring workflow. +- Do not implement a full artifact post-processing platform for all report/monitor types. +- Do not convert every staged scene into a direct Rust executor. +- Do not replace the existing Zhihu-specific runtime path in this slice. + +## Source of Truth and Paths + +### Staged scene source +The new staged scene source for this work is: + +- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging` + +The runtime integration must read scene metadata from this location for the initial slice. + +### Existing runtime integration points +- `src/compat/config_adapter.rs` — current skills-dir resolution logic +- `src/compat/runtime.rs` — current skill loading and browser-script tool exposure +- `src/runtime/engine.rs` — runtime instruction building and allowed-tool shaping +- `src/compat/workflow_executor.rs` — existing direct execution routing pattern +- `src/compat/browser_script_skill_tool.rs` — browser-backed execution path for `browser_script` tools + +## Scene Contract Model + +Introduce a small internal scene contract model derived from `scene.json` and paired runtime policy. The loader should extract only the fields needed for the first slice: + +- `id` +- `name` +- `summary` +- `tags` +- `inputs` +- `outputs` +- `skill.package` +- `skill.tool` +- `skill.artifact_type` + +Add a runtime-only dispatch policy associated with each enabled scene inside the same internal registry entry used at runtime: + +- `dispatch_mode` + - `direct_browser` + - `agent_browser` +- `expected_domain` + - bare hostname required by the underlying browser-backed skill tool +- optional `aliases` + - additional deterministic keywords/phrases when `id/name/summary/tags` are not enough for first-slice matching +- optional `default_args` + - runtime-supplied tool arguments when a scene needs fixed/default values for first execution + +This runtime policy may be hardcoded in Rust for the first slice, but it must be represented through one consistent scene-routing abstraction so future scenes can join the same path without rewriting the whole design. The abstraction should be a single registry entry type that combines scene metadata with runtime dispatch policy, rather than a metadata loader plus a separate ad hoc match table. + +## Dispatch Modes + +### 1. `direct_browser` +This mode is for scenes whose collection flow is deterministic enough to bypass the model once the scene is recognized. + +**Initial scene:** `fault-details-report` + +**Behavior:** +- Detect scene from natural language. +- Resolve the corresponding browser-backed skill tool. +- Execute it directly through the existing browser-backed skill path. +- Return the collected artifact result without delegating tool choice to the model. + +**Important constraint:** +This is not a local fake implementation. Even in direct mode, the actual collection must still go through the existing browser-backed execution path, meaning it ultimately uses browser-internal methods through the browser backend. + +### 2. `agent_browser` +This mode is for scenes that still benefit from agent orchestration, explanation, or downstream reasoning, but whose business data must still come from browser-backed execution. + +**Initial scene:** `95598-repair-city-dispatch` + +**Behavior:** +- Detect scene from natural language. +- Inject a strong scene execution contract into the runtime instruction. +- Treat calling the matching browser-backed skill tool first as a policy requirement for the scene. +- In slice one, enforce that policy through scene-specific instruction injection rather than a hard runtime gate. +- Allow generic browser probing only as a fallback after the scene tool fails. +- Keep final explanation/summarization in the agent path, but never let the model invent business data. + +## Matching Strategy + +Implement a minimal matcher that scores user instructions against enabled scenes using: + +- scene `id` +- scene `name` +- scene `summary` +- scene `tags` +- optional runtime aliases for the first slice + +The matcher should be intentionally simple and deterministic in this slice. Avoid semantic embedding or fuzzy retrieval infrastructure. + +Expected first-slice matches: + +- `fault-details-report` + - phrases like `故障明细`, `故障明细报表`, `导出故障明细` +- `95598-repair-city-dispatch` + - phrases like `95598抢修市指`, `市指抢修监测`, `95598抢修队列` + +If no scene matches, runtime behavior must remain unchanged. + +## Runtime Loading Design + +### Scene registry loading +Add a small loader that reads enabled scenes from the staged scene directory. For the first slice, it is acceptable to read the concrete scene files directly instead of implementing a full generic registry parser, as long as the resulting module boundary is registry-oriented rather than one-off. + +The loader should: +- resolve the staged scene root +- read the two initial `scene.json` files +- deserialize them into a small internal scene metadata struct +- pair them with dispatch policy in the same in-memory registry entry +- ignore malformed or missing scenes safely +- never fail runtime startup solely because one or both initial scene files are absent + +### Skill loading alignment +The corresponding skill packages must still be loaded into runtime skill exposure so the browser-backed tools are available to the runtime. + +For this slice, the staged scene source and staged skill packages should be treated as coming from the same external root: +- staged scenes under `.../skill_staging/scenes` +- staged skill packages under `.../skill_staging/skills` + +The implementation must make that staged skill package root visible to runtime skill loading. If current `skills_dir` resolution cannot express that directly, the design should extend configuration/path resolution to support a staged external skills root explicitly rather than relying on implicit mirroring. + +## Execution Design + +### Direct browser path (`fault-details-report`) +Add a direct execution route that is scene-driven rather than Zhihu-specific. + +High-level flow: +1. Runtime receives user instruction. +2. Scene matcher recognizes `fault-details-report`. +3. Runtime resolves the browser-backed tool name `fault-details-report.collect_fault_details`. +4. Runtime builds the required tool arguments, including: + - `expected_domain` from the matched scene's runtime policy + - any first-slice scene inputs that can be deterministically derived from the current request/context + - any fixed/default args declared in runtime policy +5. Runtime executes that skill through the existing browser-backed mechanism. +6. Runtime returns normalized tool output as the direct route result. + +Input/argument rules for the first slice: +- Direct execution is only allowed when all required tool arguments are available. +- `expected_domain` must always come from runtime scene policy, not from model inference. +- If a required scene/tool input cannot be derived from the user request or current browser context, the direct route must fail clearly instead of fabricating values. +- The first slice may keep direct-mode argument mapping intentionally narrow; unsupported requests should fall back safely rather than guessing. + +Return-shape rule for the first slice: +- The direct route should return normalized serialized tool output (for example, the tool payload string or normalized JSON text), not a model-authored prose summary. This keeps direct mode deterministic and makes the browser-backed result explicit. + +Implementation note: +The cleanest first slice is to add a small scene direct-execution helper in the compat runtime/workflow area that invokes the already-loaded browser-backed skill tool abstraction rather than duplicating browser request logic. + +### Agent browser path (`95598-repair-city-dispatch`) +This path stays inside the agent flow. + +High-level flow: +1. Runtime receives user instruction. +2. Scene matcher recognizes `95598-repair-city-dispatch`. +3. `RuntimeEngine::build_instruction` injects a scene execution contract containing: + - the matched scene name + - the required tool name `95598-repair-city-dispatch.collect_repair_orders` + - explicit requirement that this is a browser workflow, not a text-only task + - explicit requirement that business data must come from the browser-backed scene tool + - fallback rules for generic browser probing only after tool failure +4. Agent runs and chooses the required tool. +5. Tool executes through the existing browser-backed skill path. +6. Agent may summarize the result, but cannot fabricate data. + +Enforcement note for the first slice: +- The `agent_browser` guarantee is primarily an instruction-contract guarantee in slice one. +- If allowed-tool shaping can narrow the exposed tool set for a matched scene without destabilizing existing behavior, that is a valid enhancement, but it is not required for the first slice. +- The minimum guaranteed behavior for slice one is strong scene-specific prompt injection plus preservation of the rule that the model must not invent collected business data. + +## Browser Execution Contract + +This requirement is non-negotiable for both dispatch modes: + +- scene skills must execute like the Zhihu flow in the sense that the final business action is performed through browser-internal methods +- scene skills must not devolve into text-only pseudo execution +- direct mode and agent mode both reuse the existing browser-backed skill execution path + +Concretely, the final path for scene skill execution should remain compatible with: +- `BrowserScriptSkillTool` +- browser backend invocation +- browser-side `Eval` / browser action execution semantics + +## Error Handling + +- **Scene metadata missing or invalid:** skip that scene and continue with normal runtime behavior. +- **Scene matched but skill/tool unavailable:** do not crash; log enough context for diagnosis and fall back safely. +- **Browser surface unavailable:** disable scene browser routing for that turn and fall back to current non-scene behavior. +- **Tool execution fails in `agent_browser` mode:** allow existing fallback prompt behavior to continue, but preserve the rule that the model cannot invent collected data. +- **Tool execution fails in `direct_browser` mode:** return a concise execution failure instead of pretending collection succeeded. + +## Extensibility Rules + +This slice should be built so future scene additions only need: +- a new scene metadata file under the staged scene path +- a matching skill package/tool +- a dispatch-mode declaration/policy +- optional aliases if the natural-language names are not sufficiently explicit + +Avoid these anti-patterns: +- per-scene `if user said X then do Y` branches scattered across runtime files +- duplicating browser execution code for each scene +- binding future scenes to Zhihu-specific assumptions + +## Testing Strategy + +### Scene registry tests +- load valid metadata for `fault-details-report` +- load valid metadata for `95598-repair-city-dispatch` +- ignore broken/missing scene files safely + +### Matching tests +- instruction variants match `fault-details-report` +- instruction variants match `95598-repair-city-dispatch` +- unrelated instructions do not match + +### Instruction-building tests +- `agent_browser` scene injects the required browser-first scene contract +- unmatched instructions do not gain scene-specific constraints +- Zhihu-specific instruction behavior remains unchanged + +### Tool exposure tests +- staged skills from the moved path are loaded into runtime +- browser-backed tool names include: + - `fault-details-report.collect_fault_details` + - `95598-repair-city-dispatch.collect_repair_orders` + +### Direct execution tests +- `fault-details-report` direct route invokes the browser-backed tool path rather than bypassing the browser layer +- direct route returns failure cleanly when tool execution fails + +## Recommended First Implementation Slice + +1. Add a tiny scene metadata loader and dispatch-mode policy module. +2. Extend runtime path resolution so the moved staged skills/scenes are visible. +3. Add deterministic scene matching for the two initial scenes. +4. Implement `agent_browser` instruction injection for `95598-repair-city-dispatch`. +5. Implement `direct_browser` execution for `fault-details-report` using the browser-backed skill path. +6. Add focused tests for matching, loading, tool exposure, and direct-vs-agent behavior. + +## Open Design Constraint Captured From Discussion + +The user explicitly requires the following combined behavior: + +- support both kinds of scene execution in the same architecture +- one initial scene should be able to execute without the model +- one initial scene should execute through the model +- both must still use browser-internal execution methods like the Zhihu path +- the design must stay extensible because more staged skills may be added under the same path later + +This design is built around those exact constraints. diff --git a/resources/rules.json b/resources/rules.json index 5a5a175..ba2d1fd 100644 --- a/resources/rules.json +++ b/resources/rules.json @@ -6,6 +6,8 @@ "oa.example.com", "erp.example.com", "hr.example.com", + "sgcc.example.invalid", + "95598.example.invalid", "baidu.com", "www.baidu.com", "zhihu.com", diff --git a/src/agent/task_runner.rs b/src/agent/task_runner.rs index 3dc6cbe..686a2a9 100644 --- a/src/agent/task_runner.rs +++ b/src/agent/task_runner.rs @@ -9,6 +9,7 @@ use crate::config::SgClawSettings; use crate::pipe::{ AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport, }; +use crate::runtime::RuntimeEngine; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentRuntimeContext { @@ -175,7 +176,7 @@ pub fn run_submit_task( let completion = match context.load_sgclaw_settings() { Ok(Some(settings)) => { - let resolved_skills_dir = + let resolved_skills_dirs = resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings); let _ = sink.send(&AgentMessage::LogEntry { level: "info".to_string(), @@ -188,7 +189,7 @@ pub fn run_submit_task( }); let _ = sink.send(&AgentMessage::LogEntry { level: "info".to_string(), - message: format!("skills dir resolved to {}", resolved_skills_dir.display()), + message: format!("skills dirs resolved to [{}]", resolved_skills_dirs.iter().map(|d| d.display().to_string()).collect::>().join(", ")), }); let _ = sink.send(&AgentMessage::LogEntry { level: "info".to_string(), @@ -197,11 +198,13 @@ pub fn run_submit_task( settings.runtime_profile, settings.skills_prompt_mode ), }); - if crate::compat::orchestration::should_use_primary_orchestration( - &instruction, - task_context.page_url.as_deref(), - task_context.page_title.as_deref(), - ) { + if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled() + && crate::compat::orchestration::should_use_primary_orchestration( + &instruction, + task_context.page_url.as_deref(), + task_context.page_title.as_deref(), + ) + { let _ = send_mode_log(sink, "zeroclaw_process_message_primary"); match crate::compat::orchestration::execute_task_with_sgclaw_settings( transport, @@ -307,7 +310,7 @@ pub fn run_submit_task_with_browser_backend( let completion = match context.load_sgclaw_settings() { Ok(Some(settings)) => { - let resolved_skills_dir = + let resolved_skills_dirs = resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings); let _ = sink.send(&AgentMessage::LogEntry { level: "info".to_string(), @@ -320,7 +323,7 @@ pub fn run_submit_task_with_browser_backend( }); let _ = sink.send(&AgentMessage::LogEntry { level: "info".to_string(), - message: format!("skills dir resolved to {}", resolved_skills_dir.display()), + message: format!("skills dirs resolved to [{}]", resolved_skills_dirs.iter().map(|d| d.display().to_string()).collect::>().join(", ")), }); let _ = sink.send(&AgentMessage::LogEntry { level: "info".to_string(), @@ -329,11 +332,13 @@ pub fn run_submit_task_with_browser_backend( settings.runtime_profile, settings.skills_prompt_mode ), }); - if crate::compat::orchestration::should_use_primary_orchestration( - &instruction, - task_context.page_url.as_deref(), - task_context.page_title.as_deref(), - ) { + if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled() + && crate::compat::orchestration::should_use_primary_orchestration( + &instruction, + task_context.page_url.as_deref(), + task_context.page_title.as_deref(), + ) + { let _ = send_mode_log(sink, "zeroclaw_process_message_primary"); match crate::compat::orchestration::execute_task_with_browser_backend( sink, diff --git a/src/compat/browser_script_skill_tool.rs b/src/compat/browser_script_skill_tool.rs index 7c34c49..f12603f 100644 --- a/src/compat/browser_script_skill_tool.rs +++ b/src/compat/browser_script_skill_tool.rs @@ -12,25 +12,31 @@ use zeroclaw::tools::{Tool, ToolResult}; use crate::browser::BrowserBackend; use crate::pipe::Action; +pub struct BrowserScriptInvocation<'a> { + pub tool: &'a SkillTool, + pub skill_root: &'a Path, +} + pub struct BrowserScriptSkillTool { tool_name: String, tool_description: String, - script_path: PathBuf, + tool: SkillTool, + skill_root: PathBuf, args: HashMap, browser_tool: Arc, } -impl BrowserScriptSkillTool { - pub fn new( - skill_name: &str, - tool: &SkillTool, - skill_root: &Path, - browser_tool: Arc, - ) -> anyhow::Result { - let script_path = skill_root.join(&tool.command); - let canonical_skill_root = skill_root +impl BrowserScriptInvocation<'_> { + fn script_path(&self) -> PathBuf { + self.skill_root.join(&self.tool.command) + } + + fn canonical_script_path(&self) -> anyhow::Result { + let script_path = self.script_path(); + let canonical_skill_root = self + .skill_root .canonicalize() - .unwrap_or_else(|_| skill_root.to_path_buf()); + .unwrap_or_else(|_| self.skill_root.to_path_buf()); let canonical_script_path = script_path.canonicalize().map_err(|err| { anyhow::anyhow!( "failed to resolve browser script {}: {err}", @@ -43,11 +49,25 @@ impl BrowserScriptSkillTool { canonical_script_path.display() ); } + Ok(canonical_script_path) + } +} + +impl BrowserScriptSkillTool { + pub fn new( + skill_name: &str, + tool: &SkillTool, + skill_root: &Path, + browser_tool: Arc, + ) -> anyhow::Result { + let invocation = BrowserScriptInvocation { tool, skill_root }; + invocation.canonical_script_path()?; Ok(Self { tool_name: format!("{}.{}", skill_name, tool.name), tool_description: tool.description.clone(), - script_path: canonical_script_path, + tool: tool.clone(), + skill_root: skill_root.to_path_buf(), args: tool.args.clone(), browser_tool, }) @@ -99,81 +119,12 @@ impl Tool for BrowserScriptSkillTool { } async fn execute(&self, args: Value) -> anyhow::Result { - let mut args = match args { - Value::Object(args) => args, - other => { - return Ok(failed_tool_result(format!( - "expected object arguments, got {other}" - ))) - } - }; - - let raw_expected_domain = match args.remove("expected_domain") { - Some(Value::String(value)) if !value.trim().is_empty() => value, - Some(other) => { - return Ok(failed_tool_result(format!( - "expected_domain must be a non-empty string, got {other}" - ))) - } - None => { - return Ok(failed_tool_result( - "missing required field expected_domain".to_string(), - )) - } - }; - let expected_domain = match normalize_domain_like(&raw_expected_domain) { - Some(value) => value, - None => { - return Ok(failed_tool_result(format!( - "expected_domain must resolve to a hostname, got {raw_expected_domain:?}" - ))) - } - }; - - for required_arg in self.args.keys() { - if !args.contains_key(required_arg) { - return Ok(failed_tool_result(format!( - "missing required field {required_arg}" - ))); - } - } - - let script_body = match fs::read_to_string(&self.script_path) { - Ok(value) => value, - Err(err) => { - return Ok(failed_tool_result(format!( - "failed to read browser script {}: {err}", - self.script_path.display() - ))) - } - }; - - let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone())); - let result = match self.browser_tool.invoke( - Action::Eval, - json!({ "script": wrapped_script }), - &expected_domain, - ) { - Ok(result) => result, - Err(err) => return Ok(failed_tool_result(err.to_string())), - }; - - if !result.success { - return Ok(failed_tool_result(format_browser_script_error( - &result.data, - ))); - } - - let payload = result - .data - .get("text") - .cloned() - .unwrap_or_else(|| result.data.clone()); - Ok(ToolResult { - success: true, - output: stringify_tool_payload(&payload)?, - error: None, - }) + execute_browser_script_impl( + &self.tool, + &self.skill_root, + self.browser_tool.clone(), + args, + ) } } @@ -211,6 +162,99 @@ pub fn build_browser_script_skill_tools( Ok(tools) } +pub async fn execute_browser_script_tool( + tool: &SkillTool, + skill_root: &Path, + browser_tool: Arc, + args: Value, +) -> anyhow::Result { + execute_browser_script_impl(tool, skill_root, browser_tool, args) +} + +fn execute_browser_script_impl( + tool: &SkillTool, + skill_root: &Path, + browser_tool: Arc, + args: Value, +) -> anyhow::Result { + let invocation = BrowserScriptInvocation { tool, skill_root }; + let script_path = invocation.canonical_script_path()?; + + let mut args = match args { + Value::Object(args) => args, + other => { + return Ok(failed_tool_result(format!( + "expected object arguments, got {other}" + ))) + } + }; + + let raw_expected_domain = match args.remove("expected_domain") { + Some(Value::String(value)) if !value.trim().is_empty() => value, + Some(other) => { + return Ok(failed_tool_result(format!( + "expected_domain must be a non-empty string, got {other}" + ))) + } + None => { + return Ok(failed_tool_result( + "missing required field expected_domain".to_string(), + )) + } + }; + let expected_domain = match normalize_domain_like(&raw_expected_domain) { + Some(value) => value, + None => { + return Ok(failed_tool_result(format!( + "expected_domain must resolve to a hostname, got {raw_expected_domain:?}" + ))) + } + }; + + for required_arg in tool.args.keys() { + if !args.contains_key(required_arg) { + return Ok(failed_tool_result(format!( + "missing required field {required_arg}" + ))); + } + } + + let script_body = match fs::read_to_string(&script_path) { + Ok(value) => value, + Err(err) => { + return Ok(failed_tool_result(format!( + "failed to read browser script {}: {err}", + script_path.display() + ))) + } + }; + + let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone())); + let result = match browser_tool.invoke( + Action::Eval, + json!({ "script": wrapped_script }), + &expected_domain, + ) { + Ok(result) => result, + Err(err) => return Ok(failed_tool_result(err.to_string())), + }; + + if !result.success { + return Ok(failed_tool_result(format_browser_script_error(&result.data))); + } + + let payload = result + .data + .get("text") + .cloned() + .unwrap_or_else(|| result.data.clone()); + Ok(ToolResult { + success: true, + output: stringify_tool_payload(&payload)?, + error: None, + }) +} + fn wrap_browser_script(script_body: &str, args: &Value) -> String { format!( "(function() {{\nconst args = {};\n{}\n}})()", diff --git a/src/compat/config_adapter.rs b/src/compat/config_adapter.rs index 8308901..6bfb7ce 100644 --- a/src/compat/config_adapter.rs +++ b/src/compat/config_adapter.rs @@ -12,6 +12,7 @@ use crate::runtime::RuntimeProfile; const SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace"; const SKILLS_DIR_NAME: &str = "skills"; +const STAGED_SKILLS_DIR_NAME: &str = "skill_staging"; pub fn build_zeroclaw_config( workspace_root: &Path, @@ -87,15 +88,41 @@ pub fn zeroclaw_default_skills_dir(workspace_root: &Path) -> PathBuf { zeroclaw_workspace_dir(workspace_root).join(SKILLS_DIR_NAME) } -pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> PathBuf { - resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref()) +pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> Vec { + resolve_skills_dir_paths(workspace_root, &settings.skills_dir) } pub fn resolve_skills_dir_from_sgclaw_settings( workspace_root: &Path, settings: &SgClawSettings, -) -> PathBuf { - resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref()) +) -> Vec { + resolve_skills_dir_paths(workspace_root, &settings.skills_dir) +} + +pub fn resolve_scene_skills_dir_from_sgclaw_settings( + workspace_root: &Path, + settings: &SgClawSettings, +) -> Vec { + resolve_skills_dir_from_sgclaw_settings(workspace_root, settings) + .into_iter() + .flat_map(|dir| { + let scene_dir = resolve_scene_skills_dir_path(dir.clone()); + if scene_dir != dir { + vec![dir, scene_dir] + } else { + vec![dir] + } + }) + .collect() +} + +pub fn resolve_scene_skills_dir_path(skills_dir: PathBuf) -> PathBuf { + let staged_skills_dir = skills_dir.join(STAGED_SKILLS_DIR_NAME).join(SKILLS_DIR_NAME); + if staged_skills_dir.is_dir() { + staged_skills_dir + } else { + skills_dir + } } fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf { @@ -111,8 +138,13 @@ fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf { } } -fn resolve_skills_dir_path(workspace_root: &Path, configured_dir: Option<&Path>) -> PathBuf { - configured_dir - .map(normalize_configured_skills_dir) - .unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root)) +fn resolve_skills_dir_paths(workspace_root: &Path, configured_dirs: &[PathBuf]) -> Vec { + if configured_dirs.is_empty() { + vec![zeroclaw_default_skills_dir(workspace_root)] + } else { + configured_dirs + .iter() + .map(|d| normalize_configured_skills_dir(d)) + .collect() + } } diff --git a/src/compat/runtime.rs b/src/compat/runtime.rs index b9236d3..c58d615 100644 --- a/src/compat/runtime.rs +++ b/src/compat/runtime.rs @@ -146,7 +146,7 @@ pub async fn execute_task_with_provider( instruction: &str, task_context: &CompatTaskContext, config: ZeroClawConfig, - skills_dir: PathBuf, + skills_dir: Vec, settings: SgClawSettings, ) -> Result { let engine = RuntimeEngine::new(settings.runtime_profile); diff --git a/src/compat/workflow_executor.rs b/src/compat/workflow_executor.rs index 250beab..b3fa78b 100644 --- a/src/compat/workflow_executor.rs +++ b/src/compat/workflow_executor.rs @@ -5,11 +5,15 @@ use std::thread; use std::time::Duration; use regex::Regex; +use reqwest::Url; use serde_json::{json, Value}; +use zeroclaw::skills::load_skills_from_directory; use zeroclaw::tools::Tool; use crate::browser::{BrowserBackend, PipeBrowserBackend}; use crate::compat::artifact_open::{open_exported_xlsx, open_local_dashboard, PostExportOpen}; +use crate::compat::browser_script_skill_tool::execute_browser_script_tool; +use crate::compat::config_adapter::resolve_scene_skills_dir_from_sgclaw_settings; use crate::compat::openxml_office_tool::OpenXmlOfficeTool; use crate::compat::runtime::CompatTaskContext; use crate::compat::screen_html_export_tool::ScreenHtmlExportTool; @@ -23,17 +27,19 @@ const ZHIHU_EDITOR_DOMAIN: &str = "zhuanlan.zhihu.com"; const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot"; const ZHIHU_CREATOR_URL: &str = "https://www.zhihu.com/creator"; const ZHIHU_EDITOR_URL: &str = "https://zhuanlan.zhihu.com/write"; +const FAULT_DETAILS_SCENE_ID: &str = "fault-details-report"; const HOTLIST_READY_POLL_ATTEMPTS: usize = 10; const HOTLIST_READY_POLL_INTERVAL: Duration = Duration::from_millis(500); -// Simplified readiness pattern: only checks that *some* heat metric exists -// (e.g. "3440万热度", "2.1亿"). The full rank-title-heat structure is validated -// later by the extraction script. Using a simple pattern avoids problems with -// the multi-line innerText format where rank, title, and heat are on separate -// lines (`.` does not cross newlines by default). +const EDITOR_READY_POLL_ATTEMPTS: usize = 12; +const EDITOR_READY_POLL_INTERVAL: Duration = Duration::from_millis(500); +// Readiness pattern: requires the "热度" suffix so that sidebar "大家都在搜" +// entries (which show bare "414万" without "热度") do NOT trigger a premature +// readiness signal. The main hotlist always renders "538万热度". const HOTLIST_TEXT_READY_PATTERN: &str = - r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*(?:热度)?"; + r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*热度"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WorkflowRoute { + FaultDetailsReport, ZhihuHotlistExportXlsx, ZhihuHotlistScreen, ZhihuArticleEntry, @@ -60,6 +66,13 @@ pub fn detect_route( page_url: Option<&str>, page_title: Option<&str>, ) -> Option { + if let Some(scene) = crate::runtime::match_scene_instruction(instruction) { + if scene.id == FAULT_DETAILS_SCENE_ID + && matches!(scene.dispatch_mode, crate::runtime::DispatchMode::DirectBrowser) + { + return Some(WorkflowRoute::FaultDetailsReport); + } + } if crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) { let normalized = instruction.to_ascii_lowercase(); if normalized.contains("dashboard") @@ -93,7 +106,8 @@ pub fn detect_route( pub fn prefers_direct_execution(route: &WorkflowRoute) -> bool { matches!( route, - WorkflowRoute::ZhihuHotlistExportXlsx + WorkflowRoute::FaultDetailsReport + | WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen | WorkflowRoute::ZhihuArticleEntry | WorkflowRoute::ZhihuArticleDraft @@ -119,7 +133,8 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo looks_like_denial || matches!( route, - WorkflowRoute::ZhihuHotlistExportXlsx + WorkflowRoute::FaultDetailsReport + | WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen | WorkflowRoute::ZhihuArticleEntry | WorkflowRoute::ZhihuArticleDraft @@ -138,6 +153,13 @@ pub fn execute_route_with_browser_backend( settings: &SgClawSettings, ) -> Result { match route { + WorkflowRoute::FaultDetailsReport => execute_fault_details_route( + browser_backend.clone(), + instruction, + workspace_root, + settings, + task_context.page_url.as_deref(), + ), WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => { let top_n = extract_top_n(instruction); let items = collect_hotlist_items(transport, browser_backend.as_ref(), top_n, task_context)?; @@ -210,6 +232,157 @@ pub fn execute_route( ) } +fn execute_fault_details_route( + browser_backend: Arc, + instruction: &str, + workspace_root: &Path, + settings: &SgClawSettings, + page_url: Option<&str>, +) -> Result { + let scene = crate::runtime::match_scene_instruction(instruction).ok_or_else(|| { + PipeError::Protocol("故障明细直连路由失败:未找到场景元数据。".to_string()) + })?; + if scene.id != FAULT_DETAILS_SCENE_ID { + return Err(PipeError::Protocol(format!( + "故障明细直连路由失败:场景不匹配,got {}", + scene.id + ))); + } + + let period = derive_fault_details_period(instruction).ok_or_else(|| { + PipeError::Protocol( + "故障明细直连路由失败:无法从当前指令安全推导必填参数 period,请明确提供例如“导出 2026-04 故障明细”。" + .to_string(), + ) + })?; + + let skills_dirs = resolve_scene_skills_dir_from_sgclaw_settings(workspace_root, settings); + let skill = skills_dirs + .iter() + .flat_map(|dir| load_skills_from_directory(dir, true)) + .find(|skill| skill.name == scene.skill_package) + .ok_or_else(|| { + PipeError::Protocol(format!( + "故障明细直连路由失败:未找到技能包 {} in [{}]", + scene.skill_package, + skills_dirs.iter().map(|d| d.display().to_string()).collect::>().join(", ") + )) + })?; + let skill_root = skill + .location + .as_deref() + .and_then(Path::parent) + .ok_or_else(|| { + PipeError::Protocol(format!( + "故障明细直连路由失败:技能包 {} 缺少有效位置元数据", + scene.skill_package + )) + })?; + let tool = skill + .tools + .iter() + .find(|tool| tool.name == scene.skill_tool) + .ok_or_else(|| { + PipeError::Protocol(format!( + "故障明细直连路由失败:技能包 {} 缺少工具 {}", + scene.skill_package, scene.skill_tool + )) + })?; + if tool.kind != "browser_script" { + return Err(PipeError::Protocol(format!( + "故障明细直连路由失败:工具 {} 必须是 browser_script,当前为 {}", + scene.skill_tool, tool.kind + ))); + } + + let expected_domain = fault_details_expected_domain(page_url, &scene.expected_domain) + .ok_or_else(|| { + PipeError::Protocol( + "故障明细直连路由失败:无法从当前页面上下文解析可用域名。".to_string(), + ) + })?; + + let runtime = tokio::runtime::Runtime::new() + .map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?; + let result = runtime + .block_on(execute_browser_script_tool( + tool, + skill_root, + browser_backend, + json!({ + "expected_domain": expected_domain, + "period": period, + }), + )) + .map_err(|err| PipeError::Protocol(err.to_string()))?; + if !result.success { + return Err(PipeError::Protocol( + result + .error + .unwrap_or_else(|| "fault-details-report browser script failed".to_string()), + )); + } + + Ok(result.output) +} + +fn fault_details_expected_domain(page_url: Option<&str>, fallback: &str) -> Option { + page_url + .and_then(host_from_url) + .or_else(|| host_from_url(fallback)) +} + +fn host_from_url(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(url) = Url::parse(trimmed) { + return url.host_str().map(|host| host.to_ascii_lowercase()); + } + + let host = trimmed + .trim_start_matches("https://") + .trim_start_matches("http://") + .split(['/', '?', '#']) + .next() + .unwrap_or_default() + .split(':') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + + (!host.is_empty()).then_some(host) +} + +fn derive_fault_details_period(instruction: &str) -> Option { + let month_re = Regex::new(r"(20\d{2})[-/年](0?[1-9]|1[0-2])").expect("valid fault details month regex"); + let derived = month_re.captures_iter(instruction).find_map(|capture| { + let matched = capture.get(0)?; + let before_is_digit = instruction[..matched.start()] + .chars() + .next_back() + .is_some_and(|ch| ch.is_ascii_digit()); + let after_is_digit = instruction[matched.end()..] + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_digit()); + if before_is_digit || after_is_digit { + return None; + } + + let year = capture.get(1).map(|m| m.as_str()).unwrap_or_default(); + let month = capture + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(1); + Some(format!("{year}-{month:02}")) + }); + derived +} + fn collect_hotlist_items( transport: &dyn crate::agent::AgentEventSink, browser_tool: &dyn BrowserBackend, @@ -258,10 +431,16 @@ fn ensure_hotlist_page_ready( .as_deref() .is_some_and(|title| title.contains("热榜")); - if starts_on_hotlist && poll_for_hotlist_readiness(browser_tool)? { - return Ok(None); - } + // Always validate via probe_hotlist_extractor rather than returning + // Ok(None) on a bare readiness pass. The readiness poll uses getText(body) + // which can be triggered by sidebar / nav-bar content before the main + // hotlist DOM has rendered. probe_hotlist_extractor runs the full + // extraction script and returns None when no valid rows are found, + // allowing the retry loop to kick in. if starts_on_hotlist { + // Best-effort wait for content to appear; ignore the boolean result – + // we always follow up with the probe. + let _ = poll_for_hotlist_readiness(browser_tool); if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? { return Ok(Some(items)); } @@ -270,19 +449,77 @@ fn ensure_hotlist_page_ready( let mut last_error = None; for attempt in 0..2 { navigate_hotlist_page(transport, browser_tool)?; - if poll_for_hotlist_readiness(browser_tool)? { - return Ok(None); - } + let _ = poll_for_hotlist_readiness(browser_tool); if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? { return Ok(Some(items)); } - last_error = Some(PipeError::Protocol(format!( + last_error = Some(format!( "知乎热榜页面已打开,但在短轮询窗口内仍未出现可读热榜内容(attempt={})", attempt + 1 - ))); + )); } - Err(last_error.unwrap_or_else(|| PipeError::Protocol("知乎热榜页面未就绪".to_string()))) + // Log the last failure for diagnostics, then let caller try one final + // extraction as a last resort. + if let Some(msg) = last_error { + transport.send(&AgentMessage::LogEntry { + level: "warn".to_string(), + message: msg, + }).ok(); + } + Ok(None) +} + +/// Poll the Zhihu write page until `prepare_article_editor.js` reports +/// "editor_ready" or a terminal state (login_required). The editor page +/// is a React SPA whose title textarea and Draft.js body take noticeable +/// time to mount after navigation, so a single immediate check frequently +/// reports "editor_unavailable". +fn poll_for_editor_readiness( + browser_tool: &dyn BrowserBackend, + desired_mode: &str, +) -> Result { + let args = json!({ "desired_mode": desired_mode }); + let mut last_state: Option = None; + + for attempt in 0..EDITOR_READY_POLL_ATTEMPTS { + match execute_browser_skill_script( + browser_tool, + "zhihu-write", + "prepare_article_editor.js", + args.clone(), + ZHIHU_EDITOR_DOMAIN, + ) { + Ok(state) => { + let status = payload_status(&state); + if status == Some("editor_ready") || status == Some("login_required") { + return Ok(state); + } + last_state = Some(state); + } + Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed), + Err(_) => { + // Script may fail while the page is still navigating; tolerate. + } + } + + if attempt + 1 < EDITOR_READY_POLL_ATTEMPTS { + thread::sleep(EDITOR_READY_POLL_INTERVAL); + } + } + + // Return the last observed state so the caller can surface the + // "editor_unavailable" message; or make one final attempt. + match last_state { + Some(state) => Ok(state), + None => execute_browser_skill_script( + browser_tool, + "zhihu-write", + "prepare_article_editor.js", + args, + ZHIHU_EDITOR_DOMAIN, + ), + } } fn probe_hotlist_extractor( @@ -516,12 +753,9 @@ fn execute_zhihu_article_route( level: "info".to_string(), message: "call zhihu-write.prepare_article_editor".to_string(), })?; - let editor_state = execute_browser_skill_script( + let editor_state = poll_for_editor_readiness( browser_tool, - "zhihu-write", - "prepare_article_editor.js", - json!({ "desired_mode": if publish_mode { "publish" } else { "draft" } }), - ZHIHU_EDITOR_DOMAIN, + if publish_mode { "publish" } else { "draft" }, )?; if is_login_required_payload(&editor_state) { return Ok(build_login_block_message(payload_current_url( @@ -669,12 +903,9 @@ fn execute_zhihu_article_entry_route( level: "info".to_string(), message: "call zhihu-write.prepare_article_editor".to_string(), })?; - let editor_state = execute_browser_skill_script( + let editor_state = poll_for_editor_readiness( browser_tool, - "zhihu-write", - "prepare_article_editor.js", - json!({ "desired_mode": "draft" }), - ZHIHU_EDITOR_DOMAIN, + "draft", )?; if is_login_required_payload(&editor_state) { return Ok(build_login_block_message(payload_current_url( @@ -1044,7 +1275,7 @@ mod tests { "test-key".to_string(), "http://127.0.0.1:9".to_string(), "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap() } diff --git a/src/config/settings.rs b/src/config/settings.rs index fab28bc..b7a45ab 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; +use serde::de; use thiserror::Error; use crate::runtime::RuntimeProfile; @@ -105,7 +106,7 @@ pub struct DeepSeekSettings { pub api_key: String, pub base_url: String, pub model: String, - pub skills_dir: Option, + pub skills_dir: Vec, } impl DeepSeekSettings { @@ -124,7 +125,7 @@ pub struct SgClawSettings { pub provider_api_key: String, pub provider_base_url: String, pub provider_model: String, - pub skills_dir: Option, + pub skills_dir: Vec, pub skills_prompt_mode: SkillsPromptMode, pub runtime_profile: RuntimeProfile, pub planner_mode: PlannerMode, @@ -155,7 +156,7 @@ impl SgClawSettings { api_key: String, base_url: String, model: String, - skills_dir: Option, + skills_dir: Vec, ) -> Result { Self::new( api_key, @@ -198,7 +199,7 @@ impl SgClawSettings { api_key, base_url, model, - None, + Vec::new(), None, None, None, @@ -283,7 +284,7 @@ impl SgClawSettings { config.api_key, config.base_url, config.model, - resolve_configured_skills_dir(config.skills_dir, config_dir), + resolve_configured_skills_dirs(config.skills_dir, config_dir), skills_prompt_mode, runtime_profile, planner_mode, @@ -301,7 +302,7 @@ impl SgClawSettings { api_key: String, base_url: String, model: String, - skills_dir: Option, + skills_dir: Vec, skills_prompt_mode: Option, runtime_profile: Option, planner_mode: Option, @@ -432,18 +433,18 @@ fn parse_office_backend(raw: &str) -> Result { } } -fn resolve_configured_skills_dir(raw: Option, config_dir: &Path) -> Option { - let trimmed = raw - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty())?; - let path = PathBuf::from(trimmed); - - if path.is_absolute() { - Some(path) - } else { - Some(config_dir.join(path)) - } +fn resolve_configured_skills_dirs(raw: Vec, config_dir: &Path) -> Vec { + raw.into_iter() + .filter(|s| !s.trim().is_empty()) + .map(|s| { + let path = PathBuf::from(s.trim()); + if path.is_absolute() { + path + } else { + config_dir.join(path) + } + }) + .collect() } fn normalize_required_value(field: &'static str, raw: String) -> Result { @@ -485,6 +486,49 @@ fn normalize_enum_token(raw: &str) -> String { .to_ascii_lowercase() } +fn deserialize_skills_dirs<'de, D>(deserializer: D) -> Result, D::Error> +where + D: de::Deserializer<'de>, +{ + struct StringOrVec; + + impl<'de> de::Visitor<'de> for StringOrVec { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or array of strings") + } + + fn visit_str(self, value: &str) -> Result, E> { + if value.trim().is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![value.to_string()]) + } + } + + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut dirs = Vec::new(); + while let Some(value) = seq.next_element::()? { + if !value.trim().is_empty() { + dirs.push(value); + } + } + Ok(dirs) + } + + fn visit_none(self) -> Result, E> { + Ok(Vec::new()) + } + + fn visit_unit(self) -> Result, E> { + Ok(Vec::new()) + } + } + + deserializer.deserialize_any(StringOrVec) +} + #[derive(Debug, Deserialize)] struct RawSgClawSettings { #[serde(rename = "apiKey", default)] @@ -493,8 +537,8 @@ struct RawSgClawSettings { base_url: String, #[serde(default)] model: String, - #[serde(rename = "skillsDir", alias = "skills_dir", default)] - skills_dir: Option, + #[serde(rename = "skillsDir", alias = "skills_dir", default, deserialize_with = "deserialize_skills_dirs")] + skills_dir: Vec, #[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)] skills_prompt_mode: Option, #[serde(rename = "runtimeProfile", alias = "runtime_profile", default)] diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index b69d088..7aafc06 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use zeroclaw::agent::dispatcher::NativeToolDispatcher; @@ -12,8 +12,9 @@ use zeroclaw::tools::{self, ReadSkillTool}; use zeroclaw::SecurityPolicy; use crate::compat::memory_adapter::build_memory; +use crate::compat::config_adapter::resolve_scene_skills_dir_path; use crate::pipe::PipeError; -use crate::runtime::{RuntimeProfile, ToolPolicy}; +use crate::runtime::{match_scene_instruction, DispatchMode, RuntimeProfile, ToolPolicy}; const BROWSER_ACTION_TOOL_NAME: &str = "browser_action"; const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser"; @@ -25,6 +26,7 @@ const ZHIHU_HOTLIST_EXECUTION_PROMPT: &str = "Zhihu hotlist execution contract:\ const OFFICE_EXPORT_COMPLETION_PROMPT: &str = "Export completion contract:\n- This task requires a real Excel export.\n- After the Zhihu rows are available, you must call openxml_office before finishing.\n- Never fabricate, simulate, or invent substitute hotlist data when a live collection/export task fails.\n- If live collection fails, report the failure concisely instead of producing fake rows.\n- Do not stop after describing how you will parse or export the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the generated local .xlsx path."; const SCREEN_EXPORT_COMPLETION_PROMPT: &str = "Presentation completion contract:\n- This task requires a real dashboard artifact.\n- After the Zhihu rows are available, you must call screen_html_export before finishing.\n- Do not stop after describing how you will render or present the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the local .html path and the presentation object."; const ZHIHU_WRITE_PUBLISH_PROMPT: &str = "Zhihu article publish contract:\n- This task may publish a Zhihu article.\n- You must not click publish without explicit human confirmation in the current session.\n- If the user asked to publish but no explicit confirmation phrase is present yet, ask for confirmation concisely and stop after the confirmation request.\n- Do not keep exploring tools after you have determined that publish confirmation is missing.\n- If the user only asked to write or draft, stay in draft mode and do not treat it as publish mode.\n- Do not repeat the same sentence or section in your final answer."; +const REPAIR_CITY_DISPATCH_EXECUTION_PROMPT: &str = "95598 repair city dispatch execution contract:\n- Treat this as a browser workflow, not a text-only task.\n- You must call `95598-repair-city-dispatch.collect_repair_orders` first when the tool is available.\n- Use generic browser probing only after the scene-specific collection tool fails or is unavailable.\n- Collect the live repair order queue before summarizing or reporting status."; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeEngine { @@ -59,7 +61,7 @@ impl RuntimeEngine { &self, provider: Box, config: &ZeroClawConfig, - skills_dir: &Path, + skills_dirs: &[PathBuf], mut tools: Vec>, browser_surface_present: bool, instruction: &str, @@ -71,7 +73,7 @@ impl RuntimeEngine { &config.workspace_dir, )); let observer: Arc = Arc::new(NoopObserver); - let skills = load_runtime_skills(config, skills_dir); + let skills = self.load_skills_for_surface(config, skills_dirs, browser_surface_present); let (mut runtime_tools, _, _, _, _, _) = tools::all_tools_with_runtime( Arc::new(config.clone()), &security, @@ -90,15 +92,21 @@ impl RuntimeEngine { ); runtime_tools.append(&mut tools); + let default_skills_dir = config.workspace_dir.join("skills"); + let has_custom_skills_dir = skills_dirs.iter().any(|d| *d != default_skills_dir); if matches!( config.skills.prompt_injection_mode, SkillsPromptInjectionMode::Compact - ) && skills_dir != config.workspace_dir.join("skills") + ) && has_custom_skills_dir { + let first_custom = skills_dirs + .iter() + .find(|d| **d != default_skills_dir) + .cloned(); runtime_tools.retain(|tool| tool.name() != READ_SKILL_TOOL_NAME); runtime_tools.push(Box::new(ReadSkillTool::with_runtime_skills_dir( config.workspace_dir.clone(), - Some(skills_dir.to_path_buf()), + first_custom, config.skills.allow_scripts, config.skills.open_skills_enabled, config.skills.open_skills_dir.clone(), @@ -124,7 +132,7 @@ impl RuntimeEngine { .skills_prompt_mode(config.skills.prompt_injection_mode) .allowed_tools(self.allowed_tools_for_config( config, - skills_dir, + skills_dirs, browser_surface_present, instruction, )) @@ -145,6 +153,9 @@ impl RuntimeEngine { } let mut sections = vec![BROWSER_TOOL_CONTRACT_PROMPT.to_string()]; + if let Some(scene_contract) = build_scene_execution_contract(trimmed_instruction) { + sections.push(scene_contract); + } if is_zhihu_hotlist_task(trimmed_instruction, page_url, page_title) { sections.push(ZHIHU_HOTLIST_EXECUTION_PROMPT.to_string()); } @@ -167,16 +178,9 @@ impl RuntimeEngine { pub fn loaded_skills( &self, config: &ZeroClawConfig, - skills_dir: &Path, + skills_dirs: &[PathBuf], ) -> Vec { - let mut skills = load_runtime_skills(config, skills_dir); - skills.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then(left.version.cmp(&right.version)) - }); - skills.dedup_by(|left, right| left.name == right.name && left.version == right.version); - skills + self.load_skills_for_surface(config, skills_dirs, self.browser_surface_enabled()) } pub fn should_attach_openxml_office_tool(&self, instruction: &str) -> bool { @@ -190,11 +194,12 @@ impl RuntimeEngine { fn allowed_tools_for_config( &self, config: &ZeroClawConfig, - skills_dir: &Path, + skills_dirs: &[PathBuf], browser_surface_present: bool, instruction: &str, ) -> Option> { let mut allowed_tools = self.tool_policy.allowed_tools.clone(); + let skills = self.load_skills_for_surface(config, skills_dirs, browser_surface_present); if !browser_surface_present { allowed_tools.retain(|tool| { tool != BROWSER_ACTION_TOOL_NAME && tool != SUPERRPA_BROWSER_TOOL_NAME @@ -216,9 +221,7 @@ impl RuntimeEngine { allowed_tools.push("file_read".to_string()); } if browser_surface_present { - allowed_tools.extend(browser_script_tool_names(&load_runtime_skills( - config, skills_dir, - ))); + allowed_tools.extend(browser_script_tool_names(&skills)); } allowed_tools.dedup(); @@ -230,6 +233,28 @@ impl RuntimeEngine { Some(allowed_tools) } } + + fn load_skills_for_surface( + &self, + config: &ZeroClawConfig, + skills_dirs: &[PathBuf], + browser_surface_present: bool, + ) -> Vec { + let mut skills = load_runtime_skills(config, skills_dirs); + if !browser_surface_present { + skills.iter_mut().for_each(|skill| { + skill.tools.retain(|tool| tool.kind != "browser_script"); + }); + skills.retain(|skill| !skill.tools.is_empty()); + } + skills.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then(left.version.cmp(&right.version)) + }); + skills.dedup_by(|left, right| left.name == right.name && left.version == right.version); + skills + } } fn browser_script_tool_names(skills: &[zeroclaw::skills::Skill]) -> Vec { @@ -251,6 +276,17 @@ fn task_needs_local_file_read(instruction: &str) -> bool { normalized.contains("/home/") || normalized.contains("./") || normalized.contains("../") } +fn build_scene_execution_contract(instruction: &str) -> Option { + let scene = match_scene_instruction(instruction)?; + if scene.id == "95598-repair-city-dispatch" + && matches!(scene.dispatch_mode, DispatchMode::AgentBrowser) + { + Some(REPAIR_CITY_DISPATCH_EXECUTION_PROMPT.to_string()) + } else { + None + } +} + pub fn is_zhihu_hotlist_task( instruction: &str, page_url: Option<&str>, @@ -338,12 +374,17 @@ pub fn is_zhihu_write_task( is_zhihu && is_write } -fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec { +fn load_runtime_skills(config: &ZeroClawConfig, skills_dirs: &[PathBuf]) -> Vec { let default_skills_dir = config.workspace_dir.join("skills"); - if skills_dir == default_skills_dir { + + // When using only the default workspace skills directory, use the + // config-aware loader which respects open_skills configuration. + if skills_dirs.len() == 1 && skills_dirs[0] == default_skills_dir { return zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config); } + // Start with workspace skills, then filter out those from the default dir + // so they don't duplicate skills loaded from the configured directories. let mut skills = zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config); skills.retain(|skill| { skill @@ -352,10 +393,24 @@ fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec, + pub inputs: Vec, + pub outputs: Vec, + pub skill_package: String, + pub skill_tool: String, + pub skill_artifact_type: String, + pub dispatch_mode: DispatchMode, + pub expected_domain: String, + pub aliases: Vec, + pub default_args: Map, +} + +#[derive(Debug, Deserialize)] +struct SceneMetadata { + id: String, + name: String, + summary: String, + #[serde(default)] + tags: Vec, + #[serde(default)] + inputs: Vec, + #[serde(default)] + outputs: Vec, + skill: SceneSkillMetadata, +} + +#[derive(Debug, Deserialize)] +struct SceneSkillMetadata { + package: String, + tool: String, + artifact_type: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct SceneMatchScore { + matched_terms: usize, + longest_term: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SceneMatchResult { + score: SceneMatchScore, + has_strong_phrase_hit: bool, +} + +#[derive(Debug, Clone)] +struct SceneRuntimePolicy { + scene_id: &'static str, + dispatch_mode: DispatchMode, + expected_domain: &'static str, + aliases: &'static [&'static str], +} + +const FIRST_SLICE_POLICIES: [SceneRuntimePolicy; 2] = [ + SceneRuntimePolicy { + scene_id: "fault-details-report", + dispatch_mode: DispatchMode::DirectBrowser, + expected_domain: "sgcc.example.invalid", + aliases: &["故障明细", "故障明细报表", "导出故障明细"], + }, + SceneRuntimePolicy { + scene_id: "95598-repair-city-dispatch", + dispatch_mode: DispatchMode::AgentBrowser, + expected_domain: "95598.example.invalid", + aliases: &["95598抢修市指", "市指抢修监测", "95598抢修队列", "95598抢修市指监测"], + }, +]; + +pub fn load_first_slice_scene_registry() -> Vec { + load_scene_registry_from_root(Path::new(STAGED_SCENE_ROOT)) +} + +pub fn load_scene_registry_from_root(root: &Path) -> Vec { + let mut registry = Vec::new(); + for policy in FIRST_SLICE_POLICIES { + if let Some(entry) = load_scene_entry(root, &policy) { + registry.push(entry); + } + } + registry +} + +pub fn match_scene_instruction(instruction: &str) -> Option { + let registry = load_first_slice_scene_registry(); + match_scene_instruction_in_registry(®istry, instruction) +} + +pub fn match_scene_instruction_in_registry( + registry: &[SceneRegistryEntry], + instruction: &str, +) -> Option { + let normalized_instruction = normalize_for_match(instruction); + if normalized_instruction.is_empty() { + return None; + } + + let mut best_match: Option<(SceneMatchResult, &SceneRegistryEntry)> = None; + let mut ambiguous = false; + let mut strong_phrase_hits = 0; + + for entry in registry { + let Some(result) = score_scene_instruction(entry, &normalized_instruction) else { + continue; + }; + if result.has_strong_phrase_hit { + strong_phrase_hits += 1; + } + + match &best_match { + None => { + best_match = Some((result, entry)); + ambiguous = false; + } + Some((current_result, _)) if result.score > current_result.score => { + best_match = Some((result, entry)); + ambiguous = false; + } + Some((current_result, current_entry)) if result.score == current_result.score => { + if result.has_strong_phrase_hit && current_result.has_strong_phrase_hit { + if current_entry.id != entry.id { + ambiguous = true; + } + } else if result.has_strong_phrase_hit && !current_result.has_strong_phrase_hit { + best_match = Some((result, entry)); + ambiguous = false; + } else if current_result.has_strong_phrase_hit && !result.has_strong_phrase_hit { + ambiguous = false; + } else if current_entry.id != entry.id { + ambiguous = true; + } + } + Some(_) => {} + } + } + + if ambiguous || strong_phrase_hits > 1 { + None + } else { + best_match.map(|(_, entry)| entry.clone()) + } +} + +fn load_scene_entry(root: &Path, policy: &SceneRuntimePolicy) -> Option { + let scene_path = scene_json_path(root, policy.scene_id); + let contents = fs::read_to_string(scene_path).ok()?; + let metadata: SceneMetadata = serde_json::from_str(&contents).ok()?; + if metadata.id != policy.scene_id { + return None; + } + + Some(SceneRegistryEntry { + id: policy.scene_id.to_string(), + name: metadata.name, + summary: metadata.summary, + tags: metadata.tags, + inputs: metadata.inputs, + outputs: metadata.outputs, + skill_package: metadata.skill.package, + skill_tool: metadata.skill.tool, + skill_artifact_type: metadata.skill.artifact_type, + dispatch_mode: policy.dispatch_mode, + expected_domain: policy.expected_domain.to_string(), + aliases: policy.aliases.iter().map(|alias| (*alias).to_string()).collect(), + default_args: Map::new(), + }) +} + +fn scene_json_path(root: &Path, scene_id: &str) -> PathBuf { + root.join("scenes").join(scene_id).join("scene.json") +} + +fn score_scene_instruction( + entry: &SceneRegistryEntry, + normalized_instruction: &str, +) -> Option { + let mut matched_terms = 0; + let mut longest_term = 0; + let mut has_strong_phrase_hit = false; + + for term in candidate_match_terms(entry) { + let normalized_term = normalize_for_match(&term); + if normalized_term.len() < 2 { + continue; + } + if normalized_instruction.contains(&normalized_term) { + matched_terms += 1; + longest_term = longest_term.max(normalized_term.len()); + if normalized_term.len() >= 6 { + has_strong_phrase_hit = true; + } + } + } + + if matched_terms == 0 { + None + } else { + Some(SceneMatchResult { + score: SceneMatchScore { + matched_terms, + longest_term, + }, + has_strong_phrase_hit, + }) + } +} + +fn candidate_match_terms(entry: &SceneRegistryEntry) -> Vec { + let mut terms = Vec::new(); + terms.push(entry.id.clone()); + terms.push(entry.name.clone()); + terms.push(entry.summary.clone()); + terms.extend(entry.tags.iter().cloned()); + terms.extend(entry.aliases.iter().cloned()); + terms +} + +fn normalize_for_match(value: &str) -> String { + value + .chars() + .filter(|ch| !ch.is_whitespace() && *ch != '-' && *ch != '_' && *ch != ':') + .flat_map(|ch| ch.to_lowercase()) + .collect() +} diff --git a/tests/browser_script_skill_tool_test.rs b/tests/browser_script_skill_tool_test.rs index 333f12f..091101a 100644 --- a/tests/browser_script_skill_tool_test.rs +++ b/tests/browser_script_skill_tool_test.rs @@ -10,7 +10,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use common::MockTransport; use serde_json::json; use sgclaw::browser::{BrowserBackend, PipeBrowserBackend}; -use sgclaw::compat::browser_script_skill_tool::BrowserScriptSkillTool; +use sgclaw::compat::browser_script_skill_tool::{ + execute_browser_script_tool, BrowserScriptSkillTool, +}; use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing}; use sgclaw::security::MacPolicy; use zeroclaw::skills::SkillTool; @@ -113,6 +115,125 @@ return { )); } +#[tokio::test] +async fn browser_script_helper_executes_packaged_script_via_eval() { + let skill_dir = unique_temp_dir("sgclaw-browser-script-helper"); + let scripts_dir = skill_dir.join("scripts"); + fs::create_dir_all(&scripts_dir).unwrap(); + fs::write( + scripts_dir.join("collect_fault_details.js"), + r#" +return { + sheet_name: "故障明细", + rows: [[args.period, "已完成"]] +}; +"#, + ) + .unwrap(); + + let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response { + seq: 1, + success: true, + data: json!({ + "text": { + "sheet_name": "故障明细", + "rows": [["2026-04", "已完成"]] + } + }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 5, + }, + }])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + let backend: Arc = Arc::new(PipeBrowserBackend::from_inner(browser_tool)); + + let mut args = HashMap::new(); + args.insert("period".to_string(), "Target report period".to_string()); + let skill_tool = SkillTool { + name: "collect_fault_details".to_string(), + description: "Collect fault detail rows".to_string(), + kind: "browser_script".to_string(), + command: "scripts/collect_fault_details.js".to_string(), + args, + }; + + let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({ + "expected_domain": "https://www.zhihu.com/hot", + "period": "2026-04" + })) + .await + .unwrap(); + + let sent = transport.sent_messages(); + assert!(result.success); + assert_eq!( + serde_json::from_str::(&result.output).unwrap(), + json!({ + "sheet_name": "故障明细", + "rows": [["2026-04", "已完成"]] + }) + ); + assert!(matches!( + &sent[0], + AgentMessage::Command { + action, + params, + security, + .. + } if action == &Action::Eval + && security.expected_domain == "www.zhihu.com" + && params["script"].as_str().unwrap().contains("const args = {\"period\":\"2026-04\"};") + && params["script"].as_str().unwrap().contains("sheet_name") + )); +} + +#[tokio::test] +async fn browser_script_helper_requires_expected_domain() { + let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-missing-domain"); + let scripts_dir = skill_dir.join("scripts"); + fs::create_dir_all(&scripts_dir).unwrap(); + fs::write(scripts_dir.join("collect_fault_details.js"), "return { ok: true };\n").unwrap(); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + let backend: Arc = Arc::new(PipeBrowserBackend::from_inner(browser_tool)); + + let mut args = HashMap::new(); + args.insert("period".to_string(), "Target report period".to_string()); + let skill_tool = SkillTool { + name: "collect_fault_details".to_string(), + description: "Collect fault detail rows".to_string(), + kind: "browser_script".to_string(), + command: "scripts/collect_fault_details.js".to_string(), + args, + }; + + let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({ + "period": "2026-04" + })) + .await + .unwrap(); + + assert!(!result.success); + assert_eq!( + result.error.as_deref(), + Some("missing required field expected_domain") + ); + assert!(transport.sent_messages().is_empty()); +} + fn unique_temp_dir(prefix: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/tests/compat_config_test.rs b/tests/compat_config_test.rs index e8a0fe6..a3bdae1 100644 --- a/tests/compat_config_test.rs +++ b/tests/compat_config_test.rs @@ -4,8 +4,8 @@ use std::sync::{Mutex, OnceLock}; use sgclaw::compat::config_adapter::{ build_zeroclaw_config, build_zeroclaw_config_from_settings, - build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir, zeroclaw_default_skills_dir, - zeroclaw_workspace_dir, + build_zeroclaw_config_from_sgclaw_settings, resolve_scene_skills_dir_path, resolve_skills_dir, + zeroclaw_default_skills_dir, zeroclaw_workspace_dir, }; use sgclaw::config::{ BrowserBackend, DeepSeekSettings, OfficeBackend, PlannerMode, SgClawSettings, SkillsPromptMode, @@ -47,7 +47,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() { api_key: "key".to_string(), base_url: "https://proxy.example.com/v1".to_string(), model: "deepseek-reasoner".to_string(), - skills_dir: None, + skills_dir: Vec::new(), }; let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw")); @@ -66,7 +66,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() { ); assert_eq!( resolve_skills_dir(Path::new("/var/lib/sgclaw"), &settings), - zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw")) + vec![zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw"))] ); } @@ -92,7 +92,7 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() { assert_eq!(first.api_key, "sk-first"); assert_eq!(first.base_url, "https://api.deepseek.com"); assert_eq!(first.model, "deepseek-chat"); - assert_eq!(first.skills_dir, None); + assert!(first.skills_dir.is_empty()); fs::write( &config_path, @@ -111,7 +111,7 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() { assert_eq!(second.api_key, "sk-second"); assert_eq!(second.base_url, "https://proxy.example.com/v1"); assert_eq!(second.model, "deepseek-reasoner"); - assert_eq!(second.skills_dir, Some(root.join("skill_lib"))); + assert_eq!(second.skills_dir, vec![root.join("skill_lib")]); } #[test] @@ -122,12 +122,12 @@ fn resolve_skills_dir_prefers_nested_skills_subdirectory_for_configured_repo_roo api_key: "key".to_string(), base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), - skills_dir: Some(root.join("skill_lib")), + skills_dir: vec![root.join("skill_lib")], }; let resolved = resolve_skills_dir(&root, &settings); - assert_eq!(resolved, root.join("skill_lib/skills")); + assert_eq!(resolved, vec![root.join("skill_lib/skills")]); } #[test] @@ -139,12 +139,41 @@ fn resolve_skills_dir_preserves_absolute_configured_skills_directory() { api_key: "key".to_string(), base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), - skills_dir: Some(external_skills.clone()), + skills_dir: vec![external_skills.clone()], }; let resolved = resolve_skills_dir(&root, &settings); - assert_eq!(resolved, external_skills); + assert_eq!(resolved, vec![external_skills]); +} + +#[test] +fn resolve_skills_dir_uses_skills_child_for_external_staged_root() { + let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", Uuid::new_v4())); + let staged_root = root.join("external/skill_staging"); + fs::create_dir_all(staged_root.join("skills")).unwrap(); + fs::create_dir_all(staged_root.join("scenes")).unwrap(); + let settings = DeepSeekSettings { + api_key: "key".to_string(), + base_url: "https://api.deepseek.com".to_string(), + model: "deepseek-chat".to_string(), + skills_dir: vec![staged_root.clone()], + }; + + let resolved = resolve_skills_dir(&root, &settings); + + assert_eq!(resolved, vec![staged_root.join("skills")]); +} + +#[test] +fn resolve_scene_skills_dir_path_prefers_staged_skills_child_under_project_root() { + let root = std::env::temp_dir().join(format!("sgclaw-scene-skills-{}", Uuid::new_v4())); + let top_level_skills = root.join("project/skills"); + fs::create_dir_all(top_level_skills.join("skill_staging/skills")).unwrap(); + + let resolved = resolve_scene_skills_dir_path(top_level_skills.clone()); + + assert_eq!(resolved, top_level_skills.join("skill_staging/skills")); } #[test] @@ -153,7 +182,7 @@ fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() { "sk-test".to_string(), "https://api.deepseek.com".to_string(), "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); @@ -187,7 +216,7 @@ fn sgclaw_settings_load_new_runtime_fields_from_browser_config() { assert_eq!(settings.runtime_profile, RuntimeProfile::GeneralAssistant); assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Full); - assert_eq!(settings.skills_dir, Some(root.join("skill_lib"))); + assert_eq!(settings.skills_dir, vec![root.join("skill_lib")]); assert_eq!(config.skills.prompt_injection_mode, SkillsPromptMode::Full); } @@ -251,7 +280,7 @@ fn browser_attached_config_uses_low_temperature_for_deterministic_execution() { "sk-test".to_string(), "https://api.deepseek.com".to_string(), "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); diff --git a/tests/compat_cron_test.rs b/tests/compat_cron_test.rs index e7c9f6d..cbd06fe 100644 --- a/tests/compat_cron_test.rs +++ b/tests/compat_cron_test.rs @@ -17,7 +17,7 @@ async fn compat_cron_adapter_creates_lists_and_runs_due_agent_jobs() { api_key: "key".to_string(), base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), - skills_dir: None, + skills_dir: Vec::new(), }; let workspace_root = workspace_root("sgclaw-cron"); let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings); diff --git a/tests/compat_memory_test.rs b/tests/compat_memory_test.rs index 74f712c..5fafdac 100644 --- a/tests/compat_memory_test.rs +++ b/tests/compat_memory_test.rs @@ -16,7 +16,7 @@ async fn compat_memory_adapter_uses_workspace_local_sqlite_backend() { api_key: "key".to_string(), base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), - skills_dir: None, + skills_dir: Vec::new(), }; let workspace_root = workspace_root("sgclaw-memory"); let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings); diff --git a/tests/compat_runtime_test.rs b/tests/compat_runtime_test.rs index 0bc4fa2..97f39e2 100644 --- a/tests/compat_runtime_test.rs +++ b/tests/compat_runtime_test.rs @@ -118,6 +118,17 @@ fn real_skill_lib_root() -> PathBuf { .join("skill_lib") } +fn staged_skill_root() -> PathBuf { + PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging") +} + +fn project_skills_root() -> PathBuf { + staged_skill_root() + .parent() + .expect("staged skill root should have parent") + .to_path_buf() +} + fn success_browser_response(seq: u64, data: Value) -> BrowserMessage { BrowserMessage::Response { seq, @@ -412,7 +423,7 @@ fn compat_runtime_includes_default_workspace_skills_in_provider_request() { api_key: "deepseek-test-key".to_string(), base_url, model: "deepseek-chat".to_string(), - skills_dir: None, + skills_dir: Vec::new(), }; let transport = Arc::new(MockTransport::new(vec![])); let browser_tool = BrowserPipeTool::new( @@ -697,7 +708,7 @@ fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_ll } #[test] -fn handle_browser_message_emits_plan_preview_before_runtime_execution() { +fn handle_browser_message_executes_without_legacy_plan_preview() { let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); let first_response = json!({ @@ -771,28 +782,21 @@ fn handle_browser_message_emits_plan_preview_before_runtime_execution() { server_handle.join().unwrap(); let sent = transport.sent_messages(); - let preview_index = sent - .iter() - .position(|message| { - matches!( - message, - AgentMessage::LogEntry { level, message } - if level == "plan" && message.contains("navigate https://www.baidu.com") - ) - }) - .expect("expected plan preview log entry"); - let navigate_index = sent - .iter() - .position(|message| { - matches!( - message, - AgentMessage::LogEntry { level, message } - if level == "info" && message == "navigate https://www.baidu.com" - ) - }) - .expect("expected runtime navigate log entry"); - assert!(preview_index < navigate_index); + assert!(!sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "plan" && message.contains("navigate https://www.baidu.com") + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.baidu.com" + ) + })); } #[test] @@ -957,7 +961,7 @@ fn compat_runtime_includes_prior_turns_in_follow_up_provider_request() { api_key: "deepseek-test-key".to_string(), base_url, model: "deepseek-chat".to_string(), - skills_dir: None, + skills_dir: Vec::new(), }; let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response { seq: 1, @@ -1056,7 +1060,7 @@ fn compat_runtime_does_not_forward_raw_aom_snapshot_back_to_provider() { api_key: "deepseek-test-key".to_string(), base_url, model: "deepseek-chat".to_string(), - skills_dir: None, + skills_dir: Vec::new(), }; let large_snapshot_marker = "snapshot-marker ".repeat(2048); @@ -1118,7 +1122,7 @@ fn compat_runtime_injects_browser_contract_and_page_context_into_provider_reques api_key: "deepseek-test-key".to_string(), base_url, model: "deepseek-chat".to_string(), - skills_dir: None, + skills_dir: Vec::new(), }; let transport = Arc::new(MockTransport::new(vec![])); let browser_tool = BrowserPipeTool::new( @@ -1194,7 +1198,7 @@ fn compat_runtime_can_complete_a_text_only_turn_without_browser_tool_calls() { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); settings.runtime_profile = RuntimeProfile::GeneralAssistant; @@ -1272,7 +1276,7 @@ fn compat_runtime_allows_read_skill_under_compact_mode_policy() { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); let transport = Arc::new(MockTransport::new(vec![])); @@ -1357,7 +1361,7 @@ top_n = "How many hotlist rows to extract." "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -1464,7 +1468,7 @@ return { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -1535,7 +1539,7 @@ fn zhihu_hotlist_browser_skill_flow_does_not_expose_shell_or_glob_tools() { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - Some(real_skill_lib_root()), + vec![real_skill_lib_root()], ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -1589,7 +1593,7 @@ fn compat_runtime_browser_attached_profile_keeps_file_read_available_for_local_p "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -1640,7 +1644,7 @@ fn browser_attached_export_flow_exposes_browser_and_office_tools_only() { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - Some(real_skill_lib_root()), + vec![real_skill_lib_root()], ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -1695,7 +1699,7 @@ fn compat_runtime_allows_zhihu_hotlist_screen_export_tool_in_browser_profile() { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - Some(real_skill_lib_root()), + vec![real_skill_lib_root()], ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -1777,7 +1781,7 @@ fn compat_runtime_logs_read_skill_usage_with_skill_name() { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); let transport = Arc::new(MockTransport::new(vec![])); @@ -1914,7 +1918,7 @@ fn browser_attached_excel_request_uses_execution_contract_not_skill_source_stuff "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - Some(real_skill_lib_root()), + vec![real_skill_lib_root()], ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -1966,7 +1970,7 @@ fn browser_attached_publish_request_injects_confirmation_contract() { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - Some(real_skill_lib_root()), + vec![real_skill_lib_root()], ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -2597,6 +2601,711 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator_for_zhihu_p })); } +#[test] +fn handle_browser_message_exposes_project_skills_and_staged_scene_skills_together() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let response = json!({ + "choices": [{ + "message": { + "content": "已同时看到顶层与场景技能" + } + }] + }); + let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + &base_url, + "deepseek-chat", + Some(project_skills_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "告诉我当前有哪些知乎和95598相关 skill".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://example.net/".to_string(), + page_title: "Example Domain".to_string(), + }, + ) + .unwrap(); + server_handle.join().unwrap(); + + let sent = transport.sent_messages(); + let request_bodies = requests.lock().unwrap().clone(); + let first_request = request_bodies[0].to_string(); + let loaded_skills_message = sent + .iter() + .find_map(|message| match message { + AgentMessage::LogEntry { level, message } + if level == "info" && message.starts_with("loaded skills: ") => + { + Some(message.clone()) + } + _ => None, + }) + .expect("expected loaded skills log entry"); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary == "已同时看到顶层与场景技能" + ) + })); + assert!(loaded_skills_message.contains("zhihu-hotlist@0.1.0")); + assert!(loaded_skills_message.contains("zhihu-write@0.1.0")); + assert!(loaded_skills_message.contains("fault-details-report@0.1.0")); + assert!(loaded_skills_message.contains("95598-repair-city-dispatch@0.1.0")); + assert!(first_request.contains("zhihu-hotlist")); + assert!(first_request.contains("zhihu-write")); + assert!(first_request.contains("fault-details-report")); + assert!(first_request.contains("95598-repair-city-dispatch")); +} + +#[test] +fn fault_details_route_finds_staged_scene_skill_under_project_skills_root() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(project_skills_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![success_browser_response( + 1, + json!({ + "text": { + "sheet_name": "故障明细", + "rows": [["2026-04", "已完成"]] + } + }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["example.invalid"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "导出 2026-04 故障明细".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://example.invalid/workbench".to_string(), + page_title: "业务台账".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary.contains("sheet_name") && summary.contains("故障明细") + ) + })); + assert!(sent.iter().any(|message| { + matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval) + })); +} + +#[test] +fn handle_browser_message_exposes_staged_95598_scene_skills_and_contract_on_agent_path() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let response = json!({ + "choices": [{ + "message": { + "content": "已按95598场景进入通用代理路径" + } + }] + }); + let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + &base_url, + "deepseek-chat", + Some(staged_skill_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["95598.example.invalid"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列" + .to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://95598.example.invalid/dispatch".to_string(), + page_title: "95598抢修市指监测".to_string(), + }, + ) + .unwrap(); + server_handle.join().unwrap(); + + let sent = transport.sent_messages(); + let request_bodies = requests.lock().unwrap().clone(); + let first_request = request_bodies[0].to_string(); + let loaded_skills_message = sent + .iter() + .find_map(|message| match message { + AgentMessage::LogEntry { level, message } + if level == "info" && message.starts_with("loaded skills: ") => + { + Some(message.clone()) + } + _ => None, + }) + .expect("expected loaded skills log entry"); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && message == "compat_llm_primary" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary == "已按95598场景进入通用代理路径" + ) + })); + assert!(loaded_skills_message.contains("fault-details-report@0.1.0")); + assert!(loaded_skills_message.contains("95598-repair-city-dispatch@0.1.0")); + assert_eq!(request_bodies.len(), 1); + assert!(first_request.contains("95598-repair-city-dispatch.collect_repair_orders")); + assert!(first_request.contains("Current page URL: https://95598.example.invalid/dispatch")); + assert!(first_request.contains("Current page title: 95598抢修市指监测")); +} + +#[test] +fn browser_surface_disabled_fault_details_turn_uses_general_assistant_provider_path() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let response = json!({ + "choices": [{ + "message": { + "content": "已走通用助手路径" + } + }] + }); + let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]); + + let workspace_root = temp_workspace_root(); + let config_path = workspace_root.join("sgclaw_config.json"); + fs::write( + &config_path, + serde_json::to_string_pretty(&json!({ + "apiKey": "deepseek-test-key", + "baseUrl": base_url, + "model": "deepseek-chat", + "runtimeProfile": "generalAssistant", + "skillsDir": staged_skill_root(), + })) + .unwrap(), + ) + .unwrap(); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["example.invalid"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "导出 2026-04 故障明细".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://example.invalid/workbench".to_string(), + page_title: "业务台账".to_string(), + }, + ) + .unwrap(); + server_handle.join().unwrap(); + + let sent = transport.sent_messages(); + let request_bodies = requests.lock().unwrap().clone(); + let first_request = request_bodies[0].to_string(); + let loaded_skills_message = sent.iter().find_map(|message| match message { + AgentMessage::LogEntry { level, message } + if level == "info" && message.starts_with("loaded skills: ") => + { + Some(message.clone()) + } + _ => None, + }); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && message == "compat_llm_primary" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary == "已走通用助手路径" + ) + })); + assert_eq!(request_bodies.len(), 1); + assert!(!first_request.contains("95598-repair-city-dispatch.collect_repair_orders")); + assert!(!first_request.contains("browser workflow, not a text-only task")); + assert!(!first_request.contains("generic browser probing only after")); + assert!(loaded_skills_message.is_none()); + assert!(!sent.iter().any(|message| { + matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval) + })); +} + +#[test] +fn browser_attached_zhihu_hotlist_request_keeps_zhihu_contract_without_scene_injection() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let response = json!({ + "choices": [{ + "message": { + "content": "已收到知乎导出任务" + } + }] + }); + let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]); + + let workspace_root = temp_workspace_root(); + let mut settings = SgClawSettings::from_legacy_deepseek_fields( + "deepseek-test-key".to_string(), + base_url, + "deepseek-chat".to_string(), + vec![real_skill_lib_root()], + ) + .unwrap(); + settings.runtime_profile = RuntimeProfile::BrowserAttached; + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let summary = execute_task_with_sgclaw_settings( + transport.as_ref(), + browser_tool, + "读取知乎热榜数据,并导出 excel 文件", + &CompatTaskContext::default(), + &workspace_root, + &settings, + ) + .unwrap(); + server_handle.join().unwrap(); + + let request_bodies = requests.lock().unwrap().clone(); + let first_request = request_bodies[0].to_string(); + + assert_eq!(summary, "已收到知乎导出任务"); + assert_eq!(request_bodies.len(), 1); + assert!(first_request.contains("Zhihu hotlist execution contract")); + assert!(first_request.contains("Export completion contract")); + assert!(first_request.contains("openxml_office")); + assert!(!first_request.contains("95598 repair city dispatch execution contract")); + assert!(!first_request.contains("browser workflow, not a text-only task")); + assert!(!first_request.contains("generic browser probing only after")); +} + +#[test] +fn fault_details_direct_browser_scene_matches_primary_orchestration_gate() { + assert!(sgclaw::compat::orchestration::should_use_primary_orchestration( + "导出故障明细", + Some("https://example.invalid/workbench"), + Some("业务台账"), + )); +} + +#[test] +fn fault_details_direct_browser_scene_detects_direct_route() { + use sgclaw::compat::workflow_executor::{detect_route, WorkflowRoute}; + + assert_eq!( + detect_route( + "导出故障明细", + Some("https://example.invalid/workbench"), + Some("业务台账") + ), + Some(WorkflowRoute::FaultDetailsReport) + ); +} + +#[test] +fn missing_scene_metadata_keeps_unrelated_primary_routing_unchanged() { + let registry = [sgclaw::runtime::SceneRegistryEntry { + id: "unrelated-scene".to_string(), + name: "无关场景".to_string(), + summary: "与故障明细无关。".to_string(), + tags: vec!["other".to_string()], + inputs: vec!["period".to_string()], + outputs: vec!["artifact".to_string()], + skill_package: "unrelated-skill".to_string(), + skill_tool: "run_other".to_string(), + skill_artifact_type: "artifact".to_string(), + dispatch_mode: sgclaw::runtime::DispatchMode::DirectBrowser, + expected_domain: "other.example.invalid".to_string(), + aliases: vec!["别的事情".to_string()], + default_args: serde_json::Map::new(), + }]; + + assert!(sgclaw::runtime::match_scene_instruction_in_registry(®istry, "别的事情").is_some()); + assert!(sgclaw::runtime::match_scene_instruction_in_registry(®istry, "导出故障明细").is_none()); + assert!(!sgclaw::compat::orchestration::should_use_primary_orchestration( + "帮我汇总今天待办", + Some("https://example.invalid/workbench"), + Some("业务台账"), + )); +} + +#[test] +fn fault_details_route_returns_clear_failure_when_period_cannot_be_derived() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(staged_skill_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "导出故障明细".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://example.invalid/workbench".to_string(), + page_title: "业务台账".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && message == "zeroclaw_process_message_primary" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if !*success && summary.contains("period") && summary.contains("无法") + ) + })); + assert!(!sent.iter().any(|message| { + matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval) + })); +} + +#[test] +fn fault_details_route_uses_current_page_host_as_expected_domain() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(staged_skill_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![success_browser_response( + 1, + json!({ + "text": { + "sheet_name": "故障明细", + "rows": [["2026-04", "已完成"]] + } + }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["example.invalid"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "导出 2026-04 故障明细".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://example.invalid/workbench".to_string(), + page_title: "业务台账".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary.contains("sheet_name") && summary.contains("故障明细") + ) + })); + let eval_command = sent.iter().find_map(|message| match message { + AgentMessage::Command { + action, + params, + security, + .. + } if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())), + _ => None, + }); + let (params, expected_domain) = eval_command.expect("direct route should call browser eval"); + assert_eq!(expected_domain, "example.invalid"); + let script = params["script"].as_str().unwrap_or_default(); + assert!(script.contains("const args = {\"period\":\"2026-04\"};")); +} + +#[test] +fn fault_details_route_uses_packaged_browser_script_from_configured_skills_root() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let staged_root = workspace_root.join("custom_fault_details_staging"); + let custom_skills_dir = staged_root.join("skills"); + let skill_dir = write_skill_manifest_package( + &custom_skills_dir, + "fault-details-report", + r#" +[skill] +name = "fault-details-report" +description = "Collect fault detail rows via a packaged browser script." +version = "0.1.0" + +[[tools]] +name = "collect_fault_details" +description = "Collect fault detail rows for the target period." +kind = "browser_script" +command = "scripts/custom_fault_details.js" + +[tools.args] +period = "Target report period." +"#, + ); + write_skill_script( + &skill_dir, + "scripts/custom_fault_details.js", + r#" +return { + sheet_name: "故障明细", + rows: [[args.period || "unknown", "CUSTOM_FAULT_DETAILS_MARKER"]], + marker: "CUSTOM_FAULT_DETAILS_MARKER" +}; +"#, + ); + + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(staged_root.to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![success_browser_response( + 1, + json!({ + "text": { + "sheet_name": "故障明细", + "rows": [["2026-04", "已完成"]] + } + }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["example.invalid"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "导出 2026-04 故障明细".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://example.invalid/workbench".to_string(), + page_title: "业务台账".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary.contains("sheet_name") && summary.contains("故障明细") + ) + })); + let eval_command = sent.iter().find_map(|message| match message { + AgentMessage::Command { + action, + params, + security, + .. + } if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())), + _ => None, + }); + let (params, expected_domain) = eval_command.expect("direct route should call browser eval"); + assert_eq!(expected_domain, "example.invalid"); + let script = params["script"].as_str().unwrap_or_default(); + assert!(script.contains("const args = {\"period\":\"2026-04\"};")); + assert!(script.contains("CUSTOM_FAULT_DETAILS_MARKER")); + assert!(!script.contains("collect_fault_details.js")); +} + +#[test] +fn fault_details_route_executes_browser_script_eval_when_period_is_derived() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(staged_skill_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![success_browser_response( + 1, + json!({ + "text": { + "sheet_name": "故障明细", + "rows": [["2026-04", "已完成"]] + } + }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + policy_for_domains(&["example.invalid"]), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "导出 2026-04 故障明细".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://example.invalid/workbench".to_string(), + page_title: "业务台账".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary.contains("sheet_name") && summary.contains("故障明细") + ) + })); + let eval_command = sent.iter().find_map(|message| match message { + AgentMessage::Command { + action, + params, + security, + .. + } if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())), + _ => None, + }); + let (params, expected_domain) = eval_command.expect("direct route should call browser eval"); + assert_eq!(expected_domain, "example.invalid"); + let script = params["script"].as_str().unwrap_or_default(); + assert!(script.contains("const args = {\"period\":\"2026-04\"};")); + assert!(script.contains("sheet_name") || script.contains("return JSON.stringify") || script.contains("rows")); +} + #[test] fn zhihu_generated_auto_publish_matches_primary_orchestration_gate() { assert!( diff --git a/tests/deepseek_provider_test.rs b/tests/deepseek_provider_test.rs index 44f5928..53d6d64 100644 --- a/tests/deepseek_provider_test.rs +++ b/tests/deepseek_provider_test.rs @@ -21,7 +21,7 @@ fn deepseek_settings_load_defaults_from_env() { assert_eq!(settings.api_key, "test-key"); assert_eq!(settings.base_url, "https://api.deepseek.com"); assert_eq!(settings.model, "deepseek-chat"); - assert_eq!(settings.skills_dir, None); + assert!(settings.skills_dir.is_empty()); } #[test] @@ -30,7 +30,7 @@ fn deepseek_request_shape_matches_openai_compatible_chat_format() { api_key: "test-key".to_string(), base_url: "https://api.deepseek.com".to_string(), model: "deepseek-chat".to_string(), - skills_dir: None, + skills_dir: Vec::new(), }); let messages = vec![ ChatMessage { diff --git a/tests/runtime_profile_test.rs b/tests/runtime_profile_test.rs index 5e4c77f..1f354e8 100644 --- a/tests/runtime_profile_test.rs +++ b/tests/runtime_profile_test.rs @@ -1,5 +1,73 @@ +use std::fs; +use std::path::PathBuf; + +use sgclaw::compat::config_adapter::{ + build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir_from_sgclaw_settings, +}; use sgclaw::config::{BrowserBackend, OfficeBackend, PlannerMode, SgClawSettings}; use sgclaw::runtime::{RuntimeEngine, RuntimeProfile, ToolPolicy}; +use uuid::Uuid; + +fn temp_skill_root() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "sgclaw-runtime-profile-skills-{}", + Uuid::new_v4() + )); + fs::create_dir_all(root.join("skills")).unwrap(); + root +} + +fn write_browser_script_skill(skill_root: &std::path::Path, skill_name: &str) { + let skill_dir = skill_root.join("skills").join(skill_name); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.toml"), + format!( + r#" +[skill] +name = "{skill_name}" +description = "Browser-only test skill." +version = "0.1.0" + +[[tools]] +name = "run" +description = "Run browser-only script." +kind = "browser_script" +command = "scripts/run.js" +"# + ), + ) + .unwrap(); + fs::create_dir_all(skill_dir.join("scripts")).unwrap(); + fs::write(skill_dir.join("scripts/run.js"), "return { ok: true };\n").unwrap(); +} + +#[test] +fn loaded_skills_excludes_browser_script_tools_when_browser_surface_is_unavailable() { + let workspace_root = std::env::temp_dir().join(format!( + "sgclaw-runtime-profile-workspace-{}", + Uuid::new_v4() + )); + fs::create_dir_all(&workspace_root).unwrap(); + let skill_root = temp_skill_root(); + write_browser_script_skill(&skill_root, "fault-details-report"); + + let mut settings = SgClawSettings::from_legacy_deepseek_fields( + "sk-test".to_string(), + "https://api.deepseek.com".to_string(), + "deepseek-chat".to_string(), + vec![skill_root.clone()], + ) + .unwrap(); + settings.runtime_profile = RuntimeProfile::GeneralAssistant; + let config = build_zeroclaw_config_from_sgclaw_settings(&workspace_root, &settings); + let skills_dir = resolve_skills_dir_from_sgclaw_settings(&workspace_root, &settings); + let engine = RuntimeEngine::new(RuntimeProfile::GeneralAssistant); + + let loaded_skills = engine.loaded_skills(&config, &skills_dir); + + assert!(loaded_skills.is_empty()); +} #[test] fn browser_attached_profile_exposes_browser_surface_without_becoming_browser_only() { @@ -56,13 +124,61 @@ fn browser_attached_publish_prompt_requires_explicit_confirmation_before_clickin assert!(instruction.contains("stop after the confirmation request")); } +#[test] +fn browser_attached_95598_scene_prompt_requires_scene_tool_before_generic_browser_probing() { + let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached); + + let instruction = engine.build_instruction( + "请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列", + Some("https://95598.example.invalid/dispatch"), + Some("95598抢修市指监测"), + true, + ); + + assert!(instruction.contains("95598-repair-city-dispatch.collect_repair_orders")); + assert!(instruction.contains("browser workflow, not a text-only task")); + assert!(instruction.contains("generic browser probing only after")); +} + +#[test] +fn browser_attached_unrelated_task_does_not_receive_95598_scene_contract() { + let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached); + + let instruction = engine.build_instruction( + "帮我总结今天的会议纪要", + None, + None, + true, + ); + + assert!(!instruction.contains("95598-repair-city-dispatch.collect_repair_orders")); + assert!(!instruction.contains("browser workflow, not a text-only task")); + assert!(!instruction.contains("generic browser probing only after")); +} + +#[test] +fn general_assistant_95598_scene_prompt_does_not_receive_browser_scene_contract() { + let engine = RuntimeEngine::new(RuntimeProfile::GeneralAssistant); + + let instruction = engine.build_instruction( + "请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列", + Some("https://95598.example.invalid/dispatch"), + Some("95598抢修市指监测"), + false, + ); + + assert!(!instruction.contains("95598-repair-city-dispatch.collect_repair_orders")); + assert!(!instruction.contains("browser workflow, not a text-only task")); + assert!(!instruction.contains("generic browser probing only after")); +} + #[test] fn legacy_settings_default_to_plan_first_superrpa_and_openxml_backends() { let settings = SgClawSettings::from_legacy_deepseek_fields( "sk-test".to_string(), "https://api.deepseek.com".to_string(), "deepseek-chat".to_string(), - None, + Vec::new(), ) .unwrap(); diff --git a/tests/scene_registry_test.rs b/tests/scene_registry_test.rs new file mode 100644 index 0000000..d5a54f5 --- /dev/null +++ b/tests/scene_registry_test.rs @@ -0,0 +1,223 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use sgclaw::runtime::{ + load_scene_registry_from_root, match_scene_instruction_in_registry, DispatchMode, +}; +use uuid::Uuid; + +#[test] +fn scene_registry_loads_first_slice_dispatch_policies() { + let root = TempSceneRoot::new(); + write_first_slice_scenes(&root); + + let registry = load_scene_registry_from_root(root.path()); + + let fault_details = registry + .iter() + .find(|entry| entry.id == "fault-details-report") + .expect("fault-details-report scene should load"); + assert_eq!(fault_details.dispatch_mode, DispatchMode::DirectBrowser); + assert_eq!(fault_details.expected_domain, "sgcc.example.invalid"); + assert_eq!(fault_details.skill_package, "fault-details-report"); + assert_eq!(fault_details.skill_tool, "collect_fault_details"); + + let repair_dispatch = registry + .iter() + .find(|entry| entry.id == "95598-repair-city-dispatch") + .expect("95598-repair-city-dispatch scene should load"); + assert_eq!(repair_dispatch.dispatch_mode, DispatchMode::AgentBrowser); + assert_eq!(repair_dispatch.skill_package, "95598-repair-city-dispatch"); + assert_eq!(repair_dispatch.skill_tool, "collect_repair_orders"); +} + +#[test] +fn scene_registry_matches_fault_details_natural_language_instruction() { + let root = TempSceneRoot::new(); + write_first_slice_scenes(&root); + let registry = load_scene_registry_from_root(root.path()); + + let matched = + match_scene_instruction_in_registry(®istry, "请帮我导出故障明细").expect("scene should match"); + + assert_eq!(matched.id, "fault-details-report"); + assert_eq!(matched.dispatch_mode, DispatchMode::DirectBrowser); +} + +#[test] +fn scene_registry_matches_city_dispatch_natural_language_instruction() { + let root = TempSceneRoot::new(); + write_first_slice_scenes(&root); + let registry = load_scene_registry_from_root(root.path()); + + let matched = match_scene_instruction_in_registry(®istry, "帮我看一下95598抢修市指监测") + .expect("scene should match"); + + assert_eq!(matched.id, "95598-repair-city-dispatch"); + assert_eq!(matched.dispatch_mode, DispatchMode::AgentBrowser); +} + +#[test] +fn scene_registry_matches_rephrased_instruction_via_alias_terms() { + let root = TempSceneRoot::new(); + write_first_slice_scenes(&root); + let registry = load_scene_registry_from_root(root.path()); + + let matched = match_scene_instruction_in_registry(®istry, "想看市指那边的95598抢修队列") + .expect("scene should match"); + + assert_eq!(matched.id, "95598-repair-city-dispatch"); +} + +#[test] +fn scene_registry_returns_none_for_unrelated_instruction() { + let root = TempSceneRoot::new(); + write_first_slice_scenes(&root); + let registry = load_scene_registry_from_root(root.path()); + + assert!(match_scene_instruction_in_registry(®istry, "今天上海天气怎么样").is_none()); +} + +#[test] +fn scene_registry_ignores_missing_or_broken_scene_files() { + let root = TempSceneRoot::new(); + root.write_scene( + "fault-details-report", + r#"{ + "id": "fault-details-report", + "name": "故障明细", + "summary": "查询故障明细行并生成结构化报表。", + "inputs": ["period"], + "outputs": ["report-artifact"], + "tags": ["fault", "report"], + "skill": { + "package": "fault-details-report", + "tool": "collect_fault_details", + "artifact_type": "report-artifact" + } +}"#, + ); + root.write_scene("95598-repair-city-dispatch", "{ broken json"); + + let registry = load_scene_registry_from_root(root.path()); + + assert_eq!(registry.len(), 1); + assert_eq!(registry[0].id, "fault-details-report"); + assert_eq!(registry[0].dispatch_mode, DispatchMode::DirectBrowser); +} + +#[test] +fn scene_registry_ignores_mismatched_scene_metadata_id() { + let root = TempSceneRoot::new(); + root.write_scene( + "fault-details-report", + r#"{ + "id": "wrong-scene-id", + "name": "故障明细", + "summary": "查询故障明细行并生成结构化报表。", + "inputs": ["period"], + "outputs": ["report-artifact"], + "tags": ["fault", "report"], + "skill": { + "package": "fault-details-report", + "tool": "collect_fault_details", + "artifact_type": "report-artifact" + } +}"#, + ); + root.write_scene( + "95598-repair-city-dispatch", + r#"{ + "id": "95598-repair-city-dispatch", + "name": "95598抢修市指监测", + "summary": "采集95598抢修市指监测列表。", + "inputs": ["period"], + "outputs": ["repair-orders"], + "tags": ["95598", "repair", "dispatch"], + "skill": { + "package": "95598-repair-city-dispatch", + "tool": "collect_repair_orders", + "artifact_type": "repair-orders" + } +}"#, + ); + + let registry = load_scene_registry_from_root(root.path()); + + assert_eq!(registry.len(), 1); + assert_eq!(registry[0].id, "95598-repair-city-dispatch"); +} + +#[test] +fn scene_registry_returns_none_for_ambiguous_instruction() { + let root = TempSceneRoot::new(); + write_first_slice_scenes(&root); + let registry = load_scene_registry_from_root(root.path()); + + assert!( + match_scene_instruction_in_registry(®istry, "请同时处理导出故障明细和95598抢修市指监测").is_none() + ); +} + +struct TempSceneRoot { + root: PathBuf, +} + +impl TempSceneRoot { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("scene-registry-test-{}", Uuid::new_v4())); + fs::create_dir_all(root.join("scenes")).expect("temp scene root should be created"); + Self { root } + } + + fn path(&self) -> &Path { + &self.root + } + + fn write_scene(&self, scene_id: &str, contents: &str) { + let scene_dir = self.root.join("scenes").join(scene_id); + fs::create_dir_all(&scene_dir).expect("scene directory should be created"); + fs::write(scene_dir.join("scene.json"), contents).expect("scene file should be written"); + } +} + +fn write_first_slice_scenes(root: &TempSceneRoot) { + root.write_scene( + "fault-details-report", + r#"{ + "id": "fault-details-report", + "name": "故障明细", + "summary": "查询故障明细行并生成结构化报表。", + "inputs": ["period"], + "outputs": ["report-artifact"], + "tags": ["fault", "report"], + "skill": { + "package": "fault-details-report", + "tool": "collect_fault_details", + "artifact_type": "report-artifact" + } +}"#, + ); + root.write_scene( + "95598-repair-city-dispatch", + r#"{ + "id": "95598-repair-city-dispatch", + "name": "95598抢修市指监测", + "summary": "采集95598抢修市指监测列表。", + "inputs": ["period"], + "outputs": ["repair-orders"], + "tags": ["95598", "repair", "dispatch"], + "skill": { + "package": "95598-repair-city-dispatch", + "tool": "collect_repair_orders", + "artifact_type": "repair-orders" + } +}"#, + ); +} + +impl Drop for TempSceneRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} diff --git a/tests/task_runner_test.rs b/tests/task_runner_test.rs index 441bf74..cb1a569 100644 --- a/tests/task_runner_test.rs +++ b/tests/task_runner_test.rs @@ -1,5 +1,7 @@ mod common; +use std::fs; +use std::path::PathBuf; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration;