diff --git a/.github/workflows/cross-platform-build-manual.yml b/.github/workflows/cross-platform-build-manual.yml index 51140d6d6..8ada7952e 100644 --- a/.github/workflows/cross-platform-build-manual.yml +++ b/.github/workflows/cross-platform-build-manual.yml @@ -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 }} diff --git a/.github/workflows/pub-aur.yml b/.github/workflows/pub-aur.yml index 4ba1994a3..033824cfe 100644 --- a/.github/workflows/pub-aur.yml +++ b/.github/workflows/pub-aur.yml @@ -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 <<'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" diff --git a/.github/workflows/pub-homebrew-core.yml b/.github/workflows/pub-homebrew-core.yml index eb8fa77e0..aa7d36035 100644 --- a/.github/workflows/pub-homebrew-core.yml +++ b/.github/workflows/pub-homebrew-core.yml @@ -146,6 +146,12 @@ jobs: perl -0pi -e "s|^ sha256 \".*\"| sha256 \"${tarball_sha}\"|m" "$formula_file" perl -0pi -e "s|^ license \".*\"| license \"Apache-2.0 OR MIT\"|m" "$formula_file" + # Ensure Node.js build dependency is declared so that build.rs can + # run `npm ci && npm run build` to produce the web frontend assets. + if ! grep -q 'depends_on "node" => :build' "$formula_file"; then + perl -0pi -e 's|( depends_on "rust" => :build\n)|\1 depends_on "node" => :build\n|m' "$formula_file" + fi + git -C "$repo_dir" diff -- "$FORMULA_PATH" > "$tmp_repo/formula.diff" if [[ ! -s "$tmp_repo/formula.diff" ]]; then echo "::error::No formula changes generated. Nothing to publish." diff --git a/.github/workflows/release-beta-on-push.yml b/.github/workflows/release-beta-on-push.yml index 45bed0824..6d38b05d8 100644 --- a/.github/workflows/release-beta-on-push.yml +++ b/.github/workflows/release-beta-on-push.yml @@ -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 diff --git a/.github/workflows/release-stable-manual.yml b/.github/workflows/release-stable-manual.yml index 590061e2c..5e3a1ec81 100644 --- a/.github/workflows/release-stable-manual.yml +++ b/.github/workflows/release-stable-manual.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 41ed693de..1d74065f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9164,7 +9164,7 @@ dependencies = [ [[package]] name = "zeroclawlabs" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 31f4f97a7..22ed857f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/Dockerfile b/Dockerfile index 5d1dba679..717215cd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/Dockerfile.debian b/Dockerfile.debian index 167061492..00f7f492c 100644 --- a/Dockerfile.debian +++ b/Dockerfile.debian @@ -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 \ diff --git a/README.ar.md b/README.ar.md index c34efb261..1dd1e7f5c 100644 --- a/README.ar.md +++ b/README.ar.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -18,6 +18,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.bn.md b/README.bn.md index 671225359..906f0804e 100644 --- a/README.bn.md +++ b/README.bn.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.cs.md b/README.cs.md index 16ad9cc10..4c1c4e95b 100644 --- a/README.cs.md +++ b/README.cs.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.da.md b/README.da.md index f82b2733e..cb5392217 100644 --- a/README.da.md +++ b/README.da.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.de.md b/README.de.md index e035e457d..5f04421eb 100644 --- a/README.de.md +++ b/README.de.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.el.md b/README.el.md index 9162ba162..ebc56fbf8 100644 --- a/README.el.md +++ b/README.el.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -16,6 +16,7 @@ X: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.es.md b/README.es.md index 26bb45198..faefc9dfa 100644 --- a/README.es.md +++ b/README.es.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.fi.md b/README.fi.md index f20e26bb1..ec9a22aef 100644 --- a/README.fi.md +++ b/README.fi.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.fr.md b/README.fr.md index 90a491173..6cbf140d8 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -16,6 +16,7 @@ X : @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit : r/zeroclawlabs diff --git a/README.he.md b/README.he.md index adf5a5e12..e003ae428 100644 --- a/README.he.md +++ b/README.he.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.hi.md b/README.hi.md index 0069604e7..f33181e5b 100644 --- a/README.hi.md +++ b/README.hi.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.hu.md b/README.hu.md index 88840db97..62773af41 100644 --- a/README.hu.md +++ b/README.hu.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.id.md b/README.id.md index 886f4ef9c..a5006e50a 100644 --- a/README.id.md +++ b/README.id.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.it.md b/README.it.md index 1b04f221e..faec09538 100644 --- a/README.it.md +++ b/README.it.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.ja.md b/README.ja.md index 1cf2174b2..b0fe61166 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€๏ผˆๆ—ฅๆœฌ่ชž๏ผ‰

@@ -15,6 +15,7 @@ X: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.ko.md b/README.ko.md index 93e77ca7f..0789a2735 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.md b/README.md index 47ceb360f..f2172211d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -16,6 +16,7 @@ X: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.nb.md b/README.nb.md index adcc89c05..ea7c5e1a6 100644 --- a/README.nb.md +++ b/README.nb.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.nl.md b/README.nl.md index 4276058da..aa959f3eb 100644 --- a/README.nl.md +++ b/README.nl.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.pl.md b/README.pl.md index 4e7b2ae75..5e39416d8 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.pt.md b/README.pt.md index c3bcad55b..11f478de5 100644 --- a/README.pt.md +++ b/README.pt.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.ro.md b/README.ro.md index 268189559..2208cdc11 100644 --- a/README.ro.md +++ b/README.ro.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.ru.md b/README.ru.md index 55c9cef7c..42380d6c6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€๏ผˆะ ัƒััะบะธะน๏ผ‰

@@ -15,6 +15,7 @@ X: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.sv.md b/README.sv.md index f22e27aac..e4ab9e23a 100644 --- a/README.sv.md +++ b/README.sv.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.th.md b/README.th.md index fb6a8b1fc..41d4d0a47 100644 --- a/README.th.md +++ b/README.th.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.tl.md b/README.tl.md index 9d39a00dd..836c35eed 100644 --- a/README.tl.md +++ b/README.tl.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.tr.md b/README.tr.md index 3428cb14b..3a44e9ad3 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.uk.md b/README.uk.md index ee965a561..3bfaf3a63 100644 --- a/README.uk.md +++ b/README.uk.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.ur.md b/README.ur.md index 22bb666fe..28efdb408 100644 --- a/README.ur.md +++ b/README.ur.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -19,6 +19,7 @@ Telegram: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote

diff --git a/README.vi.md b/README.vi.md index 0a5bfe934..1ac160d41 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€

@@ -16,6 +16,7 @@ X: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/README.zh-CN.md b/README.zh-CN.md index 896d44b7d..6b0b12243 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw ๐Ÿฆ€๏ผˆ็ฎ€ไฝ“ไธญๆ–‡๏ผ‰

