source-kb 0.2.2__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.
Files changed (228) hide show
  1. cli/__init__.py +50 -0
  2. cli/__main__.py +5 -0
  3. cli/commands/__init__.py +1 -0
  4. cli/commands/anchor_fix.py +47 -0
  5. cli/commands/diff_doc.py +52 -0
  6. cli/commands/dispatch.py +77 -0
  7. cli/commands/extract.py +72 -0
  8. cli/commands/file_list.py +74 -0
  9. cli/commands/index.py +84 -0
  10. cli/commands/lock.py +89 -0
  11. cli/commands/merge.py +60 -0
  12. cli/commands/merge_delta.py +19 -0
  13. cli/commands/metadata.py +24 -0
  14. cli/commands/pipeline.py +45 -0
  15. cli/commands/post_merge.py +43 -0
  16. cli/commands/query.py +52 -0
  17. cli/commands/render.py +101 -0
  18. cli/commands/scan_repos.py +46 -0
  19. cli/commands/setup.py +94 -0
  20. cli/commands/split.py +196 -0
  21. cli/commands/stale_files.py +98 -0
  22. cli/commands/validate.py +191 -0
  23. core/__init__.py +32 -0
  24. core/config.py +261 -0
  25. core/docs/__init__.py +7 -0
  26. core/docs/section_updater.py +286 -0
  27. core/docs/shared.py +149 -0
  28. core/git.py +294 -0
  29. core/interfaces.py +249 -0
  30. core/monitor/__init__.py +5 -0
  31. core/monitor/progress.py +83 -0
  32. core/monitor/prompt_store.py +49 -0
  33. core/paths.py +141 -0
  34. core/preset.py +237 -0
  35. core/preset_accessors.py +202 -0
  36. core/preset_classify.py +132 -0
  37. core/preset_hooks.py +129 -0
  38. core/preset_profile.py +89 -0
  39. core/prompt/__init__.py +7 -0
  40. core/prompt/__main__.py +147 -0
  41. core/prompt/content.py +320 -0
  42. core/prompt/context_manager.py +164 -0
  43. core/prompt/renderer.py +236 -0
  44. core/prompt/response_parser.py +274 -0
  45. core/prompt/templates.py +357 -0
  46. core/prompt/validate_parity.py +162 -0
  47. core/prompt/variables.py +339 -0
  48. core/rag/__init__.py +22 -0
  49. core/rag/__main__.py +136 -0
  50. core/rag/bm25_index.py +268 -0
  51. core/rag/chunker.py +273 -0
  52. core/rag/embedder.py +151 -0
  53. core/rag/indexer.py +292 -0
  54. core/rag/loader.py +89 -0
  55. core/rag/retriever.py +82 -0
  56. core/skeleton/__init__.py +11 -0
  57. core/skeleton/__main__.py +934 -0
  58. core/skeleton/anchor_fix.py +250 -0
  59. core/skeleton/classify.py +331 -0
  60. core/skeleton/cmd_anchor_fix.py +43 -0
  61. core/skeleton/cmd_diff_doc.py +44 -0
  62. core/skeleton/cmd_lock.py +87 -0
  63. core/skeleton/cmd_merge_delta.py +41 -0
  64. core/skeleton/community.py +233 -0
  65. core/skeleton/dependency_graph.py +306 -0
  66. core/skeleton/diff_doc.py +248 -0
  67. core/skeleton/dispatch.py +273 -0
  68. core/skeleton/dispatch_render.py +319 -0
  69. core/skeleton/dispatch_source.py +111 -0
  70. core/skeleton/extract.py +218 -0
  71. core/skeleton/extract_methods.py +298 -0
  72. core/skeleton/file_list.py +239 -0
  73. core/skeleton/impact.py +278 -0
  74. core/skeleton/jar_download.py +177 -0
  75. core/skeleton/jar_resolver.py +186 -0
  76. core/skeleton/loader.py +162 -0
  77. core/skeleton/merge.py +278 -0
  78. core/skeleton/merge_delta.py +229 -0
  79. core/skeleton/metadata.py +96 -0
  80. core/skeleton/metadata_builders.py +264 -0
  81. core/skeleton/module_dag.py +330 -0
  82. core/skeleton/parsers/__init__.py +71 -0
  83. core/skeleton/parsers/jqassistant.py +300 -0
  84. core/skeleton/parsers/jqassistant_cypher.py +225 -0
  85. core/skeleton/parsers/regex.py +171 -0
  86. core/skeleton/parsers/treesitter.py +324 -0
  87. core/skeleton/parsers/treesitter_java.py +284 -0
  88. core/skeleton/parsers/treesitter_multi.py +289 -0
  89. core/skeleton/pom_parser.py +299 -0
  90. core/skeleton/post_merge.py +295 -0
  91. core/skeleton/post_merge_llm.py +82 -0
  92. core/skeleton/query.py +195 -0
  93. core/skeleton/shard_context.py +177 -0
  94. core/skeleton/split.py +180 -0
  95. core/skeleton/split_cache.py +107 -0
  96. core/skeleton/split_feedback.py +174 -0
  97. core/skeleton/split_plan.py +219 -0
  98. core/skeleton/split_plan_helpers.py +305 -0
  99. core/skeleton/split_plan_llm.py +274 -0
  100. core/utils.py +135 -0
  101. core/validators/__init__.py +65 -0
  102. core/validators/__main__.py +215 -0
  103. core/validators/consistency.py +203 -0
  104. core/validators/coverage.py +171 -0
  105. core/validators/duplicates.py +76 -0
  106. core/validators/engine.py +224 -0
  107. core/validators/links.py +76 -0
  108. core/validators/sampling.py +169 -0
  109. core/validators/structure.py +144 -0
  110. engine/__init__.py +7 -0
  111. engine/assembler.py +231 -0
  112. engine/confirm.py +65 -0
  113. engine/dedup.py +106 -0
  114. engine/main.py +211 -0
  115. engine/pipeline/__init__.py +163 -0
  116. engine/pipeline/recovery.py +250 -0
  117. engine/pipeline/steps/__init__.py +23 -0
  118. engine/pipeline/steps/audit.py +220 -0
  119. engine/pipeline/steps/audit_apply.py +195 -0
  120. engine/pipeline/steps/audit_helpers.py +155 -0
  121. engine/pipeline/steps/classify_llm.py +236 -0
  122. engine/pipeline/steps/classify_prompt.py +223 -0
  123. engine/pipeline/steps/finalize.py +160 -0
  124. engine/pipeline/steps/generate.py +169 -0
  125. engine/pipeline/steps/generate_batch.py +197 -0
  126. engine/pipeline/steps/generate_recovery.py +170 -0
  127. engine/pipeline/steps/llm_plan_split.py +253 -0
  128. engine/pipeline/steps/lock.py +64 -0
  129. engine/pipeline/steps/preflight.py +237 -0
  130. engine/pipeline/steps/preflight_adjust.py +147 -0
  131. engine/pipeline/steps/pregenerate.py +130 -0
  132. engine/pipeline/steps/quality.py +81 -0
  133. engine/pipeline/steps/skeleton.py +149 -0
  134. engine/pipeline/steps/source.py +163 -0
  135. engine/pipeline/steps/sync.py +117 -0
  136. engine/pipeline/steps/sync_finalize.py +237 -0
  137. engine/pipeline/steps/sync_update.py +341 -0
  138. engine/pipelines.py +91 -0
  139. engine/runner.py +335 -0
  140. engine/strategies/__init__.py +86 -0
  141. engine/strategies/api.py +128 -0
  142. engine/strategies/delegated.py +50 -0
  143. engine/strategies/dryrun.py +25 -0
  144. engine/two_phase.py +143 -0
  145. mcp_server/__init__.py +73 -0
  146. mcp_server/__main__.py +5 -0
  147. mcp_server/tools/__init__.py +1 -0
  148. mcp_server/tools/config.py +63 -0
  149. mcp_server/tools/discovery.py +276 -0
  150. mcp_server/tools/generation.py +184 -0
  151. mcp_server/tools/planning.py +144 -0
  152. mcp_server/tools/source.py +175 -0
  153. mcp_server/tools/validation.py +140 -0
  154. mcp_server/tools/workflow.py +166 -0
  155. mcp_server/workflow_loader.py +204 -0
  156. presets/generic/audit_dimensions.md +132 -0
  157. presets/generic/doc_types.yaml +152 -0
  158. presets/generic/preset.yaml +115 -0
  159. presets/java-spring/audit_dimensions.md +228 -0
  160. presets/java-spring/audit_dimensions.yaml +203 -0
  161. presets/java-spring/doc_types.yaml +269 -0
  162. presets/java-spring/hooks.py +122 -0
  163. presets/java-spring/preset.yaml +341 -0
  164. presets/java-spring/templates/README.md +34 -0
  165. presets/java-spring/templates/audit-system.md +15 -0
  166. presets/java-spring/templates/subagent-aop.md +105 -0
  167. presets/java-spring/templates/subagent-api.md +63 -0
  168. presets/java-spring/templates/subagent-architecture.md +111 -0
  169. presets/java-spring/templates/subagent-async-events.md +107 -0
  170. presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
  171. presets/java-spring/templates/subagent-audit-architecture.md +38 -0
  172. presets/java-spring/templates/subagent-audit-business.md +40 -0
  173. presets/java-spring/templates/subagent-audit-data-models.md +40 -0
  174. presets/java-spring/templates/subagent-business.md +129 -0
  175. presets/java-spring/templates/subagent-caching.md +75 -0
  176. presets/java-spring/templates/subagent-database-access.md +114 -0
  177. presets/java-spring/templates/subagent-enum.md +75 -0
  178. presets/java-spring/templates/subagent-error-handling.md +91 -0
  179. presets/java-spring/templates/subagent-external-integrations.md +80 -0
  180. presets/java-spring/templates/subagent-index.md +122 -0
  181. presets/java-spring/templates/subagent-messaging.md +97 -0
  182. presets/java-spring/templates/subagent-model.md +88 -0
  183. presets/java-spring/templates/subagent-observability.md +91 -0
  184. presets/java-spring/templates/subagent-scheduled.md +81 -0
  185. presets/java-spring/templates/subagent-security.md +102 -0
  186. presets/java-spring/templates/subagent-structure.md +101 -0
  187. presets/java-spring/templates/subagent-sync-section.md +34 -0
  188. presets/java-spring/templates/subagent-utils.md +73 -0
  189. presets/java-spring/templates/sync-system.md +8 -0
  190. presets/java-spring/workflow-extensions.md +112 -0
  191. skills/__init__.py +1 -0
  192. skills/_shared/README.md +30 -0
  193. skills/_shared/doc-coverage-shared.md +134 -0
  194. skills/_shared/doc-quality-standard.md +1058 -0
  195. skills/_shared/doc-subagent-rules.md +762 -0
  196. skills/_shared/windows-compat.md +89 -0
  197. skills/kb-audit/SKILL.md +52 -0
  198. skills/kb-audit/rules.md +88 -0
  199. skills/kb-audit/steps/step-01-prepare.md +75 -0
  200. skills/kb-audit/steps/step-02-audit.md +96 -0
  201. skills/kb-audit/steps/step-03-verify.md +65 -0
  202. skills/kb-audit/steps/step-04-report.md +64 -0
  203. skills/kb-init/SKILL.md +146 -0
  204. skills/kb-init/rules.md +187 -0
  205. skills/kb-init/steps/step-01-scope.md +62 -0
  206. skills/kb-init/steps/step-02-source.md +410 -0
  207. skills/kb-init/steps/step-03-generate.md +307 -0
  208. skills/kb-init/steps/step-04-quality.md +92 -0
  209. skills/kb-init/steps/step-05-finalize.md +132 -0
  210. skills/kb-init/templates/core/execution-modes.md +29 -0
  211. skills/kb-init/templates/core/output-only.md +4 -0
  212. skills/kb-init/templates/core/readwrite.md +33 -0
  213. skills/kb-search/SKILL.md +138 -0
  214. skills/kb-search/rules.md +64 -0
  215. skills/kb-sync/SKILL.md +43 -0
  216. skills/kb-sync/rules.md +70 -0
  217. skills/kb-sync/scripts/rebuild_module.py +91 -0
  218. skills/kb-sync/scripts/scan_repos.py +687 -0
  219. skills/kb-sync/steps/step-01-detect.md +72 -0
  220. skills/kb-sync/steps/step-02-update.md +71 -0
  221. skills/kb-sync/steps/step-03-verify.md +47 -0
  222. skills/kb-sync/steps/step-04-finalize.md +52 -0
  223. source_kb-0.2.2.dist-info/METADATA +194 -0
  224. source_kb-0.2.2.dist-info/RECORD +228 -0
  225. source_kb-0.2.2.dist-info/WHEEL +5 -0
  226. source_kb-0.2.2.dist-info/entry_points.txt +3 -0
  227. source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
  228. source_kb-0.2.2.dist-info/top_level.txt +6 -0
