Skip to content

fix(llm-agents): delete the flows three agent specs left behind (#1346) - #1348

Merged
rafaelgiln merged 2 commits into
mainfrom
fix/issue-1346-attribute-leaked-agent-flows
Aug 7, 2026
Merged

fix(llm-agents): delete the flows three agent specs left behind (#1346)#1348
rafaelgiln merged 2 commits into
mainfrom
fix/issue-1346-attribute-leaked-agent-flows

Conversation

@rafaelgiln

Copy link
Copy Markdown
Collaborator

Closes #1346.

What the issue asked

Name which of the four candidate paths puts a run's tokens in the platform's unattributed bucket. On run 31093877484 (2026-08-06) that bucket was 4,120 tokens / 4 calls / $0.0052 — 8.0% of the run.

The answer: path 4 — the flow is never deleted at all

Reconstructed trace by trace from the run's own artifacts (token-probes-*.jsonl × token-attrib-*.jsonl × results.json):

trace shard flow tokens / calls owner
1027dfd2 2 abb0557a 936 / 1 agent-empty-refusal-response.spec.tsmodel refusal…
6676e05d 2 c52cca3e 918 / 1 agent-empty-refusal-response.spec.tsempty response…
e7c60610 3 b5a8c06a 2266 / 2 agent-max-iterations.spec.tscausal control… (@stable)
3 traces 1 0 / 0 window of the failing openai-provider + traces-list-filters

936 + 918 + 2266 = 4,120; 1 + 1 + 2 = 4 calls. The three zero-token traces cost nothing.

Both specs call SimpleAgentTemplatePage.load() and never delete the flow it returns. Token attribution lives on the delete path (#1197), so a flow that is never deleted is never attributed.

What made this hard to read: both specs do emit an attrib_cost record per test. That record comes from loadTemplateByName's own cleanup of the surplus flows it creates — those never ran, carried no traces, and the attribution read came back empty.

The other three paths are refuted by the same data, not by argument:

  • Path 1 (deleted through the UI) — none of the six flow ids appears in any token-attrib-*.jsonl, so no delete ran for them at all.
  • Path 2 (no test.info() to name) — the attrib_cost records name both the test and the file, so resolution worked.
  • Path 3 (deleted between two poller ticks) — measured the test end − trace start gap for all 29 attributed token-bearing traces on this run: 2.5 s to 13.1 s. The unattributed ones sit at 1.9 / 2.4 / 4.6 s, inside that range. The gap does not discriminate; the missing delete does.

Contra-example on the same run: agent-multi-tool-selection.spec.ts — same template, same model, has an id-scoped afterEach, attributed 2 of 2 traces.

The change

A repo-wide scan found exactly three specs that load a template and clean up nothing. All three are wired to the shared tracker (#1108): capture the creation POST, delete id-scoped in afterEach, never a delete-all sweep (#553). cleanup() derives the attribution from the running test on its own (#1197 §1.1) and stays inert unless the lane sets TOKENS_ATTRIB, so local runs and the PR lane are unaffected.

  • agent-max-iterations.spec.ts
  • agent-empty-refusal-response.spec.ts
  • agent-model-connection-isolation.spec.ts — never executes its flow, so it produced no unattributed tokens; the leak was its whole cost, and the same hook closes both.

The same stale claim is corrected in all three specs and their docs: SimpleAgentTemplatePage.load() does not wipe existing flows — the cross-worker delete-all was removed in #553 — which is why the missing cleanup read as intentional.

Verification — local A/B

start-langflow-docker.sh sets LANGFLOW_DEACTIVATE_TRACING=true by design, so a second nightly container was started with tracing on to measure the attribution half.

flows before → after TRACE lines in TOKENS_ATTRIB result
without the fix (origin/main) 26 → 29 (3 leaked) 0 3 passed
with the fix 26 → 26 3 3 passed

The three lines, attributed to the right spec and test, reproducing the daily almost token for token:

TRACE tok=936  claude-haiku-4-5 calls=1 | agent-empty-refusal-response | model refusal…   (daily: 936)
TRACE tok=917  claude-haiku-4-5 calls=1 | agent-empty-refusal-response | empty response…  (daily: 918)
TRACE tok=2251 claude-haiku-4-5 calls=2 | agent-max-iterations        | causal control…   (daily: 2266, 2 calls)

npm run typecheck clean, npm run lint 0 errors, both QA-CHECKLIST guards pass.

Reproduce:

docker run -d --name lf-trace -p 7861:7860 \
  -e LANGFLOW_AUTO_LOGIN=true -e LANGFLOW_DEACTIVATE_TRACING=false \
  -e LANGFLOW_ALLOW_CUSTOM_COMPONENTS=true -e LANGFLOW_A2A_ENABLED=true -e LANGFLOW_WORKERS=1 \
  langflowai/langflow-nightly:latest

PLAYWRIGHT_BASE_URL=http://localhost:7861 MODEL_TEST_PROVIDER=anthropic MODEL_TEST_ID=claude-haiku-4-5 \
TOKENS_ATTRIB=/tmp/attrib.jsonl npx playwright test \
  tests/tests-automations/regression/core-functionality/llm-agents/agent-max-iterations.spec.ts \
  tests/tests-automations/regression/core-functionality/llm-agents/agent-empty-refusal-response.spec.ts \
  tests/tests-automations/regression/core-functionality/llm-agents/agent-model-connection-isolation.spec.ts \
  --workers=1 --retries=0 --grep "@stable"

grep -c '"trace_id"' /tmp/attrib.jsonl    # 3 with the fix, 0 without

Expected: 4 passed, 3 TRACE lines, flow count unchanged.

The issue's own cheap check still applies as the final proof: the unattributed row on the platform's By spec table should fall to the zero-token traces on the next daily.

Not in scope, observed while verifying

agent-max-iterations"agent stops when max iterations is reached" fails on claude-haiku-4-5 (and on claude-opus-5 / claude-opus-4-5 in a multi-model sweep): the agent replies "I'll fetch that URL for you." with no limit message. It fails identically on origin/main, so it is not this diff. Already tracked as #1264 — this run adds that it reproduces locally, off CI load, across several Anthropic models. That test is @regression, never @stable, which is why the daily never surfaces it.

🤖 Generated with Claude Code

Rafael and others added 2 commits August 6, 2026 20:39
`agent-max-iterations`, `agent-empty-refusal-response` and
`agent-model-connection-isolation` load the Simple Agent template and never
delete the flow they run on. That costs twice: an orphan `Simple Agent` per test
on the shared instance, and — because token attribution lives on the delete path
(#1197) — the tokens those runs spend reach the QA platform with no spec to claim
them.

Measured on the 2026-08-06 daily (run 31093877484): the run's `unattributed`
bucket is 4,120 tokens over 4 `claude-haiku-4-5` calls, and all of it comes from
three traces owned by two of these specs — `1027dfd2` (936) and `6676e05d` (918)
from `agent-empty-refusal-response`, `e7c60610` (2,266, 2 calls) from
`agent-max-iterations`. None of the three flow ids appears in any
`token-attrib-*.jsonl`, so no delete ever ran for them. The `attrib_cost` records
these specs did produce came from `loadTemplateByName`'s cleanup of the surplus
flows it creates: those never ran, carried no traces, and the attribution read
came back empty.

Wire each spec to the shared tracker (#1108) — capture the creation POST, delete
id-scoped in `afterEach`, never a delete-all sweep (#553). `cleanup()` derives the
attribution from the running test on its own (#1197 §1.1) and stays inert unless
the lane sets `TOKENS_ATTRIB`, so local runs and the PR lane are unaffected.

Also correct the same stale claim in all three specs and their docs:
`SimpleAgentTemplatePage.load()` does not wipe existing flows — the cross-worker
delete-all was removed in #553 — which is why the missing cleanup read as
intentional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#1264

The PR lane went red on `agent-max-iterations` › "agent stops when max iterations
is reached", 3 of 3 attempts, with the agent answering the task normally instead
of returning `Model call limits exceeded: run limit (1/1)`. The bubble renders and
the locator resolves 34 times, so it is a content failure, not a timeout — the
signature #1264 has been open on since the 2026-08-04 daily.

Not this branch's diff: the same test fails identically on `origin/main`. What
changed is the SELECTION — touching this file put a spec the daily never runs
(`@regression`, never `@stable`) into the impacted-specs lane.

The file is serial, so that failure also skipped the `@stable` causal control —
the half that works, and the half this branch's cleanup change needed to exercise.
So a red here bought no coverage and cost the lane its verification.

Quarantine with `test.fixme`, the same treatment mcp-server.spec.ts (#1266) and
openai-compatible-provider-setup.spec.ts already carry, and record the measurement
where a reader will hit it: reproduced on 1.12.0.dev18 LOCALLY, off CI load, on
claude-haiku-4-5, claude-opus-5 and claude-opus-4-5 — which rules out the mid-run
backend wedge #1264's triage left open as a possible cover.

The causal control is deliberately NOT quarantined: on its own it proves only that
a high limit finishes, and it is what will show the pair working again. Lifting the
quarantine is #1264's call.

Checklist bullets move from `[x]` to `[~]` with the reason, in both §6.2 and §7.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rafaelgiln
rafaelgiln merged commit 3e5c8ce into main Aug 7, 2026
24 of 28 checks passed
rafaelgiln added a commit that referenced this pull request Aug 7, 2026
…1355) (#1357)

* fix(collect-models): wait for Save to leave aria-busy, and stop swallowing a save that never landed (#1355)

`Collect models` failed twice in a row on PR #1348 with

    locator.click: Timeout 20000ms exceeded
      - waiting for getByRole('button', { name: 'Save', exact: true })
      - locator resolved to <button aria-busy="true" aria-disabled="true" …>

The button was not broken: the panel is walked one provider at a time and the
PREVIOUS provider's validation was still in flight. `click()` does wait for
"enabled and stable", but its ceiling is 20s — shorter than the ~35s Google
validation this file already documents — and when it expires the error names the
click, sending the reader to the wrong step entirely.

It surfaced when the CI `OPENAI_API_KEY` was replaced with a funded key: saving
openai used to fail fast on `no credits remaining`, so no validation was ever in
flight when the loop moved on.

Three changes, one per defect:

- `waitForButtonIdle` polls the button until it is genuinely actionable —
  `aria-busy`, `aria-disabled` and `isEnabled()` are three separate claims and
  any one of them blocks a click — with a 60s ceiling sized against the
  validation, and `formatSaveBusyFailure` names the provider, the observed
  attributes and the likely cause. It returns a verdict rather than throwing, so
  the caller supplies the provider name and the whole decision is unit-testable
  without a browser.
- The `waitFor(Disconnect, 60s)` after the save no longer ends in
  `.catch(() => {})`. "Never configured" and "configured fine" were the same
  observation, which is why an empty model list downstream read as a provider
  without models.
- A provider whose key IS set collecting ZERO models now warns. That state is
  what produces the silent daily: `Collect models` is `continue-on-error` there
  (#980), so a `models.json` missing a provider makes every parametrized spec
  skip and the run reports green having tested no agent — #570/#1012's
  green-by-absence.

Six unit tests drive the wait with an injected clock and a scripted locator: the
real failure needs a funded key and a slow backend, but the decision — when it
gives up and what it reports — is testable without either. They pin both
orderings of the not-idle state, and that a zero timeout still observes once
rather than reporting a state it never read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(collect-models): confirm each model toggle, so one provider's writes stop wedging the next (#1355)

The instrumentation from the previous commit named the real cause, and it is not
the one that commit assumed. The Save button was not merely slow to settle:

    ⚠️  provider "anthropic" never showed the configured state ("Disconnect") within 60s of Save
    ⚠️  provider "anthropic" has a key configured but collected ZERO models
    Models found (anthropic): []
    Error: the "Save" button for provider "google" never became actionable after 60.0s

anthropic's Save was clicked on an IDLE button and never completed. Waiting
longer was never going to help — 120s across the two providers and nothing
settled. The failure screenshot shows why: OpenAI carries a `52 models` badge
while google's Save spins without anyone having clicked it, so the busy state is
the form's, not the button's, and the write in flight is anthropic's.

What puts it there: enabling a model is a WRITE, and this loop enables every
model of every provider as fast as the clicks land. With a funded OpenAI key the
panel exposes 41 visible models where a drained key exposed none worth toggling
— so one provider went from ~0 writes to 41 against a backend the lanes run with
`LANGFLOW_WORKERS=1`. The next provider's Save queues behind them.

`waitForToggleChecked` confirms `aria-checked="true"` after each click, which is
what serialises the writes. Two bounds, mirroring the token-attribution sidecar
(#1197 §4.4): 5s per toggle bounds ONE write, and a 60s per-provider budget
bounds the sum — a per-item timeout alone would let 41 slow-but-succeeding
confirmations spend 41 x 5s and blow the spec's own 5-minute budget. Past the
budget the clicks continue unconfirmed and the count is warned about, never
silently dropped (#1012).

Four more unit tests, same injected-clock approach; deleting the confirmation
kills three of them. Locally the healthy path is unchanged — 23.9s against 24.9s
before, 3 providers, 90 models, no new warning fired — because a confirmation on
a responsive panel costs tens of milliseconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(collect-models): wait for the credential WRITE, not for a clock (#1355)

Reproduced locally with a funded OpenAI key and every `/api/v1/variables/`
response logged. The measurement settles it:

    collect-models: provider "anthropic" credential write took 103.1s (HTTP 201)

All three saves answer 201 — none is rejected, none is refused. anthropic's POST
simply does not come back for ~103s, and the panel keeps its Save `aria-busy`
for as long as a write is in flight. So the previous two commits were both
measuring the same thing from the outside and calling it different names: the
60s button wait, and the toggle serialisation, were guesses at a duration.

Waiting for the RESPONSE removes the guess. `page.waitForResponse` is registered
before the click (or the response can land first and be missed) and resolves the
moment the write answers, with a 180s backstop set well clear of the worst
measurement rather than just above it — the cost grows with how many providers
are already configured, which is why any fixed clock here eventually expires
again.

The outcome is reported three ways, none of them silent: no write observed, a
write that answered non-2xx, and a write that succeeded but took over 10s — the
last one is a trend line, so the next time this cost approaches the ceiling it
is visible before it crosses it.

Verified on a fresh container with `--retries=0`: `1 passed (2.6m)`, all three
providers collected, where the same command previously left anthropic at zero
models and died on google's Save.

The toggle serialisation from the previous commit stays, with its comment
corrected to say what it is: it was added on a hypothesis the next CI run
refuted, it fixed nothing, and it is kept only because a burst of 41 unconfirmed
writes against a LANGFLOW_WORKERS=1 backend is worth avoiding at a measured cost
of ~0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Rafael <rafael@oriontech.me>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Victor-w-Madeira
Victor-w-Madeira deleted the fix/issue-1346-attribute-leaked-agent-flows branch August 8, 2026 07:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8% of a run's tokens arrive unattributed — name which of the four paths puts them there

1 participant