feat: add config-owned direct skill submit path
Add fixed direct-submit skill loading from configured staged skills and validate directSubmitSkill early so malformed configs fail before routing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,281 @@
|
|||||||
|
# Config-Owned Direct Skill Contract 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:** Validate the `directSubmitSkill` control surface early and prevent malformed direct-skill configs from entering the submit routing path, without changing the current happy-path direct execution behavior.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing direct-submit runtime and submit-task seam intact for valid configs. Move `directSubmitSkill` format validation into the normal `SgClawSettings` load path so malformed config fails before routing begins, while leaving valid-but-unresolvable `skill.tool` targets as direct runtime errors in the current direct path.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2021, `serde` config parsing, current `BrowserMessage::SubmitTask` path, current direct skill runtime, Rust integration tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Context
|
||||||
|
|
||||||
|
- Follow @superpowers:test-driven-development for the Rust code changes in this plan.
|
||||||
|
- Follow @superpowers:verification-before-completion before claiming any task is done.
|
||||||
|
- Do **not** create a git worktree unless the user explicitly asks. This project prefers staying in the current checkout.
|
||||||
|
- Keep scope tight: this plan does **not** add per-skill dispatch metadata, docs changes, intent classification, or LLM routing changes.
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### Existing files to modify
|
||||||
|
|
||||||
|
- Modify: `src/config/settings.rs`
|
||||||
|
- validate `directSubmitSkill` during config normalization
|
||||||
|
- keep the stored field as `Option<String>` so the current direct runtime API stays stable
|
||||||
|
- Modify: `tests/compat_config_test.rs`
|
||||||
|
- add a failing config-load regression for malformed `directSubmitSkill`
|
||||||
|
- Modify: `tests/agent_runtime_test.rs`
|
||||||
|
- add a failing submit-path regression proving malformed config is rejected before direct routing begins
|
||||||
|
|
||||||
|
### Existing files to read but not broaden
|
||||||
|
|
||||||
|
- Reuse without redesign: `src/agent/mod.rs`
|
||||||
|
- Reuse without redesign: `src/compat/direct_skill_runtime.rs`
|
||||||
|
- Reuse without redesign: `docs/superpowers/specs/2026-04-09-config-owned-direct-skill-dispatch-design.md`
|
||||||
|
|
||||||
|
### No new files expected
|
||||||
|
|
||||||
|
This slice should fit in the existing config and tests surfaces only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Validate `directSubmitSkill` Before Submit Routing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/compat_config_test.rs`
|
||||||
|
- Modify: `tests/agent_runtime_test.rs`
|
||||||
|
- Modify: `src/config/settings.rs`
|
||||||
|
- Read only: `src/agent/mod.rs`
|
||||||
|
- Read only: `src/compat/direct_skill_runtime.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing config test for malformed `directSubmitSkill`**
|
||||||
|
|
||||||
|
Add this focused test to `tests/compat_config_test.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn sgclaw_settings_reject_invalid_direct_submit_skill_format() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"sgclaw-invalid-direct-submit-skill-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let config_path = root.join("sgclaw_config.json");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
r#"{
|
||||||
|
"providers": [],
|
||||||
|
"skillsDir": "skill_lib",
|
||||||
|
"directSubmitSkill": "fault-details-report"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = SgClawSettings::load(Some(config_path.as_path()))
|
||||||
|
.expect_err("expected invalid directSubmitSkill format");
|
||||||
|
let message = err.to_string();
|
||||||
|
|
||||||
|
assert!(message.contains("directSubmitSkill"));
|
||||||
|
assert!(message.contains("skill.tool"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused config test and verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test sgclaw_settings_reject_invalid_direct_submit_skill_format -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the current config loader accepts the malformed string instead of rejecting it early.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the failing agent regression for malformed config**
|
||||||
|
|
||||||
|
Add this focused test to `tests/agent_runtime_test.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn submit_task_rejects_invalid_direct_submit_skill_config_before_routing() {
|
||||||
|
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||||
|
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||||
|
std::env::remove_var("DEEPSEEK_MODEL");
|
||||||
|
|
||||||
|
let skill_root = build_direct_runtime_skill_root();
|
||||||
|
let workspace_root = std::env::temp_dir().join(format!(
|
||||||
|
"sgclaw-invalid-direct-submit-workspace-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&workspace_root).unwrap();
|
||||||
|
let config_path = workspace_root.join("sgclaw_config.json");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
serde_json::json!({
|
||||||
|
"providers": [],
|
||||||
|
"skillsDir": skill_root,
|
||||||
|
"directSubmitSkill": "fault-details-report"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
direct_runtime_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,
|
||||||
|
submit_fault_details_message(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
assert!(matches!(
|
||||||
|
sent.last(),
|
||||||
|
Some(AgentMessage::TaskComplete { success, summary })
|
||||||
|
if !success && summary.contains("skill.tool")
|
||||||
|
));
|
||||||
|
assert!(direct_submit_mode_logs(&sent).is_empty());
|
||||||
|
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the focused agent test and verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test agent_runtime_test submit_task_rejects_invalid_direct_submit_skill_config_before_routing -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the malformed config currently loads, enters the direct-submit branch, and emits `direct_skill_primary` before failing later.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement the minimal config validation**
|
||||||
|
|
||||||
|
In `src/config/settings.rs`, add a small helper that validates the normalized `directSubmitSkill` string during `SgClawSettings::new(...)`.
|
||||||
|
|
||||||
|
Recommended implementation shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn normalize_direct_submit_skill(raw: Option<String>) -> Result<Option<String>, ConfigError> {
|
||||||
|
let value = normalize_optional_value(raw);
|
||||||
|
let Some(value) = value.as_deref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some((skill_name, tool_name)) = value.split_once('.') else {
|
||||||
|
return Err(ConfigError::InvalidValue(
|
||||||
|
"directSubmitSkill",
|
||||||
|
format!("must use skill.tool format, got {value}"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if skill_name.trim().is_empty() || tool_name.trim().is_empty() {
|
||||||
|
return Err(ConfigError::InvalidValue(
|
||||||
|
"directSubmitSkill",
|
||||||
|
format!("must use skill.tool format, got {value}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(value.to_string()))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use it here:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let direct_submit_skill = normalize_direct_submit_skill(direct_submit_skill)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- do not change the public field type from `Option<String>`
|
||||||
|
- do not move parsing responsibility into `src/agent/mod.rs`
|
||||||
|
- do not redesign `src/compat/direct_skill_runtime.rs`
|
||||||
|
- keep valid-but-unresolvable `skill.tool` targets as runtime errors in the direct path
|
||||||
|
|
||||||
|
- [ ] **Step 6: Re-run the two focused tests and verify they pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test sgclaw_settings_reject_invalid_direct_submit_skill_format -- --nocapture
|
||||||
|
cargo test --test agent_runtime_test submit_task_rejects_invalid_direct_submit_skill_config_before_routing -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Re-run the broader regression suites**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test -- --nocapture
|
||||||
|
cargo test --test agent_runtime_test -- --nocapture
|
||||||
|
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||||
|
cargo build --bin sgclaw
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS, including:
|
||||||
|
- the direct-submit happy path
|
||||||
|
- the existing no-LLM fallback behavior when `directSubmitSkill` is absent
|
||||||
|
- unchanged browser-script helper semantics
|
||||||
|
- clean binary build
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
### Config validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: malformed `directSubmitSkill` is rejected early, while the existing direct-only config shape still loads.
|
||||||
|
|
||||||
|
### Submit-path behavior
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test agent_runtime_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- malformed `directSubmitSkill` never reaches direct routing
|
||||||
|
- valid configured direct skill still succeeds without LLM config
|
||||||
|
- no direct skill configured still returns the existing no-LLM message
|
||||||
|
|
||||||
|
### Browser-script helper safety
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: current browser-script execution semantics remain unchanged.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --bin sgclaw
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the main binary compiles cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes For The Engineer
|
||||||
|
|
||||||
|
- The paired spec is `docs/superpowers/specs/2026-04-09-config-owned-direct-skill-dispatch-design.md`.
|
||||||
|
- Do **not** add sgClaw-specific dispatch metadata under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` in this slice.
|
||||||
|
- Do **not** turn this into a per-skill registry task yet. This plan only hardens the current config-owned bootstrap contract.
|
||||||
|
- Keep the current direct target example as `fault-details-report.collect_fault_details`; avoid hard-coding that name into new generic APIs.
|
||||||
|
- If you discover a need for broader policy routing (`direct_browser` / `llm_agent` by skill), stop and write a new spec/plan instead of expanding this one.
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
# Direct Skill Invocation Without LLM 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:** Let the current pipe submit-task flow accept natural-language input but directly invoke one fixed staged browser skill without calling any model, while reserving a clean switch back to LLM-based routing later.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing `BrowserMessage::SubmitTask` entrypoint and add one narrow pre-routing seam before the current compat/LLM chain. When a new config field points to a fixed direct-submit skill, sgClaw loads that skill package from the configured external skills root, finds the target `browser_script` tool, executes it through the existing browser-script wrapper, and returns the result directly. When the field is absent, the current behavior stays unchanged. This preserves a future path where each skill can later declare `direct_browser` or `llm_agent` dispatch without rewriting the submit pipeline again.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2021, existing `BrowserPipeTool`, current submit-task agent entrypoint, current browser-script skill executor, current sgClaw JSON config loader, `zeroclaw` skill manifest loader.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended First Skill
|
||||||
|
|
||||||
|
Use `fault-details-report.collect_fault_details` from:
|
||||||
|
- `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/skills/fault-details-report/SKILL.toml`
|
||||||
|
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
|
||||||
|
|
||||||
|
Why this one first:
|
||||||
|
- it is clearly a report/export skill
|
||||||
|
- it exposes exactly one browser-script tool: `collect_fault_details`
|
||||||
|
- it has the smallest contract surface (`period` only)
|
||||||
|
- its current JS is deterministic and simple, so the first slice can focus on plumbing instead of browser scraping complexity
|
||||||
|
|
||||||
|
## Scope Guardrails
|
||||||
|
|
||||||
|
- Do **not** redesign the existing submit-task protocol.
|
||||||
|
- Do **not** remove or rewrite the current LLM/compat path; leave it as the fallback/default path.
|
||||||
|
- Do **not** introduce generic NL intent routing in this slice; this is one fixed direct skill only.
|
||||||
|
- Do **not** modify `third_party/zeroclaw` skill manifest schema in phase 1.
|
||||||
|
- Do **not** add Excel export wiring in the first slice unless a test explicitly requires it.
|
||||||
|
- Do **not** invent a new browser-script execution model; reuse the existing wrapper semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### Existing files to modify
|
||||||
|
|
||||||
|
- Modify: `src/config/settings.rs`
|
||||||
|
- add a minimal config field for one direct-submit skill name
|
||||||
|
- Modify: `src/agent/mod.rs`
|
||||||
|
- add a narrow pre-routing branch before the current compat/LLM path
|
||||||
|
- Modify: `src/compat/browser_script_skill_tool.rs`
|
||||||
|
- expose the smallest reusable helper for direct browser-script execution
|
||||||
|
- Modify: `src/compat/mod.rs` or the nearest module export surface
|
||||||
|
- export the new narrow direct-skill runtime module if needed
|
||||||
|
- Modify: `tests/compat_config_test.rs`
|
||||||
|
- add config coverage for the new direct-submit field
|
||||||
|
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||||
|
- add coverage for the reusable direct-execution helper
|
||||||
|
- Modify: `tests/agent_runtime_test.rs`
|
||||||
|
- prove submit-task can bypass the model and directly invoke the fixed skill
|
||||||
|
|
||||||
|
### New files to create
|
||||||
|
|
||||||
|
- Create: `src/compat/direct_skill_runtime.rs`
|
||||||
|
- small runtime for loading one configured skill, resolving one configured tool, deriving minimal args, and executing it directly
|
||||||
|
|
||||||
|
### Files to reuse without changing behavior
|
||||||
|
|
||||||
|
- Reuse: `src/compat/runtime.rs`
|
||||||
|
- Reuse: `src/compat/orchestration.rs`
|
||||||
|
- Reuse: `src/compat/config_adapter.rs`
|
||||||
|
- Reuse: `third_party/zeroclaw/src/skills/mod.rs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add A Minimal Direct-Submit Skill Config Field
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config/settings.rs`
|
||||||
|
- Modify: `tests/compat_config_test.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing config test for the new field**
|
||||||
|
|
||||||
|
In `tests/compat_config_test.rs`, add a focused config-load test proving the browser config file can declare one fixed direct-submit skill.
|
||||||
|
|
||||||
|
Test shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn sgclaw_settings_load_direct_submit_skill_from_browser_config() {
|
||||||
|
let root = std::env::temp_dir().join(format!("sgclaw-direct-skill-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let config_path = root.join("sgclaw_config.json");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&config_path,
|
||||||
|
r#"{
|
||||||
|
"apiKey": "sk-runtime",
|
||||||
|
"baseUrl": "https://api.deepseek.com",
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"skillsDir": "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging",
|
||||||
|
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let settings = sgclaw::config::SgClawSettings::load(Some(config_path.as_path()))
|
||||||
|
.unwrap()
|
||||||
|
.expect("expected sgclaw settings from config file");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings.direct_submit_skill.as_deref(),
|
||||||
|
Some("fault-details-report.collect_fault_details")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused config test and verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test sgclaw_settings_load_direct_submit_skill_from_browser_config -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the config field does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimal config field**
|
||||||
|
|
||||||
|
In `src/config/settings.rs`, add:
|
||||||
|
- `direct_submit_skill: Option<String>` to `SgClawSettings`
|
||||||
|
- `direct_submit_skill: Option<String>` to `RawSgClawSettings`
|
||||||
|
- field normalization in `SgClawSettings::new(...)`
|
||||||
|
|
||||||
|
Recommended JSON key shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(rename = "directSubmitSkill", alias = "direct_submit_skill", default)]
|
||||||
|
direct_submit_skill: Option<String>,
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- trim empty values to `None`
|
||||||
|
- keep `DeepSeekSettings` unchanged for this slice unless a compile error proves it must mirror the field
|
||||||
|
- do not alter unrelated config semantics
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run the focused config test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test sgclaw_settings_load_direct_submit_skill_from_browser_config -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the broader config file tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit Task 1**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/config/settings.rs tests/compat_config_test.rs
|
||||||
|
git commit -m "feat: add direct submit skill config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Extract A Reusable Browser-Script Direct Execution Helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/compat/browser_script_skill_tool.rs`
|
||||||
|
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the first failing helper test**
|
||||||
|
|
||||||
|
In `tests/browser_script_skill_tool_test.rs`, add a focused test proving direct code can execute a packaged browser script without constructing a full `Tool` object first.
|
||||||
|
|
||||||
|
Test shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_browser_script_tool_runs_packaged_script_with_expected_domain() {
|
||||||
|
// build temp skill script
|
||||||
|
// call the helper directly
|
||||||
|
// assert Action::Eval was sent with wrapped args and normalized domain
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required assertions:
|
||||||
|
- the helper reads the packaged JS file
|
||||||
|
- it wraps args with `const args = ...`
|
||||||
|
- it normalizes URL-like `expected_domain`
|
||||||
|
- it returns the serialized payload string on success
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the helper test and verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_runs_packaged_script_with_expected_domain -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the helper does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the second failing helper test for required-domain validation**
|
||||||
|
|
||||||
|
Add a focused failure-path test proving the helper rejects missing or invalid `expected_domain` before any browser command is sent.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the validation test and verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_rejects_missing_expected_domain -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the helper does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement the minimal reusable helper**
|
||||||
|
|
||||||
|
In `src/compat/browser_script_skill_tool.rs`, extract the smallest reusable function, for example:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn execute_browser_script_tool<T: Transport + 'static>(
|
||||||
|
tool: &SkillTool,
|
||||||
|
skill_root: &Path,
|
||||||
|
browser_tool: BrowserPipeTool<T>,
|
||||||
|
args: Value,
|
||||||
|
) -> anyhow::Result<ToolResult>
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- reuse the current path validation, script loading, wrapping, `Action::Eval`, and payload formatting logic already used by `BrowserScriptSkillTool::execute`
|
||||||
|
- do not change outward behavior of `BrowserScriptSkillTool`
|
||||||
|
- keep the helper narrow and browser-script-only
|
||||||
|
|
||||||
|
- [ ] **Step 6: Refactor `BrowserScriptSkillTool::execute` to call the helper**
|
||||||
|
|
||||||
|
Keep existing behavior and tests green while removing duplicate execution logic.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Re-run the browser-script tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit Task 2**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/browser_script_skill_tool.rs tests/browser_script_skill_tool_test.rs
|
||||||
|
git commit -m "refactor: extract direct browser script execution helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add A Narrow Direct Skill Runtime For One Fixed Skill
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/compat/direct_skill_runtime.rs`
|
||||||
|
- Modify: `src/compat/mod.rs` or nearest module export point
|
||||||
|
- Reuse: `src/compat/config_adapter.rs`
|
||||||
|
- Reuse: `third_party/zeroclaw/src/skills/mod.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the first failing direct-runtime test**
|
||||||
|
|
||||||
|
Add a focused test in `tests/agent_runtime_test.rs` or a new narrow compat test proving code can resolve the configured external skills root, load `fault-details-report`, find `collect_fault_details`, and execute it directly.
|
||||||
|
|
||||||
|
Recommended shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn direct_skill_runtime_executes_fault_details_report_without_provider() {
|
||||||
|
// config points at skill_staging root
|
||||||
|
// direct_submit_skill points at fault-details-report.collect_fault_details
|
||||||
|
// browser response returns report-artifact payload
|
||||||
|
// assert no provider/http path is touched
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused direct-runtime test and verify it fails**
|
||||||
|
|
||||||
|
Run the narrowest test command for the new test.
|
||||||
|
|
||||||
|
Expected: FAIL because the direct runtime does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `src/compat/direct_skill_runtime.rs`**
|
||||||
|
|
||||||
|
Add a narrow runtime with responsibilities only to:
|
||||||
|
- resolve the configured skills dir with `resolve_skills_dir_from_sgclaw_settings(...)`
|
||||||
|
- load skills from that directory with `load_skills_from_directory(...)`
|
||||||
|
- parse the configured tool name into `skill_name` + `tool_name`
|
||||||
|
- find the matching skill and matching tool
|
||||||
|
- verify `tool.kind == "browser_script"`
|
||||||
|
- derive the minimal argument object
|
||||||
|
- call the new browser-script helper
|
||||||
|
- return the output string or a clear `PipeError`
|
||||||
|
|
||||||
|
Do **not** add generic routing, scenes, or model fallback here.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Keep argument derivation intentionally minimal**
|
||||||
|
|
||||||
|
For the first slice, derive only:
|
||||||
|
- `expected_domain` from `page_url` when present, otherwise fail with a clear message
|
||||||
|
- `period` from the instruction using a narrow deterministic pattern such as `YYYY-MM`
|
||||||
|
|
||||||
|
If the period cannot be derived, return a concise error telling the user to provide it explicitly. Do not guess.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the focused direct-runtime test**
|
||||||
|
|
||||||
|
Run the same test command again.
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit Task 3**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/compat/direct_skill_runtime.rs src/compat/mod.rs tests/agent_runtime_test.rs
|
||||||
|
git commit -m "feat: add fixed direct skill runtime"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Insert The Pre-Routing Seam In Submit-Task Entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/agent/mod.rs`
|
||||||
|
- Modify: `tests/agent_runtime_test.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the first failing submit-path bypass test**
|
||||||
|
|
||||||
|
In `tests/agent_runtime_test.rs`, add a focused regression proving that when `directSubmitSkill` is configured, `BrowserMessage::SubmitTask` can succeed without any model/provider being configured.
|
||||||
|
|
||||||
|
Test shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn submit_task_uses_direct_skill_mode_without_llm_configuration() {
|
||||||
|
// config contains skillsDir + directSubmitSkill, but no reachable provider
|
||||||
|
// natural-language instruction includes period and page_url
|
||||||
|
// expect TaskComplete success from direct skill result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required assertions:
|
||||||
|
- task succeeds even if provider would be unavailable
|
||||||
|
- output contains the report artifact payload
|
||||||
|
- no summary like `未配置大语言模型`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the bypass test and verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test agent_runtime_test submit_task_uses_direct_skill_mode_without_llm_configuration -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because submit-task still goes into the current LLM-oriented path.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the second failing priority test**
|
||||||
|
|
||||||
|
Add one focused test proving the direct-submit branch runs before the existing compat/LLM branch.
|
||||||
|
|
||||||
|
The easiest assertion is that the mode log becomes something new like:
|
||||||
|
- `direct_skill_primary`
|
||||||
|
|
||||||
|
and the normal mode logs do not appear for that turn.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the priority test and verify it fails**
|
||||||
|
|
||||||
|
Run the narrow test command for the new test.
|
||||||
|
|
||||||
|
Expected: FAIL because the mode does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the narrow pre-routing branch in `src/agent/mod.rs`**
|
||||||
|
|
||||||
|
In `handle_browser_message_with_context(...)`, after config load/logging and before the existing `should_use_primary_orchestration(...)` / `compat::runtime` path:
|
||||||
|
- check `settings.direct_submit_skill`
|
||||||
|
- if present, emit mode log `direct_skill_primary`
|
||||||
|
- call the new direct runtime
|
||||||
|
- send `TaskComplete` and return immediately
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- if `direct_submit_skill` is absent, keep existing behavior byte-for-byte where possible
|
||||||
|
- do not modify `compat::runtime.rs` or `compat::orchestration.rs` for this slice
|
||||||
|
- do not silently fall through to LLM when direct execution fails; return the direct error clearly so the first slice is debuggable
|
||||||
|
|
||||||
|
- [ ] **Step 6: Re-run the focused submit-path tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test agent_runtime_test submit_task_uses_direct_skill_mode_without_llm_configuration -- --nocapture
|
||||||
|
cargo test --test agent_runtime_test direct_skill_mode_logs_direct_skill_primary -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Re-run existing no-LLM submit regression coverage**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test agent_runtime_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS, including existing cases where no direct skill is configured and the old no-LLM failure still applies.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit Task 4**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/agent/mod.rs tests/agent_runtime_test.rs
|
||||||
|
git commit -m "feat: route submit tasks through fixed direct skill mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Lock The Future Migration Seam Without Implementing LLM Dispatch Yet
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify only if needed: `src/config/settings.rs`
|
||||||
|
- Modify only if needed: `src/compat/direct_skill_runtime.rs`
|
||||||
|
- Reuse: docs/plan only unless code needs one tiny naming fix
|
||||||
|
|
||||||
|
- [ ] **Step 1: Keep the config naming compatible with future per-skill dispatch**
|
||||||
|
|
||||||
|
Document and preserve this future meaning in code naming:
|
||||||
|
- current field: one fixed direct skill for submit-task bootstrap
|
||||||
|
- future model: each skill can declare dispatch mode such as `direct_browser` or `llm_agent`
|
||||||
|
|
||||||
|
Prefer neutral names in helper code like:
|
||||||
|
- `direct skill mode`
|
||||||
|
- `direct submit skill`
|
||||||
|
|
||||||
|
Avoid hard-coding `fault_details` into generic APIs.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add one small negative test for fallback behavior**
|
||||||
|
|
||||||
|
Add a focused test proving that when `directSubmitSkill` is not configured, submit-task still behaves exactly as before and can still return the existing no-LLM message.
|
||||||
|
|
||||||
|
If an existing test already proves this, keep it and do not add another.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run the focused end-to-end verification set**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test -- --nocapture
|
||||||
|
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||||
|
cargo test --test agent_runtime_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the main binary**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --bin sgclaw
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit Task 5**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/config/settings.rs src/compat/direct_skill_runtime.rs src/compat/browser_script_skill_tool.rs src/agent/mod.rs tests/compat_config_test.rs tests/browser_script_skill_tool_test.rs tests/agent_runtime_test.rs
|
||||||
|
git commit -m "test: verify fixed direct skill submit path"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
### Config loading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test compat_config_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `directSubmitSkill` loads correctly and existing config behavior remains intact.
|
||||||
|
|
||||||
|
### Browser-script helper
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: direct helper preserves the existing browser-script execution semantics.
|
||||||
|
|
||||||
|
### Submit-path bypass
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --test agent_runtime_test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: configured direct skill bypasses the model path, while unconfigured submit-task behavior stays unchanged.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --bin sgclaw
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the binary compiles cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes For The Engineer
|
||||||
|
|
||||||
|
- The key to keeping this slice small is to avoid changing `compat::runtime.rs` and `compat::orchestration.rs`; they remain the future LLM path.
|
||||||
|
- `fault-details-report.collect_fault_details` is only the bootstrap skill. The plumbing must stay generic enough that the configured tool name can later point to another staged browser skill.
|
||||||
|
- Phase 1 should not add per-skill dispatch metadata to the external skill manifests yet. Keep that decision in sgClaw config first; move it into skill metadata only after the direct path is proven useful.
|
||||||
|
- Once the intranet model is ready, the clean next step is to add a dispatch policy layer that chooses between `direct_browser` and `llm_agent` before the current compat path is entered, reusing this same pre-routing seam.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Config-Owned Direct Skill Dispatch Design
|
||||||
|
|
||||||
|
**Goal:** Preserve the current minimal submit flow where sgClaw accepts natural-language input, directly invokes one configured staged browser skill without calling an LLM, and keeps dispatch ownership in sgClaw configuration rather than external skill metadata.
|
||||||
|
|
||||||
|
**Status:** Approved design direction for the next slice. The current minimal direct-submit path already works; this document records the ownership boundary that future dispatch-policy work should follow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Summary
|
||||||
|
|
||||||
|
1. Keep direct-skill selection in sgClaw configuration.
|
||||||
|
2. Continue using `skillsDir` plus `directSubmitSkill` as the only control surface for the no-LLM direct path.
|
||||||
|
3. Do not add sgClaw-specific dispatch fields to files under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` in this slice.
|
||||||
|
4. Keep the currently bound skill as `fault-details-report.collect_fault_details`.
|
||||||
|
5. When dispatch expands beyond one fixed skill, add the next policy layer on the sgClaw side first, not in `scene.json` or `SKILL.toml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Minimal Flow
|
||||||
|
|
||||||
|
The intended user experience stays unchanged:
|
||||||
|
- the user types natural language into the input box
|
||||||
|
- sgClaw receives `BrowserMessage::SubmitTask`
|
||||||
|
- sgClaw loads runtime config
|
||||||
|
- if `directSubmitSkill` is configured, sgClaw bypasses LLM routing and directly resolves the configured staged skill from `skillsDir`
|
||||||
|
- sgClaw executes the target `browser_script` tool through the browser runtime and returns the result
|
||||||
|
- if `directSubmitSkill` is absent, sgClaw falls back to the existing orchestration / compat behavior
|
||||||
|
|
||||||
|
This keeps the first slice small while preserving a clear seam for future expansion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ownership Boundary
|
||||||
|
|
||||||
|
### sgClaw configuration owns dispatch choice
|
||||||
|
|
||||||
|
sgClaw configuration is responsible for deciding whether submit-task should bypass the LLM path and which direct skill should run.
|
||||||
|
|
||||||
|
For the current slice, that means:
|
||||||
|
- `skillsDir` tells sgClaw where to load staged skills from
|
||||||
|
- `directSubmitSkill` tells sgClaw which `skill.tool` should be used for the direct path
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"skillsDir": "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging",
|
||||||
|
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### skill_staging owns skill identity and execution assets
|
||||||
|
|
||||||
|
Files under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` remain responsible for describing the skill package, tool identity, and browser-script implementation.
|
||||||
|
|
||||||
|
For the current bound skill:
|
||||||
|
- `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/skills/fault-details-report/SKILL.toml`
|
||||||
|
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
|
||||||
|
|
||||||
|
These files already provide enough information for sgClaw to locate the package and run the tool. This slice does not add a new dispatch field inside them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why This Boundary Is Recommended
|
||||||
|
|
||||||
|
### One source of truth for routing
|
||||||
|
|
||||||
|
If sgClaw configuration owns the direct-skill decision, the operator can switch the direct skill by changing config only. There is no need to edit code and no need to mutate external skill assets just to change routing.
|
||||||
|
|
||||||
|
### Avoid freezing external manifest semantics too early
|
||||||
|
|
||||||
|
`skill_staging` is an external skill asset set. Adding sgClaw-specific dispatch metadata now would couple the staged-skill format to one integration strategy before the policy model is stable.
|
||||||
|
|
||||||
|
### Preserve a clean migration path
|
||||||
|
|
||||||
|
The current minimal path is intentionally narrow: one fixed configured direct skill, no LLM dispatch, no per-skill policy registry yet. Keeping dispatch control in sgClaw makes it easier to add a broader policy layer later without rewriting the staged-skill package format first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicit Non-Goals
|
||||||
|
|
||||||
|
This design does not do the following:
|
||||||
|
- redesign the submit-task protocol
|
||||||
|
- move dispatch control into `scene.json` or `SKILL.toml`
|
||||||
|
- require every staged skill to declare `direct_browser` or `llm_agent` right now
|
||||||
|
- expand the current direct path into generic natural-language intent classification
|
||||||
|
- change the browser-script execution model
|
||||||
|
- change the current fallback orchestration / compat execution semantics when `directSubmitSkill` is not configured
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Skill Contract
|
||||||
|
|
||||||
|
The current direct path remains intentionally deterministic.
|
||||||
|
|
||||||
|
For `fault-details-report.collect_fault_details`, sgClaw derives only the minimum required arguments:
|
||||||
|
- `expected_domain` from the current `page_url`
|
||||||
|
- `period` from an explicit `YYYY-MM` token in the user's natural-language input
|
||||||
|
|
||||||
|
That means the UX still looks like natural-language submission, but the runtime does not ask an LLM to infer intent or invent missing parameters. If the period is missing, sgClaw should return a clear error instead of guessing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Dispatch Policy Direction
|
||||||
|
|
||||||
|
When more than one staged skill needs routing control, the next layer should still begin on the sgClaw side.
|
||||||
|
|
||||||
|
Recommended direction:
|
||||||
|
- keep `directSubmitSkill` as the current bootstrap switch for the minimal fixed-skill path
|
||||||
|
- introduce a sgClaw-owned registry or config mapping that can later express `skill.tool -> direct_browser | llm_agent`
|
||||||
|
- keep external skill manifests unchanged until the policy surface proves stable in real use
|
||||||
|
|
||||||
|
Only after the routing model is stable should we consider whether external skill metadata needs a default dispatch hint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resulting Design Rule
|
||||||
|
|
||||||
|
For this project, the direct-skill decision remains config-owned:
|
||||||
|
- sgClaw config decides whether submit-task bypasses the LLM path
|
||||||
|
- staged skill metadata identifies what the skill is and how its browser tool runs
|
||||||
|
- future per-skill dispatch policy should be added in sgClaw first, not in `skill_staging`
|
||||||
|
|
||||||
|
This is the approved baseline for the next dispatch-policy slice.
|
||||||
@@ -219,6 +219,31 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
|||||||
settings.runtime_profile, settings.skills_prompt_mode
|
settings.runtime_profile, settings.skills_prompt_mode
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
if settings
|
||||||
|
.direct_submit_skill
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
let _ = send_mode_log(transport, "direct_skill_primary");
|
||||||
|
let completion = match crate::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||||||
|
browser_tool.clone(),
|
||||||
|
&instruction,
|
||||||
|
&task_context,
|
||||||
|
&context.workspace_root,
|
||||||
|
&settings,
|
||||||
|
) {
|
||||||
|
Ok(summary) => AgentMessage::TaskComplete {
|
||||||
|
success: true,
|
||||||
|
summary,
|
||||||
|
},
|
||||||
|
Err(err) => AgentMessage::TaskComplete {
|
||||||
|
success: false,
|
||||||
|
summary: err.to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return transport.send(&completion);
|
||||||
|
}
|
||||||
if crate::compat::orchestration::should_use_primary_orchestration(
|
if crate::compat::orchestration::should_use_primary_orchestration(
|
||||||
&instruction,
|
&instruction,
|
||||||
task_context.page_url.as_deref(),
|
task_context.page_url.as_deref(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::pipe::{Action, BrowserPipeTool, Transport};
|
|||||||
pub struct BrowserScriptSkillTool<T: Transport> {
|
pub struct BrowserScriptSkillTool<T: Transport> {
|
||||||
tool_name: String,
|
tool_name: String,
|
||||||
tool_description: String,
|
tool_description: String,
|
||||||
|
skill_root: PathBuf,
|
||||||
script_path: PathBuf,
|
script_path: PathBuf,
|
||||||
args: HashMap<String, String>,
|
args: HashMap<String, String>,
|
||||||
browser_tool: BrowserPipeTool<T>,
|
browser_tool: BrowserPipeTool<T>,
|
||||||
@@ -25,27 +26,13 @@ impl<T: Transport> BrowserScriptSkillTool<T> {
|
|||||||
skill_root: &Path,
|
skill_root: &Path,
|
||||||
browser_tool: BrowserPipeTool<T>,
|
browser_tool: BrowserPipeTool<T>,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let script_path = skill_root.join(&tool.command);
|
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
|
||||||
let canonical_skill_root = skill_root
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| skill_root.to_path_buf());
|
|
||||||
let canonical_script_path = script_path.canonicalize().map_err(|err| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"failed to resolve browser script {}: {err}",
|
|
||||||
script_path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if !canonical_script_path.starts_with(&canonical_skill_root) {
|
|
||||||
anyhow::bail!(
|
|
||||||
"browser script path escapes skill root: {}",
|
|
||||||
canonical_script_path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
tool_name: format!("{}.{}", skill_name, tool.name),
|
tool_name: format!("{}.{}", skill_name, tool.name),
|
||||||
tool_description: tool.description.clone(),
|
tool_description: tool.description.clone(),
|
||||||
script_path: canonical_script_path,
|
skill_root: skill_root.to_path_buf(),
|
||||||
|
script_path,
|
||||||
args: tool.args.clone(),
|
args: tool.args.clone(),
|
||||||
browser_tool,
|
browser_tool,
|
||||||
})
|
})
|
||||||
@@ -97,82 +84,101 @@ impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||||
let mut args = match args {
|
let tool = SkillTool {
|
||||||
Value::Object(args) => args,
|
name: self.tool_name.clone(),
|
||||||
other => {
|
description: self.tool_description.clone(),
|
||||||
return Ok(failed_tool_result(format!(
|
kind: "browser_script".to_string(),
|
||||||
"expected object arguments, got {other}"
|
command: self.script_path.to_string_lossy().into_owned(),
|
||||||
)))
|
args: self.args.clone(),
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let raw_expected_domain = match args.remove("expected_domain") {
|
execute_browser_script_tool(&tool, &self.skill_root, self.browser_tool.clone(), args).await
|
||||||
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() {
|
pub async fn execute_browser_script_tool<T: Transport + 'static>(
|
||||||
if !args.contains_key(required_arg) {
|
tool: &SkillTool,
|
||||||
return Ok(failed_tool_result(format!(
|
skill_root: &Path,
|
||||||
"missing required field {required_arg}"
|
browser_tool: BrowserPipeTool<T>,
|
||||||
)));
|
args: Value,
|
||||||
}
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
if tool.kind != "browser_script" {
|
||||||
|
return Ok(failed_tool_result(format!(
|
||||||
|
"browser script tool kind must be browser_script, got {}",
|
||||||
|
tool.kind
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
|
||||||
|
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:?}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let script_body = match fs::read_to_string(&self.script_path) {
|
for required_arg in tool.args.keys() {
|
||||||
Ok(value) => value,
|
if !args.contains_key(required_arg) {
|
||||||
Err(err) => {
|
return Ok(failed_tool_result(format!(
|
||||||
return Ok(failed_tool_result(format!(
|
"missing required field {required_arg}"
|
||||||
"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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_browser_script_skill_tools<T: Transport + 'static>(
|
pub fn build_browser_script_skill_tools<T: Transport + 'static>(
|
||||||
@@ -213,6 +219,32 @@ fn wrap_browser_script(script_body: &str, args: &Value) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_browser_script_path(skill_root: &Path, command: &str) -> anyhow::Result<PathBuf> {
|
||||||
|
let script_path = PathBuf::from(command);
|
||||||
|
let script_path = if script_path.is_absolute() {
|
||||||
|
script_path
|
||||||
|
} else {
|
||||||
|
skill_root.join(script_path)
|
||||||
|
};
|
||||||
|
let canonical_skill_root = skill_root
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| skill_root.to_path_buf());
|
||||||
|
let canonical_script_path = script_path.canonicalize().map_err(|err| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"failed to resolve browser script {}: {err}",
|
||||||
|
script_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !canonical_script_path.starts_with(&canonical_skill_root) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"browser script path escapes skill root: {}",
|
||||||
|
canonical_script_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(canonical_script_path)
|
||||||
|
}
|
||||||
|
|
||||||
fn stringify_tool_payload(payload: &Value) -> anyhow::Result<String> {
|
fn stringify_tool_payload(payload: &Value) -> anyhow::Result<String> {
|
||||||
Ok(match payload {
|
Ok(match payload {
|
||||||
Value::String(value) => value.clone(),
|
Value::String(value) => value.clone(),
|
||||||
|
|||||||
189
src/compat/direct_skill_runtime.rs
Normal file
189
src/compat/direct_skill_runtime.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use reqwest::Url;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use zeroclaw::skills::load_skills_from_directory;
|
||||||
|
|
||||||
|
use crate::compat::browser_script_skill_tool::execute_browser_script_tool;
|
||||||
|
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||||
|
use crate::compat::runtime::CompatTaskContext;
|
||||||
|
use crate::config::SgClawSettings;
|
||||||
|
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
||||||
|
|
||||||
|
pub fn execute_direct_submit_skill<T: Transport + 'static>(
|
||||||
|
browser_tool: BrowserPipeTool<T>,
|
||||||
|
instruction: &str,
|
||||||
|
task_context: &CompatTaskContext,
|
||||||
|
workspace_root: &Path,
|
||||||
|
settings: &SgClawSettings,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
let configured_tool = settings
|
||||||
|
.direct_submit_skill
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| PipeError::Protocol("direct submit skill is not configured".to_string()))?;
|
||||||
|
let (skill_name, tool_name) = parse_configured_tool_name(configured_tool)?;
|
||||||
|
let expected_domain = derive_expected_domain(task_context)?;
|
||||||
|
let period = derive_period(instruction)?;
|
||||||
|
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
|
||||||
|
let skills = load_skills_from_directory(&skills_dir, true);
|
||||||
|
let skill = skills
|
||||||
|
.iter()
|
||||||
|
.find(|skill| skill.name == skill_name)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
PipeError::Protocol(format!(
|
||||||
|
"direct submit skill {skill_name} was not found in {}",
|
||||||
|
skills_dir.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let tool = skill
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.find(|tool| tool.name == tool_name)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
PipeError::Protocol(format!(
|
||||||
|
"direct submit tool {configured_tool} was not found"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if tool.kind != "browser_script" {
|
||||||
|
return Err(PipeError::Protocol(format!(
|
||||||
|
"direct submit tool {configured_tool} must be browser_script, got {}",
|
||||||
|
tool.kind
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let skill_root = skill
|
||||||
|
.location
|
||||||
|
.as_deref()
|
||||||
|
.and_then(Path::parent)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
PipeError::Protocol(format!(
|
||||||
|
"direct submit skill {skill_name} is missing a resolvable location"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut args = Map::new();
|
||||||
|
args.insert("expected_domain".to_string(), Value::String(expected_domain));
|
||||||
|
args.insert("period".to_string(), Value::String(period));
|
||||||
|
|
||||||
|
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_tool,
|
||||||
|
Value::Object(args),
|
||||||
|
))
|
||||||
|
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||||
|
|
||||||
|
if result.success {
|
||||||
|
Ok(result.output)
|
||||||
|
} else {
|
||||||
|
Err(PipeError::Protocol(
|
||||||
|
result
|
||||||
|
.error
|
||||||
|
.unwrap_or_else(|| "direct submit skill execution failed".to_string()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_configured_tool_name(configured_tool: &str) -> Result<(&str, &str), PipeError> {
|
||||||
|
let (skill_name, tool_name) = configured_tool.split_once('.').ok_or_else(|| {
|
||||||
|
PipeError::Protocol(format!(
|
||||||
|
"direct submit skill must use skill.tool format, got {configured_tool}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let skill_name = skill_name.trim();
|
||||||
|
let tool_name = tool_name.trim();
|
||||||
|
if skill_name.is_empty() || tool_name.is_empty() {
|
||||||
|
return Err(PipeError::Protocol(format!(
|
||||||
|
"direct submit skill must use skill.tool format, got {configured_tool}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok((skill_name, tool_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_expected_domain(task_context: &CompatTaskContext) -> Result<String, PipeError> {
|
||||||
|
let page_url = task_context
|
||||||
|
.page_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
PipeError::Protocol(
|
||||||
|
"direct submit skill requires page_url so expected_domain can be derived"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Url::parse(page_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
PipeError::Protocol(format!(
|
||||||
|
"direct submit skill could not derive expected_domain from page_url {page_url:?}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_period(instruction: &str) -> Result<String, PipeError> {
|
||||||
|
let chars = instruction.chars().collect::<Vec<_>>();
|
||||||
|
if chars.len() < 7 {
|
||||||
|
return Err(PipeError::Protocol(
|
||||||
|
"direct submit skill requires an explicit YYYY-MM period in the instruction"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for start in 0..=chars.len() - 7 {
|
||||||
|
let candidate = chars[start..start + 7].iter().collect::<String>();
|
||||||
|
if is_year_month(&candidate) {
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(PipeError::Protocol(
|
||||||
|
"direct submit skill requires an explicit YYYY-MM period in the instruction"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_year_month(candidate: &str) -> bool {
|
||||||
|
let bytes = candidate.as_bytes();
|
||||||
|
bytes.len() == 7
|
||||||
|
&& bytes[0..4].iter().all(u8::is_ascii_digit)
|
||||||
|
&& bytes[4] == b'-'
|
||||||
|
&& bytes[5..7].iter().all(u8::is_ascii_digit)
|
||||||
|
&& matches!((bytes[5] - b'0') * 10 + (bytes[6] - b'0'), 1..=12)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{derive_period, is_year_month, parse_configured_tool_name};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_configured_tool_name_requires_skill_and_tool() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_configured_tool_name("fault-details-report.collect_fault_details")
|
||||||
|
.unwrap(),
|
||||||
|
("fault-details-report", "collect_fault_details")
|
||||||
|
);
|
||||||
|
assert!(parse_configured_tool_name("fault-details-report").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_period_requires_explicit_year_month() {
|
||||||
|
assert_eq!(derive_period("收集 2026-03 故障明细").unwrap(), "2026-03");
|
||||||
|
assert!(derive_period("收集三月故障明细").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn year_month_validation_rejects_invalid_month() {
|
||||||
|
assert!(is_year_month("2026-12"));
|
||||||
|
assert!(!is_year_month("2026-00"));
|
||||||
|
assert!(!is_year_month("2026-13"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod browser_script_skill_tool;
|
|||||||
pub mod browser_tool_adapter;
|
pub mod browser_tool_adapter;
|
||||||
pub mod config_adapter;
|
pub mod config_adapter;
|
||||||
pub mod cron_adapter;
|
pub mod cron_adapter;
|
||||||
|
pub mod direct_skill_runtime;
|
||||||
pub mod event_bridge;
|
pub mod event_bridge;
|
||||||
pub mod memory_adapter;
|
pub mod memory_adapter;
|
||||||
pub mod openxml_office_tool;
|
pub mod openxml_office_tool;
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ pub use zeroclaw::config::SkillsPromptInjectionMode as SkillsPromptMode;
|
|||||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
||||||
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat";
|
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat";
|
||||||
const DEFAULT_PROVIDER_ID: &str = "deepseek";
|
const DEFAULT_PROVIDER_ID: &str = "deepseek";
|
||||||
|
const DIRECT_SUBMIT_PROVIDER_ID: &str = "direct-submit";
|
||||||
|
const DIRECT_SUBMIT_BASE_URL: &str = "http://127.0.0.1/direct-submit";
|
||||||
|
const DIRECT_SUBMIT_MODEL: &str = "direct-submit-placeholder-model";
|
||||||
|
const DIRECT_SUBMIT_API_KEY: &str = "direct-submit-placeholder-key";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PlannerMode {
|
pub enum PlannerMode {
|
||||||
@@ -66,6 +70,19 @@ impl ProviderSettings {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn direct_submit_placeholder() -> Self {
|
||||||
|
Self {
|
||||||
|
id: DIRECT_SUBMIT_PROVIDER_ID.to_string(),
|
||||||
|
provider: DIRECT_SUBMIT_PROVIDER_ID.to_string(),
|
||||||
|
api_key: DIRECT_SUBMIT_API_KEY.to_string(),
|
||||||
|
base_url: Some(DIRECT_SUBMIT_BASE_URL.to_string()),
|
||||||
|
model: DIRECT_SUBMIT_MODEL.to_string(),
|
||||||
|
api_path: None,
|
||||||
|
wire_api: None,
|
||||||
|
requires_openai_auth: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn from_raw(raw: RawProviderSettings) -> Result<Self, ConfigError> {
|
fn from_raw(raw: RawProviderSettings) -> Result<Self, ConfigError> {
|
||||||
let id = raw.id.trim().to_string();
|
let id = raw.id.trim().to_string();
|
||||||
if id.is_empty() {
|
if id.is_empty() {
|
||||||
@@ -125,6 +142,7 @@ pub struct SgClawSettings {
|
|||||||
pub provider_base_url: String,
|
pub provider_base_url: String,
|
||||||
pub provider_model: String,
|
pub provider_model: String,
|
||||||
pub skills_dir: Option<PathBuf>,
|
pub skills_dir: Option<PathBuf>,
|
||||||
|
pub direct_submit_skill: Option<String>,
|
||||||
pub skills_prompt_mode: SkillsPromptMode,
|
pub skills_prompt_mode: SkillsPromptMode,
|
||||||
pub runtime_profile: RuntimeProfile,
|
pub runtime_profile: RuntimeProfile,
|
||||||
pub planner_mode: PlannerMode,
|
pub planner_mode: PlannerMode,
|
||||||
@@ -163,6 +181,7 @@ impl SgClawSettings {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -198,6 +217,7 @@ impl SgClawSettings {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -278,6 +298,7 @@ impl SgClawSettings {
|
|||||||
config.base_url,
|
config.base_url,
|
||||||
config.model,
|
config.model,
|
||||||
resolve_configured_skills_dir(config.skills_dir, config_dir),
|
resolve_configured_skills_dir(config.skills_dir, config_dir),
|
||||||
|
config.direct_submit_skill,
|
||||||
skills_prompt_mode,
|
skills_prompt_mode,
|
||||||
runtime_profile,
|
runtime_profile,
|
||||||
planner_mode,
|
planner_mode,
|
||||||
@@ -294,6 +315,7 @@ impl SgClawSettings {
|
|||||||
base_url: String,
|
base_url: String,
|
||||||
model: String,
|
model: String,
|
||||||
skills_dir: Option<PathBuf>,
|
skills_dir: Option<PathBuf>,
|
||||||
|
direct_submit_skill: Option<String>,
|
||||||
skills_prompt_mode: Option<SkillsPromptMode>,
|
skills_prompt_mode: Option<SkillsPromptMode>,
|
||||||
runtime_profile: Option<RuntimeProfile>,
|
runtime_profile: Option<RuntimeProfile>,
|
||||||
planner_mode: Option<PlannerMode>,
|
planner_mode: Option<PlannerMode>,
|
||||||
@@ -302,10 +324,15 @@ impl SgClawSettings {
|
|||||||
browser_backend: Option<BrowserBackend>,
|
browser_backend: Option<BrowserBackend>,
|
||||||
office_backend: Option<OfficeBackend>,
|
office_backend: Option<OfficeBackend>,
|
||||||
) -> Result<Self, ConfigError> {
|
) -> Result<Self, ConfigError> {
|
||||||
|
let direct_submit_skill = normalize_direct_submit_skill(direct_submit_skill)?;
|
||||||
let providers = if providers.is_empty() {
|
let providers = if providers.is_empty() {
|
||||||
vec![ProviderSettings::from_legacy_deepseek(
|
if direct_submit_skill.is_some() {
|
||||||
api_key, base_url, model,
|
vec![ProviderSettings::direct_submit_placeholder()]
|
||||||
)?]
|
} else {
|
||||||
|
vec![ProviderSettings::from_legacy_deepseek(
|
||||||
|
api_key, base_url, model,
|
||||||
|
)?]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
providers
|
providers
|
||||||
};
|
};
|
||||||
@@ -329,6 +356,7 @@ impl SgClawSettings {
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
provider_model: active_provider_settings.model.clone(),
|
provider_model: active_provider_settings.model.clone(),
|
||||||
skills_dir,
|
skills_dir,
|
||||||
|
direct_submit_skill,
|
||||||
skills_prompt_mode: skills_prompt_mode.unwrap_or(SkillsPromptMode::Compact),
|
skills_prompt_mode: skills_prompt_mode.unwrap_or(SkillsPromptMode::Compact),
|
||||||
runtime_profile: runtime_profile.unwrap_or(RuntimeProfile::BrowserAttached),
|
runtime_profile: runtime_profile.unwrap_or(RuntimeProfile::BrowserAttached),
|
||||||
planner_mode: planner_mode.unwrap_or(PlannerMode::ZeroclawPlanFirst),
|
planner_mode: planner_mode.unwrap_or(PlannerMode::ZeroclawPlanFirst),
|
||||||
@@ -447,6 +475,29 @@ fn normalize_optional_value(raw: Option<String>) -> Option<String> {
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_direct_submit_skill(raw: Option<String>) -> Result<Option<String>, ConfigError> {
|
||||||
|
let value = normalize_optional_value(raw);
|
||||||
|
let Some(value) = value.as_deref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some((skill_name, tool_name)) = value.split_once('.') else {
|
||||||
|
return Err(ConfigError::InvalidValue(
|
||||||
|
"directSubmitSkill",
|
||||||
|
format!("must use skill.tool format, got {value}"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if skill_name.trim().is_empty() || tool_name.trim().is_empty() {
|
||||||
|
return Err(ConfigError::InvalidValue(
|
||||||
|
"directSubmitSkill",
|
||||||
|
format!("must use skill.tool format, got {value}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_base_url(raw: String) -> String {
|
fn normalize_base_url(raw: String) -> String {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -483,6 +534,8 @@ struct RawSgClawSettings {
|
|||||||
model: String,
|
model: String,
|
||||||
#[serde(rename = "skillsDir", alias = "skills_dir", default)]
|
#[serde(rename = "skillsDir", alias = "skills_dir", default)]
|
||||||
skills_dir: Option<String>,
|
skills_dir: Option<String>,
|
||||||
|
#[serde(rename = "directSubmitSkill", alias = "direct_submit_skill", default)]
|
||||||
|
direct_submit_skill: Option<String>,
|
||||||
#[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)]
|
#[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)]
|
||||||
skills_prompt_mode: Option<String>,
|
skills_prompt_mode: Option<String>,
|
||||||
#[serde(rename = "runtimeProfile", alias = "runtime_profile", default)]
|
#[serde(rename = "runtimeProfile", alias = "runtime_profile", default)]
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use common::MockTransport;
|
use common::MockTransport;
|
||||||
use sgclaw::agent::handle_browser_message;
|
use sgclaw::agent::{
|
||||||
|
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
|
||||||
|
};
|
||||||
use sgclaw::agent::runtime::{browser_action_tool_definition, execute_task_with_provider};
|
use sgclaw::agent::runtime::{browser_action_tool_definition, execute_task_with_provider};
|
||||||
|
use sgclaw::compat::runtime::CompatTaskContext;
|
||||||
|
use sgclaw::config::SgClawSettings;
|
||||||
use sgclaw::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
use sgclaw::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
||||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||||
use sgclaw::security::MacPolicy;
|
use sgclaw::security::MacPolicy;
|
||||||
@@ -24,20 +32,346 @@ impl LlmProvider for FakeProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_policy() -> MacPolicy {
|
fn provider_path_test_policy() -> MacPolicy {
|
||||||
|
policy_for_domains(&["www.baidu.com"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_runtime_test_policy() -> MacPolicy {
|
||||||
|
policy_for_domains(&["95598.sgcc.com.cn"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_for_domains(domains: &[&str]) -> MacPolicy {
|
||||||
MacPolicy::from_json_str(
|
MacPolicy::from_json_str(
|
||||||
r#"{
|
&serde_json::json!({
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"domains": { "allowed": ["www.baidu.com"] },
|
"domains": { "allowed": domains },
|
||||||
"pipe_actions": {
|
"pipe_actions": {
|
||||||
"allowed": ["click", "type", "navigate", "getText"],
|
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||||
"blocked": []
|
"blocked": []
|
||||||
}
|
}
|
||||||
}"#,
|
})
|
||||||
|
.to_string(),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_direct_runtime_skill_root() -> PathBuf {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"sgclaw-agent-runtime-skill-root-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let skill_dir = root.join("fault-details-report");
|
||||||
|
let script_dir = skill_dir.join("scripts");
|
||||||
|
|
||||||
|
fs::create_dir_all(&script_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
skill_dir.join("SKILL.toml"),
|
||||||
|
r#"
|
||||||
|
[skill]
|
||||||
|
name = "fault-details-report"
|
||||||
|
description = "Collect 95598 fault detail data via browser eval."
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "collect_fault_details"
|
||||||
|
description = "Collect structured fault detail rows for a specific period."
|
||||||
|
kind = "browser_script"
|
||||||
|
command = "scripts/collect_fault_details.js"
|
||||||
|
|
||||||
|
[tools.args]
|
||||||
|
period = "YYYY-MM period to collect."
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
script_dir.join("collect_fault_details.js"),
|
||||||
|
r#"
|
||||||
|
return {
|
||||||
|
fault_type: "outage",
|
||||||
|
observed_at: `${args.period}-15 09:00`,
|
||||||
|
affected_scope: "line-7",
|
||||||
|
expected_domain: args.expected_domain,
|
||||||
|
artifact_payload: "report artifact payload"
|
||||||
|
};
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_direct_submit_config(workspace_root: &std::path::Path, skill_root: &std::path::Path) -> PathBuf {
|
||||||
|
let config_path = workspace_root.join("sgclaw_config.json");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
serde_json::json!({
|
||||||
|
"providers": [],
|
||||||
|
"skillsDir": skill_root,
|
||||||
|
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
config_path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_submit_runtime_context(skill_root: &std::path::Path) -> AgentRuntimeContext {
|
||||||
|
let workspace_root = std::env::temp_dir().join(format!(
|
||||||
|
"sgclaw-agent-runtime-workspace-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&workspace_root).unwrap();
|
||||||
|
let config_path = write_direct_submit_config(&workspace_root, skill_root);
|
||||||
|
AgentRuntimeContext::new(Some(config_path), workspace_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_fault_details_message() -> BrowserMessage {
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "请采集 2026-03 的故障明细并返回结果".to_string(),
|
||||||
|
conversation_id: String::new(),
|
||||||
|
messages: vec![],
|
||||||
|
page_url: "https://95598.sgcc.com.cn/".to_string(),
|
||||||
|
page_title: "网上国网".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_submit_mode_logs(sent: &[AgentMessage]) -> Vec<String> {
|
||||||
|
sent.iter()
|
||||||
|
.filter_map(|message| match message {
|
||||||
|
AgentMessage::LogEntry { level, message } if level == "mode" => Some(message.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_submit_completion(sent: &[AgentMessage]) -> Option<(bool, String)> {
|
||||||
|
sent.iter().find_map(|message| match message {
|
||||||
|
AgentMessage::TaskComplete { success, summary } => Some((*success, summary.clone())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn success_browser_response(seq: u64, data: serde_json::Value) -> BrowserMessage {
|
||||||
|
BrowserMessage::Response {
|
||||||
|
seq,
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: Timing {
|
||||||
|
queue_ms: 1,
|
||||||
|
exec_ms: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_submit_runtime_executes_fault_details_skill_without_provider_path() {
|
||||||
|
let skill_root = build_direct_runtime_skill_root();
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||||
|
1,
|
||||||
|
serde_json::json!({
|
||||||
|
"text": {
|
||||||
|
"fault_type": "outage",
|
||||||
|
"observed_at": "2026-03-15 09:00",
|
||||||
|
"affected_scope": "line-7"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
direct_runtime_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||||
|
"unused-key".to_string(),
|
||||||
|
"http://127.0.0.1:9".to_string(),
|
||||||
|
"unused-model".to_string(),
|
||||||
|
Some(skill_root.clone()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
settings.direct_submit_skill = Some("fault-details-report.collect_fault_details".to_string());
|
||||||
|
|
||||||
|
let summary = sgclaw::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||||||
|
browser_tool,
|
||||||
|
"请采集 2026-03 的故障明细并返回结果",
|
||||||
|
&CompatTaskContext {
|
||||||
|
page_url: Some("https://95598.sgcc.com.cn/".to_string()),
|
||||||
|
..CompatTaskContext::default()
|
||||||
|
},
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).as_path(),
|
||||||
|
&settings,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(summary.contains("fault_type"));
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
assert!(sent.iter().all(|message| !matches!(message, AgentMessage::LogEntry { level, message } if level == "info" && message.contains("DeepSeek config loaded"))));
|
||||||
|
assert!(matches!(
|
||||||
|
&sent[0],
|
||||||
|
AgentMessage::Command {
|
||||||
|
seq,
|
||||||
|
action,
|
||||||
|
params,
|
||||||
|
security,
|
||||||
|
} if *seq == 1
|
||||||
|
&& action == &Action::Eval
|
||||||
|
&& security.expected_domain == "95598.sgcc.com.cn"
|
||||||
|
&& params["script"].as_str().is_some_and(|script| script.contains("2026-03"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submit_task_uses_direct_skill_mode_without_llm_configuration() {
|
||||||
|
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||||
|
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||||
|
std::env::remove_var("DEEPSEEK_MODEL");
|
||||||
|
|
||||||
|
let skill_root = build_direct_runtime_skill_root();
|
||||||
|
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||||
|
1,
|
||||||
|
serde_json::json!({
|
||||||
|
"text": {
|
||||||
|
"fault_type": "outage",
|
||||||
|
"observed_at": "2026-03-15 09:00",
|
||||||
|
"affected_scope": "line-7",
|
||||||
|
"artifact_payload": "report artifact payload"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
direct_runtime_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,
|
||||||
|
submit_fault_details_message(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
let completion = direct_submit_completion(&sent).expect("task completion");
|
||||||
|
|
||||||
|
assert!(completion.0, "expected direct submit task to succeed: {sent:?}");
|
||||||
|
assert!(
|
||||||
|
completion.1.contains("report artifact payload"),
|
||||||
|
"expected report artifact payload in summary: {}",
|
||||||
|
completion.1
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!completion.1.contains("未配置大语言模型"),
|
||||||
|
"did not expect missing-llm summary: {}",
|
||||||
|
completion.1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submit_task_rejects_invalid_direct_submit_skill_config_before_routing() {
|
||||||
|
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||||
|
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||||
|
std::env::remove_var("DEEPSEEK_MODEL");
|
||||||
|
|
||||||
|
let skill_root = build_direct_runtime_skill_root();
|
||||||
|
let workspace_root = std::env::temp_dir().join(format!(
|
||||||
|
"sgclaw-invalid-direct-submit-workspace-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&workspace_root).unwrap();
|
||||||
|
let config_path = workspace_root.join("sgclaw_config.json");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
serde_json::json!({
|
||||||
|
"providers": [],
|
||||||
|
"skillsDir": skill_root,
|
||||||
|
"directSubmitSkill": "fault-details-report"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
direct_runtime_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,
|
||||||
|
submit_fault_details_message(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
assert!(matches!(
|
||||||
|
sent.last(),
|
||||||
|
Some(AgentMessage::TaskComplete { success, summary })
|
||||||
|
if !success && summary.contains("skill.tool")
|
||||||
|
));
|
||||||
|
assert!(direct_submit_mode_logs(&sent).is_empty());
|
||||||
|
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_skill_mode_logs_direct_skill_primary() {
|
||||||
|
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||||
|
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||||
|
std::env::remove_var("DEEPSEEK_MODEL");
|
||||||
|
|
||||||
|
let skill_root = build_direct_runtime_skill_root();
|
||||||
|
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||||
|
1,
|
||||||
|
serde_json::json!({
|
||||||
|
"text": {
|
||||||
|
"fault_type": "outage",
|
||||||
|
"observed_at": "2026-03-15 09:00",
|
||||||
|
"affected_scope": "line-7",
|
||||||
|
"artifact_payload": "report artifact payload"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
direct_runtime_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,
|
||||||
|
submit_fault_details_message(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
let mode_logs = direct_submit_mode_logs(&sent);
|
||||||
|
|
||||||
|
assert_eq!(mode_logs, vec!["direct_skill_primary".to_string()]);
|
||||||
|
assert!(
|
||||||
|
!mode_logs.iter().any(|mode| mode == "compat_llm_primary"),
|
||||||
|
"unexpected compat mode logs: {mode_logs:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mode_logs
|
||||||
|
.iter()
|
||||||
|
.any(|mode| mode == "zeroclaw_process_message_primary"),
|
||||||
|
"unexpected zeroclaw mode logs: {mode_logs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browser_action_tool_definition_uses_expected_name() {
|
fn browser_action_tool_definition_uses_expected_name() {
|
||||||
let tool = browser_action_tool_definition();
|
let tool = browser_action_tool_definition();
|
||||||
@@ -73,7 +407,7 @@ fn runtime_executes_provider_tool_calls_and_returns_summary() {
|
|||||||
]));
|
]));
|
||||||
let browser_tool = BrowserPipeTool::new(
|
let browser_tool = BrowserPipeTool::new(
|
||||||
transport.clone(),
|
transport.clone(),
|
||||||
test_policy(),
|
provider_path_test_policy(),
|
||||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
)
|
)
|
||||||
.with_response_timeout(Duration::from_secs(1));
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
@@ -148,7 +482,7 @@ fn production_submit_task_does_not_route_into_legacy_runtime_without_llm_config(
|
|||||||
let transport = Arc::new(MockTransport::new(vec![]));
|
let transport = Arc::new(MockTransport::new(vec![]));
|
||||||
let browser_tool = BrowserPipeTool::new(
|
let browser_tool = BrowserPipeTool::new(
|
||||||
transport.clone(),
|
transport.clone(),
|
||||||
test_policy(),
|
provider_path_test_policy(),
|
||||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
)
|
)
|
||||||
.with_response_timeout(Duration::from_secs(1));
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use common::MockTransport;
|
use common::MockTransport;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
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::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||||
use sgclaw::security::MacPolicy;
|
use sgclaw::security::MacPolicy;
|
||||||
use zeroclaw::skills::SkillTool;
|
use zeroclaw::skills::SkillTool;
|
||||||
@@ -29,6 +31,174 @@ fn test_policy() -> MacPolicy {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_browser_script_tool_runs_packaged_script_with_expected_domain() {
|
||||||
|
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("extract_hotlist.js"),
|
||||||
|
"return { wrapped_args: args, source: \"packaged script\" };\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||||
|
seq: 1,
|
||||||
|
success: true,
|
||||||
|
data: json!({
|
||||||
|
"text": {
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"rows": [[1, "标题", "10条"]]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
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 mut tool_args = HashMap::new();
|
||||||
|
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||||
|
let skill_tool = SkillTool {
|
||||||
|
name: "extract_hotlist".to_string(),
|
||||||
|
description: "Extract structured hotlist rows".to_string(),
|
||||||
|
kind: "browser_script".to_string(),
|
||||||
|
command: "scripts/extract_hotlist.js".to_string(),
|
||||||
|
args: tool_args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = execute_browser_script_tool(
|
||||||
|
&skill_tool,
|
||||||
|
&skill_dir,
|
||||||
|
browser_tool,
|
||||||
|
json!({
|
||||||
|
"expected_domain": "https://WWW.ZHIHU.COM/hot?foo=bar",
|
||||||
|
"top_n": "10"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
assert!(result.success);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||||
|
json!({
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"rows": [[1, "标题", "10条"]]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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 = {\"top_n\":\"10\"};")
|
||||||
|
&& params["script"].as_str().unwrap().contains("source: \"packaged script\"")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_browser_script_tool_rejects_non_browser_script_tool_kind() {
|
||||||
|
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-kind");
|
||||||
|
let scripts_dir = skill_dir.join("scripts");
|
||||||
|
fs::create_dir_all(&scripts_dir).unwrap();
|
||||||
|
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\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 mut tool_args = HashMap::new();
|
||||||
|
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||||
|
let skill_tool = SkillTool {
|
||||||
|
name: "extract_hotlist".to_string(),
|
||||||
|
description: "Extract structured hotlist rows".to_string(),
|
||||||
|
kind: "shell".to_string(),
|
||||||
|
command: "scripts/extract_hotlist.js".to_string(),
|
||||||
|
args: tool_args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = execute_browser_script_tool(
|
||||||
|
&skill_tool,
|
||||||
|
&skill_dir,
|
||||||
|
browser_tool,
|
||||||
|
json!({
|
||||||
|
"expected_domain": "www.zhihu.com",
|
||||||
|
"top_n": "10"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.success);
|
||||||
|
assert_eq!(
|
||||||
|
result.error.as_deref(),
|
||||||
|
Some("browser script tool kind must be browser_script, got shell")
|
||||||
|
);
|
||||||
|
assert!(transport.sent_messages().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_browser_script_tool_rejects_missing_expected_domain() {
|
||||||
|
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-domain");
|
||||||
|
let scripts_dir = skill_dir.join("scripts");
|
||||||
|
fs::create_dir_all(&scripts_dir).unwrap();
|
||||||
|
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\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 mut tool_args = HashMap::new();
|
||||||
|
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||||
|
let skill_tool = SkillTool {
|
||||||
|
name: "extract_hotlist".to_string(),
|
||||||
|
description: "Extract structured hotlist rows".to_string(),
|
||||||
|
kind: "browser_script".to_string(),
|
||||||
|
command: "scripts/extract_hotlist.js".to_string(),
|
||||||
|
args: tool_args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = execute_browser_script_tool(
|
||||||
|
&skill_tool,
|
||||||
|
&skill_dir,
|
||||||
|
browser_tool,
|
||||||
|
json!({
|
||||||
|
"expected_domain": " ",
|
||||||
|
"top_n": "10"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.success);
|
||||||
|
assert_eq!(
|
||||||
|
result.error.as_deref(),
|
||||||
|
Some("expected_domain must be a non-empty string, got \" \"")
|
||||||
|
);
|
||||||
|
assert!(transport.sent_messages().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn browser_script_skill_tool_executes_packaged_script_via_eval() {
|
async fn browser_script_skill_tool_executes_packaged_script_via_eval() {
|
||||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-skill");
|
let skill_dir = unique_temp_dir("sgclaw-browser-script-skill");
|
||||||
@@ -111,6 +281,87 @@ return {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browser_script_skill_tool_executes_script_directly_under_skill_root() {
|
||||||
|
let skill_root = unique_temp_dir("sgclaw-browser-script-direct-root");
|
||||||
|
let script_name = "extract_hotlist_direct.js";
|
||||||
|
let script_path = skill_root.join(script_name);
|
||||||
|
fs::write(
|
||||||
|
&script_path,
|
||||||
|
r#"
|
||||||
|
return {
|
||||||
|
sheet_name: "知乎热榜",
|
||||||
|
rows: [[1, "标题", args.top_n]]
|
||||||
|
};
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||||
|
seq: 1,
|
||||||
|
success: true,
|
||||||
|
data: json!({
|
||||||
|
"text": {
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"rows": [[1, "标题", "10条"]]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
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 mut args = HashMap::new();
|
||||||
|
args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||||
|
let skill_tool = SkillTool {
|
||||||
|
name: "extract_hotlist".to_string(),
|
||||||
|
description: "Extract structured hotlist rows".to_string(),
|
||||||
|
kind: "browser_script".to_string(),
|
||||||
|
command: script_name.to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_root, browser_tool)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"expected_domain": "https://www.zhihu.com/hot",
|
||||||
|
"top_n": "10条"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
assert!(result.success);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||||
|
json!({
|
||||||
|
"sheet_name": "知乎热榜",
|
||||||
|
"rows": [[1, "标题", "10条"]]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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 = {\"top_n\":\"10条\"};")
|
||||||
|
&& params["script"].as_str().unwrap().contains("rows: [[1, \"标题\", args.top_n]]")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|||||||
@@ -161,6 +161,60 @@ fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
|
|||||||
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
|
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sgclaw_settings_load_direct_submit_only_config_and_resolve_relative_skills_dir() {
|
||||||
|
let root = std::env::temp_dir().join(format!("sgclaw-direct-submit-only-config-{}", Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let config_path = root.join("sgclaw_config.json");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
r#"{
|
||||||
|
"providers": [],
|
||||||
|
"skillsDir": "skill_lib",
|
||||||
|
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||||
|
.unwrap()
|
||||||
|
.expect("expected sgclaw settings from config file");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings.direct_submit_skill.as_deref(),
|
||||||
|
Some("fault-details-report.collect_fault_details")
|
||||||
|
);
|
||||||
|
assert_eq!(settings.skills_dir, Some(root.join("skill_lib")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sgclaw_settings_reject_invalid_direct_submit_skill_format() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"sgclaw-invalid-direct-submit-skill-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let config_path = root.join("sgclaw_config.json");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
r#"{
|
||||||
|
"providers": [],
|
||||||
|
"skillsDir": "skill_lib",
|
||||||
|
"directSubmitSkill": "fault-details-report"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = SgClawSettings::load(Some(config_path.as_path()))
|
||||||
|
.expect_err("expected invalid directSubmitSkill format");
|
||||||
|
let message = err.to_string();
|
||||||
|
|
||||||
|
assert!(message.contains("directSubmitSkill"));
|
||||||
|
assert!(message.contains("skill.tool"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
||||||
let root = std::env::temp_dir().join(format!("sgclaw-runtime-config-{}", Uuid::new_v4()));
|
let root = std::env::temp_dir().join(format!("sgclaw-runtime-config-{}", Uuid::new_v4()));
|
||||||
|
|||||||
Reference in New Issue
Block a user