engine/runner.py ADDED
@@ -0,0 +1,335 @@
1
+ """Batch execution runner — concurrent LLM task execution with retry and heartbeat.
2
+
3
+ Manages the lifecycle of sub-agent tasks: dispatch, monitor, retry, validate.
4
+
5
+ Usage:
6
+ from engine.runner import run_batch, SubagentTask, SubagentResult
7
+
8
+ tasks = [SubagentTask(task_id="mod__business", prompt="...", output_path=...)]
9
+ results = run_batch(tasks, strategy, max_concurrent=5)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import threading
16
+ import time
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Literal
21
+
22
+ from core.interfaces import LlmRequest, LlmResponse, LlmStrategy
23
+ from core.monitor.progress import write_progress
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ MAX_RETRIES = 2
28
+ MIN_DOC_SIZE_BYTES = 500
29
+
30
+
31
+ class BatchAbortError(Exception):
32
+ """Raised when circuit breaker triggers batch abort."""
33
+
34
+ def __init__(self, reason: str, diagnosis: str, failed_count: int, total_count: int):
35
+ self.reason = reason
36
+ self.diagnosis = diagnosis
37
+ self.failed_count = failed_count
38
+ self.total_count = total_count
39
+ super().__init__(f"Batch aborted: {reason} ({failed_count}/{total_count} failed)")
40
+
41
+
42
+ @dataclass
43
+ class CircuitBreakerConfig:
44
+ """Configuration for batch circuit breaker."""
45
+ max_consecutive_failures: int = 3
46
+ failure_rate_threshold: float = 0.5
47
+ timeout_error_keywords: tuple[str, ...] = ("timeout", "Timeout", "timed out", "PoolTimeout", "ConnectTimeout")
48
+ garbage_error_keywords: tuple[str, ...] = ("tool-call artifacts", "web_search", "read_file")
49
+
50
+ @classmethod
51
+ def from_config(cls, config: dict) -> CircuitBreakerConfig:
52
+ limits = config.get("limits", {})
53
+ return cls(
54
+ max_consecutive_failures=limits.get("max_consecutive_failures", 3),
55
+ failure_rate_threshold=limits.get("failure_rate_threshold", 0.5),
56
+ )
57
+
58
+
59
+ class _CircuitBreaker:
60
+ """Tracks failures within a batch and triggers abort when thresholds are exceeded."""
61
+
62
+ def __init__(self, total_tasks: int, config: CircuitBreakerConfig):
63
+ self._total = total_tasks
64
+ self._config = config
65
+ self._lock = threading.Lock()
66
+ self._consecutive_failures = 0
67
+ self._total_failures = 0
68
+ self._total_completed = 0
69
+ self._timeout_count = 0
70
+ self._garbage_count = 0
71
+ self._tripped = False
72
+ self._trip_reason = ""
73
+ self._trip_diagnosis = ""
74
+
75
+ def record_success(self) -> None:
76
+ with self._lock:
77
+ self._consecutive_failures = 0
78
+ self._total_completed += 1
79
+
80
+ def record_failure(self, error: str) -> None:
81
+ with self._lock:
82
+ self._consecutive_failures += 1
83
+ self._total_failures += 1
84
+ self._total_completed += 1
85
+
86
+ if any(kw in error for kw in self._config.timeout_error_keywords):
87
+ self._timeout_count += 1
88
+ if any(kw in error for kw in self._config.garbage_error_keywords):
89
+ self._garbage_count += 1
90
+
91
+ self._check_trip(error)
92
+
93
+ def _check_trip(self, last_error: str) -> None:
94
+ if self._consecutive_failures >= self._config.max_consecutive_failures:
95
+ self._tripped = True
96
+ self._trip_reason = f"{self._consecutive_failures} consecutive failures"
97
+ self._trip_diagnosis = self._diagnose()
98
+ elif (self._total_completed >= 3 and
99
+ self._total_failures / self._total_completed > self._config.failure_rate_threshold):
100
+ self._tripped = True
101
+ self._trip_reason = f"Failure rate too high ({self._total_failures}/{self._total_completed})"
102
+ self._trip_diagnosis = self._diagnose()
103
+
104
+ def _diagnose(self) -> str:
105
+ if self._timeout_count >= 2:
106
+ return "Network timeout. Check: 1) Is the LLM API reachable? 2) Is the network stable? 3) Consider increasing subagent_timeout"
107
+ if self._garbage_count >= 2:
108
+ return "Model output error (tool-call artifacts). Check: 1) Does the model support plain text generation? 2) Is the prompt too large causing model confusion? 3) Try a different model"
109
+ if self._total_failures == self._total_completed:
110
+ return "All tasks failed. Check: 1) Is the API connection working? 2) Is the API key valid? 3) Is the model name correct?"
111
+ return "Most tasks failed. Check prompt content and size in .meta/prompts/ to confirm it does not exceed the model context window"
112
+
113
+ @property
114
+ def is_tripped(self) -> bool:
115
+ with self._lock:
116
+ return self._tripped
117
+
118
+ def get_abort_error(self) -> BatchAbortError | None:
119
+ with self._lock:
120
+ if not self._tripped:
121
+ return None
122
+ return BatchAbortError(
123
+ reason=self._trip_reason,
124
+ diagnosis=self._trip_diagnosis,
125
+ failed_count=self._total_failures,
126
+ total_count=self._total,
127
+ )
128
+
129
+
130
+ @dataclass
131
+ class SubagentTask:
132
+ """A single document generation task."""
133
+ task_id: str
134
+ prompt: str
135
+ output_path: Path
136
+ doc_type: str
137
+ system_prompt: str = (
138
+ "You are a technical documentation expert. "
139
+ "Generate a knowledge base document based ONLY on the source code provided in the user prompt. "
140
+ "You have NO tools available — do NOT output <web_search>, <read_file>, or any XML tool calls. "
141
+ "Do NOT search the web. Do NOT attempt to read files. All source code is already inlined in the prompt. "
142
+ "Output ONLY the final markdown document content, nothing else."
143
+ )
144
+ timeout: int = 900
145
+ model: str | None = None
146
+
147
+
148
+ @dataclass
149
+ class SubagentResult:
150
+ """Result of a sub-agent task execution."""
151
+ task_id: str
152
+ status: Literal["done", "failed", "empty", "delegated"]
153
+ output_path: Path | None = None
154
+ content: str = ""
155
+ elapsed: float = 0.0
156
+ error: str = ""
157
+ usage: dict[str, int] = field(default_factory=dict)
158
+ prompt_file: str = ""
159
+
160
+
161
+ def run_single(task: SubagentTask, strategy: LlmStrategy, *, save_prompts: bool = False) -> SubagentResult:
162
+ """Execute a single task via the LLM strategy."""
163
+ module_dir = task.output_path.parent
164
+ write_progress(module_dir, task.doc_type, "START")
165
+
166
+ if save_prompts:
167
+ from core.monitor.prompt_store import save_prompt
168
+ save_prompt(module_dir, task.task_id, task.system_prompt, task.prompt, model=task.model or "")
169
+
170
+ t0 = time.time()
171
+ try:
172
+ resp: LlmResponse = strategy.call(LlmRequest(
173
+ system=task.system_prompt,
174
+ user=task.prompt,
175
+ model=task.model,
176
+ max_tokens=16384,
177
+ temperature=0.3,
178
+ ))
179
+ elapsed = time.time() - t0
180
+
181
+ if resp.status == "failed":
182
+ write_progress(module_dir, task.doc_type, f"FAILED: {resp.error}")
183
+ return SubagentResult(
184
+ task_id=task.task_id, status="failed", elapsed=elapsed,
185
+ error=resp.error or "LLM returned failed status", usage=resp.usage,
186
+ )
187
+
188
+ if resp.status == "delegated":
189
+ write_progress(module_dir, task.doc_type, "DELEGATED")
190
+ return SubagentResult(
191
+ task_id=task.task_id, status="delegated",
192
+ output_path=task.output_path, elapsed=elapsed,
193
+ usage=resp.usage, prompt_file=resp.prompt_file,
194
+ )
195
+
196
+ content = resp.content.strip()
197
+ content = _strip_markdown_fence(content)
198
+
199
+ if _is_garbage_output(content):
200
+ write_progress(module_dir, task.doc_type, "GARBAGE")
201
+ return SubagentResult(
202
+ task_id=task.task_id, status="empty",
203
+ content="", elapsed=elapsed,
204
+ error="Output contains tool-call artifacts (web_search/read_file)", usage=resp.usage,
205
+ )
206
+
207
+ if not content or len(content.encode("utf-8")) < MIN_DOC_SIZE_BYTES:
208
+ write_progress(module_dir, task.doc_type, "EMPTY")
209
+ return SubagentResult(
210
+ task_id=task.task_id, status="empty",
211
+ content=content, elapsed=elapsed,
212
+ error=f"Output too small ({len(content)} chars)", usage=resp.usage,
213
+ )
214
+
215
+ # Write output
216
+ task.output_path.parent.mkdir(parents=True, exist_ok=True)
217
+ task.output_path.write_text(content, encoding="utf-8")
218
+
219
+ # Verify write
220
+ if not task.output_path.exists() or task.output_path.stat().st_size < MIN_DOC_SIZE_BYTES:
221
+ write_progress(module_dir, task.doc_type, "WRITE_FAILED")
222
+ return SubagentResult(
223
+ task_id=task.task_id, status="failed", elapsed=elapsed,
224
+ error="Write verification failed", usage=resp.usage,
225
+ )
226
+
227
+ write_progress(module_dir, task.doc_type, f"DONE size={task.output_path.stat().st_size}")
228
+ return SubagentResult(
229
+ task_id=task.task_id, status="done",
230
+ output_path=task.output_path, content=content,
231
+ elapsed=elapsed, usage=resp.usage,
232
+ )
233
+
234
+ except Exception as e:
235
+ elapsed = time.time() - t0
236
+ write_progress(module_dir, task.doc_type, f"ERROR: {e}")
237
+ return SubagentResult(task_id=task.task_id, status="failed", elapsed=elapsed, error=str(e))
238
+
239
+
240
+ _GARBAGE_PATTERNS_DEFAULT = ("<web_search>", "<read_file>", "<search_results>", "Search results for")
241
+
242
+ _garbage_patterns_override: tuple[str, ...] | None = None
243
+
244
+
245
+ def configure_garbage_patterns(patterns: list[str] | None) -> None:
246
+ """Set garbage patterns from preset config. Call once at pipeline startup."""
247
+ global _garbage_patterns_override
248
+ if patterns:
249
+ _garbage_patterns_override = tuple(patterns)
250
+
251
+
252
+ def _strip_markdown_fence(content: str) -> str:
253
+ """Strip outer markdown code fence if LLM wrapped the output."""
254
+ if content.startswith("```markdown") and content.endswith("```"):
255
+ return content[len("```markdown"):-(len("```"))].strip()
256
+ if content.startswith("```md") and content.endswith("```"):
257
+ return content[len("```md"):-(len("```"))].strip()
258
+ if content.startswith("```") and content.endswith("```"):
259
+ first_nl = content.index("\n") if "\n" in content else 3
260
+ return content[first_nl + 1:-(len("```"))].strip()
261
+ return content
262
+
263
+
264
+ def _is_garbage_output(content: str) -> bool:
265
+ """Detect LLM outputs that contain tool-call artifacts instead of documentation."""
266
+ patterns = _garbage_patterns_override or _GARBAGE_PATTERNS_DEFAULT
267
+ first_500 = content[:500]
268
+ return any(p in first_500 for p in patterns)
269
+
270
+
271
+ def _run_with_retry(task: SubagentTask, strategy: LlmStrategy, *, save_prompts: bool = False) -> SubagentResult:
272
+ """Run a task with retry on failure/empty."""
273
+ for attempt in range(1, MAX_RETRIES + 1):
274
+ result = run_single(task, strategy, save_prompts=save_prompts and attempt == 1)
275
+ if result.status in ("done", "delegated"):
276
+ return result
277
+ if attempt < MAX_RETRIES:
278
+ logger.warning("[%s] attempt %d/%d %s — retrying", task.task_id, attempt, MAX_RETRIES, result.status)
279
+ time.sleep(2 ** attempt)
280
+ return result
281
+
282
+
283
+ def run_batch(
284
+ tasks: list[SubagentTask],
285
+ strategy: LlmStrategy,
286
+ max_concurrent: int = 5,
287
+ *,
288
+ save_prompts: bool = False,
289
+ breaker_config: CircuitBreakerConfig | None = None,
290
+ ) -> list[SubagentResult]:
291
+ """Execute a batch of tasks with concurrency control, retry, and circuit breaker.
292
+
293
+ Raises BatchAbortError if the circuit breaker trips (consecutive failures
294
+ or failure rate exceeds threshold).
295
+
296
+ Returns results in the same order as input tasks.
297
+ """
298
+ if not tasks:
299
+ return []
300
+
301
+ results: dict[str, SubagentResult] = {}
302
+ effective = min(max_concurrent, len(tasks))
303
+ breaker = _CircuitBreaker(len(tasks), breaker_config or CircuitBreakerConfig())
304
+
305
+ logger.info("Starting batch: %d tasks, concurrency=%d", len(tasks), effective)
306
+
307
+ with ThreadPoolExecutor(max_workers=effective) as pool:
308
+ futures = {pool.submit(_run_with_retry, t, strategy, save_prompts=save_prompts): t.task_id for t in tasks}
309
+ for future in as_completed(futures):
310
+ tid = futures[future]
311
+ try:
312
+ result = future.result()
313
+ results[tid] = result
314
+ if result.status in ("done", "delegated"):
315
+ breaker.record_success()
316
+ else:
317
+ breaker.record_failure(result.error)
318
+ except Exception as e:
319
+ results[tid] = SubagentResult(task_id=tid, status="failed", error=str(e))
320
+ breaker.record_failure(str(e))
321
+
322
+ if breaker.is_tripped:
323
+ abort_err = breaker.get_abort_error()
324
+ # Cancel remaining futures
325
+ for f in futures:
326
+ f.cancel()
327
+ logger.error("Circuit breaker tripped: %s", abort_err)
328
+ raise abort_err
329
+
330
+ ordered = [results[t.task_id] for t in tasks]
331
+ done = sum(1 for r in ordered if r.status == "done")
332
+ failed = sum(1 for r in ordered if r.status in ("failed", "empty"))
333
+ logger.info("Batch complete: %d done, %d failed", done, failed)
334
+ return ordered
335
+
@@ -0,0 +1,86 @@
1
+ """LLM strategy selection based on configuration.
2
+
3
+ Selects the appropriate LlmStrategy implementation based on agent.model config:
4
+ - "delegated" → DelegatedLlmStrategy
5
+ - "dry-run" → DryRunLlmStrategy
6
+ - anything else → ApiLlmStrategy (OpenAI-compatible)
7
+
8
+ Usage:
9
+ from engine.strategies import create_strategy, ConfigProxy
10
+ strategy = create_strategy(ConfigProxy(raw_config))
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+
17
+ from core.interfaces import LlmStrategy
18
+
19
+
20
+ class ConfigProxy:
21
+ """Adapts a raw config dict to the interface expected by create_strategy().
22
+
23
+ Resolves values from environment variables first, then config dict.
24
+ """
25
+
26
+ def __init__(self, config: dict, *, timeout_override: int | None = None):
27
+ self._raw = config
28
+ self._timeout_override = timeout_override
29
+
30
+ @property
31
+ def agent_backend(self):
32
+ model = os.getenv("LLM_MODEL", "") or self._raw.get("agent", {}).get("model", "")
33
+ if model == "delegated": return "delegated"
34
+ if model == "dry-run": return "dry-run"
35
+ return "api"
36
+
37
+ @property
38
+ def agent_base_url(self):
39
+ return os.getenv("LLM_BASE_URL", "") or self._raw.get("agent", {}).get("base_url", "")
40
+
41
+ @property
42
+ def agent_api_key(self):
43
+ return os.getenv("LLM_API_KEY", "") or self._raw.get("agent", {}).get("api_key", "")
44
+
45
+ @property
46
+ def agent_model(self):
47
+ return os.getenv("LLM_MODEL", "") or self._raw.get("agent", {}).get("model", "")
48
+
49
+ @property
50
+ def agent_timeout(self):
51
+ if self._timeout_override is not None:
52
+ return self._timeout_override
53
+ env = os.getenv("KB_AGENT_TIMEOUT", "")
54
+ if env:
55
+ try:
56
+ return int(env)
57
+ except ValueError:
58
+ pass
59
+ return self._raw.get("agent", {}).get("subagent_timeout", 900)
60
+
61
+
62
+ def create_strategy(config) -> LlmStrategy:
63
+ """Create the appropriate LLM strategy from config.
64
+
65
+ Args:
66
+ config: ConfigProxy instance or any object with agent_* properties
67
+
68
+ Returns:
69
+ LlmStrategy implementation
70
+ """
71
+ backend = config.agent_backend
72
+
73
+ if backend == "delegated":
74
+ from engine.strategies.delegated import DelegatedLlmStrategy
75
+ return DelegatedLlmStrategy()
76
+ elif backend == "dry-run":
77
+ from engine.strategies.dryrun import DryRunLlmStrategy
78
+ return DryRunLlmStrategy()
79
+ else:
80
+ from engine.strategies.api import ApiLlmStrategy
81
+ return ApiLlmStrategy(
82
+ base_url=config.agent_base_url,
83
+ api_key=config.agent_api_key,
84
+ model=config.agent_model,
85
+ timeout=config.agent_timeout,
86
+ )
@@ -0,0 +1,128 @@
1
+ """OpenAI-compatible API LLM strategy.
2
+
3
+ Calls any LLM provider supporting /v1/chat/completions endpoint.
4
+ Includes retry with exponential backoff, rate-limit handling, and timeout.
5
+
6
+ Supports: Anthropic, OpenAI, DeepSeek, Moonshot, Ollama, vLLM, LocalAI, etc.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+
15
+ import requests
16
+
17
+ from core.interfaces import LlmRequest, LlmResponse, LlmStrategy
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ MAX_RETRIES = 3
22
+
23
+
24
+ class ApiLlmStrategy(LlmStrategy):
25
+ """LLM execution via OpenAI-compatible HTTP API."""
26
+
27
+ def __init__(self, base_url: str, api_key: str, model: str, timeout: int = 900):
28
+ if not base_url:
29
+ raise RuntimeError(
30
+ "LLM API endpoint not configured. Set LLM_BASE_URL env var.\n"
31
+ "Examples: https://api.anthropic.com, https://api.openai.com, http://localhost:11434"
32
+ )
33
+ self._base_url = base_url.rstrip("/")
34
+ self._api_key = api_key
35
+ self._model = model
36
+ self._timeout = timeout
37
+ self._session = requests.Session()
38
+ self._session.headers.update({"Content-Type": "application/json"})
39
+ if api_key:
40
+ self._session.headers["Authorization"] = f"Bearer {api_key}"
41
+
42
+ def call(self, request: LlmRequest) -> LlmResponse:
43
+ model = request.model or self._model
44
+
45
+ body = {
46
+ "model": model,
47
+ "max_tokens": request.max_tokens,
48
+ "temperature": request.temperature,
49
+ "messages": [
50
+ {"role": "system", "content": request.system},
51
+ {"role": "user", "content": request.user},
52
+ ],
53
+ }
54
+
55
+ url = f"{self._base_url}/v1/chat/completions"
56
+
57
+ for attempt in range(1, MAX_RETRIES + 1):
58
+ t0 = time.time()
59
+ try:
60
+ resp = self._session.post(url, json=body, timeout=self._timeout)
61
+ except requests.exceptions.Timeout:
62
+ if attempt >= MAX_RETRIES:
63
+ return LlmResponse(content="", status="failed", error=f"Timeout after {MAX_RETRIES} retries")
64
+ time.sleep(2 ** attempt)
65
+ continue
66
+ except requests.exceptions.ConnectionError as e:
67
+ if attempt >= MAX_RETRIES:
68
+ return LlmResponse(content="", status="failed", error=f"Connection failed: {e}")
69
+ time.sleep(2 ** attempt)
70
+ continue
71
+
72
+ elapsed = time.time() - t0
73
+
74
+ if resp.status_code == 429:
75
+ retry_after = int(resp.headers.get("Retry-After", 2 ** attempt))
76
+ if attempt >= MAX_RETRIES:
77
+ return LlmResponse(content="", status="failed", error="Rate limited")
78
+ time.sleep(retry_after)
79
+ continue
80
+
81
+ if resp.status_code >= 500:
82
+ if attempt >= MAX_RETRIES:
83
+ return LlmResponse(content="", status="failed", error=f"Server error {resp.status_code}")
84
+ time.sleep(2 ** attempt)
85
+ continue
86
+
87
+ if resp.status_code >= 400:
88
+ return LlmResponse(content="", status="failed",
89
+ error=f"HTTP {resp.status_code}: {resp.text[:300]}")
90
+
91
+ try:
92
+ data = resp.json()
93
+ except ValueError:
94
+ return LlmResponse(content="", status="failed", error="Non-JSON response")
95
+
96
+ choices = data.get("choices", [])
97
+ if not choices:
98
+ return LlmResponse(content="", status="failed", error="Empty choices")
99
+
100
+ usage = data.get("usage", {})
101
+ return LlmResponse(
102
+ content=choices[0]["message"]["content"],
103
+ status="done",
104
+ usage={"prompt_tokens": usage.get("prompt_tokens", 0),
105
+ "completion_tokens": usage.get("completion_tokens", 0)},
106
+ elapsed=elapsed,
107
+ )
108
+
109
+ return LlmResponse(content="", status="failed", error="All retries exhausted")
110
+
111
+ def call_batch(self, requests_list: list[LlmRequest], max_concurrent: int = 5) -> list[LlmResponse]:
112
+ """Execute multiple requests with ThreadPoolExecutor."""
113
+ if not requests_list:
114
+ return []
115
+
116
+ results: dict[int, LlmResponse] = {}
117
+ effective = min(max_concurrent, len(requests_list))
118
+
119
+ with ThreadPoolExecutor(max_workers=effective) as pool:
120
+ futures = {pool.submit(self.call, req): i for i, req in enumerate(requests_list)}
121
+ for future in as_completed(futures):
122
+ idx = futures[future]
123
+ try:
124
+ results[idx] = future.result()
125
+ except Exception as e:
126
+ results[idx] = LlmResponse(content="", status="failed", error=str(e))
127
+
128
+ return [results[i] for i in range(len(requests_list))]
@@ -0,0 +1,50 @@
1
+ """Delegated LLM strategy — writes prompt JSON for external agent execution.
2
+
3
+ Instead of calling an API, generates prompt files that an external agent
4
+ (or human) can execute. Used with `--resume` to continue the pipeline
5
+ after prompts are fulfilled.
6
+
7
+ Usage:
8
+ strategy = DelegatedLlmStrategy()
9
+ response = strategy.call(request) # response.status == "delegated"
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import itertools
15
+ import json
16
+
17
+ from core.interfaces import LlmRequest, LlmResponse, LlmStrategy
18
+
19
+ _counter = itertools.count(1)
20
+
21
+
22
+ class DelegatedLlmStrategy(LlmStrategy):
23
+ """Write prompt JSON files for external execution."""
24
+
25
+ def call(self, request: LlmRequest) -> LlmResponse:
26
+ seq = next(_counter)
27
+ prompt_data = {
28
+ "id": f"prompt_{seq:04d}",
29
+ "system": request.system,
30
+ "user": request.user,
31
+ "model": request.model or "",
32
+ "max_tokens": request.max_tokens,
33
+ "temperature": request.temperature,
34
+ }
35
+
36
+ return LlmResponse(
37
+ content="",
38
+ status="delegated",
39
+ prompt_file=json.dumps(prompt_data, ensure_ascii=False),
40
+ )
41
+
42
+ def call_batch(self, requests_list: list[LlmRequest], max_concurrent: int = 5) -> list[LlmResponse]:
43
+ """Delegated mode: generate all prompts sequentially (no concurrency needed)."""
44
+ return [self.call(r) for r in requests_list]
45
+
46
+
47
+ def reset_counter() -> None:
48
+ """Reset the sequence counter (for testing)."""
49
+ global _counter
50
+ _counter = itertools.count(1)
@@ -0,0 +1,25 @@
1
+ """Dry-run LLM strategy — returns placeholder responses without network calls.
2
+
3
+ Used for testing pipeline logic, validating prompt construction, and
4
+ estimating token usage without incurring API costs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from core.interfaces import LlmRequest, LlmResponse, LlmStrategy
10
+
11
+
12
+ class DryRunLlmStrategy(LlmStrategy):
13
+ """Placeholder strategy that returns synthetic responses."""
14
+
15
+ def call(self, request: LlmRequest) -> LlmResponse:
16
+ model = request.model or "dry-run"
17
+ return LlmResponse(
18
+ content=(
19
+ f"[dry-run] model={model} "
20
+ f"system_len={len(request.system)} "
21
+ f"user_len={len(request.user)}"
22
+ ),
23
+ status="dry-run",
24
+ usage={"prompt_tokens": 0, "completion_tokens": 0},
25
+ )