specmodule 0.3.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.3.0/specmodule.egg-info → specmodule-0.5.0}/PKG-INFO +2 -2
  2. {specmodule-0.3.0 → specmodule-0.5.0}/llm/client.py +144 -14
  3. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/__init__.py +4 -1
  4. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/cli/cli.py +1421 -1433
  5. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/cli/entry.py +39 -0
  6. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/cli/loader.py +222 -215
  7. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/core/call.py +8 -2
  8. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/core/config.py +19 -0
  9. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/core/harness.py +110 -48
  10. specmodule-0.5.0/module_harness/infra/artifacts.py +93 -0
  11. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/infra/checkpoint.py +36 -9
  12. specmodule-0.5.0/module_harness/infra/entry_pack.py +218 -0
  13. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/infra/events.py +6 -0
  14. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/infra/query.py +1001 -855
  15. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/infra/store.py +87 -5
  16. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/infra/stream.py +3 -1
  17. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/model/module.py +48 -9
  18. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/model/spec.py +56 -3
  19. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/model/submodule.py +285 -280
  20. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/model/translator.py +26 -2
  21. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/orchestrate/graph_builder.py +7 -0
  22. {specmodule-0.3.0 → specmodule-0.5.0}/pyproject.toml +4 -4
  23. {specmodule-0.3.0 → specmodule-0.5.0/specmodule.egg-info}/PKG-INFO +2 -2
  24. {specmodule-0.3.0 → specmodule-0.5.0}/specmodule.egg-info/SOURCES.txt +2 -0
  25. {specmodule-0.3.0 → specmodule-0.5.0}/specmodule.egg-info/requires.txt +1 -1
  26. {specmodule-0.3.0 → specmodule-0.5.0}/LICENSE +0 -0
  27. {specmodule-0.3.0 → specmodule-0.5.0}/README.md +0 -0
  28. {specmodule-0.3.0 → specmodule-0.5.0}/llm/__init__.py +0 -0
  29. {specmodule-0.3.0 → specmodule-0.5.0}/llm/config.py +0 -0
  30. {specmodule-0.3.0 → specmodule-0.5.0}/llm/mock.py +0 -0
  31. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/cli/__init__.py +0 -0
  32. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/cli/__main__.py +0 -0
  33. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/cli/command.py +0 -0
  34. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/cli/scaffold.py +0 -0
  35. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/core/__init__.py +0 -0
  36. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/core/builtins.py +0 -0
  37. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/core/outputfmt.py +0 -0
  38. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/core/prompt.py +0 -0
  39. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/core/registry.py +0 -0
  40. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/infra/__init__.py +0 -0
  41. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/infra/control.py +0 -0
  42. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/infra/status.py +0 -0
  43. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/model/__init__.py +0 -0
  44. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/orchestrate/__init__.py +0 -0
  45. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/orchestrate/align.py +0 -0
  46. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/orchestrate/consistency.py +0 -0
  47. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/orchestrate/feed.py +0 -0
  48. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/templates/builtin/codereview.json +0 -0
  49. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/templates/builtin/docwrite.json +0 -0
  50. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/templates/builtin/summarize.json +0 -0
  51. {specmodule-0.3.0 → specmodule-0.5.0}/module_harness/templates/builtin/translate.json +0 -0
  52. {specmodule-0.3.0 → specmodule-0.5.0}/setup.cfg +0 -0
  53. {specmodule-0.3.0 → specmodule-0.5.0}/specmodule.egg-info/dependency_links.txt +0 -0
  54. {specmodule-0.3.0 → specmodule-0.5.0}/specmodule.egg-info/entry_points.txt +0 -0
  55. {specmodule-0.3.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.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
@@ -136,6 +136,76 @@ def _safe_on_token(on_token: Callable[[str], None] | None, chunk: str) -> None:
136
136
  log.exception("on_token 回调异常;已忽略")
137
137
 
138
138
 
139
+ def _safe_on_thinking(on_thinking: Callable[[str], None] | None, chunk: str) -> None:
140
+ """调用 on_thinking,回调异常不得影响主流程(同 _safe_on_token)。"""
141
+ if on_thinking is None or not chunk:
142
+ return
143
+ try:
144
+ on_thinking(chunk)
145
+ except Exception:
146
+ log.exception("on_thinking 回调异常;已忽略")
147
+
148
+
149
+ class _ThinkTagStripper:
150
+ """跨 chunk 安全的内联 ``<think>…</think>`` 剥离器。
151
+
152
+ 部分兼容网关不单设 reasoning 通道,思考文本带标签内联在 content 里。
153
+ feed() 逐 chunk 喂入,返回 (content_delta, thinking_delta):标签外增量
154
+ 归 content、标签内增量归 thinking;hold-back 缓冲处理标签自身被 chunk
155
+ 劈开的情况;flush() 在流结束吐出残留(未闭合标签按思考处理)。
156
+ """
157
+
158
+ _OPEN = "<think>"
159
+ _CLOSE = "</think>"
160
+
161
+ def __init__(self) -> None:
162
+ self._inside = False
163
+ self._buf = "" # hold-back:可能是未判定标签前缀的尾部
164
+
165
+ def feed(self, chunk: str) -> tuple[str, str]:
166
+ self._buf += chunk
167
+ content_parts: list[str] = []
168
+ thinking_parts: list[str] = []
169
+ while self._buf:
170
+ if self._inside:
171
+ end = self._buf.find(self._CLOSE)
172
+ if end >= 0:
173
+ thinking_parts.append(self._buf[:end])
174
+ self._buf = self._buf[end + len(self._CLOSE):]
175
+ self._inside = False
176
+ continue
177
+ keep = self._holdback(self._CLOSE)
178
+ else:
179
+ start = self._buf.find(self._OPEN)
180
+ if start >= 0:
181
+ content_parts.append(self._buf[:start])
182
+ self._buf = self._buf[start + len(self._OPEN):]
183
+ self._inside = True
184
+ continue
185
+ keep = self._holdback(self._OPEN)
186
+ emit = len(self._buf) - keep
187
+ if emit:
188
+ (thinking_parts if self._inside else content_parts).append(self._buf[:emit])
189
+ self._buf = self._buf[emit:]
190
+ break
191
+ return "".join(content_parts), "".join(thinking_parts)
192
+
193
+ def flush(self) -> tuple[str, str]:
194
+ """流结束:按当前内/外状态吐出残留 hold-back。"""
195
+ if not self._buf:
196
+ return "", ""
197
+ out = self._buf
198
+ self._buf = ""
199
+ return ("", out) if self._inside else (out, "")
200
+
201
+ def _holdback(self, tag: str) -> int:
202
+ """缓冲尾部可能是 tag 真前缀的最大长度( TagLen-1 向下探)。"""
203
+ for n in range(min(len(self._buf), len(tag) - 1), 0, -1):
204
+ if tag.startswith(self._buf[-n:]):
205
+ return n
206
+ return 0
207
+
208
+
139
209
  # ---------------------------------------------------------------------------
140
210
  # Anthropic 客户端
141
211
  # ---------------------------------------------------------------------------
@@ -235,6 +305,7 @@ class AnthropicClient:
235
305
  output_format: dict[str, Any] | None = None,
236
306
  notdo: list[str] | None = None,
237
307
  on_token: Callable[[str], None] | None = None,
308
+ on_thinking: Callable[[str], None] | None = None,
238
309
  api_params: dict[str, Any] | None = None,
239
310
  ) -> LLMResponse:
240
311
  """单轮调用入口(harness body 用)。
@@ -242,7 +313,8 @@ class AnthropicClient:
242
313
  - ``prompt``:三层渲染后的用户提示词
243
314
  - ``model``/``temperature``/``think``:按调用覆盖,缺省回落 config
244
315
  - ``output_format``:原生结构化输出(强制 tool-use)
245
- - ``on_token``:流式 token 回调(提供时走流式接口)
316
+ - ``on_token``/``on_thinking``:流式 token/思考增量回调(任一提供即走流式接口;
317
+ thinking_delta → on_thinking,text_delta → on_token;回调异常不破主流程)
246
318
  - ``api_params``:透传给 SDK 的额外参数(已知字段入 kwargs,未知入 extra_body)
247
319
  """
248
320
  self._require_ready()
@@ -282,8 +354,8 @@ class AnthropicClient:
282
354
  _apply_api_params(kwargs, api_params, _KNOWN_ANTHROPIC_PARAMS)
283
355
 
284
356
  try:
285
- if on_token:
286
- content, tool_calls, usage, finish = await self._stream(kwargs, forced_tool, on_token)
357
+ if on_token or on_thinking:
358
+ content, tool_calls, usage, finish = await self._stream(kwargs, forced_tool, on_token, on_thinking)
287
359
  else:
