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.
- cli/__init__.py +50 -0
- cli/__main__.py +5 -0
- cli/commands/__init__.py +1 -0
- cli/commands/anchor_fix.py +47 -0
- cli/commands/diff_doc.py +52 -0
- cli/commands/dispatch.py +77 -0
- cli/commands/extract.py +72 -0
- cli/commands/file_list.py +74 -0
- cli/commands/index.py +84 -0
- cli/commands/lock.py +89 -0
- cli/commands/merge.py +60 -0
- cli/commands/merge_delta.py +19 -0
- cli/commands/metadata.py +24 -0
- cli/commands/pipeline.py +45 -0
- cli/commands/post_merge.py +43 -0
- cli/commands/query.py +52 -0
- cli/commands/render.py +101 -0
- cli/commands/scan_repos.py +46 -0
- cli/commands/setup.py +94 -0
- cli/commands/split.py +196 -0
- cli/commands/stale_files.py +98 -0
- cli/commands/validate.py +191 -0
- core/__init__.py +32 -0
- core/config.py +261 -0
- core/docs/__init__.py +7 -0
- core/docs/section_updater.py +286 -0
- core/docs/shared.py +149 -0
- core/git.py +294 -0
- core/interfaces.py +249 -0
- core/monitor/__init__.py +5 -0
- core/monitor/progress.py +83 -0
- core/monitor/prompt_store.py +49 -0
- core/paths.py +141 -0
- core/preset.py +237 -0
- core/preset_accessors.py +202 -0
- core/preset_classify.py +132 -0
- core/preset_hooks.py +129 -0
- core/preset_profile.py +89 -0
- core/prompt/__init__.py +7 -0
- core/prompt/__main__.py +147 -0
- core/prompt/content.py +320 -0
- core/prompt/context_manager.py +164 -0
- core/prompt/renderer.py +236 -0
- core/prompt/response_parser.py +274 -0
- core/prompt/templates.py +357 -0
- core/prompt/validate_parity.py +162 -0
- core/prompt/variables.py +339 -0
- core/rag/__init__.py +22 -0
- core/rag/__main__.py +136 -0
- core/rag/bm25_index.py +268 -0
- core/rag/chunker.py +273 -0
- core/rag/embedder.py +151 -0
- core/rag/indexer.py +292 -0
- core/rag/loader.py +89 -0
- core/rag/retriever.py +82 -0
- core/skeleton/__init__.py +11 -0
- core/skeleton/__main__.py +934 -0
- core/skeleton/anchor_fix.py +250 -0
- core/skeleton/classify.py +331 -0
- core/skeleton/cmd_anchor_fix.py +43 -0
- core/skeleton/cmd_diff_doc.py +44 -0
- core/skeleton/cmd_lock.py +87 -0
- core/skeleton/cmd_merge_delta.py +41 -0
- core/skeleton/community.py +233 -0
- core/skeleton/dependency_graph.py +306 -0
- core/skeleton/diff_doc.py +248 -0
- core/skeleton/dispatch.py +273 -0
- core/skeleton/dispatch_render.py +319 -0
- core/skeleton/dispatch_source.py +111 -0
- core/skeleton/extract.py +218 -0
- core/skeleton/extract_methods.py +298 -0
- core/skeleton/file_list.py +239 -0
- core/skeleton/impact.py +278 -0
- core/skeleton/jar_download.py +177 -0
- core/skeleton/jar_resolver.py +186 -0
- core/skeleton/loader.py +162 -0
- core/skeleton/merge.py +278 -0
- core/skeleton/merge_delta.py +229 -0
- core/skeleton/metadata.py +96 -0
- core/skeleton/metadata_builders.py +264 -0
- core/skeleton/module_dag.py +330 -0
- core/skeleton/parsers/__init__.py +71 -0
- core/skeleton/parsers/jqassistant.py +300 -0
- core/skeleton/parsers/jqassistant_cypher.py +225 -0
- core/skeleton/parsers/regex.py +171 -0
- core/skeleton/parsers/treesitter.py +324 -0
- core/skeleton/parsers/treesitter_java.py +284 -0
- core/skeleton/parsers/treesitter_multi.py +289 -0
- core/skeleton/pom_parser.py +299 -0
- core/skeleton/post_merge.py +295 -0
- core/skeleton/post_merge_llm.py +82 -0
- core/skeleton/query.py +195 -0
- core/skeleton/shard_context.py +177 -0
- core/skeleton/split.py +180 -0
- core/skeleton/split_cache.py +107 -0
- core/skeleton/split_feedback.py +174 -0
- core/skeleton/split_plan.py +219 -0
- core/skeleton/split_plan_helpers.py +305 -0
- core/skeleton/split_plan_llm.py +274 -0
- core/utils.py +135 -0
- core/validators/__init__.py +65 -0
- core/validators/__main__.py +215 -0
- core/validators/consistency.py +203 -0
- core/validators/coverage.py +171 -0
- core/validators/duplicates.py +76 -0
- core/validators/engine.py +224 -0
- core/validators/links.py +76 -0
- core/validators/sampling.py +169 -0
- core/validators/structure.py +144 -0
- engine/__init__.py +7 -0
- engine/assembler.py +231 -0
- engine/confirm.py +65 -0
- engine/dedup.py +106 -0
- engine/main.py +211 -0
- engine/pipeline/__init__.py +163 -0
- engine/pipeline/recovery.py +250 -0
- engine/pipeline/steps/__init__.py +23 -0
- engine/pipeline/steps/audit.py +220 -0
- engine/pipeline/steps/audit_apply.py +195 -0
- engine/pipeline/steps/audit_helpers.py +155 -0
- engine/pipeline/steps/classify_llm.py +236 -0
- engine/pipeline/steps/classify_prompt.py +223 -0
- engine/pipeline/steps/finalize.py +160 -0
- engine/pipeline/steps/generate.py +169 -0
- engine/pipeline/steps/generate_batch.py +197 -0
- engine/pipeline/steps/generate_recovery.py +170 -0
- engine/pipeline/steps/llm_plan_split.py +253 -0
- engine/pipeline/steps/lock.py +64 -0
- engine/pipeline/steps/preflight.py +237 -0
- engine/pipeline/steps/preflight_adjust.py +147 -0
- engine/pipeline/steps/pregenerate.py +130 -0
- engine/pipeline/steps/quality.py +81 -0
- engine/pipeline/steps/skeleton.py +149 -0
- engine/pipeline/steps/source.py +163 -0
- engine/pipeline/steps/sync.py +117 -0
- engine/pipeline/steps/sync_finalize.py +237 -0
- engine/pipeline/steps/sync_update.py +341 -0
- engine/pipelines.py +91 -0
- engine/runner.py +335 -0
- engine/strategies/__init__.py +86 -0
- engine/strategies/api.py +128 -0
- engine/strategies/delegated.py +50 -0
- engine/strategies/dryrun.py +25 -0
- engine/two_phase.py +143 -0
- mcp_server/__init__.py +73 -0
- mcp_server/__main__.py +5 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/config.py +63 -0
- mcp_server/tools/discovery.py +276 -0
- mcp_server/tools/generation.py +184 -0
- mcp_server/tools/planning.py +144 -0
- mcp_server/tools/source.py +175 -0
- mcp_server/tools/validation.py +140 -0
- mcp_server/tools/workflow.py +166 -0
- mcp_server/workflow_loader.py +204 -0
- presets/generic/audit_dimensions.md +132 -0
- presets/generic/doc_types.yaml +152 -0
- presets/generic/preset.yaml +115 -0
- presets/java-spring/audit_dimensions.md +228 -0
- presets/java-spring/audit_dimensions.yaml +203 -0
- presets/java-spring/doc_types.yaml +269 -0
- presets/java-spring/hooks.py +122 -0
- presets/java-spring/preset.yaml +341 -0
- presets/java-spring/templates/README.md +34 -0
- presets/java-spring/templates/audit-system.md +15 -0
- presets/java-spring/templates/subagent-aop.md +105 -0
- presets/java-spring/templates/subagent-api.md +63 -0
- presets/java-spring/templates/subagent-architecture.md +111 -0
- presets/java-spring/templates/subagent-async-events.md +107 -0
- presets/java-spring/templates/subagent-audit-api-contracts.md +40 -0
- presets/java-spring/templates/subagent-audit-architecture.md +38 -0
- presets/java-spring/templates/subagent-audit-business.md +40 -0
- presets/java-spring/templates/subagent-audit-data-models.md +40 -0
- presets/java-spring/templates/subagent-business.md +129 -0
- presets/java-spring/templates/subagent-caching.md +75 -0
- presets/java-spring/templates/subagent-database-access.md +114 -0
- presets/java-spring/templates/subagent-enum.md +75 -0
- presets/java-spring/templates/subagent-error-handling.md +91 -0
- presets/java-spring/templates/subagent-external-integrations.md +80 -0
- presets/java-spring/templates/subagent-index.md +122 -0
- presets/java-spring/templates/subagent-messaging.md +97 -0
- presets/java-spring/templates/subagent-model.md +88 -0
- presets/java-spring/templates/subagent-observability.md +91 -0
- presets/java-spring/templates/subagent-scheduled.md +81 -0
- presets/java-spring/templates/subagent-security.md +102 -0
- presets/java-spring/templates/subagent-structure.md +101 -0
- presets/java-spring/templates/subagent-sync-section.md +34 -0
- presets/java-spring/templates/subagent-utils.md +73 -0
- presets/java-spring/templates/sync-system.md +8 -0
- presets/java-spring/workflow-extensions.md +112 -0
- skills/__init__.py +1 -0
- skills/_shared/README.md +30 -0
- skills/_shared/doc-coverage-shared.md +134 -0
- skills/_shared/doc-quality-standard.md +1058 -0
- skills/_shared/doc-subagent-rules.md +762 -0
- skills/_shared/windows-compat.md +89 -0
- skills/kb-audit/SKILL.md +52 -0
- skills/kb-audit/rules.md +88 -0
- skills/kb-audit/steps/step-01-prepare.md +75 -0
- skills/kb-audit/steps/step-02-audit.md +96 -0
- skills/kb-audit/steps/step-03-verify.md +65 -0
- skills/kb-audit/steps/step-04-report.md +64 -0
- skills/kb-init/SKILL.md +146 -0
- skills/kb-init/rules.md +187 -0
- skills/kb-init/steps/step-01-scope.md +62 -0
- skills/kb-init/steps/step-02-source.md +410 -0
- skills/kb-init/steps/step-03-generate.md +307 -0
- skills/kb-init/steps/step-04-quality.md +92 -0
- skills/kb-init/steps/step-05-finalize.md +132 -0
- skills/kb-init/templates/core/execution-modes.md +29 -0
- skills/kb-init/templates/core/output-only.md +4 -0
- skills/kb-init/templates/core/readwrite.md +33 -0
- skills/kb-search/SKILL.md +138 -0
- skills/kb-search/rules.md +64 -0
- skills/kb-sync/SKILL.md +43 -0
- skills/kb-sync/rules.md +70 -0
- skills/kb-sync/scripts/rebuild_module.py +91 -0
- skills/kb-sync/scripts/scan_repos.py +687 -0
- skills/kb-sync/steps/step-01-detect.md +72 -0
- skills/kb-sync/steps/step-02-update.md +71 -0
- skills/kb-sync/steps/step-03-verify.md +47 -0
- skills/kb-sync/steps/step-04-finalize.md +52 -0
- source_kb-0.2.2.dist-info/METADATA +194 -0
- source_kb-0.2.2.dist-info/RECORD +228 -0
- source_kb-0.2.2.dist-info/WHEEL +5 -0
- source_kb-0.2.2.dist-info/entry_points.txt +3 -0
- source_kb-0.2.2.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|
engine/strategies/api.py
ADDED
|
@@ -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
|
+
)
|