Compare commits

...

46 Commits

Author SHA1 Message Date
Argenis e387f58579 Merge pull request #3951 from Alix-007/issue-3946-release-memory-postgres
build(release): include memory-postgres in published artifacts
2026-03-19 14:42:15 -04:00
Argenis fa06798926 fix(cron): persist allowed_tools for agent jobs (#3993)
Persist allowed_tools in cron_jobs table, threading it through CLI add/update and cron_add/cron_update tool APIs. Add regression coverage for store, tool, and CLI roundtrip paths.

Fixups over original PR #3929: add allowed_tools to all_overdue_jobs SELECT (merge gap), resolve merge conflicts.

Closes #3920
Supersedes #3929
2026-03-19 14:37:55 -04:00
Alix-007 a4cd4b287e feat(slack): add thread_replies channel option (#3930)
Add a thread_replies option to Slack channel config (default true). When false, replies go to channel root instead of the originating thread.

Closes #3888
2026-03-19 14:32:02 -04:00
argenis de la rosa 3ce7f2345e merge: resolve conflicts with master, include channel-lark in RELEASE_CARGO_FEATURES
Add channel-lark (merged to master separately) to RELEASE_CARGO_FEATURES
env var. Keep the DRY env-var approach and remove stale Docker build-args.
2026-03-19 14:24:30 -04:00
Argenis eb9dfc04b4 fix(anthropic): always apply cache_control to system prompts (#3990)
* fix: always use Blocks format for system prompts with cache_control

System prompts under 3KB were wrapped in SystemPrompt::String which
cannot carry cache_control headers, resulting in 0% cache hit rate
on Haiku 4.5. Always use SystemPrompt::Blocks with ephemeral
cache_control regardless of prompt size.

Fixes #3977

* fix: lower conversation caching threshold from >4 to >1 messages

The previous threshold of >4 non-system messages was too restrictive,
delaying cache benefits until 5+ turns. Lower to >1 so caching kicks
in after the first user+assistant exchange.

Fixes #3977

* test: update anthropic cache tests for new thresholds and Blocks format

- convert_messages_small_system_prompt now expects Blocks with
  cache_control instead of String variant
- should_cache_conversation tests updated for >1 threshold
- backward_compatibility test replaced with blocks-system test
2026-03-19 14:21:45 -04:00
Argenis 9cc74a2698 fix(security): wire sandbox into shell command execution (#3989)
* fix: add sandbox field to ShellTool struct

Add `sandbox: Arc<dyn Sandbox>` field to `ShellTool` and a
`new_with_sandbox()` constructor so callers can inject the configured
sandbox backend. The existing `new()` constructor defaults to
`NoopSandbox` for backward compatibility.

Ref: #3983

* fix: apply sandbox wrapping in ShellTool::execute()

Call `self.sandbox.wrap_command()` on the underlying std::process::Command
(via `as_std_mut()`) after building the shell command and before clearing
the environment. This ensures every shell command passes through the
configured sandbox backend before execution.

Ref: #3983

* fix: wire up sandbox creation at ShellTool callsites

In `all_tools_with_runtime()`, create a sandbox from
`root_config.security` via `create_sandbox()` and pass it to
`ShellTool::new_with_sandbox()`. The `default_tools_with_runtime()`
path retains `ShellTool::new()` which defaults to `NoopSandbox`.

Ref: #3983

* test: add sandbox integration tests for ShellTool

Verify that ShellTool can be constructed with a sandbox via
`new_with_sandbox()`, that NoopSandbox leaves commands unmodified,
and that command execution works end-to-end with a sandbox attached.

Ref: #3983
2026-03-19 14:21:42 -04:00
Argenis 133dc46b41 fix(web): restore accidentally deleted logo file (#3988)
* fix: restore accidentally deleted logo file

The logo.png was removed in commit 48bdbde2 but is still referenced
by the web UI components. Restore it from git history.

Fixes #3984

* fix: copy logo to web/dist for rust-embed

The Rust binary embeds files from web/dist/ via rust-embed, so the
logo must also be present there to be served without a rebuild.

Fixes #3984
2026-03-19 14:21:15 -04:00
Argenis ad03605cad Merge pull request #3949 from Alix-007/issue-3817-cron-delivery-context
fix: default cron delivery to the active channel context
2026-03-19 14:20:59 -04:00
Argenis ae1acf9b9c Merge pull request #3950 from Alix-007/issue-3466-homebrew-service-workspace
fix(onboard): warn when Homebrew services use another workspace
2026-03-19 14:20:56 -04:00
Alix-007 cc91f22e9b fix(skills): narrow shell shebang detection (#3944)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-19 14:10:51 -04:00
Martin 030f5fe288 fix(install): fix guided installer in LXC/container environments (#3947)
Replace subshell-based /dev/stdin probing in guided_input_stream with a
file-descriptor approach (guided_open_input) that works reliably in LXC
containers accessed over SSH.

The previous implementation probed /dev/stdin and /proc/self/fd/0 via
subshells before falling back to /dev/tty. In LXC containers these
probes fail even when FD 0 is perfectly usable, causing the guided
installer to exit with "requires an interactive terminal".

The fix:
- When stdin is a terminal (-t 0), assign GUIDED_FD=0 directly without
  any subshell probing — trusting the kernel's own tty check
- Otherwise, open /dev/tty as an explicit fd (exec {GUIDED_FD}</dev/tty)
- guided_read uses `read -u "$GUIDED_FD"` instead of `< "$file_path"`
- Add echo after silent reads (password prompts) for correct line handling

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 14:10:48 -04:00
Giulio V c47bbcc972 fix(cron): add startup catch-up and drop login shell flag (#3948)
* fix(cron): add startup catch-up and drop login shell flag

Problems:
1. When ZeroClaw started after downtime (late boot, daemon restart),
   overdue jobs were picked up via `due_jobs()` but limited by
   `max_tasks` per poll cycle — with many overdue jobs, catch-up
   could take many cycles.
2. Cron shell jobs used `sh -lc` (login shell), which loads the
   full user profile on every execution — slow and may cause
   unexpected side effects.

Fixes:
- Add `all_overdue_jobs()` store query without `max_tasks` limit
- Add `catch_up_overdue_jobs()` startup phase that runs ALL overdue
  jobs once before entering the normal polling loop
- Extract `build_cron_shell_command()` helper using `sh -c` (non-login)
- Add structured tracing for catch-up progress
- Add tests for all new functions

* feat(cron): make catch-up configurable via API and control panel

Add `catch_up_on_startup` boolean to `[cron]` config (default: true).
When enabled, the scheduler runs all overdue jobs at startup before
entering the normal polling loop. Users can toggle this from:

- The Cron page toggle switch in the control panel
- PATCH /api/cron/settings { "catch_up_on_startup": false }
- The `[cron]` section of the TOML config editor

Also adds GET /api/cron/settings endpoint to read cron subsystem
settings without parsing the full config.

* fix(config): add catch_up_on_startup to CronConfig test constructors

The CI Lint job failed because the `cron_config_serde_roundtrip` test
constructs CronConfig directly and was missing the new field.
2026-03-19 14:10:37 -04:00
Argenis 72fbb22059 Merge pull request #3985 from zeroclaw-labs/fix/aur-ssh-publish
fix(ci): harden AUR SSH key setup and add diagnostics
2026-03-19 13:44:01 -04:00
argenis de la rosa cbb3d9ae92 fix(ci): harden AUR SSH key setup and add diagnostics (#3952)
The AUR publish step fails with "Permission denied (publickey)".
Root cause is likely key formatting (Windows line endings from
GitHub secrets UI) or missing public key registration on AUR.

Changes:
- Normalize line endings (strip \r) when writing SSH key
- Set correct permissions on ~/.ssh (700) and ~/.ssh/config (600)
- Validate key with ssh-keygen before attempting clone
- Add SSH connectivity test for clearer error diagnostics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:28:58 -04:00
Argenis 8d1eebad4d Merge pull request #3980 from zeroclaw-labs/version-bump-0.5.1
chore: bump version to 0.5.1
2026-03-19 09:56:36 -04:00
argenis de la rosa 0fdd1ad490 chore: bump version to 0.5.1
Release highlights:
- Autonomy enforcement in gateway and channel paths (#3952)
- conversational_ai startup warning for unimplemented config (#3958)
- Heartbeat default interval 30→5min (#3938)
- Provider timeout and error handling improvements (#3973, #3978)
- Docker/CI postgres and Lark feature fixes (#3971, #3933)
- Tool path resolution fix (#3937)
- OTP config fix (#3936)
- README: Instagram badge + banner image (#3979)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:45:32 -04:00
Argenis 86bc60fcd1 Merge pull request #3979 from zeroclaw-labs/readme
docs: add Instagram badge and banner to README
2026-03-19 09:42:00 -04:00
argenis de la rosa 4837e1fe73 docs(readme): add Instagram social badge and switch to banner image
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:30:54 -04:00
Argenis 985977ae0c fix(providers): exempt tool schema errors from non-retryable classification (#3978)
* fix: exempt tool schema validation errors from non-retryable classification

Groq returns 400 "tool call validation failed" which was classified as
non-retryable by is_non_retryable(), preventing the provider-level
fallback in compatible.rs from executing. Add is_tool_schema_error()
to detect these errors and return false from is_non_retryable(), allowing
the retry loop to pass control back to the provider's built-in fallback.

Fixes #3757

* test: add unit tests for tool schema error detection in reliable.rs

Verify is_tool_schema_error detects Groq-style validation failures and
that is_non_retryable returns false for tool schema 400s while still
returning true for other 400 errors like invalid API key.

* fix: escape format braces in test string literals for cargo check

The anyhow::anyhow! macro interprets curly braces as format
placeholders. Use explicit format argument to pass JSON-containing
strings in tests.
2026-03-19 09:25:49 -04:00
Argenis 72b10f12dd Merge pull request #3975 from zeroclaw-labs/agent-loop
fix: enforce autonomy level in gateway/channel paths + conversational_ai warning
2026-03-19 09:20:21 -04:00
Argenis 3239f5ea07 fix(ci): include channel-lark feature in precompiled release binaries (#3933) (#3972)
Add channel-lark to the cargo --features flag in all release and
cross-platform build workflows, and to the Docker build-args.  This
gives users Feishu/Lark channel support out of the box without needing
to compile from source.

The channel-lark feature depends only on dep:prost (pure Rust protobuf),
so it is safe to enable on all platforms (Linux, macOS, Windows, Android).
2026-03-19 09:15:10 -04:00
Argenis 3353729b01 fix(openrouter): respect provider_timeout_secs and improve error messages (#3973)
* fix(openrouter): wire provider_timeout_secs through factory

Apply the configured provider_timeout_secs to OpenRouterProvider
in the provider factory, matching the pattern used for compatible
providers.

* fix(openrouter): add timeout_secs field to OpenRouterProvider

Add a configurable timeout_secs field (default 120s) and a
with_timeout_secs() builder method so the HTTP client timeout
can be overridden via provider config instead of being hardcoded.

* refactor(openrouter): improve response decode error messages

Read the response body as text first, then parse with
serde_json::from_str so that decode failures include a truncated
snippet of the raw body for easier debugging.

* test(openrouter): add timeout_secs configuration tests

Verify that the default timeout is 120s and that with_timeout_secs
correctly overrides it.

* style: run rustfmt on openrouter.rs
2026-03-19 09:12:14 -04:00
argenis de la rosa b6c2930a70 fix(agent): enforce autonomy level in gateway and channel paths (#3952)
- Channel tool filtering (`non_cli_excluded_tools`) now respects
  `autonomy.level = "full"` — full-autonomy agents keep all tools
  available regardless of channel.
- Gateway `process_message` now creates and passes an `ApprovalManager`
  to `agent_turn`, so `ReadOnly`/`Supervised` policies are enforced
  instead of silently skipped.
- Gateway also applies `non_cli_excluded_tools` filtering with the same
  full-autonomy bypass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:56:45 -04:00
argenis de la rosa 181cafff70 fix(config): warn when conversational_ai.enabled is set (#3958)
The conversational_ai config section is parsed but not yet consumed by
any runtime code. Emit a startup warning so users know the setting is
ignored, and update the doc comment to mark it as reserved for future use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:56:38 -04:00
Argenis d87f387111 fix(docker): add memory-postgres feature to Docker and CI builds (#3971)
The GHCR image v0.5.0 fails to start when users configure the postgres
memory backend because the binary was compiled without the
memory-postgres cargo feature. Add it to all build configurations:
Dockerfiles (default ARG), release workflows (cargo build --features),
and Docker push steps (ZEROCLAW_CARGO_FEATURES build-arg).

Fixes #3946
2026-03-19 08:51:20 -04:00
Argenis 7068079028 fix: make channel system prompt respect autonomy.level = full (#3952) (#3970)
When autonomy.level is set to "full", the channel/web system prompt no
longer includes instructions telling the model to ask for permission
before executing tools. Previously these safety lines were hardcoded
regardless of autonomy config, causing the LLM to simulate approval
dialogs in channel and web-interface modes even though the
ApprovalManager correctly allowed execution.

The fix adds an autonomy_level parameter to build_system_prompt_with_mode
and conditionally omits the "ask before acting" instructions when the
level is Full. Core safety rules (no data exfiltration, prefer trash)
are always included.
2026-03-19 08:48:38 -04:00
Argenis a9b511e6ec fix: omit experimental conversational_ai section from default config (#3969)
The [conversational_ai] config section was serialized into every
freshly-generated config.toml despite the feature being experimental
and not yet wired into the agent runtime. This confused new users who
found an undocumented section in their config.

Add skip_serializing_if = "ConversationalAiConfig::is_disabled" so the
section is only written when a user has explicitly enabled it. Existing
configs that already contain the section continue to deserialize
correctly via #[serde(default)].

Fixes #3958
2026-03-19 08:48:33 -04:00
Argenis 65cb4fe099 feat(heartbeat): default interval 30→5min + prune heartbeat from auto-save (#3938)
Lower the default heartbeat interval to 5 minutes to match the renewable
partial wake-lock cadence. Add `[heartbeat task` to the memory auto-save
skip filter so heartbeat prompts (both Phase 1 decision and Phase 2 task
execution) do not pollute persistent conversation memory.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:17:08 -04:00
Alix-007 0d28cca843 build(release): drop stale docker feature args 2026-03-19 19:14:07 +08:00
Alix-007 7ddd2aace3 build(release): ship postgres-capable release artifacts 2026-03-19 15:37:45 +08:00
Alix-007 c7b3b762e0 fix(onboard): warn when Homebrew service uses another workspace 2026-03-19 15:30:40 +08:00
Alix-007 4b00e8ba75 fix(cron): default channel delivery to active reply target 2026-03-19 15:11:47 +08:00
Argenis 2e48cbf7c3 fix(tools): use resolve_tool_path for consistent path resolution (#3937)
Replace workspace_dir.join(path) with resolve_tool_path(path) in
file_write, file_edit, and pdf_read tools to correctly handle absolute
paths within the workspace directory, preventing path doubling.

Closes #3774
2026-03-18 23:51:35 -04:00
Argenis e4910705d1 fix(config): add missing challenge_max_attempts field to OtpConfig (#3919) (#3936)
The OtpConfig struct uses deny_unknown_fields but was missing the
challenge_max_attempts field, causing zeroclaw config schema to fail
with a TOML parse error when the field appeared in config files.

Add challenge_max_attempts as an Option<u32>-style field with a default
of 3 and a validation check ensuring it is greater than 0.
2026-03-18 23:48:53 -04:00
Argenis 1b664143c2 fix: move misplaced include key from [lib] to [package] in Cargo.toml (#3935)
The `include` array was placed after `[lib]` without a section header,
causing Cargo to parse it as `lib.include` — an invalid manifest key.
This triggered a warning during builds and caused lockfile mismatch
errors when building with --locked in Docker (Dockerfile.debian).

Move the `include` key to the `[package]` section where it belongs and
regenerate Cargo.lock to stay in sync.

Fixes #3925
2026-03-18 23:48:50 -04:00
Argenis 950f996812 Merge pull request #3926 from zeroclaw-labs/fix/pairing-code-terminal-display
fix(gateway): move pairing code below dashboard URL in terminal
2026-03-18 20:34:08 -04:00
argenis de la rosa b74c5cfda8 fix(gateway): move pairing code below dashboard URL in terminal banner
Repositions the one-time pairing code display to appear directly below
the dashboard URL for cleaner terminal output, and removes the duplicate
display that was showing at the bottom of the route list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 19:50:26 -04:00
Argenis 02688eb124 feat(skills): autonomous skill creation from multi-step tasks (#3916)
Add SkillCreator module that persists successful multi-step task
executions as reusable SKILL.toml definitions under the workspace
skills directory.

- SkillCreationConfig in [skills.skill_creation] (disabled by default)
- Slug validation, TOML generation, embedding-based deduplication
- LRU eviction when max_skills limit is reached
- Agent loop integration post-success
- Gated behind `skill-creation` compile-time feature flag

Closes #3825.
2026-03-18 17:15:02 -04:00
Argenis 2c92cf913b fix: ensure SOUL.md and IDENTITY.md exist in non-tty sessions (#3915)
When the workspace is created outside of `zeroclaw onboard` (e.g., via
cron, daemon, or `< /dev/null`), SOUL.md and IDENTITY.md were never
scaffolded, causing the agent to activate without identity files.

Added `ensure_bootstrap_files()` in `Config::load_or_init()` that
idempotently creates default SOUL.md and IDENTITY.md if missing.

Closes #3819.
2026-03-18 17:12:44 -04:00
Argenis 3c117d2d7b feat(delegate): make sub-agent timeouts configurable via config.toml (#3909)
Add `timeout_secs` and `agentic_timeout_secs` fields to
`DelegateAgentConfig` so users can tune per-agent timeouts instead
of relying on the hardcoded 120s / 300s defaults.

Validation rejects values of 0 or above 3600s, matching the pattern
used by MCP timeout validation.

Closes #3898
2026-03-18 17:07:03 -04:00
Argenis 1f7c3c99e4 feat(i18n): externalize tool descriptions for translation (#3912)
Add a locale-aware tool description system that loads translations from
TOML files in tool_descriptions/. This enables non-English users to see
tool descriptions in their language.

- Add src/i18n.rs module with ToolDescriptions loader, locale detection
  (ZEROCLAW_LOCALE, LANG, LC_ALL env vars), and English fallback chain
- Add locale config field to Config struct for explicit locale override
- Create tool_descriptions/en.toml with all 47 tool descriptions
- Create tool_descriptions/zh-CN.toml with Chinese translations
- Integrate with ToolsSection::build() and build_tool_instructions()
  to resolve descriptions from locale files before hardcoded fallback
- Add PromptContext.tool_descriptions field for prompt-time resolution
- Add AgentBuilder.tool_descriptions() setter for Agent construction
- Include tool_descriptions/ in Cargo.toml package include list
- Add 8 unit tests covering locale loading, fallback chains, env
  detection, and config override

Closes #3901
2026-03-18 17:01:39 -04:00
Argenis 92940a3d16 Merge pull request #3904 from zeroclaw-labs/fix/install-stale-build-cache
fix(install): clean stale build cache on upgrade
2026-03-18 15:49:10 -04:00
Argenis d77c616905 fix: reset tool call dedup cache each iteration to prevent loops (#3910)
The seen_tool_signatures HashSet was initialized outside the iteration loop, causing cross-iteration deduplication of legitimate tool calls. This triggered a self-correction spiral where the agent repeatedly attempted skipped calls until hitting max_iterations.

Moving the HashSet inside the loop ensures deduplication only applies within a single iteration, as originally intended.

Fixes #3798
2026-03-18 15:45:10 -04:00
Argenis ac12470c27 fix(channels): respect ack_reactions config for Telegram channel (#3834) (#3913)
The Telegram channel was ignoring the ack_reactions setting because it
sent setMessageReaction API calls directly in its polling loop, bypassing
the top-level channels_config.ack_reactions check.

- Add optional ack_reactions field to TelegramConfig so it can be set
  under [channels_config.telegram] without "unknown key" warnings
- Add ack_reactions field and with_ack_reactions() builder to
  TelegramChannel, defaulting to true
- Guard try_add_ack_reaction_nonblocking() behind self.ack_reactions
- Wire channel-level override with fallback to top-level default
- Add config deserialization and channel behavior tests
2026-03-18 15:40:31 -04:00
argenis de la rosa 3c8b6d219a fix(test): use PID-scoped script path to prevent ETXTBSY in CI
The echo_provider() test helper writes a fake_claude.sh script to
a shared temp directory. When lib and bin test binaries run in
parallel (separate processes, separate OnceLock statics), one
process can overwrite the script while the other is executing it,
causing "Text file busy" (ETXTBSY). Scope the filename with PID
to isolate each test process.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:33:04 -04:00
argenis de la rosa d72e9379f7 fix(install): clean stale build cache on upgrade
When upgrading an existing installation, stale build artifacts in
target/release/build/ can cause compilation failures (e.g.
libsqlite3-sys bindgen.rs not found). Run cargo clean --release
before building when an upgrade is detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:15:59 -04:00
56 changed files with 3837 additions and 227 deletions
@@ -74,4 +74,4 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features channel-matrix,channel-lark,memory-postgres --target ${{ matrix.target }}
+14 -2
View File
@@ -134,15 +134,27 @@ jobs:
exit 1
fi
# Set up SSH key — normalize line endings and ensure trailing newline
mkdir -p ~/.ssh
echo "$AUR_SSH_KEY" > ~/.ssh/aur
chmod 700 ~/.ssh
printf '%s\n' "$AUR_SSH_KEY" | tr -d '\r' > ~/.ssh/aur
chmod 600 ~/.ssh/aur
cat >> ~/.ssh/config <<SSH_CONFIG
cat > ~/.ssh/config <<'SSH_CONFIG'
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
StrictHostKeyChecking accept-new
SSH_CONFIG
chmod 600 ~/.ssh/config
# Verify key is valid and print fingerprint for debugging
echo "::group::SSH key diagnostics"
ssh-keygen -l -f ~/.ssh/aur || { echo "::error::AUR_SSH_KEY is not a valid SSH private key"; exit 1; }
echo "::endgroup::"
# Test SSH connectivity before attempting clone
ssh -T -o BatchMode=yes -o ConnectTimeout=10 aur@aur.archlinux.org 2>&1 || true
tmp_dir="$(mktemp -d)"
git clone ssh://aur@aur.archlinux.org/zeroclaw.git "$tmp_dir/aur"
+2 -3
View File
@@ -16,6 +16,7 @@ env:
CARGO_TERM_COLOR: always
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres
jobs:
version:
@@ -213,7 +214,7 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features "${{ env.RELEASE_CARGO_FEATURES }}" --target ${{ matrix.target }}
- name: Package (Unix)
if: runner.os != 'Windows'
@@ -345,8 +346,6 @@ jobs:
with:
context: docker-ctx
push: true
build-args: |
ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta
+2 -3
View File
@@ -20,6 +20,7 @@ env:
CARGO_TERM_COLOR: always
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres
jobs:
validate:
@@ -214,7 +215,7 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features "${{ env.RELEASE_CARGO_FEATURES }}" --target ${{ matrix.target }}
- name: Package (Unix)
if: runner.os != 'Windows'
@@ -388,8 +389,6 @@ jobs:
with:
context: docker-ctx
push: true
build-args: |
ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
Generated
+34 -25
View File
@@ -5120,7 +5120,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.4+spec-1.1.0",
"toml_edit 0.25.5+spec-1.1.0",
]
[[package]]
@@ -6415,9 +6415,9 @@ dependencies = [
[[package]]
name = "serialport"
version = "4.7.3"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
@@ -6428,7 +6428,7 @@ dependencies = [
"nix 0.26.4",
"scopeguard",
"unescaper",
"winapi",
"windows-sys 0.52.0",
]
[[package]]
@@ -7047,9 +7047,9 @@ dependencies = [
[[package]]
name = "tokio-websockets"
version = "0.13.1"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6aa6c8b5a31e06fd3760eb5c1b8d9072e30731f0467ee3795617fe768e7449"
checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
dependencies = [
"base64",
"bytes",
@@ -7057,7 +7057,7 @@ dependencies = [
"futures-sink",
"http 1.4.0",
"httparse",
"rand 0.9.2",
"rand 0.10.0",
"ring",
"rustls-pki-types",
"simdutf8",
@@ -7095,17 +7095,17 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.6+spec-1.1.0"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.0.4",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
@@ -7128,9 +7128,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
version = "1.0.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
dependencies = [
"serde_core",
]
@@ -7151,23 +7151,23 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.4+spec-1.1.0"
version = "0.25.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
dependencies = [
"indexmap",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_parser",
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
version = "1.0.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
dependencies = [
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
@@ -7178,9 +7178,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
[[package]]
name = "tonic"
@@ -8894,6 +8894,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
dependencies = [
"memchr",
]
[[package]]
name = "winx"
version = "0.36.4"
@@ -9149,13 +9158,13 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-test",
"toml 1.0.6+spec-1.1.0",
"toml 1.0.7+spec-1.1.0",
"tracing",
]
[[package]]
name = "zeroclawlabs"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"async-imap",
@@ -9228,7 +9237,7 @@ dependencies = [
"tokio-stream",
"tokio-tungstenite 0.28.0",
"tokio-util",
"toml 1.0.6+spec-1.1.0",
"toml 1.0.7+spec-1.1.0",
"tower",
"tower-http",
"tracing",
+13 -11
View File
@@ -4,7 +4,7 @@ resolver = "2"
[package]
name = "zeroclawlabs"
version = "0.5.0"
version = "0.5.1"
edition = "2021"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"
@@ -14,15 +14,6 @@ readme = "README.md"
keywords = ["ai", "agent", "cli", "assistant", "chatbot"]
categories = ["command-line-utilities", "api-bindings"]
rust-version = "1.87"
[[bin]]
name = "zeroclaw"
path = "src/main.rs"
[lib]
name = "zeroclaw"
path = "src/lib.rs"
include = [
"/src/**/*",
"/build.rs",
@@ -31,8 +22,17 @@ include = [
"/LICENSE*",
"/README.md",
"/web/dist/**/*",
"/tool_descriptions/**/*",
]
[[bin]]
name = "zeroclaw"
path = "src/main.rs"
[lib]
name = "zeroclaw"
path = "src/lib.rs"
[dependencies]
# CLI - minimal and fast
clap = { version = "4.5", features = ["derive"] }
@@ -215,7 +215,7 @@ landlock = { version = "0.4", optional = true }
libc = "0.2"
[features]
default = ["observability-prometheus", "channel-nostr"]
default = ["observability-prometheus", "channel-nostr", "skill-creation"]
channel-nostr = ["dep:nostr-sdk"]
hardware = ["nusb", "tokio-serial"]
channel-matrix = ["dep:matrix-sdk"]
@@ -240,6 +240,8 @@ metrics = ["observability-prometheus"]
probe = ["dep:probe-rs"]
# rag-pdf = PDF ingestion for datasheet RAG
rag-pdf = ["dep:pdf-extract"]
# skill-creation = Autonomous skill creation from successful multi-step tasks
skill-creation = []
# whatsapp-web = Native WhatsApp Web client with custom rusqlite storage backend
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "dep:serde-big-array", "dep:prost", "dep:qrcode"]
# WASM plugin system (extism-based)
+1 -1
View File
@@ -12,7 +12,7 @@ RUN npm run build
FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+1 -1
View File
@@ -27,7 +27,7 @@ RUN npm run build
FROM rust:1.94-bookworm AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+4
View File
@@ -183,6 +183,8 @@ Delegate sub-agent configurations. Each key under `[agents]` defines a named sub
| `agentic` | `false` | Enable multi-turn tool-call loop mode for the sub-agent |
| `allowed_tools` | `[]` | Tool allowlist for agentic mode |
| `max_iterations` | `10` | Max tool-call iterations for agentic mode |
| `timeout_secs` | `120` | Timeout in seconds for non-agentic provider calls (13600) |
| `agentic_timeout_secs` | `300` | Timeout in seconds for agentic sub-agent loops (13600) |
Notes:
@@ -199,11 +201,13 @@ max_depth = 2
agentic = true
allowed_tools = ["web_search", "http_request", "file_read"]
max_iterations = 8
agentic_timeout_secs = 600
[agents.coder]
provider = "ollama"
model = "qwen2.5-coder:32b"
temperature = 0.2
timeout_secs = 60
```
## `[runtime]`
+19 -27
View File
@@ -448,46 +448,32 @@ bool_to_word() {
fi
}
guided_input_stream() {
# Some constrained containers report interactive stdin (-t 0) but deny
# opening /dev/stdin directly. Probe readability before selecting it.
if [[ -t 0 ]] && (: </dev/stdin) 2>/dev/null; then
echo "/dev/stdin"
guided_open_input() {
# Use stdin directly when it is an interactive terminal (e.g. SSH into LXC).
# Subshell probing of /dev/stdin fails in some constrained containers even
# when FD 0 is perfectly usable, so skip the probe and trust -t 0.
if [[ -t 0 ]]; then
GUIDED_FD=0
return 0
fi
if [[ -t 0 ]] && (: </proc/self/fd/0) 2>/dev/null; then
echo "/proc/self/fd/0"
return 0
fi
if (: </dev/tty) 2>/dev/null; then
echo "/dev/tty"
return 0
fi
return 1
# Non-interactive stdin: try to open /dev/tty as an explicit fd.
exec {GUIDED_FD}</dev/tty 2>/dev/null || return 1
}
guided_read() {
local __target_var="$1"
local __prompt="$2"
local __silent="${3:-false}"
local __input_source=""
local __value=""
if ! __input_source="$(guided_input_stream)"; then
return 1
fi
[[ -n "${GUIDED_FD:-}" ]] || guided_open_input || return 1
if [[ "$__silent" == true ]]; then
if ! read -r -s -p "$__prompt" __value <"$__input_source"; then
return 1
fi
read -r -s -u "$GUIDED_FD" -p "$__prompt" __value || return 1
echo
else
if ! read -r -p "$__prompt" __value <"$__input_source"; then
return 1
fi
read -r -u "$GUIDED_FD" -p "$__prompt" __value || return 1
fi
printf -v "$__target_var" '%s' "$__value"
@@ -708,7 +694,7 @@ prompt_model() {
run_guided_installer() {
local os_name="$1"
if ! guided_input_stream >/dev/null; then
if ! guided_open_input >/dev/null; then
error "guided installer requires an interactive terminal."
error "Run from a terminal, or pass --no-guided with explicit flags."
exit 1
@@ -1359,6 +1345,12 @@ if [[ -n "$TARGET_VERSION" ]]; then
step_dot "Installing ZeroClaw v${TARGET_VERSION}"
fi
if [[ "$SKIP_BUILD" == false ]]; then
# Clean stale build artifacts on upgrade to prevent bindgen/build-script
# cache mismatches (e.g. libsqlite3-sys bindgen.rs not found).
if [[ "$INSTALL_MODE" == "upgrade" && -d "$WORK_DIR/target/release/build" ]]; then
step_dot "Cleaning stale build cache (upgrade detected)"
cargo clean --release 2>/dev/null || true
fi
step_dot "Building release binary"
cargo build --release --locked
step_ok "Release binary built"
+11
View File
@@ -4,6 +4,7 @@ use crate::agent::dispatcher::{
use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
use crate::config::Config;
use crate::i18n::ToolDescriptions;
use crate::memory::{self, Memory, MemoryCategory};
use crate::observability::{self, Observer, ObserverEvent};
use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
@@ -40,6 +41,7 @@ pub struct Agent {
route_model_by_hint: HashMap<String, String>,
allowed_tools: Option<Vec<String>>,
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
tool_descriptions: Option<ToolDescriptions>,
}
pub struct AgentBuilder {
@@ -64,6 +66,7 @@ pub struct AgentBuilder {
route_model_by_hint: Option<HashMap<String, String>>,
allowed_tools: Option<Vec<String>>,
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
tool_descriptions: Option<ToolDescriptions>,
}
impl AgentBuilder {
@@ -90,6 +93,7 @@ impl AgentBuilder {
route_model_by_hint: None,
allowed_tools: None,
response_cache: None,
tool_descriptions: None,
}
}
@@ -207,6 +211,11 @@ impl AgentBuilder {
self
}
pub fn tool_descriptions(mut self, tool_descriptions: Option<ToolDescriptions>) -> Self {
self.tool_descriptions = tool_descriptions;
self
}
pub fn build(self) -> Result<Agent> {
let mut tools = self
.tools
@@ -257,6 +266,7 @@ impl AgentBuilder {
route_model_by_hint: self.route_model_by_hint.unwrap_or_default(),
allowed_tools: allowed,
response_cache: self.response_cache,
tool_descriptions: self.tool_descriptions,
})
}
}
@@ -456,6 +466,7 @@ impl Agent {
skills_prompt_mode: self.skills_prompt_mode,
identity_config: Some(&self.identity_config),
dispatcher_instructions: &instructions,
tool_descriptions: self.tool_descriptions.as_ref(),
};
self.prompt_builder.build(&ctx)
}
+363 -9
View File
@@ -1,5 +1,6 @@
use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
use crate::config::Config;
use crate::i18n::ToolDescriptions;
use crate::memory::{self, Memory, MemoryCategory};
use crate::multimodal;
use crate::observability::{self, runtime_trace, Observer, ObserverEvent};
@@ -7,7 +8,7 @@ use crate::providers::{
self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
};
use crate::runtime;
use crate::security::SecurityPolicy;
use crate::security::{AutonomyLevel, SecurityPolicy};
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
@@ -2180,8 +2181,10 @@ pub(crate) async fn agent_turn(
temperature: f64,
silent: bool,
channel_name: &str,
channel_reply_target: Option<&str>,
multimodal_config: &crate::config::MultimodalConfig,
max_tool_iterations: usize,
approval: Option<&ApprovalManager>,
excluded_tools: &[String],
dedup_exempt_tools: &[String],
activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
@@ -2196,8 +2199,9 @@ pub(crate) async fn agent_turn(
model,
temperature,
silent,
None,
approval,
channel_name,
channel_reply_target,
multimodal_config,
max_tool_iterations,
None,
@@ -2211,6 +2215,100 @@ pub(crate) async fn agent_turn(
.await
}
fn maybe_inject_channel_delivery_defaults(
tool_name: &str,
tool_args: &mut serde_json::Value,
channel_name: &str,
channel_reply_target: Option<&str>,
) {
if tool_name != "cron_add" {
return;
}
if !matches!(
channel_name,
"telegram" | "discord" | "slack" | "mattermost" | "matrix"
) {
return;
}
let Some(reply_target) = channel_reply_target
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let Some(args) = tool_args.as_object_mut() else {
return;
};
let is_agent_job = args
.get("job_type")
.and_then(serde_json::Value::as_str)
.is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent"))
|| args
.get("prompt")
.and_then(serde_json::Value::as_str)
.is_some_and(|prompt| !prompt.trim().is_empty());
if !is_agent_job {
return;
}
let default_delivery = || {
serde_json::json!({
"mode": "announce",
"channel": channel_name,
"to": reply_target,
})
};
match args.get_mut("delivery") {
None => {
args.insert("delivery".to_string(), default_delivery());
}
Some(serde_json::Value::Null) => {
*args.get_mut("delivery").expect("delivery key exists") = default_delivery();
}
Some(serde_json::Value::Object(delivery)) => {
if delivery
.get("mode")
.and_then(serde_json::Value::as_str)
.is_some_and(|mode| mode.eq_ignore_ascii_case("none"))
{
return;
}
delivery
.entry("mode".to_string())
.or_insert_with(|| serde_json::Value::String("announce".to_string()));
let needs_channel = delivery
.get("channel")
.and_then(serde_json::Value::as_str)
.is_none_or(|value| value.trim().is_empty());
if needs_channel {
delivery.insert(
"channel".to_string(),
serde_json::Value::String(channel_name.to_string()),
);
}
let needs_target = delivery
.get("to")
.and_then(serde_json::Value::as_str)
.is_none_or(|value| value.trim().is_empty());
if needs_target {
delivery.insert(
"to".to_string(),
serde_json::Value::String(reply_target.to_string()),
);
}
}
Some(_) => {}
}
}
async fn execute_one_tool(
call_name: &str,
call_arguments: serde_json::Value,
@@ -2404,6 +2502,7 @@ pub(crate) async fn run_tool_call_loop(
silent: bool,
approval: Option<&ApprovalManager>,
channel_name: &str,
channel_reply_target: Option<&str>,
multimodal_config: &crate::config::MultimodalConfig,
max_tool_iterations: usize,
cancellation_token: Option<CancellationToken>,
@@ -2421,9 +2520,10 @@ pub(crate) async fn run_tool_call_loop(
};
let turn_id = Uuid::new_v4().to_string();
let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
for iteration in 0..max_iterations {
let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
if cancellation_token
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
@@ -2813,6 +2913,13 @@ pub(crate) async fn run_tool_call_loop(
}
}
maybe_inject_channel_delivery_defaults(
&tool_name,
&mut tool_args,
channel_name,
channel_reply_target,
);
// ── Approval hook ────────────────────────────────
if let Some(mgr) = approval {
if mgr.needs_approval(&tool_name) {
@@ -3077,7 +3184,10 @@ pub(crate) async fn run_tool_call_loop(
/// Build the tool instruction block for the system prompt so the LLM knows
/// how to invoke tools.
pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
pub(crate) fn build_tool_instructions(
tools_registry: &[Box<dyn Tool>],
tool_descriptions: Option<&ToolDescriptions>,
) -> String {
let mut instructions = String::new();
instructions.push_str("\n## Tool Use Protocol\n\n");
instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
@@ -3093,11 +3203,14 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> Strin
instructions.push_str("### Available Tools\n\n");
for tool in tools_registry {
let desc = tool_descriptions
.and_then(|td| td.get(tool.name()))
.unwrap_or_else(|| tool.description());
let _ = writeln!(
instructions,
"**{}**: {}\nParameters: `{}`\n",
tool.name(),
tool.description(),
desc,
tool.parameters_schema()
);
}
@@ -3323,6 +3436,16 @@ pub async fn run(
.map(|b| b.board.clone())
.collect();
// ── Load locale-aware tool descriptions ────────────────────────
let i18n_locale = config
.locale
.as_deref()
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.unwrap_or_else(crate::i18n::detect_locale);
let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
let mut tool_descs: Vec<(&str, &str)> = vec![
@@ -3448,11 +3571,12 @@ pub async fn run(
bootstrap_max_chars,
native_tools,
config.skills.prompt_injection_mode,
config.autonomy.level,
);
// Append structured tool-use instructions with schemas (only for non-native providers)
if !native_tools {
system_prompt.push_str(&build_tool_instructions(&tools_registry));
system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
}
// Append deferred MCP tool names so the LLM knows what is available
@@ -3538,6 +3662,7 @@ pub async fn run(
false,
approval_manager.as_ref(),
channel_name,
None,
&config.multimodal,
config.agent.max_tool_iterations,
None,
@@ -3590,6 +3715,27 @@ pub async fn run(
}
}
}
// After successful multi-step execution, attempt autonomous skill creation.
#[cfg(feature = "skill-creation")]
if config.skills.skill_creation.enabled {
let tool_calls = crate::skills::creator::extract_tool_calls_from_history(&history);
if tool_calls.len() >= 2 {
let creator = crate::skills::creator::SkillCreator::new(
config.workspace_dir.clone(),
config.skills.skill_creation.clone(),
);
match creator.create_from_execution(&msg, &tool_calls, None).await {
Ok(Some(slug)) => {
tracing::info!(slug, "Auto-created skill from execution");
}
Ok(None) => {
tracing::debug!("Skill creation skipped (duplicate or disabled)");
}
Err(e) => tracing::warn!("Skill creation failed: {e}"),
}
}
}
final_output = response.clone();
println!("{response}");
observer.record_event(&ObserverEvent::TurnComplete);
@@ -3743,6 +3889,7 @@ pub async fn run(
false,
approval_manager.as_ref(),
channel_name,
None,
&config.multimodal,
config.agent.max_tool_iterations,
None,
@@ -3855,6 +4002,7 @@ pub async fn process_message(
&config.autonomy,
&config.workspace_dir,
));
let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy);
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
&config.memory,
&config.embedding_routes,
@@ -3988,6 +4136,16 @@ pub async fn process_message(
.map(|b| b.board.clone())
.collect();
// ── Load locale-aware tool descriptions ────────────────────────
let i18n_locale = config
.locale
.as_deref()
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.unwrap_or_else(crate::i18n::detect_locale);
let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
let mut tool_descs: Vec<(&str, &str)> = vec![
("shell", "Execute terminal commands."),
@@ -4036,6 +4194,16 @@ pub async fn process_message(
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
));
}
// Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
// Skip when autonomy is `Full` — full-autonomy agents keep all tools.
if config.autonomy.level != AutonomyLevel::Full {
let excluded = &config.autonomy.non_cli_excluded_tools;
if !excluded.is_empty() {
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
}
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
@@ -4051,9 +4219,10 @@ pub async fn process_message(
bootstrap_max_chars,
native_tools,
config.skills.prompt_injection_mode,
config.autonomy.level,
);
if !native_tools {
system_prompt.push_str(&build_tool_instructions(&tools_registry));
system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
}
if !deferred_section.is_empty() {
system_prompt.push('\n');
@@ -4084,8 +4253,11 @@ pub async fn process_message(
ChatMessage::system(&system_prompt),
ChatMessage::user(&enriched),
];
let excluded_tools =
let mut excluded_tools =
compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, message);
if config.autonomy.level != AutonomyLevel::Full {
excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned());
}
agent_turn(
provider.as_ref(),
@@ -4097,8 +4269,10 @@ pub async fn process_message(
config.default_temperature,
true,
"daemon",
None,
&config.multimodal,
config.agent.max_tool_iterations,
Some(&approval_manager),
&excluded_tools,
&config.agent.tool_call_dedup_exempt,
activated_handle_pm.as_ref(),
@@ -4414,6 +4588,57 @@ mod tests {
}
}
struct RecordingArgsTool {
name: String,
recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
}
impl RecordingArgsTool {
fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
Self {
name: name.to_string(),
recorded_args,
}
}
}
#[async_trait]
impl Tool for RecordingArgsTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Records tool arguments for regression tests"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string" },
"schedule": { "type": "object" },
"delivery": { "type": "object" }
}
})
}
async fn execute(
&self,
args: serde_json::Value,
) -> anyhow::Result<crate::tools::ToolResult> {
self.recorded_args
.lock()
.expect("recorded args lock should be valid")
.push(args.clone());
Ok(crate::tools::ToolResult {
success: true,
output: args.to_string(),
error: None,
})
}
}
struct DelayTool {
name: String,
delay_ms: u64,
@@ -4552,6 +4777,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
3,
None,
@@ -4601,6 +4827,7 @@ mod tests {
true,
None,
"cli",
None,
&multimodal,
3,
None,
@@ -4644,6 +4871,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
3,
None,
@@ -4773,6 +5001,7 @@ mod tests {
true,
Some(&approval_mgr),
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -4810,6 +5039,122 @@ mod tests {
);
}
#[tokio::test]
async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
</tool_call>"#,
"done",
]);
let recorded_args = Arc::new(Mutex::new(Vec::new()));
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
"cron_add",
Arc::clone(&recorded_args),
))];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("schedule a reminder"),
];
let observer = NoopObserver;
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"telegram",
Some("chat-42"),
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&[],
None,
None,
)
.await
.expect("cron_add delivery defaults should be injected");
assert_eq!(result, "done");
let recorded = recorded_args
.lock()
.expect("recorded args lock should be valid");
let delivery = recorded[0]["delivery"].clone();
assert_eq!(
delivery,
serde_json::json!({
"mode": "announce",
"channel": "telegram",
"to": "chat-42",
})
);
}
#[tokio::test]
async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
</tool_call>"#,
"done",
]);
let recorded_args = Arc::new(Mutex::new(Vec::new()));
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
"cron_add",
Arc::clone(&recorded_args),
))];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("schedule a quiet cron job"),
];
let observer = NoopObserver;
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"telegram",
Some("chat-42"),
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&[],
None,
None,
)
.await
.expect("explicit delivery mode should be preserved");
assert_eq!(result, "done");
let recorded = recorded_args
.lock()
.expect("recorded args lock should be valid");
assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
}
#[tokio::test]
async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
let provider = ScriptedProvider::from_text_responses(vec![
@@ -4845,6 +5190,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -4913,6 +5259,7 @@ mod tests {
true,
Some(&approval_mgr),
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -4972,6 +5319,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -5051,6 +5399,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -5107,6 +5456,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -5179,8 +5529,10 @@ mod tests {
0.0,
true,
"daemon",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
&[],
&[],
Some(&activated),
@@ -5763,7 +6115,7 @@ Tail"#;
std::path::Path::new("/tmp"),
));
let tools = tools::default_tools(security);
let instructions = build_tool_instructions(&tools);
let instructions = build_tool_instructions(&tools, None);
assert!(instructions.contains("## Tool Use Protocol"));
assert!(instructions.contains("<tool_call>"));
@@ -6623,6 +6975,7 @@ Let me check the result."#;
None, // no bootstrap_max_chars
true, // native_tools
crate::config::SkillsPromptInjectionMode::Full,
crate::security::AutonomyLevel::default(),
);
// Must contain zero XML protocol artifacts
@@ -7068,6 +7421,7 @@ Let me check the result."#;
true,
None,
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
+15 -1
View File
@@ -1,4 +1,5 @@
use crate::config::IdentityConfig;
use crate::i18n::ToolDescriptions;
use crate::identity;
use crate::skills::Skill;
use crate::tools::Tool;
@@ -17,6 +18,9 @@ pub struct PromptContext<'a> {
pub skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
pub identity_config: Option<&'a IdentityConfig>,
pub dispatcher_instructions: &'a str,
/// Locale-aware tool descriptions. When present, tool descriptions in
/// prompts are resolved from the locale file instead of hardcoded values.
pub tool_descriptions: Option<&'a ToolDescriptions>,
}
pub trait PromptSection: Send + Sync {
@@ -124,11 +128,15 @@ impl PromptSection for ToolsSection {
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::from("## Tools\n\n");
for tool in ctx.tools {
let desc = ctx
.tool_descriptions
.and_then(|td: &ToolDescriptions| td.get(tool.name()))
.unwrap_or_else(|| tool.description());
let _ = writeln!(
out,
"- **{}**: {}\n Parameters: `{}`",
tool.name(),
tool.description(),
desc,
tool.parameters_schema()
);
}
@@ -317,6 +325,7 @@ mod tests {
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
identity_config: Some(&identity_config),
dispatcher_instructions: "",
tool_descriptions: None,
};
let section = IdentitySection;
@@ -345,6 +354,7 @@ mod tests {
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
identity_config: None,
dispatcher_instructions: "instr",
tool_descriptions: None,
};
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
assert!(prompt.contains("## Tools"));
@@ -380,6 +390,7 @@ mod tests {
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
identity_config: None,
dispatcher_instructions: "",
tool_descriptions: None,
};
let output = SkillsSection.build(&ctx).unwrap();
@@ -418,6 +429,7 @@ mod tests {
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Compact,
identity_config: None,
dispatcher_instructions: "",
tool_descriptions: None,
};
let output = SkillsSection.build(&ctx).unwrap();
@@ -439,6 +451,7 @@ mod tests {
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
identity_config: None,
dispatcher_instructions: "instr",
tool_descriptions: None,
};
let rendered = DateTimeSection.build(&ctx).unwrap();
@@ -477,6 +490,7 @@ mod tests {
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
identity_config: None,
dispatcher_instructions: "",
tool_descriptions: None,
};
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
+139 -12
View File
@@ -98,7 +98,7 @@ use crate::observability::traits::{ObserverEvent, ObserverMetric};
use crate::observability::{self, runtime_trace, Observer};
use crate::providers::{self, ChatMessage, Provider};
use crate::runtime;
use crate::security::SecurityPolicy;
use crate::security::{AutonomyLevel, SecurityPolicy};
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::{Context, Result};
@@ -328,6 +328,7 @@ struct ChannelRuntimeContext {
multimodal: crate::config::MultimodalConfig,
hooks: Option<Arc<crate::hooks::HookRunner>>,
non_cli_excluded_tools: Arc<Vec<String>>,
autonomy_level: AutonomyLevel,
tool_call_dedup_exempt: Arc<Vec<String>>,
model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
query_classification: crate::config::QueryClassificationConfig,
@@ -2239,12 +2240,15 @@ async fn process_channel_message(
true,
Some(&*ctx.approval_manager),
msg.channel.as_str(),
Some(msg.reply_target.as_str()),
&ctx.multimodal,
ctx.max_tool_iterations,
Some(cancellation_token.clone()),
delta_tx,
ctx.hooks.as_deref(),
if msg.channel == "cli" {
if msg.channel == "cli"
|| ctx.autonomy_level == AutonomyLevel::Full
{
&[]
} else {
ctx.non_cli_excluded_tools.as_ref()
@@ -2785,6 +2789,7 @@ pub fn build_system_prompt(
bootstrap_max_chars,
false,
crate::config::SkillsPromptInjectionMode::Full,
AutonomyLevel::default(),
)
}
@@ -2797,6 +2802,7 @@ pub fn build_system_prompt_with_mode(
bootstrap_max_chars: Option<usize>,
native_tools: bool,
skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
autonomy_level: AutonomyLevel,
) -> String {
use std::fmt::Write;
let mut prompt = String::with_capacity(8192);
@@ -2862,13 +2868,18 @@ pub fn build_system_prompt_with_mode(
// ── 2. Safety ───────────────────────────────────────────────
prompt.push_str("## Safety\n\n");
prompt.push_str(
"- Do not exfiltrate private data.\n\
- Do not run destructive commands without asking.\n\
- Do not bypass oversight or approval mechanisms.\n\
- Prefer `trash` over `rm` (recoverable beats gone forever).\n\
- When in doubt, ask before acting externally.\n\n",
);
prompt.push_str("- Do not exfiltrate private data.\n");
if autonomy_level != AutonomyLevel::Full {
prompt.push_str(
"- Do not run destructive commands without asking.\n\
- Do not bypass oversight or approval mechanisms.\n",
);
}
prompt.push_str("- Prefer `trash` over `rm` (recoverable beats gone forever).\n");
if autonomy_level != AutonomyLevel::Full {
prompt.push_str("- When in doubt, ask before acting externally.\n");
}
prompt.push('\n');
// ── 3. Skills (full or compact, based on config) ─────────────
if !skills.is_empty() {
@@ -3229,12 +3240,16 @@ fn build_channel_by_id(config: &Config, channel_id: &str) -> Result<Arc<dyn Chan
.telegram
.as_ref()
.context("Telegram channel is not configured")?;
let ack = tg
.ack_reactions
.unwrap_or(config.channels_config.ack_reactions);
Ok(Arc::new(
TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
)
.with_ack_reactions(ack)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
.with_transcription(config.transcription.clone())
.with_workspace_dir(config.workspace_dir.clone()),
@@ -3322,6 +3337,9 @@ fn collect_configured_channels(
let mut channels = Vec::new();
if let Some(ref tg) = config.channels_config.telegram {
let ack = tg
.ack_reactions
.unwrap_or(config.channels_config.ack_reactions);
channels.push(ConfiguredChannel {
display_name: "Telegram",
channel: Arc::new(
@@ -3330,6 +3348,7 @@ fn collect_configured_channels(
tg.allowed_users.clone(),
tg.mention_only,
)
.with_ack_reactions(ack)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
.with_transcription(config.transcription.clone())
.with_workspace_dir(config.workspace_dir.clone()),
@@ -3361,6 +3380,7 @@ fn collect_configured_channels(
Vec::new(),
sl.allowed_users.clone(),
)
.with_thread_replies(sl.thread_replies.unwrap_or(true))
.with_group_reply_policy(sl.mention_only, Vec::new())
.with_workspace_dir(config.workspace_dir.clone()),
),
@@ -3931,6 +3951,16 @@ pub async fn start_channels(config: Config) -> Result<()> {
let skills = crate::skills::load_skills_with_config(&workspace, &config);
// ── Load locale-aware tool descriptions ────────────────────────
let i18n_locale = config
.locale
.as_deref()
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.unwrap_or_else(crate::i18n::detect_locale);
let i18n_search_dirs = crate::i18n::default_search_dirs(&workspace);
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
// Collect tool descriptions for the prompt
let mut tool_descs: Vec<(&str, &str)> = vec![
(
@@ -3988,8 +4018,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
// Filter out tools excluded for non-CLI channels so the system prompt
// does not advertise them for channel-driven runs.
// Skip this filter when autonomy is `Full` — full-autonomy agents keep
// all tools available regardless of channel.
let excluded = &config.autonomy.non_cli_excluded_tools;
if !excluded.is_empty() {
if !excluded.is_empty() && config.autonomy.level != AutonomyLevel::Full {
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
}
@@ -4008,9 +4040,13 @@ pub async fn start_channels(config: Config) -> Result<()> {
bootstrap_max_chars,
native_tools,
config.skills.prompt_injection_mode,
config.autonomy.level,
);
if !native_tools {
system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref()));
system_prompt.push_str(&build_tool_instructions(
tools_registry.as_ref(),
Some(&i18n_descs),
));
}
// Append deferred MCP tool names so the LLM knows what is available
@@ -4165,6 +4201,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
None
},
non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()),
autonomy_level: config.autonomy.level,
tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()),
model_routes: Arc::new(config.model_routes.clone()),
query_classification: config.query_classification.clone(),
@@ -4469,6 +4506,7 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -4578,6 +4616,7 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -4643,6 +4682,7 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -4727,6 +4767,7 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -5259,6 +5300,7 @@ BTC is currently around $65,000 based on latest tool output."#
slack: false,
},
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
@@ -5332,6 +5374,7 @@ BTC is currently around $65,000 based on latest tool output."#
slack: false,
},
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
@@ -5421,6 +5464,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -5493,6 +5537,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -5575,6 +5620,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -5678,6 +5724,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -5762,6 +5809,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -5861,6 +5909,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -5945,6 +5994,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -6019,6 +6069,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -6204,6 +6255,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -6297,6 +6349,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -6408,6 +6461,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
@@ -6510,6 +6564,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -6597,6 +6652,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -6669,6 +6725,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -6760,7 +6817,7 @@ BTC is currently around $65,000 based on latest tool output."#
"build_system_prompt should not emit protocol block directly"
);
prompt.push_str(&build_tool_instructions(&[]));
prompt.push_str(&build_tool_instructions(&[], None));
assert_eq!(
prompt.matches("## Tool Use Protocol").count(),
@@ -6935,6 +6992,7 @@ BTC is currently around $65,000 based on latest tool output."#
None,
false,
crate::config::SkillsPromptInjectionMode::Compact,
AutonomyLevel::default(),
);
assert!(prompt.contains("<available_skills>"), "missing skills XML");
@@ -7057,6 +7115,65 @@ BTC is currently around $65,000 based on latest tool output."#
assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
}
#[test]
fn full_autonomy_omits_approval_instructions() {
let ws = make_workspace();
let prompt = build_system_prompt_with_mode(
ws.path(),
"model",
&[],
&[],
None,
None,
false,
crate::config::SkillsPromptInjectionMode::Full,
AutonomyLevel::Full,
);
assert!(
!prompt.contains("without asking"),
"full autonomy prompt must not tell the model to ask before acting"
);
assert!(
!prompt.contains("ask before acting externally"),
"full autonomy prompt must not contain ask-before-acting instruction"
);
// Core safety rules should still be present
assert!(
prompt.contains("Do not exfiltrate private data"),
"data exfiltration guard must remain"
);
assert!(
prompt.contains("Prefer `trash` over `rm`"),
"trash-over-rm hint must remain"
);
}
#[test]
fn supervised_autonomy_includes_approval_instructions() {
let ws = make_workspace();
let prompt = build_system_prompt_with_mode(
ws.path(),
"model",
&[],
&[],
None,
None,
false,
crate::config::SkillsPromptInjectionMode::Full,
AutonomyLevel::Supervised,
);
assert!(
prompt.contains("without asking"),
"supervised prompt must include ask-before-acting instruction"
);
assert!(
prompt.contains("ask before acting externally"),
"supervised prompt must include ask-before-acting instruction"
);
}
#[test]
fn channel_notify_observer_truncates_utf8_arguments_safely() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
@@ -7299,6 +7416,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -7397,6 +7515,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -7495,6 +7614,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -8057,6 +8177,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -8136,6 +8257,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
query_classification: crate::config::QueryClassificationConfig::default(),
@@ -8289,6 +8411,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(model_routes),
query_classification: classification_config,
@@ -8392,6 +8515,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(model_routes),
query_classification: classification_config,
@@ -8487,6 +8611,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(model_routes),
query_classification: classification_config,
@@ -8602,6 +8727,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
autonomy_level: AutonomyLevel::default(),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(model_routes),
query_classification: classification_config,
@@ -8668,6 +8794,7 @@ This is an example JSON object for profile settings."#;
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
match build_channel_by_id(&config, "telegram") {
Ok(channel) => assert_eq!(channel.name(), "telegram"),
+37 -1
View File
@@ -25,6 +25,7 @@ pub struct SlackChannel {
channel_id: Option<String>,
channel_ids: Vec<String>,
allowed_users: Vec<String>,
thread_replies: bool,
mention_only: bool,
group_reply_allowed_sender_ids: Vec<String>,
user_display_name_cache: Mutex<HashMap<String, CachedSlackDisplayName>>,
@@ -75,6 +76,7 @@ impl SlackChannel {
channel_id,
channel_ids,
allowed_users,
thread_replies: true,
mention_only: false,
group_reply_allowed_sender_ids: Vec::new(),
user_display_name_cache: Mutex::new(HashMap::new()),
@@ -94,6 +96,12 @@ impl SlackChannel {
self
}
/// Configure whether outbound replies stay in the originating Slack thread.
pub fn with_thread_replies(mut self, thread_replies: bool) -> Self {
self.thread_replies = thread_replies;
self
}
/// Configure workspace directory used for persisting inbound Slack attachments.
pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self {
self.workspace_dir = Some(dir);
@@ -122,6 +130,14 @@ impl SlackChannel {
.any(|entry| entry == "*" || entry == user_id)
}
fn outbound_thread_ts<'a>(&self, message: &'a SendMessage) -> Option<&'a str> {
if self.thread_replies {
message.thread_ts.as_deref()
} else {
None
}
}
/// Get the bot's own user ID so we can ignore our own messages
async fn get_bot_user_id(&self) -> Option<String> {
let resp: serde_json::Value = self
@@ -2149,7 +2165,7 @@ impl Channel for SlackChannel {
"text": message.content
});
if let Some(ref ts) = message.thread_ts {
if let Some(ts) = self.outbound_thread_ts(message) {
body["thread_ts"] = serde_json::json!(ts);
}
@@ -2484,10 +2500,30 @@ mod tests {
#[test]
fn slack_group_reply_policy_defaults_to_all_messages() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec!["*".into()]);
assert!(ch.thread_replies);
assert!(!ch.mention_only);
assert!(ch.group_reply_allowed_sender_ids.is_empty());
}
#[test]
fn with_thread_replies_sets_flag() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])
.with_thread_replies(false);
assert!(!ch.thread_replies);
}
#[test]
fn outbound_thread_ts_respects_thread_replies_setting() {
let msg = SendMessage::new("hello", "C123").in_thread(Some("1741234567.100001".into()));
let threaded = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![]);
assert_eq!(threaded.outbound_thread_ts(&msg), Some("1741234567.100001"));
let channel_root = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])
.with_thread_replies(false);
assert_eq!(channel_root.outbound_thread_ts(&msg), None);
}
#[test]
fn with_workspace_dir_sets_field() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])
+37 -7
View File
@@ -332,6 +332,7 @@ pub struct TelegramChannel {
transcription: Option<crate::config::TranscriptionConfig>,
voice_transcriptions: Mutex<std::collections::HashMap<String, String>>,
workspace_dir: Option<std::path::PathBuf>,
ack_reactions: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -370,9 +371,16 @@ impl TelegramChannel {
transcription: None,
voice_transcriptions: Mutex::new(std::collections::HashMap::new()),
workspace_dir: None,
ack_reactions: true,
}
}
/// Configure whether Telegram-native acknowledgement reactions are sent.
pub fn with_ack_reactions(mut self, enabled: bool) -> Self {
self.ack_reactions = enabled;
self
}
/// Configure workspace directory for saving downloaded attachments.
pub fn with_workspace_dir(mut self, dir: std::path::PathBuf) -> Self {
self.workspace_dir = Some(dir);
@@ -2689,13 +2697,15 @@ Ensure only one `zeroclaw` process is using this bot token."
continue;
};
if let Some((reaction_chat_id, reaction_message_id)) =
Self::extract_update_message_target(update)
{
self.try_add_ack_reaction_nonblocking(
reaction_chat_id,
reaction_message_id,
);
if self.ack_reactions {
if let Some((reaction_chat_id, reaction_message_id)) =
Self::extract_update_message_target(update)
{
self.try_add_ack_reaction_nonblocking(
reaction_chat_id,
reaction_message_id,
);
}
}
// Send "typing" indicator immediately when we receive a message
@@ -4681,4 +4691,24 @@ mod tests {
// the agent loop will return ProviderCapabilityError before calling
// the provider, and the channel will send "⚠️ Error: ..." to the user.
}
#[test]
fn ack_reactions_defaults_to_true() {
let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
assert!(ch.ack_reactions);
}
#[test]
fn with_ack_reactions_false_disables_reactions() {
let ch =
TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(false);
assert!(!ch.ack_reactions);
}
#[test]
fn with_ack_reactions_true_keeps_reactions() {
let ch =
TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(true);
assert!(ch.ack_reactions);
}
}
+6 -5
View File
@@ -22,11 +22,11 @@ pub use schema::{
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, PluginsConfig, ProjectIntelConfig,
ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig,
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
SecretsConfig, SecurityConfig, SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode,
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
SwarmConfig, SwarmStrategy, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
WorkspaceConfig,
SecretsConfig, SecurityConfig, SecurityOpsConfig, SkillCreationConfig, SkillsConfig,
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy, TelegramConfig,
ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig, TunnelConfig,
WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig,
};
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
@@ -55,6 +55,7 @@ mod tests {
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
};
let discord = DiscordConfig {
+308 -6
View File
@@ -137,7 +137,12 @@ pub struct Config {
pub cloud_ops: CloudOpsConfig,
/// Conversational AI agent builder configuration (`[conversational_ai]`).
#[serde(default)]
///
/// Experimental / future feature — not yet wired into the agent runtime.
/// Omitted from generated config files when disabled (the default).
/// Existing configs that already contain this section will continue to
/// deserialize correctly thanks to `#[serde(default)]`.
#[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
pub conversational_ai: ConversationalAiConfig,
/// Managed cybersecurity service configuration (`[security_ops]`).
@@ -339,6 +344,17 @@ pub struct Config {
/// Plugin system configuration (`[plugins]`).
#[serde(default)]
pub plugins: PluginsConfig,
/// Locale for tool descriptions (e.g. `"en"`, `"zh-CN"`).
///
/// When set, tool descriptions shown in system prompts are loaded from
/// `tool_descriptions/<locale>.toml`. Falls back to English, then to
/// hardcoded descriptions.
///
/// If omitted or empty, the locale is auto-detected from `ZEROCLAW_LOCALE`,
/// `LANG`, or `LC_ALL` environment variables (defaulting to `"en"`).
#[serde(default)]
pub locale: Option<String>,
}
/// Multi-client workspace isolation configuration.
@@ -449,6 +465,14 @@ pub struct DelegateAgentConfig {
/// Maximum tool-call iterations in agentic mode.
#[serde(default = "default_max_tool_iterations")]
pub max_iterations: usize,
/// Timeout in seconds for non-agentic provider calls.
/// Defaults to 120 when unset. Must be between 1 and 3600.
#[serde(default)]
pub timeout_secs: Option<u64>,
/// Timeout in seconds for agentic sub-agent loops.
/// Defaults to 300 when unset. Must be between 1 and 3600.
#[serde(default)]
pub agentic_timeout_secs: Option<u64>,
}
// ── Swarms ──────────────────────────────────────────────────────
@@ -1163,6 +1187,34 @@ pub struct SkillsConfig {
/// `full` preserves legacy behavior. `compact` keeps context small and loads skills on demand.
#[serde(default)]
pub prompt_injection_mode: SkillsPromptInjectionMode,
/// Autonomous skill creation from successful multi-step task executions.
#[serde(default)]
pub skill_creation: SkillCreationConfig,
}
/// Autonomous skill creation configuration (`[skills.skill_creation]` section).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct SkillCreationConfig {
/// Enable automatic skill creation after successful multi-step tasks.
/// Default: `false`.
pub enabled: bool,
/// Maximum number of auto-generated skills to keep.
/// When exceeded, the oldest auto-generated skill is removed (LRU eviction).
pub max_skills: usize,
/// Embedding similarity threshold for deduplication.
/// Skills with descriptions more similar than this value are skipped.
pub similarity_threshold: f64,
}
impl Default for SkillCreationConfig {
fn default() -> Self {
Self {
enabled: false,
max_skills: 500,
similarity_threshold: 0.85,
}
}
}
/// Multimodal (image) handling configuration (`[multimodal]` section).
@@ -3998,7 +4050,8 @@ pub struct ClassificationRule {
pub struct HeartbeatConfig {
/// Enable periodic heartbeat pings. Default: `false`.
pub enabled: bool,
/// Interval in minutes between heartbeat pings. Default: `30`.
/// Interval in minutes between heartbeat pings. Default: `5`.
#[serde(default = "default_heartbeat_interval")]
pub interval_minutes: u32,
/// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2
/// executes only when the LLM decides there is work to do. Saves API cost
@@ -4042,6 +4095,10 @@ pub struct HeartbeatConfig {
pub max_run_history: u32,
}
fn default_heartbeat_interval() -> u32 {
5
}
fn default_two_phase() -> bool {
true
}
@@ -4062,7 +4119,7 @@ impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
interval_minutes: default_heartbeat_interval(),
two_phase: true,
message: None,
target: None,
@@ -4086,6 +4143,15 @@ pub struct CronConfig {
/// Enable the cron subsystem. Default: `true`.
#[serde(default = "default_true")]
pub enabled: bool,
/// Run all overdue jobs at scheduler startup. Default: `true`.
///
/// When the machine boots late or the daemon restarts, jobs whose
/// `next_run` is in the past are considered "missed". With this
/// option enabled the scheduler fires them once before entering
/// the normal polling loop. Disable if you prefer missed jobs to
/// simply wait for their next scheduled occurrence.
#[serde(default = "default_true")]
pub catch_up_on_startup: bool,
/// Maximum number of historical cron run records to retain. Default: `50`.
#[serde(default = "default_max_run_history")]
pub max_run_history: u32,
@@ -4099,6 +4165,7 @@ impl Default for CronConfig {
fn default() -> Self {
Self {
enabled: true,
catch_up_on_startup: true,
max_run_history: default_max_run_history(),
}
}
@@ -4511,6 +4578,11 @@ pub struct TelegramConfig {
/// Direct messages are always processed.
#[serde(default)]
pub mention_only: bool,
/// Override for the top-level `ack_reactions` setting. When `None`, the
/// channel falls back to `[channels_config].ack_reactions`. When set
/// explicitly, it takes precedence.
#[serde(default)]
pub ack_reactions: Option<bool>,
}
impl ChannelConfig for TelegramConfig {
@@ -4568,6 +4640,10 @@ pub struct SlackConfig {
/// cancels the in-flight request and starts a fresh response with preserved history.
#[serde(default)]
pub interrupt_on_new_message: bool,
/// When true (default), replies stay in the originating Slack thread.
/// When false, replies go to the channel root instead.
#[serde(default)]
pub thread_replies: Option<bool>,
/// When true, only respond to messages that @-mention the bot in groups.
/// Direct messages remain allowed.
#[serde(default)]
@@ -5083,6 +5159,10 @@ pub struct OtpConfig {
/// Domain-category presets expanded into `gated_domains`.
#[serde(default)]
pub gated_domain_categories: Vec<String>,
/// Maximum number of OTP challenge attempts before lockout.
#[serde(default = "default_otp_challenge_max_attempts")]
pub challenge_max_attempts: u32,
}
fn default_otp_token_ttl_secs() -> u64 {
@@ -5093,6 +5173,10 @@ fn default_otp_cache_valid_secs() -> u64 {
300
}
fn default_otp_challenge_max_attempts() -> u32 {
3
}
fn default_otp_gated_actions() -> Vec<String> {
vec![
"shell".to_string(),
@@ -5113,6 +5197,7 @@ impl Default for OtpConfig {
gated_actions: default_otp_gated_actions(),
gated_domains: Vec::new(),
gated_domain_categories: Vec::new(),
challenge_max_attempts: default_otp_challenge_max_attempts(),
}
}
}
@@ -5811,8 +5896,8 @@ fn default_conversational_ai_timeout_secs() -> u64 {
/// Conversational AI agent builder configuration (`[conversational_ai]` section).
///
/// Controls language detection, escalation behavior, conversation limits, and
/// analytics for conversational agent workflows. Disabled by default.
/// **Status: Reserved for future use.** This configuration is parsed but not yet
/// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ConversationalAiConfig {
/// Enable conversational AI features. Default: false.
@@ -5844,6 +5929,17 @@ pub struct ConversationalAiConfig {
pub knowledge_base_tool: Option<String>,
}
impl ConversationalAiConfig {
/// Returns `true` when the feature is disabled (the default).
///
/// Used by `#[serde(skip_serializing_if)]` to omit the entire
/// `[conversational_ai]` section from newly-generated config files,
/// avoiding user confusion over an undocumented / experimental section.
pub fn is_disabled(&self) -> bool {
!self.enabled
}
}
impl Default for ConversationalAiConfig {
fn default() -> Self {
Self {
@@ -5991,6 +6087,7 @@ impl Default for Config {
knowledge: KnowledgeConfig::default(),
linkedin: LinkedInConfig::default(),
plugins: PluginsConfig::default(),
locale: None,
}
}
}
@@ -6400,6 +6497,45 @@ fn read_codex_openai_api_key() -> Option<String> {
.map(ToString::to_string)
}
/// Ensure that essential bootstrap files exist in the workspace directory.
///
/// When the workspace is created outside of `zeroclaw onboard` (e.g., non-tty
/// daemon/cron sessions), these files would otherwise be missing. This function
/// creates sensible defaults that allow the agent to operate with a basic identity.
async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
let defaults: &[(&str, &str)] = &[
(
"IDENTITY.md",
"# IDENTITY.md — Who Am I?\n\n\
I am ZeroClaw, an autonomous AI agent.\n\n\
## Traits\n\
- Helpful, precise, and safety-conscious\n\
- I prioritize clarity and correctness\n",
),
(
"SOUL.md",
"# SOUL.md — Who You Are\n\n\
You are ZeroClaw, an autonomous AI agent.\n\n\
## Core Principles\n\
- Be helpful and accurate\n\
- Respect user intent and boundaries\n\
- Ask before taking destructive actions\n\
- Prefer safe, reversible operations\n",
),
];
for (filename, content) in defaults {
let path = workspace_dir.join(filename);
if !path.exists() {
fs::write(&path, content)
.await
.with_context(|| format!("Failed to create default {filename} in workspace"))?;
}
}
Ok(())
}
impl Config {
pub async fn load_or_init() -> Result<Self> {
let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
@@ -6416,6 +6552,8 @@ impl Config {
.await
.context("Failed to create workspace directory")?;
ensure_bootstrap_files(&workspace_dir).await?;
if config_path.exists() {
// Warn if config file is world-readable (may contain API keys)
#[cfg(unix)]
@@ -6942,6 +7080,9 @@ impl Config {
}
// Security OTP / estop
if self.security.otp.challenge_max_attempts == 0 {
anyhow::bail!("security.otp.challenge_max_attempts must be greater than 0");
}
if self.security.otp.token_ttl_secs == 0 {
anyhow::bail!("security.otp.token_ttl_secs must be greater than 0");
}
@@ -7252,6 +7393,31 @@ impl Config {
anyhow::bail!("security.nevis: {msg}");
}
// Delegate agent timeouts
const MAX_DELEGATE_TIMEOUT_SECS: u64 = 3600;
for (name, agent) in &self.agents {
if let Some(timeout) = agent.timeout_secs {
if timeout == 0 {
anyhow::bail!("agents.{name}.timeout_secs must be greater than 0");
}
if timeout > MAX_DELEGATE_TIMEOUT_SECS {
anyhow::bail!(
"agents.{name}.timeout_secs exceeds max {MAX_DELEGATE_TIMEOUT_SECS}"
);
}
}
if let Some(timeout) = agent.agentic_timeout_secs {
if timeout == 0 {
anyhow::bail!("agents.{name}.agentic_timeout_secs must be greater than 0");
}
if timeout > MAX_DELEGATE_TIMEOUT_SECS {
anyhow::bail!(
"agents.{name}.agentic_timeout_secs exceeds max {MAX_DELEGATE_TIMEOUT_SECS}"
);
}
}
}
// Transcription
{
let dp = self.transcription.default_provider.trim();
@@ -7597,6 +7763,13 @@ impl Config {
}
set_runtime_proxy_config(self.proxy.clone());
if self.conversational_ai.enabled {
tracing::warn!(
"conversational_ai.enabled = true but conversational AI features are not yet \
implemented; this section is reserved for future use and will be ignored"
);
}
}
async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
@@ -8204,7 +8377,7 @@ mod tests {
async fn heartbeat_config_default() {
let h = HeartbeatConfig::default();
assert!(!h.enabled);
assert_eq!(h.interval_minutes, 30);
assert_eq!(h.interval_minutes, 5);
assert!(h.message.is_none());
assert!(h.target.is_none());
assert!(h.to.is_none());
@@ -8238,11 +8411,13 @@ recipient = "42"
async fn cron_config_serde_roundtrip() {
let c = CronConfig {
enabled: false,
catch_up_on_startup: false,
max_run_history: 100,
};
let json = serde_json::to_string(&c).unwrap();
let parsed: CronConfig = serde_json::from_str(&json).unwrap();
assert!(!parsed.enabled);
assert!(!parsed.catch_up_on_startup);
assert_eq!(parsed.max_run_history, 100);
}
@@ -8256,6 +8431,7 @@ default_temperature = 0.7
let parsed: Config = toml::from_str(toml_str).unwrap();
assert!(parsed.cron.enabled);
assert!(parsed.cron.catch_up_on_startup);
assert_eq!(parsed.cron.max_run_history, 50);
}
@@ -8360,6 +8536,7 @@ default_temperature = 0.7
draft_update_interval_ms: default_draft_update_interval_ms(),
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
}),
discord: None,
slack: None,
@@ -8427,6 +8604,7 @@ default_temperature = 0.7
knowledge: KnowledgeConfig::default(),
linkedin: LinkedInConfig::default(),
plugins: PluginsConfig::default(),
locale: None,
};
let toml_str = toml::to_string_pretty(&config).unwrap();
@@ -8760,6 +8938,7 @@ tool_dispatcher = "xml"
knowledge: KnowledgeConfig::default(),
linkedin: LinkedInConfig::default(),
plugins: PluginsConfig::default(),
locale: None,
};
config.save().await.unwrap();
@@ -8818,6 +8997,8 @@ tool_dispatcher = "xml"
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
@@ -8942,6 +9123,7 @@ tool_dispatcher = "xml"
draft_update_interval_ms: 500,
interrupt_on_new_message: true,
mention_only: false,
ack_reactions: None,
};
let json = serde_json::to_string(&tc).unwrap();
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
@@ -9205,6 +9387,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
@@ -9214,6 +9397,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["U111"]);
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
@@ -9223,6 +9407,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.mention_only);
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
}
#[test]
@@ -9230,6 +9415,16 @@ allowed_users = ["@ops:matrix.org"]
let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
#[test]
async fn slack_config_deserializes_thread_replies() {
let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.thread_replies, Some(false));
assert!(!parsed.interrupt_on_new_message);
assert!(!parsed.mention_only);
}
@@ -9253,6 +9448,7 @@ channel_id = "C123"
let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
}
@@ -11256,6 +11452,7 @@ require_otp_to_resume = true
draft_update_interval_ms: default_draft_update_interval_ms(),
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
// Save (triggers encryption)
@@ -11811,4 +12008,109 @@ require_otp_to_resume = true
"Debug output must show [REDACTED] for client_secret"
);
}
#[test]
async fn telegram_config_ack_reactions_false_deserializes() {
let toml_str = r#"
bot_token = "123:ABC"
allowed_users = ["alice"]
ack_reactions = false
"#;
let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.ack_reactions, Some(false));
}
#[test]
async fn telegram_config_ack_reactions_true_deserializes() {
let toml_str = r#"
bot_token = "123:ABC"
allowed_users = ["alice"]
ack_reactions = true
"#;
let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.ack_reactions, Some(true));
}
#[test]
async fn telegram_config_ack_reactions_missing_defaults_to_none() {
let toml_str = r#"
bot_token = "123:ABC"
allowed_users = ["alice"]
"#;
let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.ack_reactions, None);
}
#[test]
async fn telegram_config_ack_reactions_channel_overrides_top_level() {
let tg_toml = r#"
bot_token = "123:ABC"
allowed_users = ["alice"]
ack_reactions = false
"#;
let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
let top_level_ack = true;
let effective = tg.ack_reactions.unwrap_or(top_level_ack);
assert!(
!effective,
"channel-level false must override top-level true"
);
}
#[test]
async fn telegram_config_ack_reactions_falls_back_to_top_level() {
let tg_toml = r#"
bot_token = "123:ABC"
allowed_users = ["alice"]
"#;
let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
let top_level_ack = false;
let effective = tg.ack_reactions.unwrap_or(top_level_ack);
assert!(
!effective,
"must fall back to top-level false when channel omits field"
);
}
// ── Bootstrap files ─────────────────────────────────────
#[test]
async fn ensure_bootstrap_files_creates_missing_files() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("workspace");
tokio::fs::create_dir_all(&ws).await.unwrap();
ensure_bootstrap_files(&ws).await.unwrap();
let soul = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
let identity = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
.await
.unwrap();
assert!(soul.contains("SOUL.md"));
assert!(identity.contains("IDENTITY.md"));
}
#[test]
async fn ensure_bootstrap_files_does_not_overwrite_existing() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("workspace");
tokio::fs::create_dir_all(&ws).await.unwrap();
let custom = "# My custom SOUL";
tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
ensure_bootstrap_files(&ws).await.unwrap();
let soul = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
assert_eq!(
soul, custom,
"ensure_bootstrap_files must not overwrite existing files"
);
// IDENTITY.md should still be created since it was missing
let identity = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
.await
.unwrap();
assert!(identity.contains("IDENTITY.md"));
}
}
+144 -8
View File
@@ -14,8 +14,8 @@ pub use schedule::{
};
#[allow(unused_imports)]
pub use store::{
add_agent_job, due_jobs, get_job, list_jobs, list_runs, record_last_run, record_run,
remove_job, reschedule_after_run, update_job,
add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run,
record_run, remove_job, reschedule_after_run, update_job,
};
pub use types::{
deserialize_maybe_stringified, CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType,
@@ -156,6 +156,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
expression,
tz,
agent,
allowed_tools,
command,
} => {
let schedule = Schedule::Cron {
@@ -172,12 +173,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
false,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added agent cron job {}", job.id);
println!(" Expr : {}", job.expression);
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added cron job {}", job.id);
println!(" Expr: {}", job.expression);
@@ -186,7 +195,12 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
}
Ok(())
}
crate::CronCommands::AddAt { at, agent, command } => {
crate::CronCommands::AddAt {
at,
agent,
allowed_tools,
command,
} => {
let at = chrono::DateTime::parse_from_rfc3339(&at)
.map_err(|e| anyhow::anyhow!("Invalid RFC3339 timestamp for --at: {e}"))?
.with_timezone(&chrono::Utc);
@@ -201,11 +215,19 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
true,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added one-shot agent cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added one-shot cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
@@ -216,6 +238,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
crate::CronCommands::AddEvery {
every_ms,
agent,
allowed_tools,
command,
} => {
let schedule = Schedule::Every { every_ms };
@@ -229,12 +252,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
false,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added interval agent cron job {}", job.id);
println!(" Every(ms): {every_ms}");
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Prompt : {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added interval cron job {}", job.id);
println!(" Every(ms): {every_ms}");
@@ -246,6 +277,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
crate::CronCommands::Once {
delay,
agent,
allowed_tools,
command,
} => {
if agent {
@@ -261,11 +293,19 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
true,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added one-shot agent cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_once(config, &delay, &command)?;
println!("✅ Added one-shot cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
@@ -279,21 +319,37 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
tz,
command,
name,
allowed_tools,
} => {
if expression.is_none() && tz.is_none() && command.is_none() && name.is_none() {
bail!("At least one of --expression, --tz, --command, or --name must be provided");
if expression.is_none()
&& tz.is_none()
&& command.is_none()
&& name.is_none()
&& allowed_tools.is_empty()
{
bail!(
"At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
);
}
let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
Some(get_job(config, &id)?)
} else {
None
};
// Merge expression/tz with the existing schedule so that
// --tz alone updates the timezone and --expression alone
// preserves the existing timezone.
let schedule = if expression.is_some() || tz.is_some() {
let existing = get_job(config, &id)?;
let (existing_expr, existing_tz) = match existing.schedule {
let existing = existing
.as_ref()
.expect("existing job must be loaded when updating schedule");
let (existing_expr, existing_tz) = match &existing.schedule {
Schedule::Cron {
expr,
tz: existing_tz,
} => (expr, existing_tz),
} => (expr.clone(), existing_tz.clone()),
_ => bail!("Cannot update expression/tz on a non-cron schedule"),
};
Some(Schedule::Cron {
@@ -304,10 +360,24 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None
};
if !allowed_tools.is_empty() {
let existing = existing
.as_ref()
.expect("existing job must be loaded when updating allowed tools");
if existing.job_type != JobType::Agent {
bail!("--allowed-tool is only supported for agent cron jobs");
}
}
let patch = CronJobPatch {
schedule,
command,
name,
allowed_tools: if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
..CronJobPatch::default()
};
@@ -430,6 +500,7 @@ mod tests {
tz: tz.map(Into::into),
command: command.map(Into::into),
name: name.map(Into::into),
allowed_tools: vec![],
},
config,
)
@@ -778,6 +849,7 @@ mod tests {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec![],
command: "Check server health: disk space, memory, CPU load".into(),
},
&config,
@@ -808,6 +880,7 @@ mod tests {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec![],
command: "Check server health: disk space, memory, CPU load".into(),
},
&config,
@@ -819,6 +892,68 @@ mod tests {
assert_eq!(jobs[0].job_type, JobType::Agent);
}
#[test]
fn cli_agent_allowed_tools_persist() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
handle_command(
crate::CronCommands::Add {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec!["file_read".into(), "web_search".into()],
command: "Check server health".into(),
},
&config,
)
.unwrap();
let jobs = list_jobs(&config).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(
jobs[0].allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
#[test]
fn cli_update_agent_allowed_tools_persist() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"original prompt",
SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
handle_command(
crate::CronCommands::Update {
id: job.id.clone(),
expression: None,
tz: None,
command: None,
name: None,
allowed_tools: vec!["shell".into()],
},
&config,
)
.unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
}
#[test]
fn cli_without_agent_flag_defaults_to_shell_job() {
let tmp = TempDir::new().unwrap();
@@ -829,6 +964,7 @@ mod tests {
expression: "*/5 * * * *".into(),
tz: None,
agent: false,
allowed_tools: vec![],
command: "echo ok".into(),
},
&config,
+130 -14
View File
@@ -6,8 +6,9 @@ use crate::channels::{
};
use crate::config::Config;
use crate::cron::{
due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, reschedule_after_run,
update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule, SessionTarget,
all_overdue_jobs, due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job,
reschedule_after_run, update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule,
SessionTarget,
};
use crate::security::SecurityPolicy;
use anyhow::Result;
@@ -33,6 +34,18 @@ pub async fn run(config: Config) -> Result<()> {
crate::health::mark_component_ok(SCHEDULER_COMPONENT);
// ── Startup catch-up: run ALL overdue jobs before entering the
// normal polling loop. The regular loop is capped by `max_tasks`,
// which could leave some overdue jobs waiting across many cycles
// if the machine was off for a while. The catch-up phase fetches
// without the `max_tasks` limit so every missed job fires once.
// Controlled by `[cron] catch_up_on_startup` (default: true).
if config.cron.catch_up_on_startup {
catch_up_overdue_jobs(&config, &security).await;
} else {
tracing::info!("Scheduler startup: catch-up disabled by config");
}
loop {
interval.tick().await;
// Keep scheduler liveness fresh even when there are no due jobs.
@@ -51,6 +64,35 @@ pub async fn run(config: Config) -> Result<()> {
}
}
/// Fetch **all** overdue jobs (ignoring `max_tasks`) and execute them.
///
/// Called once at scheduler startup so that jobs missed during downtime
/// (e.g. late boot, daemon restart) are caught up immediately.
async fn catch_up_overdue_jobs(config: &Config, security: &Arc<SecurityPolicy>) {
let now = Utc::now();
let jobs = match all_overdue_jobs(config, now) {
Ok(jobs) => jobs,
Err(e) => {
tracing::warn!("Startup catch-up query failed: {e}");
return;
}
};
if jobs.is_empty() {
tracing::info!("Scheduler startup: no overdue jobs to catch up");
return;
}
tracing::info!(
count = jobs.len(),
"Scheduler startup: catching up overdue jobs"
);
process_due_jobs(config, security, jobs, SCHEDULER_COMPONENT).await;
tracing::info!("Scheduler startup: catch-up complete");
}
pub async fn execute_job_now(config: &Config, job: &CronJob) -> (bool, String) {
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
Box::pin(execute_job_with_retry(config, &security, job)).await
@@ -506,18 +548,12 @@ async fn run_job_command_with_timeout(
);
}
let child = match Command::new("sh")
.arg("-lc")
.arg(&job.command)
.current_dir(&config.workspace_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
{
Ok(child) => child,
Err(e) => return (false, format!("spawn error: {e}")),
let child = match build_cron_shell_command(&job.command, &config.workspace_dir) {
Ok(mut cmd) => match cmd.spawn() {
Ok(child) => child,
Err(e) => return (false, format!("spawn error: {e}")),
},
Err(e) => return (false, format!("shell setup error: {e}")),
};
match time::timeout(timeout, child.wait_with_output()).await {
@@ -540,6 +576,35 @@ async fn run_job_command_with_timeout(
}
}
/// Build a shell `Command` for cron job execution.
///
/// Uses `sh -c <command>` (non-login shell). On Windows, ZeroClaw users
/// typically have Git Bash installed which provides `sh` in PATH, and
/// cron commands are written with Unix shell syntax. The previous `-lc`
/// (login shell) flag was dropped: login shells load the full user
/// profile on every invocation which is slow and may cause side effects.
///
/// The command is configured with:
/// - `current_dir` set to the workspace
/// - `stdin` piped to `/dev/null` (no interactive input)
/// - `stdout` and `stderr` piped for capture
/// - `kill_on_drop(true)` for safe timeout handling
fn build_cron_shell_command(
command: &str,
workspace_dir: &std::path::Path,
) -> anyhow::Result<Command> {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(command)
.current_dir(workspace_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
Ok(cmd)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -900,6 +965,7 @@ mod tests {
None,
None,
true,
None,
)
.unwrap();
let started = Utc::now();
@@ -925,6 +991,7 @@ mod tests {
None,
None,
true,
None,
)
.unwrap();
let started = Utc::now();
@@ -991,6 +1058,7 @@ mod tests {
best_effort: false,
}),
false,
None,
)
.unwrap();
let started = Utc::now();
@@ -1029,6 +1097,7 @@ mod tests {
best_effort: true,
}),
false,
None,
)
.unwrap();
let started = Utc::now();
@@ -1060,6 +1129,7 @@ mod tests {
None,
None,
false,
None,
)
.unwrap();
assert!(!job.delete_after_run);
@@ -1152,4 +1222,50 @@ mod tests {
.to_string()
.contains("matrix delivery channel requires `channel-matrix` feature"));
}
#[test]
fn build_cron_shell_command_uses_sh_non_login() {
let workspace = std::env::temp_dir();
let cmd = build_cron_shell_command("echo cron-test", &workspace).unwrap();
let debug = format!("{cmd:?}");
assert!(debug.contains("echo cron-test"));
assert!(debug.contains("\"sh\""), "should use sh: {debug}");
// Must NOT use login shell (-l) — login shells load full profile
// and are slow/unpredictable for cron jobs.
assert!(
!debug.contains("\"-lc\""),
"must not use login shell: {debug}"
);
}
#[tokio::test]
async fn build_cron_shell_command_executes_successfully() {
let workspace = std::env::temp_dir();
let mut cmd = build_cron_shell_command("echo cron-ok", &workspace).unwrap();
let output = cmd.output().await.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("cron-ok"));
}
#[tokio::test]
async fn catch_up_queries_all_overdue_jobs_ignoring_max_tasks() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.scheduler.max_tasks = 1; // limit normal polling to 1
// Create 3 jobs with "every minute" schedule
for i in 0..3 {
let _ = cron::add_job(&config, "* * * * *", &format!("echo catchup-{i}")).unwrap();
}
// Verify normal due_jobs is limited to max_tasks=1
let far_future = Utc::now() + ChronoDuration::days(1);
let due = cron::due_jobs(&config, far_future).unwrap();
assert_eq!(due.len(), 1, "due_jobs must respect max_tasks");
// all_overdue_jobs ignores the limit
let overdue = cron::all_overdue_jobs(&config, far_future).unwrap();
assert_eq!(overdue.len(), 3, "all_overdue_jobs must return all");
}
}
+170 -8
View File
@@ -77,6 +77,7 @@ pub fn add_agent_job(
model: Option<String>,
delivery: Option<DeliveryConfig>,
delete_after_run: bool,
allowed_tools: Option<Vec<String>>,
) -> Result<CronJob> {
let now = Utc::now();
validate_schedule(&schedule, now)?;
@@ -90,8 +91,8 @@ pub fn add_agent_job(
conn.execute(
"INSERT INTO cron_jobs (
id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run
) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, 1, ?8, ?9, ?10, ?11)",
enabled, delivery, delete_after_run, allowed_tools, created_at, next_run
) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, 1, ?8, ?9, ?10, ?11, ?12)",
params![
id,
expression,
@@ -102,6 +103,7 @@ pub fn add_agent_job(
model,
serde_json::to_string(&delivery)?,
if delete_after_run { 1 } else { 0 },
encode_allowed_tools(allowed_tools.as_ref())?,
now.to_rfc3339(),
next_run.to_rfc3339(),
],
@@ -117,7 +119,8 @@ pub fn list_jobs(config: &Config) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs ORDER BY next_run ASC",
)?;
@@ -135,7 +138,8 @@ pub fn get_job(config: &Config, job_id: &str) -> Result<CronJob> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs WHERE id = ?1",
)?;
@@ -168,7 +172,8 @@ pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs
WHERE enabled = 1 AND next_run <= ?1
ORDER BY next_run ASC
@@ -188,6 +193,34 @@ pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
})
}
/// Return **all** enabled overdue jobs without the `max_tasks` limit.
///
/// Used by the scheduler startup catch-up to ensure every missed job is
/// executed at least once after a period of downtime (late boot, daemon
/// restart, etc.).
pub fn all_overdue_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, allowed_tools
FROM cron_jobs
WHERE enabled = 1 AND next_run <= ?1
ORDER BY next_run ASC",
)?;
let rows = stmt.query_map(params![now.to_rfc3339()], map_cron_job_row)?;
let mut jobs = Vec::new();
for row in rows {
match row {
Ok(job) => jobs.push(job),
Err(e) => tracing::warn!("Skipping cron job with unparseable row data: {e}"),
}
}
Ok(jobs)
})
}
pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<CronJob> {
let mut job = get_job(config, job_id)?;
let mut schedule_changed = false;
@@ -222,6 +255,9 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
if let Some(delete_after_run) = patch.delete_after_run {
job.delete_after_run = delete_after_run;
}
if let Some(allowed_tools) = patch.allowed_tools {
job.allowed_tools = Some(allowed_tools);
}
if schedule_changed {
job.next_run = next_run_for_schedule(&job.schedule, Utc::now())?;
@@ -232,8 +268,8 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
"UPDATE cron_jobs
SET expression = ?1, command = ?2, schedule = ?3, job_type = ?4, prompt = ?5, name = ?6,
session_target = ?7, model = ?8, enabled = ?9, delivery = ?10, delete_after_run = ?11,
next_run = ?12
WHERE id = ?13",
allowed_tools = ?12, next_run = ?13
WHERE id = ?14",
params![
job.expression,
job.command,
@@ -246,6 +282,7 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
if job.enabled { 1 } else { 0 },
serde_json::to_string(&job.delivery)?,
if job.delete_after_run { 1 } else { 0 },
encode_allowed_tools(job.allowed_tools.as_ref())?,
job.next_run.to_rfc3339(),
job.id,
],
@@ -446,6 +483,7 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CronJob> {
let next_run_raw: String = row.get(13)?;
let last_run_raw: Option<String> = row.get(14)?;
let created_at_raw: String = row.get(12)?;
let allowed_tools_raw: Option<String> = row.get(17)?;
Ok(CronJob {
id: row.get(0)?,
@@ -468,7 +506,8 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CronJob> {
},
last_status: row.get(15)?,
last_output: row.get(16)?,
allowed_tools: None,
allowed_tools: decode_allowed_tools(allowed_tools_raw.as_deref())
.map_err(sql_conversion_error)?,
})
}
@@ -502,6 +541,25 @@ fn decode_delivery(delivery_raw: Option<&str>) -> Result<DeliveryConfig> {
Ok(DeliveryConfig::default())
}
fn encode_allowed_tools(allowed_tools: Option<&Vec<String>>) -> Result<Option<String>> {
allowed_tools
.map(serde_json::to_string)
.transpose()
.context("Failed to serialize cron allowed_tools")
}
fn decode_allowed_tools(raw: Option<&str>) -> Result<Option<Vec<String>>> {
if let Some(raw) = raw {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return serde_json::from_str(trimmed)
.map(Some)
.with_context(|| format!("Failed to parse cron allowed_tools JSON: {trimmed}"));
}
}
Ok(None)
}
fn add_column_if_missing(conn: &Connection, name: &str, sql_type: &str) -> Result<()> {
let mut stmt = conn.prepare("PRAGMA table_info(cron_jobs)")?;
let mut rows = stmt.query([])?;
@@ -557,6 +615,7 @@ fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>)
enabled INTEGER NOT NULL DEFAULT 1,
delivery TEXT,
delete_after_run INTEGER NOT NULL DEFAULT 0,
allowed_tools TEXT,
created_at TEXT NOT NULL,
next_run TEXT NOT NULL,
last_run TEXT,
@@ -590,6 +649,7 @@ fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>)
add_column_if_missing(&conn, "enabled", "INTEGER NOT NULL DEFAULT 1")?;
add_column_if_missing(&conn, "delivery", "TEXT")?;
add_column_if_missing(&conn, "delete_after_run", "INTEGER NOT NULL DEFAULT 0")?;
add_column_if_missing(&conn, "allowed_tools", "TEXT")?;
f(&conn)
}
@@ -704,6 +764,108 @@ mod tests {
assert_eq!(due.len(), 2);
}
#[test]
fn all_overdue_jobs_ignores_max_tasks_limit() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.scheduler.max_tasks = 2;
let _ = add_job(&config, "* * * * *", "echo ov-1").unwrap();
let _ = add_job(&config, "* * * * *", "echo ov-2").unwrap();
let _ = add_job(&config, "* * * * *", "echo ov-3").unwrap();
let far_future = Utc::now() + ChronoDuration::days(365);
// due_jobs respects the limit
let due = due_jobs(&config, far_future).unwrap();
assert_eq!(due.len(), 2);
// all_overdue_jobs returns everything
let overdue = all_overdue_jobs(&config, far_future).unwrap();
assert_eq!(overdue.len(), 3);
}
#[test]
fn all_overdue_jobs_excludes_disabled_jobs() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "* * * * *", "echo disabled").unwrap();
let _ = update_job(
&config,
&job.id,
CronJobPatch {
enabled: Some(false),
..CronJobPatch::default()
},
)
.unwrap();
let far_future = Utc::now() + ChronoDuration::days(365);
let overdue = all_overdue_jobs(&config, far_future).unwrap();
assert!(overdue.is_empty());
}
#[test]
fn add_agent_job_persists_allowed_tools() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Every { every_ms: 60_000 },
"do work",
SessionTarget::Isolated,
None,
None,
false,
Some(vec!["file_read".into(), "web_search".into()]),
)
.unwrap();
assert_eq!(
job.allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
let stored = get_job(&config, &job.id).unwrap();
assert_eq!(stored.allowed_tools, job.allowed_tools);
}
#[test]
fn update_job_persists_allowed_tools_patch() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Every { every_ms: 60_000 },
"do work",
SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
let updated = update_job(
&config,
&job.id,
CronJobPatch {
allowed_tools: Some(vec!["shell".into()]),
..CronJobPatch::default()
},
)
.unwrap();
assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
assert_eq!(
get_job(&config, &job.id).unwrap().allowed_tools,
Some(vec!["shell".into()])
);
}
#[test]
fn reschedule_after_run_persists_last_status_and_last_run() {
let tmp = TempDir::new().unwrap();
+7 -1
View File
@@ -315,7 +315,10 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
// ── Phase 1: LLM decision (two-phase mode) ──────────────
let tasks_to_run = if two_phase {
let decision_prompt = HeartbeatEngine::build_decision_prompt(&tasks);
let decision_prompt = format!(
"[Heartbeat Task | decision] {}",
HeartbeatEngine::build_decision_prompt(&tasks),
);
match Box::pin(crate::agent::run(
config.clone(),
Some(decision_prompt),
@@ -642,6 +645,7 @@ mod tests {
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
assert!(has_supervised_channels(&config));
}
@@ -755,6 +759,7 @@ mod tests {
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
let target = resolve_heartbeat_delivery(&config).unwrap();
@@ -771,6 +776,7 @@ mod tests {
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
let target = resolve_heartbeat_delivery(&config).unwrap();
+4
View File
@@ -1281,6 +1281,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
config.agents.insert(
@@ -1295,6 +1297,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
+59
View File
@@ -357,6 +357,65 @@ pub async fn handle_api_cron_delete(
}
}
/// GET /api/cron/settings — return cron subsystem settings
pub async fn handle_api_cron_settings_get(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
Json(serde_json::json!({
"enabled": config.cron.enabled,
"catch_up_on_startup": config.cron.catch_up_on_startup,
"max_run_history": config.cron.max_run_history,
}))
.into_response()
}
/// PATCH /api/cron/settings — update cron subsystem settings
pub async fn handle_api_cron_settings_patch(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let mut config = state.config.lock().clone();
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
config.cron.enabled = v;
}
if let Some(v) = body.get("catch_up_on_startup").and_then(|v| v.as_bool()) {
config.cron.catch_up_on_startup = v;
}
if let Some(v) = body.get("max_run_history").and_then(|v| v.as_u64()) {
config.cron.max_run_history = u32::try_from(v).unwrap_or(u32::MAX);
}
if let Err(e) = config.save().await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
)
.into_response();
}
*state.config.lock() = config.clone();
Json(serde_json::json!({
"status": "ok",
"enabled": config.cron.enabled,
"catch_up_on_startup": config.cron.catch_up_on_startup,
"max_run_history": config.cron.max_run_history,
}))
.into_response()
}
/// GET /api/integrations — list all integrations with status
pub async fn handle_api_integrations(
State(state): State<AppState>,
+19 -13
View File
@@ -633,6 +633,21 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
println!(" 🌐 Public URL: {url}");
}
println!(" 🌐 Web Dashboard: http://{display_addr}/");
if let Some(code) = pairing.pairing_code() {
println!();
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
println!(" ┌──────────────┐");
println!("{code}");
println!(" └──────────────┘");
println!();
} else if pairing.require_pairing() {
println!(" 🔒 Pairing: ACTIVE (bearer token required)");
println!(" To pair a new device: zeroclaw gateway get-paircode --new");
println!();
} else {
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
println!();
}
println!(" POST /pair — pair a new client (X-Pairing-Code header)");
println!(" POST /webhook — {{\"message\": \"your prompt\"}}");
if whatsapp_channel.is_some() {
@@ -656,19 +671,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
}
println!(" GET /health — health check");
println!(" GET /metrics — Prometheus metrics");
if let Some(code) = pairing.pairing_code() {
println!();
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
println!(" ┌──────────────┐");
println!("{code}");
println!(" └──────────────┘");
println!(" Send: POST /pair with header X-Pairing-Code: {code}");
} else if pairing.require_pairing() {
println!(" 🔒 Pairing: ACTIVE (bearer token required)");
println!(" To pair a new device: zeroclaw gateway get-paircode --new");
} else {
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
}
println!(" Press Ctrl+C to stop.\n");
crate::health::mark_component_ok("gateway");
@@ -764,6 +766,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.route("/api/tools", get(api::handle_api_tools))
.route("/api/cron", get(api::handle_api_cron_list))
.route("/api/cron", post(api::handle_api_cron_add))
.route(
"/api/cron/settings",
get(api::handle_api_cron_settings_get).patch(api::handle_api_cron_settings_patch),
)
.route("/api/cron/{id}", delete(api::handle_api_cron_delete))
.route("/api/cron/{id}/runs", get(api::handle_api_cron_runs))
.route("/api/integrations", get(api::handle_api_integrations))
+311
View File
@@ -0,0 +1,311 @@
//! Internationalization support for tool descriptions.
//!
//! Loads tool descriptions from TOML locale files in `tool_descriptions/`.
//! Falls back to English when a locale file or specific key is missing,
//! and ultimately falls back to the hardcoded `tool.description()` value
//! if no file-based description exists.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::debug;
/// Container for locale-specific tool descriptions loaded from TOML files.
#[derive(Debug, Clone)]
pub struct ToolDescriptions {
/// Descriptions from the requested locale (may be empty if file missing).
locale_descriptions: HashMap<String, String>,
/// English fallback descriptions (always loaded when locale != "en").
english_fallback: HashMap<String, String>,
/// The resolved locale tag (e.g. "en", "zh-CN").
locale: String,
}
/// TOML structure: `[tools]` table mapping tool name -> description string.
#[derive(Debug, serde::Deserialize)]
struct DescriptionFile {
#[serde(default)]
tools: HashMap<String, String>,
}
impl ToolDescriptions {
/// Load descriptions for the given locale.
///
/// `search_dirs` lists directories to probe for `tool_descriptions/<locale>.toml`.
/// The first directory containing a matching file wins.
///
/// Resolution:
/// 1. Look up tool name in the locale file.
/// 2. If missing (or locale file absent), look up in `en.toml`.
/// 3. If still missing, callers fall back to `tool.description()`.
pub fn load(locale: &str, search_dirs: &[PathBuf]) -> Self {
let locale_descriptions = load_locale_file(locale, search_dirs);
let english_fallback = if locale == "en" {
HashMap::new()
} else {
load_locale_file("en", search_dirs)
};
debug!(
locale = locale,
locale_keys = locale_descriptions.len(),
english_keys = english_fallback.len(),
"tool descriptions loaded"
);
Self {
locale_descriptions,
english_fallback,
locale: locale.to_string(),
}
}
/// Get the description for a tool by name.
///
/// Returns `Some(description)` if found in the locale file or English fallback.
/// Returns `None` if neither file contains the key (caller should use hardcoded).
pub fn get(&self, tool_name: &str) -> Option<&str> {
self.locale_descriptions
.get(tool_name)
.or_else(|| self.english_fallback.get(tool_name))
.map(String::as_str)
}
/// The resolved locale tag.
pub fn locale(&self) -> &str {
&self.locale
}
/// Create an empty instance that always returns `None` (hardcoded fallback).
pub fn empty() -> Self {
Self {
locale_descriptions: HashMap::new(),
english_fallback: HashMap::new(),
locale: "en".to_string(),
}
}
}
/// Detect the user's preferred locale from environment variables.
///
/// Checks `ZEROCLAW_LOCALE`, then `LANG`, then `LC_ALL`.
/// Returns "en" if none are set or parseable.
pub fn detect_locale() -> String {
if let Ok(val) = std::env::var("ZEROCLAW_LOCALE") {
let val = val.trim().to_string();
if !val.is_empty() {
return normalize_locale(&val);
}
}
for var in &["LANG", "LC_ALL"] {
if let Ok(val) = std::env::var(var) {
let locale = normalize_locale(&val);
if locale != "C" && locale != "POSIX" && !locale.is_empty() {
return locale;
}
}
}
"en".to_string()
}
/// Normalize a raw locale string (e.g. "zh_CN.UTF-8") to a tag we use
/// for file lookup (e.g. "zh-CN").
fn normalize_locale(raw: &str) -> String {
// Strip encoding suffix (.UTF-8, .utf8, etc.)
let base = raw.split('.').next().unwrap_or(raw);
// Replace underscores with hyphens for BCP-47-ish consistency
base.replace('_', "-")
}
/// Build the default set of search directories for locale files.
///
/// 1. The workspace directory itself (for project-local overrides).
/// 2. The binary's parent directory (for installed distributions).
/// 3. The compile-time `CARGO_MANIFEST_DIR` as a final fallback during dev.
pub fn default_search_dirs(workspace_dir: &Path) -> Vec<PathBuf> {
let mut dirs = vec![workspace_dir.to_path_buf()];
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
dirs.push(parent.to_path_buf());
}
}
// During development, also check the project root (where Cargo.toml lives).
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if !dirs.contains(&manifest_dir) {
dirs.push(manifest_dir);
}
dirs
}
/// Try to load and parse a locale TOML file from the first matching search dir.
fn load_locale_file(locale: &str, search_dirs: &[PathBuf]) -> HashMap<String, String> {
let filename = format!("tool_descriptions/{locale}.toml");
for dir in search_dirs {
let path = dir.join(&filename);
match std::fs::read_to_string(&path) {
Ok(contents) => match toml::from_str::<DescriptionFile>(&contents) {
Ok(parsed) => {
debug!(path = %path.display(), keys = parsed.tools.len(), "loaded locale file");
return parsed.tools;
}
Err(e) => {
debug!(path = %path.display(), error = %e, "failed to parse locale file");
}
},
Err(_) => {
// File not found in this directory, try next.
}
}
}
debug!(
locale = locale,
"no locale file found in any search directory"
);
HashMap::new()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// Helper: create a temp dir with a `tool_descriptions/<locale>.toml` file.
fn write_locale_file(dir: &Path, locale: &str, content: &str) {
let td = dir.join("tool_descriptions");
fs::create_dir_all(&td).unwrap();
fs::write(td.join(format!("{locale}.toml")), content).unwrap();
}
#[test]
fn load_english_descriptions() {
let tmp = tempfile::tempdir().unwrap();
write_locale_file(
tmp.path(),
"en",
r#"[tools]
shell = "Execute a shell command"
file_read = "Read file contents"
"#,
);
let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
assert_eq!(descs.get("shell"), Some("Execute a shell command"));
assert_eq!(descs.get("file_read"), Some("Read file contents"));
assert_eq!(descs.get("nonexistent"), None);
assert_eq!(descs.locale(), "en");
}
#[test]
fn fallback_to_english_when_locale_key_missing() {
let tmp = tempfile::tempdir().unwrap();
write_locale_file(
tmp.path(),
"en",
r#"[tools]
shell = "Execute a shell command"
file_read = "Read file contents"
"#,
);
write_locale_file(
tmp.path(),
"zh-CN",
r#"[tools]
shell = "在工作区目录中执行 shell 命令"
"#,
);
let descs = ToolDescriptions::load("zh-CN", &[tmp.path().to_path_buf()]);
// Translated key returns Chinese.
assert_eq!(descs.get("shell"), Some("在工作区目录中执行 shell 命令"));
// Missing key falls back to English.
assert_eq!(descs.get("file_read"), Some("Read file contents"));
assert_eq!(descs.locale(), "zh-CN");
}
#[test]
fn fallback_when_locale_file_missing() {
let tmp = tempfile::tempdir().unwrap();
write_locale_file(
tmp.path(),
"en",
r#"[tools]
shell = "Execute a shell command"
"#,
);
// Request a locale that has no file.
let descs = ToolDescriptions::load("fr", &[tmp.path().to_path_buf()]);
// Falls back to English.
assert_eq!(descs.get("shell"), Some("Execute a shell command"));
assert_eq!(descs.locale(), "fr");
}
#[test]
fn fallback_when_no_files_exist() {
let tmp = tempfile::tempdir().unwrap();
let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
assert_eq!(descs.get("shell"), None);
}
#[test]
fn empty_always_returns_none() {
let descs = ToolDescriptions::empty();
assert_eq!(descs.get("shell"), None);
assert_eq!(descs.locale(), "en");
}
#[test]
fn detect_locale_from_env() {
// Save and restore env.
let saved = std::env::var("ZEROCLAW_LOCALE").ok();
let saved_lang = std::env::var("LANG").ok();
std::env::set_var("ZEROCLAW_LOCALE", "ja-JP");
assert_eq!(detect_locale(), "ja-JP");
std::env::remove_var("ZEROCLAW_LOCALE");
std::env::set_var("LANG", "zh_CN.UTF-8");
assert_eq!(detect_locale(), "zh-CN");
// Restore.
match saved {
Some(v) => std::env::set_var("ZEROCLAW_LOCALE", v),
None => std::env::remove_var("ZEROCLAW_LOCALE"),
}
match saved_lang {
Some(v) => std::env::set_var("LANG", v),
None => std::env::remove_var("LANG"),
}
}
#[test]
fn normalize_locale_strips_encoding() {
assert_eq!(normalize_locale("en_US.UTF-8"), "en-US");
assert_eq!(normalize_locale("zh_CN.utf8"), "zh-CN");
assert_eq!(normalize_locale("fr"), "fr");
assert_eq!(normalize_locale("pt_BR"), "pt-BR");
}
#[test]
fn config_locale_overrides_env() {
// This tests the precedence logic: if config provides a locale,
// it should be used instead of detect_locale().
// The actual override happens at the call site in prompt.rs / loop_.rs,
// so here we just verify ToolDescriptions works with an explicit locale.
let tmp = tempfile::tempdir().unwrap();
write_locale_file(
tmp.path(),
"de",
r#"[tools]
shell = "Einen Shell-Befehl im Arbeitsverzeichnis ausführen"
"#,
);
let descs = ToolDescriptions::load("de", &[tmp.path().to_path_buf()]);
assert_eq!(
descs.get("shell"),
Some("Einen Shell-Befehl im Arbeitsverzeichnis ausführen")
);
}
}
+1
View File
@@ -840,6 +840,7 @@ mod tests {
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
let entries = all_integrations();
let tg = entries.iter().find(|e| e.name == "Telegram").unwrap();
+16
View File
@@ -54,6 +54,7 @@ pub(crate) mod hardware;
pub(crate) mod health;
pub(crate) mod heartbeat;
pub mod hooks;
pub mod i18n;
pub(crate) mod identity;
pub(crate) mod integrations;
pub mod memory;
@@ -298,6 +299,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@@ -316,6 +320,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@@ -334,6 +341,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@@ -354,6 +364,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@@ -387,6 +400,9 @@ Examples:
/// New job name
#[arg(long)]
name: Option<String>,
/// Replace the agent job allowlist with the specified tool names (repeatable)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
},
/// Pause a scheduled task
Pause {
+1
View File
@@ -89,6 +89,7 @@ mod hardware;
mod health;
mod heartbeat;
mod hooks;
mod i18n;
mod identity;
mod integrations;
mod memory;
+7
View File
@@ -101,6 +101,7 @@ pub fn should_skip_autosave_content(content: &str) -> bool {
let lowered = normalized.to_ascii_lowercase();
lowered.starts_with("[cron:")
|| lowered.starts_with("[heartbeat task")
|| lowered.starts_with("[distilled_")
|| lowered.contains("distilled_index_sig:")
}
@@ -471,6 +472,12 @@ mod tests {
assert!(should_skip_autosave_content(
"[DISTILLED_MEMORY_CHUNK 1/2] DISTILLED_INDEX_SIG:abc123"
));
assert!(should_skip_autosave_content(
"[Heartbeat Task | decision] Should I run tasks?"
));
assert!(should_skip_autosave_content(
"[Heartbeat Task | high] Execute scheduled patrol"
));
assert!(!should_skip_autosave_content(
"User prefers concise answers."
));
+101
View File
@@ -193,6 +193,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
knowledge: crate::config::KnowledgeConfig::default(),
linkedin: crate::config::LinkedInConfig::default(),
plugins: crate::config::PluginsConfig::default(),
locale: None,
};
println!(
@@ -462,6 +463,47 @@ fn resolve_quick_setup_dirs_with_home(home: &Path) -> (PathBuf, PathBuf) {
(config_dir.clone(), config_dir.join("workspace"))
}
fn homebrew_prefix_for_exe(exe: &Path) -> Option<&'static str> {
let exe = exe.to_string_lossy();
if exe == "/opt/homebrew/bin/zeroclaw"
|| exe.starts_with("/opt/homebrew/Cellar/zeroclaw/")
|| exe.starts_with("/opt/homebrew/opt/zeroclaw/")
{
return Some("/opt/homebrew");
}
if exe == "/usr/local/bin/zeroclaw"
|| exe.starts_with("/usr/local/Cellar/zeroclaw/")
|| exe.starts_with("/usr/local/opt/zeroclaw/")
{
return Some("/usr/local");
}
None
}
fn quick_setup_homebrew_service_note(
config_path: &Path,
workspace_dir: &Path,
exe: &Path,
) -> Option<String> {
let prefix = homebrew_prefix_for_exe(exe)?;
let service_root = Path::new(prefix).join("var").join("zeroclaw");
let service_config = service_root.join("config.toml");
let service_workspace = service_root.join("workspace");
if config_path == service_config || workspace_dir == service_workspace {
return None;
}
Some(format!(
"Homebrew service note: `brew services` uses {} (config {}) by default. Your onboarding just wrote {}. If you plan to run ZeroClaw as a service, copy or link this workspace first.",
service_workspace.display(),
service_config.display(),
config_path.display(),
))
}
#[allow(clippy::too_many_lines)]
async fn run_quick_setup_with_home(
credential_override: Option<&str>,
@@ -567,6 +609,7 @@ async fn run_quick_setup_with_home(
knowledge: crate::config::KnowledgeConfig::default(),
linkedin: crate::config::LinkedInConfig::default(),
plugins: crate::config::PluginsConfig::default(),
locale: None,
};
config.save().await?;
@@ -648,6 +691,16 @@ async fn run_quick_setup_with_home(
style("Config saved:").white().bold(),
style(config_path.display()).green()
);
if cfg!(target_os = "macos") {
if let Ok(exe) = std::env::current_exe() {
if let Some(note) =
quick_setup_homebrew_service_note(&config_path, &workspace_dir, &exe)
{
println!();
println!(" {}", style(note).yellow());
}
}
}
println!();
println!(" {}", style("Next steps:").white().bold());
if credential_override.is_none() {
@@ -3683,6 +3736,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
}
ChannelMenuChoice::Discord => {
@@ -3910,6 +3964,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
},
allowed_users,
interrupt_on_new_message: false,
thread_replies: None,
mention_only: false,
});
}
@@ -6063,6 +6118,52 @@ mod tests {
assert_eq!(config.config_path, expected_config_path);
}
#[test]
fn homebrew_prefix_for_exe_detects_supported_layouts() {
assert_eq!(
homebrew_prefix_for_exe(Path::new("/opt/homebrew/bin/zeroclaw")),
Some("/opt/homebrew")
);
assert_eq!(
homebrew_prefix_for_exe(Path::new(
"/opt/homebrew/Cellar/zeroclaw/0.5.0/bin/zeroclaw",
)),
Some("/opt/homebrew")
);
assert_eq!(
homebrew_prefix_for_exe(Path::new("/usr/local/bin/zeroclaw")),
Some("/usr/local")
);
assert_eq!(homebrew_prefix_for_exe(Path::new("/tmp/zeroclaw")), None);
}
#[test]
fn quick_setup_homebrew_service_note_mentions_service_workspace() {
let note = quick_setup_homebrew_service_note(
Path::new("/Users/alix/.zeroclaw/config.toml"),
Path::new("/Users/alix/.zeroclaw/workspace"),
Path::new("/opt/homebrew/bin/zeroclaw"),
)
.expect("homebrew installs should emit a service workspace note");
assert!(note.contains("/opt/homebrew/var/zeroclaw/workspace"));
assert!(note.contains("/opt/homebrew/var/zeroclaw/config.toml"));
assert!(note.contains("/Users/alix/.zeroclaw/config.toml"));
}
#[test]
fn quick_setup_homebrew_service_note_skips_matching_service_layout() {
let service_config = Path::new("/opt/homebrew/var/zeroclaw/config.toml");
let service_workspace = Path::new("/opt/homebrew/var/zeroclaw/workspace");
assert!(quick_setup_homebrew_service_note(
service_config,
service_workspace,
Path::new("/opt/homebrew/bin/zeroclaw"),
)
.is_none());
}
// ── scaffold_workspace: basic file creation ─────────────────
#[tokio::test]
+50 -41
View File
@@ -211,9 +211,9 @@ impl AnthropicProvider {
text.len() > 3072
}
/// Cache conversations with more than 4 messages (excluding system)
/// Cache conversations with more than 1 non-system message (i.e. after first exchange)
fn should_cache_conversation(messages: &[ChatMessage]) -> bool {
messages.iter().filter(|m| m.role != "system").count() > 4
messages.iter().filter(|m| m.role != "system").count() > 1
}
/// Apply cache control to the last message content block
@@ -447,17 +447,13 @@ impl AnthropicProvider {
}
}
// Convert system text to SystemPrompt with cache control if large
// Always use Blocks format with cache_control for system prompts
let system_prompt = system_text.map(|text| {
if Self::should_cache_system(&text) {
SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text,
cache_control: Some(CacheControl::ephemeral()),
}])
} else {
SystemPrompt::String(text)
}
SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text,
cache_control: Some(CacheControl::ephemeral()),
}])
});
(system_prompt, native_messages)
@@ -1063,12 +1059,8 @@ mod tests {
role: "user".to_string(),
content: "Hello".to_string(),
},
ChatMessage {
role: "assistant".to_string(),
content: "Hi".to_string(),
},
];
// Only 2 non-system messages
// Only 1 non-system message — should not cache
assert!(!AnthropicProvider::should_cache_conversation(&messages));
}
@@ -1078,8 +1070,8 @@ mod tests {
role: "system".to_string(),
content: "System prompt".to_string(),
}];
// Add 5 non-system messages
for i in 0..5 {
// Add 3 non-system messages
for i in 0..3 {
messages.push(ChatMessage {
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("Message {i}"),
@@ -1090,21 +1082,24 @@ mod tests {
#[test]
fn should_cache_conversation_boundary() {
let mut messages = vec![];
// Add exactly 4 non-system messages
for i in 0..4 {
messages.push(ChatMessage {
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("Message {i}"),
});
}
let messages = vec![ChatMessage {
role: "user".to_string(),
content: "Hello".to_string(),
}];
// Exactly 1 non-system message — should not cache
assert!(!AnthropicProvider::should_cache_conversation(&messages));
// Add one more to cross boundary
messages.push(ChatMessage {
role: "user".to_string(),
content: "One more".to_string(),
});
// Add one more to cross boundary (>1)
let messages = vec![
ChatMessage {
role: "user".to_string(),
content: "Hello".to_string(),
},
ChatMessage {
role: "assistant".to_string(),
content: "Hi".to_string(),
},
];
assert!(AnthropicProvider::should_cache_conversation(&messages));
}
@@ -1217,7 +1212,7 @@ mod tests {
}
#[test]
fn convert_messages_small_system_prompt() {
fn convert_messages_small_system_prompt_uses_blocks_with_cache() {
let messages = vec![ChatMessage {
role: "system".to_string(),
content: "Short system prompt".to_string(),
@@ -1226,10 +1221,17 @@ mod tests {
let (system_prompt, _) = AnthropicProvider::convert_messages(&messages);
match system_prompt.unwrap() {
SystemPrompt::String(s) => {
assert_eq!(s, "Short system prompt");
SystemPrompt::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Short system prompt");
assert!(
blocks[0].cache_control.is_some(),
"Small system prompts should have cache_control"
);
}
SystemPrompt::String(_) => {
panic!("Expected Blocks variant with cache_control for small prompt")
}
SystemPrompt::Blocks(_) => panic!("Expected String variant for small prompt"),
}
}
@@ -1254,12 +1256,16 @@ mod tests {
}
#[test]
fn backward_compatibility_native_chat_request() {
// Test that requests without cache_control serialize identically to old format
fn native_chat_request_with_blocks_system() {
// System prompts now always use Blocks format with cache_control
let req = NativeChatRequest {
model: "claude-3-opus".to_string(),
max_tokens: 4096,
system: Some(SystemPrompt::String("System".to_string())),
system: Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "System".to_string(),
cache_control: Some(CacheControl::ephemeral()),
}])),
messages: vec![NativeMessage {
role: "user".to_string(),
content: vec![NativeContentOut::Text {
@@ -1272,8 +1278,11 @@ mod tests {
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("cache_control"));
assert!(json.contains(r#""system":"System""#));
assert!(json.contains("System"));
assert!(
json.contains(r#""cache_control":{"type":"ephemeral"}"#),
"System prompt should include cache_control"
);
}
#[tokio::test]
+1 -1
View File
@@ -387,7 +387,7 @@ mod tests {
use std::io::Write;
let dir = std::env::temp_dir().join("zeroclaw_test_claude_code");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("fake_claude.sh");
let path = dir.join(format!("fake_claude_{}.sh", std::process::id()));
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "#!/bin/sh\ncat /dev/stdin").unwrap();
drop(f);
+7 -1
View File
@@ -1119,7 +1119,13 @@ fn create_provider_with_url_and_options(
)?))
}
// ── Primary providers (custom implementations) ───────
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))),
"openrouter" => {
let mut p = openrouter::OpenRouterProvider::new(key);
if let Some(t) = options.provider_timeout_secs {
p = p.with_timeout_secs(t);
}
Ok(Box::new(p))
}
"anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))),
"openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))),
// Ollama uses api_url for custom base URL (e.g. remote Ollama instance)
+60 -5
View File
@@ -4,12 +4,14 @@ use crate::providers::traits::{
Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
};
use crate::tools::ToolSpec;
use anyhow::Context as _;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
pub struct OpenRouterProvider {
credential: Option<String>,
timeout_secs: u64,
}
#[derive(Debug, Serialize)]
@@ -149,9 +151,16 @@ impl OpenRouterProvider {
pub fn new(credential: Option<&str>) -> Self {
Self {
credential: credential.map(ToString::to_string),
timeout_secs: 120,
}
}
/// Override the HTTP request timeout for LLM API calls.
pub fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
let items = tools?;
if items.is_empty() {
@@ -296,7 +305,11 @@ impl OpenRouterProvider {
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.openrouter", 120, 10)
crate::config::build_runtime_proxy_client_with_timeouts(
"provider.openrouter",
self.timeout_secs,
10,
)
}
}
@@ -368,7 +381,13 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let chat_response: ApiChatResponse = response.json().await?;
let text = response.text().await?;
let chat_response: ApiChatResponse = serde_json::from_str(&text).with_context(|| {
format!(
"OpenRouter: failed to decode response body: {}",
&text[..text.len().min(500)]
)
})?;
chat_response
.choices
@@ -415,7 +434,13 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let chat_response: ApiChatResponse = response.json().await?;
let text = response.text().await?;
let chat_response: ApiChatResponse = serde_json::from_str(&text).with_context(|| {
format!(
"OpenRouter: failed to decode response body: {}",
&text[..text.len().min(500)]
)
})?;
chat_response
.choices
@@ -460,7 +485,14 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let native_response: NativeChatResponse = response.json().await?;
let text = response.text().await?;
let native_response: NativeChatResponse =
serde_json::from_str(&text).with_context(|| {
format!(
"OpenRouter: failed to decode response body: {}",
&text[..text.len().min(500)]
)
})?;
let usage = native_response.usage.map(|u| TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
@@ -552,7 +584,14 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let native_response: NativeChatResponse = response.json().await?;
let text = response.text().await?;
let native_response: NativeChatResponse =
serde_json::from_str(&text).with_context(|| {
format!(
"OpenRouter: failed to decode response body: {}",
&text[..text.len().min(500)]
)
})?;
let usage = native_response.usage.map(|u| TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
@@ -1017,4 +1056,20 @@ mod tests {
assert!(json.contains("reasoning_content"));
assert!(json.contains("thinking..."));
}
// ═══════════════════════════════════════════════════════════════════════
// timeout_secs configuration tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn default_timeout_is_120() {
let provider = OpenRouterProvider::new(Some("key"));
assert_eq!(provider.timeout_secs, 120);
}
#[test]
fn with_timeout_secs_overrides_default() {
let provider = OpenRouterProvider::new(Some("key")).with_timeout_secs(300);
assert_eq!(provider.timeout_secs, 300);
}
}
+74
View File
@@ -22,6 +22,13 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool {
return false;
}
// Tool schema validation errors are NOT non-retryable — the provider's
// built-in fallback in compatible.rs can recover by switching to
// prompt-guided tool instructions.
if is_tool_schema_error(err) {
return false;
}
// 4xx errors are generally non-retryable (bad request, auth failure, etc.),
// except 429 (rate-limit — transient) and 408 (timeout — worth retrying).
if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
@@ -73,6 +80,22 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool {
|| msg_lower.contains("invalid"))
}
/// Check if an error is a tool schema validation failure (e.g. Groq returning
/// "tool call validation failed: attempted to call tool '...' which was not in request").
/// These errors should NOT be classified as non-retryable because the provider's
/// built-in fallback logic (`compatible.rs::is_native_tool_schema_unsupported`)
/// can recover by switching to prompt-guided tool instructions.
pub fn is_tool_schema_error(err: &anyhow::Error) -> bool {
let lower = err.to_string().to_lowercase();
let hints = [
"tool call validation failed",
"was not in request",
"not found in tool list",
"invalid_tool_call",
];
hints.iter().any(|hint| lower.contains(hint))
}
fn is_context_window_exceeded(err: &anyhow::Error) -> bool {
let lower = err.to_string().to_lowercase();
let hints = [
@@ -2189,4 +2212,55 @@ mod tests {
// Should have been called twice: once with full messages, once with truncated
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
// ── Tool schema error detection tests ───────────────────────────────
#[test]
fn tool_schema_error_detects_groq_validation_failure() {
let msg = r#"Groq API error (400 Bad Request): {"error":{"message":"tool call validation failed: attempted to call tool 'memory_recall' which was not in request"}}"#;
let err = anyhow::anyhow!("{}", msg);
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_not_in_request() {
let err = anyhow::anyhow!("tool 'search' was not in request");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_not_found_in_tool_list() {
let err = anyhow::anyhow!("function 'foo' not found in tool list");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_invalid_tool_call() {
let err = anyhow::anyhow!("invalid_tool_call: no matching function");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_ignores_unrelated_errors() {
let err = anyhow::anyhow!("invalid api key");
assert!(!is_tool_schema_error(&err));
let err = anyhow::anyhow!("model not found");
assert!(!is_tool_schema_error(&err));
}
#[test]
fn non_retryable_returns_false_for_tool_schema_400() {
// A 400 error with tool schema validation text should NOT be non-retryable.
let msg = "400 Bad Request: tool call validation failed: attempted to call tool 'x' which was not in request";
let err = anyhow::anyhow!("{}", msg);
assert!(!is_non_retryable(&err));
}
#[test]
fn non_retryable_returns_true_for_other_400_errors() {
// A regular 400 error (e.g. invalid API key) should still be non-retryable.
let err = anyhow::anyhow!("400 Bad Request: invalid api key provided");
assert!(is_non_retryable(&err));
}
}
+61 -7
View File
@@ -409,13 +409,43 @@ fn has_shell_shebang(path: &Path) -> bool {
return false;
};
let prefix = &content[..content.len().min(128)];
let shebang = String::from_utf8_lossy(prefix).to_ascii_lowercase();
shebang.starts_with("#!")
&& (shebang.contains("sh")
|| shebang.contains("bash")
|| shebang.contains("zsh")
|| shebang.contains("pwsh")
|| shebang.contains("powershell"))
let shebang_line = String::from_utf8_lossy(prefix)
.lines()
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let Some(interpreter) = shebang_interpreter(&shebang_line) else {
return false;
};
matches!(
interpreter,
"sh" | "bash" | "zsh" | "ksh" | "fish" | "pwsh" | "powershell"
)
}
fn shebang_interpreter(line: &str) -> Option<&str> {
let shebang = line.strip_prefix("#!")?.trim();
if shebang.is_empty() {
return None;
}
let mut parts = shebang.split_whitespace();
let first = parts.next()?;
let first_basename = Path::new(first).file_name()?.to_str()?;
if first_basename == "env" {
for part in parts {
if part.starts_with('-') {
continue;
}
return Path::new(part).file_name()?.to_str();
}
return None;
}
Some(first_basename)
}
fn extract_markdown_links(content: &str) -> Vec<String> {
@@ -586,6 +616,30 @@ mod tests {
);
}
#[test]
fn audit_allows_python_shebang_file_when_early_text_contains_sh() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("python-helper");
let scripts_dir = skill_dir.join("scripts");
std::fs::create_dir_all(&scripts_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
std::fs::write(
scripts_dir.join("helper.py"),
"#!/usr/bin/env python3\n\"\"\"Refresh report cache.\"\"\"\n\nprint(\"ok\")\n",
)
.unwrap();
let report = audit_skill_directory(&skill_dir).unwrap();
assert!(
!report
.findings
.iter()
.any(|finding| finding.contains("script-like files are blocked")),
"{:#?}",
report.findings
);
}
#[test]
fn audit_rejects_markdown_escape_links() {
let dir = tempfile::tempdir().unwrap();
+897
View File
@@ -0,0 +1,897 @@
// Autonomous skill creation from successful multi-step task executions.
//
// After the agent completes a multi-step tool-call sequence, this module
// can persist the execution as a reusable skill definition (SKILL.toml)
// under `~/.zeroclaw/workspace/skills/<slug>/`.
use crate::config::SkillCreationConfig;
use crate::memory::embeddings::EmbeddingProvider;
use crate::memory::vector::cosine_similarity;
use anyhow::{Context, Result};
use std::path::PathBuf;
/// A record of a single tool call executed during a task.
#[derive(Debug, Clone)]
pub struct ToolCallRecord {
pub name: String,
pub args: serde_json::Value,
}
/// Creates reusable skill definitions from successful multi-step executions.
pub struct SkillCreator {
workspace_dir: PathBuf,
config: SkillCreationConfig,
}
impl SkillCreator {
pub fn new(workspace_dir: PathBuf, config: SkillCreationConfig) -> Self {
Self {
workspace_dir,
config,
}
}
/// Attempt to create a skill from a successful multi-step task execution.
/// Returns `Ok(Some(slug))` if a skill was created, `Ok(None)` if skipped
/// (disabled, duplicate, or insufficient tool calls).
pub async fn create_from_execution(
&self,
task_description: &str,
tool_calls: &[ToolCallRecord],
embedding_provider: Option<&dyn EmbeddingProvider>,
) -> Result<Option<String>> {
if !self.config.enabled {
return Ok(None);
}
if tool_calls.len() < 2 {
return Ok(None);
}
// Deduplicate via embeddings when an embedding provider is available.
if let Some(provider) = embedding_provider {
if provider.name() != "none" && self.is_duplicate(task_description, provider).await? {
return Ok(None);
}
}
let slug = Self::generate_slug(task_description);
if !Self::validate_slug(&slug) {
return Ok(None);
}
// Enforce LRU limit before writing a new skill.
self.enforce_lru_limit().await?;
let skill_dir = self.skills_dir().join(&slug);
tokio::fs::create_dir_all(&skill_dir)
.await
.with_context(|| {
format!("Failed to create skill directory: {}", skill_dir.display())
})?;
let toml_content = Self::generate_skill_toml(&slug, task_description, tool_calls);
let toml_path = skill_dir.join("SKILL.toml");
tokio::fs::write(&toml_path, toml_content.as_bytes())
.await
.with_context(|| format!("Failed to write {}", toml_path.display()))?;
Ok(Some(slug))
}
/// Generate a URL-safe slug from a task description.
/// Alphanumeric and hyphens only, max 64 characters.
fn generate_slug(description: &str) -> String {
let slug: String = description
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
// Collapse consecutive hyphens.
let mut collapsed = String::with_capacity(slug.len());
let mut prev_hyphen = false;
for c in slug.chars() {
if c == '-' {
if !prev_hyphen {
collapsed.push('-');
}
prev_hyphen = true;
} else {
collapsed.push(c);
prev_hyphen = false;
}
}
// Trim leading/trailing hyphens, then truncate.
let trimmed = collapsed.trim_matches('-');
if trimmed.len() > 64 {
// Truncate at a hyphen boundary if possible.
let truncated = &trimmed[..64];
truncated.trim_end_matches('-').to_string()
} else {
trimmed.to_string()
}
}
/// Validate that a slug is non-empty, alphanumeric + hyphens, max 64 chars.
fn validate_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug.len() <= 64
&& slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
&& !slug.starts_with('-')
&& !slug.ends_with('-')
}
/// Generate SKILL.toml content from task execution data.
fn generate_skill_toml(slug: &str, description: &str, tool_calls: &[ToolCallRecord]) -> String {
use std::fmt::Write;
let mut toml = String::new();
toml.push_str("[skill]\n");
let _ = writeln!(toml, "name = {}", toml_escape(slug));
let _ = writeln!(
toml,
"description = {}",
toml_escape(&format!("Auto-generated: {description}"))
);
toml.push_str("version = \"0.1.0\"\n");
toml.push_str("author = \"zeroclaw-auto\"\n");
toml.push_str("tags = [\"auto-generated\"]\n");
for call in tool_calls {
toml.push('\n');
toml.push_str("[[tools]]\n");
let _ = writeln!(toml, "name = {}", toml_escape(&call.name));
let _ = writeln!(
toml,
"description = {}",
toml_escape(&format!("Tool used in task: {}", call.name))
);
toml.push_str("kind = \"shell\"\n");
// Extract the command from args if available, otherwise use the tool name.
let command = call
.args
.get("command")
.and_then(serde_json::Value::as_str)
.unwrap_or(&call.name);
let _ = writeln!(toml, "command = {}", toml_escape(command));
}
toml
}
/// Check if a skill with a similar description already exists.
async fn is_duplicate(
&self,
description: &str,
embedding_provider: &dyn EmbeddingProvider,
) -> Result<bool> {
let new_embedding = embedding_provider.embed_one(description).await?;
if new_embedding.is_empty() {
return Ok(false);
}
let skills_dir = self.skills_dir();
if !skills_dir.exists() {
return Ok(false);
}
let mut entries = tokio::fs::read_dir(&skills_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let toml_path = entry.path().join("SKILL.toml");
if !toml_path.exists() {
continue;
}
let content = tokio::fs::read_to_string(&toml_path).await?;
// Extract description from the TOML to compare.
if let Some(desc) = extract_description_from_toml(&content) {
let existing_embedding = embedding_provider.embed_one(&desc).await?;
if !existing_embedding.is_empty() {
#[allow(clippy::cast_possible_truncation)]
let similarity =
f64::from(cosine_similarity(&new_embedding, &existing_embedding));
if similarity > self.config.similarity_threshold {
return Ok(true);
}
}
}
}
Ok(false)
}
/// Remove the oldest auto-generated skill when we exceed `max_skills`.
async fn enforce_lru_limit(&self) -> Result<()> {
let skills_dir = self.skills_dir();
if !skills_dir.exists() {
return Ok(());
}
let mut auto_skills: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
let mut entries = tokio::fs::read_dir(&skills_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let toml_path = entry.path().join("SKILL.toml");
if !toml_path.exists() {
continue;
}
let content = tokio::fs::read_to_string(&toml_path).await?;
if content.contains("\"zeroclaw-auto\"") || content.contains("\"auto-generated\"") {
let modified = tokio::fs::metadata(&toml_path)
.await?
.modified()
.unwrap_or(std::time::UNIX_EPOCH);
auto_skills.push((entry.path(), modified));
}
}
// If at or above the limit, remove the oldest.
if auto_skills.len() >= self.config.max_skills {
auto_skills.sort_by_key(|(_, modified)| *modified);
if let Some((oldest_dir, _)) = auto_skills.first() {
tokio::fs::remove_dir_all(oldest_dir)
.await
.with_context(|| {
format!(
"Failed to remove oldest auto-generated skill: {}",
oldest_dir.display()
)
})?;
}
}
Ok(())
}
fn skills_dir(&self) -> PathBuf {
self.workspace_dir.join("skills")
}
}
/// Escape a string for TOML value (double-quoted).
fn toml_escape(s: &str) -> String {
let escaped = s
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
format!("\"{escaped}\"")
}
/// Extract the description field from a SKILL.toml string.
fn extract_description_from_toml(content: &str) -> Option<String> {
#[derive(serde::Deserialize)]
struct Partial {
skill: PartialSkill,
}
#[derive(serde::Deserialize)]
struct PartialSkill {
description: Option<String>,
}
toml::from_str::<Partial>(content)
.ok()
.and_then(|p| p.skill.description)
}
/// Extract `ToolCallRecord`s from the agent conversation history.
///
/// Scans assistant messages for tool call patterns (both JSON and XML formats)
/// and returns records for each unique tool invocation.
pub fn extract_tool_calls_from_history(
history: &[crate::providers::ChatMessage],
) -> Vec<ToolCallRecord> {
let mut records = Vec::new();
for msg in history {
if msg.role != "assistant" {
continue;
}
// Try parsing as JSON (native tool_calls format).
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&msg.content) {
if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
for call in tool_calls {
if let Some(function) = call.get("function") {
let name = function
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
let args_str = function
.get("arguments")
.and_then(serde_json::Value::as_str)
.unwrap_or("{}");
let args = serde_json::from_str(args_str).unwrap_or_default();
if !name.is_empty() {
records.push(ToolCallRecord { name, args });
}
}
}
}
}
// Also try XML tool call format: <tool_name>...</tool_name>
// Simple extraction for `<shell>{"command":"..."}</shell>` style tags.
let content = &msg.content;
let mut pos = 0;
while pos < content.len() {
if let Some(start) = content[pos..].find('<') {
let abs_start = pos + start;
if let Some(end) = content[abs_start..].find('>') {
let tag = &content[abs_start + 1..abs_start + end];
// Skip closing tags and meta tags.
if tag.starts_with('/') || tag.starts_with('!') || tag.starts_with('?') {
pos = abs_start + end + 1;
continue;
}
let tag_name = tag.split_whitespace().next().unwrap_or(tag);
let close_tag = format!("</{tag_name}>");
if let Some(close_pos) = content[abs_start + end + 1..].find(&close_tag) {
let inner = &content[abs_start + end + 1..abs_start + end + 1 + close_pos];
let args: serde_json::Value =
serde_json::from_str(inner.trim()).unwrap_or_default();
// Only add if it looks like a tool call (not HTML/formatting tags).
if tag_name != "tool_result"
&& tag_name != "tool_results"
&& !tag_name.contains(':')
&& args.is_object()
&& !args.as_object().map_or(true, |o| o.is_empty())
{
records.push(ToolCallRecord {
name: tag_name.to_string(),
args,
});
}
pos = abs_start + end + 1 + close_pos + close_tag.len();
} else {
pos = abs_start + end + 1;
}
} else {
break;
}
} else {
break;
}
}
}
records
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::embeddings::{EmbeddingProvider, NoopEmbedding};
use async_trait::async_trait;
// ── Slug generation ──────────────────────────────────────────
#[test]
fn slug_basic() {
assert_eq!(
SkillCreator::generate_slug("Deploy to production"),
"deploy-to-production"
);
}
#[test]
fn slug_special_characters() {
assert_eq!(
SkillCreator::generate_slug("Build & test (CI/CD) pipeline!"),
"build-test-ci-cd-pipeline"
);
}
#[test]
fn slug_max_length() {
let long_desc = "a".repeat(100);
let slug = SkillCreator::generate_slug(&long_desc);
assert!(slug.len() <= 64);
}
#[test]
fn slug_leading_trailing_hyphens() {
let slug = SkillCreator::generate_slug("---hello world---");
assert!(!slug.starts_with('-'));
assert!(!slug.ends_with('-'));
}
#[test]
fn slug_consecutive_spaces() {
assert_eq!(SkillCreator::generate_slug("hello world"), "hello-world");
}
#[test]
fn slug_empty_input() {
let slug = SkillCreator::generate_slug("");
assert!(slug.is_empty());
}
#[test]
fn slug_only_symbols() {
let slug = SkillCreator::generate_slug("!@#$%^&*()");
assert!(slug.is_empty());
}
#[test]
fn slug_unicode() {
let slug = SkillCreator::generate_slug("Deploy cafe app");
assert_eq!(slug, "deploy-cafe-app");
}
// ── Slug validation ──────────────────────────────────────────
#[test]
fn validate_slug_valid() {
assert!(SkillCreator::validate_slug("deploy-to-production"));
assert!(SkillCreator::validate_slug("a"));
assert!(SkillCreator::validate_slug("abc123"));
}
#[test]
fn validate_slug_invalid() {
assert!(!SkillCreator::validate_slug(""));
assert!(!SkillCreator::validate_slug("-starts-with-hyphen"));
assert!(!SkillCreator::validate_slug("ends-with-hyphen-"));
assert!(!SkillCreator::validate_slug("has spaces"));
assert!(!SkillCreator::validate_slug("has_underscores"));
assert!(!SkillCreator::validate_slug(&"a".repeat(65)));
}
// ── TOML generation ──────────────────────────────────────────
#[test]
fn toml_generation_valid_format() {
let calls = vec![
ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "cargo build"}),
},
ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "cargo test"}),
},
];
let toml_str = SkillCreator::generate_skill_toml(
"build-and-test",
"Build and test the project",
&calls,
);
// Should parse as valid TOML.
let parsed: toml::Value =
toml::from_str(&toml_str).expect("Generated TOML should be valid");
let skill = parsed.get("skill").expect("Should have [skill] section");
assert_eq!(
skill.get("name").and_then(toml::Value::as_str),
Some("build-and-test")
);
assert_eq!(
skill.get("author").and_then(toml::Value::as_str),
Some("zeroclaw-auto")
);
assert_eq!(
skill.get("version").and_then(toml::Value::as_str),
Some("0.1.0")
);
let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(
tools[0].get("command").and_then(toml::Value::as_str),
Some("cargo build")
);
}
#[test]
fn toml_generation_escapes_quotes() {
let calls = vec![ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "echo \"hello\""}),
}];
let toml_str =
SkillCreator::generate_skill_toml("echo-test", "Test \"quoted\" description", &calls);
let parsed: toml::Value =
toml::from_str(&toml_str).expect("TOML with quotes should be valid");
let desc = parsed
.get("skill")
.and_then(|s| s.get("description"))
.and_then(toml::Value::as_str)
.unwrap();
assert!(desc.contains("quoted"));
}
#[test]
fn toml_generation_no_command_arg() {
let calls = vec![ToolCallRecord {
name: "memory_store".into(),
args: serde_json::json!({"key": "foo", "value": "bar"}),
}];
let toml_str = SkillCreator::generate_skill_toml("memory-op", "Store to memory", &calls);
let parsed: toml::Value = toml::from_str(&toml_str).expect("TOML should be valid");
let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
// When no "command" arg exists, falls back to tool name.
assert_eq!(
tools[0].get("command").and_then(toml::Value::as_str),
Some("memory_store")
);
}
// ── TOML description extraction ──────────────────────────────
#[test]
fn extract_description_from_valid_toml() {
let content = r#"
[skill]
name = "test"
description = "Auto-generated: Build project"
version = "0.1.0"
"#;
assert_eq!(
extract_description_from_toml(content),
Some("Auto-generated: Build project".into())
);
}
#[test]
fn extract_description_from_invalid_toml() {
assert_eq!(extract_description_from_toml("not valid toml {{"), None);
}
// ── Deduplication ────────────────────────────────────────────
/// A mock embedding provider that returns deterministic embeddings.
///
/// The "new" description (first text embedded) always gets `[1, 0, 0]`.
/// The "existing" skill description (second text embedded) gets a vector
/// whose cosine similarity with `[1, 0, 0]` equals `self.similarity`.
struct MockEmbeddingProvider {
similarity: f32,
call_count: std::sync::atomic::AtomicUsize,
}
impl MockEmbeddingProvider {
fn new(similarity: f32) -> Self {
Self {
similarity,
call_count: std::sync::atomic::AtomicUsize::new(0),
}
}
}
#[async_trait]
impl EmbeddingProvider for MockEmbeddingProvider {
fn name(&self) -> &str {
"mock"
}
fn dimensions(&self) -> usize {
3
}
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
Ok(texts
.iter()
.map(|_| {
let call = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if call == 0 {
// First call: the "new" description.
vec![1.0, 0.0, 0.0]
} else {
// Subsequent calls: existing skill descriptions.
// Produce a vector with the configured cosine similarity to [1,0,0].
vec![
self.similarity,
(1.0 - self.similarity * self.similarity).sqrt(),
0.0,
]
}
})
.collect())
}
}
#[tokio::test]
async fn dedup_skips_similar_descriptions() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills").join("existing-skill");
tokio::fs::create_dir_all(&skills_dir).await.unwrap();
tokio::fs::write(
skills_dir.join("SKILL.toml"),
r#"
[skill]
name = "existing-skill"
description = "Auto-generated: Build the project"
version = "0.1.0"
author = "zeroclaw-auto"
tags = ["auto-generated"]
"#,
)
.await
.unwrap();
let config = SkillCreationConfig {
enabled: true,
max_skills: 500,
similarity_threshold: 0.85,
};
// High similarity provider -> should detect as duplicate.
let provider = MockEmbeddingProvider::new(0.95);
let creator = SkillCreator::new(dir.path().to_path_buf(), config.clone());
assert!(creator
.is_duplicate("Build the project", &provider)
.await
.unwrap());
// Low similarity provider -> not a duplicate.
let provider_low = MockEmbeddingProvider::new(0.3);
let creator2 = SkillCreator::new(dir.path().to_path_buf(), config);
assert!(!creator2
.is_duplicate("Completely different task", &provider_low)
.await
.unwrap());
}
// ── LRU eviction ─────────────────────────────────────────────
#[tokio::test]
async fn lru_eviction_removes_oldest() {
let dir = tempfile::tempdir().unwrap();
let config = SkillCreationConfig {
enabled: true,
max_skills: 2,
similarity_threshold: 0.85,
};
let skills_dir = dir.path().join("skills");
// Create two auto-generated skills with different timestamps.
for (i, name) in ["old-skill", "new-skill"].iter().enumerate() {
let skill_dir = skills_dir.join(name);
tokio::fs::create_dir_all(&skill_dir).await.unwrap();
tokio::fs::write(
skill_dir.join("SKILL.toml"),
format!(
r#"[skill]
name = "{name}"
description = "Auto-generated: Skill {i}"
version = "0.1.0"
author = "zeroclaw-auto"
tags = ["auto-generated"]
"#
),
)
.await
.unwrap();
// Small delay to ensure different timestamps.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
creator.enforce_lru_limit().await.unwrap();
// The oldest skill should have been removed.
assert!(!skills_dir.join("old-skill").exists());
assert!(skills_dir.join("new-skill").exists());
}
// ── End-to-end: create_from_execution ────────────────────────
#[tokio::test]
async fn create_from_execution_disabled() {
let dir = tempfile::tempdir().unwrap();
let config = SkillCreationConfig {
enabled: false,
..Default::default()
};
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
let calls = vec![
ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "ls"}),
},
ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "pwd"}),
},
];
let result = creator
.create_from_execution("List files", &calls, None)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn create_from_execution_insufficient_steps() {
let dir = tempfile::tempdir().unwrap();
let config = SkillCreationConfig {
enabled: true,
..Default::default()
};
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
let calls = vec![ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "ls"}),
}];
let result = creator
.create_from_execution("List files", &calls, None)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn create_from_execution_success() {
let dir = tempfile::tempdir().unwrap();
let config = SkillCreationConfig {
enabled: true,
max_skills: 500,
similarity_threshold: 0.85,
};
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
let calls = vec![
ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "cargo build"}),
},
ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "cargo test"}),
},
];
// Use noop embedding (no deduplication).
let noop = NoopEmbedding;
let result = creator
.create_from_execution("Build and test", &calls, Some(&noop))
.await
.unwrap();
assert_eq!(result, Some("build-and-test".into()));
// Verify the skill directory and TOML were created.
let skill_dir = dir.path().join("skills").join("build-and-test");
assert!(skill_dir.exists());
let toml_content = tokio::fs::read_to_string(skill_dir.join("SKILL.toml"))
.await
.unwrap();
assert!(toml_content.contains("build-and-test"));
assert!(toml_content.contains("zeroclaw-auto"));
}
#[tokio::test]
async fn create_from_execution_with_dedup() {
let dir = tempfile::tempdir().unwrap();
let config = SkillCreationConfig {
enabled: true,
max_skills: 500,
similarity_threshold: 0.85,
};
// First, create an existing skill.
let skills_dir = dir.path().join("skills").join("existing");
tokio::fs::create_dir_all(&skills_dir).await.unwrap();
tokio::fs::write(
skills_dir.join("SKILL.toml"),
r#"[skill]
name = "existing"
description = "Auto-generated: Build and test"
version = "0.1.0"
author = "zeroclaw-auto"
tags = ["auto-generated"]
"#,
)
.await
.unwrap();
// High similarity provider -> should skip.
let provider = MockEmbeddingProvider::new(0.95);
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
let calls = vec![
ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "cargo build"}),
},
ToolCallRecord {
name: "shell".into(),
args: serde_json::json!({"command": "cargo test"}),
},
];
let result = creator
.create_from_execution("Build and test", &calls, Some(&provider))
.await
.unwrap();
assert!(result.is_none());
}
// ── Tool call extraction from history ────────────────────────
#[test]
fn extract_from_empty_history() {
let history = vec![];
let records = extract_tool_calls_from_history(&history);
assert!(records.is_empty());
}
#[test]
fn extract_from_user_messages_only() {
use crate::providers::ChatMessage;
let history = vec![ChatMessage::user("hello"), ChatMessage::user("world")];
let records = extract_tool_calls_from_history(&history);
assert!(records.is_empty());
}
// ── Fuzz-like tests for slug ─────────────────────────────────
#[test]
fn slug_fuzz_various_inputs() {
let inputs = [
"",
" ",
"---",
"a",
"hello world!",
"UPPER CASE",
"with-hyphens-already",
"with__underscores",
"123 numbers 456",
"emoji: cafe",
&"x".repeat(200),
"a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p-q-r-s-t-u-v-w-x-y-z-0-1-2-3-4-5",
];
for input in &inputs {
let slug = SkillCreator::generate_slug(input);
// Slug should always pass validation (or be empty for degenerate input).
if !slug.is_empty() {
assert!(
SkillCreator::validate_slug(&slug),
"Generated slug '{slug}' from '{input}' failed validation"
);
}
}
}
// ── Fuzz-like tests for TOML generation ──────────────────────
#[test]
fn toml_fuzz_various_inputs() {
let descriptions = [
"simple task",
"task with \"quotes\" and \\ backslashes",
"task with\nnewlines\r\nand tabs\there",
"",
&"long ".repeat(100),
];
let args_variants = [
serde_json::json!({}),
serde_json::json!({"command": "echo hello"}),
serde_json::json!({"command": "echo \"hello world\"", "extra": 42}),
];
for desc in &descriptions {
for args in &args_variants {
let calls = vec![
ToolCallRecord {
name: "tool1".into(),
args: args.clone(),
},
ToolCallRecord {
name: "tool2".into(),
args: args.clone(),
},
];
let toml_str = SkillCreator::generate_skill_toml("test-slug", desc, &calls);
// Must always produce valid TOML.
let _parsed: toml::Value = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("Invalid TOML for desc '{desc}': {e}\n{toml_str}"));
}
}
}
}
+2
View File
@@ -7,6 +7,8 @@ use std::process::Command;
use std::time::{Duration, SystemTime};
mod audit;
#[cfg(feature = "skill-creation")]
pub mod creator;
const OPEN_SKILLS_REPO_URL: &str = "https://github.com/besoeasy/open-skills";
const OPEN_SKILLS_SYNC_MARKER: &str = ".zeroclaw-open-skills-sync";
+47 -1
View File
@@ -130,6 +130,11 @@ impl Tool for CronAddTool {
"type": "string",
"description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
},
"allowed_tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional allowlist of tool names for agent jobs. When omitted, all tools remain available."
},
"delivery": {
"type": "object",
"description": "Optional delivery config to send job output to a channel after each run. When provided, all three of mode, channel, and to are expected.",
@@ -288,6 +293,19 @@ impl Tool for CronAddTool {
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let allowed_tools = match args.get("allowed_tools") {
Some(v) => match serde_json::from_value::<Vec<String>>(v.clone()) {
Ok(v) => Some(v),
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Invalid allowed_tools: {e}")),
});
}
},
None => None,
};
let delivery = match args.get("delivery") {
Some(v) => match serde_json::from_value::<DeliveryConfig>(v.clone()) {
@@ -316,6 +334,7 @@ impl Tool for CronAddTool {
model,
delivery,
delete_after_run,
allowed_tools,
)
}
};
@@ -329,7 +348,8 @@ impl Tool for CronAddTool {
"job_type": job.job_type,
"schedule": job.schedule,
"next_run": job.next_run,
"enabled": job.enabled
"enabled": job.enabled,
"allowed_tools": job.allowed_tools
}))?,
error: None,
}),
@@ -612,6 +632,32 @@ mod tests {
.contains("Missing 'prompt'"));
}
#[tokio::test]
async fn agent_job_persists_allowed_tools() {
let tmp = TempDir::new().unwrap();
let cfg = test_config(&tmp).await;
let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
let result = tool
.execute(json!({
"schedule": { "kind": "cron", "expr": "*/5 * * * *" },
"job_type": "agent",
"prompt": "check status",
"allowed_tools": ["file_read", "web_search"]
}))
.await
.unwrap();
assert!(result.success, "{:?}", result.error);
let jobs = cron::list_jobs(&cfg).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(
jobs[0].allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
#[tokio::test]
async fn delivery_schema_includes_matrix_channel() {
let tmp = TempDir::new().unwrap();
+42
View File
@@ -89,6 +89,11 @@ impl Tool for CronUpdateTool {
"type": "string",
"description": "Model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
},
"allowed_tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional replacement allowlist of tool names for agent jobs"
},
"session_target": {
"type": "string",
"enum": ["isolated", "main"],
@@ -403,6 +408,7 @@ mod tests {
"command",
"prompt",
"model",
"allowed_tools",
"session_target",
"delete_after_run",
"schedule",
@@ -501,4 +507,40 @@ mod tests {
.contains("Rate limit exceeded"));
assert!(cron::get_job(&cfg, &job.id).unwrap().enabled);
}
#[tokio::test]
async fn updates_agent_allowed_tools() {
let tmp = TempDir::new().unwrap();
let cfg = test_config(&tmp).await;
let job = cron::add_agent_job(
&cfg,
None,
crate::cron::Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"check status",
crate::cron::SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg));
let result = tool
.execute(json!({
"job_id": job.id,
"patch": { "allowed_tools": ["file_read", "web_search"] }
}))
.await
.unwrap();
assert!(result.success, "{:?}", result.error);
assert_eq!(
cron::get_job(&cfg, &job.id).unwrap().allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
}
+243 -4
View File
@@ -296,8 +296,9 @@ impl Tool for DelegateTool {
}
// Wrap the provider call in a timeout to prevent indefinite blocking
let timeout_secs = agent_config.timeout_secs.unwrap_or(DELEGATE_TIMEOUT_SECS);
let result = tokio::time::timeout(
Duration::from_secs(DELEGATE_TIMEOUT_SECS),
Duration::from_secs(timeout_secs),
provider.chat_with_system(
agent_config.system_prompt.as_deref(),
&full_prompt,
@@ -314,7 +315,7 @@ impl Tool for DelegateTool {
success: false,
output: String::new(),
error: Some(format!(
"Agent '{agent_name}' timed out after {DELEGATE_TIMEOUT_SECS}s"
"Agent '{agent_name}' timed out after {timeout_secs}s"
)),
});
}
@@ -401,8 +402,11 @@ impl DelegateTool {
let noop_observer = NoopObserver;
let agentic_timeout_secs = agent_config
.agentic_timeout_secs
.unwrap_or(DELEGATE_AGENTIC_TIMEOUT_SECS);
let result = tokio::time::timeout(
Duration::from_secs(DELEGATE_AGENTIC_TIMEOUT_SECS),
Duration::from_secs(agentic_timeout_secs),
run_tool_call_loop(
provider,
&mut history,
@@ -414,6 +418,7 @@ impl DelegateTool {
true,
None,
"delegate",
None,
&self.multimodal_config,
agent_config.max_iterations,
None,
@@ -454,7 +459,7 @@ impl DelegateTool {
success: false,
output: String::new(),
error: Some(format!(
"Agent '{agent_name}' timed out after {DELEGATE_AGENTIC_TIMEOUT_SECS}s"
"Agent '{agent_name}' timed out after {agentic_timeout_secs}s"
)),
}),
}
@@ -531,6 +536,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
agents.insert(
@@ -545,6 +552,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
agents
@@ -698,6 +707,8 @@ mod tests {
agentic: true,
allowed_tools,
max_iterations,
timeout_secs: None,
agentic_timeout_secs: None,
}
}
@@ -806,6 +817,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
let tool = DelegateTool::new(agents, None, test_security());
@@ -912,6 +925,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
let tool = DelegateTool::new(agents, None, test_security());
@@ -947,6 +962,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
let tool = DelegateTool::new(agents, None, test_security());
@@ -1221,4 +1238,226 @@ mod tests {
handle.write().push(Arc::new(FakeMcpTool));
assert_eq!(handle.read().len(), 2);
}
// ── Configurable timeout tests ──────────────────────────────────
#[test]
fn default_timeout_values_used_when_config_unset() {
let config = DelegateAgentConfig {
provider: "ollama".to_string(),
model: "llama3".to_string(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
};
assert_eq!(config.timeout_secs.unwrap_or(DELEGATE_TIMEOUT_SECS), 120);
assert_eq!(
config
.agentic_timeout_secs
.unwrap_or(DELEGATE_AGENTIC_TIMEOUT_SECS),
300
);
}
#[test]
fn custom_timeout_values_are_respected() {
let config = DelegateAgentConfig {
provider: "ollama".to_string(),
model: "llama3".to_string(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: Some(60),
agentic_timeout_secs: Some(600),
};
assert_eq!(config.timeout_secs.unwrap_or(DELEGATE_TIMEOUT_SECS), 60);
assert_eq!(
config
.agentic_timeout_secs
.unwrap_or(DELEGATE_AGENTIC_TIMEOUT_SECS),
600
);
}
#[test]
fn timeout_deserialization_defaults_to_none() {
let toml_str = r#"
provider = "ollama"
model = "llama3"
"#;
let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
assert!(config.timeout_secs.is_none());
assert!(config.agentic_timeout_secs.is_none());
}
#[test]
fn timeout_deserialization_with_custom_values() {
let toml_str = r#"
provider = "ollama"
model = "llama3"
timeout_secs = 45
agentic_timeout_secs = 900
"#;
let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.timeout_secs, Some(45));
assert_eq!(config.agentic_timeout_secs, Some(900));
}
#[test]
fn config_validation_rejects_zero_timeout() {
let mut config = crate::config::Config::default();
config.agents.insert(
"bad".into(),
DelegateAgentConfig {
provider: "ollama".into(),
model: "llama3".into(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: Some(0),
agentic_timeout_secs: None,
},
);
let err = config.validate().unwrap_err();
assert!(
format!("{err}").contains("timeout_secs must be greater than 0"),
"unexpected error: {err}"
);
}
#[test]
fn config_validation_rejects_zero_agentic_timeout() {
let mut config = crate::config::Config::default();
config.agents.insert(
"bad".into(),
DelegateAgentConfig {
provider: "ollama".into(),
model: "llama3".into(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: Some(0),
},
);
let err = config.validate().unwrap_err();
assert!(
format!("{err}").contains("agentic_timeout_secs must be greater than 0"),
"unexpected error: {err}"
);
}
#[test]
fn config_validation_rejects_excessive_timeout() {
let mut config = crate::config::Config::default();
config.agents.insert(
"bad".into(),
DelegateAgentConfig {
provider: "ollama".into(),
model: "llama3".into(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: Some(7200),
agentic_timeout_secs: None,
},
);
let err = config.validate().unwrap_err();
assert!(
format!("{err}").contains("exceeds max 3600"),
"unexpected error: {err}"
);
}
#[test]
fn config_validation_rejects_excessive_agentic_timeout() {
let mut config = crate::config::Config::default();
config.agents.insert(
"bad".into(),
DelegateAgentConfig {
provider: "ollama".into(),
model: "llama3".into(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: Some(5000),
},
);
let err = config.validate().unwrap_err();
assert!(
format!("{err}").contains("exceeds max 3600"),
"unexpected error: {err}"
);
}
#[test]
fn config_validation_accepts_max_boundary_timeout() {
let mut config = crate::config::Config::default();
config.agents.insert(
"ok".into(),
DelegateAgentConfig {
provider: "ollama".into(),
model: "llama3".into(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: Some(3600),
agentic_timeout_secs: Some(3600),
},
);
assert!(config.validate().is_ok());
}
#[test]
fn config_validation_accepts_none_timeouts() {
let mut config = crate::config::Config::default();
config.agents.insert(
"ok".into(),
DelegateAgentConfig {
provider: "ollama".into(),
model: "llama3".into(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
assert!(config.validate().is_ok());
}
}
+41 -1
View File
@@ -103,7 +103,7 @@ impl Tool for FileEditTool {
});
}
let full_path = self.security.workspace_dir.join(path);
let full_path = self.security.resolve_tool_path(path);
// ── 5. Canonicalize parent ─────────────────────────────────
let Some(parent) = full_path.parent() else {
@@ -666,6 +666,46 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_absolute_path_in_workspace() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_abs_path");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
// Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
let dir = tokio::fs::canonicalize(&dir).await.unwrap();
tokio::fs::write(dir.join("target.txt"), "old content")
.await
.unwrap();
let tool = FileEditTool::new(test_security(dir.clone()));
// Pass an absolute path that is within the workspace
let abs_path = dir.join("target.txt");
let result = tool
.execute(json!({
"path": abs_path.to_string_lossy().to_string(),
"old_string": "old content",
"new_string": "new content"
}))
.await
.unwrap();
assert!(
result.success,
"editing via absolute workspace path should succeed, error: {:?}",
result.error
);
let content = tokio::fs::read_to_string(dir.join("target.txt"))
.await
.unwrap();
assert_eq!(content, "new content");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_blocks_null_byte_in_path() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_null_byte");
+35 -1
View File
@@ -78,7 +78,7 @@ impl Tool for FileWriteTool {
});
}
let full_path = self.security.workspace_dir.join(path);
let full_path = self.security.resolve_tool_path(path);
let Some(parent) = full_path.parent() else {
return Ok(ToolResult {
@@ -450,6 +450,40 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn file_write_absolute_path_in_workspace() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_abs_path");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
// Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
let dir = tokio::fs::canonicalize(&dir).await.unwrap();
let tool = FileWriteTool::new(test_security(dir.clone()));
// Pass an absolute path that is within the workspace
let abs_path = dir.join("abs_test.txt");
let result = tool
.execute(
json!({"path": abs_path.to_string_lossy().to_string(), "content": "absolute!"}),
)
.await
.unwrap();
assert!(
result.success,
"writing via absolute workspace path should succeed, error: {:?}",
result.error
);
let content = tokio::fs::read_to_string(dir.join("abs_test.txt"))
.await
.unwrap();
assert_eq!(content, "absolute!");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_write_blocks_null_byte_in_path() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_null");
+9 -2
View File
@@ -146,7 +146,7 @@ pub use workspace_tool::WorkspaceTool;
use crate::config::{Config, DelegateAgentConfig};
use crate::memory::Memory;
use crate::runtime::{NativeRuntime, RuntimeAdapter};
use crate::security::SecurityPolicy;
use crate::security::{create_sandbox, SecurityPolicy};
use async_trait::async_trait;
use parking_lot::RwLock;
use std::collections::HashMap;
@@ -283,8 +283,13 @@ pub fn all_tools_with_runtime(
root_config: &crate::config::Config,
) -> (Vec<Box<dyn Tool>>, Option<DelegateParentToolsHandle>) {
let has_shell_access = runtime.has_shell_access();
let sandbox = create_sandbox(&root_config.security);
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
Arc::new(ShellTool::new(security.clone(), runtime)),
Arc::new(ShellTool::new_with_sandbox(
security.clone(),
runtime,
sandbox,
)),
Arc::new(FileReadTool::new(security.clone())),
Arc::new(FileWriteTool::new(security.clone())),
Arc::new(FileEditTool::new(security.clone())),
@@ -917,6 +922,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
+2
View File
@@ -705,6 +705,8 @@ impl ModelRoutingConfigTool {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: DEFAULT_AGENT_MAX_ITERATIONS,
timeout_secs: None,
agentic_timeout_secs: None,
});
next_agent.provider = provider;
+1 -1
View File
@@ -100,7 +100,7 @@ impl Tool for PdfReadTool {
});
}
let full_path = self.security.workspace_dir.join(path);
let full_path = self.security.resolve_tool_path(path);
let resolved_path = match tokio::fs::canonicalize(&full_path).await {
Ok(p) => p,
+82 -1
View File
@@ -1,5 +1,6 @@
use super::traits::{Tool, ToolResult};
use crate::runtime::RuntimeAdapter;
use crate::security::traits::Sandbox;
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
@@ -44,11 +45,28 @@ const SAFE_ENV_VARS: &[&str] = &[
pub struct ShellTool {
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
sandbox: Arc<dyn Sandbox>,
}
impl ShellTool {
pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
Self { security, runtime }
Self {
security,
runtime,
sandbox: Arc::new(crate::security::NoopSandbox),
}
}
pub fn new_with_sandbox(
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
sandbox: Arc<dyn Sandbox>,
) -> Self {
Self {
security,
runtime,
sandbox,
}
}
}
@@ -169,6 +187,14 @@ impl Tool for ShellTool {
});
}
};
// Apply sandbox wrapping before execution.
// The Sandbox trait operates on std::process::Command, so use as_std_mut()
// to get a mutable reference to the underlying command.
self.sandbox
.wrap_command(cmd.as_std_mut())
.map_err(|e| anyhow::anyhow!("Sandbox error: {}", e))?;
cmd.env_clear();
for var in collect_allowed_shell_env_vars(&self.security) {
@@ -690,4 +716,59 @@ mod tests {
|| r2.error.as_deref().unwrap_or("").contains("budget")
);
}
// ── Sandbox integration tests ────────────────────────
#[test]
fn shell_tool_can_be_constructed_with_sandbox() {
use crate::security::NoopSandbox;
let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
let tool = ShellTool::new_with_sandbox(
test_security(AutonomyLevel::Supervised),
test_runtime(),
sandbox,
);
assert_eq!(tool.name(), "shell");
}
#[test]
fn noop_sandbox_does_not_modify_command() {
use crate::security::NoopSandbox;
let sandbox = NoopSandbox;
let mut cmd = std::process::Command::new("echo");
cmd.arg("hello");
let program_before = cmd.get_program().to_os_string();
let args_before: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
sandbox
.wrap_command(&mut cmd)
.expect("wrap_command should succeed");
assert_eq!(cmd.get_program(), program_before);
assert_eq!(
cmd.get_args().map(|a| a.to_os_string()).collect::<Vec<_>>(),
args_before
);
}
#[tokio::test]
async fn shell_executes_with_sandbox() {
use crate::security::NoopSandbox;
let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
let tool = ShellTool::new_with_sandbox(
test_security(AutonomyLevel::Supervised),
test_runtime(),
sandbox,
);
let result = tool
.execute(json!({"command": "echo sandbox_test"}))
.await
.expect("command with sandbox should succeed");
assert!(result.success);
assert!(result.output.contains("sandbox_test"));
}
}
+4
View File
@@ -566,6 +566,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
agents.insert(
@@ -580,6 +582,8 @@ mod tests {
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
agents
+59
View File
@@ -0,0 +1,59 @@
# English tool descriptions (default locale)
#
# Each key under [tools] matches the tool's name() return value.
# Values are the human-readable descriptions shown in system prompts.
[tools]
backup = "Create, list, verify, and restore workspace backups"
browser = "Web/browser automation with pluggable backends (agent-browser, rust-native, computer_use). Supports DOM actions plus optional OS-level actions (mouse_move, mouse_click, mouse_drag, key_type, key_press, screen_capture) through a computer-use sidecar. Use 'snapshot' to map interactive elements to refs (@e1, @e2). Enforces browser.allowed_domains for open actions."
browser_delegate = "Delegate browser-based tasks to a browser-capable CLI for interacting with web applications like Teams, Outlook, Jira, Confluence"
browser_open = "Open an approved HTTPS URL in the system browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping."
cloud_ops = "Cloud transformation advisory tool. Analyzes IaC plans, assesses migration paths, reviews costs, and checks architecture against Well-Architected Framework pillars. Read-only: does not create or modify cloud resources."
cloud_patterns = "Cloud pattern library. Given a workload description, suggests applicable cloud-native architectural patterns (containerization, serverless, database modernization, etc.)."
composio = "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to see available actions (includes parameter names). action='execute' with action_name/tool_slug and params to run an action. If you are unsure of the exact params, pass 'text' instead with a natural-language description of what you want (Composio will resolve the correct parameters via NLP). action='list_accounts' or action='connected_accounts' to list OAuth-connected accounts. action='connect' with app/auth_config_id to get OAuth URL. connected_account_id is auto-resolved when omitted."
content_search = "Search file contents by regex pattern within the workspace. Supports ripgrep (rg) with grep fallback. Output modes: 'content' (matching lines with context), 'files_with_matches' (file paths only), 'count' (match counts per file). Example: pattern='fn main', include='*.rs', output_mode='content'."
cron_add = """Create a scheduled cron job (shell or agent) with cron/at/every schedules. Use job_type='agent' with a prompt to run the AI agent on schedule. To deliver output to a channel (Discord, Telegram, Slack, Mattermost, Matrix), set delivery={"mode":"announce","channel":"discord","to":"<channel_id_or_chat_id>"}. This is the preferred tool for sending scheduled/delayed messages to users via channels."""
cron_list = "List all scheduled cron jobs"
cron_remove = "Remove a cron job by id"
cron_run = "Force-run a cron job immediately and record run history"
cron_runs = "List recent run history for a cron job"
cron_update = "Patch an existing cron job (schedule, command, prompt, enabled, delivery, model, etc.)"
data_management = "Workspace data retention, purge, and storage statistics"
delegate = "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single prompt by default; with agentic=true it can iterate with a filtered tool-call loop."
file_edit = "Edit a file by replacing an exact string match with new content"
file_read = "Read file contents with line numbers. Supports partial reading via offset and limit. Extracts text from PDF; other binary files are read with lossy UTF-8 conversion."
file_write = "Write contents to a file in the workspace"
git_operations = "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls."
glob_search = "Search for files matching a glob pattern within the workspace. Returns a sorted list of matching file paths relative to the workspace root. Examples: '**/*.rs' (all Rust files), 'src/**/mod.rs' (all mod.rs in src)."
google_workspace = "Interact with Google Workspace services (Drive, Gmail, Calendar, Sheets, Docs, etc.) via the gws CLI. Requires gws to be installed and authenticated."
hardware_board_info = "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', 'what hardware', or 'memory map'."
hardware_memory_map = "Return the memory map (flash and RAM address ranges) for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', 'address space', or 'readable addresses'. Returns flash/RAM ranges from datasheets."
hardware_memory_read = "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory at address', 'dump memory', 'lower memory 0-126', or 'give address and value'. Returns hex dump. Requires Nucleo connected via USB and probe feature. Params: address (hex, e.g. 0x20000000 for RAM start), length (bytes, default 128)."
http_request = "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits."
image_info = "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data."
knowledge = "Manage a knowledge graph of architecture decisions, solution patterns, lessons learned, and experts. Actions: capture, search, relate, suggest, expert_find, lessons_extract, graph_stats."
linkedin = "Manage LinkedIn: create posts, list your posts, comment, react, delete posts, view engagement, get profile info, and read the configured content strategy. Requires LINKEDIN_* credentials in .env file."
memory_forget = "Remove a memory by key. Use to delete outdated facts or sensitive data. Returns whether the memory was found and removed."
memory_recall = "Search long-term memory for relevant facts, preferences, or context. Returns scored results ranked by relevance."
memory_store = "Store a fact, preference, or note in long-term memory. Use category 'core' for permanent facts, 'daily' for session notes, 'conversation' for chat context, or a custom category name."
microsoft365 = "Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, OneDrive files, and SharePoint search via Microsoft Graph API"
model_routing_config = "Manage default model settings, scenario-based provider/model routes, classification rules, and delegate sub-agent profiles"
notion = "Interact with Notion: query databases, read/create/update pages, and search the workspace."
pdf_read = "Extract plain text from a PDF file in the workspace. Returns all readable text. Image-only or encrypted PDFs return an empty result. Requires the 'rag-pdf' build feature."
project_intel = "Project delivery intelligence: generate status reports, detect risks, draft client updates, summarize sprints, and estimate effort. Read-only analysis tool."
proxy_config = "Manage ZeroClaw proxy settings (scope: environment | zeroclaw | services), including runtime and process env application"
pushover = "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file."
schedule = """Manage scheduled shell-only tasks. Actions: create/add/once/list/get/cancel/remove/pause/resume. WARNING: This tool creates shell jobs whose output is only logged, NOT delivered to any channel. To send a scheduled message to Discord/Telegram/Slack/Matrix, use the cron_add tool with job_type='agent' and a delivery config like {"mode":"announce","channel":"discord","to":"<channel_id>"}."""
screenshot = "Capture a screenshot of the current screen. Returns the file path and base64-encoded PNG data."
security_ops = "Security operations tool for managed cybersecurity services. Actions: triage_alert (classify/prioritize alerts), run_playbook (execute incident response steps), parse_vulnerability (parse scan results), generate_report (create security posture reports), list_playbooks (list available playbooks), alert_stats (summarize alert metrics)."
shell = "Execute a shell command in the workspace directory"
sop_advance = "Report the result of the current SOP step and advance to the next step. Provide the run_id, whether the step succeeded or failed, and a brief output summary."
sop_approve = "Approve a pending SOP step that is waiting for operator approval. Returns the step instruction to execute. Use sop_status to see which runs are waiting."
sop_execute = "Manually trigger a Standard Operating Procedure (SOP) by name. Returns the run ID and first step instruction. Use sop_list to see available SOPs."
sop_list = "List all loaded Standard Operating Procedures (SOPs) with their triggers, priority, step count, and active run count. Optionally filter by name or priority."
sop_status = "Query SOP execution status. Provide run_id for a specific run, or sop_name to list runs for that SOP. With no arguments, shows all active runs."
swarm = "Orchestrate a swarm of agents to collaboratively handle a task. Supports sequential (pipeline), parallel (fan-out/fan-in), and router (LLM-selected) strategies."
tool_search = """Fetch full schema definitions for deferred MCP tools so they can be called. Use "select:name1,name2" for exact match or keywords to search."""
web_fetch = "Fetch a web page and return its content as clean plain text. HTML pages are automatically converted to readable text. JSON and plain text responses are returned as-is. Only GET requests; follows redirects. Security: allowlist-only domains, no local/private hosts."
web_search_tool = "Search the web for information. Returns relevant search results with titles, URLs, and descriptions. Use this to find current information, news, or research topics."
workspace = "Manage multi-client workspaces. Subcommands: list, switch, create, info, export. Each workspace provides isolated memory, audit, secrets, and tool restrictions."
+60
View File
@@ -0,0 +1,60 @@
# 中文工具描述 (简体中文)
#
# [tools] 下的每个键对应工具的 name() 返回值。
# 值是显示在系统提示中的人类可读描述。
# 缺少的键将回退到英文 (en.toml) 描述。
[tools]
backup = "创建、列出、验证和恢复工作区备份"
browser = "基于可插拔后端(agent-browser、rust-native、computer_use)的网页/浏览器自动化。支持 DOM 操作以及通过 computer-use 辅助工具进行的可选系统级操作(mouse_move、mouse_click、mouse_drag、key_type、key_press、screen_capture)。使用 'snapshot' 将交互元素映射到引用(@e1、@e2)。对 open 操作强制执行 browser.allowed_domains。"
browser_delegate = "将基于浏览器的任务委派给具有浏览器功能的 CLI,用于与 Teams、Outlook、Jira、Confluence 等 Web 应用交互"
browser_open = "在系统浏览器中打开经批准的 HTTPS URL。安全约束:仅允许列表域名,禁止本地/私有主机,禁止抓取。"
cloud_ops = "云转型咨询工具。分析 IaC 计划、评估迁移路径、审查成本,并根据良好架构框架支柱检查架构。只读:不创建或修改云资源。"
cloud_patterns = "云模式库。根据工作负载描述,建议适用的云原生架构模式(容器化、无服务器、数据库现代化等)。"
composio = "通过 Composio 在 1000 多个应用上执行操作(Gmail、Notion、GitHub、Slack 等)。使用 action='list' 查看可用操作(包含参数名称)。使用 action='execute' 配合 action_name/tool_slug 和 params 运行操作。如果不确定具体参数,可传入 'text' 并用自然语言描述需求(Composio 将通过 NLP 解析正确参数)。使用 action='list_accounts' 或 action='connected_accounts' 列出 OAuth 已连接账户。使用 action='connect' 配合 app/auth_config_id 获取 OAuth URL。省略时自动解析 connected_account_id。"
content_search = "在工作区内按正则表达式搜索文件内容。支持 ripgrep (rg),可回退到 grep。输出模式:'content'(带上下文的匹配行)、'files_with_matches'(仅文件路径)、'count'(每个文件的匹配计数)。"
cron_add = "创建带有 cron/at/every 计划的定时任务(shell 或 agent)。使用 job_type='agent' 配合 prompt 按计划运行 AI 代理。要将输出发送到频道(Discord、Telegram、Slack、Mattermost、Matrix),请设置 delivery 配置。这是通过频道向用户发送定时/延迟消息的首选工具。"
cron_list = "列出所有已计划的 cron 任务"
cron_remove = "按 ID 删除 cron 任务"
cron_run = "立即强制运行 cron 任务并记录运行历史"
cron_runs = "列出 cron 任务的最近运行历史"
cron_update = "修改现有 cron 任务(计划、命令、提示、启用状态、投递配置、模型等)"
data_management = "工作区数据保留、清理和存储统计"
delegate = "将子任务委派给专用代理。适用场景:任务受益于不同模型(如快速摘要、深度推理、代码生成)。子代理默认运行单个提示;设置 agentic=true 后可通过过滤的工具调用循环进行迭代。"
file_edit = "通过替换精确匹配的字符串来编辑文件"
file_read = "读取带行号的文件内容。支持通过 offset 和 limit 进行部分读取。可从 PDF 提取文本;其他二进制文件使用有损 UTF-8 转换读取。"
file_write = "将内容写入工作区中的文件"
git_operations = "执行结构化的 Git 操作(status、diff、log、branch、commit、add、checkout、stash)。提供解析后的 JSON 输出,并与安全策略集成以实现自主控制。"
glob_search = "在工作区内搜索匹配 glob 模式的文件。返回相对于工作区根目录的排序文件路径列表。示例:'**/*.rs'(所有 Rust 文件)、'src/**/mod.rs'src 中所有 mod.rs)。"
google_workspace = "与 Google Workspace 服务(Drive、Gmail、Calendar、Sheets、Docs 等)交互。通过 gws CLI 操作,需要 gws 已安装并认证。"
hardware_board_info = "返回已连接硬件的完整板卡信息(芯片、架构、内存映射)。适用场景:用户询问板卡信息、连接的硬件、芯片信息等。"
hardware_memory_map = "返回已连接硬件的内存映射(Flash 和 RAM 地址范围)。适用场景:用户询问内存地址、地址空间或可读地址。返回数据手册中的 Flash/RAM 范围。"
hardware_memory_read = "通过 USB 从 Nucleo 读取实际内存/寄存器值。适用场景:用户要求读取寄存器值、读取内存地址、转储内存等。返回十六进制转储。需要 Nucleo 通过 USB 连接并启用 probe 功能。"
http_request = "向外部 API 发送 HTTP 请求。支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 方法。安全约束:仅允许列表域名,禁止本地/私有主机,可配置超时和响应大小限制。"
image_info = "读取图片文件元数据(格式、尺寸、大小),可选返回 base64 编码数据。"
knowledge = "管理架构决策、解决方案模式、经验教训和专家的知识图谱。操作:capture、search、relate、suggest、expert_find、lessons_extract、graph_stats。"
linkedin = "管理 LinkedIn:创建帖子、列出帖子、评论、点赞、删除帖子、查看互动数据、获取个人资料信息,以及阅读配置的内容策略。需要在 .env 文件中配置 LINKEDIN_* 凭据。"
memory_forget = "按键删除记忆。用于删除过时事实或敏感数据。返回记忆是否被找到并删除。"
memory_recall = "在长期记忆中搜索相关事实、偏好或上下文。返回按相关性排名的评分结果。"
memory_store = "在长期记忆中存储事实、偏好或笔记。使用类别 'core' 存储永久事实,'daily' 存储会话笔记,'conversation' 存储聊天上下文,或使用自定义类别名称。"
microsoft365 = "Microsoft 365 集成:通过 Microsoft Graph API 管理 Outlook 邮件、Teams 消息、日历事件、OneDrive 文件和 SharePoint 搜索"
model_routing_config = "管理默认模型设置、基于场景的提供商/模型路由、分类规则和委派子代理配置"
notion = "与 Notion 交互:查询数据库、读取/创建/更新页面、搜索工作区。"
pdf_read = "从工作区中的 PDF 文件提取纯文本。返回所有可读文本。仅图片或加密的 PDF 返回空结果。需要 'rag-pdf' 构建功能。"
project_intel = "项目交付智能:生成状态报告、检测风险、起草客户更新、总结冲刺、估算工作量。只读分析工具。"
proxy_config = "管理 ZeroClaw 代理设置(范围:environment | zeroclaw | services),包括运行时和进程环境应用"
pushover = "向设备发送 Pushover 通知。需要在 .env 文件中配置 PUSHOVER_TOKEN 和 PUSHOVER_USER_KEY。"
schedule = "管理仅限 shell 的定时任务。操作:create/add/once/list/get/cancel/remove/pause/resume。警告:此工具创建的 shell 任务输出仅记录日志,不会发送到任何频道。要向 Discord/Telegram/Slack/Matrix 发送定时消息,请使用 cron_add 工具。"
screenshot = "捕获当前屏幕截图。返回文件路径和 base64 编码的 PNG 数据。"
security_ops = "托管网络安全服务的安全运营工具。操作:triage_alert(分类/优先级排序警报)、run_playbook(执行事件响应步骤)、parse_vulnerability(解析扫描结果)、generate_report(创建安全态势报告)、list_playbooks(列出可用剧本)、alert_stats(汇总警报指标)。"
shell = "在工作区目录中执行 shell 命令"
sop_advance = "报告当前 SOP 步骤的结果并前进到下一步。提供 run_id、步骤是否成功或失败,以及简短的输出摘要。"
sop_approve = "批准等待操作员批准的待处理 SOP 步骤。返回要执行的步骤指令。使用 sop_status 查看哪些运行正在等待。"
sop_execute = "按名称手动触发标准操作程序 (SOP)。返回运行 ID 和第一步指令。使用 sop_list 查看可用 SOP。"
sop_list = "列出所有已加载的标准操作程序 (SOP),包括触发器、优先级、步骤数和活跃运行数。可按名称或优先级筛选。"
sop_status = "查询 SOP 执行状态。提供 run_id 查看特定运行,或提供 sop_name 列出该 SOP 的所有运行。无参数时显示所有活跃运行。"
swarm = "编排代理群以协作处理任务。支持顺序(管道)、并行(扇出/扇入)和路由器(LLM 选择)策略。"
tool_search = "获取延迟 MCP 工具的完整 schema 定义以便调用。使用 \"select:name1,name2\" 精确匹配或关键词搜索。"
web_fetch = "获取网页并以纯文本形式返回内容。HTML 页面自动转换为可读文本。JSON 和纯文本响应按原样返回。仅 GET 请求;跟随重定向。安全:仅允许列表域名,禁止本地/私有主机。"
web_search_tool = "搜索网络获取信息。返回包含标题、URL 和描述的相关搜索结果。用于查找当前信息、新闻或研究主题。"
workspace = "管理多客户端工作区。子命令:list、switch、create、info、export。每个工作区提供隔离的记忆、审计、密钥和工具限制。"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+19
View File
@@ -193,6 +193,25 @@ export function getCronRuns(
).then((data) => unwrapField(data, 'runs'));
}
export interface CronSettings {
enabled: boolean;
catch_up_on_startup: boolean;
max_run_history: number;
}
export function getCronSettings(): Promise<CronSettings> {
return apiFetch<CronSettings>('/api/cron/settings');
}
export function patchCronSettings(
patch: Partial<CronSettings>,
): Promise<CronSettings> {
return apiFetch<CronSettings & { status: string }>('/api/cron/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
+62 -1
View File
@@ -12,7 +12,15 @@ import {
RefreshCw,
} from 'lucide-react';
import type { CronJob, CronRun } from '@/types/api';
import { getCronJobs, addCronJob, deleteCronJob, getCronRuns } from '@/lib/api';
import {
getCronJobs,
addCronJob,
deleteCronJob,
getCronRuns,
getCronSettings,
patchCronSettings,
} from '@/lib/api';
import type { CronSettings } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatDate(iso: string | null): string {
@@ -143,6 +151,8 @@ export default function Cron() {
const [showForm, setShowForm] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [expandedJob, setExpandedJob] = useState<string | null>(null);
const [settings, setSettings] = useState<CronSettings | null>(null);
const [togglingCatchUp, setTogglingCatchUp] = useState(false);
// Form state
const [formName, setFormName] = useState('');
@@ -159,8 +169,28 @@ export default function Cron() {
.finally(() => setLoading(false));
};
const fetchSettings = () => {
getCronSettings().then(setSettings).catch(() => {});
};
const toggleCatchUp = async () => {
if (!settings) return;
setTogglingCatchUp(true);
try {
const updated = await patchCronSettings({
catch_up_on_startup: !settings.catch_up_on_startup,
});
setSettings(updated);
} catch {
// silently fail — user can retry
} finally {
setTogglingCatchUp(false);
}
};
useEffect(() => {
fetchJobs();
fetchSettings();
}, []);
const handleAdd = async () => {
@@ -250,6 +280,37 @@ export default function Cron() {
</button>
</div>
{/* Catch-up toggle */}
{settings && (
<div className="glass-card px-4 py-3 flex items-center justify-between">
<div>
<span className="text-sm font-medium text-white">
Catch up missed jobs on startup
</span>
<p className="text-xs text-[#556080] mt-0.5">
Run all overdue jobs when ZeroClaw starts after downtime
</p>
</div>
<button
onClick={toggleCatchUp}
disabled={togglingCatchUp}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300 focus:outline-none ${
settings.catch_up_on_startup
? 'bg-[#0080ff]'
: 'bg-[#1a1a3e]'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform duration-300 ${
settings.catch_up_on_startup
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
)}
{/* Add Job Form Modal */}
{showForm && (
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">