Skip to content

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

Merged
taras merged 2 commits into
mainfrom
agent/issue-883-pr1
Oct 17, 2026
Merged

taras merged 2 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.

Delivered onto current main (2026-10-16)

This branch is the replacement for the original stack, rebased onto main
fd8ae521 (#927). Head dbc0fc92, two signed commits, base main.

Its old base was 986948ea. Main has since merged four #181 PRs (#902, #925,
#926, #927), which touch REPL program and session, the runtime entrypoints and
the repository and issue composition. This PR overlaps that range on two
documentation files only, and range-diff against the reviewed source reports
both commits identical.

Evidence on this exact head — the five frozen Core/ACP selections:

Selection Result
host-generated-root, generated-xmd-root, generated-xmd-preview, agent-conversation 31 passed (141 steps)
generated-xmd, generated-composition, generated-selection, generated-owner-metadata, evaluation-profile, evaluate-provider-lifetime 34 passed (266 steps)
execution-protocol, document-output-api, syntax-component, syntax-api, syntax-loaded-copy 14 passed (143 steps)
agent-components, agent-function-components, agent-session-launch, acp host-conversation, acp loaded-copy 24 passed (160 steps)
workflow generated-observations, evaluate-component, evaluate-workflow 8 passed (33 steps)

111 tests, 743 steps, zero failures. deno task check, deno task lint and
deno task validate:docs all exit 0, and deno task check:jsr ends
Success Dry run complete.

PR #921 is stacked on this branch and merges after it.

taras added 2 commits October 16, 2026 19:34
… PR 1 of 2)

Replacement PR1, re-implemented on main's captured-selection and live-
authorization model rather than rebased. #890's four commits and the Core-only
bound-evaluation amendment from PR2 are redistributed here; the provider
configuration, direct-provider and Sidekick work stays with PR2.

A trusted host can now evaluate one complete Agent-generated **root**: the same
admission, the same durable record and the same pinned resolution a fragment
gets, in the one context that supplies a top-level <Output>. It answers with
the canonical rendering and whether the root declared a region at all, so a
host can tell an empty region from no region.

What main contributes, and what changed to meet it:

- The record's marker carries the root/fragment distinction. Main threaded one
  RECORD_VERSION = 4 through policyRecord, readPolicy, admitSource and
  readAdmission; that is now FRAGMENT_RECORD = 4 and ROOT_RECORD = 5, threaded
  as the marker an evaluation expects at the result and inside the policy. A
  marker-4 record cannot authorize a root and a marker-5 record cannot
  authorize a fragment, even for identical source and selected rows. Marker 4's
  closed shape is byte-identical to main's, so no context member, defaulted
  field or conversion exists and every fragment record stays readable.
- The admission language travels with the walk. Lexical gained `language` and
  `topLevel`; <Output> left NO_GENERATED_CONTEXT and admits only in a root at
  the literal top level, validated by body-structure's own outputPropsViolation
  so <Output> is one contract. <Content> and <Return> stay barred in both.
  Every construct that encloses its children clears topLevel once.
- Interpolation stays the fragment's refusal. A root begins with its own empty
  environment, so the only names it resolves are the ones it bound itself; a
  fragment expands against the document that admitted it and may not read a
  binding at all.
- The selection is main's captured one, resolved per evaluation. selectedTables
  moved from Evaluate.ts to evaluation-profile.ts with the two refusals it
  raises, so the authored site and the root go through one machinery and
  neither can admit a row the other would not. The root's binding carries that
  selection as a closure, the minted <Syntax /> implementation, the guarded
  route and the captured reference — Core-private arguments, no Context
  authority, callback or token. Canonical <Syntax /> joins both the table a
  root may write and the entries it is described with, through
  admittedSymbols, with core's own documentation provenance.
- An absent profile stays absent. A host that selected no generated components,
  or an execution that minted no canonical <Syntax />, leaves the bound
  operation refusing when it is called rather than failing the execution at
  startup — most executions never evaluate a root.
- Main's own capture is preserved. The conflicting hunks in execute.ts kept
  main's describe(includes)/useRootSyntaxReference rather than #883's
  rootSyntaxReference, and the root narrows from that reference.
- scanner.ts reports unfinished constructs, resolving its one conflict by
  keeping #883's separate pos-at-end branch and main's comment cleanup.

generated-root-owner.ts allocates each evaluation a durable sequence past every
one the history holds, partitions it from the document's continuation, and makes
an authoritative failure the execution's own outcome. conversation.ts is taken
at the amendment commit, which has the evaluation mailbox, readiness bridge and
sequence partition and none of PR2's provider work.

Evidence — the five frozen commands, all green on this tree:

  1  host-generated-root, generated-xmd-root, generated-xmd-preview,
     agent-conversation          28 tests / 129 steps
  2  generated-xmd, composition, selection, owner-metadata,
     evaluation-profile, evaluate-provider-lifetime   34 / 266
  3  execution-protocol, document-output-api, syntax-component,
     syntax-api, syntax-loaded-copy                   14 / 143
  4  agent-components, agent-function-components, agent-session-launch,
     acp host-conversation, acp loaded-copy           24 / 160
  5  workflow generated-observations, cli evaluate-component,
     cli evaluate-workflow                             8 / 33

Total 108 tests / 731 steps, zero failures. `deno task check` exits 0. Commands
2, 3 and 5 were measured on pristine main first and come back at exactly the
same counts, so main's fragment, selection, Syntax and workflow behaviour is
untouched.

The four ported Core tiers and the ACP tier had to be adapted from passing
tables per call to declaring a captured selection — the handoff's "adapt
discovered test setup while preserving their assertions". Every assertion is
the accepted one except GR11, which asserted the removed `context` member and
now asserts the markers it was replaced by.

