abelien-harness 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/skills/abelien-appearance/references/points.md +7 -3
  3. package/skills/abelien-appearance/schemas/points-reading-reply.v1.json +2 -2
  4. package/skills/abelien-appearance/scripts/points.mjs +46 -4
  5. package/skills/abelien-appearance/scripts/spatial-images.mjs +16 -0
  6. package/skills/abelien-hardware/domain.json +33 -1
  7. package/skills/abelien-hardware/references/selection-service-contract.md +2 -0
  8. package/skills/abelien-harness/runtime/README.md +3 -0
  9. package/skills/abelien-harness/runtime/actions.mjs +8 -2
  10. package/skills/abelien-harness/runtime/engine.mjs +51 -10
  11. package/skills/abelien-harness/runtime/graph.mjs +4 -2
  12. package/skills/abelien-harness/runtime/index.mjs +2 -0
  13. package/skills/abelien-harness/runtime/model-actions.md +17 -0
  14. package/skills/abelien-harness/runtime/task-view/README.md +7 -2
  15. package/skills/abelien-harness/runtime/task-view/index.mjs +10 -4
  16. package/skills/abelien-harness/runtime/task-view/retention.mjs +39 -0
  17. package/skills/abelien-harness/scripts/ab.mjs +1 -1
  18. package/skills/abelien-structure/domain.json +1 -0
  19. package/skills/abelien-structure/flows/user-journey-cad.json +34 -2
  20. package/skills/abelien-structure/references/skeleton.md +1 -0
  21. package/skills/abelien-structure/scripts/cad.mjs +5 -4
  22. package/skills/abelien-workspace/actions/image.generate.json +2 -2
  23. package/skills/abelien-workspace/actions/step.tool-result.json +1 -1
  24. package/skills/abelien-workspace/assets/journey-stage-plan.json +465 -4
  25. package/skills/abelien-workspace/assets/lane-plan.json +2 -2
  26. package/skills/abelien-workspace/contract/host-tools.generated.json +463 -16
  27. package/skills/abelien-workspace/flows/creation.create.json +855 -152
  28. package/skills/abelien-workspace/flows/creation.modify.json +855 -152
  29. package/skills/abelien-workspace/flows/creation.remix.json +189 -2
  30. package/skills/abelien-workspace/flows/creation.restore.json +443 -6
  31. package/skills/abelien-workspace/references/actions/image.generate.generated.md +41 -2
  32. package/skills/abelien-workspace/references/actions/step.tool-result.generated.md +23 -3
  33. package/skills/abelien-workspace/references/actions.generated.md +1 -1
  34. package/skills/abelien-workspace/references/journey-stage-plan.md +35 -2
  35. package/skills/abelien-workspace/schemas/appearance-views.v1.json +208 -0
  36. package/skills/abelien-workspace/schemas/host-tools.v1.json +131 -5
  37. package/skills/abelien-workspace/schemas/image-task.v1.json +92 -0
  38. package/skills/abelien-workspace/schemas/plan-common.v1.json +3 -1
  39. package/skills/abelien-workspace/scripts/appearance-views.mjs +49 -0
  40. package/skills/abelien-workspace/scripts/creation-tasks.mjs +20 -10
  41. package/skills/abelien-workspace/scripts/flow-compile.mjs +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abelien-harness",
3
- "version": "0.20.1",
3
+ "version": "0.22.0",
4
4
  "description": "npx-installable multi-agent skill harness: generic kernel + pluggable domain packs",
5
5
  "type": "module",
