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.
- {specmodule-0.4.0/specmodule.egg-info → specmodule-0.5.0}/PKG-INFO +2 -2
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/call.py +8 -2
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/config.py +19 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/harness.py +103 -49
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/query.py +25 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/module.py +13 -1
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/spec.py +2 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/submodule.py +285 -283
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/translator.py +26 -2
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/graph_builder.py +7 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/pyproject.toml +4 -4
- {specmodule-0.4.0 → specmodule-0.5.0/specmodule.egg-info}/PKG-INFO +2 -2
- {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/requires.txt +1 -1
- {specmodule-0.4.0 → specmodule-0.5.0}/LICENSE +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/README.md +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/llm/__init__.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/llm/client.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/llm/config.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/llm/mock.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/__init__.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/__init__.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/__main__.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/cli.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/command.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/entry.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/loader.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/cli/scaffold.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/__init__.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/builtins.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/outputfmt.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/prompt.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/core/registry.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/__init__.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/artifacts.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/checkpoint.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/control.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/entry_pack.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/events.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/status.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/store.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/infra/stream.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/model/__init__.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/__init__.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/align.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/consistency.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/orchestrate/feed.py +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/templates/builtin/codereview.json +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/templates/builtin/docwrite.json +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/templates/builtin/summarize.json +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/module_harness/templates/builtin/translate.json +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/setup.cfg +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/SOURCES.txt +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/dependency_links.txt +0 -0
- {specmodule-0.4.0 → specmodule-0.5.0}/specmodule.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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
|
|
71
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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["
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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=
|
|
220
|
-
extracted=
|
|
221
|
-
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=
|
|
227
|
-
extracted=
|
|
228
|
-
error=
|
|
255
|
+
passed=False,
|
|
256
|
+
extracted=False,
|
|
257
|
+
error=result.error,
|
|
229
258
|
))
|
|
230
|
-
return result
|
|
231
259
|
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
- audit=
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if
|
|
193
|
-
raise
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if self.
|
|
234
|
-
raise ValueError(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
(p / "
|
|
239
|
-
(p / "
|
|
240
|
-
(p / "
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
"
|
|
249
|
-
"
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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.
|
|
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.
|
|
25
|
-
# 0.
|
|
26
|
-
"tickflow-py>=0.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|