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.
Files changed (85) hide show
  1. widdx/__init__.py +6 -0
  2. widdx/__main__.py +5 -0
  3. widdx/agent_factory/__init__.py +482 -0
  4. widdx/agent_factory/runner.py +150 -0
  5. widdx/agent_factory/spec.py +106 -0
  6. widdx/agents/__init__.py +4 -0
  7. widdx/agents/architect.py +70 -0
  8. widdx/agents/backend.py +50 -0
  9. widdx/agents/code_reviewer.py +43 -0
  10. widdx/agents/data_engineer.py +42 -0
  11. widdx/agents/database_architect.py +48 -0
  12. widdx/agents/devops.py +60 -0
  13. widdx/agents/frontend.py +58 -0
  14. widdx/agents/fullstack.py +45 -0
  15. widdx/agents/laravel.py +86 -0
  16. widdx/agents/qa.py +58 -0
  17. widdx/agents/registry.py +127 -0
  18. widdx/agents/security.py +39 -0
  19. widdx/agents/systems_architect.py +50 -0
  20. widdx/bootstrap/__init__.py +352 -0
  21. widdx/cli.py +214 -0
  22. widdx/config.py +44 -0
  23. widdx/evolution.py +99 -0
  24. widdx/git_ops.py +74 -0
  25. widdx/hooks/__init__.py +247 -0
  26. widdx/hooks/events.py +19 -0
  27. widdx/hooks/handlers.py +278 -0
  28. widdx/hooks/lint_check.py +71 -0
  29. widdx/hooks/security_check.py +70 -0
  30. widdx/hooks/session_log.py +70 -0
  31. widdx/indexer.py +162 -0
  32. widdx/mcp/__init__.py +398 -0
  33. widdx/mcp/builtin.py +344 -0
  34. widdx/mcp/client.py +509 -0
  35. widdx/mcp/config.py +107 -0
  36. widdx/memory.py +712 -0
  37. widdx/orchestrator.py +170 -0
  38. widdx/permissions.py +256 -0
  39. widdx/pipeline.py +920 -0
  40. widdx/preflight.py +146 -0
  41. widdx/project_planner.py +1193 -0
  42. widdx/project_state.py +122 -0
  43. widdx/repair_memory.py +278 -0
  44. widdx/repl/__init__.py +595 -0
  45. widdx/repl/commands.py +1041 -0
  46. widdx/repl/context.py +124 -0
  47. widdx/router/__init__.py +418 -0
  48. widdx/router/pricing.py +46 -0
  49. widdx/router/streaming.py +135 -0
  50. widdx/router/tools_api.py +142 -0
  51. widdx/scaffolder.py +739 -0
  52. widdx/scheduler.py +232 -0
  53. widdx/self_learning.py +185 -0
  54. widdx/skills/__init__.py +237 -0
  55. widdx/skills/loader.py +84 -0
  56. widdx/tool_loop/__init__.py +383 -0
  57. widdx/tool_loop/display.py +269 -0
  58. widdx/tool_loop/system_prompt.py +160 -0
  59. widdx/tool_loop/tools.py +426 -0
  60. widdx/tools/__init__.py +1 -0
  61. widdx/tools/file_ops.py +151 -0
  62. widdx/tools/search.py +50 -0
  63. widdx/tools/shell.py +179 -0
  64. widdx/tools/web.py +179 -0
  65. widdx/ui/__init__.py +80 -0
  66. widdx/ui/arabic.py +206 -0
  67. widdx/ui/boot.py +120 -0
  68. widdx/ui/chat.py +35 -0
  69. widdx/ui/drawing.py +275 -0
  70. widdx/ui/professional.py +393 -0
  71. widdx/ui/prompt.py +235 -0
  72. widdx/ui/theme.py +73 -0
  73. widdx/ui/views.py +128 -0
  74. widdx/ui/widgets/__init__.py +18 -0
  75. widdx/ui/widgets/progress_bar.py +121 -0
  76. widdx/ui/widgets/status_bar.py +79 -0
  77. widdx/ui/widgets/task_dashboard.py +119 -0
  78. widdx/ui/widgets/toast_notifications.py +125 -0
  79. widdx/verifier/__init__.py +676 -0
  80. widdx-1.4.0.dist-info/METADATA +28 -0
  81. widdx-1.4.0.dist-info/RECORD +85 -0
  82. widdx-1.4.0.dist-info/WHEEL +5 -0
  83. widdx-1.4.0.dist-info/entry_points.txt +2 -0
  84. widdx-1.4.0.dist-info/licenses/LICENSE +21 -0
  85. widdx-1.4.0.dist-info/top_level.txt +1 -0
widdx/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """WIDDX CLI — Autonomous AI Orchestration System.
2
+
3
+ Powered by DeepSeek V4.
4
+ """
5
+
6
+ __version__ = "1.4.0"
widdx/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """WIDDX CLI — Entry point for `python -m widdx`."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -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
+ }