Curriculum/Multi-Agent Systems
Tool Isolation Is the Point
Each role has an allow-list. Planner cannot patch src. Coder cannot refund. Isolation is a security boundary the dispatcher enforces, not a vibe in a persona paragraph.
The Tools track taught a dispatcher that rejects unknown names. Multi-agent adds who is asking. The previous lesson gave planner, worker, and critic different jobs. Those jobs are fiction until the runtime refuses the wrong caller.
ALLOW[role] is the contract. It is a map from role name to paths or tool names that role may touch. Copy the repo (or the world) on write so a rejected call does not mutate anything. Logs should show role=planner path=src/app.py denied. If the planner can “just this once” edit src, the split is a slide.
Isolation is why you split. Context isolation without tool isolation still lets the planner shell out. Done-check isolation without tool isolation still lets the critic refund. Nicknames never isolated anything.
What the allow-list must cover
An allow-list is not only tool names. It is names, arguments that point at resources, and sometimes verbs.
| Layer | Example | Fail closed looks like |
|---|---|---|
| Tool name | planner may not call edit | unknown_or_forbidden_tool |
| Path / id | coder may patch src/app.py, not PLAN.md’s inverse: planner may write PLAN.md, not src/ | forbidden_path |
| Record | billing may refund customer_id it owns; loyalty may not | locked (later lesson) |
| Peer message | coder may not DM docs worker | peer_forbidden (supervisor lesson) |
The planner cannot patch src. The coder cannot refund. The dispatcher enforces it.
Isolation is who is askingRole is an authenticated field on the job, not a string the model typed. If the model can set role=coder on a planner trace, you have a costume. The orchestrator stamps role when it starts the child.
Copy-on-write: apply_patch in the box receives a copy of the repo. A denied planner call returns the same x in src/app.py. If you mutate in place and then return an error, on-call will not know whether the file changed. Agents that “failed” after writing are how incidents hide in error traces.
Reads can be isolation too. A researcher may read logs. A coder may not. A billing worker may read this tenant’s invoice. A docs worker may not. Cross-tenant reads are evals-track incidents; the same dispatcher pattern applies.
Walkthrough: planner tries to save a hop
Acme’s planner decides the timeout bump is obvious. It calls apply_patch on src/app.py with hack. The dispatcher looks up ALLOW["planner"], which is only PLAN.md. Result: forbidden_path. Repo copy still has x.
The coder, same function, same path, different role, is allowed. Result: ok, content y.
The planner writing PLAN.md is allowed. That is its artifact, not a back door into src.
If a human on-call “temporarily” adds src/app.py to the planner list to ship a hotfix, they have merged the roles. Do it as an explicit one-agent run with the baseline allow-list, not as a silent widen. Silent widen is how the split dies.
Run to execute this in your browser. Nothing is sent to a server.
What printed: coder patching src/app.py is True. Planner patching src is a dict with ok: False and forbidden_path; the returned repo still has x because we passed a copy and never wrote. Planner writing PLAN.md is True. Isolation is a denied path, not a scolding sentence in the planner prompt.
Try adding "src/app.py" to the planner set and run the middle call again. It will succeed. That is the bug you are refusing. The test suite should include this middle call as a fixture that must stay denied.
Prompts are not allow-lists
“You are a careful planner; never edit source” is a hope. Models drop hopes under load, injection, or a “just this once” user. The dispatcher does not read the system prompt. It reads ALLOW.
Frameworks that advertise “each agent has tools” still need your map. If the framework’s default is “all tools to all nodes,” you have a mesh with extra YAML. Set the map. Test illegal edges with no tokens, same as you will test hop graphs.
Default deny: ALLOW.get(role, set()) means unknown roles get nothing. Do not default to the coder list. Do not default to “whatever the last hop had.” Child jobs start with the role’s own set.
Unknown roles showing up in logs are an incident. It means the orchestrator stamped a string the catalog does not know, or a model forged a role. Forged roles must be impossible: the stamp comes from the job record, signed or at least not taken from model text. If your framework lets the agent set role in JSON, that field is not a role. It is fanfic. Drop it.
How agents use this
Same table as Tools permissions, keyed by role. Logs should show role=planner path=src/app.py denied. If the planner can “just this once” edit src, the split is fiction.
Put ALLOW in config next to the role catalog from split-or-merge. A unit test loops illegal (role, path) pairs. CI should fail when someone adds a worker tool to the planner to “speed up local demo.”
Dashboards: count denies by role. A spike in planner denies means the model is trying to do the work. That is a planner prompt or plan-schema bug, not a reason to grant the path. A zero on denies forever can mean the planner is well behaved — or that you are not logging. Log the denies.
Combine with the critic-no-writes rule next: the critic’s allow-list is empty of writes, even if the critic’s prompt is senior. Combine later with one-writer-per-record so two allowed roles still cannot mutate the same customer.
Check your understanding