288
360
  content, tool_calls, usage, finish = await self._nonstream(kwargs, forced_tool)
289
361
  except LLMError:
@@ -309,13 +381,22 @@ class AnthropicClient:
309
381
  }
310
382
  return content, tool_calls, usage, response.stop_reason
311
383
 
312
- async def _stream(self, kwargs: dict, forced_tool: str | None, on_token) -> tuple:
384
+ async def _stream(self, kwargs: dict, forced_tool: str | None, on_token, on_thinking) -> tuple:
313
385
  content = ""
314
386
  tool_calls: list[dict[str, Any]] = []
315
387
  async with self._client.messages.stream(**kwargs) as stream:
316
- async for text in stream.text_stream:
317
- content += text
318
- _safe_on_token(on_token, text)
388
+ async for event in stream:
389
+ delta = getattr(event, "delta", None)
390
+ dtype = getattr(delta, "type", None)
391
+ if dtype == "thinking_delta":
392
+ piece = getattr(delta, "thinking", None) or ""
393
+ if piece:
394
+ _safe_on_thinking(on_thinking, piece)
395
+ elif dtype == "text_delta":
396
+ text = getattr(delta, "text", "") or ""
397
+ if text:
398
+ content += text
399
+ _safe_on_token(on_token, text)
319
400
  final = await stream.get_final_message()
320
401
  for block in final.content:
321
402
  if block.type == "tool_use":
@@ -328,15 +409,36 @@ class AnthropicClient:
328
409
  }
329
410
  return content, tool_calls, usage, final.stop_reason
330
411
 
331
- # --- 多轮底层接口(保留供对齐检查 / spec 翻译等 LLM 调用复用) -------------
412
+ # --- 多轮底层接口(首个正当消费端 = agent 工具循环;spec 翻译 / 对齐检查等 LLM 调用亦复用) -------------
332
413
 
333
414
  def _convert_messages(self, messages: list[Message]) -> tuple[str | None, list[dict[str, Any]]]:
334
- """分离 system 消息并转换其余消息。"""
415
+ """分离 system 消息并转换其余消息;工具循环形状:assistant+tool_calls →
416
+ tool_use 内容块,tool 角色 → tool_result 用户消息块(Anthropic 契约)。
417
+ 连续 tool 消息聚合为一条 user 消息(多个 tool_result 块)——Anthropic 要求
418
+ 消息角色交替,逐条拆开会 400(Bedrock 实测)。"""
335
419
  system_prompt = None
336
420
  api_messages = []
337
421
  for msg in messages:
338
422
  if msg.role == "system":
339
423
  system_prompt = msg.content
