Skip to content

Commit 9049c69

Browse files
committed
Declare what each frame projection reads and returns, and fix two copies that had drifted
A frame line can name a projection but a Markdown file cannot hold a function, so until now a host got only the name - how lastOrNone differs from resultMaybe was answerable only by reading our code, and that difference is part of the criterion. src/frame-projections.ts now declares, for each of the 14 names, the cell it reads, the shape it returns, and what it promises on a missing value. The names and every from have one source of truth; decisions.ts supplies only function bodies, so a missing or extra implementation is a compile error. This also corrects a real mistake from an earlier commit. canDeliver's evidence is declared in DECISION.md as writeEvidence, and the file wins over the code fallback - but writeEvidence returned an array while I had changed only the fallback, so the declared chars bound still never applied and the delivery gate's input was still unmarked. My test used the fallback, so it passed for the wrong layer. Both copies are now identical and a new guard compiles each node's file spec against its fallback and fails on any field that differs; I verified the guard by breaking it. The projection test caught a phantom too: the published capability report listed a lastResult projection that exists nowhere. A report naming a capability that does not exist is worse than no report.
1 parent 3605444 commit 9049c69

13 files changed

Lines changed: 519 additions & 49 deletions

‎TODO.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ What is missing is the only thing that would turn that into more than a claim: *
169169
The pieces a second implementer would hit first, in the order we expect them to bite:
170170

