diff --git a/README.md b/README.md index d09490906..9f62f16de 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/runbooks/tool-approval-gates.md b/docs/runbooks/tool-approval-gates.md index 85e2c8e2c..9abea1028 100644 --- a/docs/runbooks/tool-approval-gates.md +++ b/docs/runbooks/tool-approval-gates.md @@ -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`: @@ -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 | diff --git a/evals/README.md b/evals/README.md index b54c820e2..4852f8476 100644 --- a/evals/README.md +++ b/evals/README.md @@ -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). @@ -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 @@ -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 ` 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. diff --git a/evals/quick-multi-turn-test.sh b/evals/quick-multi-turn-test.sh new file mode 100755 index 000000000..a62d32dbb --- /dev/null +++ b/evals/quick-multi-turn-test.sh @@ -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 diff --git a/evals/run-evals.sh b/evals/run-evals.sh index f80c3745c..3457743a8 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -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:-}" @@ -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 @@ -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() { @@ -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 @@ -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 diff --git a/scripts/smoke/check.sh b/scripts/smoke/check.sh index 9684f28d8..bb8b1ea3b 100755 --- a/scripts/smoke/check.sh +++ b/scripts/smoke/check.sh @@ -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)" @@ -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. diff --git a/src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs b/src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs index e2da39ff1..995ba53db 100644 --- a/src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs @@ -60,6 +60,8 @@ public void Parse_known_commands_returns_Known_with_mode(string command) [InlineData("bar")] [InlineData("unknown-command")] [InlineData("frobble")] + [InlineData("-p")] + [InlineData("--prompt")] public void Parse_unknown_commands_returns_Unknown_with_mode(string command) { var result = CliArgsParser.Parse([command]); @@ -67,36 +69,6 @@ public void Parse_unknown_commands_returns_Unknown_with_mode(string command) Assert.Equal(command, result.Mode); } - [Fact] - public void Parse_short_prompt_flag_with_arg_returns_Headless() - { - var result = CliArgsParser.Parse(["-p", "hello world"]); - Assert.Equal(CliParseKind.Headless, result.Kind); - Assert.Equal("hello world", result.HeadlessPrompt); - } - - [Fact] - public void Parse_long_prompt_flag_with_arg_returns_Headless() - { - var result = CliArgsParser.Parse(["--prompt", "some query"]); - Assert.Equal(CliParseKind.Headless, result.Kind); - Assert.Equal("some query", result.HeadlessPrompt); - } - - [Fact] - public void Parse_prompt_flag_without_arg_returns_MissingPromptArg() - { - var result = CliArgsParser.Parse(["-p"]); - Assert.Equal(CliParseKind.MissingPromptArg, result.Kind); - } - - [Fact] - public void Parse_long_prompt_flag_without_arg_returns_MissingPromptArg() - { - var result = CliArgsParser.Parse(["--prompt"]); - Assert.Equal(CliParseKind.MissingPromptArg, result.Kind); - } - /// /// Guard test: asserts that KnownCommands contains exactly the expected set. /// If a new command is added to CliArgsParser.KnownCommands, this test fails, diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index f09614e70..04e358458 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -37,7 +37,7 @@ public async Task ResumeSessionAsync_reattaches_to_existing_session_via_EnsureSe outputReceived.TrySetResult(); }); - var resumedSessionId = await client2.ResumeSessionAsync(originalSessionId, TestContext.Current.CancellationToken); + var resumedSessionId = await client2.ResumeSessionAsync(originalSessionId, Netclaw.Actors.Channels.ChannelType.Tui, TestContext.Current.CancellationToken); // EnsureSession should return the same session ID, not create a new one Assert.Equal(originalSessionId, resumedSessionId); diff --git a/src/Netclaw.Cli/CliArgsParser.cs b/src/Netclaw.Cli/CliArgsParser.cs index 32c56f709..703326978 100644 --- a/src/Netclaw.Cli/CliArgsParser.cs +++ b/src/Netclaw.Cli/CliArgsParser.cs @@ -5,18 +5,15 @@ public enum CliParseKind NoArgs, Help, Version, - Headless, Known, Unknown, - MissingPromptArg, } -public record CliParseResult(CliParseKind Kind, string? Mode = null, string? HeadlessPrompt = null) +public record CliParseResult(CliParseKind Kind, string? Mode = null) { public static readonly CliParseResult NoArgs = new(CliParseKind.NoArgs); public static readonly CliParseResult Help = new(CliParseKind.Help); public static readonly CliParseResult Version = new(CliParseKind.Version); - public static readonly CliParseResult MissingPromptArg = new(CliParseKind.MissingPromptArg); } /// Classifies top-level command-line arguments for the netclaw CLI. @@ -50,13 +47,6 @@ public static CliParseResult Parse(string[] args) if (first is "version" or "--version" or "-V") return CliParseResult.Version; - if (first is "-p" or "--prompt") - { - if (args.Length < 2) - return CliParseResult.MissingPromptArg; - return new CliParseResult(CliParseKind.Headless, "headless", HeadlessPrompt: args[1]); - } - if (KnownCommands.Contains(first)) return new CliParseResult(CliParseKind.Known, first); diff --git a/src/Netclaw.Cli/Daemon/DaemonClient.cs b/src/Netclaw.Cli/Daemon/DaemonClient.cs index de028c0fb..307c8c830 100644 --- a/src/Netclaw.Cli/Daemon/DaemonClient.cs +++ b/src/Netclaw.Cli/Daemon/DaemonClient.cs @@ -244,11 +244,12 @@ public async Task EnsureSessionAsync( /// public async Task ResumeSessionAsync( string sessionId, + Actors.Channels.ChannelType channelType, CancellationToken cancellationToken = default) { - _channelType = TuiChannelType; + _channelType = channelType; _sessionId = sessionId; - return await EnsureSessionInternalAsync(TuiChannelType, cancellationToken); + return await EnsureSessionInternalAsync(channelType, cancellationToken); } public async Task SendAsync(ChannelInput input, CancellationToken cancellationToken = default) diff --git a/src/Netclaw.Cli/HeadlessChannel.cs b/src/Netclaw.Cli/HeadlessChannel.cs index 6bbd50f5f..03a7279b1 100644 --- a/src/Netclaw.Cli/HeadlessChannel.cs +++ b/src/Netclaw.Cli/HeadlessChannel.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -10,7 +12,7 @@ namespace Netclaw.Cli; /// -/// Headless channel for single-prompt mode (-p / --prompt). +/// Headless channel for single-prompt mode (chat -p). /// Sends one message to the LLM session, streams all output to stdout, /// and exits on . /// @@ -21,12 +23,20 @@ public sealed class HeadlessChannel : IChannel private readonly IHostApplicationLifetime _lifetime; private readonly TimeProvider _timeProvider; private readonly string _prompt; + private readonly string? _resumeSessionId; + private readonly bool _jsonOutput; private readonly ILogger _logger; private bool _isConnected; private bool _receivedTextDeltaInCurrentTurn; private bool _receivedThinkingDeltaInCurrentTurn; + // JSON output accumulation + private readonly StringBuilder _responseBuffer = new(); + private readonly List _toolCalls = []; + private JsonUsage? _usage; + private string? _resolvedSessionId; + public Actors.Channels.ChannelType ChannelType => Actors.Channels.ChannelType.Headless; public string DisplayName => "Headless Prompt"; @@ -44,14 +54,16 @@ public HeadlessChannel( NetclawPaths paths, IHostApplicationLifetime lifetime, TimeProvider timeProvider, - string prompt, + HeadlessOptions options, ILogger logger) { _daemonClient = daemonClient; _paths = paths; _lifetime = lifetime; _timeProvider = timeProvider; - _prompt = prompt; + _prompt = options.Prompt; + _resumeSessionId = options.ResumeSessionId; + _jsonOutput = options.JsonOutput; _logger = logger; } @@ -69,29 +81,26 @@ public async Task StopAsync(CancellationToken cancellationToken) private async Task RunHeadlessAsync(CancellationToken stopping) { + // Log writer is deferred until we know the session ID (after create/resume). + // Volatile ensures the subscription callback sees the assigned value across threads. + StreamWriter? logWriter = null; + try { - var sessionId = new SessionId($"headless/{Guid.NewGuid():N}"); - - // Set up session log file _paths.EnsureDirectoriesExist(); - var logFileName = $"{sessionId.Value.Replace("/", "-", StringComparison.Ordinal)}.log"; - var logPath = Path.Combine(_paths.LogsDirectory, logFileName); - await using var logWriter = new StreamWriter(logPath, append: false) { AutoFlush = true }; - - logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] Headless session started: {sessionId}"); - logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] PROMPT: {_prompt}"); var turnCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var connectionSubscription = _daemonClient.ConnectionEvents.Subscribe(evt => { - Log(logWriter, $"CONNECTION: {evt.Message}"); + var lw = Volatile.Read(ref logWriter); + if (lw is not null) + Log(lw, $"CONNECTION: {evt.Message}"); }); using var subscription = _daemonClient.SessionOutput.Subscribe(output => { - HandleOutput(output, logWriter); + HandleOutput(output, Volatile.Read(ref logWriter)); if (output is TurnCompleted) turnCompleted.TrySetResult(); }); @@ -99,7 +108,27 @@ private async Task RunHeadlessAsync(CancellationToken stopping) await _daemonClient.ConnectAsync(stopping); _isConnected = true; - sessionId = new SessionId(await _daemonClient.CreateSessionAsync(ChannelType, stopping)); + // Create or resume session + string sessionIdValue; + if (_resumeSessionId is not null) + { + sessionIdValue = await _daemonClient.ResumeSessionAsync( + _resumeSessionId, ChannelType, stopping); + } + else + { + sessionIdValue = await _daemonClient.CreateSessionAsync(ChannelType, stopping); + } + + _resolvedSessionId = sessionIdValue; + var sessionId = new SessionId(sessionIdValue); + + var logFileName = $"{sessionId.Value.Replace("/", "-", StringComparison.Ordinal)}.log"; + var logPath = Path.Combine(_paths.LogsDirectory, logFileName); + Volatile.Write(ref logWriter, new StreamWriter(logPath, append: true) { AutoFlush = true }); + + logWriter!.WriteLine($"[{_timeProvider.GetUtcNow():o}] Headless session started: {sessionId}"); + logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] PROMPT: {_prompt}"); await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput { @@ -126,9 +155,14 @@ await _daemonClient.SendAsync(new Netclaw.Actors.Channels.ChannelInput Environment.ExitCode = 1; _lifetime.StopApplication(); } + finally + { + if (logWriter is not null) + await logWriter.DisposeAsync(); + } } - private void HandleOutput(SessionOutput output, StreamWriter log) + private void HandleOutput(SessionOutput output, StreamWriter? log) { switch (output) { @@ -143,13 +177,19 @@ private void HandleOutput(SessionOutput output, StreamWriter log) break; } - Console.WriteLine(msg.Text); + if (_jsonOutput) + _responseBuffer.Append(msg.Text); + else + Console.WriteLine(msg.Text); Log(log, $"ASSISTANT: {msg.Text}"); break; case TextDeltaOutput msg: _receivedTextDeltaInCurrentTurn = true; - Console.Write(msg.Delta); + if (_jsonOutput) + _responseBuffer.Append(msg.Delta); + else + Console.Write(msg.Delta); Log(log, $"ASSISTANT_DELTA: {msg.Delta}"); break; @@ -170,17 +210,44 @@ private void HandleOutput(SessionOutput output, StreamWriter log) break; case ToolCallOutput msg: - Console.WriteLine($"[tool:call] {msg.ToolName}({msg.ArgumentsJson ?? ""})"); + if (_jsonOutput) + { + _toolCalls.Add(new JsonToolCall + { + CallId = msg.CallId, + ToolName = msg.ToolName, + ArgumentsJson = msg.ArgumentsJson + }); + } + else + { + Console.WriteLine($"[tool:call] {msg.ToolName}({msg.ArgumentsJson ?? ""})"); + } Log(log, $"TOOL_CALL: {msg.ToolName} call_id={msg.CallId} args={msg.ArgumentsJson ?? "{}"}"); break; case ToolResultOutput msg: - Console.WriteLine($"[tool:result] {msg.ToolName} \u2192 {msg.Result}"); + if (!_jsonOutput) + Console.WriteLine($"[tool:result] {msg.ToolName} \u2192 {msg.Result}"); Log(log, $"TOOL_RESULT: {msg.ToolName} call_id={msg.CallId} result={msg.Result}"); break; case UsageOutput msg: - Console.WriteLine($"[usage] in={msg.InputTokens} out={msg.OutputTokens} total={msg.TotalTokens}"); + if (_jsonOutput) + { + _usage = new JsonUsage + { + InputTokens = msg.InputTokens, + OutputTokens = msg.OutputTokens, + TotalTokens = msg.TotalTokens, + CachedInputTokens = msg.CachedInputTokens, + ReasoningTokens = msg.ReasoningTokens + }; + } + else + { + Console.WriteLine($"[usage] in={msg.InputTokens} out={msg.OutputTokens} total={msg.TotalTokens}"); + } Log(log, $"USAGE: in={msg.InputTokens} out={msg.OutputTokens} total={msg.TotalTokens} cached={msg.CachedInputTokens} reasoning={msg.ReasoningTokens} context_window={msg.ContextWindowTokens}"); break; @@ -192,7 +259,14 @@ private void HandleOutput(SessionOutput output, StreamWriter log) break; case TurnCompleted msg: - Console.WriteLine(); + if (_jsonOutput) + { + WriteJsonEnvelope(); + } + else + { + Console.WriteLine(); + } Log(log, $"TURN_COMPLETED: turn={msg.TurnNumber}"); Log(log, "SESSION_ENDED"); _receivedTextDeltaInCurrentTurn = false; @@ -200,34 +274,57 @@ private void HandleOutput(SessionOutput output, StreamWriter log) break; case FileOutput msg: - Console.WriteLine($"[file] {msg.FileName} \u2192 {msg.FilePath}"); + if (!_jsonOutput) + Console.WriteLine($"[file] {msg.FileName} \u2192 {msg.FilePath}"); Log(log, $"FILE: name={msg.FileName} path={msg.FilePath} mime={msg.MimeType}"); break; case SubAgentOutput msg: if (msg.Phase == Netclaw.Actors.SubAgents.SubAgentPhase.Started) { - Console.WriteLine($"[subagent:start] {msg.AgentName} ({msg.ToolCount} tools)"); + if (!_jsonOutput) + Console.WriteLine($"[subagent:start] {msg.AgentName} ({msg.ToolCount} tools)"); Log(log, $"SUBAGENT_START: name={msg.AgentName} tools={msg.ToolCount}"); } else { var status = msg.Success ? "success" : "failed"; - Console.WriteLine($"[subagent:done] {msg.AgentName} ({status}, {msg.Duration.TotalSeconds:F1}s)"); + if (!_jsonOutput) + Console.WriteLine($"[subagent:done] {msg.AgentName} ({status}, {msg.Duration.TotalSeconds:F1}s)"); Log(log, $"SUBAGENT_DONE: name={msg.AgentName} success={msg.Success} duration={msg.Duration.TotalSeconds:F1}s"); } break; case CompactionOutput msg: - Console.WriteLine($"[compaction] {msg.MessagesBefore} \u2192 {msg.MessagesAfter} messages (keep={msg.KeepCountUsed}, context={msg.PreCompactionInputTokens}/{msg.ContextWindowTokens} tokens)"); + if (!_jsonOutput) + Console.WriteLine($"[compaction] {msg.MessagesBefore} \u2192 {msg.MessagesAfter} messages (keep={msg.KeepCountUsed}, context={msg.PreCompactionInputTokens}/{msg.ContextWindowTokens} tokens)"); Log(log, $"COMPACTION: before={msg.MessagesBefore} after={msg.MessagesAfter} tool_results_cleared={msg.ToolResultsCleared} summarized={msg.Summarized} context_window={msg.ContextWindowTokens} input_tokens={msg.PreCompactionInputTokens} keep_count={msg.KeepCountUsed}"); break; } } - private void Log(StreamWriter log, string message) + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private void WriteJsonEnvelope() { - log.WriteLine($"[{_timeProvider.GetUtcNow():o}] {message}"); + var envelope = new JsonEnvelope + { + SessionId = _resolvedSessionId!, + Response = _responseBuffer.ToString(), + ToolCalls = _toolCalls.Count > 0 ? _toolCalls : null, + Usage = _usage + }; + + Console.WriteLine(JsonSerializer.Serialize(envelope, s_jsonOptions)); + } + + private void Log(StreamWriter? log, string message) + { + log?.WriteLine($"[{_timeProvider.GetUtcNow():o}] {message}"); } private void WriteFailureLog(string kind, Exception ex) @@ -244,4 +341,30 @@ private void WriteFailureLog(string kind, Exception ex) Console.Error.WriteLine($"[headless:error] Failed to write failure log: {logEx.Message}"); } } + + // ── JSON output types ── + + private sealed class JsonEnvelope + { + public required string SessionId { get; init; } + public required string Response { get; init; } + public List? ToolCalls { get; init; } + public JsonUsage? Usage { get; init; } + } + + private sealed class JsonToolCall + { + public required string CallId { get; init; } + public required string ToolName { get; init; } + public string? ArgumentsJson { get; init; } + } + + private sealed class JsonUsage + { + public long? InputTokens { get; init; } + public long? OutputTokens { get; init; } + public long? TotalTokens { get; init; } + public long? CachedInputTokens { get; init; } + public long? ReasoningTokens { get; init; } + } } diff --git a/src/Netclaw.Cli/HeadlessOptions.cs b/src/Netclaw.Cli/HeadlessOptions.cs new file mode 100644 index 000000000..3fac3c6f6 --- /dev/null +++ b/src/Netclaw.Cli/HeadlessOptions.cs @@ -0,0 +1,11 @@ +namespace Netclaw.Cli; + +/// +/// Configuration for headless (chat -p) mode. +/// +public sealed record HeadlessOptions +{ + public required string Prompt { get; init; } + public string? ResumeSessionId { get; init; } + public bool JsonOutput { get; init; } +} diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index b9ad6fed4..ec0377cb2 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -55,20 +55,11 @@ static async Task RunAsync(string[] args) case CliParseKind.Version: Console.WriteLine($"netclaw {BuildInfo.Version} (commit {BuildInfo.CommitHash}, built {BuildInfo.BuildTimestamp})"); return; - case CliParseKind.MissingPromptArg: - Console.Error.WriteLine("netclaw: -p/--prompt requires an argument."); - Console.Error.WriteLine("Usage: netclaw -p \"your prompt here\""); - Environment.ExitCode = 1; - return; case CliParseKind.Unknown: Console.Error.WriteLine($"netclaw: '{parseResult.Mode}' is not a netclaw command. See 'netclaw --help'."); WriteGeneralHelp(); Environment.ExitCode = 2; return; - case CliParseKind.Headless: - headlessPrompt = parseResult.HeadlessPrompt; - mode = "headless"; - break; default: // CliParseKind.Known mode = parseResult.Mode!; break; @@ -833,10 +824,14 @@ static async Task RunAsync(string[] args) } } - // ── Parse --resume flag for chat mode ── + // ── Parse chat flags: --resume, -p/--prompt, --json ── string? resumeSessionId = null; + bool chatJsonOutput = false; if (mode is "chat") { + bool chatHeadless = false; + string? chatPrompt = null; + for (var i = 1; i < args.Length; i++) { if (args[i] is "--resume" or "-r") @@ -859,11 +854,44 @@ static async Task RunAsync(string[] args) continue; } + if (args[i] is "-p" or "--prompt") + { + chatHeadless = true; + continue; + } + + if (args[i] is "--json") + { + chatJsonOutput = true; + continue; + } + if (IsHelpToken(args[i])) { WriteChatHelp(); return; } + + // Positional argument: prompt text (when -p is specified) + if (chatPrompt is null) + { + chatPrompt = args[i]; + } + } + + if (chatHeadless) + { + if (chatPrompt is null) + { + Console.Error.WriteLine("netclaw: chat -p requires a prompt argument."); + Console.Error.WriteLine("Usage: netclaw chat -p \"your prompt here\""); + WriteChatHelp(); + Environment.ExitCode = 1; + return; + } + + headlessPrompt = chatPrompt; + mode = "headless"; } } @@ -901,8 +929,14 @@ static async Task RunAsync(string[] args) break; case "headless": + var headlessOpts = new HeadlessOptions + { + Prompt = headlessPrompt!, + ResumeSessionId = resumeSessionId, + JsonOutput = chatJsonOutput, + }; webBuilder.Services.AddSingleton(sp => - ActivatorUtilities.CreateInstance(sp, headlessPrompt!)); + ActivatorUtilities.CreateInstance(sp, headlessOpts)); webBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); webBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); break; @@ -938,9 +972,9 @@ static void WriteGeneralHelp() Console.WriteLine("Commands:"); Console.WriteLine(" chat Interactive TUI chat"); Console.WriteLine(" chat --resume Resume an existing session by ID"); + Console.WriteLine(" chat -p Headless single-prompt mode (supports --resume, --json)"); Console.WriteLine(" sessions Browse and resume recent sessions (TUI)"); Console.WriteLine(" sessions --once List sessions and exit (no TUI, plain text or JSON)"); - Console.WriteLine(" -p, --prompt Headless single-prompt mode"); Console.WriteLine(" doctor Configuration diagnostics (offline)"); Console.WriteLine(" status Runtime status from daemon health JSON endpoint"); Console.WriteLine(" stats Usage activity statistics from daemon"); @@ -1024,13 +1058,24 @@ static void WriteDoctorHelp() static void WriteChatHelp() { - Console.WriteLine("Usage: netclaw chat [options]"); + Console.WriteLine("Usage: netclaw chat [options] [prompt]"); Console.WriteLine(); - Console.WriteLine("Start an interactive TUI chat session with the daemon."); + Console.WriteLine("Start an interactive TUI chat session, or send a headless prompt."); Console.WriteLine(); Console.WriteLine("Options:"); - Console.WriteLine(" --resume, -r Resume an existing session by its catalog ID"); - Console.WriteLine(" Use `netclaw sessions` to browse available sessions"); + Console.WriteLine(" --resume, -r Resume (or create) a session by ID"); + Console.WriteLine(" -p, --prompt Send a single headless prompt (non-interactive)"); + Console.WriteLine(" --json Output structured JSON (headless mode only)"); + Console.WriteLine(" Includes sessionId, response, toolCalls, and usage"); + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" netclaw chat Interactive TUI"); + Console.WriteLine(" netclaw chat --resume abc123 Resume session in TUI"); + Console.WriteLine(" netclaw chat -p \"hello\" Headless single prompt"); + Console.WriteLine(" netclaw chat -p --resume my-session \"hello\" Named session, headless"); + Console.WriteLine(" netclaw chat -p --resume my-session --json \"hello\" JSON output, named session"); + Console.WriteLine(); + Console.WriteLine("Use `netclaw sessions` to browse available sessions."); } static void WriteStatusHelp() diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index 054538e6d..356a6e64e 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -313,7 +313,7 @@ private async Task EnsureSessionAndFlushAsync() var resumeId = _resumeSessionId; _resumeSessionId = null; var sessionId = resumeId is not null - ? await _daemonClient.ResumeSessionAsync(resumeId) + ? await _daemonClient.ResumeSessionAsync(resumeId, DaemonClient.TuiChannelType) : await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); SessionIdDisplay.Value = sessionId; _sessionReady = true;