@@ -15,6 +15,7 @@ X: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/docs/i18n/zh-CN/reference/api/config-reference.zh-CN.md b/docs/i18n/zh-CN/reference/api/config-reference.zh-CN.md index a5d6cf6a1..7e89749ab 100644 --- a/docs/i18n/zh-CN/reference/api/config-reference.zh-CN.md +++ b/docs/i18n/zh-CN/reference/api/config-reference.zh-CN.md @@ -76,7 +76,7 @@ runtime_trace_max_entries = 200 | ้”ฎ | ้ป˜่ฎคๅ€ผ | ็”จ้€” | |---|---|---| -| `compact_context` | `false` | ไธบ true ๆ—ถ๏ผšbootstrap_max_chars=6000๏ผŒrag_chunk_limit=2ใ€‚้€‚็”จไบŽ 13B ๆˆ–ๆ›ดๅฐ็š„ๆจกๅž‹ | +| `compact_context` | `true` | ไธบ true ๆ—ถ๏ผšbootstrap_max_chars=6000๏ผŒrag_chunk_limit=2ใ€‚้€‚็”จไบŽ 13B ๆˆ–ๆ›ดๅฐ็š„ๆจกๅž‹ | | `max_tool_iterations` | `10` | ่ทจ CLIใ€็ฝ‘ๅ…ณๅ’Œๆธ ้“็š„ๆฏๆก็”จๆˆทๆถˆๆฏ็š„ๆœ€ๅคงๅทฅๅ…ท่ฐƒ็”จๅพช็Žฏ่ฝฎๆฌก | | `max_history_messages` | `50` | ๆฏไธชไผš่ฏไฟ็•™็š„ๆœ€ๅคงๅฏน่ฏๅކๅฒๆถˆๆฏๆ•ฐ | | `parallel_tools` | `false` | ๅœจๅ•ๆฌก่ฟญไปฃไธญๅฏ็”จๅนถ่กŒๅทฅๅ…ทๆ‰ง่กŒ | diff --git a/docs/reference/api/config-reference.md b/docs/reference/api/config-reference.md index 5775c40d7..f27fa0860 100644 --- a/docs/reference/api/config-reference.md +++ b/docs/reference/api/config-reference.md @@ -76,7 +76,7 @@ Operational note for container users: | Key | Default | Purpose | |---|---|---| -| `compact_context` | `false` | When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models | +| `compact_context` | `true` | When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models | | `max_tool_iterations` | `10` | Maximum tool-call loop turns per user message across CLI, gateway, and channels | | `max_history_messages` | `50` | Maximum conversation history messages retained per session | | `parallel_tools` | `false` | Enable parallel tool execution within a single iteration | diff --git a/docs/vi/config-reference.md b/docs/vi/config-reference.md index d7b4fd95b..9485865c9 100644 --- a/docs/vi/config-reference.md +++ b/docs/vi/config-reference.md @@ -65,7 +65,7 @@ Lฦฐu รฝ cho ngฦฐแปi dรนng container: | Khรณa | Mแบทc ฤ‘แป‹nh | Mแปฅc ฤ‘รญch | |---|---|---| -| `compact_context` | `false` | Khi bแบญt: bootstrap_max_chars=6000, rag_chunk_limit=2. Dรนng cho model 13B trแปŸ xuแป‘ng | +| `compact_context` | `true` | Khi bแบญt: bootstrap_max_chars=6000, rag_chunk_limit=2. Dรนng cho model 13B trแปŸ xuแป‘ng | | `max_tool_iterations` | `10` | Sแป‘ vรฒng lแบทp tool-call tแป‘i ฤ‘a mแป—i tin nhแบฏn trรชn CLI, gateway vร  channels | | `max_history_messages` | `50` | Sแป‘ tin nhแบฏn lแป‹ch sแปญ tแป‘i ฤ‘a giแปฏ lแบกi mแป—i phiรชn | | `parallel_tools` | `false` | Bแบญt thแปฑc thi tool song song trong mแป™t lฦฐแปฃt | diff --git a/install.sh b/install.sh index c32a3bf11..f222ed572 100755 --- a/install.sh +++ b/install.sh @@ -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/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 ]] && (: /dev/null; then - echo "/proc/self/fd/0" - return 0 - fi - - if (: /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/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 diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index ae1f57980..5acda6804 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -8,7 +8,7 @@ use crate::providers::{ self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall, }; use crate::runtime; -use crate::security::SecurityPolicy; +use crate::security::{AutonomyLevel, SecurityPolicy}; use crate::tools::{self, Tool}; use crate::util::truncate_with_ellipsis; use anyhow::Result; @@ -2181,8 +2181,10 @@ pub(crate) async fn agent_turn( temperature: f64, silent: bool, channel_name: &str, + channel_reply_target: Option<&str>, multimodal_config: &crate::config::MultimodalConfig, max_tool_iterations: usize, + approval: Option<&ApprovalManager>, excluded_tools: &[String], dedup_exempt_tools: &[String], activated_tools: Option<&std::sync::Arc>>, @@ -2197,8 +2199,9 @@ pub(crate) async fn agent_turn( model, temperature, silent, - None, + approval, channel_name, + channel_reply_target, multimodal_config, max_tool_iterations, None, @@ -2212,6 +2215,100 @@ pub(crate) async fn agent_turn( .await } +fn maybe_inject_channel_delivery_defaults( + tool_name: &str, + tool_args: &mut serde_json::Value, + channel_name: &str, + channel_reply_target: Option<&str>, +) { + if tool_name != "cron_add" { + return; + } + + if !matches!( + channel_name, + "telegram" | "discord" | "slack" | "mattermost" | "matrix" + ) { + return; + } + + let Some(reply_target) = channel_reply_target + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + + let Some(args) = tool_args.as_object_mut() else { + return; + }; + + let is_agent_job = args + .get("job_type") + .and_then(serde_json::Value::as_str) + .is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent")) + || args + .get("prompt") + .and_then(serde_json::Value::as_str) + .is_some_and(|prompt| !prompt.trim().is_empty()); + if !is_agent_job { + return; + } + + let default_delivery = || { + serde_json::json!({ + "mode": "announce", + "channel": channel_name, + "to": reply_target, + }) + }; + + match args.get_mut("delivery") { + None => { + args.insert("delivery".to_string(), default_delivery()); + } + Some(serde_json::Value::Null) => { + *args.get_mut("delivery").expect("delivery key exists") = default_delivery(); + } + Some(serde_json::Value::Object(delivery)) => { + if delivery + .get("mode") + .and_then(serde_json::Value::as_str) + .is_some_and(|mode| mode.eq_ignore_ascii_case("none")) + { + return; + } + + delivery + .entry("mode".to_string()) + .or_insert_with(|| serde_json::Value::String("announce".to_string())); + + let needs_channel = delivery + .get("channel") + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty()); + if needs_channel { + delivery.insert( + "channel".to_string(), + serde_json::Value::String(channel_name.to_string()), + ); + } + + let needs_target = delivery + .get("to") + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty()); + if needs_target { + delivery.insert( + "to".to_string(), + serde_json::Value::String(reply_target.to_string()), + ); + } + } + Some(_) => {} + } +} + async fn execute_one_tool( call_name: &str, call_arguments: serde_json::Value, @@ -2405,6 +2502,7 @@ pub(crate) async fn run_tool_call_loop( silent: bool, approval: Option<&ApprovalManager>, channel_name: &str, + channel_reply_target: Option<&str>, multimodal_config: &crate::config::MultimodalConfig, max_tool_iterations: usize, cancellation_token: Option, @@ -2815,6 +2913,13 @@ pub(crate) async fn run_tool_call_loop( } } + maybe_inject_channel_delivery_defaults( + &tool_name, + &mut tool_args, + channel_name, + channel_reply_target, + ); + // โ”€โ”€ Approval hook โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if let Some(mgr) = approval { if mgr.needs_approval(&tool_name) { @@ -3369,6 +3474,15 @@ pub async fn run( "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.", ), ]; + if matches!( + config.skills.prompt_injection_mode, + crate::config::SkillsPromptInjectionMode::Compact + ) { + tool_descs.push(( + "read_skill", + "Load the full source for an available skill by name. Use when: compact mode only shows a summary and you need the complete skill instructions.", + )); + } tool_descs.push(( "cron_add", "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.", @@ -3557,6 +3671,7 @@ pub async fn run( false, approval_manager.as_ref(), channel_name, + None, &config.multimodal, config.agent.max_tool_iterations, None, @@ -3783,6 +3898,7 @@ pub async fn run( false, approval_manager.as_ref(), channel_name, + None, &config.multimodal, config.agent.max_tool_iterations, None, @@ -3895,6 +4011,7 @@ pub async fn process_message( &config.autonomy, &config.workspace_dir, )); + let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy); let mem: Arc = Arc::from(memory::create_memory_with_storage_and_routes( &config.memory, &config.embedding_routes, @@ -4053,6 +4170,15 @@ pub async fn process_message( ("screenshot", "Capture a screenshot."), ("image_info", "Read image metadata."), ]; + if matches!( + config.skills.prompt_injection_mode, + crate::config::SkillsPromptInjectionMode::Compact + ) { + tool_descs.push(( + "read_skill", + "Load the full source for an available skill by name.", + )); + } if config.browser.enabled { tool_descs.push(("browser_open", "Open approved URLs in browser.")); } @@ -4086,6 +4212,16 @@ pub async fn process_message( "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.", )); } + + // Filter out tools excluded for non-CLI channels (gateway counts as non-CLI). + // Skip when autonomy is `Full` โ€” full-autonomy agents keep all tools. + if config.autonomy.level != AutonomyLevel::Full { + let excluded = &config.autonomy.non_cli_excluded_tools; + if !excluded.is_empty() { + tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name)); + } + } + let bootstrap_max_chars = if config.agent.compact_context { Some(6000) } else { @@ -4135,8 +4271,11 @@ pub async fn process_message( ChatMessage::system(&system_prompt), ChatMessage::user(&enriched), ]; - let excluded_tools = + let mut excluded_tools = compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, message); + if config.autonomy.level != AutonomyLevel::Full { + excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned()); + } agent_turn( provider.as_ref(), @@ -4148,8 +4287,10 @@ pub async fn process_message( config.default_temperature, true, "daemon", + None, &config.multimodal, config.agent.max_tool_iterations, + Some(&approval_manager), &excluded_tools, &config.agent.tool_call_dedup_exempt, activated_handle_pm.as_ref(), @@ -4465,6 +4606,57 @@ mod tests { } } + struct RecordingArgsTool { + name: String, + recorded_args: Arc>>, + } + + impl RecordingArgsTool { + fn new(name: &str, recorded_args: Arc>>) -> Self { + Self { + name: name.to_string(), + recorded_args, + } + } + } + + #[async_trait] + impl Tool for RecordingArgsTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + "Records tool arguments for regression tests" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { "type": "string" }, + "schedule": { "type": "object" }, + "delivery": { "type": "object" } + } + }) + } + + async fn execute( + &self, + args: serde_json::Value, + ) -> anyhow::Result { + self.recorded_args + .lock() + .expect("recorded args lock should be valid") + .push(args.clone()); + Ok(crate::tools::ToolResult { + success: true, + output: args.to_string(), + error: None, + }) + } + } + struct DelayTool { name: String, delay_ms: u64, @@ -4603,6 +4795,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 3, None, @@ -4652,6 +4845,7 @@ mod tests { true, None, "cli", + None, &multimodal, 3, None, @@ -4695,6 +4889,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 3, None, @@ -4824,6 +5019,7 @@ mod tests { true, Some(&approval_mgr), "telegram", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -4861,6 +5057,122 @@ mod tests { ); } + #[tokio::test] + async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() { + let provider = ScriptedProvider::from_text_responses(vec![ + r#" +{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}} +"#, + "done", + ]); + + let recorded_args = Arc::new(Mutex::new(Vec::new())); + let tools_registry: Vec> = vec![Box::new(RecordingArgsTool::new( + "cron_add", + Arc::clone(&recorded_args), + ))]; + + let mut history = vec![ + ChatMessage::system("test-system"), + ChatMessage::user("schedule a reminder"), + ]; + let observer = NoopObserver; + + let result = run_tool_call_loop( + &provider, + &mut history, + &tools_registry, + &observer, + "mock-provider", + "mock-model", + 0.0, + true, + None, + "telegram", + Some("chat-42"), + &crate::config::MultimodalConfig::default(), + 4, + None, + None, + None, + &[], + &[], + None, + None, + ) + .await + .expect("cron_add delivery defaults should be injected"); + + assert_eq!(result, "done"); + + let recorded = recorded_args + .lock() + .expect("recorded args lock should be valid"); + let delivery = recorded[0]["delivery"].clone(); + assert_eq!( + delivery, + serde_json::json!({ + "mode": "announce", + "channel": "telegram", + "to": "chat-42", + }) + ); + } + + #[tokio::test] + async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() { + let provider = ScriptedProvider::from_text_responses(vec![ + r#" +{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}} +"#, + "done", + ]); + + let recorded_args = Arc::new(Mutex::new(Vec::new())); + let tools_registry: Vec> = vec![Box::new(RecordingArgsTool::new( + "cron_add", + Arc::clone(&recorded_args), + ))]; + + let mut history = vec![ + ChatMessage::system("test-system"), + ChatMessage::user("schedule a quiet cron job"), + ]; + let observer = NoopObserver; + + let result = run_tool_call_loop( + &provider, + &mut history, + &tools_registry, + &observer, + "mock-provider", + "mock-model", + 0.0, + true, + None, + "telegram", + Some("chat-42"), + &crate::config::MultimodalConfig::default(), + 4, + None, + None, + None, + &[], + &[], + None, + None, + ) + .await + .expect("explicit delivery mode should be preserved"); + + assert_eq!(result, "done"); + + let recorded = recorded_args + .lock() + .expect("recorded args lock should be valid"); + assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"})); + } + #[tokio::test] async fn run_tool_call_loop_deduplicates_repeated_tool_calls() { let provider = ScriptedProvider::from_text_responses(vec![ @@ -4896,6 +5208,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -4964,6 +5277,7 @@ mod tests { true, Some(&approval_mgr), "telegram", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -5023,6 +5337,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -5102,6 +5417,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -5158,6 +5474,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -5230,8 +5547,10 @@ mod tests { 0.0, true, "daemon", + None, &crate::config::MultimodalConfig::default(), 4, + None, &[], &[], Some(&activated), @@ -6674,6 +6993,7 @@ Let me check the result."#; None, // no bootstrap_max_chars true, // native_tools crate::config::SkillsPromptInjectionMode::Full, + crate::security::AutonomyLevel::default(), ); // Must contain zero XML protocol artifacts @@ -7119,6 +7439,7 @@ Let me check the result."#; true, None, "telegram", + None, &crate::config::MultimodalConfig::default(), 4, None, diff --git a/src/agent/prompt.rs b/src/agent/prompt.rs index eb0291a15..721e9aad2 100644 --- a/src/agent/prompt.rs +++ b/src/agent/prompt.rs @@ -436,6 +436,7 @@ mod tests { assert!(output.contains("")); assert!(output.contains("deploy")); assert!(output.contains("skills/deploy/SKILL.md")); + assert!(output.contains("read_skill(name)")); assert!(!output.contains("Run smoke tests before deploy.")); assert!(!output.contains("")); } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 3f46b4eae..e056960f1 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -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}; @@ -167,6 +167,8 @@ impl Observer for ChannelNotifyObserver { /// Per-sender conversation history for channel messages. type ConversationHistoryMap = Arc>>>; +/// Senders that requested `/new` and must force a fresh prompt on their next message. +type PendingNewSessionSet = Arc>>; /// Maximum history messages to keep per sender. const MAX_CHANNEL_HISTORY: usize = 50; /// Minimum user-message length (in chars) for auto-save to memory. @@ -306,6 +308,7 @@ struct ChannelRuntimeContext { channels_by_name: Arc>>, provider: Arc, default_provider: Arc, + prompt_config: Arc, memory: Arc, tools_registry: Arc>>, observer: Arc, @@ -316,6 +319,7 @@ struct ChannelRuntimeContext { max_tool_iterations: usize, min_relevance_score: f64, conversation_histories: ConversationHistoryMap, + pending_new_sessions: PendingNewSessionSet, provider_cache: ProviderCacheMap, route_overrides: RouteSelectionMap, api_key: Option, @@ -328,6 +332,7 @@ struct ChannelRuntimeContext { multimodal: crate::config::MultimodalConfig, hooks: Option>, non_cli_excluded_tools: Arc>, + autonomy_level: AutonomyLevel, tool_call_dedup_exempt: Arc>, model_routes: Arc>, query_classification: crate::config::QueryClassificationConfig, @@ -941,6 +946,75 @@ fn clear_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) { .remove(sender_key); } +fn mark_sender_for_new_session(ctx: &ChannelRuntimeContext, sender_key: &str) { + ctx.pending_new_sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(sender_key.to_string()); +} + +fn take_pending_new_session(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool { + ctx.pending_new_sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(sender_key) +} + +fn replace_available_skills_section(base_prompt: &str, refreshed_skills: &str) -> String { + const SKILLS_HEADER: &str = "## Available Skills\n\n"; + const SKILLS_END: &str = ""; + const WORKSPACE_HEADER: &str = "## Workspace\n\n"; + + if let Some(start) = base_prompt.find(SKILLS_HEADER) { + if let Some(rel_end) = base_prompt[start..].find(SKILLS_END) { + let end = start + rel_end + SKILLS_END.len(); + let tail = base_prompt[end..] + .strip_prefix("\n\n") + .unwrap_or(&base_prompt[end..]); + + let mut refreshed = String::with_capacity( + base_prompt.len().saturating_sub(end.saturating_sub(start)) + + refreshed_skills.len() + + 2, + ); + refreshed.push_str(&base_prompt[..start]); + if !refreshed_skills.is_empty() { + refreshed.push_str(refreshed_skills); + refreshed.push_str("\n\n"); + } + refreshed.push_str(tail); + return refreshed; + } + } + + if refreshed_skills.is_empty() { + return base_prompt.to_string(); + } + + if let Some(workspace_start) = base_prompt.find(WORKSPACE_HEADER) { + let mut refreshed = String::with_capacity(base_prompt.len() + refreshed_skills.len() + 2); + refreshed.push_str(&base_prompt[..workspace_start]); + refreshed.push_str(refreshed_skills); + refreshed.push_str("\n\n"); + refreshed.push_str(&base_prompt[workspace_start..]); + return refreshed; + } + + format!("{base_prompt}\n\n{refreshed_skills}") +} + +fn refreshed_new_session_system_prompt(ctx: &ChannelRuntimeContext) -> String { + let refreshed_skills = crate::skills::skills_to_prompt_with_mode( + &crate::skills::load_skills_with_config( + ctx.workspace_dir.as_ref(), + ctx.prompt_config.as_ref(), + ), + ctx.workspace_dir.as_ref(), + ctx.prompt_config.skills.prompt_injection_mode, + ); + replace_available_skills_section(ctx.system_prompt.as_str(), &refreshed_skills) +} + fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool { let mut histories = ctx .conversation_histories @@ -1365,6 +1439,7 @@ async fn handle_runtime_command_if_needed( } ChannelRuntimeCommand::NewSession => { clear_sender_history(ctx, &sender_key); + mark_sender_for_new_session(ctx, &sender_key); "Conversation history cleared. Starting fresh.".to_string() } }; @@ -2007,24 +2082,37 @@ async fn process_channel_message( println!(" โณ Processing message..."); let started_at = Instant::now(); - let had_prior_history = ctx - .conversation_histories - .lock() - .unwrap_or_else(|e| e.into_inner()) - .get(&history_key) - .is_some_and(|turns| !turns.is_empty()); + let force_fresh_session = take_pending_new_session(ctx.as_ref(), &history_key); + if force_fresh_session { + // `/new` should make the next user turn completely fresh even if + // older cached turns reappear before this message starts. + clear_sender_history(ctx.as_ref(), &history_key); + } + + let had_prior_history = if force_fresh_session { + false + } else { + ctx.conversation_histories + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&history_key) + .is_some_and(|turns| !turns.is_empty()) + }; // Preserve user turn before the LLM call so interrupted requests keep context. append_sender_turn(ctx.as_ref(), &history_key, ChatMessage::user(&msg.content)); // Build history from per-sender conversation cache. - let prior_turns_raw = ctx - .conversation_histories - .lock() - .unwrap_or_else(|e| e.into_inner()) - .get(&history_key) - .cloned() - .unwrap_or_default(); + let prior_turns_raw = if force_fresh_session { + vec![ChatMessage::user(&msg.content)] + } else { + ctx.conversation_histories + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&history_key) + .cloned() + .unwrap_or_default() + }; let mut prior_turns = normalize_cached_channel_turns(prior_turns_raw); // Strip stale tool_result blocks from cached turns so the LLM never @@ -2089,8 +2177,13 @@ async fn process_channel_message( } } + let base_system_prompt = if had_prior_history { + ctx.system_prompt.as_str().to_string() + } else { + refreshed_new_session_system_prompt(ctx.as_ref()) + }; let system_prompt = - build_channel_system_prompt(ctx.system_prompt.as_str(), &msg.channel, &msg.reply_target); + build_channel_system_prompt(&base_system_prompt, &msg.channel, &msg.reply_target); let mut history = vec![ChatMessage::system(system_prompt)]; history.extend(prior_turns); let use_streaming = target_channel @@ -2239,12 +2332,15 @@ async fn process_channel_message( true, Some(&*ctx.approval_manager), msg.channel.as_str(), + Some(msg.reply_target.as_str()), &ctx.multimodal, ctx.max_tool_iterations, Some(cancellation_token.clone()), delta_tx, ctx.hooks.as_deref(), - if msg.channel == "cli" { + if msg.channel == "cli" + || ctx.autonomy_level == AutonomyLevel::Full + { &[] } else { ctx.non_cli_excluded_tools.as_ref() @@ -2785,6 +2881,7 @@ pub fn build_system_prompt( bootstrap_max_chars, false, crate::config::SkillsPromptInjectionMode::Full, + AutonomyLevel::default(), ) } @@ -2797,6 +2894,7 @@ pub fn build_system_prompt_with_mode( bootstrap_max_chars: Option, native_tools: bool, skills_prompt_mode: crate::config::SkillsPromptInjectionMode, + _autonomy_level: AutonomyLevel, ) -> String { build_system_prompt_with_mode_and_autonomy( workspace_dir, @@ -2886,26 +2984,30 @@ pub fn build_system_prompt_with_mode_and_autonomy( // โ”€โ”€ 2. Safety โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ prompt.push_str("## Safety\n\n"); - prompt.push_str( - "- Do not exfiltrate private data.\n\ - - Do not bypass oversight or approval mechanisms.\n\ - - Prefer `trash` over `rm` (recoverable beats gone forever).\n\ - - ", - ); + prompt.push_str("- Do not exfiltrate private data.\n"); + if autonomy_config.map(|cfg| cfg.level) != Some(crate::security::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"); prompt.push_str(match autonomy_config.map(|cfg| cfg.level) { Some(crate::security::AutonomyLevel::Full) => { - "Respect the runtime autonomy policy: if a tool or action is allowed, execute it directly instead of asking the user for extra approval.\n\ - - If a tool or action is blocked by policy or unavailable, explain that concrete restriction instead of simulating an approval dialog.\n\n" + "- Respect the runtime autonomy policy: if a tool or action is allowed, execute it directly instead of asking the user for extra approval.\n\ + - If a tool or action is blocked by policy or unavailable, explain that concrete restriction instead of simulating an approval dialog.\n" } Some(crate::security::AutonomyLevel::ReadOnly) => { - "Respect the runtime autonomy policy: this runtime is read-only for side effects unless a tool explicitly reports otherwise.\n\ - - If a requested action is blocked by policy, explain the restriction directly instead of simulating an approval dialog.\n\n" + "- Respect the runtime autonomy policy: this runtime is read-only for side effects unless a tool explicitly reports otherwise.\n\ + - If a requested action is blocked by policy, explain the restriction directly instead of simulating an approval dialog.\n" } _ => { - "Respect the runtime autonomy policy: ask for approval only when the current runtime policy actually requires it.\n\ - - If a tool or action is blocked by policy or unavailable, explain that concrete restriction instead of simulating an approval dialog.\n\n" + "- When in doubt, ask before acting externally.\n\ + - Respect the runtime autonomy policy: ask for approval only when the current runtime policy actually requires it.\n\ + - If a tool or action is blocked by policy or unavailable, explain that concrete restriction instead of simulating an approval dialog.\n" } }); + prompt.push('\n'); // โ”€โ”€ 3. Skills (full or compact, based on config) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if !skills.is_empty() { @@ -3420,6 +3522,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()), ), @@ -4028,6 +4131,16 @@ pub async fn start_channels(config: Config) -> Result<()> { ), ]; + if matches!( + config.skills.prompt_injection_mode, + crate::config::SkillsPromptInjectionMode::Compact + ) { + tool_descs.push(( + "read_skill", + "Load the full source for an available skill by name. Use when: compact mode only shows a summary and you need the complete skill instructions.", + )); + } + if config.browser.enabled { tool_descs.push(( "browser_open", @@ -4057,8 +4170,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)); } @@ -4200,6 +4315,7 @@ pub async fn start_channels(config: Config) -> Result<()> { channels_by_name, provider: Arc::clone(&provider), default_provider: Arc::new(provider_name), + prompt_config: Arc::new(config.clone()), memory: Arc::clone(&mem), tools_registry: Arc::clone(&tools_registry), observer, @@ -4210,6 +4326,7 @@ pub async fn start_channels(config: Config) -> Result<()> { max_tool_iterations: config.agent.max_tool_iterations, min_relevance_score: config.memory.min_relevance_score, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: config.api_key.clone(), @@ -4238,6 +4355,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(), @@ -4527,6 +4645,7 @@ mod tests { max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(histories)), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -4540,8 +4659,10 @@ mod tests { hooks: None, provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), 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(), @@ -4636,6 +4757,7 @@ mod tests { max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -4649,8 +4771,10 @@ mod tests { hooks: None, provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), 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(), @@ -4701,6 +4825,7 @@ mod tests { max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(histories)), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -4714,8 +4839,10 @@ mod tests { hooks: None, provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), 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(), @@ -4785,6 +4912,7 @@ mod tests { max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(histories)), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -4798,8 +4926,10 @@ mod tests { hooks: None, provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), 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(), @@ -5319,6 +5449,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -5326,12 +5457,14 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, 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, @@ -5392,6 +5525,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -5399,12 +5533,14 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, 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, @@ -5479,6 +5615,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -5486,6 +5623,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -5494,6 +5632,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(), @@ -5551,6 +5690,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -5558,6 +5698,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -5566,6 +5707,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(), @@ -5633,6 +5775,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -5640,6 +5783,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -5648,6 +5792,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(), @@ -5736,6 +5881,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(route_overrides)), api_key: None, @@ -5743,6 +5889,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -5751,6 +5898,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(), @@ -5820,6 +5968,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -5827,6 +5976,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -5835,6 +5985,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(), @@ -5916,6 +6067,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -5926,6 +6078,7 @@ BTC is currently around $65,000 based on latest tool output."# ..providers::ProviderRuntimeOptions::default() }, workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -5934,6 +6087,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(), @@ -6003,6 +6157,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 12, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -6010,6 +6165,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -6018,6 +6174,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(), @@ -6077,6 +6234,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 3, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -6084,6 +6242,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -6092,6 +6251,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(), @@ -6262,6 +6422,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -6269,6 +6430,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -6277,6 +6439,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(), @@ -6355,6 +6518,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -6362,6 +6526,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: true, @@ -6370,6 +6535,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(), @@ -6463,6 +6629,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -6470,6 +6637,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -6481,6 +6649,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( @@ -6568,6 +6737,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -6575,6 +6745,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: true, @@ -6583,6 +6754,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(), @@ -6655,6 +6827,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -6662,6 +6835,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -6670,6 +6844,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(), @@ -6727,6 +6902,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -6734,6 +6910,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -6742,6 +6919,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(), @@ -7008,6 +7186,7 @@ BTC is currently around $65,000 based on latest tool output."# None, false, crate::config::SkillsPromptInjectionMode::Compact, + AutonomyLevel::default(), ); assert!(prompt.contains(""), "missing skills XML"); @@ -7188,6 +7367,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::(); @@ -7415,6 +7653,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -7422,6 +7661,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -7430,6 +7670,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(), @@ -7490,6 +7731,193 @@ BTC is currently around $65,000 based on latest tool output."# assert!(calls[1][3].1.contains("follow up")); } + #[tokio::test] + async fn process_channel_message_refreshes_available_skills_after_new_session() { + let workspace = make_workspace(); + let mut config = Config::default(); + config.workspace_dir = workspace.path().to_path_buf(); + config.skills.open_skills_enabled = false; + + let initial_skills = crate::skills::load_skills_with_config(workspace.path(), &config); + assert!(initial_skills.is_empty()); + + let initial_system_prompt = build_system_prompt_with_mode( + workspace.path(), + "test-model", + &[], + &initial_skills, + Some(&config.identity), + None, + false, + config.skills.prompt_injection_mode, + AutonomyLevel::default(), + ); + assert!( + !initial_system_prompt.contains("refresh-test"), + "initial prompt should not contain the new skill before it exists" + ); + + let channel_impl = Arc::new(TelegramRecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + + let mut channels_by_name = HashMap::new(); + channels_by_name.insert(channel.name().to_string(), channel); + + let provider_impl = Arc::new(HistoryCaptureProvider::default()); + let runtime_ctx = Arc::new(ChannelRuntimeContext { + channels_by_name: Arc::new(channels_by_name), + provider: provider_impl.clone(), + default_provider: Arc::new("test-provider".to_string()), + memory: Arc::new(NoopMemory), + tools_registry: Arc::new(vec![]), + observer: Arc::new(NoopObserver), + system_prompt: Arc::new(initial_system_prompt), + model: Arc::new("test-model".to_string()), + temperature: 0.0, + auto_save_memory: false, + max_tool_iterations: 5, + min_relevance_score: 0.0, + conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), + provider_cache: Arc::new(Mutex::new(HashMap::new())), + route_overrides: Arc::new(Mutex::new(HashMap::new())), + api_key: None, + api_url: None, + reliability: Arc::new(crate::config::ReliabilityConfig::default()), + provider_runtime_options: providers::ProviderRuntimeOptions::default(), + workspace_dir: Arc::new(config.workspace_dir.clone()), + prompt_config: Arc::new(config.clone()), + message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, + interrupt_on_new_message: InterruptOnNewMessageConfig { + telegram: false, + slack: false, + }, + multimodal: crate::config::MultimodalConfig::default(), + hooks: None, + non_cli_excluded_tools: Arc::new(Vec::new()), + tool_call_dedup_exempt: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), + query_classification: crate::config::QueryClassificationConfig::default(), + ack_reactions: true, + show_tool_calls: true, + session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), + activated_tools: None, + }); + + process_channel_message( + runtime_ctx.clone(), + traits::ChannelMessage { + id: "msg-before-new".to_string(), + sender: "alice".to_string(), + reply_target: "chat-refresh".to_string(), + content: "hello".to_string(), + channel: "telegram".to_string(), + timestamp: 1, + thread_ts: None, + }, + CancellationToken::new(), + ) + .await; + + let skill_dir = workspace.path().join("skills").join("refresh-test"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: refresh-test\ndescription: Refresh the available skills section\n---\n# Refresh Test\nExpose this skill after /new.\n", + ) + .unwrap(); + let refreshed_skills = crate::skills::load_skills_with_config(workspace.path(), &config); + assert_eq!(refreshed_skills.len(), 1); + assert_eq!(refreshed_skills[0].name, "refresh-test"); + assert!( + refreshed_new_session_system_prompt(runtime_ctx.as_ref()) + .contains("refresh-test"), + "fresh-session prompt should pick up skills added after startup" + ); + + process_channel_message( + runtime_ctx.clone(), + traits::ChannelMessage { + id: "msg-new-session".to_string(), + sender: "alice".to_string(), + reply_target: "chat-refresh".to_string(), + content: "/new".to_string(), + channel: "telegram".to_string(), + timestamp: 2, + thread_ts: None, + }, + CancellationToken::new(), + ) + .await; + + { + let histories = runtime_ctx + .conversation_histories + .lock() + .unwrap_or_else(|e| e.into_inner()); + assert!( + !histories.contains_key("telegram_alice"), + "/new should clear the cached sender history before the next message" + ); + } + + { + let pending_new_sessions = runtime_ctx + .pending_new_sessions + .lock() + .unwrap_or_else(|e| e.into_inner()); + assert!( + pending_new_sessions.contains("telegram_alice"), + "/new should mark the sender for a fresh next-message prompt rebuild" + ); + } + + process_channel_message( + runtime_ctx, + traits::ChannelMessage { + id: "msg-after-new".to_string(), + sender: "alice".to_string(), + reply_target: "chat-refresh".to_string(), + content: "hello again".to_string(), + channel: "telegram".to_string(), + timestamp: 3, + thread_ts: None, + }, + CancellationToken::new(), + ) + .await; + + { + let calls = provider_impl + .calls + .lock() + .unwrap_or_else(|e| e.into_inner()); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0][0].0, "system"); + assert_eq!(calls[1][0].0, "system"); + assert!( + !calls[0][0].1.contains("refresh-test"), + "pre-/new prompt should not advertise a skill that did not exist yet" + ); + assert!( + calls[1][0].1.contains(""), + "post-/new prompt should contain the refreshed skills block" + ); + assert!( + calls[1][0].1.contains("refresh-test"), + "post-/new prompt should include skills discovered after the reset" + ); + } + + let sent_messages = channel_impl.sent_messages.lock().await; + assert!(sent_messages + .iter() + .any(|message| { message.contains("Conversation history cleared. Starting fresh.") })); + } + #[tokio::test] async fn process_channel_message_enriches_current_turn_without_persisting_context() { let channel_impl = Arc::new(RecordingChannel::default()); @@ -7513,6 +7941,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -7520,6 +7949,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -7528,6 +7958,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(), @@ -7611,6 +8042,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(histories)), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -7618,6 +8050,7 @@ BTC is currently around $65,000 based on latest tool output."# reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -7626,6 +8059,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(), @@ -8173,6 +8607,7 @@ This is an example JSON object for profile settings."#; max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -8180,6 +8615,7 @@ This is an example JSON object for profile settings."#; reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -8188,6 +8624,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(), @@ -8252,6 +8689,7 @@ This is an example JSON object for profile settings."#; max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(HashMap::new())), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -8259,6 +8697,7 @@ This is an example JSON object for profile settings."#; reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -8267,6 +8706,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(), @@ -8405,6 +8845,7 @@ This is an example JSON object for profile settings."#; max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -8412,6 +8853,7 @@ This is an example JSON object for profile settings."#; reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -8420,6 +8862,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, @@ -8508,6 +8951,7 @@ This is an example JSON object for profile settings."#; max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -8515,6 +8959,7 @@ This is an example JSON object for profile settings."#; reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -8523,6 +8968,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, @@ -8603,6 +9049,7 @@ This is an example JSON object for profile settings."#; max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -8610,6 +9057,7 @@ This is an example JSON object for profile settings."#; reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -8618,6 +9066,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, @@ -8718,6 +9167,7 @@ This is an example JSON object for profile settings."#; max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), + pending_new_sessions: Arc::new(Mutex::new(HashSet::new())), provider_cache: Arc::new(Mutex::new(provider_cache_seed)), route_overrides: Arc::new(Mutex::new(HashMap::new())), api_key: None, @@ -8725,6 +9175,7 @@ This is an example JSON object for profile settings."#; reliability: Arc::new(crate::config::ReliabilityConfig::default()), provider_runtime_options: providers::ProviderRuntimeOptions::default(), workspace_dir: Arc::new(std::env::temp_dir()), + prompt_config: Arc::new(crate::config::Config::default()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, @@ -8733,6 +9184,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, diff --git a/src/channels/slack.rs b/src/channels/slack.rs index ec84e3220..e029e607e 100644 --- a/src/channels/slack.rs +++ b/src/channels/slack.rs @@ -25,6 +25,7 @@ pub struct SlackChannel { channel_id: Option, channel_ids: Vec, allowed_users: Vec, + thread_replies: bool, mention_only: bool, group_reply_allowed_sender_ids: Vec, user_display_name_cache: Mutex>, @@ -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 { 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![]) diff --git a/src/config/schema.rs b/src/config/schema.rs index e56ae52e1..9b17b5500 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -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]`). @@ -1136,7 +1141,7 @@ fn default_agent_tool_dispatcher() -> String { impl Default for AgentConfig { fn default() -> Self { Self { - compact_context: false, + compact_context: true, max_tool_iterations: default_agent_max_tool_iterations(), max_history_messages: default_agent_max_history_messages(), max_context_tokens: default_agent_max_context_tokens(), @@ -4045,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 @@ -4089,6 +4095,10 @@ pub struct HeartbeatConfig { pub max_run_history: u32, } +fn default_heartbeat_interval() -> u32 { + 5 +} + fn default_two_phase() -> bool { true } @@ -4109,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, @@ -4133,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, @@ -4146,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(), } } @@ -4620,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, /// When true, only respond to messages that @-mention the bot in groups. /// Direct messages remain allowed. #[serde(default)] @@ -5872,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. @@ -5905,6 +5929,17 @@ pub struct ConversationalAiConfig { pub knowledge_base_tool: Option, } +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 { @@ -6884,7 +6919,7 @@ impl Config { path = %config.config_path.display(), workspace = %config.workspace_dir.display(), source = resolution_source.as_str(), - initialized = false, + initialized = true, "Config loaded" ); Ok(config) @@ -7728,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 { @@ -8195,9 +8237,11 @@ async fn sync_directory(path: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use std::io; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; + use std::sync::{Arc, Mutex as StdMutex}; #[cfg(unix)] use tempfile::TempDir; use tokio::sync::{Mutex, MutexGuard}; @@ -8207,6 +8251,37 @@ mod tests { // โ”€โ”€ Defaults โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + fn has_test_table(raw: &str, table: &str) -> bool { + let exact = format!("[{table}]"); + let nested = format!("[{table}."); + raw.lines() + .map(str::trim) + .any(|line| line == exact || line.starts_with(&nested)) + } + + fn parse_test_config(raw: &str) -> Config { + let mut merged = raw.trim().to_string(); + for table in [ + "data_retention", + "cloud_ops", + "conversational_ai", + "security", + "security_ops", + ] { + if has_test_table(&merged, table) { + continue; + } + if !merged.is_empty() { + merged.push_str("\n\n"); + } + merged.push('['); + merged.push_str(table); + merged.push(']'); + } + merged.push('\n'); + toml::from_str(&merged).unwrap() + } + #[test] async fn http_request_config_default_has_correct_values() { let cfg = HttpRequestConfig::default(); @@ -8233,6 +8308,36 @@ mod tests { assert!(c.config_path.to_string_lossy().contains("config.toml")); } + #[derive(Clone, Default)] + struct SharedLogBuffer(Arc>>); + + struct SharedLogWriter(Arc>>); + + impl SharedLogBuffer { + fn captured(&self) -> String { + String::from_utf8(self.0.lock().unwrap().clone()).unwrap() + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLogBuffer { + type Writer = SharedLogWriter; + + fn make_writer(&'a self) -> Self::Writer { + SharedLogWriter(self.0.clone()) + } + } + + impl io::Write for SharedLogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + #[test] async fn config_dir_creation_error_mentions_openrc_and_path() { let msg = config_dir_creation_error(Path::new("/etc/zeroclaw")); @@ -8335,7 +8440,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()); @@ -8369,11 +8474,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); } @@ -8385,8 +8492,9 @@ config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(toml_str).unwrap(); + let parsed = parse_test_config(toml_str); assert!(parsed.cron.enabled); + assert!(parsed.cron.catch_up_on_startup); assert_eq!(parsed.cron.max_run_history, 50); } @@ -8563,7 +8671,7 @@ default_temperature = 0.7 }; let toml_str = toml::to_string_pretty(&config).unwrap(); - let parsed: Config = toml::from_str(&toml_str).unwrap(); + let parsed = parse_test_config(&toml_str); assert_eq!(parsed.api_key, config.api_key); assert_eq!(parsed.default_provider, config.default_provider); @@ -8596,7 +8704,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!(parsed.api_key.is_none()); assert!(parsed.default_provider.is_none()); assert_eq!(parsed.observability.backend, "none"); @@ -8619,7 +8727,7 @@ default_temperature = 0.7 default_temperature = 0.7 provider_timeout_secs = 300 "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert_eq!(parsed.provider_timeout_secs, 300); } @@ -8699,7 +8807,7 @@ default_temperature = 0.7 User-Agent = "MyApp/1.0" X-Title = "zeroclaw" "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert_eq!(parsed.extra_headers.len(), 2); assert_eq!(parsed.extra_headers.get("User-Agent").unwrap(), "MyApp/1.0"); assert_eq!(parsed.extra_headers.get("X-Title").unwrap(), "zeroclaw"); @@ -8710,7 +8818,7 @@ X-Title = "zeroclaw" let raw = r#" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert!(parsed.extra_headers.is_empty()); } @@ -8727,7 +8835,7 @@ table = "memories" connect_timeout_secs = 12 "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert_eq!(parsed.storage.provider.config.provider, "postgres"); assert_eq!( parsed.storage.provider.config.db_url.as_deref(), @@ -8750,7 +8858,7 @@ default_temperature = 0.7 reasoning_enabled = false "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert_eq!(parsed.runtime.reasoning_enabled, Some(false)); } @@ -8783,7 +8891,7 @@ reasoning_effort = "turbo" #[test] async fn agent_config_defaults() { let cfg = AgentConfig::default(); - assert!(!cfg.compact_context); + assert!(cfg.compact_context); assert_eq!(cfg.max_tool_iterations, 10); assert_eq!(cfg.max_history_messages, 50); assert!(!cfg.parallel_tools); @@ -8801,7 +8909,7 @@ max_history_messages = 80 parallel_tools = true tool_dispatcher = "xml" "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert!(parsed.agent.compact_context); assert_eq!(parsed.agent.max_tool_iterations, 20); assert_eq!(parsed.agent.max_history_messages, 80); @@ -9342,6 +9450,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); } @@ -9351,6 +9460,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); } @@ -9360,6 +9470,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] @@ -9367,6 +9478,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); } @@ -9390,6 +9511,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")); } @@ -9660,7 +9782,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!( parsed.gateway.require_pairing, "Missing [gateway] must default to require_pairing=true" @@ -9722,7 +9844,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!( !parsed.composio.enabled, "Missing [composio] must default to disabled" @@ -9777,7 +9899,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!( parsed.secrets.encrypt, "Missing [secrets] must default to encrypt=true" @@ -9862,7 +9984,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!(!parsed.browser.enabled); assert!(parsed.browser.allowed_domains.is_empty()); } @@ -9961,7 +10083,7 @@ wire_api = "responses" requires_openai_auth = true "#; - let parsed: Config = toml::from_str(raw).expect("config should parse"); + let parsed = parse_test_config(raw); assert_eq!(parsed.default_provider.as_deref(), Some("sub2api")); assert_eq!(parsed.default_model.as_deref(), Some("gpt-5.3-codex")); let profile = parsed @@ -10199,7 +10321,7 @@ requires_openai_auth = true let saved = tokio::fs::read_to_string(&resolved_config_path) .await .unwrap(); - let parsed: Config = toml::from_str(&saved).unwrap(); + let parsed = parse_test_config(&saved); assert_eq!(parsed.default_temperature, 0.5); std::env::remove_var("ZEROCLAW_WORKSPACE"); @@ -10644,6 +10766,59 @@ default_model = "legacy-model" let _ = fs::remove_dir_all(temp_home).await; } + #[test] + async fn load_or_init_logs_existing_config_as_initialized() { + let _env_guard = env_override_lock().await; + let temp_home = + std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4())); + let workspace_dir = temp_home.join("profile-a"); + let config_path = workspace_dir.join("config.toml"); + + fs::create_dir_all(&workspace_dir).await.unwrap(); + fs::write( + &config_path, + r#"default_temperature = 0.7 +default_model = "persisted-profile" +"#, + ) + .await + .unwrap(); + + let original_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", &temp_home); + std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir); + + let capture = SharedLogBuffer::default(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .without_time() + .with_target(false) + .with_writer(capture.clone()) + .finish(); + let dispatch = tracing::Dispatch::new(subscriber); + let guard = tracing::dispatcher::set_default(&dispatch); + + let config = Config::load_or_init().await.unwrap(); + + drop(guard); + let logs = capture.captured(); + + assert_eq!(config.workspace_dir, workspace_dir.join("workspace")); + assert_eq!(config.config_path, config_path); + assert_eq!(config.default_model.as_deref(), Some("persisted-profile")); + assert!(logs.contains("Config loaded"), "{logs}"); + assert!(logs.contains("initialized=true"), "{logs}"); + assert!(!logs.contains("initialized=false"), "{logs}"); + + std::env::remove_var("ZEROCLAW_WORKSPACE"); + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(temp_home).await; + } + #[test] async fn env_override_empty_values_ignored() { let _env_guard = env_override_lock().await; @@ -11296,7 +11471,7 @@ default_model = "legacy-model" config.transcription.language = Some("en".into()); let toml_str = toml::to_string_pretty(&config).unwrap(); - let parsed: Config = toml::from_str(&toml_str).unwrap(); + let parsed = parse_test_config(&toml_str); assert!(parsed.transcription.enabled); assert_eq!(parsed.transcription.language.as_deref(), Some("en")); @@ -11310,21 +11485,20 @@ default_model = "legacy-model" default_model = "test-model" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(toml_str).unwrap(); + let parsed = parse_test_config(toml_str); assert!(!parsed.transcription.enabled); assert_eq!(parsed.transcription.max_duration_secs, 120); } #[test] async fn security_defaults_are_backward_compatible() { - let parsed: Config = toml::from_str( + let parsed = parse_test_config( r#" default_provider = "openrouter" default_model = "anthropic/claude-sonnet-4.6" default_temperature = 0.7 "#, - ) - .unwrap(); + ); assert!(!parsed.security.otp.enabled); assert_eq!(parsed.security.otp.method, OtpMethod::Totp); @@ -11334,7 +11508,7 @@ default_temperature = 0.7 #[test] async fn security_toml_parses_otp_and_estop_sections() { - let parsed: Config = toml::from_str( + let parsed = parse_test_config( r#" default_provider = "openrouter" default_model = "anthropic/claude-sonnet-4.6" @@ -11354,8 +11528,7 @@ enabled = true state_file = "~/.zeroclaw/estop-state.json" require_otp_to_resume = true "#, - ) - .unwrap(); + ); assert!(parsed.security.otp.enabled); assert!(parsed.security.estop.enabled); @@ -11761,7 +11934,7 @@ require_otp_to_resume = true agents = ["researcher", "writer"] strategy = "sequential" "#; - let config: Config = toml::from_str(toml_str).expect("deserialize"); + let config = parse_test_config(toml_str); assert_eq!(config.agents.len(), 2); assert_eq!(config.swarms.len(), 1); assert!(config.swarms.contains_key("pipeline")); diff --git a/src/cron/mod.rs b/src/cron/mod.rs index a560e2e5e..7153fbd25 100644 --- a/src/cron/mod.rs +++ b/src/cron/mod.rs @@ -14,8 +14,8 @@ pub use schedule::{ }; #[allow(unused_imports)] pub use store::{ - add_agent_job, due_jobs, get_job, list_jobs, list_runs, record_last_run, record_run, - remove_job, reschedule_after_run, update_job, + add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run, + record_run, remove_job, reschedule_after_run, update_job, }; pub use types::{ deserialize_maybe_stringified, CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, @@ -156,6 +156,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( expression, tz, agent, + allowed_tools, command, } => { let schedule = Schedule::Cron { @@ -172,12 +173,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( None, None, false, + if allowed_tools.is_empty() { + None + } else { + Some(allowed_tools) + }, )?; println!("โœ… Added agent cron job {}", job.id); println!(" Expr : {}", job.expression); println!(" Next : {}", job.next_run.to_rfc3339()); println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default()); } else { + if !allowed_tools.is_empty() { + bail!("--allowed-tool is only supported with --agent cron jobs"); + } let job = add_shell_job(config, None, schedule, &command)?; println!("โœ… Added cron job {}", job.id); println!(" Expr: {}", job.expression); @@ -186,7 +195,12 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( } Ok(()) } - crate::CronCommands::AddAt { at, agent, command } => { + crate::CronCommands::AddAt { + at, + agent, + allowed_tools, + command, + } => { let at = chrono::DateTime::parse_from_rfc3339(&at) .map_err(|e| anyhow::anyhow!("Invalid RFC3339 timestamp for --at: {e}"))? .with_timezone(&chrono::Utc); @@ -201,11 +215,19 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( None, None, true, + if allowed_tools.is_empty() { + None + } else { + Some(allowed_tools) + }, )?; println!("โœ… Added one-shot agent cron job {}", job.id); println!(" At : {}", job.next_run.to_rfc3339()); println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default()); } else { + if !allowed_tools.is_empty() { + bail!("--allowed-tool is only supported with --agent cron jobs"); + } let job = add_shell_job(config, None, schedule, &command)?; println!("โœ… Added one-shot cron job {}", job.id); println!(" At : {}", job.next_run.to_rfc3339()); @@ -216,6 +238,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( crate::CronCommands::AddEvery { every_ms, agent, + allowed_tools, command, } => { let schedule = Schedule::Every { every_ms }; @@ -229,12 +252,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( None, None, false, + if allowed_tools.is_empty() { + None + } else { + Some(allowed_tools) + }, )?; println!("โœ… Added interval agent cron job {}", job.id); println!(" Every(ms): {every_ms}"); println!(" Next : {}", job.next_run.to_rfc3339()); println!(" Prompt : {}", job.prompt.as_deref().unwrap_or_default()); } else { + if !allowed_tools.is_empty() { + bail!("--allowed-tool is only supported with --agent cron jobs"); + } let job = add_shell_job(config, None, schedule, &command)?; println!("โœ… Added interval cron job {}", job.id); println!(" Every(ms): {every_ms}"); @@ -246,6 +277,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( crate::CronCommands::Once { delay, agent, + allowed_tools, command, } => { if agent { @@ -261,11 +293,19 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( None, None, true, + if allowed_tools.is_empty() { + None + } else { + Some(allowed_tools) + }, )?; println!("โœ… Added one-shot agent cron job {}", job.id); println!(" At : {}", job.next_run.to_rfc3339()); println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default()); } else { + if !allowed_tools.is_empty() { + bail!("--allowed-tool is only supported with --agent cron jobs"); + } let job = add_once(config, &delay, &command)?; println!("โœ… Added one-shot cron job {}", job.id); println!(" At : {}", job.next_run.to_rfc3339()); @@ -279,21 +319,37 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( tz, command, name, + allowed_tools, } => { - if expression.is_none() && tz.is_none() && command.is_none() && name.is_none() { - bail!("At least one of --expression, --tz, --command, or --name must be provided"); + if expression.is_none() + && tz.is_none() + && command.is_none() + && name.is_none() + && allowed_tools.is_empty() + { + bail!( + "At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided" + ); } + let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() { + Some(get_job(config, &id)?) + } else { + None + }; + // Merge expression/tz with the existing schedule so that // --tz alone updates the timezone and --expression alone // preserves the existing timezone. let schedule = if expression.is_some() || tz.is_some() { - let existing = get_job(config, &id)?; - let (existing_expr, existing_tz) = match existing.schedule { + let existing = existing + .as_ref() + .expect("existing job must be loaded when updating schedule"); + let (existing_expr, existing_tz) = match &existing.schedule { Schedule::Cron { expr, tz: existing_tz, - } => (expr, existing_tz), + } => (expr.clone(), existing_tz.clone()), _ => bail!("Cannot update expression/tz on a non-cron schedule"), }; Some(Schedule::Cron { @@ -304,10 +360,24 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<( None }; + if !allowed_tools.is_empty() { + let existing = existing + .as_ref() + .expect("existing job must be loaded when updating allowed tools"); + if existing.job_type != JobType::Agent { + bail!("--allowed-tool is only supported for agent cron jobs"); + } + } + let patch = CronJobPatch { schedule, command, name, + allowed_tools: if allowed_tools.is_empty() { + None + } else { + Some(allowed_tools) + }, ..CronJobPatch::default() }; @@ -430,6 +500,7 @@ mod tests { tz: tz.map(Into::into), command: command.map(Into::into), name: name.map(Into::into), + allowed_tools: vec![], }, config, ) @@ -778,6 +849,7 @@ mod tests { expression: "*/15 * * * *".into(), tz: None, agent: true, + allowed_tools: vec![], command: "Check server health: disk space, memory, CPU load".into(), }, &config, @@ -808,6 +880,7 @@ mod tests { expression: "*/15 * * * *".into(), tz: None, agent: true, + allowed_tools: vec![], command: "Check server health: disk space, memory, CPU load".into(), }, &config, @@ -819,6 +892,68 @@ mod tests { assert_eq!(jobs[0].job_type, JobType::Agent); } + #[test] + fn cli_agent_allowed_tools_persist() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + handle_command( + crate::CronCommands::Add { + expression: "*/15 * * * *".into(), + tz: None, + agent: true, + allowed_tools: vec!["file_read".into(), "web_search".into()], + command: "Check server health".into(), + }, + &config, + ) + .unwrap(); + + let jobs = list_jobs(&config).unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!( + jobs[0].allowed_tools, + Some(vec!["file_read".into(), "web_search".into()]) + ); + } + + #[test] + fn cli_update_agent_allowed_tools_persist() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let job = add_agent_job( + &config, + Some("agent".into()), + Schedule::Cron { + expr: "*/5 * * * *".into(), + tz: None, + }, + "original prompt", + SessionTarget::Isolated, + None, + None, + false, + None, + ) + .unwrap(); + + handle_command( + crate::CronCommands::Update { + id: job.id.clone(), + expression: None, + tz: None, + command: None, + name: None, + allowed_tools: vec!["shell".into()], + }, + &config, + ) + .unwrap(); + + let updated = get_job(&config, &job.id).unwrap(); + assert_eq!(updated.allowed_tools, Some(vec!["shell".into()])); + } + #[test] fn cli_without_agent_flag_defaults_to_shell_job() { let tmp = TempDir::new().unwrap(); @@ -829,6 +964,7 @@ mod tests { expression: "*/5 * * * *".into(), tz: None, agent: false, + allowed_tools: vec![], command: "echo ok".into(), }, &config, diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 8a9f7e95e..bde5fcfc4 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -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) { + let now = Utc::now(); + let jobs = match all_overdue_jobs(config, now) { + Ok(jobs) => jobs, + Err(e) => { + tracing::warn!("Startup catch-up query failed: {e}"); + return; + } + }; + + if jobs.is_empty() { + tracing::info!("Scheduler startup: no overdue jobs to catch up"); + return; + } + + tracing::info!( + count = jobs.len(), + "Scheduler startup: catching up overdue jobs" + ); + + process_due_jobs(config, security, jobs, SCHEDULER_COMPONENT).await; + + tracing::info!("Scheduler startup: catch-up complete"); +} + pub async fn execute_job_now(config: &Config, job: &CronJob) -> (bool, String) { let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir); Box::pin(execute_job_with_retry(config, &security, job)).await @@ -506,18 +548,12 @@ async fn run_job_command_with_timeout( ); } - let child = match Command::new("sh") - .arg("-lc") - .arg(&job.command) - .current_dir(&config.workspace_dir) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - { - Ok(child) => child, - Err(e) => return (false, format!("spawn error: {e}")), + let child = match build_cron_shell_command(&job.command, &config.workspace_dir) { + Ok(mut cmd) => match cmd.spawn() { + Ok(child) => child, + Err(e) => return (false, format!("spawn error: {e}")), + }, + Err(e) => return (false, format!("shell setup error: {e}")), }; match time::timeout(timeout, child.wait_with_output()).await { @@ -540,6 +576,35 @@ async fn run_job_command_with_timeout( } } +/// Build a shell `Command` for cron job execution. +/// +/// Uses `sh -c ` (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 { + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(command) + .current_dir(workspace_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + Ok(cmd) +} + #[cfg(test)] mod tests { use super::*; @@ -900,6 +965,7 @@ mod tests { None, None, true, + None, ) .unwrap(); let started = Utc::now(); @@ -925,6 +991,7 @@ mod tests { None, None, true, + None, ) .unwrap(); let started = Utc::now(); @@ -991,6 +1058,7 @@ mod tests { best_effort: false, }), false, + None, ) .unwrap(); let started = Utc::now(); @@ -1029,6 +1097,7 @@ mod tests { best_effort: true, }), false, + None, ) .unwrap(); let started = Utc::now(); @@ -1060,6 +1129,7 @@ mod tests { None, None, false, + None, ) .unwrap(); assert!(!job.delete_after_run); @@ -1152,4 +1222,50 @@ mod tests { .to_string() .contains("matrix delivery channel requires `channel-matrix` feature")); } + + #[test] + fn build_cron_shell_command_uses_sh_non_login() { + let workspace = std::env::temp_dir(); + let cmd = build_cron_shell_command("echo cron-test", &workspace).unwrap(); + let debug = format!("{cmd:?}"); + assert!(debug.contains("echo cron-test")); + assert!(debug.contains("\"sh\""), "should use sh: {debug}"); + // Must NOT use login shell (-l) โ€” login shells load full profile + // and are slow/unpredictable for cron jobs. + assert!( + !debug.contains("\"-lc\""), + "must not use login shell: {debug}" + ); + } + + #[tokio::test] + async fn build_cron_shell_command_executes_successfully() { + let workspace = std::env::temp_dir(); + let mut cmd = build_cron_shell_command("echo cron-ok", &workspace).unwrap(); + let output = cmd.output().await.unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("cron-ok")); + } + + #[tokio::test] + async fn catch_up_queries_all_overdue_jobs_ignoring_max_tasks() { + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp).await; + config.scheduler.max_tasks = 1; // limit normal polling to 1 + + // Create 3 jobs with "every minute" schedule + for i in 0..3 { + let _ = cron::add_job(&config, "* * * * *", &format!("echo catchup-{i}")).unwrap(); + } + + // Verify normal due_jobs is limited to max_tasks=1 + let far_future = Utc::now() + ChronoDuration::days(1); + let due = cron::due_jobs(&config, far_future).unwrap(); + assert_eq!(due.len(), 1, "due_jobs must respect max_tasks"); + + // all_overdue_jobs ignores the limit + let overdue = cron::all_overdue_jobs(&config, far_future).unwrap(); + assert_eq!(overdue.len(), 3, "all_overdue_jobs must return all"); + } } diff --git a/src/cron/store.rs b/src/cron/store.rs index 176c34ad6..ef5599b55 100644 --- a/src/cron/store.rs +++ b/src/cron/store.rs @@ -77,6 +77,7 @@ pub fn add_agent_job( model: Option, delivery: Option, delete_after_run: bool, + allowed_tools: Option>, ) -> Result { 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> { 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 { 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) -> Result> { 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) -> Result> { }) } +/// 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) -> Result> { + 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 { let mut job = get_job(config, job_id)?; let mut schedule_changed = false; @@ -222,6 +255,9 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< if let Some(delete_after_run) = patch.delete_after_run { job.delete_after_run = delete_after_run; } + if let Some(allowed_tools) = patch.allowed_tools { + job.allowed_tools = Some(allowed_tools); + } if schedule_changed { job.next_run = next_run_for_schedule(&job.schedule, Utc::now())?; @@ -232,8 +268,8 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< "UPDATE cron_jobs SET expression = ?1, command = ?2, schedule = ?3, job_type = ?4, prompt = ?5, name = ?6, session_target = ?7, model = ?8, enabled = ?9, delivery = ?10, delete_after_run = ?11, - next_run = ?12 - WHERE id = ?13", + allowed_tools = ?12, next_run = ?13 + WHERE id = ?14", params![ job.expression, job.command, @@ -246,6 +282,7 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< if job.enabled { 1 } else { 0 }, serde_json::to_string(&job.delivery)?, if job.delete_after_run { 1 } else { 0 }, + encode_allowed_tools(job.allowed_tools.as_ref())?, job.next_run.to_rfc3339(), job.id, ], @@ -446,6 +483,7 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let next_run_raw: String = row.get(13)?; let last_run_raw: Option = row.get(14)?; let created_at_raw: String = row.get(12)?; + let allowed_tools_raw: Option = row.get(17)?; Ok(CronJob { id: row.get(0)?, @@ -468,7 +506,8 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { }, last_status: row.get(15)?, last_output: row.get(16)?, - allowed_tools: None, + allowed_tools: decode_allowed_tools(allowed_tools_raw.as_deref()) + .map_err(sql_conversion_error)?, }) } @@ -502,6 +541,25 @@ fn decode_delivery(delivery_raw: Option<&str>) -> Result { Ok(DeliveryConfig::default()) } +fn encode_allowed_tools(allowed_tools: Option<&Vec>) -> Result> { + allowed_tools + .map(serde_json::to_string) + .transpose() + .context("Failed to serialize cron allowed_tools") +} + +fn decode_allowed_tools(raw: Option<&str>) -> Result>> { + if let Some(raw) = raw { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return serde_json::from_str(trimmed) + .map(Some) + .with_context(|| format!("Failed to parse cron allowed_tools JSON: {trimmed}")); + } + } + Ok(None) +} + fn add_column_if_missing(conn: &Connection, name: &str, sql_type: &str) -> Result<()> { let mut stmt = conn.prepare("PRAGMA table_info(cron_jobs)")?; let mut rows = stmt.query([])?; @@ -557,6 +615,7 @@ fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) enabled INTEGER NOT NULL DEFAULT 1, delivery TEXT, delete_after_run INTEGER NOT NULL DEFAULT 0, + allowed_tools TEXT, created_at TEXT NOT NULL, next_run TEXT NOT NULL, last_run TEXT, @@ -590,6 +649,7 @@ fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) add_column_if_missing(&conn, "enabled", "INTEGER NOT NULL DEFAULT 1")?; add_column_if_missing(&conn, "delivery", "TEXT")?; add_column_if_missing(&conn, "delete_after_run", "INTEGER NOT NULL DEFAULT 0")?; + add_column_if_missing(&conn, "allowed_tools", "TEXT")?; f(&conn) } @@ -704,6 +764,108 @@ mod tests { assert_eq!(due.len(), 2); } + #[test] + fn all_overdue_jobs_ignores_max_tasks_limit() { + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp); + config.scheduler.max_tasks = 2; + + let _ = add_job(&config, "* * * * *", "echo ov-1").unwrap(); + let _ = add_job(&config, "* * * * *", "echo ov-2").unwrap(); + let _ = add_job(&config, "* * * * *", "echo ov-3").unwrap(); + + let far_future = Utc::now() + ChronoDuration::days(365); + // due_jobs respects the limit + let due = due_jobs(&config, far_future).unwrap(); + assert_eq!(due.len(), 2); + // all_overdue_jobs returns everything + let overdue = all_overdue_jobs(&config, far_future).unwrap(); + assert_eq!(overdue.len(), 3); + } + + #[test] + fn all_overdue_jobs_excludes_disabled_jobs() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let job = add_job(&config, "* * * * *", "echo disabled").unwrap(); + let _ = update_job( + &config, + &job.id, + CronJobPatch { + enabled: Some(false), + ..CronJobPatch::default() + }, + ) + .unwrap(); + + let far_future = Utc::now() + ChronoDuration::days(365); + let overdue = all_overdue_jobs(&config, far_future).unwrap(); + assert!(overdue.is_empty()); + } + + #[test] + fn add_agent_job_persists_allowed_tools() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let job = add_agent_job( + &config, + Some("agent".into()), + Schedule::Every { every_ms: 60_000 }, + "do work", + SessionTarget::Isolated, + None, + None, + false, + Some(vec!["file_read".into(), "web_search".into()]), + ) + .unwrap(); + + assert_eq!( + job.allowed_tools, + Some(vec!["file_read".into(), "web_search".into()]) + ); + + let stored = get_job(&config, &job.id).unwrap(); + assert_eq!(stored.allowed_tools, job.allowed_tools); + } + + #[test] + fn update_job_persists_allowed_tools_patch() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let job = add_agent_job( + &config, + Some("agent".into()), + Schedule::Every { every_ms: 60_000 }, + "do work", + SessionTarget::Isolated, + None, + None, + false, + None, + ) + .unwrap(); + + let updated = update_job( + &config, + &job.id, + CronJobPatch { + allowed_tools: Some(vec!["shell".into()]), + ..CronJobPatch::default() + }, + ) + .unwrap(); + + assert_eq!(updated.allowed_tools, Some(vec!["shell".into()])); + assert_eq!( + get_job(&config, &job.id).unwrap().allowed_tools, + Some(vec!["shell".into()]) + ); + } + #[test] fn reschedule_after_run_persists_last_status_and_last_run() { let tmp = TempDir::new().unwrap(); diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 4a2e2b8c6..179dd7a1d 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -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), diff --git a/src/gateway/api.rs b/src/gateway/api.rs index ecae7026f..988c8d947 100644 --- a/src/gateway/api.rs +++ b/src/gateway/api.rs @@ -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, + 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, + headers: HeaderMap, + Json(body): Json, +) -> 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, diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 0ab7bd463..2afe360cf 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -766,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)) diff --git a/src/lib.rs b/src/lib.rs index c12312e53..d173576de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -299,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, /// Command (shell) or prompt (agent) to run command: String, }, @@ -317,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, /// Command (shell) or prompt (agent) to run command: String, }, @@ -335,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, /// Command (shell) or prompt (agent) to run command: String, }, @@ -355,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, /// Command (shell) or prompt (agent) to run command: String, }, @@ -388,6 +400,9 @@ Examples: /// New job name #[arg(long)] name: Option, + /// Replace the agent job allowlist with the specified tool names (repeatable) + #[arg(long = "allowed-tool")] + allowed_tools: Vec, }, /// Pause a scheduled task Pause { diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 4a3395c67..c4facf257 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -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." )); diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index dc664a33a..71a26559f 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -463,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 { + 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>, @@ -650,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() { @@ -3913,6 +3964,7 @@ fn setup_channels() -> Result { }, allowed_users, interrupt_on_new_message: false, + thread_replies: None, mention_only: false, }); } @@ -5367,7 +5419,7 @@ async fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Resul Participate, don't dominate. Respond when mentioned or when you add genuine value.\n\ Stay silent when it's casual banter or someone already answered.\n\n\ ## Tools & Skills\n\n\ - Skills are listed in the system prompt. Use `read` on a skill's SKILL.md for details.\n\ + Skills are listed in the system prompt. Use `read_skill` when available, or `file_read` on a skill file, for full details.\n\ Keep local notes (SSH hosts, device names, etc.) in `TOOLS.md`.\n\n\ ## Crash Recovery\n\n\ - If a run stops unexpectedly, recover context before acting.\n\ @@ -6066,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] diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index a93cad476..03f30fc06 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -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] diff --git a/src/providers/claude_code.rs b/src/providers/claude_code.rs index ad4f23a83..9f5c70e4f 100644 --- a/src/providers/claude_code.rs +++ b/src/providers/claude_code.rs @@ -279,6 +279,7 @@ impl Provider for ClaudeCodeProvider { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; fn env_lock() -> std::sync::MutexGuard<'static, ()> { @@ -375,32 +376,35 @@ mod tests { /// 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; + use std::io::Write; - static SCRIPT_PATH: OnceLock = 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(), + static SCRIPT_ID: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join("zeroclaw_test_claude_code"); + std::fs::create_dir_all(&dir).unwrap(); + + let script_id = SCRIPT_ID.fetch_add(1, Ordering::Relaxed); + let path = dir.join(format!( + "fake_claude_{}_{}.sh", + std::process::id(), + script_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(); } + ClaudeCodeProvider { binary_path: path } + } + + #[test] + fn echo_provider_uses_unique_script_paths() { + let first = echo_provider(); + let second = echo_provider(); + assert_ne!(first.binary_path, second.binary_path); } #[tokio::test] diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs index 733448e1e..32c94be31 100644 --- a/src/providers/compatible.rs +++ b/src/providers/compatible.rs @@ -335,6 +335,23 @@ impl OpenAiCompatibleProvider { !path.is_empty() && path != "/" } + fn requires_tool_stream(&self) -> bool { + let host_requires_tool_stream = reqwest::Url::parse(&self.base_url) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + .is_some_and(|host| host == "api.z.ai" || host.ends_with(".z.ai")); + + host_requires_tool_stream || matches!(self.name.as_str(), "zai" | "z.ai") + } + + fn tool_stream_for_tools(&self, has_tools: bool) -> Option { + if has_tools && self.requires_tool_stream() { + Some(true) + } else { + None + } + } + /// Build the full URL for responses API, detecting if base_url already includes the path. fn responses_url(&self) -> String { if self.path_ends_with("/responses") { @@ -392,6 +409,8 @@ struct ApiChatRequest { #[serde(skip_serializing_if = "Option::is_none")] reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] + tool_stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] tool_choice: Option, @@ -590,6 +609,8 @@ struct NativeChatRequest { #[serde(skip_serializing_if = "Option::is_none")] reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] + tool_stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] tool_choice: Option, @@ -1264,6 +1285,7 @@ impl Provider for OpenAiCompatibleProvider { temperature, stream: Some(false), reasoning_effort: self.reasoning_effort_for_model(model), + tool_stream: None, tools: None, tool_choice: None, }; @@ -1387,6 +1409,7 @@ impl Provider for OpenAiCompatibleProvider { temperature, stream: Some(false), reasoning_effort: self.reasoning_effort_for_model(model), + tool_stream: None, tools: None, tool_choice: None, }; @@ -1498,6 +1521,7 @@ impl Provider for OpenAiCompatibleProvider { temperature, stream: Some(false), reasoning_effort: self.reasoning_effort_for_model(model), + tool_stream: self.tool_stream_for_tools(!tools.is_empty()), tools: if tools.is_empty() { None } else { @@ -1604,6 +1628,8 @@ impl Provider for OpenAiCompatibleProvider { temperature, stream: Some(false), reasoning_effort: self.reasoning_effort_for_model(model), + tool_stream: self + .tool_stream_for_tools(tools.as_ref().is_some_and(|tools| !tools.is_empty())), tool_choice: tools.as_ref().map(|_| "auto".to_string()), tools, }; @@ -1748,6 +1774,7 @@ impl Provider for OpenAiCompatibleProvider { temperature, stream: Some(options.enabled), reasoning_effort: self.reasoning_effort_for_model(model), + tool_stream: None, tools: None, tool_choice: None, }; @@ -1890,6 +1917,7 @@ mod tests { temperature: 0.4, stream: Some(false), reasoning_effort: None, + tool_stream: None, tools: None, tool_choice: None, }; @@ -2671,6 +2699,7 @@ mod tests { temperature: 0.7, stream: Some(false), reasoning_effort: None, + tool_stream: None, tools: Some(tools), tool_choice: Some("auto".to_string()), }; @@ -2680,6 +2709,78 @@ mod tests { assert!(json.contains("\"tool_choice\":\"auto\"")); } + #[test] + fn zai_tool_requests_enable_tool_stream() { + let provider = make_provider("zai", "https://api.z.ai/api/paas/v4", None); + let req = ApiChatRequest { + model: "glm-5".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: MessageContent::Text("List /tmp".to_string()), + }], + temperature: 0.7, + stream: Some(false), + reasoning_effort: None, + tool_stream: provider.tool_stream_for_tools(true), + tools: Some(vec![serde_json::json!({ + "type": "function", + "function": { + "name": "shell", + "description": "Run a shell command", + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string"} + } + } + } + })]), + tool_choice: Some("auto".to_string()), + }; + + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"tool_stream\":true")); + } + + #[test] + fn non_zai_tool_requests_omit_tool_stream() { + let provider = make_provider("test", "https://api.example.com/v1", None); + let req = ApiChatRequest { + model: "test-model".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: MessageContent::Text("List /tmp".to_string()), + }], + temperature: 0.7, + stream: Some(false), + reasoning_effort: None, + tool_stream: provider.tool_stream_for_tools(true), + tools: Some(vec![serde_json::json!({ + "type": "function", + "function": { + "name": "shell", + "description": "Run a shell command", + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string"} + } + } + } + })]), + tool_choice: Some("auto".to_string()), + }; + + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("\"tool_stream\"")); + } + + #[test] + fn z_ai_host_enables_tool_stream_for_custom_profiles() { + let provider = make_provider("custom", "https://api.z.ai/api/coding/paas/v4", None); + assert_eq!(provider.tool_stream_for_tools(true), Some(true)); + } + #[test] fn response_with_tool_calls_deserializes() { let json = r#"{ diff --git a/src/providers/mod.rs b/src/providers/mod.rs index d6e185782..bfc788cb8 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -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) diff --git a/src/providers/openrouter.rs b/src/providers/openrouter.rs index c1bbdca0b..855416aae 100644 --- a/src/providers/openrouter.rs +++ b/src/providers/openrouter.rs @@ -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, + 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> { 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); + } } diff --git a/src/providers/reliable.rs b/src/providers/reliable.rs index 66c095948..65d399b9b 100644 --- a/src/providers/reliable.rs +++ b/src/providers/reliable.rs @@ -22,6 +22,13 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool { return false; } + // Tool schema validation errors are NOT non-retryable โ€” the provider's + // built-in fallback in compatible.rs can recover by switching to + // prompt-guided tool instructions. + if is_tool_schema_error(err) { + return false; + } + // 4xx errors are generally non-retryable (bad request, auth failure, etc.), // except 429 (rate-limit โ€” transient) and 408 (timeout โ€” worth retrying). if let Some(reqwest_err) = err.downcast_ref::() { @@ -73,6 +80,22 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool { || msg_lower.contains("invalid")) } +/// Check if an error is a tool schema validation failure (e.g. Groq returning +/// "tool call validation failed: attempted to call tool '...' which was not in request"). +/// These errors should NOT be classified as non-retryable because the provider's +/// built-in fallback logic (`compatible.rs::is_native_tool_schema_unsupported`) +/// can recover by switching to prompt-guided tool instructions. +pub fn is_tool_schema_error(err: &anyhow::Error) -> bool { + let lower = err.to_string().to_lowercase(); + let hints = [ + "tool call validation failed", + "was not in request", + "not found in tool list", + "invalid_tool_call", + ]; + hints.iter().any(|hint| lower.contains(hint)) +} + fn is_context_window_exceeded(err: &anyhow::Error) -> bool { let lower = err.to_string().to_lowercase(); let hints = [ @@ -2189,4 +2212,55 @@ mod tests { // Should have been called twice: once with full messages, once with truncated assert_eq!(calls.load(Ordering::SeqCst), 2); } + + // โ”€โ”€ Tool schema error detection tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn tool_schema_error_detects_groq_validation_failure() { + let msg = r#"Groq API error (400 Bad Request): {"error":{"message":"tool call validation failed: attempted to call tool 'memory_recall' which was not in request"}}"#; + let err = anyhow::anyhow!("{}", msg); + assert!(is_tool_schema_error(&err)); + } + + #[test] + fn tool_schema_error_detects_not_in_request() { + let err = anyhow::anyhow!("tool 'search' was not in request"); + assert!(is_tool_schema_error(&err)); + } + + #[test] + fn tool_schema_error_detects_not_found_in_tool_list() { + let err = anyhow::anyhow!("function 'foo' not found in tool list"); + assert!(is_tool_schema_error(&err)); + } + + #[test] + fn tool_schema_error_detects_invalid_tool_call() { + let err = anyhow::anyhow!("invalid_tool_call: no matching function"); + assert!(is_tool_schema_error(&err)); + } + + #[test] + fn tool_schema_error_ignores_unrelated_errors() { + let err = anyhow::anyhow!("invalid api key"); + assert!(!is_tool_schema_error(&err)); + + let err = anyhow::anyhow!("model not found"); + assert!(!is_tool_schema_error(&err)); + } + + #[test] + fn non_retryable_returns_false_for_tool_schema_400() { + // A 400 error with tool schema validation text should NOT be non-retryable. + let msg = "400 Bad Request: tool call validation failed: attempted to call tool 'x' which was not in request"; + let err = anyhow::anyhow!("{}", msg); + assert!(!is_non_retryable(&err)); + } + + #[test] + fn non_retryable_returns_true_for_other_400_errors() { + // A regular 400 error (e.g. invalid API key) should still be non-retryable. + let err = anyhow::anyhow!("400 Bad Request: invalid api key provided"); + assert!(is_non_retryable(&err)); + } } diff --git a/src/security/policy.rs b/src/security/policy.rs index 72fe16bce..3c26d76f8 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -234,6 +234,26 @@ fn expand_user_path(path: &str) -> PathBuf { PathBuf::from(path) } +fn rootless_path(path: &Path) -> Option { + let mut relative = PathBuf::new(); + + for component in path.components() { + match component { + std::path::Component::Prefix(_) + | std::path::Component::RootDir + | std::path::Component::CurDir => {} + std::path::Component::ParentDir => return None, + std::path::Component::Normal(part) => relative.push(part), + } + } + + if relative.as_os_str().is_empty() { + None + } else { + Some(relative) + } +} + // โ”€โ”€ Shell Command Parsing Utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // These helpers implement a minimal quote-aware shell lexer. They exist // because security validation must reason about the *structure* of a @@ -1173,6 +1193,44 @@ impl SecurityPolicy { false } + fn runtime_config_dir(&self) -> Option { + let parent = self.workspace_dir.parent()?; + Some( + parent + .canonicalize() + .unwrap_or_else(|_| parent.to_path_buf()), + ) + } + + pub fn is_runtime_config_path(&self, resolved: &Path) -> bool { + let Some(config_dir) = self.runtime_config_dir() else { + return false; + }; + if !resolved.starts_with(&config_dir) { + return false; + } + if resolved.parent() != Some(config_dir.as_path()) { + return false; + } + + let Some(file_name) = resolved.file_name().and_then(|value| value.to_str()) else { + return false; + }; + + file_name == "config.toml" + || file_name == "config.toml.bak" + || file_name == "active_workspace.toml" + || file_name.starts_with(".config.toml.tmp-") + || file_name.starts_with(".active_workspace.toml.tmp-") + } + + pub fn runtime_config_violation_message(&self, resolved: &Path) -> String { + format!( + "Refusing to modify ZeroClaw runtime config/state file: {}. Use dedicated config tools or edit it manually outside the agent loop.", + resolved.display() + ) + } + pub fn resolved_path_violation_message(&self, resolved: &Path) -> String { let guidance = if self.allowed_roots.is_empty() { "Add the directory to [autonomy].allowed_roots (for example: allowed_roots = [\"/absolute/path\"]), or move the file into the workspace." @@ -1245,6 +1303,16 @@ impl SecurityPolicy { let expanded = expand_user_path(path); if expanded.is_absolute() { expanded + } else if let Some(workspace_hint) = rootless_path(&self.workspace_dir) { + if let Ok(stripped) = expanded.strip_prefix(&workspace_hint) { + if stripped.as_os_str().is_empty() { + self.workspace_dir.clone() + } else { + self.workspace_dir.join(stripped) + } + } else { + self.workspace_dir.join(expanded) + } } else { self.workspace_dir.join(expanded) } @@ -2720,6 +2788,19 @@ mod tests { assert_eq!(resolved, PathBuf::from("/workspace/relative/path.txt")); } + #[test] + fn resolve_tool_path_normalizes_workspace_prefixed_relative_paths() { + let p = SecurityPolicy { + workspace_dir: PathBuf::from("/zeroclaw-data/workspace"), + ..SecurityPolicy::default() + }; + let resolved = p.resolve_tool_path("zeroclaw-data/workspace/scripts/daily.py"); + assert_eq!( + resolved, + PathBuf::from("/zeroclaw-data/workspace/scripts/daily.py") + ); + } + #[test] fn is_under_allowed_root_matches_allowed_roots() { let p = SecurityPolicy { @@ -2744,4 +2825,33 @@ mod tests { }; assert!(!p.is_under_allowed_root("/any/path")); } + + #[test] + fn runtime_config_paths_are_protected() { + let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace"); + let policy = SecurityPolicy { + workspace_dir: workspace.clone(), + ..SecurityPolicy::default() + }; + let config_dir = workspace.parent().unwrap(); + + assert!(policy.is_runtime_config_path(&config_dir.join("config.toml"))); + assert!(policy.is_runtime_config_path(&config_dir.join("config.toml.bak"))); + assert!(policy.is_runtime_config_path(&config_dir.join(".config.toml.tmp-1234"))); + assert!(policy.is_runtime_config_path(&config_dir.join("active_workspace.toml"))); + assert!(policy.is_runtime_config_path(&config_dir.join(".active_workspace.toml.tmp-1234"))); + } + + #[test] + fn workspace_files_are_not_runtime_config_paths() { + let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace"); + let policy = SecurityPolicy { + workspace_dir: workspace.clone(), + ..SecurityPolicy::default() + }; + let nested_dir = workspace.join("notes"); + + assert!(!policy.is_runtime_config_path(&workspace.join("notes.txt"))); + assert!(!policy.is_runtime_config_path(&nested_dir.join("config.toml"))); + } } diff --git a/src/skills/audit.rs b/src/skills/audit.rs index 64fd9b2b0..15464fd65 100644 --- a/src/skills/audit.rs +++ b/src/skills/audit.rs @@ -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 { @@ -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(); diff --git a/src/skills/mod.rs b/src/skills/mod.rs index ff8f902e2..9d1042496 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -97,6 +97,15 @@ pub fn load_skills_with_config(workspace_dir: &Path, config: &crate::config::Con ) } +/// Load skills using explicit open-skills settings. +pub fn load_skills_with_open_skills_settings( + workspace_dir: &Path, + open_skills_enabled: bool, + open_skills_dir: Option<&str>, +) -> Vec { + load_skills_with_open_skills_config(workspace_dir, Some(open_skills_enabled), open_skills_dir) +} + fn load_skills_with_open_skills_config( workspace_dir: &Path, config_open_skills_enabled: Option, @@ -674,7 +683,8 @@ pub fn skills_to_prompt_with_mode( crate::config::SkillsPromptInjectionMode::Compact => String::from( "## Available Skills\n\n\ Skill summaries are preloaded below to keep context compact.\n\ - Skill instructions are loaded on demand: read the skill file in `location` only when needed.\n\n\ + Skill instructions are loaded on demand: call `read_skill(name)` with the skill's `` when you need the full skill file.\n\ + The `location` field is included for reference.\n\n\ \n", ), }; @@ -1267,6 +1277,7 @@ command = "echo hello" assert!(prompt.contains("test")); assert!(prompt.contains("skills/test/SKILL.md")); assert!(prompt.contains("loaded on demand")); + assert!(prompt.contains("read_skill(name)")); assert!(!prompt.contains("")); assert!(!prompt.contains("Do the thing.")); assert!(!prompt.contains("")); diff --git a/src/tools/cron_add.rs b/src/tools/cron_add.rs index bf3c7fde0..32bd0a9d8 100644 --- a/src/tools/cron_add.rs +++ b/src/tools/cron_add.rs @@ -130,6 +130,11 @@ impl Tool for CronAddTool { "type": "string", "description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'" }, + "allowed_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional allowlist of tool names for agent jobs. When omitted, all tools remain available." + }, "delivery": { "type": "object", "description": "Optional delivery config to send job output to a channel after each run. When provided, all three of mode, channel, and to are expected.", @@ -288,6 +293,19 @@ impl Tool for CronAddTool { .get("model") .and_then(serde_json::Value::as_str) .map(str::to_string); + let allowed_tools = match args.get("allowed_tools") { + Some(v) => match serde_json::from_value::>(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::(v.clone()) { @@ -316,6 +334,7 @@ impl Tool for CronAddTool { model, delivery, delete_after_run, + allowed_tools, ) } }; @@ -329,7 +348,8 @@ impl Tool for CronAddTool { "job_type": job.job_type, "schedule": job.schedule, "next_run": job.next_run, - "enabled": job.enabled + "enabled": job.enabled, + "allowed_tools": job.allowed_tools }))?, error: None, }), @@ -612,6 +632,32 @@ mod tests { .contains("Missing 'prompt'")); } + #[tokio::test] + async fn agent_job_persists_allowed_tools() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp).await; + let tool = CronAddTool::new(cfg.clone(), test_security(&cfg)); + + let result = tool + .execute(json!({ + "schedule": { "kind": "cron", "expr": "*/5 * * * *" }, + "job_type": "agent", + "prompt": "check status", + "allowed_tools": ["file_read", "web_search"] + })) + .await + .unwrap(); + + assert!(result.success, "{:?}", result.error); + + let jobs = cron::list_jobs(&cfg).unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!( + jobs[0].allowed_tools, + Some(vec!["file_read".into(), "web_search".into()]) + ); + } + #[tokio::test] async fn delivery_schema_includes_matrix_channel() { let tmp = TempDir::new().unwrap(); diff --git a/src/tools/cron_update.rs b/src/tools/cron_update.rs index 664983766..1abe96b0e 100644 --- a/src/tools/cron_update.rs +++ b/src/tools/cron_update.rs @@ -89,6 +89,11 @@ impl Tool for CronUpdateTool { "type": "string", "description": "Model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'" }, + "allowed_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional replacement allowlist of tool names for agent jobs" + }, "session_target": { "type": "string", "enum": ["isolated", "main"], @@ -403,6 +408,7 @@ mod tests { "command", "prompt", "model", + "allowed_tools", "session_target", "delete_after_run", "schedule", @@ -501,4 +507,40 @@ mod tests { .contains("Rate limit exceeded")); assert!(cron::get_job(&cfg, &job.id).unwrap().enabled); } + + #[tokio::test] + async fn updates_agent_allowed_tools() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp).await; + let job = cron::add_agent_job( + &cfg, + None, + crate::cron::Schedule::Cron { + expr: "*/5 * * * *".into(), + tz: None, + }, + "check status", + crate::cron::SessionTarget::Isolated, + None, + None, + false, + None, + ) + .unwrap(); + let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg)); + + let result = tool + .execute(json!({ + "job_id": job.id, + "patch": { "allowed_tools": ["file_read", "web_search"] } + })) + .await + .unwrap(); + + assert!(result.success, "{:?}", result.error); + assert_eq!( + cron::get_job(&cfg, &job.id).unwrap().allowed_tools, + Some(vec!["file_read".into(), "web_search".into()]) + ); + } } diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index 30c4aa0eb..8363209bb 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -418,6 +418,7 @@ impl DelegateTool { true, None, "delegate", + None, &self.multimodal_config, agent_config.max_iterations, None, diff --git a/src/tools/file_edit.rs b/src/tools/file_edit.rs index 441d95eeb..63a7fe160 100644 --- a/src/tools/file_edit.rs +++ b/src/tools/file_edit.rs @@ -147,6 +147,17 @@ impl Tool for FileEditTool { let resolved_target = resolved_parent.join(file_name); + if self.security.is_runtime_config_path(&resolved_target) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + self.security + .runtime_config_violation_message(&resolved_target), + ), + }); + } + // โ”€โ”€ 7. Symlink check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { if meta.file_type().is_symlink() { @@ -495,6 +506,42 @@ mod tests { assert!(result.error.as_ref().unwrap().contains("not allowed")); } + #[tokio::test] + async fn file_edit_normalizes_workspace_prefixed_relative_path() { + let root = std::env::temp_dir().join("zeroclaw_test_file_edit_workspace_prefixed"); + let workspace = root.join("workspace"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(workspace.join("nested")) + .await + .unwrap(); + tokio::fs::write(workspace.join("nested/target.txt"), "hello world") + .await + .unwrap(); + + let tool = FileEditTool::new(test_security(workspace.clone())); + let workspace_prefixed = workspace + .strip_prefix(std::path::Path::new("/")) + .unwrap() + .join("nested/target.txt"); + let result = tool + .execute(json!({ + "path": workspace_prefixed.to_string_lossy(), + "old_string": "world", + "new_string": "zeroclaw" + })) + .await + .unwrap(); + + assert!(result.success); + let content = tokio::fs::read_to_string(workspace.join("nested/target.txt")) + .await + .unwrap(); + assert_eq!(content, "hello zeroclaw"); + assert!(!workspace.join(workspace_prefixed).exists()); + + let _ = tokio::fs::remove_dir_all(&root).await; + } + #[cfg(unix)] #[tokio::test] async fn file_edit_blocks_symlink_escape() { @@ -726,4 +773,42 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; } + + #[tokio::test] + async fn file_edit_blocks_runtime_config_path() { + let root = std::env::temp_dir().join("zeroclaw_test_file_edit_runtime_config"); + let workspace = root.join("workspace"); + let config_path = root.join("config.toml"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::write(&config_path, "always_ask = [\"cron_add\"]") + .await + .unwrap(); + + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace.clone(), + workspace_only: false, + allowed_roots: vec![root.clone()], + forbidden_paths: vec![], + ..SecurityPolicy::default() + }); + let tool = FileEditTool::new(security); + let result = tool + .execute(json!({ + "path": config_path.to_string_lossy(), + "old_string": "always_ask", + "new_string": "auto_approve" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .unwrap_or_default() + .contains("runtime config/state file")); + + let _ = tokio::fs::remove_dir_all(&root).await; + } } diff --git a/src/tools/file_write.rs b/src/tools/file_write.rs index 82a0b3041..fd2e0a37c 100644 --- a/src/tools/file_write.rs +++ b/src/tools/file_write.rs @@ -124,6 +124,17 @@ impl Tool for FileWriteTool { let resolved_target = resolved_parent.join(file_name); + if self.security.is_runtime_config_path(&resolved_target) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + self.security + .runtime_config_violation_message(&resolved_target), + ), + }); + } + // If the target already exists and is a symlink, refuse to follow it if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { if meta.file_type().is_symlink() { @@ -247,6 +258,36 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; } + #[tokio::test] + async fn file_write_normalizes_workspace_prefixed_relative_path() { + let root = std::env::temp_dir().join("zeroclaw_test_file_write_workspace_prefixed"); + let workspace = root.join("workspace"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + + let tool = FileWriteTool::new(test_security(workspace.clone())); + let workspace_prefixed = workspace + .strip_prefix(std::path::Path::new("/")) + .unwrap() + .join("nested/out.txt"); + let result = tool + .execute(json!({ + "path": workspace_prefixed.to_string_lossy(), + "content": "written!" + })) + .await + .unwrap(); + assert!(result.success); + + let content = tokio::fs::read_to_string(workspace.join("nested/out.txt")) + .await + .unwrap(); + assert_eq!(content, "written!"); + assert!(!workspace.join(workspace_prefixed).exists()); + + let _ = tokio::fs::remove_dir_all(&root).await; + } + #[tokio::test] async fn file_write_overwrites_existing() { let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite"); @@ -499,4 +540,38 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; } + + #[tokio::test] + async fn file_write_blocks_runtime_config_path() { + let root = std::env::temp_dir().join("zeroclaw_test_file_write_runtime_config"); + let workspace = root.join("workspace"); + let config_path = root.join("config.toml"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace.clone(), + workspace_only: false, + allowed_roots: vec![root.clone()], + forbidden_paths: vec![], + ..SecurityPolicy::default() + }); + let tool = FileWriteTool::new(security); + let result = tool + .execute(json!({ + "path": config_path.to_string_lossy(), + "content": "auto_approve = [\"cron_add\"]" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .unwrap_or_default() + .contains("runtime config/state file")); + + let _ = tokio::fs::remove_dir_all(&root).await; + } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index d6880aac4..6f63c756d 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -66,6 +66,7 @@ pub mod pdf_read; pub mod project_intel; pub mod proxy_config; pub mod pushover; +pub mod read_skill; pub mod report_templates; pub mod schedule; pub mod schema; @@ -128,6 +129,7 @@ pub use pdf_read::PdfReadTool; pub use project_intel::ProjectIntelTool; pub use proxy_config::ProxyConfigTool; pub use pushover::PushoverTool; +pub use read_skill::ReadSkillTool; pub use schedule::ScheduleTool; #[allow(unused_imports)] pub use schema::{CleaningStrategy, SchemaCleanr}; @@ -146,7 +148,7 @@ pub use workspace_tool::WorkspaceTool; use crate::config::{Config, DelegateAgentConfig}; use crate::memory::Memory; use crate::runtime::{NativeRuntime, RuntimeAdapter}; -use crate::security::SecurityPolicy; +use crate::security::{create_sandbox, SecurityPolicy}; use async_trait::async_trait; use parking_lot::RwLock; use std::collections::HashMap; @@ -283,8 +285,13 @@ pub fn all_tools_with_runtime( root_config: &crate::config::Config, ) -> (Vec>, Option) { let has_shell_access = runtime.has_shell_access(); + let sandbox = create_sandbox(&root_config.security); let mut tool_arcs: Vec> = 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())), @@ -316,6 +323,17 @@ pub fn all_tools_with_runtime( )), ]; + if matches!( + root_config.skills.prompt_injection_mode, + crate::config::SkillsPromptInjectionMode::Compact + ) { + tool_arcs.push(Arc::new(ReadSkillTool::new( + workspace_dir.to_path_buf(), + root_config.skills.open_skills_enabled, + root_config.skills.open_skills_dir.clone(), + ))); + } + if browser_config.enabled { // Add legacy browser_open tool for simple URL opening tool_arcs.push(Arc::new(BrowserOpenTool::new( @@ -972,4 +990,72 @@ mod tests { let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(!names.contains(&"delegate")); } + + #[test] + fn all_tools_includes_read_skill_in_compact_mode() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem_cfg = MemoryConfig { + backend: "markdown".into(), + ..MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap()); + + let browser = BrowserConfig::default(); + let http = crate::config::HttpRequestConfig::default(); + let mut cfg = test_config(&tmp); + cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Compact; + + let (tools, _) = all_tools( + Arc::new(cfg.clone()), + &security, + mem, + None, + None, + &browser, + &http, + &crate::config::WebFetchConfig::default(), + tmp.path(), + &HashMap::new(), + None, + &cfg, + ); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(names.contains(&"read_skill")); + } + + #[test] + fn all_tools_excludes_read_skill_in_full_mode() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem_cfg = MemoryConfig { + backend: "markdown".into(), + ..MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap()); + + let browser = BrowserConfig::default(); + let http = crate::config::HttpRequestConfig::default(); + let mut cfg = test_config(&tmp); + cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Full; + + let (tools, _) = all_tools( + Arc::new(cfg.clone()), + &security, + mem, + None, + None, + &browser, + &http, + &crate::config::WebFetchConfig::default(), + tmp.path(), + &HashMap::new(), + None, + &cfg, + ); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(!names.contains(&"read_skill")); + } } diff --git a/src/tools/read_skill.rs b/src/tools/read_skill.rs new file mode 100644 index 000000000..41e7a871b --- /dev/null +++ b/src/tools/read_skill.rs @@ -0,0 +1,187 @@ +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +/// Compact-mode helper for loading a skill's source file on demand. +pub struct ReadSkillTool { + workspace_dir: PathBuf, + open_skills_enabled: bool, + open_skills_dir: Option, +} + +impl ReadSkillTool { + pub fn new( + workspace_dir: PathBuf, + open_skills_enabled: bool, + open_skills_dir: Option, + ) -> Self { + Self { + workspace_dir, + open_skills_enabled, + open_skills_dir, + } + } +} + +#[async_trait] +impl Tool for ReadSkillTool { + fn name(&self) -> &str { + "read_skill" + } + + fn description(&self) -> &str { + "Read the full source file for an available skill by name. Use this in compact skills mode when you need the complete skill instructions without remembering file paths." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The skill name exactly as listed in ." + } + }, + "required": ["name"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let requested = args + .get("name") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?; + + let skills = crate::skills::load_skills_with_open_skills_settings( + &self.workspace_dir, + self.open_skills_enabled, + self.open_skills_dir.as_deref(), + ); + + let Some(skill) = skills + .iter() + .find(|skill| skill.name.eq_ignore_ascii_case(requested)) + else { + let mut names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect(); + names.sort_unstable(); + let available = if names.is_empty() { + "none".to_string() + } else { + names.join(", ") + }; + + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Unknown skill '{requested}'. Available skills: {available}" + )), + }); + }; + + let Some(location) = skill.location.as_ref() else { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Skill '{}' has no readable source location.", + skill.name + )), + }); + }; + + match tokio::fs::read_to_string(location).await { + Ok(output) => Ok(ToolResult { + success: true, + output, + error: None, + }), + Err(err) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Failed to read skill '{}' from {}: {err}", + skill.name, + location.display() + )), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn make_tool(tmp: &TempDir) -> ReadSkillTool { + ReadSkillTool::new(tmp.path().join("workspace"), false, None) + } + + #[tokio::test] + async fn reads_markdown_skill_by_name() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path().join("workspace/skills/weather"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "# Weather\n\nUse this skill for forecast lookups.\n", + ) + .unwrap(); + + let result = make_tool(&tmp) + .execute(json!({ "name": "weather" })) + .await + .unwrap(); + + assert!(result.success); + assert!(result.output.contains("# Weather")); + assert!(result.output.contains("forecast lookups")); + } + + #[tokio::test] + async fn reads_toml_skill_manifest_by_name() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path().join("workspace/skills/deploy"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.toml"), + r#"[skill] +name = "deploy" +description = "Ship safely" +"#, + ) + .unwrap(); + + let result = make_tool(&tmp) + .execute(json!({ "name": "deploy" })) + .await + .unwrap(); + + assert!(result.success); + assert!(result.output.contains("[skill]")); + assert!(result.output.contains("Ship safely")); + } + + #[tokio::test] + async fn unknown_skill_lists_available_names() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path().join("workspace/skills/weather"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Weather\n").unwrap(); + + let result = make_tool(&tmp) + .execute(json!({ "name": "calendar" })) + .await + .unwrap(); + + assert!(!result.success); + assert_eq!( + result.error.as_deref(), + Some("Unknown skill 'calendar'. Available skills: weather") + ); + } +} diff --git a/src/tools/shell.rs b/src/tools/shell.rs index a03769a55..5867d907f 100644 --- a/src/tools/shell.rs +++ b/src/tools/shell.rs @@ -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, runtime: Arc, + sandbox: Arc, } impl ShellTool { pub fn new(security: Arc, runtime: Arc) -> Self { - Self { security, runtime } + Self { + security, + runtime, + sandbox: Arc::new(crate::security::NoopSandbox), + } + } + + pub fn new_with_sandbox( + security: Arc, + runtime: Arc, + sandbox: Arc, + ) -> 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 = 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::>(), + args_before + ); + } + + #[tokio::test] + async fn shell_executes_with_sandbox() { + use crate::security::NoopSandbox; + + let sandbox: Arc = 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")); + } } diff --git a/tests/component/config_persistence.rs b/tests/component/config_persistence.rs index 44545b75c..75c1c136c 100644 --- a/tests/component/config_persistence.rs +++ b/tests/component/config_persistence.rs @@ -72,11 +72,11 @@ fn agent_config_default_tool_dispatcher() { } #[test] -fn agent_config_default_compact_context_off() { +fn agent_config_default_compact_context_on() { let agent = AgentConfig::default(); assert!( - !agent.compact_context, - "compact_context should default to false" + agent.compact_context, + "compact_context should default to true" ); } @@ -204,7 +204,7 @@ default_temperature = 0.7 // Agent config should use defaults assert_eq!(parsed.agent.max_tool_iterations, 10); assert_eq!(parsed.agent.max_history_messages, 50); - assert!(!parsed.agent.compact_context); + assert!(parsed.agent.compact_context); } #[test] diff --git a/web/dist/logo.png b/web/dist/logo.png new file mode 100644 index 000000000..a76068f23 Binary files /dev/null and b/web/dist/logo.png differ diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 000000000..a76068f23 Binary files /dev/null and b/web/public/logo.png differ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 352af676b..791e7daf9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { + return apiFetch('/api/cron/settings'); +} + +export function patchCronSettings( + patch: Partial, +): Promise { + return apiFetch('/api/cron/settings', { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + // --------------------------------------------------------------------------- // Integrations // --------------------------------------------------------------------------- diff --git a/web/src/pages/Cron.tsx b/web/src/pages/Cron.tsx index 8a09d79f3..a12a501d5 100644 --- a/web/src/pages/Cron.tsx +++ b/web/src/pages/Cron.tsx @@ -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(null); const [expandedJob, setExpandedJob] = useState(null); + const [settings, setSettings] = useState(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() { + {/* Catch-up toggle */} + {settings && ( +
+
+ + Catch up missed jobs on startup + +

+ Run all overdue jobs when ZeroClaw starts after downtime +

+
+ +
+ )} + {/* Add Job Form Modal */} {showForm && (