specmodule 0.4.0__tar.gz → 0.5.0__tar.gz

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 (55) hide show
  1. {specmodule-0.4.0/specmodule.egg-info → specmodule-0.5.0}/PKG-INFO +2 -2
  2. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/call.py +8 -2
  3. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/config.py +19 -0
  4. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/harness.py +103 -49
  5. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/query.py +25 -0
  6. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/module.py +13 -1
  7. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/spec.py +2 -0
  8. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/submodule.py +285 -283
  9. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/translator.py +26 -2
  10. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/graph_builder.py +7 -0
  11. {specmodule-0.4.0 → specmodule-0.5.0}/pyproject.toml +4 -4
  12. {specmodule-0.4.0 → specmodule-0.5.0/specmodule.egg-info}/PKG-INFO +2 -2
  13. {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/requires.txt +1 -1
  14. {specmodule-0.4.0 → specmodule-0.5.0}/LICENSE +0 -0
  15. {specmodule-0.4.0 → specmodule-0.5.0}/README.md +0 -0
  16. {specmodule-0.4.0 → specmodule-0.5.0}/llm/__init__.py +0 -0
  17. {specmodule-0.4.0 → specmodule-0.5.0}/llm/client.py +0 -0
  18. {specmodule-0.4.0 → specmodule-0.5.0}/llm/config.py +0 -0
  19. {specmodule-0.4.0 → specmodule-0.5.0}/llm/mock.py +0 -0
  20. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/__init__.py +0 -0
  21. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/__init__.py +0 -0
  22. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/__main__.py +0 -0
  23. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/cli.py +0 -0
  24. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/command.py +0 -0
  25. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/entry.py +0 -0
  26. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/loader.py +0 -0
  27. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/scaffold.py +0 -0
  28. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/__init__.py +0 -0
  29. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/builtins.py +0 -0
  30. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/outputfmt.py +0 -0
  31. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/prompt.py +0 -0
  32. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/registry.py +0 -0
  33. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/__init__.py +0 -0
  34. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/artifacts.py +0 -0
  35. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/checkpoint.py +0 -0
  36. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/control.py +0 -0
  37. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/entry_pack.py +0 -0
  38. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/events.py +0 -0
  39. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/status.py +0 -0
  40. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/store.py +0 -0
  41. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/stream.py +0 -0
  42. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/__init__.py +0 -0
  43. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/__init__.py +0 -0
  44. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/align.py +0 -0
  45. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/consistency.py +0 -0
  46. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/feed.py +0 -0
  47. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/templates/builtin/codereview.json +0 -0
  48. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/templates/builtin/docwrite.json +0 -0
  49. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/templates/builtin/summarize.json +0 -0
  50. {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/templates/builtin/translate.json +0 -0
  51. {specmodule-0.4.0 → specmodule-0.5.0}/setup.cfg +0 -0
  52. {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/SOURCES.txt +0 -0
  53. {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/dependency_links.txt +0 -0
  54. {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/entry_points.txt +0 -0
  55. {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specmodule
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: 可审计、可调试、可完全掌控的 LLM 使用框架(tickflow + llm + module_harness)
5
5
  License: MIT
6
6
  Keywords: llm,workflow,petri-net,agent
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Python: >=3.10
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
- Requires-Dist: tickflow-py<0.3,>=0.2.0
18
+ Requires-Dist: tickflow-py<0.4,>=0.3.0
19
19
  Provides-Extra: anthropic
20
20
  Requires-Dist: anthropic; extra == "anthropic"
21
21
  Provides-Extra: openai
@@ -24,6 +24,7 @@ class HarnessCallResult:
24
24
  """独立调用结果:校验后输出 + LLM 原始输出 + token 用量。
25
25
 
26
26
  图像模式不产生文本 raw(无 _llm_raw),raw 为 None。
27
+ validate_retries > 0 时:usage 为各次尝试之和;raw 为最后一次尝试的原始输出。
27
28
  """
28
29
 
29
30
  value: Any # 校验后的输出(json_object → 解析值;text → str)
@@ -67,8 +68,13 @@ async def call_harness(
67
68
  OutputValidated / ...),不传零开销(EventBus.null())。
68
69
 
69
70
  失败(LLM 错误 / 输出校验不通过)抛 HarnessCallError,携带 failure 与
70
- 渲染 prompt / 原始输出 / usage 诊断链。task 层没有"下游跳过"概念,
71
- Failure 一律翻译为异常;promptmode 缺 key → KeyError 原样冒出。
71
+ 渲染 prompt / 原始输出 / usage 诊断链。``validate_retries > 0`` 时校验
72
+ 失败在 body 内带反馈重问:``prompt`` 为最后一次尝试的实际 prompt(含
73
+ 反馈段)、``raw`` 为最后一次原始输出、``usage`` 为各次尝试之和。LLM
74
+ 错误路径:``prompt`` 仍为出错尝试的实际 prompt,``raw``/``usage`` 为
75
+ 最后一次有输出的尝试(出错尝试无输出,错误本身见 failure.error)。task
76
+ 层没有"下游跳过"概念,Failure 一律翻译为异常;promptmode 缺 key →
77
+ KeyError 原样冒出。
72
78
  """
73
79
  bus = event_bus or EventBus.null()
74
80
  body = Harness(config, llm_client, bus).build_body(
@@ -32,6 +32,15 @@ class HarnessConfig:
32
32
  notdo: list[str] = field(default_factory=list)
33
33
  """否定性约束列表,拼入 system prompt。"""
34
34
 
35
+ validate_retries: int = 0
36
+ """输出校验失败时的额外重试次数(带校验错误反馈重问)。
37
+
38
+ 0(缺省)= 不重试,行为与无此字段时逐字节一致。
39
+ N > 0 = 校验失败后最多再问 N 次,每次 prompt 追加校验错误反馈段;
40
+ 预算耗尽返回最后一次的 Failure(type="llm")。
41
+ 仅作用于输出校验失败;LLMError(传输层)不重试、不消耗预算。
42
+ """
43
+
35
44
  # ── LLM 参数(Task 可逐项覆盖)──
36
45
  model: str | None = None
37
46
  temperature: float | None = None
@@ -66,6 +75,14 @@ class HarnessConfig:
66
75
  )
67
76
  if self.mode == "image" and self.image_dir is None:
68
77
  raise ValueError("mode='image' 不接受显式 null 的 image_dir")
78
+ if isinstance(self.validate_retries, bool) or not isinstance(self.validate_retries, int):
79
+ raise ValueError(
80
+ f"validate_retries 须为 int,得到 {type(self.validate_retries).__name__}"
81
+ )
82
+ if self.validate_retries < 0:
83
+ raise ValueError(f"validate_retries 须 >= 0,得到 {self.validate_retries!r}")
84
+ if self.validate_retries > 0 and self.mode == "image":
85
+ raise ValueError("mode='image' 无文本输出格式可校验,validate_retries 须为 0")
69
86
 
70
87
  def to_dict(self) -> dict[str, Any]:
71
88
  """序列化为 JSON 可写 dict(含 output_format)。"""
@@ -96,6 +113,7 @@ class HarnessConfig:
96
113
  - mode → 调用形态:"text"(默认)| "image"
97
114
  - image_size → 图像尺寸(仅 mode="image")
98
115
  - image_dir → 图像落盘目录(仅 mode="image")
116
+ - validate_retries → 校验失败重试预算(int ≥ 0,缺省 0)
99
117
 
100
118
  mode/image_size/image_dir:显式 null 的 image_dir 会被拒绝。
101
119
  """
@@ -120,4 +138,5 @@ class HarnessConfig:
120
138
  mode=task.get("mode", "text"),
121
139
  image_size=task.get("image_size"),
122
140
  image_dir=task.get("image_dir", "images"),
141
+ validate_retries=task.get("validate_retries", 0),
123
142
  )
@@ -26,6 +26,13 @@ from ..infra.events import (
26
26
  )
27
27
 
28
28
 
29
+ # 校验失败反馈段(框架契约文案):重试时追加到原渲染 prompt 末尾重问
30
+ _VALIDATION_FEEDBACK = (
31
+ "\n\n上一次输出未通过校验:{error}\n"
32
+ "请修正该问题后重新输出完整结果,不要复述错误内容,不要解释修改过程。"
33
+ )
34
+
35
+
29
36
  class Harness:
30
37
  """持有 HarnessConfig + LLM 客户端 + EventBus。
31
38
 
@@ -151,16 +158,22 @@ class Harness:
151
158
  rendered=rendered,
152
159
  ))
153
160
 
154
- # 2. 调用 LLM
155
- bus.emit(LlmCallStarted(
156
- timestamp=time.monotonic(), node=node, tick=0,
157
- model=config.model or "default",
158
- prompt_chars=len(rendered),
159
- ))
160
-
161
+ # 2. 调用 LLM(image 单次;text 带校验重试循环)
161
162
  if config.mode == "image":
163
+ bus.emit(LlmCallStarted(
164
+ timestamp=time.monotonic(), node=node, tick=0,
165
+ model=config.model or "default",
166
+ prompt_chars=len(rendered),
167
+ ))
162
168
  return await _run_image(view, rendered, state)
163
169
 
170
+ budget = config.validate_retries
171
+ retrying = budget > 0
172
+ attempt_prompt = rendered
173
+ usage_acc: dict[str, int] = {}
174
+ attempts = 0
175
+ retry_errors: list[str] = []
176
+
164
177
  def on_token(chunk: str) -> None:
165
178
  bus.emit(LlmToken(
166
179
  timestamp=time.monotonic(), node=node, tick=0,
@@ -173,63 +186,92 @@ class Harness:
173
186
  chunk=chunk,
174
187
  ))
175
188
 
176
- try:
177
- from llm.client import LLMError
189
+ while True:
190
+ attempts += 1
191
+ bus.emit(LlmCallStarted(
192
+ timestamp=time.monotonic(), node=node, tick=0,
193
+ model=config.model or "default",
194
+ prompt_chars=len(attempt_prompt),
195
+ ))
178
196
 
179
- # notdo 由 LLM client 内部通过 _build_system() 拼入 system prompt
180
- response = await llm.complete(
181
- prompt=rendered,
182
- model=config.model,
183
- temperature=config.temperature,
184
- think=config.think,
185
- output_format=dataclasses.asdict(config.output_format) if config.output_format else None,
186
- notdo=config.notdo if config.notdo else None,
187
- on_token=on_token,
188
- on_thinking=on_thinking,
189
- api_params=config.api_params if config.api_params else None,
190
- )
191
- except LLMError as e:
197
+ try:
198
+ from llm.client import LLMError
199
+
200
+ # notdo 由 LLM client 内部通过 _build_system() 拼入 system prompt
201
+ response = await llm.complete(
202
+ prompt=attempt_prompt,
203
+ model=config.model,
204
+ temperature=config.temperature,
205
+ think=config.think,
206
+ output_format=dataclasses.asdict(config.output_format) if config.output_format else None,
207
+ notdo=config.notdo if config.notdo else None,
208
+ on_token=on_token,
209
+ on_thinking=on_thinking,
210
+ api_params=config.api_params if config.api_params else None,
211
+ )
212
+ except LLMError as e:
213
+ # 传输层失败不重试(SDK max_retries 已管)、不消耗校验重试预算
214
+ if state is not None:
215
+ state["_llm_error"] = str(e)
216
+ bus.emit(HarnessFailed(
217
+ timestamp=time.monotonic(), node=node, tick=0,
218
+ reason=str(e),
219
+ failure_type="infrastructure",
220
+ ))
221
+ return Failure(str(e), type="infrastructure")
222
+
223
+ # LLM 原始响应 + usage 写入节点状态(审计链:NodeState.mutable_state);
224
+ # 重试开启时 usage 累计、_validation_attempts 记实际调用次数
192
225
  if state is not None:
193
- state["_llm_error"] = str(e)
194
- bus.emit(HarnessFailed(
226
+ state["_llm_raw"] = response.content
227
+ usage_acc = _merge_usage(usage_acc, response.usage)
228
+ state["_usage"] = dict(usage_acc)
229
+ if retrying:
230
+ state["_validation_attempts"] = attempts
231
+
232
+ # 3. 校验输出
233
+ bus.emit(LlmCallCompleted(
195
234
  timestamp=time.monotonic(), node=node, tick=0,
196
- reason=str(e),
197
- failure_type="infrastructure",
235
+ content_chars=len(response.content),
236
+ usage=response.usage,
237
+ finish_reason=response.finish_reason,
198
238
  ))
199
- return Failure(str(e), type="infrastructure")
200
239
 
201
- # 3. LLM 原始响应 + usage 写入节点状态(审计链:NodeState.mutable_state)
202
- if state is not None:
203
- state["_llm_raw"] = response.content
204
- state["_usage"] = dict(response.usage)
205
-
206
- # 3. 校验输出
207
- bus.emit(LlmCallCompleted(
208
- timestamp=time.monotonic(), node=node, tick=0,
209
- content_chars=len(response.content),
210
- usage=response.usage,
211
- finish_reason=response.finish_reason,
212
- ))
240
+ if validator is None:
241
+ return response.content
213
242
 
214
- if validator is not None:
215
243
  result = validator.validate(response.content)
216
- if isinstance(result, Failure):
244
+ if not isinstance(result, Failure):
217
245
  bus.emit(OutputValidated(
218
246
  timestamp=time.monotonic(), node=node, tick=0,
219
- passed=False,
220
- extracted=False,
221
- error=result.error,
247
+ passed=True,
248
+ extracted=_was_extracted(response.content, result),
249
+ error=None,
222
250
  ))
223
251
  return result
252
+
224
253
  bus.emit(OutputValidated(
225
254
  timestamp=time.monotonic(), node=node, tick=0,
226
- passed=True,
227
- extracted=_was_extracted(response.content, result),
228
- error=None,
255
+ passed=False,
256
+ extracted=False,
257
+ error=result.error,
229
258
  ))
230
- return result
231
259
 
232
- return response.content
260
+ if budget <= 0:
261
+ return result # 预算耗尽:最后一次的 Failure(type="llm")
262
+
263
+ # 带反馈重问:prompt 追加校验错误反馈段,LLM 参数原样保留
264
+ budget -= 1
265
+ retry_errors.append(result.error)
266
+ if state is not None:
267
+ state["_validation_retry_errors"] = list(retry_errors)
268
+ attempt_prompt = rendered + _VALIDATION_FEEDBACK.format(error=result.error)
269
+ if state is not None:
270
+ state["_prompt"] = attempt_prompt
271
+ bus.emit(PromptRendered(
272
+ timestamp=time.monotonic(), node=node, tick=0,
273
+ rendered=attempt_prompt,
274
+ ))
233
275
 
234
276
  return body
235
277
 
@@ -239,3 +281,15 @@ def _was_extracted(raw: str, result: Any) -> bool:
239
281
  if not isinstance(result, str):
240
282
  return True # JSON 解析必然是提取
241
283
  return raw.strip() != result.strip()
284
+
285
+
286
+ def _merge_usage(acc: dict[str, int], new: dict[str, int]) -> dict[str, int]:
287
+ """累计多次尝试的 token 用量:数值键求和,单侧缺键取另一侧。"""
288
+ merged = dict(acc)
289
+ for key, val in new.items():
290
+ prev = merged.get(key)
291
+ if isinstance(prev, int) and isinstance(val, int):
292
+ merged[key] = prev + val
293
+ else:
294
+ merged[key] = val
295
+ return merged
@@ -768,6 +768,31 @@ def timeline_to_dict(timeline: ReviewTimeline) -> dict[str, Any]:
768
768
  }
769
769
 
770
770
 
771
+ def node_run_summary(module_id: str, base_dir: Path | None = None) -> dict[str, dict[str, Any]] | None:
772
+ """按节点累计运行摘要:``{node: {fired_count, last_status, last_tick}}``。
773
+
774
+ firings 表全量累计(去重语义与 build_timeline 一致,append 序即 tick 序,
775
+ 末条即最新状态);未执行节点不在表内,消费方按全节点集叠加 0/None。
776
+ 监控面共用组合(Web 图叠加 + WS status 推送):推送携带累计结构,客户端
777
+ 纯覆盖即可,无需逐 tick 增量记账(轮询跳拍/断线重连不丢状态)。
778
+ db 缺失 / 读失败 → None(查询容错)。
779
+ """
780
+ tl = build_timeline(module_id, base_dir=base_dir)
781
+ if tl is None:
782
+ return None
783
+ by_node: dict[str, list[ReviewEntry]] = {}
784
+ for e in tl.entries:
785
+ by_node.setdefault(e.node, []).append(e)
786
+ return {
787
+ node: {
788
+ "fired_count": len(entries),
789
+ "last_status": entries[-1].status,
790
+ "last_tick": entries[-1].tick,
791
+ }
792
+ for node, entries in by_node.items()
793
+ }
794
+
795
+
771
796
  @dataclass
772
797
  class QueryValueResult:
773
798
  """细粒度查询结果:tick + 命中值 / 未命中时的可用键(MCP peek 用)。"""
@@ -483,7 +483,19 @@ class Module:
483
483
  self._write_phase("cancelled", error=runner.cancel_reason or "cancelled")
484
484
  return "cancelled"
485
485
  elif runner.status == RunStatus.FAILED:
486
- self._write_phase("aborted", error="all nodes failed")
486
+ # tickflow 0.3:FAILED = 饿死(有 pending 槽位/未触发 start 但无
487
+ # 可激发节点)。未点火清单由点火历史推导(firings_of 非空 = 点过
488
+ # 火;勿用 last_output——输出为 None 的已点火节点会误判,也勿用
489
+ # audit_log——keep_records=False 时为空表)。
490
+ fired = {
491
+ n for n in runner.graph.nodes if runner.run_state.firings_of(n)
492
+ }
493
+ unfired = sorted(set(runner.graph.nodes) - fired)
494
+ self._write_phase(
495
+ "aborted",
496
+ error="starved: work pending but nothing fireable; unfired: "
497
+ + (", ".join(unfired) if unfired else "(none)"),
498
+ )
487
499
  return "aborted"
488
500
  elif runner.status == RunStatus.RUNNING:
489
501
  # max_ticks 耗尽(pause 挂起发生在 run_until_idle 内部不返回,
@@ -65,6 +65,7 @@ class TaskDefinition:
65
65
  mode: str | None = None # "image" = 图像生成节点
66
66
  image_size: str | None = None # 图像尺寸覆盖
67
67
  image_dir: str | None = None # 图像落盘目录覆盖
68
+ validate_retries: int | None = None # 校验失败重试预算覆盖(None = 沿用注册 config)
68
69
  inputs: dict[str, str] | None = None
69
70
 
70
71
  @classmethod
@@ -89,6 +90,7 @@ class TaskDefinition:
89
90
  mode=d.get("mode"),
90
91
  image_size=d.get("image_size"),
91
92
  image_dir=d.get("image_dir"),
93
+ validate_retries=d.get("validate_retries"),
92
94
  inputs=d.get("inputs"),
93
95
  )
94
96
 
@@ -1,283 +1,285 @@
1
- # module_harness/submodule.py
2
- """SubModule — 类式 submodule 定义 + 嵌入/完整运行 + pack 导出。"""
3
-
4
- from __future__ import annotations
5
-
6
- import inspect
7
- import json
8
- import textwrap
9
- import uuid
10
- from dataclasses import asdict
11
- from pathlib import Path
12
- from typing import Any, Callable, Literal
13
-
14
- from llm import LLMConfig, create_llm_client
15
-
16
- from ..core.builtins import register_builtin_harnesses
17
- from ..cli.command import CommandConfig
18
- from ..core.config import HarnessConfig
19
- from ..infra.events import EventBus
20
- from .module import Module
21
- from ..core.registry import HarnessRegistry
22
- from .spec import SpecSchema, SpecValidationError, Tasklist
23
-
24
-
25
- def script(name: str):
26
- """类内 script 标记装饰器:标记函数,__init_subclass__ 时收集。
27
-
28
- 脚本是类体内普通函数(不绑定 self),与 @reg.script 语义一致。
29
- 注册名必须与函数名一致(函数名 = 注册名 = 打包文件名)。
30
- """
31
-
32
- def deco(fn: Callable) -> Callable:
33
- if name != fn.__name__:
34
- raise ValueError(
35
- f"script 注册名 '{name}' 与函数名 '{fn.__name__}' 不一致"
36
- )
37
- fn._submodule_script_name = name # type: ignore[attr-defined]
38
- return fn
39
-
40
- return deco
41
-
42
-
43
- class SubModule:
44
- """类式 submodule 定义。类属性 = 注册信息,@script 收集脚本。
45
-
46
- run() 内部组合 Module:注册 provides → 构造 Module → 运行。
47
- pack() 导出发布目录(module.json + harnesses/ + scripts/ + commands/)。
48
- """
49
-
50
- name: str = ""
51
- version: str = "0.1.0"
52
- description: str = ""
53
- spec_schema: SpecSchema = SpecSchema()
54
- default_spec: dict[str, Any] | None = None
55
- harnesses: list[HarnessConfig] = []
56
- commands: list[CommandConfig] = []
57
- requires: list[str] = []
58
- guards: list[tuple[str, Callable]] = [] # [(名字, 函数)],名字 = 注册名 = 打包文件名
59
- modules: dict[str, type["SubModule"]] = {} # submodule 节点引用表 {tasklist 名: 类}
60
- tasklist: Tasklist | None = None
61
- mode: Literal["persist", "fast"] = "persist"
62
- # 发布者声明轻量特性:"fast" = 快速模式(NullBackend 全内存,零落盘零 I/O,
63
- # D11);默认 "persist" 落盘到 .specmodule/runs/<run_id>/(D9)。
64
- _scripts: dict[str, Callable] = {}
65
-
66
- def __init_subclass__(cls, **kwargs: Any) -> None:
67
- super().__init_subclass__(**kwargs)
68
- inherited = dict(getattr(cls, "_scripts", {}))
69
- collected = {
70
- n: fn for n, fn in cls.__dict__.items()
71
- if callable(fn) and getattr(fn, "_submodule_script_name", None)
72
- }
73
- cls._scripts = inherited
74
- cls._scripts.update(collected)
75
- # 列表类属性按子类复制,防止子类就地修改污染父类注册
76
- for attr in ("harnesses", "commands", "requires", "guards"):
77
- if attr not in cls.__dict__:
78
- setattr(cls, attr, list(getattr(cls, attr)))
79
- # dict 类属性同理由:按子类复制,防止子类就地修改污染父类注册
80
- if "modules" not in cls.__dict__:
81
- setattr(cls, "modules", dict(getattr(cls, "modules")))
82
-
83
- def __init__(
84
- self,
85
- llm_client: Any = None,
86
- event_bus: EventBus | None = None,
87
- ) -> None:
88
- self._llm_client = llm_client
89
- self._event_bus = event_bus
90
-
91
- def _ensure_client(self) -> Any:
92
- """直接类使用(未注入 client)时从 env 懒创建。"""
93
- if self._llm_client is None:
94
- self._llm_client = create_llm_client(LLMConfig.from_env())
95
- return self._llm_client
96
-
97
- def _module_id(self) -> str:
98
- return f"{self.name}_{uuid.uuid4().hex[:6]}"
99
-
100
- def _build_registry(
101
- self,
102
- audit: bool,
103
- harness_overrides: dict[str, Any] | None = None,
104
- *,
105
- llm_client: Any = None,
106
- event_bus: EventBus | None = None,
107
- ) -> HarnessRegistry:
108
- # 事件投递与 keep_records/persist 解耦:宿主传了 event_bus 就始终投递
109
- # (与 audit 无关);未传则静默 EventBus.null()(嵌入零开销)。audit 只
110
- # 在 run() 里映射 keep_records。
111
- bus = event_bus if event_bus is not None else (self._event_bus or EventBus.null())
112
- client = llm_client if llm_client is not None else self._ensure_client()
113
- reg = HarnessRegistry(llm_client=client, event_bus=bus)
114
- for hc in self.harnesses:
115
- if not hc.name:
116
- raise ValueError(f"harnesses 配置缺少 name: {hc}")
117
- cfg = (
118
- self._apply_harness_overrides(hc, harness_overrides)
119
- if harness_overrides else hc
120
- )
121
- reg.harness(cfg.name, cfg)
122
- for cc in self.commands:
123
- if not cc.name:
124
- raise ValueError(f"commands 配置缺少 name: {cc}")
125
- reg.command(cc.name, cc)
126
- for sname, fn in self._scripts.items():
127
- reg.script(sname)(fn)
128
- for gname, gfn in self.guards:
129
- reg.guard(gname, gfn)
130
- register_builtin_harnesses(reg)
131
- return reg
132
-
133
- @staticmethod
134
- def _apply_harness_overrides(
135
- hc: HarnessConfig, overrides: dict[str, Any]
136
- ) -> HarnessConfig:
137
- """批量应用 LLM 覆盖(model/temperature/think/api_params)到单个 harness。"""
138
- api_params = dict(hc.api_params)
139
- if overrides.get("api_params"):
140
- api_params.update(overrides["api_params"])
141
- return HarnessConfig(
142
- name=hc.name,
143
- prompt_core=hc.prompt_core,
144
- prompt_modes=dict(hc.prompt_modes),
145
- output_format=hc.output_format,
146
- notdo=list(hc.notdo),
147
- model=overrides.get("model", hc.model),
148
- temperature=overrides.get("temperature", hc.temperature),
149
- think=overrides.get("think", hc.think),
150
- api_params=api_params,
151
- # 调用形态三字段原样携带——漏掉会让 image 模式在覆盖时静默降级为 text
152
- mode=hc.mode,
153
- image_size=hc.image_size,
154
- image_dir=hc.image_dir,
155
- )
156
-
157
- async def run(
158
- self,
159
- spec: dict[str, Any],
160
- *,
161
- tasklist: Tasklist | dict[str, Any] | None = None,
162
- audit: bool = False,
163
- max_ticks: int = 100,
164
- harness_overrides: dict[str, Any] | None = None,
165
- persist: bool | None = None,
166
- llm_client: Any = None,
167
- event_bus: EventBus | None = None,
168
- hooks: dict | None = None,
169
- ) -> list[Any]:
170
- """执行 submodule。
171
-
172
- - tasklist=None:用自身固定 tasklist,不触发一致性审核(发布前已验证)
173
- - 传入自定义 tasklist:与 Module 一致,校验 + 一致性审核
174
- - harness_overrides:{model/temperature/think/api_params} 覆盖,
175
- 构建 registry 时应用到 submodule 自身的全部 harness(不含内置
176
- harness)(submodule 节点 LLM 配置传播)
177
- - audit=False(默认):嵌入模式,keep_records=False;除非 mode="fast",
178
- 嵌入模式同样落盘(D11)
179
- - audit=True:keep_records 全开(全量审计轨迹)
180
- - 事件投递与 records/persist 解耦:构造传入 event_bus 时事件始终投递
181
- (与 audit 取值无关);未传则静默 EventBus.null()(嵌入零开销)。宿主
182
- 需失败原因等现场反馈时,传 event_bus 选择性订阅即可,无需开启审计
183
- - persist:False = 快速模式(NullBackend 全内存 + 无 status.json +
184
- 无 stream.log,零落盘零 I/O);None = 按 mode 决定("fast" → False,
185
- 否则 True)
186
- - llm_client/event_bus:覆盖实例级注入(宿主进程传入);None 用实例值
187
- - hooks:runner hooks 透传(观察通道,与 Module hooks 同语义)
188
- """
189
- errors = self.spec_schema.validate(spec)
190
- if errors:
191
- raise SpecValidationError(errors)
192
- if self.tasklist is None and tasklist is None:
193
- raise ValueError(f"submodule '{self.name}' 未定义 tasklist")
194
- use_tasklist = self.tasklist if tasklist is None else tasklist
195
- if isinstance(use_tasklist, dict):
196
- use_tasklist = Tasklist.from_json(use_tasklist)
197
- review = None if tasklist is None else "spec_tasklist_review"
198
- use_persist = persist if persist is not None else (self.mode != "fast")
199
- use_client = llm_client if llm_client is not None else self._ensure_client()
200
- use_bus = event_bus if event_bus is not None else self._event_bus
201
- reg = self._build_registry(audit, harness_overrides, llm_client=use_client, event_bus=use_bus)
202
- module = Module(
203
- spec=spec,
204
- tasklist=use_tasklist,
205
- llm_client=use_client,
206
- event_bus=use_bus,
207
- module_id=self._module_id(),
208
- module=self.name, # 溯源:status.json "module" 键(与 entry 路径一致)
209
- registry=reg,
210
- review_harness=review,
211
- keep_records=audit,
212
- persist=use_persist,
213
- status_file=use_persist,
214
- # fast 模式 = 零残留模式:三个落盘通道(run.sqlite /
215
- # status.json / stream.log)由 mode 一并关闭。stream_log 的
216
- # 默认 True 是刻意的(CLI 拉起的子进程零接线即可流式观测),
217
- # 故只在具名模式侧统一关——直接构造 Module 无"模式"概念,
218
- # 每个通道由调用方逐个点名。
219
- stream_log=use_persist,
220
- modules=self.modules,
221
- hooks=hooks,
222
- )
223
- return await module.run(max_ticks=max_ticks)
224
-
225
- def pack(self, out_dir: str | Path) -> Path:
226
- """导出发布目录:module.json + harnesses/ + scripts/ + commands/。
227
-
228
- scripts/*.py = 函数源码 + 必要 import(含 @script 装饰器行),
229
- 加载时 exec 后按函数名取注册,pack/load round-trip 无签名改写。
230
- """
231
- if not self.name:
232
- raise ValueError("submodule 缺少 name,无法打包")
233
- if self.tasklist is None:
234
- raise ValueError(f"submodule '{self.name}' 未定义 tasklist,无法打包")
235
- p = Path(out_dir)
236
- (p / "harnesses").mkdir(parents=True, exist_ok=True)
237
- (p / "scripts").mkdir(exist_ok=True)
238
- (p / "commands").mkdir(exist_ok=True)
239
- (p / "guards").mkdir(exist_ok=True)
240
- (p / "submodules").mkdir(exist_ok=True)
241
- manifest = {
242
- "name": self.name,
243
- "version": self.version,
244
- "description": self.description,
245
- "submodule": True,
246
- "spec_schema": asdict(self.spec_schema),
247
- "requires": list(self.requires),
248
- "modules": list(self.modules),
249
- "tasklist": self.tasklist.to_dict(),
250
- }
251
- if self.default_spec is not None:
252
- manifest["default_spec"] = dict(self.default_spec)
253
- (p / "module.json").write_text(
254
- json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
255
- )
256
- for hc in self.harnesses:
257
- if not hc.name:
258
- raise ValueError(f"harnesses 配置缺少 name: {hc}")
259
- (p / "harnesses" / f"{hc.name}.json").write_text(
260
- json.dumps(hc.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
261
- )
262
- for cc in self.commands:
263
- if not cc.name:
264
- raise ValueError(f"commands 配置缺少 name: {cc}")
265
- (p / "commands" / f"{cc.name}.json").write_text(
266
- json.dumps(cc.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
267
- )
268
- for sname, fn in self._scripts.items():
269
- src = textwrap.dedent(inspect.getsource(fn))
270
- header = "from __future__ import annotations\nfrom module_harness.model.submodule import script\n\n"
271
- (p / "scripts" / f"{sname}.py").write_text(header + src, encoding="utf-8")
272
- for gname, gfn in self.guards:
273
- if gname != gfn.__name__:
274
- raise ValueError(
275
- f"guard 注册名 '{gname}' 与函数名 '{gfn.__name__}' 不一致"
276
- "(注册名 = 打包文件名 = 加载键,与 @script 同约定)"
277
- )
278
- src = textwrap.dedent(inspect.getsource(gfn))
279
- header = "from __future__ import annotations\n\n"
280
- (p / "guards" / f"{gname}.py").write_text(header + src, encoding="utf-8")
281
- for mname, mcls in self.modules.items():
282
- mcls().pack(p / "submodules" / mname)
283
- return p
1
+ # module_harness/submodule.py
2
+ """SubModule — 类式 submodule 定义 + 嵌入/完整运行 + pack 导出。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import inspect
7
+ import json
8
+ import textwrap
9
+ import uuid
10
+ from dataclasses import asdict
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Literal
13
+
14
+ from llm import LLMConfig, create_llm_client
15
+
16
+ from ..core.builtins import register_builtin_harnesses
17
+ from ..cli.command import CommandConfig
18
+ from ..core.config import HarnessConfig
19
+ from ..infra.events import EventBus
20
+ from .module import Module
21
+ from ..core.registry import HarnessRegistry
22
+ from .spec import SpecSchema, SpecValidationError, Tasklist
23
+
24
+
25
+ def script(name: str):
26
+ """类内 script 标记装饰器:标记函数,__init_subclass__ 时收集。
27
+
28
+ 脚本是类体内普通函数(不绑定 self),与 @reg.script 语义一致。
29
+ 注册名必须与函数名一致(函数名 = 注册名 = 打包文件名)。
30
+ """
31
+
32
+ def deco(fn: Callable) -> Callable:
33
+ if name != fn.__name__:
34
+ raise ValueError(
35
+ f"script 注册名 '{name}' 与函数名 '{fn.__name__}' 不一致"
36
+ )
37
+ fn._submodule_script_name = name # type: ignore[attr-defined]
38
+ return fn
39
+
40
+ return deco
41
+
42
+
43
+ class SubModule:
44
+ """类式 submodule 定义。类属性 = 注册信息,@script 收集脚本。
45
+
46
+ run() 内部组合 Module:注册 provides → 构造 Module → 运行。
47
+ pack() 导出发布目录(module.json + harnesses/ + scripts/ + commands/)。
48
+ """
49
+
50
+ name: str = ""
51
+ version: str = "0.1.0"
52
+ description: str = ""
53
+ spec_schema: SpecSchema = SpecSchema()
54
+ default_spec: dict[str, Any] | None = None
55
+ harnesses: list[HarnessConfig] = []
56
+ commands: list[CommandConfig] = []
57
+ requires: list[str] = []
58
+ guards: list[tuple[str, Callable]] = [] # [(名字, 函数)],名字 = 注册名 = 打包文件名
59
+ modules: dict[str, type["SubModule"]] = {} # submodule 节点引用表 {tasklist 名: 类}
60
+ tasklist: Tasklist | None = None
61
+ mode: Literal["persist", "fast"] = "persist"
62
+ # 发布者声明轻量特性:"fast" = 快速模式(NullBackend 全内存,零落盘零 I/O,
63
+ # D11);默认 "persist" 落盘到 .specmodule/runs/<run_id>/(D9)。
64
+ _scripts: dict[str, Callable] = {}
65
+
66
+ def __init_subclass__(cls, **kwargs: Any) -> None:
67
+ super().__init_subclass__(**kwargs)
68
+ inherited = dict(getattr(cls, "_scripts", {}))
69
+ collected = {
70
+ n: fn for n, fn in cls.__dict__.items()
71
+ if callable(fn) and getattr(fn, "_submodule_script_name", None)
72
+ }
73
+ cls._scripts = inherited
74
+ cls._scripts.update(collected)
75
+ # 列表类属性按子类复制,防止子类就地修改污染父类注册
76
+ for attr in ("harnesses", "commands", "requires", "guards"):
77
+ if attr not in cls.__dict__:
78
+ setattr(cls, attr, list(getattr(cls, attr)))
79
+ # dict 类属性同理由:按子类复制,防止子类就地修改污染父类注册
80
+ if "modules" not in cls.__dict__:
81
+ setattr(cls, "modules", dict(getattr(cls, "modules")))
82
+
83
+ def __init__(
84
+ self,
85
+ llm_client: Any = None,
86
+ event_bus: EventBus | None = None,
87
+ ) -> None:
88
+ self._llm_client = llm_client
89
+ self._event_bus = event_bus
90
+
91
+ def _ensure_client(self) -> Any:
92
+ """直接类使用(未注入 client)时从 env 懒创建。"""
93
+ if self._llm_client is None:
94
+ self._llm_client = create_llm_client(LLMConfig.from_env())
95
+ return self._llm_client
96
+
97
+ def _module_id(self) -> str:
98
+ return f"{self.name}_{uuid.uuid4().hex[:6]}"
99
+
100
+ def _build_registry(
101
+ self,
102
+ audit: bool,
103
+ harness_overrides: dict[str, Any] | None = None,
104
+ *,
105
+ llm_client: Any = None,
106
+ event_bus: EventBus | None = None,
107
+ ) -> HarnessRegistry:
108
+ # 事件投递与 keep_records/persist 解耦:宿主传了 event_bus 就始终投递
109
+ # (与 audit 无关);未传则静默 EventBus.null()(嵌入零开销)。audit 只
110
+ # 在 run() 里映射 keep_records。
111
+ bus = event_bus if event_bus is not None else (self._event_bus or EventBus.null())
112
+ client = llm_client if llm_client is not None else self._ensure_client()
113
+ reg = HarnessRegistry(llm_client=client, event_bus=bus)
114
+ for hc in self.harnesses:
115
+ if not hc.name:
116
+ raise ValueError(f"harnesses 配置缺少 name: {hc}")
117
+ cfg = (
118
+ self._apply_harness_overrides(hc, harness_overrides)
119
+ if harness_overrides else hc
120
+ )
121
+ reg.harness(cfg.name, cfg)
122
+ for cc in self.commands:
123
+ if not cc.name:
124
+ raise ValueError(f"commands 配置缺少 name: {cc}")
125
+ reg.command(cc.name, cc)
126
+ for sname, fn in self._scripts.items():
127
+ reg.script(sname)(fn)
128
+ for gname, gfn in self.guards:
129
+ reg.guard(gname, gfn)
130
+ register_builtin_harnesses(reg)
131
+ return reg
132
+
133
+ @staticmethod
134
+ def _apply_harness_overrides(
135
+ hc: HarnessConfig, overrides: dict[str, Any]
136
+ ) -> HarnessConfig:
137
+ """批量应用 LLM 覆盖(model/temperature/think/api_params)到单个 harness。"""
138
+ api_params = dict(hc.api_params)
139
+ if overrides.get("api_params"):
140
+ api_params.update(overrides["api_params"])
141
+ return HarnessConfig(
142
+ name=hc.name,
143
+ prompt_core=hc.prompt_core,
144
+ prompt_modes=dict(hc.prompt_modes),
145
+ output_format=hc.output_format,
146
+ notdo=list(hc.notdo),
147
+ model=overrides.get("model", hc.model),
148
+ temperature=overrides.get("temperature", hc.temperature),
149
+ think=overrides.get("think", hc.think),
150
+ api_params=api_params,
151
+ # 调用形态三字段原样携带——漏掉会让 image 模式在覆盖时静默降级为 text
152
+ mode=hc.mode,
153
+ image_size=hc.image_size,
154
+ image_dir=hc.image_dir,
155
+ # 校验重试预算原样携带——漏掉会在覆盖时静默归零
156
+ validate_retries=hc.validate_retries,
157
+ )
158
+
159
+ async def run(
160
+ self,
161
+ spec: dict[str, Any],
162
+ *,
163
+ tasklist: Tasklist | dict[str, Any] | None = None,
164
+ audit: bool = False,
165
+ max_ticks: int = 100,
166
+ harness_overrides: dict[str, Any] | None = None,
167
+ persist: bool | None = None,
168
+ llm_client: Any = None,
169
+ event_bus: EventBus | None = None,
170
+ hooks: dict | None = None,
171
+ ) -> list[Any]:
172
+ """执行 submodule。
173
+
174
+ - tasklist=None:用自身固定 tasklist,不触发一致性审核(发布前已验证)
175
+ - 传入自定义 tasklist:与 Module 一致,校验 + 一致性审核
176
+ - harness_overrides:{model/temperature/think/api_params} 覆盖,
177
+ 构建 registry 时应用到 submodule 自身的全部 harness(不含内置
178
+ harness)(submodule 节点 LLM 配置传播)
179
+ - audit=False(默认):嵌入模式,keep_records=False;除非 mode="fast",
180
+ 嵌入模式同样落盘(D11)
181
+ - audit=True:keep_records 全开(全量审计轨迹)
182
+ - 事件投递与 records/persist 解耦:构造传入 event_bus 时事件始终投递
183
+ (与 audit 取值无关);未传则静默 EventBus.null()(嵌入零开销)。宿主
184
+ 需失败原因等现场反馈时,传 event_bus 选择性订阅即可,无需开启审计
185
+ - persist:False = 快速模式(NullBackend 全内存 + 无 status.json +
186
+ 无 stream.log,零落盘零 I/O);None = 按 mode 决定("fast" → False,
187
+ 否则 True)
188
+ - llm_client/event_bus:覆盖实例级注入(宿主进程传入);None 用实例值
189
+ - hooks:runner hooks 透传(观察通道,与 Module hooks 同语义)
190
+ """
191
+ errors = self.spec_schema.validate(spec)
192
+ if errors:
193
+ raise SpecValidationError(errors)
194
+ if self.tasklist is None and tasklist is None:
195
+ raise ValueError(f"submodule '{self.name}' 未定义 tasklist")
196
+ use_tasklist = self.tasklist if tasklist is None else tasklist
197
+ if isinstance(use_tasklist, dict):
198
+ use_tasklist = Tasklist.from_json(use_tasklist)
199
+ review = None if tasklist is None else "spec_tasklist_review"
200
+ use_persist = persist if persist is not None else (self.mode != "fast")
201
+ use_client = llm_client if llm_client is not None else self._ensure_client()
202
+ use_bus = event_bus if event_bus is not None else self._event_bus
203
+ reg = self._build_registry(audit, harness_overrides, llm_client=use_client, event_bus=use_bus)
204
+ module = Module(
205
+ spec=spec,
206
+ tasklist=use_tasklist,
207
+ llm_client=use_client,
208
+ event_bus=use_bus,
209
+ module_id=self._module_id(),
210
+ module=self.name, # 溯源:status.json "module" 键(与 entry 路径一致)
211
+ registry=reg,
212
+ review_harness=review,
213
+ keep_records=audit,
214
+ persist=use_persist,
215
+ status_file=use_persist,
216
+ # fast 模式 = 零残留模式:三个落盘通道(run.sqlite /
217
+ # status.json / stream.log)由 mode 一并关闭。stream_log 的
218
+ # 默认 True 是刻意的(CLI 拉起的子进程零接线即可流式观测),
219
+ # 故只在具名模式侧统一关——直接构造 Module 无"模式"概念,
220
+ # 每个通道由调用方逐个点名。
221
+ stream_log=use_persist,
222
+ modules=self.modules,
223
+ hooks=hooks,
224
+ )
225
+ return await module.run(max_ticks=max_ticks)
226
+
227
+ def pack(self, out_dir: str | Path) -> Path:
228
+ """导出发布目录:module.json + harnesses/ + scripts/ + commands/。
229
+
230
+ scripts/*.py = 函数源码 + 必要 import(含 @script 装饰器行),
231
+ 加载时 exec 后按函数名取注册,pack/load round-trip 无签名改写。
232
+ """
233
+ if not self.name:
234
+ raise ValueError("submodule 缺少 name,无法打包")
235
+ if self.tasklist is None:
236
+ raise ValueError(f"submodule '{self.name}' 未定义 tasklist,无法打包")
237
+ p = Path(out_dir)
238
+ (p / "harnesses").mkdir(parents=True, exist_ok=True)
239
+ (p / "scripts").mkdir(exist_ok=True)
240
+ (p / "commands").mkdir(exist_ok=True)
241
+ (p / "guards").mkdir(exist_ok=True)
242
+ (p / "submodules").mkdir(exist_ok=True)
243
+ manifest = {
244
+ "name": self.name,
245
+ "version": self.version,
246
+ "description": self.description,
247
+ "submodule": True,
248
+ "spec_schema": asdict(self.spec_schema),
249
+ "requires": list(self.requires),
250
+ "modules": list(self.modules),
251
+ "tasklist": self.tasklist.to_dict(),
252
+ }
253
+ if self.default_spec is not None:
254
+ manifest["default_spec"] = dict(self.default_spec)
255
+ (p / "module.json").write_text(
256
+ json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
257
+ )
258
+ for hc in self.harnesses:
259
+ if not hc.name:
260
+ raise ValueError(f"harnesses 配置缺少 name: {hc}")
261
+ (p / "harnesses" / f"{hc.name}.json").write_text(
262
+ json.dumps(hc.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
263
+ )
264
+ for cc in self.commands:
265
+ if not cc.name:
266
+ raise ValueError(f"commands 配置缺少 name: {cc}")
267
+ (p / "commands" / f"{cc.name}.json").write_text(
268
+ json.dumps(cc.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
269
+ )
270
+ for sname, fn in self._scripts.items():
271
+ src = textwrap.dedent(inspect.getsource(fn))
272
+ header = "from __future__ import annotations\nfrom module_harness.model.submodule import script\n\n"
273
+ (p / "scripts" / f"{sname}.py").write_text(header + src, encoding="utf-8")
274
+ for gname, gfn in self.guards:
275
+ if gname != gfn.__name__:
276
+ raise ValueError(
277
+ f"guard 注册名 '{gname}' 与函数名 '{gfn.__name__}' 不一致"
278
+ "(注册名 = 打包文件名 = 加载键,与 @script 同约定)"
279
+ )
280
+ src = textwrap.dedent(inspect.getsource(gfn))
281
+ header = "from __future__ import annotations\n\n"
282
+ (p / "guards" / f"{gname}.py").write_text(header + src, encoding="utf-8")
283
+ for mname, mcls in self.modules.items():
284
+ mcls().pack(p / "submodules" / mname)
285
+ return p
@@ -15,7 +15,13 @@ from tickflow.views import NodeView, Resolved
15
15
 
16
16
  from ..core.config import HarnessConfig
17
17
  from ..core.harness import Harness
18
- from .spec import Spec, Tasklist, TasklistTemplate, TaskDefinition
18
+ from .spec import (
19
+ ArtifactDecl,
20
+ Spec,
21
+ Tasklist,
22
+ TasklistTemplate,
23
+ TaskDefinition,
24
+ )
19
25
  from ..core.registry import HarnessRegistry
20
26
 
21
27
  # Regex to find the first node name in a flow line.
@@ -130,6 +136,17 @@ class TasklistValidator:
130
136
  else:
131
137
  errors.append(f"Task '{key}': 未知 type '{task.type}'")
132
138
 
139
+ if task.validate_retries is not None:
140
+ if isinstance(task.validate_retries, bool) or not isinstance(task.validate_retries, int):
141
+ errors.append(
142
+ f"Task '{key}': validate_retries 应为 int,"
143
+ f"得到 {type(task.validate_retries).__name__}"
144
+ )
145
+ elif task.validate_retries < 0:
146
+ errors.append(
147
+ f"Task '{key}': validate_retries 须 >= 0,得到 {task.validate_retries}"
148
+ )
149
+
133
150
  return errors
134
151
 
135
152
  @staticmethod
@@ -225,8 +242,13 @@ class Translator:
225
242
 
226
243
  # 检测 LLM 返回的包装格式 {"Tasks": {...}, "Flow": "..."}
227
244
  flow = template.tasklist.flow
245
+ artifacts: list[ArtifactDecl] = []
228
246
  if isinstance(tasks_dict, dict) and "Tasks" in tasks_dict:
229
247
  flow = tasks_dict.get("Flow", flow)
248
+ # 产物声明随包装格式透传(翻译脚本插值后的具体 glob 串)
249
+ raw_artifacts = tasks_dict.get("Artifacts", [])
250
+ if raw_artifacts:
251
+ artifacts = [ArtifactDecl.from_dict(a) for a in raw_artifacts]
230
252
  tasks_dict = tasks_dict["Tasks"]
231
253
 
232
254
  # 兼容 LLM 将 Tasks 输出为数组:转为 {A: ..., B: ...} 格式
@@ -250,7 +272,7 @@ class Translator:
250
272
  tasklist = Tasklist(tasks={
251
273
  key: TaskDefinition.from_dict(td) if isinstance(td, dict) else td
252
274
  for key, td in tasks_dict.items()
253
- }, flow=flow)
275
+ }, flow=flow, artifacts=artifacts)
254
276
 
255
277
  errors = TasklistValidator.validate(tasklist, self.reg)
256
278
  if errors:
@@ -281,6 +303,8 @@ class Translator:
281
303
  if prompt_core is not None:
282
304
  existing = self.reg.harness_config(harness_name)
283
305
  if existing is not None:
306
+ # 既有缺口(见 specs/2026-09-30-validation-retry-design.md 非目标):此处
307
+ # 重建 config 不携带 mode/image_*/validate_retries——修时四字段一起补
284
308
  overridden = HarnessConfig(
285
309
  prompt_core=prompt_core,
286
310
  prompt_modes=dict(existing.prompt_modes),
@@ -211,6 +211,13 @@ class TasklistTranslator:
211
211
  if task.image_dir is not None
212
212
  else existing.image_dir
213
213
  ),
214
+ # 校验重试预算:task 级覆盖,缺省沿用注册 config——漏掉会让
215
+ # tasklist 想调的预算静默回注册值
216
+ validate_retries=(
217
+ task.validate_retries
218
+ if task.validate_retries is not None
219
+ else existing.validate_retries
220
+ ),
214
221
  )
215
222
 
216
223
  # 解析常量引用:promptmode 的 "{spec.xxx}" 与 inputs 的常量 token
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specmodule"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "可审计、可调试、可完全掌控的 LLM 使用框架(tickflow + llm + module_harness)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,9 +21,9 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.13",
22
22
  ]
23
23
  dependencies = [
24
- # 0.2.0 起依赖 tickflow bind 时代 API(Bind/NodeView/GuardView/保留名约定);
25
- # 0.1 的 DictView 视图时代不满足
26
- "tickflow-py>=0.2.0,<0.3",
24
+ # 0.3.0 起依赖 tickflow FAILED 终态(饿死 run 判 FAILED,phase=aborted);
25
+ # 0.2 的空 tick 恒 IDLE 会把饿死 run 误报成 done
26
+ "tickflow-py>=0.3.0,<0.4",
27
27
  ]
28
28
 
29
29
  [project.optional-dependencies]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specmodule
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: 可审计、可调试、可完全掌控的 LLM 使用框架(tickflow + llm + module_harness)
5
5
  License: MIT
6
6
  Keywords: llm,workflow,petri-net,agent
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Python: >=3.10
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
- Requires-Dist: tickflow-py<0.3,>=0.2.0
18
+ Requires-Dist: tickflow-py<0.4,>=0.3.0
19
19
  Provides-Extra: anthropic
20
20
  Requires-Dist: anthropic; extra == "anthropic"
21
21
  Provides-Extra: openai
@@ -1,4 +1,4 @@
1
- tickflow-py<0.3,>=0.2.0
1
+ tickflow-py<0.4,>=0.3.0
2
2
 
3
3
  [anthropic]
4
4
  anthropic
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes