Files
hmbown--codewhale/docs/PREVIEW_REQUEST.md
CodeWhale Bot 037aab9cf3 docs: align the v0.9.6 public surface
Document Work and To-do as the two product concepts, synchronize every shipped locale, publish the six-tool and typed-image contracts, and keep static web generation offline while live GitHub chrome refreshes after deployment.
2026-08-11 06:16:30 -07:00

29 KiB

/preview-request — see the outbound request without sending it

/preview-request renders a request manifest: a typed, redacted description of the request the next primary agent turn would send. It never sends that request, never appends to the conversation, and never writes engine, session, or Work state. /dryrun and /preview_request are compatibility aliases.

It is a human command. There is deliberately no model-visible tool for it.

/preview-request                      # session facts; route/body reported unavailable
/preview-request json                 # same manifest as JSON
/preview-request --prompt <text>      # the next turn for that prompt
/preview-request json --prompt <text> # both
/preview-request base-prompt          # exact base layer only; no runtime additions

Argument grammar

args   := flag* [ "--prompt" <whitespace> prompt ]
flag   := json | --json | manifest | --manifest | prompt | base-prompt | --base-prompt
prompt := every remaining byte, verbatim

Flags come before --prompt, which is terminal. Everything after it is prompt text — including a trailing json. /preview-request --prompt fix it json previews the prompt "fix it json" as a human table; the JSON spelling is /preview-request json --prompt fix it. There is exactly one reading of any input, and an unknown argument before --prompt is rejected rather than guessed at.

The prompt keeps your bytes. Repeated whitespace and newlines inside it survive; exactly one whitespace codepoint separates --prompt from its text and is consumed as syntax. Any additional leading whitespace, plus all trailing whitespace and newlines, remains prompt data. The prompt is hashed into the previewed body, so normalizing it would describe a request that differs from the real one in the one field you typed.

prompt remains a compatibility alias for the ordinary protected manifest. base-prompt / --base-prompt are an explicit, human-only disclosure mode: they print exactly the effective base-prompt bytes and nothing else. They cannot be combined with JSON or --prompt. Effective system text is never printed because it can contain project instructions, skills, and memory.

--prompt is necessary for an exact manifest, and not always sufficient

The next user message is part of the request. Without it there is no next-turn body to describe, and under auto model routing there is not even a route: the route is decided by the text you have not typed yet.

So the manifest is sectioned, and each section is either exact or typed-absent:

Section Exact when
session always — posture, gates, base-prompt provenance, requested model/reasoning
route --prompt was supplied, an active goal has not exhausted its token budget, a fixed route is selected, no message_submit hooks are configured, and the shared planner resolved that route
tools the route is exact and the MCP tool state can be snapshotted without connecting
body the tool surface and authoritative Work snapshot are exact and no runtime transform would rewrite the request first

Why a section stops being exact

Typed reason What a real turn would do that an inspection may not
auto-route-unresolved-until-next-prompt decide the route from text you have not typed
auto-route-classification-not-executed call the provider-backed Auto classifier; preview is strictly offline, so production must resolve it
no-hypothetical-prompt-supplied send a message the manifest does not have
message-submit-hooks-not-executed run mutable hooks that can rewrite or block the text — and therefore the route, tool policy, and body derived from it
prompt-resolution-failed fail the same way on skill authority or file mentions
route-plan-failed fail route resolution or preflight
mcp-state-not-snapshottable connect MCP servers and discover tools this catalog does not contain
runtime-transforms-before-send auto-compact, run context-overflow recovery, inject a background-shell completion, allow a running or terminal-undelivered child to complete, or flush pending LSP diagnostics before the first request
work-state-not-snapshottable read the current graph-backed Work projection; preview never substitutes an asynchronously published, potentially stale To-do view
goal-token-budget-exhausted stop the active goal before dispatch because durable token usage has reached the configured budget
goal-state-not-snapshottable decide whether the active goal's terminal budget gate permits another request
request-preparation-failed build no body at all

mcp-state-not-snapshottable takes the body with it. A catalog missing its MCP contribution is not "the same request with no MCP tools" — a real turn connects, and may send a different tool list, a different tool region, and therefore a different body and hashes. The body inherits the tool section's reason rather than publishing an exact hash of a request that would never be sent. The route section survives: the endpoint, dialect, and wire model do not depend on which tools are on the request.

Detection is read-only. Nothing is drained, received, flushed, or compacted to find out: eligible running and terminal-undelivered children are checked, LSP blocks are checked for emptiness, the shell manager is checked without polling or marking completions reported, and the compaction decision is evaluated against the borrowed hypothetical message list with its active slop gate pinned. Inspecting the pending state does not consume it.

