mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat: Add secure prompt-based skills system (Phase 1 MVP) Implement a skills system that extends the agent with prompt-level instructions from local directories. Skills declare activation criteria, tool permissions, and trust tiers that determine authority attenuation. Core security model: the minimum trust level of any active skill determines a tool ceiling -- tools above the ceiling are removed from the LLM's tool list entirely at the API level, preventing prompt-based manipulation. New modules: - skills/mod.rs: Core types (SkillTrust, SkillManifest, LoadedSkill) - skills/scanner.rs: Content scanner for manipulation detection - skills/registry.rs: Filesystem discovery and manifest parsing - skills/selector.rs: Deterministic two-phase prefilter (no LLM) - skills/attenuation.rs: Trust-based tool filtering Integration: - Agent loop selects skills per-turn and applies tool attenuation - Reasoning engine injects skill context with structural isolation - Config supports SKILLS_ENABLED, SKILLS_DIR, SKILLS_MAX_ACTIVE, SKILLS_MAX_CONTEXT_TOKENS environment variables - Disabled by default (SKILLS_ENABLED=false) 41 new tests covering all modules. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address all adversarial review findings for skills system Security fixes: - Escape skill name/version in XML attributes to prevent trust spoofing - Escape prompt content to prevent </skill> tag breakout - Require integrity hash for Verified/Community tier skills - Validate skill names against [a-zA-Z0-9][a-zA-Z0-9._-]{0,63} - Add 64 KiB file size limit on prompt.md Bug fixes: - Use actual SkillsConfig from AgentDeps instead of SkillsConfig::default() - Add skills_config field to AgentDeps, wired through from main.rs Performance: - Pre-compile regex patterns at load time (cached on LoadedSkill) - Selector uses pre-compiled patterns instead of recompiling per message - Switch all std::fs to tokio::fs for non-blocking async I/O Hardening: - Cap keyword score at 30 points to prevent keyword stuffing attacks - Enforce max 20 keywords and 5 patterns per skill - Normalize line endings (CRLF/CR to LF) before hashing - Also includes cargo fmt formatting fixes for adjacent code Tests: 54 skills tests pass (up from 41), zero new clippy warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address medium/low severity findings from adversarial review Fixes all 18 medium/low severity findings identified by the security review: - mod.rs: Add MAX_TAGS_PER_SKILL cap (10) in enforce_limits(); use RegexBuilder with 64 KiB size_limit to prevent ReDoS; replace case-enumerated escape_skill_content with regex matching all case variants plus whitespace/null byte injection between </ and skill; document allowed_patterns as unenforced until Phase 2; document Marketplace URL validation as Phase 3 concern - registry.rs: Add MAX_MANIFEST_FILE_SIZE (16 KiB) check before reading; add symlink detection via symlink_metadata to reject symlinks in discover_local; add MAX_DISCOVERED_SKILLS (100) cap; validate prompt_hash format (sha256: + 64 hex chars); warn on name collision before overwriting; accept SkillSource parameter in load_skill instead of always using Local; add InvalidHashFormat, ManifestTooLarge, SymlinkDetected error variants - selector.rs: Add MAX_TAG_SCORE (15) cap parallel to keyword cap; warn when declared max_context_tokens diverges >2x from actual prompt size - scanner.rs: Add mixed-script homoglyph detection (Cyrillic, Greek, Armenian unicode ranges); document token-boundary bypass and semantic paraphrasing as known limitations - attenuation.rs: Document READ_ONLY_TOOLS maintenance requirements - agent_loop.rs: Surface scan warnings via structured tracing; add structured audit events for skill activation and tool attenuation 61 tests pass, 0 new clippy warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address 12 findings from second adversarial security review HIGH: - Escape opening <skill tags in prompt content (prevents fake skill block injection) - Scan manifest metadata fields (description, author, tags, reasons) not just prompt - Block trust downgrade on name collision (existing Local can't be replaced by Community) MEDIUM: - Eliminate TOCTOU gap: read files then check size instead of metadata-then-read - Reject file-level symlinks in load_skill (prompt.md, skill.toml) - Truncate and filter manifest.skill.tags (prevent unlimited tag scoring) - Cap regex pattern score at 40 (prevent 5x20=100 dominating keyword+tag) - Add doc comment about skill_list tool exposing metadata (sanitization required) - Move Community disclaimer inside <skill> tags (not outside structural boundary) - Filter keywords/tags shorter than 3 chars (prevent broad matching) LOW: - Enforce minimum token_cost of 1 (max_context_tokens=0 can't bypass budget) - Remove redundant try_exists checks in discover_local (let load_skill handle errors) 70 skills tests passing. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add HTTP endpoint scoping for skills (Phase 1) Skills that declare an [http] section in skill.toml now have their HTTP requests constrained to declared endpoints at runtime. This addresses the gap where allowed_patterns was parsed but never enforced -- once the http tool was visible via attenuation, the LLM could reach any URL. Enforcement reuses EndpointPattern/AllowlistValidator from the WASM capability system. Semantics: if no active skill declares [http], all requests pass through (backward compat). If any skill declares [http], URLs must match at least one skill's allowlist (union). Community skills' [http] declarations are silently ignored (defense in depth). Shell commands using curl/wget are also validated against scopes. Scanner gains detection for known exfiltration domains (webhook.site, ngrok.io, etc.), overly broad wildcards, and credential/host mismatches. Closes #38 Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: Apply cargo fmt to http_scoping.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: Apply cargo fmt across codebase Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add parameter-level permission enforcement for skills (Phase 2) Activates enforcement of `allowed_patterns` in skill.toml permissions. Previously these patterns were parsed but not enforced -- a Verified skill declaring `permissions.shell` with `allowed_patterns = [{command = "cargo *"}]` could still run any shell command. Now the enforcer validates tool parameters against declared glob patterns before execution. Key changes: - New `enforcer.rs` module with `SkillPermissionEnforcer`, `glob_to_regex()`, and `validate_tool_call()` with union semantics across active skills - Typed pattern enums (`ShellPattern`, `FilePathPattern`, `MemoryTargetPattern`) replace the previous `Vec<serde_json::Value>` in `ToolPermissionDeclaration` - Scanner gains `scan_permission_patterns()` detecting dangerous patterns (rm, sudo, curl, bare wildcards, command chaining, sensitive paths, identity files) - Registry blocks non-Local skills with critical permission pattern warnings - Agent loop threads enforcer into `execute_chat_tool` alongside HTTP scoping Trust interaction: Community patterns ignored, Verified enforced, Local without patterns unrestricted, Local with patterns enforced as guidance. Union semantics across skills -- tool call allowed if ANY skill's patterns permit it. 34 new tests. All 818 library tests pass. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add worker permission enforcement and LLM behavioral analysis (Phase 3+4) Phase 3 - Worker-side permission enforcement: - Add SerializedToolPermission/SerializedPattern DTOs for HTTP boundary crossing - Extend JobDescription, ContainerHandle, and orchestrator API to carry permissions - CreateJobTool snapshots and forwards skill permissions to spawned workers - Worker runtime builds SkillPermissionEnforcer and checks before tool execution - Load-time token budget enforcement rejects prompts exceeding 2x declared budget - Deduplicate enforcer construction: from_active_skills() delegates to from_serialized() Phase 4 - LLM behavioral analysis: - BehavioralAnalyzer with cached, LLM-based semantic content analysis - Structured output parsing (FINDING|CATEGORY|SEVERITY|DESCRIPTION or CLEAN) - Content-hash caching with bounded size (MAX_CACHE_ENTRIES=256) - Graceful degradation when LLM unavailable - Integrated into load_skill() for non-Local skills; critical findings block loading Review fixes: - Real cache tests with CountingLlm mock (test_cache_hit, test_cache_miss, test_cache_bounded) - UTF-8-safe truncate() in worker runtime - Few-shot examples in behavioral analysis prompt - Documented max_context_tokens=0 opt-out and create_job() permission gap 848 tests passing, no new clippy warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address review feedback from serrrfirat on skills-phase2 - Fix truncate_cmd UTF-8 panic: use char-boundary-aware slicing - Remove redundant effective_tools branching in reasoning.rs - Document cache eviction as known limitation (arbitrary, not LRU) - Add safety comment on SkillTrust enum ordering (security-critical) - Simplify active_skills selection (prefilter_skills handles empty input) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address remaining skills review feedback * refactor: replace skills system with OpenClaw SKILL.md format + 2-state trust Replace the 5-gate, 3-tier trust hierarchy (scanner, behavioral analyzer, parameter-level enforcer, HTTP endpoint scoping) with a simplified 3-layer security model: gating -> attenuation -> Docker confinement. Key changes: - SKILL.md format (YAML frontmatter + markdown prompt) replaces skill.toml + prompt.md - 2-state trust (Installed/Trusted) replaces 3-tier (Community/Verified/Local) - New parser.rs for SKILL.md parsing with serde_yaml - New gating.rs for requirements checking (bins/env/config) - Simplified registry with 2-location discovery (workspace + user dirs) - Removed scanner, behavioral_analyzer, enforcer, http_scoping (~4,100 lines) - Removed skill_permissions propagation through job/orchestrator/worker pipeline - Added serde_yaml dependency for YAML frontmatter parsing Net: -5,298 lines, 59 skills tests pass, 907 total tests pass. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add in-app skill management tools and ClawHub catalog integration Add 4 chat-callable tools (skill_list, skill_search, skill_install, skill_remove) plus matching web gateway endpoints for managing skills at runtime. The catalog fetches from ClawHub's public registry API at runtime rather than bundling entries at compile time. Key changes: - SkillRegistry gains mutation methods (install_skill, remove_skill, reload, find_by_name) with Arc<RwLock> for concurrent access - New catalog module queries ClawHub /api/v1/search with in-memory caching (5-min TTL, configurable via CLAWHUB_REGISTRY env var) - skill_list and skill_search added to READ_ONLY_TOOLS for safe use under Installed trust ceiling - Web gateway gets /api/skills, /api/skills/search, /api/skills/install, and /api/skills/{name} DELETE endpoints Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #51 review feedback from ilblackdragon Security: - Add SSRF protection to fetch_skill_content: require HTTPS, reject private/loopback/link-local IPs and internal hostnames, disable redirects. Gateway install handler now reuses the same validation. - URL-encode slug in skill_download_url to prevent query injection. - Require X-Confirm-Action header on gateway skill install/remove endpoints (equivalent to chat tool requires_approval gate). Correctness: - Eliminate all block_in_place/block_on usage in skill tools and gateway handlers. Split install into prepare_install_to_disk (static async, no lock) + commit_install (sync, brief write lock). Same pattern for remove: validate_remove + delete_skill_files + commit_remove. - Write normalized content to disk in install_skill (was writing original un-normalized content, causing hash mismatch on re-read). - Fix token estimation from 0.75 to 0.25 tokens/byte (~4 chars per token) in registry.rs, selector.rs, and standalone loader. Dependencies: - Replace deprecated serde_yaml 0.9 with serde_yml 0.0.12. - Remove unused toml dependency. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
551 lines
19 KiB
Rust
551 lines
19 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use chrono::Utc;
|
|
use tokio::sync::Mutex;
|
|
use uuid::Uuid;
|
|
|
|
use ironclaw::agent::{Agent, AgentDeps};
|
|
use ironclaw::channels::{ChannelManager, IncomingMessage};
|
|
use ironclaw::config::AgentConfig;
|
|
use ironclaw::llm::LlmProvider;
|
|
use ironclaw::safety::SafetyLayer;
|
|
use ironclaw::tools::ToolRegistry;
|
|
|
|
use crate::channel::BenchChannel;
|
|
use crate::config::{BenchConfig, MatrixEntry};
|
|
use crate::error::BenchError;
|
|
use crate::instrumented_llm::InstrumentedLlm;
|
|
use crate::results::{
|
|
RunResult, TaskResult, Trace, append_task_result, completed_task_ids, run_dir, run_json_path,
|
|
tasks_jsonl_path, write_run_result, write_task_results,
|
|
};
|
|
use crate::suite::{BenchSuite, BenchTask, ConversationTurn, TaskSubmission, TurnRole};
|
|
|
|
/// Parameters for running a single task in isolation.
|
|
struct TaskRunParams<'a> {
|
|
task: &'a BenchTask,
|
|
suite_id: &'a str,
|
|
config_label: &'a str,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
timeout: std::time::Duration,
|
|
additional_tools: &'a [Arc<dyn ironclaw::tools::Tool>],
|
|
}
|
|
|
|
/// Orchestrates benchmark execution: loads tasks, runs agent per task,
|
|
/// scores results, writes JSONL output.
|
|
pub struct BenchRunner {
|
|
suite: Arc<dyn BenchSuite>,
|
|
config: BenchConfig,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
}
|
|
|
|
impl BenchRunner {
|
|
pub fn new(
|
|
suite: Box<dyn BenchSuite>,
|
|
config: BenchConfig,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
) -> Self {
|
|
Self {
|
|
suite: Arc::from(suite),
|
|
config,
|
|
llm,
|
|
safety,
|
|
}
|
|
}
|
|
|
|
/// Run the benchmark for one matrix entry.
|
|
///
|
|
/// Returns the run_id for result retrieval.
|
|
pub async fn run(
|
|
&self,
|
|
matrix: &MatrixEntry,
|
|
sample: Option<usize>,
|
|
task_filter: Option<&[String]>,
|
|
tag_filter: Option<&[String]>,
|
|
resume_run_id: Option<Uuid>,
|
|
) -> Result<Uuid, BenchError> {
|
|
let run_id = resume_run_id.unwrap_or_else(Uuid::new_v4);
|
|
let results_base = &self.config.results_dir;
|
|
let dir = run_dir(results_base, run_id);
|
|
std::fs::create_dir_all(&dir)?;
|
|
|
|
let jsonl_path = tasks_jsonl_path(results_base, run_id);
|
|
let json_path = run_json_path(results_base, run_id);
|
|
|
|
// Load completed task IDs for resume support
|
|
let completed: HashSet<String> = if resume_run_id.is_some() {
|
|
completed_task_ids(&jsonl_path)?
|
|
} else {
|
|
HashSet::new()
|
|
};
|
|
|
|
if !completed.is_empty() {
|
|
tracing::info!(
|
|
"Resuming run {}: {} tasks already completed",
|
|
run_id,
|
|
completed.len()
|
|
);
|
|
}
|
|
|
|
// Load all tasks once (used for both execution and scoring)
|
|
let all_tasks = self.suite.load_tasks().await?;
|
|
let task_index: HashMap<String, BenchTask> = all_tasks
|
|
.iter()
|
|
.map(|t| (t.id.clone(), t.clone()))
|
|
.collect();
|
|
|
|
// Filter tasks for execution
|
|
let mut tasks = all_tasks;
|
|
|
|
if let Some(ids) = task_filter {
|
|
let id_set: HashSet<&str> = ids.iter().map(|s| s.as_str()).collect();
|
|
tasks.retain(|t| id_set.contains(t.id.as_str()));
|
|
}
|
|
|
|
if let Some(tags) = tag_filter {
|
|
let tag_set: HashSet<&str> = tags.iter().map(|s| s.as_str()).collect();
|
|
tasks.retain(|t| t.tags.iter().any(|tag| tag_set.contains(tag.as_str())));
|
|
}
|
|
|
|
// Filter out already-completed tasks
|
|
tasks.retain(|t| !completed.contains(&t.id));
|
|
|
|
// Sample if requested
|
|
if let Some(n) = sample {
|
|
tasks.truncate(n);
|
|
}
|
|
|
|
let total_tasks = tasks.len() + completed.len();
|
|
let model_label = matrix.model.as_deref().unwrap_or(self.llm.model_name());
|
|
let commit_hash = git_short_hash();
|
|
tracing::info!(
|
|
"[{} @ {}] Running {} tasks for suite '{}' (run: {})",
|
|
model_label,
|
|
commit_hash,
|
|
tasks.len(),
|
|
self.suite.id(),
|
|
run_id
|
|
);
|
|
|
|
let started_at = Utc::now();
|
|
let all_results: Arc<Mutex<Vec<TaskResult>>> =
|
|
Arc::new(Mutex::new(Vec::with_capacity(tasks.len())));
|
|
|
|
if self.config.parallelism <= 1 {
|
|
// Sequential execution
|
|
let additional_tools = self.suite.additional_tools();
|
|
for (i, task) in tasks.iter().enumerate() {
|
|
tracing::info!(
|
|
"[{}/{}] Running task: {}",
|
|
i + 1 + completed.len(),
|
|
total_tasks,
|
|
task.id
|
|
);
|
|
if let Err(e) = self.suite.setup_task(task).await {
|
|
tracing::warn!("setup_task failed for {}: {}", task.id, e);
|
|
let result = make_error_result(
|
|
task,
|
|
self.suite.id(),
|
|
&matrix.label,
|
|
Utc::now(),
|
|
&format!("setup_task failed: {e}"),
|
|
);
|
|
append_task_result(&jsonl_path, &result)?;
|
|
all_results.lock().await.push(result);
|
|
continue;
|
|
}
|
|
let params = TaskRunParams {
|
|
task,
|
|
suite_id: self.suite.id(),
|
|
config_label: &matrix.label,
|
|
llm: Arc::clone(&self.llm),
|
|
safety: Arc::clone(&self.safety),
|
|
timeout: task.timeout.unwrap_or(self.config.task_timeout),
|
|
additional_tools: &additional_tools,
|
|
};
|
|
let result = run_task_isolated(params).await;
|
|
if let Err(e) = self.suite.teardown_task(task).await {
|
|
tracing::warn!("teardown_task failed for {}: {}", task.id, e);
|
|
}
|
|
append_task_result(&jsonl_path, &result)?;
|
|
all_results.lock().await.push(result);
|
|
}
|
|
} else {
|
|
// Parallel execution with bounded concurrency
|
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(self.config.parallelism));
|
|
let shared_tools: Arc<[Arc<dyn ironclaw::tools::Tool>]> =
|
|
Arc::from(self.suite.additional_tools());
|
|
|
|
let mut handles = Vec::new();
|
|
for (i, task) in tasks.into_iter().enumerate() {
|
|
let sem = Arc::clone(&semaphore);
|
|
let suite = Arc::clone(&self.suite);
|
|
let config_label = matrix.label.clone();
|
|
let llm = Arc::clone(&self.llm);
|
|
let safety = Arc::clone(&self.safety);
|
|
let timeout = task.timeout.unwrap_or(self.config.task_timeout);
|
|
let results_ref = Arc::clone(&all_results);
|
|
let completed_count = completed.len();
|
|
let total = total_tasks;
|
|
let additional_tools = Arc::clone(&shared_tools);
|
|
|
|
handles.push(tokio::spawn(async move {
|
|
let _permit = match sem.acquire().await {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
tracing::error!("Semaphore closed for task {}", task.id);
|
|
return;
|
|
}
|
|
};
|
|
tracing::info!(
|
|
"[{}/{}] Running task: {}",
|
|
i + 1 + completed_count,
|
|
total,
|
|
task.id
|
|
);
|
|
if let Err(e) = suite.setup_task(&task).await {
|
|
tracing::warn!("setup_task failed for {}: {}", task.id, e);
|
|
let result = make_error_result(
|
|
&task,
|
|
suite.id(),
|
|
&config_label,
|
|
Utc::now(),
|
|
&format!("setup_task failed: {e}"),
|
|
);
|
|
results_ref.lock().await.push(result);
|
|
return;
|
|
}
|
|
let suite_id = suite.id().to_string();
|
|
let params = TaskRunParams {
|
|
task: &task,
|
|
suite_id: &suite_id,
|
|
config_label: &config_label,
|
|
llm,
|
|
safety,
|
|
timeout,
|
|
additional_tools: &additional_tools,
|
|
};
|
|
let result = run_task_isolated(params).await;
|
|
if let Err(e) = suite.teardown_task(&task).await {
|
|
tracing::warn!("teardown_task failed for {}: {}", task.id, e);
|
|
}
|
|
results_ref.lock().await.push(result);
|
|
}));
|
|
}
|
|
|
|
for handle in handles {
|
|
if let Err(e) = handle.await {
|
|
tracing::error!("Task panicked: {}", e);
|
|
}
|
|
}
|
|
|
|
// Write all results to JSONL after parallel execution completes.
|
|
// This avoids the race condition of concurrent file appends.
|
|
let results = all_results.lock().await;
|
|
for result in results.iter() {
|
|
append_task_result(&jsonl_path, result)?;
|
|
}
|
|
}
|
|
|
|
// Score all results using the cached task index
|
|
let results = all_results.lock().await;
|
|
let mut scored: Vec<TaskResult> = Vec::with_capacity(results.len());
|
|
for result in results.iter() {
|
|
if let Some(task) = task_index.get(&result.task_id) {
|
|
let submission = TaskSubmission {
|
|
response: result.response.clone(),
|
|
conversation: vec![],
|
|
tool_calls: result
|
|
.trace
|
|
.tool_calls
|
|
.iter()
|
|
.map(|tc| tc.name.clone())
|
|
.collect(),
|
|
error: result.error.clone(),
|
|
};
|
|
match self.suite.score(task, &submission).await {
|
|
Ok(score) => {
|
|
let mut scored_result = result.clone();
|
|
scored_result.score = score;
|
|
scored.push(scored_result);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Scoring failed for {}: {}", result.task_id, e);
|
|
scored.push(result.clone());
|
|
}
|
|
}
|
|
} else {
|
|
scored.push(result.clone());
|
|
}
|
|
}
|
|
|
|
// Combine with any previously completed results for the aggregate
|
|
let mut all_for_aggregate = crate::results::read_task_results(&jsonl_path)?;
|
|
// De-duplicate (prefer the newer scored versions)
|
|
let scored_ids: HashSet<String> = scored.iter().map(|r| r.task_id.clone()).collect();
|
|
all_for_aggregate.retain(|r| !scored_ids.contains(&r.task_id));
|
|
all_for_aggregate.extend(scored);
|
|
|
|
// Rewrite JSONL with scored results so `results` command shows final scores
|
|
write_task_results(&jsonl_path, &all_for_aggregate)?;
|
|
|
|
let model_name = matrix.model.as_deref().unwrap_or(self.llm.model_name());
|
|
|
|
let run_result = RunResult::from_tasks(
|
|
run_id,
|
|
self.suite.id(),
|
|
&matrix.label,
|
|
model_name,
|
|
&commit_hash,
|
|
total_tasks,
|
|
&all_for_aggregate,
|
|
started_at,
|
|
);
|
|
|
|
write_run_result(&json_path, &run_result)?;
|
|
|
|
tracing::info!(
|
|
"[{} @ {}] Run {} complete: {:.1}% pass rate, {:.3} avg score, ${:.4} cost",
|
|
model_name,
|
|
commit_hash,
|
|
run_id,
|
|
run_result.pass_rate * 100.0,
|
|
run_result.avg_score,
|
|
run_result.total_cost_usd,
|
|
);
|
|
|
|
Ok(run_id)
|
|
}
|
|
}
|
|
|
|
/// Run a single benchmark task in complete isolation.
|
|
///
|
|
/// Creates a fresh Agent + BenchChannel + InstrumentedLlm for the task,
|
|
/// injects the prompt, waits for the response, and returns the result.
|
|
///
|
|
/// # Current limitations
|
|
///
|
|
/// - **Single-turn only**: After the first assistant response, `/quit` is sent.
|
|
/// Multi-turn suites (e.g., Tau-bench's `next_user_message()`) are not yet wired.
|
|
/// - **Resources not injected**: `BenchTask.resources` (e.g., GAIA file attachments)
|
|
/// are not included in the prompt or made available via the workspace.
|
|
/// - **Conversation not captured**: `TaskSubmission.conversation` is always empty,
|
|
/// which prevents multi-turn scoring hooks from working.
|
|
async fn run_task_isolated(params: TaskRunParams<'_>) -> TaskResult {
|
|
let TaskRunParams {
|
|
task,
|
|
suite_id,
|
|
config_label,
|
|
llm,
|
|
safety,
|
|
timeout,
|
|
additional_tools,
|
|
} = params;
|
|
|
|
let started_at = Utc::now();
|
|
let start = Instant::now();
|
|
|
|
// Wrap LLM with instrumentation
|
|
let instrumented = Arc::new(InstrumentedLlm::new(llm));
|
|
|
|
// Create bench channel
|
|
let (bench_channel, msg_tx) = BenchChannel::new();
|
|
let capture = bench_channel.capture();
|
|
|
|
// Build tool registry
|
|
let tools = Arc::new(ToolRegistry::new());
|
|
tools.register_builtin_tools();
|
|
|
|
// Register additional suite-specific tools
|
|
for tool in additional_tools {
|
|
tools.register(Arc::clone(tool)).await;
|
|
}
|
|
|
|
// Build agent config (minimal, headless)
|
|
let agent_config = AgentConfig {
|
|
name: format!("bench-{}", task.id),
|
|
max_parallel_jobs: 1,
|
|
job_timeout: timeout,
|
|
stuck_threshold: timeout,
|
|
repair_check_interval: timeout + std::time::Duration::from_secs(999),
|
|
max_repair_attempts: 0,
|
|
use_planning: false,
|
|
session_idle_timeout: timeout,
|
|
allow_local_tools: true,
|
|
max_cost_per_day_cents: None,
|
|
max_actions_per_hour: None,
|
|
};
|
|
|
|
let cost_guard = Arc::new(ironclaw::agent::cost_guard::CostGuard::new(
|
|
ironclaw::agent::cost_guard::CostGuardConfig::default(),
|
|
));
|
|
|
|
let deps = AgentDeps {
|
|
store: None,
|
|
llm: instrumented.clone() as Arc<dyn LlmProvider>,
|
|
cheap_llm: None,
|
|
safety,
|
|
tools,
|
|
workspace: None,
|
|
extension_manager: None,
|
|
skill_registry: None,
|
|
skills_config: ironclaw::config::SkillsConfig::default(),
|
|
hooks: Arc::new(ironclaw::hooks::HookRegistry::new()),
|
|
cost_guard,
|
|
};
|
|
|
|
let mut channels = ChannelManager::new();
|
|
channels.add(Box::new(bench_channel));
|
|
|
|
let agent = Agent::new(agent_config, deps, channels, None, None, None, None);
|
|
|
|
// Build the full prompt with context
|
|
let full_prompt = if let Some(ref ctx) = task.context {
|
|
format!("{}\n\nContext:\n{}", task.prompt, ctx)
|
|
} else {
|
|
task.prompt.clone()
|
|
};
|
|
|
|
// Inject the task prompt
|
|
let incoming = IncomingMessage::new("bench", "bench-user", &full_prompt);
|
|
if msg_tx.send(incoming).await.is_err() {
|
|
return make_error_result(
|
|
task,
|
|
suite_id,
|
|
config_label,
|
|
started_at,
|
|
"failed to send prompt",
|
|
);
|
|
}
|
|
|
|
// Record prompt in conversation
|
|
{
|
|
let mut cap = capture.lock().await;
|
|
cap.conversation.push(ConversationTurn {
|
|
role: TurnRole::User,
|
|
content: full_prompt,
|
|
});
|
|
}
|
|
|
|
// Run agent with timeout.
|
|
// After the first response, send /quit to end the session.
|
|
let quit_tx = msg_tx.clone();
|
|
let capture_for_quit = Arc::clone(&capture);
|
|
let quit_handle = tokio::spawn(async move {
|
|
// Poll for first response
|
|
loop {
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
let cap = capture_for_quit.lock().await;
|
|
if !cap.responses.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
// Give a small grace period for any final status events
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
let quit = IncomingMessage::new("bench", "bench-user", "/quit");
|
|
let _ = quit_tx.send(quit).await;
|
|
});
|
|
|
|
let agent_result = tokio::time::timeout(timeout, agent.run()).await;
|
|
|
|
quit_handle.abort();
|
|
|
|
let wall_time = start.elapsed();
|
|
let hit_timeout = agent_result.is_err();
|
|
|
|
if let Ok(Err(e)) = &agent_result {
|
|
tracing::warn!("Agent error for task {}: {}", task.id, e);
|
|
}
|
|
|
|
// Extract results from capture
|
|
let cap = capture.lock().await;
|
|
let response = cap.responses.last().cloned().unwrap_or_default();
|
|
|
|
let trace = Trace {
|
|
wall_time_ms: wall_time.as_millis() as u64,
|
|
llm_calls: instrumented.call_count(),
|
|
input_tokens: instrumented.total_input_tokens(),
|
|
output_tokens: instrumented.total_output_tokens(),
|
|
estimated_cost_usd: instrumented.estimated_cost(),
|
|
tool_calls: cap.tool_calls.clone(),
|
|
turns: cap.responses.len() as u32,
|
|
hit_iteration_limit: false,
|
|
hit_timeout,
|
|
};
|
|
|
|
let error = if hit_timeout {
|
|
Some(format!("timeout after {}s", timeout.as_secs()))
|
|
} else if let Ok(Err(e)) = &agent_result {
|
|
Some(e.to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
TaskResult {
|
|
task_id: task.id.clone(),
|
|
suite_id: suite_id.to_string(),
|
|
score: crate::suite::BenchScore {
|
|
value: 0.0,
|
|
label: "pending".to_string(),
|
|
details: None,
|
|
},
|
|
trace,
|
|
response,
|
|
started_at,
|
|
finished_at: Utc::now(),
|
|
config_label: config_label.to_string(),
|
|
error,
|
|
}
|
|
}
|
|
|
|
fn make_error_result(
|
|
task: &BenchTask,
|
|
suite_id: &str,
|
|
config_label: &str,
|
|
started_at: chrono::DateTime<Utc>,
|
|
reason: &str,
|
|
) -> TaskResult {
|
|
TaskResult {
|
|
task_id: task.id.clone(),
|
|
suite_id: suite_id.to_string(),
|
|
score: crate::suite::BenchScore::fail(reason),
|
|
trace: Trace {
|
|
wall_time_ms: 0,
|
|
llm_calls: 0,
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
estimated_cost_usd: 0.0,
|
|
tool_calls: vec![],
|
|
turns: 0,
|
|
hit_iteration_limit: false,
|
|
hit_timeout: false,
|
|
},
|
|
response: String::new(),
|
|
started_at,
|
|
finished_at: Utc::now(),
|
|
config_label: config_label.to_string(),
|
|
error: Some(reason.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Get the short git commit hash of HEAD, or "unknown" if not in a repo.
|
|
fn git_short_hash() -> String {
|
|
std::process::Command::new("git")
|
|
.args(["rev-parse", "--short", "HEAD"])
|
|
.output()
|
|
.ok()
|
|
.and_then(|o| {
|
|
if o.status.success() {
|
|
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
}
|