autoforge-agent 0.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 (70) hide show
  1. autoforge/__init__.py +87 -0
  2. autoforge/__main__.py +16 -0
  3. autoforge/agent.py +2971 -0
  4. autoforge/autonomy/__init__.py +14 -0
  5. autoforge/autonomy/confirm.py +130 -0
  6. autoforge/autonomy/policy.py +245 -0
  7. autoforge/autonomy/roles.py +191 -0
  8. autoforge/autonomy/selfmod.py +146 -0
  9. autoforge/autonomy/spawn.py +227 -0
  10. autoforge/autonomy/topology.py +235 -0
  11. autoforge/browser.py +924 -0
  12. autoforge/cli.py +1119 -0
  13. autoforge/configfile.py +86 -0
  14. autoforge/core/__init__.py +29 -0
  15. autoforge/core/agent.py +271 -0
  16. autoforge/core/compaction.py +765 -0
  17. autoforge/core/lineedit.py +678 -0
  18. autoforge/core/llm.py +324 -0
  19. autoforge/core/message.py +89 -0
  20. autoforge/core/steering.py +280 -0
  21. autoforge/cpu/__init__.py +99 -0
  22. autoforge/cpu/kernel.py +442 -0
  23. autoforge/cpu/ops.py +652 -0
  24. autoforge/cpu/probe.py +569 -0
  25. autoforge/cpu/safety.py +463 -0
  26. autoforge/cpu/tune.py +564 -0
  27. autoforge/ecosystem.py +485 -0
  28. autoforge/forge/__init__.py +21 -0
  29. autoforge/forge/adversary.py +206 -0
  30. autoforge/forge/evolution.py +476 -0
  31. autoforge/forge/fuzzer.py +328 -0
  32. autoforge/forge/generator.py +591 -0
  33. autoforge/forge/invariance.py +369 -0
  34. autoforge/forge/metacog.py +177 -0
  35. autoforge/forge/pipeline.py +304 -0
  36. autoforge/forge/sandbox.py +301 -0
  37. autoforge/forge/validity.py +608 -0
  38. autoforge/forge/verifier.py +365 -0
  39. autoforge/gpu/__init__.py +116 -0
  40. autoforge/gpu/backend_triton.py +421 -0
  41. autoforge/gpu/bench.py +30 -0
  42. autoforge/gpu/kernel.py +430 -0
  43. autoforge/gpu/ops.py +349 -0
  44. autoforge/gpu/probe.py +267 -0
  45. autoforge/gpu/safety.py +248 -0
  46. autoforge/gpu/units.py +39 -0
  47. autoforge/mcp.py +1050 -0
  48. autoforge/measure.py +368 -0
  49. autoforge/modes.py +299 -0
  50. autoforge/notify.py +339 -0
  51. autoforge/route/__init__.py +3 -0
  52. autoforge/route/router.py +217 -0
  53. autoforge/schedule.py +427 -0
  54. autoforge/setup_wizard.py +232 -0
  55. autoforge/skills.py +496 -0
  56. autoforge/store.py +611 -0
  57. autoforge/timing.py +375 -0
  58. autoforge/tools/__init__.py +4 -0
  59. autoforge/tools/composition.py +143 -0
  60. autoforge/tools/registry.py +392 -0
  61. autoforge/tools/spec.py +230 -0
  62. autoforge/vision.py +279 -0
  63. autoforge/web/__init__.py +5 -0
  64. autoforge/web/server.py +689 -0
  65. autoforge/webtools.py +1382 -0
  66. autoforge_agent-0.4.0.dist-info/METADATA +477 -0
  67. autoforge_agent-0.4.0.dist-info/RECORD +70 -0
  68. autoforge_agent-0.4.0.dist-info/WHEEL +5 -0
  69. autoforge_agent-0.4.0.dist-info/entry_points.txt +3 -0
  70. autoforge_agent-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,146 @@
