Repository navigation
Debugging with ASH_VERBOSE
This page is for a developer troubleshooting a problem with an ash install — a failed tool call, an unexpected assistant reply, a stuck scratch script, or a provider request that behaved oddly. It explains how to turn on ASH_VERBOSE, exactly what it changes, how to read every field in a log entry (including the EID and request_id fields), where the execution dashboard report comes from, and where logs live when ASH_VERBOSE is not set.
export ASH_VERBOSE=1
ash "list the files in this directory"Accepted values (case-insensitive), checked by verboseLoggingEnabled in support.go: 1, true, yes, y, on, debug. Anything else (including unset) leaves verbose logging off.
Two optional companions:
export ASH_LOG_FILE=/tmp/ash-debug.log # write debug JSON to a file instead of stderr
export ASH_LOG_MAX_BYTES=1048576 # rotate ASH_LOG_FILE at this size (default 1 MiB)Both are read in configureDebugLogging in support.go. If ASH_LOG_FILE is set, logs go to a self-rotating file (newRotatingSchedulerLogWriter, same file) instead of your terminal's stderr — useful when you want a clean terminal but a full trace to inspect afterward.
Turn it off again with unset ASH_VERBOSE (or export ASH_VERBOSE=0).
Two independent things happen when it's enabled, both wired up once per invocation in run() (ash.go):
-
Log level rises from Info to Debug. Without
ASH_VERBOSE, onlyslog.Info/slog.Warn/slog.Errorcalls are emitted. With it,slog.Debugcalls also print — this is where almost all of the useful step-by-step detail lives (AI request/response metadata, tool invocation names and results, tool-loop iteration counts, sub-agent completions). See the level switch inconfigureDebugLogging(support.go). -
An execution dashboard prints before the process exits. This is a plain-text (or ANSI-colored, if stdout is a terminal) report written to stdout, produced by a
deferinrun()so it prints even when the invocation fails or exits early. The samedeferalso emits a structured JSON "execution summary" log line vialogExecutionSummary(see below) before rendering the dashboard:
defer func() {
metrics.finish(timeNow())
if verboseLoggingEnabled() {
logExecutionSummary(summaryRequestID, metrics)
_, _ = io.WriteString(stdout, renderExecutionDashboard(metrics, writerIsTerminal(stdout)))
}
}()See ash.go.
This is the "always available" baseline a developer can rely on even without reproducing with verbose logging turned on:
-
slog.Info/slog.Warn/slog.Errorcalls are always emitted, regardless ofASH_VERBOSE— they are not gated behind it, onlyslog.Debugcalls are. So install recommendations, top-level failures, and warnings are always visible. - The default destination is stderr, as structured JSON (
newStructuredLogger, support.go) — there is no persistent log file unless you setASH_LOG_FILEyourself. - Every one of those log lines still carries an
EIDfield (see below), so even a plain non-verbose failure can be traced back to its exact source line. -
Scheduled invocations are the one exception: a one-off
schedule_future_promptrun automatically enables verbose logging and writes JSON debug logs to~/.ash/logs/task_$SESSION_ID.log, rotating at 1 MiB, regardless of whether you setASH_VERBOSEyourself. This is the file to check first when a scheduled job misbehaves.
A representative line (pretty-printed here; actual output is single-line JSON):
{
"time": "2026-08-30T11:16:17.6259633-04:00",
"level": "debug",
"message": "Tool invocation requested",
"request_id": "3f1a9c2b7e0d4488",
"name": "run_unix_command",
"arg_count": 1,
"EID": "iYWCHf8N"
}| Field | Meaning |
|---|---|
time |
RFC3339-nano timestamp, from Go's standard slog JSON handler. |
level |
debug, info, warn, or error, lowercased by the ReplaceAttr hook in newStructuredLogger (support.go). |
message |
Human-readable event name (msg is renamed to message by the same hook). |
request_id |
Identifies which single ash invocation this line belongs to — see below. |
EID |
Identifies which exact source line emitted this log call — see below. |
| everything else | Call-specific fields — e.g. name/arg_count for a tool invocation, bytes/sha256/status for an AI request/response, parent_session_id/child_session_id for a sub-agent completion. These are always bounded metadata (sizes, hashes, statuses, names) — never raw prompts, credentials, tool arguments, or tool output (see the Logging hygiene section of Security-Model-and-Environment-Variables). |
request_id ties together every debug log line that belongs to one ash invocation — one AI request/response cycle, every tool call it made, every tool-loop iteration, and any sub-agent it spawned — so you can filter a busy log file down to just the run you care about.
- It is generated once per invocation, near the top of
run(), byrequestIDGenerator()— a package-level function variable initialized ininit()tonewRandomRequestID, which fills 8 random bytes viacrypto/rand.Readand hex-encodes them into a 16-character string. See support.go#L263-L275. - That single ID is then threaded through the rest of the call chain via
context.Context, usingwithRequestID/requestIDFromContext(metrics.go#L76-L91) — the same pattern used to carry the execution-metrics object (withExecutionMetrics/executionMetricsFromContext) throughchat(),runToolLoop(), and the tool shim. - Practically: every debug line for a single
ash "..."command shares the samerequest_id; the next invocation (a new process) gets an independent, unrelated one. There's no ordering guarantee across invocations — treat it purely as a correlation key, not a sequence number.
If you're staring at a log file with many interleaved invocations (for example a rotated scheduler log), the fastest way to isolate one run is:
jq -r 'select(.request_id == "3f1a9c2b7e0d4488")' /tmp/ash-debug.logEvery slog.Debug/Info/Warn/Error call in the codebase — and every error-carrying toolCommandResult literal in tools.go — carries a static EID field: an 8-character random alphanumeric string, generated once, at commit time, and then frozen into the source forever.
- Generation happens in
scripts/eid-injector, specificallymustRandAlphaNum(8)drawing fromcrypto/rand(main.go#L816-L824). - It runs automatically as a git pre-commit hook (githooks/pre-commit) that scans staged
.gofiles and injects a canonical"EID", "<8-char-code>"pair into any logging call that's missing one — see scripts/eid-injector/README.md for the standalone usage (go run ./scripts/eid-injector --allrescans the whole tree). -
make lint testenforces that every logging call has one (AST-validated), so you should never see a log line without anEIDin a build that passed CI.
Why it matters for debugging: log messages are free text and can be non-unique (the same message string might appear in more than one place, or be rephrased later). The EID is a stable, unique pointer to the exact source line that produced a given entry — much faster than searching for message text, and it survives message wording changes. Once you have an EID from a log line or an error message, jump straight to its source:
grep -rn '"iYWCHf8N"' --include='*.go' .This will land you on the exact slog.Debug(...)/slog.Error(...) call (or toolCommandResult{...} literal) that produced the line you're looking at — no need to guess which of several similarly-worded log statements fired.
Alongside the printed dashboard, logExecutionSummary (metrics.go) emits a single slog.Info("execution summary", ...) line carrying the same statistics as the dashboard, but as machine-parseable JSON fields instead of formatted text. It is gated by the same verboseLoggingEnabled() check as the dashboard — it only appears when ASH_VERBOSE is on — and is logged at Info level (not Debug), so it is easy to filter for on its own.
Fields: request_id, version (from executionDashboardVersion()), defaults_ms, connect_ms, connection_reused, ai_processing_ms, tool_calls, tool_duration_ms, tool_call_counts (per-tool-name map), sub_agent_calls, sub_agent_duration_ms, sub_agent_canceled, sub_agent_timed_out, sub_agent_failed, input_tokens, input_tokens_available, output_tokens, output_tokens_available, scratch_files_written, scratch_files_executed.
Use it when you want to aggregate or alert on execution stats across many invocations (for example scanning a rotated scheduler log for runs where tool_calls spiked or sub_agent_failed is non-zero) instead of parsing the printed dashboard text:
jq -r 'select(.message == "execution summary") | "\(.request_id) tool_calls=\(.tool_calls) sub_agent_failed=\(.sub_agent_failed)"' /tmp/ash-debug.logPrinted to stdout at process exit whenever ASH_VERBOSE is enabled, built by renderExecutionDashboard in metrics.go from the executionMetrics struct (metrics.go) that accumulates counters throughout the run. The header includes the running ash version via executionDashboardVersion(). Example:
ASH v0.14.0 EXECUTION SUMMARY
Loading defaults 12 ms
Connecting to AI server 45 ms
Connection reused yes
AI processing 820 ms
Tool calls 3 tools (140 ms)
by tool
ash_write_scratch_file 1
run_unix_command 2
Sub-agents 0 (0 ms), canceled 0, timed out 0, failed 0
Input tokens 512
Output tokens 128
Scratch files written
plan/notes.txt 1
Scratch files executed
plan/run.sh 1
Total realtime 1.10 s
Field-by-field:
| Line | Source of truth |
|---|---|
Loading defaults / Connecting to AI server / AI processing
|
Per-stage timers (metricsStageDefaults/metricsStageConnect/metricsStageAIProcessing), recorded via addStageDuration (metrics.go). |
Connection reused |
Whether the Connection-Sharing broker (or direct HTTP client) reused an existing TLS connection for this request. Connect timing (connect_ms/"Connecting to AI server") is measured precisely via an httptrace.ClientTrace hook in httpClientDoWithReuse (chat.go), and the "AI processing" stage timer starts from the end of that measured connect duration rather than a second independent clock read — so the two stages never double-count the same time window. |
Tool calls + by tool breakdown |
Total tool invocations and per-name counts, recorded by addToolCall(name, duration) (metrics.go#L121-L133), called from the tool loop in runner.go. The by tool section is omitted entirely when no tools were called. |
Sub-agents |
Count/duration/outcome of any run_sub_agent delegations for this invocation. |
Input tokens / Output tokens
|
Token usage reported by the AI provider, when available. |
Scratch files written |
Distinct scratch-file paths written via ash_write_scratch_file/ash_append_scratch_file/ash_edit_scratch_file, with per-path counts, recorded by addScratchWrite(relPath) (metrics.go#L139-L146). Omitted when nothing was written. |
Scratch files executed |
Distinct scratch-file paths passed as an argument to run_unix_command (for example bash /path/to/scratch/run.sh), recorded by addScratchExec(relPath) (metrics.go) via recordScratchExecArgs in tools.go. A scratch file's absolute path can never be the executable name itself (normalizeToolName rejects any value containing / or a leading .), so this only fires for interpreter arguments, not the allowlisted command name. Omitted when nothing scratch-related was executed. |
Total realtime |
Wall-clock time from the start of run() to this defer firing. |
The dashboard is plain text with no ANSI escapes when stdout isn't a terminal (writerIsTerminal, metrics.go) — safe to pipe or redirect for later inspection.
- Reproduce with
ASH_VERBOSE=1(addASH_LOG_FILE=/tmp/ash-debug.logif you want a clean terminal and a file to grep afterward). - Find the failing or suspicious log line; note its
messageandEID. -
grep -rn '"<EID>"' --include='*.go' .in a checkout of ash to jump to the exact call site that produced it. - Use
request_idto pull every line belonging to that same invocation out of a busy or rotated log file (see thejqexample above) and read the whole sequence in order. - Check the dashboard at the end of the run for aggregate confirmation — for example, "did
run_unix_commandactually get called twice?" or "was the scratch script I wrote actually executed?" — before digging further into individual log lines.
- Security-Model-and-Environment-Variables — the full environment variable reference, including logging hygiene guarantees (bounded metadata only, never raw payloads).
- Connection-Sharing — what "Connection reused" in the dashboard means and how the broker works.
-
README.md — the condensed
ASH_VERBOSE/ASH_LOG_FILE/ASH_LOG_MAX_BYTESreference and full environment variable list. - docs/ash.1 — the man page entry for these variables.
- AGENTS.md — repository-level note that the EID is a unique ID for every logging call, used to find the source of the log message.