GNL 0.17: early and moving fast — APIs can change between releasesJoin us

GNL
Docs menu
Studio@gnldev/lab

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.

Experimental
@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.

install
npm i @gnldev/lab @gnldev/durable ai

Six questions, six functions#

Each function plays something different, and what it plays decides whether a tool can run:

  • What brought this call about? explainRun replays 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 its noise. It takes 2^parts × trials model calls.
  • Which words decided it? wordShares with toolChoiceProbability reads, 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), so sampledToolChoice asks 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? checkPrune plays 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? bestForm tries 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? runCapsule copies 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 from answers you 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? threadPlan reads a thread's turns and calls no model; diagnoseTurn needs expect — what a right answer does — and refuses to start without it. With guess, 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#

explain a recorded run
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));
play a recorded run changed, beside the run as it was
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.

the Lab tab
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#

fnexplainRun

What brought a recorded run's calls about: parts left out in turn, replayed, no tool runs. Returns shares with noise, representative and dependsOnParts.

fnexplainRunSize

How many replays an explanation will take, so you know the bill before you ask.

fnwordShares

The 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.

fntoolChoiceProbability

The 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.

fnsampledToolChoice

The same value by asking each subset several times (default 5), with its variance, for any model the AI SDK reaches.

fncheckPrune

Whether a prompt section can go: new prompts played for real, beside the noise floor and a control section. The tools run — give it fakes.

fnbestForm

Every 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.

fnrunCapsule

A recorded run copied and played again changed (system prompt, tools, history, memory), beside the run as it was. No tool runs, nothing is written.

fnthreadPlan

A 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.

fndiagnoseTurn

Why a turn of a thread went wrong, against an expectation you write: an optional guess first, then plays beside the turn unchanged.

fnstudioLab

Builds 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.

Related
The replay under Lab is the one the cost optimizer uses to test a cheaper model; Lab itself opens from the Studio Inspector.