autoforge-agent 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. autoforge/__init__.py +87 -0
  2. autoforge/__main__.py +16 -0
  3. autoforge/agent.py +2971 -0
  4. autoforge/autonomy/__init__.py +14 -0
  5. autoforge/autonomy/confirm.py +130 -0
  6. autoforge/autonomy/policy.py +245 -0
  7. autoforge/autonomy/roles.py +191 -0
  8. autoforge/autonomy/selfmod.py +146 -0
  9. autoforge/autonomy/spawn.py +227 -0
  10. autoforge/autonomy/topology.py +235 -0
  11. autoforge/browser.py +924 -0
  12. autoforge/cli.py +1119 -0
  13. autoforge/configfile.py +86 -0
  14. autoforge/core/__init__.py +29 -0
  15. autoforge/core/agent.py +271 -0
  16. autoforge/core/compaction.py +765 -0
  17. autoforge/core/lineedit.py +678 -0
  18. autoforge/core/llm.py +324 -0
  19. autoforge/core/message.py +89 -0
  20. autoforge/core/steering.py +280 -0
  21. autoforge/cpu/__init__.py +99 -0
  22. autoforge/cpu/kernel.py +442 -0
  23. autoforge/cpu/ops.py +652 -0
  24. autoforge/cpu/probe.py +569 -0
  25. autoforge/cpu/safety.py +463 -0
  26. autoforge/cpu/tune.py +564 -0
  27. autoforge/ecosystem.py +485 -0
  28. autoforge/forge/__init__.py +21 -0
  29. autoforge/forge/adversary.py +206 -0
  30. autoforge/forge/evolution.py +476 -0
  31. autoforge/forge/fuzzer.py +328 -0
  32. autoforge/forge/generator.py +591 -0
  33. autoforge/forge/invariance.py +369 -0
  34. autoforge/forge/metacog.py +177 -0
  35. autoforge/forge/pipeline.py +304 -0
  36. autoforge/forge/sandbox.py +301 -0
  37. autoforge/forge/validity.py +608 -0
  38. autoforge/forge/verifier.py +365 -0
  39. autoforge/gpu/__init__.py +116 -0
  40. autoforge/gpu/backend_triton.py +421 -0
  41. autoforge/gpu/bench.py +30 -0
  42. autoforge/gpu/kernel.py +430 -0
  43. autoforge/gpu/ops.py +349 -0
  44. autoforge/gpu/probe.py +267 -0
  45. autoforge/gpu/safety.py +248 -0
  46. autoforge/gpu/units.py +39 -0
  47. autoforge/mcp.py +1050 -0
  48. autoforge/measure.py +368 -0
  49. autoforge/modes.py +299 -0
  50. autoforge/notify.py +339 -0
  51. autoforge/route/__init__.py +3 -0
  52. autoforge/route/router.py +217 -0
  53. autoforge/schedule.py +427 -0
  54. autoforge/setup_wizard.py +232 -0
  55. autoforge/skills.py +496 -0
  56. autoforge/store.py +611 -0
  57. autoforge/timing.py +375 -0
  58. autoforge/tools/__init__.py +4 -0
  59. autoforge/tools/composition.py +143 -0
  60. autoforge/tools/registry.py +392 -0
  61. autoforge/tools/spec.py +230 -0
  62. autoforge/vision.py +279 -0
  63. autoforge/web/__init__.py +5 -0
  64. autoforge/web/server.py +689 -0
  65. autoforge/webtools.py +1382 -0
  66. autoforge_agent-0.4.0.dist-info/METADATA +477 -0
  67. autoforge_agent-0.4.0.dist-info/RECORD +70 -0
  68. autoforge_agent-0.4.0.dist-info/WHEEL +5 -0
  69. autoforge_agent-0.4.0.dist-info/entry_points.txt +3 -0
  70. autoforge_agent-0.4.0.dist-info/top_level.txt +1 -0
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"]