Skip to content

✨ Slice B of #854: run live Agent turns and permission waits in the REPL - #859

Open
taras wants to merge 7 commits into
mainfrom
agent/issue-854-kernel
Open

taras wants to merge 7 commits into
mainfrom
agent/issue-854-kernel

Conversation

@taras

@taras taras commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Part of #854. Slice B of five.

Stacked on two merged foundations, both already on main:

Why

Slice A let the REPL project retained Agent conversations from the Journal.
It could not run one. A live <Prompt> had nowhere to report its streaming
reading, a permission request arriving mid-turn had no owner that could answer
it, and the decision the REPL's own authority made never reached the owning
Prompt's durable audit.

What changes

Before:

  • A <Prompt> in a REPL entry produced no live overlay; only retained turns
    existed.
  • A permission request reaching the REPL had no authoritative answer path.
  • A live permission decision bypassed the owning Prompt's durable audit.

After:

  • One REPL session reports live Agent turns and permission waits, while the
    Journal remains retained truth.
  • Core's Agent middleware audits every permission decision from outside every
    policy, and each live turn is tied to its durable record by the journal
    boundary itself.

How it works

Auditing is owned by Core's outer Agent middleware, not by the component.
Its prompt handler makes one private ledger for that exact Agent.prompt()
invocation and wraps the cold stream it returns; subscribing and every
next() run beneath that ledger, so a provider that asks for permission the
moment it is subscribed already finds the right one, and the context is
restored however each step finishes. Its requestPermission handler reads the
inherited ledger, reserves the request before delegating, delegates exactly
once, and records what came back. Only completed places are published, so a
request still in flight at teardown is omitted rather than invented.

<Prompt> installs nothing. It reads its audit from the exact stream it
consumed, through a private association held per Agent installation. Holding
the stream is the only way to name an entry.

Correlation is the canonical publication. Core calls the host publisher's
begin(input) immediately before the provider is asked, and hands the same
opaque handle back on the publication that ends the turn. append() is the
single durable handoff, and the live turn it began is retired inside that same
transition — so no announced snapshot holds a turn both ways or neither. When
publication fails, the turn is retired and the removal announced before the
failure travels on, because nothing was retained and nothing may still be
shown as though it is about to be.

A direct public Agent.prompt() begins nothing: it gets no reading and can
neither claim a record nor be claimed by one. A replayed turn asks no
provider, so it begins nothing either. Nothing correlates by coroutine queue,
prompt text, session, completion order or recency.

Admission ordering is structural. Both session subscriptions are created
in the enclosing scope, filtered with filter() before the document spawns;
consumeAdmissions() takes a Subscription rather than a Stream, so the
losing shape — a consumer that subscribes as its first act, a turn too late —
cannot be written through the signature.

Review guide

Start with: packages/core/src/agent/journal.ts — the middleware that owns
both sides of auditing, the wrapped stream, and the per-installation
association.

Then review:

  1. packages/core/src/agent/publication.ts — begin() / begun, the host
    boundary correlation is built on.
  2. packages/cli/src/repl/agent.ts — the REPL publisher, handle lookup, and
    the retirement path on success and on failure.
  3. packages/cli/src/repl/admission.ts and session.ts — subscription
    ownership.

What must stay true

  • Cold reconstruction does no work. No provider, permission, read or
    compile work. The cold row performs a real component read and eval compile
    on the live run, proves both are zero on the cold run, calls no provider,
    and compares the cold execution's serialized journal event-for-event with
    the live bytes.
  • The audit machinery stays private. Nothing is added to AgentApi,
    component props or core/mod.ts. begin() is an optional addition to the
    existing useAgentPromptPublisher host boundary; a publisher declaring none
    behaves exactly as before.
  • The Journal remains retained truth. Overlays are live-only.

How to verify it

deno task test \
  packages/core/tests/agent-function-components.test.ts \
  packages/cli/tests/repl-agent-execution.test.ts \
  packages/cli/tests/repl-execution.test.ts \
  packages/cli/tests/repl-admission.test.ts
# ok | 27 passed (129 steps) | 0 failed   — exit 0

deno task check      # exit 0
deno task lint       # exit 0
deno task check:jsr  # exit 0
git diff --check     # exit 0

Each control below was applied alone and reverted byte-for-byte:

Deliberate defect Rows it reddens
Drop the ledger from the wrapped stream's next() the whole A1/A2 audit suite
Observe direct public Agent.prompt() calls too both P6 direct-call rows, and only those
That, plus draining a per-coroutine queue on append P6, on the leftover live turn
Stop retiring the turn when publication fails the refused-publication row
Subscribe inside the spawned consumer AD2 records that shape dropping the value AD1 proves survives

AFP0 is the other side of the publication-failure comparison: the same
harness with a publisher that appends succeeds and records exactly one event,
where AFP1 (raises) and AFP2 (returns without appending) both fail through
the durability path and leave none.

Scope

Included

  • Live Agent turn overlays and permission waits in one REPL session.
  • Middleware-owned permission auditing, and canonical publication correlation.