424
+ elif msg.role == "assistant" and msg.tool_calls:
425
+ blocks: list[dict[str, Any]] = []
426
+ if msg.content:
427
+ blocks.append({"type": "text", "text": msg.content})
428
+ for tc in msg.tool_calls:
429
+ blocks.append({"type": "tool_use", "id": tc["id"],
430
+ "name": tc["name"], "input": tc.get("arguments", {})})
431
+ api_messages.append({"role": "assistant", "content": blocks})
432
+ elif msg.role == "tool":
433
+ result = {"type": "tool_result", "tool_use_id": msg.tool_call_id or "",
434
+ "content": msg.content}
435
+ last = api_messages[-1] if api_messages else None
436
+ # 连续 tool 消息并入同一条 user 消息(角色交替契约,见 docstring)
437
+ if last and last["role"] == "user" and isinstance(last["content"], list) \
438
+ and last["content"] and last["content"][0]["type"] == "tool_result":
439
+ last["content"].append(result)
440
+ else:
441
+ api_messages.append({"role": "user", "content": [result]})
340
442
  else:
341
443
  api_messages.append({"role": msg.role, "content": msg.content})
342
444
  return system_prompt, api_messages
@@ -514,6 +616,7 @@ class OpenAIClient:
514
616
  output_format: dict[str, Any] | None = None,
515
617
  notdo: list[str] | None = None,
516
618
  on_token: Callable[[str], None] | None = None,
619
+ on_thinking: Callable[[str], None] | None = None,
517
620
  api_params: dict[str, Any] | None = None,
518
621
  ) -> LLMResponse:
519
622
  """单轮调用入口(harness body 用)。
@@ -523,6 +626,10 @@ class OpenAIClient:
523
626
  - ``think``:reasoning 模型映射为 ``reasoning_effort``("low"/"medium"/"high",
524
627
  dict 可指定 ``effort``;非 reasoning 模型忽略)
525
628
  - ``api_params``:透传给 SDK 的额外参数(已知字段入 kwargs,未知入 extra_body)
629
+ - ``on_thinking``:思考/推理增量回调(reasoning_content/reasoning 方言 +
630
+ content 内联 <think> 剥离;Anthropic thinking_delta);流式/非流式路径均
631
+ 无条件剥离内联 <think> 标签,返回 content 不含思考文本(含仅传 on_token
632
+ 的存量调用);仅传 on_token 时思考增量静默丢弃;回调异常不破主流程
526
633
  """
527
634
  self._require_ready()
528
635
  model = model or self.config.model
@@ -562,8 +669,8 @@ class OpenAIClient:
562
669
  _apply_api_params(kwargs, api_params, _KNOWN_OPENAI_PARAMS)
563
670
 
564
671
  try:
565
- if on_token:
566
- content, tool_calls, usage, finish = await self._stream(kwargs, on_token)
672
+ if on_token or on_thinking:
673
+ content, tool_calls, usage, finish = await self._stream(kwargs, on_token, on_thinking)
567
674
  else:
568
675
  content, tool_calls, usage, finish = await self._nonstream(kwargs)
569
676
  except LLMError:
@@ -576,6 +683,11 @@ class OpenAIClient:
576
683
  response = await self._client.chat.completions.create(**kwargs)
577
684
  choice = response.choices[0]
578
685
  content = choice.message.content or ""
686
+ # 内联 <think> 剥离与流式路径对齐:完整文本过一遍剥离器,返回 content 不含思考文本
687
+ stripper = _ThinkTagStripper()
688
+ c_text, _ = stripper.feed(content)
689
+ tail_c, _ = stripper.flush()
690
+ content = c_text + tail_c
579
691
  tool_calls: list[dict[str, Any]] = []
580
692
  if choice.message.tool_calls:
581
693
  for tc in choice.message.tool_calls:
@@ -590,7 +702,7 @@ class OpenAIClient:
590
702
  }
591
703
  return content, tool_calls, usage, choice.finish_reason
592
704
 
593
- async def _stream(self, kwargs: dict, on_token) -> tuple:
705
+ async def _stream(self, kwargs: dict, on_token, on_thinking) -> tuple:
594
706
  kwargs["stream"] = True
595
707
  # stream_options 仅官方 OpenAI 必然支持;兼容接口(base_url 非空)省略以免被拒
596
708
  if not self.config.base_url:
@@ -599,6 +711,7 @@ class OpenAIClient:
599
711
  tool_calls: list[dict[str, Any]] = []
600
712
  usage: dict[str, int] = {}
