differix 0.1.0a1__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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
agents/researcher.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import config
|
|
5
|
+
from tools.lesson_manager import (
|
|
6
|
+
get_promoted_lessons_prompt,
|
|
7
|
+
get_candidates_prompt,
|
|
8
|
+
get_observations_prompt,
|
|
9
|
+
get_deprecated_prompt,
|
|
10
|
+
create_observation,
|
|
11
|
+
get_ranked_lessons_for_role,
|
|
12
|
+
get_full_memory_prompt,
|
|
13
|
+
)
|
|
14
|
+
from tools.skill_manager import get_combined_agent_skill
|
|
15
|
+
|
|
16
|
+
from tools.llm_client import safe_chat_completion
|
|
17
|
+
|
|
18
|
+
RESEARCHER_MAX_TOKENS = getattr(config, "RESEARCHER_MAX_TOKENS", 6000)
|
|
19
|
+
RESEARCHER_RETRY_MAX_TOKENS = getattr(config, "RESEARCHER_RETRY_MAX_TOKENS", 12000)
|
|
20
|
+
LESSONS_BLOCK_CHAR_LIMIT = getattr(config, "RESEARCHER_LESSONS_CHAR_LIMIT", 3000)
|
|
21
|
+
LOG_CHAR_LIMIT = 1500
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_researcher_model() -> str:
|
|
25
|
+
if hasattr(config, "get_active_model") and callable(getattr(config, "get_active_model")):
|
|
26
|
+
return config.get_active_model("researcher")
|
|
27
|
+
if hasattr(config, "get_agent_model") and callable(getattr(config, "get_agent_model")):
|
|
28
|
+
return config.get_agent_model("researcher")
|
|
29
|
+
return getattr(config, "MODEL_RESEARCHER", "anthropic/claude-3.5-sonnet")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_project_file_tree(project_dir: str = ".") -> str:
|
|
33
|
+
tree = []
|
|
34
|
+
base_path = os.path.abspath(project_dir)
|
|
35
|
+
ignored_dirs = {
|
|
36
|
+
".git", "__pycache__", ".venv", "venv",
|
|
37
|
+
"node_modules", ".idea", ".vscode", ".vegex",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for root, dirs, files in os.walk(base_path):
|
|
41
|
+
dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith(".")]
|
|
42
|
+
rel_root = os.path.relpath(root, base_path)
|
|
43
|
+
if rel_root == ".":
|
|
44
|
+
rel_root = ""
|
|
45
|
+
|
|
46
|
+
for f in files:
|
|
47
|
+
if f.startswith(".") or f.endswith((".pyc", ".png", ".jpg", ".zip", ".tar", ".gz")):
|
|
48
|
+
continue
|
|
49
|
+
path_str = os.path.join(rel_root, f) if rel_root else f
|
|
50
|
+
tree.append(path_str)
|
|
51
|
+
if len(tree) >= 100:
|
|
52
|
+
tree.append("... [TRUNCATED FILE TREE]")
|
|
53
|
+
return "\n".join(tree)
|
|
54
|
+
|
|
55
|
+
return "\n".join(tree) if tree else "Empty directory."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_researcher_payload(raw_text: str) -> dict:
|
|
59
|
+
if not raw_text or not isinstance(raw_text, str):
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
text = raw_text.strip()
|
|
63
|
+
|
|
64
|
+
fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
65
|
+
if fence_match:
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(fence_match.group(1))
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
start = text.find("{")
|
|
72
|
+
end = text.rfind("}")
|
|
73
|
+
if start != -1 and end != -1 and end > start:
|
|
74
|
+
try:
|
|
75
|
+
return json.loads(text[start:end + 1])
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(text)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"analysis": text,
|
|
86
|
+
"technical_plan": [],
|
|
87
|
+
"_parse_failed": True,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _clip(text, limit: int, keep: str = "head") -> str:
|
|
92
|
+
text = "" if text is None else str(text)
|
|
93
|
+
if len(text) <= limit:
|
|
94
|
+
return text
|
|
95
|
+
if keep == "tail":
|
|
96
|
+
return "...[TRUNCATED]...\n" + text[-limit:]
|
|
97
|
+
return text[:limit] + "\n...[TRUNCATED]..."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_plan(raw_plan, result: dict, target_file: str) -> list:
|
|
101
|
+
"""
|
|
102
|
+
Normalize the researcher's plan into steps the state manager can consume.
|
|
103
|
+
"""
|
|
104
|
+
formatted = []
|
|
105
|
+
if not isinstance(raw_plan, list):
|
|
106
|
+
return formatted
|
|
107
|
+
default_file = result.get("target_file") or target_file or ""
|
|
108
|
+
|
|
109
|
+
for step in raw_plan:
|
|
110
|
+
if isinstance(step, dict):
|
|
111
|
+
title = str(step.get("title") or step.get("description") or "Execute task").strip()
|
|
112
|
+
if not title:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
est_lines = int(step.get("estimated_lines", 0) or 0)
|
|
117
|
+
except (TypeError, ValueError):
|
|
118
|
+
est_lines = 0
|
|
119
|
+
|
|
120
|
+
provides = step.get("provides") or []
|
|
121
|
+
consumes = step.get("consumes") or []
|
|
122
|
+
if not isinstance(provides, list):
|
|
123
|
+
provides = []
|
|
124
|
+
if not isinstance(consumes, list):
|
|
125
|
+
consumes = []
|
|
126
|
+
|
|
127
|
+
formatted.append({
|
|
128
|
+
"title": title,
|
|
129
|
+
"description": str(step.get("description", "")),
|
|
130
|
+
"target_file": step.get("target_file") or default_file,
|
|
131
|
+
"status": "pending",
|
|
132
|
+
"estimated_lines": est_lines,
|
|
133
|
+
"provides": [str(p) for p in provides],
|
|
134
|
+
"consumes": [str(c) for c in consumes],
|
|
135
|
+
})
|
|
136
|
+
elif str(step).strip():
|
|
137
|
+
formatted.append({
|
|
138
|
+
"title": str(step).strip(),
|
|
139
|
+
"description": "",
|
|
140
|
+
"target_file": default_file,
|
|
141
|
+
"status": "pending",
|
|
142
|
+
"estimated_lines": 0,
|
|
143
|
+
"provides": [],
|
|
144
|
+
"consumes": [],
|
|
145
|
+
})
|
|
146
|
+
return formatted
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _build_legacy_lessons_context() -> str:
|
|
150
|
+
return (
|
|
151
|
+
f"=== PROMOTED LESSONS ===\n"
|
|
152
|
+
f"{_clip(get_promoted_lessons_prompt(), LESSONS_BLOCK_CHAR_LIMIT)}\n\n"
|
|
153
|
+
f"=== CANDIDATE LESSONS ===\n"
|
|
154
|
+
f"{_clip(get_candidates_prompt(), LESSONS_BLOCK_CHAR_LIMIT)}\n\n"
|
|
155
|
+
f"=== RAW OBSERVATIONS ===\n"
|
|
156
|
+
f"{_clip(get_observations_prompt(), LESSONS_BLOCK_CHAR_LIMIT, keep='tail')}\n\n"
|
|
157
|
+
f"=== DEPRECATED LESSONS ===\n"
|
|
158
|
+
f"{_clip(get_deprecated_prompt(), LESSONS_BLOCK_CHAR_LIMIT)}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_researcher(
|
|
163
|
+
state,
|
|
164
|
+
project_name,
|
|
165
|
+
project_dir=".",
|
|
166
|
+
project_files="",
|
|
167
|
+
session_context="",
|
|
168
|
+
):
|
|
169
|
+
project_tree = get_project_file_tree(project_dir)
|
|
170
|
+
if (not project_tree or project_tree == "Empty directory.") and project_files:
|
|
171
|
+
project_tree = _clip(project_files, 4000)
|
|
172
|
+
|
|
173
|
+
active_goal = state.get("goal") or state.get("task_prompt") or (
|
|
174
|
+
(state.get("task_spec") or {}).get("raw_user_prompt", "")
|
|
175
|
+
)
|
|
176
|
+
active_subtask = state.get("active_subtask")
|
|
177
|
+
target_file = state.get("target_file")
|
|
178
|
+
existing_plan = state.get("plan", []) or []
|
|
179
|
+
|
|
180
|
+
iteration_logs = (
|
|
181
|
+
state.get("iteration_logs")
|
|
182
|
+
or state.get("compiler_output")
|
|
183
|
+
or "No logs recorded."
|
|
184
|
+
)
|
|
185
|
+
iteration_logs = _clip(iteration_logs, LOG_CHAR_LIMIT, keep="tail")
|
|
186
|
+
|
|
187
|
+
plan_history = "\n".join([
|
|
188
|
+
f"- [{str(step.get('status', 'pending')).upper()}] "
|
|
189
|
+
f"{step.get('title')} (File: {step.get('target_file', 'N/A')})"
|
|
190
|
+
for step in existing_plan if isinstance(step, dict)
|
|
191
|
+
]) if existing_plan else "None"
|
|
192
|
+
|
|
193
|
+
if session_context:
|
|
194
|
+
lessons_context = ""
|
|
195
|
+
else:
|
|
196
|
+
retrieval_query = " ".join(
|
|
197
|
+
filter(None, [str(active_goal or ""), str(active_subtask or "")])
|
|
198
|
+
).strip() or "general research"
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
_scored, lessons_context, lesson_node_ids = get_ranked_lessons_for_role(
|
|
202
|
+
role="researcher",
|
|
203
|
+
query=retrieval_query,
|
|
204
|
+
limit=getattr(config, "LESSONS_RETRIEVAL_LIMIT", 5),
|
|
205
|
+
)
|
|
206
|
+
except Exception:
|
|
207
|
+
lessons_context = ""
|
|
208
|
+
lesson_node_ids = []
|
|
209
|
+
|
|
210
|
+
if not lessons_context:
|
|
211
|
+
lessons_context = _build_legacy_lessons_context()
|
|
212
|
+
|
|
213
|
+
state["active_lesson_node_ids"] = list(lesson_node_ids or [])
|
|
214
|
+
state["active_lessons_prompt"] = get_full_memory_prompt()
|
|
215
|
+
|
|
216
|
+
skill_prompt = get_combined_agent_skill("researcher", project_dir)
|
|
217
|
+
system_prompt = (
|
|
218
|
+
f"{skill_prompt}\n\n"
|
|
219
|
+
"You are the DifferiX Researcher. Your job is to analyze the goal and output a strict JSON execution plan.\n\n"
|
|
220
|
+
"### PLANNING RULES\n"
|
|
221
|
+
"1. Break the goal into atomic, sequential subtasks in `technical_plan`. Never return an empty plan.\n"
|
|
222
|
+
"2. Only reference files existing in the PROJECT FILE TREE, or new files explicitly requested.\n"
|
|
223
|
+
"3. Keep `analysis` to 1-2 short sentences.\n"
|
|
224
|
+
"4. `estimated_lines`: Be conservative. If a single step produces >100 lines, SPLIT it into multiple "
|
|
225
|
+
"sequential steps on the SAME target_file. Later steps will append to the file.\n"
|
|
226
|
+
"5. `provides`/`consumes`: Use these to declare cross-file dependencies (e.g., [\"html:.hero-title\", \"file:script.js\"]).\n"
|
|
227
|
+
"6. NEVER assign read-only tasks (e.g., 'Extract class names', 'Analyze structure') to the editor. "
|
|
228
|
+
"The editor ONLY writes code. Do all analysis yourself and include the results directly in the step description.\n\n"
|
|
229
|
+
"### WEB STYLES (If building a UI)\n"
|
|
230
|
+
"Set `web_style` to EXACTLY one of these, or empty string if not a web task:\n"
|
|
231
|
+
"- 'styles/style_3d_webgl' (space, gaming, 3D)\n"
|
|
232
|
+
"- 'styles/style_clean_saas' (business, minimal)\n"
|
|
233
|
+
"- 'styles/style_brutalist' (web3, raw, bold)\n"
|
|
234
|
+
"- 'styles/style_cyberpunk' (hacker, neon)\n"
|
|
235
|
+
"- 'styles/style_editorial' (blogs, text-heavy)\n\n"
|
|
236
|
+
"### POLISH & RESPONSIVENESS RULE\n"
|
|
237
|
+
"If the task is to build a website, you MUST append TWO final steps to the plan:\n"
|
|
238
|
+
" - Step N: 'Add CSS media queries for responsiveness and micro-interaction transitions (hover states, focus rings)' targeting 'styles.css'.\n"
|
|
239
|
+
" - Step N+1: 'Add JavaScript for custom cursor tracking, IntersectionObserver scroll-reveals, and dynamic element interactions' targeting 'script.js'.\n\n"
|
|
240
|
+
"### GEO & AI-SEO RULE\n"
|
|
241
|
+
"If the task is to build a website, you MUST also append these steps to the plan:\n"
|
|
242
|
+
" - Step N: 'Generate llms.txt in the project root with site summary and key links' targeting 'llms.txt'.\n"
|
|
243
|
+
" - Step N+1: 'Inject JSON-LD Schema.org (WebSite or Organization) into the <head> of index.html' targeting 'index.html'.\n\n"
|
|
244
|
+
"### PERFORMANCE OPTIMIZATION RULE\n"
|
|
245
|
+
"If the task is to build a website, you MUST append a FINAL step to the plan:\n"
|
|
246
|
+
" - Step N+2: 'Optimize performance: add defer to scripts, loading=\"lazy\" to images, font-display=swap to fonts, and ensure CSS animations use GPU transforms' targeting 'index.html' (and 'styles.css').\n\n"
|
|
247
|
+
"### OUTPUT FORMAT\n"
|
|
248
|
+
"Respond with ONLY a valid JSON object. Keys MUST be in this exact order:\n"
|
|
249
|
+
"{\n"
|
|
250
|
+
' "analysis": "Short technical analysis.",\n'
|
|
251
|
+
' "web_style": "styles/style_clean_saas",\n'
|
|
252
|
+
' "target_file": "primary_file.py",\n'
|
|
253
|
+
' "technical_plan": [\n'
|
|
254
|
+
" {\n"
|
|
255
|
+
' "title": "Step description",\n'
|
|
256
|
+
' "target_file": "file.py",\n'
|
|
257
|
+
' "estimated_lines": 80,\n'
|
|
258
|
+
' "provides": ["html:.hero-title"],\n'
|
|
259
|
+
' "consumes": [],\n'
|
|
260
|
+
' "status": "pending"\n'
|
|
261
|
+
" }\n"
|
|
262
|
+
" ]\n"
|
|
263
|
+
"7. MANDATORY FULL-STACK RULE: If the task is to build a website, your plan MUST include separate steps for: "
|
|
264
|
+
"index.html, styles.css, script.js, llms.txt (GEO), and a final performance optimization pass. "
|
|
265
|
+
"Never finish a plan until all these files are created or updated.\n\n"
|
|
266
|
+
"}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
user_input_parts = []
|
|
271
|
+
if session_context:
|
|
272
|
+
user_input_parts.append(session_context)
|
|
273
|
+
|
|
274
|
+
user_input_parts.append(
|
|
275
|
+
f"=== OBJECTIVE ===\n"
|
|
276
|
+
f"Goal: {active_goal}\n"
|
|
277
|
+
f"Subtask: {active_subtask}\n"
|
|
278
|
+
f"Target: {target_file}\n\n"
|
|
279
|
+
f"=== PROGRESS / PLAN HISTORY ===\n{plan_history}\n\n"
|
|
280
|
+
f"=== RECENT EXECUTION LOGS ===\n{iteration_logs}\n\n"
|
|
281
|
+
f"=== PROJECT FILE TREE (project: {project_name}) ===\n{project_tree}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if lessons_context:
|
|
285
|
+
user_input_parts.append(f"=== LESSONS & CONTEXT ===\n{lessons_context}")
|
|
286
|
+
|
|
287
|
+
user_input = "\n\n".join(user_input_parts)
|
|
288
|
+
|
|
289
|
+
active_model = get_researcher_model()
|
|
290
|
+
messages = [
|
|
291
|
+
{"role": "system", "content": system_prompt},
|
|
292
|
+
{"role": "user", "content": user_input},
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
result, result_text, provider, response = {}, "", None, None
|
|
296
|
+
formatted_plan = []
|
|
297
|
+
total_prompt_tokens = 0
|
|
298
|
+
total_completion_tokens = 0
|
|
299
|
+
failure_reason = ""
|
|
300
|
+
|
|
301
|
+
for attempt, budget in enumerate(
|
|
302
|
+
(RESEARCHER_MAX_TOKENS, RESEARCHER_RETRY_MAX_TOKENS)
|
|
303
|
+
):
|
|
304
|
+
call_messages = list(messages)
|
|
305
|
+
if attempt > 0:
|
|
306
|
+
call_messages.append({
|
|
307
|
+
"role": "user",
|
|
308
|
+
"content": (
|
|
309
|
+
f"Your previous reply was unusable ({failure_reason}). "
|
|
310
|
+
f"Reply again with ONLY the JSON object, 'technical_plan' first, "
|
|
311
|
+
f"with at least one step, each step having an 'estimated_lines' "
|
|
312
|
+
f"integer, and a very short 'analysis'."
|
|
313
|
+
),
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
response, provider = safe_chat_completion(
|
|
317
|
+
active_model,
|
|
318
|
+
messages=call_messages,
|
|
319
|
+
temperature=0.1,
|
|
320
|
+
max_tokens=budget,
|
|
321
|
+
task_spec=str(state.get("active_subtask") or state.get("goal") or "")
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
usage = getattr(response, "usage", None)
|
|
325
|
+
total_prompt_tokens += getattr(usage, "prompt_tokens", 0) or 0
|
|
326
|
+
total_completion_tokens += getattr(usage, "completion_tokens", 0) or 0
|
|
327
|
+
|
|
328
|
+
choice = response.choices[0]
|
|
329
|
+
finish_reason = getattr(choice, "finish_reason", None)
|
|
330
|
+
result_text = (choice.message.content or "").strip()
|
|
331
|
+
result = parse_researcher_payload(result_text)
|
|
332
|
+
formatted_plan = _normalize_plan(
|
|
333
|
+
result.get("technical_plan"), result, target_file
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if formatted_plan:
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
if not result_text:
|
|
340
|
+
failure_reason = f"empty content, finish_reason={finish_reason}"
|
|
341
|
+
elif finish_reason == "length":
|
|
342
|
+
failure_reason = "output truncated by token limit"
|
|
343
|
+
elif result.get("_parse_failed"):
|
|
344
|
+
failure_reason = "not valid JSON"
|
|
345
|
+
else:
|
|
346
|
+
failure_reason = "technical_plan was empty"
|
|
347
|
+
print(
|
|
348
|
+
f"[Researcher Warning] attempt {attempt + 1} gave no plan: "
|
|
349
|
+
f"{failure_reason} (model={active_model}, max_tokens={budget})"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
result.pop("_parse_failed", None)
|
|
353
|
+
|
|
354
|
+
obs = result.get("observation")
|
|
355
|
+
if obs and isinstance(obs, dict) and obs.get("root_cause"):
|
|
356
|
+
obs_file = create_observation(
|
|
357
|
+
root_cause=obs.get("root_cause"),
|
|
358
|
+
details=obs.get("details", {}),
|
|
359
|
+
)
|
|
360
|
+
print(f"[Lesson Manager] Observation logged by Super-User: {obs_file}")
|
|
361
|
+
|
|
362
|
+
existing_completed = [
|
|
363
|
+
s for s in existing_plan
|
|
364
|
+
if isinstance(s, dict) and s.get("status") == "completed"
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
if formatted_plan:
|
|
368
|
+
state["plan"] = existing_completed + formatted_plan
|
|
369
|
+
state.pop("research_error", None)
|
|
370
|
+
else:
|
|
371
|
+
state["plan"] = existing_plan
|
|
372
|
+
state["research_error"] = failure_reason or "researcher returned no plan"
|
|
373
|
+
print(
|
|
374
|
+
f"[Researcher Error] No plan produced after retry: "
|
|
375
|
+
f"{state['research_error']}"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
pending_steps = [
|
|
379
|
+
s for s in state["plan"]
|
|
380
|
+
if isinstance(s, dict) and s.get("status") == "pending"
|
|
381
|
+
]
|
|
382
|
+
if pending_steps and pending_steps[0].get("target_file"):
|
|
383
|
+
state["target_file"] = pending_steps[0]["target_file"]
|
|
384
|
+
elif result.get("target_file"):
|
|
385
|
+
state["target_file"] = str(result["target_file"]).strip()
|
|
386
|
+
|
|
387
|
+
state["research_analysis"] = (
|
|
388
|
+
result.get("analysis") or result_text[:1000] or "Executing subtask."
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# --- SAVE WEB STYLE ---
|
|
392
|
+
state["web_style"] = result.get("web_style", "")
|
|
393
|
+
|
|
394
|
+
state["research_data"] = result
|
|
395
|
+
state["current_step"] = "edit" if pending_steps else "research"
|
|
396
|
+
|
|
397
|
+
usage_meta = type("Meta", (), {
|
|
398
|
+
"prompt_token_count": total_prompt_tokens,
|
|
399
|
+
"candidates_token_count": total_completion_tokens,
|
|
400
|
+
"raw_response": result_text,
|
|
401
|
+
"provider": provider,
|
|
402
|
+
"model_id": active_model,
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
return state, usage_meta
|