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.
- autoforge/__init__.py +87 -0
- autoforge/__main__.py +16 -0
- autoforge/agent.py +2971 -0
- autoforge/autonomy/__init__.py +14 -0
- autoforge/autonomy/confirm.py +130 -0
- autoforge/autonomy/policy.py +245 -0
- autoforge/autonomy/roles.py +191 -0
- autoforge/autonomy/selfmod.py +146 -0
- autoforge/autonomy/spawn.py +227 -0
- autoforge/autonomy/topology.py +235 -0
- autoforge/browser.py +924 -0
- autoforge/cli.py +1119 -0
- autoforge/configfile.py +86 -0
- autoforge/core/__init__.py +29 -0
- autoforge/core/agent.py +271 -0
- autoforge/core/compaction.py +765 -0
- autoforge/core/lineedit.py +678 -0
- autoforge/core/llm.py +324 -0
- autoforge/core/message.py +89 -0
- autoforge/core/steering.py +280 -0
- autoforge/cpu/__init__.py +99 -0
- autoforge/cpu/kernel.py +442 -0
- autoforge/cpu/ops.py +652 -0
- autoforge/cpu/probe.py +569 -0
- autoforge/cpu/safety.py +463 -0
- autoforge/cpu/tune.py +564 -0
- autoforge/ecosystem.py +485 -0
- autoforge/forge/__init__.py +21 -0
- autoforge/forge/adversary.py +206 -0
- autoforge/forge/evolution.py +476 -0
- autoforge/forge/fuzzer.py +328 -0
- autoforge/forge/generator.py +591 -0
- autoforge/forge/invariance.py +369 -0
- autoforge/forge/metacog.py +177 -0
- autoforge/forge/pipeline.py +304 -0
- autoforge/forge/sandbox.py +301 -0
- autoforge/forge/validity.py +608 -0
- autoforge/forge/verifier.py +365 -0
- autoforge/gpu/__init__.py +116 -0
- autoforge/gpu/backend_triton.py +421 -0
- autoforge/gpu/bench.py +30 -0
- autoforge/gpu/kernel.py +430 -0
- autoforge/gpu/ops.py +349 -0
- autoforge/gpu/probe.py +267 -0
- autoforge/gpu/safety.py +248 -0
- autoforge/gpu/units.py +39 -0
- autoforge/mcp.py +1050 -0
- autoforge/measure.py +368 -0
- autoforge/modes.py +299 -0
- autoforge/notify.py +339 -0
- autoforge/route/__init__.py +3 -0
- autoforge/route/router.py +217 -0
- autoforge/schedule.py +427 -0
- autoforge/setup_wizard.py +232 -0
- autoforge/skills.py +496 -0
- autoforge/store.py +611 -0
- autoforge/timing.py +375 -0
- autoforge/tools/__init__.py +4 -0
- autoforge/tools/composition.py +143 -0
- autoforge/tools/registry.py +392 -0
- autoforge/tools/spec.py +230 -0
- autoforge/vision.py +279 -0
- autoforge/web/__init__.py +5 -0
- autoforge/web/server.py +689 -0
- autoforge/webtools.py +1382 -0
- autoforge_agent-0.4.0.dist-info/METADATA +477 -0
- autoforge_agent-0.4.0.dist-info/RECORD +70 -0
- autoforge_agent-0.4.0.dist-info/WHEEL +5 -0
- autoforge_agent-0.4.0.dist-info/entry_points.txt +3 -0
- 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"]
|