Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ When no config files exist, the daemon defaults to:

```
netclaw chat Interactive TUI chat session
netclaw -p "prompt" Headless single-prompt mode
netclaw chat -p "prompt" Headless single-prompt mode
netclaw daemon start Start the daemon as a background process
netclaw daemon stop Gracefully stop the daemon (SIGTERM)
netclaw daemon status Show daemon PID and uptime
Expand Down
4 changes: 2 additions & 2 deletions docs/runbooks/tool-approval-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Existing configs without `ApprovalPolicy` are unaffected — all tools remain in

### Headless mode

Headless mode (`netclaw -p "prompt"`) cannot ask for approval — there is no
Headless mode (`netclaw chat -p "prompt"`) cannot ask for approval — there is no
interactive user. Approval-gated tools are **automatically denied** in headless
mode. If you need unrestricted shell in headless scripts, explicitly set
`shell_execute` to `Auto`:
Expand Down Expand Up @@ -186,7 +186,7 @@ Custom patterns are added to the defaults — they don't replace them.
| Slack | Yes | Text prompt with ABC options (Block Kit buttons planned) |
| TUI (`netclaw chat`) | Yes | Inline prompt |
| SignalR (web client) | Yes | Inline prompt |
| Headless (`netclaw -p`) | No — auto-deny | N/A |
| Headless (`netclaw chat -p`) | No — auto-deny | N/A |
| Reminders | No — auto-deny | N/A |
| Webhooks | No — auto-deny | N/A |

Expand Down
8 changes: 4 additions & 4 deletions evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ reduces `--network host` to bridge mode).

## What It Tests

The suite runs prompts via `netclaw -p` against the eval container and
The suite runs prompts via `netclaw chat -p` against the eval container and
verifies both **stdout output** (tool calls, text content) and **daemon
log patterns** (skill loading, memory recall, checkpoint formation).

Expand All @@ -75,7 +75,7 @@ phrasing — not just one magic prompt.

### Assertion Types

- **stdout assertions** — check `netclaw -p` output for tool calls
- **stdout assertions** — check `netclaw chat -p` output for tool calls
(`[tool:call]`), text content, or absence of hallucinated content.
- **daemon log assertions** — check the daemon's file log (tailed from
`$EVAL_HOME/logs/daemon-$(date +%F).log`) for structured patterns like
Expand Down Expand Up @@ -214,8 +214,8 @@ skips persistence.
depends on inheriting the host's DNS resolver. Docker Desktop
(macOS/Windows) degrades `--network host` to bridge mode; set
`NETCLAW_EVAL_PROVIDER_ENDPOINT` to a reachable IP/hostname instead.
- **Single-turn only**: `netclaw -p` is one prompt per session. Multi-turn
conversation evals are deferred.
- **Multi-turn support**: `netclaw chat -p --resume <id>` enables multi-turn
scripted conversations against a named session.
- **Identity is borrowed from host**: the container does not
self-bootstrap identity. CI will need a committed fixture under
`evals/fixtures/identity/` — tracked as a follow-up.
Expand Down
192 changes: 192 additions & 0 deletions evals/quick-multi-turn-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
# Quick multi-turn verification test for chat -p --resume.
# Builds from source, starts an isolated container, runs a 2-turn conversation,
# and verifies context carryover + JSON output.
#
# Usage:
# NETCLAW_EVAL_PROVIDER_TYPE=openai-compatible \
# NETCLAW_EVAL_PROVIDER_ENDPOINT=https://llm.example.com \
# NETCLAW_EVAL_MODEL_ID=my-model \
# ./evals/quick-multi-turn-test.sh
#
# Set NETCLAW_EVAL_NO_BUILD=1 to skip build if image/binaries already exist.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

EVAL_PORT="${NETCLAW_EVAL_PORT:-5399}"
CONTAINER_NAME="netclaw-multi-turn-test-$$"
IMAGE="${NETCLAW_IMAGE:-ghcr.io/aaronontheweb/netclawd:dev}"
NETCLAW_BIN="$REPO_ROOT/publish/cli/netclaw"
NO_BUILD="${NETCLAW_EVAL_NO_BUILD:-0}"
PROMPT_TIMEOUT=90

# Provider config — required
PROVIDER_TYPE="${NETCLAW_EVAL_PROVIDER_TYPE:-}"
PROVIDER_ENDPOINT="${NETCLAW_EVAL_PROVIDER_ENDPOINT:-}"
MODEL_ID="${NETCLAW_EVAL_MODEL_ID:-}"

if [[ -z "$PROVIDER_TYPE" || -z "$PROVIDER_ENDPOINT" || -z "$MODEL_ID" ]]; then
echo "ERROR: Provider configuration required." >&2
echo " Set NETCLAW_EVAL_PROVIDER_TYPE, NETCLAW_EVAL_PROVIDER_ENDPOINT, NETCLAW_EVAL_MODEL_ID" >&2
exit 1
fi

# ─── Cleanup ──────────────────────────────────────────────────────────────────

EVAL_HOME=""
cleanup() {
echo ""
echo "→ Cleaning up..."
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
if [[ -n "$EVAL_HOME" && -d "$EVAL_HOME" ]]; then
rm -rf "$EVAL_HOME" 2>/dev/null || \
docker run --rm -v "$EVAL_HOME:/target" alpine:latest \
sh -c 'rm -rf /target/..?* /target/.[!.]* /target/*' >/dev/null 2>&1 || true
rmdir "$EVAL_HOME" 2>/dev/null || true
fi
}
trap cleanup EXIT

# ─── Build ────────────────────────────────────────────────────────────────────

if [[ "$NO_BUILD" != "1" ]]; then
echo "→ Building from source..."
"$REPO_ROOT/scripts/docker/build-image.sh"
else
echo "→ Skipping build (NO_BUILD=1)"
fi

if [[ ! -x "$NETCLAW_BIN" ]]; then
echo "ERROR: CLI binary not found at $NETCLAW_BIN" >&2
exit 1
fi

# ─── Start Container ─────────────────────────────────────────────────────────

EVAL_HOME=$(mktemp -d -t netclaw-mt-test-XXXXXX)
mkdir -p "$EVAL_HOME/identity" "$EVAL_HOME/logs"

if [[ -d "$HOME/.netclaw/identity" ]]; then
cp -r "$HOME/.netclaw/identity/." "$EVAL_HOME/identity/"
else
echo "WARN: No identity at ~/.netclaw/identity — container will use defaults"
fi

echo "→ Starting eval container on port $EVAL_PORT..."
docker run -d --rm \
--name "$CONTAINER_NAME" \
--network host \
-v "$EVAL_HOME/identity:/root/.netclaw/identity" \
-v "$EVAL_HOME/logs:/root/.netclaw/logs" \
-e "NETCLAW_Daemon__Host=127.0.0.1" \
-e "NETCLAW_Daemon__Port=$EVAL_PORT" \
-e "NETCLAW_Providers__eval__Type=$PROVIDER_TYPE" \
-e "NETCLAW_Providers__eval__Endpoint=$PROVIDER_ENDPOINT" \
-e "NETCLAW_Models__Main__Provider=eval" \
-e "NETCLAW_Models__Main__ModelId=$MODEL_ID" \
-e "NETCLAW_Models__Fallback__Provider=eval" \
-e "NETCLAW_Models__Fallback__ModelId=$MODEL_ID" \
-e "NETCLAW_Models__Compaction__Provider=eval" \
-e "NETCLAW_Models__Compaction__ModelId=$MODEL_ID" \
"$IMAGE" >/dev/null

# Wait for healthy
echo "→ Waiting for daemon health..."
deadline=$((SECONDS + 60))
while (( SECONDS < deadline )); do
if curl -fsS "http://127.0.0.1:$EVAL_PORT/api/health/ready" >/dev/null 2>&1; then
echo "→ Daemon ready"
break
fi
running=$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo "false")
if [[ "$running" != "true" ]]; then
echo "ERROR: Container exited during startup" >&2
docker logs "$CONTAINER_NAME" 2>&1 || true
exit 2
fi
sleep 1
done

if ! curl -fsS "http://127.0.0.1:$EVAL_PORT/api/health/ready" >/dev/null 2>&1; then
echo "ERROR: Daemon did not become healthy within 60s" >&2
docker logs "$CONTAINER_NAME" 2>&1 | tail -30 || true
exit 2
fi

# ─── Tests ────────────────────────────────────────────────────────────────────

PASSED=0
FAILED=0
SESSION_ID="test/multi-turn-$$"

run_headless() {
local extra_args=("$@")
NETCLAW_DAEMON_ENDPOINT="http://127.0.0.1:$EVAL_PORT" \
NETCLAW_HOME="$EVAL_HOME" \
timeout "$PROMPT_TIMEOUT" "$NETCLAW_BIN" "${extra_args[@]}" 2>&1
}

assert_contains() {
local label="$1" output="$2" pattern="$3"
if echo "$output" | grep -qi "$pattern"; then
echo " ✓ $label"
PASSED=$((PASSED + 1))
else
echo " ✗ $label (expected '$pattern' in output)"
echo " Output: $(echo "$output" | head -3)"
FAILED=$((FAILED + 1))
fi
}

assert_json_field() {
local label="$1" output="$2" field="$3"
if echo "$output" | python3 -c "import json,sys; d=json.load(sys.stdin); assert '$field' in d" 2>/dev/null; then
echo " ✓ $label"
PASSED=$((PASSED + 1))
else
echo " ✗ $label (field '$field' not found in JSON)"
echo " Output: $(echo "$output" | head -1)"
FAILED=$((FAILED + 1))
fi
}

echo ""
echo "=== Multi-Turn Resume Test ==="
echo "Session: $SESSION_ID"
echo ""

# Test 1: Create a named session with a memorable fact
echo "Turn 1: Establishing context..."
turn1=$(run_headless chat -p --resume "$SESSION_ID" "My favorite color is chartreuse. Just acknowledge that and nothing else.")
echo " Response: $(echo "$turn1" | head -1)"
assert_contains "Turn 1 produced output" "$turn1" "."

# Test 2: Resume the session and ask about the fact
echo "Turn 2: Verifying context carryover..."
turn2=$(run_headless chat -p --resume "$SESSION_ID" "What is my favorite color? Answer in one word.")
echo " Response: $(echo "$turn2" | head -1)"
assert_contains "Turn 2 references chartreuse" "$turn2" "chartreuse"

# Test 3: JSON output mode
echo "JSON output test..."
json_out=$(run_headless chat -p --json "Say hello in one word.")
echo " Output: $(echo "$json_out" | head -1)"
assert_json_field "JSON has sessionId" "$json_out" "sessionId"
assert_json_field "JSON has response" "$json_out" "response"

# Test 4: JSON output with --resume
echo "JSON + resume test..."
json_resume=$(run_headless chat -p --json --resume "test/json-resume-$$" "Say goodbye in one word.")
echo " Output: $(echo "$json_resume" | head -1)"
assert_json_field "JSON+resume has sessionId" "$json_resume" "sessionId"

echo ""
echo "─────────────────────────────────────────────────"
echo "Results: $PASSED passed, $FAILED failed"
echo "─────────────────────────────────────────────────"

if [[ "$FAILED" -gt 0 ]]; then
exit 1
fi
53 changes: 44 additions & 9 deletions evals/run-evals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,37 @@
# NETCLAW_EVAL_COMPACTION_MODEL_ID
#
# Container + runtime:
# NETCLAW_IMAGE Image ref (default: ghcr.io/aaronontheweb/netclawd:latest)
# NETCLAW_IMAGE Image ref (default: ghcr.io/aaronontheweb/netclawd:dev — built locally)
# NETCLAW_EVAL_PORT Host-side port for the eval daemon (default 5299)
# NETCLAW_EVAL_CONTEXT_WINDOW Override model context window (future compaction evals)
#
# Build:
# NETCLAW_EVAL_NO_BUILD Set to 1 to skip `dotnet publish` + `docker build`
# (reuse existing ./publish output and image)
# NETCLAW_BIN Path to netclaw CLI (default: ./publish/cli/netclaw)
#
# Eval suite knobs:
# NETCLAW_EVAL_RUNS Runs per case (default: 5)
# NETCLAW_EVAL_THRESHOLD Pass threshold 0.0-1.0 (default: 0.80)
# NETCLAW_EVAL_TIMEOUT Per-prompt timeout in seconds (default: 60)
# NETCLAW_BIN Path to netclaw CLI (default: netclaw)
set -euo pipefail

# ─── Configuration ────────────────────────────────────────────────────────────

# Repo root — derived from this script's location (evals/ is one level deep).
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

RUNS="${NETCLAW_EVAL_RUNS:-5}"
THRESHOLD="${NETCLAW_EVAL_THRESHOLD:-0.80}"
PROMPT_TIMEOUT="${NETCLAW_EVAL_TIMEOUT:-60}"
NETCLAW_BIN="${NETCLAW_BIN:-netclaw}"
NETCLAW_IMAGE="${NETCLAW_IMAGE:-ghcr.io/aaronontheweb/netclawd:latest}"
EVAL_PORT="${NETCLAW_EVAL_PORT:-5299}"
EVAL_CONTAINER_NAME="netclaw-eval-$$"
NO_BUILD="${NETCLAW_EVAL_NO_BUILD:-0}"

# Image and CLI binary default to the locally-built artifacts. Evals should
# always test the current source tree, not a stale published image.
NETCLAW_IMAGE="${NETCLAW_IMAGE:-ghcr.io/aaronontheweb/netclawd:dev}"
NETCLAW_BIN="${NETCLAW_BIN:-$REPO_ROOT/publish/cli/netclaw}"

# Eval target — resolved by check_prerequisites after optional interactive prompt.
EVAL_PROVIDER_TYPE="${NETCLAW_EVAL_PROVIDER_TYPE:-}"
Expand Down Expand Up @@ -74,10 +85,8 @@ DAEMON_LOG_LINES_BEFORE=0
# ─── Prerequisites ────────────────────────────────────────────────────────────

check_prerequisites() {
if ! command -v "$NETCLAW_BIN" >/dev/null 2>&1; then
echo "ERROR: '$NETCLAW_BIN' not found in PATH" >&2
exit 1
fi
# NETCLAW_BIN existence is verified after build_local_image (the binary
# may not exist yet when the default points to ./publish/cli/netclaw).

if ! command -v timeout >/dev/null 2>&1; then
echo "ERROR: 'timeout' command not found (install coreutils)" >&2
Expand Down Expand Up @@ -211,6 +220,24 @@ force_rmrf() {
rmdir "$path" 2>/dev/null || true
}

# ─── Local Build ─────────────────────────────────────────────────────────────

build_local_image() {
if [[ "$NO_BUILD" == "1" ]]; then
echo "→ NETCLAW_EVAL_NO_BUILD=1 — skipping local build"
if [[ ! -x "$NETCLAW_BIN" ]]; then
echo "ERROR: NO_BUILD=1 but CLI binary not found at $NETCLAW_BIN" >&2
echo " Run without NO_BUILD or publish the CLI first." >&2
exit 1
fi
return 0
fi

echo "→ Building netclaw from source (image + CLI)..."
"$REPO_ROOT/scripts/docker/build-image.sh"
echo "→ Local build complete: $NETCLAW_IMAGE"
}

# ─── Eval Daemon Lifecycle ────────────────────────────────────────────────────

start_eval_daemon() {
Expand Down Expand Up @@ -381,7 +408,7 @@ run_prompt() {
# daemon and keep CLI-side path resolution inside the eval sandbox.
NETCLAW_DAEMON_ENDPOINT="http://127.0.0.1:$EVAL_PORT" \
NETCLAW_HOME="$EVAL_HOME" \
timeout "$PROMPT_TIMEOUT" "$NETCLAW_BIN" -p "$prompt" \
timeout "$PROMPT_TIMEOUT" "$NETCLAW_BIN" chat -p "$prompt" \
> "$STDOUT_FILE" 2>&1 || true

# Brief pause for daemon log flush
Expand Down Expand Up @@ -728,6 +755,14 @@ run_all() {

main() {
check_prerequisites
build_local_image

# Verify CLI binary exists (may have just been built by build_local_image).
if [[ ! -x "$NETCLAW_BIN" ]]; then
echo "ERROR: CLI binary not found at '$NETCLAW_BIN'" >&2
exit 1
fi

start_eval_daemon
init_db

Expand Down
31 changes: 30 additions & 1 deletion scripts/smoke/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ echo "Waiting for daemon health endpoint..."
wait_for_health

echo "Sending a headless prompt to create a session..."
run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw -p "Say hello in one word" || true
run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw chat -p "Say hello in one word" || true

echo "Checking session catalog via REST API..."
sessions_output="$(run_sandbox_timed "$STEP_TIMEOUT_SECONDS" curl -fsS http://127.0.0.1:5199/api/sessions)"
Expand All @@ -274,6 +274,35 @@ if [[ "$resume_help" != *"--resume"* ]]; then
echo "Expected chat help to include --resume flag."
exit 1
fi
if [[ "$resume_help" != *"-p"* ]]; then
echo "Expected chat help to include -p flag."
exit 1
fi

# ── Multi-turn headless resume smoke test ──
# Validates that chat -p --resume creates, resumes, and maintains
# conversation state through the daemon's persistence layer.

MULTI_TURN_SESSION="smoke/multi-turn-$$"

echo "Testing multi-turn: Turn 1 (create named session)..."
run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw chat -p --resume "$MULTI_TURN_SESSION" "hello" || true

echo "Testing multi-turn: Turn 2 (resume and verify continuity)..."
turn2_output="$(run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw chat -p --resume "$MULTI_TURN_SESSION" "what was my first message?" || true)"
echo "$turn2_output"
if ! echo "$turn2_output" | grep -qi "hello"; then
echo "[WARN] Multi-turn continuity: response did not reference 'hello'."
echo "This may be a model quality issue, not a CLI bug. Continuing..."
fi

echo "Testing headless --json output..."
json_output="$(run_sandbox_timed "$STEP_TIMEOUT_SECONDS" netclaw chat -p --json "Say hello in one word" || true)"
echo "$json_output"
if [[ "$json_output" != *"sessionId"* ]]; then
echo "Expected --json output to include sessionId field."
exit 1
fi

# ── Stats smoke tests ──
# Verify the stats command returns data from the running daemon.
Expand Down
Loading
Loading