specmodule 0.1.0__py3-none-any.whl
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.
- llm/__init__.py +21 -0
- llm/client.py +654 -0
- llm/config.py +213 -0
- module_harness/__init__.py +201 -0
- module_harness/align.py +39 -0
- module_harness/builtins.py +29 -0
- module_harness/checkpoint.py +336 -0
- module_harness/cli.py +1492 -0
- module_harness/command.py +115 -0
- module_harness/config.py +95 -0
- module_harness/consistency.py +123 -0
- module_harness/entry.py +74 -0
- module_harness/events.py +149 -0
- module_harness/feed.py +197 -0
- module_harness/graph_builder.py +334 -0
- module_harness/harness.py +181 -0
- module_harness/loader.py +215 -0
- module_harness/module.py +452 -0
- module_harness/outputfmt.py +139 -0
- module_harness/prompt.py +84 -0
- module_harness/query.py +216 -0
- module_harness/registry.py +180 -0
- module_harness/scaffold.py +404 -0
- module_harness/spec.py +209 -0
- module_harness/status.py +96 -0
- module_harness/store.py +482 -0
- module_harness/submodule.py +268 -0
- module_harness/templates/builtin/codereview.json +32 -0
- module_harness/templates/builtin/docwrite.json +30 -0
- module_harness/templates/builtin/summarize.json +24 -0
- module_harness/templates/builtin/translate.json +27 -0
- module_harness/translator.py +314 -0
- specmodule-0.1.0.dist-info/METADATA +321 -0
- specmodule-0.1.0.dist-info/RECORD +38 -0
- specmodule-0.1.0.dist-info/WHEEL +5 -0
- specmodule-0.1.0.dist-info/entry_points.txt +2 -0
- specmodule-0.1.0.dist-info/licenses/LICENSE +21 -0
- specmodule-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# module_harness/command.py
|
|
2
|
+
"""Command 节点 — 一行 shell 命令即一个 tickflow body。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import dataclasses
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from tickflow import Failure
|
|
13
|
+
from tickflow.views import DictView
|
|
14
|
+
|
|
15
|
+
from .events import (
|
|
16
|
+
EventBus,
|
|
17
|
+
CommandStarted,
|
|
18
|
+
CommandCompleted,
|
|
19
|
+
CommandFailed,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CommandConfig:
|
|
25
|
+
"""shell 命令配置。"""
|
|
26
|
+
|
|
27
|
+
command: str # shell 命令字符串
|
|
28
|
+
timeout: float = 60.0 # 超时秒数
|
|
29
|
+
cwd: str | None = None # 工作目录
|
|
30
|
+
env: dict[str, str] | None = None # 额外环境变量
|
|
31
|
+
capture_output: bool = True
|
|
32
|
+
shell: bool = True
|
|
33
|
+
|
|
34
|
+
name: str | None = None
|
|
35
|
+
"""注册名。submodule 的 commands 列表中必须提供。"""
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return dataclasses.asdict(self)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls, d: dict[str, Any]) -> "CommandConfig":
|
|
42
|
+
return cls(**d)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Command:
|
|
46
|
+
"""持有 CommandConfig + EventBus,类似 Harness 类。"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: CommandConfig, event_bus: EventBus) -> None:
|
|
49
|
+
self.config = config
|
|
50
|
+
self.bus = event_bus
|
|
51
|
+
|
|
52
|
+
def build_body(self, *, timeout: float | None = None, cwd: str | None = None):
|
|
53
|
+
"""返回一个 sync body callable。
|
|
54
|
+
|
|
55
|
+
body 执行流程:
|
|
56
|
+
1. emit CommandStarted
|
|
57
|
+
2. subprocess.run(command, ...)
|
|
58
|
+
3. 成功 → emit CommandCompleted → return {"stdout", "stderr", "returncode"}
|
|
59
|
+
4. 异常 → emit CommandFailed → return Failure(type="llm")
|
|
60
|
+
"""
|
|
61
|
+
config = self.config
|
|
62
|
+
bus = self.bus
|
|
63
|
+
final_timeout = timeout if timeout is not None else config.timeout
|
|
64
|
+
final_cwd = cwd if cwd is not None else config.cwd
|
|
65
|
+
|
|
66
|
+
def body(view: DictView):
|
|
67
|
+
node = view.node
|
|
68
|
+
bus.emit(CommandStarted(
|
|
69
|
+
timestamp=time.monotonic(), node=node, tick=0,
|
|
70
|
+
))
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
config.command,
|
|
75
|
+
shell=config.shell,
|
|
76
|
+
timeout=final_timeout,
|
|
77
|
+
cwd=final_cwd,
|
|
78
|
+
env=config.env,
|
|
79
|
+
capture_output=config.capture_output,
|
|
80
|
+
text=True,
|
|
81
|
+
# 显式 UTF-8 + replace:不依赖 locale 默认编码,非 UTF-8 控制台
|
|
82
|
+
# (中文 Windows GBK)的子进程输出不炸 reader 线程(D1)
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
errors="replace",
|
|
85
|
+
)
|
|
86
|
+
except subprocess.TimeoutExpired as e:
|
|
87
|
+
error_msg = f"命令超时 ({final_timeout}s): {e}"
|
|
88
|
+
bus.emit(CommandFailed(
|
|
89
|
+
timestamp=time.monotonic(), node=node, tick=0,
|
|
90
|
+
error=error_msg,
|
|
91
|
+
))
|
|
92
|
+
return Failure(error_msg, type="llm")
|
|
93
|
+
except Exception as e:
|
|
94
|
+
error_msg = f"命令执行失败: {e}"
|
|
95
|
+
bus.emit(CommandFailed(
|
|
96
|
+
timestamp=time.monotonic(), node=node, tick=0,
|
|
97
|
+
error=error_msg,
|
|
98
|
+
))
|
|
99
|
+
return Failure(error_msg, type="llm")
|
|
100
|
+
|
|
101
|
+
stdout = result.stdout or ""
|
|
102
|
+
stderr = result.stderr or ""
|
|
103
|
+
|
|
104
|
+
bus.emit(CommandCompleted(
|
|
105
|
+
timestamp=time.monotonic(), node=node, tick=0,
|
|
106
|
+
stdout=stdout, stderr=stderr, returncode=result.returncode,
|
|
107
|
+
))
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"stdout": stdout,
|
|
111
|
+
"stderr": stderr,
|
|
112
|
+
"returncode": result.returncode,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return body
|
module_harness/config.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# module_harness/config.py
|
|
2
|
+
"""HarnessConfig — harness 节点的完整配置数据模型。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import dataclasses
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .outputfmt import OutputFormat
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class HarnessConfig:
|
|
15
|
+
"""harness 节点的完整配置。
|
|
16
|
+
|
|
17
|
+
对标 tasklist 中 Task 定义的字段。
|
|
18
|
+
翻译层使用 :meth:`from_task_definition` 直接构造。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# ── 三层 prompt ──
|
|
22
|
+
prompt_core: str
|
|
23
|
+
"""Layer 1:核心提示词模板,含 {key} 占位符。"""
|
|
24
|
+
|
|
25
|
+
prompt_modes: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
"""Layer 2:动态 prompt 选项集。{"formal": "...", "casual": "..."}。"""
|
|
27
|
+
|
|
28
|
+
# ── 输出约束 ──
|
|
29
|
+
output_format: OutputFormat | None = None
|
|
30
|
+
"""输出格式约束(None = 不约束)。"""
|
|
31
|
+
|
|
32
|
+
notdo: list[str] = field(default_factory=list)
|
|
33
|
+
"""否定性约束列表,拼入 system prompt。"""
|
|
34
|
+
|
|
35
|
+
# ── LLM 参数(Task 可逐项覆盖)──
|
|
36
|
+
model: str | None = None
|
|
37
|
+
temperature: float | None = None
|
|
38
|
+
think: bool | dict | None = None
|
|
39
|
+
|
|
40
|
+
# ── SDK 透传参数 ──
|
|
41
|
+
api_params: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
"""透传给 LLM SDK 的额外参数。按 API 官方格式写入,如
|
|
43
|
+
``{"temperature": 0.3, "thinking": {"type": "enabled"}}``。
|
|
44
|
+
会与 temperature / think 等独立字段合并(api_params 优先级更高)。"""
|
|
45
|
+
|
|
46
|
+
# ── 注册信息(submodule 用)──
|
|
47
|
+
name: str | None = None
|
|
48
|
+
"""注册名。submodule 的 harnesses 列表中必须提供。"""
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
"""序列化为 JSON 可写 dict(含 output_format)。"""
|
|
52
|
+
return dataclasses.asdict(self)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, d: dict[str, Any]) -> "HarnessConfig":
|
|
56
|
+
"""从 to_dict() 输出还原。"""
|
|
57
|
+
data = dict(d)
|
|
58
|
+
of = data.pop("output_format", None)
|
|
59
|
+
if of is not None:
|
|
60
|
+
data["output_format"] = OutputFormat(**of)
|
|
61
|
+
return cls(**data)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_task_definition(cls, task: dict[str, Any]) -> "HarnessConfig":
|
|
65
|
+
"""从 tasklist Task dict 构造 HarnessConfig。
|
|
66
|
+
|
|
67
|
+
task 中预期的键:
|
|
68
|
+
- prompt_core → Layer 1
|
|
69
|
+
- prompt_modes → Layer 2
|
|
70
|
+
- outputformat → 输出格式(dict,含 type/schema/instruction)
|
|
71
|
+
- notdo → 否定性约束列表
|
|
72
|
+
- model → LLM 模型覆盖
|
|
73
|
+
- temperature → 温度覆盖
|
|
74
|
+
- think → 扩展思考覆盖
|
|
75
|
+
- api_params → SDK 透传参数(dict)
|
|
76
|
+
"""
|
|
77
|
+
output_format = None
|
|
78
|
+
of_data = task.get("outputformat")
|
|
79
|
+
if of_data is not None:
|
|
80
|
+
output_format = OutputFormat(
|
|
81
|
+
type=of_data["type"],
|
|
82
|
+
schema=of_data.get("schema"),
|
|
83
|
+
instruction=of_data.get("instruction"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return cls(
|
|
87
|
+
prompt_core=task["prompt_core"],
|
|
88
|
+
prompt_modes=task.get("prompt_modes", {}),
|
|
89
|
+
output_format=output_format,
|
|
90
|
+
notdo=task.get("notdo", []),
|
|
91
|
+
model=task.get("model"),
|
|
92
|
+
temperature=task.get("temperature"),
|
|
93
|
+
think=task.get("think"),
|
|
94
|
+
api_params=task.get("api_params", {}),
|
|
95
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# module_harness/consistency.py
|
|
2
|
+
"""一致性审核 — spec + tasklist 语义一致性检查。
|
|
3
|
+
|
|
4
|
+
独立于翻译通道:审核不经过模板,直接调用注册的审核 harness body(不走 tickflow)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from tickflow import Failure
|
|
14
|
+
from tickflow.views import DictView, Resolved
|
|
15
|
+
|
|
16
|
+
from .config import HarnessConfig
|
|
17
|
+
from .outputfmt import OutputFormat
|
|
18
|
+
from .registry import HarnessRegistry
|
|
19
|
+
from .spec import Spec, Tasklist
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ConsistencyReport:
|
|
24
|
+
"""一致性审核结果。"""
|
|
25
|
+
|
|
26
|
+
consistent: bool
|
|
27
|
+
suggestions: str
|
|
28
|
+
raw: str # LLM 原始输出(审计链)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConsistencyError(ValueError):
|
|
32
|
+
"""一致性审核未通过。携带完整 report,str() 输出问题描述。"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, report: ConsistencyReport) -> None:
|
|
35
|
+
self.report = report
|
|
36
|
+
super().__init__(f"一致性审核未通过: {report.suggestions}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
REVIEW_HARNESS_CONFIG = HarnessConfig(
|
|
40
|
+
prompt_core=(
|
|
41
|
+
"你是一致性审核器。判断给定 tasklist 是否能实现 spec 的目标。\n"
|
|
42
|
+
"审核要点:\n"
|
|
43
|
+
"1. spec 的每个目标/需求是否被 tasklist 中的任务覆盖\n"
|
|
44
|
+
"2. task 中引用的字段({spec.xxx}、inputs)在 spec 中是否存在\n"
|
|
45
|
+
"3. flow 是否可达、是否有死路或未定义节点\n"
|
|
46
|
+
"spec: {spec}\n"
|
|
47
|
+
"tasklist: {tasklist}\n"
|
|
48
|
+
'输出 JSON:{"consistent": true/false, "suggestions": "..."}'
|
|
49
|
+
),
|
|
50
|
+
output_format=OutputFormat(type="json_object"),
|
|
51
|
+
temperature=0.1,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def register_review_harness(
|
|
56
|
+
reg: HarnessRegistry, name: str = "spec_tasklist_review"
|
|
57
|
+
) -> None:
|
|
58
|
+
"""注册内置一致性审核 harness(默认名 spec_tasklist_review)。"""
|
|
59
|
+
reg.harness(name, REVIEW_HARNESS_CONFIG)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ConsistencyReviewer:
|
|
63
|
+
"""调用审核 harness body,返回 ConsistencyReport。"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self, registry: HarnessRegistry, harness_name: str = "spec_tasklist_review"
|
|
67
|
+
) -> None:
|
|
68
|
+
self.reg = registry
|
|
69
|
+
self.harness_name = harness_name
|
|
70
|
+
|
|
71
|
+
async def review(self, spec: Spec, tasklist: Tasklist) -> ConsistencyReport:
|
|
72
|
+
"""执行一致性审核。审核失败(LLM 错误/输出不合法)抛 ValueError。"""
|
|
73
|
+
if self.reg.harness_config(self.harness_name) is None:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"审核 harness '{self.harness_name}' 未注册。"
|
|
76
|
+
f"请先调用 register_review_harness(reg) 注册内置审核器,"
|
|
77
|
+
f"或自行 reg.harness('{self.harness_name}', ...)。"
|
|
78
|
+
)
|
|
79
|
+
body = self.reg.get_body(self.harness_name)
|
|
80
|
+
|
|
81
|
+
tasklist_dict = tasklist.to_dict()
|
|
82
|
+
state: dict[str, Any] = {}
|
|
83
|
+
view = DictView(
|
|
84
|
+
{
|
|
85
|
+
"spec": Resolved(value=spec.to_dict(), k=None),
|
|
86
|
+
"tasklist": Resolved(
|
|
87
|
+
value=json.dumps(tasklist_dict, ensure_ascii=False), k=None
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
state=state,
|
|
91
|
+
node="__review__",
|
|
92
|
+
)
|
|
93
|
+
result = await body(view)
|
|
94
|
+
|
|
95
|
+
if isinstance(result, Failure):
|
|
96
|
+
raise ValueError(f"审核 harness 返回 Failure: {result.error}")
|
|
97
|
+
|
|
98
|
+
if isinstance(result, str):
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(result)
|
|
101
|
+
except json.JSONDecodeError as e:
|
|
102
|
+
raise ValueError(f"审核输出不是合法 JSON: {e}") from e
|
|
103
|
+
elif isinstance(result, dict):
|
|
104
|
+
data = result
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(f"审核输出类型异常: {type(result).__name__}")
|
|
107
|
+
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
raise ValueError(f"审核输出必须是 JSON 对象: {data!r}")
|
|
110
|
+
|
|
111
|
+
consistent = data.get("consistent")
|
|
112
|
+
suggestions = data.get("suggestions") # 缺字段 → None → 下方 isinstance 校验抛错
|
|
113
|
+
if not isinstance(consistent, bool):
|
|
114
|
+
raise ValueError(f"审核输出缺少合法的 'consistent' 布尔字段: {data!r}")
|
|
115
|
+
if not isinstance(suggestions, str):
|
|
116
|
+
raise ValueError(f"审核输出 'suggestions' 必须是字符串: {data!r}")
|
|
117
|
+
|
|
118
|
+
raw = state.get("_llm_raw")
|
|
119
|
+
if raw is None:
|
|
120
|
+
raw = result if isinstance(result, str) else json.dumps(data, ensure_ascii=False)
|
|
121
|
+
return ConsistencyReport(
|
|
122
|
+
consistent=consistent, suggestions=suggestions, raw=raw
|
|
123
|
+
)
|
module_harness/entry.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# module_harness/entry.py
|
|
2
|
+
"""模块入口合约:ModuleEntry + 目录发现(roadmap Phase 0,CLI 使用)。
|
|
3
|
+
|
|
4
|
+
一个 module 一个 py 文件(``modules/<name>.py``),文件内声明模块级
|
|
5
|
+
``entry`` 变量。未来 ``init`` 脚手架可据此生成实例骨架
|
|
6
|
+
(scripts/harnesses/submodules/modules 分目录)。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from .events import EventBus
|
|
18
|
+
from .registry import HarnessRegistry
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ModuleEntry:
|
|
25
|
+
"""模块入口声明:模板 + submodule + registry 构建 + 默认 spec/schema。"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
description: str
|
|
29
|
+
templates: dict[str, dict] # {模板名: TasklistTemplate JSON}
|
|
30
|
+
submodules: dict[str, type] = field(default_factory=dict) # {tasklist 名: SubModule 类}
|
|
31
|
+
build_registry: Callable[[Any, str, EventBus], HarnessRegistry] | None = None
|
|
32
|
+
default_spec: dict[str, Any] | None = None
|
|
33
|
+
default_template: str | None = None
|
|
34
|
+
spec_schema: dict[str, str] | None = None # {字段: 类型名}
|
|
35
|
+
review_harness: str | None = "spec_tasklist_review"
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if self.default_template is not None and self.default_template not in self.templates:
|
|
39
|
+
raise ValueError(f"default_template '{self.default_template}' 不在 templates 中")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def discover_modules(modules_dir: Path | str) -> dict[str, ModuleEntry]:
|
|
43
|
+
"""扫描 ``modules_dir/*.py``,导入后收集模块级 ``entry`` 变量。
|
|
44
|
+
|
|
45
|
+
缺 ``entry`` 或类型不符的文件跳过并 log 警告;同名冲突后者覆盖 + 警告;
|
|
46
|
+
文件导入抛异常跳过 + log exception(不阻断整体发现)。
|
|
47
|
+
"""
|
|
48
|
+
out: dict[str, ModuleEntry] = {}
|
|
49
|
+
d = Path(modules_dir)
|
|
50
|
+
if not d.is_dir():
|
|
51
|
+
return out
|
|
52
|
+
for p in sorted(d.glob("*.py")):
|
|
53
|
+
if p.name.startswith("_"):
|
|
54
|
+
continue
|
|
55
|
+
spec = importlib.util.spec_from_file_location(
|
|
56
|
+
f"specmodule_module_{p.stem}", p
|
|
57
|
+
)
|
|
58
|
+
if spec is None or spec.loader is None:
|
|
59
|
+
log.warning("无法加载模块入口文件(跳过): %s", p)
|
|
60
|
+
continue
|
|
61
|
+
mod = importlib.util.module_from_spec(spec)
|
|
62
|
+
try:
|
|
63
|
+
spec.loader.exec_module(mod)
|
|
64
|
+
except Exception:
|
|
65
|
+
log.exception("模块入口加载失败(跳过): %s", p)
|
|
66
|
+
continue
|
|
67
|
+
entry = getattr(mod, "entry", None)
|
|
68
|
+
if not isinstance(entry, ModuleEntry):
|
|
69
|
+
log.warning("文件 %s 缺少 entry 变量(ModuleEntry)——跳过", p)
|
|
70
|
+
continue
|
|
71
|
+
if entry.name in out:
|
|
72
|
+
log.warning("模块名 '%s' 重复(%s 覆盖)", entry.name, p)
|
|
73
|
+
out[entry.name] = entry
|
|
74
|
+
return out
|
module_harness/events.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# module_harness/events.py
|
|
2
|
+
"""EventBus 与 harness/script 事件类型定义。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Harness 事件基类 ──────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class HarnessEvent:
|
|
18
|
+
timestamp: float
|
|
19
|
+
node: str
|
|
20
|
+
tick: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PromptRendered(HarnessEvent):
|
|
25
|
+
rendered: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class LlmCallStarted(HarnessEvent):
|
|
30
|
+
model: str
|
|
31
|
+
prompt_chars: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class LlmToken(HarnessEvent):
|
|
36
|
+
chunk: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class LlmCallCompleted(HarnessEvent):
|
|
41
|
+
content_chars: int
|
|
42
|
+
usage: dict[str, int]
|
|
43
|
+
finish_reason: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class OutputValidated(HarnessEvent):
|
|
48
|
+
passed: bool
|
|
49
|
+
extracted: bool
|
|
50
|
+
error: str | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class HarnessFailed(HarnessEvent):
|
|
55
|
+
reason: str
|
|
56
|
+
failure_type: str # "llm" | "infrastructure"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ConsistencyReviewed(HarnessEvent):
|
|
61
|
+
"""一致性审核事件(spec + 自定义 tasklist 通道)。"""
|
|
62
|
+
consistent: bool
|
|
63
|
+
suggestions: str
|
|
64
|
+
raw: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── Script 事件基类 ──────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ScriptEvent:
|
|
71
|
+
timestamp: float
|
|
72
|
+
node: str
|
|
73
|
+
tick: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ScriptStarted(ScriptEvent):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ScriptCompleted(ScriptEvent):
|
|
83
|
+
output_type: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ScriptFailed(ScriptEvent):
|
|
88
|
+
error: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Command 事件类型 ────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class CommandStarted(HarnessEvent):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class CommandCompleted(HarnessEvent):
|
|
100
|
+
stdout: str
|
|
101
|
+
stderr: str
|
|
102
|
+
returncode: int
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class CommandFailed(HarnessEvent):
|
|
107
|
+
error: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── EventBus ──────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
class EventBus:
|
|
113
|
+
"""同步发布/订阅。
|
|
114
|
+
|
|
115
|
+
回调异常 → 记录日志并吞掉(与 tickflow hooks 行为一致)。
|
|
116
|
+
使用 ``EventBus.null()`` 获取静默实例(嵌入式场景)。
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self) -> None:
|
|
120
|
+
self._subscribers: dict[type, list[Callable]] = {}
|
|
121
|
+
|
|
122
|
+
def subscribe(self, event_type: type, callback: Callable) -> None:
|
|
123
|
+
"""为某个事件类型注册回调。"""
|
|
124
|
+
self._subscribers.setdefault(event_type, []).append(callback)
|
|
125
|
+
|
|
126
|
+
def emit(self, event: HarnessEvent | ScriptEvent) -> None:
|
|
127
|
+
"""发布事件到所有匹配类型的订阅者。"""
|
|
128
|
+
for event_type, callbacks in self._subscribers.items():
|
|
129
|
+
if isinstance(event, event_type):
|
|
130
|
+
for cb in callbacks:
|
|
131
|
+
try:
|
|
132
|
+
cb(event)
|
|
133
|
+
except Exception:
|
|
134
|
+
log.exception("EventBus callback raised; swallowed")
|
|
135
|
+
|
|
136
|
+
def on(self, event_type: type):
|
|
137
|
+
"""装饰器方式订阅: ``@bus.on(LlmToken) def handle(e): ...``"""
|
|
138
|
+
def deco(fn: Callable) -> Callable:
|
|
139
|
+
self.subscribe(event_type, fn)
|
|
140
|
+
return fn
|
|
141
|
+
return deco
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def null() -> "EventBus":
|
|
145
|
+
"""返回一个静默 EventBus,emit 无操作。"""
|
|
146
|
+
bus = EventBus()
|
|
147
|
+
# 直接替换 emit 方法为 no-op,保留 subscribe 语义但无实际操作
|
|
148
|
+
bus.emit = lambda event: None # type: ignore[method-assign]
|
|
149
|
+
return bus
|