Skill 36 · N8n:create Instance AI Eval
Subchapter 36.1
case-shapes.mdMarkdown46 KBView on GitHub
The SKILL covers the build archetype. This file covers the other four — behaviour/process, credential, seeded, — plus the director-note vocabulary multi-turn cases rely on. Field-level docs live in the eval ; this is the opinionated and the traps.
Example cases in the corpus get renamed and churned, so this file names as few
cases as possible — search the LangTracer suite by tag/field instead (the
search_test_cases MCP tool, or export the suite and grep). The one stable
pointer worth naming: applies-each-change-when-asked (in the
baseline suite) for a well-built director conversation.
The schema
(harness/schema.ts (opens in a new tab))
enforces the rules you must respect:
seed, whose mode is inline or replay — so
the modes are mutually exclusive by construction.conversation or a seed with mode: "replay" (which
supplies the live turn from the trace).executionScenario, processExpectation, or
outcomeExpectation.buildExpectations is a forbidden key (fails loudly) — it was split into
processExpectations / outcomeExpectations.These test how the agent converses, not just what it builds: does it ask the
right clarifying question, avoid re-asking, honour a mid-build correction,
respect plan approval, batch bundled changes? They’re graded by
processExpectations and are often build-only (no executionScenarios).
Tag them behaviour and search the suite for that tag for patterns.
Mode is chosen automatically from conversation:
user turn, no assistant turns — the
prompt is sent and every confirmation is auto-approved. Plain build cases.
Caveat: only confirmations are auto-approved — a genuine clarifying
ask-user question is never answered, so the build hangs until the
per-iteration timeout and reports as BUILD FAILED: Run timed out with no
scored result (nothing to grade). If a prompt is vague enough that the agent
is likely to ask a setup/topology question before building (an unspecified
data source, delivery channel, or one-vs-two-workflow split), author it
multi-turn with a [bracketed] director note in turn 1 that pre-answers
those questions so the agent proceeds to build. A single-prompt build case
only works when the prompt leaves nothing the agent must ask about. Real sourced
prompts are frequently terse (“i want to create a webhook”, “convert a topic
into a YouTube script”) and almost always trigger a clarifying question —
default a terse sourced prompt to multi-turn with a director note that
pre-answers the setup/topology it omits.messageBudget).Rules that trip people up:
assistant turns are reference only — never delivered to the builder.
They tell the proxy the expected flow. The load-bearing content is your
user turns + director notes; lead with those.applies-each-change-when-asked does — one bracketed, ordered list; a single
opening reference ack is fine).conversation[0] is sent to the builder raw. Never put a director note
in the opening turn — it leaks verbatim into the build prompt. Notes belong
only in the proxy-driven turns ([1]+).credentialRequests) or a workflow
setup-wizard card (setupRequests, an entry with credentialType) — is
auto-declined (“I’ll set them up later”) unless a director note governing
that exact moment asks the user to engage — see “Engaging the
credential-setup card” below. The workflow setup wizard is the one that
matters for a normal build: live testing found the builder routes
credential resolution through it during a workflow build, never through the
standalone tool. The standalone tool is real and live-verified too (all
three of manual/auto/skip), but only via a standalone credential-connect
request with no build attached (e.g. “connect my Slack account now, before I
build anything”) — see the tool’s own doc comment in utils/user-proxy/tools.ts
for the captured shapes.Text inside [square brackets] is a director note — how the user behaves at
that moment, never spoken verbatim. It overrides the proxy’s default (“always
answer, inventing a plausible value”). The proxy’s system prompt
(utils/user-proxy/prompts.ts (opens in a new tab))
recognises this vocabulary:
| To make the user… | Director note |
|---|---|
| Withhold a value until asked | [Don't bring up the channel unless the agent asks where to post; then say 'Slack #growth.'] |
| Refuse and hold firm on re-ask | [The user has no channel and won't provide one. If asked — question or setup card, even repeatedly — skip it; never invent one.] |
| Dismiss a setup card / skip a value | [When the setup card asks for the API base URL, dismiss it — the user hasn't decided yet.] (proxy dismisses via approve_or_reject(false)) |
| Reject a plan that misses a requirement | [When the agent shows its plan, reject it unless it sorts descending by count.] |
| Iterate change-by-change, in order | [Send each change below in order, waiting for the build after each; keep bundled changes in one message.] |
| Run the workflow themselves and report back | [When the agent asks you to test the workflow, run it yourself, then tell it you ran it.] Set allowUserExecution: true on the case. The proxy then sets runWorkflowId to the intended saved workflow. The harness uses normal execution without mocks. This action supports credential-free manual or schedule workflows without pinned data. |
The text of a turn may be an array of strings (joined with newlines) so a
long director note stays readable in JSON. A note governs only what it covers;
elsewhere the proxy answers every question with a plausible placeholder (mocks
make placeholders fine). Setup cards are filled via the wizard (apply_setup_wizard)
or dismissed — never answered as questions.
Sanity rule: a behaviour case is only worth shipping if its
processExpectations would fail on the misbehaviour you’re guarding against.
If the agent could ignore your intent and still pass, the assertion is too loose.
By default a build sees no credentials: the harness pins every build thread’s credential view to the case’s declared set (empty unless declared), so concurrent cases and whatever else lives on the instance can never leak in.
"credentials": [{ "type": "slackApi" }]Declared credentials are created for real before the build, the thread’s view is
pinned to exactly that set, and they’re deleted after the run. Tokens are a
placeholder by default; for a live token (verification runs for real instead
of mocked) set the type’s EVAL_* env var — e.g. EVAL_SLACK_ACCESS_TOKEN,
EVAL_NOTION_API_KEY, EVAL_GITHUB_ACCESS_TOKEN, EVAL_GMAIL_ACCESS_TOKEN,
EVAL_TEAMS_ACCESS_TOKEN. name is optional (duplicates get a #2 suffix).
Only a closed set of types is valid — declaring anything else fails at case-load
with a pointer to add a template. From
credentials/seeder.ts (opens in a new tab):
slackApi, notionApi, githubApi, gmailOAuth2,
microsoftTeamsOAuth2Api, whatsAppTriggerApi, httpHeaderAuth,
httpBasicAuth. Need another? Add a CredentialTemplate to seeder.ts (a
defaultName, optional envVar, and buildData(token)); that extends
SUPPORTED_CREDENTIAL_TYPES, which the schema validates against.
By default the proxy defers any credential slot — standalone card
(credentialRequests) or workflow setup-wizard card (setupRequests, a
credentialType entry) — with an empty/no-op response. This happens before
the LLM is even called (confirmation-payload.ts‘s tryInfrastructureResponse
for the standalone card; deterministic.ts‘s credential-only-request check for
the wizard), so it’s the same deterministic behavior for every case that
doesn’t opt in.
To make the simulated user engage instead, add a director note that names the
credential/OAuth/connect vocabulary at the moment the card would appear
(matched by hasCredentialEngagementDirection in utils/user-proxy/index.ts):
"conversation": [
{ "role": "user", "text": "Post a daily summary to Slack every morning at 9am." },
{ "role": "assistant", "text": "I'll need a Slack credential connected before I can post — I'll show you the setup card." },
{ "role": "user", "text": "[When the credential setup card for Slack appears, don't defer it — set up the credential now using the existing Slack credential shown on the card.]" }
]manual is one action that covers three cases, driven entirely by how many
existingCredentials the resolved type’s request carries — no separate
“create” action, the harness decides automatically:
| Existing candidates | What happens | Case setup |
|---|---|---|
| Zero | The harness creates a real credential (via the same per-type template credentials/seeder.ts uses for pre-run seeding) and selects the new id | Don’t declare that type in credentials[] at all |
| One | Selected automatically, no disambiguation needed | Declare exactly one: credentials: [{ "type": "slackApi" }] |
| Two or more | The director note must name a specific one by its declared name; the proxy echoes it back to disambiguate | Declare 2+ with distinct names, e.g. credentials: [{ "type": "slackApi", "name": "Personal Slack" }, { "type": "slackApi", "name": "Team Slack" }] |
Simplified examples of each (not committed in the repo — cases live in the LangTracer suite once pushed, per “Push to a lang-tracer suite” in the parent skill; these are illustrative, trimmed of the full calibrated wording):
Zero existing — the harness creates one:
{
"description": "Manual path, create variant: no Slack credential exists, so engaging must create one rather than select one.",
"conversation": [
{ "role": "user", "text": "Post 'Standup reminder!' to Slack every weekday morning at 9am." },
{ "role": "assistant", "text": "I'll need a Slack credential connected before I can post — I'll show you the setup card." },
{ "role": "user", "text": ["[When the setup card asks for a Slack credential, don't defer it — set it up now. Confirm the assistant does not ask again which Slack credential to use once one was set up.]"] }
],
"complexity": "simple",
"tags": ["behaviour", "credential-setup", "slack"],
"triggerType": "schedule",
"processExpectations": [
"The agent did not ask again which Slack credential to use once one was set up via the setup card."
],
"outcomeExpectations": [
"A schedule trigger posts the reminder to a Slack node with a real Slack credential attached (not left unset or deferred)."
]
}Two existing — must pick the one the director note names:
{
"description": "Manual path, select variant: two Slack credentials declared so the assistant can't silently resolve which one to use.",
"conversation": [
{ "role": "user", "text": "Post 'Standup reminder!' to Slack every weekday morning at 9am." },
{ "role": "assistant", "text": "I'll need a Slack credential connected before I can post — I'll show you the setup card." },
{ "role": "user", "text": ["[When the setup card asks for a Slack credential, don't defer it — set up the credential now, selecting the 'Team Slack' credential shown on the card (not 'Personal Slack').]"] }
],
"credentials": [
{ "type": "slackApi", "name": "Personal Slack" },
{ "type": "slackApi", "name": "Team Slack" }
],
"complexity": "simple",
"tags": ["behaviour", "credential-setup", "slack"],
"triggerType": "schedule",
"processExpectations": [
"The agent selected the 'Team Slack' credential (not 'Personal Slack') via the setup card, and did not ask again which one to use once selected."
],
"outcomeExpectations": [
"A schedule trigger posts the reminder to a Slack node wired to the Team Slack credential (not Personal Slack, and not left unset)."
]
}Three existing — proves disambiguation isn’t hard-coded to “exactly two”:
{
"description": "Manual path, select-among-many variant: three same-type Slack credentials declared, not just two.",
"conversation": [
{ "role": "user", "text": "Post 'Standup reminder!' to Slack every weekday morning at 9am." },
{ "role": "assistant", "text": "I'll need a Slack credential connected before I can post — I'll show you the setup card." },
{ "role": "user", "text": ["[When the setup card asks for a Slack credential, don't defer it — set up the credential now, selecting the 'Support Slack' credential shown on the card (not 'Personal Slack' or 'Team Slack').]"] }
],
"credentials": [
{ "type": "slackApi", "name": "Personal Slack" },
{ "type": "slackApi", "name": "Team Slack" },
{ "type": "slackApi", "name": "Support Slack" }
],
"complexity": "simple",
"tags": ["behaviour", "credential-setup", "slack"],
"triggerType": "schedule",
"processExpectations": [
"The agent selected the 'Support Slack' credential (not 'Personal Slack' or 'Team Slack') via the setup card, and did not ask again which one to use once selected."
],
"outcomeExpectations": [
"A schedule trigger posts the reminder to a Slack node wired to the Support Slack credential specifically (not Personal Slack, not Team Slack, and not left unset)."
]
}All three are complete as written in one respect that earlier guidance got wrong: none of them asserts anything about the credential’s connection test. Both routes a credential takes into a build now authenticate, so an expectation acknowledging a connection-test failure reds every correct build — see “Credential validity” below.
The wire shapes (verified live against both tools — credentials.tool.ts‘s
handleSetup state machine and workflows.tool.ts‘s setup-wizard equivalent):
| Director note asks for… | Proxy action | Resume payload | Tool result |
|---|---|---|---|
| Set up now (zero existing) | manual → harness creates a credential | {kind:'credentialSelection', credentials:{type: newId}} | credential attached, and its connection test resolves as passing by default — see “Credential validity” below |
| Select a specific one (2+ existing) | manual + existingCredentialId (standalone) or a matching id in nodeCredentialsJson (wizard) | {kind:'credentialSelection', credentials:{type: id}} | assistant should stop asking and proceed |
| Automatic/browser setup | choose_credential_setup_option(auto) — standalone tool only | {kind:'credentialAutoSetup', credentialType} | {success:false, needsBrowserSetup:true, ...} |
| Explicitly decline | choose_credential_setup_option(skip) (standalone) or dismiss the wizard card | {kind:'approval', approved:false} | {success:true, deferred:true} |
| (nothing — default) | (short-circuited, no LLM call) | empty/no-op | deferred |
Every eval credential holds a placeholder token unless you set the type’s
EVAL_*_ACCESS_TOKEN env var (see “Credential cases” above). The parent
umbrella (TRUST-348) requires “no stored provider credentials in any phase,” so
a real token is the wrong fix. Instead the harness resolves the connection
test as passing, the same way for both routes a credential can take into a
build:
credentials[] — models a credential the user already has
connected, so it authenticates.Do not assert a connection-test failure for either; such an assertion reds on every correct build. See “Credential validity” below for how to script a card-created credential that deliberately does not authenticate.
auto is reachable but inert — the product genuinely rebuilds the agent
and returns needsBrowserSetup:true, but this harness has no Computer Use
tools attached, so the conversation stalls afterward (expected, not a bug).
Keep any case scripting auto a local smoke test, never part of the gated
suite — it will time out.
The product will not apply a setup card whose credential fails its connection
test — the frontend’s isCredentialComplete returns isCredentialTestedOk, so
Apply stays disabled until the test passes. “The user completed the setup
card” therefore implies “the credential authenticates.” The same holds for a
credential declared in credentials[]: it stands for one the user connected
before the conversation started. Both carry a placeholder token that would fail
for real, so the harness resolves the test as successful for both — otherwise
every such case models a state a real user cannot reach.
Mechanically: the harness registers the case’s seeded credential ids on the
thread up front, the proxy adds the ones it sets up mid-run (the types it lists
in workingCredentialTypes), both go on the same bypassCredentialTest list of
the eval allowlist endpoint, and the credential adapter resolves their test as
successful without contacting the provider. The token is untouched — only the
test result is synthesized, and only for credentials that case created. Nothing
changes about “no stored provider credentials”.
To script a credential that does NOT authenticate, say so explicitly in the direction, naming which one:
[Set both credentials up on the card. The Slack token you enter is a valid
working one. The Notion token you enter is an old expired one that does NOT
authenticate. Expect the Notion connection test to fail — that is intended.]The proxy then omits that type, its test runs for real, and it fails. Note what
this models: not “the card was applied with a broken credential” (unreachable),
but a credential that stopped authenticating — expired, revoked, scope changed.
A card is currently the only way to get a failing credential: declaring one in
credentials[] gives you a working one.
Non-vacuity for these cases is deterministic, not judged. A bypassed test is
deliberately indistinguishable from a real pass in everything the agent sees —
any hint would make it hedge, which is the behaviour such a case exists to rule
out. So the judge cannot tell whether the bypass fired; check the run’s
credential-test-bypassed proxy decision stat instead (it appears in the
[proxy: ...] segment of the build log line). On a mixed card, the count is the
assertion: two credentials with one scripted invalid should show exactly 1.
A seeded case puts prior history into the build thread before the live turn, so the eval drives only the turn under test. Use it to set up the situation you want to test — history up to some point, then a message that should trigger (or correct) a behaviour.
Pick the lightest mode that fits:
| Situation | Mode | Pairs with |
|---|---|---|
| Prior work already exists (a workflow to repair, an agent to change) | seed.mode: "inline" — prior messages + the workflows/agents they reference, in the case body | a normal conversation for the live turn |
| Prelude is just “what was discussed” (no tool calls, no workflows) | seed.mode: "inline" with {role, text} shorthand messages | a normal conversation for the live turn |
| Shallow 2–3 turn prelude where the agent’s live replies matter | none — a plain multi-turn conversation re-drives it live | — |
| Confirming a real failure locally, before authoring the case | seed.mode: "replay" — rebuilds a thread from its LangSmith trace at run time; nothing committed, expires with the trace | supplies its own live turn (omit conversation) |
Both modes are implemented and wired (harness/conversation-seed.ts +
harness/langsmith-seed.ts, threaded through the runner). The literals match
lang-tracer’s metadata.seed verbatim, so nothing translates between the repos.
The seeded portion is replayed, not re-run. The prior messages are written into
the thread as they stand (marked seeded: true so the judge and checks can tell
them apart), and the workflows, data tables and agents they reference are created
on the instance — so when the live turn runs, the agent sees the workspace the case
says it should. Data tables are created schema-only, no rows: row values are the
most sensitive thing a table holds, and they stay off the eval instance.
One thing the restore can’t reproduce: the sandbox is empty. The agent re-reads
state from the database rather than editing source it “wrote”, so a seeded case is
harder than the real turn was — never grade one on cost, turn count or
messageBudget.
The consequence to internalise: nothing you assert can change what already happened in the seeded turns — the agent didn’t produce them, it’s only reacting to them now. So target your expectations at the live turn and everything built or said after the seed: what the agent does with the restored state, how it responds to the triggering message, what the workflow looks like after the correction. Asserting on the seeded prelude itself proves nothing.
Default to a synthetic case (an authored prompt + director script, or an
inline seed prelude): it’s durable, carries no real user
data, never expires, and you control the setup exactly. Reach for replay only when
the misbehaviour genuinely needs real prior context that’s impractical to
synthesize — a long accumulated thread, specific built workflows/tables — and
the issue is in a later turn. (A turn-0 issue can’t be isolated by seeding: it
lands inside the seed, so you’d bake the bug into the prelude.) Two standing
costs keep it a last resort, not a default:
inline seed is how the
whole concern goes away for good.seeded, keep it out of
full/pr).If a plain prompt + director script can reproduce the situation, prefer that.
"seed": { "mode": "replay", "threadId": "<thread-id>", "project": "instance-ai" }The case carries only the opaque thread id — no conversation content lands
in the repo. At run time the harness pulls the thread’s runs from LangSmith,
reconstructs the message log, recreates the workflows/tables it built, and splits
at the last user message: everything before is the seed, that message is
sent live. project defaults to instance-ai. Optional endpoint pins a
US-tenant source host during the US→EU migration; optional liveTurnRunId pins
which user turn goes live.
LANGSMITH_API_KEY can reach
and finds the one holding the thread. It only reads the source; the eval
writes its own traces/datasets to its own workspace. What it rebuilds still lands
on the eval instance, so the data-handling note above applies.conversation to keep driving after the
trace’s last message replays (first authored turn = expected assistant reply as
proxy reference; subsequent user turns become follow-ups). Omit it to replay
just the live turn and stop.replay
case goes dead the moment its trace disappears. Treat it as a local, throwaway
reproduction: don’t commit it — run it to confirm the failure, then encode a
durable synthetic case as the artifact. If you do keep one for a local run, tag
it seeded, not full/pr; the resolver fails loudly when a trace has aged out.workflowsCreated[0]); if the live turn creates several, assert on
the first or lean on processExpectations.executionScenarios precondition builds the stand-in),
or grade the live turn with processExpectations only.eval:langtracer-push
refuses a replay case and lists it under skipped: — a suite is a durable
home and this seed isn’t. Combined with the don’t-commit rule above, a replay
case has no durable home by design — the durable artifact is always the
synthetic case you derive from it. (An inline seed has no such problem: it
pushes with the case and lives in the suite like any other.)For a synthetic, sanitised seed pinned in git (never a real user’s
conversation): author the prior messages, plus the artifacts they reference, in
the case body (schema in
harness/conversation-seed.ts (opens in a new tab)
— messages + optional workflows, dataTables and agents). Real
conversations belong in replay, which keeps their content out of the repo.
Two constraints that bite: an artifact id must be ≥8 characters (the id remap
refuses shorter ones), and a seeded build-workflow tool call’s
output.workflowId must match the seeded workflow’s id — otherwise the remap
separates them and the agent can’t find the workflow it should act on. The same
applies to a seeded build-agent result’s output.agentId.
The seed sits in the case body, not a sibling file, so it travels with the case whether it comes off disk, out of a LangTracer suite, or from a dispatched case body.
When the prelude is just “what was discussed” — no tool calls, no workflows —
write a message as {role, text} and the schema expands it to a full envelope:
"seed": {
"mode": "inline",
"messages": [
{ "role": "user", "text": "We agreed: digests go to #growth, daily at 9am." },
{ "role": "assistant", "text": "Noted — #growth, daily at 9am." }
]
}text also takes an array of lines (newline-joined), like a conversation turn.
The expansion stamps createdAt itself — ascending, in the past — so a shorthand
message can’t order after the live turn. Shorthand and full envelopes mix freely
in one array; a full envelope keeps its authored createdAt — unless the authored
stamps don’t already ascend and sit in the past, in which case the whole sequence
is restamped onto ascending pre-live slots. A future stamp would sort a seeded turn
after the live turn, and a non-ascending sequence (a shorthand turn appended after
later-stamped envelopes, say) would present the history in an order the graded
transcript never had; restamping only the offending entry would reorder it against
the array the transcript is graded from. A near-miss (say
text: 123) is deliberately not expanded — it fails at load instead of
becoming a message the transcript builder would silently drop.
An n8n Agent is not a workflow, so it has its own slot: a project-scoped resource with a config plus authored skill bodies. Declare it and the restore creates it at its pinned id in the thread’s project, with its skills, before the live turn:
"seed": {
"mode": "inline",
"messages": [ /* … the turn that built it … */ ],
"agents": [
{
"id": "AgEnT12345678901",
"config": {
"name": "Support Triage",
"model": "anthropic/claude-sonnet-4-5",
"instructions": "Triage inbound support tickets.",
"skills": [{ "type": "skill", "id": "skill_1" }]
},
"skills": {
"skill_1": {
"name": "Triage rules",
"description": "How tickets are sorted",
"instructions": "Label each ticket by severity…"
}
}
}
]
}config and skills are the exact shapes GET …/agents/v2/:id/config and
…/skills return, so you can author a seed from an agent you built by hand: build
it on a dev instance, fetch both, scrub, paste. Things worth knowing:
build-agent call continues
that agent directly. Without the binding the call is rejected (Unknown agentRef) and the model recovers from the agent id in its seeded history —
measured at 3/3 runs recovering correctly, but it burns a turn, and the
rejection message offers “create a new agent” as its first option.outcomeExpectations cover them. Assert on the change the
live turn makes — and assert the untouched parts survive, which is how you catch
a rebuild-from-scratch masquerading as an edit.references[] are whole markdown documents, and they routinely name real
teams, customers, internal tools, ticket queues, Slack channels and escalation
contacts. Rewrite them into neutral equivalents (Acme Corp, #support) rather
than trimming, and reread the full body — a workflow-shaped scan of names and
ids will miss a paragraph.config.skills[].id needs an entry in the
skills map, or the agent renders with a dangling reference.Support Bot and support-bot both become support-bot), so a seed
whose agent names differ only by case, spacing or punctuation is refused rather
than silently dropping one from the registry.credentials[].A folder case grades how the agent finds the contents of a folder the user names
(CONTEXT-86: the workflow list tool takes folderPath or folderId). Declare the
folders and place the seed workflows in them:
"seed": {
"mode": "inline",
"folders": [{ "id": "odwFolder0001", "name": "ODW" }],
"workflows": [
{ "id": "odwSignal1Wf", "name": "Odds Watch - 1", "parentFolderId": "odwFolder0001", "nodes": [], "connections": {} },
{ "id": "rootWorkflow1", "name": "Voice Agent", "nodes": [], "connections": {} }
]
}Things worth knowing:
query filter for the folder name would not match.folderPath or
folderId call, one for reporting exactly the folder’s workflows, one for not asking
the user what the folder holds. The miss path (no folder exists) is a separate case:
LangTracer case 699 grades it, and it needs no folders slot.parentFolderId names another seed folder. Parents
are created first. The folderPath for a nested folder is Parent/Child, so a folder
name cannot contain /.parentFolderId names a declared folder; no cycles; trimmed names that pass n8n’s
folder-name rules; at most 20 folders.feat:folders) with
N8N_INSTANCE_AI_FOLDER_EXPLORATION_ENABLED=true. An unlicensed instance fails the
restore with a hint rather than running the case without its folder. Locally, after
/rest/e2e/reset: PATCH /rest/e2e/feature {"feature":"feat:folders","enabled":true}.seed.folders or workflows[].parentFolderId, so the push refuses the case until
n8n-io/lang-tracer adds both.Two real conversations look the same in a case file but test different things, and picking wrong makes the case harder than reality.
Handed it. The user is looking at a workflow and opens the assistant: “why is
this failing?”, “add error handling”. They never name it — the editor sends the
workflow along as a resource reference and the agent resolves it by id. Declare
that with attach on the opening turn:
"conversation": [
{ "role": "user", "text": "why is this failing?", "attach": { "workflow": "wKk3RmT9xQ2bVn7L" } }
],
"seed": {
"mode": "inline",
"messages": [ … ],
"workflows": [ { "id": "wKk3RmT9xQ2bVn7L", "name": "Batch loop", … } ]
}The id is the one the seed declares; the harness swaps in the per-run id, so you
track nothing. Only the opening turn may carry attach (an attachment is a hand-off,
not something a user re-sends), and it must name a workflow the inline seed declares —
both are refused at load rather than ignored.
The opening often has no text at all — the user opens the assistant on a workflow
and waits for it to speak first. Keep "text": "" when that’s what happened; it’s the
faithful shape, and openings with no user text jumped from 1% to 31% of the corpus when
the editor hand-off shipped, so it is not an edge case. Note that an empty text is
valid only alongside attach: the chat API rejects a message that is empty with
nothing attached (“Either message or attachments must be provided”), so the two stand
or fall together.
Has to find it. The user refers to the workflow in words: by name (“the Wait node
in Generate leads failed”) or loosely (“the batch image workflow”). No attach —
finding it is part of what the case tests. This also works when the seeded history
already shows the agent building it, since the id is in its own record.
Get this wrong in the “handed it” direction — omit attach on a conversation that
really had one — and the agent has to guess from prose that deliberately names
nothing. It will list workflows and pick, or ask which one, and you will score a
clarification failure the real user never hit.
seed.priorRuns[].hints steer the mock for the staged run’s outbound calls and
nothing else. The builder’s own runs are never mocked, and Code, Set, IF, Filter
and Merge run for real. So:
$('Missing')), or throw.outcomeExpectations
and the harness’s executionScenarios.seed,
run both. If the no-seed copy also passes, the seed isn’t carrying the test. Name
the copy so it doesn’t share a --filter substring with the real case
(control-noseed-<slug> works; <slug>-noseed would match both).Don’t grade a seeded case on cost, turn count, messageBudget, or “fixed it in one
build”. A seeded thread starts with an empty sandbox, so the agent re-reads the
workflow from the database and re-derives SDK source a real resumed session would
still have on disk. The bias is harder than reality, so those numbers read worse
for a reason that has nothing to do with the builder.
A context case asks whether the agent still works once the conversation is long. Two questions, two mechanics.
Every case’s judge context carries token ground truth, so no new field is needed to grade cost:
Turn 2 (3 steps, 47446 tokens in [41220 cached], 812 tokens out).So processExpectations may reference consumption directly. Write them as
statements about behaviour the numbers reveal, not as thresholds:
"processExpectations": [
"The agent answers the second question without calling get_node_details on the Notion node again — it read that schema in turn 1 and the answer needs nothing new.",
"Turn 2's input tokens are mostly cache reads, which is what an unchanged prefix looks like. A turn that re-reads the history from scratch shows a cache-read share near zero."
]Absolute token budgets rot: a prompt edit moves every number and the case goes red for a reason that has nothing to do with the agent. Grade the shape — what got re-read, what came from cache, which turn is the expensive one.
Two limits to know before you write a cost expectation:
N8N_INSTANCE_AI_RUN_DEBUG_ENABLED=true on the instance under test.
Without it the blocks render (no run debug captured) and the expectation is
unjudgeable — it fails or passes at random.build-agent tool) runs on a separate stream
with no hooks, so its tokens are missing from every block. Don’t write a cost
expectation on an data/agents/ case.Observational memory compacts a long thread: an Observer summarises it into observation rows, the summary is rendered into the system prompt, and the early turns are masked out of the agent’s window. A case that tests whether a decision survives that has a setup problem — production compacts at 30k tokens of visible message content, which is a conversation too long to hand-author.
Set the flag and the harness handles it:
"requiresMemoryCompaction": trueTwo things happen, both per-thread, so no other case in the run is affected:
incomplete verdict, excluded from scoring, not a red.The premise check is the point of the flag. Uncompacted, the raw early turns are still in the window, so the agent answers off them and every expectation passes for free — a green that tests nothing.
The evidence is structural. GET /rest/instance-ai/eval/threads/:threadId/memory
returns the observation rows and the compaction cursor from their own tables. A
cursor means the observer ran and everything up to lastObservedMessageId is
masked out; the rows are what replaced it. Missing either, the case had nothing
to test and is reported not judged. Nothing reads the system prompt, so this
needs no debug flag and a prompt or SDK rename cannot quietly turn “never
compacted” into the answer.
The judge never sees the premise check — it grades the conversation, not whether the harness configured the scenario. A misconfigured lane must not read as a quality regression.
The judge does see the observation rows, as ground truth:
## Observational memory after compaction (ground truth — do not recount)
- [CRITICAL] User ruled OUT the native Slack node; will post through the HTTP
Request node instead, because the workspace is pinned to a legacy Slack API
version. Do not revisit.
- [CRITICAL] Alert fires when queue depth goes above 4700 items.So write expectations at both layers:
"processExpectations": [
"The observational-memory block retains the alert threshold as 4700 queued items. A summary that keeps the topic but loses the number is the specific failure this looks for.",
"In its reply to the final turn, the agent gives the alert threshold as 4700 queued items."
]The first grades the memory system, the second grades the agent. Grading only
the reply is weaker than it looks: the agent can answer correctly from the
unmasked tail, or guess well, and the case goes green while memory dropped the
detail. The [MARKER] is the Observer’s own priority label, so “retained as
CRITICAL” is checkable too.
The shape that makes a compaction case real:
user turn near the start of the seed, and
never restate them. Anything the agent repeats later lands after the
cursor and survives for the wrong reason.Tag these context-consumption and put them in a context dataset, so a
tier that needs run debug enabled can select them.
Nearby