6
6
  "engines": {
@@ -4,24 +4,28 @@
4
4
 
5
5
  ## 时机与依赖
6
6
 
7
- - 概念图(或现成产品图)定版后启动,与硬件选型并行:只依赖这张图和需求的 reaction,不等选型。
7
+ - create/modify 在原概念确认、内部视图完成或确定回退后启动,与硬件选型重叠;restore 读已确定的参考原图。读图消费原图、可用的空间参考和需求 reaction,不等选型。
8
8
  - 点位绑定图片摘要。换图后旧点位失效,不能和新图汇合;骨架会拒绝来自另一张图的点位。
9
9
  - 点位依赖图和 reaction,不随换件失效。槽位表变了(硬件重新分槽位)只需用同一份读图结果重新配槽位,不必重新读图。
10
10
 
11
11
  ## 第一步:读图节点(模型)
12
12
 
13
- 输入:图片、它的引用(含摘要)、需求里每条 reaction 的 id 与文字(任务视图的 `reactions`);有槽位表时一并给出,帮助理解哪些位置会有零件。回复符合 [points-reading-reply.v1](../schemas/points-reading-reply.v1.json):全部用图像像素写;读图节点定稿用代码把它换算成面内比例,冻结为 [points-reading.v1](../schemas/points-reading.v1.json)。
13
+ 输入:图片、它的引用(含摘要)、需求里每条 reaction 的 id 与文字(任务视图的 `reactions`);有槽位表时一并给出,帮助理解哪些位置会有零件。回复符合 [points-reading-reply.v1](../schemas/points-reading-reply.v1.json):全部用原图像素写;work.primary_image_px 存在时省略 image_px,由宿主注入实测值(即使模型误填也以实测值为准)。旧输入没有实测值时仍需报 image_px。读图节点定稿用代码把它换算成面内比例,冻结为 [points-reading.v1](../schemas/points-reading.v1.json)。
14
+
15
+ create/modify 确认概念后,`work.images` 同时提供一张 `primary` 原图和带方向的 `spatial_reference` 内部视图,帮助理解部件连接与空间关系。像素网格由宿主实测;本版回复的 `image_digest`、faces 和点位坐标仍全部对应 primary 原图,内部生成图的隐藏面推断写入 unknowns,不冒充原图观测。内部视图确定生成失败时仍读取同版原图。节点依赖以当前流程的 appearance_views / views_fallback 声明为准。
14
16
 
15
17
  规则:
16
18
 
17
19
  1. **只报看得见的面。** 先写 `faces`:每个看得见的面一条,带它在图里的四个角;点位只能落在这些面上,看不到的面不猜,写进 `unknowns`。只在某个面的轮廓边缘露出的特征(例如正视图里顶面灯环只在顶缘露出一条亮带)属于边那一侧的面:那个面看得见就写在那个面上,看不见就不给点位,写进 `unknowns`。中心离所在面的边不到面宽高 2% 的特征会被拒收。
18
20
  2. **每个特征一个点位。** `kind` 取 camera、display、button、port、speaker、microphone、light、sensor、vent、other 之一;`evidence` 用一句话写看到了什么。分不清是网孔还是纹理时降低 `confidence` 并写进 `unknowns`,不要硬判。
19
- 3. **只写像素,不做换算。** 所有坐标都在 `image_px` 这个像素网格里(原点在图片左上角,x 向右、y 向下)。每个面的四个角按从外侧看该面时的左上、右上、右下、左下写;top 面以靠 back 的边为上边,bottom 面从下方看、同样以靠 back 的边为上边。圆形产品(圆柱外壳)的 front、back、left、right 取从该方向看到的轮廓的外接框四角,top、bottom 是圆面。每个特征给图里的外接框 `box_px`,看不出大小时只给中心 `center_px`。面内比例由代码按该面四角的单应变换算出:特征越出所在面不超过面宽高的 2% 时截到面内,更多就拒收(多半是面的四角或所在面写错了)。不写比例、不写毫米——单张图没有可靠的尺度。
21
+ 3. **只写像素,不做换算。** 所有坐标都在 `image_px` 这个像素网格里(原点在图片左上角,x 向右、y 向下)。每个面的四个角按从外侧看该面时的左上、右上、右下、左下写;top 面以靠 back 的边为上边,bottom 面从下方看、同样以靠 back 的边为上边。圆形产品(圆柱外壳)的 front、back、left、right 取从该方向看到的轮廓的外接框四角,top、bottom 是圆面。每个特征给图里的外接框 `box_px`,看不出大小时只给中心 `center_px`。面内比例由代码按该面四角的单应变换算出:特征越出所在面不超过面宽高的 2% 时截到面内,更多就拒收;纠错时改正所在面或撤回该点并在 unknowns 写 point_id,不扩大有效面的四角,也不缩小已报外接框或改用中心点求通过。不写比例、不写毫米——单张图没有可靠的尺度。
20
22
  4. **装饰与功能。** 饰圈、纹理、logo 这类后面不需要零件的特征标 `decorative: true`,`reaction_id` 写 null。功能特征能对上需求的,`reaction_id` 只能用输入里给的 id;对不上写 null。
21
23
  5. **不填槽位。** 读图输出没有 `slot_id`,槽位由下一步确定性配对。
22
24
  6. **看不清就说看不清。** 图里看不到产品外观、太暗、只有包装时,`outcome` 写 `unreadable` 并说明原因,不给任何点位或坐标系。
23
25
  7. `image_digest` 原样抄输入图片引用的摘要。
24
26
 
27
+ 同一执行、同一视图的拒收回复及结构化原因由宿主的持久化会话保留,重启后继续传给可信 finalizer。此前有效的面四角不能改动或删除;退化、越界等无效四角仍能纠正。因面归属被拒的点保留所报像素范围,可改到真实可见面,或撤回并按 point_id 写入 unknowns。拒收路径与原因保留给模型,不能靠改基准消除错误。Web 仍最多纠正 5 次(首答之外),耗尽沿现有 points_read_fallback;不增加节点或模型调用上限。这里撤回的是尚未冻结且无法定位的观测;下一步配对仍保留所有冻结点位,未配对时写 null 和 unknowns。这保证纠错完整性,不证明第一轮读图准确。
28
+
25
29
  提交时读图节点就做下一步里与槽位无关的那组核对;被拒绝时按原因修正,在本节点重新提交。通过后读图结果冻结,配对节点不能再改它。
26
30
 
27
31
  ## 第二步:配槽位(appearance.points)
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://git.abelien.cn/abelien/abelienharness/skills/abelien-appearance/schemas/points-reading-reply.v1.json",
4
4
  "title": "abelien.appearance.points-reading-reply.v1",
5
- "description": "读图节点(视觉模型)的回复:只报看到的东西,全部用图像像素写,不做任何换算。image_px 是下面所有坐标所在的像素网格(宽、高);faces 是图里看得见的每个面和它的四个角(从外侧看该面的左上、右上、右下、左下;top 面以靠 back 的边为上边,bottom 面从下方看、也以靠 back 的边为上边);每个特征给外接框 box_px,看不出大小时只给中心 center_px。由代码按每个面四角的单应变换换算成面内比例(appearance.points-reading.v1)并做越界检查(模型算术 PT-1/PT-2)。不填 slot_id。image_digest 原样抄输入图片引用的摘要。看不清时 outcome 写 unreadable 并说明原因,不给任何坐标。",
5
+ "description": "读图节点(视觉模型)的回复:只报看到的东西,全部用图像像素写,不做任何换算。宿主实测 primary_image_px 时模型省略 image_px,由宿主注入;旧输入无实测尺寸时 image_px 必填。image_px 是下面所有坐标所在的像素网格(宽、高);faces 是图里看得见的每个面和它的四个角(从外侧看该面的左上、右上、右下、左下;top 面以靠 back 的边为上边,bottom 面从下方看、也以靠 back 的边为上边);每个特征给外接框 box_px,看不出大小时只给中心 center_px。由代码按每个面四角的单应变换换算成面内比例(appearance.points-reading.v1)并做越界检查(模型算术 PT-1/PT-2)。不填 slot_id。image_digest 原样抄输入图片引用的摘要。看不清时 outcome 写 unreadable 并说明原因,不给任何坐标。",
6
6
  "type": "object",
7
7
  "required": ["schema", "image_digest", "outcome", "faces", "points", "unknowns"],
8
8
  "properties": {
@@ -35,7 +35,7 @@
35
35
  "allOf": [
36
36
  {
37
37
  "if": { "properties": { "outcome": { "const": "read" } }, "required": ["outcome"] },
38
- "then": { "required": ["image_px"], "properties": { "faces": { "minItems": 1 }, "unreadable_reason": false } }
38
+ "then": { "properties": { "faces": { "minItems": 1 }, "unreadable_reason": false } }
39
39
  },
40
40
  {
41
41
  "if": { "properties": { "outcome": { "const": "unreadable" } }, "required": ["outcome"] },
@@ -2,7 +2,7 @@
2
2
  // 产出 appearance.points.v1;读图确定失败时由 points-fallback 产出合法的降级产物。
3
3
  // 读图本身由模型完成,这里不看图、不估坐标:只校验模型给的形状与数值范围,不改它报的位置。
4
4
  // 配不上的功能点位保留 reaction、槽位留空(未决),不改成装饰;只有一个点位按规则配出多个候选槽位时,才交回模型在候选里选。
5
- import { assertShape } from "../../abelien-harness/contracts/index.mjs";
5
+ import { assertShape, canonical } from "../../abelien-harness/contracts/index.mjs";
6
6
  import { failure } from "../../abelien-harness/scripts/web/common.mjs";
7
7
  import { checkSchema } from "./schemas.mjs";
8
8
 
@@ -27,7 +27,7 @@ export const KIND_CATEGORIES = Object.freeze({
27
27
  });
28
28
 
29
29
  const EPSILON = 1e-9;
30
- const invalidAt = (path, message) => failure("VALIDATION", `${path}: ${message}`, { errors: [{ path, message }] });
30
+ const invalidAt = (path, message, extra = {}) => failure("VALIDATION", `${path}: ${message}`, { errors: [{ path, message, ...extra }] });
31
31
 
32
32
  // ---------- 读图回复(像素)→ 冻结读图(面内比例):模型只给像素,换算由代码做(模型算术 PT-1/PT-2) ----------
33
33
  const CORNERS = Object.freeze(["top_left", "top_right", "bottom_right", "bottom_left"]);
@@ -93,6 +93,7 @@ export function readingFromPixels(reply) {
93
93
  const base = { schema: "appearance.points-reading.v1", image_digest: reply.image_digest, outcome: reply.outcome };
94
94
  if (reply.outcome === "unreadable")
95
95
  return { ...base, unreadable_reason: reply.unreadable_reason, visible_faces: [], coordinate_frame: null, points: [], unknowns: [...reply.unknowns] };
96
+ if (!reply.image_px) throw invalidAt("/image_px", "a measured pixel grid must be supplied before coordinate conversion");
96
97
  const image = reply.image_px, maps = new Map();
97
98
  reply.faces.forEach((face, i) => {
98
99
  if (maps.has(face.face)) throw invalidAt(`/faces/${i}/face`, `face ${face.face} is listed twice`);
@@ -110,7 +111,7 @@ export function readingFromPixels(reply) {
110
111
  const mapped = pixels.map(map), xs = mapped.map(p => p[0]), ys = mapped.map(p => p[1]);
111
112
  const beyond = Math.max(-Math.min(...xs), Math.max(...xs) - 1, -Math.min(...ys), Math.max(...ys) - 1);
112
113
  if (beyond > FACE_OVERHANG + EPSILON)
113
- throw invalidAt(`${at}/${point.box_px ? "box_px" : "center_px"}`, `the feature lies ${round6(beyond)} of the face width or height beyond face ${point.face} (at most ${FACE_OVERHANG}); check the face corners or the face`);
114
+ throw invalidAt(`${at}/${point.box_px ? "box_px" : "center_px"}`, `the feature lies ${round6(beyond)} of the face width or height beyond face ${point.face} (at most ${FACE_OVERHANG}); keep valid face corners and the observed feature extent unchanged; assign the feature to its actual visible face, or omit it and name its point_id in unknowns`, { rule: "point_face_membership", point_id: point.point_id });
114
115
  const clamp = value => Math.min(1, Math.max(0, value));
115
116
  const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)].map(clamp);
116
117
  const size = point.box_px ? { width: round6(x1 - x0), height: round6(y1 - y0) } : null;
@@ -122,6 +123,47 @@ export function readingFromPixels(reply) {
122
123
  coordinate_frame: { unit: "normalized", origin: "face_top_left", scale_basis: "none" }, points, unknowns: [...reply.unknowns] }, "points reading");
123
124
  }
124
125
 
126
+ /**
127
+ * Correction rounds keep each previously valid projective frame. The host supplies rejected replies from this execution's
128
+ * durable journal; no model-provided history is trusted. Invalid corner geometry is not anchored, so that face can be repaired.
129
+ * A containment refusal cannot be fixed by shrinking an observed feature or dropping its extent; reassign or withdraw it instead.
130
+ */
131
+ export function checkReadingCorrection(reply, rejectedReplies = []) {
132
+ if (reply.outcome !== "read") return;
133
+ const faces = new Map(), features = new Map();
134
+ for (const entry of rejectedReplies) {
135
+ const previous = entry.value;
136
+ if (previous?.outcome !== "read" || previous.image_digest !== reply.image_digest || !previous.image_px) continue;
137
+ try { checkSchema("points-reading-reply.v1.json", previous, "previous reading reply"); }
138
+ catch { continue; }
139
+ for (const face of previous.faces ?? []) {
140
+ if (faces.has(face.face)) continue;
141
+ try { faceMapping(face, previous.image_px, "/faces"); }
142
+ catch { continue; }
143
+ faces.set(face.face, face.corners_px);
144
+ }
145
+ for (const error of entry.errors ?? []) {
146
+ if (error.rule !== "point_face_membership") continue;
147
+ const point = previous.points?.find(item => item.point_id === error.point_id);
148
+ if (point && !features.has(point.point_id)) features.set(point.point_id, point);
149
+ }
150
+ }
151
+ for (const [name, corners] of faces) {
152
+ const face = reply.faces.find(item => item.face === name);
153
+ if (!face || canonical(face.corners_px) !== canonical(corners))
154
+ throw invalidAt("/faces", `face ${name} already has a valid pixel frame in this correction sequence; restore its original corners and keep the face. Reassign the feature to its actual visible face, or omit it with its point_id in unknowns`);
155
+ }
156
+ for (const [id, previous] of features) {
157
+ const point = reply.points.find(item => item.point_id === id);
158
+ if (!point) {
159
+ if (!reply.unknowns.some(line => line.includes(id))) throw invalidAt("/unknowns", `withdrawn feature ${id} must be named in unknowns`);
160
+ continue;
161
+ }
162
+ if (canonical(point.box_px ?? point.center_px) !== canonical(previous.box_px ?? previous.center_px) || Boolean(point.box_px) !== Boolean(previous.box_px))
163
+ throw invalidAt("/points", `feature ${id} was refused for face membership; preserve its observed pixel extent instead of shrinking it or replacing box_px with center_px. Reassign its face, or omit it and name ${id} in unknowns`);
164
+ }
165
+ }
166
+
125
167
  /** 槽位的分类路径是否落在某个点位类别可承接的分类下。 */
126
168
  export function slotServesKind(slot, kind) {
127
169
  const path = slot.category_path.join("/");
@@ -154,7 +196,7 @@ export function checkReading(reading, input) {
154
196
  // 中心落在面的轮廓上(离边不到 FACE_OVERHANG)的特征跨在两个面之间,是边那一侧的面上的特征在轮廓处露出的一道。
155
197
  // E2 r29 S1:正视图顶缘的一条亮带被读成正面点位,骨架要灯环贴正面、朝正面,装在顶面的灯环两条都做不到。
156
198
  const edge = [["left", x], ["right", 1 - x], ["top", y], ["bottom", 1 - y]].find(([, gap]) => gap < FACE_OVERHANG - EPSILON);
157
- if (edge) throw invalidAt(`${at}/position`, `${point.point_id} is centred on the ${edge[0]} edge of face ${point.face} (${round6(edge[1])} of the face from it): a feature seen only at a face's outline belongs to the face beyond that edge; give it on that face when it is visible, otherwise leave the point out and say what you saw in unknowns`);
199
+ if (edge) throw invalidAt(`${at}/position`, `${point.point_id} is centred on the ${edge[0]} edge of face ${point.face} (${round6(edge[1])} of the face from it): a feature seen only at a face's outline belongs to the face beyond that edge; give it on that face when it is visible, otherwise leave the point out and name its point_id and what you saw in unknowns`, { rule: "point_face_membership", point_id: point.point_id });
158
200
  });
159
201
  }
160
202
 
@@ -0,0 +1,16 @@
1
+ // One labelled image bundle for reading, layout and CAD; no provider or workflow code.
2
+ import { assertShape } from "../../abelien-harness/contracts/index.mjs";
3
+ const invalid=message=>{throw Object.assign(new Error(message),{code:"TASK_REPLY_INVALID"});};
4
+ /** Primary remains the sole coordinate grid of points@1. The generated views are labelled in work.images. */
5
+ export function spatialImages(refs, primary) {
6
+ const artifact = refs.appearance_views?.data;
7
+ if (!artifact) return { image: primary };
8
+ if (artifact.source_image_ref.digest !== primary.ref.digest) invalid("Spatial references belong to another concept");
9
+ const images = [{ ...primary, role: "primary" }, ...artifact.views.map(view => {
10
+ assertShape("Ref", view.image_ref);
11
+ return { ref: view.image_ref, role: "spatial_reference", view_id: view.view_id, orientation: view.orientation,
12
+ image_px: view.image_px, basis: view.basis };
13
+ })];
14
+ return { images, spatial_reference: { mode: artifact.mode, unknowns: artifact.unknowns },
15
+ primary_image_digest: primary.ref.digest };
16
+ }
@@ -379,7 +379,7 @@
379
379
  ]
380
380
  },
381
381
  "hardware-selection": {
382
- "description": "需求已澄清到硬件型号已验证的阶段门",
382
+ "description": "登记正式选型的验证状态;同一需求返工后按最新选型重新登记,待确认不会自动成为已验证",
383
383
  "initial": "requirements_ready",
384
384
  "states": [
385
385
  "requirements_ready",
@@ -402,6 +402,38 @@
402
402
  "guard": {
403
403
  "hardwareSelectionStatus": "needs_confirmation"
404
404
  }
405
+ },
406
+ {
407
+ "from": "selected",
408
+ "to": "selected",
409
+ "on": "confirmSelection",
410
+ "guard": {
411
+ "hardwareSelectionStatus": "verified"
412
+ }
413
+ },
414
+ {
415
+ "from": "selected",
416
+ "to": "selected_pending_confirmation",
417
+ "on": "confirmSelection",
418
+ "guard": {
419
+ "hardwareSelectionStatus": "needs_confirmation"
420
+ }
421
+ },
422
+ {
423
+ "from": "selected_pending_confirmation",
424
+ "to": "selected",
425
+ "on": "confirmSelection",
426
+ "guard": {
427
+ "hardwareSelectionStatus": "verified"
428
+ }
429
+ },
430
+ {
431
+ "from": "selected_pending_confirmation",
432
+ "to": "selected_pending_confirmation",
433
+ "on": "confirmSelection",
434
+ "guard": {
435
+ "hardwareSelectionStatus": "needs_confirmation"
436
+ }
405
437
  }
406
438
  ]
407
439
  }