Without --prompt the route section is unavailable even on a fixed model. That is deliberate: a route is only reported when it was resolved by the same planner that would send the turn, for the same next message. A route that was "probably still the current one" is exactly the kind of almost-true fact this command exists to avoid.

An unavailable section publishes a typed reason and no fields. When auto routing is unresolved there is no provider_id, route_id, dialect, endpoint_host_class, endpoint_fingerprint, wire_model, billing, tool_surface_budget, or body_sha256 anywhere in the JSON — not a null, not the previous turn's value. requested_model reads auto, because that is what you actually selected.

What runs, and what does not

With --prompt, the preview executes the deterministic part of the production path up to (but not including) the send:

  1. The prompt is resolved into model-facing content exactly as a real submit does — the pending active skill it would be wrapped with (cloned, not consumed), file mentions, git mentions, and the paused-command note — with the same error propagation. What a submit does that an inspection may not is run message_submit hooks; when any are configured the manifest says so and claims nothing downstream of the text.
  2. For a fixed route, that content goes through the same shared route planner (plan_turn_route) that spawned_dispatch_inner uses for a real turn: effective provider and model, route identity resolution, preflight, route limits, compaction policy, and reasoning-effort normalization. Auto stops before this step because the planner would call a model classifier.
  3. The engine projects the planned route into a throw-away client — the same client construction a turn installs, without installing it.
  4. It rebuilds the tool catalog and narrows it with the same planner the turn loop uses, composes the system prompt for that route's model and context window, and appends the hypothetical user message through the same constructor production uses (turn metadata, route stamp, and provenance), then resolves an auto reasoning tier against those messages the way the turn loop does. Production sends stored history and nothing else — Codewhale does not re-state the To-do list on model steps — so the previewed outbound message list is exactly that list, and one estimate over stored messages plus system covers both the manifest number and the overflow decision.
  5. It prepares the request through DeepSeekClient::prepare_outbound_request and describes the result — unless a runtime transform would rewrite it first, in which case the body is typed unavailable instead.

Nothing is installed, not even briefly. Everything a turn installs before building its request — the command-scoped tool gate, the effective mode and approval posture, the policy-narrowing event, the working set with the new message observed — is passed as a value or snapshotted onto a clone. There is no write-then-restore: a restore is not atomic across an await, and it does not survive a cancellation or a panic. Terminal continuation state is read without mutating its counters. A regression test asserts that config, caches, session messages, model, system prompt, working set, provider, mode, and the MCP pool are all byte-identical afterwards.

No outbound call can happen. With a fixed model, planning and request preparation are local. With Auto selected, the command reports auto-route-classification-not-executed and stops before the shared planner, because resolving the route would call a model classifier. Preview never reads or populates the classifier response cache and never changes provider retry or rate-limit state.

Nothing else has a side effect. The tool-catalog build runs in a passive mode: it never creates the MCP pool, calls connect_all, reloads an MCP config source, starts a server, spawns a sub-agent runtime task, captures a fork snapshot, or emits a UI status event. When the connected MCP state is not already exactly what a turn would use — no pool yet, a config source changed, or an enabled server is not connected — the tools section reports mcp-state-not-snapshottable instead of connecting to find out.

Scope: primary agent turns only

This manifest describes LlmClient::create_message / create_message_stream — the model turns the agent loop runs. It does not describe Codewhale's auxiliary provider calls, which have their own shapes:

Auxiliary call Status
Chat-dialect translation (translate) Not on the prepared seam: it builds a small fixed body (no tools, temperature 0.1) directly. Out of scope.
Anthropic/Responses-dialect translation Routed through prepare_outbound_request to avoid a second builder, but still an auxiliary call and still out of scope for this manifest.
FIM completion, speech, provider-native search, /models listing Separate endpoints and bodies. Out of scope.
Auto-router classifier A separate small turn on the router route. Out of scope and never executed by preview.

Any claim that "every outbound request" goes through the previewed seam would be false, and this document does not make one.

Where the numbers come from

The prepared outbound request. Every primary model turn reaches the wire through DeepSeekClient::prepare_outbound_request, which returns a PreparedOutboundRequest: dialect, endpoint identity, canonical wire model, the final body, and a reasoning receipt. Production dispatch sends that value; the preview describes it. There is no second body builder.

Parity tests do not feed a captured logical request back through that builder. They run a real production turn against an HTTP mock, parse the first body the server actually received, canonicalize those captured bytes independently, and compare that hash with preview. The coverage includes translated prompt context, paused-command detach, and native Anthropic Messages shaping.

