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 ๐ฆ
@@ -18,6 +18,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -16,6 +16,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -16,6 +16,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ๏ผๆฅๆฌ่ช๏ผ
@@ -15,6 +15,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
diff --git a/README.md b/README.md
index 47ceb360f..f2172211d 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
ZeroClaw ๐ฆ
@@ -16,6 +16,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ๏ผะ ัััะบะธะน๏ผ
@@ -15,6 +15,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -19,6 +19,7 @@
+
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 ๐ฆ
@@ -16,6 +16,7 @@
+
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 ๐ฆ๏ผ็ฎไฝไธญๆ๏ผ
@@ -15,6 +15,7 @@
+
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