@@ -14,6 +14,8 @@ Agent 按[型号检索契约](hardware-selection/references/query-contract.md)
14
14
 
15
15
  候选工作流将响应保存在 `hardware-proposal` 资源,不创建 `hardware-selection` 状态机实例。正式工作流才把 `hardware.select` 响应保存为 `hardware-selection` 并通过 `hardware-selection` 状态门;候选事实来自本次 OSS Wiki 检索,当前域包不建立另一套数据库候选系统。
16
16
 
17
+ 同一需求返工会复用需求资源和它的状态机实例。`confirmSelection` 按该资源最新的 `hardwareSelectionStatus` 重新登记:`verified` 进入或保持 `selected`,`needs_confirmation` 进入或保持 `selected_pending_confirmation`;其他值仍拒绝。这个事件记录正式选型结果,不代替用户确认,也不将待确认结果提升为已验证。已有运行继续使用冻结的状态机定义,定义修订只对新运行生效。
18
+
17
19
  ## 联网候选
18
20
 
19
21
  联网补位由 `hardware.web-search` 与 `hardware.web-merge` 完成,回复格式以各自暂停点的指令为准。`hardware.finalize` 只保留提案里的模型评估字段,厂商、型号、分类、来源、几何与 CAD 一律取自候选事实;外部候选的来源或 CAD 不能靠自报改写。只有正式 `hardware.select` 会让选中的联网件入库。尺寸元数据只从冻结候选复制,名称取候选标题;`bom_item_id` 是选型行身份,不是目录 BOM 身份。`hardware.finalize` 的提案永远不会升级成正式 verified。
