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.
@@ -0,0 +1,181 @@
1
+ """Harness 类 — 配置持有 + async body 生成。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import time
7
+ from typing import Any
8
+
9
+ from tickflow import Failure
10
+ from tickflow.views import DictView, Missing
11
+
12
+ from .config import HarnessConfig
13
+ from .prompt import PromptRenderer
14
+ from .outputfmt import OutputValidator
15
+ from .events import (
16
+ EventBus,
17
+ PromptRendered,
18
+ LlmCallStarted,
19
+ LlmToken,
20
+ LlmCallCompleted,
21
+ OutputValidated,
22
+ HarnessFailed,
23
+ )
24
+
25
+
26
+ class Harness:
27
+ """持有 HarnessConfig + LLM 客户端 + EventBus。
28
+
29
+ 由 HarnessRegistry 管理,用户不直接使用。
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ config: HarnessConfig,
35
+ llm_client: Any,
36
+ event_bus: EventBus,
37
+ ) -> None:
38
+ self.config = config
39
+ self.llm = llm_client
40
+ self.bus = event_bus
41
+ self._renderer = PromptRenderer(config)
42
+
43
+ def build_body(
44
+ self,
45
+ *,
46
+ promptmode: str | None = None,
47
+ prompt_extra: str | None = None,
48
+ spec_inputs: dict[str, Any] | None = None,
49
+ input_aliases: dict[str, str] | None = None,
50
+ ):
51
+ """返回一个 async body callable。
52
+
53
+ ``spec_inputs``:spec 字段常量({field_name: value}),
54
+ 渲染时作为占位符兜底值(graph_builder 解析 ``{spec.xxx}`` 后注入)。
55
+
56
+ ``input_aliases``:跨节点输入别名({field_name: producer})。
57
+ task.inputs 的 field 名在 view 中解析为 Missing(field 不是节点名),
58
+ 运行时把 producer 的实际值合并进 extra_values,使 prompt 的
59
+ ``{field}`` 占位符能渲染(与 {spec.xxx} 兜底同一机制)。
60
+
61
+ body 执行流程:
62
+ 1. 渲染三层 prompt
63
+ 2. 调 LLM(流式 token 经 on_token 发射)
64
+ 3. 校验输出格式
65
+ 4. 发事件
66
+ """
67
+ config = self.config
68
+ llm = self.llm
69
+ bus = self.bus
70
+ renderer = self._renderer
71
+ validator = OutputValidator(config.output_format) if config.output_format else None
72
+
73
+ async def body(view: DictView) -> Any:
74
+ node = view.node
75
+ now = time.monotonic()
76
+ # view.state 可为 None(bare DictView);写入后自动进入 NodeState.mutable_state 审计
77
+ state = view.state
78
+
79
+ # 1. 渲染 prompt
80
+ extra = dict(spec_inputs) if spec_inputs else {}
81
+ if input_aliases:
82
+ for field, producer in input_aliases.items():
83
+ if field in extra:
84
+ continue
85
+ try:
86
+ val = view[producer].value
87
+ except (KeyError, AttributeError):
88
+ continue
89
+ if val is not Missing:
90
+ extra[field] = val
91
+ rendered = renderer.render(
92
+ view,
93
+ promptmode=promptmode,
94
+ prompt_extra=prompt_extra,
95
+ extra_values=extra,
96
+ )
97
+ if state is not None:
98
+ state["_prompt"] = rendered
99
+ bus.emit(PromptRendered(
100
+ timestamp=time.monotonic(), node=node, tick=0,
101
+ rendered=rendered,
102
+ ))
103
+
104
+ # 2. 调用 LLM
105
+ bus.emit(LlmCallStarted(
106
+ timestamp=time.monotonic(), node=node, tick=0,
107
+ model=config.model or "default",
108
+ prompt_chars=len(rendered),
109
+ ))
110
+
111
+ def on_token(chunk: str) -> None:
112
+ bus.emit(LlmToken(
113
+ timestamp=time.monotonic(), node=node, tick=0,
114
+ chunk=chunk,
115
+ ))
116
+
117
+ try:
118
+ from llm.client import LLMError
119
+
120
+ # notdo 由 LLM client 内部通过 _build_system() 拼入 system prompt
121
+ response = await llm.complete(
122
+ prompt=rendered,
123
+ model=config.model,
124
+ temperature=config.temperature,
125
+ think=config.think,
126
+ output_format=dataclasses.asdict(config.output_format) if config.output_format else None,
127
+ notdo=config.notdo if config.notdo else None,
128
+ on_token=on_token,
129
+ api_params=config.api_params if config.api_params else None,
130
+ )
131
+ except LLMError as e:
132
+ if state is not None:
133
+ state["_llm_error"] = str(e)
134
+ bus.emit(HarnessFailed(
135
+ timestamp=time.monotonic(), node=node, tick=0,
136
+ reason=str(e),
137
+ failure_type="infrastructure",
138
+ ))
139
+ return Failure(str(e), type="infrastructure")
140
+
141
+ # 3. LLM 原始响应 + usage 写入节点状态(审计链:NodeState.mutable_state)
142
+ if state is not None:
143
+ state["_llm_raw"] = response.content
144
+ state["_usage"] = dict(response.usage)
145
+
146
+ # 3. 校验输出
147
+ bus.emit(LlmCallCompleted(
148
+ timestamp=time.monotonic(), node=node, tick=0,
149
+ content_chars=len(response.content),
150
+ usage=response.usage,
151
+ finish_reason=response.finish_reason,
152
+ ))
153
+
154
+ if validator is not None:
155
+ result = validator.validate(response.content)
156
+ if isinstance(result, Failure):
157
+ bus.emit(OutputValidated(
158
+ timestamp=time.monotonic(), node=node, tick=0,
159
+ passed=False,
160
+ extracted=False,
161
+ error=result.error,
162
+ ))
163
+ return result
164
+ bus.emit(OutputValidated(
165
+ timestamp=time.monotonic(), node=node, tick=0,
166
+ passed=True,
167
+ extracted=_was_extracted(response.content, result),
168
+ error=None,
169
+ ))
170
+ return result
171
+
172
+ return response.content
173
+
174
+ return body
175
+
176
+
177
+ def _was_extracted(raw: str, result: Any) -> bool:
178
+ """简单判断原始内容是否经过了提取处理(内容不直接相等)。"""
179
+ if not isinstance(result, str):
180
+ return True # JSON 解析必然是提取
181
+ return raw.strip() != result.strip()
@@ -0,0 +1,215 @@
1
+ # module_harness/loader.py
2
+ """ModuleLoader — 加载发布目录为 SubModule 实例(第二层用户入口)。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from llm import LLMConfig, create_llm_client
11
+
12
+ from .builtins import BUILTIN_HARNESS_NAMES
13
+ from .command import CommandConfig
14
+ from .config import HarnessConfig
15
+ from .events import EventBus
16
+ from .spec import SpecSchema, Tasklist
17
+ from .submodule import SubModule
18
+
19
+
20
+ class ModuleRequirementError(Exception):
21
+ """requires 声明的名字无法在「内置集 ∪ provides」中解析。"""
22
+
23
+ def __init__(self, missing: list[str], available: list[str]) -> None:
24
+ self.missing = missing
25
+ self.available = available
26
+ super().__init__(
27
+ "requires 无法解析: " + ", ".join(missing)
28
+ + "\n可用: " + ", ".join(available)
29
+ )
30
+
31
+
32
+ class ModuleManifestError(Exception):
33
+ """module.json 缺失、损坏或缺少必需字段。"""
34
+
35
+
36
+ class ModuleLoader:
37
+ """第二层用户入口:加载发布目录,返回可运行的 SubModule 实例。
38
+
39
+ llm_client 优先(测试/注入用);否则由 llm_config(None → from_env)创建。
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ llm_config: LLMConfig | None = None,
45
+ *,
46
+ llm_client: Any = None,
47
+ event_bus: EventBus | None = None,
48
+ ) -> None:
49
+ self._llm_client = llm_client
50
+ self._llm_config = llm_config
51
+ self._event_bus = event_bus
52
+
53
+ def _ensure_client(self) -> Any:
54
+ """llm_client 优先;否则由 llm_config(None → from_env)惰性创建。"""
55
+ if self._llm_client is None:
56
+ if self._llm_config is None:
57
+ self._llm_config = LLMConfig.from_env()
58
+ self._llm_client = create_llm_client(self._llm_config)
59
+ return self._llm_client
60
+
61
+ def load(self, path: str | Path, *, lazy_client: bool = False) -> SubModule:
62
+ """解析 module.json → 注册 provides → 校验 requires → 返回 SubModule。
63
+
64
+ ``lazy_client=True``:校验/解析阶段不实例化 LLM client(D6——校验
65
+ 无网络/key 需求);SubModule 构造传 None,运行期惰性 from_env 或
66
+ 由调用方经 ``run(llm_client=...)`` 注入。默认 False 保持旧语义。
67
+ """
68
+ p = Path(path)
69
+ manifest_path = p / "module.json"
70
+ if not manifest_path.is_file():
71
+ raise ModuleManifestError(f"缺少 module.json: {manifest_path}")
72
+ try:
73
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
74
+ except json.JSONDecodeError as e:
75
+ raise ModuleManifestError(f"module.json 解析失败: {e}") from e
76
+ if not isinstance(manifest, dict):
77
+ raise ModuleManifestError("module.json 顶层必须是对象")
78
+
79
+ name = manifest.get("name")
80
+ tasklist_data = manifest.get("tasklist")
81
+ if not name:
82
+ raise ModuleManifestError("module.json 缺少 'name'")
83
+ if tasklist_data is None:
84
+ raise ModuleManifestError("module.json 缺少 'tasklist'")
85
+ try:
86
+ tasklist = Tasklist.from_json(tasklist_data)
87
+ except (ValueError, TypeError) as e:
88
+ raise ModuleManifestError(f"tasklist 无效: {e}") from e
89
+
90
+ harnesses = self._load_harnesses(p)
91
+ commands = self._load_commands(p)
92
+ scripts = self._load_scripts(p)
93
+ guards = self._load_guards(p)
94
+ submodules = self._load_submodules(p)
95
+
96
+ modules_raw = manifest.get("modules", []) or []
97
+ if not isinstance(modules_raw, list) or not all(
98
+ isinstance(m, str) for m in modules_raw):
99
+ raise ModuleManifestError("modules 必须是字符串列表")
100
+ missing_mods = [m for m in modules_raw if m not in submodules]
101
+ if missing_mods:
102
+ raise ModuleManifestError(
103
+ "modules 缺少子模块目录: " + ", ".join(missing_mods))
104
+ extra_mods = [m for m in submodules if m not in modules_raw]
105
+ if extra_mods:
106
+ raise ModuleManifestError(
107
+ "submodules/ 目录未在 manifest 声明: " + ", ".join(sorted(extra_mods)))
108
+
109
+ schema_data = manifest.get("spec_schema", {}) or {}
110
+ spec_schema = SpecSchema(
111
+ input=schema_data.get("input", {}) or {},
112
+ output=schema_data.get("output", {}) or {},
113
+ )
114
+ requires_raw = manifest.get("requires", []) or []
115
+ if not isinstance(requires_raw, list) or not all(
116
+ isinstance(r, str) for r in requires_raw):
117
+ raise ModuleManifestError("requires 必须是字符串列表")
118
+ requires = list(requires_raw)
119
+
120
+ provides = {h.name for h in harnesses} | {c.name for c in commands} | set(scripts)
121
+ all_names = [h.name for h in harnesses] + [c.name for c in commands] + list(scripts)
122
+ dups = {n for n in all_names if all_names.count(n) > 1}
123
+ if dups:
124
+ raise ModuleManifestError("provides 名称重复: " + ", ".join(sorted(dups)))
125
+ missing = [r for r in requires if r not in BUILTIN_HARNESS_NAMES and r not in provides]
126
+ if missing:
127
+ raise ModuleRequirementError(missing, sorted(BUILTIN_HARNESS_NAMES | provides))
128
+
129
+ cls = type(name, (SubModule,), {
130
+ "name": name,
131
+ "version": manifest.get("version", "0.1.0"),
132
+ "description": manifest.get("description", ""),
133
+ "spec_schema": spec_schema,
134
+ "requires": requires,
135
+ "tasklist": tasklist,
136
+ "harnesses": harnesses,
137
+ "commands": commands,
138
+ "_scripts": scripts,
139
+ "guards": list(guards.items()),
140
+ "modules": submodules,
141
+ })
142
+ client = None if lazy_client else self._ensure_client()
143
+ return cls(llm_client=client, event_bus=self._event_bus)
144
+
145
+ def _load_harnesses(self, p: Path) -> list[HarnessConfig]:
146
+ result: list[HarnessConfig] = []
147
+ for f in sorted((p / "harnesses").glob("*.json")):
148
+ try:
149
+ data = json.loads(f.read_text(encoding="utf-8"))
150
+ cfg = HarnessConfig.from_dict(data)
151
+ except (json.JSONDecodeError, TypeError, ValueError) as e:
152
+ raise ModuleManifestError(f"{f} 无效: {e}") from e
153
+ if not cfg.name:
154
+ raise ModuleManifestError(f"{f} 缺少 'name'")
155
+ result.append(cfg)
156
+ return result
157
+
158
+ def _load_commands(self, p: Path) -> list[CommandConfig]:
159
+ result: list[CommandConfig] = []
160
+ for f in sorted((p / "commands").glob("*.json")):
161
+ try:
162
+ data = json.loads(f.read_text(encoding="utf-8"))
163
+ cfg = CommandConfig.from_dict(data)
164
+ except (json.JSONDecodeError, TypeError, ValueError) as e:
165
+ raise ModuleManifestError(f"{f} 无效: {e}") from e
166
+ if not cfg.name:
167
+ raise ModuleManifestError(f"{f} 缺少 'name'")
168
+ result.append(cfg)
169
+ return result
170
+
171
+ def _load_scripts(self, p: Path) -> dict[str, Any]:
172
+ """加载 scripts/*.py 为可调用函数(exec 执行——加载目录视为可信代码)。"""
173
+ result: dict[str, Any] = {}
174
+ for f in sorted((p / "scripts").glob("*.py")):
175
+ ns: dict[str, Any] = {}
176
+ try:
177
+ exec(compile(f.read_text(encoding="utf-8"), str(f), "exec"), ns)
178
+ except Exception as e: # 脚本自身报错视为清单错误
179
+ raise ModuleManifestError(f"{f} 加载失败: {e}") from e
180
+ fn = ns.get(f.stem)
181
+ if not callable(fn):
182
+ raise ModuleManifestError(f"{f} 未定义函数 {f.stem}")
183
+ result[f.stem] = fn
184
+ return result
185
+
186
+ def _load_guards(self, p: Path) -> dict[str, Any]:
187
+ """加载 guards/*.py 为可调用函数(exec 执行——与 scripts 同机制)。"""
188
+ result: dict[str, Any] = {}
189
+ for f in sorted((p / "guards").glob("*.py")):
190
+ ns: dict[str, Any] = {}
191
+ try:
192
+ exec(compile(f.read_text(encoding="utf-8"), str(f), "exec"), ns)
193
+ except Exception as e: # 函数自身报错视为清单错误
194
+ raise ModuleManifestError(f"{f} 加载失败: {e}") from e
195
+ fn = ns.get(f.stem)
196
+ if not callable(fn):
197
+ raise ModuleManifestError(f"{f} 未定义函数 {f.stem}")
198
+ result[f.stem] = fn
199
+ return result
200
+
201
+ def _load_submodules(self, p: Path) -> dict[str, SubModule]:
202
+ """递归加载 submodules/*/(每个是完整子包)→ {目录名: 实例}。
203
+
204
+ 目录名为引用键(pack 时以父模块 modules 的键命名),与子模块
205
+ 自身 name 无关。guard 名不进入 provides/requires(边引用,不参与
206
+ 重复名检测);子模块实例是父的 modules 值,加载时同样解析。"""
207
+ result: dict[str, SubModule] = {}
208
+ base = p / "submodules"
209
+ if not base.is_dir():
210
+ return result
211
+ for d in sorted(base.iterdir()):
212
+ if not (d / "module.json").is_file():
213
+ raise ModuleManifestError(f"{d} 缺少 module.json(submodule 目录无效)")
214
+ result[d.name] = self.load(d)
215
+ return result