Intentionally unchanged

  • Slice C bounded Elicit forms, Slice D Sessions and permission presentation,
    Slice E command, packaged Plan and journeys — including specs/repl-spec.md,
    which the plan assigns to Slice E.

New dependencies

  • @executablemd/cli now declares @effectionx/stream-helpers@0.8.3, already
    in the lockfile and already used elsewhere in the workspace. The lock delta
    is one line. Used for filter(), so admission filtering composes with the
    stream API instead of being hand-rolled inside the consumer.

Risks and limitations

  • For concurrent spawns, ReplAgentTurn.sequence is Prompt scheduling
    chronology, not authored order. <All> separately preserves authored output
    order. A surface wanting authored order cannot read it from sequence.
  • deno task verify:clean was not run for the dependency change. The delta is
    a single member-scope reference to a package the lock already pinned, and
    deno task check:jsr passes, but the repository's dependency-mutation
    procedure asks for it before pushing anything that could move dependency
    state.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Prompt>` returns only once its provider turn has settled and its
ordinary `agent_prompt` record has appended. Between those two moments
there is a turn nobody could read. This adds the private live owner that
reports it — queued, streaming, finished-but-unpublished — and that holds
and settles the interactive permission requests a turn makes.

The observer wraps `Agent.prompt()`'s cold stream rather than subscribing
to it, so `<Prompt>` stays the one subscriber and the one owner of
provider cancellation, and each provider event travels back untouched.

Correlation is by coroutine, not by resemblance. Two `<Spawn>` children
may run identical prompts against identical responses and settle in
either order, so nothing about a turn's content identifies it. Expansion
inside one coroutine is sequential and a record appends on the coroutine
that made it, so the Nth turn observed on a coroutine is the Nth record
appended there. A record and the overlay it replaces change in one
transition, so no snapshot holds a turn twice or neither time.

An interactive request belongs to the live turn on whose coroutine the
provider asked. Where there is not exactly one, nothing is published,
nothing is denied, and the session owner is terminated — reporting only
into the permission operation would leave the REPL running without the
identity it needs. A post-admission correlation failure ends the owner
the same way: authority is withdrawn, the execution is halted and joined,
and `join()` returns that exact failure.

Dismissal while the session is live denies once through Core's own rule,
which `@executablemd/core` now exports. Whole-session teardown is
structured cancellation instead, and claims no denial outcome.

Preflight now validates a submitted entry against the vocabulary its
execution installs. A host that declares `<Session>` to the execution
would otherwise have every entry naming one refused before it ran.
A turn the REPL ran retained no permission audit at all. The observation was
installed per Prompt, inside the turn; the REPL's authority is installed with the
session's execution installations, outside it. A policy decides every request
inside its scope without deferring outward — that is what deciding means — so the
observer never saw the call it exists to record, and `PromptRecord.permissions`
was empty for every REPL turn while Slice A's contract held everywhere else.

One observer now sits with the Agent installation itself, outside every policy
installed after it. Each Prompt places a private ledger where its own requests
will find it, and a request raised while that Prompt's provider stream is being
consumed inherits that ledger through its own scope — so two turns asking at once
keep their audits apart with nothing correlating by recency, prompt text, agent,
conversation or the order decisions settled in. The observer reserves the
request's place on the way in, delegates exactly once, and copies the returned
outcome into that same place, so two overlapping requests from one turn keep the
order they were asked in however their answers interleave. A place nobody
completed is published as nothing: a policy that raised, and a wait cancelled with
its session, leave no member rather than an outcome nobody reached.

The ledger and its context are private to the journal module. Nothing new is
exported, no authority is added, and the safe fields are still copied one by one —
`rawInput`, the Session and the provider's own objects reach neither the bytes nor
the model.

`{ at: "max" }` is not outside a later default install: it *is* the default, and
an outer scope's middleware ends up outermost. That is why this is installed in
the Agent installation rather than once per execution.
…lish

`spawn()` returns before its child body runs, and a Signal drops whatever it
sends while no subscription is active. Both admission watchers created their
subscription inside the spawned child, so an announcement the execution made
immediately — the queued Agent turn or the elicitation that is the only
evidence admitting a cold session — could be sent into no subscriber at all,
leaving `start()` waiting for an admission that had already happened.

Effection's contract is explicit: create the subscription in the enclosing
scope, iterate it in the child. Spawning the consumer earlier is not
equivalent, because what must precede the document is the subscription, not
the task that reads it.

The elicitation watcher had the same lossy ordering and predates this slice.
Both belong to one admission boundary, so both are corrected rather than
leaving one known race beside the fixed one.
The REPL observed every public `Agent.prompt()` call and drained a per-coroutine
queue whenever an `agent_prompt` record appended. `Agent` is exported, so a
registered component may call it directly: that call was observed, journalled
nothing, and left its queue entry behind — and the next canonical record then
retired the direct call's entry instead of its own. The canonical turn stayed
in the live overlay while also being durable, and a permission it was granted
was audited against the wrong turn.

A queue cannot establish that identity, because it holds calls that can never
produce a record. The journal boundary already knows which calls are its own,
so it now says so: core calls the host publisher's `begin()` at the one moment
that is both canonical and live — in the turn's own scope, as its private audit
ledger is placed — and hands the same value back on the publication that ends
it. `append()` remains the single durable handoff, and the live turn it began
is removed inside that same transition, so no announced snapshot holds a turn
both ways or neither.

The handle is opaque to core, ephemeral, process-local and never journalled.
`PromptAudit` stays private, `AgentApi` is untouched, and no parallel host API
is introduced: `begin()` is an optional addition to the existing
`AgentPromptPublisher`, so a publisher that declares none behaves exactly as
before.

A direct `Agent.prompt()` begins nothing, so it gets no reading and can neither
claim a record nor be claimed by one. A replayed turn asks no provider, so it
begins nothing either.

P6 covers it: a direct public prompt completing before a canonical `<Prompt>`
on the same coroutine, and the same exact handoff for failed and cancelled
turns, where no `association` exists and only the handle identifies the turn.
`place()` installed this Prompt's ledger and then trusted its caller to have
opened a scope of exactly the right size first. Two things that must be
inseparable were separate: installing the ledger, and running this Prompt's
provider stream beneath it. Called one scope too high it would have leaked
silently into sibling work, and nothing in the signature said so.

`within(body)` is a lexical bracket instead. The ledger exists for exactly as
long as `body` runs, descendants inherit it, and it is restored when `body`
returns, raises or is cancelled. No caller can install it without naming the
work it governs, so the invariant is structural rather than remembered.

The canonical turn is begun inside that same bracket, which is the one moment
that is both canonical and live.

No behavior changes: the audit rows that prove two requests from one turn keep
their arrival order, and that a later request from an earlier turn is still
that turn's, pass unchanged.
…iptions

Auditing was something the canonical `<Prompt>` had to remember to install.
It now belongs to Core's outer Agent middleware, which owns both sides of it.

The `prompt` handler makes one private ledger for that exact invocation and
wraps the cold stream it returns. Subscribing and every `next()` run beneath
`PromptAudit.with(ledger, …)`, so a provider that asks for permission the
moment it is subscribed already finds the right ledger, and the context is
restored whichever way each step finishes. The `requestPermission` handler
reads the inherited ledger, reserves the request before delegating, delegates
once, and records what came back.

`<Prompt>` no longer creates, places or brackets anything. It reads its audit
from the exact stream it consumed, through a private association held in a
WeakMap that belongs to the installation rather than the module. Holding the
stream is the only way to name an entry, so a bystander cannot read one.
`PromptPermissionAudit.within()` is gone from the component-facing protocol.
`scoped()` stays: it owns provider cancellation, permission routing and the
session lock, which have nothing to do with audit correlation.

Admission's ordering is now structural rather than remembered.
`consumeAdmissions()` takes a `Subscription`, not a `Stream`, so the losing
shape — a consumer that subscribes as its first act, a turn too late — cannot
be written through the signature at all. Both session subscriptions are
created in the enclosing scope before the document spawns.

Evidence, with the controls that make each row mean something:

- AD1/AD2 hold the admission ordering: AD2 shows the replaced shape dropping a
  value announced before its consumer began, which is what AD1 proves survives.
- P6 covers a direct public `Agent.prompt()` beside a canonical `<Prompt>` on
  one coroutine and across concurrent spawns, and the same handoff for failed
  and cancelled turns where no association exists.
- AFP0/AFP1/AFP2 hold publication failure: a publisher that raises and one that
  returns without appending both fail through the durability path and leave no
  `agent_prompt`, while the same harness with a publisher that appends
  succeeds and records exactly one.
…by lookup

Four corrections to the canonical-correlation work.

A publication that fails retained nothing, but the live turn it began stayed
mounted — a terminal overlay waiting for a record that was never going to
arrive. The failure path now retires exactly that turn and announces the
removal before the failure travels on.

The handle is read back by lookup instead of by assertion. `begin()` mints an
opaque object token and remembers what it stood for; `publish()` narrows at
runtime and asks the map. A value this owner did not mint is simply not a key,
so nothing has to claim it is a turn. The two `as const` assertions in the new
rows are gone too.

Admission filtering composes with the stream API. `filter()` is applied in the
enclosing scope before either subscription exists, so `consumeAdmissions()` is
a generic drain of an already-active subscription and cannot know or care what
admits. `@executablemd/cli` declares `@effectionx/stream-helpers`, which the
lockfile already held; the delta is one line.

The published contracts now describe what is actually there: `publication.ts`
documents a publisher seen at two moments rather than only at completion,
`components.ts` and both specs describe middleware that owns each invocation's
ledger and wraps its stream, and the Prompt-owned-ledger and FIFO-correlation
wording is gone.

Evidence: the refused-publication row is REPL-level and drives a journal that
refuses the record, proving the turn is neither retained nor left mounted. It
reddens when the failure path stops retiring the turn.

This branch has not been deployed

No deployments
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.

1 participant