Skip to content

✨ Evaluate, preview and converse as a trusted host (#883, PR 1 of 2) - #890

Open
taras wants to merge 4 commits into
mainfrom
agent/issue-883-pr1
Open

taras wants to merge 4 commits into
mainfrom
agent/issue-883-pr1

Conversation

@taras

@taras taras commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Closes nothing on its own — PR 1 of 2 for #883.

Why

xmd repl's Sidekick needs three things a document already has and a host does
not: a way to evaluate generated XMD as a root rather than a fragment, a way
to show source that is still being typed without guessing at it, and a way to
hold one agent conversation open across a process. Today a host can reach none
of them: <Output> is refused at the top level, the scanner reports nothing
about the construct it gave up on, and <Session>/<Prompt> only exist inside
an expansion with an element to name the placement and a journal already around
them.

This PR adds exactly those three capabilities and the specification they answer
to. It ships no Sidekick; PR 2 is the CLI.

What changes

Before:

  • A generated root carrying a top-level <Output> was refused, and a fragment's
    retained admission could be resumed as a root.
  • A host wanting to preview partial source had to re-implement the grammar, or
    guess from a failed parse which refusals meant "not allowed" and which meant
    "not finished yet".
  • A host could not open an agent conversation at all. A DurablePreparation
    runs before Execution.document, so one opened there reaches no provider and
    no placement coordinator.

After:

  • evaluateGeneratedXmdRoot(source) admits the fragment grammar plus one
    construct — a top-level <Output> — and the context is a term of the
    retained policy
    , so a root admission never resumes as a fragment or the
    reverse.
  • previewGeneratedXmdRoot(source) is a pure prefix projection, no Operation.
    The scanner now reports what it abandoned (UnfinishedConstruct), which is
    what tells "still arriving" from "not allowed" without a second grammar.
  • useAgentConversation(request) opens the genuine thing over a DurableStream
    the host supplies: canonical Session placement, journaled turns, verified
    configuration readback, and a native identity the provider reattaches to.

How it works

host installations → executeInstalled(synthetic root) → Execution.document
  → AgentConversationHost element → durable turn:N per prompt

The conversation is a host-declared identity component written into a
synthetic root core owns (agent/conversation.md), because that is the only
position from which a session can be placed: canonical resolution hands the
element this execution's claimant, so the session runs under the engine's own
identity rather than under anything the host supplied. The host's id names the
history and nothing else.

Its root is never completed. A durable root that records its terminal
replays instead of reopening, and reopening a chat is the ordinary case — so the
element serves turns and does not return, and the halt that releases the
resource appends nothing. What the history keeps is a partial continuation.

Each turn is one journaled Prompt under its own durable name, offered in the
conversation's sequence exactly as <Prompt> offers one in a document's and
through the same runPrompt the component uses, run in a task of its own so
cancelling the call halts the turn.

Review guide

Start with: packages/core/host.ts — the three exports and why each exists.

Then review:

  1. specs/acp-client-spec.md §A conversation a trusted host holds open, and
    specs/executable-mdx-spec.md — the contract this implements.
  2. packages/core/src/agent/conversation.ts — the whole C3 capability.
  3. packages/core/src/generated-xmd.ts — GeneratedContext threaded through
    admission, the retained Policy.context, and the Result boundary.
  4. packages/core/src/generated-xmd-preview.ts and src/scanner.ts —
    the projection and the unfinished-construct reporting it needs.

Look carefully at:

  • deliver() and the caller's ensure in handle(): the cancellation join.
    The ensure that reports a turn joined is registered before the turn is
    spawned, which is what makes it unwind last — cleanups registered later run
    first, so the provider's own cleanup is finished before the caller is
    released, and busy stays set through it.
  • ordinaryGeneratedFailure(): which failures become Err with a partial
    rendering and which are rethrown as themselves.

What must stay true

  • A root admission is never resumed as a fragment, or the reverse — enforced
    by context being a term of the retained Policy compared in
    policyHolds(), checked by GR13 and GR14.
  • A released fragment record still carries no context — enforced by writing
    context only for a root, checked by GR10/GR11/GR15 and the whole released
    fragment row.
  • Cancelling a turn joins the provider's teardown, and the conversation
    admits no second turn until it ends
    — enforced by the pre-spawn ensure and
    by live.busy clearing in the caller's own finally, checked by CV18/CV19.
  • An established chat is never reattached to another native conversation —
    enforced by comparing the resolved and recorded agentSessionId against what
    the retained records establish, checked by CV21/CV23/CV24 and HC3.
  • A turn whose outcome the history does not hold refuses before anything
    opens
    — so a refusal never appends a terminal to the history it refused,
    checked by CV14.

How to verify it

deno task test packages/core/tests/generated-xmd-root.test.ts packages/core/tests/generated-xmd-preview.test.ts packages/core/tests/agent-conversation.test.ts
deno task test packages/acp/tests/host-conversation.test.ts
  • GR6/GR7 prove a runtime failure after a real earlier effect answers Err
    with the truthful partial rendering, and fail if the partial were dropped or a
    refused admission were allowed to carry one (GR8).
  • GR13/GR14 prove a changed context refuses a retained admission, and fail
    if context were left out of the policy comparison.
  • GP23–GP27 prove invalid source is distinguished from incomplete source,
    and fail if the preview inferred "still typing" from any parse failure.
  • GP31/GP32 prove the preview reaches no filesystem, provider or component,
    and fail if it became an Operation that could.
  • CV18 is the reviewer's own held-teardown probe as a regression: it proves
    a caller's halt does not return until the provider's blocked cleanup finishes,
    and fails if the caller merely signals cancellation and leaves. CV19 then
    proves fresh explicit work still starts, so the fix is not a deadlock.
  • CV21/CV23/CV24 prove an established native identity is not replaced —
    refused before the turn, refused after it, and unopenable when the history's
    own turns name two conversations.
  • HC1 proves the one window a stub cannot show. It installs the real ACP
    provider the way installAgentProviderStack does, with ACPX's runtime
    replaced by this package's scriptable fake and the host's own mapping
    acknowledgement refused once — the ordering provider.test.ts SM9
    demonstrates. The provider's assertion survives, the owner is released, and
    explicit new work reconciles to that same conversation, keeps one store
    record and does not resend the interrupted prompt. It fails if a reopening
    created a replacement conversation or re-sent accepted work.

Also run: deno task check (exit 0), deno task lint (0 errors, format clean),
deno task validate:docs. The new test file was run under Node and Bun as well
as Deno, so it needs no runtime exclusion.

Scope

Included

  • evaluateGeneratedXmdRoot(), previewGeneratedXmdRoot() and
    useAgentConversation() on @executablemd/core/host.
  • The scanner's unfinished-construct reporting, which the preview needs.
  • architecture.md, specs/acp-client-spec.md and
    specs/executable-mdx-spec.md updated to match.

Intentionally unchanged

  • The REPL and the CLI. No Sidekick, no surface, no command. That is PR 2,
    which starts from this commit.
  • packages/acp/src/provider.ts. Tier HC was written to find out whether
    the provider's commit window needed a production change. It did not, so there
    is none; the file is untouched and only gains a test alongside it.
  • The released fragment path. evaluateGeneratedXmd() and its records
    behave exactly as before, which the preservation row asserts.

New abstractions

  • GeneratedContext / ROOT_CONTEXT exists because the one thing separating a
    root from a fragment is a ceiling, and a ceiling that is not retained with the
    admission can be changed between runs. Consumers: admission, the policy
    comparison, and the record.

  • UnfinishedConstruct exists because the preview's whole question — is this
    not allowed, or just not finished — is unanswerable from a parse failure
    alone. Consumers: previewGeneratedXmdRoot().

  • AgentConversation / AgentConversationRequest / AgentConversationError
    exist because a host conversation is a resource with a mailbox, not a door:
    the handle reaches the element only through its queue. Consumers:
    useAgentConversation() and, next, the CLI Sidekick.

  • readsBinding() in src/generated-interpolation.ts exists because admission
    and preview must apply one interpolation rule; two copies would let a
    preview promise what admission refuses.

  • Each new abstraction has multiple concrete uses or a clear justification.

  • No speculative functionality is included.

Generated or mechanical changes

  • packages/core/src/agent/function-components.ts only exports runPrompt and
    Carried, so the component and the host operation reach one definition of a
    canonical turn instead of a copy. No behavior change.
  • packages/core/src/errors.ts only exports the existing firstCause.

Risks and limitations

  • The conversation's durable root is deliberately never completed, so a history
    it owns is always a partial continuation. A consumer that treats an absent
    terminal as corruption would misread it; the spec states this.
  • A turn whose outcome the history does not hold is refused rather than
    reconciled, by design — reconciling it needs the provider's own account of
    that turn, which is the host's to fetch. PR 2 is the first consumer that will
    have to surface that refusal to a person.
  • Recovery or rollback: the three exports are additive and nothing else consumes
    them yet, so reverting this commit removes the capability without touching any
    existing path.

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.

taras added 4 commits October 10, 2026 10:47
A host that means to discuss something with an agent and show somebody what
the reply *says* needs three things core did not have.

**Evaluate the reply as a root.** `evaluateGeneratedXmdRoot()` admits source
through the same walk, the same durable record and the same pinned resolution a
fragment gets, and differs over exactly one construct: a top-level `<Output>`,
which is what lets a reply select what it renders. The admission's context
travels with its policy, so a root admission does not resume as a fragment and
a fragment admission does not resume as a root — and because a fragment record
states no context, every record already written stays readable and resumable as
what it is. The walk's invariant inputs are bundled rather than threaded, so the
one difference is read from where the rest of the decision is instead of copying
the policy walk per context.

It answers with a `Result`, because the two kinds of failure are different
things for a host to hold. Source core refused, and work the root's own elements
failed at, are the request's and come back as `Err` carrying what the root had
rendered. A durability failure, an absent Files provider, a secret-policy
refusal and a failed teardown are not, and each keeps its own classification.

**Read what an arriving prefix already says.** `previewGeneratedXmdRoot()` is a
pure projection with no `Operation`: it reads no file, resolves no name, invokes
no component, evaluates no expression and grants no admission. Source still
arriving is a success that says so; source the language does not allow where it
is written is a refusal. Telling those apart needs the scanner's own walk — it
is the only reader that knows how far it got and why it stopped — so the scan
now reports the constructs it gave up on, beside the elements it already
reported. A host recognizing `<Output>` with a regular expression would be a
second reading of one syntax.

**Hold a conversation open.** `useAgentConversation()` is the conversation a
document's `<Session>` and `<Prompt>` cannot be: no element to name a placement,
no journal already around it, and a history that outlives the process. It is an
execution the host keeps open, and three things follow. It is an *element* — a
host-declared identity component in a synthetic root of core's own — because a
provider and its placement coordinator are installed inside `Execution.document`
and a preparation precedes both, so the session is placed under the engine's own
claimant-issued identity rather than under anything the host supplied. Its root
is never completed, because a recorded terminal replays instead of reopening:
the element serves turns until the resource is released, and the halt appends
nothing. And each turn is one journaled Prompt under its own durable name, run
in a task of its own, so cancelling the call halts the turn, leaves its append
unmade, and makes the next turn a new turn rather than that one resumed.

A turn whose outcome the history does not hold refuses the conversation before
it opens — resuming it would re-send a prompt the provider may already have
accepted, and writing a terminal for it would make an unfinished turn look
complete. The history is bound to the conversation it belongs to, so one
established for another id or another agent refuses rather than reconnecting the
wrong chat.

`runPrompt` is now shared rather than copied: the component and the host
operation both reach the one definition of what a canonical turn is.

Covers C1, C2 and C3 of the issue's frozen matrix.
…ed (#883)

Two Planner findings against `d45bbfc6`.

**P1 — cancelling a prompt returned before the provider had stopped.** The
caller's cleanup resolved the cancellation signal and returned; the worker then
halted the turn, but that worker belongs to the long-lived conversation rather
than to the cancelled caller. So a halt that had returned proved nothing about
whether provider work was still running — and a CLI cycle cannot build on that.

The caller now waits for the turn to finish unwinding before its own halt
completes. The worker reports that by resolving a signal from an `ensure`
registered *before* the turn is spawned, which is what makes it the last thing
to unwind: cleanups registered later run first, so the turn and every cleanup
the provider registered inside it have finished by the time the caller is
released. The conversation stays unavailable to another turn through that
cleanup, because it is not free until the previous turn's work is over. A
failure while the turn unwinds stays an authoritative teardown failure, and the
conversation closing ends the wait too — then there is nothing left to do the
joining.

**P2 — nothing tested the native conversation an established history names.**
The previous evidence compared the logical host id and the agent, which are
different contracts. A conversation's established native identity is now read
from the canonical turn records themselves: a completed Prompt retains the
exact conversation the provider said that turn ran in, so the journal already
holds the establishment. That is what lets a turn accepted before anything
acknowledged a mapping be reconciled to *that* identity rather than leaving the
conversation looking unestablished and open to a different one — and it needs no
mapping record of its own, so there is no second account to disagree with the
first.

A provider that resolves a different native conversation is refused before the
turn starts, where refusing costs nothing. One that names a different
conversation only after the turn has run is refused too, and the established
identity stands rather than being replaced. A history whose own turns name two
different conversations cannot be opened at all: there is no rule for choosing
between them that is not a guess.

CV18–CV19 are the held-teardown regressions, CV20–CV24 the identity ones. Each
was checked against its own negative control: removing the caller's join fails
CV18 alone, removing the pre-turn check fails CV21 alone, removing the
late-assertion check fails CV23 alone, and removing the two-identity refusal
fails CV24 alone.
The provider's commit order is the contract: the adapter accepts the session,
ACPX's own record is promoted to assert an identity, the host's mapping is
acknowledged second, and the session becomes established last. The window
between the first two is the one a chat has to recover from — the agent is in a
conversation nobody wrote down — and no stub provider can show it, because a
stub has no acknowledgement to interrupt.

Tier HC drives the real provider for that: `installAgentComponents` with
`createAcpxProvider` as the root provider, exactly as `installAgentProviderStack`
assembles it, with ACPX's runtime replaced by the scriptable fake this package
already drives it with and the host's `established` hook as the one seam a case
refuses. `provider.test.ts` SM9 says that window leaves one canonical
assertion; these rows ask what the conversation can still do after it.

HC1 is the window itself. The interrupted turn never started, so it retained no
identity, and what the next turn joins is decided by the provider's own record:
reopening reconciles to that same assertion, commits it, creates nothing in its
place and does not resend the interrupted prompt. HC2 is the same interruption
once a completed turn has retained an identity, where the journal is the account
that survives — the refused turn is its own recorded fact and the chat is still
the conversation its first turn established. HC3 is what that identity is for: a
provider answering with another conversation has that turn's outcome refused,
and a history whose own turns name two of them cannot be opened.

CV22 claimed the first of these and did not test it; it is renamed to what it
proves — reopening joins the identity its retained turns name, without resending
them — and points at HC1 for the window before a turn has retained one. The
architecture section and §A conversation a trusted host holds open now state the
established-identity rule and the cancellation join the revision introduced.

Each row was checked against its own control: leaving the acknowledgement alone
fails HC1 alone, and then HC2 alone; letting the provider answer with the same
conversation fails HC3 alone. Against the parent conversation source HC3 fails
and HC1/HC2 pass, which is the expected shape — their reconciliation is the
provider's work, and HC3 is the row the Core change owns.
A root could not write an entry draft, which is the one thing the following CLI
consumer needs it for. The accepted program captures entry source with
`<Let select="code">` and hands the captured string to a paired control, and
admission refused it twice over: `{code}` is a read, and so is the `{names}`
inside the captured passive fence. I had written that refusal in as GR17 on the
premise that a root inherits nothing and therefore needs the fragment's rule.

The premise was wrong in both directions, which is why the fix is two things
rather than one. A root *did* inherit the admitting document's bindings — a
root evaluated inside a host component rendered `{secret}` as that document's
value — so the refusal was load-bearing for disclosure and relaxing it alone
would have let a reply read a host binding by naming it, and let a captured
draft carry one into an entry. But a root driven from a preparation had no
environment to bind into either, so `<Let>` had nothing to write to.

So a root is now given an environment of its own — empty bindings, empty meta,
empty props — installed on the expansion scope, and only then does its
interpolation become admissible. The only names it resolves are the ones it
bound itself; every other brace renders exactly as written, which is what lets
an entry's own braces and nested fences reach the control as the text they are.

A fragment is deliberately untouched. It is admitted *into* a document and
expands against that document's environment, which is why its rule is that it
may not read a binding through interpolation at all. Released fragment
behaviour and records are unchanged: 28 tests, 289 steps, as before.

GR17 is inverted to what the contract actually says — a root reads a binding it
bound itself. GR18–GR21 are the accepted flow: a literal fill reaching the
paired receiver exactly once, a captured passive fence, the accepted
Each/greeting source with its entry-only references intact and none of it run,
and a nested passive fence. GR22 is what the environment is for: a root does
not read the bindings of the document that evaluated it, in rendered chat or in
a filled draft. The GR9 row asserting that a non-existent binding refuses a
root is gone; it is literal text now.

Each half is necessary and was checked by removing it: restoring the blanket
refusal fails GR17 and GR19–GR22, and removing the environment fails the same
rows — the first because admission refuses the capture, the second because
`<Let>` has nowhere to bind. The reviewer's archived passive-draft probe passes
unchanged, as does the cancellation probe.

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