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
tools/skill_manager.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
|
|
5
|
+
CONFIG_FILE = "skills_config.json"
|
|
6
|
+
|
|
7
|
+
# Framework-level skills directory (relative to the VegeX install root)
|
|
8
|
+
_FRAMEWORK_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
9
|
+
_FRAMEWORK_SKILLS_DIR = os.path.join(_FRAMEWORK_ROOT, "skills")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SkillManager:
|
|
13
|
+
def __init__(self, config_path: str = CONFIG_FILE):
|
|
14
|
+
self.config_path = config_path
|
|
15
|
+
self._config: Optional[Dict[str, Any]] = None
|
|
16
|
+
|
|
17
|
+
def _ensure_loaded(self):
|
|
18
|
+
if self._config is not None:
|
|
19
|
+
return
|
|
20
|
+
if os.path.exists(self.config_path):
|
|
21
|
+
try:
|
|
22
|
+
with open(self.config_path, "r", encoding="utf-8") as f:
|
|
23
|
+
self._config = json.load(f)
|
|
24
|
+
except Exception:
|
|
25
|
+
self._config = self._get_default_config()
|
|
26
|
+
else:
|
|
27
|
+
self._config = self._get_default_config()
|
|
28
|
+
|
|
29
|
+
def _get_default_config(self) -> Dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"active_skills": {
|
|
32
|
+
"researcher": "python",
|
|
33
|
+
"editor": "python",
|
|
34
|
+
"reviewer": "python",
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def save_config(self):
|
|
39
|
+
if self._config is None:
|
|
40
|
+
self._ensure_loaded()
|
|
41
|
+
os.makedirs(os.path.dirname(os.path.abspath(self.config_path)), exist_ok=True)
|
|
42
|
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
|
43
|
+
json.dump(self._config, f, indent=2, ensure_ascii=False)
|
|
44
|
+
|
|
45
|
+
def get_skill_for_role(self, role: str) -> str:
|
|
46
|
+
self._ensure_loaded()
|
|
47
|
+
return self._config.get("active_skills", {}).get(role, "")
|
|
48
|
+
|
|
49
|
+
def set_skill_for_role(self, role: str, skill_name: str):
|
|
50
|
+
self._ensure_loaded()
|
|
51
|
+
if "active_skills" not in self._config:
|
|
52
|
+
self._config["active_skills"] = {}
|
|
53
|
+
self._config["active_skills"][role] = skill_name
|
|
54
|
+
self.save_config()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_default_manager: Optional[SkillManager] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_manager() -> SkillManager:
|
|
61
|
+
global _default_manager
|
|
62
|
+
if _default_manager is None:
|
|
63
|
+
_default_manager = SkillManager()
|
|
64
|
+
return _default_manager
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_skill_file(filename: str, search_dirs) -> str:
|
|
68
|
+
"""Read a skill file from the first directory where it exists."""
|
|
69
|
+
for base in search_dirs:
|
|
70
|
+
if not base:
|
|
71
|
+
continue
|
|
72
|
+
path = os.path.join(base, filename)
|
|
73
|
+
if os.path.isfile(path):
|
|
74
|
+
try:
|
|
75
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
76
|
+
return f.read().strip()
|
|
77
|
+
except Exception:
|
|
78
|
+
continue
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_combined_agent_skill(role: str, project_dir: str = ".") -> str:
|
|
83
|
+
"""
|
|
84
|
+
Return the fully-assembled skill prompt for a role.
|
|
85
|
+
|
|
86
|
+
Combines:
|
|
87
|
+
1. The role-level rules file: `{role}_rules.md` (e.g. `researcher_rules.md`)
|
|
88
|
+
2. The domain skill configured in skills_config.json (e.g. `python.md`)
|
|
89
|
+
|
|
90
|
+
Lookup order: project_dir/skills → framework_root/skills.
|
|
91
|
+
Both directories are searched so per-project overrides work.
|
|
92
|
+
"""
|
|
93
|
+
manager = get_manager()
|
|
94
|
+
domain_skill_name = manager.get_skill_for_role(role)
|
|
95
|
+
|
|
96
|
+
search_dirs = (
|
|
97
|
+
os.path.join(project_dir, "skills") if project_dir else None,
|
|
98
|
+
_FRAMEWORK_SKILLS_DIR,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
role_file = f"{role}_rules.md"
|
|
102
|
+
role_text = _read_skill_file(role_file, search_dirs)
|
|
103
|
+
|
|
104
|
+
domain_text = ""
|
|
105
|
+
if domain_skill_name:
|
|
106
|
+
domain_text = _read_skill_file(f"{domain_skill_name}.md", search_dirs)
|
|
107
|
+
|
|
108
|
+
parts = []
|
|
109
|
+
if role_text:
|
|
110
|
+
parts.append(f"=== ROLE RULES ({role}) ===\n{role_text}")
|
|
111
|
+
if domain_text:
|
|
112
|
+
parts.append(
|
|
113
|
+
f"=== DOMAIN SKILL ({domain_skill_name}) ===\n{domain_text}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if not parts:
|
|
117
|
+
return (
|
|
118
|
+
f"Standard skill definition for role: {role} "
|
|
119
|
+
f"(no rules or domain skill file found; configured={domain_skill_name or 'None'})"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return "\n\n".join(parts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = [
|
|
126
|
+
"SkillManager",
|
|
127
|
+
"get_manager",
|
|
128
|
+
"get_combined_agent_skill",
|
|
129
|
+
]
|
tools/state_manager.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
import tempfile
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseStateModel(BaseModel):
|
|
13
|
+
"""Базовая модель с поддержкой сохранения произвольных доп. полей от LLM."""
|
|
14
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MetaModel(BaseStateModel):
|
|
18
|
+
version: str = "1.1"
|
|
19
|
+
session_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
20
|
+
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
|
21
|
+
target_hardware: str = "generic"
|
|
22
|
+
language: str = "auto"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExecutionControl(BaseStateModel):
|
|
26
|
+
status: Literal["idle", "planning", "in_progress", "paused", "done", "failed", "completed", "reflect"] = (
|
|
27
|
+
"idle"
|
|
28
|
+
)
|
|
29
|
+
current_step: Literal["init", "research", "edit", "review", "done", "idle", "reflect"] = "init"
|
|
30
|
+
active_agent: str = "researcher"
|
|
31
|
+
iteration_count: int = 0
|
|
32
|
+
max_iterations: int = 10
|
|
33
|
+
edit_retries: int = 0
|
|
34
|
+
max_edit_retries: int = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Environment(BaseStateModel):
|
|
38
|
+
build_command: str = ""
|
|
39
|
+
test_command: str = ""
|
|
40
|
+
target_files: List[str] = Field(default_factory=list)
|
|
41
|
+
active_target_file: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TaskSpec(BaseStateModel):
|
|
45
|
+
task_id: str = Field(default_factory=lambda: f"task_{uuid.uuid4().hex[:6]}")
|
|
46
|
+
raw_user_prompt: str = ""
|
|
47
|
+
decomposed_goal: str = ""
|
|
48
|
+
domain: str = ""
|
|
49
|
+
doc_constraints: List[str] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PlanStep(BaseStateModel):
|
|
53
|
+
"""
|
|
54
|
+
Универсальный шаг плана.
|
|
55
|
+
|
|
56
|
+
Поля, связанные с cross-file координацией, добавлены для борьбы с
|
|
57
|
+
рассинхронизацией HTML/CSS/JS и больших файлов:
|
|
58
|
+
- estimated_lines: сколько строк кода примерно выдаст этот шаг
|
|
59
|
+
- provides: список новых идентификаторов (классов, функций, файлов)
|
|
60
|
+
- consumes: список идентификаторов из предыдущих шагов
|
|
61
|
+
- part: маркер части (например, "2/3") если шаг разбит
|
|
62
|
+
- depends_on: id шагов, которые должны завершиться раньше
|
|
63
|
+
"""
|
|
64
|
+
id: Union[int, str] = Field(default=1)
|
|
65
|
+
title: str
|
|
66
|
+
description: str = ""
|
|
67
|
+
target_file: Optional[str] = None
|
|
68
|
+
assign_to: str = "editor"
|
|
69
|
+
status: Literal["pending", "in_progress", "completed", "failed"] = "pending"
|
|
70
|
+
estimated_lines: int = 0
|
|
71
|
+
provides: List[str] = Field(default_factory=list)
|
|
72
|
+
consumes: List[str] = Field(default_factory=list)
|
|
73
|
+
part: Optional[str] = None
|
|
74
|
+
depends_on: List[Union[int, str]] = Field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
@field_validator("id", mode="before")
|
|
77
|
+
@classmethod
|
|
78
|
+
def coerce_id(cls, v: Any) -> Union[int, str]:
|
|
79
|
+
if v is None:
|
|
80
|
+
return 1
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FeedbackLoop(BaseStateModel):
|
|
85
|
+
human_feedback: Optional[str] = None
|
|
86
|
+
reviewer_feedback: Optional[str] = None
|
|
87
|
+
last_error: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Metrics(BaseStateModel):
|
|
91
|
+
total_tokens: int = 0
|
|
92
|
+
total_cost_usd: float = 0.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AppState(BaseStateModel):
|
|
96
|
+
schema_version: str = "2.0"
|
|
97
|
+
meta: MetaModel = Field(default_factory=MetaModel)
|
|
98
|
+
execution_control: ExecutionControl = Field(default_factory=ExecutionControl)
|
|
99
|
+
environment: Environment = Field(default_factory=Environment)
|
|
100
|
+
system_constraints: List[str] = Field(default_factory=list)
|
|
101
|
+
task_spec: TaskSpec = Field(default_factory=TaskSpec)
|
|
102
|
+
plan: List[PlanStep] = Field(default_factory=list)
|
|
103
|
+
feedback_loop: FeedbackLoop = Field(default_factory=FeedbackLoop)
|
|
104
|
+
metrics: Metrics = Field(default_factory=Metrics)
|
|
105
|
+
findings_and_context: Dict[str, Any] = Field(default_factory=dict)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Plan chunking
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
_DEFAULT_CHUNK_THRESHOLD = 200
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _chunk_threshold() -> int:
|
|
115
|
+
try:
|
|
116
|
+
import config
|
|
117
|
+
return int(getattr(config, "PLAN_CHUNK_THRESHOLD", _DEFAULT_CHUNK_THRESHOLD))
|
|
118
|
+
except Exception:
|
|
119
|
+
return _DEFAULT_CHUNK_THRESHOLD
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _maybe_chunk_step(step: PlanStep) -> List[PlanStep]:
|
|
123
|
+
"""
|
|
124
|
+
Split a PlanStep into sequential parts if its estimated output exceeds
|
|
125
|
+
the chunk threshold. Each part is a separate PlanStep targeting the same
|
|
126
|
+
file, with a "part N/M" title marker.
|
|
127
|
+
|
|
128
|
+
No-op if estimated_lines is unset or below threshold.
|
|
129
|
+
"""
|
|
130
|
+
threshold = _chunk_threshold()
|
|
131
|
+
if not step.estimated_lines or step.estimated_lines <= threshold:
|
|
132
|
+
return [step]
|
|
133
|
+
|
|
134
|
+
n_parts = math.ceil(step.estimated_lines / threshold)
|
|
135
|
+
parts: List[PlanStep] = []
|
|
136
|
+
for i in range(n_parts):
|
|
137
|
+
remaining = step.estimated_lines - i * threshold
|
|
138
|
+
chunk_lines = min(threshold, remaining)
|
|
139
|
+
part_title = f"{step.title} (part {i + 1}/{n_parts})"
|
|
140
|
+
p = PlanStep(
|
|
141
|
+
title=part_title,
|
|
142
|
+
description=step.description,
|
|
143
|
+
target_file=step.target_file,
|
|
144
|
+
assign_to=step.assign_to,
|
|
145
|
+
status=step.status,
|
|
146
|
+
estimated_lines=chunk_lines,
|
|
147
|
+
provides=list(step.provides),
|
|
148
|
+
consumes=list(step.consumes),
|
|
149
|
+
part=f"{i + 1}/{n_parts}",
|
|
150
|
+
depends_on=list(step.depends_on),
|
|
151
|
+
)
|
|
152
|
+
parts.append(p)
|
|
153
|
+
|
|
154
|
+
# Chain: part 2 depends on part 1, etc.
|
|
155
|
+
for i in range(1, len(parts)):
|
|
156
|
+
parts[i].depends_on.append(parts[i - 1].id if parts[i - 1].id else i)
|
|
157
|
+
return parts
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# State template
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
def build_state_template(
|
|
164
|
+
user_prompt: str = "",
|
|
165
|
+
target_hardware: str = "generic",
|
|
166
|
+
system_constraints: Optional[List[str]] = None,
|
|
167
|
+
) -> AppState:
|
|
168
|
+
is_active = bool(user_prompt and user_prompt.strip())
|
|
169
|
+
|
|
170
|
+
return AppState(
|
|
171
|
+
meta=MetaModel(target_hardware=target_hardware),
|
|
172
|
+
execution_control=ExecutionControl(
|
|
173
|
+
status="planning" if is_active else "idle",
|
|
174
|
+
current_step="research" if is_active else "init",
|
|
175
|
+
active_agent="researcher",
|
|
176
|
+
),
|
|
177
|
+
system_constraints=system_constraints or [],
|
|
178
|
+
task_spec=TaskSpec(raw_user_prompt=user_prompt),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_state(filepath: str) -> AppState:
|
|
183
|
+
if not os.path.exists(filepath):
|
|
184
|
+
return build_state_template()
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
188
|
+
raw_data = json.load(f)
|
|
189
|
+
return AppState.model_validate(raw_data)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
print(f"[StateManager Warning] Failed to parse state.json ({e}).")
|
|
192
|
+
|
|
193
|
+
backup_path = f"{filepath}.corrupted_{int(datetime.now().timestamp())}"
|
|
194
|
+
try:
|
|
195
|
+
if os.path.exists(filepath):
|
|
196
|
+
os.rename(filepath, backup_path)
|
|
197
|
+
print(f"[StateManager] Backup of broken state saved to: {backup_path}")
|
|
198
|
+
except OSError as err:
|
|
199
|
+
print(f"[StateManager Warning] Could not rename broken state file: {err}")
|
|
200
|
+
|
|
201
|
+
print("[StateManager] Rebuilding clean template.")
|
|
202
|
+
return build_state_template()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def save_state(state: Union[AppState, dict], filepath: str) -> None:
|
|
206
|
+
if isinstance(state, dict):
|
|
207
|
+
if "execution_control" not in state or not isinstance(
|
|
208
|
+
state["execution_control"], dict
|
|
209
|
+
):
|
|
210
|
+
state["execution_control"] = {}
|
|
211
|
+
if "task_spec" not in state or not isinstance(state["task_spec"], dict):
|
|
212
|
+
state["task_spec"] = {}
|
|
213
|
+
|
|
214
|
+
if "current_step" in state:
|
|
215
|
+
state["execution_control"]["current_step"] = state["current_step"]
|
|
216
|
+
if "status" in state:
|
|
217
|
+
state["execution_control"]["status"] = state["status"]
|
|
218
|
+
if "active_agent" in state:
|
|
219
|
+
state["execution_control"]["active_agent"] = state["active_agent"]
|
|
220
|
+
if "iteration_count" in state:
|
|
221
|
+
state["execution_control"]["iteration_count"] = state["iteration_count"]
|
|
222
|
+
|
|
223
|
+
if "task_prompt" in state:
|
|
224
|
+
state["task_spec"]["raw_user_prompt"] = state["task_prompt"]
|
|
225
|
+
if "goal" in state and not state["task_spec"].get("raw_user_prompt"):
|
|
226
|
+
state["task_spec"]["raw_user_prompt"] = state["goal"]
|
|
227
|
+
|
|
228
|
+
app_state = AppState.model_validate(state)
|
|
229
|
+
else:
|
|
230
|
+
app_state = state
|
|
231
|
+
|
|
232
|
+
target_dir = os.path.dirname(os.path.abspath(filepath))
|
|
233
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
234
|
+
|
|
235
|
+
temp_file = None
|
|
236
|
+
try:
|
|
237
|
+
with tempfile.NamedTemporaryFile(
|
|
238
|
+
"w", dir=target_dir, delete=False, encoding="utf-8"
|
|
239
|
+
) as tf:
|
|
240
|
+
tf.write(app_state.model_dump_json(indent=2))
|
|
241
|
+
temp_file = tf.name
|
|
242
|
+
|
|
243
|
+
os.replace(temp_file, filepath)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
if temp_file and os.path.exists(temp_file):
|
|
246
|
+
os.remove(temp_file)
|
|
247
|
+
raise e
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def extract_json_payload(raw_text: str) -> Union[dict, list]:
|
|
251
|
+
if not isinstance(raw_text, str):
|
|
252
|
+
return raw_text
|
|
253
|
+
match = re.search(r"```(?:json)?\s*([\[\{].*?[\]\}])\s*```", raw_text, re.DOTALL)
|
|
254
|
+
candidate = match.group(1) if match else raw_text.strip()
|
|
255
|
+
start_obj, start_arr = candidate.find("{"), candidate.find("[")
|
|
256
|
+
if start_arr != -1 and (start_obj == -1 or start_arr < start_obj):
|
|
257
|
+
end = candidate.rfind("]")
|
|
258
|
+
if end != -1:
|
|
259
|
+
candidate = candidate[start_arr:end + 1]
|
|
260
|
+
elif start_obj != -1:
|
|
261
|
+
end = candidate.rfind("}")
|
|
262
|
+
if end != -1:
|
|
263
|
+
candidate = candidate[start_obj:end + 1]
|
|
264
|
+
return json.loads(candidate)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def apply_researcher_plan(
|
|
268
|
+
current_state: AppState, researcher_data: Union[dict, list, str]
|
|
269
|
+
) -> AppState:
|
|
270
|
+
"""
|
|
271
|
+
Мерджит вывод LLM-Исследователя в AppState.
|
|
272
|
+
|
|
273
|
+
New in this version:
|
|
274
|
+
- Preserves estimated_lines / provides / consumes from the researcher
|
|
275
|
+
- Chunks steps whose estimated_lines exceed the threshold
|
|
276
|
+
- Preserves completed work by merging on completed-status boundary
|
|
277
|
+
"""
|
|
278
|
+
raw_plan = None
|
|
279
|
+
|
|
280
|
+
if isinstance(researcher_data, str):
|
|
281
|
+
try:
|
|
282
|
+
researcher_data = extract_json_payload(researcher_data)
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
if isinstance(researcher_data, list):
|
|
287
|
+
raw_plan = researcher_data
|
|
288
|
+
elif isinstance(researcher_data, dict):
|
|
289
|
+
if researcher_data.get("decomposed_goal"):
|
|
290
|
+
current_state.task_spec.decomposed_goal = str(
|
|
291
|
+
researcher_data["decomposed_goal"]
|
|
292
|
+
)
|
|
293
|
+
elif researcher_data.get("analysis") and not current_state.task_spec.decomposed_goal:
|
|
294
|
+
current_state.task_spec.decomposed_goal = str(researcher_data["analysis"])[:500]
|
|
295
|
+
|
|
296
|
+
if isinstance(researcher_data.get("findings_and_context"), dict):
|
|
297
|
+
current_state.findings_and_context = researcher_data["findings_and_context"]
|
|
298
|
+
|
|
299
|
+
for key in ("technical_plan", "plan", "steps", "raw_response"):
|
|
300
|
+
candidate = researcher_data.get(key)
|
|
301
|
+
if isinstance(candidate, list) and candidate:
|
|
302
|
+
raw_plan = candidate
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
if isinstance(raw_plan, list) and raw_plan:
|
|
306
|
+
default_target = (
|
|
307
|
+
current_state.environment.active_target_file
|
|
308
|
+
or (researcher_data.get("target_file") if isinstance(researcher_data, dict) else None)
|
|
309
|
+
)
|
|
310
|
+
valid_status = {"pending", "in_progress", "completed", "failed"}
|
|
311
|
+
|
|
312
|
+
completed = [s for s in current_state.plan if s.status == "completed"]
|
|
313
|
+
new_steps: List[PlanStep] = []
|
|
314
|
+
|
|
315
|
+
for item in raw_plan:
|
|
316
|
+
if isinstance(item, dict):
|
|
317
|
+
title = str(item.get("title") or item.get("description") or "").strip()
|
|
318
|
+
if not title:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
status = item.get("status", "pending")
|
|
322
|
+
try:
|
|
323
|
+
est_lines = int(item.get("estimated_lines", 0) or 0)
|
|
324
|
+
except (TypeError, ValueError):
|
|
325
|
+
est_lines = 0
|
|
326
|
+
|
|
327
|
+
provides = [str(p) for p in (item.get("provides") or []) if isinstance(p, (str, int))]
|
|
328
|
+
consumes = [str(p) for p in (item.get("consumes") or []) if isinstance(p, (str, int))]
|
|
329
|
+
|
|
330
|
+
step = PlanStep(
|
|
331
|
+
title=title,
|
|
332
|
+
description=str(item.get("description", "")),
|
|
333
|
+
target_file=item.get("target_file") or default_target,
|
|
334
|
+
assign_to=str(item.get("assign_to", "editor")),
|
|
335
|
+
status=status if status in valid_status else "pending",
|
|
336
|
+
estimated_lines=est_lines,
|
|
337
|
+
provides=provides,
|
|
338
|
+
consumes=consumes,
|
|
339
|
+
)
|
|
340
|
+
new_steps.extend(_maybe_chunk_step(step))
|
|
341
|
+
|
|
342
|
+
elif isinstance(item, str) and item.strip():
|
|
343
|
+
step = PlanStep(
|
|
344
|
+
title=item.strip(),
|
|
345
|
+
description=item.strip(),
|
|
346
|
+
target_file=default_target,
|
|
347
|
+
)
|
|
348
|
+
new_steps.append(step)
|
|
349
|
+
|
|
350
|
+
if new_steps:
|
|
351
|
+
merged = completed + new_steps
|
|
352
|
+
for idx, step in enumerate(merged, start=1):
|
|
353
|
+
step.id = idx
|
|
354
|
+
current_state.plan = merged
|
|
355
|
+
|
|
356
|
+
has_pending = any(s.status == "pending" for s in current_state.plan)
|
|
357
|
+
if has_pending:
|
|
358
|
+
current_state.execution_control.status = "in_progress"
|
|
359
|
+
current_state.execution_control.current_step = "edit"
|
|
360
|
+
current_state.execution_control.active_agent = "editor"
|
|
361
|
+
else:
|
|
362
|
+
current_state.execution_control.status = "planning"
|
|
363
|
+
current_state.execution_control.current_step = "research"
|
|
364
|
+
current_state.execution_control.active_agent = "researcher"
|
|
365
|
+
|
|
366
|
+
return current_state
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
__all__ = [
|
|
370
|
+
"AppState",
|
|
371
|
+
"ExecutionControl",
|
|
372
|
+
"Environment",
|
|
373
|
+
"FeedbackLoop",
|
|
374
|
+
"MetaModel",
|
|
375
|
+
"Metrics",
|
|
376
|
+
"PlanStep",
|
|
377
|
+
"TaskSpec",
|
|
378
|
+
"apply_researcher_plan",
|
|
379
|
+
"build_state_template",
|
|
380
|
+
"extract_json_payload",
|
|
381
|
+
"load_state",
|
|
382
|
+
"save_state",
|
|
383
|
+
]
|