Docs menu
Lab
Why an agent did what it did, and what it would do changed — measured by playing its runs again with parts left out. Experimental: the API will move.
@gnldev/lab is experimental, and Studio's Lab tab says so. Its API moves faster than the rest of GNL's: it changed several times before 0.16 and will change again, and every change is in the changelog with what to do. Its diagnosis is measured, but narrowly — what the numbers are not is under "Limits" below.What it is for#
An agent did something you did not expect, and a prompt is a pile of sentences: which one brought the call about? Lab answers by running the agent again with parts left out and counting what changed; a part's share is its Shapley value. Nothing here changes your agent. The result is something you read, and a change you then make in your own code.
Lab sits on @gnldev/experiment (the replay, the masking, the trials and the arithmetic), which @gnldev/optimizer uses too; neither needs the other.
Where it comes from#
Lab is open source under Apache-2.0, in the public repository and on npm like the other packages — it is not part of the paid layer. It needs @gnldev/durable and ai 7 as peers.
npm i @gnldev/lab @gnldev/durable aiSix questions, six functions#
Each function plays something different, and what it plays decides whether a tool can run:
- What brought this call about?
explainRunreplays a recorded run with each of its parts left out — prompt sections, earlier messages, tool results. No tool runs. Before any share it says whether the recording is what the model usually does (representative) and whether the outcome depends on the parts at all (dependsOnParts), and every share carries itsnoise. It takes 2^parts × trials model calls. - Which words decided it?
wordShareswithtoolChoiceProbabilityreads, from a provider's logprobs, how much each group of words moved the chance that the next call is a given tool. OpenAI's API gives no probability on a tool call (measured), sosampledToolChoiceasks each subset several times instead, and each share then says how far chance can move it. It explains one decision, the next one, not a whole run. - Can this prompt section go?
checkPruneplays new prompts for real, with the section and without it, beside the noise floor and a control section that should matter. The tools run — there is nothing recorded to replay — so give it fakes. - Which parts does a new agent need?
bestFormtries every subset of an agent's components on a scenario set with fake tools, checked by a program and not a judge model, and names the cheapest form that does as well as the best. It picks nothing for you: you write the chosen components into the agent's code. - What would this run have done, changed?
runCapsulecopies a recorded run and plays it again with a different system prompt, other tools, or without its history or memory. No tool runs and nothing is written. A call the recording never made is answered fromanswersyou write, or listed as pending. The change is read against the run played as it was, as many times, so a difference is not mistaken for chance. - Why did this turn of a thread go wrong?
threadPlanreads a thread's turns and calls no model;diagnoseTurnneedsexpect— what a right answer does — and refuses to start without it. Withguess, a model first names the one or two parts most likely to blame, and only those are played beside the turn unchanged; the cause is named only by plays, never by the guess.
Two of them in code#
import { explainRun, explainRunSize } from '@gnldev/lab';
import { retryingFetch } from '@gnldev/experiment';
import { SqliteStorage } from '@gnldev/durable/sqlite';
import { createOpenAI } from '@ai-sdk/openai';
const storage = new SqliteStorage('runs.db');
const model = createOpenAI({ fetch: retryingFetch() }).chat('gpt-4.1-mini');
declare const tools: Parameters<typeof explainRun>[0]['tools']; // the agent's own definitions; execute never runs
console.log(explainRunSize(4, 3), 'replays'); // know the bill before you ask
const r = await explainRun({
journal: storage.runs, runId: 'order-42', model, tools,
systemSections: { rules: 'Always look the order up first.', policy: 'Damaged: replace. Refund only if asked.' },
messages: { greeting: [0, 1], orderNumber: [2, 3] }, // positions in the run's input; the last message stays
toolResults: { lookup: 'searchOrders' }, // the result is hidden, the call stays
trials: 3,
});
if (!r.representative.ok) console.log('this recording is not what the model usually does — shares are weak');
if (!r.dependsOnParts) console.log('the outcome is the same with every part and with none — read signs, not %');
for (const s of r.shares) console.log(s.part, s.share.toFixed(2), '±', s.noise.toFixed(2));import { runCapsule } from '@gnldev/lab';
import { SqliteStorage } from '@gnldev/durable/sqlite';
import type { LanguageModel, ToolSet } from 'ai';
declare const model: LanguageModel;
declare const tools: ToolSet; // the agent's definitions, changed as you want; execute never runs
const storage = new SqliteStorage('runs.db');
const r = await runCapsule({
journal: storage.runs, runId: 'order-42', model, tools,
system: 'Help with orders. If it came broken, refund it.',
answers: [{ toolName: 'refund', args: { orderId: '42' }, output: { refunded: 250 } }],
unchanged: { tools }, // also play the run as it was, as many times
});
console.log(r.sameAsRecorded.value, r.pending, r.difference);In Studio#
Give Studio the lab and it shows a Lab tab. The tab lists no runs of its own: you open a completed run in the Inspector and send it with Open in Lab (for a thread, from the thread ledger). It has three tabs — Improve, New agent and Scenarios. Improve puts a run or a thread on one screen against one written expectation: the turns on a strip, the turn's explanation, its diagnosis and its capsule. Without the option the routes answer 501 and the tab is hidden.
import { createStudioApp } from '@gnldev/studio';
import { studioLab } from '@gnldev/lab';
import { toJournal, type CreateGnlConfig } from '@gnldev/durable';
import { SqliteStorage } from '@gnldev/durable/sqlite';
import { openai } from '@ai-sdk/openai';
declare const config: CreateGnlConfig; // the config createGnl is given
const storage = new SqliteStorage('runs.db');
const studio = createStudioApp({
reader: toJournal(storage),
apiBase: '/studio',
lab: studioLab({ config, models: { 'gpt-4.1-mini': openai('gpt-4.1-mini') } }),
});Nothing is spent before you see the plan. Every Lab call shows the plays, the tokens, the price and the provider first, and says that no tool runs and no record changes; a call is audited by what was asked (counts and names, never the text you typed). A long call shows how far it got, with a Stop button; when the provider answers 429 it slows to one call at a time and says it is waiting. The Lab only offers a run of an agent you gave it, and the agent's own tools never run in it: only the fake tools of the scenarios do.
Limits — what the numbers are not#
A thread diagnosis is measured on planted faults, with one model. Forty faults were planted in agents' threads (a stale working memory, a turn fallen out of the window, a wrong tool result, a weak rule) and diagnosed with gpt-6-luna through Studio's own routes: the right cause was named in 39, a wrong one in none, and the 40th stayed undecided. On 15 turns with nothing wrong in their input, each made to fail once, no cause was named in any. Elimination alone — the diagnosis before guess — named 15 of the same 40.
The faults were planted to be found: the misleading fact is in the text, where a reader sees it. A misleading recalled message could not be planted at all, because the model never followed one. It is one model. Zero wrong causes in 55 puts the rate at most about 6.5%. The label comes off with faults a reader does not see, and more than one model.
Which words decided it, on a provider that gives no logprobs, is sampled: with few tries most shares are within chance. And Lab is not a verdict machine — the result is something you read, with its noise beside it.
API#
explainRunWhat brought a recorded run's calls about: parts left out in turn, replayed, no tool runs. Returns shares with noise, representative and dependsOnParts.
explainRunSizeHow many replays an explanation will take, so you know the bill before you ask.
wordSharesThe share of each group of words (at most 12) in a value you give it; with toolChoiceProbability or sampledToolChoice, in the chance the next call is a given tool.
toolChoiceProbabilityThe probability that the next call is a tool, read from an OpenAI-compatible provider's logprobs; one call per subset. A provider with none is an error, not a zero.
sampledToolChoiceThe same value by asking each subset several times (default 5), with its variance, for any model the AI SDK reaches.
checkPruneWhether a prompt section can go: new prompts played for real, beside the noise floor and a control section. The tools run — give it fakes.
bestFormEvery subset of a new agent's components on a scenario set with fake tools; the cheapest form that passes as well as the best, absent when none passed a run.
runCapsuleA recorded run copied and played again changed (system prompt, tools, history, memory), beside the run as it was. No tool runs, nothing is written.
threadPlanA thread's turns, a page at a time: how each ended, where its input came from, what can be tested, and what diagnosing costs. Calls no model.
diagnoseTurnWhy a turn of a thread went wrong, against an expectation you write: an optional guess first, then plays beside the turn unchanged.
studioLabBuilds the object Studio's Lab tab calls; hand it to createStudioApp as the lab option, with the config and the models an explanation may use.