Skip to content

fix: fail closed on unknown turn header keys and unconsumable queue output (#82, #83, #84) - #85

Merged
TGPSKI merged 3 commits into
mainfrom
fix/turn-header-validation-and-queue-output
Aug 9, 2026
Merged

TGPSKI merged 3 commits into
mainfrom
fix/turn-header-validation-and-queue-output

Conversation

@TGPSKI

@TGPSKI TGPSKI commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #82, closes #83, closes #84.

Three defects of one kind, all found by operating the reference tannery: a
configuration or prompt mistake that produced no error, only a run that looked
successful. Same class as v0.5.2.

#82 — an unrecognized turn header key became prompt text

parseTurnSection recognized four keys; the first line that was not one of them
ended the header and fell into the prompt, silently. tool_rounds: 12 set no
budget and shipped to the model as prose. A typo'd toolset: left the turn
inheriting the previous turn's scope — the case where a turn written to drop a
write tool keeps it.

A header-position line shaped like a declaration (^[a-z][a-z0-9_]*:) but
naming an unrecognized key now fails the load, naming the file, the turn, and
the recognized set.

The strict check needed an escape hatch, so the header now also ends at the
first blank line. Measured before relying on it: no agent in the repo separates
two declarations with a blank line, and no prompt would newly fail. All 75
*.agent.md files under examples/ and dev/ load clean. A URL opening a
prompt (http://…) is excluded explicitly.

#83 — lifecycle output: type: queue produced items no curing could consume

The route enqueued an item carrying only a payload, no HideID. A curing's
first act is LoadIntoBuffer(item.HideID), so every item hit "hide missing" and
dead-lettered on first touch. There was no input for which it worked; the schema
accepted it and leather validate passed it. The curing-side equivalent
(dispatchQueue) had always written the hide first — the lifecycle route never
got that step.

Runner.HideStoreFn is a function rather than a *hide.Store because
leather serve registers scheduled agents before it constructs the tannery,
while output routing runs long after startup; a pointer captured at registration
would always be nil. Where no store resolves, the route warns and enqueues
nothing rather than producing an item guaranteed to dead-letter.

#84 — require_tool:, a turn that must act cannot end on text

An acting turn ends the moment the model produces text, and producing text
instead of acting is the failure mode: a fan-out agent composed five dispatch
blocks as its reply, called nothing, and recorded success while zero downstream
work queued. The only defense was a sentence in the prompt.

A text response arriving with none of the named tools called is refused, the
model is told to make the call, and the turn continues — the usual cause is a
model that composed the arguments as prose and needs telling to send them. If
the rounds run out first the run fails, naming the turn and the tool.

Settling the issue's open questions:

  • Any one named tool satisfies the requirement.
  • A failed call counts. The requirement is that the model attempted the act;
    a tool that rejects its arguments has its own contract with the model.
    Out-of-scope calls never execute, so they never count.
  • A requirement no tool in the turn's scope can satisfy is unsatisfiable and is
    rejected by leather validate and again at run start, before the first LLM
    call — one comparison instead of every round.

Docs

docs/GUIDE.md: the turn-header key table, the require_tool contract, a
shape-selection table (cron / curing / fan-out / fan-in), five authoring
anti-patterns drawn from live failures, and two corrections —

  • hide_types is not a content filter for chained artifacts: dispatchQueue
    stamps a chained hide's kind with the upstream curing's name, so everything
    from one curing arrives with the same kind. Already recorded in LEP-0008;
    the GUIDE said otherwise.
  • page_size_bytes smaller than the document silently drops the tail, and a
    guard that requires the tail then refuses every item.

docs/modules/agent.md and docs/modules/runner.md updated; doclint's export
gate covers the new TurnRequireFor.

Verification

  • gofmt, go vet, golangci-lint run (0 issues), go test ./... all clean.
  • go run ./scripts/doclint — 0 violations.
  • 75 agent definitions validated for header regressions: 0 failures.
  • New tests: unknown-key rejection, prose/URL/blank-line non-regression,
    require_tool parsing; runner tests for refuse-then-accept, never-called
    failure, and unsatisfiable-scope; the queue route test now asserts the item
    references a readable hide, plus a no-store case asserting nothing is enqueued.

TGPSKI added 3 commits August 9, 2026 02:18
…utput (#82, #83, #84)

Three defects of one kind: a configuration or prompt mistake that produced no
error, only a run that looked successful.

An unrecognized per-turn header key became the first line of the prompt
(#82). `tool_rounds: 12` set no budget and shipped as prose; a typo'd
`toolset:` left the turn holding the previous turn's scope — the case where a
turn written to drop a write tool keeps it. A declaration-shaped line naming an
unrecognized key now fails the load, and the header ends at the first blank
line so prompt text has an escape hatch. No agent under examples/ or dev/
changes meaning: all 75 load clean.

Lifecycle `output: type: queue` enqueued an item with no hide (#83). A curing
loads its item's hide first, so every item this route produced dead-lettered on
first touch; there was no input for which it worked. It now stages the response
in the hide store and enqueues a reference, as curing-to-curing chaining
already did. The store is resolved through a function because serve registers
agents before it builds the tannery.

`require_tool:` closes the gap prose could not (#84): an acting turn no longer
ends on a text response until one of its named tools is dispatched, and the run
fails if the rounds run out first, rather than recording success over work that
never happened. Unsatisfiable requirements are caught by `leather validate` and
again before the first LLM call.

Docs: turn-header key table, the require_tool contract, a shape-selection
table, five authoring anti-patterns from live failures, and corrections to
`hide_types` (does not filter chained artifacts) and `page_size_bytes`
(a short page silently drops the tail a guard depends on).
Steps 3-5 read as chores that must produce a diff. Cutting v0.5.3 hit all
three: no version strings are pinned outside the CHANGELOG, a patch on the
current minor needs no SECURITY.md row, and Step 5 asked for a README commands
table the README does not have — it carries a feature table and a routing
table instead. The step that cannot be satisfied is the one that invites
padding the release with edits to clear a box.

They are now checks with an explicit no-op result, reported as one line.
Step 5 also drops to the two tables a human maintains; doclint's export gate
already covers docs/modules/cli.md.

Step 1 sizes by impact rather than pattern-matching commit subjects, and an
explicit version from the operator skips the table entirely. The old table
made an additive opt-in key a MINOR on a technicality; leather's own history
reserves minors for themed batches.

Step 2 promotes an existing Unreleased section instead of rewriting prose
written when the change was fresh.
@TGPSKI
TGPSKI force-pushed the fix/turn-header-validation-and-queue-output branch from 4a2552f to 2c79aa4 Compare August 9, 2026 09:19
@TGPSKI
TGPSKI merged commit 47221bd into main Aug 9, 2026
4 checks passed
@TGPSKI
TGPSKI deleted the fix/turn-header-validation-and-queue-output branch August 9, 2026 09:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant