Rand Stats

LLM::Agent

zef:apogee
Revision history for LLM::Agent

0.6.1  2026-08-22T00:15:45+01:00
    - An appended envelope ends in exactly the one 0x0A byte the writer
      wrote, on Windows too: JSONL::Writer (0.1.6) writes records as
      bytes, bypassing the handle's encoder — whose default newline
      translation was appending CRLF envelopes on Windows, silently
      flipping a transcript's separator convention and breaking the
      repaired-separator contract (caught by Windows CI in t/08). The
      repair path itself was always byte-exact. Requires JSONL 0.1.6.

0.6.0  2026-08-21T22:50:40+01:00
    - A TOOL CALL CAN NOW ANSWER TWICE: once immediately, and once with
      what it actually did. New LLM::Agent::CompletionBus is the register
      of background work that has been acknowledged and not yet reported,
      and the queue of the reports themselves. Given one (`completion-bus`
      on the Loop), two things change and nothing else does: whatever is
      on the bus at a round boundary becomes framed user turns at the top
      of that round, and a model that stops asking for tools with work
      still outstanding PARKS rather than ending the run. The round-top
      order is completions, then steers, then the caps — a steer is very
      often the user reacting to a completion, and the caps must weigh
      both. Completion content goes through the same observation excerpt
      seam a tool result does, so a child that answers with a megabyte
      lands as an excerpt with the rest in an artifact file; the framing
      that says "this is an automated event, not the user" is never
      excerpted. A park wakes on a completion, a steer or a cancel, ends
      the run when the bus goes quiet, and is bounded by a new
      `park-idle-timeout` (1800s) whose expiry closes what is outstanding
      and fails the run with `reason => 'park-idle'`, naming what never
      answered. New `park-poll`, `on-park` and `on-unpark` (balanced on
      every exit path, for a host that lends a concurrency slot for the
      duration of a park). `max-wall-clock` now bounds the time a run
      spent WORKING: parked seconds are subtracted, and reported as
      `spent`. Five new events —
      BackgroundOpStarted/Settled/Delivered, RunParked, RunResumed — all
      non-terminal.
    - Subagents gained `completion-bus`, and with it BACKGROUND
      DELEGATION. A `task` call acknowledges and returns instead of
      waiting; the child reports itself through the paths that already
      existed for a child nobody was waiting on, and the settle goes onto
      the bus from inside the same once-guard that writes the transcript
      envelope. An answer is presented exactly once whichever way round
      the settle and a `task_wait` happened: a parked join closes the
      operation `collected` and enqueues nothing, and a join that arrives
      after the settle withdraws the turn it would have been. A child's
      question reaches its parent as an untracked turn of its own — the
      child stays outstanding, because it is stopped rather than
      finished — and stays in the question table until it is ANSWERED, so
      the deliberately blunt interrupt predicate is unchanged. A wedged
      child now leaves the outstanding count as well as the admission
      count. `subagent-settled` gained `call-id`; the acknowledgement's
      tool message is recorded with `background => True` so crash repair
      can tell an acknowledged-but-unreported operation from a tool call
      that finished. The `task` / `task_wait` / `task_answer` descriptions
      and guidance texts change with the mode. WITHOUT a bus, every path
      and every word is exactly what it was.
    - Request-budget preflight and provider-length verdicts now count the
      complete request through the selected model profile's counter:
      conversation, runtime context, tool catalogue and tokenizer-specific
      request framing. TokenCount gained `count-request`; Exact delegates to
      a tokenizer's `get-request-count` when available and safely composes
      the older counting API otherwise, while Usage treats billed prompt
      tokens as already including unchanged context/tools and estimates only
      appended history. Usage calibration is invalidated per selected
      counter when model, context, tools or the calibrated history changes.
      Heterogeneous forced compaction now carries the chosen target with its
      model counter, uses that counter for every before/after, summary and
      hard-trim check (and emitted count), then re-preflights each fallback in
      its own units. Single-counter loops retain their existing behaviour.
    - Loop admission now waits for the previous Run.drained, provider
      callbacks are attributed to the dispatched run/operation, and a tool
      deadline prevents every later call in that assistant batch from being
      dispatched. Those later calls receive ordered, explicit never-ran
      results so the model can reassess an unknown side effect.
    - Failed provider attempts now contribute their reported usage and cost,
      AttemptFailed exposes that usage, and spent.complete states whether
      every started attempt reported a total or both token halves. Usage
      calibration is learned by the selected backend profile's counter.
    - Session.load makes a complete final JSON envelope without an EOL
      append-safe before reopening it, preserving an existing LF/CRLF
      convention. tool-dispatched now explicitly means “may have reached the
      provider”, not proof that it ran.
    - Subagents gained configurable cancel-grace and drain-grace. A child
      that exceeds drain-grace is visible as wedged but remains fail-closed:
      its slot and cancellation/resource ownership are retained until it
      actually drains (what a wedge no longer holds is admission capacity —
      see the live/owned split below).
    - OLD TOOL RESULTS CAN NOW BE ELIDED INSTEAD OF SUMMARIZED. New
      LLM::Agent::Compactor options `age-observations` (Bool, default
      False), `age-min-chars` (2,048) and `age-reclaim-min` (49,152).
      With aging on, a compaction pass tries the cheap thing first: the
      content of a fat `tool` message in the middle region is replaced IN
      PLACE with a deterministic stub — `[elided by the harness: NNNN-char
       result; re-run the call if you need it again]` — and nothing
      else about the message changes. Same position, same role, same
      tool_call_id, so there is no pair alignment to get wrong and no
      assistant turn left holding tool_calls that nothing answers. Old
      observations are the bulk of every agent transcript and are almost
      never re-read; a model that wants the bytes back re-runs the call,
      which is cheaper than the summary that would have described them.
      Two ways a pass is entered, and ONE predicate decides:
      `needs-compaction` now answers for both reasons, and takes an
      optional `:$tokens` for a caller that has already counted (or that
      knows a bigger number the same window has to hold). Over the trigger:
      the middle is aged FIRST, recounted, and if that was enough the pass
      ends there — no summarization request, no synthetic message, nothing
      dropped; if it was not, the summarizer runs as it always did over the
      stubbed middle, so one enormous fs_read no longer dominates the
      transcript it is asked to summarize. BELOW the trigger: a
      reclaimable-batch epoch, entered as soon as the eligible pool reaches
      `age-reclaim-min` characters, and always elision-only. The floor is
      not a threshold for its own sake — providers cache the prompt prefix,
      any change below the tail invalidates it, and eliding one result per
      round would pay a full cache miss to reclaim a few thousand
      characters. One miss per batch instead.
      Eligibility is deliberately conservative. A result must be a `tool`
      message in the middle region (the same cut the summarizer works from —
      never sticky, never inside the recent window), longer than
      `age-min-chars`, not already a stub, and it must have at least
      `age-reclaim-min` characters of conversation content AFTER it: the
      keep-recent window is positional, and one tool-heavy round can push
      eight messages and evict something the model is mid-sentence about, so
      recency is measured in content instead. On top of that, call identity
      — the tool name plus its canonicalised arguments, joined back through
      tool_call_id to the assistant turn that asked — is counted: where the
      same call was answered more than once, the NEWEST answer is protected
      permanently and every older copy becomes eligible whatever its size or
      age. That is the loop breaker. Elision is a bet, and a lost bet costs
      one re-read; without the rule a lost bet costs a re-read and a cache
      epoch every turn, for ever.
      The failure ladder is untouched: classify, retry, hard trim, and the
      `exhausted` verdict all behave exactly as they did, and a summary is
      still only accepted if it is smaller than the conversation it
      replaces — measured against the AGED conversation, which is the
      stronger test. `%result` is new (`[ { index, stub }, ... ]`,
      indices into the input array) and empty unless aging is on.
    - New LLM::Agent::Session envelope `type: elision`, payload
      `{ items: [ { id, stub } ] }`, and `append-elision`. It is the one
      envelope that rewrites something the file already said, which is why
      it names messages by envelope id: replay applies the stubs in FILE
      ORDER alongside compactions, so the two compose in either direction —
      a compaction can fold away messages an elision stubbed (which is what
      the live run's summarizer was shown), and an elision can stub a
      message a compaction kept, including the summary itself. An id the
      live message set does not hold is FATAL, before the write and on
      replay, exactly as a compaction naming nothing is; an empty `items` is
      refused by the appender. The original tool result stays in the file on
      the `message` line it was written on: a transcript records what
      happened, and the elision records what the model was shown afterwards.
    - LLM::Agent::Loop's compaction consult now asks the compactor
      `needs-compaction(@conversation, :$tokens)` rather than comparing
      against `compactor.trigger` itself — every reason to make a pass lives
      in the compactor, and the loop learns none of them. It writes the
      `elision` line where it writes the `compaction` line (elisions first,
      so a compaction that folds them in cannot invalidate the ids they
      name), and swaps the working array for an elision-only pass, which
      reports `dropped => 0` and moves no ids. A compactor with
      `age-observations` on and a session that cannot `append-elision` is
      now refused at construction, for the same reason a compactor with a
      session that cannot `append-compaction` always was. There are no new
      constructor options and no new event types: an elision-only pass is
      framed by the CompactionStarted / CompactionDone pair, with
      `dropped => 0`.
    - A TRUNCATED COMPLETION IS NO LONGER RETRIED WITH AN IDENTICAL
      REQUEST. A provider that accepts the prompt, runs out of room and
      reports `finish_reason: 'length'` arrives at the loop as an
      error-class of 'response', which classify-error buckets as `advance`,
      which on a single-backend chain degrades into a `retry-same`. So a
      conversation flush against its window was re-sent, byte for byte,
      three to seven times — a full prompt each, hundreds of thousands of
      tokens and real money per run — to be truncated identically every
      time, and then the run failed anyway. The outcome of a cap is
      deterministic; there was never anything to retry.
      A `length` is now intercepted BEFORE the retry buckets get it, read
      off `$resp.finish-reason` (LLM::Chat stamps the reason before it
      quits, so this is structured rather than sniffed) and split into the
      two failures it really is. NEAR THE WINDOW it is context overflow the
      preflight's counter missed, and it joins the existing degradation
      path exactly where a preflight refusal goes: it counts as that
      backend's window refusal, it is the second advance that does NOT
      degrade into a retry, the chain still gets to try a backend with a
      bigger window, and when every backend has refused, the loop compacts
      ONCE and tries again — RunFailed with `reason => 'context-exhausted'`
      if that does not help. NOWHERE NEAR IT, `max_tokens` was the binding
      constraint, compaction cannot help and no other backend truncates the
      same answer differently: the round ends with RunFailed and a new
      `reason => 'completion-truncated'`, whose error names the cap that
      bound and what to raise.
      The judgement leans towards "the window", deliberately: the counter
      is the thing that let the doomed request through, so a `length`
      arriving where the budget thought there was room is evidence that the
      count was low. The counted request has to clear the window by more
      than a QUARTER of itself before the cheerful reading is believed, and
      a truncated response that carried `usage` is judged on the provider's
      own numbers with no tolerance at all. The compaction it forces aims
      at the size that would still fit if the counter were as wrong as it
      is allowed to be — not at the preflight's number, which the counter
      has just been proved wrong about and which would ask the compactor to
      drop nothing.
      Nothing else about the retry buckets moves: an ordinary 'response'
      failure (a malformed body, an empty completion) retries exactly as it
      did, and LLM::Chat is unchanged.
    - AND NEITHER IS A TRUNCATED COMPLETION THE PROVIDER DID NOT ADMIT TO.
      A provider decoding tool call arguments under a grammar can reach
      `max_tokens` with the JSON closed — every brace balanced, every string
      terminated — and report `finish_reason: 'tool_calls'` rather than
      'length'. What arrived was a syntactically perfect call missing
      whatever the model had not written yet: a subagent brief that stopped
      mid-sentence, the rest of it railroaded into keys nobody declared, and
      a loop that dispatched it because nothing about the response looked
      wrong. One witness was left, and it was the provider's own: a
      completion billed AT (or past) the backend's `max_tokens` was stopped
      by `max_tokens`, whatever the finish reason claimed.
      So every successful streamed turn — prose as much as tool calls — is
      now checked against the cap the backend declares, and
      `completion-tokens >= max_tokens` is treated as the truncation it is:
      no AttemptSucceeded, and the failure path from there is exactly the
      one a reported 'length' takes — the same verdict, so a clip on a
      request near the window compacts once and a clip on a small one ends
      the run with `reason => 'completion-truncated'`, and the same refusal
      to re-send identical bytes. One Log at `warning` names the
      disagreement (the reason the provider gave, the tokens it billed, the
      cap they met); there is deliberately no event class of its own,
      because the round did not fail in a new way. `>=` rather than `==`,
      because usage that includes reasoning tokens overshoots the cap, and
      no tolerance in the other direction: a completion that stopped short
      of the cap stopped because it was finished. The check is BEST-EFFORT
      and documented as such — a provider that reports no usage leaves
      nothing to check and the gate is inert, and asking for usage on a
      stream is deliberately not done (it hangs at least one gateway in the
      header phase). The honest `finish_reason: 'length'` path above stays
      primary; this is the one for providers that do not say so.
    - A committed assistant turn's session extras now carry
      `finish-reason`, beside `reasoning` and `usage`. A transcript is the
      only place a truncation nobody caught can still be seen afterwards,
      and a turn only gets written once the clip check above has cleared
      it — so the reason recorded is one the billed tokens agree with.
      Extras remain replay-visible and dropped when Messages are rebuilt,
      so nothing about a resumed conversation changes.
    - The context-exhausted terminals reached through a forced compaction
      now carry their `attempts` instead of an empty list. It never mattered
      much when nothing had been sent; it matters now that one of the ways
      in is a truncation somebody paid for, and the record of it was being
      dropped on the way out.
    - A `task` CALL IS NOW CHECKED STRICTLY, AND THE UNKNOWN KEYS ARE THE
      DIAGNOSIS. The clip above has a second signature: what was left of a
      brief when the decoder ran out of room becomes KEY NAMES — a key
      called `sh_run.\n\nCONTEXT` whose name is a fragment of the sentence
      that was being written. Such a call parses, carries both required
      arguments, and hands over a `prompt` that is a stump of what was
      meant; the composer used to ignore the junk and spawn a subagent on
      it, with nothing anywhere saying that most of the brief was gone.
      LLM::Agent::Subagents now refuses a `task` call carrying any key its
      declaration does not name — `agent-type`, `prompt`, `label`, plus
      `reason` — as an is_error result, checked BEFORE agent-type and
      before prompt so a mangled call is diagnosed as mangled rather than
      as whichever required key the clip happened to eat. Nothing is
      started on that path: no child, no slot, no entry in the
      identical-spawn tally. The refusal names the offending keys
      (whitespace flattened and long ones cut, because they can carry
      newlines and a paragraph of the prompt), names the keys that are
      allowed, and says what this usually means and what to do — re-emit
      the whole call with the complete task in `prompt`. `reason` is
      tolerated whether or not a reasons layer is in the stack, by literal
      rather than by import: that layer strips the parameter when it is
      there, and a model asked for a reason on every call for a whole
      conversation will write one here regardless. Matching is exact and
      case-sensitive — `Agent-Type` is a key no schema declared. This
      strictness is deliberately confined to `task`, whose arguments are a
      whole conversation's worth of instructions; every other tool goes
      through untouched.
    - The `task` tool's description now says that A BRIEF IS WORTH ITS
      LENGTH: write it in full, and when several long briefs are queued,
      splitting the spawns across turns is fine. The batching guidance it
      sits beside is unchanged — a batch of long briefs is exactly the
      shape that provokes the clip, and telling a model to batch without
      telling it that length is allowed is telling it to compress, which
      is the same lost context arrived at on purpose.
    - The summarizer's transcript now MARKS a truncated tool CALL, as it
      has always marked a truncated tool result: `... [N more characters
      of tool-call arguments, truncated for summarization]`. Arguments
      over `tool-result-cap` were being cut silently, so a call whose JSON
      stopped mid-string read to the summarizer like a call the model
      really made that way — and "the model wrote a truncated tool call"
      is precisely the kind of thing a summary then repeats as fact. The
      cap itself is unchanged, and still applies only to what the
      summarizer is shown.
    - A USER CAN NOW SAY SOMETHING WHILE A RUN IS STILL GOING. New
      LLM::Agent::Loop option `steer-source`: a thunk taking no arguments
      and answering a (possibly empty) list of Str, called at the top of
      every round, each answer appended to the conversation as an ordinary
      user message. Undefined by default, and a loop without one behaves
      exactly as it did. Before this the only way to react to a run that
      had gone the wrong way was to cancel it and start again, losing ten
      rounds of tool results to say one sentence.
      The placement is the whole feature: the thunk is asked at a round
      boundary with NOTHING in flight — no stream open, no tool call
      dispatched — so an injected turn can never land between an assistant
      turn carrying tool_calls and the tool messages that answer it, which
      is the malformed conversation every provider rejects. It goes in
      before the caps check, before a compaction and before the request is
      built, so the preflight weighs it, a compaction can move it,
      RoundStarted counts it, and the wire carries it. It is history, not a
      sidecar: the transcript records it as a plain user turn, and a
      resumed session replays it as one.
      There is no new event and no push method. The thunk IS the delivery
      notification — the loop asked, and what the app handed over is on its
      way — and the queue behind it, its thread safety, and whether three
      queued lines are three messages or one paragraph are all the app's
      business. What is handed over is recorded exactly: three answers are
      three user messages, in order.
      A thunk that throws is shielded — an error Log event, no steers this
      round, and the next round asks again — and an answer that is not a
      defined Str is dropped with a Log event rather than stringified into
      somebody's transcript. A broken queue cannot end a run. One that
      cannot be called with no arguments (`-> $x { }`) is refused at
      construction, where the closure is still on screen.
    - THE IDENTICAL-CALL GUARD'S DEFAULT IS NOW 30, NOT 3. Three could
      not tell a loop from patience. Repeating one call with one set of
      arguments is the DESIGNED flow for a great many honest things — an
      agent contending for a file lease retries the same `lock_acquire`
      until the holder gives it back, an agent that has just written a
      file reads it back to check, a poll is a poll — and at 3 the guard
      fired on the fourth honest repeat, the loop disabled tools, and the
      agent reported half a job to whatever was supervising it, which
      then started the whole thing again. A model that really is going
      round in circles will do it thirty times as happily as four; thirty
      repeats cost some tokens, a false positive costs the task.
      `max-tool-rounds` and `max-tool-calls` are UNCHANGED — they were
      never the limit anybody was hitting.
    - New LLM::Agent::Loop option `identical-call-exempt`: a list of
      tool-name patterns (exact names or trailing-* prefix globs, the
      same shape `concurrent-tools` takes) whose calls the identical-call
      guard ignores entirely. Empty by default. A matching call is
      skipped BEFORE it is counted, so it neither trips the limit nor
      walks the batch's own tally up under a call that is not exempt;
      everything else in the batch is counted exactly as it was. What
      qualifies is a tool whose repetition is a PROTOCOL rather than a
      loop — `lock_acquire` against a contended lease, where the
      arguments cannot vary and the thing that ends the repetition is
      another agent finishing. `max-tool-rounds` and `max-tool-calls`
      still bound an exempt tool, so an exemption is not a licence for an
      unbounded run.
    - ADVANCE WITH NOWHERE TO GO IS NO LONGER A DEATH SENTENCE. When a
      failure is classified `advance` — "this is a property of THIS
      backend, ask a different one" — and the backend that failed is the
      LAST link of the chain, the loop now degrades that advance to a
      `retry-same`: it waits out the normal backoff and asks the same
      backend again, spending the same per-backend budget a retry-same
      would have. On a single-backend config, which is most of them, the
      old behaviour was to exhaust the chain and end the run having made
      exactly one call. The arithmetic is one-sided: if the retry fails
      the chain is over exactly as it was, one backoff later.
      The AttemptFailed says `retry-same` with its `backoff`, because
      `disposition` reports what the loop DOES; when the budget is spent
      the next one says `advance` and the chain ends, unchanged. A real
      next backend still advances at once, with no wait — pinned.
      The ONE advance that does not degrade is the context-overflow
      seatbelt (a 400 saying the conversation does not fit): that is a
      deterministic refusal of these exact bytes, and re-sending them
      buys an identical 400 and a wait on top of it.
    - New LLM::Agent::Loop option `backoff-cap` (default 30, unchanged
      behaviour): the ceiling `LLM::Chat::Retry::retry-backoff` is
      clamped to, which the loop used to leave on the library default
      with no way to say otherwise. It is also the test seam for the
      retry path — a few milliseconds of cap exercises the whole ladder
      without a mocked clock or a real wait.
    - New LLM::Agent::Loop option `concurrent-tools`: a list of tool-name
      patterns (exact names or trailing-* prefix globs, the same shape
      `idempotency-rules` matches with) whose CONSECUTIVE calls are
      dispatched as ONE batch. Empty by default, and an empty one is the
      loop exactly as it was — one call at a time, per-call envelopes,
      per-call blast radius. Group formation only ever merges NEIGHBOURS:
      the batch is walked in the model's order and the first call whose
      tool does not match ends the run being built, so `task, task,
      fs_read, task` is three groups and the read still happens between
      them. A group's N dispatch envelopes and N ToolStarted events are
      written BEFORE the batch goes down, and each call then settles with
      the result at its own index — its own tool message, its own
      tool-settled envelope, its own ToolResult, in model order.
      What a group trades is stated in the Pod and is real: a crash mid
      group leaves N dispatched-unsettled operations rather than one (a
      resume repairs all N), a cancel or a deadline takes the whole
      group, and a policy in front of it decides every call in the group
      before any of it runs. A tool qualifies when its side effects are
      confined to its own resources and its result is a report — `task`
      is the argument's shape; fs_* and sh_* are not.
    - LLM::Agent::Subagents runs a batch's `task` calls CONCURRENTLY —
      one thread each, results reassembled in the caller's order. A task
      call returns only when its child has settled, so the previous
      `for @tasks` loop made N delegations strictly serial however many
      the host could really run. Every guard is unchanged and still
      holds under concurrent entry: max-live, the identical-spawn tally,
      the id counter and the slot table are one critical section per
      spawn, so N concurrent calls mint N distinct ids and admit exactly
      as many children as there was room for. The visible consequence is
      that every permission question for a fan-out arrives up front —
      which is why the loop only hands this provider a batch when the app
      has named `task` in `concurrent-tools`.
    - New LLM::Agent::Loop.absorb-spend(%spent, :$run-id): add spend that
      happened somewhere else — another run's settled record, in exactly
      the shape a finished run hands back — into this run's accumulators,
      so the request budget's caps see it. Optional keys stay optional
      ("nobody counted" is still not zero), `wall-clock` is deliberately
      IGNORED (this run's elapsed time is its own, and a child ran inside
      it), and `:$run-id` is how a caller says "drop this if that run is
      over" so a late child cannot be billed to whatever run started
      next. It never checks a cap and never ends a run: the next round or
      operation boundary does that, in the ordinary way.
    - LLM::Agent::Subagents feeds a settled child's `spent` into the
      parent loop through that seam, which closes the known limitation
      that a parent's max-cost / max-total-tokens could see only the
      parent's own turns. A parent's budget is now the budget of its
      WHOLE SUBTREE, recursively: a composer under a child bills that
      child, whose settled record already carries its own children's. A
      child that pushes its parent over is refused on the parent's NEXT
      attempt, with the ordinary `budget-exhausted` reason — there is no
      separate subtree cap and no new refusal shape. Shielded and
      duck-typed throughout: a loop without the method, a stand-in in a
      test, or an accumulator that has moved on all mean "nothing
      happens", and the task call still returns.
    - A run's `spent` Map gains the `prompt-tokens` / `completion-tokens`
      split beside `total-tokens`, accumulated across every attempt that
      reported one. Both keys follow the same rule `cost` already
      followed: present only when SOMETHING reported that number, absent
      when nothing did — a backend that publishes only a total leaves the
      split out rather than claiming zero tokens went each way. Caps are
      unchanged; no cap is written against half a turn.
    - LLM::Agent::Event::Subagent gains `call-id`: the provider's id for
      the `task` call that started the child, constant for that child's
      whole life and carried on EVERY wrapped event. It is the only
      thing joining a delegation's tool events (ToolCall / ToolResult /
      ToolAbandoned, all keyed by that id) to the child events it
      produced, so a UI can draw one card per delegation instead of an
      orphan tool card beside an orphan agent card. Optional: a composer
      forwarding a child no tool call started leaves it undefined, and
      `to-hash` omits the key entirely rather than writing a null.
    - The `subagent-spawned` transcript envelope gains the same `call-id`
      key, beside `child-path`, which joins the spawn line to the
      `tool-dispatched` line for the same call. Replay is unchanged and
      unconditionally tolerant: a transcript written before the key
      existed has spawn lines without it and loads exactly as it always
      did (nothing reads the key back).
    - The stream, tool-batch and settle-child waits now poll with a bare
      timer instead of `Promise.anyof` over long-lived promises. Every
      anyof pass registered a continuation on `Run.cancellation` (and, in
      the composer, on the child's result) which the runtime keeps until
      that promise settles — some forty a second, and unbounded on a run
      nobody cancels — and a cancel that did arrive woke every one of
      them at once. The end of a stream, of a tool batch and of a child's
      run is now noticed on the next poll tick (at most 50ms later);
      cancel promptness is unchanged, because it never came from the
      wait — the on-cancel poke aborts the in-flight stream directly.
      Run's Pod now warns against re-awaiting `cancellation` inside a
      poll loop.
    - Subagents admission counts only the children that can still answer.
      A wedged child — one whose result was kept and whose `drained`
      never came — keeps its place in `live-agents`, in the new
      `owned-count`, and in `cancel-children`'s reach, so ownership stays
      fail-closed; what it no longer does is refuse new spawns against
      `max-live`, which would have let one abandoned tool call cost the
      composer a slot for the rest of the host's life. The refusal
      message reports the live count for the same reason: telling a model
      to wait for a child that is never going to answer is telling it to
      wait for ever. New `live-count` and `owned-count` methods expose
      the two numbers.
    - A SUBAGENT CAN NOW ASK THE AGENT THAT STARTED IT. A child given a
      brief it cannot act on had three options — guess, give up, or ask
      the human, who did not write the brief and cannot see the
      conversation it came from. The agent that wrote it is the one that
      knows what it meant, so LLM::Agent::Subagents grew the engine half
      of a child→parent ask channel.
      New `post-question(agent-id, :message!, :schema)` parks a question
      and answers with the Promise the host's ask thread blocks on; it is
      kept with an elicitation outcome (`{ action, content }`), so a host
      hands it straight back to the server that asked. Every refusal is a
      VALUE, never an exception: an agent-id this composer does not own,
      one that has already finished, a second question while the first is
      still waiting, or an empty question all come back as an
      already-kept `{ action => 'cancel' }`. Nothing can hand a child an
      accept nobody wrote. `pending-questions` is the snapshot beside it,
      for a UI drawing "waiting on its parent" against an agent.
      A parked question RELEASES EVERY `task` CALL WAITING IN THAT GROUP,
      at once, as an interim result — `is_error` False, content starting
      `STATUS: interim`. That is forced rather than chosen:
      `execute-tool-calls` answers a batch with one List, so there is no
      way to hand back the asking child's call and go on waiting for the
      others, and holding them open would mean a parent that cannot
      answer until unrelated children happen to finish. The asking
      child's own call carries the question in full, with what to do
      about it; the others say their child is still running and name the
      tool that collects it; all of them list whatever else is open. A
      question is delivered in full EXACTLY ONCE — later waves mention it
      as a line and never repeat the text, because a model shown the same
      question three times answers it three times, and a long question
      repeated in every result is a context window spent on nothing.
      While ANY question is open — delivered or not, from any child — a
      `task` or `task_wait` call comes straight back with an interim
      result instead of parking. A parent that waits on one child while
      another is blocked on an answer only it can give is the same
      deadlock arriving a moment later, and the cost of the blunt rule is
      one turn spent clearing the table, which the interim result says
      how to do.
      A question asked while the parent is mid-generation stays pending
      and is delivered at the next park; a question from a child nobody is
      waiting on is delivered by whichever other waiting call gets there
      first, which is not an edge (a child whose call already came back
      interim has nobody parked on it).
      Two new tools, published beside `task` and checked by the same
      strict unknown-key check. `task_answer(agent-id, answer?, fields?,
      decline?, reason?)` resolves a parked question: prose for a
      question asked in prose, `fields` for one that asked for named
      fields (validated against the ones it REQUIRED), or a decline with
      a reason. Every refusal on that path leaves the question parked —
      an answer the model got the shape of wrong must not strand the
      agent that asked. Answering is not collecting: the child carries on
      from where it stopped. `task_wait(agent-id)` collects it, answering
      with exactly what the original `task` call would have, and is
      idempotent: a child already collected answers from a cache in the
      same words however many times a model asks. Neither is a spawn —
      no slot, no id, no entry in the identical-spawn tally — but a host
      should name both in Loop's `identical-call-exempt`, because
      collecting the same agent twice is by design.
      The settle envelope and the child's spend absorption are now ONCE
      PER CHILD whoever was waiting, and a child that finishes
      UNCOLLECTED records itself as it drains, before its slot is
      released: otherwise its answer would go with it and the `task_wait`
      the parent was told to make would find no such agent. EVERY way out
      of a park counts as having waited — a call released by a question
      re-reads its child on the way out and writes the terminal itself if
      the child settled underneath it, because the drain that follows can
      only see that the call was parked and defers its own record to it.
      An interim wave writes no settle envelope and absorbs no spend for
      a child that is still running.
      Two new transcript envelopes, `subagent-question` (agent-id, token,
      call-id, message, schema) and `subagent-answered` (agent-id, token,
      call-id, action, content, reason?), on the parent's session through
      `append-event` — so a reader that has never heard of them replays
      the file exactly as it always did.
      Cancellation reaches the questions too, because a parked one is a
      host thread blocked on a Promise: `cancel-children` sweeps the whole
      table (every vow kept `cancel`), a child that settles, refuses to
      stop, or is released takes its own question with it, and the
      parent's TERMINAL — not just its cancellation — now cascades. A run
      that ends with a child still going leaves that child working for a
      conversation that is over and a host thread blocked on a question
      nobody will ever see; nothing can reach either once the run is
      finished, so both are ended. When everything settled normally the
      hook finds nothing and does nothing.
      New constructor callbacks `on-child-park` / `on-child-unpark` fire
      when a `task` or `task_wait` call starts and stops waiting on a
      child (`{ agent-id, call-id, tool }`, plus an `outcome` of
      `interim` / `final` / `unstoppable` / `error` on the way out),
      balanced on every path and shielded. They are the host's accounting
      rails: a parent parked on a child is doing nothing, and the asking
      child is itself suspended, so the two together hold one slot rather
      than two.
      The `task` description now tells the model that an agent which
      finds its brief incomplete asks IT rather than guessing, that an
      interim result is never the task's answer, and how to get back to
      the agent. The loop needed no changes at all: an interim result is
      an ordinary tool result, and both new tools are ordinary tool
      calls.

0.4.1  2026-08-10T21:58:15+01:00
    - Fix flaky t/15 test

0.4.0  2026-08-10T20:17:25+01:00
    - New LLM::Agent::Subagents: a tool provider that delegates. It
      stacks over another provider the way MCP::Client::Registry and
      MCP::Client::Policy do — same duck-typed tools-for-llm /
      execute-tool-calls pair — publishes that provider's catalogue plus
      a `task` tool whose agent-type is an enum of the types it was
      built with, and answers a task call with a CHILD agent run's final
      message. How a child is built is entirely the app's: `spawn` is
      called with { agent-id, type, prompt, label } and hands back
      anything with `.run` (an LLM::Agent::Run, already started) and
      `.session-path`. There is deliberately no dependency on a queue or
      a scheduler — an app that wants one wraps the callback.
    - The child's events are not merged into the parent's stream, they
      are WRAPPED: one new event, LLM::Agent::Event::Subagent (kind
      'subagent', payload agent-id / agent-type / label? / inner),
      carrying the child event's .to-hash as `inner`. The wrapper is
      stamped with the PARENT's run-id and seq; the inner hash keeps the
      child's. A Subagent event is never terminal, whatever the child
      emitted, so a child completing cannot end the parent's Supply or
      stop a consumer that quits on a terminal.
    - New Loop.emit-external(Event) and Loop.emitter-for(Run): the two
      seams a layer above the loop publishes through. Both stamp and
      mailbox the event like the driver's own, are safe from any thread,
      cannot wedge `drained` (the work section is closed by a LEAVE, and
      a refused ticket opens none), and die on a terminal event because
      only _finish can keep the result Promise. They differ in WHICH run
      they publish onto, and the difference is load-bearing:
      emit-external asks the loop what is running now — right for a hook
      firing inside the run — while emitter-for binds to one Run for
      ever and answers False once it ends. Anything that outlives its
      run (a subagent winding down, a detached job) must use the second:
      a straggler from run A published onto run B is a turn in B's
      transcript that never happened.
    - Subagents forwards every child event, session envelope and log
      through an emitter captured at spawn time, so a child that
      outlives its parent has its late events DROPPED rather than filed
      under whatever run is live when they arrive.
    - A child is owned until it has DRAINED, not until it has answered.
      The task call still settles at the child's result — a model must
      not wait on a call nobody is waiting for — but the max-live slot,
      the live-agents entry and the right to cancel are held until the
      child's `drained` Promise is kept. The gap is real: a child that
      abandoned a tool call to a deadline has a result while that call
      is still writing files, and letting go there freed a slot, hid the
      child from a UI, and left cancel-children with nothing to cancel.
      live-agents gained `starting` and `draining` to say which end of
      that life an entry is at.
    - Two guards, both refusing in words the model can act on rather
      than by throwing: an identical-spawn cap over the (agent-type,
      prompt) digest — canonicalised, so key order and JSON whitespace
      are not identity, mirroring the loop's identical-call guard — and
      a max-live backstop that refuses while `max-live` children are
      running and tells the model to wait. Both checks and the child's
      slot reservation happen in one critical section, so two spawns
      arriving at once cannot both take the last slot. The identical
      tally is scoped PER PARENT RUN by default (identical-spawn-scope);
      'composer' keeps one tally for the object's whole life. A model
      going round in circles does it inside one run; three identical
      delegations across three runs are three things somebody asked for.
    - Cancelling the parent cascades to every child, at every point of a
      child's life — including the window where the spawn callback is
      still building one. The cancellation target is the SLOT, taken
      before the callback is called, so cancel-children records the
      request on children that do not exist yet and the spawn path
      cancels (or never starts) them; registration and that check share
      one critical section, so there is no interleaving in which nothing
      happens. Every path settles the task call: is_error for a child
      that was stopped, and — for a child that ignores its own
      cancellation — an outcome-unknown is_error after a grace period,
      rather than a call that hangs for ever.
    - Two new session envelope types on the PARENT's transcript, written
      through append-event and therefore ignored by every older reader:
      'subagent-spawned' { agent-id, agent-type, prompt, label,
      child-path } and 'subagent-settled' { agent-id, outcome, result,
      spent? }. child-path is a pointer, not a copy: replaying a parent
      session needs none of the children's files. Both writes are
      shielded — a transcript that cannot take an audit record never
      fails a working tool call; the failure becomes a Log event.
    - LLM::Agent::Subagents.new answers with a ::WithGrants subclass
      when the inner provider has grants, so .can('grants') stays honest
      in both directions: a composer that always had it would make the
      loop persist grants for a stack with none, and one that never had
      it would silently break grant persistence for a policy underneath.

0.3.0  2026-08-10
    - New LLM::Agent::RunContext: the refreshable half of a prompt —
      identity, instruction files, and facts like the date, the working
      directory and the git HEAD — rendered into the REQUEST per run
      rather than baked into a sticky system message. Loop.run gains an
      optional :$context; the wire view is [head, |@conversation, tail]
      and is built in exactly one place. @conversation itself never
      contains the context, so the seed check, the session appends, the
      compactor, RunStarted.message-count and %outcome are all
      unchanged — which is what lets a resumed session send TODAY's date
      and today's AGENTS.md instead of replaying the ones the transcript
      was created with. Facts are an ordered List of Pairs and a Hash is
      refused at construction: randomised hash order would render
      differently per process while the (sorted-key) digest stayed
      identical. Head/tail split is for prefix caching — volatile blocks
      near index 0 re-prefill the whole conversation every request.
    - New session envelope type 'run-context': one line per run that had
      a context, carrying its digest, its facts in render order, and its
      sections. Section bodies are stored ONCE per transcript and
      back-pointed to afterwards (rendered-in), which keeps an unchanged
      AGENTS.md from being written a hundred times; Session.run-contexts,
      .last-run-context and .run-context-section(digest) read them back,
      and an unresolvable back-pointer (a crash-tail repair removed the
      carrying line) answers with an undefined Str rather than dying. No
      envelope version bump: an older reader skips it as an unknown type.
      BREAKING, narrowly: 'run-context' is now a built-in type, so
      append-event refuses it — an app that was writing a custom line of
      that name must rename it or use append-run-context.
    - TokenCount gains count-text (how big a lump of text is, weighed as
      one message) and invalidate (drop what has been learned). The loop
      weighs the rendered context as TEXT and adds it as its own term to
      the preflight's needed AND usable and to the compaction trigger —
      never by counting the wire view, which would make Usage throw its
      calibration away every round. A run whose context digest differs
      from the previous run's calls invalidate once: a calibration billed
      against yesterday's context describes a prompt nobody is sending.
    - RunStarted gains an optional context-digest payload key, present
      only when the run was given a context.
    - The preflight error string now names the context term explicitly
      ("... + context 0 + margin ..."), so the sum stays checkable.
    - RunContext tells a caller who wrote `facts => (date => 'x')` that
      it was handed a single Pair — a Pair does the Associative role, so
      it used to get the (wrong, unactionable) "was given a Hash"
      explanation instead of "add the comma".
    - Prompt's Pod now says outright that an `assemble`d prompt
      fossilizes on every resume of the transcript it reaches, and points
      at RunContext for the half that should not.

0.2.0  2026-08-10T04:05:00+01:00
    - New durable ToolOperation layer: every dispatched tool call gets a
      tool-dispatched / tool-settled pair of session envelopes,
      pending-tool-operations and resolve-tool-operation read the
      surviving state back, and outcomes distinguish the three things a
      SIGKILL can leave behind — never-dispatched, dispatched-still-
      running and completed-unpersisted. Tool calls in one assistant
      turn now dispatch sequentially, one at a time, rather than as an
      atomic batch. BEHAVIOR CHANGE: side effects happen in strict model
      order, batch atomicity is gone, and a permission ask can now
      interleave with execution of calls either side of it. A new
      tool-deadline yields outcome-unknown — never is_error — so a
      human taking their time over an ask is not treated as a tool
      failure; idempotency-rules config records what a repair may
      honestly retry.
    - Events v2: TurnCommitted, TurnDiscarded(reason), ToolStarted,
      ToolProgress and ToolAbandoned(reason, dispatched) join the event
      vocabulary, and ToolResult now carries artifact metadata when its
      result was too big for the conversation.
    - New LLM::Agent::RequestBudget: per-backend context-window
      profiles, a per-attempt preflight that skips past a backend the
      conversation will not fit without burning a retry on it, forced
      targeted compaction (Compactor.compact gains :$target) and a
      clean context-exhausted failure when there is no compactor to
      try. A 400 that looks like a context overflow is best-effort
      reclassified so the loop can advance instead of retrying the same
      backend forever, cost is plumbed through from OpenRouter's
      responses, and optional max-cost / max-total-tokens /
      max-wall-clock caps end a run in RunFailed reason
      'budget-exhausted'. The result Map gains spent.
    - New LLM::Agent::Artifacts: a tool result too big to live in the
      conversation is excerpted, with the full bytes written to a
      .artifacts/ sidecar beside the session — replay
      never needs the artifact file to exist.
    - Grants are now persisted the moment the policy applies them, via
      the new Policy.on-grant hook, replacing 0.1.1's snapshot watcher.
    - Session.events returns true copies: mutating a returned envelope,
      including its payload, can no longer reach the replayed state.

0.1.1  2026-08-09T23:15:02+01:00
    - Crash repair is now physical: a malformed final transcript line is
      removed by an atomic byte-prefix rewrite+rename before the append
      handle opens (was: warning-only, garbage stayed, second resume
      died / first append after a real crash tail was silently
      swallowed). Repair handles CRLF and mid-UTF-8-character
      truncation; two bad tail lines still die.
    - Session appends are validate -> write -> commit: a failed write no
      longer leaves replayed state ahead of the file.
    - Event publication is serialized through a per-run mailbox: every
      event carries run-id + seq (contiguous, terminal last); nothing is
      ever published after the terminal; a throwing subscriber loses
      only its own event. BEHAVIOR CHANGE: the result Promise is now
      kept BEFORE the terminal event is published (0.1.0 documented the
      opposite order) — a terminal subscriber reading .result finds it
      Kept.
    - cancel on a finished run is a total no-op (no cancellation-vow
      keep, no backend poke); the loop's active stream slot is
      owner-tagged, so a stale handle can never cancel or clear a newer
      run's stream. New: Run.drained Promise (producer quiescence;
      diverges from .result under cancellation).
    - Compaction now works sessionless (Session and Compactor
      independently optional, tested as a matrix) and proves progress:
      tokens-after must shrink, one tightened summarizer retry, then
      hard-trim; an impossible-to-fit conversation ends in a clean
      RunFailed with new reason 'context-exhausted' (RunFailed gains an
      optional reason field, also in the result Map).
    - Session seeding compares canonical per-message digests (role,
      content, tool-calls, ids, sticky, sysprompt, depth) via the new
      LLM::Agent::Canonical module — same-prose different-semantics
      prefixes are rejected. TokenCount::Usage calibration is now
      conversation- and backend-scoped (prefix digest + backend
      identity; unrelated conversations and heterogeneous fallbacks no
      longer inherit stale prefix costs).
    - Tool sharp edges: identical-call guard canonicalizes JSON
      arguments and counts within-batch duplicates; provider tool
      results are forced onto the expected tool_call_id (mismatches
      logged); always-* permission grants are persisted to the session
      the moment the policy applies them (and on cancelled/failed
      exits), compared by content digest rather than count; the session
      duck-type is validated for append-compaction/append-grants at
      construction.

0.1.0  2026-08-09T20:36:12+01:00
    - Initial release: the engine behind a coding/tool-using agent — a
      streaming loop that calls tools through a duck-typed provider,
      survives backend failure with per-round-trip retry and fallback,
      writes a durable JSONL transcript, and compacts the conversation
      to stay inside a context budget.
    - LLM::Agent::Event: the typed event taxonomy the loop publishes.
      One Supply of events for every consumer (TUI, logger, test), with
      a stable C string and a plain-data C per class, and
      the ATTEMPT FRAMING contract that makes mid-stream retry
      replayable: Tokens belong to the AttemptStarted that opened them,
      an AttemptFailed retracts them, an AttemptSucceeded commits them.
      Exactly one terminal event (RunCompleted / RunFailed /
      RunCancelled) is emitted per run, and the Supply is then done —
      it is never C.
    - LLM::Agent::Run: the per-run handle. C<.events> (a
      Supplier::Preserving Supply, so a consumer that taps late still
      sees the whole run), C<.result> (a Promise KEPT — never broken —
      with the outcome Map), an idempotent C<.cancel>, and C<.is-done>
      for poll-friendly tests.
    - LLM::Agent::TokenCount: the counting seam — a one-method role with
      three implementations. C<::Exact> delegates to an
      LLM::Chat::TokenCounter (real tokenizer, exact, needs one),
      C<::Heuristic> is chars/4 plus per-message overhead (no
      dependencies, always available), and the default C<::Usage>
      calibrates against the prompt-token counts the provider actually
      billed and only estimates the tail beyond them.
    - LLM::Agent::Prompt: four pure system-prompt builders — C
      (platform/OS/date plus caller-supplied pairs, nothing inferred),
      C (markdown from tools-for-llm declarations),
      C (existing files, order preserved) and
      C (the sticky sysprompt Message).
    - LLM::Agent::Loop: the state machine. A backend chain with
      per-round-trip retry and fallback (LLM::Chat::Retry's buckets,
      Task's max-retries-is-attempts-per-backend semantics), tools
      through any duck-typed tools-for-llm/execute-tool-calls provider,
      ToolLoop's limit wording and "name\0json-args" signature scheme,
      an INACTIVITY timeout on $resp.last-activity-at (deliberately not
      Task's total-duration bound — an agent turn legitimately runs for
      minutes), cooperative cancellation, and the wrap-ask / log-hook
      shims that let a policy's questions and a server's logs come out
      of the same event Supply as the tokens. One run at a time per
      Loop, on purpose: scheduling belongs to the layer above.
      Two ordering rules are load-bearing and pinned by tests: a limit
      is checked BEFORE the assistant turn is committed (so a turn
      carrying tool_calls that nothing answers is never written), and a
      run cancelled during a tool batch closes every abandoned call off
      with a synthetic tool message (same reason — the transcript has
      to stay resumable).
    - LLM::Agent::Session: an append-only JSONL transcript held open on
      one flushed handle-mode JSONL::Writer, so every line is durable
      the moment its method returns. Four envelope types (session-meta /
      message / grants / compaction) under a versioned envelope; replay
      tolerates a malformed FINAL line (what a crash mid-write looks
      like) and dies on one anywhere else; compactions compose, so
      messages() after a resume equals the array the loop was working
      with when it stopped.
    - LLM::Agent::Compactor: summarize the middle, keep the sysprompt
      and the recent window, never split a tool-call/tool-result pair.
      A summarization failure is classified with classify-error — an
      abort bucket hard-trims at once, anything else retries up to three
      total attempts with backoff — and a still-failing summarizer falls
      back to a pair-aligned hard trim with C True>, so the
      loop always makes progress.
    - LLM::Agent: the umbrella. No wrapper class — it loads the other
      seven modules and carries the dist's flagship Pod: the canonical
      wiring recipe (backends, MCP client + registry + policy with the
      forward-declaration idiom, the log-level gotcha, a shared
      TokenCount instance), the resume recipe, and the cancellation
      truth table.

# Run the suite with:
#   prove6 -Ilib -It/lib -I../LLM-Chat/lib -I../MCP-Client/lib \
#          -I../JSONL/lib -I../Template-Jinja2/lib t/
#
# `mi6 build` needs a git repository: run `git init` here and `git add`
# the lib files BEFORE the first build, or the ReadmeFromPod step runs
# against an unstaged tree.