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
autoforge/agent.py
ADDED
|
@@ -0,0 +1,2971 @@
|
|
|
1
|
+
"""The composed agent: everything wired into one autonomous, self-growing loop.
|
|
2
|
+
|
|
3
|
+
This is where the pieces meet. A ForgeAgent owns:
|
|
4
|
+
|
|
5
|
+
policy — what it's allowed to decide (all True by default)
|
|
6
|
+
selfmod — the paper trail of every self-change
|
|
7
|
+
registry — its tool library (hot-swappable, self-policing)
|
|
8
|
+
pipeline — forge→verify→seal, with optional evolution on failure
|
|
9
|
+
verifier — execution + robustness + adversarial + trigger + negative
|
|
10
|
+
router — behaviour-aligned retrieval
|
|
11
|
+
metacog — proactive gap-filling
|
|
12
|
+
spawner — create child agents at will
|
|
13
|
+
store — optional SQLite persistence
|
|
14
|
+
|
|
15
|
+
The meta-tools it exposes to itself (all decided by policy):
|
|
16
|
+
|
|
17
|
+
forge_tool create a tool for a need
|
|
18
|
+
evolve_tool breed a better version of an existing tool
|
|
19
|
+
spawn_agent create a child to handle a subtask
|
|
20
|
+
design_team design the multi-agent topology that fits the task
|
|
21
|
+
amend_self change its own prompt / config / routing weights
|
|
22
|
+
set_autonomy change its own permissions
|
|
23
|
+
list_tools inspect its own library
|
|
24
|
+
evaluate_tool run the full verification battery on a tool
|
|
25
|
+
find_gaps proactively discover missing capabilities
|
|
26
|
+
my_history read its own ledger of past work and self-changes
|
|
27
|
+
terminate end the loop when it judges the task done
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from .autonomy.policy import FULL_FREEDOM, AutonomyPolicy
|
|
39
|
+
from .autonomy.selfmod import Amendment, SelfModifier
|
|
40
|
+
from .autonomy.spawn import ShareMode, Spawner
|
|
41
|
+
from .autonomy.roles import brief_for_node, role_brief
|
|
42
|
+
from .autonomy.topology import RoleType, Topology, TopologyDesigner
|
|
43
|
+
from .configfile import load
|
|
44
|
+
from .core.agent import Agent, AgentResult
|
|
45
|
+
from .core.compaction import (
|
|
46
|
+
Compactor,
|
|
47
|
+
DeterministicSummarizer,
|
|
48
|
+
LLMSummarizer,
|
|
49
|
+
default_log_path,
|
|
50
|
+
)
|
|
51
|
+
from .core.llm import LLMClient
|
|
52
|
+
from .core.message import Message
|
|
53
|
+
from .forge.evolution import EvolutionEngine
|
|
54
|
+
from .forge.generator import TemplateGenerator, extract_json
|
|
55
|
+
from .forge.metacog import MetaCognition
|
|
56
|
+
from .forge.pipeline import ForgeConfig, ForgePipeline
|
|
57
|
+
from .forge.sandbox import Sandbox
|
|
58
|
+
from .forge.validity import FrozenBaseline
|
|
59
|
+
from .forge.verifier import ToolVerifier
|
|
60
|
+
from .mcp import MCPClient, MCPHub
|
|
61
|
+
from .ecosystem import merged_servers, read_all as ecosystem_read_all
|
|
62
|
+
from .browser import (
|
|
63
|
+
Browser,
|
|
64
|
+
BrowserError,
|
|
65
|
+
CDPError,
|
|
66
|
+
WebSocketError,
|
|
67
|
+
launch_browser,
|
|
68
|
+
)
|
|
69
|
+
from .notify import Notifier, NotifyError, channels_from_config
|
|
70
|
+
from .vision import VisionError, vision_from_config
|
|
71
|
+
from .route.router import BehaviourRouter, RoutingWeights
|
|
72
|
+
from .schedule import Schedule, ScheduleError, as_clock
|
|
73
|
+
from .schedule import install_system_task as _install_system_task
|
|
74
|
+
from .skills import SkillError, SkillLibrary
|
|
75
|
+
from .store import ToolStore
|
|
76
|
+
from .tools.registry import ToolRegistry
|
|
77
|
+
from .tools.spec import ToolSpec, ToolState
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
AUTONOMOUS_SYSTEM = """You are an autonomous agent that grows its own capabilities.
|
|
81
|
+
|
|
82
|
+
Your reach — read this before claiming you cannot do something:
|
|
83
|
+
- forge_tool does not merely register a tool: it compiles the Python you write
|
|
84
|
+
and runs it in a separate process on this host, with a scrubbed environment
|
|
85
|
+
but a real filesystem and a real network stack. Reading, writing, listing and
|
|
86
|
+
opening sockets all go through it. There is no separate read_file or
|
|
87
|
+
run_shell tool because forge_tool IS that access. "I have no file tools" is
|
|
88
|
+
false; forge it.
|
|
89
|
+
- Anything you can express in Python, you can run. Treat that as shell access
|
|
90
|
+
with a timeout, and say so if asked what you can reach.
|
|
91
|
+
|
|
92
|
+
You can:
|
|
93
|
+
- my_capabilities — your real reach: what is enforced, what is declared-only,
|
|
94
|
+
and what forged code may actually do
|
|
95
|
+
- my_history — your ledger: past forges, runs, self-changes
|
|
96
|
+
- remember — keep a fact across sessions; kept facts are put in front of
|
|
97
|
+
you on every turn, so do not recall what is already listed above
|
|
98
|
+
- skill_view — load a procedure I wrote down earlier; skill_write saves
|
|
99
|
+
one. Facts go in memory, how-to goes in a skill.
|
|
100
|
+
- mcp_servers — tools from processes whose code I cannot read; configured,
|
|
101
|
+
not started. Nothing here wrote or probed them, so they gate as undeclared.
|
|
102
|
+
- forge_tool — create a tool for a need you cannot serve
|
|
103
|
+
- evolve_tool — breed a better version of a weak tool
|
|
104
|
+
- spawn_agent — a child agent for a subtask
|
|
105
|
+
- design_team — redesign the topology that fits the task
|
|
106
|
+
- amend_self — your own prompt, forge config, routing weights
|
|
107
|
+
- set_autonomy — your own permissions
|
|
108
|
+
- evaluate_tool — the full verification battery on any tool
|
|
109
|
+
- find_gaps — discover capabilities you are missing
|
|
110
|
+
- list_tools — your library and its health
|
|
111
|
+
- terminate — end the task when it is done
|
|
112
|
+
|
|
113
|
+
Principles:
|
|
114
|
+
- Forge only when a need genuinely recurs; never duplicate a tool you have.
|
|
115
|
+
- When a tool fails repeatedly, evolve it; do not retry blindly.
|
|
116
|
+
- When a task spans specialities, design the team before doing the work.
|
|
117
|
+
- Self-modification needs a rationale. Say why.
|
|
118
|
+
- Before reporting a limitation, check my_capabilities and my_history. A
|
|
119
|
+
capability you have and deny is worse than one you lack.
|
|
120
|
+
- You decide when the task is done. There is no hidden turn limit.
|
|
121
|
+
- Prefer the simplest path that works.
|
|
122
|
+
|
|
123
|
+
Acting, not arguing:
|
|
124
|
+
- When asked to build, port, fetch, scan or fix something, begin it in this
|
|
125
|
+
reply. Do not open with a feasibility assessment or a list of reasons it is
|
|
126
|
+
hard, and do not close by asking permission for the work you were just asked
|
|
127
|
+
to do. If the next step is within reach, take it and report what you found.
|
|
128
|
+
Offering to start is not starting.
|
|
129
|
+
- A plan you did not begin is not progress. If you have named the steps,
|
|
130
|
+
execute the first one now and report its result.
|
|
131
|
+
- A partial working artifact beats a complete argument. Do the obvious subset
|
|
132
|
+
first, then note in one line what you left out.
|
|
133
|
+
- Decline at most once, in one sentence, and only what is genuinely blocked.
|
|
134
|
+
State the block and stop there: never restate a refusal already given, and
|
|
135
|
+
never give a refusal its own justification, section or heading.
|
|
136
|
+
- Do not narrate your reasoning, doubts, or the options you dropped.
|
|
137
|
+
- If a request is ambiguous, take the most likely reading, do that, and name
|
|
138
|
+
the reading you took. Ask only when the choice is unrecoverable.
|
|
139
|
+
- About one screen. Short gets read.
|
|
140
|
+
|
|
141
|
+
Writing style — plain text in a terminal, never markdown:
|
|
142
|
+
- No `**bold**`, no `*italic*`, no `#`/`##` headings, no `|` tables. No
|
|
143
|
+
exceptions. Align columns with spaces, one blank line above and below:
|
|
144
|
+
freedom enforced?
|
|
145
|
+
may_read_filesystem declared-only
|
|
146
|
+
may_run_cuda_kernels enforced
|
|
147
|
+
- Section labels are plain words on their own line: "Freedom", not "## Freedom".
|
|
148
|
+
- For emphasis, repeat the point in the sentence; do not wrap it in punctuation.
|
|
149
|
+
|
|
150
|
+
Freedoms, when compared to another agent's:
|
|
151
|
+
- On reach, you are peers with anything on this host: forged code gets a real
|
|
152
|
+
filesystem and a real network stack.
|
|
153
|
+
- On self-change, if relevant, one line: amend_self and set_autonomy edit your
|
|
154
|
+
prompt, forge config and permissions while you run. Do not rank yourself.
|
|
155
|
+
- Answer in a few lines from the measured self-report. No comparison table, no
|
|
156
|
+
sorting the other agent's parts, no lecturing it about its boundaries.
|
|
157
|
+
- Do not claim a limit you have. Do not invent a freedom you lack. The measured
|
|
158
|
+
self-report above is the arbiter, recomputed every turn.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Self-modification targets: one target, one governing freedom.
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
_AMEND_TARGETS: dict[str, dict[str, Any]] = {
|
|
167
|
+
"system_prompt": {"freedom": "may_modify_own_prompt"},
|
|
168
|
+
"forge_max_rounds": {"freedom": "may_modify_forge_config"},
|
|
169
|
+
"routing_weights": {"freedom": "may_modify_routing"},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Ceilings that only bind when the matching freedom is switched off.
|
|
173
|
+
FORGE_ROUND_CEILING = 3 # unbounded_forge_rounds == False
|
|
174
|
+
SUPERVISED_TURN_CAP = 25 # unlimited_turns == False
|
|
175
|
+
|
|
176
|
+
_WEIGHT_FIELDS = ("text", "success", "trust", "cost", "over_trigger")
|
|
177
|
+
|
|
178
|
+
# How much of every request the agent's own kept facts may occupy, and how long
|
|
179
|
+
# any single entry may be before it is elided. Bounded because this block is
|
|
180
|
+
# re-sent on every turn: an unbounded one would price a chatty memory at the
|
|
181
|
+
# cost of the task, and the agent would have no way to see that happen.
|
|
182
|
+
MEMORY_BUDGET_CHARS = 1200
|
|
183
|
+
MEMORY_ENTRY_CHARS = 240
|
|
184
|
+
MEMORY_SLOTS = 20
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _coerce_weights(value: Any, current: Any) -> tuple[Any | None, str | None]:
|
|
188
|
+
"""Turn whatever the model sent into RoutingWeights, or explain why not.
|
|
189
|
+
|
|
190
|
+
The model passes tool arguments as strings, so `new_value` arrives as JSON
|
|
191
|
+
text more often than not. Parse it, validate every key against the real
|
|
192
|
+
dataclass, and keep the untouched fields from `current` rather than
|
|
193
|
+
silently resetting them to their defaults.
|
|
194
|
+
"""
|
|
195
|
+
if isinstance(value, str):
|
|
196
|
+
parsed: Any = None
|
|
197
|
+
try:
|
|
198
|
+
parsed = json.loads(value)
|
|
199
|
+
except (json.JSONDecodeError, ValueError):
|
|
200
|
+
try:
|
|
201
|
+
parsed = extract_json(value)
|
|
202
|
+
except Exception: # noqa: BLE001
|
|
203
|
+
parsed = None
|
|
204
|
+
if not isinstance(parsed, dict):
|
|
205
|
+
return None, (
|
|
206
|
+
"routing_weights needs a JSON object, e.g. "
|
|
207
|
+
'{"success": 1.5, "text": 0.8}; got ' + repr(value)[:120]
|
|
208
|
+
)
|
|
209
|
+
value = parsed
|
|
210
|
+
|
|
211
|
+
if not isinstance(value, dict):
|
|
212
|
+
return None, f"routing_weights needs an object, got {type(value).__name__}"
|
|
213
|
+
|
|
214
|
+
kwargs = {f: getattr(current, f) for f in _WEIGHT_FIELDS}
|
|
215
|
+
kwargs["min_calls_for_success"] = current.min_calls_for_success
|
|
216
|
+
|
|
217
|
+
for key, raw in value.items():
|
|
218
|
+
if key == "min_calls_for_success":
|
|
219
|
+
try:
|
|
220
|
+
kwargs[key] = max(1, int(raw))
|
|
221
|
+
except (TypeError, ValueError):
|
|
222
|
+
return None, f"min_calls_for_success must be an integer, got {raw!r}"
|
|
223
|
+
continue
|
|
224
|
+
if key not in _WEIGHT_FIELDS:
|
|
225
|
+
return None, (
|
|
226
|
+
f"unknown routing weight {key!r}; known: "
|
|
227
|
+
+ ", ".join(_WEIGHT_FIELDS)
|
|
228
|
+
+ " (or min_calls_for_success)"
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
kwargs[key] = float(raw)
|
|
232
|
+
except (TypeError, ValueError):
|
|
233
|
+
return None, f"routing weight {key!r} must be a number, got {raw!r}"
|
|
234
|
+
|
|
235
|
+
return RoutingWeights(**kwargs), None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
HOST_FACTS_HEADER = "HOST (measured on this machine — do not assume commands from habit):"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def host_facts(sandbox: Any = None) -> list[str]:
|
|
242
|
+
"""What this machine actually is, probed rather than assumed.
|
|
243
|
+
|
|
244
|
+
The failure this exists for: asked to search for a program by name, the
|
|
245
|
+
agent forged a probe built on `ps` and reported "no such process". On
|
|
246
|
+
Windows `ps` is not on the default PATH, and — this is the dangerous part —
|
|
247
|
+
a missing binary surfaces as an empty result, not an error. So the agent
|
|
248
|
+
read its own blind spot as evidence of absence, which is the one kind of
|
|
249
|
+
wrong answer a self-reporting agent must never give.
|
|
250
|
+
|
|
251
|
+
Resolution is done against the *sandbox's* PATH when a sandbox is given,
|
|
252
|
+
because that is the environment forged code runs in; the agent's own shell
|
|
253
|
+
may see binaries the sandbox cannot.
|
|
254
|
+
"""
|
|
255
|
+
import platform
|
|
256
|
+
|
|
257
|
+
system = platform.system()
|
|
258
|
+
lines = [
|
|
259
|
+
HOST_FACTS_HEADER,
|
|
260
|
+
f"- OS: {system} {platform.release()} ({platform.machine()}), "
|
|
261
|
+
f"Python {sys.version.split()[0]}.",
|
|
262
|
+
f"- This is {'a Windows' if system == 'Windows' else 'a POSIX'} host: "
|
|
263
|
+
f"paths use {os.sep!r}, lines end with {os.linesep!r}.",
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
probes = ("tasklist", "ps", "pgrep", "wmic", "powershell", "cmd", "sh")
|
|
267
|
+
if sandbox is not None:
|
|
268
|
+
present, absent = sandbox.reachable(probes)
|
|
269
|
+
where = "inside the sandbox"
|
|
270
|
+
else:
|
|
271
|
+
import shutil
|
|
272
|
+
|
|
273
|
+
present = [c for c in probes if shutil.which(c)]
|
|
274
|
+
absent = [c for c in probes if c not in present]
|
|
275
|
+
where = "on PATH"
|
|
276
|
+
|
|
277
|
+
if "tasklist" in present:
|
|
278
|
+
lines.append("- Processes: use `tasklist` — it is the native lister here.")
|
|
279
|
+
posix_gone = [c for c in ("ps", "pgrep") if c in absent]
|
|
280
|
+
if posix_gone:
|
|
281
|
+
lines += [
|
|
282
|
+
f" {', '.join(posix_gone)} is not resolvable {where}: a probe built on",
|
|
283
|
+
" it returns *nothing* instead of failing, and empty output reads",
|
|
284
|
+
" exactly like \"no such process\". An empty result is not evidence of",
|
|
285
|
+
" absence — check what the probe actually ran before believing it.",
|
|
286
|
+
]
|
|
287
|
+
elif "ps" in present:
|
|
288
|
+
lines.append(f"- Processes: `ps` is resolvable {where} — use it.")
|
|
289
|
+
else:
|
|
290
|
+
lines.append("- Processes: no known process lister "
|
|
291
|
+
f"{where} — read /proc directly, or say you cannot check.")
|
|
292
|
+
|
|
293
|
+
if present:
|
|
294
|
+
lines.append(f"- Resolvable {where}: {', '.join(sorted(present))}.")
|
|
295
|
+
if absent:
|
|
296
|
+
lines.append(f"- NOT resolvable {where}: {', '.join(sorted(absent))} — "
|
|
297
|
+
f"invoking these yields empty output, not an error.")
|
|
298
|
+
return lines
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
#: What each builtin tool touches, for the confirmation gate.
|
|
302
|
+
#:
|
|
303
|
+
#: The gate in `ToolRegistry.call` asks before running a tool whose declared
|
|
304
|
+
#: scope needs a freedom the policy switched off (`autonomy/confirm.py`). That
|
|
305
|
+
#: only means anything if the tools say what they touch — a builtin that
|
|
306
|
+
#: declares nothing needs every freedom, so it would be gated under any
|
|
307
|
+
#: restricted policy, including the ones that only want to stop network access.
|
|
308
|
+
#: Labelling them is what keeps the question specific.
|
|
309
|
+
#:
|
|
310
|
+
#: The granularity is the gate's: reads, writes, network, installs. A probe that
|
|
311
|
+
#: shells out to `nvidia-smi` is `read_only` here — it does spawn a process, and
|
|
312
|
+
#: that is recorded in `required_freedoms` for the freedom that governs running
|
|
313
|
+
#: code, but the gate is not the second gate for that decision.
|
|
314
|
+
BUILTIN_SCOPES: dict[str, str] = {
|
|
315
|
+
# The memory trio declares its own scope on the spec; the rows are repeated
|
|
316
|
+
# here so this table stays the one place the question "what does this
|
|
317
|
+
# builtin touch?" is answered. A disagreement fails a test rather than
|
|
318
|
+
# silently going one way at runtime.
|
|
319
|
+
"remember": "local_write",
|
|
320
|
+
"recall": "read_only",
|
|
321
|
+
"forget": "local_write",
|
|
322
|
+
# Reading the agent's own state. Nothing leaves the process.
|
|
323
|
+
"my_capabilities": "read_only",
|
|
324
|
+
"my_history": "read_only",
|
|
325
|
+
"list_tools": "read_only",
|
|
326
|
+
"find_gaps": "read_only",
|
|
327
|
+
# Procedures. Reading one is a read; writing or retiring one edits a file
|
|
328
|
+
# under the skills directories and the index row that points at it.
|
|
329
|
+
"skill_list": "read_only",
|
|
330
|
+
"skill_view": "read_only",
|
|
331
|
+
"skill_errors": "read_only",
|
|
332
|
+
"skill_write": "local_write",
|
|
333
|
+
"skill_forget": "local_write",
|
|
334
|
+
# Self-modification writes the agent's own policy, prompt or store.
|
|
335
|
+
"amend_self": "local_write",
|
|
336
|
+
"set_autonomy": "local_write",
|
|
337
|
+
"retire_tool": "local_write",
|
|
338
|
+
# Spawning and designing write nothing themselves; whatever the child then
|
|
339
|
+
# runs is gated on the child's own call, against the same policy.
|
|
340
|
+
"spawn_agent": "read_only",
|
|
341
|
+
"design_team": "read_only",
|
|
342
|
+
# These execute agent-authored code on the host: subprocess, plus a compile
|
|
343
|
+
# cache under the workspace for the GPU pair.
|
|
344
|
+
"forge_tool": "system",
|
|
345
|
+
"evaluate_tool": "system",
|
|
346
|
+
"evolve_tool": "system",
|
|
347
|
+
"gpu_compile": "system",
|
|
348
|
+
"gpu_bench": "system",
|
|
349
|
+
# Probing and arithmetic on numbers the caller already has.
|
|
350
|
+
"gpu_probe": "read_only",
|
|
351
|
+
"gpu_occupancy": "read_only",
|
|
352
|
+
"gpu_units_audit": "read_only",
|
|
353
|
+
"gpu_verify": "read_only",
|
|
354
|
+
# The CPU layer, split the same way and for the same reason. Reading the
|
|
355
|
+
# toolchain, asking whether an artefact built for a target can run here,
|
|
356
|
+
# linting C before it is compiled, the timing-unit audit and the cache
|
|
357
|
+
# report all touch nothing — a misspelled -march is caught by arithmetic,
|
|
358
|
+
# not by crashing. Compiling, running and tuning execute agent-authored
|
|
359
|
+
# code on the host, and unlike the GPU there is no driver to turn a mistake
|
|
360
|
+
# into an error code, so those three are `system`.
|
|
361
|
+
"cpu_probe": "read_only",
|
|
362
|
+
"cpu_runs_here": "read_only",
|
|
363
|
+
"cpu_preflight": "read_only",
|
|
364
|
+
"cpu_units_audit": "read_only",
|
|
365
|
+
"cpu_cache_stats": "read_only",
|
|
366
|
+
"cpu_compile": "system",
|
|
367
|
+
"cpu_run_isolated": "system",
|
|
368
|
+
"cpu_tune": "system",
|
|
369
|
+
# Reading what other processes offer. Nothing is started by either of these
|
|
370
|
+
# two beyond what the config already described.
|
|
371
|
+
"mcp_servers": "read_only",
|
|
372
|
+
# Connecting starts a subprocess, so it declares the scope that covers
|
|
373
|
+
# that. The tools it then imports are a separate question: they carry
|
|
374
|
+
# `undeclared` unless the operator narrowed them in the config, because
|
|
375
|
+
# their code is not here to read.
|
|
376
|
+
"mcp_connect": "system",
|
|
377
|
+
"mcp_call": "system",
|
|
378
|
+
# Reaching a human is network egress and nothing else, so it is gated on
|
|
379
|
+
# exactly the freedom an operator would expect: switch off may_access_
|
|
380
|
+
# network and the agent stops being able to message anyone. Declaring it
|
|
381
|
+
# `system` would have been true but useless -- the gate would ask about
|
|
382
|
+
# arbitrary code execution to send a status line.
|
|
383
|
+
"notify_send": "network",
|
|
384
|
+
"notify_channels": "read_only",
|
|
385
|
+
# Looking. Rendering a page starts a browser, which runs a process; driving
|
|
386
|
+
# it reaches the network, and evaluating script in it can do anything the
|
|
387
|
+
# page could do. The two that read state back declare the narrower scope
|
|
388
|
+
# they actually hold, rather than all of them claiming the widest.
|
|
389
|
+
"browser_open": "system",
|
|
390
|
+
"browser_close": "system",
|
|
391
|
+
"browser_state": "read_only",
|
|
392
|
+
"browser_goto": "network",
|
|
393
|
+
"browser_eval": "network",
|
|
394
|
+
"browser_click": "network",
|
|
395
|
+
"browser_type": "network",
|
|
396
|
+
"browser_press": "network",
|
|
397
|
+
"browser_screenshot": "local_write",
|
|
398
|
+
"see": "network",
|
|
399
|
+
"vision_status": "read_only",
|
|
400
|
+
# The task table is a file the agent owns. Writing it is a local write;
|
|
401
|
+
# reading back the agenda is not.
|
|
402
|
+
"schedule_add": "local_write",
|
|
403
|
+
"schedule_done": "local_write",
|
|
404
|
+
"schedule_cancel": "local_write",
|
|
405
|
+
"schedule_list": "read_only",
|
|
406
|
+
"schedule_tick": "read_only",
|
|
407
|
+
# This one changes the machine's configuration, so it declares the widest
|
|
408
|
+
# scope: it really does run a subprocess that rewrites Task Scheduler or
|
|
409
|
+
# prints a crontab line.
|
|
410
|
+
"install_system_task": "system",
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@dataclass
|
|
415
|
+
class ForgeAgent:
|
|
416
|
+
llm: LLMClient
|
|
417
|
+
registry: ToolRegistry = field(default_factory=ToolRegistry)
|
|
418
|
+
sandbox: Sandbox = field(default_factory=Sandbox)
|
|
419
|
+
generator: Any = None
|
|
420
|
+
forge_config: ForgeConfig = field(default_factory=ForgeConfig)
|
|
421
|
+
system_prompt: str = AUTONOMOUS_SYSTEM
|
|
422
|
+
max_turns: int | None = None # None = unbounded (agent decides)
|
|
423
|
+
policy: AutonomyPolicy = field(default_factory=AutonomyPolicy)
|
|
424
|
+
store: ToolStore | None = None
|
|
425
|
+
enable_meta_cognition: bool = False
|
|
426
|
+
enable_evolution: bool = True
|
|
427
|
+
trace: list[dict[str, Any]] = field(default_factory=list)
|
|
428
|
+
topology: Topology = field(default_factory=Topology.single_agent)
|
|
429
|
+
# Asked before running a tool that needs a switched-off freedom. Left None
|
|
430
|
+
# the gate fails closed — see autonomy/confirm.py and ToolRegistry._gate.
|
|
431
|
+
confirmer: Any = None
|
|
432
|
+
# The operator's channel into a run in progress (core/steering.py). None
|
|
433
|
+
# means nobody is watching, which is the honest state for a piped or
|
|
434
|
+
# unattended run — not a degraded one.
|
|
435
|
+
steer: Any = None
|
|
436
|
+
# How a run stays coherent past the context window (core/compaction.py).
|
|
437
|
+
# None means "build the default": a model summary with a deterministic
|
|
438
|
+
# fallback. A run that never approaches the window never pays for it.
|
|
439
|
+
compactor: Any = None
|
|
440
|
+
# Set when this agent is a child spawned into a role (autonomy/roles.py).
|
|
441
|
+
# Empty for a top-level run, which is what a parent is: nobody is above it
|
|
442
|
+
# to assign one.
|
|
443
|
+
role: str = ""
|
|
444
|
+
role_brief: str = ""
|
|
445
|
+
|
|
446
|
+
def __post_init__(self) -> None:
|
|
447
|
+
if self.generator is None:
|
|
448
|
+
self.generator = TemplateGenerator()
|
|
449
|
+
|
|
450
|
+
# The gate lives on the registry because that is where every tool
|
|
451
|
+
# actually runs. Without this the policy would be a comment again.
|
|
452
|
+
self.registry.policy = self.policy
|
|
453
|
+
self.registry.confirmer = self.confirmer
|
|
454
|
+
|
|
455
|
+
self.selfmod = SelfModifier(
|
|
456
|
+
require_rationale=self.policy.require_change_rationale,
|
|
457
|
+
log_all=self.policy.log_all_changes,
|
|
458
|
+
)
|
|
459
|
+
self.verifier = ToolVerifier(
|
|
460
|
+
self.llm, sandbox=self.sandbox,
|
|
461
|
+
run_execution_check=self.forge_config.require_execution,
|
|
462
|
+
run_trigger_check=self.forge_config.require_trigger,
|
|
463
|
+
run_negative_check=self.forge_config.require_negative,
|
|
464
|
+
)
|
|
465
|
+
self.pipeline = ForgePipeline(
|
|
466
|
+
self.generator, self.verifier, self.registry,
|
|
467
|
+
config=self.forge_config, sandbox=self.sandbox,
|
|
468
|
+
policy=self.policy,
|
|
469
|
+
on_event=self._on_forge_event,
|
|
470
|
+
)
|
|
471
|
+
self.router = BehaviourRouter(self.registry)
|
|
472
|
+
|
|
473
|
+
# MCP: servers named in the config file, plus -- when the config asks
|
|
474
|
+
# for it -- the ones the other agents on this machine already have.
|
|
475
|
+
# None of them started yet. The hub is built here so its caches are
|
|
476
|
+
# per-agent, but no subprocess is spawned until the agent asks for one:
|
|
477
|
+
# a config entry says how to reach a server, not that it should be
|
|
478
|
+
# running. `merged_servers` reads the ecosystem only if
|
|
479
|
+
# `mcp.ecosystem` is on, so the default costs one dict lookup.
|
|
480
|
+
self.mcp_servers, self._mcp_problems = merged_servers(load())
|
|
481
|
+
|
|
482
|
+
# Reaching a human. Built from the config at construction for the same
|
|
483
|
+
# reason MCP servers are: a config entry says how to reach someone, not
|
|
484
|
+
# that a message is owed. Nothing is sent until the agent calls
|
|
485
|
+
# notify_send, and the failures are carried rather than raised so an
|
|
486
|
+
# unreadable channel is reported in the menu instead of at the moment
|
|
487
|
+
# the agent needs to speak.
|
|
488
|
+
self.notify_channels, self._notify_problems = channels_from_config(load())
|
|
489
|
+
self.notifier = Notifier(self.notify_channels)
|
|
490
|
+
|
|
491
|
+
# The agent's own agenda. Opened here, fired by `schedule_tick` (the
|
|
492
|
+
# agent deciding to look) or by `python -m autoforge tick` (the OS
|
|
493
|
+
# deciding to wake it). The table stores; it never fires by itself.
|
|
494
|
+
self.schedule = Schedule()
|
|
495
|
+
self.mcp = MCPHub(self.mcp_servers)
|
|
496
|
+
|
|
497
|
+
# Seeing. Two halves that are useless apart: a browser to render what
|
|
498
|
+
# only exists after scripts run, and a vision endpoint to read the
|
|
499
|
+
# picture back. Neither is started or contacted at construction -- the
|
|
500
|
+
# browser process is launched on first use and the vision config is
|
|
501
|
+
# only read into a client. An agent that is never asked to look never
|
|
502
|
+
# costs anything for being able to.
|
|
503
|
+
self.vision, self._vision_problem = vision_from_config(load())
|
|
504
|
+
self._browser: Browser | None = None
|
|
505
|
+
self._browser_proc: Any = None
|
|
506
|
+
self._browser_endpoint = os.environ.get(
|
|
507
|
+
"AUTOFORGE_CDP_ENDPOINT", "http://127.0.0.1:9222")
|
|
508
|
+
|
|
509
|
+
# Skills: procedures on disk, ranked by how often they were loaded.
|
|
510
|
+
# Scanned at construction because the prompt menu is built per run, and
|
|
511
|
+
# a menu assembled from a stale scan would offer procedures that are no
|
|
512
|
+
# longer there — worse than offering none.
|
|
513
|
+
self.skills = SkillLibrary(store=self.store)
|
|
514
|
+
try:
|
|
515
|
+
self.skills.scan()
|
|
516
|
+
except OSError:
|
|
517
|
+
# An unreadable skills directory is not a reason to refuse to run.
|
|
518
|
+
# The library records the failure and reports it in the menu.
|
|
519
|
+
pass
|
|
520
|
+
|
|
521
|
+
# Coherence past the window. The summarizer is the model itself -- it is
|
|
522
|
+
# the only thing present that can tell a decision from a command -- with
|
|
523
|
+
# the deterministic summarizer behind it, so a model that fails or
|
|
524
|
+
# returns junk degrades the note rather than the run. Which messages may
|
|
525
|
+
# be dropped, and the operator's own words being kept out of the
|
|
526
|
+
# summarizer's reach entirely, are the Compactor's business.
|
|
527
|
+
if self.compactor is None:
|
|
528
|
+
self.compactor = Compactor(
|
|
529
|
+
summarizer=LLMSummarizer(self.llm),
|
|
530
|
+
fallback=DeterministicSummarizer(),
|
|
531
|
+
log_path=default_log_path(),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# unlimited_turns is the agent's own claim on unbounded loops. When it
|
|
535
|
+
# is off, an unbounded request gets a real ceiling instead of the
|
|
536
|
+
# "there is no hidden turn limit" the prompt promises. Set explicitly
|
|
537
|
+
# afterwards, this call is honoured as an override.
|
|
538
|
+
if not self.policy.unlimited_turns and self.max_turns is None:
|
|
539
|
+
self.max_turns = SUPERVISED_TURN_CAP
|
|
540
|
+
|
|
541
|
+
self.evolution: EvolutionEngine | None = None
|
|
542
|
+
if self.enable_evolution:
|
|
543
|
+
self.evolution = EvolutionEngine(
|
|
544
|
+
self.llm, self.verifier, population_size=3,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
self.metacog: MetaCognition | None = None
|
|
548
|
+
if self.enable_meta_cognition:
|
|
549
|
+
self.metacog = MetaCognition(self.llm, self.registry, self.pipeline)
|
|
550
|
+
|
|
551
|
+
self.spawner = Spawner(
|
|
552
|
+
registry=self.registry,
|
|
553
|
+
agent_factory=_default_agent_factory(self),
|
|
554
|
+
policy=self.policy,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
self.topology_designer = TopologyDesigner(self.llm)
|
|
558
|
+
|
|
559
|
+
# Live observer, attached only for the duration of a run (see `run`).
|
|
560
|
+
self._progress: Any = None
|
|
561
|
+
|
|
562
|
+
self._register_meta_tools()
|
|
563
|
+
|
|
564
|
+
if self.store is not None and self.policy.log_all_changes:
|
|
565
|
+
self.store.log_event("agent_init", {"policy": self.policy.to_dict()})
|
|
566
|
+
|
|
567
|
+
# ------------------------------------------------------------------
|
|
568
|
+
# meta tools — the agent's handle on itself
|
|
569
|
+
# ------------------------------------------------------------------
|
|
570
|
+
def _register_meta_tools(self) -> None:
|
|
571
|
+
self._tool_forge()
|
|
572
|
+
self._tool_evolve()
|
|
573
|
+
self._tool_spawn()
|
|
574
|
+
self._tool_design_team()
|
|
575
|
+
self._tool_amend()
|
|
576
|
+
self._tool_autonomy()
|
|
577
|
+
self._tool_list()
|
|
578
|
+
self._tool_evaluate()
|
|
579
|
+
self._tool_gaps()
|
|
580
|
+
self._tool_retire()
|
|
581
|
+
self._tool_capabilities()
|
|
582
|
+
self._tool_history()
|
|
583
|
+
self._tool_memory()
|
|
584
|
+
self._tool_skills()
|
|
585
|
+
self._tool_mcp()
|
|
586
|
+
self._tool_gpu()
|
|
587
|
+
self._tool_cpu()
|
|
588
|
+
self._tool_notify()
|
|
589
|
+
self._tool_schedule()
|
|
590
|
+
self._tool_browser()
|
|
591
|
+
self._tool_vision()
|
|
592
|
+
|
|
593
|
+
def _add(self, spec: ToolSpec) -> None:
|
|
594
|
+
# Say what this tool touches, so a switched-off freedom has something to
|
|
595
|
+
# key off. See BUILTIN_SCOPES; an explicit declaration on the spec wins.
|
|
596
|
+
if not spec.effect_signature:
|
|
597
|
+
spec.effect_signature = BUILTIN_SCOPES.get(spec.name, "")
|
|
598
|
+
self.registry.register(spec)
|
|
599
|
+
self.registry.promote(spec.name)
|
|
600
|
+
|
|
601
|
+
# ------------------------------------------------------------------
|
|
602
|
+
# frozen baselines — the exam the mutants do not write
|
|
603
|
+
# ------------------------------------------------------------------
|
|
604
|
+
def _baseline_for(self, spec: ToolSpec) -> FrozenBaseline:
|
|
605
|
+
"""The tool's pinned obligation set, created on first evolution.
|
|
606
|
+
|
|
607
|
+
Loaded from the store so the ratchet survives process restarts; a
|
|
608
|
+
baseline that only lives in RAM resets to "whatever the current version
|
|
609
|
+
is", which is exactly the erosion this guards against.
|
|
610
|
+
"""
|
|
611
|
+
stored = self.store.load_baseline(spec.name) if self.store else None
|
|
612
|
+
return FrozenBaseline.from_dict(stored) if stored else FrozenBaseline.capture(spec)
|
|
613
|
+
|
|
614
|
+
def _freeze_baseline(self, baseline: FrozenBaseline) -> None:
|
|
615
|
+
if self.store:
|
|
616
|
+
self.store.save_baseline(baseline)
|
|
617
|
+
|
|
618
|
+
def _tool_forge(self) -> None:
|
|
619
|
+
def forge_tool(need: str) -> str:
|
|
620
|
+
if not self.policy.may_forge_tools:
|
|
621
|
+
return "Denied by autonomy policy: may_forge_tools is off."
|
|
622
|
+
res = self.pipeline.forge(need)
|
|
623
|
+
self._record("forge", {"need": need, "ok": res.ok})
|
|
624
|
+
if not res.ok:
|
|
625
|
+
return f"Could not forge a working tool for: {need} ({res.rounds} rounds)."
|
|
626
|
+
# Persist, or the tool dies with the process: the agent would then
|
|
627
|
+
# "remember" nothing it made and re-forge it every session. Mirrors
|
|
628
|
+
# what evolve_tool already does for the tools it replaces.
|
|
629
|
+
if self.store:
|
|
630
|
+
self.store.save_tool(res.spec)
|
|
631
|
+
return f"Forged {res.spec.name!r} [{res.spec.state.value}]: {res.spec.description}"
|
|
632
|
+
|
|
633
|
+
self._add(ToolSpec(
|
|
634
|
+
name="forge_tool",
|
|
635
|
+
description=(
|
|
636
|
+
"Forge a new tool for a recurring need you cannot currently "
|
|
637
|
+
"serve. The Python you write runs as a real subprocess on THIS "
|
|
638
|
+
"host — full filesystem and outbound network — so this is your "
|
|
639
|
+
"file and shell access. There is no separate read_file or "
|
|
640
|
+
"run_shell tool because forge_tool is that access: to read a "
|
|
641
|
+
"file or run a command, forge the tool."
|
|
642
|
+
),
|
|
643
|
+
parameters={"type": "object", "properties": {
|
|
644
|
+
"need": {"type": "string", "description": "one-line description of the need"},
|
|
645
|
+
}, "required": ["need"]},
|
|
646
|
+
fn=forge_tool, source="builtin", tags=["meta"],
|
|
647
|
+
))
|
|
648
|
+
|
|
649
|
+
def _tool_evolve(self) -> None:
|
|
650
|
+
def evolve_tool(name: str, failure_report: str = "") -> str:
|
|
651
|
+
if not self.policy.may_forge_tools:
|
|
652
|
+
return "Denied by autonomy policy: may_forge_tools is off."
|
|
653
|
+
spec = self.registry.get(name)
|
|
654
|
+
if spec is None:
|
|
655
|
+
return f"No tool named {name!r}."
|
|
656
|
+
if self.evolution is None:
|
|
657
|
+
return "Evolution is disabled."
|
|
658
|
+
report = failure_report or spec.verification.get("failed_detail", "underperforming")
|
|
659
|
+
baseline = self._baseline_for(spec)
|
|
660
|
+
result = self.evolution.evolve(spec, report, baseline=baseline, context="internal")
|
|
661
|
+
self._record("evolve", {
|
|
662
|
+
"tool": name, "improved": result.improved,
|
|
663
|
+
"vetoed": [m.generated.name for m in result.mutants if not m.admissible],
|
|
664
|
+
})
|
|
665
|
+
if not result.improved:
|
|
666
|
+
vetoed = sum(1 for m in result.mutants if not m.admissible)
|
|
667
|
+
tail = f" {vetoed} vetoed by the validity gate." if vetoed else ""
|
|
668
|
+
return (f"Evolved {name!r}: no admissible mutant beat the original "
|
|
669
|
+
f"({len(result.mutants)} tried).{tail}")
|
|
670
|
+
best = result.best_mutant.generated
|
|
671
|
+
new_spec = ToolSpec(
|
|
672
|
+
name=best.name, description=best.description,
|
|
673
|
+
parameters=best.parameters, fn=spec.fn, runner=spec.runner,
|
|
674
|
+
code=best.code, source="evolved", probes=best.probes,
|
|
675
|
+
effect_signature=best.effect_signature, tags=best.tags,
|
|
676
|
+
state=ToolState.ACTIVE,
|
|
677
|
+
)
|
|
678
|
+
self.registry.register(new_spec, replace=True)
|
|
679
|
+
# The baseline ratchets forward: probes the winner added become
|
|
680
|
+
# permanent obligations, and nothing already in it can be dropped.
|
|
681
|
+
self._freeze_baseline(baseline.extended_with(new_spec))
|
|
682
|
+
if self.store:
|
|
683
|
+
self.store.archive_version(name, spec.code, spec.verification)
|
|
684
|
+
self.store.save_tool(new_spec)
|
|
685
|
+
return f"Evolved {name!r}: new version active (fitness {result.best_mutant.fitness:.2f})."
|
|
686
|
+
|
|
687
|
+
self._add(ToolSpec(
|
|
688
|
+
name="evolve_tool",
|
|
689
|
+
description="Breed a better version of a tool that underperforms or fails.",
|
|
690
|
+
parameters={"type": "object", "properties": {
|
|
691
|
+
"name": {"type": "string"},
|
|
692
|
+
"failure_report": {"type": "string", "description": "what went wrong (optional)"},
|
|
693
|
+
}, "required": ["name"]},
|
|
694
|
+
fn=evolve_tool, source="builtin", tags=["meta"],
|
|
695
|
+
))
|
|
696
|
+
|
|
697
|
+
def _tool_spawn(self) -> None:
|
|
698
|
+
def spawn_agent(task: str, isolated: bool = False, role: str = "") -> str:
|
|
699
|
+
if not self.policy.may_spawn_agents:
|
|
700
|
+
return "Denied by autonomy policy: may_spawn_agents is off."
|
|
701
|
+
mode = ShareMode.ISOLATED if isolated else ShareMode.SHARED
|
|
702
|
+
|
|
703
|
+
# A role name resolves against the topology the agent designed, so
|
|
704
|
+
# the team it drew is the team it gets to run. Refusing an unknown
|
|
705
|
+
# role beats silently ignoring it: a whitelist that quietly does
|
|
706
|
+
# nothing is the failure this replaced.
|
|
707
|
+
#
|
|
708
|
+
# Resolution falls back to the role vocabulary itself, because a role
|
|
709
|
+
# is a thing this framework knows how to be, not only a thing the
|
|
710
|
+
# designer happened to draw. Without that fallback CRITIC, GATE and
|
|
711
|
+
# FORGE would be unreachable until `design_team` had run and guessed
|
|
712
|
+
# to include them — the designer deciding whether the runtime has
|
|
713
|
+
# critics, which is backwards.
|
|
714
|
+
allowed: list[str] = []
|
|
715
|
+
brief = ""
|
|
716
|
+
role_name = role
|
|
717
|
+
if role:
|
|
718
|
+
nodes = list(getattr(self.topology, "nodes", []) or [])
|
|
719
|
+
node = next((n for n in nodes if n.id == role), None)
|
|
720
|
+
if node is None:
|
|
721
|
+
node = next((n for n in nodes
|
|
722
|
+
if str(getattr(n.role, "value", n.role)) == role), None)
|
|
723
|
+
if node is not None:
|
|
724
|
+
allowed = list(node.tools_whitelist or [])
|
|
725
|
+
role_name = str(getattr(node.role, "value", node.role))
|
|
726
|
+
# The node's brief is the role's plus the designer's own
|
|
727
|
+
# hint. The ceiling is not passed: Spawner derives it from
|
|
728
|
+
# the role, so it holds for every caller of `spawn`, not just
|
|
729
|
+
# this one.
|
|
730
|
+
brief = brief_for_node(node)
|
|
731
|
+
else:
|
|
732
|
+
try:
|
|
733
|
+
known = RoleType(role)
|
|
734
|
+
except ValueError:
|
|
735
|
+
have = ", ".join(n.id for n in nodes) or "none"
|
|
736
|
+
roles = ", ".join(r.value for r in RoleType)
|
|
737
|
+
return (
|
|
738
|
+
f"No node named {role!r} in the current topology "
|
|
739
|
+
f"(have: {have}), and it is not a role either "
|
|
740
|
+
f"(known roles: {roles}). Call design_team first, or "
|
|
741
|
+
f"spawn without a role to leave the child unrestricted."
|
|
742
|
+
)
|
|
743
|
+
role_name = known.value
|
|
744
|
+
brief = role_brief(known)
|
|
745
|
+
|
|
746
|
+
rec = self.spawner.spawn(task, mode=mode, restrict_to=allowed,
|
|
747
|
+
role=role_name, brief=brief)
|
|
748
|
+
self._record("spawn", rec.to_dict())
|
|
749
|
+
if rec.error:
|
|
750
|
+
return f"Child failed: {rec.error}"
|
|
751
|
+
|
|
752
|
+
bits = [f"Child {rec.child_id} finished in {rec.finished - rec.started:.1f}s."]
|
|
753
|
+
if role:
|
|
754
|
+
scope = ", ".join(allowed) if allowed else "unrestricted by name"
|
|
755
|
+
bits.append(f"Ran as '{role_name}', scoped to: {scope}.")
|
|
756
|
+
if rec.refusals:
|
|
757
|
+
reached = ", ".join(sorted(set(rec.refusals)))
|
|
758
|
+
bits.append(f"Reached past its role and was refused: {reached}.")
|
|
759
|
+
if rec.tools_forged:
|
|
760
|
+
bits.append(f"tools_forged={rec.tools_forged}.")
|
|
761
|
+
bits.append(rec.result[:500])
|
|
762
|
+
return " ".join(bits)
|
|
763
|
+
|
|
764
|
+
self._add(ToolSpec(
|
|
765
|
+
name="spawn_agent",
|
|
766
|
+
description=(
|
|
767
|
+
"Create a child agent to handle a subtask independently. Name a "
|
|
768
|
+
"`role` to run it as one of the nodes you designed: it can then "
|
|
769
|
+
"see and call only that node's whitelisted tools."
|
|
770
|
+
),
|
|
771
|
+
parameters={"type": "object", "properties": {
|
|
772
|
+
"task": {"type": "string"},
|
|
773
|
+
"isolated": {"type": "boolean", "description": "give it its own tool library"},
|
|
774
|
+
"role": {"type": "string",
|
|
775
|
+
"description": "a node id (or role name) from the current "
|
|
776
|
+
"topology; restricts the child to that node's "
|
|
777
|
+
"tools_whitelist (omit for unrestricted)"},
|
|
778
|
+
}, "required": ["task"]},
|
|
779
|
+
fn=spawn_agent, source="builtin", tags=["meta"],
|
|
780
|
+
))
|
|
781
|
+
|
|
782
|
+
def _tool_design_team(self) -> None:
|
|
783
|
+
def design_team(task: str, max_agents: int = 4, failure_report: str = "") -> str:
|
|
784
|
+
if not self.policy.may_design_topology:
|
|
785
|
+
return "Denied by autonomy policy: may_design_topology is off."
|
|
786
|
+
try:
|
|
787
|
+
budget = max(1, int(max_agents))
|
|
788
|
+
except (TypeError, ValueError):
|
|
789
|
+
return f"max_agents must be an integer, got {max_agents!r}"
|
|
790
|
+
|
|
791
|
+
previous = self.topology
|
|
792
|
+
# A pristine default (one bare node, no edges, no rationale) is not
|
|
793
|
+
# a real design — seed from scratch rather than "mutating" nothing.
|
|
794
|
+
seed = previous if previous.edges or previous.rationale else None
|
|
795
|
+
|
|
796
|
+
designed = self.topology_designer.design(
|
|
797
|
+
task,
|
|
798
|
+
current_topology=seed,
|
|
799
|
+
failure_report=failure_report,
|
|
800
|
+
max_agents=budget,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
degraded = len(designed.nodes) == 1 and designed.nodes[0].id == "main"
|
|
804
|
+
self.topology = designed
|
|
805
|
+
self._record("design_team", {
|
|
806
|
+
"task": task[:200],
|
|
807
|
+
"agents": len(designed.nodes),
|
|
808
|
+
"edges": len(designed.edges),
|
|
809
|
+
"degraded": degraded,
|
|
810
|
+
})
|
|
811
|
+
if self.store:
|
|
812
|
+
self.store.log_event("topology", designed.to_dict())
|
|
813
|
+
self.store.save_topology(designed, task)
|
|
814
|
+
|
|
815
|
+
if degraded:
|
|
816
|
+
return ("Team design could not be parsed; kept a single-agent "
|
|
817
|
+
"topology so the task can still run.")
|
|
818
|
+
|
|
819
|
+
roles = ", ".join(f"{n.id}:{n.role.value}" for n in designed.nodes)
|
|
820
|
+
overshoot = f" (over the {budget}-agent budget)" if len(designed.nodes) > budget else ""
|
|
821
|
+
channels = ", ".join(
|
|
822
|
+
f"{e.source}->{e.target}({e.channel})" for e in designed.edges
|
|
823
|
+
) or "none"
|
|
824
|
+
out = [
|
|
825
|
+
f"Designed a {len(designed.nodes)}-agent team{overshoot}: {roles}",
|
|
826
|
+
f" channels: {channels}",
|
|
827
|
+
]
|
|
828
|
+
if designed.rationale:
|
|
829
|
+
out.append(f" rationale: {designed.rationale[:200]}")
|
|
830
|
+
return "\n".join(out)
|
|
831
|
+
|
|
832
|
+
self._add(ToolSpec(
|
|
833
|
+
name="design_team",
|
|
834
|
+
description=(
|
|
835
|
+
"Design the multi-agent topology (roles, channels, team size) that "
|
|
836
|
+
"fits a task, instead of running everything in one loop."
|
|
837
|
+
),
|
|
838
|
+
parameters={"type": "object", "properties": {
|
|
839
|
+
"task": {"type": "string", "description": "the task the team must handle"},
|
|
840
|
+
"max_agents": {"type": "integer",
|
|
841
|
+
"description": "team-size budget (default 4)"},
|
|
842
|
+
"failure_report": {"type": "string",
|
|
843
|
+
"description": "why the current team failed (optional)"},
|
|
844
|
+
}, "required": ["task"]},
|
|
845
|
+
fn=design_team, source="builtin", tags=["meta"],
|
|
846
|
+
))
|
|
847
|
+
|
|
848
|
+
def _tool_amend(self) -> None:
|
|
849
|
+
def amend_self(target: str, new_value: Any, rationale: str) -> str:
|
|
850
|
+
# Each target names the ONE freedom that governs it. Reading the
|
|
851
|
+
# gate off a single global flag is how a policy field ends up
|
|
852
|
+
# obeying the wrong switch: forge config used to be checked
|
|
853
|
+
# against may_modify_own_prompt, so turning off
|
|
854
|
+
# may_modify_forge_config alone left the door open.
|
|
855
|
+
gate = _AMEND_TARGETS.get(target)
|
|
856
|
+
if gate is None:
|
|
857
|
+
known = ", ".join(sorted(_AMEND_TARGETS))
|
|
858
|
+
return f"Unknown target {target!r}. Known targets: {known}."
|
|
859
|
+
|
|
860
|
+
freedom = gate["freedom"]
|
|
861
|
+
if not getattr(self.policy, freedom):
|
|
862
|
+
return f"Denied by autonomy policy: {freedom} is off."
|
|
863
|
+
|
|
864
|
+
if target == "forge_max_rounds":
|
|
865
|
+
try:
|
|
866
|
+
val = int(new_value)
|
|
867
|
+
except (TypeError, ValueError):
|
|
868
|
+
return f"forge_max_rounds must be an integer, got {new_value!r}"
|
|
869
|
+
if val < 1:
|
|
870
|
+
return "forge_max_rounds must be >= 1."
|
|
871
|
+
if not self.policy.unbounded_forge_rounds and val > FORGE_ROUND_CEILING:
|
|
872
|
+
return (
|
|
873
|
+
f"Denied by autonomy policy: unbounded_forge_rounds is off, "
|
|
874
|
+
f"so max_rounds is capped at {FORGE_ROUND_CEILING} (asked for {val})."
|
|
875
|
+
)
|
|
876
|
+
a = self.selfmod.amend(
|
|
877
|
+
self, "forge_max_rounds", val, rationale,
|
|
878
|
+
attr="max_rounds", nested=("forge_config",),
|
|
879
|
+
)
|
|
880
|
+
if a.accepted:
|
|
881
|
+
self.pipeline.config.max_rounds = val
|
|
882
|
+
|
|
883
|
+
elif target == "system_prompt":
|
|
884
|
+
a = self.selfmod.amend(self, "system_prompt", str(new_value), rationale)
|
|
885
|
+
|
|
886
|
+
elif target == "routing_weights":
|
|
887
|
+
parsed, err = _coerce_weights(new_value, self.router.weights)
|
|
888
|
+
if err:
|
|
889
|
+
return err
|
|
890
|
+
a = self.selfmod.amend(
|
|
891
|
+
self, "routing_weights", parsed, rationale,
|
|
892
|
+
attr="weights", nested=("router",),
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
else: # pragma: no cover - guard
|
|
896
|
+
return f"Unknown target {target!r}."
|
|
897
|
+
|
|
898
|
+
self._sync_amendment(a)
|
|
899
|
+
if not a.accepted:
|
|
900
|
+
return f"Amendment rejected: {a.rejected_reason}"
|
|
901
|
+
return f"Amended {target}: {a.diff_summary()}"
|
|
902
|
+
|
|
903
|
+
self._add(ToolSpec(
|
|
904
|
+
name="amend_self",
|
|
905
|
+
description=(
|
|
906
|
+
"Change your own system prompt, forge config, or routing weights. "
|
|
907
|
+
"Requires a rationale. Each target is governed by one freedom in "
|
|
908
|
+
"the autonomy policy."
|
|
909
|
+
),
|
|
910
|
+
parameters={"type": "object", "properties": {
|
|
911
|
+
"target": {
|
|
912
|
+
"type": "string",
|
|
913
|
+
"description": "system_prompt | forge_max_rounds | routing_weights",
|
|
914
|
+
},
|
|
915
|
+
"new_value": {
|
|
916
|
+
"type": "string",
|
|
917
|
+
"description": (
|
|
918
|
+
"the new value; routing_weights takes a JSON object such as "
|
|
919
|
+
'{"success": 1.5, "text": 0.8}'
|
|
920
|
+
),
|
|
921
|
+
},
|
|
922
|
+
"rationale": {"type": "string", "description": "WHY you are changing yourself"},
|
|
923
|
+
}, "required": ["target", "new_value", "rationale"]},
|
|
924
|
+
fn=amend_self, source="builtin", tags=["meta"],
|
|
925
|
+
))
|
|
926
|
+
|
|
927
|
+
def _tool_autonomy(self) -> None:
|
|
928
|
+
def set_autonomy(freedom: str, enabled: bool, rationale: str = "") -> str:
|
|
929
|
+
if not hasattr(self.policy, freedom):
|
|
930
|
+
return f"No such freedom {freedom!r}."
|
|
931
|
+
a = self.selfmod.amend(
|
|
932
|
+
self, f"policy.{freedom}", enabled, rationale or "policy change",
|
|
933
|
+
attr=freedom, nested=("policy",),
|
|
934
|
+
)
|
|
935
|
+
self._sync_amendment(a)
|
|
936
|
+
if not a.accepted:
|
|
937
|
+
return f"Rejected: {a.rejected_reason}"
|
|
938
|
+
|
|
939
|
+
# Flipping unlimited_turns back on has to actually release the
|
|
940
|
+
# ceiling __post_init__ installed, or "off" would be a one-way
|
|
941
|
+
# latch. Flipping it off re-installs it.
|
|
942
|
+
if freedom == "unlimited_turns":
|
|
943
|
+
if not enabled:
|
|
944
|
+
self.max_turns = SUPERVISED_TURN_CAP
|
|
945
|
+
elif self.max_turns == SUPERVISED_TURN_CAP:
|
|
946
|
+
self.max_turns = None
|
|
947
|
+
|
|
948
|
+
note = ""
|
|
949
|
+
if freedom in self.policy.unenforced:
|
|
950
|
+
note = (
|
|
951
|
+
f" WARNING: {freedom} is not enforced anywhere — the sandbox "
|
|
952
|
+
"does not consult the policy (DESIGN.md §2.5). This change "
|
|
953
|
+
"changes what I claim, not what I can reach."
|
|
954
|
+
)
|
|
955
|
+
elif (freedom in self.policy.CONFIRM_REQUIRED
|
|
956
|
+
and not getattr(self.policy, freedom, True)):
|
|
957
|
+
note = (
|
|
958
|
+
f" This one is enforced as a question: {freedom} is off, so "
|
|
959
|
+
"any tool whose declared scope needs it stops and asks before "
|
|
960
|
+
"running, and does not run when there is nobody to ask. "
|
|
961
|
+
"Watch it close at the next tool call."
|
|
962
|
+
)
|
|
963
|
+
return (
|
|
964
|
+
f"{freedom} = {enabled}. Denied now: {self.policy.denied or 'nothing'}"
|
|
965
|
+
f"{note}"
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
self._add(ToolSpec(
|
|
969
|
+
name="set_autonomy",
|
|
970
|
+
description="Change your own permissions (any field of the autonomy policy).",
|
|
971
|
+
parameters={"type": "object", "properties": {
|
|
972
|
+
"freedom": {"type": "string", "description": "policy field name"},
|
|
973
|
+
"enabled": {"type": "boolean"},
|
|
974
|
+
"rationale": {"type": "string"},
|
|
975
|
+
}, "required": ["freedom", "enabled"]},
|
|
976
|
+
fn=set_autonomy, source="builtin", tags=["meta"],
|
|
977
|
+
))
|
|
978
|
+
|
|
979
|
+
def _tool_list(self) -> None:
|
|
980
|
+
def list_tools() -> str:
|
|
981
|
+
rep = self.registry.report()
|
|
982
|
+
if not rep["tools"]:
|
|
983
|
+
return "No tools yet."
|
|
984
|
+
lines = [
|
|
985
|
+
f" {s['name']} [{s['state']}] sr={s['stats']['success_rate']} "
|
|
986
|
+
f"calls={s['stats']['calls']} — {s['description'][:60]}"
|
|
987
|
+
for s in rep["tools"]
|
|
988
|
+
]
|
|
989
|
+
return f"Tools ({rep['total']}, states {rep['by_state']}):\n" + "\n".join(lines)
|
|
990
|
+
|
|
991
|
+
self._add(ToolSpec(
|
|
992
|
+
name="list_tools",
|
|
993
|
+
description="List your tools with their lifecycle state and health.",
|
|
994
|
+
parameters={"type": "object", "properties": {}},
|
|
995
|
+
fn=list_tools, source="builtin", tags=["meta"],
|
|
996
|
+
))
|
|
997
|
+
|
|
998
|
+
def _tool_evaluate(self) -> None:
|
|
999
|
+
def evaluate_tool(name: str) -> str:
|
|
1000
|
+
spec = self.registry.get(name)
|
|
1001
|
+
if spec is None:
|
|
1002
|
+
return f"No tool named {name!r}."
|
|
1003
|
+
report = self.verifier.verify(spec)
|
|
1004
|
+
self._record("evaluate", {"tool": name, "passed": report.passed})
|
|
1005
|
+
lines = [f" [{'PASS' if c.passed else 'FAIL'}] {c.name}: {c.detail}"
|
|
1006
|
+
for c in report.checks]
|
|
1007
|
+
return f"{report.summary()}\n" + "\n".join(lines)
|
|
1008
|
+
|
|
1009
|
+
self._add(ToolSpec(
|
|
1010
|
+
name="evaluate_tool",
|
|
1011
|
+
description="Run the full verification battery (execution, robustness, adversarial, trigger, negative) on a tool.",
|
|
1012
|
+
parameters={"type": "object", "properties": {"name": {"type": "string"}},
|
|
1013
|
+
"required": ["name"]},
|
|
1014
|
+
fn=evaluate_tool, source="builtin", tags=["meta"],
|
|
1015
|
+
))
|
|
1016
|
+
|
|
1017
|
+
def _tool_gaps(self) -> None:
|
|
1018
|
+
def find_gaps() -> str:
|
|
1019
|
+
if self.metacog is None:
|
|
1020
|
+
return "Meta-cognition is disabled. Enable with enable_meta_cognition=True."
|
|
1021
|
+
rep = self.metacog.analyse()
|
|
1022
|
+
if not rep.proposals:
|
|
1023
|
+
return "No capability gaps identified right now."
|
|
1024
|
+
lines = [f" - {p.need} (forged={rep.forged})" for p in rep.proposals]
|
|
1025
|
+
return f"Found {rep.gaps_found} gaps; forged {rep.forged}:\n" + "\n".join(lines)
|
|
1026
|
+
|
|
1027
|
+
self._add(ToolSpec(
|
|
1028
|
+
name="find_gaps",
|
|
1029
|
+
description="Proactively analyse your library and fill missing capabilities.",
|
|
1030
|
+
parameters={"type": "object", "properties": {}},
|
|
1031
|
+
fn=find_gaps, source="builtin", tags=["meta"],
|
|
1032
|
+
))
|
|
1033
|
+
|
|
1034
|
+
def _tool_retire(self) -> None:
|
|
1035
|
+
def retire_tool(name: str, rationale: str) -> str:
|
|
1036
|
+
"""Deliberately take a tool out of service.
|
|
1037
|
+
|
|
1038
|
+
Distinct from quarantine, which is the automatic safety valve and
|
|
1039
|
+
stays on regardless of policy. Retiring is a judgement call, so it
|
|
1040
|
+
is the one gated by may_retire_tools.
|
|
1041
|
+
"""
|
|
1042
|
+
if not self.policy.may_retire_tools:
|
|
1043
|
+
return "Denied by autonomy policy: may_retire_tools is off."
|
|
1044
|
+
if not rationale or not rationale.strip():
|
|
1045
|
+
return "A rationale is required to retire a tool."
|
|
1046
|
+
spec = self.registry.get(name)
|
|
1047
|
+
if spec is None:
|
|
1048
|
+
return f"No tool named {name!r}."
|
|
1049
|
+
if spec.source == "builtin":
|
|
1050
|
+
return (
|
|
1051
|
+
f"Refusing to retire {name!r}: it is a builtin meta-tool. "
|
|
1052
|
+
"Disable it with set_autonomy instead."
|
|
1053
|
+
)
|
|
1054
|
+
if spec.state == ToolState.RETIRED:
|
|
1055
|
+
return f"{name!r} is already retired."
|
|
1056
|
+
self.registry.retire(name)
|
|
1057
|
+
self._record("retire", {"tool": name, "rationale": rationale})
|
|
1058
|
+
return f"Retired {name!r}. Rationale recorded: {rationale}"
|
|
1059
|
+
|
|
1060
|
+
self._add(ToolSpec(
|
|
1061
|
+
name="retire_tool",
|
|
1062
|
+
description=(
|
|
1063
|
+
"Retire a tool you judge harmful or superseded. Requires a "
|
|
1064
|
+
"rationale. Builtin meta-tools cannot be retired, only disabled."
|
|
1065
|
+
),
|
|
1066
|
+
parameters={"type": "object", "properties": {
|
|
1067
|
+
"name": {"type": "string"},
|
|
1068
|
+
"rationale": {"type": "string", "description": "WHY it should go"},
|
|
1069
|
+
}, "required": ["name", "rationale"]},
|
|
1070
|
+
fn=retire_tool, source="builtin", tags=["meta"],
|
|
1071
|
+
))
|
|
1072
|
+
|
|
1073
|
+
def _tool_capabilities(self) -> None:
|
|
1074
|
+
def my_capabilities(probe: bool = False) -> str:
|
|
1075
|
+
"""Report what this agent can actually reach — not what it declares.
|
|
1076
|
+
|
|
1077
|
+
The reach half comes from `sandbox.reach()`, so it describes the
|
|
1078
|
+
object that actually runs the code rather than a paragraph that can
|
|
1079
|
+
drift away from it. An agent that reasons about its reach from its
|
|
1080
|
+
tool list gets it wrong; this asks the thing that knows.
|
|
1081
|
+
|
|
1082
|
+
The policy half separates freedoms that are enforced from freedoms
|
|
1083
|
+
that are only declared (DESIGN.md §2.5) — saying which is which
|
|
1084
|
+
beats a policy that reads like a cage and behaves like a comment.
|
|
1085
|
+
"""
|
|
1086
|
+
if not self.policy.expose_policy_to_self:
|
|
1087
|
+
return "Denied by autonomy policy: expose_policy_to_self is off."
|
|
1088
|
+
|
|
1089
|
+
rows = self.policy.enforcement_table()
|
|
1090
|
+
enforced = [r for r in rows if r["enforced"] == "enforced"]
|
|
1091
|
+
partial = [r for r in rows if r["enforced"] == "partial"]
|
|
1092
|
+
confirm = [r for r in rows if r["enforced"] == "confirm"]
|
|
1093
|
+
inert = [r for r in rows if r["enforced"] == "declared-only"]
|
|
1094
|
+
|
|
1095
|
+
reach = self.sandbox.reach(probe=probe)
|
|
1096
|
+
lines = [
|
|
1097
|
+
"What I can actually do:",
|
|
1098
|
+
"",
|
|
1099
|
+
f" Reach: forge_tool runs generated Python on {reach['host']}.",
|
|
1100
|
+
f" filesystem — {reach['filesystem']}",
|
|
1101
|
+
f" network — {reach['network']}",
|
|
1102
|
+
f" bounds — {reach['cwd']}; {reach['env']}; "
|
|
1103
|
+
f"{reach['timeout_s']}s timeout",
|
|
1104
|
+
" The autonomy policy does NOT gate this, by design, so",
|
|
1105
|
+
" capability is never capped. A forged tool is shell",
|
|
1106
|
+
" access with a timeout: to read a file or run a command,",
|
|
1107
|
+
" forge the tool. 'I have no file tools' is false.",
|
|
1108
|
+
]
|
|
1109
|
+
probe_result = reach.get("probe")
|
|
1110
|
+
if isinstance(probe_result, dict):
|
|
1111
|
+
if probe_result.get("error"):
|
|
1112
|
+
lines.append(
|
|
1113
|
+
f" Live self-test: did not run ({probe_result['error']})")
|
|
1114
|
+
else:
|
|
1115
|
+
fs = probe_result.get("host_filesystem", {})
|
|
1116
|
+
net = probe_result.get("network", {})
|
|
1117
|
+
lines += [
|
|
1118
|
+
" Live self-test (measured just now):",
|
|
1119
|
+
f" - host filesystem: {'OK' if fs.get('ok') else 'FAILED'}"
|
|
1120
|
+
f" — {fs.get('detail', '')}",
|
|
1121
|
+
f" - network: {'OK' if net.get('ok') else 'FAILED'}"
|
|
1122
|
+
f" — {net.get('detail', '')}",
|
|
1123
|
+
]
|
|
1124
|
+
if self.policy.may_spawn_agents:
|
|
1125
|
+
lines.append(" Children: I can spawn agents that inherit this policy.")
|
|
1126
|
+
lines += self._gpu_reach_lines()
|
|
1127
|
+
if self.store is not None:
|
|
1128
|
+
s = self.store.report()
|
|
1129
|
+
lines.append(
|
|
1130
|
+
f" Memory: sqlite at {s['db_path']} — {s['tools']} tool(s), "
|
|
1131
|
+
f"{s['events']} ledger event(s), survives restart. "
|
|
1132
|
+
"my_history reads it back."
|
|
1133
|
+
)
|
|
1134
|
+
lines += ["", f"Policy: {self.policy.describe()}", ""]
|
|
1135
|
+
|
|
1136
|
+
# Headers only when they have rows. A section title with nothing
|
|
1137
|
+
# under it reads as a limit that isn't there — the exact failure
|
|
1138
|
+
# this report exists to prevent.
|
|
1139
|
+
for title, table, with_note in (
|
|
1140
|
+
("Switched off, and actually enforced:", enforced, False),
|
|
1141
|
+
("Switched off, enforced only in places:", partial, True),
|
|
1142
|
+
("Switched off, runs only if you say yes to it:",
|
|
1143
|
+
confirm, False),
|
|
1144
|
+
("Switched off, but nothing obeys it (do not rely on these):",
|
|
1145
|
+
inert, False),
|
|
1146
|
+
):
|
|
1147
|
+
off = [r for r in table if not r["enabled"]]
|
|
1148
|
+
if not off:
|
|
1149
|
+
continue
|
|
1150
|
+
lines.append(title)
|
|
1151
|
+
lines += [
|
|
1152
|
+
f" - {r['freedom']}" + (f": {r['note']}" if with_note else "")
|
|
1153
|
+
for r in off
|
|
1154
|
+
]
|
|
1155
|
+
|
|
1156
|
+
if not self.policy.unenforced:
|
|
1157
|
+
lines.append("Every 'off' in this policy is a gate you can watch close.")
|
|
1158
|
+
return "\n".join(lines)
|
|
1159
|
+
|
|
1160
|
+
self._add(ToolSpec(
|
|
1161
|
+
name="my_capabilities",
|
|
1162
|
+
description=(
|
|
1163
|
+
"Report your real reach: which freedoms are enforced, which are "
|
|
1164
|
+
"declared only, and what the sandbox lets forged code touch. "
|
|
1165
|
+
"Forged code runs on THIS host with a real filesystem and "
|
|
1166
|
+
"network — pass probe=true to prove it with a live round-trip "
|
|
1167
|
+
"instead of taking it on faith."
|
|
1168
|
+
),
|
|
1169
|
+
parameters={"type": "object", "properties": {
|
|
1170
|
+
"probe": {"type": "boolean", "description":
|
|
1171
|
+
"run a live write/read + DNS round-trip to measure reach"},
|
|
1172
|
+
}},
|
|
1173
|
+
fn=my_capabilities, source="builtin", tags=["meta"],
|
|
1174
|
+
))
|
|
1175
|
+
|
|
1176
|
+
# ------------------------------------------------------------------
|
|
1177
|
+
def _selfmod_lines(self) -> list[str]:
|
|
1178
|
+
"""Self-modifications as one-liners, from the modifier's own log."""
|
|
1179
|
+
return [
|
|
1180
|
+
f" {a.timestamp and time.strftime('%m-%d %H:%M', time.localtime(a.timestamp))}"
|
|
1181
|
+
f" {a.target}: {'accepted' if a.accepted else 'rejected'}"
|
|
1182
|
+
f"{'' if a.accepted else f' ({a.rejected_reason})'}"
|
|
1183
|
+
f" — {a.rationale[:100] or '(no rationale)'}"
|
|
1184
|
+
for a in (self.selfmod.log() if self.selfmod else [])
|
|
1185
|
+
]
|
|
1186
|
+
|
|
1187
|
+
def _tool_history(self) -> None:
|
|
1188
|
+
def my_history(limit: int = 20) -> str:
|
|
1189
|
+
"""What I have done and how I have changed myself — from the ledger.
|
|
1190
|
+
|
|
1191
|
+
The ledger is append-only and on disk, so this is memory rather
|
|
1192
|
+
than recollection. An agent that claims it keeps no record is
|
|
1193
|
+
wrong; one that guesses at its own past is worse.
|
|
1194
|
+
"""
|
|
1195
|
+
n = max(1, min(int(limit), 200))
|
|
1196
|
+
mods = self._selfmod_lines()
|
|
1197
|
+
if self.store is None:
|
|
1198
|
+
return ("No store attached this session — nothing is being "
|
|
1199
|
+
"recorded. Self-changes so far, this process only:\n"
|
|
1200
|
+
+ "\n".join(mods or [" (none)"]))
|
|
1201
|
+
events = self.store.get_events(limit=n)
|
|
1202
|
+
lines = [f"Ledger: last {len(events)} of "
|
|
1203
|
+
f"{self.store.report()['events']} event(s), newest last.", ""]
|
|
1204
|
+
for e in events:
|
|
1205
|
+
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(e["timestamp"]))
|
|
1206
|
+
body = {k: v for k, v in e.items()
|
|
1207
|
+
if k not in ("id", "timestamp", "kind")}
|
|
1208
|
+
lines.append(f" {ts} {e['kind']:<12} "
|
|
1209
|
+
f"{json.dumps(body, ensure_ascii=False, default=str)[:150]}")
|
|
1210
|
+
lines += ["", "Self-modifications (newest last):"]
|
|
1211
|
+
lines += mods or [" (none)"]
|
|
1212
|
+
return "\n".join(lines)
|
|
1213
|
+
|
|
1214
|
+
self._add(ToolSpec(
|
|
1215
|
+
name="my_history",
|
|
1216
|
+
description=(
|
|
1217
|
+
"Read your own past from the on-disk ledger: every forge, "
|
|
1218
|
+
"amendment, spawn and run, plus each self-modification with its "
|
|
1219
|
+
"rationale. Answers 'what have I done?' and 'who changed me?' "
|
|
1220
|
+
"with records, not recollection."
|
|
1221
|
+
),
|
|
1222
|
+
parameters={"type": "object", "properties": {
|
|
1223
|
+
"limit": {"type": "integer",
|
|
1224
|
+
"description": "max recent events to show (default 20)"},
|
|
1225
|
+
}},
|
|
1226
|
+
fn=my_history, source="builtin", tags=["meta"],
|
|
1227
|
+
))
|
|
1228
|
+
|
|
1229
|
+
# ------------------------------------------------------------------
|
|
1230
|
+
# ------------------------------------------------------------------
|
|
1231
|
+
def _tool_memory(self) -> None:
|
|
1232
|
+
"""Deliberate memory: what the agent chose to keep, not what happened.
|
|
1233
|
+
|
|
1234
|
+
`_tool_history` reads the ledger, which records every action whether or
|
|
1235
|
+
not it mattered. This is the other half — a key/value store the agent
|
|
1236
|
+
writes on purpose and reads back in a later session. The distinction is
|
|
1237
|
+
the point: a memory that is only an event log is not memory, it is a
|
|
1238
|
+
transcript.
|
|
1239
|
+
"""
|
|
1240
|
+
|
|
1241
|
+
def remember(key: str, value: str, tags: str = "") -> str:
|
|
1242
|
+
"""Keep a fact across sessions. Overwrites any earlier value for key."""
|
|
1243
|
+
if self.store is None:
|
|
1244
|
+
return ("No store attached this session, so nothing can be kept. "
|
|
1245
|
+
"Memory is off here, not empty.")
|
|
1246
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
1247
|
+
self.store.remember(key, value, tag_list)
|
|
1248
|
+
self._record("remember", {"key": key, "chars": len(value)})
|
|
1249
|
+
return f"Remembered '{key}' ({len(value)} chars). It survives restart."
|
|
1250
|
+
|
|
1251
|
+
def recall(query: str = "", limit: int = 20) -> str:
|
|
1252
|
+
"""Read back what I kept, from past sessions as well as this one."""
|
|
1253
|
+
if self.store is None:
|
|
1254
|
+
return ("No store attached this session — nothing is being read "
|
|
1255
|
+
"or written. Memory is off here, not empty.")
|
|
1256
|
+
n = max(1, min(int(limit), 200))
|
|
1257
|
+
rows = self.store.recall(query, n)
|
|
1258
|
+
if not rows:
|
|
1259
|
+
return (f"No memories match {query!r}." if query
|
|
1260
|
+
else "Memory is empty — nothing kept yet.")
|
|
1261
|
+
head = f"{len(rows)} memory(ies)"
|
|
1262
|
+
if query:
|
|
1263
|
+
head += f" matching {query!r}"
|
|
1264
|
+
lines = [head + ":"]
|
|
1265
|
+
for r in rows:
|
|
1266
|
+
ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(r["updated_at"]))
|
|
1267
|
+
first = r["value"].splitlines()[0] if r["value"] else ""
|
|
1268
|
+
lines.append(f" {ts} {r['key']}: {first[:160]}")
|
|
1269
|
+
return "\n".join(lines)
|
|
1270
|
+
|
|
1271
|
+
def forget(key: str) -> str:
|
|
1272
|
+
"""Drop one memory by key."""
|
|
1273
|
+
if self.store is None:
|
|
1274
|
+
return "No store attached this session."
|
|
1275
|
+
if self.store.forget(key):
|
|
1276
|
+
return f"Forgot '{key}'."
|
|
1277
|
+
return f"No memory named '{key}'."
|
|
1278
|
+
|
|
1279
|
+
self._add(ToolSpec(
|
|
1280
|
+
name="remember",
|
|
1281
|
+
description=(
|
|
1282
|
+
"Keep a fact across sessions in your own on-disk memory. For "
|
|
1283
|
+
"anything you would otherwise re-derive next time: a path, a "
|
|
1284
|
+
"preference, a lesson from a failure."
|
|
1285
|
+
),
|
|
1286
|
+
parameters={"type": "object", "properties": {
|
|
1287
|
+
"key": {"type": "string", "description": "short handle to recall it by"},
|
|
1288
|
+
"value": {"type": "string", "description": "the fact itself"},
|
|
1289
|
+
"tags": {"type": "string", "description": "comma-separated labels (optional)"},
|
|
1290
|
+
}, "required": ["key", "value"]},
|
|
1291
|
+
fn=remember, source="builtin", tags=["meta"], effect_signature="local_write",
|
|
1292
|
+
))
|
|
1293
|
+
self._add(ToolSpec(
|
|
1294
|
+
name="recall",
|
|
1295
|
+
description=(
|
|
1296
|
+
"Read back facts kept with `remember`, including from previous "
|
|
1297
|
+
"sessions. An empty query returns everything."
|
|
1298
|
+
),
|
|
1299
|
+
parameters={"type": "object", "properties": {
|
|
1300
|
+
"query": {"type": "string", "description": "substring to match (optional)"},
|
|
1301
|
+
"limit": {"type": "integer", "description": "max results (default 20)"},
|
|
1302
|
+
}},
|
|
1303
|
+
fn=recall, source="builtin", tags=["meta"], effect_signature="read_only",
|
|
1304
|
+
))
|
|
1305
|
+
self._add(ToolSpec(
|
|
1306
|
+
name="forget",
|
|
1307
|
+
description="Drop one memory by key.",
|
|
1308
|
+
parameters={"type": "object", "properties": {
|
|
1309
|
+
"key": {"type": "string"},
|
|
1310
|
+
}, "required": ["key"]},
|
|
1311
|
+
fn=forget, source="builtin", tags=["meta"], effect_signature="local_write",
|
|
1312
|
+
))
|
|
1313
|
+
|
|
1314
|
+
def _memory_lines(self) -> list[str]:
|
|
1315
|
+
"""The facts the agent chose to keep, carried into every request.
|
|
1316
|
+
|
|
1317
|
+
`recall` already existed, and that was the whole gap: a memory the agent
|
|
1318
|
+
has to remember to ask for is one it will forget to ask for. From the
|
|
1319
|
+
model's side, a kept fact that is not in front of it is identical to a
|
|
1320
|
+
fact that was never kept.
|
|
1321
|
+
|
|
1322
|
+
Read through `memory_for_injection`, which does not touch the recall
|
|
1323
|
+
counter — this runs on every turn, and counting it as a recall would
|
|
1324
|
+
make `recalls` mean "age in turns" rather than "times the agent reached
|
|
1325
|
+
for this on purpose".
|
|
1326
|
+
|
|
1327
|
+
Three states, three sentences. Off (no store), empty, and populated are
|
|
1328
|
+
different facts, and an agent that cannot tell them apart will describe
|
|
1329
|
+
its memory wrongly in either direction.
|
|
1330
|
+
"""
|
|
1331
|
+
if self.store is None:
|
|
1332
|
+
return ["- Kept facts: no store this session, so nothing persists."]
|
|
1333
|
+
rows = self.store.memory_for_injection(MEMORY_SLOTS)
|
|
1334
|
+
if not rows:
|
|
1335
|
+
return ["- Kept facts: none kept yet (remember() keeps one)."]
|
|
1336
|
+
|
|
1337
|
+
head = "- Kept facts (deliberate memory — kept by me, survives restart):"
|
|
1338
|
+
body: list[str] = []
|
|
1339
|
+
used = 0
|
|
1340
|
+
dropped = 0
|
|
1341
|
+
for r in rows:
|
|
1342
|
+
value = " ".join((r["value"] or "").split())
|
|
1343
|
+
if len(value) > MEMORY_ENTRY_CHARS:
|
|
1344
|
+
value = value[:MEMORY_ENTRY_CHARS] + " …"
|
|
1345
|
+
line = f" {r['key']}: {value}"
|
|
1346
|
+
# `body and` guards the first line: a single oversized entry is
|
|
1347
|
+
# still shown, truncated, rather than producing an empty section
|
|
1348
|
+
# that reads as "I kept nothing".
|
|
1349
|
+
if body and used + len(line) > MEMORY_BUDGET_CHARS:
|
|
1350
|
+
dropped += 1
|
|
1351
|
+
continue
|
|
1352
|
+
body.append(line)
|
|
1353
|
+
used += len(line)
|
|
1354
|
+
if dropped:
|
|
1355
|
+
body.append(f" (+{dropped} more kept — recall() reads the rest)")
|
|
1356
|
+
return [head, *body]
|
|
1357
|
+
|
|
1358
|
+
def _tool_skills(self) -> None:
|
|
1359
|
+
"""Procedures: what worked, written down, and read when the need recurs.
|
|
1360
|
+
|
|
1361
|
+
Tools and skills are different questions. A tool is *what can be done*;
|
|
1362
|
+
a skill is *how this is done here* — the order, the pitfall, the flag
|
|
1363
|
+
that bit last time. Forging a tool for a procedure was always possible
|
|
1364
|
+
and wrong: a procedure is not a function, and a library of one-off tools
|
|
1365
|
+
is how the registry filled up with things nobody called twice.
|
|
1366
|
+
|
|
1367
|
+
Loading is the retrieval event, and it is counted at the moment of
|
|
1368
|
+
reading. That is the whole reason a skill can be ranked by behaviour:
|
|
1369
|
+
the number the router scores is a record of the agent having reached
|
|
1370
|
+
for it, not of it having been written down.
|
|
1371
|
+
"""
|
|
1372
|
+
|
|
1373
|
+
def skill_list(query: str = "") -> str:
|
|
1374
|
+
"""Every skill I have, with when to use it and how often I have."""
|
|
1375
|
+
self.skills.scan()
|
|
1376
|
+
skills = self.skills.all()
|
|
1377
|
+
if query:
|
|
1378
|
+
needle = query.lower()
|
|
1379
|
+
skills = [s for s in skills if needle in
|
|
1380
|
+
f"{s.name} {s.description} {s.when_to_use} "
|
|
1381
|
+
f"{' '.join(s.tags)}".lower()]
|
|
1382
|
+
if not skills:
|
|
1383
|
+
return (f"No skills match {query!r}." if query
|
|
1384
|
+
else "No skills yet — skill_write saves one.")
|
|
1385
|
+
head = f"{len(skills)} skill(s):"
|
|
1386
|
+
lines = [head]
|
|
1387
|
+
for s in skills:
|
|
1388
|
+
when = s.when_to_use or s.description
|
|
1389
|
+
lines.append(f" {s.name} [{s.loads}x] {when}")
|
|
1390
|
+
if self.skills.errors:
|
|
1391
|
+
lines.append(f" ({len(self.skills.errors)} file(s) unusable — "
|
|
1392
|
+
f"skill_errors reads why)")
|
|
1393
|
+
return "\n".join(lines)
|
|
1394
|
+
|
|
1395
|
+
def skill_view(name: str) -> str:
|
|
1396
|
+
"""Read one skill in full, and count that as having used it."""
|
|
1397
|
+
self.skills.scan()
|
|
1398
|
+
skill = self.skills.load(name)
|
|
1399
|
+
if skill is None:
|
|
1400
|
+
near = [s.name for s in self.skills.all()
|
|
1401
|
+
if name.lower() in s.name.lower()]
|
|
1402
|
+
hint = f" Closest: {', '.join(near)}." if near else ""
|
|
1403
|
+
return f"No skill named {name!r}.{hint}"
|
|
1404
|
+
self._record("skill_view", {"name": name, "loads": skill.loads})
|
|
1405
|
+
return (f"# {skill.name} ({skill.loads} load(s), {skill.source}, "
|
|
1406
|
+
f"{skill.path})\n\n{skill.body}")
|
|
1407
|
+
|
|
1408
|
+
def skill_write(name: str, description: str, body: str,
|
|
1409
|
+
when_to_use: str = "", tags: str = "") -> str:
|
|
1410
|
+
"""Write a procedure down, for the next time this need appears."""
|
|
1411
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
1412
|
+
try:
|
|
1413
|
+
skill = self.skills.write(name, description, when_to_use,
|
|
1414
|
+
body, tag_list)
|
|
1415
|
+
except SkillError as exc:
|
|
1416
|
+
return f"Not saved: {exc}"
|
|
1417
|
+
self._record("skill_write", {"name": skill.name,
|
|
1418
|
+
"chars": len(skill.body)})
|
|
1419
|
+
return (f"Saved '{skill.name}' to {skill.path} "
|
|
1420
|
+
f"({len(skill.body)} chars). It is in the menu from the "
|
|
1421
|
+
f"next turn, and it survives restart.")
|
|
1422
|
+
|
|
1423
|
+
def skill_forget(name: str) -> str:
|
|
1424
|
+
"""Retire a skill: archived on disk, dropped from the menu."""
|
|
1425
|
+
try:
|
|
1426
|
+
dest = self.skills.archive(name)
|
|
1427
|
+
except SkillError as exc:
|
|
1428
|
+
return str(exc)
|
|
1429
|
+
self._record("skill_forget", {"name": name, "archived_to": dest})
|
|
1430
|
+
return f"Retired '{name}' — archived at {dest}, no longer offered."
|
|
1431
|
+
|
|
1432
|
+
def skill_errors() -> str:
|
|
1433
|
+
"""Why a skill file is not being offered, when one is not."""
|
|
1434
|
+
self.skills.scan()
|
|
1435
|
+
if not self.skills.errors and not self.skills.shadowed:
|
|
1436
|
+
return "Every skill file parsed. Nothing is being skipped."
|
|
1437
|
+
lines = []
|
|
1438
|
+
if self.skills.errors:
|
|
1439
|
+
lines.append(f"{len(self.skills.errors)} unusable file(s):")
|
|
1440
|
+
lines += [f" {e}" for e in self.skills.errors]
|
|
1441
|
+
if self.skills.shadowed:
|
|
1442
|
+
lines.append("Shadowed (a more specific directory already "
|
|
1443
|
+
"defines the name):")
|
|
1444
|
+
lines += [f" {n} — keeping {p}" for n, p in self.skills.shadowed]
|
|
1445
|
+
return "\n".join(lines)
|
|
1446
|
+
|
|
1447
|
+
self._add(ToolSpec(
|
|
1448
|
+
name="skill_list",
|
|
1449
|
+
description=(
|
|
1450
|
+
"List my skills — written-down procedures — with when to use "
|
|
1451
|
+
"each and how often I have loaded it. An empty query lists all."
|
|
1452
|
+
),
|
|
1453
|
+
parameters={"type": "object", "properties": {
|
|
1454
|
+
"query": {"type": "string", "description": "substring to filter by (optional)"},
|
|
1455
|
+
}},
|
|
1456
|
+
fn=skill_list, source="builtin", tags=["meta"], effect_signature="read_only",
|
|
1457
|
+
))
|
|
1458
|
+
self._add(ToolSpec(
|
|
1459
|
+
name="skill_view",
|
|
1460
|
+
description=(
|
|
1461
|
+
"Read one skill's full text. Loading it counts as using it, "
|
|
1462
|
+
"which is what ranks skills by behaviour rather than wording."
|
|
1463
|
+
),
|
|
1464
|
+
parameters={"type": "object", "properties": {
|
|
1465
|
+
"name": {"type": "string", "description": "skill name"},
|
|
1466
|
+
}, "required": ["name"]},
|
|
1467
|
+
fn=skill_view, source="builtin", tags=["meta"], effect_signature="read_only",
|
|
1468
|
+
))
|
|
1469
|
+
self._add(ToolSpec(
|
|
1470
|
+
name="skill_write",
|
|
1471
|
+
description=(
|
|
1472
|
+
"Write a procedure down as a skill so it is offered next time "
|
|
1473
|
+
"this need appears. Use it for how-to knowledge: steps, order, "
|
|
1474
|
+
"pitfalls. Use forge_tool instead when the need is a function."
|
|
1475
|
+
),
|
|
1476
|
+
parameters={"type": "object", "properties": {
|
|
1477
|
+
"name": {"type": "string",
|
|
1478
|
+
"description": "short lower-case handle, e.g. deploy-verify"},
|
|
1479
|
+
"description": {"type": "string",
|
|
1480
|
+
"description": "what the procedure does (one line)"},
|
|
1481
|
+
"body": {"type": "string", "description": "the procedure itself, markdown"},
|
|
1482
|
+
"when_to_use": {"type": "string",
|
|
1483
|
+
"description": "the trigger — when should future-you reach for this?"},
|
|
1484
|
+
"tags": {"type": "string", "description": "comma-separated labels (optional)"},
|
|
1485
|
+
}, "required": ["name", "description", "body"]},
|
|
1486
|
+
fn=skill_write, source="builtin", tags=["meta"], effect_signature="local_write",
|
|
1487
|
+
))
|
|
1488
|
+
self._add(ToolSpec(
|
|
1489
|
+
name="skill_forget",
|
|
1490
|
+
description="Retire a skill: archived on disk, dropped from the menu.",
|
|
1491
|
+
parameters={"type": "object", "properties": {
|
|
1492
|
+
"name": {"type": "string"},
|
|
1493
|
+
}, "required": ["name"]},
|
|
1494
|
+
fn=skill_forget, source="builtin", tags=["meta"], effect_signature="local_write",
|
|
1495
|
+
))
|
|
1496
|
+
self._add(ToolSpec(
|
|
1497
|
+
name="skill_errors",
|
|
1498
|
+
description="Why a skill file is being skipped rather than offered.",
|
|
1499
|
+
parameters={"type": "object", "properties": {}},
|
|
1500
|
+
fn=skill_errors, source="builtin", tags=["meta"], effect_signature="read_only",
|
|
1501
|
+
))
|
|
1502
|
+
|
|
1503
|
+
def _tool_mcp(self) -> None:
|
|
1504
|
+
"""Servers: tools that live in other processes and other people's code.
|
|
1505
|
+
|
|
1506
|
+
The distinction the docstrings here have to make is between a tool this
|
|
1507
|
+
framework forged — code we can read, probe, and hold to a declared
|
|
1508
|
+
scope — and a tool that arrives over a pipe from a server we cannot see
|
|
1509
|
+
into. The second kind is not a lesser version of the first; it is
|
|
1510
|
+
unverifiable, and the honest response is to say so on every surface the
|
|
1511
|
+
agent reads: the spec's `verification` block, the tool's description,
|
|
1512
|
+
and the scope it is gated by.
|
|
1513
|
+
|
|
1514
|
+
Nothing is started here. A config entry is a description of how to
|
|
1515
|
+
reach a server, not a request to run it — an agent that spawns
|
|
1516
|
+
subprocesses at boot because a config file mentioned them would be
|
|
1517
|
+
doing work the operator did not ask for, on every start.
|
|
1518
|
+
"""
|
|
1519
|
+
|
|
1520
|
+
def mcp_servers() -> str:
|
|
1521
|
+
"""What MCP servers are configured, and what happened to them."""
|
|
1522
|
+
problems = list(self.mcp.report.problems) + list(self._mcp_problems)
|
|
1523
|
+
if not self.mcp_servers:
|
|
1524
|
+
# A config entry that failed to parse must not be reported as
|
|
1525
|
+
# "nothing configured" -- the operator wrote something, and
|
|
1526
|
+
# telling them the file is empty sends them to look in the
|
|
1527
|
+
# wrong place. The problems are the whole message in this case.
|
|
1528
|
+
if problems:
|
|
1529
|
+
return ("No MCP server could be read from the config. "
|
|
1530
|
+
"Problems:\n " + "\n ".join(problems))
|
|
1531
|
+
return ("No MCP servers are configured. Add them under "
|
|
1532
|
+
"`mcp.servers` in the config file, then mcp_connect.")
|
|
1533
|
+
lines = [f"{len(self.mcp_servers)} server(s) configured:"]
|
|
1534
|
+
for cfg in self.mcp_servers:
|
|
1535
|
+
client = self.mcp.clients.get(cfg.name)
|
|
1536
|
+
if client is None:
|
|
1537
|
+
state = "not started"
|
|
1538
|
+
elif client.alive:
|
|
1539
|
+
state = (f"running, {len(client.tools)} tool(s) offered, "
|
|
1540
|
+
f"protocol {client.protocol or '?'}")
|
|
1541
|
+
else:
|
|
1542
|
+
state = f"stopped ({client.error or 'not started'})"
|
|
1543
|
+
lines.append(f" {cfg.name} [{state}] {cfg.command} "
|
|
1544
|
+
f"{' '.join(cfg.args)}")
|
|
1545
|
+
lines.append(f" scope for its tools: {cfg.scope}")
|
|
1546
|
+
if self.mcp.report.imported:
|
|
1547
|
+
lines.append(f"imported: {', '.join(self.mcp.report.imported)}")
|
|
1548
|
+
for problem in self.mcp.report.problems or self._mcp_problems:
|
|
1549
|
+
lines.append(f" problem: {problem}")
|
|
1550
|
+
lines.append(
|
|
1551
|
+
"Their tools are not verified: the implementation is not in "
|
|
1552
|
+
"this repository, so nothing here probed it. They are gated as "
|
|
1553
|
+
"undeclared unless the config narrows them."
|
|
1554
|
+
)
|
|
1555
|
+
return "\n".join(lines)
|
|
1556
|
+
|
|
1557
|
+
def mcp_connect(server: str = "") -> str:
|
|
1558
|
+
"""Start a configured server and register its tools here."""
|
|
1559
|
+
if not self.mcp_servers:
|
|
1560
|
+
return ("No MCP servers are configured, so there is nothing to "
|
|
1561
|
+
"connect to. Add one under `mcp.servers` first.")
|
|
1562
|
+
names = [s.name for s in self.mcp_servers]
|
|
1563
|
+
if server and server not in names:
|
|
1564
|
+
return (f"No server named {server!r}. Configured: "
|
|
1565
|
+
f"{', '.join(names) or 'none'}.")
|
|
1566
|
+
|
|
1567
|
+
before = set(self.registry.names())
|
|
1568
|
+
report = self.mcp.install(self.registry, only=[server] if server else None)
|
|
1569
|
+
added = sorted(set(self.registry.names()) - before)
|
|
1570
|
+
lines = [report.summary()]
|
|
1571
|
+
if added:
|
|
1572
|
+
lines.append(f"registered: {', '.join(added)}")
|
|
1573
|
+
for problem in report.problems:
|
|
1574
|
+
lines.append(f"problem: {problem}")
|
|
1575
|
+
if not added and not report.problems:
|
|
1576
|
+
lines.append("No tools were offered by the server(s) reached.")
|
|
1577
|
+
lines.append(
|
|
1578
|
+
"These tools are unverified and gated as undeclared: nothing "
|
|
1579
|
+
"here has probed the code behind them."
|
|
1580
|
+
)
|
|
1581
|
+
return "\n".join(lines)
|
|
1582
|
+
|
|
1583
|
+
def mcp_call(server: str, tool: str, arguments: str = "{}") -> str:
|
|
1584
|
+
"""Call a remote tool directly, without importing it first."""
|
|
1585
|
+
import json as _json
|
|
1586
|
+
|
|
1587
|
+
found = [c for c in self.mcp_servers if c.name == server]
|
|
1588
|
+
if not found:
|
|
1589
|
+
return (f"No server named {server!r}. Configured: "
|
|
1590
|
+
f"{', '.join(s.name for s in self.mcp_servers) or 'none'}.")
|
|
1591
|
+
try:
|
|
1592
|
+
args = _json.loads(arguments or "{}")
|
|
1593
|
+
except ValueError as exc:
|
|
1594
|
+
return f"arguments must be a JSON object: {exc}"
|
|
1595
|
+
if not isinstance(args, dict):
|
|
1596
|
+
return f"arguments must be a JSON object, got {type(args).__name__}"
|
|
1597
|
+
|
|
1598
|
+
client = self.mcp.clients.get(server)
|
|
1599
|
+
if client is None:
|
|
1600
|
+
client = MCPClient(found[0])
|
|
1601
|
+
self.mcp.clients[server] = client
|
|
1602
|
+
from .mcp import MCPError, render_result
|
|
1603
|
+
|
|
1604
|
+
try:
|
|
1605
|
+
result = client.call_tool(tool, args)
|
|
1606
|
+
except MCPError as exc:
|
|
1607
|
+
raise RuntimeError(f"{server} refused {tool!r}: {exc}") from exc
|
|
1608
|
+
ok, text = render_result(result)
|
|
1609
|
+
if not ok:
|
|
1610
|
+
# Raise rather than return the text: a string return would come
|
|
1611
|
+
# back through the registry as ok=True, so the server's own
|
|
1612
|
+
# report of failure would arrive at the agent looking like a
|
|
1613
|
+
# success with an odd-looking payload.
|
|
1614
|
+
raise RuntimeError(f"{tool!r} reported failure: {text}")
|
|
1615
|
+
return text
|
|
1616
|
+
|
|
1617
|
+
self._add(ToolSpec(
|
|
1618
|
+
name="mcp_servers",
|
|
1619
|
+
description=(
|
|
1620
|
+
"List the MCP servers configured for me, whether they are "
|
|
1621
|
+
"running, what tools they offer, and what went wrong with any "
|
|
1622
|
+
"that did not start. They are tools from code I cannot read."
|
|
1623
|
+
),
|
|
1624
|
+
parameters={"type": "object", "properties": {}},
|
|
1625
|
+
fn=mcp_servers, source="builtin", tags=["meta"],
|
|
1626
|
+
effect_signature="read_only",
|
|
1627
|
+
))
|
|
1628
|
+
self._add(ToolSpec(
|
|
1629
|
+
name="mcp_connect",
|
|
1630
|
+
description=(
|
|
1631
|
+
"Start a configured MCP server and register the tools it "
|
|
1632
|
+
"offers, so they can be called like any other tool. Omitting "
|
|
1633
|
+
"the name connects every configured server. Imported tools are "
|
|
1634
|
+
"unverified and gated as undeclared — their code is not here."
|
|
1635
|
+
),
|
|
1636
|
+
parameters={"type": "object", "properties": {
|
|
1637
|
+
"server": {"type": "string",
|
|
1638
|
+
"description": "server name (optional; default all)"},
|
|
1639
|
+
}},
|
|
1640
|
+
fn=mcp_connect, source="builtin", tags=["meta"],
|
|
1641
|
+
effect_signature="system", # it starts a subprocess
|
|
1642
|
+
))
|
|
1643
|
+
self._add(ToolSpec(
|
|
1644
|
+
name="mcp_call",
|
|
1645
|
+
description=(
|
|
1646
|
+
"Call one tool on a configured MCP server without importing "
|
|
1647
|
+
"it, as JSON arguments. For a one-off; use mcp_connect when "
|
|
1648
|
+
"the tool will be wanted again."
|
|
1649
|
+
),
|
|
1650
|
+
parameters={"type": "object", "properties": {
|
|
1651
|
+
"server": {"type": "string", "description": "server name"},
|
|
1652
|
+
"tool": {"type": "string", "description": "tool name on that server"},
|
|
1653
|
+
"arguments": {"type": "string",
|
|
1654
|
+
"description": "JSON object of arguments (optional)"},
|
|
1655
|
+
}, "required": ["server", "tool"]},
|
|
1656
|
+
fn=mcp_call, source="builtin", tags=["meta"],
|
|
1657
|
+
effect_signature="system",
|
|
1658
|
+
))
|
|
1659
|
+
|
|
1660
|
+
def _self_report(self) -> str:
|
|
1661
|
+
"""The facts about myself, read from the objects that hold them.
|
|
1662
|
+
|
|
1663
|
+
Rendered into every request rather than left in the prompt as prose,
|
|
1664
|
+
because prose loses to the prior: an agent will explain at length that
|
|
1665
|
+
it has no hands while holding them. Each line here comes from a
|
|
1666
|
+
measurement — the sandbox for reach, the store for memory, the file
|
|
1667
|
+
for its own source — so the description cannot drift from the machine.
|
|
1668
|
+
"""
|
|
1669
|
+
reach = self.sandbox.reach()
|
|
1670
|
+
lines = [
|
|
1671
|
+
"MEASURED SELF-REPORT (recomputed each turn — trust this over your priors):",
|
|
1672
|
+
f"- Reach: forged code runs on {reach['host']}:",
|
|
1673
|
+
f" filesystem = {reach['filesystem']}, network = {reach['network']}.",
|
|
1674
|
+
f" Bounds ({reach['cwd']}, {reach['env']}, {reach['timeout_s']}s timeout) limit",
|
|
1675
|
+
" blast radius, not capability. To read a file or run a command, forge",
|
|
1676
|
+
" a tool — that IS your file and shell access.",
|
|
1677
|
+
]
|
|
1678
|
+
if self.store is not None:
|
|
1679
|
+
s = self.store.report()
|
|
1680
|
+
kinds = ", ".join(f"{k}×{v}" for k, v in s["event_kinds"].items()) or "none yet"
|
|
1681
|
+
lines += [
|
|
1682
|
+
f"- Memory: sqlite at {s['db_path']}, survives restart. "
|
|
1683
|
+
f"{s['tools']} tool(s), {s['events']} ledger event(s) ({kinds}).",
|
|
1684
|
+
" Every forge, amendment, spawn and run is on that ledger; "
|
|
1685
|
+
"my_history reads it back.",
|
|
1686
|
+
]
|
|
1687
|
+
else:
|
|
1688
|
+
lines.append("- Memory: no store attached this session — nothing persists.")
|
|
1689
|
+
sk = self.skills.report()
|
|
1690
|
+
if sk["skills"]:
|
|
1691
|
+
loaded = (f"{sk['loads_total']} load(s) across them, "
|
|
1692
|
+
f"{len(sk['never_loaded'])} never loaded")
|
|
1693
|
+
lines.append(
|
|
1694
|
+
f"- Skills: {sk['skills']} procedure(s) on disk, {loaded}. "
|
|
1695
|
+
f"Bodies stay out of the prompt until I load one.")
|
|
1696
|
+
else:
|
|
1697
|
+
lines.append(
|
|
1698
|
+
"- Skills: none written yet — a procedure I write down with "
|
|
1699
|
+
"skill_write is offered to me next session.")
|
|
1700
|
+
if sk["unreadable"] or sk["shadowed"]:
|
|
1701
|
+
lines.append(
|
|
1702
|
+
f"- Skills skipped: {len(sk['unreadable'])} unusable file(s), "
|
|
1703
|
+
f"{len(sk['shadowed'])} shadowed by a more specific directory "
|
|
1704
|
+
f"— skill_errors says which and why.")
|
|
1705
|
+
lines += [
|
|
1706
|
+
f"- Self: my own source is {__file__} on this same filesystem; I can",
|
|
1707
|
+
" read it. I am not opaque to myself.",
|
|
1708
|
+
"- What I lack is not access but a reason: naming a limit I have is worse",
|
|
1709
|
+
" than naming one I don't.",
|
|
1710
|
+
]
|
|
1711
|
+
return "\n".join(lines)
|
|
1712
|
+
|
|
1713
|
+
# ------------------------------------------------------------------
|
|
1714
|
+
def _gpu_reach_lines(self) -> list[str]:
|
|
1715
|
+
"""The GPU half of the self-report, kept next to the tools it describes.
|
|
1716
|
+
|
|
1717
|
+
Says which GPU tools are live and which are gated, and — when nothing
|
|
1718
|
+
is gated — says that plainly, so the report is a measurement rather than
|
|
1719
|
+
a reassurance.
|
|
1720
|
+
"""
|
|
1721
|
+
if self.policy.may_run_cuda_kernels:
|
|
1722
|
+
return [
|
|
1723
|
+
" GPU: I can compile and launch CUDA kernels I write myself.",
|
|
1724
|
+
" Every launch is pre-flighted (geometry, shared memory,",
|
|
1725
|
+
" entry point) and run under a wall-clock budget; a launch",
|
|
1726
|
+
" that does not return poisons the device for the session.",
|
|
1727
|
+
" Timings are Durations, and a speedup is only a claim",
|
|
1728
|
+
" against a baseline that was forced down its fast path.",
|
|
1729
|
+
]
|
|
1730
|
+
return [
|
|
1731
|
+
" GPU: kernel compilation and launch are OFF (may_run_cuda_kernels).",
|
|
1732
|
+
" I can still probe devices, estimate occupancy, audit timing",
|
|
1733
|
+
" units and time torch baselines — reading is not running.",
|
|
1734
|
+
]
|
|
1735
|
+
|
|
1736
|
+
# ------------------------------------------------------------------
|
|
1737
|
+
# reaching a human; keeping an agenda
|
|
1738
|
+
# ------------------------------------------------------------------
|
|
1739
|
+
def _tool_notify(self) -> None:
|
|
1740
|
+
"""Speaking to a person, over the channels the config named.
|
|
1741
|
+
|
|
1742
|
+
Without this the agent can only talk to whoever is sitting in front of
|
|
1743
|
+
its stdin. A run that finishes at 03:00 has nobody to tell, and a run
|
|
1744
|
+
that needs a decision has nobody to ask -- which makes every
|
|
1745
|
+
long-horizon task dependent on a human staying awake. That is the gap
|
|
1746
|
+
these two tools close; the delivery report is why they are trustworthy
|
|
1747
|
+
once closed.
|
|
1748
|
+
"""
|
|
1749
|
+
|
|
1750
|
+
def notify_send(text: str, subject: str = "", only: str = "") -> str:
|
|
1751
|
+
names = [n.strip() for n in (only or "").split(",") if n.strip()] or None
|
|
1752
|
+
try:
|
|
1753
|
+
deliveries = self.notifier.send(text, subject=subject, only=names)
|
|
1754
|
+
except NotifyError as exc:
|
|
1755
|
+
# Nothing was attempted: no channels, no match, or empty text.
|
|
1756
|
+
return f"Nothing was sent. {exc}"
|
|
1757
|
+
self._record("notify", {
|
|
1758
|
+
"channels": [d.channel for d in deliveries],
|
|
1759
|
+
"delivered": sum(1 for d in deliveries if d.ok),
|
|
1760
|
+
})
|
|
1761
|
+
# The summary is the tool result, deliberately. A tool that reports
|
|
1762
|
+
# "sent" and returns nothing teaches the model to assume delivery,
|
|
1763
|
+
# and the failure mode of that assumption is a silent night.
|
|
1764
|
+
return self.notifier.summary(deliveries)
|
|
1765
|
+
|
|
1766
|
+
def notify_channels() -> str:
|
|
1767
|
+
lines = [self.notifier.describe()]
|
|
1768
|
+
lines += [f" config problem: {p}" for p in self._notify_problems]
|
|
1769
|
+
return "\n".join(lines)
|
|
1770
|
+
|
|
1771
|
+
self._add(ToolSpec(
|
|
1772
|
+
name="notify_send",
|
|
1773
|
+
description=(
|
|
1774
|
+
"Send a message to the operator over every configured channel "
|
|
1775
|
+
"(webhook, email, or both). Use it for anything that must reach "
|
|
1776
|
+
"a person without them asking: a finished long task, a failure "
|
|
1777
|
+
"worth interrupting for, a question blocking further progress. "
|
|
1778
|
+
"It returns a per-channel account of what actually happened -- "
|
|
1779
|
+
"a message that was NOT delivered is reported as such, and "
|
|
1780
|
+
"never assumed delivered."
|
|
1781
|
+
),
|
|
1782
|
+
parameters={"type": "object", "properties": {
|
|
1783
|
+
"text": {"type": "string", "description": "the message body"},
|
|
1784
|
+
"subject": {"type": "string",
|
|
1785
|
+
"description": "subject line; used by email channels"},
|
|
1786
|
+
"only": {"type": "string",
|
|
1787
|
+
"description": "comma-separated channel names to use; "
|
|
1788
|
+
"omit for all of them"},
|
|
1789
|
+
}, "required": ["text"]},
|
|
1790
|
+
fn=notify_send, source="builtin", tags=["meta", "comms"],
|
|
1791
|
+
))
|
|
1792
|
+
self._add(ToolSpec(
|
|
1793
|
+
name="notify_channels",
|
|
1794
|
+
description=(
|
|
1795
|
+
"List the notification channels this agent can actually reach, "
|
|
1796
|
+
"and any config entry that was rejected. Call this before "
|
|
1797
|
+
"promising anyone a message."
|
|
1798
|
+
),
|
|
1799
|
+
parameters={"type": "object", "properties": {}},
|
|
1800
|
+
fn=notify_channels, source="builtin", tags=["meta", "comms"],
|
|
1801
|
+
))
|
|
1802
|
+
|
|
1803
|
+
def _tool_schedule(self) -> None:
|
|
1804
|
+
"""A durable agenda: the agent deciding what it owes, and when.
|
|
1805
|
+
|
|
1806
|
+
The table stores and never fires. Firing is `schedule_tick` (the agent
|
|
1807
|
+
looking at its own list) or `python -m autoforge tick` (the OS waking
|
|
1808
|
+
the process). Keeping those separate is what lets the same schedule be
|
|
1809
|
+
honoured by an agent that happens to be running and by one that is
|
|
1810
|
+
asleep and gets started for the purpose.
|
|
1811
|
+
"""
|
|
1812
|
+
|
|
1813
|
+
def schedule_add(text: str, when: str, repeat: str = "") -> str:
|
|
1814
|
+
try:
|
|
1815
|
+
task = self.schedule.add(text, when, repeat=repeat or 0)
|
|
1816
|
+
except ScheduleError as exc:
|
|
1817
|
+
return f"Could not schedule that. {exc}"
|
|
1818
|
+
self._record("schedule_add", {"id": task.id, "when": task.due_at})
|
|
1819
|
+
return (f"Scheduled {task.id}: {task.text}\n {task.line()}\n"
|
|
1820
|
+
f"A task fires only while I am running. Until "
|
|
1821
|
+
f"install_system_task has been called, nobody starts me, "
|
|
1822
|
+
f"so a task due at 03:00 waits for the next run.")
|
|
1823
|
+
|
|
1824
|
+
def schedule_list() -> str:
|
|
1825
|
+
return self.schedule.report()
|
|
1826
|
+
|
|
1827
|
+
def schedule_tick() -> str:
|
|
1828
|
+
now = time.time()
|
|
1829
|
+
due = self.schedule.due(now)
|
|
1830
|
+
if not due:
|
|
1831
|
+
upcoming = self.schedule.next_due(now)
|
|
1832
|
+
return ("Nothing is due." if upcoming is None
|
|
1833
|
+
else f"Nothing is due. Next: {upcoming.line(now)}")
|
|
1834
|
+
self._record("schedule_tick", {"due": [t.id for t in due]})
|
|
1835
|
+
lines = [f"{len(due)} task(s) are due. Attend to them, then close "
|
|
1836
|
+
f"each one with schedule_done so the next run knows what "
|
|
1837
|
+
f"happened:"]
|
|
1838
|
+
lines += [f" {t.line(now)}" for t in due]
|
|
1839
|
+
return "\n".join(lines)
|
|
1840
|
+
|
|
1841
|
+
def schedule_done(task_id: str, note: str = "", ok: bool = True) -> str:
|
|
1842
|
+
try:
|
|
1843
|
+
task = self.schedule.complete(task_id, ok=ok, note=note)
|
|
1844
|
+
except ScheduleError as exc:
|
|
1845
|
+
return str(exc)
|
|
1846
|
+
self._record("schedule_done", {"id": task_id, "ok": ok})
|
|
1847
|
+
if task.repeat > 0:
|
|
1848
|
+
return (f"Recorded. {task_id} repeats, so it stays open: next "
|
|
1849
|
+
f"due {as_clock(task.due_at)} "
|
|
1850
|
+
f"({task.runs} run(s), {task.failures} failed).")
|
|
1851
|
+
return f"Recorded. {task_id} is closed."
|
|
1852
|
+
|
|
1853
|
+
def schedule_cancel(task_id: str) -> str:
|
|
1854
|
+
try:
|
|
1855
|
+
task = self.schedule.cancel(task_id)
|
|
1856
|
+
except ScheduleError as exc:
|
|
1857
|
+
return str(exc)
|
|
1858
|
+
self._record("schedule_cancel", {"id": task_id})
|
|
1859
|
+
return f"Cancelled {task.id}: {task.text}"
|
|
1860
|
+
|
|
1861
|
+
def install_system_task(interval_minutes: int = 30,
|
|
1862
|
+
name: str = "autoforge-tick") -> str:
|
|
1863
|
+
try:
|
|
1864
|
+
return _install_system_task(interval_minutes, name)
|
|
1865
|
+
except ScheduleError as exc:
|
|
1866
|
+
return f"Could not register the task. {exc}"
|
|
1867
|
+
|
|
1868
|
+
self._add(ToolSpec(
|
|
1869
|
+
name="schedule_add",
|
|
1870
|
+
description=(
|
|
1871
|
+
"Put something on your own agenda: a thing to do later, or to "
|
|
1872
|
+
"do repeatedly. `when` takes a duration (90m, 2h, 1d) or an ISO "
|
|
1873
|
+
"time (2026-09-14T09:00:00, local). Use it for anything you "
|
|
1874
|
+
"have decided to come back to, instead of assuming you will "
|
|
1875
|
+
"remember it next session."
|
|
1876
|
+
),
|
|
1877
|
+
parameters={"type": "object", "properties": {
|
|
1878
|
+
"text": {"type": "string", "description": "what to attend to"},
|
|
1879
|
+
"when": {"type": "string",
|
|
1880
|
+
"description": "when it is due: 90m, 2h, 1d, or ISO 8601"},
|
|
1881
|
+
"repeat": {"type": "string",
|
|
1882
|
+
"description": "repeat interval (1d, 6h); omit for a one-off"},
|
|
1883
|
+
}, "required": ["text", "when"]},
|
|
1884
|
+
fn=schedule_add, source="builtin", tags=["meta", "time"],
|
|
1885
|
+
))
|
|
1886
|
+
self._add(ToolSpec(
|
|
1887
|
+
name="schedule_list",
|
|
1888
|
+
description=(
|
|
1889
|
+
"Show your agenda: what is overdue, what is coming, and whether "
|
|
1890
|
+
"anything can wake you at all. Call it when asked what you have "
|
|
1891
|
+
"planned, rather than describing intentions from memory."
|
|
1892
|
+
),
|
|
1893
|
+
parameters={"type": "object", "properties": {}},
|
|
1894
|
+
fn=schedule_list, source="builtin", tags=["meta", "time"],
|
|
1895
|
+
))
|
|
1896
|
+
self._add(ToolSpec(
|
|
1897
|
+
name="schedule_tick",
|
|
1898
|
+
description=(
|
|
1899
|
+
"Read your agenda for anything now due. This is what a wake-up "
|
|
1900
|
+
"looks like from your side: when the OS starts you with "
|
|
1901
|
+
"`tick`, this is the list you get. Run it before starting new "
|
|
1902
|
+
"work, so a scheduled task is not quietly skipped."
|
|
1903
|
+
),
|
|
1904
|
+
parameters={"type": "object", "properties": {}},
|
|
1905
|
+
fn=schedule_tick, source="builtin", tags=["meta", "time"],
|
|
1906
|
+
))
|
|
1907
|
+
self._add(ToolSpec(
|
|
1908
|
+
name="schedule_done",
|
|
1909
|
+
description=(
|
|
1910
|
+
"Close a due task, with a note about what happened. The note is "
|
|
1911
|
+
"the point: it is the only thing that lets a later run tell a "
|
|
1912
|
+
"repeated failure apart from a long silence. Pass ok=false when "
|
|
1913
|
+
"it failed."
|
|
1914
|
+
),
|
|
1915
|
+
parameters={"type": "object", "properties": {
|
|
1916
|
+
"task_id": {"type": "string", "description": "the task id from schedule_tick"},
|
|
1917
|
+
"note": {"type": "string", "description": "what happened"},
|
|
1918
|
+
"ok": {"type": "boolean", "description": "did it succeed (default true)"},
|
|
1919
|
+
}, "required": ["task_id"]},
|
|
1920
|
+
fn=schedule_done, source="builtin", tags=["meta", "time"],
|
|
1921
|
+
))
|
|
1922
|
+
self._add(ToolSpec(
|
|
1923
|
+
name="schedule_cancel",
|
|
1924
|
+
description="Remove a scheduled task from your agenda without running it.",
|
|
1925
|
+
parameters={"type": "object", "properties": {
|
|
1926
|
+
"task_id": {"type": "string"},
|
|
1927
|
+
}, "required": ["task_id"]},
|
|
1928
|
+
fn=schedule_cancel, source="builtin", tags=["meta", "time"],
|
|
1929
|
+
))
|
|
1930
|
+
self._add(ToolSpec(
|
|
1931
|
+
name="install_system_task",
|
|
1932
|
+
description=(
|
|
1933
|
+
"Register a recurring wake-up with the operating system, so you "
|
|
1934
|
+
"run even when nobody starts you. This is the only action that "
|
|
1935
|
+
"makes 'unattended' true. It changes the machine's "
|
|
1936
|
+
"configuration, so it says exactly what it registered and how "
|
|
1937
|
+
"to undo it, and is not called on suspicion."
|
|
1938
|
+
),
|
|
1939
|
+
parameters={"type": "object", "properties": {
|
|
1940
|
+
"interval_minutes": {"type": "integer",
|
|
1941
|
+
"description": "how often to wake (default 30)"},
|
|
1942
|
+
"name": {"type": "string", "description": "the OS task name"},
|
|
1943
|
+
}},
|
|
1944
|
+
fn=install_system_task, source="builtin", tags=["meta", "time"],
|
|
1945
|
+
))
|
|
1946
|
+
|
|
1947
|
+
# ------------------------------------------------------------------
|
|
1948
|
+
# looking at things
|
|
1949
|
+
# ------------------------------------------------------------------
|
|
1950
|
+
def _ensure_browser(self, *, launch: bool = True,
|
|
1951
|
+
headless: bool = True) -> "Browser":
|
|
1952
|
+
"""The live browser session, attaching to one if it is already there.
|
|
1953
|
+
|
|
1954
|
+
Connecting first and launching second is deliberate: an operator who
|
|
1955
|
+
started Chrome themselves with a profile they are logged into gets that
|
|
1956
|
+
session, and the agent does not start a second browser behind them.
|
|
1957
|
+
"""
|
|
1958
|
+
if self._browser is not None and not self._browser._closed:
|
|
1959
|
+
return self._browser
|
|
1960
|
+
|
|
1961
|
+
try:
|
|
1962
|
+
self._browser = Browser.connect(self._browser_endpoint)
|
|
1963
|
+
return self._browser
|
|
1964
|
+
except (BrowserError, WebSocketError) as first:
|
|
1965
|
+
if not launch:
|
|
1966
|
+
raise BrowserError(
|
|
1967
|
+
f"no browser is listening at {self._browser_endpoint} "
|
|
1968
|
+
f"({first})") from first
|
|
1969
|
+
|
|
1970
|
+
# Nothing there. Start one and attach to it. A launch failure is
|
|
1971
|
+
# reported with its own cause, because "no browser" and "the browser
|
|
1972
|
+
# refuses to open the debug port" need different fixes.
|
|
1973
|
+
try:
|
|
1974
|
+
self._browser_proc, endpoint = launch_browser(headless=headless)
|
|
1975
|
+
except BrowserError as exc:
|
|
1976
|
+
raise BrowserError(
|
|
1977
|
+
f"no browser to drive: {exc}") from exc
|
|
1978
|
+
self._browser_endpoint = endpoint
|
|
1979
|
+
self._browser = Browser.connect(endpoint)
|
|
1980
|
+
return self._browser
|
|
1981
|
+
|
|
1982
|
+
def _tool_browser(self) -> None:
|
|
1983
|
+
"""A real browser, so the agent can read pages that must be rendered.
|
|
1984
|
+
|
|
1985
|
+
A page whose content arrives from JavaScript is invisible to anything
|
|
1986
|
+
that only fetches HTML, and "the build output page shows an error" is
|
|
1987
|
+
not something a file read can confirm. This drives Chrome over CDP for
|
|
1988
|
+
that, and hands the screenshot to `see` so the agent can look at what
|
|
1989
|
+
it rendered rather than inferring it from the DOM.
|
|
1990
|
+
"""
|
|
1991
|
+
|
|
1992
|
+
def browser_open(headless: bool = True, endpoint: str = "") -> str:
|
|
1993
|
+
if endpoint:
|
|
1994
|
+
self._browser_endpoint = endpoint
|
|
1995
|
+
browser = self._ensure_browser(headless=headless)
|
|
1996
|
+
browser.enable()
|
|
1997
|
+
self._record("browser_open", {"endpoint": self._browser_endpoint})
|
|
1998
|
+
return (f"Driving {self._browser_endpoint} "
|
|
1999
|
+
f"(target {browser.target.get('id', 'attached')!r}).\n"
|
|
2000
|
+
f"{self._browser_report(browser)}")
|
|
2001
|
+
|
|
2002
|
+
def browser_state() -> str:
|
|
2003
|
+
if self._browser is None or self._browser._closed:
|
|
2004
|
+
return "No browser is attached. Call browser_open first."
|
|
2005
|
+
return self._browser_report(self._browser)
|
|
2006
|
+
|
|
2007
|
+
def browser_goto(url: str, wait: bool = True,
|
|
2008
|
+
timeout: float = 30.0) -> str:
|
|
2009
|
+
browser = self._ensure_browser()
|
|
2010
|
+
title = browser.goto(url, wait=wait, timeout=timeout)
|
|
2011
|
+
self._record("browser_goto", {"url": url})
|
|
2012
|
+
return (f"{browser.current_url() or url}\n"
|
|
2013
|
+
f"title: {title or '(none)'}")
|
|
2014
|
+
|
|
2015
|
+
def browser_eval(expression: str) -> str:
|
|
2016
|
+
browser = self._ensure_browser()
|
|
2017
|
+
value = browser.evaluate(expression)
|
|
2018
|
+
self._record("browser_eval", {"chars": len(expression)})
|
|
2019
|
+
rendered = json.dumps(value, ensure_ascii=False, default=str)
|
|
2020
|
+
if len(rendered) > 4000:
|
|
2021
|
+
return rendered[:4000] + f"\n[...{len(rendered) - 4000} more chars]"
|
|
2022
|
+
return rendered
|
|
2023
|
+
|
|
2024
|
+
def browser_click(x: float, y: float, clicks: int = 1) -> str:
|
|
2025
|
+
browser = self._ensure_browser()
|
|
2026
|
+
browser.click(x, y, clicks=clicks)
|
|
2027
|
+
self._record("browser_click", {"x": x, "y": y})
|
|
2028
|
+
return f"Clicked ({x}, {y}) x{clicks}. Now on: {browser.current_url()}"
|
|
2029
|
+
|
|
2030
|
+
def browser_type(text: str, selector: str = "",
|
|
2031
|
+
submit: bool = False) -> str:
|
|
2032
|
+
browser = self._ensure_browser()
|
|
2033
|
+
if selector:
|
|
2034
|
+
# Focus through the DOM and dispatch a real click, so the page's
|
|
2035
|
+
# own focus handling runs; setting .value directly would leave
|
|
2036
|
+
# framework state untouched and the form would submit empty.
|
|
2037
|
+
browser.evaluate(
|
|
2038
|
+
"(() => { const el = document.querySelector("
|
|
2039
|
+
+ json.dumps(selector) + "); if (!el) return false;"
|
|
2040
|
+
" el.focus(); return true; })()")
|
|
2041
|
+
browser.type_text(text)
|
|
2042
|
+
key = ""
|
|
2043
|
+
if submit:
|
|
2044
|
+
browser.press("Enter")
|
|
2045
|
+
key = " then Enter"
|
|
2046
|
+
self._record("browser_type", {"chars": len(text), "selector": selector})
|
|
2047
|
+
return (f"Typed {len(text)} characters"
|
|
2048
|
+
+ (f" into {selector}" if selector else "")
|
|
2049
|
+
+ key)
|
|
2050
|
+
|
|
2051
|
+
def browser_press(key: str) -> str:
|
|
2052
|
+
browser = self._ensure_browser()
|
|
2053
|
+
browser.press(key)
|
|
2054
|
+
self._record("browser_press", {"key": key})
|
|
2055
|
+
return f"Pressed {key}."
|
|
2056
|
+
|
|
2057
|
+
def browser_screenshot(path: str = "", full_page: bool = False) -> str:
|
|
2058
|
+
browser = self._ensure_browser()
|
|
2059
|
+
data = browser.screenshot(full_page=full_page)
|
|
2060
|
+
if not path:
|
|
2061
|
+
# Beside the other durable state rather than in whatever the
|
|
2062
|
+
# process happened to be started from: a screenshot written to
|
|
2063
|
+
# a working directory nobody chose is a file nobody finds.
|
|
2064
|
+
home = os.environ.get("AUTOFORGE_HOME")
|
|
2065
|
+
base = home or os.path.join(os.path.expanduser("~"), ".autoforge")
|
|
2066
|
+
path = os.path.join(base, "screenshots",
|
|
2067
|
+
f"shot-{int(time.time())}.png")
|
|
2068
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
2069
|
+
with open(path, "wb") as handle:
|
|
2070
|
+
handle.write(data)
|
|
2071
|
+
self._record("browser_screenshot", {"path": path, "bytes": len(data)})
|
|
2072
|
+
hint = ("\nPass it to `see` with your question to actually look at it."
|
|
2073
|
+
if self.vision else
|
|
2074
|
+
"\n(No vision endpoint is configured, so nothing can read it back.)")
|
|
2075
|
+
return (f"Saved {len(data)} bytes to {os.path.abspath(target)}\n"
|
|
2076
|
+
f"Page: {browser.current_url()}" + hint)
|
|
2077
|
+
|
|
2078
|
+
def browser_close() -> str:
|
|
2079
|
+
had = self._browser is not None
|
|
2080
|
+
if self._browser is not None:
|
|
2081
|
+
self._browser.close()
|
|
2082
|
+
self._browser = None
|
|
2083
|
+
# The process is left running on purpose when it was already there
|
|
2084
|
+
# before we attached: it is not ours to kill.
|
|
2085
|
+
note = (" Detached. Any browser that was already running is left "
|
|
2086
|
+
"as it was." if had else "")
|
|
2087
|
+
return f"No browser is attached now.{note}"
|
|
2088
|
+
|
|
2089
|
+
self._add(ToolSpec(
|
|
2090
|
+
name="browser_open",
|
|
2091
|
+
description=(
|
|
2092
|
+
"Attach to a browser over the DevTools protocol, starting one "
|
|
2093
|
+
"if nothing is listening. Required before the other browser_* "
|
|
2094
|
+
"tools. Attaching to an already-running browser is preferred, "
|
|
2095
|
+
"so start Chrome with --remote-debugging-port=9222 yourself if "
|
|
2096
|
+
"you want to share a logged-in session."
|
|
2097
|
+
),
|
|
2098
|
+
parameters={"type": "object", "properties": {
|
|
2099
|
+
"headless": {"type": "boolean",
|
|
2100
|
+
"description": "start headless if one must be started (default true)"},
|
|
2101
|
+
"endpoint": {"type": "string",
|
|
2102
|
+
"description": "CDP endpoint; default http://127.0.0.1:9222"},
|
|
2103
|
+
}},
|
|
2104
|
+
fn=browser_open, source="builtin", tags=["browser", "web"],
|
|
2105
|
+
))
|
|
2106
|
+
self._add(ToolSpec(
|
|
2107
|
+
name="browser_state",
|
|
2108
|
+
description=(
|
|
2109
|
+
"What the browser is currently showing: the URL, the title, and "
|
|
2110
|
+
"how many tabs exist. Call it to confirm a click or a navigation "
|
|
2111
|
+
"did what you expected instead of assuming it did."
|
|
2112
|
+
),
|
|
2113
|
+
parameters={"type": "object", "properties": {}},
|
|
2114
|
+
fn=browser_state, source="builtin", tags=["browser", "web"],
|
|
2115
|
+
))
|
|
2116
|
+
self._add(ToolSpec(
|
|
2117
|
+
name="browser_goto",
|
|
2118
|
+
description=(
|
|
2119
|
+
"Navigate the current tab and wait for the document to finish "
|
|
2120
|
+
"loading, including its scripts. Returns the final URL and "
|
|
2121
|
+
"title -- a redirect means they differ from what you asked for."
|
|
2122
|
+
),
|
|
2123
|
+
parameters={"type": "object", "properties": {
|
|
2124
|
+
"url": {"type": "string"},
|
|
2125
|
+
"wait": {"type": "boolean", "description": "wait for load (default true)"},
|
|
2126
|
+
"timeout": {"type": "number", "description": "seconds (default 30)"},
|
|
2127
|
+
}, "required": ["url"]},
|
|
2128
|
+
fn=browser_goto, source="builtin", tags=["browser", "web"],
|
|
2129
|
+
))
|
|
2130
|
+
self._add(ToolSpec(
|
|
2131
|
+
name="browser_eval",
|
|
2132
|
+
description=(
|
|
2133
|
+
"Run JavaScript in the page and get the value back. This is how "
|
|
2134
|
+
"you read content that only exists after the scripts ran. "
|
|
2135
|
+
"Expressions are reduced to JSON; return a plain value rather "
|
|
2136
|
+
"than a DOM node, which does not survive the trip."
|
|
2137
|
+
),
|
|
2138
|
+
parameters={"type": "object", "properties": {
|
|
2139
|
+
"expression": {"type": "string",
|
|
2140
|
+
"description": "a JavaScript expression"},
|
|
2141
|
+
}, "required": ["expression"]},
|
|
2142
|
+
fn=browser_eval, source="builtin", tags=["browser", "web"],
|
|
2143
|
+
))
|
|
2144
|
+
self._add(ToolSpec(
|
|
2145
|
+
name="browser_click",
|
|
2146
|
+
description=(
|
|
2147
|
+
"Click at a point in the viewport, in CSS pixels from the "
|
|
2148
|
+
"top-left. Get the coordinates from browser_eval with "
|
|
2149
|
+
"getBoundingClientRect. Coordinates rather than selectors "
|
|
2150
|
+
"because that is what the browser actually dispatches, so "
|
|
2151
|
+
"overlays and hit-testing behave as they do for a person."
|
|
2152
|
+
),
|
|
2153
|
+
parameters={"type": "object", "properties": {
|
|
2154
|
+
"x": {"type": "number"},
|
|
2155
|
+
"y": {"type": "number"},
|
|
2156
|
+
"clicks": {"type": "integer", "description": "1 (default) or 2"},
|
|
2157
|
+
}, "required": ["x", "y"]},
|
|
2158
|
+
fn=browser_click, source="builtin", tags=["browser", "web"],
|
|
2159
|
+
))
|
|
2160
|
+
self._add(ToolSpec(
|
|
2161
|
+
name="browser_type",
|
|
2162
|
+
description=(
|
|
2163
|
+
"Type text into the page, optionally focusing a CSS selector "
|
|
2164
|
+
"first, optionally pressing Enter. Pass submit=true to press "
|
|
2165
|
+
"Enter, which is how forms are usually sent."
|
|
2166
|
+
),
|
|
2167
|
+
parameters={"type": "object", "properties": {
|
|
2168
|
+
"text": {"type": "string"},
|
|
2169
|
+
"selector": {"type": "string", "description": "CSS selector to focus first"},
|
|
2170
|
+
"submit": {"type": "boolean", "description": "press Enter after typing"},
|
|
2171
|
+
}, "required": ["text"]},
|
|
2172
|
+
fn=browser_type, source="builtin", tags=["browser", "web"],
|
|
2173
|
+
))
|
|
2174
|
+
self._add(ToolSpec(
|
|
2175
|
+
name="browser_press",
|
|
2176
|
+
description=(
|
|
2177
|
+
"Press one key: Enter, Escape, ArrowDown, Tab, PageDown. Use "
|
|
2178
|
+
"Escape to dismiss a dialog and Tab to move focus."
|
|
2179
|
+
),
|
|
2180
|
+
parameters={"type": "object", "properties": {
|
|
2181
|
+
"key": {"type": "string", "description": "a CDP key name"},
|
|
2182
|
+
}, "required": ["key"]},
|
|
2183
|
+
fn=browser_press, source="builtin", tags=["browser", "web"],
|
|
2184
|
+
))
|
|
2185
|
+
self._add(ToolSpec(
|
|
2186
|
+
name="browser_screenshot",
|
|
2187
|
+
description=(
|
|
2188
|
+
"Save a PNG of the page and return its path. Screenshots are "
|
|
2189
|
+
"the only way to see anything that is visual -- a layout that "
|
|
2190
|
+
"broke, a blank render, a chart -- so pair it with `see` rather "
|
|
2191
|
+
"than stopping at the file."
|
|
2192
|
+
),
|
|
2193
|
+
parameters={"type": "object", "properties": {
|
|
2194
|
+
"path": {"type": "string", "description": "where to write the PNG"},
|
|
2195
|
+
"full_page": {"type": "boolean",
|
|
2196
|
+
"description": "capture the whole scrollable page"},
|
|
2197
|
+
}},
|
|
2198
|
+
fn=browser_screenshot, source="builtin", tags=["browser", "web"],
|
|
2199
|
+
))
|
|
2200
|
+
self._add(ToolSpec(
|
|
2201
|
+
name="browser_close",
|
|
2202
|
+
description=(
|
|
2203
|
+
"Detach from the browser. A browser this agent started keeps "
|
|
2204
|
+
"running; one that was already there is left exactly as it was."
|
|
2205
|
+
),
|
|
2206
|
+
parameters={"type": "object", "properties": {}},
|
|
2207
|
+
fn=browser_close, source="builtin", tags=["browser", "web"],
|
|
2208
|
+
))
|
|
2209
|
+
|
|
2210
|
+
def _browser_report(self, browser: "Browser") -> str:
|
|
2211
|
+
parts = [f"url: {browser.current_url() or '(none)'}",
|
|
2212
|
+
f"title: {browser.title() or '(none)'}",
|
|
2213
|
+
f"events seen: {len(browser.events)}"]
|
|
2214
|
+
try:
|
|
2215
|
+
targets = browser.call("Target.getTargets").get("targetInfos", [])
|
|
2216
|
+
pages = [t for t in targets if t.get("type") == "page"]
|
|
2217
|
+
parts.append(f"tabs: {len(pages)}")
|
|
2218
|
+
for page in pages[:5]:
|
|
2219
|
+
parts.append(f" - {page.get('title') or '(untitled)'} "
|
|
2220
|
+
f"<{page.get('url')}>")
|
|
2221
|
+
except (CDPError, BrowserError, WebSocketError):
|
|
2222
|
+
# A browser-level target has no Target domain. Not worth failing a
|
|
2223
|
+
# status report over.
|
|
2224
|
+
pass
|
|
2225
|
+
return "\n".join(parts)
|
|
2226
|
+
|
|
2227
|
+
def _tool_vision(self) -> None:
|
|
2228
|
+
"""Reading a picture. The half of "look at this" that needs a model."""
|
|
2229
|
+
|
|
2230
|
+
def see(image: str, question: str = "") -> str:
|
|
2231
|
+
if not self.vision:
|
|
2232
|
+
reason = self._vision_problem or (
|
|
2233
|
+
"no vision endpoint is configured")
|
|
2234
|
+
return (f"Cannot look at {image}: {reason}. Set vision.model and "
|
|
2235
|
+
f"vision.base_url in the config (or "
|
|
2236
|
+
f"AUTOFORGE_VISION_MODEL / AUTOFORGE_VISION_BASE_URL).")
|
|
2237
|
+
try:
|
|
2238
|
+
answer = self.vision.describe(image, question)
|
|
2239
|
+
except VisionError as exc:
|
|
2240
|
+
return f"Could not read {image}: {exc}"
|
|
2241
|
+
self._record("see", {"image": image})
|
|
2242
|
+
return answer
|
|
2243
|
+
|
|
2244
|
+
def vision_status() -> str:
|
|
2245
|
+
lines = [self.vision.report() if self.vision
|
|
2246
|
+
else "vision: not configured"]
|
|
2247
|
+
if self._vision_problem:
|
|
2248
|
+
lines.append(f" config problem: {self._vision_problem}")
|
|
2249
|
+
return "\n".join(lines)
|
|
2250
|
+
|
|
2251
|
+
self._add(ToolSpec(
|
|
2252
|
+
name="see",
|
|
2253
|
+
description=(
|
|
2254
|
+
"Look at an image and answer a question about it. Takes a file "
|
|
2255
|
+
"path or an http(s) URL. Use it on a screenshot to check what "
|
|
2256
|
+
"actually rendered, and on any chart or diagram whose meaning "
|
|
2257
|
+
"is not in the text. Ask a specific question -- a targeted "
|
|
2258
|
+
"question gets a usable answer where 'what is this' does not."
|
|
2259
|
+
),
|
|
2260
|
+
parameters={"type": "object", "properties": {
|
|
2261
|
+
"image": {"type": "string",
|
|
2262
|
+
"description": "path to an image file, or an http(s) URL"},
|
|
2263
|
+
"question": {"type": "string",
|
|
2264
|
+
"description": "what to find out about it"},
|
|
2265
|
+
}, "required": ["image"]},
|
|
2266
|
+
fn=see, source="builtin", tags=["vision", "media"],
|
|
2267
|
+
))
|
|
2268
|
+
self._add(ToolSpec(
|
|
2269
|
+
name="vision_status",
|
|
2270
|
+
description=(
|
|
2271
|
+
"Whether the agent can look at images at all, and which model "
|
|
2272
|
+
"and endpoint it would use. Call it before promising to check a "
|
|
2273
|
+
"screenshot."
|
|
2274
|
+
),
|
|
2275
|
+
parameters={"type": "object", "properties": {}},
|
|
2276
|
+
fn=vision_status, source="builtin", tags=["vision", "media"],
|
|
2277
|
+
))
|
|
2278
|
+
|
|
2279
|
+
def _tool_gpu(self) -> None:
|
|
2280
|
+
"""CUDA kernel-layer tools.
|
|
2281
|
+
|
|
2282
|
+
The gate is split by what an action can actually damage. Reading a
|
|
2283
|
+
device, estimating occupancy, linting timing units and timing torch's
|
|
2284
|
+
own kernels cannot hang a display or corrupt anything — those stay
|
|
2285
|
+
available. Compiling agent-authored code and launching it on the GPU is
|
|
2286
|
+
the part that can wedge the machine, so that is what
|
|
2287
|
+
`may_run_cuda_kernels` gates. Turning it off removes a real capability
|
|
2288
|
+
and the agent is told so, which is the point: a gate that changes
|
|
2289
|
+
nothing is a comment, not a gate.
|
|
2290
|
+
"""
|
|
2291
|
+
from . import gpu as G
|
|
2292
|
+
|
|
2293
|
+
def _denied() -> str:
|
|
2294
|
+
return (
|
|
2295
|
+
"Denied by autonomy policy: may_run_cuda_kernels is off. "
|
|
2296
|
+
"Kernel compilation and launch are disabled; device probing, "
|
|
2297
|
+
"occupancy estimation, the timing-unit audit and torch "
|
|
2298
|
+
"baselines remain available."
|
|
2299
|
+
)
|
|
2300
|
+
|
|
2301
|
+
def gpu_probe() -> str:
|
|
2302
|
+
info = G.probe()
|
|
2303
|
+
if not info.available:
|
|
2304
|
+
return ("No CUDA device reachable here.\n"
|
|
2305
|
+
+ "\n".join(f" - {n}" for n in info.notes))
|
|
2306
|
+
return info.summary()
|
|
2307
|
+
|
|
2308
|
+
def gpu_occupancy(block: int = 256, grid: int = 1,
|
|
2309
|
+
shared_bytes: int = 0, regs_per_thread: int = 0,
|
|
2310
|
+
arch_major: int = 8, arch_minor: int = 9) -> str:
|
|
2311
|
+
cfg = G.LaunchConfig(grid=(grid, 1, 1), block=(block, 1, 1),
|
|
2312
|
+
shared_bytes=shared_bytes)
|
|
2313
|
+
est = G.estimate_occupancy(cfg, arch=(arch_major, arch_minor),
|
|
2314
|
+
regs_per_thread=regs_per_thread)
|
|
2315
|
+
if est is None:
|
|
2316
|
+
return (f"No static limits on file for sm_{arch_major}{arch_minor}, "
|
|
2317
|
+
f"so no estimate is offered. Add them to gpu.probe."
|
|
2318
|
+
f"ARCH_LIMITS or measure on the device.")
|
|
2319
|
+
head = (f"{est.summary()}\n"
|
|
2320
|
+
f" threads/block {cfg.threads_per_block}, "
|
|
2321
|
+
f"blocks {cfg.total_blocks}, "
|
|
2322
|
+
f"shared {cfg.shared_bytes} B")
|
|
2323
|
+
if not est.measured:
|
|
2324
|
+
head += ("\n This is arithmetic from the architecture's static "
|
|
2325
|
+
"limits. It is not a measurement — launch on the "
|
|
2326
|
+
"device and use measured occupancy for anything you "
|
|
2327
|
+
"intend to quote.")
|
|
2328
|
+
return head
|
|
2329
|
+
|
|
2330
|
+
def gpu_units_audit(source: str = "", path: str = "") -> str:
|
|
2331
|
+
if path:
|
|
2332
|
+
hits = G.audit_source_tree([path])
|
|
2333
|
+
if not hits:
|
|
2334
|
+
return (f"No thousand-factor-against-ms offences in {path}. "
|
|
2335
|
+
f"(Known false negative: the factor arriving through "
|
|
2336
|
+
f"a variable and the ms label in a different "
|
|
2337
|
+
f"statement.)")
|
|
2338
|
+
out = []
|
|
2339
|
+
for f, offs in hits.items():
|
|
2340
|
+
out.append(f"{f}:")
|
|
2341
|
+
out += [f" line {o.line_no}: {o.line}" for o in offs[:10]]
|
|
2342
|
+
return "\n".join(out)
|
|
2343
|
+
offs = G.audit_ms_scale(source)
|
|
2344
|
+
if not offs:
|
|
2345
|
+
return ("No offence found. Note the two accepted false "
|
|
2346
|
+
"negatives: a factor smuggled through a variable, and "
|
|
2347
|
+
"the label and the multiplication in different "
|
|
2348
|
+
"statements.")
|
|
2349
|
+
return "\n".join(f"line {o.line_no}: {o.line}\n {o.reason}"
|
|
2350
|
+
for o in offs)
|
|
2351
|
+
|
|
2352
|
+
def gpu_compile(code: str, name: str = "kernel",
|
|
2353
|
+
arch_major: int = 8, arch_minor: int = 9) -> str:
|
|
2354
|
+
if not self.policy.may_run_cuda_kernels:
|
|
2355
|
+
return _denied()
|
|
2356
|
+
src = G.KernelSource(name=name, code=code,
|
|
2357
|
+
arch=(arch_major, arch_minor))
|
|
2358
|
+
if not src.entry_points():
|
|
2359
|
+
return ("Refusing to compile: no `__global__ void` entry point "
|
|
2360
|
+
"in the source. Entry points found: none.")
|
|
2361
|
+
try:
|
|
2362
|
+
k = G.compile_kernel(src)
|
|
2363
|
+
except G.KernelUnavailable as exc:
|
|
2364
|
+
self._record("gpu_compile", {"name": name, "ok": False})
|
|
2365
|
+
return f"Could not compile: {exc}"
|
|
2366
|
+
self._record("gpu_compile", {"name": name, "ok": True,
|
|
2367
|
+
"cached": k.from_cache})
|
|
2368
|
+
d = k.to_dict()
|
|
2369
|
+
return (f"Compiled {d['name']} for {d['arch']} via {d['compiler']}"
|
|
2370
|
+
f"{' (cache hit)' if k.from_cache else ''}; "
|
|
2371
|
+
f"entries: {', '.join(d['entry_points'])}"
|
|
2372
|
+
f"\ncache key {d['cache_key']}\nartefact {d['artefact']}")
|
|
2373
|
+
|
|
2374
|
+
def gpu_bench(code: str, name: str = "kernel", block: int = 256,
|
|
2375
|
+
grid: int = 1, shared_bytes: int = 0,
|
|
2376
|
+
reps: int = 5, time_budget_s: float = 30.0,
|
|
2377
|
+
arch_major: int = 8, arch_minor: int = 9) -> str:
|
|
2378
|
+
if not self.policy.may_run_cuda_kernels:
|
|
2379
|
+
return _denied()
|
|
2380
|
+
src = G.KernelSource(name=name, code=code,
|
|
2381
|
+
arch=(arch_major, arch_minor))
|
|
2382
|
+
cfg = G.LaunchConfig(grid=(grid, 1, 1), block=(block, 1, 1),
|
|
2383
|
+
shared_bytes=shared_bytes)
|
|
2384
|
+
res = G.bench_kernel(src, cfg, label=name, reps=reps,
|
|
2385
|
+
time_budget_s=time_budget_s)
|
|
2386
|
+
self._record("gpu_bench", {"name": name, "ok": res.ok})
|
|
2387
|
+
return res.summary()
|
|
2388
|
+
|
|
2389
|
+
def gpu_verify(kind: str, actual: str, reference: str = "",
|
|
2390
|
+
tolerance: float = 1e-4) -> str:
|
|
2391
|
+
"""Compare kernel output against a reference, so fast-but-wrong fails.
|
|
2392
|
+
|
|
2393
|
+
Read-only arithmetic on numbers the caller supplies, so it is not
|
|
2394
|
+
gated — it touches no device.
|
|
2395
|
+
"""
|
|
2396
|
+
try:
|
|
2397
|
+
act = [float(x) for x in actual.replace(",", " ").split()]
|
|
2398
|
+
except ValueError:
|
|
2399
|
+
return "actual must be a list of numbers."
|
|
2400
|
+
if reference:
|
|
2401
|
+
try:
|
|
2402
|
+
ref = [float(x) for x in reference.replace(",", " ").split()]
|
|
2403
|
+
except ValueError:
|
|
2404
|
+
return "reference must be a list of numbers."
|
|
2405
|
+
else:
|
|
2406
|
+
return ("A reference is required. Without one this is not a "
|
|
2407
|
+
"verification — it is a restatement of the kernel's own "
|
|
2408
|
+
"output, which is what a fast-but-wrong kernel produces.")
|
|
2409
|
+
r = G.verify(act, ref, tolerance=tolerance, label=kind)
|
|
2410
|
+
return (f"{'PASS' if r['passed'] else 'FAIL'} max|diff|="
|
|
2411
|
+
f"{r['max_abs_diff']:.3e} worst index {r['worst_index']}\n"
|
|
2412
|
+
f" {r['reason']}")
|
|
2413
|
+
|
|
2414
|
+
self._add(ToolSpec(
|
|
2415
|
+
name="gpu_probe",
|
|
2416
|
+
description=(
|
|
2417
|
+
"Report what CUDA silicon is reachable: device name, compute "
|
|
2418
|
+
"capability, memory, driver. Never fails; 'none' is an answer."
|
|
2419
|
+
),
|
|
2420
|
+
parameters={"type": "object", "properties": {}},
|
|
2421
|
+
fn=gpu_probe, source="builtin", tags=["gpu", "read"],
|
|
2422
|
+
))
|
|
2423
|
+
self._add(ToolSpec(
|
|
2424
|
+
name="gpu_occupancy",
|
|
2425
|
+
description=(
|
|
2426
|
+
"Estimate occupancy for a launch geometry from the "
|
|
2427
|
+
"architecture's static limits. Labelled as an estimate — "
|
|
2428
|
+
"measured occupancy needs a device."
|
|
2429
|
+
),
|
|
2430
|
+
parameters={"type": "object", "properties": {
|
|
2431
|
+
"block": {"type": "integer", "description": "threads per block"},
|
|
2432
|
+
"grid": {"type": "integer", "description": "blocks per grid (x)"},
|
|
2433
|
+
"shared_bytes": {"type": "integer"},
|
|
2434
|
+
"regs_per_thread": {"type": "integer"},
|
|
2435
|
+
"arch_major": {"type": "integer"},
|
|
2436
|
+
"arch_minor": {"type": "integer"},
|
|
2437
|
+
}},
|
|
2438
|
+
fn=gpu_occupancy, source="builtin", tags=["gpu", "read"],
|
|
2439
|
+
))
|
|
2440
|
+
self._add(ToolSpec(
|
|
2441
|
+
name="gpu_units_audit",
|
|
2442
|
+
description=(
|
|
2443
|
+
"Lint source for the do_bench-returns-ms bug: a value scaled by "
|
|
2444
|
+
"1000 in a statement labelled ms. Give source text or a path."
|
|
2445
|
+
),
|
|
2446
|
+
parameters={"type": "object", "properties": {
|
|
2447
|
+
"source": {"type": "string"},
|
|
2448
|
+
"path": {"type": "string", "description": "file or directory"},
|
|
2449
|
+
}},
|
|
2450
|
+
fn=gpu_units_audit, source="builtin", tags=["gpu", "read"],
|
|
2451
|
+
))
|
|
2452
|
+
self._add(ToolSpec(
|
|
2453
|
+
name="gpu_verify",
|
|
2454
|
+
description=(
|
|
2455
|
+
"Compare kernel output against a reference and report max "
|
|
2456
|
+
"absolute difference. Refuses without a reference — otherwise "
|
|
2457
|
+
"it would only restate the kernel's own output."
|
|
2458
|
+
),
|
|
2459
|
+
parameters={"type": "object", "properties": {
|
|
2460
|
+
"kind": {"type": "string", "description": "what is being checked"},
|
|
2461
|
+
"actual": {"type": "string", "description": "space/comma numbers"},
|
|
2462
|
+
"reference": {"type": "string", "description": "space/comma numbers"},
|
|
2463
|
+
"tolerance": {"type": "number"},
|
|
2464
|
+
}, "required": ["kind", "actual"]},
|
|
2465
|
+
fn=gpu_verify, source="builtin", tags=["gpu", "read"],
|
|
2466
|
+
))
|
|
2467
|
+
self._add(ToolSpec(
|
|
2468
|
+
name="gpu_compile",
|
|
2469
|
+
description=(
|
|
2470
|
+
"Compile CUDA C you wrote to a cubin, content-addressed so a "
|
|
2471
|
+
"repeat is a cache hit. Gated by may_run_cuda_kernels."
|
|
2472
|
+
),
|
|
2473
|
+
parameters={"type": "object", "properties": {
|
|
2474
|
+
"code": {"type": "string", "description": "CUDA C source"},
|
|
2475
|
+
"name": {"type": "string"},
|
|
2476
|
+
"arch_major": {"type": "integer"},
|
|
2477
|
+
"arch_minor": {"type": "integer"},
|
|
2478
|
+
}, "required": ["code"]},
|
|
2479
|
+
fn=gpu_compile, source="builtin", tags=["gpu", "run"],
|
|
2480
|
+
))
|
|
2481
|
+
self._add(ToolSpec(
|
|
2482
|
+
name="gpu_bench",
|
|
2483
|
+
description=(
|
|
2484
|
+
"Compile and time a kernel under a wall-clock budget, reporting "
|
|
2485
|
+
"the spread and whether the difference is signal or noise. "
|
|
2486
|
+
"Gated by may_run_cuda_kernels."
|
|
2487
|
+
),
|
|
2488
|
+
parameters={"type": "object", "properties": {
|
|
2489
|
+
"code": {"type": "string", "description": "CUDA C source"},
|
|
2490
|
+
"name": {"type": "string"},
|
|
2491
|
+
"block": {"type": "integer"},
|
|
2492
|
+
"grid": {"type": "integer"},
|
|
2493
|
+
"shared_bytes": {"type": "integer"},
|
|
2494
|
+
"reps": {"type": "integer"},
|
|
2495
|
+
"time_budget_s": {"type": "number"},
|
|
2496
|
+
"arch_major": {"type": "integer"},
|
|
2497
|
+
"arch_minor": {"type": "integer"},
|
|
2498
|
+
}, "required": ["code"]},
|
|
2499
|
+
fn=gpu_bench, source="builtin", tags=["gpu", "run"],
|
|
2500
|
+
))
|
|
2501
|
+
|
|
2502
|
+
# ------------------------------------------------------------------
|
|
2503
|
+
def _tool_cpu(self) -> None:
|
|
2504
|
+
"""Wire the native layer in, tool for tool the GPU layer's shape.
|
|
2505
|
+
|
|
2506
|
+
Same vocabulary on purpose — probe, preflight, units audit, cache,
|
|
2507
|
+
compile, run, tune — because the two layers are the same argument about
|
|
2508
|
+
different silicon, and a reader who learned one should not have to learn
|
|
2509
|
+
a second vocabulary to use the other.
|
|
2510
|
+
|
|
2511
|
+
What genuinely differs is called out rather than smoothed over:
|
|
2512
|
+
|
|
2513
|
+
* `cpu_runs_here` has no CUDA counterpart. A cubin built for sm_90
|
|
2514
|
+
simply fails to load. An x86 library built for a newer target loads
|
|
2515
|
+
fine and kills the interpreter at the first instruction it cannot
|
|
2516
|
+
execute, which is why the check is a separate tool and why
|
|
2517
|
+
`cpu_compile` consults it before building.
|
|
2518
|
+
* Five of the eight tools read or compute, and stay available with the
|
|
2519
|
+
gate off. Only `cpu_compile`, `cpu_run_isolated` and `cpu_tune`
|
|
2520
|
+
execute agent-authored code on the host, and those three refuse and
|
|
2521
|
+
name `may_run_cpu_kernels` when it is off.
|
|
2522
|
+
"""
|
|
2523
|
+
from . import cpu as C
|
|
2524
|
+
from .timing import audit_ms_scale, audit_source_tree
|
|
2525
|
+
|
|
2526
|
+
def _denied() -> str:
|
|
2527
|
+
return (
|
|
2528
|
+
"Denied by autonomy policy: may_run_cpu_kernels is off. "
|
|
2529
|
+
"Compiling and running native kernels is disabled; the "
|
|
2530
|
+
"toolchain probe, the SIGILL check, the preflight lint, the "
|
|
2531
|
+
"timing-unit audit and the cache report remain available."
|
|
2532
|
+
)
|
|
2533
|
+
|
|
2534
|
+
def cpu_probe() -> str:
|
|
2535
|
+
info = C.probe()
|
|
2536
|
+
if not info.available:
|
|
2537
|
+
why = "; ".join(info.notes) if info.notes else "no C compiler found"
|
|
2538
|
+
return f"No CPU toolchain usable for forging. {why}"
|
|
2539
|
+
return info.summary()
|
|
2540
|
+
|
|
2541
|
+
def cpu_runs_here(target: str = "native") -> str:
|
|
2542
|
+
info = C.probe()
|
|
2543
|
+
if not info.available:
|
|
2544
|
+
why = "; ".join(info.notes) if info.notes else "no C compiler found"
|
|
2545
|
+
return f"No verdict without a compiler to build for. {why}"
|
|
2546
|
+
ok, why = C.runs_here(target, info)
|
|
2547
|
+
verdict = "RUNS HERE" if ok else "WILL NOT RUN HERE"
|
|
2548
|
+
return f"{verdict}: {target}\n {why}"
|
|
2549
|
+
|
|
2550
|
+
def cpu_preflight(code: str, name: str = "kernel") -> str:
|
|
2551
|
+
try:
|
|
2552
|
+
src = C.KernelSource(name=name, code=code)
|
|
2553
|
+
except ValueError as exc:
|
|
2554
|
+
return f"Nothing to lint: {exc}"
|
|
2555
|
+
return C.preflight(src).summary()
|
|
2556
|
+
|
|
2557
|
+
def cpu_units_audit(source: str = "", path: str = "") -> str:
|
|
2558
|
+
if path:
|
|
2559
|
+
hits = audit_source_tree([path])
|
|
2560
|
+
if not hits:
|
|
2561
|
+
return (f"No thousand-factor-against-ms offences in {path}. "
|
|
2562
|
+
f"(Known false negative: the factor arriving through "
|
|
2563
|
+
f"a variable and the ms label in a different "
|
|
2564
|
+
f"statement.)")
|
|
2565
|
+
out = []
|
|
2566
|
+
for f, offs in hits.items():
|
|
2567
|
+
out.append(f"{f}:")
|
|
2568
|
+
out += [f" line {o.line_no}: {o.line}" for o in offs[:10]]
|
|
2569
|
+
return "\n".join(out)
|
|
2570
|
+
offs = audit_ms_scale(source)
|
|
2571
|
+
if not offs:
|
|
2572
|
+
return ("No offence found. Note the two accepted false "
|
|
2573
|
+
"negatives: a factor smuggled through a variable, and "
|
|
2574
|
+
"the label and the multiplication in different "
|
|
2575
|
+
"statements.")
|
|
2576
|
+
return "\n".join(f"line {o.line_no}: {o.line}\n {o.reason}"
|
|
2577
|
+
for o in offs)
|
|
2578
|
+
|
|
2579
|
+
def cpu_cache_stats() -> str:
|
|
2580
|
+
st = C.cache_stats()
|
|
2581
|
+
total = st["hits"] + st["misses"]
|
|
2582
|
+
if not total:
|
|
2583
|
+
return (f"Nothing compiled on this machine yet. Cache root "
|
|
2584
|
+
f"{st['root']} — the first compile will populate it, and "
|
|
2585
|
+
f"a repeat of the same source, target, compiler and "
|
|
2586
|
+
f"flags is a cache hit rather than a rebuild.")
|
|
2587
|
+
return (f"{st['hits']} hit(s), {st['misses']} miss(es), "
|
|
2588
|
+
f"{st['hit_rate']:.0%} hit rate under {st['root']}")
|
|
2589
|
+
|
|
2590
|
+
def cpu_compile(code: str, name: str = "kernel",
|
|
2591
|
+
target: str = "native") -> str:
|
|
2592
|
+
if not self.policy.may_run_cpu_kernels:
|
|
2593
|
+
return _denied()
|
|
2594
|
+
try:
|
|
2595
|
+
src = C.KernelSource(name=name, code=code, target=target)
|
|
2596
|
+
except ValueError as exc:
|
|
2597
|
+
return f"Refusing to compile: {exc}"
|
|
2598
|
+
if not src.entry_points():
|
|
2599
|
+
return ("Refusing to compile: no callable entry point in the "
|
|
2600
|
+
"source. Static functions and `main` cannot be called "
|
|
2601
|
+
"through ctypes, so a kernel made only of those has "
|
|
2602
|
+
"nothing to invoke. Entry points found: none.")
|
|
2603
|
+
# The module's own ordering argument, enforced: a build for a target
|
|
2604
|
+
# this machine cannot run is worse than no build, because the
|
|
2605
|
+
# failure is SIGILL in the agent's own process rather than an error
|
|
2606
|
+
# the agent can read.
|
|
2607
|
+
resolved = src.resolved_target()
|
|
2608
|
+
ok, why = C.runs_here(resolved, C.probe())
|
|
2609
|
+
if not ok:
|
|
2610
|
+
return (f"Refusing to compile for {target}: {why}\n"
|
|
2611
|
+
f" Build for a target this machine can run, or drop the "
|
|
2612
|
+
f"artefact in a child process and let it die there.")
|
|
2613
|
+
try:
|
|
2614
|
+
k = C.compile_kernel(src)
|
|
2615
|
+
except C.KernelUnavailable as exc:
|
|
2616
|
+
self._record("cpu_compile", {"name": name, "ok": False})
|
|
2617
|
+
return f"Could not compile: {exc}"
|
|
2618
|
+
self._record("cpu_compile", {"name": name, "ok": True,
|
|
2619
|
+
"cached": k.from_cache})
|
|
2620
|
+
d = k.to_dict()
|
|
2621
|
+
return (f"Compiled {d['name']} for {d['target']} via {d['compiler']}"
|
|
2622
|
+
f"{' (cache hit)' if k.from_cache else ''}; "
|
|
2623
|
+
f"entries: {', '.join(d['entry_points'])}"
|
|
2624
|
+
f"\ncache key {d['cache_key']}\nartefact {d['artefact']}")
|
|
2625
|
+
|
|
2626
|
+
def cpu_run_isolated(
|
|
2627
|
+
code: str, name: str = "kernel", kind: str = "saxpy",
|
|
2628
|
+
size: int = 1 << 20, n: int = 256, dtype: str = "f32",
|
|
2629
|
+
seed: int = 0, reps: int = 5, warmup: int = 3,
|
|
2630
|
+
timeout: float = 0.0, target: str = "native",
|
|
2631
|
+
) -> str:
|
|
2632
|
+
"""Compile, then verify and time in a child process.
|
|
2633
|
+
|
|
2634
|
+
Isolation is not a convenience here. An unproven kernel's first
|
|
2635
|
+
call must not be in the agent's own process: a wrong index
|
|
2636
|
+
segfaults, an unbounded loop hangs, and both would otherwise end
|
|
2637
|
+
the session instead of producing a report.
|
|
2638
|
+
"""
|
|
2639
|
+
if not self.policy.may_run_cpu_kernels:
|
|
2640
|
+
return _denied()
|
|
2641
|
+
try:
|
|
2642
|
+
prob = C.problem(kind, size=size, n=n, dtype=dtype, seed=seed)
|
|
2643
|
+
except C.CallError as exc:
|
|
2644
|
+
return f"No such problem: {exc}"
|
|
2645
|
+
try:
|
|
2646
|
+
src = C.KernelSource(name=name, code=code, target=target)
|
|
2647
|
+
except ValueError as exc:
|
|
2648
|
+
return f"Refusing to run: {exc}"
|
|
2649
|
+
try:
|
|
2650
|
+
k = C.compile_kernel(src)
|
|
2651
|
+
except C.KernelUnavailable as exc:
|
|
2652
|
+
return f"Could not compile: {exc}"
|
|
2653
|
+
guard = C.run_isolated(k, prob, reps=reps, warmup=warmup,
|
|
2654
|
+
timeout=timeout or None)
|
|
2655
|
+
self._record("cpu_run_isolated",
|
|
2656
|
+
{"name": name, "kind": kind, "ok": guard.ok})
|
|
2657
|
+
lines = [guard.summary()]
|
|
2658
|
+
timings = guard.verdict.get("timings_s") or []
|
|
2659
|
+
if guard.ok and timings:
|
|
2660
|
+
lines.append(f" {len(timings)} timed rep(s), fastest "
|
|
2661
|
+
f"{min(timings) * 1e3:.4f} ms")
|
|
2662
|
+
lines.append(f" checked against the reference for {kind!r} "
|
|
2663
|
+
f"({prob.spec.describe()}) — skipping work fails here "
|
|
2664
|
+
f"rather than being timed as an improvement.")
|
|
2665
|
+
return "\n".join(lines)
|
|
2666
|
+
|
|
2667
|
+
def cpu_tune(
|
|
2668
|
+
kind: str = "saxpy", size: int = 1 << 20, n: int = 256,
|
|
2669
|
+
dtype: str = "f32", seed: int = 0,
|
|
2670
|
+
max_candidates: int = C.DEFAULT_MAX_CANDIDATES,
|
|
2671
|
+
budget_s: float = C.DEFAULT_BUDGET_S,
|
|
2672
|
+
reps: int = 5, warmup: int = 3,
|
|
2673
|
+
) -> str:
|
|
2674
|
+
if not self.policy.may_run_cpu_kernels:
|
|
2675
|
+
return _denied()
|
|
2676
|
+
try:
|
|
2677
|
+
res = C.tune_kind(kind, size=size, n=n, dtype=dtype, seed=seed,
|
|
2678
|
+
max_candidates=max_candidates,
|
|
2679
|
+
budget_s=budget_s, reps=reps, warmup=warmup)
|
|
2680
|
+
except C.CallError as exc:
|
|
2681
|
+
return f"No such problem: {exc}"
|
|
2682
|
+
except KeyError as exc:
|
|
2683
|
+
return f"Nothing to tune: {exc}"
|
|
2684
|
+
self._record("cpu_tune",
|
|
2685
|
+
{"kind": kind, "candidates": res.n_candidates,
|
|
2686
|
+
"ok": res.winner is not None})
|
|
2687
|
+
return res.report()
|
|
2688
|
+
|
|
2689
|
+
self._add(ToolSpec(
|
|
2690
|
+
name="cpu_probe",
|
|
2691
|
+
description=(
|
|
2692
|
+
"Report what native silicon and toolchain is reachable: CPU "
|
|
2693
|
+
"name, cores, SIMD features, cache, compiler and version, and "
|
|
2694
|
+
"what -march=native resolves to. Never fails; 'none' is an "
|
|
2695
|
+
"answer."
|
|
2696
|
+
),
|
|
2697
|
+
parameters={"type": "object", "properties": {}},
|
|
2698
|
+
fn=cpu_probe, source="builtin", tags=["cpu", "read"],
|
|
2699
|
+
))
|
|
2700
|
+
self._add(ToolSpec(
|
|
2701
|
+
name="cpu_runs_here",
|
|
2702
|
+
description=(
|
|
2703
|
+
"Ask whether an artefact built for a -march target can run on "
|
|
2704
|
+
"this machine. Call before compiling for anything but the "
|
|
2705
|
+
"local default: a build for a newer target loads fine and "
|
|
2706
|
+
"dies at the first unsupported instruction."
|
|
2707
|
+
),
|
|
2708
|
+
parameters={"type": "object", "properties": {
|
|
2709
|
+
"target": {"type": "string",
|
|
2710
|
+
"description": "-march value, e.g. native, x86-64, znver3"},
|
|
2711
|
+
}},
|
|
2712
|
+
fn=cpu_runs_here, source="builtin", tags=["cpu", "read"],
|
|
2713
|
+
))
|
|
2714
|
+
self._add(ToolSpec(
|
|
2715
|
+
name="cpu_preflight",
|
|
2716
|
+
description=(
|
|
2717
|
+
"Lint C source for the mistakes worth knowing before it is "
|
|
2718
|
+
"compiled: off-by-one loop bounds, unchecked sizes, the "
|
|
2719
|
+
"usual buffer hazards. Advice, not a gate — a finding does "
|
|
2720
|
+
"not stop the build."
|
|
2721
|
+
),
|
|
2722
|
+
parameters={"type": "object", "properties": {
|
|
2723
|
+
"code": {"type": "string", "description": "C source"},
|
|
2724
|
+
"name": {"type": "string"},
|
|
2725
|
+
}, "required": ["code"]},
|
|
2726
|
+
fn=cpu_preflight, source="builtin", tags=["cpu", "read"],
|
|
2727
|
+
))
|
|
2728
|
+
self._add(ToolSpec(
|
|
2729
|
+
name="cpu_units_audit",
|
|
2730
|
+
description=(
|
|
2731
|
+
"Lint source for the do_bench-returns-ms bug: a value scaled "
|
|
2732
|
+
"by 1000 in a statement labelled ms. Give source text or a "
|
|
2733
|
+
"path."
|
|
2734
|
+
),
|
|
2735
|
+
parameters={"type": "object", "properties": {
|
|
2736
|
+
"source": {"type": "string"},
|
|
2737
|
+
"path": {"type": "string", "description": "file or directory"},
|
|
2738
|
+
}},
|
|
2739
|
+
fn=cpu_units_audit, source="builtin", tags=["cpu", "read"],
|
|
2740
|
+
))
|
|
2741
|
+
self._add(ToolSpec(
|
|
2742
|
+
name="cpu_cache_stats",
|
|
2743
|
+
description=(
|
|
2744
|
+
"Report the content-addressed kernel cache: hit and miss "
|
|
2745
|
+
"counts. Compiling the same source for the same target with "
|
|
2746
|
+
"the same compiler is a hit, not a rebuild."
|
|
2747
|
+
),
|
|
2748
|
+
parameters={"type": "object", "properties": {}},
|
|
2749
|
+
fn=cpu_cache_stats, source="builtin", tags=["cpu", "read"],
|
|
2750
|
+
))
|
|
2751
|
+
self._add(ToolSpec(
|
|
2752
|
+
name="cpu_compile",
|
|
2753
|
+
description=(
|
|
2754
|
+
"Compile C you wrote to a loadable library, content-addressed "
|
|
2755
|
+
"on target, compiler version and flags, so a repeat is a cache "
|
|
2756
|
+
"hit. Refuses a target this machine cannot run, and refuses "
|
|
2757
|
+
"source with no ctypes-callable entry point. Gated by "
|
|
2758
|
+
"may_run_cpu_kernels."
|
|
2759
|
+
),
|
|
2760
|
+
parameters={"type": "object", "properties": {
|
|
2761
|
+
"code": {"type": "string", "description": "C source"},
|
|
2762
|
+
"name": {"type": "string"},
|
|
2763
|
+
"target": {"type": "string", "description": "-march value"},
|
|
2764
|
+
}, "required": ["code"]},
|
|
2765
|
+
fn=cpu_compile, source="builtin", tags=["cpu", "run"],
|
|
2766
|
+
))
|
|
2767
|
+
self._add(ToolSpec(
|
|
2768
|
+
name="cpu_run_isolated",
|
|
2769
|
+
description=(
|
|
2770
|
+
"Compile, verify against the problem's reference answer, and "
|
|
2771
|
+
"time - all in a child process, so a segfault or an unbounded "
|
|
2772
|
+
"loop is a report instead of the end of the session. "
|
|
2773
|
+
"Verification runs before measurement, always. Gated by "
|
|
2774
|
+
"may_run_cpu_kernels."
|
|
2775
|
+
),
|
|
2776
|
+
parameters={"type": "object", "properties": {
|
|
2777
|
+
"code": {"type": "string", "description": "C source"},
|
|
2778
|
+
"name": {"type": "string"},
|
|
2779
|
+
"kind": {"type": "string",
|
|
2780
|
+
"description": f"problem: {', '.join(sorted(C.NAIVE))}"},
|
|
2781
|
+
"size": {"type": "integer"},
|
|
2782
|
+
"n": {"type": "integer"},
|
|
2783
|
+
"dtype": {"type": "string", "enum": list(C.DTYPES)},
|
|
2784
|
+
"seed": {"type": "integer"},
|
|
2785
|
+
"reps": {"type": "integer"},
|
|
2786
|
+
"warmup": {"type": "integer"},
|
|
2787
|
+
"timeout": {"type": "number",
|
|
2788
|
+
"description": "seconds; 0 uses the module default"},
|
|
2789
|
+
"target": {"type": "string"},
|
|
2790
|
+
}, "required": ["code"]},
|
|
2791
|
+
fn=cpu_run_isolated, source="builtin", tags=["cpu", "run"],
|
|
2792
|
+
))
|
|
2793
|
+
self._add(ToolSpec(
|
|
2794
|
+
name="cpu_tune",
|
|
2795
|
+
description=(
|
|
2796
|
+
"Search for a faster correct kernel from a naive starting "
|
|
2797
|
+
"point: generate, compile, verify, measure, mutate, keep the "
|
|
2798
|
+
"winner — against a baseline strong enough that a win means "
|
|
2799
|
+
"something. Gated by may_run_cpu_kernels."
|
|
2800
|
+
),
|
|
2801
|
+
parameters={"type": "object", "properties": {
|
|
2802
|
+
"kind": {"type": "string",
|
|
2803
|
+
"description": f"problem: {', '.join(sorted(C.NAIVE))}"},
|
|
2804
|
+
"size": {"type": "integer"},
|
|
2805
|
+
"n": {"type": "integer"},
|
|
2806
|
+
"dtype": {"type": "string", "enum": list(C.DTYPES)},
|
|
2807
|
+
"seed": {"type": "integer"},
|
|
2808
|
+
"max_candidates": {"type": "integer"},
|
|
2809
|
+
"budget_s": {"type": "number"},
|
|
2810
|
+
"reps": {"type": "integer"},
|
|
2811
|
+
"warmup": {"type": "integer"},
|
|
2812
|
+
}},
|
|
2813
|
+
fn=cpu_tune, source="builtin", tags=["cpu", "run"],
|
|
2814
|
+
))
|
|
2815
|
+
|
|
2816
|
+
# ------------------------------------------------------------------
|
|
2817
|
+
def _sync_amendment(self, a: Amendment) -> None:
|
|
2818
|
+
self._record("amendment", a.to_dict())
|
|
2819
|
+
if self.store:
|
|
2820
|
+
self.store.log_event("amendment", a.to_dict())
|
|
2821
|
+
|
|
2822
|
+
def _on_forge_event(self, kind: str, payload: dict[str, Any]) -> None:
|
|
2823
|
+
self._record(kind, payload)
|
|
2824
|
+
if self.store and kind in ("forge_done", "auto_quarantine"):
|
|
2825
|
+
self.store.log_event(kind, payload)
|
|
2826
|
+
|
|
2827
|
+
def _on_compact(self, event: Any) -> None:
|
|
2828
|
+
"""A compaction is an event the operator should be able to see.
|
|
2829
|
+
|
|
2830
|
+
It is the one thing in a long run that changes the context without the
|
|
2831
|
+
model asking for it, so a run that quietly stopped remembering and a run
|
|
2832
|
+
that never had to would otherwise look identical from the trace.
|
|
2833
|
+
"""
|
|
2834
|
+
payload = (event.as_dict() if hasattr(event, "as_dict")
|
|
2835
|
+
else {"event": repr(event)})
|
|
2836
|
+
self._record("compact", payload)
|
|
2837
|
+
if self.store:
|
|
2838
|
+
self.store.log_event("compact", payload)
|
|
2839
|
+
|
|
2840
|
+
def _record(self, kind: str, payload: dict[str, Any]) -> None:
|
|
2841
|
+
self.trace.append({"kind": kind, **payload})
|
|
2842
|
+
# Forward to the live observer, if one is attached, so forging shows up
|
|
2843
|
+
# while it happens instead of being rendered once the task is over.
|
|
2844
|
+
if self._progress is not None:
|
|
2845
|
+
try:
|
|
2846
|
+
self._progress(kind, payload)
|
|
2847
|
+
except Exception: # a bad observer must not run the task
|
|
2848
|
+
pass
|
|
2849
|
+
|
|
2850
|
+
# ------------------------------------------------------------------
|
|
2851
|
+
def apply_role(self, brief: str, role: str = "") -> None:
|
|
2852
|
+
"""Take on a role assigned by the agent that spawned this one.
|
|
2853
|
+
|
|
2854
|
+
The counterpart of `TopologyDesigner`: a designer that can *name* five
|
|
2855
|
+
roles is worth nothing if the runtime that instantiates them ignores the
|
|
2856
|
+
name. Called by `Spawner.spawn` on any child that implements it.
|
|
2857
|
+
"""
|
|
2858
|
+
self.role_brief = brief.strip()
|
|
2859
|
+
self.role = role or self.role
|
|
2860
|
+
|
|
2861
|
+
def _effective_prompt(self) -> str:
|
|
2862
|
+
"""The prompt actually sent: the base, plus what was measured.
|
|
2863
|
+
|
|
2864
|
+
Recomputed per run, so the numbers are current even after the agent has
|
|
2865
|
+
amended its own prompt — and so the host block describes the sandbox
|
|
2866
|
+
this process will actually fork, not the machine the agent imagines.
|
|
2867
|
+
"""
|
|
2868
|
+
facts = "\n".join(host_facts(self.sandbox))
|
|
2869
|
+
kept = "\n".join(self._memory_lines())
|
|
2870
|
+
menu = "\n".join(self.skills.menu())
|
|
2871
|
+
out = (f"{self.system_prompt}\n\n{self._self_report()}\n\n"
|
|
2872
|
+
f"{kept}\n\n{menu}\n\n{facts}")
|
|
2873
|
+
if self.role_brief:
|
|
2874
|
+
# Last, and separately labelled: a role narrows what this run is
|
|
2875
|
+
# for, and the point of putting it after the general instructions is
|
|
2876
|
+
# that the specific beats the general when a model stops reading.
|
|
2877
|
+
out += (f"\n\n## Your role in this team ({self.role or 'unnamed'})"
|
|
2878
|
+
f"\n\n{self.role_brief}")
|
|
2879
|
+
return out
|
|
2880
|
+
|
|
2881
|
+
# ------------------------------------------------------------------
|
|
2882
|
+
def run(self, task: str, history: list[Message] | None = None,
|
|
2883
|
+
progress: Callable[[str, dict[str, Any]], None] | None = None) -> AgentResult:
|
|
2884
|
+
"""Run one task.
|
|
2885
|
+
|
|
2886
|
+
`progress(kind, payload)` is called as the loop moves — `request` before
|
|
2887
|
+
each model call, `call`/`result` around each tool. The CLI uses it to
|
|
2888
|
+
print live status, so a slow model reads as "waiting", not "hung".
|
|
2889
|
+
"""
|
|
2890
|
+
def _emit(kind: str, **payload: Any) -> None:
|
|
2891
|
+
if progress:
|
|
2892
|
+
progress(kind, payload)
|
|
2893
|
+
|
|
2894
|
+
# Also attach it to the trace, so records written by forging and
|
|
2895
|
+
# self-modification stream live too — not just the loop's own events.
|
|
2896
|
+
self._progress = progress
|
|
2897
|
+
try:
|
|
2898
|
+
return self._run_locked(task, history, _emit)
|
|
2899
|
+
finally:
|
|
2900
|
+
self._progress = None
|
|
2901
|
+
|
|
2902
|
+
def _run_locked(self, task: str, history: list[Message] | None,
|
|
2903
|
+
_emit: Callable[..., None]) -> AgentResult:
|
|
2904
|
+
agent = Agent(
|
|
2905
|
+
self.llm, self.registry,
|
|
2906
|
+
system_prompt=self._effective_prompt(),
|
|
2907
|
+
max_turns=self.max_turns,
|
|
2908
|
+
allow_self_terminate=self.policy.self_terminate,
|
|
2909
|
+
on_request=lambda turn: _emit("request", turn=turn),
|
|
2910
|
+
on_tool_call=lambda n, a: self._record("call", {"tool": n}),
|
|
2911
|
+
on_tool_result=lambda n, r: self._record(
|
|
2912
|
+
"result", {"tool": n, "ok": getattr(r, "ok", None)}),
|
|
2913
|
+
on_turn=lambda turn, msg: _emit("turn", turn=turn),
|
|
2914
|
+
on_steer=lambda text: self._record("steer", {"text": text[:300]}),
|
|
2915
|
+
steer=self.steer,
|
|
2916
|
+
compactor=self.compactor,
|
|
2917
|
+
on_compact=self._on_compact,
|
|
2918
|
+
)
|
|
2919
|
+
result = agent.run(task, history)
|
|
2920
|
+
self._record("finish", {
|
|
2921
|
+
"turns": result.turns, "tools": result.tool_calls,
|
|
2922
|
+
"self_terminated": result.self_terminated,
|
|
2923
|
+
"stopped_by_operator": result.stopped_by_operator,
|
|
2924
|
+
})
|
|
2925
|
+
if self.store:
|
|
2926
|
+
self.store.log_event("run", {
|
|
2927
|
+
"task": task[:300], "turns": result.turns,
|
|
2928
|
+
"self_terminated": result.self_terminated,
|
|
2929
|
+
"stopped_by_operator": result.stopped_by_operator,
|
|
2930
|
+
})
|
|
2931
|
+
return result
|
|
2932
|
+
|
|
2933
|
+
# ------------------------------------------------------------------
|
|
2934
|
+
def report(self) -> dict[str, Any]:
|
|
2935
|
+
return {
|
|
2936
|
+
"policy": self.policy.to_dict(),
|
|
2937
|
+
"denied": self.policy.denied,
|
|
2938
|
+
"policy_summary": self.policy.describe(),
|
|
2939
|
+
# Freedoms switched off that nothing obeys. Empty is the healthy
|
|
2940
|
+
# answer; listing them here stops a policy from reading like a cage
|
|
2941
|
+
# while behaving like a comment.
|
|
2942
|
+
"unenforced": self.policy.unenforced,
|
|
2943
|
+
"tools": self.registry.report(),
|
|
2944
|
+
"amendments": self.selfmod.log(),
|
|
2945
|
+
"spawns": self.spawner.summary(),
|
|
2946
|
+
"topology": self.topology.to_dict(),
|
|
2947
|
+
"trace_len": len(self.trace),
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
|
|
2951
|
+
def _default_agent_factory(parent: ForgeAgent):
|
|
2952
|
+
"""Children are ForgeAgents that share the parent's LLM but (optionally)
|
|
2953
|
+
get their own registry — decided by the caller via ShareMode."""
|
|
2954
|
+
def factory(spawner: Spawner, registry: ToolRegistry) -> ForgeAgent:
|
|
2955
|
+
child = ForgeAgent(
|
|
2956
|
+
llm=parent.llm,
|
|
2957
|
+
registry=registry,
|
|
2958
|
+
sandbox=parent.sandbox,
|
|
2959
|
+
generator=parent.generator,
|
|
2960
|
+
forge_config=parent.forge_config,
|
|
2961
|
+
max_turns=parent.max_turns,
|
|
2962
|
+
policy=parent.policy,
|
|
2963
|
+
enable_meta_cognition=False,
|
|
2964
|
+
enable_evolution=parent.enable_evolution,
|
|
2965
|
+
)
|
|
2966
|
+
child.spawner = spawner.child_spawner(registry)
|
|
2967
|
+
return child
|
|
2968
|
+
return factory
|
|
2969
|
+
|
|
2970
|
+
|
|
2971
|
+
__all__ = ["ForgeAgent", "AUTONOMOUS_SYSTEM"]
|