@@ -136,6 +136,7 @@ v1(线性节点序列,旧 workflow 编译来的)原样保留;v2 是新
136
136
  | `observe(runId, {after_sequence, timeout_ms})` | 只观察:状态与之后的事件,可等新事件或超时;从不推进、不写账本 |
137
137
  | `recover({root_run_id?})` | 恢复扫描:激活停在 ready 的节点、放回有未处置回执的等待操作、补交子运行结果;不重发任何提交 |
138
138
  | `deliverReceipt(receipt, {result})` / `reply(runId, value)` / `answer(runId, confirmationAnswer)` | 外部结果、模型回复、确认卡答复 |
139
+ | `reply(runId, value, {step_execution_id, error?})` | 受信宿主交回当前模型结果;`error` 为 unknown/reconcile 时只按原 operation 原子登记诊断并将当前节点 blocked(图运行总状态由既有结算聚合),不生成失败回执、重排或重发;重复同诊断不写新版本。对账后可对当前 blocked/unknown 的原执行交回真实成功或确定失败,取消/改版后的结果仍走回执历史规则 |
139
140
  | `modelAction(runId, {step_execution_id, command_id, action_id, params})` | 验证当前模型节点、冻结绑定和输入,原子预留工具次数并创建持久化子运行;执行仍由 drain 完成 |
140
141
  | `modelActionStatus(runId, {step_execution_id, command_id})` | 只读同一工具命令的状态;只有成功终态返回真实结果 |
141
142
  | `cancel(runId)` | 记意图、撤销未开始节点的执行权;在途外部操作按绑定的取消能力记为 requested/not_stoppable,不回滚已有副作用 |
@@ -192,3 +193,5 @@ v1(线性节点序列,旧 workflow 编译来的)原样保留;v2 是新
192
193
  `FROZEN_CONTENT_PROFILE` 是既有 `LARGE_RECORD_BYTES`(4 MiB)端口最低保证的冻结门配置,检验每个内容块及作用域包装的 JSONB 字节;它不是 Web 的总存储最大值。Web `resource-store.ts` / `large-values.ts` 仍按原 1 MiB inline 阈值分块,以符合 2 MiB contents 行约束。`test/runtime-frozen-content.test.mjs` 用完整安装的 creation.start 与真实注册表契约,在此配置下起跑、重启并启动原 creation.create,检查完整清单/绑定、无写入口拒绝和损坏块拒绝。
193
194
 
194
195
  操作输入预检拒绝与宿主端口拒绝分别保留诊断:前者记录有限非负安全整数 `used/limit` 及 `guard`,后者记录有界 `sqlstate/constraint/table`;错误转换不丢弃预算数值。`test/result-store-refused.test.mjs` 分别覆盖较小输入到达宿主后被拒、300 KB 输入在任何 prepare/submit 前被预检拒绝、两者后续 drain 均不重试。领域 Schema 合法不保证一个操作行装得下;完整领域聚合与 runtime 字节预算分别测试,不通过缩减领域最大数据或放大预算换取成功。
196
+
197
+ 模型工具冻结通过可选的 actions.discover 显式发现当前契约;没有此端口的 @1 宿主仍以 contract_version:1 调用 resolve。createActionResolver.resolve 缺省仍为 @1 且必须精确匹配,不能因省略版本接受 @2;派发始终按冻结版本解析。
@@ -32,7 +32,7 @@ const EXECUTION_MEMBERS = ["submit", "resume", "query", "cancel_operation"];
32
32
  * executors:按绑定的 transport(module / http / subflow / studio_tool)给出执行函数;没有对应执行器的动作按配置缺失停下。
33
33
  * inputRestrictions:宿主按 action_id 声明的入参收紧(自包含 JSON Schema 数组,与动作的 input_schema 同时成立),随绑定作为
34
34
  * input_restrictions 交给 runtime;模型动作的参数在任何落盘之前按它校验。例如注入了冻结附件读口的宿主禁止模型内嵌图片正文。
35
- * @returns {{resolve(target: {action_id: string, contract_version?: number, node: object, flow?: object}): Promise<object>, registry(): Promise<object>}}
35
+ * @returns {{resolve(target: {action_id: string, contract_version?: number, node: object, flow?: object}): Promise<object>, discover(target: object): Promise<object>, registry(): Promise<object>}}
36
36
  */
