autoforge-agent 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. autoforge/__init__.py +87 -0
  2. autoforge/__main__.py +16 -0
  3. autoforge/agent.py +2971 -0
  4. autoforge/autonomy/__init__.py +14 -0
  5. autoforge/autonomy/confirm.py +130 -0
  6. autoforge/autonomy/policy.py +245 -0
  7. autoforge/autonomy/roles.py +191 -0
  8. autoforge/autonomy/selfmod.py +146 -0
  9. autoforge/autonomy/spawn.py +227 -0
  10. autoforge/autonomy/topology.py +235 -0
  11. autoforge/browser.py +924 -0
  12. autoforge/cli.py +1119 -0
  13. autoforge/configfile.py +86 -0
  14. autoforge/core/__init__.py +29 -0
  15. autoforge/core/agent.py +271 -0
  16. autoforge/core/compaction.py +765 -0
  17. autoforge/core/lineedit.py +678 -0
  18. autoforge/core/llm.py +324 -0
  19. autoforge/core/message.py +89 -0
  20. autoforge/core/steering.py +280 -0
  21. autoforge/cpu/__init__.py +99 -0
  22. autoforge/cpu/kernel.py +442 -0
  23. autoforge/cpu/ops.py +652 -0
  24. autoforge/cpu/probe.py +569 -0
  25. autoforge/cpu/safety.py +463 -0
  26. autoforge/cpu/tune.py +564 -0
  27. autoforge/ecosystem.py +485 -0
  28. autoforge/forge/__init__.py +21 -0
  29. autoforge/forge/adversary.py +206 -0
  30. autoforge/forge/evolution.py +476 -0
  31. autoforge/forge/fuzzer.py +328 -0
  32. autoforge/forge/generator.py +591 -0
  33. autoforge/forge/invariance.py +369 -0
  34. autoforge/forge/metacog.py +177 -0
  35. autoforge/forge/pipeline.py +304 -0
  36. autoforge/forge/sandbox.py +301 -0
  37. autoforge/forge/validity.py +608 -0
  38. autoforge/forge/verifier.py +365 -0
  39. autoforge/gpu/__init__.py +116 -0
  40. autoforge/gpu/backend_triton.py +421 -0
  41. autoforge/gpu/bench.py +30 -0
  42. autoforge/gpu/kernel.py +430 -0
  43. autoforge/gpu/ops.py +349 -0
  44. autoforge/gpu/probe.py +267 -0
  45. autoforge/gpu/safety.py +248 -0
  46. autoforge/gpu/units.py +39 -0
  47. autoforge/mcp.py +1050 -0
  48. autoforge/measure.py +368 -0
  49. autoforge/modes.py +299 -0
  50. autoforge/notify.py +339 -0
  51. autoforge/route/__init__.py +3 -0
  52. autoforge/route/router.py +217 -0
  53. autoforge/schedule.py +427 -0
  54. autoforge/setup_wizard.py +232 -0
  55. autoforge/skills.py +496 -0
  56. autoforge/store.py +611 -0
  57. autoforge/timing.py +375 -0
  58. autoforge/tools/__init__.py +4 -0
  59. autoforge/tools/composition.py +143 -0
  60. autoforge/tools/registry.py +392 -0
  61. autoforge/tools/spec.py +230 -0
  62. autoforge/vision.py +279 -0
  63. autoforge/web/__init__.py +5 -0
  64. autoforge/web/server.py +689 -0
  65. autoforge/webtools.py +1382 -0
  66. autoforge_agent-0.4.0.dist-info/METADATA +477 -0
  67. autoforge_agent-0.4.0.dist-info/RECORD +70 -0
  68. autoforge_agent-0.4.0.dist-info/WHEEL +5 -0
  69. autoforge_agent-0.4.0.dist-info/entry_points.txt +3 -0
  70. autoforge_agent-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,14 @@
