* replaced connect with start * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix * Studio: build the coding-agent command from the selected server The API keys panel showed a hardcoded `unsloth start claude`. `unsloth start` defaults to 127.0.0.1:8888 and only mints a key for a loopback server, so a non-default port or a tunnel/remote base would target the wrong server or fail to mint. Build the command from the panel base/key (and emit a key for non-loopback), matching the other snippets in the panel. * CLI: keep `unsloth connect` as a hidden alias for `unsloth start` Avoids breaking existing scripts and docs that still call `unsloth connect`. * Tests: stub _unstarted_cleanup in same-task disconnect test The test builds _SameTaskStreamingResponse via __new__, so set the attribute that __call__ now reads. * Match coding-agent command loopback check to the CLI 127.0.0.0/8 rule (#6613) * Keep unsloth_cli.commands.connect importable as a deprecated shim (#6613) * Format the new coding-agents panel strings and import per biome (#6613) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop the unsloth connect alias and shim; unsloth start is the only command (#6613) * Route unsloth connect to unsloth start as a hidden backward-compatible alias (#6613) * Forward unsloth run model-load flags to unsloth start (gguf-variant, context-length, load-in-4bit, tensor-parallel) (#6613) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Session-scope coding agent config in unsloth start Configure each agent for the current session instead of writing the Studio endpoint, key, and default model into the user's own config. Codex, OpenCode, OpenClaw, and Hermes get a private config relocated through their config-path env vars (CODEX_HOME, OPENCODE_CONFIG overlay, OPENCLAW_CONFIG_PATH plus OPENCLAW_STATE_DIR, HERMES_HOME). Claude Code suppresses the attribution header for the session via the CLAUDE_CODE_ATTRIBUTION_HEADER env var plus a --settings overlay, with no ~/.claude write. --launch uses an ephemeral temp dir removed after the agent exits; --no-launch uses a stable Unsloth-owned dir and prints the matching export lines. * Read relocated agent session config in Local Agent Guides CI The contract crosscheck and the openclaw/hermes patch helpers now read each agent's config from the relocated path printed by unsloth start --no-launch (CODEX_HOME, OPENCODE_CONFIG, OPENCLAW_CONFIG_PATH, HERMES_HOME) instead of fixed home paths. The Claude attribution A/B toggles the header for the session only (shipped-config HIT vs vanilla MISS) instead of editing ~/.claude/settings.json. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Skip the POSIX-only --no-launch parser test on Windows test_no_launch_output_is_parseable mirrors the #6547 bash CI parser, which greps export/unset lines and only runs on Linux/macOS runners. On Windows --no-launch prints PowerShell ($env: / Remove-Item), so the export-line assertion does not apply there. Cross-OS staging CI surfaced this. * Size Claude Code's auto-compact window to the loaded model's context Claude Code auto-compacts against its native (~600k token) window, so against a smaller local model it overflows the server's context (silent truncation) long before it compacts. Set CLAUDE_CODE_AUTO_COMPACT_WINDOW to the loaded model's real context length (the value codex/openclaw already get via model_context_window / contextWindow). Omitted when the model reports no context length. * Pin OpenCode/Hermes context window and set 90% compaction across agents Feed every agent the server-determined sequence length (the value /v1/models reports from runtime_context_length) and a ~90% compaction threshold. OpenCode: a custom-provider model with no limit defaults to context 0, which silently disables auto-compaction, so set limit.context/output and scale the compaction buffer to 10% of the window. Hermes: pin model.context_length (it otherwise falls back to a 256k default when the server's /v1/models omits the field) and set compression.threshold 0.9. Claude: add CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=90 alongside the window. Codex (model_context_window) and OpenClaw (contextWindow) already carried the window and auto-manage off it. * Add `unsloth start pi` recipe Pi was the only agent without a built-in recipe, so the agent-guides CI hand-wrote ~/.pi/agent/models.json. Add a first-class `pi` command mirroring the others: - write_pi_config writes the session-scoped OpenAI-compatible provider config (key in the config, like openclaw/opencode). - pi() launches `pi --provider unsloth --model <id>` (Pi defaults to the google provider, so the provider/model are pinned on the command line) with HOME relocated for the session. Pi has no config-dir env var and resolves ~/.pi off $HOME, so HOME-scoping keeps the user's ~/.pi untouched. Migrate the agent-guides CI off the hand-written config onto the `unsloth start pi --no-launch` path (connection + file-edit), with a crosscheck for the provider api, so the documented recipe is exercised. * Harden unsloth start for Windows and WSL agent launches Address the Codex review on PR 6613: - write_pi_config now pins the loaded contextWindow and a sane maxTokens so Pi compacts instead of overflowing a small Studio context (it otherwise assumes its 128000 default), matching the other agents. - pi() sets USERPROFILE (and HOMEDRIVE/HOMEPATH when present) alongside HOME on native Windows, where Node resolves ~/.pi via USERPROFILE rather than HOME, so the session no longer reads or writes the user's real ~/.pi. - The WSLENV bridge flags path-valued vars with /p so a Windows npm shim under /mnt receives translated paths, while scalar vars (the numeric context window) pass through untranslated. WSLENV is deduped on the bare name. - _print_env prints the launch command with PowerShell-safe quoting so the inline --settings JSON survives copy-paste on native Windows --no-launch. Add tests for the WSLENV path flagging, PowerShell quoting, the Pi context window, and the Pi USERPROFILE relocation. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Set CLAUDE_CODE_NO_FLICKER for the Claude session A local server streams in bursts, so Claude Code's full-screen TUI redraw flickers between tokens. Disable it for the session via CLAUDE_CODE_NO_FLICKER, alongside the other CLAUDE_CODE_* session env knobs. * Add a normalized --yolo flag routed to each agent's auto-approve mode It is easy to forget which agent spells "run tools without prompting" which way, so `unsloth start` now accepts all three spellings as one option (--yolo, --dangerously-skip-permissions, --dangerously-bypass-approvals-and-sandbox) and routes to the agent's own mechanism: - claude: --dangerously-skip-permissions - codex: --dangerously-bypass-approvals-and-sandbox - hermes: --yolo - pi: --approve (Pi's only approval gate is project trust) - opencode: a permission allow block in opencode.json (no CLI flag exists) - openclaw: tools.exec security=full / ask=off / host=gateway (no CLI flag exists) Because the option is parsed by `unsloth start`, the "wrong" spelling for an agent still routes correctly instead of leaking through to the agent and erroring. IS_SANDBOX is deliberately left unset for Claude so its root/sandbox safety gate still applies. Adds routing, cross-routing, and per-config tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix review findings: IPv6 loopback command, pi USERPROFILE under WSL, yolo guard From a 10-reviewer pass over the PR: - studio/frontend agent-command.ts: normalize bracketed IPv6 hosts. URL.hostname returns "[::1]" for http://[::1]:8888, which never matched the "::1" loopback checks, so the copied command embedded the placeholder API key for a local IPv6 server instead of the bare auto-minting command. Now [::1] is treated as loopback like the CLI's is_loopback_url, so the command matches the CLI contract. - pi(): also relocate USERPROFILE (and HOMEDRIVE/HOMEPATH) when running under WSL against a /mnt Windows shim, not just on native Windows. Windows Node resolves ~/.pi via USERPROFILE, and the WSLENV bridge translates the path, so pi no longer falls back to the user's real ~/.pi in that case. - _yolo_command_flags: use .get so a config-based agent (or a typo) yields no flag instead of a latent KeyError. Adds tests for the WSL pi USERPROFILE relocation, the yolo unmapped-agent guard, and that opencode/openclaw --yolo stays config-only (no argv flag). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix round-2 review findings: WSLENV /p upgrade, agent help text - _merge_wslenv now upgrades a user's pre-existing unflagged WSLENV entry (e.g. a bare HOME or USERPROFILE) to the path-translated form (HOME/p) instead of leaving it as-is, so a Windows agent shim under WSL receives the translated session path rather than the raw Linux path. - Generalize the `unsloth start` registration help to list all six agents (was only "Claude Code, Codex"). Adds a test for the WSLENV unflagged-entry upgrade. * Fix round-3 review findings: complete openclaw --yolo, refresh stale copy - openclaw --yolo now also writes the host approvals file (exec-approvals.json with defaults security=full / ask=off / askFallback=full) alongside the tools.exec config. OpenClaw gates tool execution on both layers (the stricter wins), so the config alone could still leave it prompting or denying. Mirrors `openclaw exec-policy preset yolo`. ask=off means nothing is ever prompted, so the runtime socket block is unnecessary. - Studio API panel copy: clarify that a local server auto-mints the key while a remote one embeds it in the command, and add pi to the swap hint. - Local Agent Guides CI: drop the stale "pi has no start.py recipe" note now that all six agents are driven via `unsloth start <agent> --no-launch`. Adds the openclaw approvals-file assertions and a no-yolo openclaw test. * start: parse claude --version with a regex so a format change does not drop optimization flags * start: offer to install a missing agent (prompt then run its install command) * start: auto-start a Studio server for --model when none is running, and stop it on exit * inference: surface an actionable message when llama-server cannot compile a tool grammar * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix review findings: kill the auto-started server tree on Windows; apply the tool-grammar message to the OpenAI passthrough too * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * start: split --model org/repo:variant so a running session is not evicted `unsloth start <agent> --model org/repo:QUANT` failed against an already-running Studio server and, worse, killed whatever model another session had loaded. /v1/models lists a loaded GGUF under its bare repo id (e.g. unsloth/Qwen3-1.7B-GGUF), so _resolve_model never matched the `:QUANT`-suffixed request. It then POSTed /api/inference/load with model_path=org/repo:QUANT, which (a) Hugging Face rejects ("Repo id must use alphanumeric chars, '-', '_' or '.'") and (b) evicts the model the other session was using, so a second 'unsloth start' in a new tmux/terminal tore down the first. Re-running the command then attached to the now-empty server, which is why it 'worked the second time'. Mirror the org/repo:QUANT -> org/repo + --gguf-variant QUANT shorthand that 'unsloth run' and llama.cpp already accept, splitting it in _connect before we match or serve. Matching now resolves against the loaded bare repo id (no spurious reload, no eviction), and any real load uses a valid repo id plus gguf_variant. An explicit --gguf-variant still wins; local paths and Windows drive letters pass through untouched. The auto-serve path likewise spawns 'unsloth run --model org/repo --gguf-variant QUANT'. * start: harden auth-key handling, codex teardown, and CI transcript redaction Three review findings: 1. CI could leak a live key. agent-guides-drive.sh printed the raw 'unsloth start --no-launch' transcript (which carries export UNSLOTH_API_KEY / ANTHROPIC_AUTH_TOKEN lines) to the Actions log on both the failure path and the success path before redact() ran. Add cat_redacted() and use it for those two prints, so the key is scrubbed on the way to the log while the on-disk file stays intact for the env parsing that follows. 2. Outages masqueraded as bad keys. _key_accepted caught a broad Exception and returned False, so a 5xx or timeout while checking a cached key looked like a rejection: it discarded a good key and minted extra ones (local) or reported 'no saved key' (remote). Only treat HTTP 401/403 as a rejection; let other errors propagate so a real outage surfaces. 3. Codex preflight could leave the auto-started server up. _require_gguf_for_codex runs after _connect may have auto-started Studio but before _run installs its teardown finally, so a preflight rejection (e.g. a transformers-backend model) left the server holding the port/GPU until the atexit backstop. Tear it down explicitly at the point of failure. Tests: a 5xx on a saved key surfaces without minting; a non-GGUF codex preflight tears down the auto-served server. * start: fix IPv6/portless studio URLs, Pi config-dir isolation, and Pi install recipe Four review findings: 1. Pi ignored the session config when PI_CODING_AGENT_DIR was already set. Pi's getAgentDir() reads process.env.PI_CODING_AGENT_DIR before falling back to $HOME/.pi/agent, so a value inherited from the user's shell sent Pi to their real config and skipped our provider/key (the HOME relocation alone was not enough). Pin PI_CODING_AGENT_DIR at the session's .pi/agent dir; it is path-valued so the WSL bridge translates it automatically. 2. Pi install hint dropped Pi's documented --ignore-scripts. Pi's README installs with 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' and notes it needs no install scripts, so accepting the prompt now follows that safe recipe. 3. Auto-start ignored a portless UNSLOTH_STUDIO_URL. unsloth run binds to 'parsed.port or 8888', so http://127.0.0.1 launched the child on 8888 but the health poll (and the returned base) still used port 80, stalling until the startup timeout. Normalize the base to host:8888 (IPv6-safe) before starting and polling. 4. API-panel command mistook IPv6 loopback for the bare default. The bare 'unsloth start' only probes 127.0.0.1:8888 on the IPv4 stack, so http://[::1]:8888 must carry an explicit UNSLOTH_STUDIO_URL. Drop ::1 from the bare-default host set while keeping it a loopback host (URL emitted, no key needed). Tests: PI_CODING_AGENT_DIR is set to the session dir; _effective_base normalizes portless/IPv6 bases; a portless UNSLOTH_STUDIO_URL auto-serves on :8888. * start: apply fresh-review findings across CLI, CI, and the API-panel command From a fresh multi-reviewer pass over the merged head plus the latest Codex bot review: 1. Load knobs now always consult the server. _resolve_model matched on model id alone, so --gguf-variant / --context-length / --no-load-in-4bit / --tensor-parallel were silently ignored whenever the id was already loaded (asking for UD-Q4_K_XL kept a Q8_0 serving). With any explicit knob the CLI defers to /api/inference/load, whose already-loaded dedup answers without reloading when variant and settings match, so a second session running the same command still attaches without evicting the first. 2. OpenCode --yolo and the session model pin now ride in OPENCODE_CONFIG_CONTENT. A project's own opencode.json outranks OPENCODE_CONFIG, so a repo config could silently override the session model and the --yolo permission block; OPENCODE_CONFIG_CONTENT outranks project config. The API key stays in the private file, never in printed env. 3. The --no-launch recipe's last line is a self-contained one-liner (inline VAR=value assignments before the command, conflicting vars blanked). People copy just the last line, and a bare codex/claude there ran against the user's real ~/.codex or Anthropic credentials with zero isolation, e.g. inheriting a pre-existing damaged ~/.codex state DB and blaming the recipe. The CI drive script scrubs the key from the one 'invoking:' echo this adds. 4. The auto-serve log is 0600 and the parent handle is closed. It sat world-readable in the shared tempdir under a predictable name while carrying the minted sk-unsloth- key from the unsloth run banner. 5. _key_accepted fails with a clean message on outages. Non-auth errors (5xx, network, timeout) surfaced as a raw traceback; 401/403 still mean a rejected key. 6. _effective_base strips URL paths, and https loopback targets never auto-serve. http://127.0.0.1:8888/studio polled /studio/api/health (404) and https://127.0.0.1 polled the wrong scheme, both spinning until the 15-minute startup timeout. 7. API-panel command: only literal 127.0.0.1:8888 earns the bare command. localhost can resolve to ::1, which the bare CLI never probes, so it keeps UNSLOTH_STUDIO_URL. 8. CI artifact sweep covers redacted-configs/ and agent-workdir/, not just logs/. Tests: 125 CLI tests pass (new coverage for each fix), 156 backend tests pass, ruff clean. Adds an unsloth connect alias regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * start: hand Pi a clean screen at launch Pi paints inline from wherever the cursor sits: its first render assumes a clean screen instead of clearing or entering the alternate screen itself (current Pi never emits a clear at startup). Launched under unsloth start, that left the session starting mid-scroll beneath the connection output. Clear the screen (click.clear, cross-platform, no-op without a TTY) right before the Studio banner so Pi opens exactly one line down on a clean viewport. Launch path only: --no-launch recipes and piped output are never wiped, and alternate-screen agents are left alone. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * start: auto-override hermes' 64K context floor for small model windows Hermes refuses to initialize when the served model's context window is under 64,000 tokens, and a second copy of the same check rejects the compression model mid-session. write_hermes_config previously pinned the real window, so any small local model (e.g. 40,960) failed at startup with manual config.yaml instructions. For windows below the floor the recipe now claims 65,536 in model.context_length, scales compression.threshold so compaction still fires at 90% of the real window, and sets auxiliary.compression.context_length to cover the mid-session check. Windows at or above the floor keep the exact previous behavior. * ci: install pi with --ignore-scripts, matching the start.py hint The pi cell predates the pi recipe in start.py and still installed the package with lifecycle scripts enabled, so CI stopped exercising the exact command users are prompted to run. npm_retry now passes extra flags through, the pi branch mirrors the install hint verbatim, and the stale no-recipe comment is refreshed. * ci: fail loudly when a relocation var is missing from connect output The empty-string guards ran after appending /config.toml or /config.yaml, so they could never fire: crosscheck_contract silently skipped its contract checks and patch_hermes_tools died on the root path with a bare traceback. Check the raw variable first and guide_fail with the real cause. * staging: 6613 round 6 (https elision, no-launch home reuse, auto-start key fallback) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
526 lines
26 KiB
Bash
Executable file
526 lines
26 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
#
|
|
# Drive one coding agent against the running `unsloth run` server for the
|
|
# Local Agent Guides CI. All failures from here are failure class (c)
|
|
# "guide drift": the server preflight already passed and the agent CLI
|
|
# already installed, so a failure here means the documented recipe in
|
|
# unsloth_cli/commands/start.py no longer produces a working flow.
|
|
#
|
|
# Self-updating: for all six agents (claude, codex, hermes, openclaw,
|
|
# opencode, pi) we obtain the exact env + command from
|
|
# `unsloth start <agent> --no-launch` and run THAT, so a recipe change is
|
|
# exercised automatically.
|
|
#
|
|
# Every agent invocation is wrapped in `timeout` so a headless-TTY prompt
|
|
# can never hang the runner -- a timeout is reported as guide drift with a
|
|
# distinct message.
|
|
#
|
|
# Usage:
|
|
# agent-guides-drive.sh connection <agent>
|
|
# agent-guides-drive.sh file-edit <agent>
|
|
# agent-guides-drive.sh attribution-ab claude
|
|
#
|
|
# Required env (exported by serve-unsloth-run.sh):
|
|
# UNSLOTH_BASE_URL UNSLOTH_API_KEY UNSLOTH_MODEL_ID
|
|
# UNSLOTH_LLAMA_LOG_DIR AGENT_INVOKE_TIMEOUT UNSLOTH_SEED
|
|
set -uo pipefail
|
|
|
|
MODE="${1:?usage: agent-guides-drive.sh <mode> <agent>}"
|
|
AGENT="${2:?usage: agent-guides-drive.sh <mode> <agent>}"
|
|
|
|
: "${UNSLOTH_BASE_URL:?serve step did not export UNSLOTH_BASE_URL}"
|
|
: "${UNSLOTH_API_KEY:?serve step did not export UNSLOTH_API_KEY}"
|
|
: "${UNSLOTH_MODEL_ID:?serve step did not export UNSLOTH_MODEL_ID}"
|
|
# Determinism (seed/temp) is applied at the server level by
|
|
# serve-unsloth-run.sh --extra; agents inherit it through the API.
|
|
TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}"
|
|
|
|
# Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner
|
|
# IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless
|
|
# to the other agents, which ignore it.
|
|
export IS_SANDBOX=1
|
|
|
|
# Absolute paths anchored at the repo root (this script lives in
|
|
# .github/scripts/). Everything writes here regardless of the current working
|
|
# directory, so the file-edit mode can `cd` into a scratch work dir without
|
|
# breaking log/redaction writes.
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
LOGS_DIR="$REPO_ROOT/logs"
|
|
REDACTED_DIR="$REPO_ROOT/redacted-configs"
|
|
WORKDIR_BASE="$REPO_ROOT/agent-workdir"
|
|
CACHE_HELPER="$SCRIPT_DIR/assert-prompt-cache.sh"
|
|
mkdir -p "$LOGS_DIR" "$REDACTED_DIR"
|
|
CONNECT_REF="unsloth_cli/commands/start.py"
|
|
|
|
# Prefill-shrinking flags for Claude Code. The heavyweight agents send
|
|
# multi-thousand-token system prompts + full tool schemas, which on a CPU-only
|
|
# runner is minutes of prefill per model round-trip (~16 tok/s for a 4B model).
|
|
# Replacing the ~5.7k default system prompt with a tiny one (--system-prompt-file)
|
|
# and restricting tools cuts the prefill to a few hundred tokens so it completes
|
|
# quickly on CPU. These only shape the request size; the start.py recipe
|
|
# (endpoint, auth, model) is still exercised end to end.
|
|
#
|
|
# The bulk of Claude Code's prompt is the built-in tool JSON schemas: measured
|
|
# via `claude -p /context`, the default prompt is ~28k tokens of which ~18k is
|
|
# "System tools" alone. --allowedTools/--disallowedTools only gate PERMISSION to
|
|
# call a tool; they do NOT remove its schema from what is sent to the model, so
|
|
# the earlier whitelist left the full ~18k in the prompt and CPU prefill
|
|
# (~16 tok/s) overran claude's own request timeout into a retry loop. --tools is
|
|
# the flag that restricts which schemas are sent. (The ~8k "Memory files" chunk
|
|
# is auto-loaded CLAUDE.md; the unsloth repo ships none, so it is 0 in CI.)
|
|
#
|
|
# Connection probe: --tools "" sends ZERO tool schemas, leaving ~20 tokens total
|
|
# (a one-line --system-prompt-file + the user turn), which prefills instantly.
|
|
CLAUDE_CONNECT_FLAGS=(
|
|
--system-prompt-file "$SCRIPT_DIR/ci-connect-prompt.txt"
|
|
--tools ""
|
|
)
|
|
# File-edit: the task needs the file/shell tools, so send only those schemas
|
|
# (~2.3k tokens vs ~18k for the full set).
|
|
CLAUDE_EDIT_FLAGS=(
|
|
--system-prompt-file "$SCRIPT_DIR/ci-min-system-prompt.txt"
|
|
--tools "Bash,Edit,Write,Read"
|
|
)
|
|
|
|
guide_fail() {
|
|
echo "::error::[guide drift] agent=${AGENT}: $* (preflight passed + install OK, so the documented flow in ${CONNECT_REF} drifted)." >&2
|
|
exit 1
|
|
}
|
|
|
|
# Redact the API key from any file we are about to keep as an artifact.
|
|
# Portable across GNU sed (Linux runners) and BSD sed (macOS), so the
|
|
# redaction is never silently skipped.
|
|
redact() {
|
|
local f
|
|
for f in "$@"; do
|
|
[ -f "$f" ] || continue
|
|
if sed --version >/dev/null 2>&1; then
|
|
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
|
|
else
|
|
sed -i '' "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Print a file to the log with the key scrubbed, without mutating it (the raw file is
|
|
# still needed to parse the real env). Use this instead of `cat` for any transcript that
|
|
# carries an `export UNSLOTH_API_KEY=...` line, so a live key never reaches Actions logs.
|
|
cat_redacted() {
|
|
sed "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$1"
|
|
}
|
|
|
|
# A reply must be non-empty and free of connection/auth errors.
|
|
assert_reply() {
|
|
local out="$1"
|
|
if [ ! -s "$out" ]; then
|
|
guide_fail "agent produced an EMPTY reply"
|
|
fi
|
|
if grep -qiE 'connection refused|connection error|econnrefused|fetch failed|http 4[0-9][0-9]|unauthorized|invalid api key|authentication failed' "$out"; then
|
|
guide_fail "agent reply contained a connection/auth error: $(grep -iE 'connection|unauthorized|auth|http 4' "$out" | head -1)"
|
|
fi
|
|
echo "[$AGENT] reply (first 20 lines):"
|
|
head -20 "$out"
|
|
}
|
|
|
|
# Run a command under a hard timeout; map 124 to a guide-drift hang message.
|
|
run_timed() { # $1=outfile, rest=command
|
|
local out="$1"; shift
|
|
timeout "$TIMEOUT" "$@" > "$out" 2>&1
|
|
local rc=$?
|
|
if [ "$rc" -eq 124 ]; then
|
|
redact "$out" # guide_fail exits below, so scrub the transcript here too
|
|
echo "[$AGENT] last 40 lines before timeout:"; tail -40 "$out" 2>/dev/null || true
|
|
guide_fail "invoke timed out after ${TIMEOUT}s (headless-TTY hang -- the recipe likely needs a non-interactive/print flag)"
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
# Read a value from an `export VAR=...` line in the connect --no-launch output.
|
|
# `unsloth start` writes each agent's session config off the user's ~ and points
|
|
# at it through a relocation env var (CODEX_HOME / OPENCODE_CONFIG /
|
|
# OPENCLAW_CONFIG_PATH), so the contract checks read the path from here.
|
|
raw_env() { # $1 = var name -> value (one shlex-quote layer stripped)
|
|
local raw="$LOGS_DIR/connect-${AGENT}.txt"
|
|
local v; v="$(sed -n "s/^export $1=//p" "$raw" | tail -1)"
|
|
v="${v#\'}"; v="${v%\'}"; printf '%s' "$v"
|
|
}
|
|
|
|
# ── 5-agent start.py path: parse env + command from --no-launch ─────────
|
|
# Populates globals CONNECT_ENV (export/unset lines) and CONNECT_CMD (the
|
|
# launch command on the last printed line), and runs start.py's config
|
|
# writers as a side effect (it writes each agent's relocated session config).
|
|
parse_connect() {
|
|
local raw="$LOGS_DIR/connect-${AGENT}.txt"
|
|
if ! unsloth start "$AGENT" --no-launch --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
|
|
cat_redacted "$raw"
|
|
guide_fail "'unsloth start ${AGENT} --no-launch' exited non-zero"
|
|
fi
|
|
echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw"
|
|
CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)"
|
|
# The launch command is the last non-export, non-status line. start.py
|
|
# prints "Studio <url> · model <id>" and "Updated ..." status lines first.
|
|
CONNECT_CMD="$(grep -vE '^(export |unset |Studio |Updated |Disabled |Warning|Loading)' "$raw" \
|
|
| grep -E '[^[:space:]]' | tail -1)"
|
|
[ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output"
|
|
redact "$raw"
|
|
}
|
|
|
|
# Cross-check the documented contract knobs so silent start.py changes
|
|
# (env-var rename, wire_api flip, attribution setting drop) also fail/flag.
|
|
crosscheck_contract() {
|
|
local raw="$LOGS_DIR/connect-${AGENT}.txt"
|
|
local cfg home
|
|
case "$AGENT" in
|
|
codex)
|
|
grep -q 'UNSLOTH_STUDIO_AUTH_TOKEN' "$raw" \
|
|
|| guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (start.py _CODEX_ENV_KEY)"
|
|
home="$(raw_env CODEX_HOME)"
|
|
# An empty relocation var would make cfg "/config.toml" and silently
|
|
# skip the [ -f ] contract check below; fail loudly instead.
|
|
[ -n "$home" ] || guide_fail "CODEX_HOME missing from connect output (start.py codex())"
|
|
cfg="$home/config.toml"
|
|
if [ -f "$cfg" ]; then
|
|
grep -q 'wire_api = "responses"' "$cfg" \
|
|
|| guide_fail "Codex wire_api is no longer \"responses\" in \$CODEX_HOME/config.toml"
|
|
cp "$cfg" "$REDACTED_DIR/codex-config.toml"
|
|
fi
|
|
grep -q 'codex --oss --profile unsloth_api' "$raw" \
|
|
|| echo "::warning::Codex launch command changed from 'codex --oss --profile unsloth_api'"
|
|
;;
|
|
claude)
|
|
grep -q 'ANTHROPIC_AUTH_TOKEN' "$raw" \
|
|
|| guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (start.py claude())"
|
|
grep -q 'CLAUDE_CODE_ATTRIBUTION_HEADER' "$raw" \
|
|
|| echo "::warning::CLAUDE_CODE_ATTRIBUTION_HEADER no longer set for the session (start.py claude())"
|
|
;;
|
|
hermes)
|
|
grep -q 'UNSLOTH_API_KEY' "$raw" \
|
|
|| guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (start.py _HERMES_ENV_KEY)"
|
|
home="$(raw_env HERMES_HOME)"
|
|
[ -n "$home" ] || guide_fail "HERMES_HOME missing from connect output (start.py hermes())"
|
|
cfg="$home/config.yaml"
|
|
[ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/hermes-config.yaml"
|
|
;;
|
|
openclaw)
|
|
cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
|
|
if [ -n "$cfg" ] && [ -f "$cfg" ]; then
|
|
grep -q '"openai-completions"' "$cfg" \
|
|
|| echo "::warning::OpenClaw provider api is no longer 'openai-completions' (write_openclaw_config)"
|
|
cp "$cfg" "$REDACTED_DIR/openclaw.json"
|
|
fi
|
|
;;
|
|
opencode)
|
|
cfg="$(raw_env OPENCODE_CONFIG)"
|
|
[ -n "$cfg" ] && [ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/opencode.json"
|
|
;;
|
|
pi)
|
|
# Pi has no config-dir env var; the session is HOME-relocated, and the
|
|
# provider config lives at $HOME/.pi/agent/models.json.
|
|
cfg="$(raw_env HOME)/.pi/agent/models.json"
|
|
if [ -f "$cfg" ]; then
|
|
grep -q '"openai-completions"' "$cfg" \
|
|
|| echo "::warning::Pi provider api is no longer 'openai-completions' (write_pi_config)"
|
|
cp "$cfg" "$REDACTED_DIR/pi-models.json"
|
|
fi
|
|
;;
|
|
esac
|
|
redact "$REDACTED_DIR"/* 2>/dev/null || true
|
|
}
|
|
|
|
# Heavyweight agents (hermes, openclaw) bake a large system prompt + tool JSON
|
|
# schemas into every request, which a CPU runner cannot prefill before the invoke
|
|
# timeout. As with claude's --tools, we shrink the request from the agent's own
|
|
# config: zero tools for the connection probe collapses the prompt to a few
|
|
# hundred tokens, since both CLIs gate the bulk of their prompt on having tools.
|
|
|
|
# Hermes: an explicit empty cli toolset disables all tools (and drops the
|
|
# tool-gated guidance blocks), so -z sends ~300 tokens instead of thousands.
|
|
# Hermes enables its default cli toolset when the session config does not pin one,
|
|
# so we must set platform_toolsets.cli explicitly to [] (not just append) to get
|
|
# zero tools. That needs a YAML parser, and the runner's bare python3 has no
|
|
# PyYAML -- but the venv that ships `unsloth` does (start.py imports yaml), so run
|
|
# the patch with that interpreter. We patch the relocated $HERMES_HOME/config.yaml
|
|
# that `unsloth start` printed, not the user's ~/.hermes.
|
|
# (-z reads platform_toolsets.cli; --ignore-rules is a no-op under -z.)
|
|
patch_hermes_tools() { # $1 = none|default
|
|
# Check the raw var BEFORE appending /config.yaml: the joined path is never
|
|
# empty, so the old guard could not fire and the patcher would die on
|
|
# "/config.yaml" with a bare traceback instead of this clear failure.
|
|
local home; home="$(raw_env HERMES_HOME)"
|
|
[ -n "$home" ] || guide_fail "Hermes HERMES_HOME missing from connect output (start.py hermes())"
|
|
local cfg; cfg="$home/config.yaml"
|
|
# Find a python that can import yaml. The runner's bare python3 cannot, but the
|
|
# interpreter in the `unsloth` console-script shebang provably can (it runs
|
|
# start.py's write_hermes_config, which imports yaml). Try that first, then
|
|
# any python on PATH, then the venv sibling, picking the first with PyYAML.
|
|
local cand py="" shebang
|
|
shebang="$(head -1 "$(command -v unsloth)" 2>/dev/null | sed -n 's/^#![[:space:]]*//p' | awk '{print $1}')"
|
|
for cand in "$shebang" python3 python "$(dirname "$(command -v unsloth)")/python"; do
|
|
[ -n "$cand" ] || continue
|
|
{ [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; } || continue
|
|
if "$cand" -c 'import yaml' 2>/dev/null; then py="$cand"; break; fi
|
|
done
|
|
[ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch the hermes session config"
|
|
echo "[hermes] patching $cfg with $py"
|
|
"$py" - "$1" "$cfg" <<'PY'
|
|
import os, sys
|
|
import yaml
|
|
mode = sys.argv[1]
|
|
p = sys.argv[2]
|
|
cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {}
|
|
ts = cfg.get("platform_toolsets")
|
|
if not isinstance(ts, dict):
|
|
ts = cfg["platform_toolsets"] = {}
|
|
if mode == "none":
|
|
ts["cli"] = [] # explicit empty list -> zero tools (not "defaults")
|
|
else:
|
|
ts.pop("cli", None) # file-edit needs real tools -> restore defaults
|
|
with open(p, "w") as fh:
|
|
yaml.safe_dump(cfg, fh, sort_keys=False)
|
|
print(f"[hermes] platform_toolsets.cli = {ts.get('cli', 'default')}")
|
|
PY
|
|
}
|
|
|
|
# OpenClaw: 'openclaw agent' has no tool/prompt flags, so we define a 'ci' agent
|
|
# in openclaw.json. tools.deny ["*"] sends zero tool schemas (deny always wins)
|
|
# for the connection probe; contextInjection "never" + defaults.skipBootstrap
|
|
# drop the auto-injected AGENTS.md/SOUL.md bootstrap (the bulk of the prompt) for
|
|
# both modes. --agent must reference a defined agent, so write it before invoking.
|
|
patch_openclaw_agent() { # $1 = notools|tools
|
|
# OpenClaw reads its config from the relocated OPENCLAW_CONFIG_PATH that
|
|
# `unsloth start` printed, so patch THAT file (not the user's ~/.openclaw).
|
|
local cfg; cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
|
|
[ -n "$cfg" ] || guide_fail "OpenClaw OPENCLAW_CONFIG_PATH missing from connect output (start.py openclaw())"
|
|
python3 - "$1" "$cfg" <<'PY'
|
|
import os, sys, json
|
|
mode = sys.argv[1]
|
|
p = sys.argv[2]
|
|
cfg = json.load(open(p)) if os.path.exists(p) else {}
|
|
agents = cfg.setdefault("agents", {})
|
|
agents.setdefault("defaults", {})["skipBootstrap"] = True
|
|
lst = [a for a in agents.get("list", []) if a.get("id") != "ci"]
|
|
agent = {"id": "ci", "contextInjection": "never"}
|
|
if mode == "notools":
|
|
agent["tools"] = {"deny": ["*"]}
|
|
lst.append(agent)
|
|
agents["list"] = lst
|
|
with open(p, "w") as fh:
|
|
json.dump(cfg, fh, indent=2)
|
|
print(f"[openclaw] agent ci tools = {agent.get('tools', 'default')}")
|
|
PY
|
|
}
|
|
|
|
# Build an invoke script that applies start.py's env then runs the launch
|
|
# command (with extra args appended) under bash. We do NOT eval connect's env
|
|
# into this shell; we write it into a one-shot script so the export/unset
|
|
# semantics are exactly what start.py printed. The script path is absolute
|
|
# so it is valid even when the caller has cd'd into a scratch work dir.
|
|
invoke_via_connect() { # $1=outfile, rest=extra args appended to the command
|
|
local out="$1"; shift
|
|
local script="$LOGS_DIR/invoke-${AGENT}.sh"
|
|
local real; real="$(mktemp)"
|
|
# CONNECT_ENV_EXTRA / CONNECT_CMD_OVERRIDE let a caller (attribution-ab) flip a
|
|
# session knob without editing the user's config; empty -> use what start.py emitted.
|
|
local cmd="${CONNECT_CMD_OVERRIDE:-$CONNECT_CMD}"
|
|
{
|
|
echo "set -uo pipefail"
|
|
echo "$CONNECT_ENV"
|
|
[ -n "${CONNECT_ENV_EXTRA:-}" ] && echo "$CONNECT_ENV_EXTRA"
|
|
# Append extra args (the prompt / flags) to the launch command verbatim.
|
|
printf '%s' "$cmd"
|
|
local a
|
|
for a in "$@"; do printf ' %q' "$a"; done
|
|
printf '\n'
|
|
} > "$real"
|
|
# Upload a REDACTED copy of the script, but EXECUTE the un-redacted one from a
|
|
# temp path outside the artifact dir. Redacting the script we run would turn
|
|
# the real `export TOKEN=sk-...` line into `export TOKEN=<REDACTED>`, which is
|
|
# invalid bash (the `<`/`>` are redirections) and silently breaks every agent.
|
|
# Writing the redacted copy up front keeps the key out of the artifact even if
|
|
# the run times out (run_timed exits before returning here).
|
|
cp "$real" "$script"; redact "$script"
|
|
# The connect one-liner now carries the key as an inline env assignment; scrub it on
|
|
# the way to the log (the executed $real keeps the live value).
|
|
echo "[$AGENT] invoking (timeout ${TIMEOUT}s): ${cmd//${UNSLOTH_API_KEY}/<REDACTED>} $*"
|
|
run_timed "$out" bash "$real"
|
|
local rc=$?
|
|
rm -f "$real"
|
|
redact "$out" # the transcript can echo the token; scrub before upload
|
|
return "$rc"
|
|
}
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════
|
|
case "$MODE" in
|
|
# ── connection: trivial prompt, assert a non-empty, error-free reply ────
|
|
connection)
|
|
PROMPT='Reply with exactly the single word: pong'
|
|
OUT="$LOGS_DIR/${AGENT}-connection.txt"
|
|
parse_connect
|
|
crosscheck_contract
|
|
# claude/codex run in print mode via the flags start.py emits
|
|
# (claude -p / codex exec). For agents whose default subcommand prints
|
|
# to stdout we pass the prompt through ctx.args.
|
|
case "$AGENT" in
|
|
claude) invoke_via_connect "$OUT" "${CLAUDE_CONNECT_FLAGS[@]}" -p "$PROMPT" ;;
|
|
codex) invoke_via_connect "$OUT" exec --dangerously-bypass-approvals-and-sandbox "$PROMPT" ;;
|
|
opencode) invoke_via_connect "$OUT" run "$PROMPT" ;;
|
|
pi) invoke_via_connect "$OUT" -p "$PROMPT" ;;
|
|
hermes) patch_hermes_tools none
|
|
invoke_via_connect "$OUT" -z "$PROMPT" ;;
|
|
openclaw) patch_openclaw_agent notools
|
|
invoke_via_connect "$OUT" agent --local --agent ci \
|
|
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;;
|
|
*) invoke_via_connect "$OUT" "$PROMPT" ;;
|
|
esac
|
|
# A non-zero exit from the documented launch command is drift even if it
|
|
# printed something: a benign-looking "command not found" / usage dump would
|
|
# otherwise slip past assert_reply (which only flags empty/error-keyword text).
|
|
rc=$?
|
|
[ "$rc" -eq 0 ] || guide_fail "the documented launch command exited non-zero (rc=$rc) -- see the transcript above"
|
|
assert_reply "$OUT"
|
|
echo "[$AGENT] connection OK"
|
|
;;
|
|
|
|
# ── file-edit: deterministic 2-turn hello.py test (Qwen3.5-2B) ──────────
|
|
file-edit)
|
|
WORK="$WORKDIR_BASE/${AGENT}"
|
|
rm -rf "$WORK"; mkdir -p "$WORK"
|
|
OUT1="$LOGS_DIR/${AGENT}-fileedit-turn1.txt"
|
|
OUT2="$LOGS_DIR/${AGENT}-fileedit-turn2.txt"
|
|
T1='Create a file named hello.py in the current directory whose entire contents are a single line: print("Hello"). Do not run it.'
|
|
T2='Run hello.py with python and show me the exact output.'
|
|
|
|
# The start.py recipe writers + crosscheck must see the repo; run them
|
|
# from the repo root BEFORE cd-ing into the scratch work dir.
|
|
parse_connect
|
|
crosscheck_contract
|
|
# File-edit needs real tools, so we cannot zero them as in connection.
|
|
# hermes keeps default tools; openclaw still strips its AGENTS.md/SOUL.md
|
|
# bootstrap (the largest prompt chunk) via the 'ci' agent. The scratch work
|
|
# dir is empty, so no project context files are auto-loaded either.
|
|
case "$AGENT" in
|
|
hermes) patch_hermes_tools default ;;
|
|
openclaw) patch_openclaw_agent tools ;;
|
|
esac
|
|
|
|
# Drive from inside the work dir so the agent edits files there. All log
|
|
# writes use absolute $LOGS_DIR, so cwd does not matter for them.
|
|
cd "$WORK" || guide_fail "could not enter work dir $WORK"
|
|
|
|
invoke_turn() { # $1=outfile $2=continue? $3=prompt
|
|
local out="$1" cont="$2" prompt="$3"
|
|
case "$AGENT" in
|
|
pi)
|
|
# Pi continues the previous session with -c; provider/model come from
|
|
# the parsed `unsloth start pi` recipe (CONNECT_CMD), not hardcoded here.
|
|
if [ "$cont" = "continue" ]; then
|
|
invoke_via_connect "$out" -p --continue "$prompt"
|
|
else
|
|
invoke_via_connect "$out" -p "$prompt"
|
|
fi ;;
|
|
claude)
|
|
# --dangerously-skip-permissions lets headless claude actually use the
|
|
# Write/Bash tools (otherwise it blocks on an approval prompt and emits
|
|
# nothing). IS_SANDBOX=1 (exported above) authorizes it.
|
|
if [ "$cont" = "continue" ]; then
|
|
invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p --continue "$prompt"
|
|
else
|
|
invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p "$prompt"
|
|
fi ;;
|
|
codex)
|
|
# --dangerously-bypass-approvals-and-sandbox gives codex exec
|
|
# workspace-write (default is read-only -> cannot create hello.py) and
|
|
# skips the bubblewrap sandbox that the runner lacks.
|
|
if [ "$cont" = "continue" ]; then
|
|
invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox resume --last "$prompt"
|
|
else
|
|
invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox "$prompt"
|
|
fi ;;
|
|
opencode) invoke_via_connect "$out" run "$prompt" ;;
|
|
hermes) invoke_via_connect "$out" -z "$prompt" ;;
|
|
openclaw) invoke_via_connect "$out" agent --local --agent ci \
|
|
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;;
|
|
*) invoke_via_connect "$out" "$prompt" ;;
|
|
esac
|
|
}
|
|
|
|
# Turn 1: create hello.py.
|
|
invoke_turn "$OUT1" fresh "$T1"
|
|
# Fail on a non-zero agent exit before trusting side effects: an agent can
|
|
# error out (API/tool failure) yet leave a plausible file/transcript behind,
|
|
# which would otherwise slip past the assertions below (mirrors connection).
|
|
rc=$?
|
|
[ "$rc" -eq 0 ] || { echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true; \
|
|
guide_fail "turn 1 (create hello.py) exited non-zero (rc=$rc)"; }
|
|
|
|
# Hard assertions on the side effect (the real test): file + content + run.
|
|
if [ ! -f hello.py ]; then
|
|
echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true
|
|
guide_fail "turn 1 did not create hello.py"
|
|
fi
|
|
grep -q 'Hello' hello.py || guide_fail "hello.py does not contain 'Hello'"
|
|
RUN_OUT="$(python3 hello.py 2>&1 || true)"
|
|
[ "$RUN_OUT" = "Hello" ] || guide_fail "python3 hello.py printed '$RUN_OUT', expected exactly 'Hello'"
|
|
echo "[$AGENT] turn 1 OK (file created, prints 'Hello')"
|
|
|
|
# Turn 2: same cwd + session continuation; assert the agent's run output
|
|
# contains Hello. Narration drift is WARN-only, missing output is a hard fail.
|
|
invoke_turn "$OUT2" continue "$T2"
|
|
rc=$?
|
|
[ "$rc" -eq 0 ] || { echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true; \
|
|
guide_fail "turn 2 (run hello.py) exited non-zero (rc=$rc)"; }
|
|
if grep -q 'Hello' "$OUT2"; then
|
|
echo "[$AGENT] turn 2 OK (run output contains 'Hello')"
|
|
else
|
|
echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true
|
|
guide_fail "turn 2 run/bash output did not contain 'Hello'"
|
|
fi
|
|
cd "$REPO_ROOT" || true
|
|
echo "[$AGENT] file-edit OK"
|
|
;;
|
|
|
|
# ── attribution-ab: Claude Code KV-cache HIT vs MISS ────────────────────
|
|
attribution-ab)
|
|
[ "$AGENT" = "claude" ] || guide_fail "attribution-ab only applies to claude"
|
|
# The llama-server log filename uses the INTERNAL random llama.cpp port,
|
|
# not STUDIO_PORT, so we never glob by port: assert-prompt-cache.sh picks
|
|
# the newest llama-*.log and we slice it by a byte offset (`mark`) captured
|
|
# right before the measured turn, so an earlier turn's reuse can't leak in.
|
|
LLAMA_LOG_DIR="${UNSLOTH_LLAMA_LOG_DIR:-$HOME/.unsloth/studio/logs/llama-server}"
|
|
export LLAMA_LOG_DIR
|
|
parse_connect # prints session env + suppression flags (no ~/.claude write)
|
|
crosscheck_contract
|
|
PROMPT='Reply with exactly the single word: pong'
|
|
|
|
# Phase A: the suppression start.py ships (CLAUDE_CODE_ATTRIBUTION_HEADER=0 +
|
|
# --exclude-dynamic-system-prompt-sections + --settings overlay) -> expect a
|
|
# HIT on the continued turn, since the system-prompt prefix is stable.
|
|
invoke_via_connect "$LOGS_DIR/claude-ab-hit-1.txt" -p "$PROMPT" # turn 1 primes
|
|
FROM_HIT="$(bash "$CACHE_HELPER" mark)" # offset before turn 2
|
|
invoke_via_connect "$LOGS_DIR/claude-ab-hit-2.txt" -p --continue "$PROMPT again"
|
|
CACHE_LOG_FROM="$FROM_HIT" bash "$CACHE_HELPER" log HIT
|
|
|
|
# Phase B: vanilla Claude with the header ENABLED -> expect a MISS. We flip
|
|
# the env var to 1 and strip the suppression flags from the launch command
|
|
# (without them the dynamic attribution line is included and changes every
|
|
# turn, so the shared prefix moves and the KV cache is invalidated, ~90%
|
|
# slower). This is session-only: nothing is written to ~/.claude.
|
|
CONNECT_ENV_EXTRA='export CLAUDE_CODE_ATTRIBUTION_HEADER=1'
|
|
CONNECT_CMD_OVERRIDE="$(printf '%s' "$CONNECT_CMD" \
|
|
| sed -E "s/ --exclude-dynamic-system-prompt-sections//; s/ --settings '[^']*'//")"
|
|
invoke_via_connect "$LOGS_DIR/claude-ab-miss-1.txt" -p "$PROMPT"
|
|
FROM_MISS="$(bash "$CACHE_HELPER" mark)"
|
|
invoke_via_connect "$LOGS_DIR/claude-ab-miss-2.txt" -p --continue "$PROMPT again"
|
|
CACHE_LOG_FROM="$FROM_MISS" bash "$CACHE_HELPER" log MISS
|
|
unset CONNECT_ENV_EXTRA CONNECT_CMD_OVERRIDE
|
|
echo "[claude] attribution A/B OK (suppressed HIT, header=1 MISS)"
|
|
;;
|
|
|
|
*)
|
|
echo "agent-guides-drive.sh: unknown mode '$MODE'" >&2
|
|
exit 2
|
|
;;
|
|
esac
|