Preparation runs the full production sequence: tool-history repair and model-bound secret redaction, protocol binding and route model re-resolution, the dialect's own body builder with every provider-specific sanitizer and reasoning shaper, and exact endpoint resolution.

Every production dialect is preserved end to end — nothing is projected through Chat Completions:

Dialect Routes
chat-completions DeepSeek, Moonshot/Kimi (including the Kimi Code K3 nested thinking.effort shape and the direct K3 fixed-sampling shape), Z.ai, xAI, OpenRouter, vLLM/Ollama/SGLang, OpenCode Zen chat routes, custom compatible endpoints
anthropic-messages Anthropic, DeepSeek Messages, MiniMax Messages, OpenModel
openai-responses OpenAI Codex (ChatGPT backend path), OpenCode Zen responses routes

The manifest reports the dialect and the route shape (standard, deepseek-beta-strict-tools, kimi-code-k3, direct-moonshot-k3, codex-responses, opencode-zen, custom-compatible), so you can see which builder branch actually ran.

The engine. The manifest is built by the engine, not the command layer, because only the engine can rebuild the exact next-turn tool catalog, active subset, gates, permission posture, and tool choice. The session's last tool catalog is never consulted — it is one turn stale and stores the pre-activation catalog.

Streaming is reported as a wire fact

caller_entrypoint says which transport entry point was described (streaming / blocking). body_stream_field says what the body says, read off the finished JSON:

  • Chat Completions streaming → true; Chat blocking → the field is absent (null), because the blocking body never carries it.
  • Anthropic Messages → mirrors the caller.
  • OpenAI Responses → always true, including on the blocking entry point, which opens an SSE stream and folds it into one response.

The manifest describes the body field exactly rather than inferring it from the caller, so the Responses blocking case cannot be misreported as a non-streaming request.

tool_choice is likewise read from the finished provider body, not the logical request: Anthropic may carry an object, Responses carries its mapped string, and DeepSeek thinking requests omit the field entirely.

What the manifest tells you

Section Fields
session exact primary-agent role/lane/Fleet non-assignment, requested model (auto when auto), routing mode, requested reasoning, whether a hypothetical prompt was supplied, mode, approval posture, allow/deny gate sizes, base-prompt origin + bytes + SHA-256
route provider id + display name, named route id, typed routing source, dialect, route shape, safe endpoint host class/digest, endpoint fingerprint, wire model, caller entrypoint, body stream field, context ceiling + source (configured, provider-reported, static floor, catalog, or fallback), route input/output limits or unknown, typed billing
tools active count, catalog / deferred counts, logical catalog SHA-256, surface budget, Standard-vs-Full collapse, MCP servers and MCP tools
body reasoning resolution + wire control keys + wire effort and the key path it came from, tool_choice, system-prompt assembly + effective canonical JSON bytes/SHA-256, body/system/tool-schema/message/tool-result/attachment/framing canonical JSON sizes, per-class estimates, exact input-budget ceiling and headroom, literal wire output cap or unknown, provider-reported usage explicitly unavailable because no request ran, whole-body SHA-256, wire tool-schema SHA-256, local system/tools component SHA-256

Counts and estimates are extracted dialect-aware: a Responses body's instructions/input, an Anthropic body's system/messages, and a Chat body's inline system-role message are each read from where that dialect actually keeps them.

The byte classes are an exact accounting, not byte slices

system + tool_schemas + messages + framing == body_canonical_json_bytes, exactly, in every dialect and on both entry points. The first three are the canonical serializations of selected JSON values; they are not four disjoint ranges borrowed from the body buffer. framing is the algebraic remainder after those selected values. It includes every other top-level field and whatever JSON structure was not already counted inside a selected array value. An invariant test asserts the sum identity, and mutation tests check the intended attribution. Do not use these counts to reconstruct the request bytes.

tool_result and attachment bytes are subsets of the message bytes, reported for attribution and never added again.

Headroom is measured against the input budget

estimated_input_headroom_tokens subtracts production's conservative messages-plus-system estimate from this route's input budget ceilingcontext_input_budget_for_route, the same seam the turn loop checks before it sends, which is the context window minus the output reservation and the safety headroom. It is not the raw context_limit_tokens: subtracting input from a window the route also has to fit its response into reports headroom the turn does not have. The value goes negative when the request would not fit, rather than clamping to zero and reading as "it fits" — and when it does, the body is reported unavailable, because the turn loop would run context-overflow recovery and send something else.

This production gate is distinct from the manifest's independent conservative estimate over canonical JSON body bytes. The latter remains useful for provider-body attribution, but it does not decide overflow or headroom.

