Compare commits

..
82 changed files with 3589 additions and 6155 deletions
-7
View File
@@ -2,16 +2,9 @@
DATABASE_URL=postgres://localhost/ironclaw
DATABASE_POOL_SIZE=10
# Vector store for workspace memory (optional)
# When set to "lancedb", uses LanceDB for semantic search instead of pgvector/libsql
# VECTOR_BACKEND=builtin # default: use database's built-in index (pgvector or libsql_vector_idx); "pgvector" is also accepted as an alias for "builtin"
# VECTOR_BACKEND=lancedb
# LANCEDB_PATH=~/.ironclaw/lancedb # path for LanceDB when VECTOR_BACKEND=lancedb
# LLM Provider
# LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
# === Anthropic Direct ===
# Two auth modes:
-10
View File
@@ -36,11 +36,6 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install protoc (for lancedb)
if: matrix.name == 'all-features'
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
key: clippy-${{ matrix.name }}
@@ -67,11 +62,6 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install protoc (for lancedb)
if: matrix.name == 'all-features'
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
key: clippy-windows-${{ matrix.name }}
+1 -15
View File
@@ -14,7 +14,7 @@ jobs:
matrix:
include:
- name: all-features
flags: "--features postgres,libsql,lancedb,html-to-markdown"
flags: "--features postgres,libsql,html-to-markdown"
- name: default
flags: ""
- name: libsql-only
@@ -26,11 +26,6 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip2
- name: Install protoc (for lancedb)
if: matrix.name == 'all-features'
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.name }}
@@ -71,11 +66,6 @@ jobs:
uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install protoc (for lancedb)
if: matrix.name == 'all-features'
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
key: windows-${{ matrix.name }}
@@ -92,10 +82,6 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip2
- name: Install protoc (for lancedb)
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
key: wasm-extensions
+1 -2
View File
@@ -4,9 +4,8 @@
.env.*
!.env.example
# Claude Code worktrees and lock files
# Claude Code worktrees
.claude/worktrees/
.claude/scheduled_tasks.lock
# Sidecar tool data
.sidecar/
Generated
+18 -2898
View File
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -73,8 +73,6 @@ toml = "0.8"
# Core types
uuid = { version = "1", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
iana-time-zone = "0.1"
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
rust_decimal_macros = "1"
@@ -125,11 +123,6 @@ open = "5"
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
pgvector = { version = "0.4", features = ["postgres"], optional = true }
# LanceDB vector store (optional alternative to pgvector/libsql for workspace search)
lancedb = { version = "0.26", optional = true }
arrow-array = { version = "57", optional = true }
arrow-schema = { version = "57", optional = true }
# WASM sandbox for untrusted tool execution
wasmtime = { version = "28", features = ["component-model"] }
wasmtime-wasi = "28" # WASI support for component model
@@ -173,6 +166,7 @@ html-to-markdown-rs = { version = "2.3", optional = true }
readabilityrs = { version = "0.1.2", optional = true }
ed25519-dalek = { version = "2.2.0", features = ["std"] }
hex = "0.4.3"
rust-analyzer = "0.0.1"
# macOS keychain
[target.'cfg(target_os = "macos")'.dependencies]
@@ -194,7 +188,6 @@ insta = "1.46.3"
[features]
default = ["postgres", "libsql", "html-to-markdown"]
lancedb = ["dep:lancedb", "dep:arrow-array", "dep:arrow-schema"]
postgres = [
"dep:deadpool-postgres",
"dep:tokio-postgres",
+4 -3
View File
@@ -39,7 +39,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
| Gateway lock (PID-based) | ✅ | | `fs4` flock-based, acquired in `main.rs` before agent startup |
| Gateway lock (PID-based) | ✅ | | |
| launchd/systemd integration | ✅ | ❌ | |
| Bonjour/mDNS discovery | ✅ | ❌ | |
| Tailscale integration | ✅ | ❌ | |
@@ -340,7 +340,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Gemini embeddings | ✅ | ❌ | |
| Local embeddings | ✅ | ❌ | |
| SQLite-vec backend | ✅ | ❌ | IronClaw uses PostgreSQL |
| LanceDB backend | ✅ | | VectorStore trait + LanceDbVectorStore (configured via VECTOR_BACKEND=lancedb) |
| LanceDB backend | ✅ | | Configurable auto-capture max length |
| QMD backend | ✅ | ❌ | |
| Atomic reindexing | ✅ | ✅ | |
| Embeddings batching | ✅ | ✅ | `embed_batch` on EmbeddingProvider trait |
@@ -433,6 +433,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
| `onSessionStart` hook | ✅ | ✅ | P2 | |
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
@@ -551,7 +552,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ❌ Media handling (images, PDFs)
- ✅ Ollama/local model support (via rig::providers::ollama)
- ❌ Configuration hot-reload
- Webhook trigger endpoint in web gateway
- Webhook trigger endpoint in web gateway (`/api/webhooks/github` -> `system_event` routines)
- ❌ Channel health monitor with auto-restart
- ❌ Partial output preservation on abort
+1 -1
View File
@@ -3,7 +3,7 @@ services:
postgres:
image: pgvector/pgvector:pg16
ports:
- "127.0.0.1:5432:5432"
- "5432:5432"
environment:
POSTGRES_DB: ironclaw
POSTGRES_USER: ironclaw
@@ -0,0 +1,81 @@
---
name: ironclaw-workflow-orchestrator
description: "Install and operate a full GitHub issue-to-merge workflow in IronClaw using event-driven and cron routines. Use when setting up or tuning autonomous project orchestration: issue intake, planning, maintainer feedback handling, branch/PR execution, CI/comment follow-up, batched staging review every 8 hours, and memory updates from merge outcomes."
---
# IronClaw Workflow Orchestrator
## Overview
Use this skill to install and maintain a complete project workflow as routines, not core code changes. It maps GitHub webhook events plus scheduled checks into plan/update/implement/review/merge loops with explicit staging-batch analysis.
## Workflow
1. Gather workflow parameters.
2. Verify runtime prerequisites.
3. Install or update routine set from templates.
4. Run a dry test with `event_emit`.
5. Monitor outcomes and tune prompts/filters.
## Parameters
Collect these values before creating routines:
- `repository`: `owner/repo` (required)
- `maintainers`: GitHub handles allowed to trigger implement/replan actions
- `staging_branch`: default `staging`
- `main_branch`: default `main`
- `batch_interval_hours`: default `8`
- `implementation_label`: default `autonomous-impl`
## Prerequisites
Before installing routines, verify:
- Routines system enabled.
- GitHub tool authenticated (for issue/PR/comment/status operations).
- GitHub webhook delivery configured to `POST /api/webhooks/github`.
- Optional webhook secret configured (`GITHUB_WEBHOOK_SECRET` or gateway setting `github.webhook_secret`).
## Install Procedure
1. Open [`workflow-routines.md`](references/workflow-routines.md).
2. For each template block:
- replace placeholders (`{{repository}}`, `{{maintainers}}`, branch names)
- call `routine_create`
3. If a routine already exists:
- use `routine_update` instead of creating duplicates
- keep names stable so long-lived metrics/history stay intact
4. Confirm install with `routine_list` and `routine_history`.
## Routine Set
Install these routines:
- `wf-issue-plan`: on `issue.opened` or `issue.reopened`, generate implementation plan comment/checklist.
- `wf-maintainer-comment-gate`: on maintainer comments, decide update-plan vs start implementation.
- `wf-pr-monitor-loop`: on PR open/sync/review-comment/review, address feedback and refresh branch.
- `wf-ci-fix-loop`: on CI status/check failures, apply fixes and push updates.
- `wf-staging-batch-review`: every 8h, review ready PRs, merge into staging, run deep batch correctness analysis, fix findings, then merge staging -> main.
- `wf-learning-memory`: on merged PRs, extract mistakes/lessons and write to shared memory.
## Event Filters
Prefer top-level filters for stability:
- `repository` (string)
- `sender` (string)
- `issue_number` / `pr_number`
- `ci_status`, `ci_conclusion`
- `review_state`, `comment_author`
Use narrow filters to avoid accidental triggers across repos.
## Operating Rules
- All implementation work must occur on non-main branches.
- PR loop must resolve both human and AI review comments.
- On conflicts with `origin/main`, refresh branch before continuing.
- Staging-batch routine is the only path for bulk correctness verification before mainline merge.
- Memory update routine runs only after successful merge.
## Validation
After install, run:
1. `event_emit` with a synthetic `issue.opened` payload for the target repo.
2. Confirm at least one routine fired.
3. Check corresponding `routine_history` entries.
4. Confirm no unrelated routines fired.
## When To Update Templates
Update this skill when:
- GitHub event names/payload fields change.
- Team review policy changes (e.g., staging cadence, maintainer gates).
- New CI policy requires different failure routing.
@@ -0,0 +1,4 @@
interface:
display_name: "IronClaw Workflow Orchestrator"
short_description: "Install and run event-driven GitHub workflow routines"
default_prompt: "Set up the full issue-to-merge workflow using routines and event triggers."
@@ -0,0 +1,128 @@
# Workflow Routine Templates
Replace `{{...}}` placeholders before use.
## 1) Issue -> Plan
```json
{
"name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {
"repository": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
"cooldown_secs": 30
}
```
## 2) Maintainer Comment Gate (Update Plan vs Implement)
Trigger per-maintainer by creating one routine per handle, or maintain a shared author convention.
```json
{
"name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.comment.created",
"event_filters": {
"repository": "{{repository}}",
"comment_author": "{{maintainer}}"
},
"action_type": "full_job",
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
"cooldown_secs": 20
}
```
## 3) PR Monitor Loop
```json
{
"name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.synchronize",
"event_filters": {
"repository": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
"cooldown_secs": 20
}
```
## 4) CI Failure Fix Loop
```json
{
"name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "ci.check_run.completed",
"event_filters": {
"repository": "{{repository}}",
"ci_conclusion": "failure"
},
"action_type": "full_job",
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
"cooldown_secs": 20
}
```
## 5) Staging Batch Review (Every 8h)
```json
{
"name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main",
"trigger_type": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *",
"action_type": "full_job",
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
"cooldown_secs": 120
}
```
## 6) Post-Merge Learning -> Common Memory
```json
{
"name": "wf-learning-memory",
"description": "Capture merge learnings into shared memory",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.closed",
"event_filters": {
"repository": "{{repository}}",
"pr_merged": "true"
},
"action_type": "full_job",
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
"cooldown_secs": 30
}
```
## Optional: Synthetic Event Test
```json
{
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "{{repository}}",
"issue_number": 99999,
"sender": "test-bot"
}
}
```
Use with `event_emit` after routine install.
+1 -6
View File
@@ -356,12 +356,6 @@ impl Agent {
if let Some(workspace) = self.workspace() {
let mut config = AgentHeartbeatConfig::default()
.with_interval(std::time::Duration::from_secs(hb_config.interval_secs));
config.quiet_hours_start = hb_config.quiet_hours_start;
config.quiet_hours_end = hb_config.quiet_hours_end;
config.timezone = hb_config
.timezone
.clone()
.or_else(|| Some(self.config.default_timezone.clone()));
if let (Some(user), Some(channel)) =
(&hb_config.notify_user, &hb_config.notify_channel)
{
@@ -417,6 +411,7 @@ impl Agent {
hygiene,
workspace.clone(),
self.cheap_llm().clone(),
self.safety().clone(),
Some(notify_tx),
self.store().map(Arc::clone),
))
+7 -48
View File
@@ -345,6 +345,7 @@ impl Agent {
crate::workspace::hygiene::HygieneConfig::default(),
workspace.clone(),
self.llm().clone(),
self.safety().clone(),
);
match runner.check_heartbeat().await {
@@ -405,7 +406,7 @@ impl Agent {
.with_max_tokens(512)
.with_temperature(0.3);
let reasoning = Reasoning::new(self.llm().clone());
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Thread Summary:\n\n{}",
@@ -453,7 +454,7 @@ impl Agent {
.with_max_tokens(512)
.with_temperature(0.5);
let reasoning = Reasoning::new(self.llm().clone());
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Suggested Next Steps:\n\n{}",
@@ -662,14 +663,10 @@ impl Agent {
}
match self.llm().set_model(requested) {
Ok(()) => {
// Persist the model choice so it survives restarts.
self.persist_selected_model(requested).await;
Ok(SubmissionResult::response(format!(
"Switched model to: {}",
requested
)))
}
Ok(()) => Ok(SubmissionResult::response(format!(
"Switched model to: {}",
requested
))),
Err(e) => Ok(SubmissionResult::error(format!(
"Failed to switch model: {}",
e
@@ -825,42 +822,4 @@ impl Agent {
_ => Ok(None),
}
}
/// Persist the selected model to the settings store (DB and/or TOML config).
///
/// Best-effort: logs warnings on failure but does not propagate errors,
/// since the in-memory model switch already succeeded.
async fn persist_selected_model(&self, model: &str) {
// 1. Persist to DB if available.
if let Some(store) = self.store() {
let value = serde_json::Value::String(model.to_string());
if let Err(e) = store.set_setting("default", "selected_model", &value).await {
tracing::warn!("Failed to persist model to DB: {}", e);
}
}
// 2. Update TOML config file if it exists (sync I/O in spawn_blocking).
let model_owned = model.to_string();
if let Err(e) = tokio::task::spawn_blocking(move || {
let toml_path = crate::settings::Settings::default_toml_path();
match crate::settings::Settings::load_toml(&toml_path) {
Ok(Some(mut settings)) => {
settings.selected_model = Some(model_owned);
if let Err(e) = settings.save_toml(&toml_path) {
tracing::warn!("Failed to persist model to config.toml: {}", e);
}
}
Ok(None) => {
// No config file on disk; nothing to update.
}
Err(e) => {
tracing::warn!("Failed to load config.toml for model persistence: {}", e);
}
}
})
.await
{
tracing::warn!("Model TOML persistence task failed: {}", e);
}
}
}
+12 -4
View File
@@ -13,6 +13,7 @@ use crate::agent::context_monitor::{CompactionStrategy, ContextBreakdown};
use crate::agent::session::Thread;
use crate::error::Error;
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
use crate::safety::SafetyLayer;
use crate::workspace::Workspace;
/// Result of a compaction operation.
@@ -33,12 +34,13 @@ pub struct CompactionResult {
/// Compacts conversation context to stay within limits.
pub struct ContextCompactor {
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
}
impl ContextCompactor {
/// Create a new context compactor.
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
Self { llm }
pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> Self {
Self { llm, safety }
}
/// Compact a thread's context using the given strategy.
@@ -231,7 +233,7 @@ Be brief but capture all important details. Use bullet points."#,
.with_max_tokens(1024)
.with_temperature(0.3);
let reasoning = Reasoning::new(self.llm.clone());
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let (text, _) = reasoning.complete(request).await?;
Ok(text)
}
@@ -344,11 +346,17 @@ mod tests {
// === QA Plan - Compaction strategy tests ===
use crate::agent::context_monitor::CompactionStrategy;
use crate::config::SafetyConfig;
use crate::safety::SafetyLayer;
use crate::testing::StubLlm;
/// Helper: build a `ContextCompactor` with the given `StubLlm`.
fn make_compactor(llm: Arc<StubLlm>) -> ContextCompactor {
ContextCompactor::new(llm)
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
ContextCompactor::new(llm, safety)
}
/// Helper: build a thread with `n` completed turns.
+12 -20
View File
@@ -50,18 +50,8 @@ impl Agent {
// Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.)
// In group chats, MEMORY.md is excluded to prevent leaking personal context.
// Resolve the user's timezone
let user_tz = crate::timezone::resolve_timezone(
message.timezone.as_deref(),
None, // user setting lookup can be added later
&self.config.default_timezone,
);
let system_prompt = if let Some(ws) = self.workspace() {
match ws
.system_prompt_for_context_tz(is_group_chat, user_tz)
.await
{
match ws.system_prompt_for_context(is_group_chat).await {
Ok(prompt) if !prompt.is_empty() => Some(prompt),
Ok(_) => None,
Err(e) => {
@@ -113,7 +103,7 @@ impl Agent {
None
};
let mut reasoning = Reasoning::new(self.llm().clone())
let mut reasoning = Reasoning::new(self.llm().clone(), self.safety().clone())
.with_channel(message.channel.clone())
.with_model_name(self.llm().active_model_name())
.with_group_chat(is_group_chat);
@@ -140,7 +130,6 @@ impl Agent {
let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
job_ctx.user_timezone = user_tz.name().to_string();
// Build system prompts once for this turn. Two variants: with tools
// (normal iterations) and without (force_text final iteration).
@@ -796,7 +785,6 @@ impl Agent {
tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: Some(user_tz.name().to_string()),
};
return Ok(AgenticLoopResult::NeedApproval { pending });
@@ -1158,7 +1146,6 @@ mod tests {
max_actions_per_hour: None,
max_tool_iterations: 50,
auto_approve_tools: false,
default_timezone: "UTC".to_string(),
},
deps,
Arc::new(ChannelManager::new()),
@@ -1261,7 +1248,6 @@ mod tests {
arguments: serde_json::json!({"message": "done"}),
},
],
user_timezone: None,
};
let json = serde_json::to_string(&pending).expect("serialize");
@@ -1609,8 +1595,12 @@ mod tests {
use crate::testing::StubLlm;
let stub = Arc::new(StubLlm::failing_non_transient("ctx-bomb"));
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let reasoning = Reasoning::new(stub.clone());
let reasoning = Reasoning::new(stub.clone(), safety);
// Build a fat context with lots of history.
let messages = vec![
@@ -1720,7 +1710,11 @@ mod tests {
use crate::llm::{Reasoning, ReasoningContext, RespondResult, ToolDefinition};
let provider = Arc::new(AlwaysToolCallProvider);
let reasoning = Reasoning::new(provider);
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let reasoning = Reasoning::new(provider, safety);
let tool_def = ToolDefinition {
name: "echo".to_string(),
@@ -1906,7 +1900,6 @@ mod tests {
max_actions_per_hour: None,
max_tool_iterations,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
},
deps,
Arc::new(ChannelManager::new()),
@@ -2022,7 +2015,6 @@ mod tests {
max_actions_per_hour: None,
max_tool_iterations: max_iter,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
},
deps,
Arc::new(ChannelManager::new()),
+8 -114
View File
@@ -31,6 +31,7 @@ use tokio::sync::mpsc;
use crate::channels::OutgoingResponse;
use crate::db::Database;
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
use crate::safety::SafetyLayer;
use crate::workspace::Workspace;
use crate::workspace::hygiene::HygieneConfig;
@@ -47,12 +48,6 @@ pub struct HeartbeatConfig {
pub notify_user_id: Option<String>,
/// Channel to notify on heartbeat findings.
pub notify_channel: Option<String>,
/// Hour (0-23) when quiet hours start.
pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end.
pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name).
pub timezone: Option<String>,
}
impl Default for HeartbeatConfig {
@@ -63,9 +58,6 @@ impl Default for HeartbeatConfig {
max_failures: 3,
notify_user_id: None,
notify_channel: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
}
}
}
@@ -83,26 +75,6 @@ impl HeartbeatConfig {
self
}
/// Check whether the current time falls within configured quiet hours.
pub fn is_quiet_hours(&self) -> bool {
use chrono::Timelike;
let (Some(start), Some(end)) = (self.quiet_hours_start, self.quiet_hours_end) else {
return false;
};
let tz = self
.timezone
.as_deref()
.and_then(crate::timezone::parse_timezone)
.unwrap_or(chrono_tz::UTC);
let now_hour = crate::timezone::now_in_tz(tz).hour();
if start <= end {
now_hour >= start && now_hour < end
} else {
// Wraps midnight, e.g. 22..06
now_hour >= start || now_hour < end
}
}
/// Set the notification target.
pub fn with_notify(mut self, user_id: impl Into<String>, channel: impl Into<String>) -> Self {
self.notify_user_id = Some(user_id.into());
@@ -130,6 +102,7 @@ pub struct HeartbeatRunner {
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
store: Option<Arc<dyn Database>>,
consecutive_failures: u32,
@@ -142,12 +115,14 @@ impl HeartbeatRunner {
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
) -> Self {
Self {
config,
hygiene_config,
workspace,
llm,
safety,
response_tx: None,
store: None,
consecutive_failures: 0,
@@ -187,12 +162,6 @@ impl HeartbeatRunner {
loop {
interval.tick().await;
// Skip during quiet hours
if self.config.is_quiet_hours() {
tracing::debug!("Heartbeat skipped: quiet hours");
continue;
}
// Run memory hygiene in the background so it never delays the
// heartbeat checklist. Failures are logged inside run_if_due.
let hygiene_workspace = Arc::clone(&self.workspace);
@@ -303,7 +272,7 @@ impl HeartbeatRunner {
.with_max_tokens(max_tokens)
.with_temperature(0.3);
let reasoning = Reasoning::new(self.llm.clone());
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let (content, _usage) = match reasoning.complete(request).await {
Ok(r) => r,
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
@@ -417,10 +386,11 @@ pub fn spawn_heartbeat(
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
store: Option<Arc<dyn Database>>,
) -> tokio::task::JoinHandle<()> {
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm);
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm, safety);
if let Some(tx) = response_tx {
runner = runner.with_response_channel(tx);
}
@@ -562,83 +532,6 @@ mod tests {
assert!(!is_effectively_empty(content));
}
// ==================== quiet hours ====================
#[test]
fn test_quiet_hours_inside() {
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour();
let start = hour;
let end = (hour + 1) % 24;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// Current UTC hour is inside [start, end) by construction
assert!(config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_outside() {
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour();
let start = (hour + 1) % 24;
let end = (hour + 2) % 24;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// Current UTC hour is outside [start, end) by construction
assert!(!config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_wraparound_excludes_now() {
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour();
// Window covers all hours except the current one
let start = (hour + 1) % 24;
let end = hour;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
assert!(!config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_none_configured() {
let config = HeartbeatConfig::default();
assert!(!config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_same_start_end() {
let config = HeartbeatConfig {
quiet_hours_start: Some(10),
quiet_hours_end: Some(10),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// start == end means zero-width window, should be false
assert!(!config.is_quiet_hours());
}
#[test]
fn test_spawn_heartbeat_accepts_store_param() {
// Regression: spawn_heartbeat must accept an optional Database store
@@ -650,6 +543,7 @@ mod tests {
HygieneConfig,
Arc<crate::workspace::Workspace>,
Arc<dyn crate::llm::LlmProvider>,
Arc<crate::safety::SafetyLayer>,
Option<tokio::sync::mpsc::Sender<crate::channels::OutgoingResponse>>,
Option<Arc<dyn crate::db::Database>>,
) -> tokio::task::JoinHandle<()> = spawn_heartbeat;
+88 -87
View File
@@ -57,11 +57,7 @@ pub struct Routine {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Trigger {
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
Cron {
schedule: String,
#[serde(default)]
timezone: Option<String>,
},
Cron { schedule: String },
/// Fire when a channel message matches a pattern.
Event {
/// Optional channel filter (e.g. "telegram", "slack").
@@ -69,6 +65,16 @@ pub enum Trigger {
/// Regex pattern to match against message content.
pattern: String,
},
/// Fire when a structured system event is emitted.
SystemEvent {
/// Event source namespace (e.g. "github", "workflow", "tool").
source: String,
/// Event type within the source (e.g. "issue.opened").
event_type: String,
/// Optional exact-match filters against payload top-level fields.
#[serde(default)]
filters: std::collections::HashMap<String, String>,
},
/// Fire on incoming webhook POST to /hooks/routine/{id}.
Webhook {
/// Optional webhook path suffix (defaults to routine id).
@@ -86,6 +92,7 @@ impl Trigger {
match self {
Trigger::Cron { .. } => "cron",
Trigger::Event { .. } => "event",
Trigger::SystemEvent { .. } => "system_event",
Trigger::Webhook { .. } => "webhook",
Trigger::Manual => "manual",
}
@@ -103,21 +110,7 @@ impl Trigger {
field: "schedule".into(),
})?
.to_string();
let timezone = config
.get("timezone")
.and_then(|v| v.as_str())
.and_then(|tz| {
if crate::timezone::parse_timezone(tz).is_some() {
Some(tz.to_string())
} else {
tracing::warn!(
"Ignoring invalid timezone '{}' from DB for cron trigger",
tz
);
None
}
});
Ok(Trigger::Cron { schedule, timezone })
Ok(Trigger::Cron { schedule })
}
"event" => {
let pattern = config
@@ -134,6 +127,38 @@ impl Trigger {
.map(String::from);
Ok(Trigger::Event { channel, pattern })
}
"system_event" => {
let source = config
.get("source")
.and_then(|v| v.as_str())
.ok_or_else(|| RoutineError::MissingField {
context: "system_event trigger".into(),
field: "source".into(),
})?
.to_string();
let event_type = config
.get("event_type")
.and_then(|v| v.as_str())
.ok_or_else(|| RoutineError::MissingField {
context: "system_event trigger".into(),
field: "event_type".into(),
})?
.to_string();
let filters = config
.get("filters")
.and_then(|v| v.as_object())
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
Ok(Trigger::SystemEvent {
source,
event_type,
filters,
})
}
"webhook" => {
let path = config
.get("path")
@@ -155,14 +180,20 @@ impl Trigger {
/// Serialize trigger-specific config to JSON for DB storage.
pub fn to_config_json(&self) -> serde_json::Value {
match self {
Trigger::Cron { schedule, timezone } => serde_json::json!({
"schedule": schedule,
"timezone": timezone,
}),
Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }),
Trigger::Event { channel, pattern } => serde_json::json!({
"pattern": pattern,
"channel": channel,
}),
Trigger::SystemEvent {
source,
event_type,
filters,
} => serde_json::json!({
"source": source,
"event_type": event_type,
"filters": filters,
}),
Trigger::Webhook { path, secret } => serde_json::json!({
"path": path,
"secret": secret,
@@ -436,25 +467,12 @@ pub fn content_hash(content: &str) -> u64 {
}
/// Parse a cron expression and compute the next fire time from now.
///
/// When `timezone` is provided and valid, the schedule is evaluated in that
/// timezone and the result is converted back to UTC. Otherwise UTC is used.
pub fn next_cron_fire(
schedule: &str,
timezone: Option<&str>,
) -> Result<Option<DateTime<Utc>>, RoutineError> {
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, RoutineError> {
let cron_schedule =
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
reason: e.to_string(),
})?;
if let Some(tz) = timezone.and_then(crate::timezone::parse_timezone) {
Ok(cron_schedule
.upcoming(tz)
.next()
.map(|dt| dt.with_timezone(&Utc)))
} else {
Ok(cron_schedule.upcoming(Utc).next())
}
Ok(cron_schedule.upcoming(Utc).next())
}
#[cfg(test)]
@@ -467,11 +485,10 @@ mod tests {
fn test_trigger_roundtrip() {
let trigger = Trigger::Cron {
schedule: "0 9 * * MON-FRI".to_string(),
timezone: None,
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(matches!(parsed, Trigger::Cron { schedule, .. } if schedule == "0 9 * * MON-FRI"));
assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI"));
}
#[test]
@@ -486,6 +503,24 @@ mod tests {
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
}
#[test]
fn test_system_event_trigger_roundtrip() {
let mut filters = std::collections::HashMap::new();
filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
filters.insert("action".to_string(), "opened".to_string());
let trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: filters.clone(),
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("system_event", json).expect("parse system_event");
assert!(
matches!(parsed, Trigger::SystemEvent { source, event_type, filters: f }
if source == "github" && event_type == "issue" && f == filters)
);
}
#[test]
fn test_action_lightweight_roundtrip() {
let action = RoutineAction::Lightweight {
@@ -544,58 +579,16 @@ mod tests {
#[test]
fn test_next_cron_fire_valid() {
// Every minute should always have a next fire
let next = next_cron_fire("* * * * * *", None).expect("valid cron");
let next = next_cron_fire("* * * * * *").expect("valid cron");
assert!(next.is_some());
}
#[test]
fn test_next_cron_fire_invalid() {
let result = next_cron_fire("not a cron", None);
let result = next_cron_fire("not a cron");
assert!(result.is_err());
}
#[test]
fn test_trigger_cron_timezone_roundtrip() {
let trigger = Trigger::Cron {
schedule: "0 9 * * MON-FRI".to_string(),
timezone: Some("America/New_York".to_string()),
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(matches!(parsed, Trigger::Cron { schedule, timezone }
if schedule == "0 9 * * MON-FRI"
&& timezone.as_deref() == Some("America/New_York")));
}
#[test]
fn test_trigger_cron_no_timezone_backward_compat() {
let json = serde_json::json!({"schedule": "0 9 * * *"});
let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(matches!(parsed, Trigger::Cron { timezone, .. } if timezone.is_none()));
}
#[test]
fn test_trigger_cron_invalid_timezone_coerced_to_none() {
let json = serde_json::json!({"schedule": "0 9 * * *", "timezone": "Fake/Zone"});
let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(
matches!(parsed, Trigger::Cron { timezone, .. } if timezone.is_none()),
"invalid timezone should be coerced to None"
);
}
#[test]
fn test_next_cron_fire_with_timezone() {
let next_utc = next_cron_fire("0 0 9 * * * *", None)
.expect("valid cron")
.expect("has next");
let next_est = next_cron_fire("0 0 9 * * * *", Some("America/New_York"))
.expect("valid cron")
.expect("has next");
// EST is UTC-5 (or EDT UTC-4), so the UTC result should differ
assert_ne!(next_utc, next_est, "timezone should shift the fire time");
}
#[test]
fn test_guardrails_default() {
let g = RoutineGuardrails::default();
@@ -608,8 +601,7 @@ mod tests {
fn test_trigger_type_tag() {
assert_eq!(
Trigger::Cron {
schedule: String::new(),
timezone: None,
schedule: String::new()
}
.type_tag(),
"cron"
@@ -630,6 +622,15 @@ mod tests {
.type_tag(),
"webhook"
);
assert_eq!(
Trigger::SystemEvent {
source: String::new(),
event_type: String::new(),
filters: std::collections::HashMap::new(),
}
.type_tag(),
"system_event"
);
assert_eq!(Trigger::Manual.type_tag(), "manual");
}
}
+122 -18
View File
@@ -31,6 +31,11 @@ use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::tools::ApprovalContext;
use crate::workspace::Workspace;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
System { routine: Routine },
}
/// The routine execution engine.
pub struct RoutineEngine {
config: RoutineConfig,
@@ -41,8 +46,8 @@ pub struct RoutineEngine {
notify_tx: mpsc::Sender<OutgoingResponse>,
/// Currently running routine count (across all routines).
running_count: Arc<AtomicUsize>,
/// Compiled event regex cache: routine_id -> compiled regex.
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
/// Cached matchers for all event-driven routines.
event_cache: Arc<RwLock<Vec<EventMatcher>>>,
/// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option<Arc<Scheduler>>,
}
@@ -74,9 +79,12 @@ impl RoutineEngine {
Ok(routines) => {
let mut cache = Vec::new();
for routine in routines {
if let Trigger::Event { ref pattern, .. } = routine.trigger {
match Regex::new(pattern) {
Ok(re) => cache.push((routine.id, routine.clone(), re)),
match &routine.trigger {
Trigger::Event { pattern, .. } => match Regex::new(pattern) {
Ok(re) => cache.push(EventMatcher::Message {
routine: routine.clone(),
regex: re,
}),
Err(e) => {
tracing::warn!(
routine = %routine.name,
@@ -84,7 +92,13 @@ impl RoutineEngine {
pattern, e
);
}
},
Trigger::SystemEvent { .. } => {
cache.push(EventMatcher::System {
routine: routine.clone(),
});
}
_ => {}
}
}
let count = cache.len();
@@ -105,7 +119,11 @@ impl RoutineEngine {
let cache = self.event_cache.read().await;
let mut fired = 0;
for (_, routine, re) in cache.iter() {
for matcher in cache.iter() {
let (routine, re) = match matcher {
EventMatcher::Message { routine, regex } => (routine, regex),
EventMatcher::System { .. } => continue,
};
// Channel filter
if let Trigger::Event {
channel: Some(ch), ..
@@ -146,6 +164,82 @@ impl RoutineEngine {
fired
}
/// Emit a structured event to system-event routines.
///
/// Returns the number of routines that were fired.
pub async fn emit_system_event(
&self,
source: &str,
event_type: &str,
payload: &serde_json::Value,
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
let mut fired = 0;
for matcher in cache.iter() {
let routine = match matcher {
EventMatcher::System { routine } => routine,
EventMatcher::Message { .. } => continue,
};
let Trigger::SystemEvent {
source: expected_source,
event_type: expected_event,
filters,
} = &routine.trigger
else {
continue;
};
if expected_source != source || expected_event != event_type {
continue;
}
if let Some(uid) = user_id
&& routine.user_id != uid
{
continue;
}
let mut matched = true;
for (key, expected) in filters {
let Some(actual) = payload.get(key).and_then(json_value_as_string) else {
matched = false;
break;
};
if actual != *expected {
matched = false;
break;
}
}
if !matched {
continue;
}
if !self.check_cooldown(routine) {
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
continue;
}
if !self.check_concurrent(routine).await {
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached");
continue;
}
let detail = truncate(&format!("{source}:{event_type}"), 200);
self.spawn_fire(routine.clone(), "system_event", Some(detail));
fired += 1;
}
fired
}
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
@@ -170,7 +264,7 @@ impl RoutineEngine {
continue;
}
let detail = if let Trigger::Cron { ref schedule, .. } = routine.trigger {
let detail = if let Trigger::Cron { ref schedule } = routine.trigger {
Some(schedule.clone())
} else {
None
@@ -380,12 +474,8 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
// Update routine runtime state
let now = Utc::now();
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = routine.trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger {
next_cron_fire(schedule).unwrap_or(None)
} else {
None
};
@@ -492,13 +582,18 @@ async fn execute_full_job(
reason: "scheduler not available".to_string(),
})?;
let mut metadata = serde_json::json!({ "max_iterations": max_iterations });
// Carry the routine's notify config in job metadata so the message tool
// can resolve channel/target per-job without global state mutation.
// Set the message tool's default channel/target from the routine's notify config
// so the LLM can send results without triggering cross-channel approval.
// TODO: This mutates shared global state and can race with concurrent jobs.
// Move notify config into JobContext metadata and apply per-job instead.
if let Some(channel) = &routine.notify.channel {
metadata["notify_channel"] = serde_json::json!(channel);
scheduler
.tools()
.set_message_tool_context(Some(channel.clone()), Some(routine.notify.user.clone()))
.await;
}
metadata["notify_user"] = serde_json::json!(&routine.notify.user);
let metadata = serde_json::json!({ "max_iterations": max_iterations });
// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
// Always tools require explicit listing in tool_permissions.
@@ -724,6 +819,15 @@ fn truncate(s: &str, max: usize) -> String {
}
}
fn json_value_as_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use crate::agent::routine::{NotifyConfig, RunStatus};
-6
View File
@@ -164,10 +164,6 @@ pub struct PendingApproval {
/// executed yet when approval was requested.
#[serde(default)]
pub deferred_tool_calls: Vec<ToolCall>,
/// User timezone at the time the approval was requested, so it persists
/// through the approval flow even if the approval message lacks timezone.
#[serde(default)]
pub user_timezone: Option<String>,
}
/// A conversation thread within a session.
@@ -980,7 +976,6 @@ mod tests {
tool_call_id: "call_123".to_string(),
context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![],
user_timezone: None,
};
thread.await_approval(approval);
@@ -1006,7 +1001,6 @@ mod tests {
tool_call_id: "call_456".to_string(),
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
};
thread.await_approval(approval);
+2 -14
View File
@@ -230,7 +230,7 @@ impl Agent {
)
.await;
let compactor = ContextCompactor::new(self.llm().clone());
let compactor = ContextCompactor::new(self.llm().clone(), self.safety().clone());
if let Err(e) = compactor
.compact(thread, strategy, self.workspace().map(|w| w.as_ref()))
.await
@@ -627,7 +627,7 @@ impl Agent {
crate::agent::context_monitor::CompactionStrategy::Summarize { keep_recent: 5 },
);
let compactor = ContextCompactor::new(self.llm().clone());
let compactor = ContextCompactor::new(self.llm().clone(), self.safety().clone());
match compactor
.compact(thread, strategy, self.workspace().map(|w| w.as_ref()))
.await
@@ -746,16 +746,6 @@ impl Agent {
let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
// Prefer a valid timezone from the approval message, fall back to the
// resolved timezone stored when the approval was originally requested.
let tz_candidate = message
.timezone
.as_deref()
.filter(|tz| crate::timezone::parse_timezone(tz).is_some())
.or(pending.user_timezone.as_deref());
if let Some(tz) = tz_candidate {
job_ctx.user_timezone = tz.to_string();
}
let _ = self
.channels
@@ -1121,8 +1111,6 @@ impl Agent {
tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(),
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
// Carry forward the resolved timezone from the original pending approval
user_timezone: pending.user_timezone.clone(),
};
let request_id = new_pending.request_id;
+1 -1
View File
@@ -212,7 +212,7 @@ impl Worker {
let job_ctx = self.context_manager().get_context(self.job_id).await?;
// Create reasoning engine
let reasoning = Reasoning::new(self.llm().clone());
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
// Build initial reasoning context (tool definitions refreshed each iteration in execution_loop)
let mut reason_ctx = ReasoningContext::new().with_job(&job_ctx.description);
+26 -64
View File
@@ -255,18 +255,15 @@ impl AppBuilder {
self.libsql_db.take();
}
// Re-resolve only the LLM config with OS credentials.
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
self.db.as_ref().map(|db| db.as_ref() as _);
let toml_path = self.toml_path.as_deref();
if let Err(e) = self
.config
.re_resolve_llm(store, "default", toml_path)
.await
{
tracing::warn!(
"Failed to re-resolve LLM config after OS credential injection: {e}"
);
// Re-resolve config with OS credentials
if let Some(ref db) = self.db {
let toml_path = self.toml_path.as_deref();
if let Ok(refreshed) =
Config::from_db_with_toml(db.as_ref(), "default", toml_path).await
{
self.config = refreshed;
tracing::debug!("LlmConfig re-resolved after OS credential injection");
}
}
return Ok(());
@@ -311,16 +308,18 @@ impl AppBuilder {
// Inject LLM API keys from encrypted storage
crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), "default").await;
// Re-resolve only the LLM config with newly available keys.
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
self.db.as_ref().map(|db| db.as_ref() as _);
let toml_path = self.toml_path.as_deref();
if let Err(e) = self
.config
.re_resolve_llm(store, "default", toml_path)
.await
{
tracing::warn!("Failed to re-resolve LLM config after secret injection: {e}");
// Re-resolve config with newly available keys
if let Some(ref db) = self.db {
let toml_path = self.toml_path.as_deref();
match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
Ok(refreshed) => {
self.config = refreshed;
tracing::debug!("LlmConfig re-resolved after secret injection");
}
Err(e) => {
tracing::warn!("Failed to re-resolve config after secret injection: {}", e);
}
}
}
}
@@ -386,53 +385,12 @@ impl AppBuilder {
.embeddings
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
// Create optional external vector store for workspace semantic search
let vector_store: Option<Arc<dyn crate::workspace::VectorStore>> = {
#[cfg(feature = "lancedb")]
{
if self.config.database.vector_backend == crate::config::VectorBackend::LanceDb {
let path = self
.config
.database
.lancedb_path
.clone()
.unwrap_or_else(crate::config::default_lancedb_path);
let dim = embeddings.as_ref().map(|p| p.dimension());
match crate::workspace::LanceDbVectorStore::new(path, dim).await {
Ok(store) => {
tracing::info!("LanceDB vector store connected for workspace search");
Some(Arc::new(store) as Arc<dyn crate::workspace::VectorStore>)
}
Err(e) => {
tracing::warn!("Failed to initialize LanceDB: {}", e);
None
}
}
} else {
None
}
}
#[cfg(not(feature = "lancedb"))]
{
if self.config.database.vector_backend == crate::config::VectorBackend::LanceDb {
tracing::warn!(
"VECTOR_BACKEND=lancedb but 'lancedb' feature not enabled; \
falling back to built-in vector search"
);
}
None
}
};
// Register memory tools if database is available
let workspace = if let Some(ref db) = self.db {
let mut ws = Workspace::new_with_db("default", db.clone());
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings(emb.clone());
}
if let Some(ref vs) = vector_store {
ws = ws.with_vector_store(vs.clone());
}
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
Some(ws)
@@ -445,7 +403,11 @@ impl AppBuilder {
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
{
tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.register_builder_tool(
llm.clone(),
safety.clone(),
Some(self.config.builder.to_builder_config()),
)
.await;
tracing::info!("Builder mode enabled");
}
-251
View File
@@ -414,103 +414,10 @@ pub enum MigrationError {
Io(String),
}
// ── PID Lock ──────────────────────────────────────────────────────────────
/// Path to the PID lock file: `~/.ironclaw/ironclaw.pid`.
pub fn pid_lock_path() -> PathBuf {
ironclaw_base_dir().join("ironclaw.pid")
}
/// A PID-based lock that prevents multiple IronClaw instances from running
/// simultaneously.
///
/// Uses `fs4::try_lock_exclusive()` for atomic locking (no TOCTOU race),
/// then writes the current PID into the locked file for diagnostics.
/// The OS-level lock is held for the lifetime of this struct and
/// automatically released on drop (along with the PID file cleanup).
#[derive(Debug)]
pub struct PidLock {
path: PathBuf,
/// Held open to maintain the OS-level exclusive lock.
_file: std::fs::File,
}
/// Errors from PID lock acquisition.
#[derive(Debug, thiserror::Error)]
pub enum PidLockError {
#[error("Another IronClaw instance is already running (PID {pid})")]
AlreadyRunning { pid: u32 },
#[error("Failed to acquire PID lock: {0}")]
Io(#[from] std::io::Error),
}
impl PidLock {
/// Try to acquire the PID lock.
///
/// Uses an exclusive file lock (`flock`/`LockFileEx`) so that two
/// concurrent processes cannot both acquire the lock — no TOCTOU race.
/// If the lock file exists but the holding process is gone (stale),
/// the lock is reclaimed automatically by the OS.
pub fn acquire() -> Result<Self, PidLockError> {
Self::acquire_at(pid_lock_path())
}
/// Acquire at a specific path (for testing).
fn acquire_at(path: PathBuf) -> Result<Self, PidLockError> {
use fs4::FileExt;
use std::fs::OpenOptions;
use std::io::Write;
// Ensure parent directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// Open (or create) the lock file
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
// Try non-blocking exclusive lock — if another process holds it,
// this fails immediately instead of blocking.
if let Err(e) = file.try_lock_exclusive() {
if e.kind() == std::io::ErrorKind::WouldBlock {
// Lock held by another process — read its PID for the error message
let pid = std::fs::read_to_string(&path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
return Err(PidLockError::AlreadyRunning { pid });
}
// Other errors (permissions, unsupported filesystem, etc.)
return Err(PidLockError::Io(e));
}
// We hold the exclusive lock — write our PID
file.set_len(0)?; // truncate
write!(file, "{}", std::process::id())?;
Ok(PidLock { path, _file: file })
}
}
impl Drop for PidLock {
fn drop(&mut self) {
// Remove the PID file; the OS-level lock is released when _file is dropped.
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
use tempfile::tempdir;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
@@ -1079,162 +986,4 @@ INJECTED="pwned"#;
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
}
}
// ── PID Lock tests ───────────────────────────────────────────────
#[test]
fn test_pid_lock_acquire_and_drop() {
let dir = tempdir().unwrap();
let pid_path = dir.path().join("ironclaw.pid");
// Acquire lock
let lock = PidLock::acquire_at(pid_path.clone()).unwrap();
assert!(pid_path.exists());
// PID file should contain our PID
let contents = std::fs::read_to_string(&pid_path).unwrap();
assert_eq!(contents.trim().parse::<u32>().unwrap(), std::process::id());
// Drop should remove the file
drop(lock);
assert!(!pid_path.exists());
}
#[test]
fn test_pid_lock_rejects_second_acquire() {
let dir = tempdir().unwrap();
let pid_path = dir.path().join("ironclaw.pid");
// First lock succeeds
let _lock1 = PidLock::acquire_at(pid_path.clone()).unwrap();
// Second acquire on same file must fail (exclusive flock held)
let result = PidLock::acquire_at(pid_path.clone());
assert!(result.is_err());
match result.unwrap_err() {
PidLockError::AlreadyRunning { pid } => {
assert_eq!(pid, std::process::id());
}
other => panic!("expected AlreadyRunning, got: {}", other),
}
}
#[test]
fn test_pid_lock_reclaims_after_drop() {
let dir = tempdir().unwrap();
let pid_path = dir.path().join("ironclaw.pid");
// Acquire and release
let lock = PidLock::acquire_at(pid_path.clone()).unwrap();
drop(lock);
// Should succeed — OS lock was released on drop
let lock2 = PidLock::acquire_at(pid_path).unwrap();
drop(lock2);
}
#[test]
fn test_pid_lock_reclaims_stale_file_without_flock() {
let dir = tempdir().unwrap();
let pid_path = dir.path().join("ironclaw.pid");
// Write a stale PID file manually (no flock held)
std::fs::write(&pid_path, "4294967294").unwrap();
// Should succeed because no OS lock is held on the file
let lock = PidLock::acquire_at(pid_path.clone()).unwrap();
let contents = std::fs::read_to_string(&pid_path).unwrap();
assert_eq!(contents.trim().parse::<u32>().unwrap(), std::process::id());
drop(lock);
}
#[test]
fn test_pid_lock_handles_corrupt_pid_file() {
let dir = tempdir().unwrap();
let pid_path = dir.path().join("ironclaw.pid");
// Write garbage (no flock held)
std::fs::write(&pid_path, "not-a-number").unwrap();
// Should succeed — no OS lock held, file is reclaimed
let lock = PidLock::acquire_at(pid_path).unwrap();
drop(lock);
}
#[test]
fn test_pid_lock_creates_parent_dirs() {
let dir = tempdir().unwrap();
let pid_path = dir.path().join("nested").join("deep").join("ironclaw.pid");
let lock = PidLock::acquire_at(pid_path.clone()).unwrap();
assert!(pid_path.exists());
drop(lock);
}
#[test]
fn test_pid_lock_child_helper_holds_lock() {
if std::env::var("IRONCLAW_PID_LOCK_CHILD").ok().as_deref() != Some("1") {
return;
}
let pid_path = PathBuf::from(
std::env::var("IRONCLAW_PID_LOCK_PATH").expect("IRONCLAW_PID_LOCK_PATH missing"),
);
let hold_ms = std::env::var("IRONCLAW_PID_LOCK_HOLD_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(3000);
let _lock = PidLock::acquire_at(pid_path).expect("child failed to acquire pid lock");
thread::sleep(Duration::from_millis(hold_ms));
}
#[test]
fn test_pid_lock_rejects_lock_held_by_other_process() {
let dir = tempdir().unwrap();
let pid_path = dir.path().join("ironclaw.pid");
let current_exe = std::env::current_exe().unwrap();
let mut child = Command::new(current_exe)
.args([
"--exact",
"bootstrap::tests::test_pid_lock_child_helper_holds_lock",
"--nocapture",
"--test-threads=1",
])
.env("IRONCLAW_PID_LOCK_CHILD", "1")
.env("IRONCLAW_PID_LOCK_PATH", pid_path.display().to_string())
.env("IRONCLAW_PID_LOCK_HOLD_MS", "3000")
.spawn()
.unwrap();
let started = Instant::now();
while started.elapsed() < Duration::from_secs(2) {
if pid_path.exists() {
break;
}
if let Some(status) = child.try_wait().unwrap() {
panic!("child exited before acquiring lock: {}", status);
}
thread::sleep(Duration::from_millis(20));
}
assert!(
pid_path.exists(),
"child did not create lock file in time: {}",
pid_path.display()
);
let result = PidLock::acquire_at(pid_path.clone());
match result.unwrap_err() {
PidLockError::AlreadyRunning { .. } => {}
other => panic!("expected AlreadyRunning, got: {}", other),
}
let status = child.wait().unwrap();
assert!(status.success(), "child process failed: {}", status);
// After the child exits, lock should be released and reacquirable.
let lock = PidLock::acquire_at(pid_path).unwrap();
drop(lock);
}
}
-15
View File
@@ -79,8 +79,6 @@ pub struct IncomingMessage {
pub received_at: DateTime<Utc>,
/// Channel-specific metadata.
pub metadata: serde_json::Value,
/// IANA timezone string from the client (e.g. "America/New_York").
pub timezone: Option<String>,
/// File or media attachments on this message.
pub attachments: Vec<IncomingAttachment>,
}
@@ -101,7 +99,6 @@ impl IncomingMessage {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::Value::Null,
timezone: None,
attachments: Vec::new(),
}
}
@@ -124,12 +121,6 @@ impl IncomingMessage {
self
}
/// Set the client timezone.
pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
self.timezone = Some(tz.into());
self
}
/// Set attachments.
pub fn with_attachments(mut self, attachments: Vec<IncomingAttachment>) -> Self {
self.attachments = attachments;
@@ -463,10 +454,4 @@ mod tests {
panic!("expected ToolCompleted variant");
}
}
#[test]
fn test_incoming_message_with_timezone() {
let msg = IncomingMessage::new("test", "user1", "hello").with_timezone("America/New_York");
assert_eq!(msg.timezone.as_deref(), Some("America/New_York"));
}
}
+9 -50
View File
@@ -18,7 +18,7 @@
//! - `Esc` - Interrupt current operation
use std::borrow::Cow;
use std::io::{self, IsTerminal, Write};
use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -297,15 +297,10 @@ impl Channel for ReplChannel {
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
std::thread::spawn(move || {
let sys_tz = crate::timezone::detect_system_timezone().name().to_string();
// Single message mode: send it and return
if let Some(msg) = single_message {
let incoming = IncomingMessage::new("repl", "default", &msg).with_timezone(&sys_tz);
let incoming = IncomingMessage::new("repl", "default", &msg);
let _ = tx.blocking_send(incoming);
// Ensure the agent exits after handling exactly one turn in -m mode,
// even when other channels (gateway/http) are enabled.
let _ = tx.blocking_send(IncomingMessage::new("repl", "default", "/quit"));
return;
}
@@ -366,8 +361,7 @@ impl Channel for ReplChannel {
"/quit" | "/exit" => {
// Forward shutdown command so the agent loop exits even
// when other channels (e.g. web gateway) are still active.
let msg = IncomingMessage::new("repl", "default", "/quit")
.with_timezone(&sys_tz);
let msg = IncomingMessage::new("repl", "default", "/quit");
let _ = tx.blocking_send(msg);
break;
}
@@ -388,8 +382,7 @@ impl Channel for ReplChannel {
_ => {}
}
let msg =
IncomingMessage::new("repl", "default", line).with_timezone(&sys_tz);
let msg = IncomingMessage::new("repl", "default", line);
if tx.blocking_send(msg).is_err() {
break;
}
@@ -397,29 +390,21 @@ impl Channel for ReplChannel {
Err(ReadlineError::Interrupted) => {
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
// Esc: interrupt current operation and keep REPL open.
let msg = IncomingMessage::new("repl", "default", "/interrupt")
.with_timezone(&sys_tz);
let msg = IncomingMessage::new("repl", "default", "/interrupt");
if tx.blocking_send(msg).is_err() {
break;
}
} else {
// Ctrl+C (VINTR): request graceful shutdown.
let msg = IncomingMessage::new("repl", "default", "/quit")
.with_timezone(&sys_tz);
let msg = IncomingMessage::new("repl", "default", "/quit");
let _ = tx.blocking_send(msg);
break;
}
}
Err(ReadlineError::Eof) => {
// Ctrl+D in interactive mode: graceful shutdown.
// In daemon mode (stdin = /dev/null, no TTY), EOF arrives
// immediately — just drop the REPL thread silently so other
// channels (gateway, telegram, …) keep running.
if std::io::stdin().is_terminal() {
let msg = IncomingMessage::new("repl", "default", "/quit")
.with_timezone(&sys_tz);
let _ = tx.blocking_send(msg);
}
// Ctrl+D: send /quit so the agent loop runs graceful shutdown
let msg = IncomingMessage::new("repl", "default", "/quit");
let _ = tx.blocking_send(msg);
break;
}
Err(e) => {
@@ -629,29 +614,3 @@ impl Channel for ReplChannel {
Ok(())
}
}
#[cfg(test)]
mod tests {
use futures::StreamExt;
use super::*;
#[tokio::test]
async fn single_message_mode_sends_message_then_quit() {
let repl = ReplChannel::with_message("hi".to_string());
let mut stream = repl.start().await.expect("repl start should succeed");
let first = stream.next().await.expect("first message missing");
assert_eq!(first.channel, "repl");
assert_eq!(first.content, "hi");
let second = stream.next().await.expect("quit message missing");
assert_eq!(second.channel, "repl");
assert_eq!(second.content, "/quit");
assert!(
stream.next().await.is_none(),
"stream should end after /quit"
);
}
}
+7 -1
View File
@@ -264,7 +264,7 @@ pub async fn routines_runs_handler(
/// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
crate::agent::routine::Trigger::Cron { schedule } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
@@ -273,6 +273,12 @@ fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::SystemEvent {
source, event_type, ..
} => (
"system_event".to_string(),
format!("event: {}.{}", source, event_type),
),
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("/");
("webhook".to_string(), format!("webhook: {}", p))
+533 -11
View File
@@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade},
http::{StatusCode, header},
http::{HeaderMap, StatusCode, header},
middleware,
response::{
IntoResponse,
@@ -200,6 +200,7 @@ pub async fn start_server(
// Public routes (no auth)
let public = Router::new()
.route("/api/health", get(health_handler))
.route("/api/webhooks/github", post(github_webhook_handler))
.route("/oauth/callback", get(oauth_callback_handler));
// Protected routes (require auth)
@@ -432,6 +433,335 @@ async fn health_handler() -> Json<HealthResponse> {
})
}
#[derive(serde::Serialize)]
struct GithubWebhookResponse {
status: &'static str,
source: &'static str,
event_type: String,
fired_routines: usize,
}
/// PUBLIC webhook ingress for GitHub events.
///
/// Expects:
/// - `X-GitHub-Event` header
/// - JSON body payload
/// - Optional `X-Hub-Signature-256` HMAC when secret is configured
///
/// Secret lookup order:
/// 1. `GITHUB_WEBHOOK_SECRET` env var
/// 2. Gateway setting `github.webhook_secret`
async fn github_webhook_handler(
State(state): State<Arc<GatewayState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<(StatusCode, Json<GithubWebhookResponse>), (StatusCode, String)> {
let event = headers
.get("x-github-event")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing X-GitHub-Event header".to_string(),
))?;
let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid JSON payload: {}", e),
)
})?;
if let Some(secret) = github_webhook_secret(&state).await {
let sig = headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
.ok_or((
StatusCode::UNAUTHORIZED,
"Missing X-Hub-Signature-256 header".to_string(),
))?;
if !verify_github_signature(&secret, &body, sig) {
return Err((StatusCode::UNAUTHORIZED, "Invalid signature".to_string()));
}
}
let engine = {
let guard = state.routine_engine.read().await;
guard.as_ref().cloned().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Routine engine not available".to_string(),
))?
};
let event_type = github_event_type(event, &payload);
let enriched_payload = github_enriched_payload(event, &headers, &payload, &event_type);
let fired = engine
.emit_system_event(
"github",
&event_type,
&enriched_payload,
Some(&state.user_id),
)
.await;
Ok((
StatusCode::ACCEPTED,
Json(GithubWebhookResponse {
status: "accepted",
source: "github",
event_type,
fired_routines: fired,
}),
))
}
async fn github_webhook_secret(state: &GatewayState) -> Option<String> {
if let Ok(secret) = std::env::var("GITHUB_WEBHOOK_SECRET")
&& !secret.trim().is_empty()
{
return Some(secret);
}
let store = state.store.as_ref()?;
let value = store
.get_setting(&state.user_id, "github.webhook_secret")
.await
.ok()
.flatten()?;
value.as_str().map(ToString::to_string)
}
fn verify_github_signature(secret: &str, payload: &[u8], signature_header: &str) -> bool {
use hmac::Mac;
use subtle::ConstantTimeEq;
let Some(provided) = signature_header.strip_prefix("sha256=") else {
return false;
};
let mut mac = match hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(payload);
let expected = hex::encode(mac.finalize().into_bytes());
expected
.as_bytes()
.ct_eq(provided.to_ascii_lowercase().as_bytes())
.into()
}
fn github_event_type(event: &str, payload: &serde_json::Value) -> String {
let base = match event {
"issues" => "issue",
"pull_request" => "pr",
"issue_comment" => {
if payload.pointer("/issue/pull_request").is_some() {
"pr.comment"
} else {
"issue.comment"
}
}
"pull_request_review" => "pr.review",
"pull_request_review_comment" => "pr.review_comment",
"pull_request_review_thread" => "pr.review_thread",
"check_suite" => "ci.check_suite",
"check_run" => "ci.check_run",
"status" => "ci.status",
other => other,
};
if let Some(action) = payload.get("action").and_then(|v| v.as_str())
&& !action.is_empty()
{
return format!("{base}.{action}");
}
base.to_string()
}
fn github_enriched_payload(
raw_event: &str,
headers: &HeaderMap,
payload: &serde_json::Value,
event_type: &str,
) -> serde_json::Value {
fn put_if_missing(
obj: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
val: Option<serde_json::Value>,
) {
if !obj.contains_key(key)
&& let Some(v) = val
{
obj.insert(key.to_string(), v);
}
}
fn put_string_normalized(
obj: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
val: Option<String>,
) {
let should_set = match obj.get(key) {
None => true,
Some(existing) => !existing.is_string(),
};
if should_set && let Some(v) = val {
obj.insert(key.to_string(), serde_json::Value::String(v));
}
}
let mut obj = payload
.as_object()
.cloned()
.unwrap_or_else(serde_json::Map::new);
put_if_missing(
&mut obj,
"event",
Some(serde_json::Value::String(raw_event.to_string())),
);
put_if_missing(
&mut obj,
"event_type",
Some(serde_json::Value::String(event_type.to_string())),
);
put_if_missing(
&mut obj,
"delivery_id",
headers
.get("x-github-delivery")
.and_then(|v| v.to_str().ok())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_if_missing(
&mut obj,
"action",
payload
.get("action")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_string_normalized(
&mut obj,
"repository",
payload
.pointer("/repository/full_name")
.and_then(|v| v.as_str())
.map(ToString::to_string),
);
put_if_missing(
&mut obj,
"repository_owner",
payload
.pointer("/repository/owner/login")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_string_normalized(
&mut obj,
"sender",
payload
.pointer("/sender/login")
.and_then(|v| v.as_str())
.map(ToString::to_string),
);
put_if_missing(
&mut obj,
"issue_number",
payload.pointer("/issue/number").cloned(),
);
put_if_missing(
&mut obj,
"pr_number",
payload.pointer("/pull_request/number").cloned(),
);
put_if_missing(
&mut obj,
"comment_author",
payload
.pointer("/comment/user/login")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_if_missing(
&mut obj,
"comment_body",
payload
.pointer("/comment/body")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_if_missing(
&mut obj,
"review_state",
payload
.pointer("/review/state")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_if_missing(
&mut obj,
"pr_state",
payload
.pointer("/pull_request/state")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_if_missing(
&mut obj,
"pr_merged",
payload.pointer("/pull_request/merged").cloned(),
);
put_if_missing(
&mut obj,
"pr_draft",
payload.pointer("/pull_request/draft").cloned(),
);
put_if_missing(
&mut obj,
"base_branch",
payload
.pointer("/pull_request/base/ref")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_if_missing(
&mut obj,
"head_branch",
payload
.pointer("/pull_request/head/ref")
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_if_missing(
&mut obj,
"ci_status",
payload
.pointer("/check_run/status")
.or_else(|| payload.pointer("/check_suite/status"))
.or_else(|| payload.pointer("/status"))
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
put_if_missing(
&mut obj,
"ci_conclusion",
payload
.pointer("/check_run/conclusion")
.or_else(|| payload.pointer("/check_suite/conclusion"))
.or_else(|| payload.pointer("/state"))
.and_then(|v| v.as_str())
.map(|s| serde_json::Value::String(s.to_string())),
);
serde_json::Value::Object(obj)
}
/// Return an OAuth error landing page response.
fn oauth_error_page(label: &str) -> axum::response::Response {
let html = crate::cli::oauth_defaults::landing_html(label, false);
@@ -610,7 +940,6 @@ async fn oauth_callback_handler(
async fn chat_send_handler(
State(state): State<Arc<GatewayState>>,
headers: axum::http::HeaderMap,
Json(req): Json<SendMessageRequest>,
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
tracing::debug!(
@@ -627,14 +956,6 @@ async fn chat_send_handler(
}
let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content);
// Prefer timezone from JSON body, fall back to X-Timezone header
let tz = req
.timezone
.as_deref()
.or_else(|| headers.get("X-Timezone").and_then(|v| v.to_str().ok()));
if let Some(tz) = tz {
msg = msg.with_timezone(tz);
}
if let Some(ref thread_id) = req.thread_id {
msg = msg.with_thread(thread_id);
@@ -2124,7 +2445,7 @@ async fn routines_runs_handler(
/// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
crate::agent::routine::Trigger::Cron { schedule } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
@@ -2133,6 +2454,12 @@ fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::SystemEvent {
source, event_type, ..
} => (
"system_event".to_string(),
format!("event: {}.{}", source, event_type),
),
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("/");
("webhook".to_string(), format!("webhook: {}", p))
@@ -2483,6 +2810,12 @@ mod tests {
.with_state(state)
}
fn test_github_webhook_router(state: Arc<GatewayState>) -> Router {
Router::new()
.route("/api/webhooks/github", post(github_webhook_handler))
.with_state(state)
}
#[tokio::test]
async fn test_oauth_callback_missing_params() {
use axum::body::Body;
@@ -2788,4 +3121,193 @@ mod tests {
.is_none()
);
}
#[test]
fn test_github_event_type_normalization() {
assert_eq!(
github_event_type("issues", &serde_json::json!({"action": "opened"})),
"issue.opened"
);
assert_eq!(
github_event_type(
"pull_request",
&serde_json::json!({"action": "synchronize"})
),
"pr.synchronize"
);
assert_eq!(
github_event_type("push", &serde_json::json!({})),
"push".to_string()
);
assert_eq!(
github_event_type(
"issue_comment",
&serde_json::json!({
"action": "created",
"issue": { "pull_request": { "url": "https://api.github.com/repos/org/repo/pulls/1" } }
})
),
"pr.comment.created"
);
assert_eq!(
github_event_type("check_run", &serde_json::json!({"action": "completed"})),
"ci.check_run.completed"
);
}
#[test]
fn test_verify_github_signature_valid_and_invalid() {
use hmac::Mac;
let secret = "test-secret";
let payload = br#"{"action":"opened"}"#;
let mut mac =
hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
mac.update(payload);
let digest = hex::encode(mac.finalize().into_bytes());
let sig = format!("sha256={digest}");
assert!(verify_github_signature(secret, payload, &sig));
assert!(!verify_github_signature(secret, payload, "sha256=deadbeef"));
assert!(!verify_github_signature(secret, payload, "invalid-format"));
}
#[tokio::test]
async fn test_github_webhook_missing_event_header_rejected() {
use axum::body::Body;
use tower::ServiceExt;
let state = test_gateway_state(None);
let app = test_github_webhook_router(state);
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/webhooks/github")
.header("content-type", "application/json")
.body(Body::from(r#"{"action":"opened"}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_github_webhook_without_engine_returns_service_unavailable() {
use axum::body::Body;
use tower::ServiceExt;
let state = test_gateway_state(None);
let app = test_github_webhook_router(state);
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/webhooks/github")
.header("content-type", "application/json")
.header("x-github-event", "issues")
.body(Body::from(r#"{"action":"opened"}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[cfg(feature = "libsql")]
#[tokio::test]
async fn test_github_webhook_accepts_without_secret_when_engine_present() {
use axum::body::Body;
use tower::ServiceExt;
let state = test_gateway_state(None);
let (db, _tmp) = crate::testing::test_db().await;
let ws = Arc::new(crate::workspace::Workspace::new_with_db("test", db.clone()));
let llm = Arc::new(crate::testing::StubLlm::new("ok"));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(8);
let engine = Arc::new(crate::agent::routine_engine::RoutineEngine::new(
crate::config::RoutineConfig::default(),
db,
llm,
ws,
notify_tx,
None,
));
*state.routine_engine.write().await = Some(engine);
let app = test_github_webhook_router(state);
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/webhooks/github")
.header("content-type", "application/json")
.header("x-github-event", "issues")
.body(Body::from(r#"{"action":"opened"}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let json: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(
json.get("event_type").and_then(|v| v.as_str()),
Some("issue.opened")
);
}
#[test]
fn test_github_enriched_payload_extracts_common_fields() {
let headers = HeaderMap::new();
let payload = serde_json::json!({
"action": "created",
"repository": {
"full_name": "nearai/ironclaw",
"owner": { "login": "nearai" }
},
"sender": { "login": "maintainer1" },
"issue": { "number": 77 },
"comment": {
"body": "Please update the implementation plan",
"user": { "login": "maintainer1" }
}
});
let enriched =
github_enriched_payload("issue_comment", &headers, &payload, "issue.comment.created");
assert_eq!(
enriched.get("repository").and_then(|v| v.as_str()),
Some("nearai/ironclaw")
);
assert_eq!(
enriched.get("repository_owner").and_then(|v| v.as_str()),
Some("nearai")
);
assert_eq!(
enriched.get("sender").and_then(|v| v.as_str()),
Some("maintainer1")
);
assert_eq!(
enriched.get("issue_number").and_then(|v| v.as_i64()),
Some(77)
);
assert_eq!(
enriched.get("comment_author").and_then(|v| v.as_str()),
Some("maintainer1")
);
assert_eq!(
enriched.get("comment_body").and_then(|v| v.as_str()),
Some("Please update the implementation plan")
);
assert_eq!(
enriched.get("event_type").and_then(|v| v.as_str()),
Some("issue.comment.created")
);
}
}
+2 -10
View File
@@ -181,7 +181,6 @@ function confirmRestart() {
body: {
content: '/restart',
thread_id: currentThreadId,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
.then((response) => {
@@ -455,7 +454,7 @@ function sendMessage() {
apiFetch('/api/chat/send', {
method: 'POST',
body: { content, thread_id: currentThreadId || undefined, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
body: { content, thread_id: currentThreadId || undefined },
}).catch((err) => {
addMessage('system', 'Failed to send: ' + err.message);
});
@@ -564,13 +563,6 @@ function sendApprovalAction(requestId, action) {
function renderMarkdown(text) {
if (typeof marked !== 'undefined') {
// Escape raw HTML error pages instead of rendering them as markup.
// Only triggers when the text *starts with* a doctype or <html> tag
// (after optional whitespace), so normal messages that mention HTML
// tags in prose or code fences are not affected. See #263.
if (/^\s*<!doctype\s/i.test(text) || /^\s*<html[\s>]/i.test(text)) {
return escapeHtml(text);
}
let html = marked.parse(text);
// Sanitize HTML output to prevent XSS from tool output or LLM responses.
html = sanitizeRenderedHtml(html);
@@ -1481,7 +1473,7 @@ chatInput.addEventListener('keydown', (e) => {
}
}
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
hideSlashAutocomplete();
sendMessage();
+2 -8
View File
@@ -9,7 +9,6 @@ use uuid::Uuid;
pub struct SendMessageRequest {
pub content: String,
pub thread_id: Option<String>,
pub timezone: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -614,7 +613,6 @@ pub enum WsClientMessage {
Message {
content: String,
thread_id: Option<String>,
timezone: Option<String>,
},
/// Approve or deny a pending tool execution.
#[serde(rename = "approval")]
@@ -800,9 +798,7 @@ mod tests {
let json = r#"{"type":"message","content":"hello","thread_id":"t1"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg {
WsClientMessage::Message {
content, thread_id, ..
} => {
WsClientMessage::Message { content, thread_id } => {
assert_eq!(content, "hello");
assert_eq!(thread_id.as_deref(), Some("t1"));
}
@@ -815,9 +811,7 @@ mod tests {
let json = r#"{"type":"message","content":"hi"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg {
WsClientMessage::Message {
content, thread_id, ..
} => {
WsClientMessage::Message { content, thread_id } => {
assert_eq!(content, "hi");
assert!(thread_id.is_none());
}
+1 -10
View File
@@ -156,15 +156,8 @@ async fn handle_client_message(
direct_tx: &mpsc::Sender<WsServerMessage>,
) {
match msg {
WsClientMessage::Message {
content,
thread_id,
timezone,
} => {
WsClientMessage::Message { content, thread_id } => {
let mut incoming = IncomingMessage::new("gateway", user_id, &content);
if let Some(ref tz) = timezone {
incoming = incoming.with_timezone(tz);
}
if let Some(ref tid) = thread_id {
incoming = incoming.with_thread(tid);
}
@@ -356,7 +349,6 @@ mod tests {
WsClientMessage::Message {
content: "hello agent".to_string(),
thread_id: Some("t1".to_string()),
timezone: None,
},
&state,
"user1",
@@ -381,7 +373,6 @@ mod tests {
WsClientMessage::Message {
content: "hello".to_string(),
thread_id: None,
timezone: None,
},
&state,
"user1",
+1 -123
View File
@@ -8,35 +8,9 @@ use std::path::PathBuf;
use crate::bootstrap::ironclaw_base_dir;
use crate::settings::Settings;
/// Load settings from JSON and TOML config files, matching the runtime
/// priority: TOML overlay > settings.json > defaults.
///
/// This mirrors the loading chain in `Config::from_env_with_toml()` but
/// without resolving the full `Config` (which requires async + secrets).
fn load_settings() -> Settings {
load_settings_from(&Settings::default_path(), &Settings::default_toml_path())
}
/// Inner implementation with injectable paths (testable).
fn load_settings_from(json_path: &std::path::Path, toml_path: &std::path::Path) -> Settings {
let mut settings = Settings::load_from(json_path);
match Settings::load_toml(toml_path) {
Ok(Some(toml_settings)) => {
settings.merge_from(&toml_settings);
}
Ok(None) => {} // File not found — fine for default path
Err(e) => {
eprintln!("Warning: failed to parse {}: {}", toml_path.display(), e);
}
}
settings
}
/// Run the status command, printing system health info.
pub async fn run_status_command() -> anyhow::Result<()> {
let settings = load_settings();
let settings = Settings::default();
println!("IronClaw Status");
println!("===============\n");
@@ -235,99 +209,3 @@ fn default_tools_dir() -> PathBuf {
fn default_channels_dir() -> PathBuf {
ironclaw_base_dir().join("channels")
}
#[cfg(test)]
mod tests {
use super::load_settings_from;
/// Regression test for #354: load_settings_from must read config.toml.
#[test]
fn reads_toml_heartbeat_enabled() {
let dir = tempfile::tempdir().expect("tempdir");
let json_path = dir.path().join("settings.json");
let toml_path = dir.path().join("config.toml");
// No JSON file — only TOML
std::fs::write(
&toml_path,
"[heartbeat]\nenabled = true\ninterval_secs = 600",
)
.expect("write toml");
let settings = load_settings_from(&json_path, &toml_path);
assert!(settings.heartbeat.enabled);
assert_eq!(settings.heartbeat.interval_secs, 600);
}
/// Without any config files, defaults are returned.
#[test]
fn defaults_without_config_files() {
let dir = tempfile::tempdir().expect("tempdir");
let settings = load_settings_from(
&dir.path().join("nonexistent.json"),
&dir.path().join("nonexistent.toml"),
);
assert!(!settings.heartbeat.enabled);
}
/// settings.json is respected.
#[test]
fn reads_json_heartbeat_enabled() {
let dir = tempfile::tempdir().expect("tempdir");
let json_path = dir.path().join("settings.json");
let toml_path = dir.path().join("nonexistent.toml");
std::fs::write(
&json_path,
r#"{"heartbeat":{"enabled":true,"interval_secs":900}}"#,
)
.expect("write json");
let settings = load_settings_from(&json_path, &toml_path);
assert!(settings.heartbeat.enabled);
assert_eq!(settings.heartbeat.interval_secs, 900);
}
/// TOML overlay wins over JSON settings.
#[test]
fn toml_overlay_wins_over_json() {
let dir = tempfile::tempdir().expect("tempdir");
let json_path = dir.path().join("settings.json");
let toml_path = dir.path().join("config.toml");
std::fs::write(
&json_path,
r#"{"heartbeat":{"enabled":false,"interval_secs":100}}"#,
)
.expect("write json");
std::fs::write(
&toml_path,
"[heartbeat]\nenabled = true\ninterval_secs = 200",
)
.expect("write toml");
let settings = load_settings_from(&json_path, &toml_path);
assert!(settings.heartbeat.enabled);
assert_eq!(settings.heartbeat.interval_secs, 200);
}
/// Invalid TOML is warned but doesn't crash; falls back to JSON/defaults.
#[test]
fn invalid_toml_falls_back_gracefully() {
let dir = tempfile::tempdir().expect("tempdir");
let json_path = dir.path().join("settings.json");
let toml_path = dir.path().join("config.toml");
std::fs::write(
&json_path,
r#"{"heartbeat":{"enabled":true,"interval_secs":500}}"#,
)
.expect("write json");
std::fs::write(&toml_path, "this is not valid toml [[[").expect("write bad toml");
let settings = load_settings_from(&json_path, &toml_path);
// Should fall back to JSON values, not crash
assert!(settings.heartbeat.enabled);
assert_eq!(settings.heartbeat.interval_secs, 500);
}
}
-37
View File
@@ -27,8 +27,6 @@ pub struct AgentConfig {
pub max_tool_iterations: usize,
/// When true, skip tool approval checks entirely. For benchmarks/CI.
pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
pub default_timezone: String,
}
impl AgentConfig {
@@ -49,7 +47,6 @@ impl AgentConfig {
max_actions_per_hour: None,
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
}
}
@@ -92,40 +89,6 @@ impl AgentConfig {
"AGENT_AUTO_APPROVE_TOOLS",
settings.agent.auto_approve_tools,
)?,
default_timezone: {
let tz: String = parse_optional_env(
"DEFAULT_TIMEZONE",
settings.agent.default_timezone.clone(),
)?;
if crate::timezone::parse_timezone(&tz).is_none() {
return Err(ConfigError::InvalidValue {
key: "DEFAULT_TIMEZONE".into(),
message: format!("invalid IANA timezone: '{tz}'"),
});
}
tz
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_timezone_rejects_invalid() {
let mut settings = Settings::default();
settings.agent.default_timezone = "Fake/Zone".to_string();
let result = AgentConfig::resolve(&settings);
assert!(result.is_err(), "invalid IANA timezone should be rejected");
}
#[test]
fn test_default_timezone_accepts_valid() {
let settings = Settings::default(); // default is "UTC"
let config = AgentConfig::resolve(&settings).expect("resolve");
assert_eq!(config.default_timezone, "UTC");
}
}
-92
View File
@@ -82,31 +82,6 @@ impl std::str::FromStr for SslMode {
}
}
/// Which vector store backend to use for workspace semantic search.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VectorBackend {
/// Use the database's built-in vector support (pgvector or libsql_vector_idx).
#[default]
Builtin,
/// Use LanceDB as an external vector store (requires `lancedb` feature).
LanceDb,
}
impl std::str::FromStr for VectorBackend {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"" | "builtin" | "pgvector" | "libsql" => Ok(Self::Builtin),
"lancedb" | "lance" => Ok(Self::LanceDb),
_ => Err(format!(
"invalid vector backend '{}', expected 'builtin' or 'lancedb'",
s
)),
}
}
}
/// Database configuration.
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
@@ -126,12 +101,6 @@ pub struct DatabaseConfig {
pub libsql_url: Option<String>,
/// Turso auth token (required when libsql_url is set).
pub libsql_auth_token: Option<SecretString>,
// -- Vector store fields --
/// Which vector store to use for workspace semantic search (default: Builtin).
pub vector_backend: VectorBackend,
/// Path to LanceDB directory (default: ~/.ironclaw/lancedb when vector_backend is LanceDb).
pub lancedb_path: Option<PathBuf>,
}
impl DatabaseConfig {
@@ -190,25 +159,6 @@ impl DatabaseConfig {
});
}
let vector_backend: VectorBackend = if let Some(s) = optional_env("VECTOR_BACKEND")? {
s.parse().map_err(|e| ConfigError::InvalidValue {
key: "VECTOR_BACKEND".to_string(),
message: e,
})?
} else {
VectorBackend::default()
};
let lancedb_path = optional_env("LANCEDB_PATH")?
.map(PathBuf::from)
.or_else(|| {
if vector_backend == VectorBackend::LanceDb {
Some(default_lancedb_path())
} else {
None
}
});
Ok(Self {
backend,
url: SecretString::from(url),
@@ -217,8 +167,6 @@ impl DatabaseConfig {
libsql_path,
libsql_url,
libsql_auth_token,
vector_backend,
lancedb_path,
})
}
@@ -247,11 +195,6 @@ pub fn default_libsql_path() -> PathBuf {
ironclaw_base_dir().join("ironclaw.db")
}
/// Default LanceDB directory (~/.ironclaw/lancedb).
pub fn default_lancedb_path() -> PathBuf {
ironclaw_base_dir().join("lancedb")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -281,39 +224,4 @@ mod tests {
fn ssl_mode_parse_invalid() {
assert!("invalid".parse::<SslMode>().is_err());
}
#[test]
fn vector_backend_parse() {
assert_eq!(
"builtin".parse::<VectorBackend>().unwrap(),
VectorBackend::Builtin
);
assert_eq!(
"pgvector".parse::<VectorBackend>().unwrap(),
VectorBackend::Builtin
);
assert_eq!(
"libsql".parse::<VectorBackend>().unwrap(),
VectorBackend::Builtin
);
assert_eq!("".parse::<VectorBackend>().unwrap(), VectorBackend::Builtin);
assert_eq!(
"lancedb".parse::<VectorBackend>().unwrap(),
VectorBackend::LanceDb
);
assert_eq!(
"lance".parse::<VectorBackend>().unwrap(),
VectorBackend::LanceDb
);
assert!("invalid".parse::<VectorBackend>().is_err());
}
#[test]
fn default_lancedb_path_under_ironclaw() {
let path = super::default_lancedb_path();
assert!(path.to_string_lossy().contains("ironclaw"));
assert!(path.to_string_lossy().ends_with("lancedb"));
}
}
+1 -102
View File
@@ -1,4 +1,4 @@
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
@@ -13,12 +13,6 @@ pub struct HeartbeatConfig {
pub notify_channel: Option<String>,
/// User ID to notify on heartbeat findings.
pub notify_user: Option<String>,
/// Hour (0-23) when quiet hours start.
pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end.
pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name).
pub timezone: Option<String>,
}
impl Default for HeartbeatConfig {
@@ -28,9 +22,6 @@ impl Default for HeartbeatConfig {
interval_secs: 1800, // 30 minutes
notify_channel: None,
notify_user: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
}
}
}
@@ -47,98 +38,6 @@ impl HeartbeatConfig {
.or_else(|| settings.heartbeat.notify_channel.clone()),
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
.or_else(|| settings.heartbeat.notify_user.clone()),
quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")?
.or(settings.heartbeat.quiet_hours_start)
.map(|h| {
if h > 23 {
return Err(ConfigError::InvalidValue {
key: "HEARTBEAT_QUIET_START".into(),
message: "must be 0-23".into(),
});
}
Ok(h)
})
.transpose()?,
quiet_hours_end: parse_option_env::<u32>("HEARTBEAT_QUIET_END")?
.or(settings.heartbeat.quiet_hours_end)
.map(|h| {
if h > 23 {
return Err(ConfigError::InvalidValue {
key: "HEARTBEAT_QUIET_END".into(),
message: "must be 0-23".into(),
});
}
Ok(h)
})
.transpose()?,
timezone: {
let tz = optional_env("HEARTBEAT_TIMEZONE")?
.or_else(|| settings.heartbeat.timezone.clone());
if let Some(ref tz_str) = tz
&& crate::timezone::parse_timezone(tz_str).is_none()
{
return Err(ConfigError::InvalidValue {
key: "HEARTBEAT_TIMEZONE".into(),
message: format!("invalid IANA timezone: '{tz_str}'"),
});
}
tz
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quiet_hours_settings_fallback() {
// When env vars are not set, settings values should be used
let mut settings = Settings::default();
settings.heartbeat.quiet_hours_start = Some(22);
settings.heartbeat.quiet_hours_end = Some(6);
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
assert_eq!(config.quiet_hours_start, Some(22));
assert_eq!(config.quiet_hours_end, Some(6));
}
#[test]
fn test_quiet_hours_rejects_invalid_hour() {
let mut settings = Settings::default();
settings.heartbeat.quiet_hours_start = Some(24);
let result = HeartbeatConfig::resolve(&settings);
assert!(result.is_err());
}
#[test]
fn test_quiet_hours_accepts_boundary_values() {
let mut settings = Settings::default();
settings.heartbeat.quiet_hours_start = Some(0);
settings.heartbeat.quiet_hours_end = Some(23);
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
assert_eq!(config.quiet_hours_start, Some(0));
assert_eq!(config.quiet_hours_end, Some(23));
}
#[test]
fn test_heartbeat_timezone_rejects_invalid() {
let mut settings = Settings::default();
settings.heartbeat.timezone = Some("Fake/Zone".to_string());
let result = HeartbeatConfig::resolve(&settings);
assert!(result.is_err(), "invalid IANA timezone should be rejected");
}
#[test]
fn test_heartbeat_timezone_accepts_valid() {
let mut settings = Settings::default();
settings.heartbeat.timezone = Some("America/New_York".to_string());
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
assert_eq!(config.timezone.as_deref(), Some("America/New_York"));
}
}
-34
View File
@@ -103,10 +103,6 @@ pub struct LlmConfig {
/// Resolved provider config for registry-based providers.
/// `None` when backend is "nearai".
pub provider: Option<RegistryProviderConfig>,
/// HTTP request timeout in seconds for LLM API calls.
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
/// need more time for prompt evaluation on consumer hardware.
pub request_timeout_secs: u64,
}
/// NEAR AI configuration.
@@ -169,7 +165,6 @@ impl LlmConfig {
smart_routing_cascade: false,
},
provider: None,
request_timeout_secs: 120,
}
}
@@ -259,8 +254,6 @@ impl LlmConfig {
)?)
};
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
Ok(Self {
backend: if is_nearai {
"nearai".to_string()
@@ -272,7 +265,6 @@ impl LlmConfig {
session,
nearai,
provider,
request_timeout_secs,
})
}
@@ -1024,30 +1016,4 @@ mod tests {
assert_eq!(parsed, variant, "round-trip failed for {s}");
}
}
#[test]
fn test_request_timeout_defaults_to_120() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
}
let config = LlmConfig::resolve(&Settings::default()).expect("resolve");
assert_eq!(config.request_timeout_secs, 120);
}
#[test]
fn test_request_timeout_configurable() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("LLM_REQUEST_TIMEOUT_SECS", "300");
}
let config = LlmConfig::resolve(&Settings::default()).expect("resolve");
assert_eq!(config.request_timeout_secs, 300);
// SAFETY: Cleanup
unsafe {
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
}
}
}
+1 -32
View File
@@ -33,10 +33,7 @@ use crate::settings::Settings;
pub use self::agent::AgentConfig;
pub use self::builder::BuilderModeConfig;
pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig};
pub use self::database::{
DatabaseBackend, DatabaseConfig, SslMode, VectorBackend, default_lancedb_path,
default_libsql_path,
};
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
pub use self::embeddings::EmbeddingsConfig;
pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
@@ -110,8 +107,6 @@ impl Config {
libsql_path: Some(libsql_path),
libsql_url: None,
libsql_auth_token: None,
vector_backend: VectorBackend::default(),
lancedb_path: None,
},
llm: LlmConfig::for_testing(),
embeddings: EmbeddingsConfig::default(),
@@ -262,32 +257,6 @@ impl Config {
Ok(())
}
/// Re-resolve only the LLM config after credential injection.
///
/// Called by `AppBuilder::init_secrets()` after injecting API keys into
/// the env overlay. Only rebuilds `self.llm` — all other config fields
/// are unaffected, preserving values from the initial config load (or
/// from `Config::for_testing()` in test mode).
pub async fn re_resolve_llm(
&mut self,
store: Option<&(dyn crate::db::SettingsStore + Sync)>,
user_id: &str,
toml_path: Option<&std::path::Path>,
) -> Result<(), ConfigError> {
let settings = if let Some(store) = store {
let mut s = match store.get_all_settings(user_id).await {
Ok(map) => Settings::from_db_map(&map),
Err(_) => Settings::default(),
};
Self::apply_toml_overlay(&mut s, toml_path)?;
s
} else {
Settings::default()
};
self.llm = LlmConfig::resolve(&settings)?;
Ok(())
}
/// Build config from settings (shared by from_env and from_db).
async fn build(settings: &Settings) -> Result<Self, ConfigError> {
Ok(Self {
-9
View File
@@ -164,8 +164,6 @@ pub struct JobContext {
/// previous results by ID via `$tool_call_id` parameter syntax.
#[serde(skip)]
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
pub user_timezone: String,
}
impl JobContext {
@@ -205,16 +203,9 @@ impl JobContext {
http_interceptor: None,
metadata: serde_json::Value::Null,
tool_output_stash: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
user_timezone: "UTC".to_string(),
}
}
/// Set the user timezone on this context.
pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
self.user_timezone = tz.into();
self
}
/// Transition to a new state.
pub fn transition_to(
&mut self,
-3
View File
@@ -121,9 +121,6 @@ impl JobStore for LibSqlBackend {
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(),
)),
// TODO(#661): persist user_timezone in agent_jobs table so
// background/routine jobs retain the session's timezone context.
user_timezone: "UTC".to_string(),
}))
}
None => Ok(None),
+1 -1
View File
@@ -167,7 +167,7 @@ impl RoutineStore for LibSqlBackend {
let mut rows = conn
.query(
&format!(
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'event'",
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type IN ('event', 'system_event')",
ROUTINE_COLUMNS
),
(),
+227 -123
View File
@@ -1,10 +1,13 @@
//! Success evaluation for jobs.
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::context::{ActionRecord, JobContext};
use crate::error::EvaluationError;
use crate::llm::LlmProvider;
/// Result of evaluating job success.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -61,132 +64,233 @@ pub trait SuccessEvaluator: Send + Sync {
) -> Result<EvaluationResult, EvaluationError>;
}
/// Rule-based success evaluator.
pub struct RuleBasedEvaluator {
/// Minimum success rate for actions.
min_action_success_rate: f64,
/// Maximum allowed failures.
max_failures: u32,
}
impl RuleBasedEvaluator {
/// Create a new rule-based evaluator.
pub fn new() -> Self {
Self {
min_action_success_rate: 0.8,
max_failures: 3,
}
}
/// Set minimum action success rate.
#[allow(dead_code)] // Public API for configuring evaluation threshold
pub fn with_min_success_rate(mut self, rate: f64) -> Self {
self.min_action_success_rate = rate;
self
}
/// Set maximum failures.
#[allow(dead_code)] // Public API for configuring failure tolerance
pub fn with_max_failures(mut self, max: u32) -> Self {
self.max_failures = max;
self
}
}
impl Default for RuleBasedEvaluator {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl SuccessEvaluator for RuleBasedEvaluator {
async fn evaluate(
&self,
job: &JobContext,
actions: &[ActionRecord],
_output: Option<&str>,
) -> Result<EvaluationResult, EvaluationError> {
let mut issues = Vec::new();
// Check if there were any actions
if actions.is_empty() {
return Ok(EvaluationResult::failure(
"No actions were taken",
vec!["No actions recorded".to_string()],
));
}
// Calculate action success rate
let successful = actions.iter().filter(|a| a.success).count();
let total = actions.len();
let success_rate = successful as f64 / total as f64;
if success_rate < self.min_action_success_rate {
issues.push(format!(
"Action success rate {:.1}% below threshold {:.1}%",
success_rate * 100.0,
self.min_action_success_rate * 100.0
));
}
// Count failures
let failures = actions.iter().filter(|a| !a.success).count() as u32;
if failures > self.max_failures {
issues.push(format!(
"Too many failures: {} (max {})",
failures, self.max_failures
));
}
// Check for critical errors
for action in actions.iter().filter(|a| !a.success) {
if let Some(ref error) = action.error
&& (error.to_lowercase().contains("critical")
|| error.to_lowercase().contains("fatal"))
{
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
}
}
// Check job state
if job.state != crate::context::JobState::Completed
&& job.state != crate::context::JobState::Submitted
{
issues.push(format!("Job not in completed state: {:?}", job.state));
}
// Calculate quality score
let quality_score = if issues.is_empty() {
let base_score = (success_rate * 80.0) as u32;
let completion_bonus = if job.state == crate::context::JobState::Completed {
20
} else {
0
};
(base_score + completion_bonus).min(100)
} else {
((success_rate * 50.0) as u32).min(50)
};
if issues.is_empty() {
Ok(EvaluationResult::success(
format!(
"Job completed successfully with {}/{} actions succeeding ({:.1}%)",
successful,
total,
success_rate * 100.0
),
quality_score,
))
} else {
Ok(EvaluationResult {
success: false,
confidence: 0.85,
reasoning: format!("Job had {} issues", issues.len()),
issues,
suggestions: vec![
"Review failed actions for common patterns".to_string(),
"Consider adjusting retry logic".to_string(),
],
quality_score,
})
}
}
}
/// LLM-based success evaluator for more nuanced evaluation.
pub struct LlmEvaluator {
llm: Arc<dyn LlmProvider>,
}
impl LlmEvaluator {
/// Create a new LLM-based evaluator.
#[allow(dead_code)] // Public API for LLM-based evaluation
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
Self { llm }
}
}
#[async_trait]
impl SuccessEvaluator for LlmEvaluator {
async fn evaluate(
&self,
job: &JobContext,
actions: &[ActionRecord],
output: Option<&str>,
) -> Result<EvaluationResult, EvaluationError> {
// Build evaluation prompt
let actions_summary: Vec<String> = actions
.iter()
.map(|a| {
format!(
"- {}: {} ({})",
a.tool_name,
if a.success { "success" } else { "failed" },
a.error.as_deref().unwrap_or("ok")
)
})
.collect();
let prompt = format!(
r#"Evaluate if this job was completed successfully.
Job: {}
Description: {}
State: {:?}
Actions taken:
{}
{}
Respond in JSON format:
{{
"success": true/false,
"confidence": 0.0-1.0,
"reasoning": "...",
"issues": ["..."],
"suggestions": ["..."],
"quality_score": 0-100
}}"#,
job.title,
job.description,
job.state,
actions_summary.join("\n"),
output
.map(|o| format!("Output:\n{}", o))
.unwrap_or_default()
);
let request =
crate::llm::CompletionRequest::new(vec![crate::llm::ChatMessage::user(prompt)])
.with_max_tokens(1024)
.with_temperature(0.1);
let response = self
.llm
.complete(request)
.await
.map_err(|e| EvaluationError::Failed {
job_id: job.job_id,
reason: e.to_string(),
})?;
// Parse the response
let result: EvaluationResult =
serde_json::from_str(&response.content).map_err(|e| EvaluationError::Failed {
job_id: job.job_id,
reason: format!("Failed to parse LLM evaluation: {}", e),
})?;
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::{ActionRecord, JobContext};
use crate::error::EvaluationError;
/// Rule-based success evaluator (test-only; no production callers).
struct RuleBasedEvaluator {
min_action_success_rate: f64,
max_failures: u32,
}
impl RuleBasedEvaluator {
fn new() -> Self {
Self {
min_action_success_rate: 0.8,
max_failures: 3,
}
}
fn with_min_success_rate(mut self, rate: f64) -> Self {
self.min_action_success_rate = rate;
self
}
fn with_max_failures(mut self, max: u32) -> Self {
self.max_failures = max;
self
}
}
impl Default for RuleBasedEvaluator {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl SuccessEvaluator for RuleBasedEvaluator {
async fn evaluate(
&self,
job: &JobContext,
actions: &[ActionRecord],
_output: Option<&str>,
) -> Result<EvaluationResult, EvaluationError> {
let mut issues = Vec::new();
if actions.is_empty() {
return Ok(EvaluationResult::failure(
"No actions were taken",
vec!["No actions recorded".to_string()],
));
}
let successful = actions.iter().filter(|a| a.success).count();
let total = actions.len();
let success_rate = successful as f64 / total as f64;
if success_rate < self.min_action_success_rate {
issues.push(format!(
"Action success rate {:.1}% below threshold {:.1}%",
success_rate * 100.0,
self.min_action_success_rate * 100.0
));
}
let failures = actions.iter().filter(|a| !a.success).count() as u32;
if failures > self.max_failures {
issues.push(format!(
"Too many failures: {} (max {})",
failures, self.max_failures
));
}
for action in actions.iter().filter(|a| !a.success) {
if let Some(ref error) = action.error
&& (error.to_lowercase().contains("critical")
|| error.to_lowercase().contains("fatal"))
{
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
}
}
if job.state != crate::context::JobState::Completed
&& job.state != crate::context::JobState::Submitted
{
issues.push(format!("Job not in completed state: {:?}", job.state));
}
let quality_score = if issues.is_empty() {
let base_score = (success_rate * 80.0) as u32;
let completion_bonus = if job.state == crate::context::JobState::Completed {
20
} else {
0
};
(base_score + completion_bonus).min(100)
} else {
((success_rate * 50.0) as u32).min(50)
};
if issues.is_empty() {
Ok(EvaluationResult::success(
format!(
"Job completed successfully with {}/{} actions succeeding ({:.1}%)",
successful,
total,
success_rate * 100.0
),
quality_score,
))
} else {
Ok(EvaluationResult {
success: false,
confidence: 0.85,
reasoning: format!("Job had {} issues", issues.len()),
issues,
suggestions: vec![
"Review failed actions for common patterns".to_string(),
"Consider adjusting retry logic".to_string(),
],
quality_score,
})
}
}
}
use crate::context::JobContext;
#[tokio::test]
async fn test_rule_based_evaluator_success() {
+32
View File
@@ -1405,6 +1405,38 @@ impl ExtensionManager {
Ok(())
}
#[allow(dead_code)] // Used by upcoming hot-activation flow
async fn install_bundled_channel_from_artifacts(
&self,
name: &str,
) -> Result<InstallResult, ExtensionError> {
// Check if already installed
let channel_wasm = self.wasm_channels_dir.join(format!("{}.wasm", name));
if channel_wasm.exists() {
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
}
crate::channels::wasm::install_bundled_channel(name, &self.wasm_channels_dir, false)
.await
.map_err(ExtensionError::InstallFailed)?;
tracing::info!(
"Installed bundled channel '{}' to {}",
name,
self.wasm_channels_dir.display()
);
Ok(InstallResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
message: format!(
"Channel '{}' installed. \
Run tool_auth('{}') to configure authentication, then activate.",
name, name,
),
})
}
/// Install a WASM extension from local build artifacts (WasmBuildable source).
///
/// Resolves the build directory (relative to `CARGO_MANIFEST_DIR` or absolute),
+1 -4
View File
@@ -241,9 +241,6 @@ impl Store {
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(),
)),
// TODO(#661): persist user_timezone in agent_jobs table so
// background/routine jobs retain the session's timezone context.
user_timezone: "UTC".to_string(),
}))
}
None => Ok(None),
@@ -1084,7 +1081,7 @@ impl Store {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT * FROM routines WHERE enabled AND trigger_type = 'event'",
"SELECT * FROM routines WHERE enabled AND trigger_type IN ('event', 'system_event')",
&[],
)
.await?;
-1
View File
@@ -66,7 +66,6 @@ pub mod service;
pub mod settings;
pub mod setup;
pub mod skills;
pub mod timezone;
pub mod tools;
pub mod tracing_fmt;
pub mod transcription;
+4 -21
View File
@@ -58,10 +58,8 @@ pub fn create_llm_provider(
config: &LlmConfig,
session: Arc<SessionManager>,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
let timeout = config.request_timeout_secs;
if config.backend == "nearai" || config.backend == "near_ai" || config.backend == "near" {
return create_llm_provider_with_config(&config.nearai, session, timeout);
return create_llm_provider_with_config(&config.nearai, session);
}
let reg_config = config
@@ -81,7 +79,6 @@ pub fn create_llm_provider(
pub fn create_llm_provider_with_config(
config: &NearAiConfig,
session: Arc<SessionManager>,
request_timeout_secs: u64,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
let auth_mode = if config.api_key.is_some() {
"API key"
@@ -92,14 +89,9 @@ pub fn create_llm_provider_with_config(
model = %config.model,
base_url = %config.base_url,
auth = auth_mode,
timeout_secs = request_timeout_secs,
"Using NEAR AI (Chat Completions API)"
);
Ok(Arc::new(NearAiChatProvider::new_with_timeout(
config.clone(),
session,
request_timeout_secs,
)?))
Ok(Arc::new(NearAiChatProvider::new(config.clone(), session)?))
}
/// Create a provider from a registry-resolved config.
@@ -373,11 +365,7 @@ pub fn build_provider_chain(
let llm: Arc<dyn LlmProvider> = if let Some(ref cheap_model) = config.nearai.cheap_model {
let mut cheap_config = config.nearai.clone();
cheap_config.model = cheap_model.clone();
let cheap = create_llm_provider_with_config(
&cheap_config,
session.clone(),
config.request_timeout_secs,
)?;
let cheap = create_llm_provider_with_config(&cheap_config, session.clone())?;
let cheap: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
Arc::new(RetryProvider::new(cheap, retry_config.clone()))
} else {
@@ -409,11 +397,7 @@ pub fn build_provider_chain(
}
let mut fallback_config = config.nearai.clone();
fallback_config.model = fallback_model.clone();
let fallback = create_llm_provider_with_config(
&fallback_config,
session.clone(),
config.request_timeout_secs,
)?;
let fallback = create_llm_provider_with_config(&fallback_config, session.clone())?;
tracing::info!(
primary = %llm.model_name(),
fallback = %fallback.model_name(),
@@ -519,7 +503,6 @@ mod tests {
session: SessionConfig::default(),
nearai: test_nearai_config(),
provider: None,
request_timeout_secs: 120,
}
}
+4 -15
View File
@@ -58,28 +58,17 @@ impl NearAiChatProvider {
/// By default this enables tool-message flattening for compatibility with
/// providers that reject `role: "tool"` messages.
pub fn new(config: NearAiConfig, session: Arc<SessionManager>) -> Result<Self, LlmError> {
Self::new_with_options(config, session, true, 120)
Self::new_with_flatten(config, session, true)
}
/// Create a new provider with a custom request timeout.
pub fn new_with_timeout(
config: NearAiConfig,
session: Arc<SessionManager>,
request_timeout_secs: u64,
) -> Result<Self, LlmError> {
Self::new_with_options(config, session, true, request_timeout_secs)
}
/// Create a chat completions provider with configurable tool-message flattening
/// and request timeout.
pub fn new_with_options(
/// Create a chat completions provider with configurable tool-message flattening.
pub fn new_with_flatten(
config: NearAiConfig,
session: Arc<SessionManager>,
flatten_tool_messages: bool,
request_timeout_secs: u64,
) -> Result<Self, LlmError> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(request_timeout_secs))
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
+12 -2
View File
@@ -11,6 +11,7 @@ use crate::llm::{
ChatMessage, CompletionRequest, LlmProvider, Role, ToolCall, ToolCompletionRequest,
ToolDefinition,
};
use crate::safety::SafetyLayer;
/// Token the agent returns when it has nothing to say (e.g. in group chats).
/// The dispatcher should check for this and suppress the message.
@@ -342,6 +343,8 @@ pub struct RespondOutput {
/// Reasoning engine for the agent.
pub struct Reasoning {
llm: Arc<dyn LlmProvider>,
#[allow(dead_code)] // Will be used for sanitizing tool outputs
safety: Arc<SafetyLayer>,
/// Optional workspace for loading identity/system prompts.
workspace_system_prompt: Option<String>,
/// Optional skill context block to inject into system prompt.
@@ -359,9 +362,10 @@ pub struct Reasoning {
impl Reasoning {
/// Create a new reasoning engine.
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> Self {
Self {
llm,
safety,
workspace_system_prompt: None,
skill_context: None,
channel: None,
@@ -2113,9 +2117,15 @@ That's my plan."#;
// ---- System prompt building tests (issue #565) ----
fn make_test_reasoning() -> Reasoning {
use crate::config::SafetyConfig;
use crate::safety::SafetyLayer;
use crate::testing::StubLlm;
let llm = Arc::new(StubLlm::new("test"));
Reasoning::new(llm)
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
Reasoning::new(llm, safety)
}
#[test]
+1 -2
View File
@@ -200,10 +200,9 @@ impl SessionManager {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let preview = crate::agent::truncate_for_preview(&body, 200);
Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Validation failed: HTTP {status}: {preview}"),
reason: format!("Validation failed: HTTP {}: {}", status, body),
})
}
+6 -23
View File
@@ -145,24 +145,6 @@ async fn async_main() -> anyhow::Result<()> {
}
}
// ── PID lock (prevent multiple instances) ────────────────────────
let _pid_lock = match ironclaw::bootstrap::PidLock::acquire() {
Ok(lock) => Some(lock),
Err(ironclaw::bootstrap::PidLockError::AlreadyRunning { pid }) => {
anyhow::bail!(
"Another IronClaw instance is already running (PID {}). \
If this is incorrect, remove the stale PID file: {}",
pid,
ironclaw::bootstrap::pid_lock_path().display()
);
}
Err(e) => {
eprintln!("Warning: Could not acquire PID lock: {}", e);
eprintln!("Continuing without PID lock protection.");
None
}
};
// ── Agent startup ──────────────────────────────────────────────────
// Enhanced first-run detection
@@ -184,12 +166,13 @@ async fn async_main() -> anyhow::Result<()> {
let config = match Config::from_env_with_toml(toml_path).await {
Ok(c) => c,
Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => {
anyhow::bail!(
"Configuration error: Missing required setting '{}'. {}. \
Run 'ironclaw onboard' to configure, or set the required environment variables.",
key,
hint
eprintln!("Configuration error: Missing required setting '{}'", key);
eprintln!(" {}", hint);
eprintln!();
eprintln!(
"Run 'ironclaw onboard' to configure, or set the required environment variables."
);
std::process::exit(1);
}
Err(e) => return Err(e.into()),
};
-53
View File
@@ -42,10 +42,6 @@ pub struct Settings {
#[serde(default)]
pub secrets_master_key_source: KeySource,
/// Generated master key hex (env var mode only, written to .env by wizard).
#[serde(default, skip_serializing)]
pub secrets_master_key_hex: Option<String>,
// === Step 3: Inference Provider ===
/// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible".
#[serde(default)]
@@ -295,18 +291,6 @@ pub struct HeartbeatSettings {
/// User ID to notify on heartbeat findings.
#[serde(default)]
pub notify_user: Option<String>,
/// Hour (0-23) when quiet hours start (heartbeat skipped).
#[serde(default)]
pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end (heartbeat resumes).
#[serde(default)]
pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name, e.g. "America/New_York").
#[serde(default)]
pub timezone: Option<String>,
}
fn default_heartbeat_interval() -> u64 {
@@ -320,9 +304,6 @@ impl Default for HeartbeatSettings {
interval_secs: default_heartbeat_interval(),
notify_channel: None,
notify_user: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
}
}
}
@@ -370,10 +351,6 @@ pub struct AgentSettings {
/// When true, skip tool approval checks entirely. For benchmarks/CI.
#[serde(default)]
pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
#[serde(default = "default_timezone")]
pub default_timezone: String,
}
fn default_agent_name() -> String {
@@ -408,10 +385,6 @@ fn default_max_tool_iterations() -> usize {
50
}
fn default_timezone() -> String {
"UTC".to_string()
}
fn default_true() -> bool {
true
}
@@ -429,7 +402,6 @@ impl Default for AgentSettings {
session_idle_timeout_secs: default_session_idle_timeout(),
max_tool_iterations: default_max_tool_iterations(),
auto_approve_tools: false,
default_timezone: default_timezone(),
}
}
}
@@ -1202,31 +1174,6 @@ mod tests {
assert_eq!(loaded.heartbeat.interval_secs, 900);
}
/// Regression test: /model command must persist selected_model to TOML config.
/// Prior to the fix, `set_model()` only changed the in-memory provider and the
/// choice was lost on restart.
#[test]
fn toml_selected_model_update_persists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
// Start with a config that has a different model.
let settings = Settings {
selected_model: Some("old-model".to_string()),
..Default::default()
};
settings.save_toml(&path).unwrap();
// Simulate what persist_selected_model does: load, update, save.
let mut loaded = Settings::load_toml(&path).unwrap().unwrap();
loaded.selected_model = Some("new-model".to_string());
loaded.save_toml(&path).unwrap();
// Verify the change survived a reload.
let reloaded = Settings::load_toml(&path).unwrap().unwrap();
assert_eq!(reloaded.selected_model, Some("new-model".to_string()));
}
#[test]
fn toml_missing_file_returns_none() {
let result = Settings::load_toml(std::path::Path::new("/tmp/nonexistent_config.toml"));
+12 -106
View File
@@ -769,28 +769,13 @@ impl SetupWizard {
print_success("Master key generated and stored in OS keychain");
}
1 => {
// Env var mode — generate key, init crypto, and persist to .env
// Env var mode
print_info("Generate a key and add it to your environment:");
let key_hex = crate::secrets::keychain::generate_master_key_hex();
// Initialize crypto so subsequent wizard steps (channel setup,
// API key storage) can encrypt secrets immediately.
self.secrets_crypto = Some(Arc::new(
SecretsCrypto::new(SecretString::from(key_hex.clone()))
.map_err(|e| SetupError::Config(e.to_string()))?,
));
// Make visible to optional_env() for any subsequent config resolution.
crate::config::inject_single_var("SECRETS_MASTER_KEY", &key_hex);
// Store hex for write_bootstrap_env to persist to ~/.ironclaw/.env.
self.settings.secrets_master_key_hex = Some(key_hex.clone());
println!();
print_info("Master key generated and will be saved to ~/.ironclaw/.env");
println!(" export SECRETS_MASTER_KEY={}", key_hex);
println!();
println!(" SECRETS_MASTER_KEY={}", key_hex);
println!();
print_info("You can also copy this to another .env file or CI secrets.");
print_info("Add this to your shell profile or .env file.");
self.settings.secrets_master_key_source = KeySource::Env;
print_success("Configured for environment variable");
@@ -1037,11 +1022,10 @@ impl SetupWizard {
/// Anthropic OAuth setup: extract token from `claude login` credentials.
async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some("anthropic") {
self.settings.llm_backend = Some("anthropic".to_string());
if self.settings.selected_model.is_some() {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some("anthropic".to_string());
// Try to extract existing OAuth token from Claude Code credentials
if let Some(token) = crate::config::ClaudeCodeConfig::extract_oauth_token() {
@@ -1135,11 +1119,10 @@ impl SetupWizard {
other => other,
});
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(backend) {
self.settings.llm_backend = Some(backend.to_string());
if self.settings.selected_model.is_some() {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some(backend.to_string());
// Check env var first
if let Ok(existing) = std::env::var(env_var) {
@@ -1198,11 +1181,10 @@ impl SetupWizard {
&mut self,
def: &crate::llm::ProviderDefinition,
) -> Result<(), SetupError> {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(&def.id) {
self.settings.llm_backend = Some(def.id.clone());
if self.settings.selected_model.is_some() {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some(def.id.clone());
let default_url = self
.settings
@@ -1237,11 +1219,10 @@ impl SetupWizard {
secret_name: &str,
display_name: &str,
) -> Result<(), SetupError> {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(backend_id) {
self.settings.llm_backend = Some(backend_id.to_string());
if self.settings.selected_model.is_some() {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some(backend_id.to_string());
let existing_url = self
.settings
@@ -1495,7 +1476,6 @@ impl SetupWizard {
smart_routing_cascade: true,
},
provider: None,
request_timeout_secs: 120,
};
match create_llm_provider(&config, session) {
@@ -2344,12 +2324,6 @@ impl SetupWizard {
env_vars.push(("NEARAI_API_KEY".to_string(), api_key));
}
// Secrets master key (env var mode): write to .env so it's available
// on next startup before the DB is connected.
if let Some(ref key_hex) = self.settings.secrets_master_key_hex {
env_vars.push(("SECRETS_MASTER_KEY".to_string(), key_hex.clone()));
}
// Always write ONBOARD_COMPLETED so that check_onboard_needed()
// (which runs before the DB is connected) knows to skip re-onboarding.
if self.settings.onboard_completed {
@@ -3525,48 +3499,6 @@ mod tests {
}
}
/// Regression test for #600: re-running provider setup for the same backend
/// must NOT clear selected_model. Only switching to a different backend should.
#[test]
fn test_same_provider_preserves_selected_model() {
let mut wizard = SetupWizard::new();
wizard.settings.llm_backend = Some("ollama".to_string());
wizard.settings.selected_model = Some("llama3".to_string());
// Simulate re-entering the same provider -- model should survive
// (This is the check that each setup_* function now performs)
if wizard.settings.llm_backend.as_deref() != Some("ollama") {
wizard.settings.selected_model = None;
}
wizard.settings.llm_backend = Some("ollama".to_string());
assert_eq!(
wizard.settings.selected_model.as_deref(),
Some("llama3"),
"model should be preserved when re-selecting the same provider"
);
}
/// Regression test for #600: switching to a different provider must clear
/// selected_model since the old model may not be valid for the new backend.
#[test]
fn test_different_provider_clears_selected_model() {
let mut wizard = SetupWizard::new();
wizard.settings.llm_backend = Some("ollama".to_string());
wizard.settings.selected_model = Some("llama3".to_string());
// Simulate switching to a different provider -- model should be cleared
if wizard.settings.llm_backend.as_deref() != Some("openai") {
wizard.settings.selected_model = None;
}
wizard.settings.llm_backend = Some("openai".to_string());
assert!(
wizard.settings.selected_model.is_none(),
"model should be cleared when switching providers"
);
}
#[tokio::test]
async fn test_run_provider_setup_no_setup_hint() {
// A provider with setup: None should not error. It should set the
@@ -3604,30 +3536,4 @@ mod tests {
"backend should be set even without setup hint"
);
}
/// Regression test for #666: env-var security option must initialize
/// secrets_crypto so subsequent steps can encrypt API keys.
#[test]
fn test_env_var_security_initializes_crypto() {
use crate::secrets::SecretsCrypto;
use secrecy::SecretString;
// Simulate what option 1 in step_security() does after the fix:
let key_hex = crate::secrets::keychain::generate_master_key_hex();
// The fix: create SecretsCrypto from the generated key.
// Before the fix, this was skipped, leaving secrets_crypto = None.
let crypto = SecretsCrypto::new(SecretString::from(key_hex.clone()));
assert!(
crypto.is_ok(),
"generated key hex must produce valid SecretsCrypto"
);
// Verify the key is stored for bootstrap env persistence.
let settings = Settings {
secrets_master_key_hex: Some(key_hex),
..Settings::default()
};
assert!(settings.secrets_master_key_hex.is_some());
}
}
-1
View File
@@ -1009,7 +1009,6 @@ mod tests {
enabled: true,
trigger: Trigger::Cron {
schedule: "0 * * * *".to_string(),
timezone: None,
},
action: RoutineAction::Lightweight {
prompt: "Check status".to_string(),
-110
View File
@@ -1,110 +0,0 @@
//! Timezone resolution and utilities.
use chrono::{DateTime, NaiveDate, Utc};
use chrono_tz::Tz;
/// Resolve the effective timezone from a priority chain.
///
/// Priority: client_tz > user_setting > config_default > UTC
pub fn resolve_timezone(
client_tz: Option<&str>,
user_setting: Option<&str>,
config_default: &str,
) -> Tz {
// Try each in priority order, skipping invalid values
for candidate in [client_tz, user_setting, Some(config_default)] {
if let Some(tz) = candidate.and_then(parse_timezone) {
return tz;
}
}
Tz::UTC
}
/// Parse a timezone string (IANA name) into a `Tz`.
pub fn parse_timezone(s: &str) -> Option<Tz> {
s.parse::<Tz>().ok()
}
/// Get today's date in the given timezone.
pub fn today_in_tz(tz: Tz) -> NaiveDate {
Utc::now().with_timezone(&tz).date_naive()
}
/// Get the current time in the given timezone.
pub fn now_in_tz(tz: Tz) -> DateTime<Tz> {
Utc::now().with_timezone(&tz)
}
/// Detect the system's timezone, falling back to UTC.
pub fn detect_system_timezone() -> Tz {
iana_time_zone::get_timezone()
.ok()
.and_then(|s| parse_timezone(&s))
.unwrap_or(Tz::UTC)
}
#[cfg(test)]
mod tests {
use chrono::Datelike;
use super::*;
#[test]
fn test_resolve_client_wins() {
let tz = resolve_timezone(Some("America/New_York"), Some("Europe/London"), "UTC");
assert_eq!(tz, chrono_tz::America::New_York);
}
#[test]
fn test_resolve_user_setting_fallback() {
let tz = resolve_timezone(None, Some("Europe/London"), "UTC");
assert_eq!(tz, chrono_tz::Europe::London);
}
#[test]
fn test_resolve_config_fallback() {
let tz = resolve_timezone(None, None, "Asia/Tokyo");
assert_eq!(tz, chrono_tz::Asia::Tokyo);
}
#[test]
fn test_resolve_all_none_utc() {
let tz = resolve_timezone(None, None, "UTC");
assert_eq!(tz, Tz::UTC);
}
#[test]
fn test_resolve_invalid_client_skipped() {
let tz = resolve_timezone(Some("Fake/Zone"), Some("Europe/London"), "UTC");
assert_eq!(tz, chrono_tz::Europe::London);
}
#[test]
fn test_parse_valid() {
assert_eq!(
parse_timezone("America/Chicago"),
Some(chrono_tz::America::Chicago)
);
}
#[test]
fn test_parse_invalid() {
assert_eq!(parse_timezone("Fake/Zone"), None);
}
#[test]
fn test_detect_system_tz() {
// Should always return a valid Tz (at minimum UTC)
let tz = detect_system_timezone();
let _ = now_in_tz(tz); // Should not panic
}
#[test]
fn test_today_in_tz_returns_valid_date() {
let date = today_in_tz(Tz::UTC);
// Verify it returns a valid date (year, month, day are all positive)
assert!(date.year() > 0);
assert!((1..=12).contains(&date.month()));
assert!((1..=31).contains(&date.day()));
}
}
+16 -4
View File
@@ -43,6 +43,7 @@ use crate::error::ToolError as AgentToolError;
use crate::llm::{
ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolDefinition,
};
use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry;
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
@@ -250,18 +251,29 @@ pub trait SoftwareBuilder: Send + Sync {
pub struct LlmSoftwareBuilder {
config: BuilderConfig,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
}
impl LlmSoftwareBuilder {
/// Create a new LLM-based software builder.
pub fn new(config: BuilderConfig, llm: Arc<dyn LlmProvider>, tools: Arc<ToolRegistry>) -> Self {
pub fn new(
config: BuilderConfig,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
) -> Self {
// Ensure build directory exists
if let Err(e) = std::fs::create_dir_all(&config.build_dir) {
tracing::warn!("Failed to create build directory: {}", e);
}
Self { config, llm, tools }
Self {
config,
llm,
safety,
tools,
}
}
/// Get the build tools available for the build loop.
@@ -509,7 +521,7 @@ Create alongside the .wasm file to grant capabilities:
let mut iteration = 0;
// Create reasoning engine
let reasoning = Reasoning::new(self.llm.clone());
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
// Build initial context
let tool_defs = self.get_build_tools().await;
@@ -810,7 +822,7 @@ Create alongside the .wasm file to grant capabilities:
impl SoftwareBuilder for LlmSoftwareBuilder {
async fn analyze(&self, description: &str) -> Result<BuildRequirement, AgentToolError> {
// Use LLM to parse the description
let reasoning = Reasoning::new(self.llm.clone());
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let prompt = format!(
r#"Analyze this software requirement and extract structured information.
+4 -5
View File
@@ -172,7 +172,7 @@ impl Tool for MemoryWriteTool {
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
@@ -239,12 +239,11 @@ impl Tool for MemoryWriteTool {
paths::MEMORY.to_string()
}
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
self.workspace
.append_daily_log_tz(content, tz)
.append_daily_log(content)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d"))
}
"heartbeat" => {
if append {
+48 -182
View File
@@ -105,47 +105,42 @@ impl Tool for MessageTool {
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let content = require_str(&params, "content")?;
// Get channel: use param → conversation default → job metadata → None (broadcast all)
let channel: Option<String> =
if let Some(c) = params.get("channel").and_then(|v| v.as_str()) {
Some(c.to_string())
} else if let Some(c) = self
.default_channel
// Get channel: use param or fall back to default
let channel = if let Some(c) = params.get("channel").and_then(|v| v.as_str()) {
c.to_string()
} else {
self.default_channel
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
Some(c)
} else {
ctx.metadata
.get("notify_channel")
.and_then(|v| v.as_str())
.map(|c| c.to_string())
};
.ok_or_else(|| {
ToolError::ExecutionFailed(
"No channel specified and no active conversation. Provide channel parameter."
.to_string(),
)
})?
};
// Get target: use param → conversation default → job metadata
// Get target: use param or fall back to default
let target = if let Some(t) = params.get("target").and_then(|v| v.as_str()) {
t.to_string()
} else if let Some(t) = self
.default_target
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
t
} else if let Some(t) = ctx.metadata.get("notify_user").and_then(|v| v.as_str()) {
t.to_string()
} else {
return Err(ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
.to_string(),
));
self.default_target
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
.ok_or_else(|| {
ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
.to_string(),
)
})?
};
let attachments: Vec<String> = match params.get("attachments") {
@@ -186,80 +181,37 @@ impl Tool for MessageTool {
response = response.with_attachments(attachments);
}
if let Some(ref channel) = channel {
// Send to a specific channel
match self
.channel_manager
.broadcast(channel, &target, response)
.await
{
Ok(()) => {
tracing::info!(
message_sent = true,
channel = %channel,
target = %target,
attachments = attachment_count,
"Message sent via message tool"
);
let msg = format!("Sent message to {}:{}", channel, target);
Ok(ToolOutput::text(msg, start.elapsed()))
}
Err(e) => {
let available = self.channel_manager.channel_names().await.join(", ");
let err_msg = if available.is_empty() {
format!(
"Failed to send to {}:{}: {}. No channels connected.",
channel, target, e
)
} else {
format!(
"Failed to send to {}:{}. Available channels: {}. Error: {}",
channel, target, available, e
)
};
Err(ToolError::ExecutionFailed(err_msg))
}
}
} else {
// No channel specified — broadcast to all channels (routine with notify.channel = None)
let results = self.channel_manager.broadcast_all(&target, response).await;
let mut succeeded = Vec::new();
let mut failed: Vec<&str> = Vec::new();
for (ch, result) in &results {
match result {
Ok(()) => succeeded.push(ch.as_str()),
Err(e) => {
tracing::warn!(
channel = %ch,
target = %target,
"broadcast_all: channel failed: {}", e
);
failed.push(ch.as_str());
}
}
}
if succeeded.is_empty() {
let err_msg = if failed.is_empty() {
"No channels connected.".to_string()
} else {
format!("All channels failed: {}", failed.join(", "))
};
Err(ToolError::ExecutionFailed(err_msg))
} else {
match self
.channel_manager
.broadcast(&channel, &target, response)
.await
{
Ok(()) => {
tracing::info!(
message_sent = true,
channels = ?succeeded,
channel = %channel,
target = %target,
attachments = attachment_count,
"Message broadcast via message tool"
);
let msg = format!(
"Broadcast message to {} (target: {})",
succeeded.join(", "),
target
"Message sent via message tool"
);
let msg = format!("Sent message to {}:{}", channel, target);
Ok(ToolOutput::text(msg, start.elapsed()))
}
Err(e) => {
let available = self.channel_manager.channel_names().await.join(", ");
let err_msg = if available.is_empty() {
format!(
"Failed to send to {}:{}: {}. No channels connected.",
channel, target, e
)
} else {
format!(
"Failed to send to {}:{}. Available channels: {}. Error: {}",
channel, target, available, e
)
};
Err(ToolError::ExecutionFailed(err_msg))
}
}
}
@@ -624,90 +576,4 @@ mod tests {
ApprovalRequirement::Never,
);
}
#[tokio::test]
async fn message_tool_falls_back_to_job_metadata() {
// Regression: when no conversation context is set (e.g. routine full-job),
// the message tool should fall back to notify_channel/notify_user from
// JobContext metadata instead of returning "No target specified".
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
let mut ctx = crate::context::JobContext::new("routine-job", "price alert");
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
"notify_user": "123456789",
});
// No set_context called — simulates a routine full-job worker
let result = tool
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
.await;
// Should fail at channel broadcast (no real channel), NOT at
// "No target specified and no active conversation"
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
!err.contains("No target specified"),
"Should not get 'No target specified' when metadata has notify_user, got: {}",
err
);
assert!(
!err.contains("No channel specified"),
"Should not get 'No channel specified' when metadata has notify_channel, got: {}",
err
);
}
#[tokio::test]
async fn message_tool_no_metadata_still_errors() {
// When neither conversation context nor metadata is set, should still
// return a clear error (target resolution fails).
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
let ctx = crate::context::JobContext::new("orphan-job", "no notify config");
let result = tool
.execute(serde_json::json!({"content": "hello"}), &ctx)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("No target specified"),
"Expected 'No target specified' error, got: {}",
err
);
}
#[tokio::test]
async fn message_tool_broadcasts_all_when_no_channel() {
// Regression: when notify.channel is None but notify_user is set,
// the message tool should attempt broadcast_all instead of erroring
// with "No channel specified".
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
let mut ctx = crate::context::JobContext::new("routine-job", "price alert");
ctx.metadata = serde_json::json!({
"notify_user": "123456789",
});
let result = tool
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
.await;
// Should fail because no channels are registered (empty ChannelManager),
// NOT because "No channel specified".
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
!err.contains("No channel specified"),
"Should not get 'No channel specified' when broadcasting, got: {}",
err
);
assert!(
err.contains("No channels connected") || err.contains("All channels failed"),
"Expected channel delivery error, got: {}",
err
);
}
}
+2 -2
View File
@@ -32,8 +32,8 @@ pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTo
pub use message::MessageTool;
pub use restart::RestartTool;
pub use routine::{
RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool, RoutineListTool,
RoutineUpdateTool,
EventEmitTool, RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool,
RoutineListTool, RoutineUpdateTool,
};
pub use secrets_tools::{SecretDeleteTool, SecretListTool};
pub use shell::ShellTool;
+152 -71
View File
@@ -1,12 +1,13 @@
//! LLM-facing tools for managing routines.
//!
//! Six tools let the agent manage routines conversationally:
//! Seven tools let the agent manage routines conversationally:
//! - `routine_create` - Create a new routine
//! - `routine_list` - List all routines with status
//! - `routine_update` - Modify or toggle a routine
//! - `routine_delete` - Remove a routine
//! - `routine_fire` - Manually trigger a routine
//! - `routine_history` - View past runs
//! - `event_emit` - Emit a structured event to event-driven routines
use std::sync::Arc;
use std::time::Duration;
@@ -62,7 +63,7 @@ impl Tool for RoutineCreateTool {
},
"trigger_type": {
"type": "string",
"enum": ["cron", "event", "webhook", "manual"],
"enum": ["cron", "event", "system_event", "webhook", "manual"],
"description": "When the routine fires"
},
"schedule": {
@@ -77,6 +78,19 @@ impl Tool for RoutineCreateTool {
"type": "string",
"description": "Optional channel filter for event trigger (e.g. 'telegram')"
},
"event_source": {
"type": "string",
"description": "Event source for system_event triggers (e.g. 'github')"
},
"event_type": {
"type": "string",
"description": "Event type for system_event triggers (e.g. 'issue.opened')"
},
"event_filters": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Optional exact-match filters against payload fields for system_event triggers"
},
"prompt": {
"type": "string",
"description": "The prompt/instructions for the routine"
@@ -107,10 +121,6 @@ impl Tool for RoutineCreateTool {
"notify_user": {
"type": "string",
"description": "User/target to notify (e.g. username, chat ID). Defaults to 'default'."
},
"timezone": {
"type": "string",
"description": "IANA timezone for cron schedule evaluation (e.g. 'America/New_York'). Defaults to UTC."
}
},
"required": ["name", "trigger_type", "prompt"]
@@ -147,26 +157,12 @@ impl Tool for RoutineCreateTool {
"cron trigger requires 'schedule'".to_string(),
)
})?;
let timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(|tz| {
crate::timezone::parse_timezone(tz)
.map(|_| tz.to_string())
.ok_or_else(|| {
ToolError::InvalidParameters(format!(
"invalid IANA timezone: '{tz}'"
))
})
})
.transpose()?;
// Validate cron expression
next_cron_fire(schedule, timezone.as_deref()).map_err(|e| {
next_cron_fire(schedule).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?;
Trigger::Cron {
schedule: schedule.to_string(),
timezone,
}
}
"event" => {
@@ -190,6 +186,38 @@ impl Tool for RoutineCreateTool {
pattern: pattern.to_string(),
}
}
"system_event" => {
let source = params
.get("event_source")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters(
"system_event trigger requires 'event_source'".to_string(),
)
})?;
let event_type = params
.get("event_type")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters(
"system_event trigger requires 'event_type'".to_string(),
)
})?;
let filters = params
.get("event_filters")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string())))
.collect::<std::collections::HashMap<String, String>>()
})
.unwrap_or_default();
Trigger::SystemEvent {
source: source.to_string(),
event_type: event_type.to_string(),
filters,
}
}
"webhook" => Trigger::Webhook {
path: None,
secret: None,
@@ -246,12 +274,8 @@ impl Tool for RoutineCreateTool {
.unwrap_or(300);
// Compute next fire time for cron
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
let next_fire = if let Trigger::Cron { ref schedule } = trigger {
next_cron_fire(schedule).unwrap_or(None)
} else {
None
};
@@ -296,7 +320,10 @@ impl Tool for RoutineCreateTool {
.map_err(|e| ToolError::ExecutionFailed(format!("failed to create routine: {e}")))?;
// Refresh event cache if this is an event trigger
if routine.trigger.type_tag() == "event" {
if matches!(
routine.trigger,
Trigger::Event { .. } | Trigger::SystemEvent { .. }
) {
self.engine.refresh_event_cache().await;
}
@@ -434,10 +461,6 @@ impl Tool for RoutineUpdateTool {
"type": "string",
"description": "New cron schedule (for cron triggers)"
},
"timezone": {
"type": "string",
"description": "IANA timezone for cron schedule (e.g. 'America/New_York'). Only valid for cron triggers."
},
"description": {
"type": "string",
"description": "New description"
@@ -479,47 +502,15 @@ impl Tool for RoutineUpdateTool {
}
}
// Validate timezone param if provided
let new_timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(|tz| {
crate::timezone::parse_timezone(tz)
.map(|_| tz.to_string())
.ok_or_else(|| {
ToolError::InvalidParameters(format!("invalid IANA timezone: '{tz}'"))
})
})
.transpose()?;
if let Some(schedule) = params.get("schedule").and_then(|v| v.as_str()) {
// Validate
next_cron_fire(schedule)
.map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?;
let new_schedule = params.get("schedule").and_then(|v| v.as_str());
if new_schedule.is_some() || new_timezone.is_some() {
// Extract existing cron fields (cloned to avoid borrow conflict)
let existing_cron = match &routine.trigger {
Trigger::Cron { schedule, timezone } => Some((schedule.clone(), timezone.clone())),
_ => None,
routine.trigger = Trigger::Cron {
schedule: schedule.to_string(),
};
if let Some((old_schedule, old_tz)) = existing_cron {
let effective_schedule = new_schedule.unwrap_or(&old_schedule);
let effective_tz = new_timezone.or(old_tz);
// Validate
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?;
routine.trigger = Trigger::Cron {
schedule: effective_schedule.to_string(),
timezone: effective_tz.clone(),
};
routine.next_fire_at =
next_cron_fire(effective_schedule, effective_tz.as_deref()).unwrap_or(None);
} else {
return Err(ToolError::InvalidParameters(
"Cannot update schedule or timezone on a non-cron routine.".to_string(),
));
}
routine.next_fire_at = next_cron_fire(schedule).unwrap_or(None);
}
self.store
@@ -706,6 +697,96 @@ pub struct RoutineHistoryTool {
store: Arc<dyn Database>,
}
// ==================== event_emit ====================
pub struct EventEmitTool {
engine: Arc<RoutineEngine>,
}
impl EventEmitTool {
pub fn new(engine: Arc<RoutineEngine>) -> Self {
Self { engine }
}
}
#[async_trait]
impl Tool for EventEmitTool {
fn name(&self) -> &str {
"event_emit"
}
fn description(&self) -> &str {
"Emit a structured event to event-driven routines. \
Use this to trigger routines from tool workflows without waiting for cron."
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Never
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"source": {
"type": "string",
"description": "Event source (e.g. 'github', 'workflow', 'tool')"
},
"event_type": {
"type": "string",
"description": "Event type (e.g. 'issue.opened', 'pr.ready')"
},
"payload": {
"type": "object",
"description": "Structured event payload"
},
"user_id": {
"type": "string",
"description": "Optional target user id; defaults to current user"
}
},
"required": ["source", "event_type"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let source = require_str(&params, "source")?;
let event_type = require_str(&params, "event_type")?;
let payload = params
.get("payload")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let user_id = params
.get("user_id")
.and_then(|v| v.as_str())
.unwrap_or(&ctx.user_id);
let fired = self
.engine
.emit_system_event(source, event_type, &payload, Some(user_id))
.await;
let result = serde_json::json!({
"source": source,
"event_type": event_type,
"target_user_id": user_id,
"fired_routines": fired,
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
impl RoutineHistoryTool {
pub fn new(store: Arc<dyn Database>) -> Self {
Self { store }
+2 -46
View File
@@ -48,7 +48,7 @@ impl Tool for TimeTool {
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
@@ -57,15 +57,10 @@ impl Tool for TimeTool {
let result = match operation {
"now" => {
let now = Utc::now();
let tz =
crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::UTC);
let local = now.with_timezone(&tz);
serde_json::json!({
"iso": now.to_rfc3339(),
"unix": now.timestamp(),
"unix_millis": now.timestamp_millis(),
"local_iso": local.to_rfc3339(),
"timezone": tz.name()
"unix_millis": now.timestamp_millis()
})
}
"parse" => {
@@ -117,42 +112,3 @@ impl Tool for TimeTool {
false // Internal tool, no external data
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_now_includes_local_time_when_timezone_set() {
let tool = TimeTool;
let mut ctx = JobContext::with_user("test", "chat", "test");
ctx.user_timezone = "America/New_York".to_string();
let output = tool
.execute(serde_json::json!({"operation": "now"}), &ctx)
.await
.expect("execute");
assert!(
output.result.get("local_iso").is_some(),
"should have local_iso"
);
assert_eq!(
output.result["timezone"].as_str(),
Some("America/New_York"),
"should report timezone"
);
}
#[tokio::test]
async fn test_now_includes_utc_timezone_by_default() {
let tool = TimeTool;
let ctx = JobContext::with_user("test", "chat", "test");
// Default user_timezone is "UTC" which is a valid IANA timezone
let output = tool
.execute(serde_json::json!({"operation": "now"}), &ctx)
.await
.expect("execute");
assert!(output.result.get("iso").is_some(), "should have iso");
assert_eq!(output.result["timezone"].as_str(), Some("UTC"));
}
}
+2 -123
View File
@@ -261,9 +261,9 @@ impl McpClient {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let preview = sanitize_error_body(&body);
return Err(ToolError::ExternalService(format!(
"MCP server returned status: {status} - {preview}",
"MCP server returned status: {} - {}",
status, body
)));
}
@@ -548,58 +548,6 @@ impl Tool for McpToolWrapper {
}
}
/// Sanitize an HTTP error response body for safe display.
///
/// Detects full HTML error pages (containing `<html` or `<!DOCTYPE`) and
/// strips all tags, collapsing whitespace. Non-HTML bodies are left
/// intact. In both cases the result is truncated to 200 *characters*
/// (char-boundary safe) so that large payloads don't bloat error messages.
///
/// See #263 — raw HTML error pages were propagating through the error
/// chain into the web UI, causing a white screen.
fn sanitize_error_body(body: &str) -> String {
const MAX_CHARS: usize = 200;
// Only strip tags when the body looks like a full HTML document.
// Plain text that happens to contain `<` / `>` (e.g. log lines,
// comparison expressions) is left untouched.
let lower = body.to_ascii_lowercase();
let is_html_document = lower.contains("<html") || lower.contains("<!doctype");
let text = if is_html_document {
let stripped = body
.chars()
.fold((String::new(), false), |(mut out, in_tag), c| {
if c == '<' {
(out, true)
} else if c == '>' {
(out, false)
} else if !in_tag {
out.push(c);
(out, false)
} else {
(out, true)
}
})
.0;
stripped.split_whitespace().collect::<Vec<_>>().join(" ")
} else {
body.to_string()
};
// Truncate at a char boundary (safe for multi-byte UTF-8).
if text.chars().count() > MAX_CHARS {
let byte_offset = text
.char_indices()
.nth(MAX_CHARS)
.map(|(i, _)| i)
.unwrap_or(text.len());
format!("{}... ({} bytes total)", &text[..byte_offset], body.len())
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -792,73 +740,4 @@ mod tests {
};
assert!(!tool.requires_approval());
}
// Regression tests for #263: HTML error bodies must not propagate raw
// markup through the error chain into the web UI.
#[test]
fn test_sanitize_error_body_strips_html_tags() {
let html =
r#"<!DOCTYPE html><html><body><h1>422 Error</h1><p>Invalid token</p></body></html>"#;
let result = sanitize_error_body(html);
assert!(!result.contains('<'), "HTML tags must be stripped");
assert!(!result.contains('>'), "HTML tags must be stripped");
assert!(result.contains("422 Error"));
assert!(result.contains("Invalid token"));
}
#[test]
fn test_sanitize_error_body_truncates_large_html_page() {
let html = format!(
"<html><body><p>{}</p></body></html>",
"error detail ".repeat(50)
);
let result = sanitize_error_body(&html);
assert!(result.contains("..."));
assert!(result.contains("bytes total)"));
assert!(!result.contains('<'));
}
#[test]
fn test_sanitize_error_body_passes_short_plain_text() {
assert_eq!(sanitize_error_body("Not Found"), "Not Found");
}
#[test]
fn test_sanitize_error_body_truncates_long_plain_text() {
let long = "x".repeat(300);
let result = sanitize_error_body(&long);
assert!(result.contains("..."));
assert!(result.contains("300 bytes total)"));
}
#[test]
fn test_sanitize_error_body_multibyte_no_panic() {
// 300 CJK characters = 900 bytes; truncation must land on a
// char boundary, not in the middle of a multi-byte sequence.
let cjk = "错误".repeat(150);
let result = sanitize_error_body(&cjk);
assert!(result.contains("..."));
// Must be valid UTF-8 (would have panicked otherwise).
assert!(result.is_char_boundary(result.len()));
}
#[test]
fn test_sanitize_error_body_strips_uppercase_html() {
let html = "<HTML><BODY><H1>500 Internal Server Error</H1></BODY></HTML>";
let result = sanitize_error_body(html);
assert!(
!result.contains('<'),
"uppercase HTML tags must be stripped"
);
assert!(result.contains("500 Internal Server Error"));
}
#[test]
fn test_sanitize_error_body_preserves_angle_brackets_in_non_html() {
// Text with < and > that is NOT an HTML document should be
// left untouched (e.g. log lines, comparison expressions).
let text = "value < 10 and value > 0";
assert_eq!(sanitize_error_body(text), text);
}
}
+9 -4
View File
@@ -10,6 +10,7 @@ use crate::db::Database;
use crate::extensions::ExtensionManager;
use crate::llm::{LlmProvider, ToolDefinition};
use crate::orchestrator::job_manager::ContainerJobManager;
use crate::safety::SafetyLayer;
use crate::secrets::SecretsStore;
use crate::skills::catalog::SkillCatalog;
use crate::skills::registry::SkillRegistry;
@@ -64,6 +65,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
"routine_delete",
"routine_fire",
"routine_history",
"event_emit",
"skill_list",
"skill_search",
"skill_install",
@@ -424,8 +426,8 @@ impl ToolRegistry {
engine: Arc<crate::agent::routine_engine::RoutineEngine>,
) {
use crate::tools::builtin::{
RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool,
RoutineListTool, RoutineUpdateTool,
EventEmitTool, RoutineCreateTool, RoutineDeleteTool, RoutineFireTool,
RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
};
self.register_sync(Arc::new(RoutineCreateTool::new(
Arc::clone(&store),
@@ -445,7 +447,8 @@ impl ToolRegistry {
Arc::clone(&engine),
)));
self.register_sync(Arc::new(RoutineHistoryTool::new(store)));
tracing::info!("Registered 6 routine management tools");
self.register_sync(Arc::new(EventEmitTool::new(engine)));
tracing::info!("Registered 7 routine management tools");
}
/// Register message tool for sending messages to channels.
@@ -484,15 +487,17 @@ impl ToolRegistry {
pub async fn register_builder_tool(
self: &Arc<Self>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
config: Option<BuilderConfig>,
) {
// First register dev tools needed by the builder
self.register_dev_tools();
// Create the builder (arg order: config, llm, tools)
// Create the builder (arg order: config, llm, safety, tools)
let builder = Arc::new(LlmSoftwareBuilder::new(
config.unwrap_or_default(),
llm,
safety,
Arc::clone(self),
));
+21 -1
View File
@@ -565,12 +565,19 @@ mod tests {
"description": { "type": "string", "description": "What it does" },
"trigger_type": {
"type": "string",
"enum": ["cron", "event", "webhook", "manual"],
"enum": ["cron", "event", "system_event", "webhook", "manual"],
"description": "When the routine fires"
},
"schedule": { "type": "string", "description": "Cron expression" },
"event_pattern": { "type": "string", "description": "Regex pattern" },
"event_channel": { "type": "string", "description": "Channel filter" },
"event_source": { "type": "string", "description": "System event source" },
"event_type": { "type": "string", "description": "System event type" },
"event_filters": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Exact-match payload filters"
},
"prompt": { "type": "string", "description": "Instructions" },
"context_paths": {
"type": "array",
@@ -647,6 +654,19 @@ mod tests {
"required": ["name"]
}),
),
(
"event_emit",
serde_json::json!({
"type": "object",
"properties": {
"source": { "type": "string", "description": "Event source" },
"event_type": { "type": "string", "description": "Event type" },
"payload": { "type": "object", "description": "Event payload" },
"user_id": { "type": "string", "description": "Optional target user id" }
},
"required": ["source", "event_type"]
}),
),
// Job tools with complex deps
(
"job_events",
+1 -1
View File
@@ -133,7 +133,7 @@ impl WorkerRuntime {
.await?;
// Create reasoning engine
let reasoning = Reasoning::new(self.llm.clone());
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
// Build initial context
let mut reason_ctx = ReasoningContext::new().with_job(&job.description);
+116
View File
@@ -113,6 +113,79 @@ pub fn chunk_document(content: &str, config: ChunkConfig) -> Vec<String> {
chunks
}
/// Split content by paragraphs first, then chunk.
///
/// This is better for preserving semantic boundaries.
#[allow(dead_code)] // Alternative chunking strategy for paragraph-aware indexing
pub fn chunk_by_paragraphs(content: &str, config: ChunkConfig) -> Vec<String> {
if content.is_empty() {
return Vec::new();
}
// Split by double newlines (paragraphs)
let paragraphs: Vec<&str> = content
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if paragraphs.is_empty() {
return chunk_document(content, config);
}
let mut chunks = Vec::new();
let mut current_chunk = String::new();
let mut current_word_count = 0;
for paragraph in paragraphs {
let para_words = paragraph.split_whitespace().count();
// If this paragraph alone exceeds chunk size, chunk it separately
if para_words > config.chunk_size {
// Flush current chunk first
if !current_chunk.is_empty() {
chunks.push(current_chunk.trim().to_string());
current_chunk = String::new();
current_word_count = 0;
}
// Chunk the large paragraph
let para_chunks = chunk_document(paragraph, config.clone());
chunks.extend(para_chunks);
continue;
}
// Check if adding this paragraph would exceed chunk size
if current_word_count + para_words > config.chunk_size {
// Flush current chunk
if !current_chunk.is_empty() {
chunks.push(current_chunk.trim().to_string());
}
current_chunk = paragraph.to_string();
current_word_count = para_words;
} else {
// Add paragraph to current chunk
if !current_chunk.is_empty() {
current_chunk.push_str("\n\n");
}
current_chunk.push_str(paragraph);
current_word_count += para_words;
}
}
// Flush remaining content
if !current_chunk.is_empty() {
// If too small, merge with previous chunk if possible
if current_word_count < config.min_chunk_size && !chunks.is_empty() {
let last = chunks.pop().unwrap();
chunks.push(format!("{}\n\n{}", last, current_chunk.trim()));
} else {
chunks.push(current_chunk.trim().to_string());
}
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
@@ -180,6 +253,49 @@ mod tests {
assert_eq!(config.step_size(), 85);
}
#[test]
fn test_paragraph_chunking() {
let config = ChunkConfig::default().with_chunk_size(20);
let content = "First paragraph with some words.\n\nSecond paragraph with different content.\n\nThird paragraph here.";
let chunks = chunk_by_paragraphs(content, config);
// Should preserve paragraph boundaries
assert!(!chunks.is_empty());
for chunk in &chunks {
// No chunk should start or end with \n\n
assert!(!chunk.starts_with("\n"));
assert!(!chunk.ends_with("\n"));
}
}
#[test]
fn test_large_paragraph_handling() {
let config = ChunkConfig {
chunk_size: 10,
overlap_percent: 0.15,
min_chunk_size: 3, // Low threshold for test
};
// Create a paragraph with 30 words
let large_para = (1..=30)
.map(|i| format!("word{}", i))
.collect::<Vec<_>>()
.join(" ");
let content = format!("Short intro.\n\n{}\n\nShort outro.", large_para);
let chunks = chunk_by_paragraphs(&content, config);
// Should have multiple chunks due to large paragraph
// 30 words + 2 intro + 2 outro = 34 words, chunk_size=10
// Expect at least 3 chunks
assert!(
chunks.len() >= 3,
"Expected at least 3 chunks for 34 words with chunk_size=10, got {}",
chunks.len()
);
}
#[test]
fn test_min_chunk_size_merging() {
let config = ChunkConfig {
-644
View File
@@ -1,644 +0,0 @@
//! LanceDB-backed vector store for workspace memory chunks.
//!
//! Provides an alternative to pgvector/libsql for semantic search when the
//! `lancedb` feature is enabled. Documents and metadata stay in the main
//! database; this store holds chunk embeddings for vector similarity search.
//!
//! Configuration:
//! LANCEDB_PATH=~/.ironclaw/lancedb # Default
//! VECTOR_BACKEND=lancedb # Use LanceDB for vector search
/// Default embedding dimension (text-embedding-3-small).
/// Override by passing the actual provider dimension to `LanceDbVectorStore::new()`.
pub const DEFAULT_EMBEDDING_DIM: i32 = 1536;
#[cfg(feature = "lancedb")]
mod impl_lancedb {
use std::sync::Arc;
use arrow_array::types::Float32Type;
use arrow_array::{Array, FixedSizeListArray, RecordBatch, RecordBatchIterator, StringArray};
use arrow_schema::{DataType, Field, Schema};
use async_trait::async_trait;
use futures::StreamExt;
use lancedb::query::{ExecutableQuery, QueryBase};
use uuid::Uuid;
use super::DEFAULT_EMBEDDING_DIM;
use crate::error::WorkspaceError;
use crate::workspace::search::RankedResult;
use crate::workspace::vector_store::VectorStore;
const TABLE_NAME: &str = "memory_chunks";
/// Escapes a string for safe use in LanceDB predicate expressions.
/// Uses SQL-style escaping: single quotes are doubled to prevent injection.
fn escape_predicate_value(s: &str) -> String {
s.replace('\'', "''")
}
/// LanceDB-backed vector store.
///
/// The `update_embedding` method uses delete-then-insert (not atomic).
/// LanceDB does not support transactions, so a crash between the two
/// operations can lose the embedding for that chunk. This is acceptable
/// for personal workspace sizes where data can be reindexed.
pub struct LanceDbVectorStore {
db: Arc<lancedb::Connection>,
table_name: String,
embedding_dim: i32,
schema: Arc<Schema>,
table: tokio::sync::OnceCell<lancedb::Table>,
}
impl LanceDbVectorStore {
/// Create a new LanceDB store at the given path.
///
/// `embedding_dim` should match `EmbeddingProvider::dimension()`.
/// Pass `None` to use the default (1536, text-embedding-3-small).
pub async fn new(
path: impl AsRef<std::path::Path>,
embedding_dim: Option<usize>,
) -> Result<Self, WorkspaceError> {
let path_str = path
.as_ref()
.to_str()
.ok_or_else(|| WorkspaceError::SearchFailed {
reason: "Invalid LanceDB path".to_string(),
})?;
let db = lancedb::connect(path_str).execute().await.map_err(|e| {
WorkspaceError::SearchFailed {
reason: format!("Failed to connect to LanceDB: {}", e),
}
})?;
let dim = embedding_dim.unwrap_or(DEFAULT_EMBEDDING_DIM as usize) as i32;
let schema = Arc::new(Self::build_schema(dim));
let store = Self {
db: Arc::new(db),
table_name: TABLE_NAME.to_string(),
embedding_dim: dim,
schema,
table: tokio::sync::OnceCell::new(),
};
store.ensure_table().await?;
Ok(store)
}
async fn ensure_table(&self) -> Result<(), WorkspaceError> {
let tables = self.db.table_names().execute().await.map_err(|e| {
WorkspaceError::SearchFailed {
reason: format!("Failed to list tables: {}", e),
}
})?;
if tables.iter().any(|t| t == &self.table_name) {
return Ok(());
}
self.db
.create_empty_table(&self.table_name, self.schema.clone())
.execute()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to create table: {}", e),
})?;
// Index creation is deferred — brute-force search via
// bypass_vector_index() works without a pre-built index and is
// sufficient for personal workspace sizes.
Ok(())
}
/// Get or open the cached table handle.
async fn table(&self) -> Result<&lancedb::Table, WorkspaceError> {
self.table
.get_or_try_init(|| async {
self.db
.open_table(&self.table_name)
.execute()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to open table: {}", e),
})
})
.await
}
fn build_schema(embedding_dim: i32) -> Schema {
Schema::new(vec![
Field::new("chunk_id", DataType::Utf8, false),
Field::new("document_id", DataType::Utf8, false),
Field::new("document_path", DataType::Utf8, false),
Field::new("user_id", DataType::Utf8, false),
Field::new("agent_id", DataType::Utf8, true),
Field::new("content", DataType::Utf8, false),
Field::new(
"vector",
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::Float32, true)),
embedding_dim,
),
false,
),
])
}
}
#[async_trait]
impl VectorStore for LanceDbVectorStore {
async fn store_embedding(
&self,
chunk_id: Uuid,
document_id: Uuid,
document_path: &str,
user_id: &str,
agent_id: Option<Uuid>,
content: &str,
embedding: &[f32],
) -> Result<(), WorkspaceError> {
if embedding.len() != self.embedding_dim as usize {
return Err(WorkspaceError::EmbeddingFailed {
reason: format!(
"Embedding dimension {} does not match expected {}",
embedding.len(),
self.embedding_dim
),
});
}
let table = self.table().await?;
let chunk_ids = StringArray::from(vec![chunk_id.to_string()]);
let document_ids = StringArray::from(vec![document_id.to_string()]);
let document_paths = StringArray::from(vec![document_path]);
let user_ids = StringArray::from(vec![user_id]);
let agent_ids = StringArray::from(vec![agent_id.map(|a| a.to_string())]);
let contents = StringArray::from(vec![content]);
let vec_values: Vec<Option<f32>> = embedding.iter().map(|&x| Some(x)).collect();
let vectors = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
vec![Some(vec_values)],
self.embedding_dim,
);
let batch = RecordBatch::try_new(
self.schema.clone(),
vec![
Arc::new(chunk_ids),
Arc::new(document_ids),
Arc::new(document_paths),
Arc::new(user_ids),
Arc::new(agent_ids),
Arc::new(contents),
Arc::new(vectors),
],
)
.map_err(|e| WorkspaceError::EmbeddingFailed {
reason: format!("Failed to create record batch: {}", e),
})?;
let batches =
RecordBatchIterator::new(vec![Ok(batch)].into_iter(), self.schema.clone());
table
.add(Box::new(batches) as Box<dyn arrow_array::RecordBatchReader + Send>)
.execute()
.await
.map_err(|e| WorkspaceError::EmbeddingFailed {
reason: format!("Failed to store embedding: {}", e),
})?;
Ok(())
}
async fn update_embedding(
&self,
chunk_id: Uuid,
document_id: Uuid,
document_path: &str,
user_id: &str,
agent_id: Option<Uuid>,
content: &str,
embedding: &[f32],
) -> Result<(), WorkspaceError> {
let table = self.table().await?;
table
.delete(&format!(
"chunk_id = '{}'",
escape_predicate_value(&chunk_id.to_string())
))
.await
.map_err(|e| WorkspaceError::EmbeddingFailed {
reason: format!("Failed to delete chunk for update: {}", e),
})?;
self.store_embedding(
chunk_id,
document_id,
document_path,
user_id,
agent_id,
content,
embedding,
)
.await
}
async fn delete_embeddings(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
let table = self.table().await?;
table
.delete(&format!(
"document_id = '{}'",
escape_predicate_value(&document_id.to_string())
))
.await
.map_err(|e| WorkspaceError::EmbeddingFailed {
reason: format!("Failed to delete embeddings: {}", e),
})?;
Ok(())
}
async fn vector_search(
&self,
user_id: &str,
agent_id: Option<Uuid>,
embedding: &[f32],
limit: usize,
) -> Result<Vec<RankedResult>, WorkspaceError> {
let table = self.table().await?;
let filter = if let Some(aid) = agent_id {
format!(
"user_id = '{}' AND agent_id = '{}'",
escape_predicate_value(user_id),
escape_predicate_value(&aid.to_string())
)
} else {
format!(
"user_id = '{}' AND agent_id IS NULL",
escape_predicate_value(user_id)
)
};
let query = table
.query()
.nearest_to(embedding)
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Invalid query vector: {}", e),
})?
.only_if(&filter)
.bypass_vector_index()
.limit(limit);
let mut stream = ExecutableQuery::execute(&query).await.map_err(|e| {
WorkspaceError::SearchFailed {
reason: format!("Vector search failed: {}", e),
}
})?;
let mut results = Vec::new();
let mut rank: u32 = 1;
while let Some(batch_result) = stream.next().await {
let batch = batch_result.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Stream error: {}", e),
})?;
let chunk_id_col = batch.column_by_name("chunk_id").ok_or_else(|| {
WorkspaceError::SearchFailed {
reason: "chunk_id column missing".to_string(),
}
})?;
let document_id_col = batch.column_by_name("document_id").ok_or_else(|| {
WorkspaceError::SearchFailed {
reason: "document_id column missing".to_string(),
}
})?;
let document_path_col = batch.column_by_name("document_path").ok_or_else(|| {
WorkspaceError::SearchFailed {
reason: "document_path column missing".to_string(),
}
})?;
let content_col = batch.column_by_name("content").ok_or_else(|| {
WorkspaceError::SearchFailed {
reason: "content column missing".to_string(),
}
})?;
let chunk_ids = chunk_id_col
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| WorkspaceError::SearchFailed {
reason: "chunk_id wrong type".to_string(),
})?;
let document_ids = document_id_col
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| WorkspaceError::SearchFailed {
reason: "document_id wrong type".to_string(),
})?;
let document_paths = document_path_col
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| WorkspaceError::SearchFailed {
reason: "document_path wrong type".to_string(),
})?;
let contents = content_col
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| WorkspaceError::SearchFailed {
reason: "content wrong type".to_string(),
})?;
for i in 0..batch.num_rows() {
let raw_chunk_id = chunk_ids.value(i);
let chunk_id =
raw_chunk_id
.parse::<Uuid>()
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Invalid chunk_id UUID '{}': {}", raw_chunk_id, e),
})?;
let raw_document_id = document_ids.value(i);
let document_id = raw_document_id.parse::<Uuid>().map_err(|e| {
WorkspaceError::SearchFailed {
reason: format!(
"Invalid document_id UUID '{}': {}",
raw_document_id, e
),
}
})?;
let document_path = document_paths.value(i).to_string();
let content = contents.value(i).to_string();
results.push(RankedResult {
chunk_id,
document_id,
document_path,
content,
rank,
});
rank += 1;
}
}
Ok(results)
}
}
}
#[cfg(feature = "lancedb")]
pub use impl_lancedb::LanceDbVectorStore;
#[cfg(all(test, feature = "lancedb"))]
mod tests {
use tempfile::TempDir;
use uuid::Uuid;
use super::{DEFAULT_EMBEDDING_DIM, LanceDbVectorStore};
use crate::workspace::vector_store::VectorStore;
fn make_embedding(seed: f32) -> Vec<f32> {
(0..DEFAULT_EMBEDDING_DIM as usize)
.map(|i| (seed * (i as f32 + 1.0)).sin())
.collect()
}
#[tokio::test]
async fn test_insert_and_vector_search() {
let dir = TempDir::new().unwrap();
let store = LanceDbVectorStore::new(dir.path(), None).await.unwrap();
let chunk_id = Uuid::new_v4();
let document_id = Uuid::new_v4();
let user_id = "user1";
let content = "Rust is a systems programming language";
let embedding = make_embedding(1.0);
store
.store_embedding(
chunk_id,
document_id,
"test.md",
user_id,
None,
content,
&embedding,
)
.await
.unwrap();
let results = store
.vector_search(user_id, None, &embedding, 5)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].chunk_id, chunk_id);
assert_eq!(results[0].document_id, document_id);
assert_eq!(results[0].content, content);
assert_eq!(results[0].rank, 1);
}
#[tokio::test]
async fn test_insert_multiple_and_search_returns_ordered() {
let dir = TempDir::new().unwrap();
let store = LanceDbVectorStore::new(dir.path(), None).await.unwrap();
let doc_id = Uuid::new_v4();
let user_id = "user1";
for (i, seed) in [1.0, 2.0, 3.0].iter().enumerate() {
store
.store_embedding(
Uuid::new_v4(),
doc_id,
"test.md",
user_id,
None,
&format!("content {}", i),
&make_embedding(*seed),
)
.await
.unwrap();
}
let query_emb = make_embedding(2.0);
let results = store
.vector_search(user_id, None, &query_emb, 5)
.await
.unwrap();
assert_eq!(results.len(), 3);
let contents: Vec<_> = results.iter().map(|r| r.content.as_str()).collect();
assert!(contents.contains(&"content 0"));
assert!(contents.contains(&"content 1"));
assert!(contents.contains(&"content 2"));
}
#[tokio::test]
async fn test_delete_chunks() {
let dir = TempDir::new().unwrap();
let store = LanceDbVectorStore::new(dir.path(), None).await.unwrap();
let doc_id = Uuid::new_v4();
let user_id = "user1";
store
.store_embedding(
Uuid::new_v4(),
doc_id,
"test.md",
user_id,
None,
"content",
&make_embedding(1.0),
)
.await
.unwrap();
let results = store
.vector_search(user_id, None, &make_embedding(1.0), 5)
.await
.unwrap();
assert_eq!(results.len(), 1);
store.delete_embeddings(doc_id).await.unwrap();
let results_after = store
.vector_search(user_id, None, &make_embedding(1.0), 5)
.await
.unwrap();
assert!(results_after.is_empty());
}
#[tokio::test]
async fn test_update_chunk_embedding() {
let dir = TempDir::new().unwrap();
let store = LanceDbVectorStore::new(dir.path(), None).await.unwrap();
let chunk_id = Uuid::new_v4();
let doc_id = Uuid::new_v4();
let user_id = "user1";
let content = "original content";
store
.store_embedding(
chunk_id,
doc_id,
"test.md",
user_id,
None,
content,
&make_embedding(1.0),
)
.await
.unwrap();
let new_embedding = make_embedding(5.0);
store
.update_embedding(
chunk_id,
doc_id,
"test.md",
user_id,
None,
content,
&new_embedding,
)
.await
.unwrap();
let results = store
.vector_search(user_id, None, &new_embedding, 5)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].chunk_id, chunk_id);
}
#[tokio::test]
async fn test_vector_search_filters_by_user_and_agent() {
let dir = TempDir::new().unwrap();
let store = LanceDbVectorStore::new(dir.path(), None).await.unwrap();
let doc_id = Uuid::new_v4();
let embedding = make_embedding(1.0);
store
.store_embedding(
Uuid::new_v4(),
doc_id,
"test.md",
"user1",
None,
"user1 content",
&embedding,
)
.await
.unwrap();
store
.store_embedding(
Uuid::new_v4(),
doc_id,
"test.md",
"user2",
None,
"user2 content",
&embedding,
)
.await
.unwrap();
let results_user1 = store
.vector_search("user1", None, &embedding, 5)
.await
.unwrap();
assert_eq!(results_user1.len(), 1);
assert_eq!(results_user1[0].content, "user1 content");
let results_user2 = store
.vector_search("user2", None, &embedding, 5)
.await
.unwrap();
assert_eq!(results_user2.len(), 1);
assert_eq!(results_user2[0].content, "user2 content");
let results_wrong_user = store
.vector_search("user3", None, &embedding, 5)
.await
.unwrap();
assert!(results_wrong_user.is_empty());
}
#[tokio::test]
async fn test_insert_rejects_wrong_embedding_dim() {
let dir = TempDir::new().unwrap();
let store = LanceDbVectorStore::new(dir.path(), None).await.unwrap();
let wrong_dim: Vec<f32> = vec![1.0; 100];
let err = store
.store_embedding(
Uuid::new_v4(),
Uuid::new_v4(),
"test.md",
"user1",
None,
"content",
&wrong_dim,
)
.await
.unwrap_err();
assert!(matches!(
err,
crate::error::WorkspaceError::EmbeddingFailed { .. }
));
}
}
+22 -216
View File
@@ -42,26 +42,20 @@
mod chunker;
mod document;
pub mod embeddings;
mod embeddings;
pub mod hygiene;
#[cfg(feature = "lancedb")]
pub mod lancedb_store;
#[cfg(feature = "postgres")]
mod repository;
mod search;
pub mod vector_store;
pub use chunker::{ChunkConfig, chunk_document};
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
pub use embeddings::{
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
};
#[cfg(feature = "lancedb")]
pub use lancedb_store::{DEFAULT_EMBEDDING_DIM, LanceDbVectorStore};
#[cfg(feature = "postgres")]
pub use repository::Repository;
pub use search::{RankedResult, SearchConfig, SearchResult, reciprocal_rank_fusion};
pub use vector_store::VectorStore;
use std::sync::Arc;
@@ -338,12 +332,6 @@ pub struct Workspace {
storage: WorkspaceStorage,
/// Embedding provider for semantic search.
embeddings: Option<Arc<dyn EmbeddingProvider>>,
/// Optional external vector store for semantic search.
///
/// When set, embeddings are stored here instead of (or in addition to)
/// the database's built-in vector support, and hybrid search uses this
/// for the vector component while FTS comes from the database.
vector_store: Option<Arc<dyn VectorStore>>,
}
impl Workspace {
@@ -355,7 +343,6 @@ impl Workspace {
agent_id: None,
storage: WorkspaceStorage::Repo(Repository::new(pool)),
embeddings: None,
vector_store: None,
}
}
@@ -368,7 +355,6 @@ impl Workspace {
agent_id: None,
storage: WorkspaceStorage::Db(db),
embeddings: None,
vector_store: None,
}
}
@@ -384,17 +370,6 @@ impl Workspace {
self
}
/// Set an external vector store for semantic search.
///
/// When set, vector operations (store/search/delete embeddings) use this
/// store instead of the database's built-in vector support. FTS continues
/// to use the database. Hybrid search combines FTS from the database with
/// vector results from this store via RRF.
pub fn with_vector_store(mut self, store: Arc<dyn VectorStore>) -> Self {
self.vector_store = Some(store);
self
}
/// Get the user ID.
pub fn user_id(&self) -> &str {
&self.user_id
@@ -483,21 +458,9 @@ impl Workspace {
/// Delete a file.
///
/// Also deletes associated chunks (from both DB and external vector store).
/// Also deletes associated chunks.
pub async fn delete(&self, path: &str) -> Result<(), WorkspaceError> {
let path = normalize_path(path);
// Clean up external vector store before DB cascade deletes chunks
if let Some(ref vs) = self.vector_store
&& let Ok(doc) = self
.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
&& let Err(e) = vs.delete_embeddings(doc.id).await
{
tracing::warn!("Failed to delete embeddings from vector store: {}", e);
}
self.storage
.delete_document_by_path(&self.user_id, self.agent_id, &path)
.await
@@ -602,26 +565,11 @@ impl Workspace {
///
/// Daily logs are raw, append-only notes for the current day.
pub async fn append_daily_log(&self, entry: &str) -> Result<(), WorkspaceError> {
self.append_daily_log_tz(entry, chrono_tz::Tz::UTC)
.await
.map(|_| ())
}
/// Append an entry to today's daily log using the given timezone.
///
/// Returns the path that was written to (e.g. `daily/2024-01-15.md`).
pub async fn append_daily_log_tz(
&self,
entry: &str,
tz: chrono_tz::Tz,
) -> Result<String, WorkspaceError> {
let now = crate::timezone::now_in_tz(tz);
let today = now.date_naive();
let today = Utc::now().date_naive();
let path = format!("daily/{}.md", today.format("%Y-%m-%d"));
let timestamp = now.format("%H:%M:%S");
let timestamp = Utc::now().format("%H:%M:%S");
let timestamped_entry = format!("[{}] {}", timestamp, entry);
self.append(&path, &timestamped_entry).await?;
Ok(path)
self.append(&path, &timestamped_entry).await
}
// ==================== System Prompt ====================
@@ -636,18 +584,6 @@ impl Workspace {
self.system_prompt_for_context(false).await
}
/// Build the system prompt with timezone-aware daily log dates.
///
/// Uses the given timezone to determine "today" and "yesterday" for daily log injection.
pub async fn system_prompt_for_context_tz(
&self,
is_group_chat: bool,
tz: chrono_tz::Tz,
) -> Result<String, WorkspaceError> {
self.system_prompt_for_context_inner(is_group_chat, Some(tz))
.await
}
/// Build the system prompt, optionally excluding personal memory.
///
/// When `is_group_chat` is true, MEMORY.md is excluded to prevent
@@ -655,16 +591,6 @@ impl Workspace {
pub async fn system_prompt_for_context(
&self,
is_group_chat: bool,
) -> Result<String, WorkspaceError> {
self.system_prompt_for_context_inner(is_group_chat, None)
.await
}
/// Inner implementation for system prompt building.
async fn system_prompt_for_context_inner(
&self,
is_group_chat: bool,
tz: Option<chrono_tz::Tz>,
) -> Result<String, WorkspaceError> {
let mut parts = Vec::new();
@@ -719,10 +645,7 @@ impl Workspace {
}
// Add today's memory context (last 2 days of daily logs)
let today = match tz {
Some(t) => crate::timezone::today_in_tz(t),
None => Utc::now().date_naive(),
};
let today = Utc::now().date_naive();
let yesterday = today.pred_opt().unwrap_or(today);
for date in [today, yesterday] {
@@ -762,67 +685,20 @@ impl Workspace {
query: &str,
config: SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
// Generate embedding for semantic search only when vector search is enabled
let embedding =
if config.use_vector {
if let Some(ref provider) = self.embeddings {
Some(provider.embed(query).await.map_err(|e| {
WorkspaceError::EmbeddingFailed {
reason: e.to_string(),
}
})?)
} else {
None
}
} else {
None
};
// Generate embedding for semantic search if provider available
let embedding = if let Some(ref provider) = self.embeddings {
Some(
provider
.embed(query)
.await
.map_err(|e| WorkspaceError::EmbeddingFailed {
reason: e.to_string(),
})?,
)
} else {
None
};
// When an external vector store is configured, do FTS from the
// database and vector search from the store, then fuse with RRF.
if let Some(ref vs) = self.vector_store {
// FTS from database (disable vector to avoid double-searching)
let fts_results = if config.use_fts {
let fts_config = SearchConfig {
use_fts: true,
use_vector: false,
..config.clone()
};
let fts_search = self
.storage
.hybrid_search(&self.user_id, self.agent_id, query, None, &fts_config)
.await?;
fts_search
.into_iter()
.enumerate()
.map(|(i, r)| RankedResult {
chunk_id: r.chunk_id,
document_id: r.document_id,
document_path: r.document_path,
content: r.content,
rank: (i + 1) as u32,
})
.collect()
} else {
Vec::new()
};
// Vector search from external store
let vector_results = if config.use_vector {
if let Some(ref emb) = embedding {
vs.vector_search(&self.user_id, self.agent_id, emb, config.pre_fusion_limit)
.await?
} else {
Vec::new()
}
} else {
Vec::new()
};
return Ok(reciprocal_rank_fusion(fts_results, vector_results, &config));
}
// No external vector store — use database's built-in hybrid search
self.storage
.hybrid_search(
&self.user_id,
@@ -844,13 +720,7 @@ impl Workspace {
// Chunk the content
let chunks = chunk_document(&doc.content, ChunkConfig::default());
// Delete old embeddings from external vector store FIRST — if this fails,
// we abort before touching DB chunks, keeping the document consistent.
if let Some(ref vs) = self.vector_store {
vs.delete_embeddings(document_id).await?;
}
// Delete old chunks from database
// Delete old chunks
self.storage.delete_chunks(document_id).await?;
// Insert new chunks
@@ -868,33 +738,9 @@ impl Workspace {
None
};
// When an external vector store is active, skip writing embeddings
// to the DB (they'd never be queried from there).
let db_embedding = if self.vector_store.is_some() {
None
} else {
embedding.as_deref()
};
let chunk_id = self
.storage
.insert_chunk(document_id, index as i32, &content, db_embedding)
self.storage
.insert_chunk(document_id, index as i32, &content, embedding.as_deref())
.await?;
// Sync embedding to external vector store (propagate errors to
// avoid leaving a document with deleted-then-missing embeddings).
if let (Some(vs), Some(emb)) = (&self.vector_store, &embedding) {
vs.store_embedding(
chunk_id,
document_id,
&doc.path,
&doc.user_id,
doc.agent_id,
&content,
emb,
)
.await?;
}
}
Ok(())
@@ -1139,23 +985,6 @@ impl Workspace {
.get_chunks_without_embeddings(&self.user_id, self.agent_id, 100)
.await?;
// Prefetch document metadata to avoid N+1 queries when syncing to vector store
let doc_map: std::collections::HashMap<Uuid, crate::workspace::document::MemoryDocument> =
if self.vector_store.is_some() {
let mut map = std::collections::HashMap::new();
for chunk in &chunks {
if !map.contains_key(&chunk.document_id)
&& let Ok(doc) =
self.storage.get_document_by_id(chunk.document_id).await
{
map.insert(doc.id, doc);
}
}
map
} else {
std::collections::HashMap::new()
};
let mut count = 0;
for chunk in chunks {
match provider.embed(&chunk.content).await {
@@ -1163,29 +992,6 @@ impl Workspace {
self.storage
.update_chunk_embedding(chunk.id, &embedding)
.await?;
// Sync to external vector store
if let Some(ref vs) = self.vector_store
&& let Some(doc) = doc_map.get(&chunk.document_id)
&& let Err(e) = vs
.update_embedding(
chunk.id,
chunk.document_id,
&doc.path,
&doc.user_id,
doc.agent_id,
&chunk.content,
&embedding,
)
.await
{
tracing::warn!(
"Failed to sync embedding to vector store for chunk {}: {}",
chunk.id,
e
);
}
count += 1;
}
Err(e) => {
-71
View File
@@ -1,71 +0,0 @@
//! Vector store abstraction for workspace semantic search.
//!
//! Separates vector search from the main `Database` trait so that
//! third-party vector backends (LanceDB, Qdrant, Pinecone, etc.) can
//! be added by implementing a 4-method trait instead of wrapping the
//! entire ~80-method `Database` trait.
//!
//! When no external vector store is configured, the built-in database
//! vector support (pgvector / libsql_vector_idx) is used via the
//! `Database::hybrid_search` method directly.
use async_trait::async_trait;
use uuid::Uuid;
use crate::error::WorkspaceError;
use crate::workspace::search::RankedResult;
/// External vector store for semantic search.
///
/// Implementations hold chunk embeddings and perform vector similarity
/// queries. Document/chunk metadata and FTS stay in the main database;
/// only embeddings live here.
///
/// # Adding a new backend
///
/// 1. Implement this trait for your backend (4 methods).
/// 2. Feature-gate the module (`#[cfg(feature = "mybackend")]`).
/// 3. Pass `Arc<dyn VectorStore>` to `Workspace::with_vector_store()`.
///
/// That's it — no Database wrapper, no delegation boilerplate.
#[async_trait]
#[allow(clippy::too_many_arguments)]
pub trait VectorStore: Send + Sync {
/// Store an embedding for a chunk.
async fn store_embedding(
&self,
chunk_id: Uuid,
document_id: Uuid,
document_path: &str,
user_id: &str,
agent_id: Option<Uuid>,
content: &str,
embedding: &[f32],
) -> Result<(), WorkspaceError>;
/// Update an existing chunk's embedding (delete + re-insert is fine).
async fn update_embedding(
&self,
chunk_id: Uuid,
document_id: Uuid,
document_path: &str,
user_id: &str,
agent_id: Option<Uuid>,
content: &str,
embedding: &[f32],
) -> Result<(), WorkspaceError>;
/// Delete all embeddings for a document.
async fn delete_embeddings(&self, document_id: Uuid) -> Result<(), WorkspaceError>;
/// Vector similarity search, filtered by user and optional agent.
///
/// Returns results ranked by similarity (rank 1 = most similar).
async fn vector_search(
&self,
user_id: &str,
agent_id: Option<Uuid>,
embedding: &[f32],
limit: usize,
) -> Result<Vec<RankedResult>, WorkspaceError>;
}
+108 -12
View File
@@ -27,6 +27,8 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.with_skills()
.build()
.await;
@@ -60,6 +62,8 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.with_skills()
.build()
.await;
@@ -97,6 +101,8 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.with_skills()
.build()
.await;
@@ -197,7 +203,107 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 6: job_create_status
// Test 6: routine_system_event_emit
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_system_event_emit() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_system_event_emit.json"
))
.expect("failed to load routine_system_event_emit.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Create a system-event routine and emit an event")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let completed = rig.tool_calls_completed();
assert!(
completed.iter().any(|(n, ok)| n == "event_emit" && *ok),
"event_emit should succeed: {completed:?}"
);
let results = rig.tool_results();
let emit_result = results
.iter()
.find(|(n, _)| n == "event_emit")
.expect("event_emit result missing");
assert!(
emit_result.1.contains("fired_routines"),
"event_emit should report fired routine count: {:?}",
emit_result.1
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 7: skill_install_routine_webhook_sim
// -----------------------------------------------------------------------
#[tokio::test]
async fn skill_install_routine_webhook_sim() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json"
))
.expect("failed to load skill_install_routine_webhook_sim.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
.await;
rig.send_message("Install the workflow skill template and simulate a webhook routine run")
.await;
// `skill_install` is approval-gated in the interactive loop.
// Approve once so the trace can proceed through the remaining steps.
tokio::time::sleep(Duration::from_millis(500)).await;
rig.send_message("always").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await;
rig.verify_trace_expects(&trace, &responses);
let completed = rig.tool_calls_completed();
assert!(
completed.iter().any(|(n, _)| n == "skill_install"),
"skill_install should be called: {completed:?}"
);
for tool in &["routine_create", "event_emit", "routine_history"] {
assert!(
completed.iter().any(|(n, ok)| n == tool && *ok),
"{tool} should succeed: {completed:?}"
);
}
let results = rig.tool_results();
let emit_result = results
.iter()
.find(|(n, _)| n == "event_emit")
.expect("event_emit result missing");
assert!(
emit_result.1.contains("fired_routines"),
"event_emit should include fired_routines: {:?}",
emit_result.1
);
let _history_result = results
.iter()
.find(|(n, _)| n == "routine_history")
.expect("routine_history result missing");
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 8: job_create_status
// -----------------------------------------------------------------------
// Uses {{call_cj_1.job_id}} template to forward the dynamic UUID from
// create_job's result into job_status's arguments.
@@ -242,16 +348,6 @@ mod tests {
"create_job should return a job_id: {:?}",
create_result.1
);
assert!(
create_result.1.contains("in_progress"),
"create_job should dispatch through the scheduler, not stay pending: {:?}",
create_result.1
);
assert!(
!create_result.1.contains("scheduler unavailable"),
"create_job should not fall back to the unscheduled path: {:?}",
create_result.1
);
let status_result = results
.iter()
.find(|(n, _)| n == "job_status")
@@ -266,7 +362,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 7: job_list_cancel
// Test 9: job_list_cancel
// -----------------------------------------------------------------------
// Uses {{call_cj_lc.job_id}} template to forward the dynamic UUID from
// create_job into cancel_job.
+114 -8
View File
@@ -20,8 +20,9 @@ mod tests {
use ironclaw::agent::routine_engine::RoutineEngine;
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
use ironclaw::channels::IncomingMessage;
use ironclaw::config::RoutineConfig;
use ironclaw::config::{RoutineConfig, SafetyConfig};
use ironclaw::db::Database;
use ironclaw::safety::SafetyLayer;
use ironclaw::workspace::Workspace;
use ironclaw::workspace::hygiene::HygieneConfig;
@@ -117,7 +118,6 @@ mod tests {
"cron-test",
Trigger::Cron {
schedule: "* * * * *".to_string(),
timezone: None,
},
"Check system status.",
);
@@ -203,7 +203,6 @@ mod tests {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::json!({}),
timezone: None,
attachments: Vec::new(),
};
let fired = engine.check_event_triggers(&matching_msg).await;
@@ -225,7 +224,6 @@ mod tests {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::json!({}),
timezone: None,
attachments: Vec::new(),
};
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
@@ -236,6 +234,105 @@ mod tests {
// Test 3: routine_cooldown
// -----------------------------------------------------------------------
#[tokio::test]
async fn system_event_trigger_matches_and_filters() {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
let trace = LlmTrace::single_turn(
"test-system-event-match",
"event",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "System event handled".to_string(),
input_tokens: 40,
output_tokens: 8,
},
expected_tool_results: vec![],
}],
);
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db.clone(),
llm,
ws,
notify_tx,
None,
));
let mut filters = std::collections::HashMap::new();
filters.insert("repository".to_string(), "nearai/ironclaw".to_string());
let routine = make_routine(
"github-issue-opened",
Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue.opened".to_string(),
filters,
},
"Summarize the issue and propose an implementation plan.",
);
db.create_routine(&routine).await.expect("create_routine");
engine.refresh_event_cache().await;
// Matching event should fire.
let fired = engine
.emit_system_event(
"github",
"issue.opened",
&serde_json::json!({
"repository": "nearai/ironclaw",
"issue_number": 42
}),
Some("default"),
)
.await;
assert_eq!(fired, 1, "Expected one routine to fire for matching event");
tokio::time::sleep(Duration::from_millis(300)).await;
let runs = db
.list_routine_runs(routine.id, 10)
.await
.expect("list runs");
assert!(
!runs.is_empty(),
"Expected run history after matching event"
);
// Wrong event type should not fire.
let fired_wrong_type = engine
.emit_system_event(
"github",
"issue.closed",
&serde_json::json!({"repository": "nearai/ironclaw"}),
Some("default"),
)
.await;
assert_eq!(
fired_wrong_type, 0,
"Expected no routine for wrong event type"
);
// Wrong filter value should not fire.
let fired_wrong_filter = engine
.emit_system_event(
"github",
"issue.opened",
&serde_json::json!({"repository": "other/repo"}),
Some("default"),
)
.await;
assert_eq!(
fired_wrong_filter, 0,
"Expected no routine for filter mismatch"
);
}
#[tokio::test]
async fn routine_cooldown() {
let (db, _tmp) = create_test_db().await;
@@ -290,7 +387,6 @@ mod tests {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::json!({}),
timezone: None,
attachments: Vec::new(),
};
let fired1 = engine.check_event_triggers(&msg).await;
@@ -345,6 +441,10 @@ mod tests {
}],
);
let llm = Arc::new(TraceLlm::from_trace(trace));
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
@@ -356,8 +456,9 @@ mod tests {
state_dir: _tmp.path().to_path_buf(),
};
let runner = HeartbeatRunner::new(HeartbeatConfig::default(), hygiene_config, ws, llm)
.with_response_channel(tx);
let runner =
HeartbeatRunner::new(HeartbeatConfig::default(), hygiene_config, ws, llm, safety)
.with_response_channel(tx);
let result = runner.check_heartbeat().await;
match result {
@@ -394,6 +495,10 @@ mod tests {
// LLM should NOT be called, so provide a trace that would panic if called.
let trace = LlmTrace::single_turn("test-heartbeat-skip", "skip", vec![]);
let llm = Arc::new(TraceLlm::from_trace(trace));
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let hygiene_config = HygieneConfig {
enabled: false,
@@ -403,7 +508,8 @@ mod tests {
state_dir: _tmp.path().to_path_buf(),
};
let runner = HeartbeatRunner::new(HeartbeatConfig::default(), hygiene_config, ws, llm);
let runner =
HeartbeatRunner::new(HeartbeatConfig::default(), hygiene_config, ws, llm, safety);
let result = runner.check_heartbeat().await;
assert!(
@@ -0,0 +1,42 @@
{
"model_name": "test-routine-system-event-emit",
"expects": {
"tools_used": ["event_emit"],
"all_tools_succeeded": true,
"tool_results_contain": {
"event_emit": "fired_routines"
}
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_ee_1",
"name": "event_emit",
"arguments": {
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "nearai/ironclaw",
"issue_number": 123,
"title": "Support event-driven project workflow"
}
}
}
],
"input_tokens": 90,
"output_tokens": 28
}
},
{
"response": {
"type": "text",
"content": "Emitted a GitHub system event successfully.",
"input_tokens": 140,
"output_tokens": 14
}
}
]
}
@@ -0,0 +1,100 @@
{
"model_name": "test-skill-install-routine-webhook-sim",
"expects": {
"tools_used": ["skill_install", "routine_create", "event_emit", "routine_history"],
"tool_results_contain": {
"event_emit": "fired_routines"
}
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_skill_install_1",
"name": "skill_install",
"arguments": {
"name": "wf-orchestrator-trace-install-1",
"content": "---\nname: wf-orchestrator-trace-install-1\ndescription: Minimal workflow skill for trace install validation\nactivation:\n keywords: [\"workflow\", \"orchestrator\"]\n---\n\nYou are a minimal workflow skill used for trace install validation.\n"
}
}
],
"input_tokens": 120,
"output_tokens": 32
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_routine_create_1",
"name": "routine_create",
"arguments": {
"name": "wf-webhook-sim-trace",
"description": "Trace routine to simulate webhook event flow",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {
"repository": "nearai/ironclaw"
},
"action_type": "full_job",
"prompt": "When issue webhook event arrives, start implementation loop and create branch/PR updates."
}
}
],
"input_tokens": 170,
"output_tokens": 36
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_event_emit_1",
"name": "event_emit",
"arguments": {
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "nearai/ironclaw",
"issue_number": 4242,
"sender": "trace-bot"
}
}
}
],
"input_tokens": 210,
"output_tokens": 28
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_routine_history_1",
"name": "routine_history",
"arguments": {
"name": "wf-webhook-sim-trace",
"limit": 5
}
}
],
"input_tokens": 240,
"output_tokens": 22
}
},
{
"response": {
"type": "text",
"content": "Installed the skill template, created a system-event routine, emitted a webhook-equivalent event, and verified the routine run history.",
"input_tokens": 280,
"output_tokens": 25
}
}
]
}
+144
View File
@@ -0,0 +1,144 @@
//! Live-ish gateway workflow integration using an in-process mock OpenAI server.
//! This exercises the same path as manual validation:
//! - chat send through gateway
//! - routine creation via tool call
//! - system-event emission via tool call
//! - webhook ingestion via gateway
//! - status/runs checks via routines API
#[cfg(feature = "libsql")]
mod support;
#[cfg(feature = "libsql")]
mod tests {
use std::time::Duration;
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
use crate::support::mock_openai_server::{
MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall,
};
#[tokio::test]
async fn gateway_workflow_harness_chat_and_webhook() {
let mock = MockOpenAiServerBuilder::new()
.with_rule(MockOpenAiRule::on_user_contains(
"create workflow routine",
MockOpenAiResponse::ToolCalls(vec![MockToolCall::new(
"call_create_1",
"routine_create",
serde_json::json!({
"name": "wf-ci-webhook-demo",
"description": "CI webhook workflow demo",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {"repository": "nearai/ironclaw"},
"action_type": "lightweight",
"prompt": "Summarize webhook and report issue number"
}),
)]),
))
.with_rule(MockOpenAiRule::on_user_contains(
"emit webhook event",
MockOpenAiResponse::ToolCalls(vec![MockToolCall::new(
"call_emit_1",
"event_emit",
serde_json::json!({
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "nearai/ironclaw",
"issue": {"number": 777, "title": "Infra test"}
}
}),
)]),
))
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
.start()
.await;
let harness =
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
.await;
let thread_id = harness.create_thread().await;
harness
.send_chat(&thread_id, "create workflow routine")
.await;
harness
.wait_for_turns(&thread_id, 1, Duration::from_secs(10))
.await;
let mut routine = None;
for _ in 0..30 {
routine = harness.routine_by_name("wf-ci-webhook-demo").await;
if routine.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let routine = if let Some(r) = routine {
r
} else {
let history_dbg = harness.history(&thread_id).await;
let started_dbg = harness.test_channel.tool_calls_started();
let requests_dbg = mock.requests().await;
panic!(
"routine not created; tool_calls_started={started_dbg:?}; history={history_dbg}; mock_requests={requests_dbg:?}"
);
};
let routine_id = routine["id"].as_str().expect("routine id missing");
harness.send_chat(&thread_id, "emit webhook event").await;
let history = harness
.wait_for_turns(&thread_id, 2, Duration::from_secs(10))
.await;
let turns = history["turns"].as_array().expect("turns array missing");
assert!(turns.len() >= 2, "expected at least 2 turns");
let runs_before = harness.routine_runs(routine_id).await;
let before_count = runs_before["runs"]
.as_array()
.map(|a| a.len())
.unwrap_or_default();
let hook = harness
.github_webhook(
"issues",
serde_json::json!({
"action": "opened",
"repository": {"full_name": "nearai/ironclaw"},
"issue": {"number": 778, "title": "Webhook endpoint test"}
}),
)
.await;
assert_eq!(hook["status"], "accepted");
assert_eq!(hook["event_type"], "issue.opened");
assert!(
hook["fired_routines"].as_u64().unwrap_or(0) >= 1,
"expected webhook to fire at least one routine"
);
tokio::time::sleep(Duration::from_millis(500)).await;
let runs_after = harness.routine_runs(routine_id).await;
let after_count = runs_after["runs"]
.as_array()
.map(|a| a.len())
.unwrap_or_default();
assert!(
after_count > before_count,
"expected routine runs to increase after webhook; before={before_count}, after={after_count}"
);
let requests = mock.requests().await;
assert!(
requests.len() >= 2,
"expected mock LLM server to receive requests"
);
harness.shutdown().await;
mock.shutdown().await;
}
}
+3 -1
View File
@@ -15,6 +15,7 @@ use ironclaw::{
config::Config,
history::Store,
llm::{create_llm_provider, create_session_manager},
safety::SafetyLayer,
workspace::Workspace,
};
@@ -92,7 +93,8 @@ async fn test_heartbeat_end_to_end() {
let hb_config = ironclaw::agent::HeartbeatConfig::default();
let hygiene_config = ironclaw::workspace::hygiene::HygieneConfig::default();
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm);
let safety = Arc::new(SafetyLayer::new(&config.safety));
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm, safety);
let result = runner.check_heartbeat().await;
-123
View File
@@ -1,123 +0,0 @@
//! Integration tests for LanceDB vector store with Workspace composition.
//!
//! Requires: cargo test --features "libsql,lancedb"
//!
//! Verifies that Workspace correctly composes FTS from libSQL with vector
//! search from LanceDB via the VectorStore trait.
#![cfg(all(feature = "libsql", feature = "lancedb"))]
use std::sync::Arc;
use ironclaw::db::Database;
use ironclaw::db::libsql::LibSqlBackend;
use ironclaw::workspace::{LanceDbVectorStore, SearchConfig, Workspace};
use tempfile::TempDir;
const EMBEDDING_DIM: usize = 1536;
fn make_embedding(seed: f32) -> Vec<f32> {
(0..EMBEDDING_DIM)
.map(|i| (seed * (i as f32 + 1.0)).sin())
.collect()
}
/// Mock embedding provider that returns deterministic embeddings.
struct FixedEmbeddings {
embedding: Vec<f32>,
}
#[async_trait::async_trait]
impl ironclaw::workspace::EmbeddingProvider for FixedEmbeddings {
fn dimension(&self) -> usize {
EMBEDDING_DIM
}
fn model_name(&self) -> &str {
"fixed-test"
}
fn max_input_length(&self) -> usize {
8192
}
async fn embed(
&self,
_text: &str,
) -> Result<Vec<f32>, ironclaw::workspace::embeddings::EmbeddingError> {
Ok(self.embedding.clone())
}
}
async fn setup_workspace() -> (Workspace, TempDir, TempDir) {
// Use a temp file (not :memory:) because libSQL in-memory DBs are connection-local
let db_dir = TempDir::new().unwrap();
let db_path = db_dir.path().join("test.db");
let libsql = LibSqlBackend::new_local(&db_path).await.unwrap();
libsql.run_migrations().await.unwrap();
let lancedb_dir = TempDir::new().unwrap();
let store = LanceDbVectorStore::new(lancedb_dir.path(), None).await.unwrap();
let embedding = make_embedding(1.0);
let ws = Workspace::new_with_db("test_user", Arc::new(libsql) as Arc<dyn Database>)
.with_vector_store(Arc::new(store))
.with_embeddings(Arc::new(FixedEmbeddings { embedding }));
(ws, lancedb_dir, db_dir)
}
#[tokio::test]
async fn test_workspace_hybrid_search_with_lancedb() {
let (ws, _keep_lance, _keep_db) = setup_workspace().await;
// Write a document — this triggers chunking + embedding + LanceDB sync
ws.write(
"context/rust.md",
"Rust is a systems programming language focused on safety.",
)
.await
.unwrap();
// Hybrid search: FTS for "Rust" + vector from LanceDB
let results = ws.search("Rust", 5).await.unwrap();
assert!(!results.is_empty(), "hybrid search should return results");
assert!(results[0].content.contains("Rust"));
}
#[tokio::test]
async fn test_workspace_delete_removes_from_lancedb() {
let (ws, _keep_lance, _keep_db) = setup_workspace().await;
ws.write("notes/deleted.md", "Content to be deleted.")
.await
.unwrap();
let before = ws.search("deleted", 5).await.unwrap();
assert_eq!(before.len(), 1);
ws.delete("notes/deleted.md").await.unwrap();
let after = ws.search("deleted", 5).await.unwrap();
assert!(after.is_empty());
}
#[tokio::test]
async fn test_workspace_vector_only_search_uses_lancedb() {
let (ws, _keep_lance, _keep_db) = setup_workspace().await;
ws.write("sync/test.md", "Semantic content for vector search")
.await
.unwrap();
// Vector-only search should find via LanceDB even with non-matching FTS query
let config = SearchConfig::default().vector_only().with_limit(5);
let results = ws
.search_with_config("nonexistent_fts_term", config)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].content.contains("Semantic content"));
}
+466
View File
@@ -0,0 +1,466 @@
#![allow(dead_code)]
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use secrecy::SecretString;
use tokio::sync::mpsc;
use ironclaw::agent::routine_engine::RoutineEngine;
use ironclaw::agent::{Agent, AgentDeps, SessionManager as AgentSessionManager};
use ironclaw::app::{AppBuilder, AppBuilderFlags};
use ironclaw::channels::web::log_layer::LogBroadcaster;
use ironclaw::channels::web::server::{GatewayState, RateLimiter, start_server};
use ironclaw::channels::web::sse::SseManager;
use ironclaw::channels::web::ws::WsConnectionTracker;
use ironclaw::channels::{Channel, IncomingMessage, MessageStream};
use ironclaw::config::{Config, RegistryProviderConfig, RoutineConfig};
use ironclaw::context::ContextManager;
use ironclaw::db::Database;
use ironclaw::db::libsql::LibSqlBackend;
use ironclaw::error::ChannelError;
use ironclaw::llm::registry::ProviderProtocol;
use ironclaw::llm::{
SessionConfig as LlmSessionConfig, SessionManager as LlmSessionManager, create_llm_provider,
};
use crate::support::test_channel::TestChannel;
struct TestChannelHandle {
inner: Arc<TestChannel>,
}
impl TestChannelHandle {
fn new(inner: Arc<TestChannel>) -> Self {
Self { inner }
}
}
#[async_trait]
impl Channel for TestChannelHandle {
fn name(&self) -> &str {
"gateway"
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
self.inner.start().await
}
async fn respond(
&self,
msg: &IncomingMessage,
response: ironclaw::channels::OutgoingResponse,
) -> Result<(), ChannelError> {
self.inner.respond(msg, response).await
}
async fn send_status(
&self,
status: ironclaw::channels::StatusUpdate,
metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
self.inner.send_status(status, metadata).await
}
async fn broadcast(
&self,
user_id: &str,
response: ironclaw::channels::OutgoingResponse,
) -> Result<(), ChannelError> {
self.inner.broadcast(user_id, response).await
}
async fn health_check(&self) -> Result<(), ChannelError> {
self.inner.health_check().await
}
fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap<String, String> {
self.inner.conversation_context(metadata)
}
async fn shutdown(&self) -> Result<(), ChannelError> {
self.inner.shutdown().await
}
}
pub struct GatewayWorkflowHarness {
pub addr: SocketAddr,
pub auth_token: String,
pub client: reqwest::Client,
pub user_id: String,
pub test_channel: Arc<TestChannel>,
pub db: Arc<dyn Database>,
gateway_state: Arc<GatewayState>,
agent_handle: Option<tokio::task::JoinHandle<()>>,
bridge_handle: Option<tokio::task::JoinHandle<()>>,
_temp_dir: tempfile::TempDir,
}
impl GatewayWorkflowHarness {
pub async fn start_openai_compatible(base_url: &str, model: &str) -> Self {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let db_path = temp_dir.path().join("gateway_workflow_harness.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("failed to create test db");
backend
.run_migrations()
.await
.expect("failed to run migrations");
let db: Arc<dyn Database> = Arc::new(backend);
let skills_dir = temp_dir.path().join("skills");
let installed_skills_dir = temp_dir.path().join("installed_skills");
let _ = std::fs::create_dir_all(&skills_dir);
let _ = std::fs::create_dir_all(&installed_skills_dir);
let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir);
config.agent.auto_approve_tools = true;
config.routines.enabled = true;
config.routines.max_concurrent_routines = 4;
config.llm.backend = "openai_compatible".to_string();
config.llm.provider = Some(RegistryProviderConfig {
protocol: ProviderProtocol::OpenAiCompletions,
provider_id: "openai_compatible".to_string(),
api_key: Some(SecretString::from("dummy".to_string())),
base_url: base_url.to_string(),
model: model.to_string(),
extra_headers: Vec::new(),
oauth_token: None,
});
let llm_session = Arc::new(LlmSessionManager::new(LlmSessionConfig::default()));
let llm = create_llm_provider(&config.llm, Arc::clone(&llm_session))
.expect("failed to create openai-compatible provider");
let log_broadcaster = Arc::new(LogBroadcaster::new());
let mut app_builder = AppBuilder::new(
config,
AppBuilderFlags::default(),
None,
Arc::clone(&llm_session),
log_broadcaster,
);
app_builder.with_database(Arc::clone(&db));
app_builder.with_llm(llm);
let mut components = app_builder
.build_all()
.await
.expect("failed to build app components");
let ctx_mgr = Arc::new(ContextManager::new(
components.config.agent.max_parallel_jobs,
));
components.tools.register_job_tools(
ctx_mgr,
None,
None,
components.db.clone(),
None,
None,
None,
None,
);
let routine_slot: Arc<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>> =
Arc::new(tokio::sync::RwLock::new(None));
if let (Some(db_arc), Some(ws)) = (&components.db, &components.workspace) {
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(),
Arc::clone(db_arc),
components.llm.clone(),
Arc::clone(ws),
notify_tx,
None,
));
components
.tools
.register_routine_tools(Arc::clone(db_arc), engine);
}
let test_channel = Arc::new(TestChannel::new());
let handle = TestChannelHandle::new(Arc::clone(&test_channel));
let channel_manager = ironclaw::channels::ChannelManager::new();
channel_manager.add(Box::new(handle)).await;
let channels = Arc::new(channel_manager);
let user_id = "gateway-test-user".to_string();
let (gw_tx, mut gw_rx) = mpsc::channel::<IncomingMessage>(256);
let forward_channel = Arc::clone(&test_channel);
let bridge_handle = tokio::spawn(async move {
while let Some(msg) = gw_rx.recv().await {
forward_channel.send_incoming(msg).await;
}
});
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
Arc::new(tokio::sync::RwLock::new(None));
let agent_session_manager = Arc::new(AgentSessionManager::new());
let gateway_state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(Some(gw_tx)),
sse: SseManager::new(),
workspace: components.workspace.clone(),
session_manager: Some(Arc::clone(&agent_session_manager)),
log_broadcaster: None,
log_level_handle: None,
extension_manager: components.extension_manager.clone(),
tool_registry: Some(Arc::clone(&components.tools)),
store: components.db.clone(),
job_manager: None,
prompt_queue: None,
scheduler: Some(scheduler_slot.clone()),
user_id: user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(Arc::clone(&components.llm)),
skill_registry: components.skill_registry.clone(),
skill_catalog: components.skill_catalog.clone(),
chat_rate_limiter: RateLimiter::new(120, 60),
registry_entries: Vec::new(),
cost_guard: Some(Arc::clone(&components.cost_guard)),
routine_engine: Arc::clone(&routine_slot),
startup_time: Instant::now(),
});
let mut agent = Agent::new(
components.config.agent.clone(),
AgentDeps {
store: components.db,
llm: components.llm,
cheap_llm: components.cheap_llm,
safety: components.safety,
tools: components.tools,
workspace: components.workspace,
extension_manager: components.extension_manager,
skill_registry: components.skill_registry,
skill_catalog: components.skill_catalog,
skills_config: components.config.skills.clone(),
hooks: components.hooks,
cost_guard: components.cost_guard,
sse_tx: Some(gateway_state.sse.sender()),
http_interceptor: None,
transcription: None,
document_extraction: None,
},
channels,
None,
None,
Some(RoutineConfig {
enabled: true,
cron_check_interval_secs: 60,
max_concurrent_routines: 4,
default_cooldown_secs: 300,
max_lightweight_tokens: 4096,
}),
Some(Arc::clone(&components.context_manager)),
Some(Arc::clone(&agent_session_manager)),
);
agent.set_routine_engine_slot(Arc::clone(&routine_slot));
*scheduler_slot.write().await = Some(agent.scheduler());
let agent_handle = tokio::spawn(async move {
let _ = agent.run().await;
});
if let Some(rx) = test_channel.take_ready_rx().await {
let _ = tokio::time::timeout(Duration::from_secs(5), rx).await;
}
let auth_token = "gateway-test-token".to_string();
let addr = start_server(
"127.0.0.1:0".parse().expect("valid localhost addr"),
Arc::clone(&gateway_state),
auth_token.clone(),
)
.await
.expect("failed to start gateway server");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("failed to build reqwest client");
Self {
addr,
auth_token,
client,
user_id,
test_channel,
db,
gateway_state,
agent_handle: Some(agent_handle),
bridge_handle: Some(bridge_handle),
_temp_dir: temp_dir,
}
}
pub fn base_url(&self) -> String {
format!("http://{}", self.addr)
}
pub async fn create_thread(&self) -> String {
let resp = self
.client
.post(format!("{}/api/chat/thread/new", self.base_url()))
.bearer_auth(&self.auth_token)
.send()
.await
.expect("create thread request failed")
.error_for_status()
.expect("create thread non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid thread response");
resp.get("id")
.and_then(|v| v.as_str())
.expect("thread id missing")
.to_string()
}
pub async fn send_chat(&self, thread_id: &str, content: &str) {
let _ = self
.client
.post(format!("{}/api/chat/send", self.base_url()))
.bearer_auth(&self.auth_token)
.json(&serde_json::json!({"thread_id": thread_id, "content": content}))
.send()
.await
.expect("chat send failed")
.error_for_status()
.expect("chat send non-2xx");
}
pub async fn history(&self, thread_id: &str) -> serde_json::Value {
self.client
.get(format!(
"{}/api/chat/history?thread_id={thread_id}",
self.base_url()
))
.bearer_auth(&self.auth_token)
.send()
.await
.expect("history request failed")
.error_for_status()
.expect("history non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid history response")
}
pub async fn wait_for_turns(
&self,
thread_id: &str,
min_turns: usize,
timeout: Duration,
) -> serde_json::Value {
let deadline = Instant::now() + timeout;
loop {
let history = self.history(thread_id).await;
let turns = history
.get("turns")
.and_then(|v| v.as_array())
.map(|v| v.len())
.unwrap_or_default();
if turns >= min_turns {
return history;
}
assert!(Instant::now() < deadline, "timed out waiting for turns");
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
pub async fn list_routines(&self) -> serde_json::Value {
self.client
.get(format!("{}/api/routines", self.base_url()))
.bearer_auth(&self.auth_token)
.send()
.await
.expect("routines request failed")
.error_for_status()
.expect("routines non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid routines response")
}
pub async fn routine_by_name(&self, name: &str) -> Option<serde_json::Value> {
let routines = self.list_routines().await;
routines
.get("routines")
.and_then(|v| v.as_array())
.and_then(|arr| {
arr.iter()
.find(|r| r.get("name").and_then(|v| v.as_str()) == Some(name))
.cloned()
})
}
pub async fn routine_runs(&self, routine_id: &str) -> serde_json::Value {
self.client
.get(format!(
"{}/api/routines/{routine_id}/runs",
self.base_url()
))
.bearer_auth(&self.auth_token)
.send()
.await
.expect("routine runs request failed")
.error_for_status()
.expect("routine runs non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid routine runs response")
}
pub async fn github_webhook(
&self,
event: &str,
payload: serde_json::Value,
) -> serde_json::Value {
self.client
.post(format!("{}/api/webhooks/github", self.base_url()))
.header("x-github-event", event)
.json(&payload)
.send()
.await
.expect("webhook request failed")
.error_for_status()
.expect("webhook non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid webhook response")
}
pub async fn shutdown(mut self) {
self.test_channel.signal_shutdown();
if let Some(tx) = self.gateway_state.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
if let Some(handle) = self.bridge_handle.take() {
handle.abort();
}
if let Some(handle) = self.agent_handle.take() {
handle.abort();
}
}
}
impl Drop for GatewayWorkflowHarness {
fn drop(&mut self) {
self.test_channel.signal_shutdown();
if let Some(handle) = self.bridge_handle.take() {
handle.abort();
}
if let Some(handle) = self.agent_handle.take() {
handle.abort();
}
}
}
+300
View File
@@ -0,0 +1,300 @@
#![allow(dead_code)]
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::{Mutex, oneshot};
#[derive(Clone)]
pub struct MockOpenAiRule {
contains: String,
response: MockOpenAiResponse,
}
impl MockOpenAiRule {
pub fn on_user_contains(contains: impl Into<String>, response: MockOpenAiResponse) -> Self {
Self {
contains: contains.into(),
response,
}
}
}
#[derive(Clone)]
pub enum MockOpenAiResponse {
Text(String),
ToolCalls(Vec<MockToolCall>),
Raw(Value),
}
#[derive(Clone)]
pub struct MockToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
impl MockToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
Self {
id: id.into(),
name: name.into(),
arguments,
}
}
}
#[derive(Default)]
pub struct MockOpenAiServerBuilder {
models: Vec<String>,
rules: Vec<MockOpenAiRule>,
default_response: Option<MockOpenAiResponse>,
}
impl MockOpenAiServerBuilder {
pub fn new() -> Self {
Self {
models: vec!["mock-model".to_string()],
..Self::default()
}
}
pub fn with_models(mut self, models: Vec<String>) -> Self {
self.models = models;
self
}
pub fn with_rule(mut self, rule: MockOpenAiRule) -> Self {
self.rules.push(rule);
self
}
pub fn with_default_response(mut self, response: MockOpenAiResponse) -> Self {
self.default_response = Some(response);
self
}
pub async fn start(self) -> MockOpenAiServer {
let state = Arc::new(MockOpenAiState {
models: self.models,
rules: self.rules,
default_response: self
.default_response
.unwrap_or_else(|| MockOpenAiResponse::Text("OK".to_string())),
requests: Mutex::new(Vec::new()),
response_counter: AtomicU64::new(1),
});
let app = Router::new()
.route("/v1/models", get(models_handler))
.route("/v1/chat/completions", post(chat_completions_handler))
.with_state(Arc::clone(&state));
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind mock openai server");
let addr = listener.local_addr().expect("failed to read bound addr");
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await;
});
MockOpenAiServer {
addr,
state,
shutdown_tx: Some(shutdown_tx),
server_task: Some(handle),
}
}
}
pub struct MockOpenAiServer {
addr: SocketAddr,
state: Arc<MockOpenAiState>,
shutdown_tx: Option<oneshot::Sender<()>>,
server_task: Option<tokio::task::JoinHandle<()>>,
}
impl MockOpenAiServer {
pub fn base_url(&self) -> String {
format!("http://{}", self.addr)
}
pub fn openai_base_url(&self) -> String {
format!("{}/v1", self.base_url())
}
pub async fn requests(&self) -> Vec<Value> {
self.state.requests.lock().await.clone()
}
pub async fn shutdown(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.server_task.take() {
let _ = handle.await;
}
}
}
impl Drop for MockOpenAiServer {
fn drop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.server_task.take() {
handle.abort();
}
}
}
struct MockOpenAiState {
models: Vec<String>,
rules: Vec<MockOpenAiRule>,
default_response: MockOpenAiResponse,
requests: Mutex<Vec<Value>>,
response_counter: AtomicU64,
}
async fn models_handler(State(state): State<Arc<MockOpenAiState>>) -> Json<Value> {
Json(json!({
"object": "list",
"data": state
.models
.iter()
.map(|id| json!({"id": id, "object": "model"}))
.collect::<Vec<_>>()
}))
}
async fn chat_completions_handler(
State(state): State<Arc<MockOpenAiState>>,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
state.requests.lock().await.push(body.clone());
let model = body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("mock-model");
let last_role = body
.pointer("/messages")
.and_then(|m| m.as_array())
.and_then(|arr| arr.last())
.and_then(|v| v.get("role"))
.and_then(|r| r.as_str())
.unwrap_or_default();
fn extract_text_content(msg: &Value) -> Option<String> {
let content = msg.get("content")?;
if let Some(s) = content.as_str() {
return Some(s.to_string());
}
if let Some(parts) = content.as_array() {
let mut out = String::new();
for part in parts {
if part.get("type").and_then(|v| v.as_str()) == Some("text")
&& let Some(text) = part.get("text").and_then(|v| v.as_str())
{
if !out.is_empty() {
out.push(' ');
}
out.push_str(text);
}
}
if !out.is_empty() {
return Some(out);
}
}
None
}
let latest_user = body
.pointer("/messages")
.and_then(|m| m.as_array())
.and_then(|arr| {
arr.iter().rev().find_map(|msg| {
if msg.get("role").and_then(|r| r.as_str()) == Some("user") {
extract_text_content(msg)
} else {
None
}
})
})
.unwrap_or_default();
let selected = if last_role == "user" {
let latest_user_lower = latest_user.to_ascii_lowercase();
state
.rules
.iter()
.find(|r| latest_user_lower.contains(&r.contains.to_ascii_lowercase()))
.map(|r| r.response.clone())
.unwrap_or_else(|| state.default_response.clone())
} else {
state.default_response.clone()
};
let n = state.response_counter.fetch_add(1, Ordering::Relaxed);
let response = match selected {
MockOpenAiResponse::Text(content) => json!({
"id": format!("chatcmpl-mock-{n}"),
"object": "chat.completion",
"created": 0,
"model": model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
}),
MockOpenAiResponse::ToolCalls(tool_calls) => {
let calls = tool_calls
.iter()
.map(|tc| {
json!({
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": tc.arguments.to_string()
}
})
})
.collect::<Vec<_>>();
json!({
"id": format!("chatcmpl-mock-{n}"),
"object": "chat.completion",
"created": 0,
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": serde_json::Value::Null,
"tool_calls": calls
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
})
}
MockOpenAiResponse::Raw(v) => v,
};
Ok(Json(response))
}
+2
View File
@@ -1,7 +1,9 @@
pub mod assertions;
pub mod cleanup;
pub mod gateway_workflow_harness;
pub mod instrumented_llm;
pub mod metrics;
pub mod mock_openai_server;
pub mod test_channel;
pub mod test_rig;
pub mod trace_llm;
+46 -10
View File
@@ -379,6 +379,8 @@ pub struct TestRigBuilder {
llm: Option<Arc<dyn LlmProvider>>,
max_tool_iterations: usize,
injection_check: bool,
auto_approve_tools: Option<bool>,
enable_skills: bool,
enable_routines: bool,
http_exchanges: Vec<HttpExchange>,
extra_tools: Vec<Arc<dyn Tool>>,
@@ -392,6 +394,8 @@ impl TestRigBuilder {
llm: None,
max_tool_iterations: 10,
injection_check: false,
auto_approve_tools: None,
enable_skills: false,
enable_routines: false,
http_exchanges: Vec::new(),
extra_tools: Vec::new(),
@@ -432,6 +436,18 @@ impl TestRigBuilder {
self
}
/// Override agent-level automatic approval of `UnlessAutoApproved` tools.
pub fn with_auto_approve_tools(mut self, enable: bool) -> Self {
self.auto_approve_tools = Some(enable);
self
}
/// Enable skill discovery and registration for this test rig.
pub fn with_skills(mut self) -> Self {
self.enable_skills = true;
self
}
/// Enable the routines system so the scheduler is wired with a `RoutineEngine`,
/// allowing routine jobs to actually execute. Routine tools are always registered
/// but require the engine to dispatch jobs.
@@ -466,6 +482,8 @@ impl TestRigBuilder {
llm,
max_tool_iterations,
injection_check,
auto_approve_tools,
enable_skills,
enable_routines,
http_exchanges: explicit_http_exchanges,
extra_tools,
@@ -491,6 +509,10 @@ impl TestRigBuilder {
let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir);
config.agent.max_tool_iterations = max_tool_iterations;
config.safety.injection_check_enabled = injection_check;
config.skills.enabled = enable_skills;
if let Some(v) = auto_approve_tools {
config.agent.auto_approve_tools = v;
}
// 3. Create SessionManager + LogBroadcaster.
let session = Arc::new(SessionManager::new(SessionConfig::default()));
@@ -540,19 +562,21 @@ impl TestRigBuilder {
);
builder.with_database(Arc::clone(&db));
builder.with_llm(llm);
let components = builder
let mut components = builder
.build_all()
.await
.expect("AppBuilder::build_all() failed in test rig");
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
Arc::new(tokio::sync::RwLock::new(None));
// 6. Register job tools, routine tools, and extra tools.
{
use ironclaw::context::ContextManager;
let ctx_mgr = Arc::new(ContextManager::new(
components.config.agent.max_parallel_jobs,
));
components.tools.register_job_tools(
Arc::clone(&components.context_manager),
Some(scheduler_slot.clone()),
ctx_mgr,
None,
None,
components.db.clone(),
None,
@@ -581,6 +605,21 @@ impl TestRigBuilder {
.register_routine_tools(Arc::clone(db_arc), engine);
}
// Skills tools: ensure tests use temp skill dirs (sandbox-safe) even if
// AppBuilder did not wire them for this environment.
if enable_skills {
let registry = Arc::new(std::sync::RwLock::new(
ironclaw::skills::SkillRegistry::new(temp_dir.path().join("skills"))
.with_installed_dir(temp_dir.path().join("installed_skills")),
));
let catalog = ironclaw::skills::catalog::shared_catalog();
components
.tools
.register_skill_tools(Arc::clone(&registry), Arc::clone(&catalog));
components.skill_registry = Some(registry);
components.skill_catalog = Some(catalog);
}
// Register any extra test-specific tools.
for tool in extra_tools {
components.tools.register(tool).await;
@@ -655,13 +694,10 @@ impl TestRigBuilder {
None, // heartbeat_config
None, // hygiene_config
routine_config,
Some(Arc::clone(&components.context_manager)),
None, // context_manager
None, // session_manager
);
// Match main.rs: fill the scheduler slot once Agent::new has created it.
*scheduler_slot.write().await = Some(agent.scheduler());
// 9. Spawn agent in background task.
let agent_handle = tokio::spawn(async move {
if let Err(e) = agent.run().await {
+26 -35
View File
@@ -513,41 +513,32 @@ impl LlmProvider for TraceLlm {
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
// complete() is called when Reasoning has force_text=true (no tools
// available). Skip any remaining ToolCalls steps in the trace and
// return the next Text step, since in real usage the LLM would
// produce text when no tools are offered.
loop {
let step = self.next_step(&request.messages)?;
match step.response {
TraceResponse::Text {
content,
input_tokens,
output_tokens,
} => {
return Ok(CompletionResponse {
content,
input_tokens,
output_tokens,
finish_reason: FinishReason::Stop,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
}
TraceResponse::ToolCalls { .. } => {
// Skip tool_calls steps — complete() is called in
// force_text mode so the LLM can't use tools anyway.
continue;
}
TraceResponse::UserInput { .. } => {
return Err(LlmError::RequestFailed {
provider: self.model_name.clone(),
reason: "TraceLlm::complete() encountered a user_input step; \
these should have been filtered out during construction"
.to_string(),
});
}
}
let step = self.next_step(&request.messages)?;
match step.response {
TraceResponse::Text {
content,
input_tokens,
output_tokens,
} => Ok(CompletionResponse {
content,
input_tokens,
output_tokens,
finish_reason: FinishReason::Stop,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
}),
TraceResponse::ToolCalls { .. } => Err(LlmError::RequestFailed {
provider: self.model_name.clone(),
reason: "TraceLlm::complete() called but current step is a tool_calls response; \
use complete_with_tools() instead"
.to_string(),
}),
TraceResponse::UserInput { .. } => Err(LlmError::RequestFailed {
provider: self.model_name.clone(),
reason: "TraceLlm::complete() encountered a user_input step; \
these should have been filtered out during construction"
.to_string(),
}),
}
}
+9 -16
View File
@@ -571,29 +571,22 @@ mod trace_llm_tests {
}
#[tokio::test]
async fn complete_skips_tool_calls_step() {
// complete() is called in force_text mode where tools aren't available.
// When the trace has a ToolCalls step followed by a Text step, complete()
// should skip the ToolCalls and return the Text response.
async fn complete_errors_on_tool_calls_step() {
let trace = LlmTrace::single_turn(
"test-model",
"hi",
vec![
tool_calls_step(vec![simple_tool_call("echo")], 10, 5),
text_step("skipped past tools", 20, 8),
],
vec![tool_calls_step(vec![simple_tool_call("echo")], 10, 5)],
);
let llm = TraceLlm::from_trace(trace);
let resp = llm
.complete(make_completion_request("hi"))
.await
.expect("complete() should skip ToolCalls and return the Text step");
let result = llm.complete(make_completion_request("hi")).await;
assert_eq!(resp.content, "skipped past tools");
assert_eq!(resp.input_tokens, 20);
assert_eq!(resp.output_tokens, 8);
assert_eq!(resp.finish_reason, FinishReason::Stop);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("tool_calls"),
"Expected 'tool_calls' in error: {err_msg}"
);
}
#[tokio::test]
+28 -2
View File
@@ -5,8 +5,8 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
## Features
- **Repository Info** - Get repo details, list user repos
- **Issues** - List, create, and get issue details
- **Pull Requests** - List PRs, get PR details, review files, create reviews
- **Issues** - List/create/get issues, list/add issue comments
- **Pull Requests** - List/create/get PRs, review files, create reviews, list/reply review comments, merge PRs
- **File Content** - Read files from repos
- **Workflows** - Trigger GitHub Actions, check run status
@@ -82,6 +82,32 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
}
```
### Create Pull Request
```json
{
"action": "create_pull_request",
"owner": "nearai",
"repo": "ironclaw",
"title": "feat: add event-driven routines",
"head": "feat/event-routines",
"base": "main",
"body": "Implements system_event trigger + event_emit tool."
}
```
### Merge Pull Request
```json
{
"action": "merge_pull_request",
"owner": "nearai",
"repo": "ironclaw",
"pr_number": 42,
"merge_method": "squash"
}
```
### Get File Content
```json
@@ -9,7 +9,8 @@
"path_prefix": "/",
"methods": [
"GET",
"POST"
"POST",
"PUT"
]
}
],
@@ -56,4 +57,4 @@
"default_limit": 30,
"max_limit": 100
}
}
}
+428
View File
@@ -93,6 +93,21 @@ enum GitHubAction {
repo: String,
issue_number: u32,
},
#[serde(rename = "list_issue_comments")]
ListIssueComments {
owner: String,
repo: String,
issue_number: u32,
page: Option<u32>,
limit: Option<u32>,
},
#[serde(rename = "create_issue_comment")]
CreateIssueComment {
owner: String,
repo: String,
issue_number: u32,
body: String,
},
#[serde(rename = "list_pull_requests")]
ListPullRequests {
owner: String,
@@ -101,6 +116,16 @@ enum GitHubAction {
page: Option<u32>,
limit: Option<u32>,
},
#[serde(rename = "create_pull_request")]
CreatePullRequest {
owner: String,
repo: String,
title: String,
head: String,
base: String,
body: Option<String>,
draft: Option<bool>,
},
#[serde(rename = "get_pull_request")]
GetPullRequest {
owner: String,
@@ -121,6 +146,44 @@ enum GitHubAction {
body: String,
event: String,
},
#[serde(rename = "list_pull_request_comments")]
ListPullRequestComments {
owner: String,
repo: String,
pr_number: u32,
page: Option<u32>,
limit: Option<u32>,
},
#[serde(rename = "reply_pull_request_comment")]
ReplyPullRequestComment {
owner: String,
repo: String,
comment_id: u32,
body: String,
},
#[serde(rename = "get_pull_request_reviews")]
GetPullRequestReviews {
owner: String,
repo: String,
pr_number: u32,
page: Option<u32>,
limit: Option<u32>,
},
#[serde(rename = "get_combined_status")]
GetCombinedStatus {
owner: String,
repo: String,
r#ref: String,
},
#[serde(rename = "merge_pull_request")]
MergePullRequest {
owner: String,
repo: String,
pr_number: u32,
commit_title: Option<String>,
commit_message: Option<String>,
merge_method: Option<String>,
},
#[serde(rename = "list_repos")]
ListRepos {
username: String,
@@ -208,6 +271,19 @@ fn execute_inner(params: &str) -> Result<String, String> {
repo,
issue_number,
} => get_issue(&owner, &repo, issue_number),
GitHubAction::ListIssueComments {
owner,
repo,
issue_number,
page,
limit,
} => list_issue_comments(&owner, &repo, issue_number, page, limit),
GitHubAction::CreateIssueComment {
owner,
repo,
issue_number,
body,
} => create_issue_comment(&owner, &repo, issue_number, &body),
GitHubAction::ListPullRequests {
owner,
repo,
@@ -215,6 +291,23 @@ fn execute_inner(params: &str) -> Result<String, String> {
page,
limit,
} => list_pull_requests(&owner, &repo, state.as_deref(), page, limit),
GitHubAction::CreatePullRequest {
owner,
repo,
title,
head,
base,
body,
draft,
} => create_pull_request(
&owner,
&repo,
&title,
&head,
&base,
body.as_deref(),
draft.unwrap_or(false),
),
GitHubAction::GetPullRequest {
owner,
repo,
@@ -232,6 +325,44 @@ fn execute_inner(params: &str) -> Result<String, String> {
body,
event,
} => create_pr_review(&owner, &repo, pr_number, &body, &event),
GitHubAction::ListPullRequestComments {
owner,
repo,
pr_number,
page,
limit,
} => list_pull_request_comments(&owner, &repo, pr_number, page, limit),
GitHubAction::ReplyPullRequestComment {
owner,
repo,
comment_id,
body,
} => reply_pull_request_comment(&owner, &repo, comment_id, &body),
GitHubAction::GetPullRequestReviews {
owner,
repo,
pr_number,
page,
limit,
} => get_pull_request_reviews(&owner, &repo, pr_number, page, limit),
GitHubAction::GetCombinedStatus { owner, repo, r#ref } => {
get_combined_status(&owner, &repo, &r#ref)
}
GitHubAction::MergePullRequest {
owner,
repo,
pr_number,
commit_title,
commit_message,
merge_method,
} => merge_pull_request(
&owner,
&repo,
pr_number,
commit_title.as_deref(),
commit_message.as_deref(),
merge_method.as_deref(),
),
GitHubAction::ListRepos {
username,
page,
@@ -451,6 +582,49 @@ fn get_issue(owner: &str, repo: &str, issue_number: u32) -> Result<String, Strin
)
}
fn list_issue_comments(
owner: &str,
repo: &str,
issue_number: u32,
page: Option<u32>,
limit: Option<u32>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let limit = limit.unwrap_or(30).min(100);
let mut path = format!(
"/repos/{}/{}/issues/{}/comments?per_page={}",
encoded_owner, encoded_repo, issue_number, limit
);
if let Some(p) = page {
path.push_str(&format!("&page={}", p));
}
github_request("GET", &path, None)
}
fn create_issue_comment(
owner: &str,
repo: &str,
issue_number: u32,
body: &str,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
validate_input_length(body, "body")?;
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let path = format!(
"/repos/{}/{}/issues/{}/comments",
encoded_owner, encoded_repo, issue_number
);
let req_body = serde_json::json!({ "body": body });
github_request("POST", &path, Some(req_body.to_string()))
}
fn list_pull_requests(
owner: &str,
repo: &str,
@@ -478,6 +652,40 @@ fn list_pull_requests(
github_request("GET", &path, None)
}
fn create_pull_request(
owner: &str,
repo: &str,
title: &str,
head: &str,
base: &str,
body: Option<&str>,
draft: bool,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
validate_input_length(title, "title")?;
validate_input_length(head, "head")?;
validate_input_length(base, "base")?;
if let Some(b) = body {
validate_input_length(b, "body")?;
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let path = format!("/repos/{}/{}/pulls", encoded_owner, encoded_repo);
let mut req_body = serde_json::json!({
"title": title,
"head": head,
"base": base,
"draft": draft,
});
if let Some(body) = body {
req_body["body"] = serde_json::json!(body);
}
github_request("POST", &path, Some(req_body.to_string()))
}
fn get_pull_request(owner: &str, repo: &str, pr_number: u32) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
@@ -543,6 +751,132 @@ fn create_pr_review(
github_request("POST", &path, Some(req_body.to_string()))
}
fn list_pull_request_comments(
owner: &str,
repo: &str,
pr_number: u32,
page: Option<u32>,
limit: Option<u32>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let limit = limit.unwrap_or(30).min(100);
let mut path = format!(
"/repos/{}/{}/pulls/{}/comments?per_page={}",
encoded_owner, encoded_repo, pr_number, limit
);
if let Some(p) = page {
path.push_str(&format!("&page={}", p));
}
github_request("GET", &path, None)
}
fn reply_pull_request_comment(
owner: &str,
repo: &str,
comment_id: u32,
body: &str,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
validate_input_length(body, "body")?;
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let path = format!(
"/repos/{}/{}/pulls/comments/{}/replies",
encoded_owner, encoded_repo, comment_id
);
let req_body = serde_json::json!({ "body": body });
github_request("POST", &path, Some(req_body.to_string()))
}
fn get_pull_request_reviews(
owner: &str,
repo: &str,
pr_number: u32,
page: Option<u32>,
limit: Option<u32>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let limit = limit.unwrap_or(30).min(100);
let mut path = format!(
"/repos/{}/{}/pulls/{}/reviews?per_page={}",
encoded_owner, encoded_repo, pr_number, limit
);
if let Some(p) = page {
path.push_str(&format!("&page={}", p));
}
github_request("GET", &path, None)
}
fn get_combined_status(owner: &str, repo: &str, r#ref: &str) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
validate_input_length(r#ref, "ref")?;
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let encoded_ref = url_encode_path(r#ref);
let path = format!(
"/repos/{}/{}/commits/{}/status",
encoded_owner, encoded_repo, encoded_ref
);
github_request("GET", &path, None)
}
fn merge_pull_request(
owner: &str,
repo: &str,
pr_number: u32,
commit_title: Option<&str>,
commit_message: Option<&str>,
merge_method: Option<&str>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
if let Some(v) = commit_title {
validate_input_length(v, "commit_title")?;
}
if let Some(v) = commit_message {
validate_input_length(v, "commit_message")?;
}
let method = merge_method.unwrap_or("merge");
let valid_methods = ["merge", "squash", "rebase"];
if !valid_methods.contains(&method) {
return Err(format!(
"Invalid merge_method: '{}'. Must be one of: {}",
method,
valid_methods.join(", ")
));
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let path = format!(
"/repos/{}/{}/pulls/{}/merge",
encoded_owner, encoded_repo, pr_number
);
let mut req_body = serde_json::json!({
"merge_method": method,
});
if let Some(v) = commit_title {
req_body["commit_title"] = serde_json::json!(v);
}
if let Some(v) = commit_message {
req_body["commit_message"] = serde_json::json!(v);
}
github_request("PUT", &path, Some(req_body.to_string()))
}
fn list_repos(username: &str, page: Option<u32>, limit: Option<u32>) -> Result<String, String> {
if !validate_path_segment(username) {
return Err("Invalid username".into());
@@ -723,6 +1057,27 @@ const SCHEMA: &str = r#"{
},
"required": ["action", "owner", "repo", "issue_number"]
},
{
"properties": {
"action": { "const": "list_issue_comments" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"issue_number": { "type": "integer" },
"page": { "type": "integer" },
"limit": { "type": "integer", "default": 30 }
},
"required": ["action", "owner", "repo", "issue_number"]
},
{
"properties": {
"action": { "const": "create_issue_comment" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"issue_number": { "type": "integer" },
"body": { "type": "string" }
},
"required": ["action", "owner", "repo", "issue_number", "body"]
},
{
"properties": {
"action": { "const": "list_pull_requests" },
@@ -733,6 +1088,19 @@ const SCHEMA: &str = r#"{
},
"required": ["action", "owner", "repo"]
},
{
"properties": {
"action": { "const": "create_pull_request" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"title": { "type": "string" },
"head": { "type": "string" },
"base": { "type": "string" },
"body": { "type": "string" },
"draft": { "type": "boolean", "default": false }
},
"required": ["action", "owner", "repo", "title", "head", "base"]
},
{
"properties": {
"action": { "const": "get_pull_request" },
@@ -762,6 +1130,59 @@ const SCHEMA: &str = r#"{
},
"required": ["action", "owner", "repo", "pr_number", "body", "event"]
},
{
"properties": {
"action": { "const": "list_pull_request_comments" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"pr_number": { "type": "integer" },
"page": { "type": "integer" },
"limit": { "type": "integer", "default": 30 }
},
"required": ["action", "owner", "repo", "pr_number"]
},
{
"properties": {
"action": { "const": "reply_pull_request_comment" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"comment_id": { "type": "integer" },
"body": { "type": "string" }
},
"required": ["action", "owner", "repo", "comment_id", "body"]
},
{
"properties": {
"action": { "const": "get_pull_request_reviews" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"pr_number": { "type": "integer" },
"page": { "type": "integer" },
"limit": { "type": "integer", "default": 30 }
},
"required": ["action", "owner", "repo", "pr_number"]
},
{
"properties": {
"action": { "const": "get_combined_status" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"ref": { "type": "string" }
},
"required": ["action", "owner", "repo", "ref"]
},
{
"properties": {
"action": { "const": "merge_pull_request" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"pr_number": { "type": "integer" },
"commit_title": { "type": "string" },
"commit_message": { "type": "string" },
"merge_method": { "type": "string", "enum": ["merge", "squash", "rebase"], "default": "merge" }
},
"required": ["action", "owner", "repo", "pr_number"]
},
{
"properties": {
"action": { "const": "list_repos" },
@@ -835,6 +1256,13 @@ mod tests {
}
}
#[test]
fn test_validate_merge_method() {
let valid = ["merge", "squash", "rebase"];
assert!(valid.contains(&"merge"));
assert!(!valid.contains(&"invalid"));
}
#[test]
fn test_input_length_validation() {
assert!(validate_input_length("short", "test").is_ok());