601
713
  finish: str | None = None
714
+ stripper = _ThinkTagStripper()
602
715
  stream = await self._client.chat.completions.create(**kwargs)
603
716
  async for chunk in stream:
604
717
  if chunk.usage:
@@ -609,11 +722,26 @@ class OpenAIClient:
609
722
  if not chunk.choices:
610
723
  continue
611
724
  delta = chunk.choices[0].delta
725
+ # 思考增量:DeepSeek/Kimi 的 reasoning_content,部分网关用 reasoning;
726
+ # 无原生通道时由 <think> 剥离器从 content 转移(见下)
727
+ reasoning = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None)
728
+ if reasoning:
729
+ _safe_on_thinking(on_thinking, reasoning)
612
730
  if delta.content:
613
- content += delta.content
614
- _safe_on_token(on_token, delta.content)
731
+ c_delta, t_delta = stripper.feed(delta.content)
732
+ content += c_delta # 返回值用剥离后文本(思考不泄入 JSON 输出)
733
+ if c_delta:
734
+ _safe_on_token(on_token, c_delta)
735
+ if t_delta:
736
+ _safe_on_thinking(on_thinking, t_delta)
615
737
  if chunk.choices[0].finish_reason:
616
738
  finish = chunk.choices[0].finish_reason
739
+ tail_c, tail_t = stripper.flush()
740
+ if tail_c:
741
+ content += tail_c
742
+ _safe_on_token(on_token, tail_c)
743
+ if tail_t:
744
+ _safe_on_thinking(on_thinking, tail_t)
617
745
  return content, tool_calls, usage, finish
618
746
 
619
747
  # --- 多轮底层接口 -------------------------------------------------------
@@ -764,6 +892,7 @@ class RoutingClient:
764
892
  output_format: dict[str, Any] | None = None,
765
893
  notdo: list[str] | None = None,
766
894
  on_token: Callable[[str], None] | None = None,
895
+ on_thinking: Callable[[str], None] | None = None,
767
896
  api_params: dict[str, Any] | None = None,
768
897
  ) -> LLMResponse:
769
898
  """单轮调用(harness body 入口),按调用模型路由。"""
@@ -776,6 +905,7 @@ class RoutingClient:
776
905
  output_format=output_format,
777
906
  notdo=notdo,
778
907
  on_token=on_token,
908
+ on_thinking=on_thinking,
779
909
  api_params=api_params,
780
910
  )
781
911
 
@@ -38,6 +38,7 @@ from .infra.events import (
38
38
  PromptRendered,
39
39
  LlmCallStarted,
40
40
  LlmToken,
41
+ LlmThinking,
41
42
  LlmCallCompleted,
42
43
  OutputValidated,
43
44
  ImageSaved,
@@ -104,7 +105,7 @@ from .infra.store import (
104
105
  )
105
106
  # cli 层(CLI 实现,非库面)
106
107
  from .cli.command import Command, CommandConfig
107
- from .cli.entry import ModuleEntry, discover_modules
108
+ from .cli.entry import ModuleEntry, TemplateSpec, discover_modules
108
109
  from .cli.loader import ModuleLoader, ModuleManifestError, ModuleRequirementError
109
110
 
110
111
  # 模块对象绑定(`from module_harness import store / submodule / query` 可用;
@@ -131,6 +132,7 @@ __all__ = [
131
132
  "PromptRendered",
132
133
  "LlmCallStarted",
133
134
  "LlmToken",
135
+ "LlmThinking",
134
136
  "LlmCallCompleted",
135
137
  "OutputValidated",
136
138
  "ImageSaved",
@@ -198,6 +200,7 @@ __all__ = [
198
200
  "check_resume_compat_from_run",
199
201
  # 模块入口(roadmap Phase 0:CLI 使用)
200
202
  "ModuleEntry",
203
+ "TemplateSpec",
201
204
  "discover_modules",
202
205
  # 共享查询层(roadmap Phase 0:CLI/MCP/Web 复用)
203
206
  "ReviewEntry",