The manifest publishes that exact ceiling as input_budget_ceiling_tokens. It also keeps three different limit facts separate: the context ceiling and its resolver source, optional route/offering input and output limits, and the output cap literally serialized on the wire. If a route or dialect supplies no such fact, the value is unknown; preview never fabricates one from a neighboring model or an installed route.

Reasoning controls are read wherever the dialect puts them

reasoning_wire_effort reads the flat reasoning_effort, Kimi Code's nested thinking.effort, the Responses reasoning.effort, and the Anthropic output_config.effort, and reasoning_wire_effort_source names which one it came from (a compile-time constant, never a key lifted out of the body). Reporting only the flat key made the routes that think hardest read as "no effort sent".

reasoning_resolution distinguishes an explicit user selection from a route-default control the user never asked for, and reports not-applicable when the body asks for no reasoning at all. A Responses include field discloses reasoning output rather than requesting a tier, so include alone is never reported as a reasoning request.

The whole-body hash

body_sha256 covers the complete canonicalized wire body, not a prefix. It changes when any of these change: max-token fields, tool_choice, nested reasoning controls, provider-transformed tool schemas, attachments, stream options, sampling parameters, or any message — including the appended hypothetical prompt. Canonicalization sorts object keys, so builder insertion order alone does not move the hash. Any real input change does, including date/working-set/git metadata, runtime injections, tool discovery, prompt settings, or routing.

tools.active_tool_catalog_sha256 is a separate, stable hash over the current active tool catalog before dialect shaping (name, description, canonical logical schema, in order). It moves on membership, ordering, and logical schema changes. It is a catalog identity, not a wire fact: two routes can agree here and still send different bytes, because each dialect transforms schemas its own way and strict mode sanitizes them further.

body.tool_schema_wire_sha256 is the hash of the tools region as the provider receives it. body.local_system_tools_component_sha256 combines that digest with the final wire system-region digest as a local comparison fingerprint. It is not a provider cache key, does not claim those regions are adjacent, and carries no route-specific cache-semantics guarantee. It is omitted when the tool surface is not exactly known.

Disclosure boundary

The manifest is a fixed set of counts, hashes, enums, and short provenance labels. It has no field that can hold free-form request text. It cannot contain:

  • the system prompt, project instructions, memory, or skill content;
  • message content, tool-result bodies, or attachment payloads;
  • credentials, Authorization headers, or query strings;
  • URL paths (which can themselves carry a deployment secret);
  • absolute workspace or home paths.

Identifiers are not trusted either. A custom route id and a model id are user-authored text that can be an absolute path, a URL, a URL path, or a deployment id that is itself a credential. Every such value crosses an allowlist boundary (crate::safe_label) before it is printed: an generic identifier is published verbatim only when it contains no slash. A slash-bearing model id additionally has to match an exact entry in the active local model catalog; a vendor-looking prefix is not sufficient. Anything else is replaced by a stable sha256:<12 hex> fingerprint. Two previews of the same hostile id still compare equal; the id itself is never shown.

Error text is not trusted either, and is not scrubbed — it is allowlisted. Preflight, MCP, prompt-resolution, and request-preparation failures all interpolate host text, and that text routinely carries a route id, a server name in quotes, an endpoint whose path is the secret, or a raw credential. Every whitespace-separated token has to earn its place:

  • a token containing a control character is dropped;
  • a URL keeps only scheme://host[:port], and only when both are themselves ordinary — path, query, fragment, and userinfo are never published, and a token-shaped "host" makes the whole token opaque rather than half-published;
  • anything path-shaped (POSIX absolute, ~/, Windows drive, or containing a backslash) collapses to <path-redacted>;
  • a token carrying ", ', or a backtick is replaced wholesale — quoted spans are where hostile identifiers hide;
  • everything else must be a short ordinary word (ASCII alphanumerics plus -, _, ., bounded in length, rejected if it looks token- or key-shaped), with only sentence punctuation allowed at its edges.

Anything else becomes <redacted>, runs of redactions collapse, and the result is truncated. Ordinary diagnostic sentences survive intact; hostile ones become a generic shape.

Endpoints are published two ways only: a bounded host class (http loopback or https remote sha256:<12 hex>) and a SHA-256 fingerprint of the full URL for "same endpoint?" comparisons. A remote authority is always digested — it may be a credential-shaped tenant subdomain — and paths, IDNs, userinfo, queries, and fragments are never shown.

This is an inspectability slice, not a request-body export. A regression test asserts that no manifest field carries a serialized message array or tool schema.

Estimates are estimates

