Design
Overview
Every face of wisp sets up through Session.begin and opens its conversations from the session it
returns; every tool call the model makes passes the audit wrapper, and a command also passes the policy,
the gate, and the sandbox:
flowchart TD
cli["wisp respond, wisp chat"] --> begin["Session.begin"]
tui["wisp-tui"] -->|"JSON Lines"| json["wisp chat --json"]
json --> begin
client["MCP client"] -->|stdio| server["WispServer"]
server --> begin
begin -->|"one per face, or per thread_id"| conv["WispThread: gate, tools, prompting"]
conv --> agent["Agent"]
agent -->|"ContextComposer, from ThreadRecord"| lms["LanguageModelSession"]
lms -->|"tool call"| audited["AuditedTool"]
audited --> tools["run_command, read_file, edit_file, and the rest"]
tools -->|run_command| runner["CommandRunner"]
tools -->|"read_file, edit_file"| gate
runner -->|"policy passed"| gate["ApprovalGate: classifier, then Approver"]
runner -->|"cleared"| sandbox["sandbox-exec /bin/sh -c"]
runner -->|"outsideSandbox approved, or typed after !!"| plain["/bin/sh -c, unconfined"]
audited -.-> audit["AuditLog"]
gate -.-> audit
runner -.-> audit
The framework owns the agent loop. When the model emits a tool call, LanguageModelSession decodes the
arguments into the tool’s @Generable Arguments type, invokes call(arguments:), appends the result to the
transcript, and continues generation. Agent therefore contains no loop of its own; it guards
availability, composes the transcript each request carries, and shapes the API. read_file and edit_file consult the gate without CommandRunner;
current_date, inspect, notify, and system_info do not consult it (see “Tools” below).
Repository layout
| Path | Contents |
|---|---|
harness/ | Swift package: the wisp binary, WispCore, WispMCP, and the model backends |
harness/Evals/ | A second Swift package, depending on the harness by path: the model evaluations (ModelEvalTests), which scripts/check eval runs and the gate never builds |
tools/ | Cargo workspace: one crate per Rust tool binary |
docs/ | This documentation and the ADRs, and the source of the documentation site (an mdBook, book.toml at the root; ADR 0065) |
training/ | Labelled train, dev, and test sets for the fast classifiers; only risk/train.tsv is built in, as Resources/risk-examples.tsv |
site/ | The website around the docs (ADR 0065): the homepage, the book’s theme, and palette.css, wisp-tui’s colours on the web |
scripts/check | The quality gate for both toolchains; scripts/docs-links is its check of the site’s sources |
Targets
| Target | Kind | Responsibility |
|---|---|---|
WispCore | library | All model-facing logic, grouped by folder: Session/ (session and session host, thread, agent and its condensing, assessment, and the commands the person types in chat, thread record, context composer, target condensing, output references, context view and archive, the presentational-text finder, memory and recall, model selection, listing, routing, and context sizing, the model service fault (ModelServiceFault, Apple’s modelmanagerd refusing requests, recognised for wisp doctor and the evals), context policy, tool registry and catalogue), Exec/ (command runner, policy, splitter, regex cache), Approval/ (gate, classifiers, store, threshold, the out-of-band approver and the pending channel), Audit/ (events, details, log, turn clock, diagnostics, receipts and turn calls, call statistics, the event relay, the tool event trail, the log tail), Condense/ (the condensers, the secret rules, redaction and the model sweep, the personal-data classifier and its training), Facts/ (facts: the model, the book, subject kinds and normalisers, extraction, distillation, composition, the shared stores, the report, the running summary and its writer, the agent’s fact and summary operations, and FactKeeper, which applies the person’s answer to a caller’s request to keep a fact), Tools/ (the tools, the file reader, and the audit wrapper), Config/ (config, home, transcripts), CLI/ (the chat loop, its input, completion, events, status, and JSON Lines protocol, the pending relay, doctor, and the table layouts, here so they are testable), Support/ (timeout, ids, names, paging, the notifier and its routes, the file watcher, process and memory state). |
WispCoreAI | library | CoreAIBackend: models exported to Apple’s Core AI format, through the bridge in apple/coreai-models. Registered by the executable at launch so WispCore never links it. |
WispMLX | library | MLXBackend: models in MLX or Hugging Face layout, compiled in only under the MLX package trait (Metal toolchain), which the release is built with; otherwise registered but refusing with the reason. It sizes each model’s window from config.json (ADR 0043’s rule) and runs it through MLXModel, wisp’s own executor, or, with mlx.executor: "bridge", through mlx-swift-lm’s bridge. MLXModel hands each request to the model directory’s PrefixEngine, an actor that owns the tokenizer, the weights, and a PromptCachePool of one processed prompt per thread, reusing the longest common prefix (PrefixPlan); the engine is generic over PromptRuntime, so everything but MLXPromptRuntime and MLXPromptTokenizer (the one file under #if MLX) is tested over a fake runtime. The engine splits a reply’s text into thinking and reply by the chat template’s tags (ThinkingFormat, ThinkingSplitter), and MLXModel sends the thinking through a ThinkingStretch as Ollama’s executor does (ADR 0053). ModelPull fetches MLX models for wisp models pull into the Hugging Face cache (HubCache, huggingface_hub’s layout, reusing what is there) and links the models directory to the snapshot (ADR 0052); PublisherCheck decides whether the repository’s publisher may be pulled: one in mlx.trustedPublishers (TrustedPublishers in WispCore; mlx-community always) at once, any other only after the person answers once or trust, or with --trust-publisher (ADR 0052, amended 2026-10-09). MetalLibrary repeats MLX’s search for its Metal library for wisp doctor’s MLX finding (ADR 0047). |
WispMCP | library | WispServer and ToolCatalog: exposes wisp over MCP. Depends on WispCore and the official MCP Swift SDK. |
wisp | executable | Argument parsing and stdin/stdout only. Subcommands respond (default), chat, tools, models, mcp, logs, config, doctor, approvals, facts, questions, notify, scan, redact, watch, draft, classifier, completions. Session set-up is Session.begin in WispCore. |
EmbedSystemPrompt | build-tool plugin | Embeds Resources/system-prompt.md (and the other text resources, among them the shell completion scripts in Resources/completions/ that ShellCompletions serves to wisp completions) into WispCore as string constants at build time, and, before every build, writes BuildInfo (the git commit, whether the tree is modified, whether WISP_RELEASE=1) which WispVersion.display formats for --version. wisp-tui’s build.rs does the same for its own --version. |
WispTestSupport | library, tests only | ScriptedModel: a LanguageModel that answers from a script, so the agent, tool loop, and MCP server run in tests with no model; and ContextEval, the context eval’s scenarios and fixtures, which ModelEvalTests in harness/Evals drives on a real model. |
WispCoreTests, WispMCPTests | tests | swift-testing suites for model-independent logic; WispServerWireTests drives the server through a real MCP client on an in-memory transport. |
Components
ModelSelection and ResolvedModel
Local runtimes are ModelBackends in the ModelBackends registry, keyed by scheme; ModelSelection.local
is spelled <backend>:<name> and resolves through the registry. A ResolvedModel carries the model’s
declared capabilities and their source, and WispThread.openAgent refuses a request that needs tool
calling the model did not declare, then records model.resolved; see
ADR 0019. Any LanguageModel can be wrapped by
ResolvedModel(selection:custom:). OllamaModel is the first backend:
its Executor maps the transcript onto Ollama’s chat API (system, user, assistant with tool calls, tool
messages), sends tool definitions as JSON Schema and an output schema as format, and streams chunks back
as response and toolCalls events with usage at the end. A reasoning model’s message.thinking chunks go back
as reasoning events, counted as its reasoning tokens, and a ThinkingStretch tells the turn’s
ReasoningObserver (a task local Agent binds around each turn’s requests) when thinking begins and ends, which it
records as model.reasoning; the framework keeps the thinking as a reasoning transcript entry, the store as the
turn’s .reasoning entry, and ContextComposer never composes it into a request, nor does the executor send one
back (ADR 0053). A reply’s text that may be tool calls written in
Mistral’s name[ARGS]{…} format is held back and, when it is exactly such calls, sent as toolCalls events instead
(TextToolCalls, whose strictness rules MLX’s ToolCallRecovery shares). resolve checks the server lists the model
(blocking briefly, because agents are created synchronously). The framework’s tool loop, streaming,
transcript, and guided generation are unchanged above it. See
ADR 0016. The transcript mapping is ChatMessage, which
MLXModel’s executor shares: it renders the same messages through the model’s chat template, hands them to the
model directory’s PrefixEngine with the thread’s slot, and reports usage in the same shape, the reused prefix as
cached input tokens (ADR 0052).
OpenAICompatibleModel is the backend for runtimes that serve OpenAI’s chat-completions API, llama.cpp’s
llama-server (llamacpp:) and LM Studio (lmstudio:), registered as two OpenAICompatibleBackends, one per
OpenAICompatibleDialect; the dialect holds what differs (default port, where the runtime describes its models,
whether it takes think, whether its models’ capabilities are declared). Its executor maps the same ChatMessages
onto OpenAI’s messages (positional call ids, each tool output answering its call’s id), reads the server-sent
events, gathers tool-call fragments by index, and, like Ollama’s executor, hands thinking, text, and calls to a
ReplyRelay, which owns the ThinkingStretch, the argument completion, and the hold on calls written as text for
both. A connection’s failure is named by ConnectionFailure for both, and a server’s refusal of a request larger
than its window becomes contextSizeExceeded, which Agent condenses for. The window is the one the server reports
it holds the model at (ADR 0058).
ModelSelection names the model (system, private-cloud, or <backend>:<name>); resolve() checks
availability and returns a ResolvedModel, which erases the concrete LanguageModel behind session
makers and an optional token counter. See ADR 0013.
The models the operator turned off (models.disabled) are a session’s DisabledModels, read when
Session.begin loads the configuration and replaced by Session.setModels, which changes the file through
ConfigEdit and, for a model a backend lists as unlinked, links it through ModelBackend.link (MLX:
ModelPull.cachedPlan and the pull’s link). Session.begin refuses a disabled --model, and
WispThread.openAgent a disabled model however it was named, before anything resolves. ModelListing judges every
candidate (each backend’s installed models, the disabled ones, and the unlinked) into Entrys carrying every known
fact, and ModelTable lays them out for each face: the terminal, fitted by TerminalTable.fitting; chat’s
TextTable; tab-separated fields; --json; and the ChatChoice with toggles that wisp-tui’s picker draws.
After enabling, where Session.setModels(checking: true) links such a model but leaves its enabling to the check,
and for wisp models check, Session.checkModels checks what a model can do when its backend leaves
that to config.json (ModelBackend.declarationKeys, MLX’s mlx.models.<name>): ModelVerification resolves the
model once per question with only the capability it tries declared (ModelBackend.declaring), asks each within
Timeout.run, and decide turns the results and the file’s declaration into what to record, keeping what the
person declared by hand; from enable, the outcome (refused, text only, usable) keeps the model in
models.disabled or takes it out. The declaration is written by keys through ConfigEdit.set(keys:) and held in the session’s
DeclaredModels, which WispThread.openAgent and chat’s listings apply to the configuration, so the model is usable
in that chat at once (ADR 0056, refined 2026-10-04).
Session.openChatAgent opens chat’s conversation, on system (ModelFallback, audited as model.fallback) when
the configured model is unavailable and was not named; respond and MCP open through openAgent and
Session.thread, which never fall back (ADR 0056).
ModelActions is the one layer every face takes these through (ADR 0059):
wisp models … (face cli), chat’s /models and /model and wisp-tui’s picker (chat), and the model’s
ModelsTool (tool). Each Action (list, show, pull, trust, enable, disable, check, switch) declares
what it reads and changes, whether the model may ask for it, and the widest scope its approval is kept for; the
actions call Session.setModels, Session.checkModels, Session.trustPublisher, DisabledModels.check, and
PublisherCheck, recording in the asking conversation’s log with the face as source. A pull is WispCore’s
questions around a ModelPuller the executable registers (ModelPullers; MLXPuller over ModelPull): prepare
lists the repository into a PreparedPull (a PullSummary and closures over the runtime’s plan), and
ModelActions.pull screens the publisher, puts the question through the face’s PullDialog (the terminal’s lines,
chat’s notes and ChatChoice), asks about the download, and runs the fetch in a child task that the dialog’s
CommandStop cancels, as Ctrl-C stops a typed command, keeping the part of the file for the next pull to resume;
progress is PullProgress, which chat shows as its ChatActivity, read from the file’s part by a ticker and as each
file starts, and delivered under one lock (FetchProgress) so it arrives in order and never goes back. The model’s pull, requestPull, puts one approval
request through the conversation’s gate (ApprovalGate.clear(modelAction:subject:reasons:workingDirectory:), of kind
ApprovalRequest.Kind.modelAction), then starts the fetch in the session’s ModelPulls and returns; its end is posted
to Session.notices (ConversationNotices), which chat shows as it arrives and the agent stores as a
ThreadRecord.Kind.notice entry when the conversation’s next turn begins.
Agent
Keeps the conversation in a ThreadRecord and asks a ContextComposer for each request’s
transcript, which a LanguageModelSession created by a ResolvedModel then runs; resolve() checks
availability and throws ModelSelection.Failure.unavailable rather than letting the first request fail
obscurely. An agent can also start from a saved Transcript, or from another agent’s store (/model).
respond(to:)returns aReply: the text and whether the turn was condensed.stream(_:onDelta:)invokes a callback with each new fragment and returns the sameReply. Snapshots are cumulative; if a retry after mid-stream overflow starts an answer that does not continue the text already shown, a newline separates the two. It is a callback rather than anAsyncSequencefor a concurrency reason recorded in ADR 0003.- On context overflow the
ContextPolicy(default: condense to a token target,TargetCondensing; phase 2’s fixed four turns remain as.fixed) rebuilds the session from a condensed transcript and retries once;condensationscounts recoveries. See context-management.md and ADR 0008. transcript(the composed view the next request carries),store,contextTokens(), andreset()support saving, budgeting, and starting over.
ThreadRecord and ContextComposer
The layered-context proposal separates the conversation as stored from the context each request carries (proposal; phase 2 built the structure, phases 3 and 3b output handling). One turn, as the agent runs it:
- The prompt is audited. Every stored tool output not yet sent as a reference becomes one from this turn
on (
ContextComposer.newReferences): the agent marks it in the store (referencedAt, this turn) and recordscontext.reference. An output no longer than its reference stays whole. When the agent keeps facts (Agent.facts), it renders the facts in force into aFactFrame(ContextComposer.factFrame). - The composer decides whether to condense ahead of the window, over the literal turns alone, and the
agent applies it: saves the archive, records
context.condensation, distils the prose of the turns it drops into facts with one model call in a session of its own (FactDistiller, audited ascontext.distillation), and, when the dropped turns not yet summarised come to a batch (ContextComposer.summaryBatchTurns), writes the facts and the updated running summary in that one call instead (SummaryWriter, audited ascontext.distillationandcontext.summary;Agent.handOn), marks the dropped entries in the store with the condensation and this turn (droppedAt), and renders the frame again. For a model that reports usage, the estimate subtracts what step 1 saved. Under the default policy the decision and the steps areTargetCondensing’s: due when the context, the prompt, and the next turn’s headroom reach the budget; then references, distilling, and dropping the oldest turns, each measured, until the context is at or below its target or only the last turn is left (the floor), where the earlier block is squeezed and, if that is not enough, the person is told (Agent.contextNote). The agent is the loop’sCondensingHost; the event is recorded after the steps. - The composer builds the request’s transcript: the store’s active entries, with each reply’s cut
presentational text replaced by its marker (step 6) and each tool output replaced by its reference
(
OutputReference: tool, entry, time, status, size, the call’s arguments, first and last lines), and the frame’s two prompt-side entries: the earlier block after the instructions, the now block before the request. - The agent publishes its store, facts, subject kinds, and turn to the conversation’s
MemorySource, when it hasmemory(Agent.memory), then puts the session over the composition, continuing the live session when it already holds exactly that, and starting a new one otherwise. The framework runs the tool loop, which carries this turn’s own outputs whole; an overflow condenses the active view as it was before the prompt and retries once. - The entries the session added, whether the turn succeeded or failed, go into the store, each with
references (
AuditReference: session, turn, and the event’sid) to the audit events that recorded it, and its time. Tool events come from the conversation’sToolEventTrail, anAuditSinkeveryWispThreadtees its log into; it keepstool.call,tool.result,policy.decision,command.outcome, anderror. The frame’s entries are not stored. From the same tool events the agent extracts facts without a model (FactExtraction) and records them, then renders the frame again, and counts what the turn ran (TurnToolSummary, ADR 0051): the line every face shows beside the reply, asAgent.Reply.ran. Beside it,CitedEntriesreads the entry numbers the reply cites in wisp’s reference forms and keeps those the store does not hold (Agent.Reply.unknownEntriesandcited, ADR 0055). Third, the verification pass (ReplyVerification, the exact readers, andReplyVerifier, the model step behindverification.mode) checks the results the reply reports against the turn’s complete call list (TurnCallsover the same events, each call’s whole output read from theOutputStore), with earlier turns’ calls as a second support kept in the store’s continuity; what it finds isAgent.Reply.verificationandverified, and is recorded asresponse.verification(ADR 0060, verification.md). The trail also keepsmodel.reasoning, whoseendevent a reasoning entry refers to (ADR 0053). - After a turn that succeeded, the composer looks in each of its replies for presentational text:
a stretch that reproduces one of the turn’s tool outputs exactly, formatting aside (
Presentation). The agent marks each stretch on the reply’s store entry as aCut(segment, byte range, the output’s store id) and recordscontext.cut. The reply returned to the caller and the entry’s value stay whole; from the next request on, the composer sends the marker instead. Because a rewritten reply or output keeps its id, the agent compares replies and outputs by content as well as ids when deciding whether the live session still holds the composition.
The store is a value type the agent owns, in memory only: it caches each entry’s framework value so
composing never reads the audit files, and the audit log remains the only verbatim record on disk
(decision D8). The composer is pure; it holds the ContextPolicy, the budget, and whether it cuts
presentational text (cutsPresentation) and sends outputs as references (referencesOutput), both on by
default; ContextEquivalenceTests runs with both off to prove phase 2’s structure unchanged. Cuts,
times, and the turns entries were dropped or referenced from are saved with the store’s links
(transcripts/<name>.store), so a resumed conversation composes them again; the saved transcript holds
the replies and outputs whole. Because the store knows the turn of each change,
ContextComposer.composition(_:atTurn:) rebuilds the context of any earlier turn; ContextView renders
a composition, or the list of turns, as Markdown for chat’s /inspect context next|N|turns, wisp-tui, and the MCP context
resources, and Paging bounds it. The order is fixed by the framework and the composer: the instructions
entry (wisp’s prompt, the operator’s extension, the caller’s instructions, and the tool definitions) first
and unchanged for the conversation, then the earlier block (permanent facts, dynamic facts, then the
running summary of phase 4b), the turns oldest first, the now block (ephemeral facts, the task), then the
request: D12’s order by stability. The composer’s summarises switch (on by default) is off in
ContextEquivalenceTests with the others; its versions (RunningSummary) are kept in the store and saved
with its links.
The person’s commands (ADR 0049). A command the person types in
chat after ! is stored between turns as an entry of its own kind, ThreadRecord.Kind.command: its value is a
prompt holding what the command printed (stdout, then stderr), its PersonCommand says what ran, where, and how it
ended, its source is the command.typed event, and its turn is the turn whose first request carries it, the
next. Agent.runTyped runs it, audits it, stores it (ThreadRecord.record(command:…)), and extracts facts from
its output as from run_command’s, ranked as a tool’s observation with the detail the person's command
(FactExtraction.personCommandDetail), so a later run supersedes it. The composer
always sends it as a notice (OutputReference.personCommand): that the person ran it, not the model, where, the
exit status and line count, then the output whole when it is short, or its first and last lines and, where the
conversation has memory, the call that recalls it (without it, that its output is not repeated and is not to be run again); never as the bare output, whatever the switches (literal composes whenever the store
carriesCommands). It is never a turn’s own entry, so the context of the turn it was typed after does not show
it; turnGroups leaves it out of the headroom, since it is not the model’s work, and condensing counts it as no
turn but keeps it with the turn after it (Transcript.condensed(keepTurns:commands:)), always keeping one typed
after the last turn; ContextView labels it “the
person’s command”; the snapshot saves its PersonCommand, and a record of kind command restores only with one;
Recall reads its output from the command.typed event and labels it with the command.
wisp’s notices (ADR 0059). A note wisp adds between turns (today,
the end of a background pull the model asked for) is ThreadRecord.Kind.notice: a prompt holding the note, with no
source, stored by Agent from Session.notices as the turn begins, its turn that turn’s. It is sent as stored, and
is otherwise treated as the person’s command is (Kind.isBetweenTurns): no turn of its own in the count or the
headroom, kept with the turn after it when condensing, written ahead of that turn’s prompt for the distiller, and
labelled “wisp’s note” by ContextView.
Memory (phase 4c; tools/memory.md) reads the store, and adds to the facts, from outside
the agent: the framework calls MemoryTool on its own task, so the agent publishes a copy of its store, every
fact it sees, the subject kinds, and the turn into a MemorySource (a final class with a Mutex, one per
WispThread) before every request, and takes the notes the tool left there when the turn ends, recording them
as the model’s facts (method noted) in remember, beside the turn’s extracted facts. Memory is pure: it
reads the verb (recall, note, task; none is a recall) and a note’s or a task’s form and rules. Recall is pure too: it reads
what follows recall (entry 7, turn 3, task, summary, fact …, with from line N), gathers the
material, and pages it at the tool-output budget. An entry’s content is read from the audit event its store entry refers to,
through AuditLog.event(_:), which asks the log’s sink when it is an AuditReader (FileAuditSink searches
its files for the id, newest first; MemoryAuditSink and TeeAuditSink too); the store’s copy is the fallback
(D8). memory is a built-in tool, off by default (ADR 0057): a thread given every tool has it only with
context.memory on (ToolRegistry.defaultNames(memory:models:ask:), which Session resolves “every tool” through), and a
named list only when it names it;
WispThread.openAgent wires it and renders the prompt with its rule
(Prompting.rendered(toolsAvailable:memory:)); the composer’s recalls switch makes references name it. Each
call records context.memory.
The assessment (phase 4d; context-management.md, “The assessment per request”) is
off unless Agent.assessment holds AssessmentSettings (which WispThread.openAgent sets from assessment.enabled,
with infersTask only for chat). Between step 1 and step 2 the agent assesses the request (Agent.assess):
AssessmentRules, pure, decide the tools and whether the request is settled; otherwise Assessor’s one call, in a
session of its own with a @Generable answer, adds tools (only allowed ones), a task (recorded as the model’s, method
inferred, never over the person’s or a caller’s), and fact ids; a failure falls back to every tool. The agent sets
ContextComposer.registered (the request’s tools) and keeps what the next request’s rules need in an
AssessmentState (the previous turn’s tools, the task’s, and the grown set). The composer adds the catalogue
(ContextComposer.catalogue, ToolCatalogue) to the instructions entry as a segment with a stable id and limits the
entry’s definitions to the registered tools (instructed), and the agent builds each session with only those tools
(registeredTools), starting a new session when the set changes. The now block gains the relevant facts and the tools
line (requestNotes, FactFrame.addingNow). withToolRecovery, outside the overflow recovery, retries a request
once with every tool when the framework refuses a call to an unregistered tool. Each assessment and retry records
context.assessment; the store keeps each turn’s tool set (ThreadRecord.toolSets, in memory) so an earlier turn’s
context shows the definitions it carried.
Facts (phase 4a; context-management.md, “Facts”) are Fact values in a
FactBook per scope: the conversation’s in ThreadRecord.facts, a value saved with the store’s
links; the session’s ephemeral facts and the shared permanent ones in SharedFacts, a final class with
a Mutex since every operation is one short critical section, the permanent one written to
~/.wisp/facts.json on each change. Other wisp processes share that file, so a change is made to the file as it
now is: under the mutex and an advisory flock on facts.json.lock, it re-reads the file, applies itself, and
writes the result before letting go, so no process overwrites another’s change or undoes its deletion; a reader
takes the file again when it has changed. A file that does not decode is moved aside as
facts.json.unreadable-<time>, never overwritten, and the session notes it. Session owns both shared books, and WispThread.setUp hands them,
with the config’s SubjectKinds, to each agent as FactSettings, so an MCP server’s threads share the
session’s facts. FactView merges the current facts of the three books by {subject, name} and orders
each group’s heads by precedence; FactComposition renders the frame within factsShare of the window;
FactReport renders them for the person (/inspect facts, /inspect summary, /task, the MCP facts and summary resources). The
person’s changes go through Agent.stateFact, deleteFact, setFactScope, and setTask, each audited.
Proposed permanent facts are also mirrored, whenever a conversation’s facts change
(Agent.syncProposals, from refreshFacts), into FactProposals, one per Session and shared through
FactSettings: a final class with a Mutex holding a copy of each proposal with its conversation and its
status (awaiting, moved, withdrawn). A face lists from it (/inspect facts, wisp://facts/proposed), so a
proposal can be listed from another conversation or after its own has gone; the owning conversation marks its
copy superseded at its next sync after another conversation moved it.
A fact’s scope is a state the person sets by command, not a host effect (ADR 0044, amended 2026-09-30, which
withdrew the fact-approval dialog). Agent.setFactScope(id, to:, by:) takes one of FactTarget’s
permanent, thread, session; scope and temporal class move together, the fact enters the target book
(FactBook.admit) and the old copy is superseded, or, for a proposed permanent fact moved to thread,
changes class in place (FactBook.retarget). A move to permanent writes it as the person’s (approved is
set, so it ranks with the person); it is audited as fact.scope.changed. Chat’s /fact ID SCOPE calls it with
by: .person, and MCP’s set_fact_scope with by: .caller, refusing p… ids in the request decoder.
After each turn Agent.Reply.facts holds the facts the turn recorded or changed
(Agent.turnFactIDs, reset when a turn starts), which chat prints as a one-line note, wisp chat --json
adds to the turn’s end (and as a note), and respond returns as structuredContent.facts, with
factsProposed, the count of FactProposals.awaiting.
Permanent facts over MCP (ADR 0048). set_fact_scope
permanent goes to the server’s FactKeeper (a final class with a Mutex, one per WispServer), which
files a request of kind fact in the PendingApprovals channel (<id>.fact.json, bound to the fact’s id,
subject, name, value, and source), posts a notification through the MCP host’s routes, records it, and
starts a task that polls the channel every 200 ms; the tool call returns at once. When an answer is taken
the task applies it through a closure the server gave it: ThreadActor.keepAsked or dropAsked, which call
Agent.keepAsked (setFactScope to permanent, by: .person, with the request) or Agent.dropAsked
(a proposal retargeted to thread in place), each first checking that the fact still says what the person
was shown. The keeper remembers drops per thread by subject, name, and value, refuses a second request for
one, and returns a waiting request rather than filing another. A request has no time limit (ADR 0048,
amended 2026-10-12). When a thread closes or is evicted (release) its tasks go on, and an answer that comes
later is applied without the thread: FactKeeper.keep(_:in:audit:) admits the fact the request names to
Session.permanentFacts, audited on the server’s log. When the client disconnects (stop) every task ends and
the requests stay filed; PendingApprovals never counts a fact request stale for its server having gone, and
the process the person answers in (wisp facts keep|drop, or PendingRelay for wisp-tui) takes its own
answer and applies it (FactKeeper.settleUnwatched). The facts resources read the keeper’s records for each
fact’s request. Answers come from wisp facts keep|drop and from PendingRelay for a front end that
declared keep-facts.
Risk classification and approval
ApprovalGate (an actor, one per conversation) takes an ApprovalThreshold (a level, or never) and runs a RiskClassifier (CompositeRiskClassifier over
RuleRiskClassifier and ModelRiskClassifier) and, at or above the configured threshold, asks an
Approver (TerminalApprover, DenyingApprover, AutoApprover, JSONApprover, or, under wisp mcp,
OutOfBandApprover with the ElicitationApprover beside it). The gate calls decide(_:audit:) with the
asking conversation’s log, and puts the conversation’s id on the request (ApprovalRequest.thread), so an
approver that does work of its own (filing a request, posting a banner) audits it where the question was
asked.
A run outside the sandbox (ADR 0064). run_command‘s
outsideSandbox reaches CommandRunner.run(_:in:origin:stop:requested:outsideSandbox:), which resolves it once into
CommandRunner.OutsideSandbox from the policy’s sandbox settings and isNestedSandbox: notAsked, asked,
turnedOff (sandbox.allowOutsideSandbox false, denied after the lists), or unchanged (sandbox-off, nested).
For asked, the runner clears the line through ApprovalGate.clear(outsideSandbox:line:workingDirectory:) instead
of clear(parts:…): every part is judged and audited, then the whole line is put to the approver as one
ApprovalRequest of kind outsideSandbox (scopes [.once]), with no session, turn, or store lookup before it and
nothing recorded after it. An approver whose asksAPerson is false (AutoApprover, DenyingApprover) is not
asked; the gate refuses. Approved, the runner launches the shell without sandbox-exec, skips the refusal check
(nothing confined it), and marks the outcome outsideSandbox, whose outsideNote the result ends with. The request
travels to every face as the request’s kind: the pending file’s outsideSandbox (bound into the SHA-256), the
approval line’s outsideSandbox for wisp-tui, and the dialogs’ own wording.
The person’s own !! line (amended 2026-10-14) takes the same resolution with origin .person: ChatInput
parses it as .command(_, outsideSandbox: true), Agent.runTyped(_:in:stop:outsideSandbox:) passes it on, and
admit returns after the lists and the switch, with no gate, so asked launches without sandbox-exec at once;
OutsideSandbox.note(for:) and turnedOffReason(for:) give the person’s wording. PersonCommand.outsideSandbox
carries it into the thread record (written only when true), so the model’s notice says it ran outside the sandbox.
ChatEvents.outside turns the person’s allowed policy.decision into the chat’s line before the command runs.
In wisp-tui the input box’s InputMode (Normal, Command, Outside) replaces command mode’s flag: ! at the
start steps up (InputMode::up), Backspace at the start or Delete in an empty box steps down, a paste steps up once
per leading !, and App::marked writes the line with its marks, ! !x for command mode text that starts with !.
Approval through another face (ADR 0046). Under wisp mcp,
Session.mcpApprover composes OutOfBandApprover from the configuration: it files the request in the
PendingApprovals channel (~/.wisp/pending, a directory of user-only files, each request bound to its
command, directory, thread, and server process by a SHA-256), posts a notification through the MCP host’s
routes (when the client’s dialog asks too, a DelayedNotice posts it only once the request has waited
approval.bannerDelaySeconds, and not at all if it settles first; at once when there is no dialog, or the
dialog could not be sent; ADR 0046, amended 2026-10-12), and races two legs: a 200 ms poll of the channel for an answer file, and, when the client has it,
the elicitation dialog. The first answer settles the race; the other leg is cancelled without being
awaited (a dialog in flight ignores cancellation, as Timeout records), the request file removed or the
dialog withdrawn with notifications/cancelled, whose JSON-RPC id the server’s CompatibilityTransport
learned from the dialog’s _meta key (ElicitationTracker). A dialog whose own wait lapses is withdrawn the
same way; one withdrawn before its request went out is remembered, and the transport cancels it straight after
sending it; the tracker forgets a dialog’s key when its wait ends, so it holds only those in flight. A leg that fails leaves the other asking;
cancelling the call settles the race as abandoned. Answers come from other processes:
wisp approvals approve|deny writes the answer file, and wisp chat --json‘s PendingRelay shows a
front end that declared approve-mcp each waiting request and writes its answer. The plain chat’s
ChatPending lists the channel before each prompt, notes each new request once and each one gone once (how it
ended read from the servers’ approval.settled and inquiry.settled events through an AuditTail), and writes the
answers that /approvals approve|deny, /facts keep|drop, and /questions answer|decline give (a question’s answer
read at a prompt of its own, as wisp questions answer reads it), only when the chat’s input is a terminal. The channel carries a
second kind of request, a fact to keep (above, “Permanent facts over MCP”), and a third, the model’s question (ADR
0062); each kind has its own file
suffix, binding, and answers, and the relay shows a front end only the kinds it declared. With
approval.outOfBand off, ElicitationOnly asks through the dialog alone.
CommandRunner consults the gate after the policy check, for the model’s commands: run(_:in:origin:) takes
the command’s Origin, and a command the person typed in chat (.person, ADR 0049) skips the gate, its
classifier and its question, since typing it is the approval, while the policy, the sandbox (unless it was typed
after !!, ADR 0064), the output bounds, and the audit (marked origin: "person") apply as before. That one parameter is the seam: there is no second runner. See approval.md and
ADR 0011. The model beside the rules is
CoreMLRiskClassifier (a Core ML text classifier under contract 1 or 2; the default, with the version
the release ships, ADR 0041) or ModelRiskClassifier (the on-device language model), chosen by
approval.classifier, and a session wraps whichever it
uses in TimedRiskClassifier and CachingRiskClassifier. RiskClassifierTraining trains a contract-2
model with Create ML from RiskExamples (the bundled Resources/risk-examples.tsv, a file, or
RiskExamples.fromAudit), and RiskMeasurement measures any classifier’s accuracy and latency; wisp classifier is their CLI (ADR 0038).
CommandSplitter reads a loop or conditional as the commands inside it (a header or closing keyword runs
nothing of its own, and a body using a loop variable carries what it ranges over), so each is judged and remembered
by what it runs (ADR 0015, amended 2026-10-11). The rules and the deny
patterns also read a second spelling of each command (RuleRiskClassifier’s normalisation in
RuleNormalisation.swift): quoting, escapes, and ${IFS} taken away, while the text and patterns of programs that
only print or search (DataArguments: echo’s text, grep’s and pgrep’s pattern, sed’s and awk‘s program)
stay data, never read as a pipe, a deletion, or a write.
KnownSafeCommands is the rules’ short list of read-only commands: a verdict it gives is known safe
(RiskAssessment.isKnownSafe), and CompositeRiskClassifier then does not ask the model. ClassifierStore
keeps the versions under ~/.wisp/classifiers/risk, one directory each with a read-only
model.mlmodel and a manifest.json of its training and measurements; ShippedClassifier is the
release’s default, embedded from Resources/risk-default.json and installed as risk@X.Y.Z-default by
installDefault() when a session or wisp classifier first needs it. TrainingSplit parses the sets
under training/, deals them into parts by family, and finds overlaps between parts, for wisp classifier split and TrainingSetsTests.
Audit and diagnostics
AuditLog records AuditEvents for one session through an AuditSink (FileAuditSink with rotation,
MemoryAuditSink for tests). AuditedTool wraps every registered tool; Agent, CommandRunner, and
WispServer record at their boundaries; the CLI records session start and end. Diagnostics wraps
os.Logger per category with optional stderr mirroring. See logging.md and
ADR 0010. Every event has its own random id, which
AuditLog.record returns as an AuditReference. Every WispThread tees its audit into a
ReceiptCollector, for the respond receipt, an EventRelay, which passes events to whoever is
listening at the moment, and a ToolEventTrail, which the agent links its store’s tool entries to. The MCP server listens while a call that carried a progressToken runs.
AuditTail follows the log file, across rotation, for wisp logs --follow. AuditLog.event(_:) reads one
event back by its reference when the sink can (AuditReader), which is how memory’s recall reaches an
entry’s verbatim content.
Session and WispThread
Session.begin is the one place an entry point’s flags become a running configuration: it loads
config.json, applies --instructions, --model, and --unsafe, checks --tool names, opens the
audit log and the ApprovalStore, creates the SessionApprovals set and the risk classifier, and
records session.start with the same fields for respond, chat, and mcp. Anything the user should
see about the set-up (the --unsafe warning, the off-device model note) comes back as notes for the
face to print; library code never writes to stderr.
What a session builds from its config is injected through Session.Dependencies: live makes the
classifier approval.classifier names (the on-device model, or a Core ML model resolved by
Session.coremlModelURL, the shipped default when none is set) and appends to the audit file; testing() is
rules only with a memory sink. Every unit test passes testing(), which is how “tests never need the
model” holds for sessions as well as for gates.
A WispThread is one gate plus the tools wired to it and the Prompting the agent starts with, built by
one function for every face of wisp. Prompting renders three layers into the framework’s instructions:
wisp’s own system prompt (the file Resources/system-prompt.md, embedded at build time by the
EmbedSystemPrompt plugin), the operator’s systemPromptExtension from config, and the caller’s
conversation instructions; see ADR 0017.
What differs between faces is what the face that owns the person’s screen can do for a tool, so it is
given when a conversation is opened, not when the session begins, as a SessionHost
(ADR 0044; Foundation already has a Host). A host carries the face’s
effects: the command approver (request and answer), the notification routes (fire and forget), and since
ADR 0062 the asker (request and answer: the model’s question to the
person, Asker), beside the session’s Notifier. Session.host(approver:face:asker:) builds one for each face;
the gate asks host.approver, AskTool asks host.asker, and NotifyTool, wisp notify, and wisp watch
post through host.notify, so no tool calls a face directly. A --yes request replaces the approver with AutoApprover inside the core, so the
flag means the same everywhere. The faces are overlays on this core:
| Face | How it opens its conversation | Notification routes (NotificationRoutes.Face) |
|---|---|---|
respond | session.openAgent(host:) with a denying approver that explains --yes and chat; nobody answers a question (NobodyAsker) | .terminal |
chat | session.openAgent(host:transcript:) with the terminal approver and TerminalAsker (a line read that Ctrl-C and a deadline can end, LineReader), resumable; /model reopens with store: so the new model continues the conversation’s store | .terminal |
chat --json | The same, with JSONApprover and JSONAsker (a choice line with kind question) | .frontEnd: the front end when its hello declared notify |
mcp | session.thread(id:host:…) per thread_id with Session.mcpApprover (the out-of-band channel and the client’s dialog, ADR 0046) and Session.mcpAsker (the channel, the client’s form behind inquiry.elicitation, a 60 s hold, then the park, ADR 0062), its own audit session (recording its own session.start), gate, tools, and optional instruction, tool, and model overrides; the server keeps the thread, gate, audit log, and InquiryBox together as one OpenThread in the ThreadRegistry, so they are created and dropped together | .mcp: the process routes only |
Notifier applies the off switch, the bounds, and the per-minute limit across the process, then hands the
bounded message to the face’s NotificationRoutes, which try ADR 0044’s routes in order and return the
first that works with the reasons the earlier ones were skipped: host (the front end’s notify line),
terminal (the escape sequence TerminalNotification builds for the terminal TERM_PROGRAM or TERM
names, written to /dev/tty; only the .terminal face, since under --json the front end and under MCP
the client own the terminal), app (display notification sent to __CFBundleIdentifier’s app, only with
notifications.viaTerminalApp), and osascript. The plan is a pure function of the face, the environment,
and the setting (NotificationRoutes.steps), which the tests and wisp doctor read; the terminal writer
and the osascript runner are injected.
Every conversation of a session shares its config, ApprovalStore, and SessionApprovals, so a
“this project” answer on one MCP thread is written once and a “this session” answer covers every thread.
The MCP tests build real sessions over a scratch home and check that two threads share one store.
Introspection
Read-only views of wisp’s own state, built once and rendered three ways: the model’s inspect tool,
the MCP wisp://config|status|approvals|audit resources, and wisp config and wisp logs. It
holds the home, the effective config, the approval store, and a status closure the WispThread
supplies (session id, turn, tools, model, session approvals); the MCP server adds the thread count, a link to
wisp://threads, and the standing-approval count to the status resource. Audit reads go through the same file walk logs
uses. See ADR 0018.
Home, config, transcripts
Home resolves $WISP_HOME or ~/.wisp and lays out config.json, approvals.json, facts.json, logs/,
transcripts/, context/, pending/, models/, output/ (the output store, ADR 0050), and classifiers/.
Config is optional JSON (system prompt extension, model, run_command limits, MCP thread capacity) with defaults applied by
resolved. TranscriptStore saves and loads transcripts as <name>.json, with the store’s links to the audit log in
<name>.store, which resuming requires. Commands that write (audit log,
transcripts, the doctor’s write probe) call Home.ensure(); tools and logs never create the directory.
ToolRegistry
A static list, all, is the single source of truth for what the model can see: ten built-ins
(builtInNames) and the custom tools. select(_:) resolves names from the CLI and reports unknown ones so the
CLI can fail before touching the model. defaultNames(memory:models:ask:) is “every tool”: the whole registry less
memory, models, and ask unless context.memory, tools.models, and tools.ask turn them on (ADR 0057,
ADR 0059, ADR 0062); all three stay registered, so a list that names one still selects it.
Tools
Each tool is a struct conforming to FoundationModels.Tool under harness/Sources/WispCore/Tools/:
nameis the identifier the model uses; keep itsnake_caseand stable.descriptionis prompt text; write it for the model, not for humans.Argumentsis@Generable; use@Guideon each property to constrain what the model produces.call(arguments:)does the work. Keep the formatting logic in astatichelper so it is testable without the model (seeCurrentDateTool.format).
CurrentDateTool is the reference implementation: the on-device model has no clock, so this is the smallest
tool that changes an answer.
Every tool’s result is bounded to one tool-output budget per conversation (ToolBudget: an eighth of the
model’s window, 4 to 64 KiB; the agent writes its contextSize to it as the window becomes known), and what does
not fit is kept whole in the conversation’s output store (OutputStore, ~/.wisp/output/<audit session>/,
one file per output under the tool call’s audit call id, which AuditedTool sets as the task-local
ToolCallContext.call; swept by age at a later session’s start, context.outputDays after a session’s last use,
within a bound, by OutputSweep in Session.begin), audited as output.kept by size and hash, and shown as its head and tail with a marker
and a note (ToolOutput.fitted, ToolOutput.fit) that names how to page, filter, summarise, or question it
through memory (ADR 0050;
context-management.md, “The tool-output budget, and the output store”). AuditedTool
also counts each turn’s calls by tool and arguments (CallLedger) and puts a [repeated call: …] line above a
result that repeats an earlier call exactly.
ReadFileTool pages a text file: FileReader streams the file in chunks through a LineScanner, skips to
the requested line, and stops when the page (the budget) or its byte budget is full, so cost is bounded by the
page, not the file; the scanner keeps at most a page of a line still open, so a file with no newlines costs a
page too. Only regular files are read (a FIFO or a device is refused before it is opened, non-blocking and
close-on-exec). The rendering ends with an offset hint the model follows to continue.
Both tools split lines the same way, on bytes (TextLines, and LineScanner while streaming): a line ends at \n,
\r\n, or a lone \r, so read_file’s numbers are edit_file’s on CRLF, classic Mac, and mixed files alike. Never
on Character, which takes "\r\n" as one grapheme: a CRLF file was once one line to FileWriter, and an edit of
line 1 replaced it whole (ADR 0024, refined 2026-10-12).
EditFileTool is its counterpart: FileWriter writes, appends, or replaces one exact match, and refuses
any path outside CommandPolicy.writableRoots, the list the Seatbelt profile is built from, so the
tool can change no more than a command could. Each edit is cleared by the gate as
edit_file <mode> <path> and recorded as file.write (ADR 0024). The
tools do not share one control path: run_command passes the policy patterns, the gate, and Seatbelt;
edit_file the writable list and the gate; read_file the gate’s rules only; inspect and
current_date none; memory reads only the conversation’s record and the audit log and writes only the
model’s own facts, below the person’s and the tools’, so none either. ModelsTool reads the listing without the
gate and asks the gate for every change, as a request of its own kind (models pull <repo> never covered by a held
approval), through ModelActions (ADR 0059).
NotifyTool posts through the session’s host (above), bounded, rate-limited, and
audited as notification with the route taken, without the gate
(ADR 0030, ADR 0044).
AskTool asks the person one question through the host’s Asker (ADR 0062):
Question.validate applies the limits and the rule on secrets, the thread’s InquiryBox admits one waiting
question at a time and two a turn, the asker returns a Question.Reply (the outcome and where the person
answered), and the answer is masked by SecretScanner’s secret rules before the model, the record, and the
audit see it; FactExtraction turns an answered call into the thread’s answer fact as the person’s. Under
wisp mcp the OutOfBandAsker files the question in PendingApprovals (kind question), races the channel
against the client’s form (ElicitationAsker, behind inquiry.elicitation) for the hold, and then hands the
question to the box’s park, whose watch delivers the answer as a ConversationNotices note to the thread’s
next turn and records the fact through the server’s ThreadActor.recordAnswer. No gate: a question runs
nothing.
RunCommandTool is the generic exec tool. It delegates to CommandRunner, which checks the
CommandPolicy patterns, consults ApprovalGate (rules plus a classifier, the shipped Core ML one by default, ask at
moderate and above through an Approver per entry point), then spawns /bin/sh -c in its own process
group (posix_spawn, with POSIX_SPAWN_CLOEXEC_DEFAULT so the child holds descriptors 0, 1, and 2 alone) under
sandbox-exec with a profile rooted at the launch directory, captures stdout and stderr separately into
bounded buffers (OutputBuffer: the first and last bound bytes of each stream in memory, the whole stream
spilled to the output store as it arrives once a byte would be dropped), kills the whole group on timeout (the
limit from CommandRunner.limit(for:requested:): the caller’s request, run_command’s timeoutSeconds or the
thread’s commandTimeoutSeconds that RunCommandTool carries, clamped to commandMaxTimeoutSeconds, else the
operator’s commandTimeoutSeconds; ADR 0063), and
renders the result within the budget as a whole: each stream’s head and tail within its share of the room, a
marker between them, and a held back: note naming the stored output’s lines (CommandRunner.outcome). The
rendering (Outcome.rendered) is what the model sees; policy denials and refusals are rendered too rather
than thrown. See ADR 0009. A confined command that fails with
Operation not permitted, or names a directory that is not writable (Homebrew’s The following directories are not writable by your user: list and PATH is not writable., ADR 0054 amended 2026-10-14), is checked by
SandboxRefusal: the paths its error output names, resolved to real paths, against CommandPolicy.writableRoots;
the verdict (refused, not-the-sandbox, or a guess when no path is named) is recorded on command.outcome and
told to the model as one sandbox: line after the output, which for refused or guess ends with the hint that it
may ask again with outsideSandbox when the switch allows it (CommandRunner.outsideHint)
(ADR 0054). A command run outside the sandbox is not checked. The default deny list also refuses a nested wisp
agent (CommandPolicy.nestedWisp).
MCP server
WispMCP.WispServer serves stdio MCP (wisp mcp) through CompatibilityTransport, which
normalises messages the SDK cannot decode although the protocol allows them (see docs/mcp.md). It advertises respond, the condensing tools (triage,
summarise_diff, draft_change, scan_secrets, redact, condense_log, json_shape, dependency_audit, flaky_tests, hot_paths), set_fact_scope, and close_thread from ToolCatalog, whose JSON Schemas and descriptions are the contract other harnesses see; wisp’s own tools
are reachable only through respond, and are described to clients by the wisp://tools resources,
generated from ToolRegistry.descriptions (schema from each tool’s GenerationSchema, limits and example
prompt from the tool’s own WispTool conformance, so a changed default shows up in the catalogue).
The request types decode and validate arguments as pure, testable values; see
ADR 0006.
respond runs on a conversation thread. ThreadRegistry is an actor keeping threads by id with LRU eviction;
ThreadActor is an actor owning one Agent, and each thread’s TurnQueue (OpenThread.turns) serialises its
turns first come first served while threads run concurrently: an actor is reentrant, so the actor alone would let a
second respond enter the agent while the first waits on the model or an approval, and the server’s reads after a
turn (the turn number, the gate’s refusals, the receipt) would see the other turn’s. close_thread and eviction
close the queue: the turn under way ends before session.end is recorded, and calls still queued are refused. Results carry structuredContent.thread_id; see
ADR 0007. Each WispThread tees its audit log into a
ReceiptCollector, a bounded in-memory sink; after a turn the server folds that turn’s events into a
Receipt for structuredContent.receipt (ADR 0021), so the result and
the log never disagree. The same events fold into TurnCalls for structuredContent.calls (the
proposal’s D9): each tool call with its output as the tool returned it, inline up to inlineOutputBytes
and otherwise as a wisp://threads/{thread_id}/output/{id} reference, which the server resolves from the audit
log by the tool.result event’s id, the reference the conversation’s store keeps for that output.
Everything else about a thread is under wisp://threads too (ThreadResources, ContextResources): a
ThreadDirectory remembers each thread the server opened (model, tools, turns, open or closed, bounded
at 256 records) so its summary, tool calls, and audit stay readable after it closes, and the context
resources compose a live thread’s context from its agent’s store through RespondingThread.context, at
no model cost (the proposal’s D12). ThreadRegistry.peek reads a thread without marking it used. A call may give a JSON Schema; OutputSchema converts the accepted subset to a
DynamicGenerationSchema, Agent.respond(to:schema:) runs guided generation after checking the model
declares it, and the reply’s JSON is parsed into structuredContent.output
(ADR 0022). scan_secrets and redact share SecretScanner (the rules), Redactor (numbered markers), and
ModelSweep (the opt-in model pass over rule-redacted text, keeping only values that occur exactly)
with wisp scan and wisp redact (ADR 0031).
A chunk whose model turn fails twice is reported in failedChunks and keeps its rule findings. With
personal data asked for, SecretScan also runs PersonalDataClassifier over the lines nothing else
flagged. It is a Core ML model embedded from Resources/personal-default.json, trained by
PersonalDataTraining through wisp classifier ship --task personal
(ADR 0042).
condense_log (LogDigest, CrashReport) and json_shape (JSONShape) are deterministic: templates
and ranking for logs, a parsed .ips for crashes, a merged outline for JSON
(ADR 0032). dependency_audit (DependencyAudit),
flaky_tests (FlakyTests), and hot_paths (HotPaths) are deterministic too, and Triage runs
KnownFailures on each chunk before the model, which sees only what those patterns do not explain
(ADR 0039). The server’s condense helper gives every condensing
tool its conversation, runner, gate, and capture.
ModelRouting chooses a model by input size from Measurements.embedded and the config’s ladder, before
anything runs; ChangeDraft.route applies it, honouring an explicit model and passing over a rung that
cannot open (ADR 0037). An Ollama model’s window is chosen when it is
resolved: the model’s own ollama.models.<name>.contextLength, else ollama.contextLength, else ContextSizing
reads its shape from /api/show and sizes the window to what MemoryState says
the Mac has free (ADR 0043), and the executor asks for
that window on every request (mlx.models.<name>.contextLength and mlx.contextLength come first for MLX the
same way). A llama.cpp or LM Studio model’s window is the one the server holds it at, read from the server
(/props n_ctx, or LM Studio’s loaded context_length), never sized (ADR 0058). An MLX model’s is sized by the same rule from its config.json
(ContextSizing.shape(fromModelConfig:)) and enforced by PrefixEngine, which refuses a prompt that does not fit
as an overflow; a Core AI model’s is its bundle’s declared max_context_length
(ADR 0052). ModelRouting.forTask gives a task’s
model pass its default when the caller names none: ModelRouting.taskDefaults (secrets: system,
measured best), overridden by routing.tasks. The choice is audited as model.routed.
While a call runs, WispServer.relaying turns the conversation’s events into
notifications/progress for a caller that asked, using ChatEvents.progress for the text.
draft_change and wisp draft are ChangeDraft: a DiffSummary report, then one schema-shaped turn,
with the subject and body shape applied in code (ADR 0035).
A session’s model classifier is wrapped as CachingRiskClassifier(TimedRiskClassifier(…)): verdicts are
kept per command line and directory, and only real classifications are timed for /stats
(approval.md).
CustomTool turns a tools.custom definition from the config into a tool whose GenerationSchema is
built at run time and whose calls run the substituted line through the registry’s CommandRunner, gate
included; ToolRegistry appends them after the built-ins and drops tools.disabled
(ADR 0036).
system_info (SystemInfo, ProcessTable) answers questions about the Mac from fixed read-only probes
through the conversation’s runner without the gate, and from libproc for processes, since Seatbelt
will not run the setuid ps (ADR 0034).
wisp watch is Watcher in WispCore/Session: a loop over an AsyncStream of triggers (start,
FSEvents changes from FileWatcher, an interval) whose running, triaging, notifying, and reporting are
injected, so it is tested without time or the file system; TriggerSettler produces the triggers and
settles a burst of file changes into one after a quiet period, on an injected Clock; its command is cleared once through
CommandRunner.authorize, which returns an Authorized line that reruns without the gate but with the
policy, sandbox, and audit (ADR 0033).
triage and summarise_diff were the first condensing tools; DiffSummary chunks a diff at file
boundaries and joins the model’s summaries and flags onto the file list the diff itself gives. triage was the first: Triage
in WispCore/Condense captures a command’s output through CommandRunner (same policy, gate,
sandbox, audit) or reads a file after the gate clears it, chunks it, judges each chunk through a
schema-shaped turn on a conversation of its own, and merges the findings
(ADR 0023).
A respond call goes from WispServer to the ThreadRegistry, which finds or creates the
ThreadActor for its thread_id, and on to that thread’s Agent; close_thread removes the
thread from the store. The overview diagram above shows the rest of the path.
CLI
wisp mirrors fm respond where semantics match: positional prompt or stdin, --instructions,
--[no-]stream, repeatable --tool. wisp chat is a line-oriented REPL with slash commands parsed by
ChatInput (/help, /tools, /tokens, /inspect, /status, /approvals, /audit, /fact, /task, /last,
/show, /models, /model, /stats, /history, /config, /save, /new, /quit; ChatInput.helpEntries is the
list /help prints and the table the parser looks each command word up in), --resume <name>, and --save <name>.
wisp tools lists the registry. wisp mcp serves MCP on stdio. Instructions default to config.json. A line starting with ! parses as
ChatInput.command, a command the person runs (with !!, outside the sandbox: “A run outside the sandbox”, above) (ADR 0049): the loop
calls Agent.runTyped in the conversation’s directory with no turn, its audit events show the output through the
tap as a tool’s do, ChatActivity.begin(doing:) says what runs, and ChatLoop.IO.command lets the terminal colour
the line’s prompt marker (ChatLoop.commandMarker, the palette’s command). Over --json the front end sends the
same line as a message; wisp-tui keeps command mode, its colours, and keys held while busy on its side.
The command has no timeout (CommandRunner.limit(for:requested:) is nil for Origin.person; ADR 0049, amended
2026-10-09): the loop arms the context’s ChatInterrupt with a CommandStop while it runs, the face’s Ctrl-C
calls ChatInterrupt.press() (plain chat from a SIGINT dispatch source, --json on an interrupt line), and the
runner’s ProcessGroup sends the group SIGTERM, then SIGKILL after CommandStop.grace, never once the leader is
reaped; ChatActivity marks the work stoppable, then stopping.
Exit codes follow swift-argument-parser conventions (64 for usage errors). The chat loop itself is
ChatLoop in WispCore, with its input and output injected, so the executable only wires the
terminal to it and ChatLoopTests runs the whole loop over a scripted model. Chat shows tool activity
live through ChatEvents.Tap, an AuditSink the conversation is opened with (Session.openAgent(observer:)
tees it beside the log and the receipt collector), so the lines the user sees are rendered from the
audited events, each tool’s output included (ChatEvents.shownOutput, folded past shownOutputLines;
ChatView.output finds one again for /show); ChatOutput resolves what /show and /last show of a tool’s
output: the whole from the OutputStore when the tool held part of it back and the store still holds it, otherwise
what the tool returned, under a header saying which and why. On a terminal TerminalPager runs it through $PAGER
(less -R when unset); piped, it is printed whole; over --json the front end’s output request is answered a page
at a time as a view of kind output (ChatProtocol.outputView), which wisp-tui’s Ctrl-O view loads as it
scrolls (ADR 0050, “The person’s views”);
ChatView carries a view shown whole (/inspect context next, N, turns),
which the plain chat prints and --json sends as a view line through ChatLoop.IO.view; ChatStatus draws the status line above each prompt; Style applies colour only on a
terminal. TextTable pads chat output such as /models and /stats into columns, because tabs drift
in a terminal and in the TUI. TerminalTable and ListingLayout lay out wisp tools, models, and
approvals like wisp --help when standard output is a terminal, wrapped to its width, and keep the
tab-separated lines when it is piped; TerminalTable.fitting drops the columns of the models table least worth
their room when they do not fit, by the ranks ModelTable gives them. /stats reads CallStats, a fixed-size ring (Mutex, 256 calls) that
Session.begin creates and every WispThread hands to its Agent, which records each turn’s time,
outcome, and reported prompt tokens; the classifier is wrapped in TimedRiskClassifier unless it is the
rules alone, and a classifier’s fallback verdict carries RiskAssessment.failureKey so it counts as a
failure. ChatActivity follows a turn’s events to say what it is doing now (waiting for the model,
running a command, waiting for approval). The terminal chat redraws that as a working line; --json
sends it as activity lines. ChatLoop.history keeps the latest 100 typed lines for /history; wisp-tui keeps its own
list for Up and Down. wisp chat --json is the same loop with its IO mapped onto a JSON Lines protocol
(ChatProtocol, LineRouter, JSONApprover), so a front end in another process, tools/wisp-tui,
can own the screen while the session stays here. wisp chat on a terminal hands its session to wisp-tui,
found beside the running executable (FrontEnd), which starts its child as wisp chat (tui); ExecutablePath
finds that executable by the path the kernel started it from, never by argv[0], which a parent may set at spawn
(wisp-tui, and scripts/wisp-mcp naming the server wisp mcp (CLIENT) from WISP_MCP_CLIENT;
ADR 0061). The front end’s optional first line, hello, declares
the host effects it carries; LineRouter keeps it (declares), the host’s notification routes ask it at
each notification, and JSONApprover denies without asking when a hello left out approve. A
hello that declares approve-mcp, keep-facts, or answer-questions starts a PendingRelay for the kinds it
declared (PendingRelay.kinds), which sends the commands, or the facts to keep, waiting in wisp mcp servers as
approval lines (source: "mcp", and kind: "fact" for a fact), the model’s questions as choice lines of kind
question, and withdrawn when they stop waiting. /config shows the configuration as YAML through
YAMLText; /config set and wisp config set go through
ConfigSettings (the settings that can change, and what each takes) and ConfigEdit (one path set or
removed, the result validated as start-up would before it is written); a chat command that needs an
answer asks a ChatChoice through ChatLoop.IO.choose, a numbered list in the plain chat and a
choice line in --json; and ChatCompletion gives Tab’s candidates from the same catalogue, answered
beside the loop through LineRouter.onComplete (ADR 0040).
Error handling
Errors are typed enums named Failure, one per subsystem, conforming to Error, CustomStringConvertible,
and Equatable, and they keep the underlying cause typed where a caller could act on it
(Session.Failure.malformedConfig carries a ConfigProblem). Library code never prints or calls
fatalError; the CLI is the only place that renders errors to stderr, and Wisp.usage is the one
place a bad-input failure from the core becomes a usage error (exit 64). Entry points are the closed
EntryPoint enum, so the values session.start and the approval store record cannot drift from the docs.
Concurrency
Swift 6 strict concurrency is enabled. LanguageModelSession is not Sendable, so Agent is a plain
final class used from one task at a time, and its async methods are nonisolated(nonsending) so they run
in the caller’s isolation. That is what lets ThreadActor (an actor) own an Agent. Do not move
session work into detached tasks.
Actor or Mutex is chosen by one rule. A type is an actor when its operations suspend (awaiting a human,
the model, or another actor) or when a change is a multi-step sequence that must not interleave, such as
the approval store’s load-mutate-save (which also holds an advisory flock on approvals.json.lock, since other
processes share the file): ApprovalGate, ApprovalStore, ThreadRegistry, ThreadActor, TurnQueue. An
actor is reentrant: while one of its methods awaits, another call can run on it. When a sequence spans awaits
and must not interleave with another caller’s, as a thread’s turn does, hold an explicit queue across it
(TurnQueue, a first-come-first-served async lock) rather than rely on the actor. A type is a final class
holding a Mutex when every operation is a short
synchronous critical section that callers must not have to await: SessionApprovals, TurnClock,
AuditLog, the sinks, OutputBuffer, ClientCapabilityFlags, ToolBudget, OutputStore.Writer,
CallLedger, OutputCondenser, ThreadDirectory.
Swift’s cooperative pool, where every task runs, has one thread per core and does not add one when a thread
blocks. Two rules follow, both from a measurement. On 2026-10-10 a sample of the gate’s parallel test run, taken
by a probe when a task had waited over a second to start, showed all 16 pool threads of a 16-core Mac taken:
eleven blocked in Blocking.run (seven Core ML compiles for the risk classifier, two for the personal-data
classifier, an Ollama resolution, a Private Cloud Compute window), two in framework semaphores (OSLogStore, the
system model’s context size), one waiting on a static initialiser behind one of those, and the rest running
tests. The bridged operations ran on the pool too, so they could not finish until a thread came free, and every
Task.sleep deadline in the process waited with them: tasks waited up to 6.9 s to start, and under heavier load
a 200 ms command watchdog never fired during a 30 s command.
- A synchronous bridge runs its operation off the pool.
Blocking.run(the one bridge, forModelSelection.resolveand the classifiers’ model compiles, which are synchronous) runs the operation on a task executor of its own, a serial dispatch queue with a thread of its own, so a blocked caller waits only for the operation’s own work. The caller still holds its pool thread while it waits, so a bridged operation stays local and short (a tags request against a configured timeout, a model compile), never generation. Other blocking waits in async code are bounded to seconds (ChatStatus’s git, the notifier’sosascript), or are the chat’s read of the person’s next line, one thread for the one chat a process runs. - A bound that must hold is not a
Task.sleep. The command watchdog and its SIGKILL grace, the person’s stop grace, the deadline inTimeout.run(an approval’s wait, the model checks’ probes), and the delay before an approval’s banner (DelayedNotice, which nothing waits on) areAlarms: one-shot timers on a serial dispatch queue, which libdispatch gives a thread of its own however busy the pool is. The alarm decides on time; what it resumes still needs a pool thread to carry on, but the command has been signalled and the wait has been refused by then.Spawn.wait, which blocks inwaitpidfor as long as a command runs, has a serial queue of its own for the same reason: on a global queue enough running commands would reach libdispatch’s limit on that queue’s threads.
PoolStarvationTests blocks every pool thread and checks each: the watchdog signals a command, Timeout.run
gives up, and Blocking.run returns, all within seconds; before the change none did until the blockers let go.
What a turn’s requests need to know reaches the executors as task locals the turn binds, since the framework runs
each executor on the caller’s task: ReasoningObserver.current (who records the thinking, ADR 0053),
RequestThinking.current (what thinking the request asks for; wisp’s own calls bind false through
RequestThinking.sideCall, ADR 0053 amended 2026-10-11), and TurnStop.current (the person’s Ctrl-C, ADR 0049
amended 2026-10-11). The chat’s turn runs on its own task, which a signal cannot cancel from outside without an escape
hatch, so a stop is not a task cancellation: TurnStop is a final class with a Mutex that what the turn has under
way attaches to (a request’s connection through the task’s delegate, RequestStop; a run_command’s process group;
MLX’s generation, in a task group beside the stop), and each executor checks it before it sends.
Visibility
WispCore is an implementation library for the two executables, not a published API. A declaration is
public when WispMCP or wisp calls it, or when it is an extension point a new component conforms
to (WispTool, Approver, RiskClassifier, AuditSink) or a type such a public signature exposes.
Everything else is internal; tests reach it through @testable import.
Extension points
- New in-process tool: add a file under
Tools/, append toToolRegistry.all, add a test for its pure helper. - New Rust tool binary:
cargo new --binundertools/, list it in the workspace, then either let the model reach it throughrun_commandor give it a dedicated SwiftToolwhose description tells the model when to use it. - New MCP tool: add a
TooltoToolCatalog, a request type, and a case inWispServer.call. - Thread persistence or context management: extend
ThreadActor; record the choice in an ADR. - Session persistence and structured output have shipped (
TranscriptStore,Agent.respond(to:schema:)); a new output shape goes throughOutputSchema.