You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
examples/studio-local under weft dev (U20.2; UX-D-01, R3): setup B for the page — with WEFT_STUDIO_URL set the page's panel tag loads panel.js from that Studio, names it as data-endpoint, is scoped to pub_demo and carries a panel token the app mints per page (POST /api/panel-tokens with WEFT_STUDIO_TOKEN, "playground": true; the server token never reaches the page, no token rides a URL), so the panel, the runtime and weft dev's Studio are one Studio; the runtime link registers runtime.Sessions(demo.resolve), so weft dev -- go run ./examples/studio-local approves a parked refund from the page's panel. Under weft dev the embedded /studio/ is the app's read-only view (no playground: weft dev's Studio owns the command store in the shared WEFT_DB). The page is served on GET /{$} alone (one panel token per page view; a favicon or any other path mints none), and the app creates its threads directory when it is missing. serve splits into build (the wiring) and the listener. TestWeftDevApprovesFromThePagePanel (weft dev's environment, a token-walled Studio, the echo model: park → the panel token's approve → finished → the resumed turn refunded; then the app killed → the turn reads disconnected with the restart fix).
studio (U20.2; UX-H-14, UX-D-01, A7): an app turn whose service has no connected runtime with runtime.Sessions but one that registered it listed disconnected — even while another process of the service, a CLI, is connected without Sessions — is refused app-owned turn · read-only: the app's runtime <id> of service <svc> disconnected — last seen <UTC second> (the clause after "disconnected" left out when the row has no last_seen) with the fix restart the app: its runtime link registers again — never the closed link's or "add runtime.Sessions"; while a Sessions runtime of the service is connected no disconnected app is reported (Studio's playground header included, which otherwise reads the app's own turns disconnected with that fix, not "viewer-only"); with none of the service known and this process's own link dialled another Studio, … is connected here — the app's link dialled <url> with open the panel there (data-endpoint), or link in-process: runtime.Local(srv). /api/meta adds runtime_link{studio_url?, in_process} (this process's runtime link: in_process for runtime.Local(srv) and for an HTTP link of this process that dialled this very Studio — it holds a registration with the link's instance, RuntimeServer.HoldsInstance — else studio_url, the Studio it dialled; omitted with none open and for a panel token; internal/linkreg is how weft/runtime tells weft/studio). Both surfaces say the same words (lib/honesty.tsappGone, APP_ELSEWHERE_FIX; parity rows). TestStoppedAppTurnReadsDisconnected, TestAppTurnGoneAppWinsOverACLI, TestMetaNamesTheStudioTheLinkDialled, TestMetaRuntimeLink, TestAppTurnNamesTheStudioTheLinkDialled, testdata/api/meta-runtime-link.golden.json.
studio, studio/runtime (U20.2; UX-H-15, UX-H-12, R4): GET /api/runs/{id}/commands (capability run_commands, with the playground) lists the decisions, steers and cancels sent to a run with their lifecycle rows — RuntimeServer.CommandsOn, RunCommand — so the run page marks a parked call whose decision was lost (your approve at … was lost: <why> — decide again) after a reload or a Studio restart; the devtools panel reads the route once per parked turn and marks a lost decision after a reload by the same rule (each call's latest decide) in the same words. A Studio restarted over its command store logs one INFO line naming the runtimes it restored and the commands still open and lost. TestCommandsOnARun, TestRunCommandsRoute, testdata/api/run-commands.golden.json, the auth matrix.
Studio (U20.1; §6.11, UX-C-12, UX-S4-12, UX-S5a-15, UX-E-56): a top-level /runtimes page (capability runtimes; sidebar, command palette). Every runtime that registered, grouped connected · restored after a Studio restart · gone (disconnected and not restored; "stale" is only the manifest mark's word, here and in Settings), each told apart by pid, cwd, host and id, the connected links of one process (one host, pid and cwd) drawn as one process naming the agents two links both register; per runtime what Studio may do on the runs it starts and on the app's own turns — approve, steer, cancel, breakpoints, every "no" with why (not connected, a capability off, runtime.Sessions not installed, no agent built with runtime.Breakpoints()) — and debug_scope in words (UX-S4-06, UX-S4-10); per agent its registered manifest hash against its latest run's, tools, models and the resolver explained; the runtime-wide breakpoints (the editor moved here from Settings: clearable, the all-tools warning, who set them); a "forget" on a gone or restored runtime; ?runtime= scrolls to a card, ?agent= explains an agent no connected runtime registers. src/routes/runtimes.test.tsx (three arena runtimes apart by pid and cwd, a restored one, a forget, one process with two links, axe), src/lib/runtimes.test.ts.
Studio and the devtools panel (U20.1; UX-D-02, UX-C-11, UX-C-14): the replay drawer's target section is a "Runs in" line (lib/runs-in.ts, both surfaces, parity-tested): Runs in: rt_0396… weft-arena-dev (pid 2902, seen just now) · runtimes ⤢, the runtime a command goes to being the connected registration first, then the newest seen (registering, behind pickTarget and the panel's pickRuntime — never the first listed); a select of the connected runtimes registering the agent when there are two; a registration that is not connected is no target; when none does, the refusal — ✗ no connected runtime registers <agent>, what is connected, the fix (runtime.Install(…, runtime.Agents(<agent>))), the Studio asked (and, in Studio, the Studio each runtime's link dialled, named by that runtime: rt_… <service> chose <url>). The replay drawer, the playground, the experiment page's cell re-runs and the source matrix hold Run on that refusal, with a runtimes ⤢ link to /runtimes?agent= (runtimeRefusal, RuntimesLinkFor), and a 503 on Run reads the same refusal and fix; the panel's Runs in line is not drawn while no agent is picked. The playground's runtime select labels read <service> · <env|(no env)> · pid N · N agents · rt_… (UX-S4-05) beside a runtimes ⤢ link.
Studio (U20.1; UX-S4-01): /agents badges each card with manifest_check — "stale" (its title names the hash the latest run ran: "the latest run ran d2151022, not this manifest"), "current", "other version ran", "unchecked" (no stored run of the agent carries a manifest hash) (components/studio/manifest-badge.tsx); on a weft.json Studio a file agent, which carries no hash of its own, reads the check's verdict, "current" or "stale"; the run header reads manifest <hash> · current or · stale from manifest_check alone (no fetch a read-scoped token may not make); the run page's running app turn says why it offers no steer — the token, the capability, or no connected runtime of its service registered runtime.Sessions — with cancel's word and debug_scope explained, linked to /runtimes (UX-S4-10; steerWhyNot, steerOffLine); Settings' Runtimes section says manifest: checked N of M · stale K (…) · differs D, the runtimes by state, debug_scope in words and links to /runtimes. src/routes/agents-manifest-check.test.tsx (13 agents, 9 stale), manifest-badge.test.tsx.
studio: /api/meta's manifest_check.latest — each checked agent's latest run's manifest_hash (what the stale badge names; absent when nothing was checked). GET /api/runtimes serialises connected_since and last_seen in UTC (TestSnapshotTimesAreUTC); its rows add weft_version (the registration's) and breakpoints_by for a non-empty set — studio (this Studio's PUT; breakpoints_set_at says when), runtime (the registration carried a set an earlier Studio delivered), store (restored after a restart). DELETE /api/runtimes/{id} (capability runtime_forget, with the playground; capabilities_off names the option otherwise) forgets a runtime that is gone: 204, its row and breakpoint set removed from memory and the command store, its commands and run routes kept as history; 409 while it is connected or a queued or accepted command is addressed to it, 404 for an unknown id, 403 for any panel token. studio/runtime: RuntimeServer.Forget, ErrConnected, ErrRuntimeBusy, RuntimeView.WeftVersion, RuntimeView.BreakpointsBy and the BreakpointsBy* values (ExampleRuntimeServer_Forget, TestBreakpointsByAndForget, TestForgetThenReregisterBeforeTheWriterDrains, TestRuntimesToldApartRestoredAndForgotten, the auth matrix, meta.golden.json, runtimes.golden.json).
obsdb: Aggregator (UX plan D7, A4; UX-E-25, UX-A-14), implemented by both backends — Stats(StatsQuery) groups the runs /api/runs' filters keep, started in a window, by agent, model, tool, error type or step: runs, failed, model or tool calls and failures, retried calls (more than one attempt; obsdb.AttemptIndex reads weft.attempt.index one way on every backend, an integer or a string of digits), denials, the calls' own usage split by model, run or call duration p50/p95 and TTFT p50/p95 (nearest-rank, exact on both backends), and a per-bucket series; by=agent adds StatsGroup.Delegated, DelegatedModels and DelegatedRuns — the model calls of the Subagent descendants of another agent, each once, apart from its own. A derived run counts as a run (no calls), so a group's runs match the runs list; every span-side figure is read from the window's start; CheckStats refuses a window outside 1678–2262. by=model keys on the model name alone (two providers' models of one name are one group, its cost still priced per provider/model part); by=step keys on the run's own step index, so a replay's calls count from its from_step as step 0 (not the source's ordinal the diff aligns on). ErrorCounts(SearchQuery) reads a search match's distinct error texts. SQLite answers from span_stats (migration 0023), a derived index written at ingest beside every stored invoke_agent, chat and execute_tool span (the derivation prepared once per Write, 54 µs/span in 450-span batches, BenchmarkSpanStatsIngest, TestSpanStatsIngestCost; read through the partial index spans_attempt, so a 30,000-span trace ingests in 1.4 s), backfilled on open in keyset batches (450,000 spans in 2.8 s, TestSpanStatsBackfillTime; Open logs one INFO line with the span count before and one with the duration after) and deleted with its span by trigger; a span_stats write that fails rolls back to its own savepoint and keeps the span (one WARN per process) — the index is rebuildable, the OTLP batch is not. Migration 0025 creates spans_attempt on a file that reached 0023 without it (a no-op on a 0.18.x file: 0023 creates it). ClickHouse groups otel_traces natively, no table. obsdbtest's Stats, StatsDeleted and ErrorCounts cases hold both to the same answers; TestSpanStatsFailureKeepsTheBatch, TestUpgradeTo0023LogsTheBackfill.
obsdb: ErrorClass normalises an error text into its class (numbers N, hex ids, UUIDs and mixed ids <id>, quoted strings kept), ErrorClasses folds ErrorCounts into classes and ErrorClassTexts resolves one back to its texts; RunQuery.Errs keeps the runs whose error is one of a set (nil any, empty none) on Runs and Search.
studio: GET /api/stats?by=agent|model|tool|error_type|step&since=&until=&agent=&tag.<k>=&public_id=&parent=&playground=&experiment_id=&status= (capability stats; capabilities_off says why without an obsdb.Aggregator): per group runs, failed, failure_rate, calls, tokens, the cost block (each model's usage priced at read time; unknown, never $0, for an unpriced model; an all-scripted group the exact $0 its runs read, unknown — "no model usage recorded" — when a real model's calls that reported no usage sit beside its scripted ones), duration_ms and ttft_ms{p50, p95, n}, retry_rate, denials and a 24-bucket series; by=agent groups carry delegated{runs, input_tokens, output_tokens, cost block} (never summed across groups); agent and error_type groups carry runs_query, the /api/search query string whose total is the group's runs. parent defaults to * (every run, children under their own agent), unlike /api/runs' top-level; playground defaults to false — health numbers (rates, percentiles, call counts) never mix in scripted or playground replays — with true the replays alone (/api/runs' meaning) and all both, and runs_query carries the effective filter so a card's link lands on the same runs; with experiment_id and no playground the default is all — every cell of an experiment is a playground run, so its cells are counted — an explicit playground= winning; experiment_id and status read as /api/runs does; an unknown value or a window outside 1678–2262 is 400; a panel token's numbers are its public id's alone. One golden per by (testdata/api/stats-*.golden.json); TestStatsPlaygroundDefault, TestStatsScriptedZeroUsage, TestStatsScriptedBesideIdleModel, and TestStatsCountsTheAppsOwnRunsEndToEnd (one real run, two scripted replays through a real runtime: by=agent counts 1, playground=all 3).
studio: /api/search answers facets.error_class — the match's failures grouped by obsdb.ErrorClass, most runs first (UX-C-37); error_class=<class> filters /api/search and /api/runs to that class (TestErrorClassFacet: the arena's usage-limit failures are one class).
obsdb, studio: an experiment definition keeps a pass_predicate (UX plan D8, UX-C-38; SQLite 0024, ClickHouse 0028), stored and returned verbatim, at most 4,096 bytes; POST /api/experiments and the reads round-trip it. web/src/lib/api.ts types the new routes and fields (fetchStats, SearchFacets, error_class, pass_predicate).
studio: POST /api/playground/preview says before Run whether the scripted engine holds a recorded answer: scripted_hit[{step, hit, because[]}] from from_step through the first miss, and scripted_key_match{ok, because[], step, message}, computed from the record and the registration with the engine's own key — a narrowed tool list, a thinking level, a tool choice, sequential tool calls or other messages miss at the step they change; a recorded call to a tool the registered agent lacks misses at the next step; a source that ended on a tool call for a reason the replay does not repeat asks the step it never answered (unanswered); a step's request record naming a PrepareStep change is read for that step. Computed whatever the engine, for every identity inside the source's public id (component names only, never content). No model is called (UX-D-03, UX-C-06). TestPreviewScriptedCheck, testdata/api/playground-preview.golden.json.
studio/runtime: CommandStatus.Reason — GET /api/playground/commands/{id} reads a failed scripted run's error into reason{kind: "scripted_miss", step, components, message}, the message Studio's one sentence for the Studio UI and the panel, ending as the refusals do: scripted has no recorded answer for step 2: the tool list differs from the recording — run on live (, or replay from an earlier step when the source never answered it); error stays the runtime's text. ReasonOf and ScriptedMissReason are the mapping (ExampleReasonOf, ExampleScriptedMissReason); TestCommandReasonNamesScriptedMiss, testdata/api/playground-command-reason.golden.json, and TestScriptedEngineEndToEnd with a real runtime on both halves.
runtime, studio/runtime: the finished ack carries the scripted miss typed — reason {kind: "scripted_miss", step, components} beside error (which the runtime cuts at 4 KiB) — and CommandStatus.Reason prefers it, its components kept to the closed vocabulary; the error text is read (ReasonOf) only for a runtime older than the field. The preview and the run agree on the step, the components and the sentence. The registered defaults carry thinking_budget (a budget with no level still sets the request's thinking, which the scripted key reads), and the preview reads it. Studio keeps studio_url without userinfo, query or fragment whatever a client sends (and drops a non-http(s) one). TestFinishedAckCarriesTheTypedMiss, TestCommandReasonPrefersTheTypedAck, TestPreviewScriptedBudgetOnlyThinking, TestRegisterStripsStudioURLCredentials, TestScriptedPastTheEndAgreesEndToEnd, TestScriptedPastTheEndWithMissingToolAgreesEndToEnd.
studio/runtime: AgentView.ModelProvider — GET /api/runtimes gives each agent model_provider, its own model's provider as its manifest names it (AgentRegistration.ManifestModelProvider). Studio's replay drawer and playground say, beside the engine, when it is wefttest: live calls the app's test script, not a provider, and runs out of turns; a run that ended "model script exhausted" reads "the wefttest script ran out" on both surfaces (UX-C-09). Web types gain AgentDefaults.thinking_budget and PlaygroundRunBody.cross_agent.
runtime, studio: the registration carries each agent's manifest_hash (sha256 of its registered manifest — the weft.manifest.hash its own runs carry) and the studio_url the link dialled (credentials, query and fragment stripped; absent in-process). GET /api/runtimes gives each agent manifest_hash (the registration's, else Studio hashes the manifest — an older runtime's too) and each runtime studio_url under cwd's rule (the server token or setup A's loopback, never a panel token), beside sessions, breakpoints_own, restored and cwd; a recorded run's manifest_hash joins the registered build (UX-C-06, UX-S4-06, UX-S5a-15, UX-D-01 — the wire half; the /runtimes page and /api/meta's runtime_link are later items). testdata/api/runtimes.golden.json pins both against a real runtime dialling a real Studio; TestRuntimesViewCarriesManifestHash, the auth matrix.
studio: capability target_check (with the playground; capabilities_off names the option otherwise) — what a client gates the target check on.
weft/otel (I2b, issue 002): otel.DevMode() — the code-side twin of WEFT_ENV=dev: it satisfies the dev gate (ADR 0028 §12) that otel.RawBodies(true) and a destination's otel.Blocking() need, without reading the environment, so an isolated otel.NoEnv() pipeline in a test or harness that controls its own process can record one weft.raw per attempt (or block on a full queue). NoEnv still reads nothing, WEFT_ENV included; DevMode unlocks those two switches and nothing else. The operator's environment wins over it: when the pipeline reads it (no NoEnv) and WEFT_ENV is set to anything but dev, DevMode is refused with one WARN otel: DevMode ignored: WEFT_ENV=<value> (hint: unset it or set WEFT_ENV=dev) and RawBodies and Blocking stay off — refused together as exactly one WARN; NoEnv + DevMode and DevMode with WEFT_ENV unset keep the gate open. Whenever DevMode is what opened the gate Start logs one INFO line per switch — otel: raw bodies on by otel.DevMode(), otel: Blocking on by otel.DevMode() — so a binary that ships it is visible; a Local destination's Blocking() (Local has no queue and ignores it) neither logs that line nor counts toward the Blocking ignored WARN. The fixes that named only WEFT_ENV=dev add (or otel.DevMode()): weft doctor's dropped-records fix, the gap / raw_off hole texts (obsdb and lib/honesty.ts, so the API goldens move) and Settings' queue line. TestRawBodiesNoEnvDevModePerAttempt, TestRawBodiesDevOnly, TestBlockingDevGate, TestBlockingDevGateLocalAndBoth, ExampleDevMode.
weft/otel (I1): otel.NoDiscovery() skips the discovery file and nothing else — WEFT_STUDIO_URL, the OTEL_* destinations and the WEFT_ENV dev gate are read as usual (NoEnv drops them all): the per-pipeline form of WEFT_DISCOVERY=off, for a harness that keeps the operator's destinations. The "joined the running Studio" INFO line's hint names it; the NoGlobal godoc says it does not stop discovery. TestDiscoveryNoDiscoveryKeepsEnv, TestDiscoveryNoDiscoveryExporters, ExampleNoDiscovery.
weft mcp (I5; UX-H-27): eight read tools more, each one GET of an existing route under the same bound — logs (/api/runs/{id}/logs), spans (/api/runs/{id}/spans, tree; compact: name, start_ms / dur_ms from the first span, status, the model / token / tool / step / attempt / error attributes, content attributes and events left out, full: true as served), trace (/api/traces/{id}, compact the same way), transcript (/api/runs/{id}/transcript, step), live (/api/runs?status=running&all=1, a snapshot: an MCP answer is no stream), agents (/api/manifest), experiments (/api/experiments, /api/experiments/{id}) and runtimes (/api/runtimes). 21 tools, all GET.
Studio (U24.2; UX-E-25, UX-A-14): the Agents page draws GET /api/stats — a numbers strip per agent card (runs, failed %, duration p50 · p95, TTFT p50, tokens, cost or its badge — "unpriced", never $0 — retried %, denials, sparklines over the window, and the subagents' bill apart as delegated, whose hover says it is never summed across agents), one chart per metric across the agents, each tool's calls · failed % · p50 · p95 on its tool card, and the agents seen in runs that no manifest registers (dimmed); a registered agent always gets its own numbers — when the page's group list is cut at the route's 200-group cap (groups_cut), each registered agent it left out is read by GET /api/stats?by=agent&agent=<name> over the page's exact window, and one whose read is pending or fails says "numbers not read" with why, never "no runs"; a 24h / 7d window rides the URL (?window=7d). Every number links to the runs it counts through the group's runs_query, read into the runs list's URL (lib/stats.ts's statsRunsSearch: all=1 is with subagents, playground=falseexperiments off) — never a hand-built /runs?agent=. One small chart component, components/studio/spark.tsx (an inline-SVG sparkline and a bar list in the palette's --chart-1 / --status-bad tokens, no chart dependency: shadcn's recharts stays unused). The run header gains "vs typical" chips: this run's duration against its agent's p50 and its own tokens and cost against the mean per run over the last 7d, only with at least 5 runs (it says so otherwise; an unpriced side draws no cost chip).
Studio (U24.2; UX-C-37): the runs list's error-class facet — a failed list (or one class) reads /api/search where the database aggregates, its facets.error_class drawn as picks with counts; a pick is the error_class chip (and error_type a chip too, both read from a stats link). One class picked offers "Experiment on these N →" per agent: the playground's multi-source matrix (/playground?sources=<ids>&agent=<name>, at most 20 runs, said when the class holds more), one row per source run continuing from its own failing step (lib/matrix.ts's failingStep: the core's "at step N"), the agent's models as columns (each live, or the agent's own on scripted), a pass predicate, and the estimate before Run — "28 cells · ≈ 34k tokens · ≈ $0.012" with each column's figure — from the source steps' recorded usage priced at each column's model (priceAt, pricing.go's formula restated over GET /api/settings/pricing), "cost unknown: has no price" for an unpriced one, never $0. Run saves one experiment and issues every cell through the grid's own cell rule, then links the grid with the count it sent (/experiments/<id>?issued=N): the grid reads the experiment again until that many top-level runs joined (a subagent child carrying the experiment id is no cell), for at most two minutes — then "k issued cells never started" and the summary with them missing — its empty cells "issued, not started" and the summary waiting meanwhile. A failed source is a floor ("≥"); its failing step that billed nothing back is estimated at the step before's input tokens, said in a note, never the whole matrix unknown. A failed list stays on /api/runs (no facet) while a ?before= cursor pins its page — search pages by offset and would drop it. A playground or scripted run's "vs typical" chips say they compare with the app's runs. The client price (priceAt) follows pricing.go's order and path.Match's globs (a bad pattern — [a-], [-a], []a], an unclosed class — matches nothing); studio/testdata/pricing-cases.json holds both to the same figures (TestPricingCasesShared, matrix.test.ts).
obsdb, studio (U24.2; UX-C-37): an experiment input keeps its own from_step (obsdb.ExperimentInput.FromStep; stored in the inputs JSON, no migration) — a multi-source matrix row continues from its own failing step; absent, the experiment's from_step. POST /api/experiments refuses a negative one (400 naming inputs[i].from_step); Studio's cell re-run reads it (lib/form-state.ts's cellBody). TestUsageLimitOneFacetOneMatrix: seven usage-limit failures at different steps are one facet of 7, the class filters /api/search to exactly them, and one matrix over them saves with each row's source and step (golden testdata/api/search-usage-limit.golden.json).
Studio (U24.2; UX-C-38, UX-C-16): the experiment's pass predicate has a language, lib/predicate.ts — the one place it is read: clauses joined by AND — status = | != <word>, answer ~ | !~ /re/flags, answer = | != "text", steps | tokens | cost < <= = >= > <number>; empty is status = succeeded. The grid judges each cell's latest run ✓ pass / ✗ fail / ? not judged with its reason (a fact not read — an answer not read, content off, stripped or unreadable, an unpriced cost — is never a pass, never an empty answer), each answer ~ pattern tested off the page's thread (lib/regex-run.ts: the answer's first 8 KiB, a group holding an unbounded quantifier that is itself unboundedly quantified — at any depth, [classes] and escapes skipped, a bounded {m,n} let through — or a backreference refused when typed, every other one in a module worker bounded at 250 ms per answer, counted once the worker has loaded (the load itself bounded at 10 s) — past it "not judged", the worker replaced; a worker that cannot load says so for every job and is not respawned; a job whose query was let go is skipped — memoised per run and predicate), the answer read for every cell of the first 20 rows (the matrix's own bound, said past it); while any cell is not judged no cheapest column is claimed ("k cells not judged: no cheapest yet", with the reason) and pass rates read "n/m judged"; a bill not known reads "unpriced" only for a model with no price, else "cost unknown"; the predicate is Studio's alone — the server stores it verbatim and validates nothing in it, and no server read judges a cell, tallies each column ("5/7 · $0.0021") and names the cheapest column passing every row and the cheapest overall (never a column with rows missing); the predicate can be tried on the grid and saved on the definition.
make mcp-check (I5) seeds four shapes built in-repo on wefttest scripts through the otel pipeline — the arena's sub.failed and agentic.chaos, a thread turn denied by alice and resumed, a nine-turn session (each turn a tool call and an answer, every third tool call failing, ~220-character prompts) — and asserts explain_failure answers the child's error, the four failure shapes (SUBAGENT_FAILED, a timeout, SUBAGENT_PENDING, a panicking tool in a child that recovered), "denied by alice", each turn's own text, and the session under 4 KB; it calls spans, logs, transcript and live too.
core, weft: KeptSteps(n) — a run option saying the run's Messages end with the first n steps of the turn it continues (a replay from step n is fed them as its kept prefix). The kept steps count toward the turn's guards: MaxSteps budgets the whole turn (the run makes at most MaxSteps − n model calls) and DetectLoops' streak starts from the kept steps' tool calls. The usage limit, MaxModelRetries and StopWhen see the run's own spend and steps; the run's own steps stay indexed from 0 (StepStart, StepRecord, RunError).
Changed
Studio and the devtools panel (U20.1; UX-C-18, UX-D-02): the playground's per-variant form no longer carries "Break on (parks every run)" — it points to /runtimes with the count in force ("breakpoints for the whole runtime live on Runtimes · 1 tool break on every run"), as the replay drawers do (their pointer links /runtimes); the Breakpoints editor moved to components/studio/breakpoints.tsx. Settings' Runtimes card is a summary and a link to /runtimes. The panel's ✎ Experiment with no runtime at all opens the drawer on its Runs in refusal instead of a rejected Result card ("no runtime registers an agent" is gone). lib/drawer-spec.ts's targetLine is gone (the Runs in line replaces it).
studio: the dist gzip budget is 800 KiB (was 700; ADR 0018 §7 amended 2026-10-26 by the user — phase 10a's screens put the dist at 696 KiB before /runtimes and the Agents charts; the panel.js copy still counts). make studio-check enforces it.
Studio and the devtools panel: the experiment drawer chooses the engine per command (U17, UX-C-01; lib/engine.ts's engineChoice and factsOf, one reading of the command both drawers make, parity-tested). A plain replay opens on scripted — a source run, no edit, no prompt / model / input / fork / tools / thinking / tool-choice change, no parallelism 1 (SequentialTools is part of the replay key), a from_step the source answered, and the preview's scripted_key_match / scripted_hit not a miss (a miss disables scripted in the server's own sentence) — anything else on live; a model equal to the source's own is no override and is not sent, a tool choice or thinking equal to the agent's default and an input equal to the source's are no change. Two radios with one line each replace the Engine select: scripted's "0 tokens · replays the recorded answers", or why it is disabled; live's "calls · ~N tok ≈ $x", the source's recorded steps from from_step to its last at the pricing table's rate ("≥ $x · later steps also run live" when some are not read), or "cost unknown: " (never $0 for an unknown; a scripted model's exact $0 reads "$0.00"). The engine is never switched behind the user: a scripted pick that cannot answer holds Run with its reason inline. Run names the engine: "Run on scripted · 0 tokens" / "Run live on · ≈ $x" (the panel's "Run experiment ▶" and Studio's "Run" are gone). "Edit this result" opens on an empty field with the recording beside it and seeds no edit; an edit equal to the recording is no edit — never sent, never a reason for live (Studio's field says "same as the recording · not an edit"); a kept result or reply typed back to its recording drops its draft. editResultAndReplay(step, callID) lost its third argument; a ReplayDraft names the field it opens on (at). The playground and a saved experiment's cell re-run choose the engine the same way: the radios replace the Engine select, Run reads "Run A on scripted · 0 tokens" / "Run A live on · ≈ $x", and an engine the reader picks rides the URL (engine=live too) and the form. In 0.18.3 every replay defaulted to live, a real model call, with nothing saying what it would call or cost.
Studio and the devtools panel: the experiment drawer reads in lib/drawer-spec.ts's order — source, target, engine, 1 what changes, 2 will be sent (drawn once), 3 tools during the replay, then side effects, thread mode and the option lab folded under "advanced", and Run — each section marked data-drawer-section; the panel's engine block is a radio group. Runtime-wide breakpoints ("Break on (parks every run)") are no longer a per-replay knob: the drawers point to /runtimes, where Studio sets them (the panel's rung-3 checkboxes and its PUT /api/runtimes/{id}/breakpoints call are gone).
Studio and the devtools panel: a model the source never ran on reads "· live call" in the model choice (UX-D-04); the replay vocabulary is one (UX-D-14) — the panel's result head "Replay 1 of t3 · scripted · finished" (was "Result · t3·x1"), its chip "replay of t3 · step 0" (was "experiment of t3"), Studio's run header "replay of · step N", the panel's "replay of" line spaced; the preview folds each run of unchanged messages as "N same", the changes open (UX-D-36); Studio's drawer keeps Run in a sticky footer (UX-C-19); the side-effect modes read Studio's words in the panel too.
Studio and the devtools panel (U18.2; UX-C-02, UX-C-03, UX-C-06, UX-D-03, UX-C-07): the target is checked before Run (lib/target.ts's targetCheck, one rule both drawers and the playground read, parity-tested). A source run that names no agent, or one no connected runtime registers, holds Run with "the source run names no agent: pick one" / "the source run is agent orders; no runtime registers it: pick one, or start its app" and an agent picker — the agents whose tools match the recorded catalog first — and posts nothing until a pick; Studio never takes the first registered agent as a stand-in (pickTarget returns unnamed for a source that names none; the panel's drawer opens on no agent instead of refusing). A source run set but unread, or whose read failed, holds Run ("the source run could not be read: "). A pick of another agent than the source's is held with both names until "replay as anyway", which sends cross_agent: true (on the post only, after a release, never in the preview's body). A source recorded by another build of the same agent says so in the target block — "recorded by researcher@9b5997a3 · registered now researcher@a59ce89e · tools differ: search_docs → web_search · scripted will miss at step 2" (the run's manifest_hash against the registration's, the preview's was_sent tools else the step's catalog, its scripted_key_match); an unnamed source reads "recorded by an unnamed agent@…"; the build line, another agent and an unregistered one read one template — and, when the tools differ, holds Run until "replay on today's build anyway". An escape holds only for what it was taken for — the source run and step, its agent, the runtime, the target and the target's manifest hash (targetKey; a header agent / runtime pick, a new source, step, pick or build asks again on all three surfaces). The playground's Run and Run matrix wait for the preview, hold on its refusal and on the same target check (never a stand-in agent on a mismatch), and read the preview for the active variant. A saved matrix whose agent is not its source run's is held on /experiments with the drawer's target block until "replay as anyway", and its cells then send cross_agent: true (cellBody's crossAgent); a pre-U17 matrix with no saved engine keeps the engine its runs ran on — live included (modesOf). After Run, a scripted miss is the command's reason.message with a plain list of ways on: "run again on live (≈ $x)" — the estimate on the active variant's card only — a choice that puts the form on live, never a post; "replay from step 0 with the recorded agent" (the panel offers it only while that agent is registered); and, in Studio, "save the recording as a test"; the panel's "run again on live" switches only its own source's drawer, reopening it when another is open. The result head reads the run's status word (lib/status.ts: "failed", never "finished"; outcomeWord); the exhaustion words are "the wefttest script ran out". Studio's picker is a labelled group that takes focus once when it holds Run, Run is described (aria-describedby) by every hold that applies (or the "previewing…" line while the preview is pending) and "run again on live" never focuses a disabled Run; the panel's notes are status lines and its options a group. Studio's target block and miss line live outside the eager chunk (components/studio/target-check.tsx). TestScriptedRefusalsAreActionable, src/lib/target.test.ts, src/panel/target.test.ts, the parity test "another build of the same agent" on a fixture shaped like the seeded researcher.
studio and runtime (U18.2, UX-C-08's server half): every scripted refusal is a sentence that says what differs and the two ways on, without a doc-section reference: scripted cannot try a changed system prompt: the recorded answers ignore the prompt, so it would replay the old answer — run on live, or drop the prompt change (was scripted engine with an instructions override would silently replay the old answer (WEFT-PLAYGROUND §5.5)), scripted cannot try another model: it replays the answers the source's own model recorded — run on live, or drop the model override, scripted replays a recorded run's answers: pick a source run, or run on live (was … a source run is required), your edit changes step N's request, and scripted holds only the recorded answer to the original — run on live, or drop the edit (was … transcript edits need engine live), scripted has no recorded answer for step N: the source never answered it — run on live, or replay from an earlier step (was … (use engine live)); Studio's copies and weft/runtime's word them identically. A caller matching the old sentences must match the new ones. testdata/api/playground-scripted-refusals.golden.json.
runtime: the scripted engine's miss names the source step whose recorded turn was nearest the request, each differing key component and the remedy: weft/runtime: no recorded turn for step 2 (scripted engine, key …): the tool list differs from the recording [tools]; the scripted engine answers only what the source recorded: run on live (components tools, thinking, tool_choice, sequential, messages; unanswered when the source recorded no turn, exhausted when it answered the request fewer times than asked). The text changes: no recorded turn for this request (scripted engine, key …): the input changed since the recording, or the run is not scripted-replayable now reads no recorded turn for step N (scripted engine, key …): <why> [<components>]; … (for this request only when the source recorded no turn at all) — a caller matching the old sentence must match no recorded turn for instead. A source that ended on a tool call (failed or canceled mid-run) misses at the step it never answered, unanswered; a recorded call to a tool the agent no longer has misses at the next step, tools, and — when that call was in the source's last step — at the step the source never answered, [tools, unanswered]: the words and the step the preview's scripted_hit uses. TestScriptedMissNamesStepAndComponent (R2, UX-C-07), TestScriptedPastTheEndWithMissingToolAgreesEndToEnd.
Studio and the devtools panel: the compare reads each compared run's row in one word — kept · same · changed · missing (lib/stepdiff.ts's rowState / ROW_WORDS, read from the cells; cell state kept, differs) — and a marker, row link or cost read uses the side's own ordinal. An identical scripted replay from step 1 reads "kept · same" on both surfaces; a compared run absent at an ordinal of an N-way compare reads "missing" (stepdiff.test.ts, step-diff.test.tsx, panel/compare.test.ts over diff-replay.golden.json).
Studio: the replay drawer's inline compare, read right after its command finished, retries a 404 three times a second apart ("recording the run…") — the command's finish can arrive before Studio holds the run row — then says " has not reached Studio yet …" with a read-again; it never sticks on "could not be read: no run …" (UX-C-10; diffQuery(a, b, {arriving}), lib/stepdiff.ts's ARRIVAL). Elsewhere a 404 is still not retried. The panel reads its compare only after the run row settled (no race there).
Studio: /experiments has a replays tab beside the saved matrices (UX-C-15): every playground run (the newest 2,000 through /api/runs?playground=true, said when cut) grouped under the run it replays (forked_from), the group with the newest replay first, each row its step, what it changed (edits, prompt, model chips), its engine, its outcome, its start and a compare against its source; the page opens on replays when no matrix is saved (lib/experiments.ts's replayGroups, replayChanges, fetchReplays). GET /api/experiments/{id} for an id runs carry but no definition was saved answers a synthetic document — unsaved: true, no variants or inputs, the agent its runs name, its runs listed — instead of a 404; an id with no runs stays a 404 (TestExperimentWithRunsButNoDefinition).
Studio: an experiment grid cell reads outcome · the answer's first line · tokens · cost · engine (scripted or live · <model>, lib/engine.ts's runEngineWords) · its wall time, the run id as its tail (…VGKSMX, ULIDs share their head) and a compare against its source (UX-C-16; lib/experiments.ts's answerLine, usageWords, idTail). The grid has a source column (the run its row's cells replay and the step), and each cell a vs-source chip — "same as source" / "changed at step N" — read lazily: one GET /api/diff per cell, the grid's first 24 cells once settled (VS_CHIP_CELLS, vsSourceWords); a later cell links to the compare and says it was not read.
weft mcp (I5; UX-H-23, UX-H-24, UX-H-25): explain_failure reads the whole run — every step (at most 25) and the run's subagent children (at most 12, two levels for a first error), at most 60 reads, the run row and the transcript counted — and answers first_error (the first error anywhere, in step order, a failed call's child run before the call's own text: {run, agent, step, tool, error, via}; an error nothing recovered from first, a recovered model attempt marked (retried, attempt N ok) when it is the only one), a digest (one line per step: each call and what it returned, failed model attempts, holes; one per child: its outcome, a park, its own first error; each line at most 1 KB, +n more; the caps — steps not walked, the children past 12 by id, the budget's stop, a child the budget left unread by id — said at its end; it follows next and last_assistant_text in the JSON so a cut takes it first), decisions and resumed_by / resumed_from. A child run is matched to its call by the step's child_run_id (else core's <run>/<step>/<call> id, else a call id only one child names: call ids repeat across steps). last_assistant_text is read from the run's own output batches only (a thread turn's input — an earlier turn's answer — never), null with last_assistant_note when it produced none; a parked run a resume decided reads parked, decided: deploy denied by alice: <reason> → resumed as <run> (the run document's decided block, U13.2), never "awaiting a decision", and a resume names the calls it decided. The run tool's line says the same of a decided park.
docs/mcp-recipe.md (I5; UX-H-28): installs @latest (weft mcp ships since v0.15.0), no version pinned in the text; the tool list, the parked-run row (decided reads decided) and the worked examples follow I5.
Devtools panel: panel.js grows from 91,032 B to 95,558 B gzip (+4,526 B; the rows from "U17 engine per command" to "P10a-pre-web-fix" in studio/web/panel-budget.json; 10,938 B under the cap); the cap is 104 KiB (106,496 B), up from 96 KiB (98,304 B) by the user's decision of 2026-10-23, so phase 10's panel features land without a trim pass — the ledger's cap stays the one source the build (vite.panel.config.ts) and scripts/panel-asset.ts fail on.
Breaking
Breaking: weft/otel (I1, issue 001): an explicit otel.Local(...) switches the discovery file off, as an explicit otel.Studio does — a pipeline that names its store writes only to the stores it names (and the environment's WEFT_STUDIO_URL / OTEL_*), never into a running weft studio / weft dev it found on disk. A Local-only pipeline (setup A's own store, a test's otel.Start(ctx, otel.Local(p), otel.NoGlobal())) used to join that Studio too and double-write every run, with one INFO line as the only sign. An app that relied on the join names the Studio as well — otel.Install(otel.Local(path), otel.Studio(url, token)) — or sets WEFT_STUDIO_URL. otel.Install() with no destination still joins (its default Local is added only when nothing else is found). An otel.OTLP, Datadog, Langfuse or Exporters destination does not switch the read off (add otel.NoDiscovery()). runtime.Install follows: beside an installed pipeline with a Local and no Studio (otel.LocalDB() != nil, otel.StudioEndpoint() empty) it skips the discovery file, opens no link and WARNs once (weft/runtime: the pipeline records only into a Local store (otel.Local, or otel.Install's default) and names no Studio: no link opened) — it used to register with the discovered Studio, which could then start runs whose spans never reached it; setup A dials its embedded server with runtime.Local(srv), a running Studio takes runtime.Studio(url, tok) with otel.Studio(url, tok). The rule reads the installed pipeline only: a pipeline started with otel.NoGlobal() is not installed, so runtime.Install with no runtime.Studio / runtime.Local still joins a discovered Studio there — name it with runtime.Local(srv) or runtime.Studio(url, tok) (the runtime.Install / runtime.Studio godoc and the README say so). examples/studio-local, run bare beside a weft studio, stays its own setup A and moves its listener to 8080 because 7331 is taken (it used to fail "address in use"). TestDiscoveryLocalOnlyWritesOnlyLocal, TestDiscoveryLocalPlusStudio, TestDiscoveryWeftDevShape, TestInstallLocalPipelineSkipsDiscovery, TestListenBesideAStudio.
Breaking:weft mcp (I5; UX-H-26): the list tools — runs, search, sessions, session and the new live — answer compact rows, not the route's JSON as served. Omitted: every empty, zero, false or null field (a *_known flag is kept when false and omitted when true; cache is kept unless exact), and on a run row trace_id, manifest_hash, instructions_hash, catalog_hash, weft_version, service, last_seen, meta, event_count, message_count, delta_count, events_emitted, messages_emitted, request_count and finished (the run tool has them); unpriced_models / delegated_unpriced_models and every *cost_reason move into one top-level cost_reasons list ({reason, rows: [row ids], more?}) per answer. Changed: model reads provider/name, err is cut to 200 runes, duration_ms stands for finished, kept *_hash values are cut to 12 characters; a session row's outcomes strip becomes outcome_counts and its first_prompt is cut to 80 runes; a session's turns omit the agent, session and public id the session names, cut prompt_preview to 80 runes and omit model, cost_badge, stop_reason and stopped_by when equal to the answer's turn_defaults (the first turn's; none where a turn has none). full: true on the call answers the route's JSON as served. A nine-turn session of tool-calling turns with ~220-character prompts answers 3.2 KB, 13.6 KB in 0.18.3 (make mcp-check asserts under 4 KB).
Breaking: studio: POST /api/playground/runs refuses a source run recorded by another agent than the command's target — 400 source run <id> is agent <recorded>; this command targets agent <target>: send agent "<recorded>" to replay it as recorded, or cross_agent: true to replay it as <target> anyway — unless the body sends cross_agent: true (Studio's alone; it never reaches the runtime: commands reach a runtime only through Studio, and the source the runtime resolves carries the transcript, not the agent name, so the check sits where the run's agent is known). In 0.18.3 a replay was that agent's prompt and tools over the run's transcript, silently; under the scripted engine it then failed "no recorded turn" with no reason (A6; UX-C-03, UX-C-06). A command that names no agent is refused 400 too, naming the source's agent or saying the source names none — Studio never picks one (UX-C-02's wire half); a source with no recorded agent name still runs on the target a command names. The preview keeps assembling a cross-agent replay, with its agent warning. TestPlaygroundRunRefusesCrossAgent, testdata/api/playground-cross-agent.golden.json.
Breaking: Studio (U17, UX-C-01): a form, link or saved experiment without an engine no longer means live — it means the command's own choice (above); a saved experiment's engine (live included) is kept as a pick.
Breaking: studio: GET /api/diff aligns runs on the step ordinals of the nearest run both sides share (A8, UX-C-04). It follows each side's weft.forked_from chain (<source>#<n>, offsets summed, at most 8 links; a panel token reads only the sides' own forked_from): a replay against its source — or a replay of a replay against the root — aligns its step k with the source's step n+k, the source's steps before n rows with kept: true (the replay side null, nothing compared, never a change; a kept row only where the other side holds the step); two replays of one run — siblings, maybe from different steps — align on it, offset by their difference, steps neither ran no row, and the rows between their from_steps (one side kept the shared run's step, the other re-ran it) are compared against the shared run's own step: kept_by ("a"|"b") names the keeping side, whose column is the shared run's step (its own ordinal null), the re-run compared with it — a change there counts in changed_steps and first_changed, never in summary.kept; a shared run Studio cannot read (another public id, gone) leaves those rows not compared, every column in unknown. Studio's compare and the panel's read them "same · vs source" / "changed · vs source", the note "b kept the source's step; a vs source: changed" (lib/stepdiff.ts's rowNote, parity-tested). A reader of the rows must key them as the shared run's ordinals: step is the aligned ordinal, the new a_step / b_step each side's own (null where a side has no step — link to a run's step by its own), kept marks the prefix the replay did not re-run, aligned ({replay: "a"|"b"|"both", source, a_from, b_from}, null without a lineage) names the shared run and each side's offset into it, and summary.kept counts the kept rows. A forked_from naming a step past the source side's last is no alignment and a gap hole names it. In 0.18.3 an identical replay from step 1 read "changed at every step" (its fresh step was compared with the source's step 0). Runs with no lineage align by their own ordinals, as before. TestDiffAlignsAReplayOnItsSource, TestDiffFollowsTheForkChain, TestDiffFromStepPastTheSource, TestDiffSiblingsCompareAgainstTheSource, TestDiffSiblingsSourceUnreadable, testdata/api/diff-replay.golden.json, testdata/api/diff-siblings.golden.json; TestScriptedReplayFromStepOneIsKeptSame with a real runtime.
Breaking: studio: a usage that differs where a side billed no tokens (the scripted engine's 0→0) is no longer a change: GET /api/diff lists it in the row's new differs ([] otherwise), not changes, the numbers kept on both sides (UX-D-15). Studio and the panel draw it "differs · not counted", greyed, and the compare's line reads "no step changed of 1 · 1 kept (not re-run) · usage differs (0 tokens)" — not "identical" beside "2 of 2 steps changed".
Breaking: studio: save as test on a replay composes its whole forked_from chain: for every step the replay kept (steps 0 to n−1 of a replay from step n > 0) the parent's own composed set (followed up the chain, at most 8 links, a loop a 409) byte for byte, then its own fixtures renumbered after them, so the generated test replays the turn from its start — a replay from step 0 of a replay still keeps its parent's. A scripted replay's own fixtures are stamped with the source's model, not weft/runtime/scripted; the skeleton's header says "Kept: steps 0–n−1 are 's fixtures (not re-run by this replay)", the export answers Weft-Kept-Prefix naming the chain from the root (<source>#<n>, or <root>#<k>,<replay>#<m>) and Studio's toast "saved … (steps 0–0 from the source , 1 on from the replay)" / "steps 0–1 from the sources r → pg". A source that is gone, derived, or did not record each kept step is a 409 saying so — never a partial test. POST /api/playground/fixtures composes the same (UX-C-30; TestSaveAsTestPasses runs the composed test, two chained replays included, under go test; TestSaveAsTestForkCycle). An agentless run's generated test is run_replay_test.go (package run_test), no longer replay_replay_test.go.
Breaking: cmd/weft: weft export <id> --wefttest <dir> writes Studio's save-as-test tree into the package directory <dir> byte for byte — <agent>_replay_test.go beside testdata/TestReplay_<run>/*.json, a test that passes under go test — instead of the fixtures alone under <dir>/<run id>/; a test file or fixtures already there need --force, and a symlinked testdata or testdata/<test> is refused before any write. The old behaviour is --fixtures-only (with --test name, which is now refused without it) (UX-S5a-07; TestExportTreeIsTheUIZip compares the two trees file by file, TestUnzipTreeGuards).
Breaking: obsdb: CommandStore gains DeleteRuntime(ctx, runtimeID) — a runtime's row and its breakpoint set removed (Studio's forget, U20.1); its commands and routes stay. Both first-party backends implement it and obsdbtest's Commands case holds them to it; a third-party CommandStore must add it.
Breaking: runtime: a replay from step n > 0 — live or scripted — is the same turn: it runs with core.KeptSteps(n), so the kept steps count toward the agent's MaxSteps and DetectLoops, and an unchanged replay of a turn a guard stopped stops the same way at the same step (the compare reads kept · same, the same max_steps / loop_detected end) instead of asking a step the source never made. A substitute-mode chain's legs continue the turn too (KeptSteps of the kept steps and the legs before), so the chain ends max_steps where the source did; the chain's own "the substitute chain spent N steps, the agent's budget is N" error is gone. A decision on a parked replay still resumes with a fresh budget, as any resume does.
Breaking: runtime: a replay's command error names the step in the source's ordinals, as the scripted miss and the step compare do, and says so: weft: run failed at step 3 (the source's step ordinal; this run's step 1): … for a replay from step 2 (it read run failed at step 1: … no recorded turn for step 3). The replayed run's own record keeps its own step numbering.
Upgrade notes
The SQLite file moves to schema 0025 and a ClickHouse database to 0028 on first open; a 0.18.x binary refuses the migrated database with ErrNewerSchema. Keep a copy before upgrading if you may go back. A large SQLite file's first open waits for the span_stats backfill (Open logs its span count and duration).
An agent behind a model middleware that does not forward Info gets a new weft.manifest.hash after the upgrade (below), so its runs on either side of it read as two builds.
Fixed
cmd/weft: weft dev's one line names the app's runtime after a restart over the same database, or a restart on a .go save: it read a stable runtime id already listed (restored or disconnected) as not new and said "no runtime registered yet" while /api/runtimes listed it connected; it now waits for a runtime connected that was not connected before the app started, or connected then from another pid (TestDevRuntimeRegisteredAfterRestart).
core: weft.manifest.hash is computed after the WrapModel chain is built, so it is always sha256 of weft.Manifest(agent) — what weft/runtime registers. It was computed before: a model middleware that does not forward Info gave the run one hash and the registration another, and Studio read the same build as two. TestManifestHashAfterTheModelChain.
weft/otel (I2b, issue 002): the dev gate's refusal names its real cause — under otel.NoEnv without otel.DevMode Start WARNs otel: RawBodies ignored: otel.NoEnv reads no WEFT_ENV (hint: add otel.DevMode(), or drop NoEnv — otel.NoDiscovery() isolates from the discovery file and keeps WEFT_ENV — and set WEFT_ENV=dev); it used to say … WEFT_ENV is not dev even when WEFT_ENV was dev. Outside NoEnv the text stays … WEFT_ENV is not dev. The same pair for otel: Blocking ignored: ….