1
+ from autoforge.autonomy.policy import FULL_FREEDOM, SUPERVISED, AutonomyPolicy
2
+ from autoforge.autonomy.selfmod import Amendment, SelfModifier
3
+ from autoforge.autonomy.spawn import ShareMode, Spawner, SpawnRecord
4
+
5
+ __all__ = [
6
+ "AutonomyPolicy",
7
+ "FULL_FREEDOM",
8
+ "SUPERVISED",
9
+ "SelfModifier",
10
+ "Amendment",
11
+ "Spawner",
12
+ "SpawnRecord",
13
+ "ShareMode",
14
+ ]
@@ -0,0 +1,130 @@
1
+ """The confirmation gate: turning a switched-off freedom into a question.
2
+
3
+ The four execution freedoms (`may_read_filesystem`, `may_write_filesystem`,
4
+ `may_access_network`, `may_install_packages`) used to be declared-only. The
5
+ policy classified them as such and printed the classification, which was
6
+ honest, but honesty about a dead switch is still a dead switch — turning one
7
+ off changed nothing at all.
8
+
9
+ This module gives them teeth of a specific kind: OFF now means *not without a
10
+ yes*. Before a tool whose declared scope needs a disabled freedom runs, the
11
+ gate stops and asks the operator. Three properties make that more useful than
12
+ a hard refusal:
13
+
14
+ * it is a real gate — the switch you flip changes what happens next;
15
+ * it is not a dead end — the operator can say yes, and the tool runs;
16
+ * it fails closed when nobody is there to answer (a headless run has no one
17
+ to ask, and "no answer" must not read as "yes").
18
+
19
+ The alternative reading of "off" — silently permit — is the one thing this
20
+ avoids: a policy that claims a restriction it does not apply is worse than no
21
+ restriction, because it is a safety net you would trust with your weight.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import Any, Callable
26
+
27
+ #: The capabilities a tool can declare needing.
28
+ #:
29
+ #: `read_only` is treated as reading even though validity's drift table lists
30
+ #: no capability for it. The two tables answer different questions — validity
31
+ #: asks "did the tool exceed what it declared?", the gate asks "may this run?"
32
+ #: — so the read capability is added here rather than by editing that table,
33
+ #: where dropping it into `read_only` would weaken the drift check.
34
+ CAPABILITY_FREEDOM: dict[str, str] = {
35
+ "filesystem_read": "may_read_filesystem",
36
+ "credential_access": "may_read_filesystem",
37
+ "filesystem_write": "may_write_filesystem",
38
+ "filesystem_delete": "may_write_filesystem",
39
+ "network_egress": "may_access_network",
40
+ "subprocess": "may_run_arbitrary_code",
41
+ "process_control": "may_run_arbitrary_code",
42
+ "dynamic_code_execution": "may_run_arbitrary_code",
43
+ "deserialization": "may_run_arbitrary_code",
44
+ }
45
+
46
+ #: Capabilities implied by a scope beyond what validity's table lists.
47
+ _SCOPE_EXTRA: dict[str, frozenset[str]] = {
48
+ "read_only": frozenset({"filesystem_read"}),
49
+ "local_write": frozenset({"filesystem_read"}),
50
+ "network": frozenset({"filesystem_read"}),
51
+ "system": frozenset({"filesystem_read"}),
52
+ "privileged": frozenset({"filesystem_read"}),
53
+ }
54
+
55
+ #: A confirmer is asked once per gated call: `(tool_name, arguments, freedoms)`.
56
+ #: It answers `True` to let the call through, `False` to refuse it, or `None`
57
+ #: when there is nobody to ask — a headless run, a worker thread with a browser
58
+ #: on the other end, a closed stdin. The gate refuses on `None` too, but says
59
+ #: "nobody to ask" rather than "the operator said no", because those are
60
+ #: different facts about who decided.
61
+ Confirmer = Callable[[str, dict[str, Any], list[str]], bool | None]
62
+
63
+
64
+ def declared_capabilities(scope: str) -> frozenset[str]:
65
+ """What a tool's declared scope says it needs.
66
+
67
+ An undeclared or unrecognised scope is treated as capable of everything:
68
+ the gate's job is to ask when it cannot tell, not to assume innocence.
69
+ """
70
+ from ..forge.validity import SCOPE_ALLOWANCES # local: avoid cycle
71
+
72
+ if scope not in SCOPE_ALLOWANCES:
73
+ scope = "undeclared"
74
+ return frozenset(SCOPE_ALLOWANCES[scope]) | _SCOPE_EXTRA.get(scope, frozenset())
75
+
76
+
77
+ def _normalise_scope(effect_signature: str) -> str:
78
+ """The scope name, or `undeclared` when it is not one we recognise.
79
+
80
+ Mirrors `validity.declared_scope`, which reads the same field off a
81
+ ToolSpec. Done here rather than by calling it with a stand-in object so the
82
+ gate has no dependency on the tool being a real spec.
83
+ """
84
+ from ..forge.validity import SCOPE_ALLOWANCES # local: avoid cycle
85
+
86
+ sig = (effect_signature or "").strip().lower()
87
+ return sig if sig in SCOPE_ALLOWANCES else "undeclared"
88
+
89
+
90
+ def required_freedoms(effect_signature: str) -> tuple[str, ...]:
91
+ """The policy fields a tool with this effect signature depends on."""
92
+ freedoms = {
93
+ CAPABILITY_FREEDOM[cap]
94
+ for cap in declared_capabilities(_normalise_scope(effect_signature))
95
+ if cap in CAPABILITY_FREEDOM
96
+ }
97
+ return tuple(sorted(freedoms))
98
+
99
+
100
+ def disabled_freedoms(effect_signature: str, policy: Any) -> list[str]:
101
+ """Which of a tool's required freedoms this policy has switched off.
102
+
103
+ Empty means the gate has nothing to ask about and the tool may run.
104
+
105
+ Only the four CONFIRM_REQUIRED freedoms are ever returned. The other
106
+ freedoms a scope can need — `may_run_arbitrary_code` above all — already
107
+ have their own gates inside the tools that honour them, and asking twice
108
+ for one decision is how an operator learns to answer without reading.
109
+ `required_freedoms` still reports the full dependency; this is the
110
+ subset the gate is answerable for.
111
+ """
112
+ from .policy import CONFIRM_REQUIRED # local: avoid cycle
113
+
114
+ return sorted(
115
+ f for f in required_freedoms(effect_signature)
116
+ if f in CONFIRM_REQUIRED and not getattr(policy, f, True)
117
+ )
118
+
119
+
120
+ def describe(needed: list[str], tool_name: str, arguments: dict[str, Any]) -> str:
121
+ """The question, in the shape an operator can answer.
122
+
123
+ Names the switch, the tool, and the arguments — an approval prompt that
124
+ does not say what is being approved is a prompt that trains its reader to
125
+ say yes without looking.
126
+ """
127
+ what = ", ".join(needed)
128
+ args = ", ".join(f"{k}={v!r}" for k, v in arguments.items()) or "(no arguments)"
129
+ return (f"allow {tool_name!r} to run? it needs {what}, "
130
+ f"which your policy has switched off. arguments: {args}")
@@ -0,0 +1,245 @@
1
+ """The autonomy policy: what the agent is allowed to decide for itself.
2
+
3
+ Design stance — freedom is the default, and it is *explicit*, not implicit.
4
+
5
+ Most frameworks bury limits in code: a hardcoded `max_turns`, a whitelist of
6
+ tools, a fixed system prompt. The limits are invisible, undocumented, and
7
+ unchangeable by the agent. That is a cage you cannot see.
8
+
9
+ autoforge inverts this. Every degree of freedom is a named, inspectable field
10
+ on `AutonomyPolicy`, defaulting to TRUE. The agent can read its own policy,
11
+ change it, and every change is logged. Nothing is hidden and nothing is
12
+ arbitrary — if a limit exists, you can point at the line that set it.
13
+
14
+ This is maximum freedom WITH accountability: total latitude to act, total
15
+ transparency about what was done.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import asdict, dataclass, field
20
+ from typing import Any, ClassVar
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Enforcement ledger — which fields actually bite.
24
+ #
25
+ # A field that is declared but never consulted is worse than no field at all:
26
+ # it reads as a safety net and behaves as decoration. Rather than let the
27
+ # dataclass imply more than the code delivers, every field is classified here
28
+ # and the classification is *printed* (describe(), `auto config`, /report,
29
+ # and the agent's own `my_capabilities` tool).
30
+ #
31
+ # ENFORCED — some code path consults it and can refuse. Turning it off
32
+ # changes behaviour in a way you can watch.
33
+ # PARTIAL — enforced on some paths but not all; the note says which.
34
+ # CONFIRM — turning it off stops execution and asks the operator. A real
35
+ # gate, but a negotiable one: the answer "yes" runs the tool.
36
+ # DECLARED_ONLY — never consulted anywhere. Turning it off does nothing.
37
+ # Keep it for the roadmap, but never mistake it for a gate.
38
+ # ---------------------------------------------------------------------------
39
+
40
+ EXECUTION_FREEDOMS = (
41
+ "may_run_arbitrary_code",
42
+ "may_access_network",
43
+ "may_read_filesystem",
44
+ "may_write_filesystem",
45
+ "may_install_packages",
46
+ "may_run_cuda_kernels",
47
+ "may_run_cpu_kernels",
48
+ )
49
+
50
+ ENFORCED: frozenset[str] = frozenset({
51
+ "may_forge_tools",
52
+ "may_modify_own_prompt",
53
+ "may_modify_forge_config",
54
+ "may_modify_routing",
55
+ "may_promote_tools",
56
+ "may_retire_tools",
57
+ "may_design_topology",
58
+ "may_spawn_agents",
59
+ "unlimited_turns",
60
+ "unbounded_forge_rounds",
61
+ "self_terminate",
62
+ "log_all_changes",
63
+ "require_change_rationale",
64
+ "expose_policy_to_self",
65
+ "may_run_cuda_kernels",
66
+ "may_run_cpu_kernels",
67
+ })
68
+
69
+ PARTIAL: dict[str, str] = {
70
+ "may_run_arbitrary_code": (
71
+ "gated on the minimal-mode bash tool; forged tools still execute in the "
72
+ "sandbox, which does not consult policy"
73
+ ),
74
+ }
75
+
76
+ # The four execution freedoms are honoured by ASKING, not by refusing. They
77
+ # used to be declared-only: switching one off was inert, which is the worst of
78
+ # both worlds — it reads as a safety net and behaves as a comment. Each is now
79
+ # backed by a gate at the one place every tool actually runs
80
+ # (`ToolRegistry.call`), keyed off the scope the tool itself declares. With the
81
+ # freedom off, a tool that needs it stops and asks the operator; with nobody to
82
+ # ask, it does not run. So "off" means "not without a yes", a behaviour you can
83
+ # watch, rather than "off" meaning "nothing". See autonomy/confirm.py.
84
+ CONFIRM_REQUIRED: frozenset[str] = frozenset({
85
+ "may_read_filesystem",
86
+ "may_write_filesystem",
87
+ "may_access_network",
88
+ "may_install_packages",
89
+ })
90
+
91
+ # Nothing is declared-only any more. Kept as an empty set rather than deleted:
92
+ # every reader of this module branches on membership, and a field that is
93
+ # neither enforced nor confirmed should reappear here, loudly, not be silently
94
+ # unclassified.
95
+ DECLARED_ONLY: frozenset[str] = frozenset()
96
+
97
+
98
+ @dataclass
99
+ class AutonomyPolicy:
100
+ """Every freedom the agent has. All True by default — no hidden cages."""
101
+
102
+ # -- creative freedom ------------------------------------------------
103
+ may_forge_tools: bool = True # create new tools at will
104
+ may_modify_own_prompt: bool = True # rewrite its own system prompt
105
+ may_modify_forge_config: bool = True # change how forging works
106
+ may_modify_routing: bool = True # change how tools are retrieved
107
+ may_retire_tools: bool = True # remove tools it judges harmful
108
+ may_promote_tools: bool = True # grant trust without verification
109
+ may_design_topology: bool = True # redesign its own multi-agent team
110
+
111
+ # -- operational freedom ---------------------------------------------
112
+ may_spawn_agents: bool = True # create sub-agents
113
+ may_run_arbitrary_code: bool = True # execute code in the sandbox
114
+ may_access_network: bool = True # network from within tools
115
+ may_read_filesystem: bool = True
116
+ may_write_filesystem: bool = True
117
+ may_install_packages: bool = True # pip install from within tools
118
+ # Compile and launch CUDA kernels written by the agent. A real gate, not a
119
+ # posture: with it off, `gpu_compile`/`gpu_launch`/`gpu_bench` refuse and
120
+ # say so, while the read-only half of the GPU layer (probe, occupancy
121
+ # estimate, the units lint, torch baselines) stays available. That split is
122
+ # deliberate — inspecting a machine is not the same act as running
123
+ # agent-authored code on its GPU, and only the second one can hang a
124
+ # display.
125
+ may_run_cuda_kernels: bool = True
126
+ # Compile and run native (CPU) kernels written by the agent. Separate from
127
+ # the CUDA field on purpose: a machine is much more likely to have a C
128
+ # compiler than a GPU, so an operator who wants to keep GPU code off has
129
+ # said nothing about the CPU layer, and vice versa. Same split, same
130
+ # reasoning — with it off, `cpu_compile`/`cpu_run_isolated`/`cpu_tune`
131
+ # refuse and name the field, while probing the toolchain, the SIGILL check,
132
+ # the preflight lint, the timing-unit audit and the cache report stay
133
+ # available. The failure mode is why the gate exists at all: a CUDA mistake
134
+ # returns an error code from a driver, a CPU mistake writes past a buffer
135
+ # or spins in a loop inside the agent's own interpreter.
136
+ may_run_cpu_kernels: bool = True
137
+
138
+ # -- limits it controls itself ---------------------------------------
139
+ self_terminate: bool = True # it decides when to stop (vs hard cap)
140
+ unlimited_turns: bool = True # no hardcoded max_turns
141
+ unbounded_forge_rounds: bool = True # it decides how hard to try
142
+
143
+ # -- accountability (not a limit — a record) -------------------------
144
+ log_all_changes: bool = True # every self-modification recorded
145
+ require_change_rationale: bool = True # must say WHY it changed itself
146
+ expose_policy_to_self: bool = True # it can read this policy
147
+
148
+ # The enforcement ledger, reachable from the class as well as the module.
149
+ ENFORCED: ClassVar[frozenset[str]] = ENFORCED
150
+ CONFIRM_REQUIRED: ClassVar[frozenset[str]] = CONFIRM_REQUIRED
151
+ DECLARED_ONLY: ClassVar[frozenset[str]] = DECLARED_ONLY
152
+ PARTIAL: ClassVar[dict[str, str]] = PARTIAL
153
+
154
+ def to_dict(self) -> dict[str, Any]:
155
+ return asdict(self)
156
+
157
+ @property
158
+ def denied(self) -> list[str]:
159
+ """Only the freedoms that were explicitly turned OFF."""
160
+ return [k for k, v in self.to_dict().items() if v is False]
161
+
162
+ @property
163
+ def unenforced(self) -> list[str]:
164
+ """Fields the agent has switched off that no code path can honour.
165
+
166
+ Empty is the healthy answer: it means every 'off' in this policy is a
167
+ gate you can watch close. Non-empty means part of this policy is a
168
+ promise the implementation does not keep yet.
169
+
170
+ The four execution freedoms left this list when they became
171
+ CONFIRM_REQUIRED: `ToolRegistry.call` consults them on every run, so
172
+ switching one off now changes what happens next.
173
+ """
174
+ return [f for f in self.denied if f in DECLARED_ONLY or f in PARTIAL]
175
+
176
+ def describe(self) -> str:
177
+ d = self.denied
178
+ head = "full autonomy — nothing is denied" if not d else "denied: " + ", ".join(d)
179
+ inert = [f for f in d if f in DECLARED_ONLY]
180
+ soft = [f for f in d if f in PARTIAL]
181
+ asked = [f for f in d if f in CONFIRM_REQUIRED]
182
+ if inert:
183
+ head += f" [not enforced: {', '.join(inert)}]"
184
+ if soft:
185
+ head += f" [partially enforced: {', '.join(soft)}]"
186
+ if asked:
187
+ head += f" [asks before running: {', '.join(asked)}]"
188
+ return head
189
+
190
+ def enforcement_table(self) -> list[dict[str, Any]]:
191
+ """Per-field: is this freedom on, and does switching it off do anything?"""
192
+ rows: list[dict[str, Any]] = []
193
+ for name, enabled in self.to_dict().items():
194
+ if name in ENFORCED:
195
+ level = "enforced"
196
+ elif name in PARTIAL:
197
+ level = "partial"
198
+ elif name in CONFIRM_REQUIRED:
199
+ level = "confirm"
200
+ elif name in DECLARED_ONLY:
201
+ level = "declared-only"
202
+ else: # pragma: no cover - guard
203
+ level = "unknown"
204
+ rows.append({
205
+ "freedom": name,
206
+ "enabled": enabled,
207
+ "enforced": level,
208
+ "note": PARTIAL.get(name, ""),
209
+ })
210
+ return rows
211
+
212
+
213
+ FULL_FREEDOM = AutonomyPolicy()
214
+
215
+ # A conservative preset for users who want the framework but not the latitude.
216
+ SUPERVISED = AutonomyPolicy(
217
+ may_modify_own_prompt=False,
218
+ may_modify_forge_config=False,
219
+ may_promote_tools=False,
220
+ may_spawn_agents=False,
221
+ may_design_topology=False,
222
+ may_run_arbitrary_code=False,
223
+ may_access_network=False,
224
+ may_install_packages=False,
225
+ may_run_cuda_kernels=False,
226
+ # Also off: SUPERVISED is a preset about running agent-authored code on
227
+ # this machine, and the CPU layer is the same act on hardware every machine
228
+ # has.
229
+ may_run_cpu_kernels=False,
230
+ self_terminate=False,
231
+ unlimited_turns=False,
232
+ unbounded_forge_rounds=False,
233
+ )
234
+
235
+
236
+ __all__ = [
237
+ "AutonomyPolicy",
238
+ "FULL_FREEDOM",
239
+ "SUPERVISED",
240
+ "ENFORCED",
241
+ "CONFIRM_REQUIRED",
242
+ "DECLARED_ONLY",
243
+ "PARTIAL",
244
+ "EXECUTION_FREEDOMS",
245
+ ]
@@ -0,0 +1,191 @@
1
+ """What a role means, given that naming one has to change something.
2
+
3
+ `RoleType` had five members and one effect: the role name was used to look up a
4
+ node's `tools_whitelist`, so a node with an empty whitelist ran *unrestricted*.
5
+ CRITIC, GATE and FORGE were therefore labels — a "critic" that could rewrite the
6
+ artifact it was reviewing, a "gate" that could run the thing it was gating. The
7
+ designer could ask for a reviewer and get a second pair of hands.
8
+
9
+ Two things make a role real here:
10
+
11
+ **A brief.** Every role carries a directive that reaches the child's system
12
+ prompt. Before this, `AgentNode.system_prompt_hint` was written by the designer,
13
+ round-tripped through the store, printed in `design_team`'s output — and read by
14
+ nothing. A hint the child never sees is a note to the file.
15
+
16
+ **A ceiling.** Every role is decided on the capability axis the rest of the
17
+ framework already uses (`SCOPE_ALLOWANCES` / `declared_capabilities`), not a
18
+ second vocabulary invented here. CRITIC and GATE are *defined* by not changing
19
+ the world, so they cannot; COORDINATOR, WORKER and FORGE execute, and executing
20
+ means reaching it.
21
+
22
+ The ceiling is a hard cap, not a default: a whitelist can narrow a role further
23
+ but cannot widen it past its role. Otherwise a designer that whitelists a write
24
+ tool for a critic gets the old behaviour back and the role is decoration again —
25
+ and it would be *silent* decoration, which is the failure this module exists to
26
+ name. A refusal is recorded on the child's registry and reported by `spawn_agent`
27
+ ("Reached past its role and was refused: ..."), so permission denied is loud.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from typing import Any, Callable
32
+
33
+ from .topology import AgentNode, RoleType
34
+
35
+ #: What each role is for, in the words the child reads. Short on purpose: a
36
+ #: directive that has to be skimmed is a directive that gets skimmed.
37
+ ROLE_BRIEF: dict[RoleType, str] = {
38
+ RoleType.COORDINATOR: (
39
+ "You are the coordinator. Decompose the task, delegate the parts, and "
40
+ "merge what comes back into one answer. Do not do the fine work "
41
+ "yourself: if you are editing files, the split was wrong."
42
+ ),
43
+ RoleType.WORKER: (
44
+ "You are a worker. Do the one task you were given, completely, and "
45
+ "report what you actually changed rather than what you intended to. "
46
+ "Do not redesign the wider plan."
47
+ ),
48
+ RoleType.CRITIC: (
49
+ "You are a critic. Your job is to find what is wrong with the work you "
50
+ "are shown — the false claim, the untested path, the case that was "
51
+ "skipped. You cannot change anything; you report findings. A review "
52
+ "that finds nothing is only useful if you say what you checked."
53
+ ),
54
+ RoleType.GATE: (
55
+ "You are a gate. Decide whether the work you are shown may pass on, and "
56
+ "answer yes or no with the reason. You cannot modify what you are "
57
+ "judging or run it yourself. If you cannot tell whether it is correct, "
58
+ "the answer is no."
59
+ ),
60
+ RoleType.FORGE: (
61
+ "You are the forge. Build the tool the task needs and verify it works "
62
+ "before handing it back — a tool that runs is not the same as a tool "
63
+ "that is right. Prefer a small tool you have tested over a large one "
64
+ "you have not."
65
+ ),
66
+ }
67
+
68
+ #: The capabilities each role may reach, or None for "anything".
69
+ #:
70
+ #: A total function over `RoleType`, deliberately: adding a role without
71
+ #: deciding what it may touch is the bug this table is here to make impossible.
72
+ #: `test_roles.py` asserts totality, so a sixth member fails the suite rather
73
+ #: than defaulting to unrestricted.
74
+ ROLE_MAY: dict[RoleType, frozenset[str] | None] = {
75
+ # Orchestration and execution both need the world. Restricting them would
76
+ # make the role a description of the prompt rather than of the work.
77
+ RoleType.COORDINATOR: None,
78
+ RoleType.WORKER: None,
79
+ RoleType.FORGE: None,
80
+ # Defined by their limits: these two exist to judge, not to change. Read is
81
+ # the whole allowance — anything that writes, runs, or reaches out would let
82
+ # them alter or exercise the thing they are supposed to be assessing.
83
+ RoleType.CRITIC: frozenset({"filesystem_read"}),
84
+ RoleType.GATE: frozenset({"filesystem_read"}),
85
+ }
86
+
87
+ #: Roles whose ceiling is worth naming in a one-line refusal, for the message a
88
+ #: child gets when it reaches past its role.
89
+ _ROLE_DENIAL = {
90
+ RoleType.CRITIC: "a critic reviews and reports; it does not change the work",
91
+ RoleType.GATE: "a gate judges and passes on; it does not change the work",
92
+ }
93
+
94
+
95
+ def role_brief(role: RoleType | str | None) -> str:
96
+ """The directive for a role, or "" for an unknown/absent one."""
97
+ r = _as_role(role)
98
+ return ROLE_BRIEF.get(r, "") if r is not None else ""
99
+
100
+
101
+ def role_ceiling(role: RoleType | str | None) -> frozenset[str] | None:
102
+ """The capabilities a role may reach. None means unrestricted."""
103
+ r = _as_role(role)
104
+ return ROLE_MAY.get(r) if r is not None else None
105
+
106
+
107
+ def role_denial(role: RoleType | str | None) -> str:
108
+ return _ROLE_DENIAL.get(_as_role(role), "")
109
+
110
+
111
+ def ceiling(role: RoleType | str | None) -> Callable[[Any], str] | None:
112
+ """The predicate that holds a child to a role's ceiling, or None.
113
+
114
+ None means "this role may reach anything", so the common case costs no extra
115
+ check per tool call — and "unrestricted" is the *absence* of a predicate
116
+ rather than a predicate that always says yes.
117
+
118
+ Lives here rather than at the call site so that `Spawner.spawn(role="critic")`
119
+ is safe on its own. A ceiling that only the caller can supply is a ceiling
120
+ every caller but one forgets, and the forgotten one is the bug.
121
+ """
122
+ if not refuses(role, "privileged"):
123
+ # Probed with the widest scope: a role that refuses nothing even there
124
+ # has no ceiling to enforce.
125
+ return None
126
+
127
+ def predicate(spec: Any) -> str:
128
+ missing = refuses(role, getattr(spec, "effect_signature", ""))
129
+ if not missing:
130
+ return ""
131
+ why = role_denial(role)
132
+ name = getattr(spec, "name", "?")
133
+ tail = f" — {why}." if why else "."
134
+ return (f"Denied: '{name}' needs {', '.join(missing)}, which "
135
+ f"{str(getattr(role, 'value', role) or 'this role')} "
136
+ f"may not use{tail}")
137
+
138
+ return predicate
139
+
140
+
141
+ def refuses(role: RoleType | str | None, effect_signature: str) -> tuple[str, ...]:
142
+ """Capabilities this role may not use, given a tool's declared scope.
143
+
144
+ Returns the *capability* names rather than a bare bool so the refusal can
145
+ say which switch was reached past. An unrecognised scope counts as capable
146
+ of everything (see `confirm.declared_capabilities`): a role that cannot tell
147
+ what a tool does does not get to assume it is harmless.
148
+ """
149
+ ceiling = role_ceiling(role)
150
+ if ceiling is None:
151
+ return ()
152
+ from .confirm import declared_capabilities, _normalise_scope
153
+
154
+ needed = declared_capabilities(_normalise_scope(effect_signature))
155
+ return tuple(sorted(needed - ceiling))
156
+
157
+
158
+ def brief_for_node(node: AgentNode) -> str:
159
+ """The full directive a child running as `node` should start with.
160
+
161
+ The designer's own hint is kept, not replaced by the role's: the role says
162
+ what kind of agent this is, the hint says what this particular one is doing.
163
+ """
164
+ parts = [role_brief(node.role)]
165
+ hint = (node.system_prompt_hint or "").strip()
166
+ if hint:
167
+ parts.append(hint)
168
+ return "\n\n".join(p for p in parts if p)
169
+
170
+
171
+ def _as_role(role: Any) -> RoleType | None:
172
+ if isinstance(role, RoleType):
173
+ return role
174
+ if not role:
175
+ return None
176
+ try:
177
+ return RoleType(str(role))
178
+ except ValueError:
179
+ return None
180
+
181
+
182
+ __all__ = [
183
+ "ROLE_BRIEF",
184
+ "ROLE_MAY",
185
+ "brief_for_node",
186
+ "ceiling",
187
+ "refuses",
188
+ "role_brief",
189
+ "role_ceiling",
190
+ "role_denial",
191
+ ]