Every token number is an offline estimate (~4 bytes/token plus a 5% conservative margin), never a provider-authoritative count. Use them to compare requests to each other, not to predict a bill. Byte counts, hashes, and counts are exact.

Tool-result and attachment estimates are subsets of the message estimate and are reported for attribution; they are not added again into the total.

Base-prompt provenance (#3928)

The protected manifest distinguishes three things without printing effective system text. The explicit /preview-request base-prompt mode separately prints only the exact effective base-prompt bytes:

  • Origin — where the base-prompt bytes came from:
    • bundled in this codewhale-tui build (BASE_PROMPT, compiled in)
    • config-directory override installed at startup (prompts/constitution.md, opt-in enabled)
  • Assembly — how the effective prompt was built on top of that base: base prompt only, base prompt + configured static layers, or base prompt + configured layers + runtime/session additions.
  • Effective hash — the SHA-256 of the system region of the prepared request: the prompt in its final wire form, not an independently recomposed string.

In a live session the assembly is normally base prompt + configured layers + runtime/session additions, because the environment block, project context, skills, and memory are appended after the constitution. Codewhale does not claim the configured constitution is the effective base prompt, and no diagnostic cites a source-tree path that does not exist on an installed binary.

Tool-surface labels

standard_and_full_surfaces_collapsed is derived, not asserted: the surface shaper is run over the actual current catalog under both the Standard and the Full budget and the results are compared. Today it reports true — the two budgets produce the same catalog — and the manifest says so in plain words rather than implying a difference. The day the shaper narrows Standard differently from Full, the field flips on its own with no copy edit.

Any benchmark claim about one surface offering "more tools" has to show a different active_tool_catalog_sha256 first.

Comparing routes before you spend anything

/preview-request makes a provider-free fixed-route A/B possible: switch route, preview with the same prompt, compare.

  1. Select the route (/model, /provider, or your profile) — do not send a turn.
  2. Run /preview-request json --prompt "<the same text each time>" and save the output, e.g. glm-5.2.json.
  3. Repeat for each route.
  4. Diff the manifests.

What to read in the diff:

  • route.dialect / route.route_shape — two routes on different dialects are sending structurally different requests, not the same request to a different host.
  • route.wire_model — the id that actually goes on the wire. Router entries frequently differ from the label you selected.
  • tools.active_tool_count / body.tool_schema_wire_sha256 — identical wire hashes mean identical tool bytes, whatever the surface label says. Use tools.active_tool_catalog_sha256 only to compare logical catalogs; two dialects can agree there and still send different schemas.
  • body.estimates.tool_schemas vs route.context_limit_tokens — the fixed overhead each route pays before the conversation starts, against the window it has.
  • body.reasoning_wire_control_keys / body.reasoning_wire_effort / body.reasoning_resolution — whether the route is actually being asked to think, in which dialect, and whether that came from you or from auto routing. Two routes with different effective effort are not comparable.
  • body.body_sha256 — the whole request. If it is unchanged, nothing about the outbound bytes changed.
  • body.local_system_tools_component_sha256 — whether the two locally measured wire components changed. It does not prove provider cache reuse or invalidation.
  • route.billing — subscription quota and metered API routes are not cost-comparable, and unknown means cost reporting fails closed.

Each manifest is exact for the snapshot it describes. Repeated previews are identical only when every contributing input is identical: route, current date, git and working-set metadata, prompt settings, tool/MCP state, session history, and pending runtime transforms. Previewing does not mutate session history or the response cache, but no cross-call byte-stability claim is made. Auto is intentionally unavailable and cannot be used for this comparison until production resolves a concrete route.

schema_version is bumped whenever a field is renamed or removed, so scripted consumers can detect an incompatible manifest instead of silently reading null. The current shape is 9: v8's work-state-not-snapshottable unavailable reason is gone, because no request carries a To-do block for a snapshot to fail on. The active goal token-budget terminal gate remains an explicit fail-closed dependency of an exact outbound request.

What is still approximate

Auto routing is not approximated: its provider-backed classifier is never run by preview, and the route-dependent sections are typed unavailable instead.

Working-set drift used to be listed here. It no longer applies: a real submit calls working_set.observe_user_message before it builds <turn_meta>, and the preview now performs that same observation on a clone of the working set and builds the block from that snapshot. Same bytes, no session write.

Everything else that used to be "approximate" is now typed. If a runtime transform would change the request, the body section says so and publishes no bytes at all, rather than publishing numbers that are nearly right.

Credit

The dryrun concept — preview the next request from the real request-building seam rather than a hand-rolled summary — was harvested from PR #1099 by @GTC2080 (TaoMu). No code from that PR is reused; the implementation here is written against Codewhale's current multi-dialect client.