widdx 1.4.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.
- widdx/__init__.py +6 -0
- widdx/__main__.py +5 -0
- widdx/agent_factory/__init__.py +482 -0
- widdx/agent_factory/runner.py +150 -0
- widdx/agent_factory/spec.py +106 -0
- widdx/agents/__init__.py +4 -0
- widdx/agents/architect.py +70 -0
- widdx/agents/backend.py +50 -0
- widdx/agents/code_reviewer.py +43 -0
- widdx/agents/data_engineer.py +42 -0
- widdx/agents/database_architect.py +48 -0
- widdx/agents/devops.py +60 -0
- widdx/agents/frontend.py +58 -0
- widdx/agents/fullstack.py +45 -0
- widdx/agents/laravel.py +86 -0
- widdx/agents/qa.py +58 -0
- widdx/agents/registry.py +127 -0
- widdx/agents/security.py +39 -0
- widdx/agents/systems_architect.py +50 -0
- widdx/bootstrap/__init__.py +352 -0
- widdx/cli.py +214 -0
- widdx/config.py +44 -0
- widdx/evolution.py +99 -0
- widdx/git_ops.py +74 -0
- widdx/hooks/__init__.py +247 -0
- widdx/hooks/events.py +19 -0
- widdx/hooks/handlers.py +278 -0
- widdx/hooks/lint_check.py +71 -0
- widdx/hooks/security_check.py +70 -0
- widdx/hooks/session_log.py +70 -0
- widdx/indexer.py +162 -0
- widdx/mcp/__init__.py +398 -0
- widdx/mcp/builtin.py +344 -0
- widdx/mcp/client.py +509 -0
- widdx/mcp/config.py +107 -0
- widdx/memory.py +712 -0
- widdx/orchestrator.py +170 -0
- widdx/permissions.py +256 -0
- widdx/pipeline.py +920 -0
- widdx/preflight.py +146 -0
- widdx/project_planner.py +1193 -0
- widdx/project_state.py +122 -0
- widdx/repair_memory.py +278 -0
- widdx/repl/__init__.py +595 -0
- widdx/repl/commands.py +1041 -0
- widdx/repl/context.py +124 -0
- widdx/router/__init__.py +418 -0
- widdx/router/pricing.py +46 -0
- widdx/router/streaming.py +135 -0
- widdx/router/tools_api.py +142 -0
- widdx/scaffolder.py +739 -0
- widdx/scheduler.py +232 -0
- widdx/self_learning.py +185 -0
- widdx/skills/__init__.py +237 -0
- widdx/skills/loader.py +84 -0
- widdx/tool_loop/__init__.py +383 -0
- widdx/tool_loop/display.py +269 -0
- widdx/tool_loop/system_prompt.py +160 -0
- widdx/tool_loop/tools.py +426 -0
- widdx/tools/__init__.py +1 -0
- widdx/tools/file_ops.py +151 -0
- widdx/tools/search.py +50 -0
- widdx/tools/shell.py +179 -0
- widdx/tools/web.py +179 -0
- widdx/ui/__init__.py +80 -0
- widdx/ui/arabic.py +206 -0
- widdx/ui/boot.py +120 -0
- widdx/ui/chat.py +35 -0
- widdx/ui/drawing.py +275 -0
- widdx/ui/professional.py +393 -0
- widdx/ui/prompt.py +235 -0
- widdx/ui/theme.py +73 -0
- widdx/ui/views.py +128 -0
- widdx/ui/widgets/__init__.py +18 -0
- widdx/ui/widgets/progress_bar.py +121 -0
- widdx/ui/widgets/status_bar.py +79 -0
- widdx/ui/widgets/task_dashboard.py +119 -0
- widdx/ui/widgets/toast_notifications.py +125 -0
- widdx/verifier/__init__.py +676 -0
- widdx-1.4.0.dist-info/METADATA +28 -0
- widdx-1.4.0.dist-info/RECORD +85 -0
- widdx-1.4.0.dist-info/WHEEL +5 -0
- widdx-1.4.0.dist-info/entry_points.txt +2 -0
- widdx-1.4.0.dist-info/licenses/LICENSE +21 -0
- widdx-1.4.0.dist-info/top_level.txt +1 -0
widdx/__init__.py
ADDED
widdx/__main__.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Dynamic agent factory — designs and runs multi-agent teams with real tool access."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..agents.registry import AgentRegistry
|
|
10
|
+
from ..ui.arabic import _d
|
|
11
|
+
from .runner import AgentRunner
|
|
12
|
+
from .spec import AgentHandoff, AgentSpec
|
|
13
|
+
|
|
14
|
+
# Re-export for backward compatibility
|
|
15
|
+
__all__ = ["AgentSpec", "AgentHandoff", "AgentRunner", "AgentFactory"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentFactory:
|
|
19
|
+
"""Designs and runs multi-agent teams where each agent has real tool access."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, router) -> None:
|
|
22
|
+
self._router = router
|
|
23
|
+
|
|
24
|
+
# ── Team design ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def generate_team(self, task_description: str, complexity: int) -> list[AgentSpec]:
|
|
27
|
+
"""Ask the architect model to design the optimal agent team."""
|
|
28
|
+
system = self._team_designer_prompt()
|
|
29
|
+
user = (
|
|
30
|
+
f"Task: {task_description}\n"
|
|
31
|
+
f"Complexity: {complexity}/5\n\n"
|
|
32
|
+
f"Design the minimal effective agent team. Return valid JSON only."
|
|
33
|
+
)
|
|
34
|
+
result = self._router.call_architect(system, user, max_tokens=4096)
|
|
35
|
+
if "error" in result:
|
|
36
|
+
return self._fallback_team(task_description)
|
|
37
|
+
|
|
38
|
+
specs = self._parse_specs(result.get("text", ""))
|
|
39
|
+
return specs if specs else self._fallback_team(task_description)
|
|
40
|
+
|
|
41
|
+
def _team_designer_prompt(self) -> str:
|
|
42
|
+
available = ", ".join(AgentRegistry.domains())
|
|
43
|
+
return f"""You are an AI agent team architect. Design minimal, effective teams.
|
|
44
|
+
|
|
45
|
+
Return a JSON array of agent specs:
|
|
46
|
+
[
|
|
47
|
+
{{
|
|
48
|
+
"role": "Backend Architect",
|
|
49
|
+
"goal": "Build FastAPI endpoints with SQLAlchemy models",
|
|
50
|
+
"domain": "backend",
|
|
51
|
+
"constraints": ["Use async/await", "Pydantic v2 schemas"],
|
|
52
|
+
"depends_on": []
|
|
53
|
+
}},
|
|
54
|
+
{{
|
|
55
|
+
"role": "Frontend Developer",
|
|
56
|
+
"goal": "Build React components that consume the backend API",
|
|
57
|
+
"domain": "frontend",
|
|
58
|
+
"constraints": ["TypeScript", "Tailwind CSS", "Handle loading/error states"],
|
|
59
|
+
"depends_on": ["Backend Architect"]
|
|
60
|
+
}}
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
Available domains (use these exact values): {available}
|
|
64
|
+
depends_on: list of role names this agent must wait for (empty = runs first)
|
|
65
|
+
|
|
66
|
+
Rules:
|
|
67
|
+
- complexity 1-2: 1 agent (fullstack)
|
|
68
|
+
- complexity 3: 2 agents max
|
|
69
|
+
- complexity 4-5: 3-4 agents max (architect first, then backend, then frontend, then qa)
|
|
70
|
+
- Each agent must have a distinct domain
|
|
71
|
+
- depends_on creates the execution order
|
|
72
|
+
- Return ONLY the JSON array"""
|
|
73
|
+
|
|
74
|
+
def _parse_specs(self, raw: str) -> list[AgentSpec]:
|
|
75
|
+
raw = raw.strip()
|
|
76
|
+
# Strip markdown fences
|
|
77
|
+
if raw.startswith("```"):
|
|
78
|
+
raw = raw.split("\n", 1)[-1].strip()
|
|
79
|
+
if raw.endswith("```"):
|
|
80
|
+
raw = raw[:-3].strip()
|
|
81
|
+
# Find JSON array
|
|
82
|
+
start = raw.find("[")
|
|
83
|
+
end = raw.rfind("]")
|
|
84
|
+
if start == -1 or end == -1:
|
|
85
|
+
return []
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(raw[start:end + 1])
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return []
|
|
90
|
+
specs = []
|
|
91
|
+
for item in data:
|
|
92
|
+
specs.append(AgentSpec(
|
|
93
|
+
role=item.get("role", "Developer"),
|
|
94
|
+
goal=item.get("goal", ""),
|
|
95
|
+
domain=item.get("domain", "fullstack"),
|
|
96
|
+
constraints=item.get("constraints", []),
|
|
97
|
+
depends_on=item.get("depends_on", []),
|
|
98
|
+
))
|
|
99
|
+
return specs
|
|
100
|
+
|
|
101
|
+
def _fallback_team(self, task: str) -> list[AgentSpec]:
|
|
102
|
+
return [AgentSpec(
|
|
103
|
+
role="Full-Stack Developer",
|
|
104
|
+
goal=task,
|
|
105
|
+
domain="fullstack",
|
|
106
|
+
constraints=["Write clean code", "Handle errors", "No placeholders"],
|
|
107
|
+
depends_on=[],
|
|
108
|
+
)]
|
|
109
|
+
|
|
110
|
+
# ── Team execution ─────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def execute_team(
|
|
113
|
+
self,
|
|
114
|
+
specs: list[AgentSpec],
|
|
115
|
+
task: str,
|
|
116
|
+
task_id: str,
|
|
117
|
+
memory,
|
|
118
|
+
project_context: str = "",
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Execute agents with dependency-aware parallelism.
|
|
121
|
+
|
|
122
|
+
Agents whose ``depends_on`` list is empty (or whose dependencies have
|
|
123
|
+
already completed) are launched concurrently via a thread pool.
|
|
124
|
+
Agents that depend on others wait until all their dependencies finish.
|
|
125
|
+
|
|
126
|
+
Communication between agents uses AgentHandoff objects that summarise
|
|
127
|
+
what was done, which files were touched, and what the next agent should
|
|
128
|
+
focus on.
|
|
129
|
+
"""
|
|
130
|
+
from .. import ui
|
|
131
|
+
c = ui.get_console()
|
|
132
|
+
|
|
133
|
+
completed: dict[str, str] = {} # role → output text
|
|
134
|
+
all_results: list[dict] = []
|
|
135
|
+
handoffs: list[AgentHandoff] = []
|
|
136
|
+
remaining = list(specs)
|
|
137
|
+
|
|
138
|
+
# Build a role→spec map for dependency resolution
|
|
139
|
+
role_map = {s.role: s for s in specs}
|
|
140
|
+
|
|
141
|
+
def _run_agent(spec: AgentSpec, prior: dict[str, str]) -> dict:
|
|
142
|
+
"""Execute one agent and return its result dict."""
|
|
143
|
+
runner = AgentRunner(spec, self._router, project_context)
|
|
144
|
+
return runner.run(task, prior_outputs=prior if prior else None)
|
|
145
|
+
|
|
146
|
+
while remaining:
|
|
147
|
+
# Find all agents whose dependencies are satisfied
|
|
148
|
+
ready = [
|
|
149
|
+
s for s in remaining
|
|
150
|
+
if all(dep in completed for dep in s.depends_on)
|
|
151
|
+
]
|
|
152
|
+
if not ready:
|
|
153
|
+
# Circular dependency guard — warn the user and run the first
|
|
154
|
+
# remaining agent to avoid an infinite loop.
|
|
155
|
+
c.print(
|
|
156
|
+
f" [warning]⚠ Circular dependency detected among: "
|
|
157
|
+
f"{', '.join(s.role for s in remaining)}. "
|
|
158
|
+
f"Running '{remaining[0].role}' without waiting for its dependencies.[/]"
|
|
159
|
+
)
|
|
160
|
+
ready = [remaining[0]]
|
|
161
|
+
|
|
162
|
+
# Remove ready agents from the queue before launching
|
|
163
|
+
for s in ready:
|
|
164
|
+
remaining.remove(s)
|
|
165
|
+
|
|
166
|
+
# Build prior context for each ready agent
|
|
167
|
+
prior_map: dict[str, dict[str, str]] = {}
|
|
168
|
+
for spec in ready:
|
|
169
|
+
prior = {role: completed[role] for role in spec.depends_on if role in completed}
|
|
170
|
+
relevant_handoffs = [
|
|
171
|
+
h for h in handoffs
|
|
172
|
+
if h.to_role is None or h.to_role == spec.role
|
|
173
|
+
]
|
|
174
|
+
enriched: dict[str, str] = dict(prior)
|
|
175
|
+
for h in relevant_handoffs:
|
|
176
|
+
enriched[f"__handoff__{h.from_role}"] = h.to_context_block()
|
|
177
|
+
prior_map[spec.role] = enriched
|
|
178
|
+
|
|
179
|
+
# Announce all agents in this wave
|
|
180
|
+
for spec in ready:
|
|
181
|
+
c.print()
|
|
182
|
+
c.print(f" [brand]◆ Agent:[/] [highlight]{_d(spec.role)}[/] [dim]({_d(spec.domain)})[/]")
|
|
183
|
+
c.print(f" [dim] Goal: {_d(spec.goal[:80])}[/]")
|
|
184
|
+
relevant_handoffs = [
|
|
185
|
+
h for h in handoffs
|
|
186
|
+
if h.to_role is None or h.to_role == spec.role
|
|
187
|
+
]
|
|
188
|
+
if relevant_handoffs:
|
|
189
|
+
c.print(f" [dim] Handoffs from: {', '.join(_d(h.from_role) for h in relevant_handoffs)}[/]")
|
|
190
|
+
|
|
191
|
+
if len(ready) == 1:
|
|
192
|
+
# Single agent — run directly (no thread overhead)
|
|
193
|
+
spec = ready[0]
|
|
194
|
+
result = _run_agent(spec, prior_map[spec.role])
|
|
195
|
+
batch_results = [(spec, result)]
|
|
196
|
+
else:
|
|
197
|
+
# Multiple independent agents — run in parallel
|
|
198
|
+
c.print(f" [dim] Running {len(ready)} agents in parallel…[/]")
|
|
199
|
+
batch_results = []
|
|
200
|
+
# 5-minute timeout per wave — prevents a hung agent from blocking forever
|
|
201
|
+
_AGENT_TIMEOUT_SECS = 300
|
|
202
|
+
with ThreadPoolExecutor(max_workers=len(ready)) as pool:
|
|
203
|
+
future_to_spec = {
|
|
204
|
+
pool.submit(_run_agent, spec, prior_map[spec.role]): spec
|
|
205
|
+
for spec in ready
|
|
206
|
+
}
|
|
207
|
+
try:
|
|
208
|
+
for future in as_completed(future_to_spec, timeout=_AGENT_TIMEOUT_SECS):
|
|
209
|
+
spec = future_to_spec[future]
|
|
210
|
+
try:
|
|
211
|
+
result = future.result()
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
result = {
|
|
214
|
+
"role": spec.role,
|
|
215
|
+
"domain": spec.domain,
|
|
216
|
+
"output": f"Agent failed: {exc}",
|
|
217
|
+
"created_files": [],
|
|
218
|
+
"edited_files": [],
|
|
219
|
+
"latency_ms": 0,
|
|
220
|
+
"tool_calls": 0,
|
|
221
|
+
}
|
|
222
|
+
batch_results.append((spec, result))
|
|
223
|
+
except FuturesTimeoutError:
|
|
224
|
+
# Collect results from futures that finished; mark timed-out ones as failed
|
|
225
|
+
for future, spec in future_to_spec.items():
|
|
226
|
+
if future.done():
|
|
227
|
+
try:
|
|
228
|
+
result = future.result()
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
result = {
|
|
231
|
+
"role": spec.role,
|
|
232
|
+
"domain": spec.domain,
|
|
233
|
+
"output": f"Agent failed: {exc}",
|
|
234
|
+
"created_files": [],
|
|
235
|
+
"edited_files": [],
|
|
236
|
+
"latency_ms": _AGENT_TIMEOUT_SECS * 1000,
|
|
237
|
+
"tool_calls": 0,
|
|
238
|
+
}
|
|
239
|
+
else:
|
|
240
|
+
future.cancel()
|
|
241
|
+
result = {
|
|
242
|
+
"role": spec.role,
|
|
243
|
+
"domain": spec.domain,
|
|
244
|
+
"output": f"Agent timed out after {_AGENT_TIMEOUT_SECS}s",
|
|
245
|
+
"created_files": [],
|
|
246
|
+
"edited_files": [],
|
|
247
|
+
"latency_ms": _AGENT_TIMEOUT_SECS * 1000,
|
|
248
|
+
"tool_calls": 0,
|
|
249
|
+
}
|
|
250
|
+
c.print(f" [warning]⚠ Agent '{spec.role}' timed out after {_AGENT_TIMEOUT_SECS}s[/]")
|
|
251
|
+
# Avoid duplicates — only add if not already collected
|
|
252
|
+
if not any(r[1]["role"] == spec.role for r in batch_results):
|
|
253
|
+
batch_results.append((spec, result))
|
|
254
|
+
|
|
255
|
+
# Process results from this wave
|
|
256
|
+
for spec, result in batch_results:
|
|
257
|
+
completed[spec.role] = result["output"]
|
|
258
|
+
all_results.append(result)
|
|
259
|
+
|
|
260
|
+
next_roles = [
|
|
261
|
+
s.role for s in specs
|
|
262
|
+
if spec.role in s.depends_on
|
|
263
|
+
]
|
|
264
|
+
decisions, extracted_next = _extract_handoff_data(result["output"])
|
|
265
|
+
composed_next = (
|
|
266
|
+
f"Focus on: {next_roles[0]}" if next_roles else ""
|
|
267
|
+
)
|
|
268
|
+
if extracted_next:
|
|
269
|
+
composed_next = (
|
|
270
|
+
f"{composed_next} | {extracted_next}"
|
|
271
|
+
if composed_next
|
|
272
|
+
else extracted_next
|
|
273
|
+
)
|
|
274
|
+
handoff = AgentHandoff(
|
|
275
|
+
from_role=spec.role,
|
|
276
|
+
to_role=next_roles[0] if len(next_roles) == 1 else None,
|
|
277
|
+
summary=_summarize_for_handoff(result["output"]),
|
|
278
|
+
files_created=result.get("created_files", []),
|
|
279
|
+
files_edited=result.get("edited_files", []),
|
|
280
|
+
decisions=decisions,
|
|
281
|
+
next_steps=composed_next,
|
|
282
|
+
)
|
|
283
|
+
handoffs.append(handoff)
|
|
284
|
+
|
|
285
|
+
memory.log_agent_run(
|
|
286
|
+
task_id=task_id,
|
|
287
|
+
agent_role=spec.role,
|
|
288
|
+
agent_goal=spec.goal,
|
|
289
|
+
model_used="deepseek",
|
|
290
|
+
success="error" not in result.get("output", "").lower(),
|
|
291
|
+
output_summary=handoff.summary,
|
|
292
|
+
latency_ms=result["latency_ms"],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
created = result.get("created_files", [])
|
|
296
|
+
edited = result.get("edited_files", [])
|
|
297
|
+
if created or edited:
|
|
298
|
+
for f in created:
|
|
299
|
+
c.print(f" [success] ✚[/] [text]{f}[/]")
|
|
300
|
+
for f in edited:
|
|
301
|
+
c.print(f" [warning] ~[/] [text]{f}[/]")
|
|
302
|
+
|
|
303
|
+
c.print(f" [dim] Done in {result['latency_ms']}ms | {result['tool_calls']} tool calls[/]")
|
|
304
|
+
|
|
305
|
+
# ── Conflict detection ────────────────────────────────────
|
|
306
|
+
conflicts = _detect_file_conflicts(all_results)
|
|
307
|
+
if conflicts:
|
|
308
|
+
c.print()
|
|
309
|
+
c.print(" [warn]⚠ File conflicts detected:[/]")
|
|
310
|
+
for fname, roles in conflicts.items():
|
|
311
|
+
c.print(f" [dim]{fname}:[/] [text]{', '.join(roles)}[/]")
|
|
312
|
+
|
|
313
|
+
return _synthesize_final(all_results, handoffs, task, conflicts)
|
|
314
|
+
|
|
315
|
+
# ── Legacy compatibility ────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def execute_agent(self, agent: Any, task_context: str,
|
|
318
|
+
model_choice: str = "executor") -> dict:
|
|
319
|
+
"""Legacy single-agent execution (used by pipeline simple path)."""
|
|
320
|
+
if hasattr(agent, "to_system_prompt"):
|
|
321
|
+
agent_prompt = agent.to_system_prompt()
|
|
322
|
+
full_prompt = f"{agent_prompt}\n\nTASK CONTEXT:\n{task_context}\n\nExecute your role."
|
|
323
|
+
else:
|
|
324
|
+
full_prompt = task_context
|
|
325
|
+
|
|
326
|
+
if model_choice == "architect":
|
|
327
|
+
return dict(self._router.call_architect("", full_prompt))
|
|
328
|
+
return dict(self._router.call_executor("", full_prompt))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
def _summarize_for_handoff(output: str, max_chars: int = 600) -> str:
|
|
334
|
+
"""Compress agent output into a concise summary for handoff.
|
|
335
|
+
|
|
336
|
+
Extracts the most relevant parts: first meaningful paragraph,
|
|
337
|
+
file listings, and key decisions.
|
|
338
|
+
"""
|
|
339
|
+
if len(output) <= max_chars:
|
|
340
|
+
return output
|
|
341
|
+
|
|
342
|
+
# Take first meaningful paragraph(s)
|
|
343
|
+
lines = output.strip().split("\n")
|
|
344
|
+
summary_lines: list[str] = []
|
|
345
|
+
total = 0
|
|
346
|
+
for line in lines:
|
|
347
|
+
line = line.strip()
|
|
348
|
+
if not line:
|
|
349
|
+
if summary_lines:
|
|
350
|
+
summary_lines.append("")
|
|
351
|
+
continue
|
|
352
|
+
# Skip markdown headers that are just formatting
|
|
353
|
+
if line.startswith("```") or line.startswith("---"):
|
|
354
|
+
continue
|
|
355
|
+
if total + len(line) > max_chars:
|
|
356
|
+
break
|
|
357
|
+
summary_lines.append(line)
|
|
358
|
+
total += len(line) + 1
|
|
359
|
+
|
|
360
|
+
result = "\n".join(summary_lines).strip()
|
|
361
|
+
if len(result) < len(output):
|
|
362
|
+
result += f"\n... ({len(output)} chars total)"
|
|
363
|
+
return result
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _extract_handoff_data(output: str) -> tuple[list[str], str]:
|
|
367
|
+
"""Extract structured decisions and next steps from agent output.
|
|
368
|
+
|
|
369
|
+
Looks for ``## Decisions`` and ``## Next Steps`` sections.
|
|
370
|
+
Returns (decisions_list, next_steps_string).
|
|
371
|
+
"""
|
|
372
|
+
decisions: list[str] = []
|
|
373
|
+
next_steps_parts: list[str] = []
|
|
374
|
+
|
|
375
|
+
current_section: str | None = None
|
|
376
|
+
for line in output.split("\n"):
|
|
377
|
+
stripped = line.strip()
|
|
378
|
+
lower = stripped.lower()
|
|
379
|
+
|
|
380
|
+
if lower.startswith("## decisions"):
|
|
381
|
+
current_section = "decisions"
|
|
382
|
+
continue
|
|
383
|
+
elif lower.startswith("## next steps") or lower.startswith("## next step"):
|
|
384
|
+
current_section = "next"
|
|
385
|
+
continue
|
|
386
|
+
elif stripped.startswith("## "):
|
|
387
|
+
current_section = None
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
if current_section == "decisions":
|
|
391
|
+
# Match "- Decision: ..." or "- **Decision:** ..." or just "- ..."
|
|
392
|
+
if stripped.startswith("- "):
|
|
393
|
+
text = stripped[2:].strip()
|
|
394
|
+
# Remove bold markers
|
|
395
|
+
text = text.removeprefix("**").removesuffix("**")
|
|
396
|
+
if text.lower().startswith("decision:"):
|
|
397
|
+
text = text[len("decision:"):].strip()
|
|
398
|
+
elif text.lower().startswith("decision"):
|
|
399
|
+
text = text[len("decision"):].strip().lstrip(":")
|
|
400
|
+
decisions.append(text[:200])
|
|
401
|
+
elif current_section == "next":
|
|
402
|
+
if stripped.startswith("- "):
|
|
403
|
+
text = stripped[2:].strip()
|
|
404
|
+
text = text.removeprefix("**").removesuffix("**")
|
|
405
|
+
if text.lower().startswith("next step:"):
|
|
406
|
+
text = text[len("next step:"):].strip()
|
|
407
|
+
elif text.lower().startswith("next:"):
|
|
408
|
+
text = text[len("next:"):].strip()
|
|
409
|
+
next_steps_parts.append(text[:120])
|
|
410
|
+
|
|
411
|
+
next_steps = " | ".join(next_steps_parts) if next_steps_parts else ""
|
|
412
|
+
return decisions, next_steps
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _detect_file_conflicts(results: list[dict]) -> dict[str, list[str]]:
|
|
416
|
+
"""Detect when multiple agents touch the same file.
|
|
417
|
+
|
|
418
|
+
Returns {filename: [role1, role2, ...]} for files with conflicts.
|
|
419
|
+
"""
|
|
420
|
+
file_owners: dict[str, list[str]] = {}
|
|
421
|
+
for r in results:
|
|
422
|
+
role = r.get("role", "unknown")
|
|
423
|
+
for f in r.get("created_files", []):
|
|
424
|
+
file_owners.setdefault(f, []).append(role)
|
|
425
|
+
for f in r.get("edited_files", []):
|
|
426
|
+
file_owners.setdefault(f, []).append(role)
|
|
427
|
+
return {f: roles for f, roles in file_owners.items() if len(roles) > 1}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _synthesize_final(
|
|
431
|
+
results: list[dict],
|
|
432
|
+
handoffs: list,
|
|
433
|
+
task: str,
|
|
434
|
+
conflicts: dict[str, list[str]],
|
|
435
|
+
) -> str:
|
|
436
|
+
"""Synthesize all agent results into a clean final summary."""
|
|
437
|
+
parts = [f"# Multi-Agent Result: {task}\n"]
|
|
438
|
+
|
|
439
|
+
# File manifest
|
|
440
|
+
all_created: list[str] = []
|
|
441
|
+
all_edited: list[str] = []
|
|
442
|
+
for r in results:
|
|
443
|
+
all_created.extend(r.get("created_files", []))
|
|
444
|
+
all_edited.extend(r.get("edited_files", []))
|
|
445
|
+
all_created = list(dict.fromkeys(all_created))
|
|
446
|
+
all_edited = list(dict.fromkeys(all_edited))
|
|
447
|
+
|
|
448
|
+
if all_created or all_edited:
|
|
449
|
+
parts.append("## Files Changed\n")
|
|
450
|
+
for f in all_created:
|
|
451
|
+
parts.append(f"- ✚ `{f}` (created)")
|
|
452
|
+
for f in all_edited:
|
|
453
|
+
parts.append(f"- ~ `{f}` (edited)")
|
|
454
|
+
parts.append("")
|
|
455
|
+
|
|
456
|
+
# Conflict warning
|
|
457
|
+
if conflicts:
|
|
458
|
+
parts.append("## ⚠ File Conflicts\n")
|
|
459
|
+
for fname, roles in conflicts.items():
|
|
460
|
+
parts.append(f"- `{fname}` was touched by: {', '.join(roles)}")
|
|
461
|
+
parts.append("")
|
|
462
|
+
|
|
463
|
+
# Per-agent sections
|
|
464
|
+
for r in results:
|
|
465
|
+
role = r["role"]
|
|
466
|
+
domain = r.get("domain", "?")
|
|
467
|
+
output = r.get("output", "")
|
|
468
|
+
latency = r.get("latency_ms", 0)
|
|
469
|
+
tool_calls = r.get("tool_calls", 0)
|
|
470
|
+
|
|
471
|
+
parts.append(f"## {role} ({domain})")
|
|
472
|
+
parts.append(f"_{tool_calls} tool calls in {latency}ms_\n")
|
|
473
|
+
parts.append(output.strip())
|
|
474
|
+
parts.append("")
|
|
475
|
+
|
|
476
|
+
# Stats footer
|
|
477
|
+
total_latency = sum(r.get("latency_ms", 0) for r in results)
|
|
478
|
+
total_tools = sum(r.get("tool_calls", 0) for r in results)
|
|
479
|
+
parts.append("---")
|
|
480
|
+
parts.append(f"**{len(results)} agents** | **{total_tools} total tool calls** | **{total_latency}ms total**")
|
|
481
|
+
|
|
482
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""AgentRunner — runs a single agent with its own ToolLoop and real tool access."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from ..agents.registry import AgentRegistry
|
|
7
|
+
from .spec import AgentSpec
|
|
8
|
+
|
|
9
|
+
# Maximum characters for the isolated context message passed to the agent.
|
|
10
|
+
_MAX_CONTEXT_CHARS = 2000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentRunner:
|
|
14
|
+
"""Runs a single agent with its own ToolLoop — agents have real tool access."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, spec: AgentSpec, router, project_context: str = "") -> None:
|
|
17
|
+
self._spec = spec
|
|
18
|
+
self._router = router
|
|
19
|
+
self._project_context = project_context
|
|
20
|
+
|
|
21
|
+
# ------------------------------------------------------------------
|
|
22
|
+
# Context helpers
|
|
23
|
+
# ------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def _build_isolated_context(self, prior_outputs: dict[str, str] | None) -> str:
|
|
26
|
+
"""Convert prior_outputs into a single compact context message.
|
|
27
|
+
|
|
28
|
+
Handoff blocks (keys starting with ``__handoff__``) are preferred over
|
|
29
|
+
raw outputs because they are already compressed. The result is capped
|
|
30
|
+
at ``_MAX_CONTEXT_CHARS`` to avoid token waste.
|
|
31
|
+
|
|
32
|
+
Returns an empty string when there are no prior outputs.
|
|
33
|
+
"""
|
|
34
|
+
if not prior_outputs:
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
handoff_blocks = {
|
|
38
|
+
k[len("__handoff__"):]: v
|
|
39
|
+
for k, v in prior_outputs.items()
|
|
40
|
+
if k.startswith("__handoff__")
|
|
41
|
+
}
|
|
42
|
+
regular_outputs = {
|
|
43
|
+
k: v for k, v in prior_outputs.items()
|
|
44
|
+
if not k.startswith("__handoff__")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
parts: list[str] = ["## Context from previous agents\n"]
|
|
48
|
+
|
|
49
|
+
# Structured handoffs first — they are already compact
|
|
50
|
+
for role, handoff_text in handoff_blocks.items():
|
|
51
|
+
parts.append(f"### Handoff from {role}\n{handoff_text}\n")
|
|
52
|
+
|
|
53
|
+
# Raw outputs only for roles not already covered by a handoff
|
|
54
|
+
for role, output in regular_outputs.items():
|
|
55
|
+
if role not in handoff_blocks:
|
|
56
|
+
# Truncate raw output aggressively — it can be very long
|
|
57
|
+
truncated = output[:500] + ("…" if len(output) > 500 else "")
|
|
58
|
+
parts.append(f"### {role}\n{truncated}\n")
|
|
59
|
+
|
|
60
|
+
context = "\n".join(parts)
|
|
61
|
+
|
|
62
|
+
# Hard cap to avoid token waste
|
|
63
|
+
if len(context) > _MAX_CONTEXT_CHARS:
|
|
64
|
+
context = context[:_MAX_CONTEXT_CHARS] + "\n… (truncated)"
|
|
65
|
+
|
|
66
|
+
return context
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Main entry point
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def run(self, task: str, prior_outputs: dict[str, str] | None = None) -> dict:
|
|
73
|
+
"""Execute the agent. Returns {role, output, created_files, edited_files, error?}.
|
|
74
|
+
|
|
75
|
+
Each call creates a **fresh** ToolLoop with ``_messages = []`` so that
|
|
76
|
+
agents running in parallel never share conversation state.
|
|
77
|
+
|
|
78
|
+
prior_outputs can contain regular outputs keyed by role name,
|
|
79
|
+
and structured handoffs keyed as ``__handoff__<role>``.
|
|
80
|
+
"""
|
|
81
|
+
from ..tool_loop import ToolLoop
|
|
82
|
+
|
|
83
|
+
# Fresh isolated loop — _messages starts empty, no shared state
|
|
84
|
+
isolated_loop = ToolLoop(self._router, max_steps=50)
|
|
85
|
+
|
|
86
|
+
# Use built-in system prompt if available for this domain, else use spec prompt
|
|
87
|
+
builtin_prompt = AgentRegistry.get_system_prompt(self._spec.domain)
|
|
88
|
+
base_system = builtin_prompt if builtin_prompt else self._spec.to_system_prompt(
|
|
89
|
+
self._project_context, prior_outputs
|
|
90
|
+
)
|
|
91
|
+
isolated_loop.set_project_context(base_system)
|
|
92
|
+
|
|
93
|
+
# Build the compact handoff context that seeds this agent's conversation
|
|
94
|
+
isolated_context = self._build_isolated_context(prior_outputs)
|
|
95
|
+
|
|
96
|
+
# Compose the user message for this agent
|
|
97
|
+
user_msg_parts = [
|
|
98
|
+
f"Task: {task}",
|
|
99
|
+
"",
|
|
100
|
+
f"Your role: {self._spec.role}",
|
|
101
|
+
f"Your goal: {self._spec.goal}",
|
|
102
|
+
]
|
|
103
|
+
if prior_outputs:
|
|
104
|
+
user_msg_parts += [
|
|
105
|
+
"",
|
|
106
|
+
"Previous agents completed:",
|
|
107
|
+
*[f"- {role}: completed" for role in prior_outputs],
|
|
108
|
+
"",
|
|
109
|
+
"Now execute your part. Use tools to read existing files first.",
|
|
110
|
+
]
|
|
111
|
+
else:
|
|
112
|
+
user_msg_parts += [
|
|
113
|
+
"",
|
|
114
|
+
"Execute your part. Use tools to explore the project first.",
|
|
115
|
+
]
|
|
116
|
+
user_msg_parts += [
|
|
117
|
+
"",
|
|
118
|
+
"When you finish, include these sections at the end of your response:",
|
|
119
|
+
"## Decisions",
|
|
120
|
+
"- Decision: <key architectural or implementation decision>",
|
|
121
|
+
"",
|
|
122
|
+
"## Next Steps",
|
|
123
|
+
"- Next Step: <what the next agent should do>",
|
|
124
|
+
]
|
|
125
|
+
user_msg = "\n".join(user_msg_parts)
|
|
126
|
+
|
|
127
|
+
# Seed conversation_history with the isolated context so the agent
|
|
128
|
+
# receives prior work as a user message — not baked into the system prompt.
|
|
129
|
+
# This keeps _messages clean and isolated per agent.
|
|
130
|
+
conversation_history: list[dict] = []
|
|
131
|
+
if isolated_context:
|
|
132
|
+
conversation_history.append({"role": "user", "content": isolated_context})
|
|
133
|
+
conversation_history.append({
|
|
134
|
+
"role": "assistant",
|
|
135
|
+
"content": "Understood. I'll use this context as my foundation.",
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
start = time.perf_counter()
|
|
139
|
+
output = isolated_loop.run(user_msg, conversation_history=conversation_history)
|
|
140
|
+
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"role": self._spec.role,
|
|
144
|
+
"domain": self._spec.domain,
|
|
145
|
+
"output": output,
|
|
146
|
+
"created_files": list(isolated_loop._created_files),
|
|
147
|
+
"edited_files": list(isolated_loop._edited_files),
|
|
148
|
+
"latency_ms": elapsed_ms,
|
|
149
|
+
"tool_calls": isolated_loop._tool_calls_count,
|
|
150
|
+
}
|