Refs #883
…883)

Two live probes in the review of fdcef428 exposed contract violations the five
frozen commands did not cover, and the public request, two new casts and the
IN1/IN4 evidence needed correction with them.

A captured canonical <Syntax /> is retained once. Core appended its own copy of
the protected implementation to every root's table and description. An ordinary
host selection may name `Syntax` — main's does — and main's capture then holds
that same implementation behind this execution's own routed guard, so the two
copies reached admission as one name under two definitions and every valid root
of such a host refused. The whole row is now decided where the capture is known,
in the selection canonical execution hands over: a row this execution's route
answered for is kept as it stands, and a definition the route did not build is a
different implementation under a protected name and stays the collision it was.
Recognized by that routed provenance rather than by a name or a URL, so a
same-name replacement is not accepted as canonical. GeneratedRootBinding has no
definition of its own any more, because the selection is the one source for both
the table a root writes and the vocabulary it reads about.

An availability refusal is the call's answer. Two refusals left before the
ordinary request boundary: the absent-profile operation threw HostExecutionError,
and the selection was resolved ahead of `try`, so a class the host installed no
table for threw ComponentInvocationError out of the evaluator. The generated-root
owner reads a raised error as an authoritative failure of the run, so a host
could catch the method's refusal, release its ordinary held document, and still
be handed a failed canonical completion. Both now answer `Err` — the absent one
in the classification a closed and a busy execution already refuse with, the
unavailable class through the evaluator's own Result boundary. Nothing moves for
durability, Files, secret-policy or teardown failures, which keep their
authority.

core/host exports the decided names. The settled public root request is
GeneratedXmdRequest with its four members, and GeneratedEffectClass beside it
because the request names it. The internal table-taking fragment request held
that name and is now GeneratedFragmentRequest; the unapproved
GeneratedXmdRootRequest is gone rather than kept as a second public name. The
host root tier takes the request and the class from the entrypoint, so the
corpus checks the published boundary.

The two new formsOf array casts are gone, replaced by a typed form list and
ordinary filtering.

IN1 is established in both directions, at the seam that can establish it. The
public owner gives each evaluation a sequence of its own, which is also what
makes one kind's record unreachable from the other through it — so the reported
gap was real and the old GR15c could pass on a fresh language refusal. Tier GRM
drives the Core-private bound evaluator under a chosen coroutine, with source
legal in both kinds and byte-identical selected rows, and reads back the
expected-marker refusal by name with zero invoked effects; a matched marker
resumes and performs, on both sides. The collision guard and an older build's
marker-2 record have rows of their own.

IN4's old-root journal case is E6b: a hand-built journal in the accepted
pre-integration writer's shape (01abab2 — marker 2 at the result and in the
policy, the `context: "root"` member, four-member structural identities) holding
one completed root sequence with an acknowledged effect and one interrupted
suffix. Opening repeats neither, the old bytes are unchanged, the main
continuation takes no admission of theirs, and a new explicit root allocates
generated_root:3 and writes marker 5.

Also fixed in passing, because fdcef428 introduced them: fourteen error-level
banned-word lint findings and seven unformatted files. `deno task lint` now
exits 0, as it does on main.

Refs #883
@taras
taras force-pushed the agent/issue-883-pr1 branch from 79d404d to dbc0fc9 Compare October 17, 2026 00:07
@github-actions

Copy link
Copy Markdown

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

23 files, +8849 / -241

Scope

🔴 PR has 9090 lines changed. Split into focused PRs.

🟡 9090 lines changed. PRs under 400 receive more thorough review.

🟡 23 files changed. Are all changes related?

Structural

Oxlint structural signals:

  • no-unused-vars ×29: packages/core/src/generated-root-owner.ts, packages/core/src/components/Evaluate.ts, packages/core/src/agent/function-components.ts (+2)
  • no-unnecessary-type-assertion ×22: packages/core/src/scanner.ts, packages/core/src/execute.ts
  • no-empty-function ×1: packages/core/src/execute.ts

Slop

✅ Slop indicators look low.

Static Analysis

Oxlint: 88 diagnostics across 8 files (13 rules)
Density: 0.010 violations/added-line

no-unused-vars (29): packages/core/src/generated-root-owner.ts, packages/core/src/components/Evaluate.ts, packages/core/src/agent/function-components.ts (+2)
no-unnecessary-type-assertion (22): packages/core/src/scanner.ts, packages/core/src/execute.ts
no-shadow (13): packages/core/src/evaluation-profile.ts, packages/core/src/execute.ts, packages/core/src/agent/conversation.ts (+1)
no-unsafe-type-assertion (6): packages/core/src/scanner.ts, packages/core/src/execute.ts
unbound-method (5): packages/core/src/execute.ts
no-floating-promises (3): packages/core/src/agent/conversation.ts, packages/core/src/generated-root-owner.ts, packages/core/src/execute.ts
no-base-to-string (2): packages/core/src/agent/function-components.ts, packages/core/src/evaluation-profile.ts
consistent-return (2): packages/core/src/execute.ts
no-duplicate-type-constituents (2): packages/core/src/execute.ts
no-array-sort (1): packages/core/src/execute.ts
no-empty-function (1): packages/core/src/execute.ts
consistent-function-scoping (1): packages/core/src/execute.ts
no-unnecessary-type-conversion (1): packages/core/src/agent/function-components.ts

Correctness

No extraneous code patterns detected.

@taras
taras merged commit 98cd350 into main Oct 17, 2026
44 checks passed
@taras
taras deleted the agent/issue-883-pr1 branch October 17, 2026 01:01
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