Draft: portable-core boundary for external Agent hosts.
DECISION.mdis a declarative decision contract. JevLoop is the reference implementation, not the only intended consumer.
A host reads a document as a set of decision blocks. The portable core is:
block id
kind
position
questions
options and criteria
policy rules
frame fields and budgets
explicit exclusions and reasons
generator instructions
A host must reject a document when:
- a block, question, option, position, action, or policy predicate is malformed;
- a frame field has no bound or reason;
- an excluded field has no written reason;
- a frame field or exclusion cannot be mapped to host state;
- a policy action has no registered host handler;
- a position does not handle the policy action.
A host must not silently ignore an unknown declaration.
The file describes what a decision asks and which contract governs it. The host supplies the runtime-specific meanings.
These concepts belong in the file format:
id
kind: choice | noul | score | mixed | rule
when: <position> —— <human-readable purpose>
dynamic: <provider>(ctx: <cell>, …) → <output> —— <reason> (optional)
ask
options
policy
frame:
+ <field> <bound> [<projection>] —— <reason>
- <field> —— <reason>
generator
when: has two parts, and the parser splits them rather than leaving consumers
to reparse prose: the first token is the position identifier, and the text after
—— is the purpose for humans and audit tools. Both are on the parsed block as
position and purpose. A position is required — a block without one is
refused, because an action nothing dispatches is worse than a parse error.
dynamic: also declares four things, not one:
provider the name of a builder the host must have registered
reads which contract-level state cells it reads, named one by one
output what shape it returns — currently always `candidates`
why why the candidates must be rebuilt every step
The provider must be written <name>(ctx: <cell>, …). Naming the context alone is
not enough: candidates are computed from state, so which cells were read is
what a host needs to know — miss one and the candidate set silently loses a whole
class, with nothing to report it. output is a closed vocabulary with exactly one
member; adding a second is a deliberate change that also needs a host handling
branch.
The host must declare the same cells for the same provider, and the two must match exactly. Both directions are reported, because the two failures differ: the file naming a cell the host does not implement means the candidates are built without an input they claim to use; the host reading a cell the file does not name means the candidates move for a reason nobody wrote down.
Naming matters here. pick_input's candidates are built by fileOptions, which
returns a criteria map — not by unreadFiles, the helper it calls internally that
returns a bare array of names. The declaration names the builder, because
→ candidates has to mean one thing.
when:/dynamic: syntax lives in src/decision-syntax.ts; validation against
POSITIONS and the policy lives in src/decisiondoc.ts.
Each host must register:
- State cells: the names and types available to frame declarations;
- Projection providers: implementations for names such as
earlierMaybe; - Dynamic candidate providers: implementations for the providers named by
dynamic:declarations, keyed by provider name, matching the declared output shape, and declaring the same input cells the file names; - Position handlers: the actions that each position can execute;
- Action handlers: the behavior of
use_tool,call,ask_human,finish,deliver, and any host-specific actions; - Evidence providers: the facts used by completion and delivery checks;
- Event sink: a record containing the decision id, position, frame digest, request digest, answer, action, policy, and evidence.
The host adapter is the only place where these names acquire runtime behavior.
A portable file must not assume JevLoop's AgentCtx, local filesystem tools, or
JevLoop's loop branches.
A frame field is only as trustworthy as the cell it reads. Cells carrying bytes returned by a tool call — a file body, a directory listing, a step record — are untrusted input, the same way the tool name the model returns is. A decision model does not follow instructions, so the classic injection attack does not apply the way it does to an LLM; what applies is displacement, untrusted text moving a probability across a threshold.
So a conforming adapter should:
- derive the trust of each frame field from its source cell, not from a per-field flag anyone can forget to set;
- mark untrusted values so the decision model can discount them by provenance;
- keep the marker inside the frame digest, so a replay can tell whether a frame was marked at all;
- record which fields were untrusted on the frame artifact, so a trace can be audited after the fact;
- not silently rewrite the bytes: stripping imperative-looking sentences is not obviously correct for a classifier, since a log that genuinely says "the test failed" is both evidence and an imperative.
The reference implementation wraps untrusted string channels. List-valued
channels are unlabelled and that is a known gap, recorded in TODO.md §7 with
the reason: putting a label inside a value breaks consumers that read it as data.
DECISION.md declares which actions a position may produce (POSITIONS).
That is only half of what a host needs: an action name says nothing about what
happens after the decision. Those meanings used to live only in JevLoop's loop,
which is precisely what a portable contract is supposed to remove — a second
implementer would have to read our control flow and copy it.
So each action declares its meaning. A host can write one switch over next
and be done:
| action | next |
ends the loop | more model calls | needs evidence | retries |
|---|---|---|---|---|---|
use_tool |
pick_tool |
no | yes | no | 0 |
answer |
generate |
yes | yes | no | 0 |
call |
run_tool |
no | yes | no | 0 |
use |
run_tool |
no | yes | no | 0 |
auto |
run_tool |
no | yes | no | 0 |
auto_audit |
run_tool |
no | yes | no | 0 |
ask_human |
human |
on refusal | yes | no | 0 |
continue |
next_step |
no | yes | no | 0 |
stop |
generate |
yes | yes | no | 0 |
finish |
generate |
yes | yes | no | 0 |
keep_going |
next_step |
no | yes | no | 0 |
deliver |
end |
yes | no | yes | 0 |
revise |
regenerate |
yes | yes | yes | 1 |
escalate |
end |
yes | yes | no | 0 |
Three things this table exists to say out loud, because guessing them wrong is easy:
- "Ends the loop" is not "the run is over."
answer,stop,finishandescalateall stop the tool loop, and the run then generates a final answer anyway — including afterstop, where that answer is an honest report of failure. Onlydeliverends the run. Collapsing these into oneterminalflag would produce a host that silently drops the last generation. - Only the delivery gate needs evidence.
deliverandreviseare the two actions whose correctness depends on checking a draft against evidence; that is why the gate exists. - Only
reviseretries, and exactly once. Retrying costs money, so it is declared rather than implied.
The table is a description, not an executor: JevLoop's loop still implements
these semantics with branches, because what an action means at a position is
known only to that position's code. So the table is checked against the
runtime — tests/action-semantics.test.ts drives the loop once per action and
asserts the observed behaviour matches. A semantics table nobody checks is just
another declaration that can lie.
A frame: field can name a projection (+ task 400 earlierMaybe). A Markdown
file cannot hold a function, so the host supplies the implementation — but until
this declaration existed the host only got the name. "How does lastOrNone
differ from resultMaybe?" was answerable only by reading JevLoop's code, and the
difference is part of the criterion: one sends (还没有做过任何动作) when there
is no result, the other sends an empty string, and a classifier cannot tell "no
result yet" from "the result was empty" unless the projection says so.
Each projection therefore declares what it reads, what shape it returns, and what it promises on a missing value:
| projection | reads | returns | promise when the value is missing |
|---|---|---|---|
earlierMaybe |
earlier |
string | empty string |
filesMaybe |
files |
list | empty list |
readMaybe |
readFiles |
list | empty list |
resultMaybe |
lastResult |
string | empty string |
draftMaybe |
draft |
string | empty string |
toolOrEmpty |
lastTool |
string | empty string |
lastOrNone |
lastResult |
string | (还没有做过任何动作) — not empty |
toolOrUnknown |
lastTool |
string | unknown — not empty |
describeDone |
history |
string | a one-line summary, never a raw array |
lastInput |
history |
string | — |
readCount |
readFiles |
count | 0 |
recentSteps |
history |
list | empty list |
writeEvidence |
history |
string | — |
localToolRisk |
lastTool |
count | omitted entirely (absent), never 0 |
Two of those rows are the reason the table is worth publishing:
- The non-empty fallbacks are criteria, not cosmetics.
lastOrNoneandtoolOrUnknownexist precisely because an empty string would let a judgement confuse absent with empty, or unknown tool with no tool. localToolRiskreturns no field at all for an unrecognised tool.0means "read-only", so it cannot stand in for "unknown" — the field is dropped and recorded inFrame.absent.
The name list and each from have one source of truth
(src/frame-projections.ts); JevLoop's implementation supplies only function
bodies, and the key type makes a missing or extra implementation a compile error.
tests/frame-projections.test.ts additionally checks that every declared from is
a real state cell, that every declared returns matches what the implementation
actually returns, that no projection is declared without being used, and that the
external host fixture's capability list matches the declaration exactly.
Every decision a host makes should be recordable in a form that still makes sense
somewhere else. JevLoop writes one decision event per node into the session log;
the portable part of it is the record, format decision-record/v1:
| field | why replay needs it |
|---|---|
schema |
an unrecognised version must be refused, not guessed at |
node |
goes into the frame digest — the same body from two nodes is not the same frame |
state |
the frame that was actually sent |
sentFrameDigest |
the digest of the frame that was sent |
batchIds |
which nodes shared the forward pass; length > 1 means merged |
sentQuestions |
the question set that was sent — for a merged pass, the union |
requestDigest |
sentFrameDigest + sentQuestions |
questions |
what this node was asked (equals sentQuestions when solo) |
answers, action, reason, provider, model |
not verified, but they are how you explain why two runs differed |
frameDigest answers what did it see; requestDigest answers what was it asked.
A choice's candidates are part of the request but not of the frame, so a changed
candidate set moves only the second — a distinction this project got wrong once
(§8.17 in TODO.md).
When two nodes share one forward pass (needsTool + pickTool, every step), the event
carries something subtly different from either node's own view:
state the MERGED frame — identical in both nodes' events
frame.digest that node's OWN frame digest
sentFrameDigest the digest of the merged frame — equal to neither
sentQuestions the merged question set
questions that node's own questions
Recording the digest inputs — sentFrameDigest and sentQuestions — is what makes
a merged decision replayable at all. A verifier given only requestDigest cannot
recompute it, and one that assumes frameDigest(node, state) holds for merged records
reports a false failure on every step.
verifyRecord() runs four checks and returns verified, partial, unverifiable or
mismatch — four outcomes rather than two, because "cannot check" and "checked and
passed" are different answers:
frame-solo solo only: frameDigest(node, state) === frame.digest
sent-frame both: the frame that was sent re-derives from the record
questions-solo solo only: questions === sentQuestions
request both: requestDigest(sentFrameDigest, sentQuestions) === requestDigest
What it does not do, and this is part of the format rather than a gap to be filled:
- It does not verify the frame was correctly derived from the original state. The
raw context is deliberately not recorded — only the bounded frame is. Checking that
would need the original
AgentCtxplus the contract version in force. - It does not verify the judgement was right. That needs an external oracle: test output, file state, exit codes.
- It does not compare answers. Re-asking the same recorded request can legitimately yield a different answer, especially for decisions sitting on their threshold. That is a model-reproducibility question, and a separate one.
npm run replay <session.jsonl> applies this to a real session log, prints each
verdict, and states those three limits as part of its output — reading "replayable" as
"reproducible" or "verified correct" is the easiest mistake to make here, so the command
says otherwise every time it runs.
An adapter is conforming only if it can demonstrate all of the following:
- It parses the same file without dropping blocks or fields;
- It reports every parse and compile problem with a source location;
- It maps every
+field to exactly one state cell or projection; - It records every
-field and its reason; - It rejects an unknown projection instead of treating it as raw state;
- It rejects an unhandled action instead of logging and continuing;
- It evaluates policy rules in document order with an explicit fallback;
- It can replay one decision from the recorded frame and request;
- It emits a conformance report for both a valid file and deliberately invalid files.
The minimum integration test should run one host loop through:
needs_tool → pick_tool → step_ok → is_done
The test does not need a language model. A deterministic mock decision provider is sufficient; the purpose is to prove that the host consumes the contract and executes its actions.
The reference JevLoop loop currently has seven named decision blocks and six closed positions. That is a property of the reference host, not a limit that a portable Decision contract should impose.
A different host may declare ten, thirty, or more decision nodes. For example, a coding host might add:
classify_issue
select_repository
identify_owner
choose_search_strategy
check_dependency_change
plan_patch
select_test_scope
interpret_test_failure
check_scope
request_review
prepare_candidate_patch
This is supported only if the host supplies the corresponding adapter pieces. Adding a block to the file does not automatically add behavior to a runtime.
A decision contract declares a node's local contract:
node id
position or host phase
questions and options
frame and exclusions
policy and actions
completion/evidence requirements
The host owns the control-flow graph:
current phase + state + action → next phase
This separation is intentional. A portable file should not assume that every Agent loop is a linear sequence such as:
needs_tool → pick_tool → step_ok → is_done
A host may run a graph, a state machine, a planner/executor loop, or several nested loops. It can invoke the same decision node more than once with a new state version, provided the event records identify the invocation and the resulting transition.
To add a node, an adapter must:
- declare a unique node id;
- assign it to an existing portable position or a namespaced host phase;
- register every projection used by its frame;
- register its dynamic candidates, if any;
- register every policy action and the action's next-state behavior;
- define the states in which the node may be invoked;
- define the evidence required for its outputs;
- add a valid fixture and at least one negative fixture;
- record the node in the adapter capability report.
A node must not be considered supported merely because the parser accepts its id. An uninvoked node, an unhandled action, or a transition with no consumer is an adapter conformance failure.
Hosts should report which level they implement:
Level 0 — static declarations
Parse and inspect any number of blocks.
Level 1 — local decision execution
Execute a block with host state, projections, questions, policy and actions.
Level 2 — graph execution
Define and execute transitions between many nodes, including loops,
branches, retries, escalation and terminal states.
JevLoop's current reference loop is a Level 2 host for its own seven-node graph. It is not yet a generic graph runtime for arbitrary host-defined graphs.
The portable core should keep a small, stable vocabulary. Host-specific nodes, positions and actions should use an explicit namespace rather than silently reusing a core name:
position: host:repository-selection
action: host:request_review
projection: host:dependency-summary
The adapter must either implement a namespaced extension or reject it with a source-located error. It must never reinterpret an unknown extension as a portable action.
The current JevLoop document contains some reference-host details:
FRAME_PROJECTIONSnames are registered by JevLoop code;dynamic:is currently a provider name plus prose, not a portable callable;- policy predicates use JevLoop's closed vocabulary;
- action names are checked against JevLoop's action set;
- positions are checked against JevLoop's loop positions.
These are intentional adapter seams, not yet an industry-wide ABI. A future schema version should still publish:
(nothing on the checklist — what remains is not a format feature)
Four items have left this list. Action semantics (§3) are declared, exported
through jevloop/contract, and checked against the runtime, so a host no longer
has to read JevLoop's loop to learn what stop or deliver does. Projection
declarations (§4) now say what each of the fourteen names reads, returns, and
promises on a missing value, with one source of truth for the names. Dynamic
provider inputs are named cell by cell in the file and checked against the host's
own declaration in both directions; the check is what corrected unreadFiles to
fileOptions. Decision records and replay (§5) now have a versioned portable
format and a verifier, including the two extra fields the merged case needs.
DECISION.md now declares its schema version, and a missing or unrecognised one
is refused — by schemaProblems(), surfaced as the schema layer of
npm run conformance and of jevloop spec. The version is deliberately a
separate layer rather than part of problems, because the two mean different
things: problems says this file is malformed; the schema layer says I cannot
read the semantics this file claims. A file written for a future version is the
second case, not the first.
Positions, dynamic providers, actions, projections, evidence and replay are all declared rather than re-parsed or inferred by each consumer. What remains is not a format feature: a second implementer. Everything here is verified by our tests against our fixtures, and until someone adapts a runtime we did not write, "portable" means "we removed the parts that made it non-portable in our code".
Until the remaining items land, the honest claim is:
DECISION.mdhas a portable declarative core, a declared schema version, and a JevLoop reference adapter.
It is not yet correct to claim that any Agent runtime can execute the file without an adapter.
A host that wants only the checker must not have to install — or satisfy — JevLoop's own runtime. Two entry points:
import { … } from 'jevloop' // the package root: the whole reference runtime
import { … } from 'jevloop/contract' // parse, compile, policy, adapter checks onlyjevloop/contract deliberately excludes decisions.ts, agent.ts and
frame.ts. That exclusion is enforced, not documented: contract.ts is
registered at layer L3 in scripts/check.ts, and a dependency may only point at
a lower-numbered layer, so importing the runtime from it fails npm run check.
Verified end-to-end from a packed tarball installed into an empty project:
jevloop/contract works with DECISION.md removed from the package
jevloop fails without it — the module-load assertion needs our contract
That is the difference between "portable" and "install us first".
The reference adapter currently consists of:
src/decisiondoc.ts parse the document and check its schema
src/decision-syntax.ts the `when:` / `dynamic:` grammar, in one place
src/decision-compile.ts compile questions and policies
src/adapter.ts check a host's declared capabilities
src/contract.ts the portable entry point (jevloop/contract)
src/decisions.ts map frames and JevLoop nodes
scripts/conformance.ts validate the reference contract
scripts/adapter-report.ts check the published capability report against the code
examples/external-host.ts minimal non-loop host with its own graph
The external host example consumes src/contract.ts and nothing else. It proves
the structural boundary, while the adapter checklist records the work still
required for full execution interoperability.
Its published capability report (examples/external-host.capabilities.json) is
checked against the code by npm run adapter-report, and
tests/adapter-report.test.ts runs the same check inside npm test — so an
incomplete or stale report fails the suite rather than being discovered later.
State-cell names are deliberately not listed in the report, because they are
internal to the host.