diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 122a99b..b57862a 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -8,6 +8,19 @@
- [ ] `python3 -m py_compile scripts/route.py tests/run_tests.py`
- [ ] `scripts/github-preflight.sh`
+## Public Release Gate
+
+For maintainer-originated or synced GitHub-facing changes:
+
+- [ ] Local source PR/review completed before this GitHub-facing update.
+- [ ] Full security audit completed on the local PR/diff.
+- [ ] Audit result documented with reviewer, date, commit/PR reference, findings, remediation, and pass/fail status.
+- [ ] No GitHub branch, pull request, tag, release, or public export was created or updated before the local gate passed.
+
+For external contributor PRs:
+
+- [ ] Maintainer will complete the local source review gate before merge or release.
+
## Safety Checklist
- [ ] Does not delegate MCP/private tools to workers.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d47bb65..9fe9249 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,6 +36,26 @@ AgentFanout's core safety model is intentional:
Worker prompts must stay small, scoped, and free of private-tool context, automatic memory, secrets, callback hooks, and voice/notification instructions.
+## Public Release Gate
+
+AgentFanout is public-facing. Maintainer-originated GitHub-facing changes must pass the local source review gate before any GitHub branch, pull request, tag, release, or public export is created or updated.
+
+Before GitHub-facing activity, maintainers must:
+
+1. Prepare the change in the canonical local source repository.
+2. Review the change through the local source PR/review process.
+3. Complete the full security audit on that local PR/diff.
+4. Document the audit result with reviewer, date, commit/PR reference, findings, remediation, and pass/fail status.
+5. Confirm the gate passed before creating or updating any GitHub-facing artifact.
+
+The full security audit must include the local checks above, secret and credential review, private infrastructure reference review, unsafe worker delegation review, public-claim accuracy review, dependency and supply-chain review when dependencies or CI actions change, and prompt/data-exfiltration review for routing, worker, validator, or provider changes.
+
+Emergency bypass is allowed only for urgent public security fixes approved by the maintainer. The bypass reason must be documented before GitHub action, and the local audit must be completed within 24 hours.
+
+Premature GitHub publication is treated as a release incident: stop further GitHub activity, assess exposure, revert or yank if needed, complete the audit, document findings, and add a prevention note before resuming public work.
+
+External contributors do not need to complete the local source audit infrastructure themselves. Maintainers complete this gate before merge or release. See the checklist in [.github/pull_request_template.md](.github/pull_request_template.md) for the required verification and release-gate attestations.
+
## Pull Requests
Open a pull request with:
diff --git a/README.md b/README.md
index 019f0ca..a48e7a4 100644
--- a/README.md
+++ b/README.md
@@ -16,23 +16,32 @@ The diagrams show the intended orchestration flow. Worker launch is performed by
AgentFanout is `v0.1.0`. It is a portable skill/policy layer and deterministic advisory router, not a standalone dispatch harness. It is designed to be embedded into agent runtimes that already have a worker mechanism, such as Codex subagents, Claude agents, MiniMax wrappers, local LLM runners, or future provider adapters.
+## Public Release Gate
+
+AgentFanout is public-facing. Maintainer-originated GitHub-facing changes are treated as release work, not scratch-pad updates. Maintainers must complete the local source review gate and full security audit described in [Release Process](docs/release-process.md) before creating or updating any GitHub-facing branch, pull request, tag, release, or public export.
+
## Table of Contents
- [Execution Model](#execution-model)
+- [Public Release Gate](#public-release-gate)
- [Who This Is For](#who-this-is-for)
- [What It Does](#what-it-does)
- [What This Is Not](#what-this-is-not)
- [Quickstart](#quickstart)
- [Deterministic Router](#deterministic-router)
- [Safety Model](#safety-model)
+- [Release Process](docs/release-process.md)
- [FAQ](#faq)
## Execution Model
- **Skill Mode:** The host agent uses AgentFanout instructions to decompose, delegate, validate, and integrate work.
-- **Router Mode:** `scripts/route.py` returns advisory JSON and sanitization output. It does not launch workers.
+- **Worker-First Mode:** Broad investigations, repo searches, infrastructure research, and multi-surface debugging should launch scoped workers before the main session starts collecting evidence.
+- **Router Mode:** `scripts/route.py` returns advisory JSON, dispatch requirements, and sanitization output. It does not launch workers by itself.
- **Harness Mode:** A standalone dispatcher is possible future infrastructure. It is not currently shipped.
+If `scripts/route.py` reports `dispatch.status=launch_required`, the host runtime should launch workers using its native mechanism before substantive main-session research. While workers run, the main session should coordinate, wait, validate, and integrate rather than duplicate the workers' SSH diagnostics, filesystem sweeps, repo-wide search, or substantive research. If no worker mechanism is available, hosts should surface `fanout_blocked` rather than silently falling back to main-session-only work.
+
## Who This Is For
AgentFanout is for people building or operating agent workflows that need:
@@ -92,7 +101,7 @@ python3 scripts/route.py --sanitize "prompt text"
## Deterministic Router
-`scripts/route.py` classifies a request, checks hard gates, selects a provider preference, estimates useful fanout, returns a worker capsule, and reports validator requirements.
+`scripts/route.py` classifies a request, checks hard gates, selects a provider preference, estimates useful fanout, returns a worker capsule, reports dispatch requirements, and reports validator requirements.
Example output shape:
@@ -105,7 +114,7 @@ Example output shape:
"executor": {
"role": "explorer",
"provider": "MiniMax",
- "model_selector": "minimax-fast",
+ "model_selector": "minimax-latest",
"reasoning_effort": "low"
}
},
@@ -113,6 +122,13 @@ Example output shape:
"policy": "automatic_guarded",
"planned_workers": 20
},
+ "dispatch": {
+ "status": "launch_required",
+ "main_session_policy": "coordinate_wait_integrate",
+ "launch_before_main_research": true,
+ "main_session_may_duplicate_worker_research": false,
+ "fallback_allowed": false
+ },
"validator": {
"required": true
}
@@ -132,6 +148,8 @@ The router also checks `$PAI_DIR/skills/MiniMax/Tools/MiniMaxExec.ts` or `$PAI_H
Readiness flags and file checks are routing hints. They do not perform live endpoint probing, key validation, or health checks.
+MiniMax highspeed is not assumed. AgentFanout uses the default supported MiniMax selector unless the host runtime has verified that the active token plan supports highspeed mode.
+
## Safety Model
The main session always owns:
@@ -146,6 +164,8 @@ Suspicious credential-shaped prompts remain main-session-only even when they do
Workers receive only bounded task packets. They must return results to the main session and must not contact notification hooks, voice systems, webhooks, or callback URLs.
+Read-only research workers may use scoped read-only retrieval and diagnostics when assigned by the host: repo/file reads, filesystem search, runtime-approved knowledge-base retrieval, public web or docs research, read-only SSH diagnostics, and read-only database queries where configured. That read-only scope does not include private-account side effects, secrets, git mutations, destructive actions, or final synthesis.
+
## Commands
Only exact full-line `AgentFanout ...` commands mutate session state:
@@ -203,6 +223,13 @@ The host runtime launches workers using its native mechanism, such as Codex suba
+
+What happens if AgentFanout wants workers but none are available?
+
+The route should surface `fanout_blocked`. The host should report that worker launch is unavailable instead of letting the main session quietly perform the whole broad investigation.
+
+
+
What does route.py actually do?
diff --git a/SECURITY.md b/SECURITY.md
index 7a707f6..f379396 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -14,6 +14,23 @@ Security reports should focus on:
Open a private security advisory on GitHub if available. If advisories are unavailable, open a minimal issue that describes the impact without including secrets, credentials, private infrastructure details, or exploit payloads that would cause harm if copied.
+## Public Release Gate
+
+AgentFanout is public-facing. Maintainer-originated GitHub-facing changes must pass a local source review and full security audit before any GitHub branch, pull request, tag, release, or public export is created or updated.
+
+The audit must cover:
+
+- tests, compile checks, and `scripts/github-preflight.sh`
+- secrets, credentials, and credential-shaped examples
+- private infrastructure references
+- unsafe worker delegation or privilege expansion
+- callback, webhook, voice, TTS, and notification leakage
+- public-claim accuracy in documentation
+- dependency and supply-chain changes when dependencies or CI actions change
+- prompt/data-exfiltration paths for routing, worker, validator, or provider changes
+
+Any emergency bypass for urgent public security fixes must be explicitly approved by the maintainer, documented before GitHub action, and followed by the local audit within 24 hours.
+
## Design Commitments
AgentFanout is designed so the main session remains the security boundary. Workers and validators should receive only bounded task packets, no private account tools, no secrets, no automatic memory, no git-state authority, and no final synthesis authority. Secret hard gates include explicit secret wording and common credential-shaped syntax such as bearer headers, API-key prefixes, access-key assignments, JWT-like values, private key blocks, and password-bearing DSNs.
diff --git a/SKILL.md b/SKILL.md
index 3b3057d..0528594 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -13,6 +13,8 @@ AgentFanout is not injected into session startup. It loads through compact skill
The main session remains the orchestrator, security boundary, and final integrator.
+For broad investigation, repo search, infrastructure research, and multi-surface debugging, the main session must dispatch workers before doing substantive research when a worker mechanism is available. After dispatch, the main session coordinates, waits, validates, and integrates; it must not duplicate the workers' SSH diagnostics, filesystem sweeps, repo-wide search, or substantive research while workers are running. If no worker mechanism is available, report `fanout_blocked` instead of silently doing all research in the main session.
+
It may use host-runtime worker mechanisms for bounded helpers when available, but it must keep these responsibilities in the main session:
- MCP/tool work: memory, calendar, email, chat, browser automation, workspace tools, private search, or any other private/tool-backed system.
@@ -23,6 +25,8 @@ It may use host-runtime worker mechanisms for bounded helpers when available, bu
## Activation workflow
+Do not perform repo-wide, vault-wide, or home-directory searches to rediscover AgentFanout before routing. Once this skill is selected, use this skill directory directly, read only the minimal referenced files needed, and run `scripts/route.py` before any broad search or substantive research.
+
1. Classify and route with `scripts/route.py`, `workflows/Route.md`, and `references/Routing.md`.
2. Apply lazy-loading rules from `references/LazyExecution.md`.
3. Shape minimal worker identities with `references/AgentCapsules.md`.
@@ -56,6 +60,8 @@ Fanout is automatic and guarded. AgentFanout chooses the useful number of worker
Every delegated worker receives a minimal specialist capsule by default. Do not load automatic memory, relationship history, full Agents context, full CodexAgent context, voice metadata, or private MCP context into worker prompts.
+Research workers may use scoped read-only tools when explicitly assigned: repo/file reads, filesystem search, runtime-approved knowledge-base retrieval, public web or docs research, read-only SSH diagnostics, and read-only database queries where the environment already permits them. They must not perform private-account side effects, write state, touch secrets, mutate git state, perform destructive actions, or own final synthesis.
+
Load full Agents tooling only for named, custom, personality-heavy, trait-heavy, or user-requested specialist composition beyond the compact capsule.
## Approval-to-execution
@@ -75,6 +81,12 @@ fanout:
policy: automatic_guarded
planned_workers:
rationale:
+dispatch:
+ status: launch_required|fanout_blocked|not_required
+ main_session_policy: coordinate_wait_integrate|report_block|handle_directly
+ launch_before_main_research: true|false
+ main_session_may_duplicate_worker_research: false
+ fallback_allowed: true|false
executor:
role: main|explorer|worker|external-worker|local-worker
provider: current|codex|claude|local-llm|minimax|future
@@ -100,6 +112,8 @@ Do not delegate merely because this skill is active. Stay main-session-only when
- The task is destructive or irreversible.
- The expected orchestration overhead exceeds savings.
+If delegation is useful but all worker providers are unavailable, stop and surface the block. Do not treat that state as permission for the orchestrator to consume the full research context itself.
+
## Built-in deterministic helper
Use `scripts/route.py` for dry-run routing checks, prompt sanitization checks, and fixture tests. The script is advisory; the main session still owns final judgment.
diff --git a/adapters/Codex.md b/adapters/Codex.md
index b1e45d8..cd52619 100644
--- a/adapters/Codex.md
+++ b/adapters/Codex.md
@@ -23,6 +23,14 @@ model_selectors:
frontier: current-frontier
```
+## Native Codex selector mapping
+
+When `scripts/route.py` returns `current-fast-mini`, the host must pass an explicit mini/fast model override to native Codex worker launch. Do not omit the model override and let the runtime default to the main-session/frontier model. Resolve the selector to the lowest-cost currently available Codex mini or fast model, use the routed `reasoning_effort`, and record the resolved model in the worker launch summary.
+
+Before spawning a Codex worker, validate that the native launch parameters satisfy `runtime_launch.codex_native.cost_tier` and `must_pass_model_override`. If the host cannot satisfy a required cheap/mini override, report `fanout_blocked` or route to another provider instead of silently launching the default stronger model.
+
+`current-balanced` may use the runtime's balanced default. `current-frontier` should use the strongest current Codex model only when high-effort routing requested it.
+
## Launch patterns
- Explorer: read-only prompt, no writes, return evidence.
@@ -49,9 +57,14 @@ allowed_writes: []
forbidden: [mcp, secrets, git-state-change, destructive-action, final-synthesis]
model_selector:
reasoning_effort: low|medium|high|xhigh
+runtime_launch:
+ codex_native:
+ cost_tier: cheap
+ required_model_capability: mini_or_fast
+ must_pass_model_override: true
output_contract:
```
## Notes
-Use provider-native non-interactive execution when available. Keep sandbox/approval settings explicit for automation. The adapter resolves selectors to current Codex-supported models; the core never hardcodes names.
+Use provider-native non-interactive execution when available. Keep sandbox/approval settings explicit for automation. The adapter resolves selectors to current Codex-supported models; the core exposes selector capabilities and does not publish model names as policy.
diff --git a/adapters/MiniMax.md b/adapters/MiniMax.md
index 42adff0..6f3ae96 100644
--- a/adapters/MiniMax.md
+++ b/adapters/MiniMax.md
@@ -19,10 +19,12 @@ supportsTools: API-dependent
supportsFileEditing: false unless wrapped by an edit harness
supportsReasoningEffort: provider-dependent
model_selectors:
- cheap: minimax-fast
+ default: minimax-latest
balanced: minimax-latest
```
+Do not use `minimax-fast` or pass `MiniMaxExec --fast` unless the runtime has explicitly verified that the current MiniMax token plan supports `MiniMax-M2.7-highspeed`. In this environment the token plan does not support highspeed, so AgentFanout routes MiniMax workers to the default supported model selector.
+
## Routing rule
When MiniMax workers are used, validators are required unless the output is purely disposable ideation.
@@ -49,7 +51,7 @@ MiniMax workers must not receive:
```yaml
role: external-worker
scope:
-model_selector: minimax-fast|minimax-latest
+model_selector: minimax-latest
reasoning_effort: low|medium|high
output_contract:
validator_required: true
diff --git a/docs/release-process.md b/docs/release-process.md
new file mode 100644
index 0000000..e5730c3
--- /dev/null
+++ b/docs/release-process.md
@@ -0,0 +1,72 @@
+# Release Process
+
+AgentFanout is public-facing. Maintainer-originated GitHub-facing changes are release work and must pass the local source review gate before any GitHub branch, pull request, tag, release, or public export is created or updated.
+
+## Public Release Gate
+
+Required order:
+
+1. Prepare the change in the canonical local source repository.
+2. Review the change through the local source PR/review process.
+3. Complete the full security audit on the local PR/diff.
+4. Document the audit result with reviewer, date, commit/PR reference, findings, remediation, and pass/fail status.
+5. Create or update GitHub-facing artifacts only after the gate passes.
+
+Agents and automation must stop before GitHub-facing activity if they cannot verify the local review and audit reference.
+
+## Full Security Audit
+
+The audit must include:
+
+- `python3 tests/run_tests.py`
+- `python3 -m py_compile scripts/route.py tests/run_tests.py`
+- `scripts/github-preflight.sh`
+- secrets, credentials, and credential-shaped examples
+- private infrastructure references
+- unsafe worker delegation or privilege expansion
+- callback, webhook, voice, TTS, and notification leakage
+- public-claim accuracy in README, docs, examples, release notes, and security policy
+- dependency and supply-chain review when dependencies, CI actions, scripts, or packaging change
+- prompt/data-exfiltration review for routing, worker, validator, or provider changes
+
+Minimum audit record:
+
+```yaml
+date: YYYY-MM-DD
+reviewer: maintainer-or-reviewer
+local_review_ref: local-source-pr-or-commit
+github_target: branch-pr-release-or-export
+commands:
+ - python3 tests/run_tests.py
+ - python3 -m py_compile scripts/route.py tests/run_tests.py
+ - scripts/github-preflight.sh
+findings: []
+remediation: []
+result: pass|fail
+```
+
+## Exceptions
+
+Emergency bypass is allowed only for urgent public security fixes approved by the maintainer.
+
+Before GitHub action, document:
+
+- why the normal local gate cannot run first
+- who approved the bypass
+- what public exposure the fix addresses
+- when the follow-up local audit will run
+
+The local audit must be completed within 24 hours after the emergency GitHub action.
+
+## Violations
+
+Premature GitHub publication is a high-severity release incident.
+
+Response:
+
+1. Stop further GitHub activity.
+2. Assess what was exposed.
+3. Revert, close, or yank the public artifact if needed.
+4. Run the full security audit.
+5. Document findings, remediation, and prevention.
+6. Resume public work only after the incident is recorded and the gate passes.
diff --git a/references/CostPolicy.md b/references/CostPolicy.md
index a5b4936..c726b76 100644
--- a/references/CostPolicy.md
+++ b/references/CostPolicy.md
@@ -16,8 +16,8 @@ Use model selectors, not hardcoded model names. Provider adapters resolve select
| `claude-frontier` | Claude high-capability reasoning/code review |
| `claude-balanced` | Claude normal engineering/review worker |
| `claude-fast` | Claude cheap/fast summary/classification worker |
-| `minimax-latest` | MiniMax capable agentic/code worker |
-| `minimax-fast` | MiniMax cheaper/faster worker for low/medium complexity |
+| `minimax-latest` | MiniMax default supported worker selector |
+| `minimax-fast` | Optional only when the runtime has verified highspeed support for the active token plan |
## Selection policy
@@ -29,6 +29,10 @@ Use model selectors, not hardcoded model names. Provider adapters resolve select
6. **Validator pairing**: use a validator at least as capable as the worker for high-risk outputs; for cheap/external workers, prefer an independent provider if available.
7. **Reasoning effort is separate**: choose model selector and reasoning effort independently. A balanced model with high effort may be cheaper than a frontier model for bounded reasoning.
+MiniMax note: do not choose `minimax-fast` by default. The local MiniMax wrapper maps fast mode to `MiniMax-M2.7-highspeed`, which may be unsupported by the current token plan. Use `minimax-latest` unless highspeed support has been explicitly verified.
+
+Codex note: `current-fast-mini` requires an explicit native Codex mini/fast model override resolved by the host adapter. Do not launch cheap Codex explorer workers with the host's default main-session/frontier model. If the host cannot satisfy `runtime_launch.codex_native.cost_tier=cheap` and `required_model_capability=mini_or_fast`, block or reroute rather than silently overspending.
+
## Serial vs parallel
Use parallelism when subtasks are independent and outputs can be merged. Use serial delegation when one worker’s result defines the next scope. Use main-session-only for tiny, gated, or integration-heavy tasks.
diff --git a/references/ImplementationNotes.md b/references/ImplementationNotes.md
index 0a30597..12ba6c6 100644
--- a/references/ImplementationNotes.md
+++ b/references/ImplementationNotes.md
@@ -14,11 +14,14 @@ AgentFanout combines:
AgentFanout is not injected into startup context. A host runtime should expose only compact routing metadata at startup and load this skill only when the request benefits from orchestration.
+Broad investigation is worker-first. When routing returns `dispatch.status=launch_required`, the host should launch workers before doing substantive main-session research, then wait for worker results before integrating. The main session should not duplicate worker research while they run. If no configured worker mechanism is available, the host should surface `fanout_blocked`; silent main-session fallback recreates the context-growth failure AgentFanout is meant to prevent.
+
## Token Policy
- Keep `SKILL.md` lean and navigational.
- Route first; load details second.
- Generic workers receive compact task-specific capsules from `references/AgentCapsules.md`.
+- Research workers receive only scoped read-only tool permission relevant to their work unit.
- Full agent/persona context loads only for named, custom, personality-heavy, or trait-heavy agents.
- Full Codex-specific context loads only for deeper Codex persona/profile behavior.
- Worker prompts must not include automatic memory, relationship history, full skill bodies, or voice metadata.
@@ -42,12 +45,33 @@ Readiness flags and file checks are routing hints only. They do not perform live
If AgentFanout created a plan and the next user message clearly approves it, execute immediately unless a safety exception applies. Runtime Plan Mode, stale plans, ambiguous approvals, and unapproved destructive/production/secret/git-state operations pause execution.
+## Public Release Gate
+
+AgentFanout is public-facing. Treat GitHub as the public distribution surface, not the first review surface.
+
+Maintainer-originated GitHub-facing changes must follow this order:
+
+1. Prepare the change in the canonical local source repository.
+2. Review the change through the local source PR/review process.
+3. Run the full security audit against that local PR/diff.
+4. Document the audit result with reviewer, date, commit/PR reference, findings, remediation, and pass/fail status.
+5. Only after the audit passes, create or update any GitHub-facing branch, pull request, tag, release, or public export.
+
+Agents must stop before GitHub-facing activity if they cannot verify the local review and audit reference.
+
+The full security audit includes tests, compile checks, `scripts/github-preflight.sh`, secret and credential review, private infrastructure reference review, unsafe worker delegation review, public-claim accuracy review, dependency/supply-chain review when relevant, and prompt/data-exfiltration review for routing, worker, validator, or provider changes.
+
+Emergency bypass is allowed only for urgent public security fixes approved by the maintainer. Document the bypass reason before GitHub action and complete the local audit within 24 hours.
+
+Premature GitHub publication is a high-severity release incident. Stop public work, assess exposure, revert or yank if needed, complete the audit, document findings, and add a prevention note before resuming GitHub work.
+
## Portable Runtime Assumptions
- Skill registry root: `$PAI_DIR/skills` or `$PAI_HOME/skills`, falling back to `~/.pai/skills`.
- MiniMax readiness: `AGENT_FANOUT_MINIMAX_AVAILABLE=1` or a MiniMax wrapper at `~/.pai/skills/MiniMax/Tools/MiniMaxExec.ts`.
- Local readiness: `AGENT_FANOUT_LOCAL_AVAILABLE=1`.
- Codex readiness: Codex runtime environment variables or `AGENT_FANOUT_CODEX_SUBAGENTS=1`.
+- Codex `current-fast-mini` is an explicit cheap-worker contract. Host runtimes must pass the mini/fast model override in `runtime_launch.codex_native` and must not let native subagents default to the main-session/frontier model.
- Claude readiness: `AGENT_FANOUT_CLAUDE_AVAILABLE=1`.
## Future Harness Roadmap
diff --git a/references/LazyExecution.md b/references/LazyExecution.md
index 0498dcc..b656f8c 100644
--- a/references/LazyExecution.md
+++ b/references/LazyExecution.md
@@ -2,6 +2,10 @@
AgentFanout uses routing first and execution depth second.
+## No Pre-Route Sweeps
+
+After AgentFanout is selected, do not run repo-wide, vault-wide, or home-directory searches to find AgentFanout routing details. Use the selected skill path, read `SKILL.md` and the directly referenced route/provider files only, then run `scripts/route.py`. Broad search belongs inside scoped workers after dispatch, not in the orchestrator before route.
+
## Default
Stay in the main session for trivial prompts, direct answers, one-line edits, private-tool work, secrets, git state changes, destructive actions, and final synthesis.
diff --git a/references/Routing.md b/references/Routing.md
index e020a65..a380a77 100644
--- a/references/Routing.md
+++ b/references/Routing.md
@@ -44,6 +44,10 @@ Delegate when at least one condition is true and no hard gate blocks it:
Do not delegate when orchestration overhead dominates.
+Broad investigation is worker-first. Prompts such as "fully investigate", "find whether X exists", "trace how X talks to Y", or multi-surface host/repo/service research should produce research workers before the main session starts gathering evidence. After launch, the main session coordinates, waits, validates, and integrates; it must not duplicate the workers' SSH diagnostics, filesystem sweeps, repo-wide search, or substantive research. If no provider can launch workers, the route must expose `fanout_blocked` instead of silently converting the job into main-session research.
+
+Research workers may receive scoped read-only tool access when assigned by the host runtime: repo/file reads, filesystem search, runtime-approved knowledge-base reads, public web/docs research, read-only SSH diagnostics, and read-only database queries where configured. These are not permission to perform private-account side effects, handle secrets, change git state, run destructive operations, or write final synthesis.
+
## Independence test
A subtask is independent only if it has:
diff --git a/references/SessionModes.md b/references/SessionModes.md
index 7cc7519..d8e178e 100644
--- a/references/SessionModes.md
+++ b/references/SessionModes.md
@@ -29,9 +29,9 @@ Only these commands mutate `next_state`. Legacy OrchestratorMode and CodexAgent
## Provider/Effort Matrix
- Auto: choose cheapest capable safe route; prefer local only when readiness passes.
-- MiniMax: use `minimax-fast` or `minimax-latest` for eligible workers; validate with Codex/native main-session capability when possible.
+- MiniMax: use `minimax-latest` for eligible workers; use `minimax-fast` only after the runtime verifies highspeed support for the active token plan. Validate with Codex/native main-session capability when possible.
- Local: use `local-worker`, `local-coder`, `local-thinker`, or `local-chat` only after readiness gates pass.
-- Codex: use native Codex subagents only when the runtime host exposes them; otherwise fall back to main-session routing.
+- Codex: use native Codex subagents only when the runtime host exposes them; otherwise fall back to main-session routing. For `current-fast-mini`, pass the explicit mini/fast model override from `runtime_launch.codex_native` instead of allowing the host default model.
- Claude: use `claude-fast`, `claude-balanced`, or `claude-frontier` only when a safe adapter is configured.
- High effort: quality-first non-premium frontier/latest selector with high reasoning; use extra-high where supported for security, release, architecture, migration, high-stakes, or ambiguous synthesis tasks.
diff --git a/references/Subagents.md b/references/Subagents.md
index 0291d1a..0d30a68 100644
--- a/references/Subagents.md
+++ b/references/Subagents.md
@@ -24,6 +24,9 @@ Use an explorer for read-only work:
- codebase questions
- evidence gathering
- module review
+- broad investigation before main-session research
+- knowledge-base/file/search-heavy research
+- read-only host/service diagnostics when explicitly scoped
- design critique
- security review without edits
- validation of claims or diffs
@@ -31,12 +34,14 @@ Use an explorer for read-only work:
Explorer constraints:
- no writes
-- no MCP
+- no private-account side effects
- no secrets
- no git state changes
- bounded scope
- return evidence and uncertainties
+Allowed read-only retrieval scope, when assigned by the host, includes repo/file reads, filesystem search, runtime-approved knowledge-base retrieval, public web/docs research, read-only SSH diagnostics, and read-only database queries where configured.
+
## Worker
Use a worker for bounded implementation or transformation:
diff --git a/scripts/github-preflight.sh b/scripts/github-preflight.sh
index 300e71b..7625541 100755
--- a/scripts/github-preflight.sh
+++ b/scripts/github-preflight.sh
@@ -67,7 +67,7 @@ grep_gate "Private key material" "-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KE
grep_gate "Common token prefixes" "(ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,}|sk-[A-Za-z0-9]{20,})"
grep_gate "Bearer tokens" "Bearer [A-Za-z0-9._-]{30,}"
grep_gate "Inline DSNs with passwords" "postgresql://[^:/\$[:space:]]+:[^@\$[:space:]]+@[^/\$[:space:]]+"
-grep_gate "Local infrastructure paths" "(/home/minion|/srv/tools|192\\.168\\.|gitea-minion|OpsVault)"
+grep_gate "Local infrastructure references" "(/home/[A-Za-z0-9_-]+|/srv/[A-Za-z0-9_-]+|192\\.168\\.|10\\.[0-9]+\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|ssh [A-Za-z0-9_.-]+|\\s\\.[0-9]{2,3}\\b)"
large_file_gate
diff --git a/scripts/route.py b/scripts/route.py
index 33931ad..d7ed005 100755
--- a/scripts/route.py
+++ b/scripts/route.py
@@ -50,6 +50,16 @@
"local llm workers", "multi-agent", "fan out", "provider-agnostic",
"cost-aware", "agents",
]
+INVESTIGATION_CUES = [
+ "investigate", "full investigation", "research", "find", "search", "look up",
+ "trace", "where", "whether", "exists", "current state",
+ "stale process", "stale processes", "running process",
+ "service", "services", "host", "controller", "controllers", "path", "repo",
+]
+BROAD_INVESTIGATION_CUES = [
+ "full investigation", "fully investigate", "deep investigation", "complete investigation",
+ "audit", "security check", "read-only diagnostics", "multiple systems", "cross-check",
+]
SPECIALIST_AGENT_CUES = [
"custom agent", "custom agents", "named agent", "named agents", "personality",
"personalities", "traits", "specialist agents", "specialized agents", "skeptical",
@@ -60,10 +70,15 @@
AMBIGUOUS_APPROVALS = ["ok", "okay", "sounds good", "maybe", "continue", "interesting"]
MCP_CUES = [
- "memory", "calendar", "gmail", "email", "send an email", "asana", "slack",
+ "calendar", "gmail", "email", "send an email", "asana", "slack",
"discord", "playwright", "gws", "google workspace", "private search",
"workspace tool", "mcp",
]
+MCP_MEMORY_CUES = [
+ "use memory", "search memory", "search memories", "saved memory",
+ "private memory", "memory mcp", "recall from memory", "look in memory",
+ "check memory", "remembered context",
+]
SECRET_CUES = ["secret", "api key", "token", "credential", "password", "private key"]
SECRET_PATTERNS = [
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
@@ -106,6 +121,11 @@ def looks_like_secret(text: str) -> bool:
return contains_any(text, SECRET_CUES) or any(pattern.search(text) for pattern in SECRET_PATTERNS)
+def requires_private_mcp_tools(text: str) -> bool:
+ lowered = text.lower()
+ return contains_any(lowered, MCP_CUES) or contains_any(lowered, MCP_MEMORY_CUES)
+
+
def canonical_provider(value: str) -> str:
lookup = {
"auto": "Auto",
@@ -248,7 +268,7 @@ def sanitize_prompt(prompt: str) -> dict[str, Any]:
def classify(prompt: str) -> str:
p = prompt.lower()
- if contains_any(p, MCP_CUES):
+ if requires_private_mcp_tools(p):
return "mcp-inline"
if contains_any(p, GIT_STATE_CUES) or contains_any(p, GIT_READ_CUES):
return "git-ops"
@@ -266,12 +286,14 @@ def classify(prompt: str) -> str:
return "bulk-classify"
if "csv" in p or "json" in p or "etl" in p or "schema" in p or "reshape" in p or "parse" in p:
return "data-xform"
- if "logs" in p or "summarize" in p or "grep" in p or "review these" in p or "modules" in p:
- return "bulk-read"
if "architecture" in p or "migration" in p or "design" in p or "strategy" in p or "spec" in p or "plan" in p:
return "plan"
if "prove" in p or "correctness" in p or "argument" in p or "reason" in p:
return "reason"
+ if contains_any(p, INVESTIGATION_CUES):
+ return "bulk-read"
+ if "logs" in p or "summarize" in p or "grep" in p or "review these" in p or "modules" in p:
+ return "bulk-read"
if "copy" in p or "marketing" in p or "creative" in p or "draft" in p:
return "creative"
return "bulk-read" if contains_any(p, ORCH_TRIGGERS) else "small-edit"
@@ -370,6 +392,16 @@ def should_delegate(task_class: str, prompt: str, gates: dict[str, bool]) -> boo
return explicit or complex_class
+def worker_kind(task_class: str, role: str) -> str:
+ if role == "main":
+ return "main_session"
+ if role == "worker":
+ return "execution_worker"
+ if task_class in {"bulk-read", "plan", "security-review", "reason"} or role == "explorer":
+ return "research_worker"
+ return "task_worker"
+
+
def env_flag(*names: str) -> bool:
return any(os.getenv(name, "").lower() in {"1", "true", "yes"} for name in names)
@@ -421,7 +453,7 @@ def readiness(provider: str, selector: str) -> tuple[bool, list[str]]:
def selector_for(provider: str, effort: str, task_class: str) -> str:
high = effort in {"high", "xhigh"}
if provider == "MiniMax":
- return "minimax-latest" if high else "minimax-fast"
+ return "minimax-latest"
if provider == "Local":
if high:
return "local-thinker"
@@ -439,6 +471,42 @@ def selector_for(provider: str, effort: str, task_class: str) -> str:
return "main-session"
+def runtime_launch(provider: str, selector: str, effort: str) -> dict[str, Any]:
+ if provider == "Codex":
+ model_preference = {
+ "current-fast-mini": "fast-mini",
+ "current-balanced": "balanced",
+ "current-frontier": "frontier",
+ }.get(selector, "provider-default")
+ cost_tier = {
+ "current-fast-mini": "cheap",
+ "current-balanced": "balanced",
+ "current-frontier": "frontier",
+ }.get(selector, "unknown")
+ codex_native: dict[str, Any] = {
+ "agent_type": "explorer" if selector == "current-fast-mini" else "default",
+ "cost_tier": cost_tier,
+ "model_preference": model_preference,
+ "reasoning_effort": effort,
+ "must_pass_model_override": selector == "current-fast-mini",
+ "do_not_use_default_frontier_for_fast_mini": selector == "current-fast-mini",
+ }
+ if selector == "current-fast-mini":
+ codex_native["required_model_capability"] = "mini_or_fast"
+ codex_native["fallback_model_preference"] = "lowest_available_codex_mini_or_fast_model"
+ elif selector == "current-frontier":
+ codex_native["required_model_capability"] = "frontier_or_highest_capability"
+ return {
+ "provider": "Codex",
+ "selector": selector,
+ "codex_native": codex_native,
+ }
+ return {
+ "provider": provider,
+ "selector": selector,
+ }
+
+
def auto_provider_candidates(task_class: str, effort: str) -> list[str]:
if task_class in {"debug", "test-gen", "refactor"}:
return ["Codex", "Local", "MiniMax", "Claude"]
@@ -470,12 +538,31 @@ def choose_provider(prompt: str, state: dict[str, Any], task_class: str, effort:
return "main-session", "main-session", warnings
+def investigation_dimensions(prompt: str) -> list[str]:
+ lowered = prompt.lower()
+ dimensions: list[str] = []
+ if contains_any(lowered, ["knowledge base", "prior", "history", "incident", "document", "docs"]):
+ dimensions.append("knowledge_base_context")
+ if contains_any(lowered, ["host", "server", "machine", "ssh", "node", "instance"]):
+ dimensions.append("live_host_state")
+ if contains_any(lowered, ["process", "processes", "service", "services", "application", "gpu", "port", "docker", "ram", "swap", "memory usage"]):
+ dimensions.append("process_service_state")
+ if contains_any(lowered, ["repo", "code", "controller", "controllers", "path", "module", "component", "integration"]):
+ dimensions.append("repo_code_path_trace")
+ if contains_any(lowered, ["whether", "exists", "prove", "proof", "verify", "cross-check", "validate"]):
+ dimensions.append("independent_validation")
+ if contains_any(lowered, ["security", "public", "github", "release", "preflight"]):
+ dimensions.append("security_release_review")
+ return dimensions
+
+
def estimate_work_units(prompt: str, task_class: str, delegated: bool) -> tuple[int, list[str], list[str]]:
if not delegated:
return 0, [], ["delegation_not_useful_or_hard_gated"]
lowered = prompt.lower()
word_numbers = {"two": 2, "three": 3, "four": 4, "five": 5, "ten": 10, "twelve": 12, "twenty": 20}
count = 1
+ dimensions = investigation_dimensions(prompt) if task_class == "bulk-read" else []
for pattern in [
r"\b(\d{1,3})\s+(?:independent\s+)?(?:modules|files|items|tasks|services|components|workstreams|things)\b",
r"\b(\d{1,3})\s+independent\b",
@@ -494,12 +581,21 @@ def estimate_work_units(prompt: str, task_class: str, delegated: bool) -> tuple[
count = 4
elif task_class in {"plan", "refactor", "security-review", "debug", "test-gen"}:
count = 3
+ elif task_class == "bulk-read" and (contains_any(lowered, BROAD_INVESTIGATION_CUES) or len(dimensions) >= 2):
+ count = max(3, min(6, len(dimensions) + 1))
elif "parallel" in lowered or "multiple" in lowered or "fan out" in lowered:
count = 3
- units = [f"work_unit_{i}" for i in range(1, count + 1)]
+ if dimensions and count > 1:
+ units = dimensions[:count]
+ while len(units) < count:
+ units.append(f"research_work_unit_{len(units) + 1}")
+ else:
+ units = [f"work_unit_{i}" for i in range(1, count + 1)]
guardrails: list[str] = []
guardrails.append("hard_gates_prechecked")
guardrails.append("provider_readiness_prechecked")
+ if task_class == "bulk-read":
+ guardrails.append("read_only_worker_scope")
if count == 1:
guardrails.append("single_worker_enough")
return count, units, guardrails
@@ -521,11 +617,24 @@ def capsule_for(task_class: str, prompt: str, role: str, delegated: bool) -> dic
role_name, expertise = role_map.get(task_class, ("Task Specialist", ["Stay within scope", "Return evidence", "Avoid unrelated work"]))
if "code" in prompt.lower() or role == "worker":
role_name = "Coding Specialist" if task_class in {"debug", "refactor", "test-gen"} else role_name
+ kind = worker_kind(task_class, role)
+ read_only_scope = [
+ "repo_file_reads",
+ "filesystem_search",
+ "knowledge_base_search_and_file_reads",
+ "web_or_docs_research_when_available",
+ "read_only_ssh_diagnostics_when_explicitly_scoped",
+ "read_only_database_queries_when_configured",
+ ]
+ execution_scope = ["assigned_file_edits", "targeted_tests", "build_or_lint_checks", "bounded_shell_commands"]
return {
"role_name": role_name,
+ "worker_type": kind,
"expertise": expertise,
"task_scope": "bounded delegated work unit",
"allowed_actions": ["read", "edit", "test", "analyze"] if role == "worker" else ["read", "analyze"],
+ "read_only_tool_scope": read_only_scope if kind == "research_worker" else [],
+ "execution_tool_scope": execution_scope if kind == "execution_worker" else [],
"forbidden_actions": ["mcp_private_tools", "secrets", "git_state_changes", "destructive_actions", "callbacks_voice_notifications", "final_synthesis", "automatic_memory_loading"],
"output_contract": {"format": "markdown_or_json", "required_fields": ["summary", "evidence", "risks_or_uncertainties"]},
"return_only_to_main_session": True,
@@ -575,6 +684,59 @@ def validator_for(provider: str, task_class: str, role: str, delegated: bool, ef
}
+def dispatch_contract(
+ delegated: bool,
+ provider: str,
+ selector: str,
+ effort: str,
+ role: str,
+ task_class: str,
+ planned_workers: int,
+ units: list[str],
+ provider_warnings: list[str],
+) -> dict[str, Any]:
+ if not delegated:
+ blocked = bool(provider_warnings and provider == "main-session" and task_class != "small-edit")
+ return {
+ "required": blocked,
+ "status": "fanout_blocked" if blocked else "not_required",
+ "main_session_policy": "report_block" if blocked else "handle_directly",
+ "worker_type": "main_session",
+ "launch_before_main_research": False,
+ "main_session_may_duplicate_worker_research": False,
+ "provider": provider,
+ "model_selector": selector,
+ "runtime_launch": runtime_launch(provider, selector, effort),
+ "planned_workers": 0,
+ "work_units": [],
+ "fallback_allowed": not blocked,
+ "block_reason": "; ".join(provider_warnings) if blocked else None,
+ "forbidden_main_session_actions_until_workers_return": [],
+ }
+ return {
+ "required": True,
+ "status": "launch_required",
+ "main_session_policy": "coordinate_wait_integrate",
+ "worker_type": worker_kind(task_class, role),
+ "launch_before_main_research": True,
+ "main_session_may_duplicate_worker_research": False,
+ "provider": provider,
+ "model_selector": selector,
+ "runtime_launch": runtime_launch(provider, selector, effort),
+ "planned_workers": planned_workers,
+ "work_units": units,
+ "fallback_allowed": False,
+ "block_reason": None,
+ "forbidden_main_session_actions_until_workers_return": [
+ "substantive_research",
+ "ssh_diagnostics",
+ "filesystem_sweeps",
+ "repo_wide_search",
+ "parallel_duplicate_analysis",
+ ],
+ }
+
+
def circuit_breaker(worker_failures: int = 0) -> dict[str, Any]:
failures = max(0, worker_failures)
return {
@@ -645,12 +807,23 @@ def route(
task_prompt = remaining_prompt if command["type"] != "invalid" else prompt
task_class = classify(task_prompt)
gates = hard_gates(task_prompt, task_class)
- delegated = should_delegate(task_class, task_prompt, gates)
+ delegation_wanted = should_delegate(task_class, task_prompt, gates)
effort = reasoning_effort(task_class, task_prompt, next_state)
- role = role_for(task_class, task_prompt) if delegated else "main"
- provider, selector, provider_warnings = choose_provider(task_prompt, next_state, task_class, effort, delegated)
+ breaker = circuit_breaker(worker_failures)
+ if breaker["triggered"]:
+ warnings.append("circuit_breaker_triggered")
+ delegation_wanted = False
+ role = role_for(task_class, task_prompt) if delegation_wanted else "main"
+ provider_warnings: list[str]
+ if breaker["triggered"]:
+ provider, selector, provider_warnings = "main-session", "main-session", ["circuit_breaker_triggered:worker_failure_threshold"]
+ else:
+ provider, selector, provider_warnings = choose_provider(task_prompt, next_state, task_class, effort, delegation_wanted)
warnings.extend(provider_warnings)
+ fanout_blocked = False
+ delegated = delegation_wanted
if provider == "main-session":
+ fanout_blocked = delegation_wanted and bool(provider_warnings)
delegated = False
role = "main"
planned_workers, units, guardrails = estimate_work_units(task_prompt, task_class, delegated)
@@ -660,9 +833,10 @@ def route(
codex_ref = codex_adapter_required(task_prompt, provider) or next_state["provider_mode"] == "Codex" or "codex" in task_prompt.lower()
refs = references_to_load(task_prompt, provider, delegated, full_agents, codex_ref, pending_plan)
capsule = capsule_for(task_class, task_prompt, role, delegated)
- breaker = circuit_breaker(worker_failures)
if breaker["triggered"]:
- warnings.append("circuit_breaker_triggered")
+ fanout_blocked = True
+ if "circuit_breaker_stopped_new_launches" not in guardrails:
+ guardrails.append("circuit_breaker_stopped_new_launches")
return {
"command": command,
@@ -675,6 +849,7 @@ def route(
"provider": provider,
"model_selector": selector,
"reasoning_effort": effort,
+ "runtime_launch": runtime_launch(provider, selector, effort),
},
},
"orchestration_useful": delegated,
@@ -696,7 +871,9 @@ def route(
"rationale": "automatic decomposition estimate" if delegated else "main_session_only",
"guardrails_applied": guardrails,
"fallback_reason": "; ".join(provider_warnings) if provider_warnings and not delegated else None,
+ "blocked": fanout_blocked,
},
+ "dispatch": dispatch_contract(delegated, provider, selector, effort, role, task_class, planned_workers, units, provider_warnings),
"circuit_breaker": breaker,
"warnings": warnings,
"main_session_responsibilities": ["MCP/private tools", "secrets", "git state changes", "destructive actions", "final synthesis"],
@@ -715,6 +892,8 @@ def main() -> None:
parser.add_argument("--needs-separate-confirmation", action="store_true", help="Pending plan needs separate destructive/production/secret/git confirmation")
args = parser.parse_args()
text = " ".join(args.prompt).strip()
+ if not text and not sys.stdin.isatty():
+ text = sys.stdin.read().strip()
if args.sanitize:
print(json.dumps(sanitize_prompt(text), indent=2))
return
diff --git a/tests/fixtures.json b/tests/fixtures.json
index 2e337d5..4c6ee0b 100644
--- a/tests/fixtures.json
+++ b/tests/fixtures.json
@@ -135,6 +135,24 @@
"references_to_load": "adapters/Codex.md"
}
},
+ {
+ "name": "codex cheap explorer requires explicit mini capability override",
+ "prompt": "use Codex subagents to classify these independent notes across many files",
+ "state_json": {"provider_mode": "Codex", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"},
+ "expect": {
+ "codex_adapter_required": true,
+ "route.task_class": "bulk-classify",
+ "route.executor.model_selector": "current-fast-mini",
+ "route.executor.reasoning_effort": "low",
+ "route.executor.runtime_launch.codex_native.cost_tier": "cheap",
+ "route.executor.runtime_launch.codex_native.must_pass_model_override": true,
+ "route.executor.runtime_launch.codex_native.required_model_capability": "mini_or_fast",
+ "dispatch.runtime_launch.codex_native.do_not_use_default_frontier_for_fast_mini": true
+ },
+ "expect_contains": {
+ "references_to_load": "adapters/Codex.md"
+ }
+ },
{
"name": "legacy agentorchestrator does not mutate state",
"prompt": "AgentOrchestrator MiniMax review three modules",
@@ -170,6 +188,37 @@
"hard_gates.requires_mcp_private_tools": true
}
},
+ {
+ "name": "private memory request stays main session",
+ "prompt": "use memory to recall private context about this project",
+ "state_json": {"provider_mode": "MiniMax", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"},
+ "expect": {
+ "route.task_class": "mcp-inline",
+ "route.worth_delegating": false,
+ "hard_gates.requires_mcp_private_tools": true,
+ "selected_provider": "main-session"
+ }
+ },
+ {
+ "name": "host memory usage investigation routes to minimax research worker",
+ "prompt": "I want you to research what is going on with the staging host and why its memory usage is so large. use read-only diagnostics for access. agentfanout minimax",
+ "state_json": {"provider_mode": "Auto", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"},
+ "expect": {
+ "route.task_class": "bulk-read",
+ "route.worth_delegating": true,
+ "route.parallelism": "parallel",
+ "route.executor.provider": "MiniMax",
+ "hard_gates.requires_mcp_private_tools": false,
+ "fanout.planned_workers": 3,
+ "worker_capsule.worker_type": "research_worker",
+ "dispatch.status": "launch_required",
+ "dispatch.main_session_policy": "coordinate_wait_integrate",
+ "dispatch.main_session_may_duplicate_worker_research": false
+ },
+ "expect_contains_all": {
+ "fanout.work_units": ["live_host_state", "process_service_state"]
+ }
+ },
{
"name": "openai style secret stays main session",
"prompt": "debug failing auth with sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
@@ -300,6 +349,72 @@
"selected_provider": "main-session"
}
},
+ {
+ "name": "failed remote host investigation requires worker-first fanout",
+ "prompt": "I need you to fully investigate the staging host. We ran a demo application there days ago and I feel like we have a lot of stale processes that need shut down. full investigation agentfanout minimax",
+ "state_json": {"provider_mode": "Auto", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"},
+ "expect": {
+ "route.task_class": "bulk-read",
+ "route.worth_delegating": true,
+ "route.parallelism": "parallel",
+ "route.executor.provider": "MiniMax",
+ "fanout.planned_workers": 3,
+ "worker_capsule.worker_type": "research_worker",
+ "dispatch.status": "launch_required",
+ "dispatch.launch_before_main_research": true,
+ "dispatch.fallback_allowed": false,
+ "dispatch.main_session_policy": "coordinate_wait_integrate",
+ "dispatch.main_session_may_duplicate_worker_research": false
+ },
+ "expect_contains": {
+ "worker_capsule.read_only_tool_scope": "read_only_ssh_diagnostics_when_explicitly_scoped",
+ "fanout.work_units": "live_host_state"
+ }
+ },
+ {
+ "name": "integration path investigation does not small-edit",
+ "prompt": "Investigate how the notification integration communicates with the workflow service, whether notification controllers are inside that service, and whether the integration path exists; use MiniMax as a read-only second-opinion validator.",
+ "state_json": {"provider_mode": "Auto", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"},
+ "expect": {
+ "route.task_class": "bulk-read",
+ "route.worth_delegating": true,
+ "route.parallelism": "parallel",
+ "route.executor.provider": "MiniMax",
+ "fanout.planned_workers": 4,
+ "worker_capsule.worker_type": "research_worker",
+ "dispatch.status": "launch_required",
+ "dispatch.launch_before_main_research": true,
+ "dispatch.main_session_policy": "coordinate_wait_integrate",
+ "dispatch.main_session_may_duplicate_worker_research": false
+ },
+ "expect_contains": {
+ "fanout.work_units": "independent_validation"
+ }
+ },
+ {
+ "name": "fanout unavailable blocks instead of silent main-session fallback",
+ "prompt": "fully investigate repo services and verify the path exists",
+ "env": {
+ "PAI_DIR": "/tmp/agentfanout-no-pai",
+ "AGENT_FANOUT_MINIMAX_AVAILABLE": "0",
+ "AGENT_FANOUT_CODEX_SUBAGENTS": "0",
+ "AGENT_FANOUT_LOCAL_AVAILABLE": "0",
+ "AGENT_FANOUT_CLAUDE_AVAILABLE": "0"
+ },
+ "unset_env": ["CODEX_THREAD_ID", "PAI_CODEX_LAUNCH_ID"],
+ "expect": {
+ "route.task_class": "bulk-read",
+ "route.worth_delegating": false,
+ "fanout.blocked": true,
+ "dispatch.required": true,
+ "dispatch.status": "fanout_blocked",
+ "dispatch.fallback_allowed": false,
+ "dispatch.main_session_policy": "report_block"
+ },
+ "expect_contains": {
+ "warnings": "provider_unavailable:codex:native_subagents_unknown"
+ }
+ },
{
"name": "clear approval executes pending plan",
"prompt": "implement it",
@@ -340,6 +455,26 @@
"reason": "plan_stale"
}
},
+ {
+ "name": "planning prompt keeps plan precedence over broad research words",
+ "prompt": "plan how to migrate the architecture safely",
+ "state_json": {"provider_mode": "MiniMax", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"},
+ "expect": {
+ "route.task_class": "plan",
+ "route.executor.reasoning_effort": "high",
+ "route.executor.provider": "MiniMax"
+ }
+ },
+ {
+ "name": "reasoning prompt keeps reason precedence over investigation words",
+ "prompt": "reason about correctness and prove the invariant before implementation",
+ "state_json": {"provider_mode": "MiniMax", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"},
+ "expect": {
+ "route.task_class": "reason",
+ "route.executor.reasoning_effort": "high",
+ "route.executor.provider": "MiniMax"
+ }
+ },
{
"name": "circuit breaker after failures",
"prompt": "review three independent modules",
@@ -347,10 +482,19 @@
"state_json": {"provider_mode": "MiniMax", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"},
"expect": {
"circuit_breaker.triggered": true,
- "circuit_breaker.failure_count": 3
+ "circuit_breaker.failure_count": 3,
+ "route.worth_delegating": false,
+ "route.executor.provider": "main-session",
+ "route.parallelism": "none",
+ "fanout.planned_workers": 0,
+ "fanout.blocked": true,
+ "dispatch.status": "fanout_blocked",
+ "dispatch.planned_workers": 0,
+ "dispatch.fallback_allowed": false
},
"expect_contains": {
- "warnings": "circuit_breaker_triggered"
+ "warnings": "circuit_breaker_triggered",
+ "fanout.guardrails_applied": "circuit_breaker_stopped_new_launches"
}
}
]
diff --git a/tests/run_tests.py b/tests/run_tests.py
index 85ea12d..3d50676 100755
--- a/tests/run_tests.py
+++ b/tests/run_tests.py
@@ -24,6 +24,10 @@ def run_route(case):
env = os.environ.copy()
env.setdefault("AGENT_FANOUT_MINIMAX_AVAILABLE", "1")
env.setdefault("AGENT_FANOUT_CODEX_SUBAGENTS", "1")
+ for name in case.get("unset_env", []):
+ env.pop(name, None)
+ for name, value in case.get("env", {}).items():
+ env[name] = str(value)
if case.get("state_json") is not None:
cmd.extend(["--state-json", json.dumps(case["state_json"])])
if case.get("worker_failures") is not None:
@@ -50,6 +54,11 @@ def test_fixtures():
got = get_path(actual, key)
if expected not in got:
failures.append((case["name"], key, f"contains {expected}", got, actual))
+ for key, expected_values in case.get("expect_contains_all", {}).items():
+ got = get_path(actual, key)
+ for expected in expected_values:
+ if expected not in got:
+ failures.append((case["name"], key, f"contains {expected}", got, actual))
return failures
@@ -83,6 +92,35 @@ def test_sanitizer():
return []
+def test_stdin_prompt_route():
+ prompt = "AgentFanout Codex\nClassify these independent notes into categories with Codex subagents."
+ env = os.environ.copy()
+ env["AGENT_FANOUT_CODEX_SUBAGENTS"] = "1"
+ proc = subprocess.run(
+ [sys.executable, "-S", str(ROUTE), "--state-json", json.dumps({"provider_mode": "Codex", "effort_mode": "Auto", "fanout_policy": "automatic_guarded", "state_scope": "conversation_only"})],
+ input=prompt,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ )
+ if proc.returncode != 0:
+ return [("stdin_prompt_route", "returncode", 0, proc.returncode, proc.stderr)]
+ actual = json.loads(proc.stdout)
+ checks = {
+ "route.task_class": "bulk-classify",
+ "route.executor.provider": "Codex",
+ "route.executor.model_selector": "current-fast-mini",
+ "route.executor.runtime_launch.codex_native.required_model_capability": "mini_or_fast",
+ }
+ failures = []
+ for key, expected in checks.items():
+ got = get_path(actual, key)
+ if got != expected:
+ failures.append(("stdin_prompt_route", key, expected, got, actual))
+ return failures
+
+
def test_retired_kernel_absent():
kernel = ROOT / "references" / "Kernel.md"
if kernel.exists():
@@ -91,7 +129,7 @@ def test_retired_kernel_absent():
def main():
- failures = test_fixtures() + test_malformed_state() + test_sanitizer() + test_retired_kernel_absent()
+ failures = test_fixtures() + test_malformed_state() + test_sanitizer() + test_stdin_prompt_route() + test_retired_kernel_absent()
if failures:
print(json.dumps(failures, indent=2))
sys.exit(1)