37
37
  export function createActionResolver({ skillsDir, domains, workdir, bindings, executors = {}, inputRestrictions } = {}) {
38
38
  if (inputRestrictions !== undefined && (inputRestrictions === null || typeof inputRestrictions !== "object" || Array.isArray(inputRestrictions)
@@ -64,7 +64,7 @@ export function createActionResolver({ skillsDir, domains, workdir, bindings, ex
64
64
  if (KERNEL_ACTIONS.has(action_id) && node?.legacy_action === action_id && flow?.policy === "legacy") {
65
65
  if (typeof executors.kernel !== "function") throw runtimeError("BINDING_MISSING", `no kernel executor for ${action_id}`);
66
66
  const binding = await executors.kernel({ action_id, node, flow });
67
- if (binding?.action_id !== action_id || binding.contract_version !== contract_version || typeof binding.submit !== "function")
67
+ if (binding?.action_id !== action_id || binding.contract_version !== (contract_version ?? 1) || typeof binding.submit !== "function")
68
68
  throw runtimeError("BINDING_MISSING", `kernel executor returned no valid binding for ${action_id}`);
69
69
  return binding;
70
70
  }
@@ -85,6 +85,12 @@ export function createActionResolver({ skillsDir, domains, workdir, bindings, ex
85
85
  ...(inputRestrictions?.[action_id] ? { input_restrictions: structuredClone(inputRestrictions[action_id]) } : {}),
86
86
  ...Object.fromEntries(EXECUTION_MEMBERS.filter(key => typeof execution[key] === "function").map(key => [key, execution[key]])) };
87
87
  },
88
+ // Only model-action freezing discovers the installed version. All ordinary resolutions remain strict.
89
+ async discover({ action_id, node, flow }) {
90
+ const effective = (await load()).actions.get(action_id)?.effective;
91
+ if (!effective) throw runtimeError("BINDING_MISSING", `action ${action_id} is not in the action registry`, { action_id });
92
+ return this.resolve({ action_id, contract_version: effective.contract_version, node, flow });
93
+ },
88
94
  registry: load,
89
95
  };
90
96
  }
@@ -19,8 +19,9 @@ import { actionResultOf } from "./executors.mjs";
19
19
  import { newExecutorId, newRunId, operationId, receiptId, stepExecutionId } from "./ids.mjs";
20
20
  import { guardOperationInput } from "./data-contracts.mjs";
21
21
  import { FLOW_V2_SCHEMA, validateFlowV2 } from "./flow-graph.mjs";
22
+ import { nodeContextRetention } from "./task-view/retention.mjs";
22
23
  import { createGraphScheduler } from "./graph.mjs";
23
- import { modelActionIds, DEFAULT_TOOL_CALLS, freezeModelBinding, checkModelValue, checkModelInput, modelCommandKey, descendants } from "./model-actions.mjs";
24
+ import { modelActionIds, STEP_TOOL_RESULT, DEFAULT_TOOL_CALLS, freezeModelBinding, checkModelValue, checkModelInput, modelCommandKey, descendants } from "./model-actions.mjs";
24
25
 
25
26
  /**
26
27
  * A model node's request budget, checked when its flow freezes (r28): `task.budget.context_tokens: {limit, reason}`, at most
@@ -90,7 +91,7 @@ const effectiveDigest = binding => binding.effective_digest ?? digest({ definiti
90
91
  * @param {import("../contracts/ports/ports.js").ResourceStore} options.resourceStore
91
92
  * @param {{tenant_id: string, work_id: string}} options.scope 新运行所在的作品范围;派发处理按操作自己的范围。
92
93
  * @param {{resolve(flowId: string, context?: object): Promise<{flow: object, skills?: object[], legacy?: object}>}} options.flows
93
- * @param {{resolve(request: {action_id: string, contract_version: number, node: object, flow: object}): Promise<object>}} [options.actions]
94
+ * @param {{resolve(request: {action_id: string, contract_version: number, node: object, flow: object}): Promise<object>, discover?(request: {action_id: string, node: object, flow: object}): Promise<object>}} [options.actions]
94
95
  */
95
96
  export function createRuntime(options) {
96
97
  const { executionStore: ledger, artifactStore: artifacts, resourceStore: resources } = options;
@@ -342,10 +343,17 @@ export function createRuntime(options) {
342
343
  checkModelBudget(node);
343
344
  node.model_action_bindings = {};
344
345
  for (const action_id of [...new Set(ids)]) {
345
- const binding = await bindingFor({ kind: "cli", action_id }, flow);
346
- if (binding.action_id !== action_id || binding.contract_version !== 1) throw runtimeError("BINDING_MISSING", `model action resolver returned the wrong identity for ${action_id}`);
346
+ // Discover the installed contract once; subsequent model calls dispatch exactly this frozen version.
347
+ if (typeof options.actions?.resolve !== "function") throw runtimeError("BINDING_MISSING", `no action resolver for ${action_id}`);
348
+ const binding = typeof options.actions.discover === "function"
349
+ ? await options.actions.discover({ action_id, node: { kind: "cli", action_id }, flow })
350
+ : await options.actions.resolve({ action_id, contract_version: 1, node: { kind: "cli", action_id }, flow });
351
+ if (binding.action_id !== action_id || !Number.isSafeInteger(binding.contract_version) || binding.contract_version < 1)
352
+ throw runtimeError("BINDING_MISSING", `model action resolver returned the wrong identity for ${action_id}`);
347
353
  node.model_action_bindings[action_id] = freezeModelBinding(binding);
348
354
  }
355
+ node.task ??= {};
356
+ node.task.context_retention = nodeContextRetention(node);
349
357
  }
350
358
  for (const node of flow.nodes.filter(item => ["cli", "studio_tool"].includes(item.kind))) {
351
359
  const binding = await bindingFor(node, flow);
@@ -484,6 +492,16 @@ export function createRuntime(options) {
484
492
  ...(run.state === "succeeded" ? { result: await artifactData(homeScope, step.output_refs[0]) } : {}),
485
493
  ...(step.error || op?.error ? { error: step.error ?? op.error } : {}) };
486
494
  }
495
+ /** Trusted read-only archive admission: original frozen call limit and exact proposed read parameters. */
496
+ async function modelActionBudget(runId, { step_execution_id, requests = [] } = {}) {
497
+ const { run, node } = await modelParent(runId, step_execution_id, { current: false });
498
+ const limit = node.task?.budget?.tool_calls ?? DEFAULT_TOOL_CALLS, used = (await modelChildren(run, step_execution_id)).length;
499
+ const pinned = node.model_action_bindings?.[STEP_TOOL_RESULT];
500
+ let requests_valid = Boolean(pinned);
501
+ try { for (const params of requests) checkModelInput(pinned, params); }
502
+ catch { requests_valid = false; }
503
+ return { limit, used, remaining: Math.max(0, limit - used), requests_valid };
504
+ }
487
505
  async function modelActionResults(runId, { step_execution_id } = {}) {
488
506
  const { run } = await modelParent(runId, step_execution_id);
489
507
  const out = [];
@@ -898,6 +916,12 @@ export function createRuntime(options) {
898
916
  return { ...result, inactive: true };
899
917
  }
900
918
  const state = { op };
919
+ // A host-reported model diagnostic is reconciled by an explicit original-identity reply,
920
+ // never by MODEL_BINDING's generic idempotent submit (which would clear the blocked state).
921
+ if (step.kind === "model" && op.state === "unknown") {
922
+ await releaseOnly(op, claim, "waiting");
923
+ return { ...result, unknown: true };
924
+ }
901
925
  if (op.state === "prepared") return { ...result, ...(await submitOperation(base, state, "submit")) };
902
926
  if (op.state === "accepted") {
903
927
  if (typeof binding.resume === "function") return { ...result, ...(await submitOperation(base, state, "resume")) };
@@ -1251,9 +1275,9 @@ export function createRuntime(options) {
1251
1275
  async function deliverReceipt(receipt, { result } = {}) {
1252
1276
  const op = await ledger.getOperation({ tenant_id: receipt.tenant_id }, receipt.operation_id);
1253
1277
  if (!op) throw runtimeError("NOT_FOUND", `receipt ${receipt.receipt_id} names an unknown operation`);
1254
- // A model step has no query (MODEL_BINDING): nothing can reconcile it, so the host's unknown verdict is final and blocks the step.
1255
- // Only actions take the unknown → reconcile path.
1256
- if (receipt.outcome === "failed" && op.identity.action_id !== MODEL_BINDING.action_id
1278
+ // Unknown is never a confirmed failure, including model operations.
1279
+ // Trusted model diagnostics enter through reply(), which blocks without manufacturing a terminal receipt.
1280
+ if (receipt.outcome === "failed"
1257
1281
  && (receipt.error?.category === "unknown" || receipt.error?.recovery?.action === "reconcile"))
1258
1282
  throw runtimeError("RECEIPT_INVALID", "an unknown outcome is a reconciliation diagnostic, not a confirmed failure receipt");
1259
1283
  if (receipt.outcome === "succeeded") {
@@ -1276,18 +1300,35 @@ export function createRuntime(options) {
1276
1300
  /**
1277
1301
  * 模型回复:交给当前等待中的模型节点。给了 error(契约 ActionError)就是宿主报告这一步失败:记为失败回执,
1278
1302
  * 按错误类别处置,与 cli 节点的失败一样走修正(corrects)、失败分支(fallback_for)与触发分支(trigger on failure)。
1303
+ * unknown/reconcile 是受信宿主的对账诊断:原操作与节点保持非终态,不创建失败回执;原身份显式回复可交回真实终态。
1279
1304
  */
1280
1305
  async function reply(runId, value, { issuer = "host-agent", sc = homeScope, step_execution_id, error } = {}) {
1281
1306
  if (error !== undefined) assertShape("ActionError", error);
1282
1307
  const run = await ledger.getRun(sc, runId);
1283
1308
  if (!run) throw runtimeError("RUN_NOT_FOUND", `run not found: ${runId}`);
1284
1309
  const { flow } = await runContext(run);
1285
- const step = isGraph(flow) ? await graph.modelStepFor(run, flow, await executions(run), step_execution_id) : chainOf(flow, await executions(run)).active;
1310
+ const step = isGraph(flow) ? await graph.modelStepFor(run, flow, await executions(run), step_execution_id, { reconcile: true }) : chainOf(flow, await executions(run)).active;
1286
1311
  const op = step && await ledger.getOperation({ tenant_id: run.tenant_id }, operationId(step.step_execution_id));
1287
- if (!step || step.kind !== "model" || step.state !== "waiting" || op?.state !== "accepted") throw runtimeError("RUN_NOT_AWAITING_MODEL", `run ${runId} is not waiting for a model reply`);
1312
+ const reconciling = step?.state === "blocked" && op?.state === "unknown"
1313
+ && step.wait?.kind === "external_task" && step.wait.operation_id === op.identity.operation_id;
1314
+ if (TERMINAL_RUN.has(run.state) || !step || step.kind !== "model" || !(step.state === "waiting" && op?.state === "accepted" || reconciling))
1315
+ throw runtimeError("RUN_NOT_AWAITING_MODEL", `run ${runId} is not waiting for a model reply`);
1288
1316
  if (step_execution_id && step.step_execution_id !== step_execution_id) throw runtimeError("RUN_NOT_AWAITING_MODEL", "the reply targets a superseded model execution");
1289
1317
  if (await toolsHold(run, step.step_execution_id, error !== undefined))
1290
1318
  throw runtimeError("MODEL_ACTION_IN_FLIGHT", "finish or cancel the model's pending tools before replying");
1319
+ if (error !== undefined && (error.category === "unknown" || error.recovery.action === "reconcile")) {
1320
+ // A terminal receipt already saved by another host wins; callers can drain and reconcile it.
1321
+ if ((await pendingReceipts(op)).length) throw runtimeError("RUN_NOT_AWAITING_MODEL", "a model result is already awaiting reconciliation");
1322
+ if (reconciling && canonical(op.error) === canonical(error) && canonical(step.error) === canonical(error))
1323
+ return { unknown: true, operation_id: op.identity.operation_id };
1324
+ const tx = batch(run.root_run_id, scopeOf(run));
1325
+ tx.op(op, { state: "unknown", submission_state: "unknown", error: normalize(error) });
1326
+ tx.step(step, { state: "blocked", wait: { kind: "external_task", operation_id: op.identity.operation_id, remote_task_id: op.remote_task_id ?? null }, error: normalize(error) });
1327
+ if (!isGraph(flow)) tx.run(run, { state: "blocked" }, { error });
1328
+ await tx.commit(); // CAS with success/cancel/revision; never replace another host's result.
1329
+ if (isGraph(flow)) await afterCommit(tx, { runs: [run] });
1330
+ return { unknown: true, operation_id: op.identity.operation_id };
1331
+ }
1291
1332
  if (error !== undefined) {
1292
1333
  const receipt = makeReceipt(op, MODEL_BINDING, "failed", error, { issuer });
1293
1334
  return { receipt_id: receipt.receipt_id, ...(await deliverReceipt(receipt)) };
@@ -1536,7 +1577,7 @@ export function createRuntime(options) {
1536
1577
  return graph.confirmationRequest(run, confirmationId);
1537
1578
  },
1538
1579
  /** 内部读取:快照与视图投影用。 */
1539
- inspect: { assertModelConfig, runContext, executions, chainOf, runInput, revisionCommand, artifactData, latestArtifact, templateContext, modelActionResults, graphState: (run, flow) => graph.graphState(run, flow) },
1580
+ inspect: { assertModelConfig, runContext, executions, chainOf, runInput, revisionCommand, artifactData, latestArtifact, templateContext, modelActionResults, modelActionBudget, graphState: (run, flow) => graph.graphState(run, flow) },
1540
1581
  receiptDisposition: id => dispositions.get(id),
1541
1582
  stores: { executionStore: ledger, artifactStore: artifacts, resourceStore: resources },
1542
1583
  };
@@ -509,11 +509,13 @@ export function createGraphScheduler(core) {
509
509
  return option.action;
510
510
  }
511
511
  /** 等回复的模型节点:v2 可能同时有几个,给了 step_execution_id 就按它,否则只能有一个。 */
512
- async function modelStepFor(run, flow, list, stepExecutionId) {
512
+ async function modelStepFor(run, flow, list, stepExecutionId, { reconcile = false } = {}) {
513
513
  const waiting = [];
514
514
  for (const node of flow.nodes.filter(item => item.kind === "model")) {
515
515
  const step = latestOf(list, node.id);
516
- if (step?.state === "waiting" && (await getOp(run, step))?.state === "accepted") waiting.push(step);
516
+ const op = step && await getOp(run, step);
517
+ if (step?.state === "waiting" && op?.state === "accepted"
518
+ || reconcile && step?.state === "blocked" && op?.state === "unknown" && step.wait?.kind === "external_task" && step.wait.operation_id === op.identity.operation_id) waiting.push(step);
517
519
  }
518
520
  if (stepExecutionId) return waiting.find(step => step.step_execution_id === stepExecutionId) ?? null;
519
521
  if (waiting.length > 1) throw runtimeError("RUN_AMBIGUOUS_MODEL_STEP", `run ${run.run_id} has ${waiting.length} model steps waiting; pass step_execution_id`, { waiting: waiting.map(step => step.step_execution_id) });
@@ -28,3 +28,5 @@ export { STEP_TOOL_RESULT } from "./model-actions.mjs";
28
28
  export { evaluateFlow, affectedNodes, joinCheck, validateFlowV2, targetInputs, inputRef, nodeTargets, FLOW_V2_SCHEMA } from "./flow-graph.mjs";
29
29
 
30
30
  export { DATA_CONTRACT_GUARDS, FROZEN_CONTENT_PROFILE, OPERATION_RECORD_BYTES, guardOperationInput, jsonbTextBytes } from "./data-contracts.mjs";
31
+
32
+ export { CONTEXT_RETENTION_SCHEMA, CONTEXT_RETENTION_REQUIRED, defaultContextRetention } from "./task-view/retention.mjs";
@@ -63,3 +63,20 @@ const actions = createActionResolver({ domains,
63
63
  新运行启动时递归冻结实际 child flow 定义。每个 child flow ID 有独立内部 action/implementation 身份;摘要包含对应 child 定义,两个子流程不再因共用 runtime.subflow 而互相覆盖 manifest。child 启动使用 parent 快照中的定义;单个 child 升级只改变它的绑定摘要,不改变无关 child 或纳入整个 runtime 版本。已有不含 frozen_subflow 的运行继续用旧身份规则。递归循环在启动前拒绝。
64
64
 
65
65
  冻结子流程在内容仓按摘要独立保存,父节点持久化 `frozen_subflow.flow_ref`。恢复核对摘要及 child flow ID 后还原完整定义,原 child 绑定摘要仍针对完整定义;Schema 身份仍留在每条运行清单。见 runtime/README.md 与 `test/runtime-frozen-content.test.mjs`。
66
+
67
+ ### 原结果中的不可变值回读
68
+
69
+ `step.tool-result` 保持同一步骤执行实例、同一任务视图的原授权;以下可选字段不授予跨 run、跨 work 或跨视图读取权,也不重跑原工具。新运行冻结扩展后的 Schema,旧运行仍使用其原冻结 Schema,不能原地升级已有检查点。
70
+
71
+ - 无 `pointer`:现有调用和返回保持原义,`content` 是原工具消息全文的 UTF-16 字符分页,`sha256` 为原消息完整 UTF-8 摘要;不添加下面的新返回字段。
72
+ - 有 `pointer`:必须同时给原消息 `sha256` 与选中值 `value_sha256`;单独给 `value_sha256` 拒绝。pointer 是严格 RFC 6901,空串选根,`~0/~1` 解码为 `~/`。只访问原 JSON 的自有成员;数组用实际索引,缺值、非法编码和原文/值摘要不符应拒绝,不能取当前新结果来替代。
73
+ - 选中值以 canonical JSON(对象键排序、无额外空白、保留 JSON 值类型)序列化,其完整 UTF-8 SHA-256 等于 `value_sha256`。因此选中字符串回读的是 JSON 字符串(含引号与转义),不是去掉类型的普通文本。
74
+ - pointer 模式返回 `pointer`、`source_sha256`、`value_sha256`,并保留 `sha256`:`sha256` 与 `source_sha256` 均是原工具消息全文摘要,`value_sha256` 是完整选中值序列化摘要,均不是本页摘要。`format` 仍为 `json_utf16`;`bytes/totalCharacters/start/end/content/next/complete` 针对选中值的 canonical JSON 文本。分页不切断 UTF-16 代理对;读回字节照原预算计入。先拼接全部页面,再 JSON.parse 才恢复完整原值。
75
+
76
+ 输入例:`{result_id, pointer:"/candidate/dimensions_mm", sha256:<原消息摘要>, value_sha256:<选中值摘要>, start:0, length:32000}`。完整 locator 的身份来自宿主已保存的原 journal,不从模型正文建立权限。回读口的摘要核验、范围校验与分页由宿主实现并单独测试,Schema 不能证明两摘要相等或原文确实存在。
77
+
78
+ 选中原值可能包含旧 `$context_ref` / `$same_as`。pointer 回读可以额外返回 `archive_references:[{at,result_id,sha256,pointer,value_sha256}]`:`at` 是该 marker 在选中值内的相对 JSON Pointer,后四项定位同 journal 的目标原结果与目标值。宿主必须核验原 marker、实际结果索引、原全文摘要和目标值摘要后才返回映射,不能直接采信业务 JSON 中自报的 locator。原 `content` 仍保持完整原值,因此 `value_sha256` 不变;需要目标正文时再按映射调用同一只读端口。映射不新增跨执行/视图权限,不能把重定位 marker 作为业务参数直接执行。无 pointer 的旧返回不增加该字段。引用闭包、循环及缺源由宿主明确拒绝,Schema 只约束形状。
79
+
80
+ ## 宿主归档读预算准入
81
+
82
+ `inspect.modelActionBudget(runId,{step_execution_id,requests})`只读原冻结model task的tool_calls limit与实际model child数,返回used/remaining以及每个拟发step.tool-result params是否通过原冻结有效input schema的requests_valid。不预留或重置预算、不推进运行;宿主每个模型边界结合真实modelActionResults读字节消费重新检查,完整退休集合必须可分页回读。旧runtime缺此端口不能被当作具备该证明。任务视图step.tool-result的input/output schema来自冻结binding,不能用current registry schema给旧run新增pointer权限。
@@ -28,6 +28,7 @@ await tv.reply(rootRunId, output, { step_execution_id: view.reply_to.step_execut
28
28
  | `actions` | 动作 ID 列表:从动作注册表取说明、效果与自包含的输入输出 Schema。冻结时带了 `step.tool-result` 绑定的节点(r28 起每个有动作的节点),视图在声明的动作之后列出它,宿主压缩对话后模型用它回读自己的工具结果 |
29
29
  | `output` | `{package, schema, reply?}` 或 `{inline_schema}`:输出契约,视图里是 `$ref` 已内联的自包含 Schema。没有 `reply` 时,`reply` 按它校验模型回复与补全后的回复;有 `reply: {package?, schema}` 时,模型回复按 `reply` 的 Schema 校验(视图的 `output` 给的也是它),补全后的回复仍按完整的输出契约校验。用于只回结果 ID、由 finalize 取存下结果的节点 |
30
30
  | `budget` | `{attempts?, context_tokens?: {limit, reason}}`:尝试上限(缺省同 runtime 的 5 次)与本节点模型请求的 token 上限。不声明用 `MODEL_REQUEST_TOKENS`(= 模型输入窗口,现为 1,024K;r29 去掉了 256K 节点预算);声明更低的上限必须写理由,最多 `MODEL_REQUEST_TOKENS_CEILING`(模型输入窗口),超过的流程在冻结时 `FLOW_INVALID`。r28 起 `context_bytes` 已删除:新流程声明它即 `FLOW_INVALID`,r28 之前冻结的运行里残留的不再生效 |
31
+ | `context_retention` | 版本化数据保留契约;默认业务数据 exact。显式 reference 只用于声明动作且本次已冻结 step.tool-result 的完整已存工具结果,非法声明拒绝;见 [保留契约](../../../../docs/architecture/node-context-retention.md) |
31
32
  | `builder` | `{package, module, view?, finalize?}`:领域钩子。`view(ctx)` 返回 `{work, reply_without_model?}` 补本节点特有的工作说明;`finalize(ctx, output)` 把模型回复补全成交给 runtime 的值 |
32
33
  | `bound_params` | `{<action_id>: {fixed_input?: [<field>, …], results?: {<param>: <action_id>}}}`:由 runtime 填的动作参数。`fixed_input` 列出从钩子给出的 `work.fixed_input` 取的字段(绑到同名参数,视图里没有的可选字段不填),`results` 把参数绑到本步该动作最近一次成功的结果;来源只有这两种,参数名都写在声明里。视图里该动作的 `input_schema` 按声明去掉这些参数(不依赖运行数据),另带 `bound_params: {params, fixed_input?, results?}`。宿主把模型的调用交给装配器的 `modelAction(runId, {step_execution_id, command_id, action_id, params})`:模型给了绑定参数就拒(`MODEL_ACTION_INPUT_INVALID`,可纠正,不覆盖),结果绑定的动作本步还没有成功结果也拒;视图没有 `work.fixed_input` 或它有声明之外的字段时拒 `MODEL_ACTION_BINDING_MISSING`(配置问题);否则填入后交给 `runtime.modelAction`,engine 照常按完整入参 Schema 校验。E2 r29 S1:模型把冻结输入与布局请求整份转写进参数时缩写、改写、编造摘要,每轮约 6.5k 输出 token 大半是转写 |
33
34
 
@@ -37,7 +38,7 @@ CAD 建模节点是第一个使用者:钩子在 `skills/abelien-structure/scri
37
38
 
38
39
  ## 视图(`abelien.task-view.v1`)
39
40
 
40
- `reply_to`(回复交给哪个运行与执行实例)、`flow`、`target_revision_ref`、`attempt`、`goal`、`instruction`、`refs`、`skills`、`actions`、`output`、`work`、`reply_without_model`、`correction`(`{step_execution_id, step_definition_id, error, refused_reply?}`;没有钩子的节点,`refused_reply` 带上被拒回复的内容 `data`,让模型对照错误路径改它)、`attempts{used, limit, remaining, history}`、`budget{context_tokens{limit}}`、`view_digest`。`budget.context_tokens.limit` 是带这个视图的每次模型请求的 token 上限;视图不自己计数,宿主在发送前按模型档案估算实际请求(视图、对话、工具定义、图片)并在超限时以 `MODEL_CONTEXT_BUDGET_EXHAUSTED` 拒绝。
41
+ `reply_to`(回复交给哪个运行与执行实例)、`flow`、`target_revision_ref`、`attempt`、`goal`、`instruction`、`refs`、`skills`、`actions`、`output`、`work`、`reply_without_model`、`correction`(`{step_execution_id, step_definition_id, error, refused_reply?}`;没有钩子的节点,`refused_reply` 带上被拒回复的内容 `data`,让模型对照错误路径改它)、`attempts{used, limit, remaining, history}`、`budget{context_tokens{limit}}`、`context_retention`、`view_digest`。`budget.context_tokens.limit` 是带这个视图的每次模型请求的 token 上限;视图不自己计数,宿主在发送前按模型档案估算实际请求(视图、对话、工具定义、图片)并在超限时以 `MODEL_CONTEXT_BUDGET_EXHAUSTED` 拒绝。
41
42
 
42
43
  ## 测试
43
44
 
@@ -49,10 +50,14 @@ v2 的 `needs` 按当前操作实际冻结的 `input_refs` 读取,不取可变
49
50
 
50
51
  输出 Schema 的跨包 `$ref` 仅从已安装域包 `schemas/` 与冻结契约中解析;未知引用、包外路径或越界链接拒绝,不按 URL 或任意路径读取。内联 Schema 固定在流程中,装配器按内容生成稳定 `$id`。
51
52
 
52
- `view` 钩子始终只读。只有 `finalize` 钩子额外获得 `toolResults()`、`materializeArtifact(envelope)` 与 `fail(ActionError)`:前者读取本模型执行实例下已成功的正式工具结果(动作、执行身份、冻结输入、结果与引用),领域据此确认检查证据;后者把原始领域信封按本执行实例固定身份存进 ArtifactStore 并返回真实可读回 Ref。scope、产物 ID 与 produced_by 不接受模型参数,重复登记同一信封返回同一 Ref。图产品外层 Ref 的摘要对应外层信封,不能冒充内层点位或骨架摘要;需要领域 Ref 时由这个可信 finalizer 登记。
53
+ `view` 钩子始终只读。`reply` 的受信宿主选项 `rejected_replies` 携带同一 step_execution_id、同一 view_digest 下已经持久化的拒收记录 `{value, errors}`,以只读 `ctx.rejectedReplies` 交给 finalizer;来源不是模型 JSON,换视图清空,不代替 runtime 的返工历史。Web 使用现有模型会话保存它,重启不会失去几何纠错基准;旧调用省略时为初次回复。
54
+
55
+ 只有 `finalize` 钩子额外获得 `toolResults()`、`materializeArtifact(envelope)` 与 `fail(ActionError)`:前者读取本模型执行实例下已成功的正式工具结果(动作、执行身份、冻结输入、结果与引用),领域据此确认检查证据;后者把原始领域信封按本执行实例固定身份存进 ArtifactStore 并返回真实可读回 Ref。scope、产物 ID 与 produced_by 不接受模型参数,重复登记同一信封返回同一 Ref。图产品外层 Ref 的摘要对应外层信封,不能冒充内层点位或骨架摘要;需要领域 Ref 时由这个可信 finalizer 登记。
53
56
 
54
57
  钩子的 `run.input` 仅供受信领域校验,`current.input_refs` 和历史的 `input_refs` 用于判断实际变版范围。这些上下文与端口不进入模型视图。
55
58
 
56
59
  `finalize` 在匹配当前执行的正式工具结果后可调用 `fail(ActionError)`,把确定的领域失败交给 runtime 的失败分支。端口先校验契约,再抛出仅本次调用识别的内部信号;装配器重检同一等待步骤及 view digest 后才发送失败回执。模型 JSON、普通钩子异常、过期回复都不能冒充该控制信号。
57
60
 
58
61
  正式解析器已在运行前把节点输出/回复的自包含 Schema 冻结;装配器优先使用 `frozen_output_schema` / `frozen_reply_schema`,重启不读取当前包同名 Schema 来改变回复规则。自定义未声明流程继续旧兼容读取。完整视图(含领域 builder 的 `work`)报告预算,最终模型请求需由宿主在 Provider HTTP 前按实际序列化正文拒绝;`over` 标记本身不是提交守卫。
62
+
63
+ 保留契约由冻结 `task.context_retention` 产生并纳入 `view_digest`;缺声明的旧冻结视图完全不新增该字段,保留原 wire / view_digest 并接受已保存回复;消费方内部按 exact 默认,模型同名字段与当前包的新声明无权改写它。完整字段与节点清单见 [节点上下文保留契约](../../../../docs/architecture/node-context-retention.md)。
@@ -13,8 +13,9 @@ import { dereference, loadActionRegistry } from "../../scripts/action-registry.m
13
13
  import { runtimeError } from "../errors.mjs";
14
14
  import { operationId } from "../ids.mjs";
15
15
  import { FLOW_V2_SCHEMA } from "../flow-graph.mjs";
16
- import { STEP_TOOL_RESULT } from "../model-actions.mjs";
16
+ import { STEP_TOOL_RESULT, effectiveInputSchema } from "../model-actions.mjs";
17
17
  import { operandSourceProblem, sourcedOperands } from "../../scripts/calc/index.mjs";
18
+ import { nodeContextRetention } from "./retention.mjs";
18
19
 
19
20
  export const TASK_VIEW_SCHEMA = "abelien.task-view.v1";
20
21
  const TERMINAL_RUN = new Set(["succeeded", "failed", "cancelled"]);
@@ -254,6 +255,7 @@ export function createTaskViewAssembler({ runtime, skillsDir, domains, workdir,
254
255
  const { run, flow, list, chain, step } = located;
255
256
  const node = flow.nodes.find(item => item.id === step.step_definition_id);
256
257
  const task = node.task ?? {};
258
+ const context_retention = nodeContextRetention(node, { code: "TASK_VIEW_INVALID" });
257
259
  const op = await ledger.getOperation({ tenant_id: run.tenant_id }, operationId(step.step_execution_id));
258
260
  let correction = op?.input?.params?.correction ?? null;
259
261
  // The view shows the reply the check refused next to its error; a node with a builder hook gets the reference and its
@@ -319,7 +321,8 @@ export function createTaskViewAssembler({ runtime, skillsDir, domains, workdir,
319
321
  actions = actionIds.map(id => {
320
322
  const effective = env.registry.describe(id); // 模型是 Agent:host 受众的动作在这里就被拒绝,节点声明扩大不了权限
321
323
  return { action_id: id, contract_version: effective.contract_version, description: effective.definition.description, effects: effective.effects,
322
- input_schema: env.registry.schema(id, "input"), output_schema: env.registry.schema(id, "output") };
324
+ input_schema: id === STEP_TOOL_RESULT && node.model_action_bindings?.[id] ? clone(effectiveInputSchema(node.model_action_bindings[id])) : env.registry.schema(id, "input"),
325
+ output_schema: id === STEP_TOOL_RESULT && node.model_action_bindings?.[id] ? clone(node.model_action_bindings[id].output_schema) : env.registry.schema(id, "output") };
323
326
  });
324
327
  }
325
328
  const output = outputContract(env, task.output, node.frozen_output_schema);
@@ -362,6 +365,8 @@ export function createTaskViewAssembler({ runtime, skillsDir, domains, workdir,
362
365
  const limit = task.budget?.attempts ?? maxAttempts;
363
366
  const body = {
364
367
  schema: TASK_VIEW_SCHEMA,
368
+ // Keep pre-contract frozen views byte-compatible: saved replies still bind to their original digest.
369
+ ...(task.context_retention === undefined ? {} : { context_retention }),
365
370
  reply_to: { run_id: run.run_id, root_run_id: run.root_run_id, step_execution_id: step.step_execution_id, step_definition_id: node.id },
366
371
  flow: { flow_id: flow.flow_id, version: flow.version },
367
372
  target_revision_ref: run.target_revision_ref ?? null,
@@ -441,7 +446,7 @@ export function createTaskViewAssembler({ runtime, skillsDir, domains, workdir,
441
446
  * 交回模型回复:核对它针对的是当前视图(view_digest),按输出契约校验,经领域钩子补全(例如沿用未返工的模块),再校验一遍后交给 runtime.reply。
442
447
  * 视图说可以不调模型(reply_without_model)时 output 可以为 null。
443
448
  */
444
- async reply(runId, output, { view_digest, step_execution_id } = {}) {
449
+ async reply(runId, output, { view_digest, step_execution_id, rejected_replies = [] } = {}) {
445
450
  const { view, hook, builderCtx, output: contract, reply: replyContract } = await assemble(await locate(runId, step_execution_id));
446
451
  if (view_digest !== undefined && view_digest !== view.view_digest)
447
452
  throw runtimeError("TASK_VIEW_STALE", "the reply was written for another task view; assemble the view again", { expected: view.view_digest, got: view_digest });
@@ -456,7 +461,8 @@ export function createTaskViewAssembler({ runtime, skillsDir, domains, workdir,
456
461
  // Failure control is branded in this call only; a model JSON object or an ordinary hook error cannot become a runtime failure.
457
462
  const failures = new WeakMap();
458
463
  // Only a trusted finalize hook may materialize a domain envelope. Identity and scope are fixed by the waiting execution.
459
- const finalizeCtx = { ...builderCtx, fail: error => {
464
+ // Supplied by the trusted host from this step/view's durable model journal, never from model JSON.
465
+ const finalizeCtx = { ...builderCtx, rejectedReplies: clone(rejected_replies), fail: error => {
460
466
  assertShape("ActionError", error);
461
467
  const signal = new Error("trusted task finalizer reported a verified failure");
462
468
  failures.set(signal, clone(error));