1
+ """Self-modification: the agent rewrites parts of itself, with a paper trail.
2
+
3
+ The agent can change:
4
+ - its own system prompt
5
+ - the forge config (max rounds, promotion policy, which checks run)
6
+ - the routing weights (what it values when choosing tools)
7
+ - the autonomy policy itself
8
+
9
+ Every change is an `Amendment`: a before/after diff, a rationale, a timestamp,
10
+ and an accepted/rejected verdict. Amendments are immutable once written. The
11
+ log is the accountability mechanism — freedom without memory of what you did
12
+ with it is just chaos.
13
+
14
+ A rejected amendment is kept too. Knowing what the agent *tried* to change is
15
+ as informative as knowing what it succeeded in changing.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Callable
23
+
24
+
25
+ @dataclass
26
+ class Amendment:
27
+ """One self-modification, recorded forever."""
28
+
29
+ target: str # "system_prompt" | "forge_config" | ...
30
+ rationale: str
31
+ before: Any
32
+ after: Any
33
+ accepted: bool = True
34
+ rejected_reason: str | None = None
35
+ timestamp: float = field(default_factory=time.time)
36
+
37
+ def is_noop(self) -> bool:
38
+ return self.before == self.after
39
+
40
+ def diff_summary(self) -> str:
41
+ b = str(self.before)
42
+ a = str(self.after)
43
+ if len(b) > 120:
44
+ b = b[:120] + "..."
45
+ if len(a) > 120:
46
+ a = a[:120] + "..."
47
+ return f"{self.target}: {b!r} -> {a!r}"
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ return {
51
+ "target": self.target,
52
+ "rationale": self.rationale,
53
+ "before": str(self.before)[:500],
54
+ "after": str(self.after)[:500],
55
+ "accepted": self.accepted,
56
+ "rejected_reason": self.rejected_reason,
57
+ "timestamp": self.timestamp,
58
+ }
59
+
60
+
61
+ class SelfModifier:
62
+ """Applies and records self-modifications against a host object."""
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ require_rationale: bool = True,
68
+ log_all: bool = True,
69
+ veto: Callable[[Amendment], str | None] | None = None,
70
+ ) -> None:
71
+ self.require_rationale = require_rationale
72
+ self.log_all = log_all
73
+ self.veto = veto
74
+ self.amendments: list[Amendment] = []
75
+
76
+ def amend(
77
+ self,
78
+ host: Any,
79
+ target: str,
80
+ new_value: Any,
81
+ rationale: str = "",
82
+ *,
83
+ attr: str | None = None,
84
+ nested: tuple[str, ...] = (),
85
+ ) -> Amendment:
86
+ """Change `target` on `host` to `new_value`, recording the amendment.
87
+
88
+ `target` is a label for the log. `attr`/`nested` locate the real
89
+ attribute — left unset, `target` is used as the attribute name.
90
+ """
91
+ if self.require_rationale and not rationale.strip():
92
+ amend = Amendment(
93
+ target=target, rationale=rationale, before=None, after=new_value,
94
+ accepted=False, rejected_reason="rationale required but not provided",
95
+ )
96
+ self.amendments.append(amend)
97
+ return amend
98
+
99
+ # Resolve the container that holds the attribute
100
+ holder = host
101
+ for key in nested:
102
+ holder = getattr(holder, key)
103
+
104
+ attr_name = attr or target
105
+ before = copy.deepcopy(getattr(holder, attr_name, None))
106
+
107
+ amendment = Amendment(
108
+ target=target, rationale=rationale, before=before, after=new_value,
109
+ )
110
+
111
+ if amendment.is_noop():
112
+ amendment.accepted = False
113
+ amendment.rejected_reason = "no-op (value unchanged)"
114
+ self.amendments.append(amendment)
115
+ return amendment
116
+
117
+ if self.veto is not None:
118
+ reason = self.veto(amendment)
119
+ if reason:
120
+ amendment.accepted = False
121
+ amendment.rejected_reason = reason
122
+ self.amendments.append(amendment)
123
+ return amendment
124
+
125
+ setattr(holder, attr_name, new_value)
126
+ self.amendments.append(amendment)
127
+ return amendment
128
+
129
+ # -- introspection ---------------------------------------------------
130
+ def log(self) -> list[dict[str, Any]]:
131
+ return [a.to_dict() for a in self.amendments]
132
+
133
+ def accepted_count(self) -> int:
134
+ return sum(1 for a in self.amendments if a.accepted)
135
+
136
+ def rejected_count(self) -> int:
137
+ return sum(1 for a in self.amendments if not a.accepted)
138
+
139
+ def last_rationale(self) -> str | None:
140
+ for a in reversed(self.amendments):
141
+ if a.accepted:
142
+ return a.rationale
143
+ return None
144
+
145
+
146
+ __all__ = ["SelfModifier", "Amendment"]
@@ -0,0 +1,227 @@
1
+ """Spawning: the agent creates other agents, freely.
2
+
3
+ No pre-declared agent pool, no fixed orchestration graph. An agent decides at
4
+ runtime that a subtask wants its own worker, and spawns one. The child can
5
+ share the parent's tool registry (so tools forged by one are usable by all) or
6
+ get an isolated one (so experiments don't pollute the parent's library).
7
+
8
+ Two sharing modes:
9
+
10
+ SHARED — same registry object. A tool the child forges is immediately
11
+ available to the parent. Fast convergence, but a bad tool
12
+ propagates.
13
+ ISOLATED — child gets a copy. It can forge freely; only tools that pass its
14
+ verification AND the parent approves are merged back.
15
+
16
+ The parent chooses per-spawn. Default is SHARED, because the whole thesis is
17
+ that a growing shared library is the point — isolation is for when you want to
18
+ A/B a child's tool quality before trusting it.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from enum import Enum
25
+ from typing import Any, Callable
26
+
27
+ from ..tools.registry import ToolRegistry
28
+ from ..tools.spec import ToolSpec, ToolState
29
+ from .roles import ceiling, role_brief
30
+
31
+
32
+ class ShareMode(str, Enum):
33
+ SHARED = "shared"
34
+ ISOLATED = "isolated"
35
+
36
+
37
+ @dataclass
38
+ class SpawnRecord:
39
+ child_id: str
40
+ task: str
41
+ mode: str
42
+ parent_id: str
43
+ started: float = field(default_factory=time.time)
44
+ finished: float = 0.0
45
+ result: str = ""
46
+ tools_forged: list[str] = field(default_factory=list)
47
+ error: str | None = None
48
+ # Which role the child ran as, and — if it tried to reach past that role —
49
+ # what it was refused. A refusal is evidence the whitelist is live rather
50
+ # than decorative, so it is recorded rather than swallowed.
51
+ role: str = ""
52
+ refusals: list[str] = field(default_factory=list)
53
+ #: The directive the child was handed, kept so the trace shows the role
54
+ #: reached a model instead of only reaching a dataclass.
55
+ brief: str = ""
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return {
59
+ "child_id": self.child_id,
60
+ "task": self.task[:200],
61
+ "mode": self.mode,
62
+ "role": self.role or None,
63
+ "brief_chars": len(self.brief),
64
+ "tools_forged": self.tools_forged,
65
+ "refusals": self.refusals,
66
+ "duration_s": round(self.finished - self.started, 2) if self.finished else None,
67
+ "error": self.error,
68
+ }
69
+
70
+
71
+ @dataclass
72
+ class Spawner:
73
+ """Creates child agents on demand.
74
+
75
+ `agent_factory(parent, registry) -> agent-like` must return an object with
76
+ a `.run(task) -> result` method whose result exposes `.content` and the
77
+ child's registry (if it forged anything).
78
+ """
79
+
80
+ registry: ToolRegistry
81
+ agent_factory: Callable[[Any, ToolRegistry], Any]
82
+ parent_id: str = "root"
83
+ spawn_count: int = 0
84
+ history: list[SpawnRecord] = field(default_factory=list)
85
+ max_depth: int = 8 # a limit the agent can raise, not a cage
86
+ policy: Any = None # AutonomyPolicy | None — gates merge_back
87
+ _depth: int = 0
88
+
89
+ def spawn(
90
+ self,
91
+ task: str,
92
+ *,
93
+ mode: ShareMode = ShareMode.SHARED,
94
+ child_registry: ToolRegistry | None = None,
95
+ restrict_to: set[str] | list[str] | None = None,
96
+ role: str = "",
97
+ brief: str = "",
98
+ deny: Callable[[Any], str] | None = None,
99
+ ) -> SpawnRecord:
100
+ self.spawn_count += 1
101
+ child_id = f"{self.parent_id}.{self.spawn_count}"
102
+
103
+ # A role is enforceable on its own. The caller may add a whitelist, a
104
+ # richer brief (a node's hint on top of the role's), or a whole different
105
+ # ceiling — but naming a role is enough to get the role's behaviour, so
106
+ # there is no way to spawn a "critic" that is not held to a critic's
107
+ # limits by simply forgetting to pass one.
108
+ if role:
109
+ brief = brief or role_brief(role)
110
+ if deny is None:
111
+ deny = ceiling(role)
112
+
113
+ record = SpawnRecord(
114
+ child_id=child_id, task=task, mode=mode.value, parent_id=self.parent_id,
115
+ role=role, brief=brief,
116
+ )
117
+
118
+ if self._depth >= self.max_depth:
119
+ record.error = f"max spawn depth {self.max_depth} reached (raise it if needed)"
120
+ record.finished = time.time()
121
+ self.history.append(record)
122
+ return record
123
+
124
+ base_reg = self.registry if mode == ShareMode.SHARED else (
125
+ child_registry or ToolRegistry())
126
+ # A role's whitelist becomes a lens over the real registry, so the child
127
+ # can only see and call what its role allows. Registration still lands on
128
+ # base_reg — which is why the forged-tool diff is taken there: a child
129
+ # that forges something outside its whitelist really did create it, and
130
+ # hiding that from the parent would be the wrong kind of tidiness.
131
+ #
132
+ # `deny` is the role's capability ceiling, which is not a whitelist: a
133
+ # critic's tool set depends on what each tool does, and forged tools are
134
+ # named at runtime, so the ceiling has to be judged per-spec.
135
+ reg = base_reg.scoped(restrict_to or (), role=role, deny=deny)
136
+ before = set(base_reg.names())
137
+
138
+ try:
139
+ child = self.agent_factory(self, reg)
140
+ # Handed over after construction rather than through the factory
141
+ # signature, so an existing two-argument `agent_factory` — including
142
+ # the fakes in the tests — keeps working. A child that does not
143
+ # implement the hook is simply one that cannot be briefed, and the
144
+ # record says so by carrying an empty brief.
145
+ if brief:
146
+ hook = getattr(child, "apply_role", None)
147
+ if callable(hook):
148
+ hook(brief, role)
149
+ result = child.run(task)
150
+ record.result = getattr(result, "content", str(result))
151
+ except Exception as exc: # noqa: BLE001
152
+ record.error = f"{type(exc).__name__}: {exc}"
153
+
154
+ after = set(base_reg.names())
155
+ record.tools_forged = sorted(after - before)
156
+ record.refusals = list(getattr(reg, "refusals", []) or [])
157
+ record.finished = time.time()
158
+ self.history.append(record)
159
+ return record
160
+
161
+ def child_spawner(self, child_registry: ToolRegistry) -> "Spawner":
162
+ """A spawner for a child, one level deeper."""
163
+ return Spawner(
164
+ registry=child_registry,
165
+ agent_factory=self.agent_factory,
166
+ parent_id=f"{self.parent_id}.{self.spawn_count}",
167
+ max_depth=self.max_depth,
168
+ policy=self.policy,
169
+ _depth=self._depth + 1,
170
+ )
171
+
172
+ # -- merging isolated children back --------------------------------
173
+ def merge_back(
174
+ self,
175
+ child_registry: ToolRegistry,
176
+ *,
177
+ require_state: ToolState = ToolState.ACTIVE,
178
+ approve: Callable[[ToolSpec], bool] | None = None,
179
+ ) -> list[str]:
180
+ """Pull an isolated child's trusted tools into the parent library.
181
+
182
+ `approve=None` (the default) is not "no gate": it means the policy
183
+ decides. When may_promote_tools is off the child's tools may not be
184
+ sealed ACTIVE in the first place, so require_state already filters them
185
+ and merge_back ends up pulling nothing. Pass an explicit `approve`
186
+ callback to merge anyway, one spec at a time.
187
+ """
188
+ merged: list[str] = []
189
+ collect = self._collect_approved
190
+ for name, spec in child_registry._tools.items():
191
+ if spec.state != require_state:
192
+ continue
193
+ if not collect(spec, approve):
194
+ continue
195
+ self.registry.register(spec, replace=True)
196
+ merged.append(name)
197
+ return merged
198
+
199
+ @staticmethod
200
+ def _collect_approved(
201
+ spec: ToolSpec,
202
+ approve: Callable[[ToolSpec], bool] | None,
203
+ ) -> bool:
204
+ """Explicit callback always wins. With none, accept: require_state
205
+ already filtered, and the caller who owns the policy decides whether
206
+ merge_back is reachable at all (see `denied_reason`)."""
207
+ return approve(spec) if approve is not None else True
208
+
209
+ def denied_reason(self) -> str | None:
210
+ """Why merge_back would refuse to pull anything, or None if it won't."""
211
+ if self.policy is not None and not self.policy.may_promote_tools:
212
+ return (
213
+ "may_promote_tools is off: children may not seal tools ACTIVE, "
214
+ "so there is nothing at ACTIVE to merge. Pass approve=... to "
215
+ "accept on a case-by-case basis."
216
+ )
217
+ return None
218
+
219
+ def summary(self) -> dict[str, Any]:
220
+ return {
221
+ "spawned": self.spawn_count,
222
+ "max_depth": self.max_depth,
223
+ "children": [r.to_dict() for r in self.history],
224
+ }
225
+
226
+
227
+ __all__ = ["Spawner", "SpawnRecord", "ShareMode"]
@@ -0,0 +1,235 @@
1
+ """Agent topology: the agent can inspect and redesign its own multi-agent
2
+ architecture.
3
+
4
+ This is the dimension autoforge was missing relative to TPGO (ACL 2026).
5
+ A single agent loop, however many tools it can forge, is still a single
6
+ loop. The agent should be able to decide:
7
+
8
+ - How many agents exist in its system
9
+ - What role each agent plays
10
+ - How they communicate (tool bridge, shared registry, pipe)
11
+ - Which topology works better for which task type
12
+
13
+ The topology is modelled as a directed graph where nodes are agent roles
14
+ and edges are communication channels. The agent can mutate this graph,
15
+ run trials, and keep the winning configuration.
16
+
17
+ A topology is a *proposal*. It is not the runtime itself — the runtime
18
+ reads a topology and instantiates the described agents.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ import json
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum
26
+ from typing import Any
27
+ import re
28
+
29
+
30
+ class RoleType(str, Enum):
31
+ COORDINATOR = "coordinator" # delegates, merges results
32
+ WORKER = "worker" # focused executor
33
+ CRITIC = "critic" # reviews outputs, provides feedback
34
+ GATE = "gate" # filters/validates before passing on
35
+ FORGE = "forge" # specialised tool-forging agent
36
+
37
+
38
+ @dataclass
39
+ class AgentNode:
40
+ """One agent in the topology."""
41
+ id: str
42
+ role: RoleType
43
+ system_prompt_hint: str = ""
44
+ tools_whitelist: list[str] = field(default_factory=list)
45
+ max_turns: int | None = None
46
+
47
+
48
+ @dataclass
49
+ class TopologyEdge:
50
+ """A communication channel between two agents."""
51
+ source: str
52
+ target: str
53
+ channel: str = "tool_call" # tool_call | shared_registry | pipe | broadcast
54
+
55
+
56
+ @dataclass
57
+ class Topology:
58
+ """A complete multi-agent architecture proposal."""
59
+ nodes: list[AgentNode] = field(default_factory=list)
60
+ edges: list[TopologyEdge] = field(default_factory=list)
61
+ rationale: str = ""
62
+ created_at: float = field(default_factory=time.time)
63
+ fitness: float = 0.0 # set after evaluation
64
+ trials: int = 0
65
+
66
+ def add(self, node: AgentNode) -> "Topology":
67
+ self.nodes.append(node)
68
+ return self
69
+
70
+ def connect(self, source: str, target: str, channel: str = "tool_call") -> "Topology":
71
+ self.edges.append(TopologyEdge(source, target, channel))
72
+ return self
73
+
74
+ def validate(self) -> list[str]:
75
+ errors = []
76
+ ids = {n.id for n in self.nodes}
77
+ for e in self.edges:
78
+ if e.source not in ids:
79
+ errors.append(f"edge source {e.source!r} not in nodes")
80
+ if e.target not in ids:
81
+ errors.append(f"edge target {e.target!r} not in nodes")
82
+ return errors
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ return {
86
+ "nodes": [{"id": n.id, "role": n.role.value, "hint": n.system_prompt_hint[:80]}
87
+ for n in self.nodes],
88
+ "edges": [{"from": e.source, "to": e.target, "channel": e.channel}
89
+ for e in self.edges],
90
+ "rationale": self.rationale[:200],
91
+ "fitness": round(self.fitness, 3) if self.fitness else None,
92
+ "trials": self.trials,
93
+ }
94
+
95
+ @classmethod
96
+ def single_agent(cls, prompt_hint: str = "") -> "Topology":
97
+ return cls(
98
+ nodes=[AgentNode(id="main", role=RoleType.COORDINATOR,
99
+ system_prompt_hint=prompt_hint)],
100
+ )
101
+
102
+
103
+ _TOPOLOGY_MUTATE_SYSTEM = """You are an AI systems architect. You are given the current
104
+ multi-agent topology and a task or failure report. Design an IMPROVED topology.
105
+
106
+ The topology is a graph of agents. Each agent has:
107
+ - id: unique name
108
+ - role: coordinator | worker | critic | gate | forge
109
+ - system_prompt_hint: what this agent should focus on
110
+ - tools_whitelist: which tools it can access (empty = all)
111
+
112
+ Edges define who talks to whom. Communication channels:
113
+ - tool_call: source calls target via a tool
114
+ - shared_registry: both share a tool library
115
+ - pipe: source streams results to target
116
+
117
+ Rules for good topologies:
118
+ - coordinators delegate and merge, they do NOT do fine work themselves
119
+ - workers focus on one type of task
120
+ - critics review worker outputs before they reach the coordinator
121
+ - a forge agent specialises in tool creation, not task execution
122
+ - keep it minimal — more agents means more latency
123
+ - 2-4 agents is usually enough
124
+
125
+ Output STRICT JSON only:
126
+ {
127
+ "rationale": "why this topology fits the task",
128
+ "nodes": [{"id": "...", "role": "...", "system_prompt_hint": "...", "tools_whitelist": [...]}],
129
+ "edges": [{"source": "...", "target": "...", "channel": "tool_call"}]
130
+ }
131
+ """
132
+
133
+ _TOPOLOGY_ANALYSE_SYSTEM = """You are a systems analyst. Given a topology and a trace of
134
+ execution (tool calls, errors, results), identify what went wrong architecturally.
135
+
136
+ Output STRICT JSON:
137
+ {
138
+ "bottleneck": "which agent/edge caused the problem",
139
+ "root_cause": "one sentence",
140
+ "suggested_change": "what to add/remove/reassign"
141
+ }
142
+ """
143
+
144
+
145
+ class TopologyDesigner:
146
+ """An LLM-powered topology creator and mutator."""
147
+
148
+ def __init__(self, llm: Any, generator_model: str | None = None) -> None:
149
+ self.llm = llm
150
+ self.generator_model = generator_model
151
+
152
+ def design(self, task: str, current_topology: Topology | None = None,
153
+ failure_report: str = "", max_agents: int = 4) -> Topology:
154
+ """Design a topology from scratch or mutate an existing one.
155
+
156
+ `max_agents` is a soft ceiling on team size, passed to the model as a
157
+ budget hint. The result is not truncated if the model overshoots —
158
+ clipping nodes would leave dangling edges and silently degrade the
159
+ whole design, which is worse than a slightly oversized team.
160
+ """
161
+ from ..core.message import Message
162
+ from ..forge.generator import extract_json
163
+
164
+ prompt = f"Task: {task}\n"
165
+ prompt += f"\nTeam-size budget: at most {max_agents} agents.\n"
166
+ if current_topology:
167
+ prompt += f"\nCurrent topology:\n{json.dumps(current_topology.to_dict(), indent=2)}\n"
168
+ prompt += f"\nCurrent fitness: {current_topology.fitness}\n"
169
+ if failure_report:
170
+ prompt += f"\nFailure report:\n{failure_report}\n"
171
+ prompt += "\nDesign the topology now."
172
+
173
+ resp = self.llm.chat(
174
+ [Message.system(_TOPOLOGY_MUTATE_SYSTEM), Message.user(prompt)],
175
+ tools=None,
176
+ )
177
+ data = extract_json(resp.content)
178
+ if data is None:
179
+ return Topology.single_agent(task)
180
+
181
+ return self._parse_topology(data, task)
182
+
183
+ def analyse(self, topology: Topology, trace: list[dict[str, Any]]) -> dict[str, str]:
184
+ """Analyse a topology's execution trace for bottlenecks."""
185
+ from ..core.message import Message
186
+ from ..forge.generator import extract_json
187
+
188
+ prompt = (
189
+ f"Topology:\n{json.dumps(topology.to_dict(), indent=2)}\n"
190
+ f"\nExecution trace (last 10 events):\n"
191
+ + "\n".join(
192
+ json.dumps(t, ensure_ascii=False)[:200]
193
+ for t in trace[-10:]
194
+ )
195
+ + "\nAnalyse and suggest improvements."
196
+ )
197
+ resp = self.llm.chat(
198
+ [Message.system(_TOPOLOGY_ANALYSE_SYSTEM), Message.user(prompt)],
199
+ tools=None,
200
+ )
201
+ data = extract_json(resp.content)
202
+ if data is None:
203
+ return {"bottleneck": "unknown", "root_cause": "analysis failed",
204
+ "suggested_change": "retry"}
205
+ return {
206
+ "bottleneck": str(data.get("bottleneck", "unknown")),
207
+ "root_cause": str(data.get("root_cause", "")),
208
+ "suggested_change": str(data.get("suggested_change", "")),
209
+ }
210
+
211
+ @staticmethod
212
+ def _parse_topology(data: dict, fallback_task: str) -> Topology:
213
+ t = Topology(rationale=str(data.get("rationale", "")))
214
+ for n in data.get("nodes", []):
215
+ try:
216
+ role = RoleType(n.get("role", "worker"))
217
+ except ValueError:
218
+ role = RoleType.WORKER
219
+ t.add(AgentNode(
220
+ id=str(n.get("id", f"agent_{len(t.nodes)}")),
221
+ role=role,
222
+ system_prompt_hint=str(n.get("system_prompt_hint", "")),
223
+ tools_whitelist=list(n.get("tools_whitelist") or []),
224
+ ))
225
+ for e in data.get("edges", []):
226
+ t.connect(str(e.get("source", "")), str(e.get("target", "")),
227
+ str(e.get("channel", "tool_call")))
228
+ errors = t.validate()
229
+ if errors:
230
+ # Degrade gracefully: return a simple topology rather than crashing
231
+ return Topology.single_agent(fallback_task)
232
+ return t
233
+
234
+
235
+ __all__ = ["Topology", "TopologyDesigner", "AgentNode", "TopologyEdge", "RoleType"]