Compare commits

..
Author SHA1 Message Date
[email protected]andClaude Opus 4.6 c5b1cdc2f9 fix: address PR review comments (round 2)
Human reviewer (zmanian):
- H1: Accept configurable embedding dimension in LanceDbVectorStore::new()
  instead of hardcoding 1536. Dimension is sourced from
  EmbeddingProvider::dimension() at init time.
- H2: Skip double-write of embeddings to DB when external vector store
  is active (pass None to insert_chunk for embedding column).
- H3: Update PR title from "refactor" to "feat" (net-new feature).
- H4: Document non-atomic update_embedding in struct doc comment.

Bot reviewer (Copilot):
- Cache LanceDB table handle via tokio::sync::OnceCell (avoid
  open_table per operation).
- Cache Arc<Schema> in struct (avoid rebuilding per insert).
- Fix error variants: ChunkingFailed → EmbeddingFailed for LanceDB
  store/delete operations.
- Propagate store_embedding errors in reindex_document instead of
  warn-only (prevents silent data loss).
- Prefetch document metadata map in backfill_embeddings to avoid N+1
  queries.
- Add lancedb feature + protoc to CI test matrix so LanceDB tests
  actually run on Linux.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-08 17:34:11 -07:00
[email protected] fe17196367 Merge remote-tracking branch 'origin/main' into feat/lancedb-backend 2026-03-08 14:10:48 -07:00
605a4ba46e fix(docker): bind postgres to localhost only (#686)
5432:5432 → 127.0.0.1:5432:5432 — the default docker-compose.yml
exposed postgres on all interfaces, making it reachable from the
local network in any docker compose deployment.

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Zaki Manian <[email protected]>
2026-03-08 20:55:57 +00:00
fe91ba2ab4 fix(repl): skip /quit on EOF when stdin is not a TTY (#724)
When running as a launchd/systemd daemon, stdin is /dev/null.
rustyline reads EOF immediately and the REPL thread was sending
a /quit message, causing the agent to shut down right after
startup — making service mode non-functional on both macOS and Linux.

Fix: check std::io::stdin().is_terminal() before sending /quit on
EOF. In daemon mode (no TTY) the REPL thread exits silently, leaving
other channels (gateway, telegram, …) running as expected.

Fixes #723

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Zaki Manian <[email protected]>
2026-03-08 20:40:56 +00:00
da2569bb77 fix(web): prevent Enter key from sending message during IME composition (#715)
Co-authored-by: Zaki Manian <[email protected]>
2026-03-08 20:40:31 +00:00
732b3ecfeb test(agent): wire TestRig job tools through the scheduler (#716)
Align TestRig with the production agent wiring so create_job exercises the real scheduler path instead of silently falling back to an unscheduled context-only job. Tighten the e2e assertion to lock in the in-progress scheduler behavior for future refactors.

Made-with: Cursor

Co-authored-by: Zaki Manian <[email protected]>
2026-03-08 20:40:03 +00:00
461d7712e8 fix(config): init_secrets no longer overwrites entire config (#726)
* fix(config): init_secrets no longer overwrites entire config

init_secrets() was calling Config::from_db_with_toml() to re-resolve
config after injecting credentials. This rebuilt the entire config from
env/DB/defaults, nuking all other config fields (agent, safety, tools,
etc.) even though only LlmConfig depends on injected credentials.

This caused 5 CI test failures: the test rig's carefully chosen config
values (max_tool_iterations, allow_local_tools, etc.) were silently
overwritten with production defaults after secret injection.

Fix: add Config::re_resolve_llm() that re-resolves only the LLM config
after credential injection, leaving all other config fields untouched.
Also fix TraceLlm::complete() to skip ToolCalls steps when called in
force_text mode (iteration limit).

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(test): update test to match TraceLlm::complete() skip-tool-calls behavior [skip-regression-check]

TraceLlm::complete() now skips ToolCalls steps (force_text mode) instead
of erroring. Update the test to verify it skips past a ToolCalls step and
returns the subsequent Text step.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Zaki <[email protected]>
2026-03-08 13:32:42 -07:00
ReidandGitHub 1c5117eded feat: add PID-based gateway lock to prevent multiple instances (#717) 2026-03-08 13:17:46 -07:00
ReidandGitHub 33b02eabb7 fix(cli): status command ignores config.toml and settings.json (#354) (#734) 2026-03-08 13:17:43 -07:00
ReidandGitHub 068ad2d4b7 Fix single-message mode to exit after one turn when background channels are enabled (#719) 2026-03-08 12:54:18 -07:00
[email protected]andClaude Opus 4.6 e84448d5ea ci: install protoc for lancedb feature builds
LanceDB depends on lance-encoding which requires protoc for protobuf
compilation. Add arduino/setup-protoc to all CI jobs that build with
--all-features.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-08 01:23:11 -08:00
[email protected]andClaude Opus 4.6 3f92b9cb24 Merge origin/main into feat/lancedb-backend
Resolve merge conflicts from main's config refactoring (config.rs split
into config/ directory), app builder pattern (src/app.rs), module renames
(libsql_backend → libsql), and new RankedResult.document_path field.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-08 01:19:25 -08:00
56b7218897 fix(setup): preserve model name when re-running onboarding with same provider (#600) (#694)
Each provider setup function unconditionally cleared selected_model,
so re-running the wizard with "Keep current provider? Yes" would lose
the model name, forcing the user to re-select it every time.

Now only clears selected_model when the backend actually changes
(old model may be invalid for the new provider). When keeping the
same provider, the model is preserved and Step 4 shows the
"Keep current model" prompt.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-08 08:32:02 +00:00
200aed16cd feat: configurable LLM request timeout via LLM_REQUEST_TIMEOUT_SECS (#615) (#630)
Add LLM_REQUEST_TIMEOUT_SECS env var (default: 120) to configure the
HTTP request timeout for LLM API calls. Primarily useful for local
models (Ollama, vLLM, LM Studio) that need more time for prompt
evaluation on consumer hardware.

The timeout is applied to the NearAI provider's HTTP client. Other
providers (Anthropic, OpenAI) use rig-core's default client.

- Add request_timeout_secs field to LlmConfig
- Thread timeout through create_llm_provider -> NearAiChatProvider
- Add NearAiChatProvider::new_with_timeout constructor
- Add .env.example documentation
- 2 regression tests for default and custom timeout values

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-08 08:30:52 +00:00
4c0275bcdc fix(setup): initialize secrets crypto for env-var security option (#666) (#706)
The "Environment variable" option in the setup wizard's security step
generated a master key but never initialized `secrets_crypto`, causing
subsequent API key saves to fail silently. Fix by:

1. Creating SecretsCrypto from the generated key (matching keychain path)
2. Storing the key hex in settings for write_bootstrap_env to persist
3. Auto-writing SECRETS_MASTER_KEY to ~/.ironclaw/.env
4. Using inject_single_var for thread-safe env overlay
5. Fixing misleading message (shell profiles don't work, only .env)

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-08 08:30:02 +00:00
272d31797e chore: remove dead code (#648) (#703)
* chore: remove dead code (LlmEvaluator, chunk_by_paragraphs, bundled channel installer, Reasoning::safety)

Delete unused code flagged in #648:
- evaluation/success.rs: delete LlmEvaluator struct/impl, remove #[allow(dead_code)] from RuleBasedEvaluator methods
- workspace/chunker.rs: delete chunk_by_paragraphs() and its tests (zero production callers)
- extensions/manager.rs: delete install_bundled_channel_from_artifacts() (hot-activation never shipped)
- llm/reasoning.rs: remove unused safety field from Reasoning struct; cascade removal through ContextCompactor, HeartbeatRunner, LlmSoftwareBuilder, and all callers

Closes #648

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: move RuleBasedEvaluator into test module to fix dead_code warning

RuleBasedEvaluator has no production callers -- it was only used in
tests of itself. Moving it into #[cfg(test)] eliminates the clippy
dead_code error that broke CI.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-08 08:26:04 +00:00
edff54b0b1 fix: persist /model selection across restarts (#707)
* fix: persist /model selection across restarts

The /model command called set_model() on the LLM provider but never
saved the choice to settings, so the model reverted on restart. Now
persists to both the DB settings store and config.toml.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address CI clippy lint and use spawn_blocking for TOML I/O

- Use struct init syntax instead of field reassignment in test (clippy)
- Wrap sync filesystem operations in spawn_blocking to avoid blocking
  the tokio executor

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt formatting

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review feedback — handle JoinError, remove exists() guard

- Log warning if spawn_blocking task panics/is cancelled (JoinError)
- Remove toml_path.exists() guard; load_toml already returns Ok(None)
  for missing files, so permission errors are no longer silently skipped

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-08 08:10:46 +00:00
[email protected]andClaude Opus 4.6 1e0494e72d refactor: replace LanceDB Database decorator with VectorStore composition
Instead of wrapping all ~80 Database trait methods in a 664-line decorator
(lancedb_wrapper.rs), introduce a 4-method VectorStore trait that any vector
backend can implement. Workspace composes FTS from the database with vector
search from the external store via RRF fusion.

- Add src/workspace/vector_store.rs with VectorStore trait
- Rewrite lancedb_store.rs to implement VectorStore (not wrap Database)
- Delete src/db/lancedb_wrapper.rs (664 lines removed)
- Remove get_chunk_by_id from Database trait and all backends
- Workspace gains with_vector_store() builder for optional composition
- Fix LanceDB tests: bypass_vector_index() for brute-force search
- Fix integration tests: use temp file DB (libSQL :memory: is per-connection)
- Merge duplicate mod tests in config.rs

Net: -724 lines. Adding a new vector backend requires 4 methods, not 80.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-08 00:06:04 -08:00
4d61d3eedf fix(routines): resolve message tool channel/target from per-job metadata (#708)
* fix(routines): resolve message tool channel/target from per-job metadata

When a routine's notify.channel is None, the message tool had no way to
resolve channel/target for full-job workers, causing "No target specified"
errors. The previous approach mutated shared global state via
set_message_tool_context(), which also raced with concurrent jobs.

Now the routine's notify config (channel + user) is carried in the job's
metadata JSON, and MessageTool::execute falls back to ctx.metadata when
neither explicit params nor conversation defaults are available. This
eliminates both the None-channel bug and the concurrent-job race.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: apply cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(message): broadcast to all channels when notify.channel is None

Address review feedback:
- Fix stale "see above" comment → "populated below"
- When notify.channel is None, use broadcast_all instead of erroring
  with "No channel specified". This matches NotifyConfig semantics
  where channel=None means "broadcast to all channels"
- Channel resolution is now Option<String>: param → default → metadata → None
- When None, MessageTool uses ChannelManager::broadcast_all(target, response)
  and reports which channels succeeded/failed
- Add regression test for broadcast-all behavior

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use failed channels in error message, remove redundant comment

Address review feedback:
- Use `failed` vec in error message instead of re-querying channel_names
- Remove redundant orphaned comment block in routine_engine.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-08 08:04:19 +00:00
df3635d6be feat(timezone): add timezone-aware session context (#671)
* feat(timezone): add timezone-aware session context (#661)

All timestamps were UTC-only, causing daily logs to split at UTC midnight,
cron schedules to fire in UTC, and no quiet hours for heartbeat. This adds
timezone as a per-session property flowing from the client.

Key changes:
- New `src/timezone.rs` module with resolution chain, parsing, and detection
- `IncomingMessage` carries optional timezone from client
- `JobContext.user_timezone` flows timezone to tools
- `next_cron_fire()` accepts timezone for schedule evaluation
- `Trigger::Cron` stores optional timezone (backward-compatible)
- Workspace gains `_tz` variants for daily logs and system prompt
- Heartbeat supports quiet hours (`HEARTBEAT_QUIET_START/END`)
- Web frontend sends `Intl.DateTimeFormat().resolvedOptions().timeZone`
- REPL auto-detects system timezone
- `DEFAULT_TIMEZONE` env var / settings for server-wide default

Storage stays UTC. Conversion happens at display boundaries.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(timezone): address review feedback on timezone-aware sessions

- Validate quiet hours values (0-23) in HeartbeatConfig::resolve()
- Fall back to settings values when env vars are unset for quiet hours
- Validate IANA timezone strings in routine_create/update with parse_timezone
- Add timezone field to routine_create tool schema
- Allow standalone timezone update on cron routines without changing schedule
- Return path from append_daily_log_tz to avoid TOCTOU race at midnight
- Delegate append_daily_log to append_daily_log_tz(entry, UTC) to avoid drift
- Preserve timezone through approval flow via PendingApproval.user_timezone
- Improve test_today_in_tz to not depend on hardcoded year
- Add 3 regression tests for quiet hours config validation

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix formatting in routine.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(timezone): address second round of review feedback

- Remove .claude/scheduled_tasks.lock from repo and add to .gitignore
- Store resolved timezone (not raw message.timezone) in PendingApproval
- Carry forward user_timezone through chained approvals in thread_ops
- Wire quiet_hours_start/end from config to HeartbeatRunner
- Support X-Timezone header as fallback in chat_send_handler

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(timezone): include user's local time in time tool response

The time tool's "now" operation now returns local_iso and timezone
fields based on ctx.user_timezone, so the LLM can report time in
the user's timezone instead of always UTC.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix formatting in time.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(timezone): address Copilot review round 3 — validation, deterministic tests, schema fixes

- Validate DEFAULT_TIMEZONE and HEARTBEAT_TIMEZONE at config load time
- Add timezone field to HeartbeatSettings and config::HeartbeatConfig
- Wire heartbeat timezone from config through agent_loop to HeartbeatRunner
- Add timezone to routine_update tool schema (was accepted but not advertised)
- Error on schedule/timezone update for non-cron routines
- Validate timezone in Trigger::from_db (coerce invalid to None with warning)
- Validate timezone in approval path (thread_ops.rs) before overwriting
- Time tool always includes timezone/local_iso fields (fallback to UTC)
- Make quiet hours tests deterministic using current UTC hour
- Add regression tests for config validation

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-08 08:01:56 +00:00
ReidandGitHub a20e19ab16 fix: sanitize HTML error bodies from MCP servers to prevent web UI white screen (#263) (#656)
* fix: sanitize HTML error bodies from MCP servers to prevent web UI white screen (#263)

* style: fix cargo fmt formatting in sanitize_error_body tests
2026-03-08 02:53:02 +00:00
c9dbcb2627 Update .env.example
Co-authored-by: Copilot <[email protected]>
2026-02-22 19:52:40 +04:00
Ilgın KanatandGitHub 98f44754e4 Merge branch 'main' into feat/lancedb-backend 2026-02-18 17:03:53 +04:00
e239ea850f Update src/workspace/lancedb_store.rs
Co-authored-by: Copilot <[email protected]>
2026-02-18 16:58:38 +04:00
ILGIN KANAT 6489c433f6 feat: add SQL-style escaping for LanceDB predicate values
- Introduced `escape_predicate_value` function to safely escape strings for use in LanceDB predicate expressions, preventing SQL injection.
- Updated delete operations in the LanceDB vector store to utilize the new escaping function for `chunk_id` and `document_id`.
- Enhanced filtering logic to apply escaping for `user_id` and `agent_id` in query conditions.

This change improves security by ensuring that user inputs are properly sanitized before being used in database queries.
2026-02-18 16:57:46 +04:00
ILGIN KANAT 327e009622 feat: add LanceDB support for workspace semantic search
- Introduced optional LanceDB vector store for semantic search, configurable via environment variables.
- Updated `.env.example` and `Cargo.toml` to include LanceDB settings.
- Enhanced `DatabaseConfig` to support vector backend selection and LanceDB path configuration.
- Implemented `VectorBackend` enum to manage vector store options.
- Added functionality to connect to LanceDB in the database connection logic.
- Updated relevant documentation to reflect new features and configuration options.

This change allows users to leverage LanceDB as an alternative to pgvector/libsql for improved search capabilities.
2026-02-18 11:06:09 +04:00
69 changed files with 6281 additions and 915 deletions
+7
View File
@@ -2,9 +2,16 @@
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,6 +36,11 @@ 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 }}
@@ -62,6 +67,11 @@ 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 }}
+15 -1
View File
@@ -14,7 +14,7 @@ jobs:
matrix:
include:
- name: all-features
flags: "--features postgres,libsql,html-to-markdown"
flags: "--features postgres,libsql,lancedb,html-to-markdown"
- name: default
flags: ""
- name: libsql-only
@@ -26,6 +26,11 @@ 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 }}
@@ -66,6 +71,11 @@ 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 }}
@@ -82,6 +92,10 @@ 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
+2 -1
View File
@@ -4,8 +4,9 @@
.env.*
!.env.example
# Claude Code worktrees
# Claude Code worktrees and lock files
.claude/worktrees/
.claude/scheduled_tasks.lock
# Sidecar tool data
.sidecar/
Generated
+3044 -36
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -73,6 +73,8 @@ 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"
@@ -123,6 +125,11 @@ 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
@@ -187,6 +194,7 @@ 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",
+2 -2
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) | ✅ | | |
| Gateway lock (PID-based) | ✅ | | `fs4` flock-based, acquired in `main.rs` before agent startup |
| 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 | ✅ | | Configurable auto-capture max length |
| LanceDB backend | ✅ | | VectorStore trait + LanceDbVectorStore (configured via VECTOR_BACKEND=lancedb) |
| QMD backend | ✅ | ❌ | |
| Atomic reindexing | ✅ | ✅ | |
| Embeddings batching | ✅ | ✅ | `embed_batch` on EmbeddingProvider trait |
+1 -1
View File
@@ -3,7 +3,7 @@ services:
postgres:
image: pgvector/pgvector:pg16
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
environment:
POSTGRES_DB: ironclaw
POSTGRES_USER: ironclaw
+6 -1
View File
@@ -356,6 +356,12 @@ 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)
{
@@ -411,7 +417,6 @@ impl Agent {
hygiene,
workspace.clone(),
self.cheap_llm().clone(),
self.safety().clone(),
Some(notify_tx),
self.store().map(Arc::clone),
))
+48 -7
View File
@@ -345,7 +345,6 @@ impl Agent {
crate::workspace::hygiene::HygieneConfig::default(),
workspace.clone(),
self.llm().clone(),
self.safety().clone(),
);
match runner.check_heartbeat().await {
@@ -406,7 +405,7 @@ impl Agent {
.with_max_tokens(512)
.with_temperature(0.3);
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
let reasoning = Reasoning::new(self.llm().clone());
match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Thread Summary:\n\n{}",
@@ -454,7 +453,7 @@ impl Agent {
.with_max_tokens(512)
.with_temperature(0.5);
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
let reasoning = Reasoning::new(self.llm().clone());
match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Suggested Next Steps:\n\n{}",
@@ -663,10 +662,14 @@ impl Agent {
}
match self.llm().set_model(requested) {
Ok(()) => Ok(SubmissionResult::response(format!(
"Switched model to: {}",
requested
))),
Ok(()) => {
// Persist the model choice so it survives restarts.
self.persist_selected_model(requested).await;
Ok(SubmissionResult::response(format!(
"Switched model to: {}",
requested
)))
}
Err(e) => Ok(SubmissionResult::error(format!(
"Failed to switch model: {}",
e
@@ -822,4 +825,42 @@ 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);
}
}
}
+4 -12
View File
@@ -13,7 +13,6 @@ 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.
@@ -34,13 +33,12 @@ 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>, safety: Arc<SafetyLayer>) -> Self {
Self { llm, safety }
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
Self { llm }
}
/// Compact a thread's context using the given strategy.
@@ -233,7 +231,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(), self.safety.clone());
let reasoning = Reasoning::new(self.llm.clone());
let (text, _) = reasoning.complete(request).await?;
Ok(text)
}
@@ -346,17 +344,11 @@ 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 {
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
ContextCompactor::new(llm, safety)
ContextCompactor::new(llm)
}
/// Helper: build a thread with `n` completed turns.
+20 -12
View File
@@ -50,8 +50,18 @@ 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(is_group_chat).await {
match ws
.system_prompt_for_context_tz(is_group_chat, user_tz)
.await
{
Ok(prompt) if !prompt.is_empty() => Some(prompt),
Ok(_) => None,
Err(e) => {
@@ -103,7 +113,7 @@ impl Agent {
None
};
let mut reasoning = Reasoning::new(self.llm().clone(), self.safety().clone())
let mut reasoning = Reasoning::new(self.llm().clone())
.with_channel(message.channel.clone())
.with_model_name(self.llm().active_model_name())
.with_group_chat(is_group_chat);
@@ -130,6 +140,7 @@ 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).
@@ -785,6 +796,7 @@ 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 });
@@ -1146,6 +1158,7 @@ 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()),
@@ -1248,6 +1261,7 @@ mod tests {
arguments: serde_json::json!({"message": "done"}),
},
],
user_timezone: None,
};
let json = serde_json::to_string(&pending).expect("serialize");
@@ -1595,12 +1609,8 @@ 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(), safety);
let reasoning = Reasoning::new(stub.clone());
// Build a fat context with lots of history.
let messages = vec![
@@ -1710,11 +1720,7 @@ mod tests {
use crate::llm::{Reasoning, ReasoningContext, RespondResult, ToolDefinition};
let provider = Arc::new(AlwaysToolCallProvider);
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let reasoning = Reasoning::new(provider, safety);
let reasoning = Reasoning::new(provider);
let tool_def = ToolDefinition {
name: "echo".to_string(),
@@ -1900,6 +1906,7 @@ mod tests {
max_actions_per_hour: None,
max_tool_iterations,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
},
deps,
Arc::new(ChannelManager::new()),
@@ -2015,6 +2022,7 @@ 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()),
+114 -8
View File
@@ -31,7 +31,6 @@ 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;
@@ -48,6 +47,12 @@ 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 {
@@ -58,6 +63,9 @@ impl Default for HeartbeatConfig {
max_failures: 3,
notify_user_id: None,
notify_channel: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
}
}
}
@@ -75,6 +83,26 @@ 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());
@@ -102,7 +130,6 @@ 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,
@@ -115,14 +142,12 @@ 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,
@@ -162,6 +187,12 @@ 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);
@@ -272,7 +303,7 @@ impl HeartbeatRunner {
.with_max_tokens(max_tokens)
.with_temperature(0.3);
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let reasoning = Reasoning::new(self.llm.clone());
let (content, _usage) = match reasoning.complete(request).await {
Ok(r) => r,
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
@@ -386,11 +417,10 @@ 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, safety);
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm);
if let Some(tx) = response_tx {
runner = runner.with_response_channel(tx);
}
@@ -532,6 +562,83 @@ 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
@@ -543,7 +650,6 @@ 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;
+87 -9
View File
@@ -57,7 +57,11 @@ 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 },
Cron {
schedule: String,
#[serde(default)]
timezone: Option<String>,
},
/// Fire when a channel message matches a pattern.
Event {
/// Optional channel filter (e.g. "telegram", "slack").
@@ -99,7 +103,21 @@ impl Trigger {
field: "schedule".into(),
})?
.to_string();
Ok(Trigger::Cron { schedule })
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 })
}
"event" => {
let pattern = config
@@ -137,7 +155,10 @@ 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 } => serde_json::json!({ "schedule": schedule }),
Trigger::Cron { schedule, timezone } => serde_json::json!({
"schedule": schedule,
"timezone": timezone,
}),
Trigger::Event { channel, pattern } => serde_json::json!({
"pattern": pattern,
"channel": channel,
@@ -415,12 +436,25 @@ pub fn content_hash(content: &str) -> u64 {
}
/// Parse a cron expression and compute the next fire time from now.
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, RoutineError> {
///
/// 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> {
let cron_schedule =
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
reason: e.to_string(),
})?;
Ok(cron_schedule.upcoming(Utc).next())
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())
}
}
#[cfg(test)]
@@ -433,10 +467,11 @@ 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]
@@ -509,16 +544,58 @@ mod tests {
#[test]
fn test_next_cron_fire_valid() {
// Every minute should always have a next fire
let next = next_cron_fire("* * * * * *").expect("valid cron");
let next = next_cron_fire("* * * * * *", None).expect("valid cron");
assert!(next.is_some());
}
#[test]
fn test_next_cron_fire_invalid() {
let result = next_cron_fire("not a cron");
let result = next_cron_fire("not a cron", None);
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();
@@ -531,7 +608,8 @@ mod tests {
fn test_trigger_type_tag() {
assert_eq!(
Trigger::Cron {
schedule: String::new()
schedule: String::new(),
timezone: None,
}
.type_tag(),
"cron"
+12 -13
View File
@@ -170,7 +170,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,8 +380,12 @@ 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 } = routine.trigger {
next_cron_fire(schedule).unwrap_or(None)
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = routine.trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else {
None
};
@@ -488,18 +492,13 @@ async fn execute_full_job(
reason: "scheduler not available".to_string(),
})?;
// 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.
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.
if let Some(channel) = &routine.notify.channel {
scheduler
.tools()
.set_message_tool_context(Some(channel.clone()), Some(routine.notify.user.clone()))
.await;
metadata["notify_channel"] = serde_json::json!(channel);
}
let metadata = serde_json::json!({ "max_iterations": max_iterations });
metadata["notify_user"] = serde_json::json!(&routine.notify.user);
// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
// Always tools require explicit listing in tool_permissions.
+6
View File
@@ -164,6 +164,10 @@ 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.
@@ -976,6 +980,7 @@ 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);
@@ -1001,6 +1006,7 @@ mod tests {
tool_call_id: "call_456".to_string(),
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
};
thread.await_approval(approval);
+14 -2
View File
@@ -230,7 +230,7 @@ impl Agent {
)
.await;
let compactor = ContextCompactor::new(self.llm().clone(), self.safety().clone());
let compactor = ContextCompactor::new(self.llm().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(), self.safety().clone());
let compactor = ContextCompactor::new(self.llm().clone());
match compactor
.compact(thread, strategy, self.workspace().map(|w| w.as_ref()))
.await
@@ -746,6 +746,16 @@ 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
@@ -1111,6 +1121,8 @@ 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(), self.safety().clone());
let reasoning = Reasoning::new(self.llm().clone());
// Build initial reasoning context (tool definitions refreshed each iteration in execution_loop)
let mut reason_ctx = ReasoningContext::new().with_job(&job_ctx.description);
+65 -27
View File
@@ -255,15 +255,18 @@ impl AppBuilder {
self.libsql_db.take();
}
// 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");
}
// 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}"
);
}
return Ok(());
@@ -308,18 +311,16 @@ impl AppBuilder {
// Inject LLM API keys from encrypted storage
crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), "default").await;
// 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);
}
}
// 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}");
}
}
@@ -385,12 +386,53 @@ 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)
@@ -403,11 +445,7 @@ impl AppBuilder {
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
{
tools
.register_builder_tool(
llm.clone(),
safety.clone(),
Some(self.config.builder.to_builder_config()),
)
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
tracing::info!("Builder mode enabled");
}
@@ -542,7 +580,7 @@ impl AppBuilder {
server, mcp_sm, secrets, "default",
)
} else {
McpClient::new_with_config(server.clone())
McpClient::new_with_name(&server_name, &server.url)
};
match client.list_tools().await {
+251
View File
@@ -414,10 +414,103 @@ 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(());
@@ -986,4 +1079,162 @@ 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,6 +79,8 @@ 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>,
}
@@ -99,6 +101,7 @@ impl IncomingMessage {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::Value::Null,
timezone: None,
attachments: Vec::new(),
}
}
@@ -121,6 +124,12 @@ 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;
@@ -454,4 +463,10 @@ 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"));
}
}
+50 -9
View File
@@ -18,7 +18,7 @@
//! - `Esc` - Interrupt current operation
use std::borrow::Cow;
use std::io::{self, Write};
use std::io::{self, IsTerminal, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -297,10 +297,15 @@ 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);
let incoming = IncomingMessage::new("repl", "default", &msg).with_timezone(&sys_tz);
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;
}
@@ -361,7 +366,8 @@ 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");
let msg = IncomingMessage::new("repl", "default", "/quit")
.with_timezone(&sys_tz);
let _ = tx.blocking_send(msg);
break;
}
@@ -382,7 +388,8 @@ impl Channel for ReplChannel {
_ => {}
}
let msg = IncomingMessage::new("repl", "default", line);
let msg =
IncomingMessage::new("repl", "default", line).with_timezone(&sys_tz);
if tx.blocking_send(msg).is_err() {
break;
}
@@ -390,21 +397,29 @@ 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");
let msg = IncomingMessage::new("repl", "default", "/interrupt")
.with_timezone(&sys_tz);
if tx.blocking_send(msg).is_err() {
break;
}
} else {
// Ctrl+C (VINTR): request graceful shutdown.
let msg = IncomingMessage::new("repl", "default", "/quit");
let msg = IncomingMessage::new("repl", "default", "/quit")
.with_timezone(&sys_tz);
let _ = tx.blocking_send(msg);
break;
}
}
Err(ReadlineError::Eof) => {
// Ctrl+D: send /quit so the agent loop runs graceful shutdown
let msg = IncomingMessage::new("repl", "default", "/quit");
let _ = tx.blocking_send(msg);
// 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);
}
break;
}
Err(e) => {
@@ -614,3 +629,29 @@ 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"
);
}
}
+1 -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 {
+10 -1
View File
@@ -610,6 +610,7 @@ 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!(
@@ -626,6 +627,14 @@ 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);
@@ -2115,7 +2124,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 {
+10 -2
View File
@@ -181,6 +181,7 @@ function confirmRestart() {
body: {
content: '/restart',
thread_id: currentThreadId,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
.then((response) => {
@@ -454,7 +455,7 @@ function sendMessage() {
apiFetch('/api/chat/send', {
method: 'POST',
body: { content, thread_id: currentThreadId || undefined },
body: { content, thread_id: currentThreadId || undefined, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
}).catch((err) => {
addMessage('system', 'Failed to send: ' + err.message);
});
@@ -563,6 +564,13 @@ 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);
@@ -1473,7 +1481,7 @@ chatInput.addEventListener('keydown', (e) => {
}
}
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
hideSlashAutocomplete();
sendMessage();
+8 -2
View File
@@ -9,6 +9,7 @@ use uuid::Uuid;
pub struct SendMessageRequest {
pub content: String,
pub thread_id: Option<String>,
pub timezone: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -613,6 +614,7 @@ pub enum WsClientMessage {
Message {
content: String,
thread_id: Option<String>,
timezone: Option<String>,
},
/// Approve or deny a pending tool execution.
#[serde(rename = "approval")]
@@ -798,7 +800,9 @@ 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"));
}
@@ -811,7 +815,9 @@ 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());
}
+10 -1
View File
@@ -156,8 +156,15 @@ async fn handle_client_message(
direct_tx: &mpsc::Sender<WsServerMessage>,
) {
match msg {
WsClientMessage::Message { content, thread_id } => {
WsClientMessage::Message {
content,
thread_id,
timezone,
} => {
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);
}
@@ -349,6 +356,7 @@ mod tests {
WsClientMessage::Message {
content: "hello agent".to_string(),
thread_id: Some("t1".to_string()),
timezone: None,
},
&state,
"user1",
@@ -373,6 +381,7 @@ mod tests {
WsClientMessage::Message {
content: "hello".to_string(),
thread_id: None,
timezone: None,
},
&state,
"user1",
+1 -32
View File
@@ -47,10 +47,6 @@ pub enum McpCommand {
/// Server description
#[arg(long)]
description: Option<String>,
/// Custom HTTP headers (format: "Key:Value", can be repeated)
#[arg(long = "header", short = 'H')]
headers: Vec<String>,
},
/// Remove an MCP server
@@ -112,7 +108,6 @@ pub async fn run_mcp_command(cmd: McpCommand) -> anyhow::Result<()> {
token_url,
scopes,
description,
headers,
} => {
add_server(
name,
@@ -122,7 +117,6 @@ pub async fn run_mcp_command(cmd: McpCommand) -> anyhow::Result<()> {
token_url,
scopes,
description,
headers,
)
.await
}
@@ -139,7 +133,6 @@ pub async fn run_mcp_command(cmd: McpCommand) -> anyhow::Result<()> {
}
/// Add a new MCP server.
#[allow(clippy::too_many_arguments)]
async fn add_server(
name: String,
url: String,
@@ -148,7 +141,6 @@ async fn add_server(
token_url: Option<String>,
scopes: Option<String>,
description: Option<String>,
headers: Vec<String>,
) -> anyhow::Result<()> {
let mut config = McpServerConfig::new(&name, &url);
@@ -156,18 +148,6 @@ async fn add_server(
config = config.with_description(desc);
}
// Parse custom headers (format: "Key:Value")
if !headers.is_empty() {
let mut header_map = std::collections::HashMap::new();
for h in &headers {
let (key, value) = h.split_once(':').ok_or_else(|| {
anyhow::anyhow!("Invalid header format '{}'. Expected 'Key:Value'.", h)
})?;
header_map.insert(key.trim().to_string(), value.trim().to_string());
}
config = config.with_headers(header_map);
}
// Track if auth is required
let requires_auth = client_id.is_some();
@@ -262,17 +242,6 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {
if let Some(ref desc) = server.description {
println!(" Description: {}", desc);
}
if !server.headers.is_empty() {
println!(
" Custom headers: {}",
server
.headers
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
if let Some(ref oauth) = server.oauth {
println!(" OAuth Client ID: {}", oauth.client_id);
if !oauth.scopes.is_empty() {
@@ -405,7 +374,7 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
return Ok(());
} else {
// No OAuth and no tokens - try unauthenticated
McpClient::new_with_config(server.clone())
McpClient::new_with_name(&server.name, &server.url)
};
// Test connection
+123 -1
View File
@@ -8,9 +8,35 @@ 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 = Settings::default();
let settings = load_settings();
println!("IronClaw Status");
println!("===============\n");
@@ -209,3 +235,99 @@ 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,6 +27,8 @@ 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 {
@@ -47,6 +49,7 @@ impl AgentConfig {
max_actions_per_hour: None,
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
}
}
@@ -89,6 +92,40 @@ 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,6 +82,31 @@ 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 {
@@ -101,6 +126,12 @@ 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 {
@@ -159,6 +190,25 @@ 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),
@@ -167,6 +217,8 @@ impl DatabaseConfig {
libsql_path,
libsql_url,
libsql_auth_token,
vector_backend,
lancedb_path,
})
}
@@ -195,6 +247,11 @@ 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::*;
@@ -224,4 +281,39 @@ 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"));
}
}
+102 -1
View File
@@ -1,4 +1,4 @@
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
@@ -13,6 +13,12 @@ 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 {
@@ -22,6 +28,9 @@ 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,
}
}
}
@@ -38,6 +47,98 @@ 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,6 +103,10 @@ 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.
@@ -165,6 +169,7 @@ impl LlmConfig {
smart_routing_cascade: false,
},
provider: None,
request_timeout_secs: 120,
}
}
@@ -254,6 +259,8 @@ impl LlmConfig {
)?)
};
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
Ok(Self {
backend: if is_nearai {
"nearai".to_string()
@@ -265,6 +272,7 @@ impl LlmConfig {
session,
nearai,
provider,
request_timeout_secs,
})
}
@@ -1016,4 +1024,30 @@ 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");
}
}
}
+32 -1
View File
@@ -33,7 +33,10 @@ 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, default_libsql_path};
pub use self::database::{
DatabaseBackend, DatabaseConfig, SslMode, VectorBackend, default_lancedb_path,
default_libsql_path,
};
pub use self::embeddings::EmbeddingsConfig;
pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
@@ -107,6 +110,8 @@ 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(),
@@ -257,6 +262,32 @@ 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,6 +164,8 @@ 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 {
@@ -203,9 +205,16 @@ 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,6 +121,9 @@ 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),
+123 -227
View File
@@ -1,13 +1,10 @@
//! 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)]
@@ -64,233 +61,132 @@ 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::JobContext;
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,
})
}
}
}
#[tokio::test]
async fn test_rule_based_evaluator_success() {
+1 -33
View File
@@ -1405,38 +1405,6 @@ 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),
@@ -2508,7 +2476,7 @@ impl ExtensionManager {
&self.user_id,
)
} else {
McpClient::new_with_config(server.clone())
McpClient::new_with_name(&server.name, &server.url)
};
// Try to list and create tools
+3
View File
@@ -241,6 +241,9 @@ 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),
+1
View File
@@ -66,6 +66,7 @@ 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;
+21 -4
View File
@@ -58,8 +58,10 @@ 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);
return create_llm_provider_with_config(&config.nearai, session, timeout);
}
let reg_config = config
@@ -79,6 +81,7 @@ 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"
@@ -89,9 +92,14 @@ 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(config.clone(), session)?))
Ok(Arc::new(NearAiChatProvider::new_with_timeout(
config.clone(),
session,
request_timeout_secs,
)?))
}
/// Create a provider from a registry-resolved config.
@@ -365,7 +373,11 @@ 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())?;
let cheap = create_llm_provider_with_config(
&cheap_config,
session.clone(),
config.request_timeout_secs,
)?;
let cheap: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
Arc::new(RetryProvider::new(cheap, retry_config.clone()))
} else {
@@ -397,7 +409,11 @@ 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())?;
let fallback = create_llm_provider_with_config(
&fallback_config,
session.clone(),
config.request_timeout_secs,
)?;
tracing::info!(
primary = %llm.model_name(),
fallback = %fallback.model_name(),
@@ -503,6 +519,7 @@ mod tests {
session: SessionConfig::default(),
nearai: test_nearai_config(),
provider: None,
request_timeout_secs: 120,
}
}
+15 -4
View File
@@ -58,17 +58,28 @@ 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_flatten(config, session, true)
Self::new_with_options(config, session, true, 120)
}
/// Create a chat completions provider with configurable tool-message flattening.
pub fn new_with_flatten(
/// 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(
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(120))
.timeout(std::time::Duration::from_secs(request_timeout_secs))
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
+2 -12
View File
@@ -11,7 +11,6 @@ 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.
@@ -343,8 +342,6 @@ 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.
@@ -362,10 +359,9 @@ pub struct Reasoning {
impl Reasoning {
/// Create a new reasoning engine.
pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> Self {
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
Self {
llm,
safety,
workspace_system_prompt: None,
skill_context: None,
channel: None,
@@ -2117,15 +2113,9 @@ 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"));
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
Reasoning::new(llm, safety)
Reasoning::new(llm)
}
#[test]
+2 -1
View File
@@ -200,9 +200,10 @@ 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, body),
reason: format!("Validation failed: HTTP {status}: {preview}"),
})
}
+23 -6
View File
@@ -145,6 +145,24 @@ 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
@@ -166,13 +184,12 @@ 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 }) => {
eprintln!("Configuration error: Missing required setting '{}'", key);
eprintln!(" {}", hint);
eprintln!();
eprintln!(
"Run 'ironclaw onboard' to configure, or set the required environment variables."
anyhow::bail!(
"Configuration error: Missing required setting '{}'. {}. \
Run 'ironclaw onboard' to configure, or set the required environment variables.",
key,
hint
);
std::process::exit(1);
}
Err(e) => return Err(e.into()),
};
+53
View File
@@ -42,6 +42,10 @@ 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)]
@@ -291,6 +295,18 @@ 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 {
@@ -304,6 +320,9 @@ 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,
}
}
}
@@ -351,6 +370,10 @@ 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 {
@@ -385,6 +408,10 @@ fn default_max_tool_iterations() -> usize {
50
}
fn default_timezone() -> String {
"UTC".to_string()
}
fn default_true() -> bool {
true
}
@@ -402,6 +429,7 @@ 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(),
}
}
}
@@ -1174,6 +1202,31 @@ 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"));
+106 -12
View File
@@ -769,13 +769,28 @@ impl SetupWizard {
print_success("Master key generated and stored in OS keychain");
}
1 => {
// Env var mode
print_info("Generate a key and add it to your environment:");
// Env var mode — generate key, init crypto, and persist to .env
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!();
println!(" export SECRETS_MASTER_KEY={}", key_hex);
print_info("Master key generated and will be saved to ~/.ironclaw/.env");
println!();
print_info("Add this to your shell profile or .env file.");
println!(" SECRETS_MASTER_KEY={}", key_hex);
println!();
print_info("You can also copy this to another .env file or CI secrets.");
self.settings.secrets_master_key_source = KeySource::Env;
print_success("Configured for environment variable");
@@ -1022,10 +1037,11 @@ impl SetupWizard {
/// Anthropic OAuth setup: extract token from `claude login` credentials.
async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> {
self.settings.llm_backend = Some("anthropic".to_string());
if self.settings.selected_model.is_some() {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some("anthropic") {
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() {
@@ -1119,10 +1135,11 @@ impl SetupWizard {
other => other,
});
self.settings.llm_backend = Some(backend.to_string());
if self.settings.selected_model.is_some() {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(backend) {
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) {
@@ -1181,10 +1198,11 @@ impl SetupWizard {
&mut self,
def: &crate::llm::ProviderDefinition,
) -> Result<(), SetupError> {
self.settings.llm_backend = Some(def.id.clone());
if self.settings.selected_model.is_some() {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(&def.id) {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some(def.id.clone());
let default_url = self
.settings
@@ -1219,10 +1237,11 @@ impl SetupWizard {
secret_name: &str,
display_name: &str,
) -> Result<(), SetupError> {
self.settings.llm_backend = Some(backend_id.to_string());
if self.settings.selected_model.is_some() {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(backend_id) {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some(backend_id.to_string());
let existing_url = self
.settings
@@ -1476,6 +1495,7 @@ impl SetupWizard {
smart_routing_cascade: true,
},
provider: None,
request_timeout_secs: 120,
};
match create_llm_provider(&config, session) {
@@ -2324,6 +2344,12 @@ 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 {
@@ -3499,6 +3525,48 @@ 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
@@ -3536,4 +3604,30 @@ 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,6 +1009,7 @@ mod tests {
enabled: true,
trigger: Trigger::Cron {
schedule: "0 * * * *".to_string(),
timezone: None,
},
action: RoutineAction::Lightweight {
prompt: "Check status".to_string(),
+110
View File
@@ -0,0 +1,110 @@
//! 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()));
}
}
+4 -16
View File
@@ -43,7 +43,6 @@ 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};
@@ -251,29 +250,18 @@ 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>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
) -> Self {
pub fn new(config: BuilderConfig, llm: Arc<dyn LlmProvider>, 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,
safety,
tools,
}
Self { config, llm, tools }
}
/// Get the build tools available for the build loop.
@@ -521,7 +509,7 @@ Create alongside the .wasm file to grant capabilities:
let mut iteration = 0;
// Create reasoning engine
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let reasoning = Reasoning::new(self.llm.clone());
// Build initial context
let tool_defs = self.get_build_tools().await;
@@ -822,7 +810,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(), self.safety.clone());
let reasoning = Reasoning::new(self.llm.clone());
let prompt = format!(
r#"Analyze this software requirement and extract structured information.
+5 -4
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,11 +239,12 @@ 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(content)
.append_daily_log_tz(content, tz)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d"))
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?
}
"heartbeat" => {
if append {
+184 -50
View File
@@ -105,42 +105,47 @@ 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 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
// 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
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
.ok_or_else(|| {
ToolError::ExecutionFailed(
"No channel specified and no active conversation. Provide channel parameter."
.to_string(),
)
})?
};
{
Some(c)
} else {
ctx.metadata
.get("notify_channel")
.and_then(|v| v.as_str())
.map(|c| c.to_string())
};
// Get target: use param or fall back to default
// Get target: use param → conversation default → job metadata
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 {
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(),
)
})?
return Err(ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
.to_string(),
));
};
let attachments: Vec<String> = match params.get("attachments") {
@@ -181,36 +186,79 @@ impl Tool for MessageTool {
response = response.with_attachments(attachments);
}
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()))
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))
}
}
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 {
// 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!(
"Failed to send to {}:{}. Available channels: {}. Error: {}",
channel, target, available, e
)
format!("All channels failed: {}", failed.join(", "))
};
Err(ToolError::ExecutionFailed(err_msg))
} else {
tracing::info!(
message_sent = true,
channels = ?succeeded,
target = %target,
attachments = attachment_count,
"Message broadcast via message tool"
);
let msg = format!(
"Broadcast message to {} (target: {})",
succeeded.join(", "),
target
);
Ok(ToolOutput::text(msg, start.elapsed()))
}
}
}
@@ -576,4 +624,90 @@ 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
);
}
}
+68 -10
View File
@@ -107,6 +107,10 @@ 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"]
@@ -143,12 +147,26 @@ 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).map_err(|e| {
next_cron_fire(schedule, timezone.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?;
Trigger::Cron {
schedule: schedule.to_string(),
timezone,
}
}
"event" => {
@@ -228,8 +246,12 @@ impl Tool for RoutineCreateTool {
.unwrap_or(300);
// Compute next fire time for cron
let next_fire = if let Trigger::Cron { ref schedule } = trigger {
next_cron_fire(schedule).unwrap_or(None)
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else {
None
};
@@ -412,6 +434,10 @@ 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"
@@ -453,15 +479,47 @@ impl Tool for RoutineUpdateTool {
}
}
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}")))?;
// 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()?;
routine.trigger = Trigger::Cron {
schedule: schedule.to_string(),
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.next_fire_at = next_cron_fire(schedule).unwrap_or(None);
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(),
));
}
}
self.store
+46 -2
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,10 +57,15 @@ 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()
"unix_millis": now.timestamp_millis(),
"local_iso": local.to_rfc3339(),
"timezone": tz.name()
})
}
"parse" => {
@@ -112,3 +117,42 @@ 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"));
}
}
+124 -57
View File
@@ -3,7 +3,6 @@
//! Supports both local (unauthenticated) and hosted (OAuth-authenticated) servers.
//! Uses the Streamable HTTP transport with session management.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@@ -53,9 +52,6 @@ pub struct McpClient {
/// Server configuration (for token secret name lookup).
server_config: Option<McpServerConfig>,
/// Custom HTTP headers injected into every request.
custom_headers: HashMap<String, String>,
}
impl McpClient {
@@ -79,7 +75,6 @@ impl McpClient {
secrets: None,
user_id: "default".to_string(),
server_config: None,
custom_headers: HashMap::new(),
}
}
@@ -100,28 +95,6 @@ impl McpClient {
secrets: None,
user_id: "default".to_string(),
server_config: None,
custom_headers: HashMap::new(),
}
}
/// Create a new simple MCP client from a server configuration (no authentication).
///
/// Use this when you have an `McpServerConfig` with custom headers but no OAuth.
pub fn new_with_config(config: McpServerConfig) -> Self {
Self {
server_name: config.name.clone(),
server_url: config.url.clone(),
http_client: reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client"),
next_id: AtomicU64::new(1),
tools_cache: RwLock::new(None),
session_manager: None,
secrets: None,
user_id: "default".to_string(),
custom_headers: config.headers.clone(),
server_config: Some(config),
}
}
@@ -146,7 +119,6 @@ impl McpClient {
session_manager: Some(session_manager),
secrets: Some(secrets),
user_id: user_id.into(),
custom_headers: config.headers.clone(),
server_config: Some(config),
}
}
@@ -206,12 +178,7 @@ impl McpClient {
.header("Content-Type", "application/json")
.json(&request);
// Add custom headers from config
for (key, value) in &self.custom_headers {
req_builder = req_builder.header(key, value);
}
// Add Authorization header if we have a token (overrides custom Authorization)
// Add Authorization header if we have a token
if let Some(token) = self.get_access_token().await? {
req_builder = req_builder.header("Authorization", format!("Bearer {}", token));
}
@@ -294,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, body
"MCP server returned status: {status} - {preview}",
)));
}
@@ -507,7 +474,6 @@ impl Clone for McpClient {
secrets: self.secrets.clone(),
user_id: self.user_id.clone(),
server_config: self.server_config.clone(),
custom_headers: self.custom_headers.clone(),
}
}
}
@@ -582,6 +548,58 @@ 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::*;
@@ -726,26 +744,6 @@ mod tests {
assert_eq!(id3, 3);
}
#[test]
fn test_custom_headers_from_config() {
use std::collections::HashMap;
let mut headers = HashMap::new();
headers.insert("X-API-Key".to_string(), "secret".to_string());
headers.insert("X-Custom".to_string(), "value".to_string());
let config = McpServerConfig::new("test", "http://localhost:8080").with_headers(headers);
let client = McpClient::new_with_config(config);
assert_eq!(client.custom_headers.len(), 2);
assert_eq!(client.custom_headers.get("X-API-Key").unwrap(), "secret");
}
#[test]
fn test_new_has_no_custom_headers() {
let client = McpClient::new("http://localhost:8080");
assert!(client.custom_headers.is_empty());
}
#[test]
fn test_mcp_tool_requires_approval_destructive() {
use crate::tools::mcp::protocol::{McpTool, McpToolAnnotations};
@@ -794,4 +792,73 @@ 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);
}
}
-89
View File
@@ -32,13 +32,6 @@ pub struct McpServerConfig {
/// Optional description for the server.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Custom HTTP headers to send with every request to this server.
///
/// Useful for MCP servers that require non-OAuth authentication
/// (e.g., API keys via `X-API-Key` header).
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
}
fn default_true() -> bool {
@@ -54,16 +47,9 @@ impl McpServerConfig {
oauth: None,
enabled: true,
description: None,
headers: HashMap::new(),
}
}
/// Set custom HTTP headers for this server.
pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
self.headers = headers;
self
}
/// Set OAuth configuration.
pub fn with_oauth(mut self, oauth: OAuthConfig) -> Self {
self.oauth = Some(oauth);
@@ -607,79 +593,4 @@ mod tests {
let config = McpServerConfig::new("bad", "http://mcp.example.com");
assert!(!config.requires_auth());
}
#[test]
fn test_custom_headers_default_empty() {
let config = McpServerConfig::new("test", "http://localhost:8080");
assert!(config.headers.is_empty());
}
#[test]
fn test_custom_headers_with_builder() {
let mut headers = HashMap::new();
headers.insert("X-API-Key".to_string(), "secret123".to_string());
headers.insert("X-Custom".to_string(), "value".to_string());
let config = McpServerConfig::new("browser-use", "https://mcp.browser-use.com")
.with_headers(headers.clone());
assert_eq!(config.headers.len(), 2);
assert_eq!(config.headers.get("X-API-Key").unwrap(), "secret123");
}
#[test]
fn test_custom_headers_serde_roundtrip() {
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer tok_123".to_string());
let config =
McpServerConfig::new("test-serde", "http://localhost:3000").with_headers(headers);
let json = serde_json::to_string(&config).unwrap();
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.headers.len(), 1);
assert_eq!(
deserialized.headers.get("Authorization").unwrap(),
"Bearer tok_123"
);
}
#[test]
fn test_custom_headers_absent_in_json_defaults_empty() {
let json = serde_json::json!({
"name": "legacy",
"url": "http://localhost:8080"
});
let config: McpServerConfig = serde_json::from_value(json).unwrap();
assert!(config.headers.is_empty());
}
#[test]
fn test_custom_headers_skipped_when_empty_in_serialization() {
let config = McpServerConfig::new("minimal", "http://localhost:8080");
let json = serde_json::to_value(&config).unwrap();
// Empty headers map should not appear in serialized output
assert!(json.get("headers").is_none());
}
#[tokio::test]
async fn test_custom_headers_persist_to_disk() {
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-headers-test.json");
let mut headers = HashMap::new();
headers.insert("X-API-Key".to_string(), "key123".to_string());
let mut config = McpServersFile::default();
config.upsert(
McpServerConfig::new("headered", "http://localhost:9090").with_headers(headers),
);
save_mcp_servers_to(&config, &path).await.unwrap();
let loaded = load_mcp_servers_from(&path).await.unwrap();
let server = loaded.get("headered").unwrap();
assert_eq!(server.headers.get("X-API-Key").unwrap(), "key123");
}
}
+1 -4
View File
@@ -10,7 +10,6 @@ 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;
@@ -485,17 +484,15 @@ 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, safety, tools)
// Create the builder (arg order: config, llm, tools)
let builder = Arc::new(LlmSoftwareBuilder::new(
config.unwrap_or_default(),
llm,
safety,
Arc::clone(self),
));
+1 -1
View File
@@ -133,7 +133,7 @@ impl WorkerRuntime {
.await?;
// Create reasoning engine
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let reasoning = Reasoning::new(self.llm.clone());
// Build initial context
let mut reason_ctx = ReasoningContext::new().with_job(&job.description);
-116
View File
@@ -113,79 +113,6 @@ 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::*;
@@ -253,49 +180,6 @@ 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
@@ -0,0 +1,644 @@
//! 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 { .. }
));
}
}
+216 -22
View File
@@ -42,20 +42,26 @@
mod chunker;
mod document;
mod embeddings;
pub 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;
@@ -332,6 +338,12 @@ 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 {
@@ -343,6 +355,7 @@ impl Workspace {
agent_id: None,
storage: WorkspaceStorage::Repo(Repository::new(pool)),
embeddings: None,
vector_store: None,
}
}
@@ -355,6 +368,7 @@ impl Workspace {
agent_id: None,
storage: WorkspaceStorage::Db(db),
embeddings: None,
vector_store: None,
}
}
@@ -370,6 +384,17 @@ 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
@@ -458,9 +483,21 @@ impl Workspace {
/// Delete a file.
///
/// Also deletes associated chunks.
/// Also deletes associated chunks (from both DB and external vector store).
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
@@ -565,11 +602,26 @@ impl Workspace {
///
/// Daily logs are raw, append-only notes for the current day.
pub async fn append_daily_log(&self, entry: &str) -> Result<(), WorkspaceError> {
let today = Utc::now().date_naive();
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 path = format!("daily/{}.md", today.format("%Y-%m-%d"));
let timestamp = Utc::now().format("%H:%M:%S");
let timestamp = now.format("%H:%M:%S");
let timestamped_entry = format!("[{}] {}", timestamp, entry);
self.append(&path, &timestamped_entry).await
self.append(&path, &timestamped_entry).await?;
Ok(path)
}
// ==================== System Prompt ====================
@@ -584,6 +636,18 @@ 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
@@ -591,6 +655,16 @@ 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();
@@ -645,7 +719,10 @@ impl Workspace {
}
// Add today's memory context (last 2 days of daily logs)
let today = Utc::now().date_naive();
let today = match tz {
Some(t) => crate::timezone::today_in_tz(t),
None => Utc::now().date_naive(),
};
let yesterday = today.pred_opt().unwrap_or(today);
for date in [today, yesterday] {
@@ -685,20 +762,67 @@ impl Workspace {
query: &str,
config: SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
// 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
};
// 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
};
// 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,
@@ -720,7 +844,13 @@ impl Workspace {
// Chunk the content
let chunks = chunk_document(&doc.content, ChunkConfig::default());
// Delete old chunks
// 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
self.storage.delete_chunks(document_id).await?;
// Insert new chunks
@@ -738,9 +868,33 @@ impl Workspace {
None
};
self.storage
.insert_chunk(document_id, index as i32, &content, embedding.as_deref())
// 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)
.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(())
@@ -985,6 +1139,23 @@ 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 {
@@ -992,6 +1163,29 @@ 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
@@ -0,0 +1,71 @@
//! 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>;
}
+10
View File
@@ -242,6 +242,16 @@ 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")
+8 -15
View File
@@ -20,9 +20,8 @@ mod tests {
use ironclaw::agent::routine_engine::RoutineEngine;
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
use ironclaw::channels::IncomingMessage;
use ironclaw::config::{RoutineConfig, SafetyConfig};
use ironclaw::config::RoutineConfig;
use ironclaw::db::Database;
use ironclaw::safety::SafetyLayer;
use ironclaw::workspace::Workspace;
use ironclaw::workspace::hygiene::HygieneConfig;
@@ -118,6 +117,7 @@ mod tests {
"cron-test",
Trigger::Cron {
schedule: "* * * * *".to_string(),
timezone: None,
},
"Check system status.",
);
@@ -203,6 +203,7 @@ 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;
@@ -224,6 +225,7 @@ 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;
@@ -288,6 +290,7 @@ 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;
@@ -342,10 +345,6 @@ 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);
@@ -357,9 +356,8 @@ mod tests {
state_dir: _tmp.path().to_path_buf(),
};
let runner =
HeartbeatRunner::new(HeartbeatConfig::default(), hygiene_config, ws, llm, safety)
.with_response_channel(tx);
let runner = HeartbeatRunner::new(HeartbeatConfig::default(), hygiene_config, ws, llm)
.with_response_channel(tx);
let result = runner.check_heartbeat().await;
match result {
@@ -396,10 +394,6 @@ 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,
@@ -409,8 +403,7 @@ mod tests {
state_dir: _tmp.path().to_path_buf(),
};
let runner =
HeartbeatRunner::new(HeartbeatConfig::default(), hygiene_config, ws, llm, safety);
let runner = HeartbeatRunner::new(HeartbeatConfig::default(), hygiene_config, ws, llm);
let result = runner.check_heartbeat().await;
assert!(
+1 -3
View File
@@ -15,7 +15,6 @@ use ironclaw::{
config::Config,
history::Store,
llm::{create_llm_provider, create_session_manager},
safety::SafetyLayer,
workspace::Workspace,
};
@@ -93,8 +92,7 @@ async fn test_heartbeat_end_to_end() {
let hb_config = ironclaw::agent::HeartbeatConfig::default();
let hygiene_config = ironclaw::workspace::hygiene::HygieneConfig::default();
let safety = Arc::new(SafetyLayer::new(&config.safety));
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm, safety);
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm);
let result = runner.check_heartbeat().await;
+123
View File
@@ -0,0 +1,123 @@
//! 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"));
}
+9 -8
View File
@@ -545,16 +545,14 @@ impl TestRigBuilder {
.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(
ctx_mgr,
None,
Arc::clone(&components.context_manager),
Some(scheduler_slot.clone()),
None,
components.db.clone(),
None,
@@ -657,10 +655,13 @@ impl TestRigBuilder {
None, // heartbeat_config
None, // hygiene_config
routine_config,
None, // context_manager
Some(Arc::clone(&components.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 {
+35 -26
View File
@@ -513,32 +513,41 @@ impl LlmProvider for TraceLlm {
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
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(),
}),
// 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(),
});
}
}
}
}
+16 -9
View File
@@ -571,22 +571,29 @@ mod trace_llm_tests {
}
#[tokio::test]
async fn complete_errors_on_tool_calls_step() {
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.
let trace = LlmTrace::single_turn(
"test-model",
"hi",
vec![tool_calls_step(vec![simple_tool_call("echo")], 10, 5)],
vec![
tool_calls_step(vec![simple_tool_call("echo")], 10, 5),
text_step("skipped past tools", 20, 8),
],
);
let llm = TraceLlm::from_trace(trace);
let result = llm.complete(make_completion_request("hi")).await;
let resp = llm
.complete(make_completion_request("hi"))
.await
.expect("complete() should skip ToolCalls and return the Text step");
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}"
);
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);
}
#[tokio::test]