171171
- [x] **Action semantics.** *(2026-09-28. `src/action-semantics.ts` declares, for each of the 14 actions, the branch a host takes (`next`), whether it ends the tool loop, whether the run still has model calls afterwards, whether it needs evidence, and how many retries it allows. Exported through `jevloop/contract`; the table is in `docs/DECISION-CONTRACT.md` §3. **Checked against the runtime, not against itself** — `tests/action-semantics.test.ts` drives the loop once per action and asserts the observed behaviour, which caught two wrong assumptions while writing it. The field split that mattered: `stop`, `finish`, `answer` and `escalate` all end the **tool loop** and the run then generates anyway — a single `terminal` flag would have made a second implementer drop that generation.)*
172-
- [ ] **Projection capability declarations.** A host must register `earlierMaybe`, `toolOrEmpty` and thirteen more; the only description of what each one means is our implementation. Write down what each projection reads and returns, or let the file declare it.
172+
- [x] **Projection capability declarations.** *(2026-09-28. `src/frame-projections.ts` declares, for each of the 14 names, which state cell it reads, what shape it returns, and **what it promises on a missing value** — that last column is the point: `lastOrNone` sends `(还没有做过任何动作)` where `resultMaybe` sends an empty string, and the difference is part of the criterion, not cosmetics. The name list and every `from` have one source of truth; `decisions.ts` supplies only function bodies, so a missing or extra implementation is a compile error. `tests/frame-projections.test.ts` checks each `from` is a real state cell, each declared `returns` matches what the implementation returns, no projection is declared without being used, and the external host's capability list matches exactly — which is how this caught a phantom: the published report listed a `lastResult` projection that **exists nowhere**, not in the registry and not in `DECISION.md`. A capability report naming a capability that does not exist is worse than no report.)*
173173
- [ ] **Dynamic provider inputs.** `dynamic: toolsFor(ctx) → candidates` says the provider reads the context; it does not say *which state cells* it reads, so a host cannot check the declaration against its own state.
174174
- [ ] **Evidence and replay schema.** `frameDigest` and `requestDigest` are recorded, but there is no portable format for replaying one decision inside another runtime.
175175
- [ ] **One real adopter.** An issue or a partial PR from a host we did not write — three nodes is enough — is worth more here than any further extension of the format.

‎docs/DECISION-CONTRACT.md‎

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,54 @@ runtime** — `tests/action-semantics.test.ts` drives the loop once per action a
172172
asserts the observed behaviour matches. A semantics table nobody checks is just
173173
another declaration that can lie.
174174

175-
## 4. Adapter conformance checklist
175+
## 4. Projection declarations
176+
177+
A `frame:` field can name a projection (`+ task 400 earlierMaybe`). A Markdown
178+
file cannot hold a function, so the host supplies the implementation — but until
179+
this declaration existed the host only got the **name**. "How does `lastOrNone`
180+
differ from `resultMaybe`?" was answerable only by reading JevLoop's code, and the
181+
difference *is part of the criterion*: one sends `(还没有做过任何动作)` when there
182+
is no result, the other sends an empty string, and a classifier cannot tell "no
183+
result yet" from "the result was empty" unless the projection says so.
184+
185+
Each projection therefore declares what it reads, what shape it returns, and what
186+
it promises on a missing value:
187+
188+
| projection | reads | returns | promise when the value is missing |
189+
|---|---|---|---|
190+
| `earlierMaybe` | `earlier` | string | empty string |
191+
| `filesMaybe` | `files` | list | empty list |
192+
| `readMaybe` | `readFiles` | list | empty list |
193+
| `resultMaybe` | `lastResult` | string | empty string |
194+
| `draftMaybe` | `draft` | string | empty string |
195+
| `toolOrEmpty` | `lastTool` | string | empty string |
196+
| `lastOrNone` | `lastResult` | string | **`(还没有做过任何动作)` — not empty** |
197+
| `toolOrUnknown` | `lastTool` | string | **`unknown` — not empty** |
198+
| `describeDone` | `history` | string | a one-line summary, never a raw array |
199+
| `lastInput` | `history` | string | — |
200+
| `readCount` | `readFiles` | count | 0 |
201+
| `recentSteps` | `history` | list | empty list |
202+
| `writeEvidence` | `history` | string | — |
203+
| `localToolRisk` | `lastTool` | count | **omitted entirely** (`absent`), never 0 |
204+
205+
Two of those rows are the reason the table is worth publishing:
206+
207+
- **The non-empty fallbacks are criteria, not cosmetics.** `lastOrNone` and
208+
`toolOrUnknown` exist precisely because an empty string would let a judgement
209+
confuse *absent* with *empty*, or *unknown tool* with *no tool*.
210+
- **`localToolRisk` returns no field at all for an unrecognised tool.** `0` means
211+
"read-only", so it cannot stand in for "unknown" — the field is dropped and
212+
recorded in `Frame.absent`.
213+
214+
The name list and each `from` have **one source of truth**
215+
(`src/frame-projections.ts`); JevLoop's implementation supplies only function
216+
bodies, and the key type makes a missing or extra implementation a compile error.
217+
`tests/frame-projections.test.ts` additionally checks that every declared `from` is
218+
a real state cell, that every declared `returns` matches what the implementation
219+
actually returns, that no projection is declared without being used, and that the
220+
external host fixture's capability list matches the declaration exactly.
221+
222+
## 5. Adapter conformance checklist
176223

177224
An adapter is conforming only if it can demonstrate all of the following:
178225

@@ -196,7 +243,7 @@ The test does not need a language model. A deterministic mock decision provider
196243
is sufficient; the purpose is to prove that the host consumes the contract and
197244
executes its actions.
198245

199-
## 5. Extending beyond the reference loop
246+
## 6. Extending beyond the reference loop
200247

201248
The reference JevLoop loop currently has seven named decision blocks and six
202249
closed positions. That is a property of the reference host, not a limit that a
@@ -306,7 +353,7 @@ The adapter must either implement a namespaced extension or reject it with a
306353
source-located error. It must never reinterpret an unknown extension as a
307354
portable action.
308355

309-
## 6. Compatibility boundary
356+
## 7. Compatibility boundary
310357

311358
The current JevLoop document contains some reference-host details:
312359

@@ -320,15 +367,16 @@ These are intentional adapter seams, not yet an industry-wide ABI. A future
320367
schema version should still publish:
321368

322369
```text
323-
projection capability declarations
324370
dynamic provider input declarations (which state cells it reads)
325371
evidence schema
326372
event and replay schema
327373
```
328374

329-
Action semantics are **no longer on this list** — see §3. They are declared,
330-
exported through `jevloop/contract`, and checked against the runtime, so a host no
331-
longer has to read JevLoop's loop to learn what `stop` or `deliver` does.
375+
Two items have left this list. **Action semantics** (§3) are declared, exported
376+
through `jevloop/contract`, and checked against the runtime, so a host no longer
377+
has to read JevLoop's loop to learn what `stop` or `deliver` does. **Projection
378+
declarations** (§4) now say what each of the fourteen names reads, returns, and
379+
promises on a missing value, with one source of truth for the names.
332380

333381
`DECISION.md` now declares its schema version, and a missing or unrecognised one
334382
is refused — by `schemaProblems()`, surfaced as the `schema` layer of
@@ -338,8 +386,8 @@ things: `problems` says *this file is malformed*; the schema layer says *I canno
338386
read the semantics this file claims*. A file written for a future version is the
339387
second case, not the first.
340388

341-
Positions, dynamic providers and actions are now structured rather than
342-
re-parsed or inferred by each consumer. What is still missing: a `dynamic:`
389+
Positions, dynamic providers, actions and projections are now structured rather
390+
than re-parsed or inferred by each consumer. What is still missing: a `dynamic:`
343391
declaration says only that the provider takes the context, not which state cells
344392
it reads.
345393

@@ -351,7 +399,7 @@ Until the remaining items land, the honest claim is:
351399
It is not yet correct to claim that any Agent runtime can execute the file
352400
without an adapter.
353401

354-
## 7. Consuming the contract without the runtime
402+
## 8. Consuming the contract without the runtime
355403

356404
A host that wants only the checker must not have to install — or satisfy —
357405
JevLoop's own runtime. Two entry points:
@@ -375,7 +423,7 @@ jevloop fails without it — the module-load assertion needs our cont
375423

376424
That is the difference between "portable" and "install us first".
377425

378-
## 8. Reference implementation
426+
## 9. Reference implementation
379427

380428
The reference adapter currently consists of:
381429

‎examples/external-host.capabilities.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
"readMaybe",
3535
"lastOrNone",
3636
"toolOrEmpty",
37-
"lastResult",
3837
"lastInput",
3938
"toolOrUnknown",
4039
"localToolRisk",

‎examples/external-host.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ const PROJECTIONS: Record<string, Projection> = {
6666
readMaybe: (state) => state.already_read,
6767
lastOrNone: (state) => state.last,
6868
toolOrEmpty: (state) => state.tool,
69-
lastResult: (state) => state.output,
7069
lastInput: (state) => state.input,
7170
toolOrUnknown: (state) => state.tool || 'unknown',
7271
localToolRisk: () => 'host-defined',

‎scripts/check.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ const LAYER: Record<string, number> = {
262262
'vocab-decision': 0,
263263
// 动作语义:只 import `vocab-decision` 的 `Action` 类型,零运行时依赖
264264
'action-semantics': 0,
265+
// 帧投影的能力声明:零 import 的纯数据,可移植入口要 import 它
266+
'frame-projections': 0,
265267
// Adapter capability checks only consume the parsed contract shape; they do not
266268
// know JevLoop's state, tools, or loop, so they stay at the portable bottom.
267269
adapter: 0,

‎src/contract.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export { CURRENT_SCHEMA, SCHEMA_KEY, SUPPORTED_SCHEMAS, DYNAMIC_OUTPUTS, parseDy
4141

4242
// ── 形状:消费方要能命名它拿到的每一个东西 ──────────────────
4343
export { ANY_POSITION_ACTIONS, KINDS, POSITIONS } from './decision-shape.ts'
44+
// 帧投影的能力声明:宿主必须实现这几个名字,这里说清各自读哪一格、返回什么
45+
export { PROJECTIONS, PROJECTION_NAMES, isProjectionName } from './frame-projections.ts'
46+
export type { ProjectionName, ProjectionReturns, ProjectionSpec } from './frame-projections.ts'
47+
4448
// 动作语义:外部实现者照 `next` 写 switch 就够了,不必读我们的循环
4549
export { ACTION_SEMANTICS } from './action-semantics.ts'
4650
export type { ActionSemantics } from './action-semantics.ts'

‎src/decisions.ts‎

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { compilePolicy, compileQuestions } from './decision-compile.ts'
6969
import { toolsFor, fileOptions, pickInputInstructions, describeDone, lastInput } from './frame.ts'
7070
import type { AgentCtx, FrameSpec } from './frame.ts'
7171
import { compileFrame, frameSpecViolations, AGENT_CTX_KEYS } from './frame.ts'
72+
import { PROJECTIONS, PROJECTION_NAMES, isProjectionName, type ProjectionName } from './frame-projections.ts'
7273
export type { AgentCtx, StepRecord } from './frame.ts'
7374

7475
// ═══════════════════════════════════════════════════════════
@@ -116,52 +117,80 @@ const DONE_LINE_MAX_CHARS = 300
116117
// ★ 为什么需要这么多:`AgentCtx` 的格是可选的,而**每一栏缺省时发什么**是一个
117118
// 决定(空串?空表?省略这一栏?)。原来那些决定散在 `?? ''` 里。
118119
// ═══════════════════════════════════════════════════════════
119-
const FRAME_PROJECTIONS: Record<string, { from: keyof AgentCtx; fn: (ctx: AgentCtx) => unknown }> = {
120+
/*
121+
── 投影的**实现**(名字、来源格、返回形状在 `frame-projections.ts`)──────
122+
123+
★ 这里只写函数体。键名由 `Record<ProjectionName, …>` 强制和声明**一一对应**:
124+
125+
声明里加了名字而这里没写实现 → 编译不过(缺键)
126+
这里写了实现而声明里没有 → 编译不过(多余的键)
127+
128+
而 `from` **直接从声明里取**,所以两份不可能分叉 —— 以前 `from` 是手抄的,
129+
抄错一格会让「这个判定看了什么」这件事静默说谎,而 `frameSpecViolations`
130+
正是靠 `from` 工作的。
131+
*/
132+
const PROJECTION_IMPL: Record<ProjectionName, (ctx: AgentCtx) => unknown> = {
120133
// ── 空值兜底:缺省时发空串 / 空表(原来写作 `?? ''` / `?? []`)──
121-
earlierMaybe: { from: 'earlier', fn: (ctx) => ctx.earlier ?? '' },
122-
filesMaybe: { from: 'files', fn: (ctx) => ctx.files ?? [] },
123-
readMaybe: { from: 'readFiles', fn: (ctx) => ctx.readFiles ?? [] },
124-
resultMaybe: { from: 'lastResult', fn: (ctx) => ctx.lastResult ?? '' },
125-
draftMaybe: { from: 'draft', fn: (ctx) => ctx.draft ?? '' },
126-
toolOrEmpty: { from: 'lastTool', fn: (ctx) => ctx.lastTool ?? '' },
134+
earlierMaybe: (ctx) => ctx.earlier ?? '',
135+
filesMaybe: (ctx) => ctx.files ?? [],
136+
readMaybe: (ctx) => ctx.readFiles ?? [],
137+
resultMaybe: (ctx) => ctx.lastResult ?? '',
138+
draftMaybe: (ctx) => ctx.draft ?? '',
139+
toolOrEmpty: (ctx) => ctx.lastTool ?? '',
127140
// ── 非空兜底(缺省时那句默认话本身就是判据的一部分)──
128-
lastOrNone: {
129-
from: 'lastResult',
130-
fn: (ctx) => ctx.lastResult ?? '(还没有做过任何动作)',
131-
},
132-
toolOrUnknown: { from: 'lastTool', fn: (ctx) => ctx.lastTool ?? 'unknown' },
141+
lastOrNone: (ctx) => ctx.lastResult ?? '(还没有做过任何动作)',
142+
toolOrUnknown: (ctx) => ctx.lastTool ?? 'unknown',
133143
// ── 派生 ──
134-
describeDone: { from: 'history', fn: (ctx) => describeDone(ctx) },
135-
lastInput: { from: 'history', fn: (ctx) => lastInput(ctx) },
136-
readCount: { from: 'readFiles', fn: (ctx) => (ctx.readFiles ?? []).length },
137-
recentSteps: {
138-
from: 'history',
139-
fn: (ctx) =>
140-
(ctx.history ?? []).slice(-5).map((h) => `${h.tool}(${clip(h.input, 60)}) → ${clip(h.result, 200)}`),
141-
},
142-
writeEvidence: {
143-
from: 'history',
144-
fn: (ctx) =>
145-
(ctx.history ?? []).slice(-3).map((h, i, all) => {
144+
describeDone: (ctx) => describeDone(ctx),
145+
lastInput: (ctx) => lastInput(ctx),
146+
readCount: (ctx) => (ctx.readFiles ?? []).length,
147+
recentSteps: (ctx) =>
148+
(ctx.history ?? []).slice(-5).map((h) => `${h.tool}(${clip(h.input, 60)}) → ${clip(h.result, 200)}`),
149+
/*
150+
★ 返回**字符串**,不是数组。2026-09-28 修正:
151+
152+
这一份是 `DECISION.md` 的 `can_deliver` **真正在用**的(`framed()` 里
153+
文件赢、代码回退)。它以前 `.map()` 之后没有 join,于是运行时拿到数组 ——
154+
两个后果都不是想要的:
155+
156+
· 声明里的 `chars: 600` **从来没有生效过**(`chars` 只管字符串);
157+
· 信任边界(TODO §7)包的是字符串,所以交付闸门的主输入**没被包上**。
158+
159+
而前面那次改动只改了**代码回退**那一份,所以它当时**没有生效** ——
160+
测试用的也是代码回退,于是「测过了」和「真的改了」成了两件事。
161+
现在两份一致,并由 `tests/frame.test.ts` 的「文件与代码回退必须逐字段一致」钉住。
162+
*/
163+
writeEvidence: (ctx) =>
164+
(ctx.history ?? [])
165+
.slice(-3)
166+
.map((h, i, all) => {
146167
const writes = h.tool === 'write_file'
147168
const inputBudget = writes ? 600 : 60
148169
const resultBudget = writes ? 60 : i === all.length - 1 ? 600 : 200
149170
return `${h.tool}(${clip(h.input, inputBudget)}) → ${clip(h.result, resultBudget)}`
150-
}),
151-
},
171+
})
172+
.join('\n'),
152173
/**
153174
* 工具的静态风险基线。认不出的工具名**不放这一栏** ——
154175
* 0 分的意思是「只读」,不能用它冒充「未知」(`absent` 与 `unfilled` 必须分开)。
155176
*/
156-
localToolRisk: {
157-
from: 'lastTool',
158-
fn: (ctx) => {
159-
const t = ctx.lastTool
160-
return t && isToolName(LOCAL_TOOLS, t) ? LOCAL_TOOLS[t].baseRisk : undefined
161-
},
177+
localToolRisk: (ctx) => {
178+
const t = ctx.lastTool
179+
return t && isToolName(LOCAL_TOOLS, t) ? LOCAL_TOOLS[t].baseRisk : undefined
162180
},
163181
}
164182

183+
/** 实现 + 声明里的 `from`,合成 `frameSpecFromBlock` 要的那张表 */
184+
const FRAME_PROJECTIONS: Record<ProjectionName, { from: keyof AgentCtx; fn: (ctx: AgentCtx) => unknown }> =
185+
Object.fromEntries(
186+
// `from` 在声明里是 `string`(那一层够不到 `keyof AgentCtx`,见那个文件头的说明);
187+
// 每个名字都真的是 ctx 的格名,由 `tests/frame-projections.test.ts` 断言
188+
PROJECTION_NAMES.map((name) => [
189+
name,
190+
{ from: PROJECTIONS[name].from as keyof AgentCtx, fn: PROJECTION_IMPL[name] },
191+
]),
192+
) as Record<ProjectionName, { from: keyof AgentCtx; fn: (ctx: AgentCtx) => unknown }>
193+
165194
/**
166195
* 把**一个块**的 `frame:` 声明编成 `FrameSpec`。没写 `frame:` 就返回 `null`,
167196
* 消费方回退到代码里那份 —— **迁移因此可以逐个节点做,不必一次全搬**。
@@ -180,7 +209,8 @@ export function frameSpecFromBlock(b: DocBlock, node: string): FrameSpec | null
180209
return {
181210
node,
182211
fields: f.fields.map((x) => {
183-
const p = x.project ? FRAME_PROJECTIONS[x.project] : undefined
212+
// 文件是手写的 ⇒ 投影名是不可信输入,查表前先过 `isProjectionName`
213+
const p = x.project && isProjectionName(x.project) ? FRAME_PROJECTIONS[x.project] : undefined
184214
if (x.project && !p) {
185215
throw new Error(
186216
`${where} 里,frame 投影 '${x.project}' 不在注册表里 —— ` +

0 commit comments

Comments
 (0)