Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e387f58579 | |||
| fa06798926 | |||
| a4cd4b287e | |||
| 3ce7f2345e | |||
| eb9dfc04b4 | |||
| 9cc74a2698 | |||
| 133dc46b41 | |||
| ad03605cad | |||
| ae1acf9b9c | |||
| cc91f22e9b | |||
| 030f5fe288 | |||
| c47bbcc972 | |||
| 72fbb22059 | |||
| cbb3d9ae92 | |||
| 8d1eebad4d | |||
| 0fdd1ad490 | |||
| 86bc60fcd1 | |||
| 4837e1fe73 | |||
| 985977ae0c | |||
| 72b10f12dd | |||
| 3239f5ea07 | |||
| 3353729b01 | |||
| b6c2930a70 | |||
| 181cafff70 | |||
| d87f387111 | |||
| 7068079028 | |||
| a9b511e6ec | |||
| 65cb4fe099 | |||
| 0d28cca843 | |||
| 7ddd2aace3 | |||
| c7b3b762e0 | |||
| 4b00e8ba75 | |||
| 2e48cbf7c3 | |||
| e4910705d1 | |||
| 1b664143c2 | |||
| 950f996812 | |||
| b74c5cfda8 | |||
| 02688eb124 | |||
| 2c92cf913b | |||
| 3c117d2d7b | |||
| 1f7c3c99e4 | |||
| 92940a3d16 | |||
| d77c616905 | |||
| ac12470c27 | |||
| c5a1148ae9 | |||
| 440ad6e5b5 | |||
| 2e41cb56f6 | |||
| 2227fadb66 | |||
| 162efbb49c | |||
| 3c8b6d219a | |||
| 58b98c59a8 | |||
| d72e9379f7 | |||
| 959b933841 | |||
| caf7c7194f |
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (1–3600) |
|
||||
| `agentic_timeout_secs` | `300` | Timeout in seconds for agentic sub-agent loops (1–3600) |
|
||||
|
||||
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]`
|
||||
|
||||
+168
-28
@@ -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
|
||||
@@ -767,6 +753,140 @@ run_guided_installer() {
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_default_config_and_workspace() {
|
||||
# Creates a minimal config.toml and workspace scaffold files when the
|
||||
# onboard wizard was skipped (e.g. --skip-build --prefer-prebuilt, or
|
||||
# Docker mode without an API key).
|
||||
#
|
||||
# $1 — config directory (e.g. ~/.zeroclaw or $docker_data_dir/.zeroclaw)
|
||||
# $2 — workspace directory (e.g. ~/.zeroclaw/workspace or $docker_data_dir/workspace)
|
||||
# $3 — provider name (default: openrouter)
|
||||
local config_dir="$1"
|
||||
local workspace_dir="$2"
|
||||
local provider="${3:-openrouter}"
|
||||
|
||||
mkdir -p "$config_dir" "$workspace_dir"
|
||||
|
||||
# --- config.toml ---
|
||||
local config_path="$config_dir/config.toml"
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
step_dot "Creating default config.toml"
|
||||
cat > "$config_path" <<TOML
|
||||
# ZeroClaw configuration — generated by install.sh
|
||||
# Edit this file or run 'zeroclaw onboard' to reconfigure.
|
||||
|
||||
default_provider = "${provider}"
|
||||
workspace_dir = "${workspace_dir}"
|
||||
TOML
|
||||
if [[ -n "${API_KEY:-}" ]]; then
|
||||
printf 'api_key = "%s"\n' "$API_KEY" >> "$config_path"
|
||||
fi
|
||||
if [[ -n "${MODEL:-}" ]]; then
|
||||
printf 'default_model = "%s"\n' "$MODEL" >> "$config_path"
|
||||
fi
|
||||
chmod 600 "$config_path" 2>/dev/null || true
|
||||
step_ok "Default config.toml created at $config_path"
|
||||
else
|
||||
step_dot "config.toml already exists, skipping"
|
||||
fi
|
||||
|
||||
# --- Workspace scaffold ---
|
||||
local subdirs=(sessions memory state cron skills)
|
||||
for dir in "${subdirs[@]}"; do
|
||||
mkdir -p "$workspace_dir/$dir"
|
||||
done
|
||||
|
||||
# Seed workspace markdown files only if they don't already exist.
|
||||
local user_name="${USER:-User}"
|
||||
local agent_name="ZeroClaw"
|
||||
|
||||
_write_if_missing() {
|
||||
local filepath="$1"
|
||||
local content="$2"
|
||||
if [[ ! -f "$filepath" ]]; then
|
||||
printf '%s\n' "$content" > "$filepath"
|
||||
fi
|
||||
}
|
||||
|
||||
_write_if_missing "$workspace_dir/IDENTITY.md" \
|
||||
"# IDENTITY.md — Who Am I?
|
||||
|
||||
- **Name:** ${agent_name}
|
||||
- **Creature:** A Rust-forged AI — fast, lean, and relentless
|
||||
- **Vibe:** Sharp, direct, resourceful. Not corporate. Not a chatbot.
|
||||
|
||||
---
|
||||
|
||||
Update this file as you evolve. Your identity is yours to shape."
|
||||
|
||||
_write_if_missing "$workspace_dir/USER.md" \
|
||||
"# USER.md — Who You're Helping
|
||||
|
||||
## About You
|
||||
- **Name:** ${user_name}
|
||||
- **Timezone:** UTC
|
||||
- **Languages:** English
|
||||
|
||||
## Preferences
|
||||
- (Add your preferences here)
|
||||
|
||||
## Work Context
|
||||
- (Add your work context here)
|
||||
|
||||
---
|
||||
*Update this anytime. The more ${agent_name} knows, the better it helps.*"
|
||||
|
||||
_write_if_missing "$workspace_dir/MEMORY.md" \
|
||||
"# MEMORY.md — Long-Term Memory
|
||||
|
||||
## Key Facts
|
||||
(Add important facts here)
|
||||
|
||||
## Decisions & Preferences
|
||||
(Record decisions and preferences here)
|
||||
|
||||
## Lessons Learned
|
||||
(Document mistakes and insights here)
|
||||
|
||||
## Open Loops
|
||||
(Track unfinished tasks and follow-ups here)"
|
||||
|
||||
_write_if_missing "$workspace_dir/AGENTS.md" \
|
||||
"# AGENTS.md — ${agent_name} Personal Assistant
|
||||
|
||||
## Every Session (required)
|
||||
|
||||
Before doing anything else:
|
||||
|
||||
1. Read SOUL.md — this is who you are
|
||||
2. Read USER.md — this is who you're helping
|
||||
3. Use memory_recall for recent context
|
||||
|
||||
---
|
||||
*Add your own conventions, style, and rules.*"
|
||||
|
||||
_write_if_missing "$workspace_dir/SOUL.md" \
|
||||
"# SOUL.md — Who You Are
|
||||
|
||||
## Core Truths
|
||||
|
||||
**Be genuinely helpful, not performatively helpful.**
|
||||
**Have opinions.** You're allowed to disagree.
|
||||
**Be resourceful before asking.** Try to figure it out first.
|
||||
**Earn trust through competence.**
|
||||
|
||||
## Identity
|
||||
|
||||
You are **${agent_name}**. Built in Rust. 3MB binary. Zero bloat.
|
||||
|
||||
---
|
||||
*This file is yours to evolve.*"
|
||||
|
||||
step_ok "Workspace scaffold ready at $workspace_dir"
|
||||
|
||||
unset -f _write_if_missing
|
||||
}
|
||||
|
||||
resolve_container_cli() {
|
||||
local requested_cli
|
||||
requested_cli="${ZEROCLAW_CONTAINER_CLI:-docker}"
|
||||
@@ -884,10 +1004,17 @@ run_docker_bootstrap() {
|
||||
-v "$config_mount" \
|
||||
-v "$workspace_mount" \
|
||||
"$docker_image" \
|
||||
"${onboard_cmd[@]}"
|
||||
"${onboard_cmd[@]}" || true
|
||||
else
|
||||
info "Docker image ready. Run zeroclaw onboard inside the container to configure."
|
||||
fi
|
||||
|
||||
# Ensure config.toml and workspace scaffold exist on the host even when
|
||||
# onboard was skipped, failed, or ran non-interactively inside the container.
|
||||
ensure_default_config_and_workspace \
|
||||
"$docker_data_dir/.zeroclaw" \
|
||||
"$docker_data_dir/workspace" \
|
||||
"$PROVIDER"
|
||||
}
|
||||
|
||||
SCRIPT_PATH="${BASH_SOURCE[0]:-$0}"
|
||||
@@ -1218,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"
|
||||
@@ -1308,6 +1441,13 @@ elif [[ -z "$ZEROCLAW_BIN" ]]; then
|
||||
warn "ZeroClaw binary not found — cannot configure provider"
|
||||
fi
|
||||
|
||||
# Ensure config.toml and workspace scaffold exist even when onboard was
|
||||
# skipped, unavailable, or failed (e.g. --skip-build --prefer-prebuilt
|
||||
# without an API key, or when the binary could not run onboard).
|
||||
_native_config_dir="${ZEROCLAW_CONFIG_DIR:-$HOME/.zeroclaw}"
|
||||
_native_workspace_dir="${ZEROCLAW_WORKSPACE:-$_native_config_dir/workspace}"
|
||||
ensure_default_config_and_workspace "$_native_config_dir" "$_native_workspace_dir" "$PROVIDER"
|
||||
|
||||
# --- Gateway service management ---
|
||||
if [[ -n "$ZEROCLAW_BIN" ]]; then
|
||||
# Try to install and start the gateway service
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+588
-66
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -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();
|
||||
|
||||
+140
-12
@@ -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,18 +2240,22 @@ 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()
|
||||
},
|
||||
ctx.tool_call_dedup_exempt.as_ref(),
|
||||
ctx.activated_tools.as_ref(),
|
||||
None,
|
||||
),
|
||||
) => LlmExecutionResult::Completed(result),
|
||||
};
|
||||
@@ -2784,6 +2789,7 @@ pub fn build_system_prompt(
|
||||
bootstrap_max_chars,
|
||||
false,
|
||||
crate::config::SkillsPromptInjectionMode::Full,
|
||||
AutonomyLevel::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2796,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);
|
||||
@@ -2861,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() {
|
||||
@@ -3228,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()),
|
||||
@@ -3321,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(
|
||||
@@ -3329,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()),
|
||||
@@ -3360,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()),
|
||||
),
|
||||
@@ -3930,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![
|
||||
(
|
||||
@@ -3987,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));
|
||||
}
|
||||
|
||||
@@ -4007,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
|
||||
@@ -4164,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(),
|
||||
@@ -4468,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(),
|
||||
@@ -4577,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(),
|
||||
@@ -4642,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(),
|
||||
@@ -4726,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(),
|
||||
@@ -5258,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,
|
||||
@@ -5331,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,
|
||||
@@ -5420,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(),
|
||||
@@ -5492,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(),
|
||||
@@ -5574,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(),
|
||||
@@ -5677,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(),
|
||||
@@ -5761,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(),
|
||||
@@ -5860,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(),
|
||||
@@ -5944,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(),
|
||||
@@ -6018,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(),
|
||||
@@ -6203,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(),
|
||||
@@ -6296,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(),
|
||||
@@ -6407,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(
|
||||
@@ -6509,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(),
|
||||
@@ -6596,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(),
|
||||
@@ -6668,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(),
|
||||
@@ -6759,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(),
|
||||
@@ -6934,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");
|
||||
@@ -7056,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>();
|
||||
@@ -7298,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(),
|
||||
@@ -7396,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(),
|
||||
@@ -7494,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(),
|
||||
@@ -8056,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(),
|
||||
@@ -8135,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(),
|
||||
@@ -8288,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,
|
||||
@@ -8391,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,
|
||||
@@ -8486,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,
|
||||
@@ -8601,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,
|
||||
@@ -8667,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
@@ -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![])
|
||||
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+148
-9
@@ -14,10 +14,13 @@ 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,
|
||||
Schedule, SessionTarget,
|
||||
};
|
||||
pub use types::{CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, Schedule, SessionTarget};
|
||||
|
||||
/// Validate a shell command against the full security policy (allowlist + risk gate).
|
||||
///
|
||||
@@ -153,6 +156,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
|
||||
expression,
|
||||
tz,
|
||||
agent,
|
||||
allowed_tools,
|
||||
command,
|
||||
} => {
|
||||
let schedule = Schedule::Cron {
|
||||
@@ -169,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);
|
||||
@@ -183,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);
|
||||
@@ -198,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());
|
||||
@@ -213,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 };
|
||||
@@ -226,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}");
|
||||
@@ -243,6 +277,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
|
||||
crate::CronCommands::Once {
|
||||
delay,
|
||||
agent,
|
||||
allowed_tools,
|
||||
command,
|
||||
} => {
|
||||
if agent {
|
||||
@@ -258,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());
|
||||
@@ -276,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 {
|
||||
@@ -301,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()
|
||||
};
|
||||
|
||||
@@ -427,6 +500,7 @@ mod tests {
|
||||
tz: tz.map(Into::into),
|
||||
command: command.map(Into::into),
|
||||
name: name.map(Into::into),
|
||||
allowed_tools: vec![],
|
||||
},
|
||||
config,
|
||||
)
|
||||
@@ -775,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,
|
||||
@@ -805,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,
|
||||
@@ -816,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();
|
||||
@@ -826,6 +964,7 @@ mod tests {
|
||||
expression: "*/5 * * * *".into(),
|
||||
tz: None,
|
||||
agent: false,
|
||||
allowed_tools: vec![],
|
||||
command: "echo ok".into(),
|
||||
},
|
||||
&config,
|
||||
|
||||
+146
-16
@@ -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
|
||||
@@ -242,6 +284,15 @@ async fn persist_job_result(
|
||||
if success {
|
||||
if let Err(e) = remove_job(config, &job.id) {
|
||||
tracing::warn!("Failed to remove one-shot cron job after success: {e}");
|
||||
// Fall back to disabling the job so it won't re-trigger.
|
||||
let _ = update_job(
|
||||
config,
|
||||
&job.id,
|
||||
CronJobPatch {
|
||||
enabled: Some(false),
|
||||
..CronJobPatch::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let _ = record_last_run(config, &job.id, finished_at, false, output);
|
||||
@@ -497,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 {
|
||||
@@ -531,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::*;
|
||||
@@ -891,6 +965,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let started = Utc::now();
|
||||
@@ -916,6 +991,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let started = Utc::now();
|
||||
@@ -982,6 +1058,7 @@ mod tests {
|
||||
best_effort: false,
|
||||
}),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let started = Utc::now();
|
||||
@@ -1020,6 +1097,7 @@ mod tests {
|
||||
best_effort: true,
|
||||
}),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let started = Utc::now();
|
||||
@@ -1038,7 +1116,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_job_result_at_schedule_without_delete_after_run_is_not_deleted() {
|
||||
async fn persist_job_result_at_schedule_without_delete_after_run_is_disabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp).await;
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
@@ -1051,6 +1129,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!job.delete_after_run);
|
||||
@@ -1060,8 +1139,13 @@ mod tests {
|
||||
let success = persist_job_result(&config, &job, true, "ok", started, finished).await;
|
||||
assert!(success);
|
||||
|
||||
// After reschedule_after_run, At schedule jobs should be disabled
|
||||
// to prevent re-execution with a past next_run timestamp.
|
||||
let updated = cron::get_job(&config, &job.id).unwrap();
|
||||
assert!(updated.enabled);
|
||||
assert!(
|
||||
!updated.enabled,
|
||||
"At schedule job should be disabled after execution via reschedule"
|
||||
);
|
||||
assert_eq!(updated.last_status.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
@@ -1138,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");
|
||||
}
|
||||
}
|
||||
|
||||
+237
-25
@@ -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,
|
||||
],
|
||||
@@ -285,26 +322,41 @@ pub fn reschedule_after_run(
|
||||
output: &str,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
let next_run = next_run_for_schedule(&job.schedule, now)?;
|
||||
let status = if success { "ok" } else { "error" };
|
||||
let bounded_output = truncate_cron_output(output);
|
||||
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
next_run.to_rfc3339(),
|
||||
now.to_rfc3339(),
|
||||
status,
|
||||
bounded_output,
|
||||
job.id
|
||||
],
|
||||
)
|
||||
.context("Failed to update cron job run state")?;
|
||||
Ok(())
|
||||
})
|
||||
// One-shot `At` schedules have no future occurrence — record the run
|
||||
// result and disable the job so it won't be picked up again.
|
||||
if matches!(job.schedule, Schedule::At { .. }) {
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET enabled = 0, last_run = ?1, last_status = ?2, last_output = ?3
|
||||
WHERE id = ?4",
|
||||
params![now.to_rfc3339(), status, bounded_output, job.id],
|
||||
)
|
||||
.context("Failed to disable completed one-shot cron job")?;
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
let next_run = next_run_for_schedule(&job.schedule, now)?;
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
next_run.to_rfc3339(),
|
||||
now.to_rfc3339(),
|
||||
status,
|
||||
bounded_output,
|
||||
job.id
|
||||
],
|
||||
)
|
||||
.context("Failed to update cron job run state")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_run(
|
||||
@@ -431,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)?,
|
||||
@@ -453,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)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -487,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([])?;
|
||||
@@ -542,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,
|
||||
@@ -575,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)
|
||||
}
|
||||
@@ -689,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();
|
||||
@@ -852,6 +1029,41 @@ mod tests {
|
||||
assert!(stored.len() <= MAX_CRON_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_disables_at_schedule_job() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let job = add_shell_job(&config, None, Schedule::At { at }, "echo once").unwrap();
|
||||
|
||||
reschedule_after_run(&config, &job, true, "done").unwrap();
|
||||
|
||||
let stored = get_job(&config, &job.id).unwrap();
|
||||
assert!(
|
||||
!stored.enabled,
|
||||
"At schedule job should be disabled after reschedule"
|
||||
);
|
||||
assert_eq!(stored.last_status.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_disables_at_schedule_job_on_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let job = add_shell_job(&config, None, Schedule::At { at }, "echo once").unwrap();
|
||||
|
||||
reschedule_after_run(&config, &job, false, "failed").unwrap();
|
||||
|
||||
let stored = get_job(&config, &job.id).unwrap();
|
||||
assert!(
|
||||
!stored.enabled,
|
||||
"At schedule job should be disabled after reschedule even on failure"
|
||||
);
|
||||
assert_eq!(stored.last_status.as_deref(), Some("error"));
|
||||
assert_eq!(stored.last_output.as_deref(), Some("failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_truncates_last_output() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+66
-1
@@ -1,6 +1,32 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Try to deserialize a `serde_json::Value` as `T`. If the value is a JSON
|
||||
/// string that looks like an object (i.e. the LLM double-serialized it), parse
|
||||
/// the inner string first and then deserialize the resulting object. This
|
||||
/// provides backward-compatible handling for both `Value::Object` and
|
||||
/// `Value::String` representations.
|
||||
pub fn deserialize_maybe_stringified<T: serde::de::DeserializeOwned>(
|
||||
v: &serde_json::Value,
|
||||
) -> Result<T, serde_json::Error> {
|
||||
// Fast path: value is already the right shape (object, array, etc.)
|
||||
match serde_json::from_value::<T>(v.clone()) {
|
||||
Ok(parsed) => Ok(parsed),
|
||||
Err(first_err) => {
|
||||
// If it's a string, try parsing the string as JSON first.
|
||||
if let Some(s) = v.as_str() {
|
||||
let s = s.trim();
|
||||
if s.starts_with('{') || s.starts_with('[') {
|
||||
if let Ok(inner) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
return serde_json::from_value::<T>(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JobType {
|
||||
@@ -154,7 +180,46 @@ pub struct CronJobPatch {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::JobType;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_from_object() {
|
||||
let val = serde_json::json!({"kind": "cron", "expr": "*/5 * * * *"});
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_from_string() {
|
||||
let val = serde_json::Value::String(r#"{"kind":"cron","expr":"*/5 * * * *"}"#.to_string());
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_string_with_tz() {
|
||||
let val = serde_json::Value::String(
|
||||
r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#.to_string(),
|
||||
);
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
match sched {
|
||||
Schedule::Cron { tz, .. } => assert_eq!(tz.as_deref(), Some("Asia/Shanghai")),
|
||||
_ => panic!("expected Cron variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_every_from_string() {
|
||||
let val = serde_json::Value::String(r#"{"kind":"every","every_ms":60000}"#.to_string());
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Every { every_ms: 60000 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_invalid_string_returns_error() {
|
||||
let val = serde_json::Value::String("not json at all".to_string());
|
||||
assert!(deserialize_maybe_stringified::<Schedule>(&val).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_type_try_from_accepts_known_values_case_insensitive() {
|
||||
|
||||
+7
-1
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -89,6 +89,7 @@ mod hardware;
|
||||
mod health;
|
||||
mod heartbeat;
|
||||
mod hooks;
|
||||
mod i18n;
|
||||
mod identity;
|
||||
mod integrations;
|
||||
mod memory;
|
||||
|
||||
@@ -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."
|
||||
));
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
//!
|
||||
//! # Limitations
|
||||
//!
|
||||
//! - **Conversation history**: Only the system prompt (if present) and the last
|
||||
//! user message are forwarded. Full multi-turn history is not preserved because
|
||||
//! the CLI accepts a single prompt per invocation.
|
||||
//! - **System prompt**: The system prompt is prepended to the user message with a
|
||||
//! blank-line separator, as the CLI does not provide a dedicated system-prompt flag.
|
||||
//! - **Temperature**: The CLI does not expose a temperature parameter.
|
||||
@@ -34,7 +31,7 @@
|
||||
//!
|
||||
//! - `CLAUDE_CODE_PATH` — override the path to the `claude` binary (default: `"claude"`)
|
||||
|
||||
use crate::providers::traits::{ChatRequest, ChatResponse, Provider, TokenUsage};
|
||||
use crate::providers::traits::{ChatMessage, ChatRequest, ChatResponse, Provider, TokenUsage};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -212,6 +209,54 @@ impl Provider for ClaudeCodeProvider {
|
||||
self.invoke_cli(&full_message, model).await
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Self::validate_temperature(temperature)?;
|
||||
|
||||
// Separate system prompt from conversation messages.
|
||||
let system = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
.map(|m| m.content.as_str());
|
||||
|
||||
// Build conversation turns (skip system messages).
|
||||
let turns: Vec<&ChatMessage> = messages.iter().filter(|m| m.role != "system").collect();
|
||||
|
||||
// If there's only one user message, use the simple path.
|
||||
if turns.len() <= 1 {
|
||||
let last_user = turns.first().map(|m| m.content.as_str()).unwrap_or("");
|
||||
let full_message = match system {
|
||||
Some(s) if !s.is_empty() => format!("{s}\n\n{last_user}"),
|
||||
_ => last_user.to_string(),
|
||||
};
|
||||
return self.invoke_cli(&full_message, model).await;
|
||||
}
|
||||
|
||||
// Format multi-turn conversation into a single prompt.
|
||||
let mut parts = Vec::new();
|
||||
if let Some(s) = system {
|
||||
if !s.is_empty() {
|
||||
parts.push(format!("[system]\n{s}"));
|
||||
}
|
||||
}
|
||||
for msg in &turns {
|
||||
let label = match msg.role.as_str() {
|
||||
"user" => "[user]",
|
||||
"assistant" => "[assistant]",
|
||||
other => other,
|
||||
};
|
||||
parts.push(format!("{label}\n{}", msg.content));
|
||||
}
|
||||
parts.push("[assistant]".to_string());
|
||||
|
||||
let full_message = parts.join("\n\n");
|
||||
self.invoke_cli(&full_message, model).await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
@@ -327,4 +372,105 @@ mod tests {
|
||||
"unexpected error message: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: create a provider that uses a shell script echoing stdin back.
|
||||
/// The script ignores CLI flags (`--print`, `--model`, `-`) and just cats stdin.
|
||||
///
|
||||
/// Uses `OnceLock` to write the script file exactly once, avoiding
|
||||
/// "Text file busy" (ETXTBSY) races when parallel tests try to
|
||||
/// overwrite a script that another test is currently executing.
|
||||
fn echo_provider() -> ClaudeCodeProvider {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SCRIPT_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
let script = SCRIPT_PATH.get_or_init(|| {
|
||||
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(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);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
path
|
||||
});
|
||||
ClaudeCodeProvider {
|
||||
binary_path: script.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_single_user_message() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![ChatMessage::user("hello")];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_single_user_with_system() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are helpful."),
|
||||
ChatMessage::user("hello"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "You are helpful.\n\nhello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_multi_turn_includes_all_messages() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::system("Be concise."),
|
||||
ChatMessage::user("What is 2+2?"),
|
||||
ChatMessage::assistant("4"),
|
||||
ChatMessage::user("And 3+3?"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.contains("[system]\nBe concise."));
|
||||
assert!(result.contains("[user]\nWhat is 2+2?"));
|
||||
assert!(result.contains("[assistant]\n4"));
|
||||
assert!(result.contains("[user]\nAnd 3+3?"));
|
||||
assert!(result.ends_with("[assistant]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_multi_turn_without_system() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::user("hi"),
|
||||
ChatMessage::assistant("hello"),
|
||||
ChatMessage::user("bye"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.contains("[system]"));
|
||||
assert!(result.contains("[user]\nhi"));
|
||||
assert!(result.contains("[assistant]\nhello"));
|
||||
assert!(result.contains("[user]\nbye"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_rejects_bad_temperature() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![ChatMessage::user("test")];
|
||||
let result = provider.chat_with_history(&messages, "default", 0.5).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -1320,11 +1326,12 @@ fn create_provider_with_url_and_options(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("llama.cpp");
|
||||
Ok(compat(OpenAiCompatibleProvider::new(
|
||||
Ok(compat(OpenAiCompatibleProvider::new_with_vision(
|
||||
"llama.cpp",
|
||||
base_url,
|
||||
Some(llama_cpp_key),
|
||||
AuthStyle::Bearer,
|
||||
true,
|
||||
)))
|
||||
}
|
||||
"sglang" => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+324
-41
@@ -16,8 +16,17 @@ use std::time::Duration;
|
||||
|
||||
/// Check if an error is non-retryable (client errors that won't resolve with retries).
|
||||
pub fn is_non_retryable(err: &anyhow::Error) -> bool {
|
||||
// Context window errors are NOT non-retryable — they can be recovered
|
||||
// by truncating conversation history, so let the retry loop handle them.
|
||||
if is_context_window_exceeded(err) {
|
||||
return true;
|
||||
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.),
|
||||
@@ -71,10 +80,27 @@ 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 = [
|
||||
"exceeds the context window",
|
||||
"exceeds the available context size",
|
||||
"context window of this model",
|
||||
"maximum context length",
|
||||
"context length exceeded",
|
||||
@@ -197,6 +223,35 @@ fn compact_error_detail(err: &anyhow::Error) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Truncate conversation history by dropping the oldest non-system messages.
|
||||
/// Returns the number of messages dropped. Keeps at least the system message
|
||||
/// (if any) and the most recent user message.
|
||||
fn truncate_for_context(messages: &mut Vec<ChatMessage>) -> usize {
|
||||
// Find all non-system message indices
|
||||
let non_system: Vec<usize> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| m.role != "system")
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
// Keep at least the last non-system message (most recent user turn)
|
||||
if non_system.len() <= 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Drop the oldest half of non-system messages
|
||||
let drop_count = non_system.len() / 2;
|
||||
let indices_to_remove: Vec<usize> = non_system[..drop_count].to_vec();
|
||||
|
||||
// Remove in reverse order to preserve indices
|
||||
for &idx in indices_to_remove.iter().rev() {
|
||||
messages.remove(idx);
|
||||
}
|
||||
|
||||
drop_count
|
||||
}
|
||||
|
||||
fn push_failure(
|
||||
failures: &mut Vec<String>,
|
||||
provider_name: &str,
|
||||
@@ -338,6 +393,25 @@ impl Provider for ReliableProvider {
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: no history to truncate
|
||||
// in chat_with_system, bail immediately.
|
||||
if is_context_window_exceeded(&e) {
|
||||
let error_detail = compact_error_detail(&e);
|
||||
push_failure(
|
||||
&mut failures,
|
||||
provider_name,
|
||||
current_model,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
"non_retryable",
|
||||
&error_detail,
|
||||
);
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -376,14 +450,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -435,6 +501,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<String> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -442,22 +510,39 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
match provider
|
||||
.chat_with_history(messages, current_model, temperature)
|
||||
.chat_with_history(&effective_messages, current_model, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -494,14 +579,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -559,6 +636,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -566,22 +645,39 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
match provider
|
||||
.chat_with_tools(messages, tools, current_model, temperature)
|
||||
.chat_with_tools(&effective_messages, tools, current_model, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -618,14 +714,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -669,6 +757,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = request.messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -676,23 +766,40 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
let req = ChatRequest {
|
||||
messages: request.messages,
|
||||
messages: &effective_messages,
|
||||
tools: request.tools,
|
||||
};
|
||||
match provider.chat(req, current_model, temperature).await {
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -729,14 +836,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1071,7 +1170,8 @@ mod tests {
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"model overloaded, try again later"
|
||||
)));
|
||||
assert!(is_non_retryable(&anyhow::anyhow!(
|
||||
// Context window errors are now recoverable (not non-retryable)
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"OpenAI Codex stream error: Your input exceeds the context window of this model."
|
||||
)));
|
||||
}
|
||||
@@ -1107,7 +1207,7 @@ mod tests {
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(msg.contains("context window"));
|
||||
assert!(msg.contains("skipped"));
|
||||
// chat_with_system has no history to truncate, so it bails immediately
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
@@ -1980,4 +2080,187 @@ mod tests {
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// ── Context window truncation tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn context_window_error_is_not_non_retryable() {
|
||||
// Context window errors should be recoverable via truncation
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"exceeds the context window"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"maximum context length exceeded"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"too many tokens in the request"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!("token limit exceeded")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_context_window_exceeded_detects_llamacpp() {
|
||||
assert!(is_context_window_exceeded(&anyhow::anyhow!(
|
||||
"request (8968 tokens) exceeds the available context size (8448 tokens), try increasing it"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_context_drops_oldest_non_system() {
|
||||
let mut messages = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user("msg1"),
|
||||
ChatMessage::assistant("resp1"),
|
||||
ChatMessage::user("msg2"),
|
||||
ChatMessage::assistant("resp2"),
|
||||
ChatMessage::user("msg3"),
|
||||
];
|
||||
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
|
||||
// 5 non-system messages, drop oldest half = 2
|
||||
assert_eq!(dropped, 2);
|
||||
// System message preserved
|
||||
assert_eq!(messages[0].role, "system");
|
||||
// Remaining messages should be the newer ones
|
||||
assert_eq!(messages.len(), 4); // system + 3 remaining non-system
|
||||
// The last message should still be the most recent user message
|
||||
assert_eq!(messages.last().unwrap().content, "msg3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_context_preserves_system_and_last_message() {
|
||||
// Only one non-system message: nothing to drop
|
||||
let mut messages = vec![ChatMessage::system("sys"), ChatMessage::user("only")];
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(messages.len(), 2);
|
||||
|
||||
// No system message, only one user message
|
||||
let mut messages = vec![ChatMessage::user("only")];
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(messages.len(), 1);
|
||||
}
|
||||
|
||||
/// Mock that fails with context error on first N calls, then succeeds.
|
||||
/// Tracks the number of messages received on each call.
|
||||
struct ContextOverflowMock {
|
||||
calls: Arc<AtomicUsize>,
|
||||
fail_until_attempt: usize,
|
||||
message_counts: parking_lot::Mutex<Vec<usize>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ContextOverflowMock {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.message_counts.lock().push(messages.len());
|
||||
if attempt <= self.fail_until_attempt {
|
||||
anyhow::bail!(
|
||||
"request (8968 tokens) exceeds the available context size (8448 tokens), try increasing it"
|
||||
);
|
||||
}
|
||||
Ok("recovered after truncation".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_truncates_on_context_overflow() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mock = ContextOverflowMock {
|
||||
calls: Arc::clone(&calls),
|
||||
fail_until_attempt: 1, // fail first call, succeed after truncation
|
||||
message_counts: parking_lot::Mutex::new(Vec::new()),
|
||||
};
|
||||
|
||||
let provider = ReliableProvider::new(
|
||||
vec![("local".into(), Box::new(mock) as Box<dyn Provider>)],
|
||||
3,
|
||||
1,
|
||||
);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::system("system prompt"),
|
||||
ChatMessage::user("old message 1"),
|
||||
ChatMessage::assistant("old response 1"),
|
||||
ChatMessage::user("old message 2"),
|
||||
ChatMessage::assistant("old response 2"),
|
||||
ChatMessage::user("current question"),
|
||||
];
|
||||
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "local-model", 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "recovered after truncation");
|
||||
// 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
@@ -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();
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+108
-3
@@ -1,6 +1,8 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::config::Config;
|
||||
use crate::cron::{self, DeliveryConfig, JobType, Schedule, SessionTarget};
|
||||
use crate::cron::{
|
||||
self, deserialize_maybe_stringified, DeliveryConfig, JobType, Schedule, SessionTarget,
|
||||
};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@@ -128,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.",
|
||||
@@ -176,7 +183,7 @@ impl Tool for CronAddTool {
|
||||
}
|
||||
|
||||
let schedule = match args.get("schedule") {
|
||||
Some(v) => match serde_json::from_value::<Schedule>(v.clone()) {
|
||||
Some(v) => match deserialize_maybe_stringified::<Schedule>(v) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
@@ -286,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()) {
|
||||
@@ -314,6 +334,7 @@ impl Tool for CronAddTool {
|
||||
model,
|
||||
delivery,
|
||||
delete_after_run,
|
||||
allowed_tools,
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -327,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,
|
||||
}),
|
||||
@@ -511,6 +533,63 @@ mod tests {
|
||||
assert!(approved.success, "{:?}", approved.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_schedule_passed_as_json_string() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = test_config(&tmp).await;
|
||||
let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
// Simulate the LLM double-serializing the schedule: the value arrives
|
||||
// as a JSON string containing a JSON object, rather than an object.
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"schedule": r#"{"kind":"cron","expr":"*/5 * * * *"}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo string-schedule"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
assert!(result.output.contains("next_run"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_stringified_interval_schedule() {
|
||||
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": r#"{"kind":"every","every_ms":60000}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo interval"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_stringified_schedule_with_timezone() {
|
||||
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": r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo tz-test"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_schedule() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -553,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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::config::Config;
|
||||
use crate::cron::{self, CronJobPatch};
|
||||
use crate::cron::{self, deserialize_maybe_stringified, CronJobPatch};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@@ -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"],
|
||||
@@ -202,7 +207,7 @@ impl Tool for CronUpdateTool {
|
||||
}
|
||||
};
|
||||
|
||||
let patch = match serde_json::from_value::<CronJobPatch>(patch_val) {
|
||||
let patch = match deserialize_maybe_stringified::<CronJobPatch>(&patch_val) {
|
||||
Ok(patch) => patch,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
@@ -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()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+244
-4
@@ -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,
|
||||
@@ -422,6 +427,7 @@ impl DelegateTool {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -453,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"
|
||||
)),
|
||||
}),
|
||||
}
|
||||
@@ -530,6 +536,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
agents.insert(
|
||||
@@ -544,6 +552,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
agents
|
||||
@@ -697,6 +707,8 @@ mod tests {
|
||||
agentic: true,
|
||||
allowed_tools,
|
||||
max_iterations,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,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());
|
||||
@@ -911,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());
|
||||
@@ -946,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());
|
||||
@@ -1220,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
@@ -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
@@ -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");
|
||||
|
||||
@@ -233,12 +233,22 @@ impl Default for ActivatedToolSet {
|
||||
|
||||
/// Build the `<available-deferred-tools>` section for the system prompt.
|
||||
/// Lists only tool names so the LLM knows what is available without
|
||||
/// consuming context window on full schemas.
|
||||
/// consuming context window on full schemas. Includes an instruction
|
||||
/// block that tells the LLM to call `tool_search` to activate them.
|
||||
pub fn build_deferred_tools_section(deferred: &DeferredMcpToolSet) -> String {
|
||||
if deferred.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::from("<available-deferred-tools>\n");
|
||||
let mut out = String::new();
|
||||
out.push_str("## Deferred Tools\n\n");
|
||||
out.push_str(
|
||||
"The tools listed below are available but NOT yet loaded. \
|
||||
To use any of them you MUST first call the `tool_search` tool \
|
||||
to fetch their full schemas. Use `\"select:name1,name2\"` for \
|
||||
exact tools or keywords to search. Once activated, the tools \
|
||||
become callable for the rest of the conversation.\n\n",
|
||||
);
|
||||
out.push_str("<available-deferred-tools>\n");
|
||||
for stub in &deferred.stubs {
|
||||
out.push_str(&stub.prefixed_name);
|
||||
out.push('\n');
|
||||
@@ -416,6 +426,55 @@ mod tests {
|
||||
assert!(section.contains("</available-deferred-tools>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_deferred_section_includes_tool_search_instruction() {
|
||||
let stubs = vec![make_stub("fs__read_file", "Read a file")];
|
||||
let set = DeferredMcpToolSet {
|
||||
stubs,
|
||||
registry: std::sync::Arc::new(
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(McpRegistry::connect_all(&[]))
|
||||
.unwrap(),
|
||||
),
|
||||
};
|
||||
let section = build_deferred_tools_section(&set);
|
||||
assert!(
|
||||
section.contains("tool_search"),
|
||||
"deferred section must instruct the LLM to use tool_search"
|
||||
);
|
||||
assert!(
|
||||
section.contains("## Deferred Tools"),
|
||||
"deferred section must include a heading"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_deferred_section_multiple_servers() {
|
||||
let stubs = vec![
|
||||
make_stub("server_a__list", "List items"),
|
||||
make_stub("server_a__create", "Create item"),
|
||||
make_stub("server_b__query", "Query records"),
|
||||
];
|
||||
let set = DeferredMcpToolSet {
|
||||
stubs,
|
||||
registry: std::sync::Arc::new(
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(McpRegistry::connect_all(&[]))
|
||||
.unwrap(),
|
||||
),
|
||||
};
|
||||
let section = build_deferred_tools_section(&set);
|
||||
assert!(section.contains("server_a__list"));
|
||||
assert!(section.contains("server_a__create"));
|
||||
assert!(section.contains("server_b__query"));
|
||||
assert!(
|
||||
section.contains("tool_search"),
|
||||
"section must mention tool_search for multi-server setups"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_search_ranks_by_hits() {
|
||||
let stubs = vec![
|
||||
@@ -457,4 +516,35 @@ mod tests {
|
||||
assert!(set.get_by_name("a__one").is_some());
|
||||
assert!(set.get_by_name("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_across_multiple_servers() {
|
||||
let stubs = vec![
|
||||
make_stub("server_a__read_file", "Read a file from disk"),
|
||||
make_stub("server_b__read_config", "Read configuration from database"),
|
||||
];
|
||||
let set = DeferredMcpToolSet {
|
||||
stubs,
|
||||
registry: std::sync::Arc::new(
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(McpRegistry::connect_all(&[]))
|
||||
.unwrap(),
|
||||
),
|
||||
};
|
||||
|
||||
// "read" should match stubs from both servers
|
||||
let results = set.search("read", 10);
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
// "file" should match only server_a
|
||||
let results = set.search("file", 10);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].prefixed_name, "server_a__read_file");
|
||||
|
||||
// "config database" should rank server_b highest (2 hits)
|
||||
let results = set.search("config database", 10);
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(results[0].prefixed_name, "server_b__read_config");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -59,6 +59,7 @@ pub mod memory_recall;
|
||||
pub mod memory_store;
|
||||
pub mod microsoft365;
|
||||
pub mod model_routing_config;
|
||||
pub mod model_switch;
|
||||
pub mod node_tool;
|
||||
pub mod notion_tool;
|
||||
pub mod pdf_read;
|
||||
@@ -119,6 +120,7 @@ pub use memory_recall::MemoryRecallTool;
|
||||
pub use memory_store::MemoryStoreTool;
|
||||
pub use microsoft365::Microsoft365Tool;
|
||||
pub use model_routing_config::ModelRoutingConfigTool;
|
||||
pub use model_switch::ModelSwitchTool;
|
||||
#[allow(unused_imports)]
|
||||
pub use node_tool::NodeTool;
|
||||
pub use notion_tool::NotionTool;
|
||||
@@ -144,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;
|
||||
@@ -281,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())),
|
||||
@@ -302,6 +309,7 @@ pub fn all_tools_with_runtime(
|
||||
config.clone(),
|
||||
security.clone(),
|
||||
)),
|
||||
Arc::new(ModelSwitchTool::new(security.clone())),
|
||||
Arc::new(ProxyConfigTool::new(config.clone(), security.clone())),
|
||||
Arc::new(GitOperationsTool::new(
|
||||
security.clone(),
|
||||
@@ -914,6 +922,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::agent::loop_::get_model_switch_state;
|
||||
use crate::providers;
|
||||
use crate::security::policy::ToolOperation;
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ModelSwitchTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl ModelSwitchTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ModelSwitchTool {
|
||||
fn name(&self) -> &str {
|
||||
"model_switch"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Switch the AI model at runtime. Use 'get' to see current model, 'list_providers' to see available providers, 'list_models' to see models for a provider, or 'set' to switch to a different model. The switch takes effect immediately for the current conversation."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "set", "list_providers", "list_models"],
|
||||
"description": "Action to perform: get current model, set a new model, list available providers, or list models for a provider"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Provider name (e.g., 'openai', 'anthropic', 'groq', 'ollama'). Required for 'set' and 'list_models' actions."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model ID (e.g., 'gpt-4o', 'claude-sonnet-4-6'). Required for 'set' action."
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("get");
|
||||
|
||||
if let Err(error) = self
|
||||
.security
|
||||
.enforce_tool_operation(ToolOperation::Act, "model_switch")
|
||||
{
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
|
||||
match action {
|
||||
"get" => self.handle_get(),
|
||||
"set" => self.handle_set(&args),
|
||||
"list_providers" => self.handle_list_providers(),
|
||||
"list_models" => self.handle_list_models(&args),
|
||||
_ => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Unknown action: {}. Valid actions: get, set, list_providers, list_models",
|
||||
action
|
||||
)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelSwitchTool {
|
||||
fn handle_get(&self) -> anyhow::Result<ToolResult> {
|
||||
let switch_state = get_model_switch_state();
|
||||
let pending = switch_state.lock().unwrap().clone();
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"pending_switch": pending,
|
||||
"note": "To switch models, use action 'set' with provider and model parameters"
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_set(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let provider = args.get("provider").and_then(|v| v.as_str());
|
||||
|
||||
let provider = match provider {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Missing 'provider' parameter for 'set' action".to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let model = args.get("model").and_then(|v| v.as_str());
|
||||
|
||||
let model = match model {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Missing 'model' parameter for 'set' action".to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Validate the provider exists
|
||||
let known_providers = providers::list_providers();
|
||||
let provider_valid = known_providers.iter().any(|p| {
|
||||
p.name.eq_ignore_ascii_case(provider)
|
||||
|| p.aliases.iter().any(|a| a.eq_ignore_ascii_case(provider))
|
||||
});
|
||||
|
||||
if !provider_valid {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"available_providers": known_providers.iter().map(|p| p.name).collect::<Vec<_>>()
|
||||
}))?,
|
||||
error: Some(format!(
|
||||
"Unknown provider: {}. Use 'list_providers' to see available options.",
|
||||
provider
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
// Set the global model switch request
|
||||
let switch_state = get_model_switch_state();
|
||||
*switch_state.lock().unwrap() = Some((provider.to_string(), model.to_string()));
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"message": "Model switch requested",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"note": "The agent will switch to this model on the next turn. Use 'get' to check pending switch."
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_providers(&self) -> anyhow::Result<ToolResult> {
|
||||
let providers_list = providers::list_providers();
|
||||
|
||||
let providers: Vec<serde_json::Value> = providers_list
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"name": p.name,
|
||||
"display_name": p.display_name,
|
||||
"aliases": p.aliases,
|
||||
"local": p.local
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"providers": providers,
|
||||
"count": providers.len(),
|
||||
"example": "Use action 'set' with provider and model to switch"
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_models(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let provider = args.get("provider").and_then(|v| v.as_str());
|
||||
|
||||
let provider = match provider {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"Missing 'provider' parameter for 'list_models' action".to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Return common models for known providers
|
||||
let models = match provider.to_lowercase().as_str() {
|
||||
"openai" => vec![
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4",
|
||||
"gpt-3.5-turbo",
|
||||
],
|
||||
"anthropic" => vec![
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-3-5-sonnet",
|
||||
"claude-3-opus",
|
||||
"claude-3-haiku",
|
||||
],
|
||||
"openrouter" => vec![
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"openai/gpt-4o",
|
||||
"google/gemini-pro",
|
||||
"meta-llama/llama-3-70b-instruct",
|
||||
],
|
||||
"groq" => vec![
|
||||
"llama-3.3-70b-versatile",
|
||||
"mixtral-8x7b-32768",
|
||||
"llama-3.1-70b-speculative",
|
||||
],
|
||||
"ollama" => vec!["llama3", "llama3.1", "mistral", "codellama", "phi3"],
|
||||
"deepseek" => vec!["deepseek-chat", "deepseek-coder"],
|
||||
"mistral" => vec![
|
||||
"mistral-large-latest",
|
||||
"mistral-small-latest",
|
||||
"mistral-nemo",
|
||||
],
|
||||
"google" | "gemini" => vec!["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"xai" | "grok" => vec!["grok-2", "grok-2-vision", "grok-beta"],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"provider": provider,
|
||||
"models": [],
|
||||
"note": "No common models listed for this provider. Check provider documentation for available models."
|
||||
}))?,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"provider": provider,
|
||||
"models": models,
|
||||
"example": "Use action 'set' with this provider and a model ID to switch"
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -281,4 +281,88 @@ mod tests {
|
||||
// Tool should now be activated
|
||||
assert!(activated.lock().unwrap().is_activated("fs__read"));
|
||||
}
|
||||
|
||||
/// Verify tool_search works with stubs from multiple MCP servers,
|
||||
/// simulating a daemon-mode setup where several servers are deferred.
|
||||
#[tokio::test]
|
||||
async fn multiple_servers_stubs_all_searchable() {
|
||||
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
||||
let stubs = vec![
|
||||
make_stub("server_a__list_files", "List files on server A"),
|
||||
make_stub("server_a__read_file", "Read file on server A"),
|
||||
make_stub("server_b__query_db", "Query database on server B"),
|
||||
make_stub("server_b__insert_row", "Insert row on server B"),
|
||||
];
|
||||
let tool = ToolSearchTool::new(make_deferred_set(stubs).await, Arc::clone(&activated));
|
||||
|
||||
// Search should find tools across both servers
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"query": "file"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("server_a__list_files"));
|
||||
assert!(result.output.contains("server_a__read_file"));
|
||||
|
||||
// Server B tools should also be searchable
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"query": "database query"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("server_b__query_db"));
|
||||
}
|
||||
|
||||
/// Verify select mode activates tools and they stay activated across calls,
|
||||
/// matching the daemon-mode pattern where a single ActivatedToolSet persists.
|
||||
#[tokio::test]
|
||||
async fn select_activates_and_persists_across_calls() {
|
||||
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
||||
let stubs = vec![
|
||||
make_stub("srv__tool_a", "Tool A"),
|
||||
make_stub("srv__tool_b", "Tool B"),
|
||||
];
|
||||
let tool = ToolSearchTool::new(make_deferred_set(stubs).await, Arc::clone(&activated));
|
||||
|
||||
// Activate tool_a
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"query": "select:srv__tool_a"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(activated.lock().unwrap().is_activated("srv__tool_a"));
|
||||
assert!(!activated.lock().unwrap().is_activated("srv__tool_b"));
|
||||
|
||||
// Activate tool_b in a separate call
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"query": "select:srv__tool_b"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
// Both should remain activated
|
||||
let guard = activated.lock().unwrap();
|
||||
assert!(guard.is_activated("srv__tool_a"));
|
||||
assert!(guard.is_activated("srv__tool_b"));
|
||||
assert_eq!(guard.tool_specs().len(), 2);
|
||||
}
|
||||
|
||||
/// Verify re-activating an already-activated tool does not duplicate it.
|
||||
#[tokio::test]
|
||||
async fn reactivation_is_idempotent() {
|
||||
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
||||
let tool = ToolSearchTool::new(
|
||||
make_deferred_set(vec![make_stub("srv__tool", "A tool")]).await,
|
||||
Arc::clone(&activated),
|
||||
);
|
||||
|
||||
tool.execute(serde_json::json!({"query": "select:srv__tool"}))
|
||||
.await
|
||||
.unwrap();
|
||||
tool.execute(serde_json::json!({"query": "select:srv__tool"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(activated.lock().unwrap().tool_specs().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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。每个工作区提供隔离的记忆、审计、密钥和工具限制。"
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user