code-context-control 2.28.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.
- cli/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- tui/theme.tcss +335 -0
cli/tools/agent.py
ADDED
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
"""c3_agent — Compound workflow agent for multi-step C3 pipelines in a single call."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from concurrent.futures import TimeoutError as FuturesTimeout
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from core import count_tokens
|
|
9
|
+
|
|
10
|
+
# ── Progress logging ─────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
def _progress_ticker(svc, message, interval=3.0):
|
|
13
|
+
"""Context manager: sends animated dot-progress ticks during a slow step.
|
|
14
|
+
|
|
15
|
+
Emits one MCP log notification per interval with rotating dots and elapsed
|
|
16
|
+
time, so the user sees activity instead of silence during long waits.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
_log_progress(svc, "[3/3] Waiting for Gemini...")
|
|
20
|
+
with _progress_ticker(svc, "[3/3] Waiting for Gemini", interval=3.0):
|
|
21
|
+
result = slow_call()
|
|
22
|
+
"""
|
|
23
|
+
import threading
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
|
|
26
|
+
@contextmanager
|
|
27
|
+
def _ctx():
|
|
28
|
+
stop = threading.Event()
|
|
29
|
+
dots_cycle = [". ", ".. ", "...", " .."]
|
|
30
|
+
|
|
31
|
+
def _tick():
|
|
32
|
+
i = 0
|
|
33
|
+
t0 = time.time()
|
|
34
|
+
while not stop.wait(interval):
|
|
35
|
+
elapsed = int(time.time() - t0)
|
|
36
|
+
_log_progress(svc, f"{message}{dots_cycle[i % len(dots_cycle)]} {elapsed}s")
|
|
37
|
+
i += 1
|
|
38
|
+
|
|
39
|
+
t = threading.Thread(target=_tick, daemon=True)
|
|
40
|
+
t.start()
|
|
41
|
+
try:
|
|
42
|
+
yield
|
|
43
|
+
finally:
|
|
44
|
+
stop.set()
|
|
45
|
+
t.join(timeout=1)
|
|
46
|
+
|
|
47
|
+
return _ctx()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _log_progress(svc, message):
|
|
51
|
+
"""Emit workflow progress: MCP log notification + .c3/agent_progress.jsonl file."""
|
|
52
|
+
# Live MCP notification (shown in Claude Code during tool execution)
|
|
53
|
+
cb = getattr(svc, "_agent_progress_cb", None)
|
|
54
|
+
if cb:
|
|
55
|
+
try:
|
|
56
|
+
cb(message)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
# Persistent file log (for hub UI / external readers)
|
|
60
|
+
try:
|
|
61
|
+
import json as _json
|
|
62
|
+
progress_file = Path(svc.project_path) / ".c3" / "agent_progress.jsonl"
|
|
63
|
+
progress_file.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
with open(progress_file, "a", encoding='utf-8') as f:
|
|
65
|
+
f.write(_json.dumps({"ts": time.time(), "step": message}) + "\n")
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _delegate_failure_reason(steps_log):
|
|
71
|
+
"""Extract last delegate failure reason from steps_log, if any."""
|
|
72
|
+
for entry in reversed(steps_log):
|
|
73
|
+
if "(skipped:" in entry or "(timeout" in entry or "(error" in entry or "(idle_timeout" in entry:
|
|
74
|
+
return entry
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Workflow registry ─────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
AGENT_WORKFLOWS = {
|
|
81
|
+
"review_changes": {
|
|
82
|
+
"description": "Review staged/unstaged git changes with structural context",
|
|
83
|
+
"steps": ["git_diff", "compress_changed", "delegate_review"],
|
|
84
|
+
},
|
|
85
|
+
"prepare_context": {
|
|
86
|
+
"description": "Build a compressed context package for specified files/scope",
|
|
87
|
+
"steps": ["resolve_files", "compress_all", "bundle"],
|
|
88
|
+
},
|
|
89
|
+
"investigate": {
|
|
90
|
+
"description": "Investigate an error or issue using search + compress + activity",
|
|
91
|
+
"steps": ["search_code", "compress_hits", "activity_context", "delegate_diagnose"],
|
|
92
|
+
},
|
|
93
|
+
"preflight": {
|
|
94
|
+
"description": "Pre-edit check: validate + compress (parallel) + recent activity for target files",
|
|
95
|
+
"steps": ["validate+compress(parallel)", "activity_summary"],
|
|
96
|
+
},
|
|
97
|
+
"validate_compress": {
|
|
98
|
+
"description": "Validate and compress a file set in one call — both run in parallel",
|
|
99
|
+
"steps": ["validate+compress(parallel)"],
|
|
100
|
+
},
|
|
101
|
+
"codex_review": {
|
|
102
|
+
"description": "Review staged/unstaged git changes using Codex CLI for deeper analysis",
|
|
103
|
+
"steps": ["git_diff", "compress_changed", "codex_delegate_review"],
|
|
104
|
+
},
|
|
105
|
+
"consensus_review": {
|
|
106
|
+
"description": "Cross-model consensus: run Codex + Gemini in parallel, diff findings",
|
|
107
|
+
"steps": ["git_diff", "compress_changed", "parallel(codex_review, gemini_review)", "merge_findings"],
|
|
108
|
+
},
|
|
109
|
+
"codex_test_gen": {
|
|
110
|
+
"description": "Generate and run tests via Codex workspace-write sandbox",
|
|
111
|
+
"steps": ["identify_changes", "compress_targets", "codex_generate_tests"],
|
|
112
|
+
},
|
|
113
|
+
"gemini_review": {
|
|
114
|
+
"description": "Review staged/unstaged git changes using Gemini CLI",
|
|
115
|
+
"steps": ["git_diff", "compress_changed", "gemini_delegate_review"],
|
|
116
|
+
},
|
|
117
|
+
"tri_consensus": {
|
|
118
|
+
"description": "Cloud consensus: Codex + Gemini in parallel, merged findings",
|
|
119
|
+
"steps": ["git_diff", "compress_changed", "parallel(codex, gemini)", "merge_findings"],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def handle_agent(workflow: str, scope: str, context: str,
|
|
125
|
+
svc, finalize) -> str:
|
|
126
|
+
"""Execute a compound workflow."""
|
|
127
|
+
if workflow == "available":
|
|
128
|
+
wf_list = "\n".join(
|
|
129
|
+
f"- {k}: {v['description']}" for k, v in AGENT_WORKFLOWS.items()
|
|
130
|
+
)
|
|
131
|
+
return finalize("c3_agent", {"workflow": "available"},
|
|
132
|
+
f"[agent:available] {len(AGENT_WORKFLOWS)} workflows\n{wf_list}",
|
|
133
|
+
f"{len(AGENT_WORKFLOWS)} workflows")
|
|
134
|
+
|
|
135
|
+
wdef = AGENT_WORKFLOWS.get(workflow)
|
|
136
|
+
if not wdef:
|
|
137
|
+
available = ", ".join(AGENT_WORKFLOWS.keys())
|
|
138
|
+
return finalize("c3_agent", {"workflow": workflow},
|
|
139
|
+
f"[agent:error] Unknown workflow '{workflow}'. Available: {available}",
|
|
140
|
+
"error")
|
|
141
|
+
|
|
142
|
+
t0 = time.time()
|
|
143
|
+
steps_log = []
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
if workflow == "review_changes":
|
|
147
|
+
result = _wf_review_changes(scope, context, svc, steps_log)
|
|
148
|
+
elif workflow == "codex_review":
|
|
149
|
+
result = _wf_codex_review(scope, context, svc, steps_log)
|
|
150
|
+
elif workflow == "consensus_review":
|
|
151
|
+
result = _wf_consensus_review(scope, context, svc, steps_log)
|
|
152
|
+
elif workflow == "codex_test_gen":
|
|
153
|
+
result = _wf_codex_test_gen(scope, context, svc, steps_log)
|
|
154
|
+
elif workflow == "gemini_review":
|
|
155
|
+
result = _wf_gemini_review(scope, context, svc, steps_log)
|
|
156
|
+
elif workflow == "tri_consensus":
|
|
157
|
+
result = _wf_tri_consensus(scope, context, svc, steps_log)
|
|
158
|
+
elif workflow == "prepare_context":
|
|
159
|
+
result = _wf_prepare_context(scope, context, svc, steps_log)
|
|
160
|
+
elif workflow == "investigate":
|
|
161
|
+
result = _wf_investigate(scope, context, svc, steps_log)
|
|
162
|
+
elif workflow == "preflight":
|
|
163
|
+
result = _wf_preflight(scope, context, svc, steps_log)
|
|
164
|
+
elif workflow == "validate_compress":
|
|
165
|
+
result = _wf_validate_compress(scope, context, svc, steps_log)
|
|
166
|
+
else:
|
|
167
|
+
result = f"[agent:error] Workflow '{workflow}' not implemented"
|
|
168
|
+
except Exception as e:
|
|
169
|
+
result = f"[agent:error] {workflow} failed: {e}"
|
|
170
|
+
|
|
171
|
+
elapsed = time.time() - t0
|
|
172
|
+
steps_summary = " → ".join(steps_log) if steps_log else "no steps"
|
|
173
|
+
header = f"[agent:{workflow}] {elapsed:.1f}s | {steps_summary}\n\n"
|
|
174
|
+
|
|
175
|
+
return finalize("c3_agent", {"workflow": workflow, "scope": scope},
|
|
176
|
+
header + result,
|
|
177
|
+
f"{workflow} {elapsed:.1f}s")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── Workflow implementations ──────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def _wf_review_changes(scope, context, svc, steps_log):
|
|
183
|
+
"""Review git changes: diff → compress changed files → delegate review."""
|
|
184
|
+
|
|
185
|
+
# Workflow-level deadline: abort if total time exceeds this.
|
|
186
|
+
_workflow_deadline = time.time() + 90 # 90 seconds max for entire workflow
|
|
187
|
+
|
|
188
|
+
def _check_deadline(step_name: str):
|
|
189
|
+
if time.time() > _workflow_deadline:
|
|
190
|
+
raise TimeoutError(f"[{step_name}] workflow deadline exceeded (90s)")
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
return _wf_review_changes_inner(scope, context, svc, steps_log,
|
|
194
|
+
_workflow_deadline, _check_deadline)
|
|
195
|
+
except TimeoutError as e:
|
|
196
|
+
_log_progress(svc, f"[timeout] {e}")
|
|
197
|
+
return f"[workflow:timeout] {e}\nSteps completed: {' → '.join(steps_log)}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _wf_review_changes_inner(scope, context, svc, steps_log,
|
|
201
|
+
_workflow_deadline, _check_deadline):
|
|
202
|
+
import subprocess
|
|
203
|
+
|
|
204
|
+
# Step 1: Get git diff
|
|
205
|
+
_log_progress(svc, "[1/3] Running git diff...")
|
|
206
|
+
diff_cmd = ["git", "diff"]
|
|
207
|
+
if scope == "staged":
|
|
208
|
+
diff_cmd.append("--cached")
|
|
209
|
+
elif scope == "HEAD":
|
|
210
|
+
diff_cmd.extend(["HEAD~1", "HEAD"])
|
|
211
|
+
# default: unstaged changes
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
|
|
215
|
+
stdin=subprocess.DEVNULL,
|
|
216
|
+
cwd=svc.project_path)
|
|
217
|
+
diff_text = r.stdout.strip()
|
|
218
|
+
except Exception as e:
|
|
219
|
+
return f"[step:git_diff] Failed: {e}"
|
|
220
|
+
|
|
221
|
+
if not diff_text:
|
|
222
|
+
return "[step:git_diff] No changes found"
|
|
223
|
+
steps_log.append(f"diff({len(diff_text)}ch)")
|
|
224
|
+
|
|
225
|
+
# Step 2: Extract changed file paths
|
|
226
|
+
changed_files = set()
|
|
227
|
+
for line in diff_text.split("\n"):
|
|
228
|
+
if line.startswith("diff --git"):
|
|
229
|
+
parts = line.split(" b/")
|
|
230
|
+
if len(parts) >= 2:
|
|
231
|
+
changed_files.add(parts[-1])
|
|
232
|
+
|
|
233
|
+
# Start Gemini subprocess early so its ~9s MCP startup overlaps with compress
|
|
234
|
+
dcfg = svc.delegate_config or {}
|
|
235
|
+
gemini_proc = None
|
|
236
|
+
if dcfg.get("enabled", True) and dcfg.get("gemini_enabled", False):
|
|
237
|
+
import cli.tools.delegate as _dm
|
|
238
|
+
from cli.tools.delegate import GEMINI_MODELS, _is_gemini_on_path, _start_gemini_early
|
|
239
|
+
# Prefer cached availability (avoids shutil.which on slow network-path systems)
|
|
240
|
+
_gem_ok = (_dm._gemini_available is True) or (
|
|
241
|
+
_dm._gemini_available is None and _is_gemini_on_path()
|
|
242
|
+
)
|
|
243
|
+
if _gem_ok and _dm._gemini_available is not False:
|
|
244
|
+
gdef = GEMINI_MODELS.get("review", GEMINI_MODELS.get("ask", {}))
|
|
245
|
+
model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
|
|
246
|
+
_log_progress(svc, f"[2/3] Compressing {len(changed_files)} file(s) + starting Gemini...")
|
|
247
|
+
gemini_proc = _start_gemini_early(
|
|
248
|
+
model=model,
|
|
249
|
+
timeout=int(dcfg.get("gemini_timeout", 45)),
|
|
250
|
+
idle_timeout=15,
|
|
251
|
+
cwd=str(svc.project_path),
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
_log_progress(svc, "[2/3] Compressing changed files...")
|
|
255
|
+
else:
|
|
256
|
+
_log_progress(svc, "[2/3] Compressing changed files...")
|
|
257
|
+
|
|
258
|
+
with _progress_ticker(svc, "[2/3] Compressing", interval=1.5):
|
|
259
|
+
maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
|
|
260
|
+
|
|
261
|
+
_check_deadline("compress")
|
|
262
|
+
|
|
263
|
+
# Step 3: Feed prompt to already-running Gemini (MCP startup overlapped with compress)
|
|
264
|
+
# Clamp delegate timeout to remaining workflow time to prevent overshoot.
|
|
265
|
+
_remaining = max(10, int(_workflow_deadline - time.time()))
|
|
266
|
+
review_context = f"Git diff:\n{diff_text[:3000]}\n\nStructural maps:\n{maps}"
|
|
267
|
+
if context:
|
|
268
|
+
review_context += f"\n\nUser context: {context}"
|
|
269
|
+
|
|
270
|
+
if gemini_proc is not None:
|
|
271
|
+
_log_progress(svc, "[3/3] Sending prompt to Gemini (MCP ready)...")
|
|
272
|
+
from cli.tools.delegate import _finish_gemini_early
|
|
273
|
+
with _progress_ticker(svc, "[3/3] Waiting for Gemini", interval=3.0):
|
|
274
|
+
output, ok, _ = _finish_gemini_early(
|
|
275
|
+
gemini_proc, "Review these changes for issues", review_context,
|
|
276
|
+
timeout=min(int(dcfg.get("gemini_timeout", 45)), _remaining),
|
|
277
|
+
idle_timeout=15,
|
|
278
|
+
)
|
|
279
|
+
if ok and output:
|
|
280
|
+
steps_log.append("gemini(review,early_start)")
|
|
281
|
+
delegate_result = output
|
|
282
|
+
else:
|
|
283
|
+
if output and output.startswith("["):
|
|
284
|
+
steps_log.append(f"gemini({output.split(']')[0]}])".replace("[", ""))
|
|
285
|
+
else:
|
|
286
|
+
steps_log.append("gemini(error:no_output)")
|
|
287
|
+
delegate_result = ""
|
|
288
|
+
else:
|
|
289
|
+
_log_progress(svc, "[3/3] Delegating review to Gemini...")
|
|
290
|
+
with _progress_ticker(svc, "[3/3] Waiting for Gemini", interval=3.0):
|
|
291
|
+
delegate_result = _try_delegate(svc, "review", "Review these changes for issues",
|
|
292
|
+
review_context, steps_log, prefer_gemini=True)
|
|
293
|
+
|
|
294
|
+
parts = [f"--- Changed files ({len(changed_files)}) ---"]
|
|
295
|
+
for f in sorted(changed_files):
|
|
296
|
+
parts.append(f" {f}")
|
|
297
|
+
parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
|
|
298
|
+
# Truncate diff for response
|
|
299
|
+
if count_tokens(diff_text) > 800:
|
|
300
|
+
parts.append(diff_text[:2000] + "\n... [truncated]")
|
|
301
|
+
else:
|
|
302
|
+
parts.append(diff_text)
|
|
303
|
+
if maps:
|
|
304
|
+
parts.append(f"\n--- Structural maps ---\n{maps}")
|
|
305
|
+
if delegate_result:
|
|
306
|
+
parts.append(f"\n--- AI Review ---\n{delegate_result}")
|
|
307
|
+
else:
|
|
308
|
+
reason = _delegate_failure_reason(steps_log)
|
|
309
|
+
parts.append(f"\n--- AI Review skipped: {reason or 'no backend available'} ---")
|
|
310
|
+
|
|
311
|
+
return "\n".join(parts)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _wf_codex_review(scope, context, svc, steps_log):
|
|
315
|
+
"""Review git changes using Codex CLI for deeper analysis."""
|
|
316
|
+
import subprocess
|
|
317
|
+
|
|
318
|
+
# Step 1: Get git diff (same as review_changes)
|
|
319
|
+
_log_progress(svc, "[1/3] Running git diff...")
|
|
320
|
+
diff_cmd = ["git", "diff"]
|
|
321
|
+
if scope == "staged":
|
|
322
|
+
diff_cmd.append("--cached")
|
|
323
|
+
elif scope == "HEAD":
|
|
324
|
+
diff_cmd.extend(["HEAD~1", "HEAD"])
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
|
|
328
|
+
cwd=svc.project_path)
|
|
329
|
+
diff_text = r.stdout.strip()
|
|
330
|
+
except Exception as e:
|
|
331
|
+
return f"[step:git_diff] Failed: {e}"
|
|
332
|
+
|
|
333
|
+
if not diff_text:
|
|
334
|
+
return "[step:git_diff] No changes found"
|
|
335
|
+
steps_log.append(f"diff({len(diff_text)}ch)")
|
|
336
|
+
|
|
337
|
+
# Step 2: Compress changed files
|
|
338
|
+
changed_files = set()
|
|
339
|
+
for line in diff_text.split("\n"):
|
|
340
|
+
if line.startswith("diff --git"):
|
|
341
|
+
parts = line.split(" b/")
|
|
342
|
+
if len(parts) >= 2:
|
|
343
|
+
changed_files.add(parts[-1])
|
|
344
|
+
|
|
345
|
+
_log_progress(svc, f"[2/3] Compressing {len(changed_files)} changed files...")
|
|
346
|
+
with _progress_ticker(svc, "[2/3] Compressing", interval=1.5):
|
|
347
|
+
maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
|
|
348
|
+
|
|
349
|
+
# Step 3: Delegate to Codex (prefer_codex=True)
|
|
350
|
+
_log_progress(svc, "[3/3] Delegating review to Codex...")
|
|
351
|
+
review_context = f"Git diff:\n{diff_text[:6000]}\n\nStructural maps:\n{maps}"
|
|
352
|
+
if context:
|
|
353
|
+
review_context += f"\n\nUser context: {context}"
|
|
354
|
+
|
|
355
|
+
with _progress_ticker(svc, "[3/3] Waiting for Codex", interval=3.0):
|
|
356
|
+
delegate_result = _try_delegate(svc, "review", "Review these changes for issues, regressions, and missing tests",
|
|
357
|
+
review_context, steps_log, prefer_codex=True)
|
|
358
|
+
|
|
359
|
+
parts = [f"--- Changed files ({len(changed_files)}) ---"]
|
|
360
|
+
for f in sorted(changed_files):
|
|
361
|
+
parts.append(f" {f}")
|
|
362
|
+
parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
|
|
363
|
+
if count_tokens(diff_text) > 800:
|
|
364
|
+
parts.append(diff_text[:2000] + "\n... [truncated]")
|
|
365
|
+
else:
|
|
366
|
+
parts.append(diff_text)
|
|
367
|
+
if maps:
|
|
368
|
+
parts.append(f"\n--- Structural maps ---\n{maps}")
|
|
369
|
+
if delegate_result:
|
|
370
|
+
parts.append(f"\n--- Codex Review ---\n{delegate_result}")
|
|
371
|
+
else:
|
|
372
|
+
reason = _delegate_failure_reason(steps_log)
|
|
373
|
+
parts.append(f"\n--- Codex Review skipped: {reason or 'no backend available'} ---")
|
|
374
|
+
|
|
375
|
+
return "\n".join(parts)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _wf_consensus_review(scope, context, svc, steps_log):
|
|
379
|
+
"""Cross-model consensus: run Ollama + Codex review in parallel, merge findings."""
|
|
380
|
+
import subprocess
|
|
381
|
+
|
|
382
|
+
# Step 1: Get git diff
|
|
383
|
+
_log_progress(svc, "[1/4] Running git diff...")
|
|
384
|
+
diff_cmd = ["git", "diff"]
|
|
385
|
+
if scope == "staged":
|
|
386
|
+
diff_cmd.append("--cached")
|
|
387
|
+
elif scope == "HEAD":
|
|
388
|
+
diff_cmd.extend(["HEAD~1", "HEAD"])
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
|
|
392
|
+
cwd=svc.project_path)
|
|
393
|
+
diff_text = r.stdout.strip()
|
|
394
|
+
except Exception as e:
|
|
395
|
+
return f"[step:git_diff] Failed: {e}"
|
|
396
|
+
|
|
397
|
+
if not diff_text:
|
|
398
|
+
return "[step:git_diff] No changes found"
|
|
399
|
+
steps_log.append(f"diff({len(diff_text)}ch)")
|
|
400
|
+
|
|
401
|
+
# Step 2: Compress changed files
|
|
402
|
+
changed_files = set()
|
|
403
|
+
for line in diff_text.split("\n"):
|
|
404
|
+
if line.startswith("diff --git"):
|
|
405
|
+
parts = line.split(" b/")
|
|
406
|
+
if len(parts) >= 2:
|
|
407
|
+
changed_files.add(parts[-1])
|
|
408
|
+
_log_progress(svc, f"[2/4] Compressing {len(changed_files)} changed files...")
|
|
409
|
+
with _progress_ticker(svc, "[2/4] Compressing", interval=1.5):
|
|
410
|
+
maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
|
|
411
|
+
|
|
412
|
+
review_context = f"Git diff:\n{diff_text[:4000]}\n\nStructural maps:\n{maps}"
|
|
413
|
+
if context:
|
|
414
|
+
review_context += f"\n\nUser context: {context}"
|
|
415
|
+
review_task = "Review these changes for bugs, regressions, and missing tests. Be concise."
|
|
416
|
+
|
|
417
|
+
# Step 3: Run Codex and Gemini in parallel
|
|
418
|
+
_log_progress(svc, "[3/4] Delegating to Codex + Gemini in parallel...")
|
|
419
|
+
codex_result = ""
|
|
420
|
+
gemini_result = ""
|
|
421
|
+
codex_steps = []
|
|
422
|
+
gemini_steps = []
|
|
423
|
+
|
|
424
|
+
def _run_codex():
|
|
425
|
+
return _try_delegate(svc, "review", review_task, review_context, codex_steps, prefer_codex=True)
|
|
426
|
+
|
|
427
|
+
def _run_gemini():
|
|
428
|
+
return _try_delegate(svc, "review", review_task, review_context, gemini_steps, prefer_gemini=True)
|
|
429
|
+
|
|
430
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
431
|
+
fut_codex = pool.submit(_run_codex)
|
|
432
|
+
fut_gemini = pool.submit(_run_gemini)
|
|
433
|
+
try:
|
|
434
|
+
codex_result = fut_codex.result(timeout=100)
|
|
435
|
+
steps_log.extend(codex_steps)
|
|
436
|
+
except Exception:
|
|
437
|
+
codex_result = "[timeout/error]"
|
|
438
|
+
try:
|
|
439
|
+
gemini_result = fut_gemini.result(timeout=100)
|
|
440
|
+
steps_log.extend(gemini_steps)
|
|
441
|
+
except Exception:
|
|
442
|
+
gemini_result = "[timeout/error]"
|
|
443
|
+
|
|
444
|
+
# Step 4: Merge findings
|
|
445
|
+
parts = [f"--- Changed files ({len(changed_files)}) ---"]
|
|
446
|
+
for f in sorted(changed_files):
|
|
447
|
+
parts.append(f" {f}")
|
|
448
|
+
parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
|
|
449
|
+
if count_tokens(diff_text) > 800:
|
|
450
|
+
parts.append(diff_text[:2000] + "\n... [truncated]")
|
|
451
|
+
else:
|
|
452
|
+
parts.append(diff_text)
|
|
453
|
+
if maps:
|
|
454
|
+
parts.append(f"\n--- Structural maps ---\n{maps}")
|
|
455
|
+
|
|
456
|
+
parts.append("\n" + "=" * 60)
|
|
457
|
+
parts.append("CONSENSUS REVIEW — Cloud backend analyses")
|
|
458
|
+
parts.append("=" * 60)
|
|
459
|
+
|
|
460
|
+
backends = [
|
|
461
|
+
("Codex (cloud/GPT)", codex_result),
|
|
462
|
+
("Gemini (cloud/Google)", gemini_result),
|
|
463
|
+
]
|
|
464
|
+
available_count = 0
|
|
465
|
+
for label, result in backends:
|
|
466
|
+
if result and result != "[timeout/error]":
|
|
467
|
+
parts.append(f"\n--- {label} ---\n{result}")
|
|
468
|
+
available_count += 1
|
|
469
|
+
else:
|
|
470
|
+
parts.append(f"\n--- {label} ---\n[unavailable or no response]")
|
|
471
|
+
|
|
472
|
+
_log_progress(svc, "[4/4] Merging consensus results...")
|
|
473
|
+
if available_count == 2:
|
|
474
|
+
parts.append("\n--- Consensus ---")
|
|
475
|
+
parts.append("Both models provided reviews. Compare findings above for agreement/divergence.")
|
|
476
|
+
elif available_count == 1:
|
|
477
|
+
parts.append("\n--- Note: Only one backend responded ---")
|
|
478
|
+
else:
|
|
479
|
+
reasons = [_delegate_failure_reason(codex_steps), _delegate_failure_reason(gemini_steps)]
|
|
480
|
+
detail = ", ".join(r for r in reasons if r) or "check delegate configuration"
|
|
481
|
+
parts.append(f"\n--- No backends available: {detail} ---")
|
|
482
|
+
|
|
483
|
+
return "\n".join(parts)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _wf_codex_test_gen(scope, context, svc, steps_log):
|
|
487
|
+
"""Generate tests via Codex CLI with workspace-write sandbox."""
|
|
488
|
+
from cli.tools.delegate import _codex_available, _run_codex, check_codex
|
|
489
|
+
|
|
490
|
+
# Verify Codex
|
|
491
|
+
if _codex_available is None:
|
|
492
|
+
check_codex()
|
|
493
|
+
from cli.tools.delegate import _codex_available as avail
|
|
494
|
+
if not avail:
|
|
495
|
+
return "[codex_test_gen:error] Codex CLI not available"
|
|
496
|
+
|
|
497
|
+
dcfg = svc.delegate_config or {}
|
|
498
|
+
if not dcfg.get("codex_enabled", False):
|
|
499
|
+
return "[codex_test_gen:error] Codex not enabled in config"
|
|
500
|
+
|
|
501
|
+
# Resolve target files from scope
|
|
502
|
+
files = []
|
|
503
|
+
if scope and ("," in scope or scope.endswith((".py", ".js", ".ts", ".go", ".rs", ".java"))):
|
|
504
|
+
files = [f.strip() for f in scope.split(",") if f.strip()]
|
|
505
|
+
else:
|
|
506
|
+
# Use edit ledger to find recently edited files
|
|
507
|
+
try:
|
|
508
|
+
recent = svc.edit_ledger.get_history(limit=5)
|
|
509
|
+
files = list(dict.fromkeys(e.get("file", "") for e in recent if e.get("file")))[:5]
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
if not files:
|
|
514
|
+
return "[codex_test_gen:error] No target files found. Provide file paths in scope or edit files first."
|
|
515
|
+
|
|
516
|
+
steps_log.append(f"targets({len(files)})")
|
|
517
|
+
|
|
518
|
+
# Compress targets for context
|
|
519
|
+
maps = _parallel_compress(files, svc, steps_log)
|
|
520
|
+
|
|
521
|
+
# Build test generation prompt
|
|
522
|
+
file_list = ", ".join(files)
|
|
523
|
+
task = (
|
|
524
|
+
f"Generate focused unit tests for the following files: {file_list}. "
|
|
525
|
+
"Write tests that maximize defect coverage with minimal redundancy. "
|
|
526
|
+
"Use the project's existing test framework and conventions. "
|
|
527
|
+
"Write the test files to disk."
|
|
528
|
+
)
|
|
529
|
+
gen_context = f"Target files:\n{file_list}\n\nStructural maps:\n{maps}"
|
|
530
|
+
if context:
|
|
531
|
+
gen_context += f"\n\nAdditional context: {context}"
|
|
532
|
+
|
|
533
|
+
model = dcfg.get("codex_default_model", "gpt-5.3-codex-spark")
|
|
534
|
+
timeout = int(dcfg.get("codex_timeout", 180))
|
|
535
|
+
|
|
536
|
+
steps_log.append("codex_generate")
|
|
537
|
+
output, ok = _run_codex(
|
|
538
|
+
task=task, context=gen_context,
|
|
539
|
+
model=model, sandbox="workspace-write",
|
|
540
|
+
reasoning="high", timeout=timeout,
|
|
541
|
+
cwd=str(svc.project_path),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
parts = [f"--- Test generation for {len(files)} file(s) ---"]
|
|
545
|
+
parts.append(f"Files: {file_list}")
|
|
546
|
+
parts.append("Sandbox: workspace-write")
|
|
547
|
+
parts.append(f"Model: {model}")
|
|
548
|
+
if ok:
|
|
549
|
+
parts.append(f"\n--- Codex output ---\n{output}")
|
|
550
|
+
else:
|
|
551
|
+
parts.append(f"\n--- Error ---\n{output}")
|
|
552
|
+
|
|
553
|
+
return "\n".join(parts)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _wf_gemini_review(scope, context, svc, steps_log):
|
|
557
|
+
"""Review git changes using Gemini CLI."""
|
|
558
|
+
import subprocess
|
|
559
|
+
|
|
560
|
+
_log_progress(svc, "[1/3] Running git diff...")
|
|
561
|
+
diff_cmd = ["git", "diff"]
|
|
562
|
+
if scope == "staged":
|
|
563
|
+
diff_cmd.append("--cached")
|
|
564
|
+
elif scope == "HEAD":
|
|
565
|
+
diff_cmd.extend(["HEAD~1", "HEAD"])
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
|
|
569
|
+
cwd=svc.project_path)
|
|
570
|
+
diff_text = r.stdout.strip()
|
|
571
|
+
except Exception as e:
|
|
572
|
+
return f"[step:git_diff] Failed: {e}"
|
|
573
|
+
|
|
574
|
+
if not diff_text:
|
|
575
|
+
return "[step:git_diff] No changes found"
|
|
576
|
+
steps_log.append(f"diff({len(diff_text)}ch)")
|
|
577
|
+
|
|
578
|
+
changed_files = set()
|
|
579
|
+
for line in diff_text.split("\n"):
|
|
580
|
+
if line.startswith("diff --git"):
|
|
581
|
+
parts = line.split(" b/")
|
|
582
|
+
if len(parts) >= 2:
|
|
583
|
+
changed_files.add(parts[-1])
|
|
584
|
+
|
|
585
|
+
_log_progress(svc, f"[2/3] Compressing {len(changed_files)} changed files...")
|
|
586
|
+
with _progress_ticker(svc, "[2/3] Compressing", interval=1.5):
|
|
587
|
+
maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
|
|
588
|
+
|
|
589
|
+
review_context = f"Git diff:\n{diff_text[:6000]}\n\nStructural maps:\n{maps}"
|
|
590
|
+
if context:
|
|
591
|
+
review_context += f"\n\nUser context: {context}"
|
|
592
|
+
|
|
593
|
+
_log_progress(svc, "[3/3] Delegating review to Gemini...")
|
|
594
|
+
with _progress_ticker(svc, "[3/3] Waiting for Gemini", interval=3.0):
|
|
595
|
+
delegate_result = _try_delegate(svc, "review", "Review these changes for issues, regressions, and missing tests",
|
|
596
|
+
review_context, steps_log, prefer_gemini=True)
|
|
597
|
+
|
|
598
|
+
parts = [f"--- Changed files ({len(changed_files)}) ---"]
|
|
599
|
+
for f in sorted(changed_files):
|
|
600
|
+
parts.append(f" {f}")
|
|
601
|
+
parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
|
|
602
|
+
if count_tokens(diff_text) > 800:
|
|
603
|
+
parts.append(diff_text[:2000] + "\n... [truncated]")
|
|
604
|
+
else:
|
|
605
|
+
parts.append(diff_text)
|
|
606
|
+
if maps:
|
|
607
|
+
parts.append(f"\n--- Structural maps ---\n{maps}")
|
|
608
|
+
if delegate_result:
|
|
609
|
+
parts.append(f"\n--- Gemini Review ---\n{delegate_result}")
|
|
610
|
+
else:
|
|
611
|
+
reason = _delegate_failure_reason(steps_log)
|
|
612
|
+
parts.append(f"\n--- Gemini Review skipped: {reason or 'no backend available'} ---")
|
|
613
|
+
|
|
614
|
+
return "\n".join(parts)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _wf_tri_consensus(scope, context, svc, steps_log):
|
|
618
|
+
"""Three-way consensus: Ollama + Codex + Gemini in parallel, merge findings."""
|
|
619
|
+
import subprocess
|
|
620
|
+
|
|
621
|
+
_log_progress(svc, "[1/4] Running git diff...")
|
|
622
|
+
diff_cmd = ["git", "diff"]
|
|
623
|
+
if scope == "staged":
|
|
624
|
+
diff_cmd.append("--cached")
|
|
625
|
+
elif scope == "HEAD":
|
|
626
|
+
diff_cmd.extend(["HEAD~1", "HEAD"])
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
r = subprocess.run(diff_cmd, capture_output=True, text=True, timeout=10,
|
|
630
|
+
cwd=svc.project_path)
|
|
631
|
+
diff_text = r.stdout.strip()
|
|
632
|
+
except Exception as e:
|
|
633
|
+
return f"[step:git_diff] Failed: {e}"
|
|
634
|
+
|
|
635
|
+
if not diff_text:
|
|
636
|
+
return "[step:git_diff] No changes found"
|
|
637
|
+
steps_log.append(f"diff({len(diff_text)}ch)")
|
|
638
|
+
|
|
639
|
+
changed_files = set()
|
|
640
|
+
for line in diff_text.split("\n"):
|
|
641
|
+
if line.startswith("diff --git"):
|
|
642
|
+
parts = line.split(" b/")
|
|
643
|
+
if len(parts) >= 2:
|
|
644
|
+
changed_files.add(parts[-1])
|
|
645
|
+
_log_progress(svc, f"[2/4] Compressing {len(changed_files)} changed files...")
|
|
646
|
+
with _progress_ticker(svc, "[2/4] Compressing", interval=1.5):
|
|
647
|
+
maps = _parallel_compress(list(changed_files)[:5], svc, steps_log)
|
|
648
|
+
|
|
649
|
+
review_context = f"Git diff:\n{diff_text[:4000]}\n\nStructural maps:\n{maps}"
|
|
650
|
+
if context:
|
|
651
|
+
review_context += f"\n\nUser context: {context}"
|
|
652
|
+
review_task = "Review these changes for bugs, regressions, and missing tests. Be concise."
|
|
653
|
+
|
|
654
|
+
# Run cloud backends in parallel (Codex + Gemini)
|
|
655
|
+
_log_progress(svc, "[3/4] Delegating to Codex + Gemini in parallel...")
|
|
656
|
+
codex_steps = []
|
|
657
|
+
gemini_steps = []
|
|
658
|
+
|
|
659
|
+
def _run_codex():
|
|
660
|
+
return _try_delegate(svc, "review", review_task, review_context, codex_steps, prefer_codex=True)
|
|
661
|
+
|
|
662
|
+
def _run_gemini():
|
|
663
|
+
return _try_delegate(svc, "review", review_task, review_context, gemini_steps, prefer_gemini=True)
|
|
664
|
+
|
|
665
|
+
codex_result = gemini_result = ""
|
|
666
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
667
|
+
fut_codex = pool.submit(_run_codex)
|
|
668
|
+
fut_gemini = pool.submit(_run_gemini)
|
|
669
|
+
try:
|
|
670
|
+
codex_result = fut_codex.result(timeout=100)
|
|
671
|
+
steps_log.extend(codex_steps)
|
|
672
|
+
except Exception:
|
|
673
|
+
codex_result = "[timeout/error]"
|
|
674
|
+
try:
|
|
675
|
+
gemini_result = fut_gemini.result(timeout=100)
|
|
676
|
+
steps_log.extend(gemini_steps)
|
|
677
|
+
except Exception:
|
|
678
|
+
gemini_result = "[timeout/error]"
|
|
679
|
+
|
|
680
|
+
# Merge findings
|
|
681
|
+
parts = [f"--- Changed files ({len(changed_files)}) ---"]
|
|
682
|
+
for f in sorted(changed_files):
|
|
683
|
+
parts.append(f" {f}")
|
|
684
|
+
parts.append(f"\n--- Diff ({count_tokens(diff_text)}tok) ---")
|
|
685
|
+
if count_tokens(diff_text) > 800:
|
|
686
|
+
parts.append(diff_text[:2000] + "\n... [truncated]")
|
|
687
|
+
else:
|
|
688
|
+
parts.append(diff_text)
|
|
689
|
+
if maps:
|
|
690
|
+
parts.append(f"\n--- Structural maps ---\n{maps}")
|
|
691
|
+
|
|
692
|
+
parts.append("\n" + "=" * 60)
|
|
693
|
+
parts.append("CONSENSUS REVIEW -- Cloud backend analyses")
|
|
694
|
+
parts.append("=" * 60)
|
|
695
|
+
|
|
696
|
+
backends = [
|
|
697
|
+
("Codex (cloud/GPT)", codex_result),
|
|
698
|
+
("Gemini (cloud/Google)", gemini_result),
|
|
699
|
+
]
|
|
700
|
+
available_count = 0
|
|
701
|
+
for label, result in backends:
|
|
702
|
+
if result and result != "[timeout/error]":
|
|
703
|
+
parts.append(f"\n--- {label} ---\n{result}")
|
|
704
|
+
available_count += 1
|
|
705
|
+
else:
|
|
706
|
+
parts.append(f"\n--- {label} ---\n[unavailable or no response]")
|
|
707
|
+
|
|
708
|
+
_log_progress(svc, "[4/4] Merging consensus results...")
|
|
709
|
+
parts.append(f"\n--- Consensus ({available_count}/2 backends responded) ---")
|
|
710
|
+
if available_count == 2:
|
|
711
|
+
parts.append("Both models provided reviews. Compare findings above for agreement/divergence.")
|
|
712
|
+
elif available_count == 1:
|
|
713
|
+
parts.append("Only one backend responded. Enable the other for true consensus.")
|
|
714
|
+
else:
|
|
715
|
+
reasons = [_delegate_failure_reason(codex_steps), _delegate_failure_reason(gemini_steps)]
|
|
716
|
+
detail = ", ".join(r for r in reasons if r) or "check delegate configuration"
|
|
717
|
+
parts.append(f"No backends available: {detail}")
|
|
718
|
+
|
|
719
|
+
return "\n".join(parts)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _wf_prepare_context(scope, context, svc, steps_log):
|
|
723
|
+
"""Build compressed context package for files matching scope."""
|
|
724
|
+
# scope can be: comma-separated file paths, or a search query
|
|
725
|
+
files = []
|
|
726
|
+
if "," in scope or scope.endswith((".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java")):
|
|
727
|
+
# Treat as file paths
|
|
728
|
+
files = [f.strip() for f in scope.split(",") if f.strip()]
|
|
729
|
+
steps_log.append(f"resolve({len(files)} explicit)")
|
|
730
|
+
else:
|
|
731
|
+
# Treat as search query
|
|
732
|
+
results = svc.indexer.search(scope, top_k=5, include_content=False)
|
|
733
|
+
files = list(dict.fromkeys(r["file"] for r in results))[:5]
|
|
734
|
+
steps_log.append(f"search({len(files)} hits)")
|
|
735
|
+
|
|
736
|
+
if not files:
|
|
737
|
+
return "[step:resolve] No files found"
|
|
738
|
+
|
|
739
|
+
maps = _parallel_compress(files, svc, steps_log)
|
|
740
|
+
return f"--- Context package ({len(files)} files) ---\n{maps}"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _wf_investigate(scope, context, svc, steps_log):
|
|
744
|
+
"""Investigate an error: search → compress → activity → diagnose."""
|
|
745
|
+
query = scope or context or "error"
|
|
746
|
+
|
|
747
|
+
# Step 1: Search for relevant code
|
|
748
|
+
_log_progress(svc, f"[1/4] Searching for '{query[:40]}'...")
|
|
749
|
+
results = svc.indexer.search(query, top_k=5, include_content=True, max_tokens=600)
|
|
750
|
+
steps_log.append(f"search({len(results)}r)")
|
|
751
|
+
|
|
752
|
+
if not results:
|
|
753
|
+
return f"[step:search] No results for '{query}'"
|
|
754
|
+
|
|
755
|
+
# Step 2: Compress top hit files in parallel
|
|
756
|
+
hit_files = list(dict.fromkeys(r["file"] for r in results))[:3]
|
|
757
|
+
_log_progress(svc, f"[2/4] Compressing {len(hit_files)} files...")
|
|
758
|
+
with _progress_ticker(svc, "[2/4] Compressing", interval=1.5):
|
|
759
|
+
maps = _parallel_compress(hit_files, svc, steps_log)
|
|
760
|
+
|
|
761
|
+
# Step 3: Recent activity
|
|
762
|
+
activity = ""
|
|
763
|
+
try:
|
|
764
|
+
recent = svc.activity_log.get_recent(limit=10)
|
|
765
|
+
if recent:
|
|
766
|
+
activity = "\n".join(
|
|
767
|
+
f"[{e.get('timestamp', '').split('T')[-1][:8]}] {e.get('tool', '')} → {e.get('summary', '')[:60]}"
|
|
768
|
+
for e in reversed(recent)
|
|
769
|
+
)
|
|
770
|
+
steps_log.append(f"activity({len(recent)})")
|
|
771
|
+
except Exception:
|
|
772
|
+
pass
|
|
773
|
+
|
|
774
|
+
# Step 4: Delegate diagnosis if available
|
|
775
|
+
diag_context = f"Query: {query}\n\nSearch results:\n"
|
|
776
|
+
for r in results[:3]:
|
|
777
|
+
diag_context += f" {r['file']}:L{r['lines']} ({r['type']})\n"
|
|
778
|
+
if maps:
|
|
779
|
+
diag_context += f"\nMaps:\n{maps}"
|
|
780
|
+
if activity:
|
|
781
|
+
diag_context += f"\nRecent activity:\n{activity}"
|
|
782
|
+
if context:
|
|
783
|
+
diag_context += f"\nUser context: {context}"
|
|
784
|
+
|
|
785
|
+
_log_progress(svc, "[4/4] Delegating diagnosis to Gemini...")
|
|
786
|
+
with _progress_ticker(svc, "[4/4] Waiting for Gemini", interval=3.0):
|
|
787
|
+
delegate_result = _try_delegate(svc, "diagnose", f"Investigate: {query}",
|
|
788
|
+
diag_context, steps_log, prefer_gemini=True)
|
|
789
|
+
|
|
790
|
+
parts = [f"--- Search hits for '{query}' ---"]
|
|
791
|
+
for r in results[:5]:
|
|
792
|
+
name = f" {r['name']}" if r.get('name') else ""
|
|
793
|
+
parts.append(f" {r['file']}:L{r['lines']}{name} ({r['type']}, s={r.get('score', 0):.3f})")
|
|
794
|
+
if r.get("content"):
|
|
795
|
+
# Show first few lines of content
|
|
796
|
+
content_lines = r["content"].split("\n")[:5]
|
|
797
|
+
for cl in content_lines:
|
|
798
|
+
parts.append(f" {cl}")
|
|
799
|
+
if maps:
|
|
800
|
+
parts.append(f"\n--- Structural maps ---\n{maps}")
|
|
801
|
+
if activity:
|
|
802
|
+
parts.append(f"\n--- Recent activity ---\n{activity}")
|
|
803
|
+
if delegate_result:
|
|
804
|
+
parts.append(f"\n--- AI Diagnosis ---\n{delegate_result}")
|
|
805
|
+
|
|
806
|
+
return "\n".join(parts)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _wf_preflight(scope, context, svc, steps_log):
|
|
810
|
+
"""Pre-edit check: validate + compress (parallel) + recent activity."""
|
|
811
|
+
files = [f.strip() for f in scope.split(",") if f.strip()]
|
|
812
|
+
if not files:
|
|
813
|
+
return "[step:resolve] No files specified"
|
|
814
|
+
|
|
815
|
+
# Step 1: Validate AND compress all files concurrently
|
|
816
|
+
val_results, maps = _parallel_validate_and_compress(files, svc, steps_log)
|
|
817
|
+
|
|
818
|
+
# Step 2: Recent activity summary
|
|
819
|
+
activity = ""
|
|
820
|
+
try:
|
|
821
|
+
recent = svc.activity_log.get_recent(limit=5)
|
|
822
|
+
if recent:
|
|
823
|
+
activity = "\n".join(
|
|
824
|
+
f"[{e.get('timestamp', '').split('T')[-1][:8]}] {e.get('tool', '')} → {e.get('summary', '')[:60]}"
|
|
825
|
+
for e in reversed(recent)
|
|
826
|
+
)
|
|
827
|
+
steps_log.append(f"activity({len(recent)})")
|
|
828
|
+
except Exception:
|
|
829
|
+
pass
|
|
830
|
+
|
|
831
|
+
parts = [f"--- Preflight ({len(files)} files) ---"]
|
|
832
|
+
for fp in files:
|
|
833
|
+
status, detail = val_results.get(fp, ("UNKNOWN", ""))
|
|
834
|
+
marker = "✓" if status == "PASS" else "✗" if status == "FAIL" else "?"
|
|
835
|
+
line = f" {marker} {fp} — {status}"
|
|
836
|
+
if detail:
|
|
837
|
+
line += f": {detail}"
|
|
838
|
+
parts.append(line)
|
|
839
|
+
if maps:
|
|
840
|
+
parts.append(f"\n--- Structural maps ---\n{maps}")
|
|
841
|
+
if activity:
|
|
842
|
+
parts.append(f"\n--- Recent activity ---\n{activity}")
|
|
843
|
+
|
|
844
|
+
return "\n".join(parts)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _wf_validate_compress(scope, context, svc, steps_log):
|
|
848
|
+
"""Validate and compress a file set in one call — both run in parallel."""
|
|
849
|
+
files = [f.strip() for f in scope.split(",") if f.strip()]
|
|
850
|
+
if not files:
|
|
851
|
+
return "[step:resolve] No files specified"
|
|
852
|
+
|
|
853
|
+
val_results, maps = _parallel_validate_and_compress(files, svc, steps_log)
|
|
854
|
+
|
|
855
|
+
parts = [f"--- Validate + Compress ({len(files)} files) ---"]
|
|
856
|
+
for fp in files:
|
|
857
|
+
status, detail = val_results.get(fp, ("UNKNOWN", ""))
|
|
858
|
+
marker = "✓" if status == "PASS" else "✗" if status == "FAIL" else "?"
|
|
859
|
+
line = f" {marker} {fp} — {status}"
|
|
860
|
+
if detail:
|
|
861
|
+
line += f": {detail}"
|
|
862
|
+
parts.append(line)
|
|
863
|
+
if maps:
|
|
864
|
+
parts.append(f"\n--- Structural maps ---\n{maps}")
|
|
865
|
+
|
|
866
|
+
return "\n".join(parts)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
870
|
+
|
|
871
|
+
def _parallel_compress(files: list, svc, steps_log: list,
|
|
872
|
+
per_file_timeout: float = 10.0) -> str:
|
|
873
|
+
"""Compress multiple files in parallel, return combined maps."""
|
|
874
|
+
if not files:
|
|
875
|
+
return ""
|
|
876
|
+
|
|
877
|
+
maps = {}
|
|
878
|
+
|
|
879
|
+
def compress_one(fp):
|
|
880
|
+
full_path = str(Path(svc.project_path) / fp)
|
|
881
|
+
try:
|
|
882
|
+
res = svc.compressor.compress_file(full_path, "map")
|
|
883
|
+
if isinstance(res, dict) and res.get("compressed"):
|
|
884
|
+
return fp, res["compressed"]
|
|
885
|
+
except Exception:
|
|
886
|
+
pass
|
|
887
|
+
return fp, None
|
|
888
|
+
|
|
889
|
+
with ThreadPoolExecutor(max_workers=min(len(files), 4)) as pool:
|
|
890
|
+
futures = {pool.submit(compress_one, f): f for f in files}
|
|
891
|
+
for fut in as_completed(futures, timeout=per_file_timeout * len(files)):
|
|
892
|
+
try:
|
|
893
|
+
fp, compressed = fut.result(timeout=per_file_timeout)
|
|
894
|
+
except (FuturesTimeout, Exception):
|
|
895
|
+
continue
|
|
896
|
+
if compressed:
|
|
897
|
+
maps[fp] = compressed
|
|
898
|
+
|
|
899
|
+
steps_log.append(f"compress({len(maps)}/{len(files)})")
|
|
900
|
+
|
|
901
|
+
parts = []
|
|
902
|
+
for fp in files:
|
|
903
|
+
if fp in maps:
|
|
904
|
+
parts.append(f"## {fp}\n{maps[fp]}")
|
|
905
|
+
return "\n\n".join(parts)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _parallel_validate_and_compress(files: list, svc, steps_log: list):
|
|
909
|
+
"""Run validation and compression for all files concurrently.
|
|
910
|
+
Returns (val_results_dict, maps_str)."""
|
|
911
|
+
from services.parser import check_syntax_native_with_timeout
|
|
912
|
+
hybrid_cfg = svc.hybrid_config or {}
|
|
913
|
+
timeout_s = max(1, int(hybrid_cfg.get("validate_timeout_seconds", 35) or 35))
|
|
914
|
+
|
|
915
|
+
val_results = {}
|
|
916
|
+
compress_results = {}
|
|
917
|
+
|
|
918
|
+
def validate_one(fp):
|
|
919
|
+
full = Path(svc.project_path) / fp
|
|
920
|
+
if not full.exists():
|
|
921
|
+
return "validate", fp, "NOT_FOUND", ""
|
|
922
|
+
ext = full.suffix.lower()
|
|
923
|
+
try:
|
|
924
|
+
content = full.read_text(encoding="utf-8", errors="replace")
|
|
925
|
+
result = check_syntax_native_with_timeout(content, ext, timeout_s)
|
|
926
|
+
status = result.get("status", "checker_failed")
|
|
927
|
+
errors = result.get("errors", [])
|
|
928
|
+
if status == "clean":
|
|
929
|
+
return "validate", fp, "PASS", ""
|
|
930
|
+
elif status == "syntax_error":
|
|
931
|
+
err_lines = "; ".join(f"L{e['line']}: {e['text']}" for e in errors[:3])
|
|
932
|
+
return "validate", fp, "FAIL", err_lines
|
|
933
|
+
else:
|
|
934
|
+
return "validate", fp, status.upper(), result.get("detail", "")
|
|
935
|
+
except Exception as e:
|
|
936
|
+
return "validate", fp, "ERROR", str(e)
|
|
937
|
+
|
|
938
|
+
def compress_one(fp):
|
|
939
|
+
full_path = str(Path(svc.project_path) / fp)
|
|
940
|
+
try:
|
|
941
|
+
res = svc.compressor.compress_file(full_path, "map")
|
|
942
|
+
if isinstance(res, dict) and res.get("compressed"):
|
|
943
|
+
return "compress", fp, res["compressed"]
|
|
944
|
+
except Exception:
|
|
945
|
+
pass
|
|
946
|
+
return "compress", fp, None
|
|
947
|
+
|
|
948
|
+
# Submit ALL tasks (validate + compress) into one pool
|
|
949
|
+
with ThreadPoolExecutor(max_workers=min(len(files) * 2, 8)) as pool:
|
|
950
|
+
futures = []
|
|
951
|
+
for fp in files:
|
|
952
|
+
futures.append(pool.submit(validate_one, fp))
|
|
953
|
+
futures.append(pool.submit(compress_one, fp))
|
|
954
|
+
|
|
955
|
+
for fut in as_completed(futures):
|
|
956
|
+
result = fut.result()
|
|
957
|
+
if result[0] == "validate":
|
|
958
|
+
_, fp, status, detail = result
|
|
959
|
+
val_results[fp] = (status, detail)
|
|
960
|
+
else:
|
|
961
|
+
_, fp, compressed = result
|
|
962
|
+
if compressed:
|
|
963
|
+
compress_results[fp] = compressed
|
|
964
|
+
|
|
965
|
+
steps_log.append(f"validate+compress({len(files)}files,{len(compress_results)}maps)")
|
|
966
|
+
|
|
967
|
+
map_parts = []
|
|
968
|
+
for fp in files:
|
|
969
|
+
if fp in compress_results:
|
|
970
|
+
map_parts.append(f"## {fp}\n{compress_results[fp]}")
|
|
971
|
+
maps_str = "\n\n".join(map_parts)
|
|
972
|
+
|
|
973
|
+
return val_results, maps_str
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _try_delegate(svc, task_type: str, task: str, context: str, steps_log: list,
|
|
977
|
+
prefer_codex: bool = False, prefer_gemini: bool = False) -> str:
|
|
978
|
+
"""Try to delegate to a specific backend. Returns empty string if unavailable.
|
|
979
|
+
|
|
980
|
+
When prefer_codex or prefer_gemini is set, ONLY that backend is tried —
|
|
981
|
+
no fallback to Ollama. This prevents parallel consensus workflows from
|
|
982
|
+
running duplicate Ollama calls when a preferred backend is unavailable.
|
|
983
|
+
"""
|
|
984
|
+
try:
|
|
985
|
+
dcfg = svc.delegate_config or {}
|
|
986
|
+
if not dcfg.get("enabled", True):
|
|
987
|
+
steps_log.append("delegate(skipped:disabled)")
|
|
988
|
+
return ""
|
|
989
|
+
|
|
990
|
+
# --- Codex-only path (no fallthrough) ------------------------------
|
|
991
|
+
if prefer_codex:
|
|
992
|
+
if not dcfg.get("codex_enabled", False):
|
|
993
|
+
steps_log.append("codex(skipped:disabled)")
|
|
994
|
+
return ""
|
|
995
|
+
import cli.tools.delegate as _dm
|
|
996
|
+
from cli.tools.delegate import CODEX_MODELS, _is_codex_on_path, _run_codex
|
|
997
|
+
if not _is_codex_on_path():
|
|
998
|
+
steps_log.append("codex(skipped:not_on_path)")
|
|
999
|
+
return ""
|
|
1000
|
+
# Preflight: cached health check
|
|
1001
|
+
if _dm._codex_available is None:
|
|
1002
|
+
from cli.tools.delegate import check_codex
|
|
1003
|
+
check_codex()
|
|
1004
|
+
if _dm._codex_available is False:
|
|
1005
|
+
steps_log.append("codex(skipped:health_check_failed)")
|
|
1006
|
+
return ""
|
|
1007
|
+
cdef = CODEX_MODELS.get(task_type, CODEX_MODELS.get("ask", {}))
|
|
1008
|
+
model = dcfg.get("codex_default_model") or cdef.get("model", "gpt-5.3-codex-spark")
|
|
1009
|
+
sandbox = dcfg.get("codex_default_sandbox") or cdef.get("sandbox", "read-only")
|
|
1010
|
+
reasoning = dcfg.get("codex_reasoning_effort") or cdef.get("reasoning", "high")
|
|
1011
|
+
timeout = int(dcfg.get("codex_timeout", 90))
|
|
1012
|
+
|
|
1013
|
+
max_ctx = max(200, int(dcfg.get("codex_max_context_tokens", 4000) or 4000))
|
|
1014
|
+
ctx_text = context[:max_ctx * 4] if count_tokens(context) > max_ctx else context
|
|
1015
|
+
|
|
1016
|
+
_log_progress(svc, f"[delegate] Running Codex ({model}, {sandbox})...")
|
|
1017
|
+
output, ok = _run_codex(
|
|
1018
|
+
task=task, context=ctx_text,
|
|
1019
|
+
model=model, sandbox=sandbox,
|
|
1020
|
+
reasoning=reasoning, timeout=timeout,
|
|
1021
|
+
cwd=str(svc.project_path),
|
|
1022
|
+
)
|
|
1023
|
+
if ok and output:
|
|
1024
|
+
_log_progress(svc, f"[delegate] Codex done ({task_type})")
|
|
1025
|
+
steps_log.append(f"codex({task_type},{model})")
|
|
1026
|
+
return output
|
|
1027
|
+
# Log the specific failure from output
|
|
1028
|
+
if output and output.startswith("["):
|
|
1029
|
+
steps_log.append(f"codex({output.split(']')[0]}])".replace("[", ""))
|
|
1030
|
+
else:
|
|
1031
|
+
steps_log.append("codex(error:no_output)")
|
|
1032
|
+
return ""
|
|
1033
|
+
|
|
1034
|
+
# --- Gemini-only path (no fallthrough) -----------------------------
|
|
1035
|
+
if prefer_gemini:
|
|
1036
|
+
if not dcfg.get("gemini_enabled", False):
|
|
1037
|
+
steps_log.append("gemini(skipped:disabled)")
|
|
1038
|
+
return ""
|
|
1039
|
+
import cli.tools.delegate as _dm
|
|
1040
|
+
from cli.tools.delegate import GEMINI_MODELS, _is_gemini_on_path, _run_gemini
|
|
1041
|
+
if not _is_gemini_on_path():
|
|
1042
|
+
steps_log.append("gemini(skipped:not_on_path)")
|
|
1043
|
+
return ""
|
|
1044
|
+
# Preflight: cached health check (gemini --version, 5s timeout)
|
|
1045
|
+
if _dm._gemini_available is None:
|
|
1046
|
+
from cli.tools.delegate import check_gemini
|
|
1047
|
+
check_gemini()
|
|
1048
|
+
if _dm._gemini_available is False:
|
|
1049
|
+
steps_log.append("gemini(skipped:health_check_failed)")
|
|
1050
|
+
return ""
|
|
1051
|
+
gdef = GEMINI_MODELS.get(task_type, GEMINI_MODELS.get("ask", {}))
|
|
1052
|
+
model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
|
|
1053
|
+
timeout = int(dcfg.get("gemini_timeout", 45))
|
|
1054
|
+
|
|
1055
|
+
max_ctx = max(200, int(dcfg.get("gemini_max_context_tokens", 8000) or 8000))
|
|
1056
|
+
ctx_text = context[:max_ctx * 4] if count_tokens(context) > max_ctx else context
|
|
1057
|
+
|
|
1058
|
+
_log_progress(svc, f"[delegate] Running Gemini ({model})...")
|
|
1059
|
+
output, ok, _ = _run_gemini(
|
|
1060
|
+
task=task, context=ctx_text,
|
|
1061
|
+
model=model, timeout=timeout,
|
|
1062
|
+
cwd=str(svc.project_path),
|
|
1063
|
+
)
|
|
1064
|
+
if ok and output:
|
|
1065
|
+
steps_log.append(f"gemini({task_type},{model})")
|
|
1066
|
+
return output
|
|
1067
|
+
# Log the specific failure from output
|
|
1068
|
+
if output and output.startswith("["):
|
|
1069
|
+
steps_log.append(f"gemini({output.split(']')[0]}])".replace("[", ""))
|
|
1070
|
+
else:
|
|
1071
|
+
steps_log.append("gemini(error:no_output)")
|
|
1072
|
+
return ""
|
|
1073
|
+
|
|
1074
|
+
# --- Ollama path (default) -----------------------------------------
|
|
1075
|
+
# For heavy tasks, prefer a cloud CLI if one is available on the system.
|
|
1076
|
+
# Ollama can take 30-90s per call; cloud CLIs are faster for reviews/diagnose.
|
|
1077
|
+
_LIGHT_TASKS = {"ask", "explain", "summarize", "docstring"}
|
|
1078
|
+
if task_type not in _LIGHT_TASKS:
|
|
1079
|
+
import cli.tools.delegate as _dm_auto
|
|
1080
|
+
from cli.tools.delegate import (
|
|
1081
|
+
CODEX_MODELS,
|
|
1082
|
+
GEMINI_MODELS,
|
|
1083
|
+
_is_codex_on_path,
|
|
1084
|
+
_is_gemini_on_path,
|
|
1085
|
+
_run_codex,
|
|
1086
|
+
_run_gemini,
|
|
1087
|
+
)
|
|
1088
|
+
# Gemini first (prefer; enabled in config for this project)
|
|
1089
|
+
_gem_ok = (_dm_auto._gemini_available is True) or (
|
|
1090
|
+
_dm_auto._gemini_available is None and _is_gemini_on_path()
|
|
1091
|
+
)
|
|
1092
|
+
if _gem_ok and _dm_auto._gemini_available is not False:
|
|
1093
|
+
gdef = GEMINI_MODELS.get(task_type, GEMINI_MODELS.get("ask", {}))
|
|
1094
|
+
g_model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
|
|
1095
|
+
g_timeout = int(dcfg.get("gemini_timeout", 45))
|
|
1096
|
+
g_max = max(200, int(dcfg.get("gemini_max_context_tokens", 8000) or 8000))
|
|
1097
|
+
g_ctx = context[:g_max * 4] if count_tokens(context) > g_max else context
|
|
1098
|
+
_log_progress(svc, f"[auto] Routing {task_type} → Gemini (skipping slow Ollama)...")
|
|
1099
|
+
g_out, g_ok, _ = _run_gemini(
|
|
1100
|
+
task=task, context=g_ctx, model=g_model,
|
|
1101
|
+
timeout=g_timeout, cwd=str(svc.project_path),
|
|
1102
|
+
)
|
|
1103
|
+
if g_ok and g_out:
|
|
1104
|
+
steps_log.append(f"gemini({task_type},{g_model},auto)")
|
|
1105
|
+
return g_out
|
|
1106
|
+
# Codex second
|
|
1107
|
+
_codex_ok = (_dm_auto._codex_available is True) or (
|
|
1108
|
+
_dm_auto._codex_available is None and _is_codex_on_path()
|
|
1109
|
+
)
|
|
1110
|
+
if _codex_ok and _dm_auto._codex_available is not False:
|
|
1111
|
+
cdef = CODEX_MODELS.get(task_type, CODEX_MODELS.get("ask", {}))
|
|
1112
|
+
c_model = dcfg.get("codex_default_model") or cdef.get("model", "gpt-5.3-codex-spark")
|
|
1113
|
+
c_sandbox = dcfg.get("codex_default_sandbox") or cdef.get("sandbox", "read-only")
|
|
1114
|
+
c_reason = dcfg.get("codex_reasoning_effort") or cdef.get("reasoning", "high")
|
|
1115
|
+
c_timeout = int(dcfg.get("codex_timeout", 90))
|
|
1116
|
+
c_max = max(200, int(dcfg.get("codex_max_context_tokens", 4000) or 4000))
|
|
1117
|
+
c_ctx = context[:c_max * 4] if count_tokens(context) > c_max else context
|
|
1118
|
+
_log_progress(svc, f"[auto] Routing {task_type} → Codex (skipping slow Ollama)...")
|
|
1119
|
+
c_out, c_ok = _run_codex(
|
|
1120
|
+
task=task, context=c_ctx, model=c_model,
|
|
1121
|
+
sandbox=c_sandbox, reasoning=c_reason,
|
|
1122
|
+
timeout=c_timeout, cwd=str(svc.project_path),
|
|
1123
|
+
)
|
|
1124
|
+
if c_ok and c_out:
|
|
1125
|
+
steps_log.append(f"codex({task_type},{c_model},auto)")
|
|
1126
|
+
return c_out
|
|
1127
|
+
|
|
1128
|
+
ollama = svc.ollama_client
|
|
1129
|
+
if not ollama or not ollama.is_available():
|
|
1130
|
+
return ""
|
|
1131
|
+
|
|
1132
|
+
from cli.tools.delegate import DELEGATE_TASKS, _fallback_model_order, resolve_model_name
|
|
1133
|
+
|
|
1134
|
+
tdef = DELEGATE_TASKS.get(task_type)
|
|
1135
|
+
if not tdef:
|
|
1136
|
+
return ""
|
|
1137
|
+
|
|
1138
|
+
req_model = dcfg.get(f"{task_type}_model") or dcfg.get("preferred_model") or tdef["default_model"]
|
|
1139
|
+
avail = ollama.list_models() or []
|
|
1140
|
+
model = resolve_model_name(req_model, avail)
|
|
1141
|
+
if not model:
|
|
1142
|
+
for cand in _fallback_model_order(task_type) + avail:
|
|
1143
|
+
model = resolve_model_name(cand, avail)
|
|
1144
|
+
if model:
|
|
1145
|
+
break
|
|
1146
|
+
if not model:
|
|
1147
|
+
return ""
|
|
1148
|
+
|
|
1149
|
+
# Truncate context
|
|
1150
|
+
max_ctx = max(200, int(dcfg.get("max_context_tokens", 1400) or 1400))
|
|
1151
|
+
if count_tokens(context) > max_ctx:
|
|
1152
|
+
context = context[:max_ctx * 4]
|
|
1153
|
+
|
|
1154
|
+
response = ollama.generate(
|
|
1155
|
+
model=model,
|
|
1156
|
+
prompt=f"{task}\n\nContext:\n{context}",
|
|
1157
|
+
system=tdef.get("system_prompt", ""),
|
|
1158
|
+
max_tokens=int(dcfg.get("max_tokens", 512)),
|
|
1159
|
+
temperature=float(dcfg.get("temperature", 0.3)),
|
|
1160
|
+
)
|
|
1161
|
+
steps_log.append(f"delegate({task_type},{model})")
|
|
1162
|
+
return response.strip() if response else ""
|
|
1163
|
+
except Exception as e:
|
|
1164
|
+
steps_log.append(f"delegate(error:{type(e).__name__})")
|
|
1165
|
+
return ""
|