Skip to content
4shilPublic

About

Adaptive Agent Controller — learned neural routing to minimize LLM execution cost in agentic workflows (V0.1+V1+V1.1, YELLOW PASS, -39% cost vs baseline)

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

AAC — Adaptive Agent Control

AAC is a control plane for agent computation. It attaches to an AI agent harness, reads what that harness is doing right now, and answers one question per step: which model, tool, or cached trajectory should this step spend its compute on? It is not a chatbot and not an agent — it never writes your code, never calls your tools, and never holds your credentials.

Why does it exist? An agent that reaches for the frontier model on every step pays frontier prices for work a read_file could have answered, and pays nothing at all when a validated trajectory from yesterday would have done. How does it work? The harness tells AAC what it has (models, tools, subagents, permissions); AAC builds an action space from exactly that, scores candidates against a cost model, and returns a single opaque decision — an ID, never a tool-call JSON. The harness resolves the ID and executes. If AAC raises, times out, misconfigures, or simply has nothing useful to say, it returns FALLBACK_TO_HARNESS and the agent continues exactly as it did before AAC was installed.

                    BEFORE AAC                              AFTER AAC
 ┌───────────────────────────────┐          ┌───────────────────────────────┐
 │  Agent harness, unaided       │          │  Agent harness, unaided       │
 │                               │          │                               │
 │  step 1  ── frontier model    │  100     │  step 1  ── deterministic tool│    1
 │  step 2  ── frontier model    │  100     │  step 2  ── small model       │    5
 │  step 3  ── frontier model    │  100     │  step 3  ── large model ¹     │   20
 │  step 4  ── frontier model    │  100     │  step 4  ── reused trajectory │    1
 │                               │          │                               │
 │  total ................ 400   │          │  total ................  27   │
 └───────────────────────────────┘          └───────────────────────────────┘
   ¹ escalation happens only after the small tier actually failed —
     never pre-emptively, and never more than `controller.max_escalations` times.

Two promises, stated up front

AAC never bypasses harness permissions. An adapter reports resources; it does not acquire them. If the harness denies an action, that is the end of the matter — the adapter records the failure and the controller chooses differently. DecisionValidator is constructed with allow_privilege_escalation = False and no code path sets it to True.

AAC never becomes a single point of failure. Every public method on AACRuntime catches its own exceptions. A controller that raises, an adapter that dies, a corrupt experience store, an unwritable ledger — all of these degrade to "no opinion", and the host agent continues with its native behaviour. decide() returning FALLBACK_TO_HARNESS is the normal, expected outcome of a failure, not an error to propagate. See ARCHITECTURE.md § 4.


What is AAC?

A local, offline, observational control plane with one narrow output: a computational-allocation decision per agent step. It sits beside your agent harness (OpenCode, Claude Code, Codex, any MCP-capable harness, or any harness that speaks a universal event protocol) and rides along in its execution path.

Nothing about AAC requires a network, an API key, or a GPU. pip install . and aac install is the whole setup.

Why does it exist?

Agents over-spend in two directions at once:

  1. They reach for expensive compute by default. A frontier model call for a step that a deterministic tool (read_file, grep) could answer is pure waste — and the waste is invisible, because nothing was measuring it.
  2. They re-derive work they have already done. The same "fix the failing test in calculator.py" task recurs; yesterday's successful trajectory is thrown away.

AAC fixes both by making the choice explicit and explainable. Every decision carries a reason string (deterministic_tool_sufficient, cheapest_adequate_model_tier_small, validated_experience_semantic_match). A control plane that cannot explain itself cannot be debugged or trusted.

It also measures itself. Every saving AAC reports is net of AAC's own overhead. There is no code path in aac/cost/accounting.py that reports gross savings alone — net_savings = gross_savings - aac_overhead_cost, always.

How does it work?

  ┌──────────────────────────────────────────────────────────────────────┐
  │  AGENT HARNESS  (OpenCode · Claude Code · Codex · MCP host · any)    │
  │  The harness owns: tools, models, credentials, permissions, execution│
  └───────┬──────────────────────────────────────────────┬───────────────┘
          │ capabilities + state                         │ decision (an ID)
          ▼                                              ▼
  ┌──────────────────────────────────────────────────────────────────────┐
  │  ADAPTER LAYER          aac/adapters/                                │
  │  opencode · claude_code · codex · mcp · generic                      │
  │  Normalizes one harness into AAC's vocabulary. Degrades to           │
  │  "smaller capability set", never to an exception.                    │
  └───────┬──────────────────────────────────────────────────────────────┘
          │ HarnessCapabilities + AgentStateSnapshot
          ▼
  ┌──────────────────────────────────────────────────────────────────────┐
  │  RUNTIME  ── aac/runtime/runtime.py                                  │
  │  Owns: lifecycle · metrics · adapter registry · experience store     │
  │  Guards the hot path. Every method catches its own exceptions.       │
  └───────┬──────────────────────────────────────────────────────────────┘
          │
          ▼
  ┌──────────────────────────────────────────────────────────────────────┐
  │  CONTROLLER  ── aac/control/controller.py                            │
  │                                                                      │
  │   1. ActionSpace.from_capabilities(caps)   what may be offered       │
  │   2. experience search + trajectory validation                        │
  │   3. ControllerState.from_snapshot(...)     what the policy may see  │
  │   4. policy.decide(state, caps)             cheapest adequate thing  │
  │   5. DecisionValidator.validate(...)        reject / repair          │
  │   6. measure latency                        never skip this          │
  └───────┬──────────────────────────────────────────────────────────────┘
          │ Decision { action, resource (an ID), confidence, expected_cost,
          │           expected_success, reason, fallback }
          ▼
  ┌──────────────────────────────────────────────────────────────────────┐
  │  POLICY  ── aac/control/policy.py    rule (shipped) · bandit · RL    │
  │  COST ENGINE ── aac/cost/estimator.py  provider-independent weights  │
  └──────────────────────────────────────────────────────────────────────┘

  The controller NEVER emits tool JSON. It emits an ID. The harness resolves it.

The action vocabulary is data, not code (aac/core/actions.py):

Action Default cost What it means
REUSE_EXPERIENCE 1.0 Replay a validated trajectory
CALL_DETERMINISTIC_TOOL 2.0 Run a deterministic tool; no model involved
CALL_TOOL 3.0 Run a harness tool
RUN_SMALL_MODEL 5.0 Cheapest adequate tier
RUN_LARGE_MODEL 20.0 Escalate to a large model
RUN_FRONTIER_MODEL 100.0 Last resort
DELEGATE_SUBAGENT 30.0 Hand an isolated sub-task to a subagent
RETRY 3.0 Retry once after a transient failure
CONTINUE 0.5 Continue with harness-native reasoning
DONE 0.0 No further computation required
FALLBACK_TO_HARNESS 0.0 AAC stays silent; the agent is unaffected

Supported agents

Harness Mechanism Status
OpenCode config + MCP Shipped adapter — src/aac/adapters/opencode.py
Claude Code hooks + MCP Shipped adapter — src/aac/adapters/claude_code.py
OpenAI Codex CLI MCP + config Shipped adapter — src/aac/adapters/codex.py
Any MCP-capable harness AAC as MCP server Shipped adapter — src/aac/adapters/mcp.py
Anything else universal event protocol (NDJSON over stdio / HTTP / unix socket / Windows named pipe) Shipped adapter — src/aac/adapters/generic.py

A fifth path needs no adapter at all: aac connect --command "my-agent" or aac proxy -- my-agent --serve spawns the agent and reads NDJSON events from its stdout, running the real controller on each one. If the agent never emits an AAC event, the generic adapter degrades to observation only — it still counts steps, tokens, and wall time, and reports them. That is honest: it cannot optimize what it cannot observe.

See ADAPTERS.md for the full capability matrix and how to write your own.

Verified adapter capability matrix

"Can route" below means AAC advertises the capability and can produce that decision. Whether the harness acts on the decision depends on the mechanism, which is why the notes column matters. Every cell is derived from the adapter source in src/aac/adapters/, cross-checked against the verified findings in the research notes.

Harness Detection Integration mechanism Observes Can route models Can route tools Notes / caveats
OpenCode $OPENCODE, $OPENCODE_CONFIG, opencode on PATH, or opencode.json/.jsonc in ~/.config/opencode, the workspace, or $OPENCODE_CONFIG config+mcp — writes exactly one new file, <config dir>/aac.json, carrying a _aac_managed marker. opencode.json is never opened for writing. Models, tools, subagents, MCP servers declared in the user's own config; plus the last goal any event carried Advertised, from the user's config. supports_model_routing = bool(models), from provider.<name>.models plus model/small_model. AAC never spawns OpenCode, so the decision arrives through AAC's MCP server and the harness must act on it. Advertised, from the config tools: block only. OpenCode ships built-in tools whether or not they are named, so an absent tools key proves nothing — AAC reports no tools rather than guessing. Event hooks are claimed only when plugins are present (a plugin/plugins/hook key, or a non-empty .opencode/plugins/ directory). No per-step model swap is documented for OpenCode.
Claude Code an env marker (CLAUDE_CODE/CLAUDECODE/ANTHROPIC_API_KEY) together with the claude binary, or the binary alone, or ~/.claude/settings.json · settings.local.json · ~/.claude.json · <workspace>/.claude/settings.json hooks+mcp — appends one hook group per event to each settings file, always preceded by a timestamped backup; the merge only ever appends 7 events: PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, SessionEnd No. supports_model_routing = False. Models appear only if an explicit models: array exists in settings. Claude Code has no documented hook field that requests a model change — PreModelSwitch/PostModelSwitch observe or block a switch the user already asked for, which is the opposite of routing. Yes, and it may block. supports_tool_routing = True with 13 built-in tools advertised as descriptors. PreToolUse carries a real veto. execute_action() returns advisory-hook: a hook's stdout carries a verdict about the event that fired, and there is no channel that says "instead of this, call that". The only shipped adapter that can block an action. The advertised tool costs and latencies are AAC's own table, not measurements.
OpenAI Codex CLI $CODEX_HOME directory, codex on PATH, ~/.codex/config.toml, or ~/.codex directory mcp+config — appends a [mcp_servers.aac] table to config.toml; append-only, re-parsed after the write, and the backup is restored if verification fails Config (model, model_provider, model_providers, [models]) and, after the fact, rollout logs at ~/.codex/sessions/**/rollout-*.jsonl Advertised, but not per-step. supports_model_routing = True from the config's model/provider tables. Codex has no hook system, so nothing can swap the model mid-turn; execute_action() returns advisory. The only lever is a per-session rewrite of model/model_provider. No. supports_tool_routing = False, tools empty — Codex applies its own patch/exec tools behind a boundary AAC cannot read. harness_version_detected() reads cli_version from the newest rollout instead of spawning codex --version. For per-step observation, run codex exec --json and let the generic adapter read the JSONL.
Any MCP-capable host any of ~/.config/opencode/opencode.json(c), ~/.mcp.json, ~/.claude.json, ~/.codeium/mcp_config.json, <workspace>/.mcp.json, ~/.codex/config.toml mcp-server — AAC hosts an MCP server (python -m aac.mcp), JSON-RPC 2.0 over stdio, protocol version 2025-06-18; the harness calls aac_controller_decide as a tool The MCP servers the host already declares — names and transports only. A server entry routinely holds secrets and AAC has no reason to surface them into a snapshot. Partly. supports_model_routing = True, but get_capabilities() returns an empty models list; the config-backed registry is exposed through get_available_models() / aac models. Effect: AAC can name one of the models it was configured with, if the harness acts on the advice. Partly, same caveat. supports_tool_routing = True, tools come from the tools: block of aac.yaml. Only the stdio transport is served; --transport http is a documented non-goal for now. AAC is one MCP server among several, not the only integration path.
Generic (universal event protocol) an agents: entry in aac.yaml with a non-empty command event-bridge — spawns the agent as a child process and reads NDJSON events from its stdout. GenericAdapter.send() can write a decision back to the child's stdin, but run_agent() — which aac connect and aac proxy call — does not use it, so the ready-made path is observe-only in the write direction. Whatever the agent emits. If it emits nothing AAC understands, the adapter degrades to observation only and still counts steps and wall time Configured, small tier only. supports_model_routing = bool(models) and the list comes from the models: block of aac.yaml, filtered to tier small in GenericAdapter.get_capabilities(). A large or frontier entry is not offered as a route by this adapter. Configured only, from the tools: block of aac.yaml. Always last in discovery order — it is the fallback, not a peer. Foreign or malformed lines are skipped, never fatal.

Harnesses with no shipped adapter

These are not claimed as supported. The evidence below comes from the verified research notes; cells marked unverified are unverified, and no capability is inferred for them.

Harness Hooks Per-step model control Why AAC has no adapter
Zed No hook system (verified: zero occurrences of "hook" across its docs index) No — profiles are per-thread selection only Nothing to attach to
Windsurf / Devin Desktop Yes — 12 events incl. post_cascade_response_with_transcript No — model_name is read-only on hook input No shipped adapter; config paths are migrating .windsurf/ → .devin/
Kiro (IDE + CLI) Yes — 12 triggers Kiro CLI, via ACP session/set_model — per session/turn boundary, not mid-turn. The only agent besides Tabnine with any documented non-startup model change. No shipped adapter
Tabnine CLI Yes — 12 events incl. BeforeModel/AfterModel Yes — the only agent in the survey with per-request model override (hookSpecificOutput.llm_request) No shipped adapter; CLI is in maintenance mode and deprecates after 2026-12-31
Warp No hook system Per-conversation only; and custom inference endpoints reject localhost Nothing to attach to
Crush PreToolUse only No No shipped adapter
Qwen Code Yes — ~20 events, 4 executor types incl. HTTP No — UserPromptSubmit rewrites the prompt, not the model No shipped adapter
Amazon Q Developer CLI Yes — 5 hook types No Deprecated; superseded by Kiro CLI
Sourcegraph Cody No hook system No — defaultModels is per-feature, server-side Base-URL override is instance-admin only, out of reach of a local control plane
PearAI / Void None documented / unverified No / unverified Evidence insufficient to build on; do not claim support

Install

Requires Python 3.11+. No compiler, no admin rights, no service install.

Unix / macOS / Linux

curl -fsSL https://raw.githubusercontent.com/4shil/aac/main/installer/unix/install.sh | sh

Windows (PowerShell 5.1+, no administrator required)

irm https://raw.githubusercontent.com/4shil/aac/main/installer/windows/install.ps1 | iex

The installers clone the repository to a temporary directory, run python -m pip install --user . (falling back to pipx, then to printed instructions), verify the result with aac --version, and clean up after themselves. Both are idempotent and safe to re-run.

Prefer to do it by hand?

git clone https://github.com/4shil/aac.git
cd aac
python -m pip install --user .

Then attach to a harness

aac install --dry-run     # show the exact plan; write nothing
aac install               # apply it (asks first; backs up every file it touches)
aac doctor                # verify the installation

aac install is the only command that writes to a harness's configuration, and it is never run for you. The installers deliberately stop short of it: modifying an agent's config requires your consent, not a script's. Run it when you are ready.

Quick start

aac demo                       # the control loop, end to end, offline, deterministic
aac doctor                     # is everything detected and healthy?
aac adapters                   # which adapters are known, detected, compatible
aac models                     # which models AAC could route between
aac tools                      # which tools AAC could route between
aac status                     # live session state, resources, cost, overhead
aac dashboard                  # observational UI on http://127.0.0.1:8731
aac serve                      # warm runtime on 127.0.0.1 — removes hook cold-start cost
aac route --goal "fix the failing test"     # one decision, shown for inspection
aac connect --command "my-agent"            # run an agent under AAC
aac logs -n 50                             # read the local event log

If your harness invokes a hook per event (Claude Code does), run aac serve first. Without it the hook pays ~300 ms of Python cold start per call; with it, a few milliseconds.

aac route exists for inspection. AAC is automatic: there is no per-action command to run, and aac route does not enable one.

The full command list is aac --help. ADAPTERS.md, PROTOCOL.md, CONFIGURATION.md and ARCHITECTURE.md cover the details.

Architecture

Seven independently replaceable layers:

Layer Module Replaceable because
Runtime aac/runtime/ Owns lifecycle, metrics, registry, ledger. Talks only to the controller and the adapter interface.
Controller aac/control/controller.py State in, decision out. Does not know which harness it is talking to.
Policy aac/control/policy.py A protocol with one method: decide(state, caps). Swap rule for bandit / RL / neural.
Adapters aac/adapters/ One interface, every method optional. See ADAPTERS.md.
Providers aac/providers/ ModelRegistry / ToolRegistry hold normalized descriptors.
Experience aac/experience/ SQLite, local-first, fail-soft. Retrieval proposes; validation disposes.
Observability aac/runtime/metrics.py, aac/dashboard/ Purely observational — nothing here feeds back into the controller, or a metric would change a decision and the control plane would stop being reproducible.

Full detail, the decision-pipeline sequence diagram, the state contract, and "how do I swap a component" instructions: ARCHITECTURE.md.

The wire protocol harnesses speak to AAC: PROTOCOL.md.

Dashboard

aac dashboard                # binds 127.0.0.1:8731 by default
aac dashboard --port 9000 --no-open

Stdlib http.server only — no CDN, no build step, one self-contained page. It shows session state, compute allocation across cache / tools / small / large / frontier tiers, savings net of AAC's overhead, and experience-store hit rate. Every route is fail-soft: no runtime means zeros and 200, never 500.

The dashboard is observational. It can evaluate the decision function on a supplied state (POST /api/decision) but it cannot change the controller's policy, configuration, or capabilities, and it cannot reach the host harness. AAC behaves identically whether that page is open, closed, or absent.

Create an adapter

The whole interface is in src/aac/adapters/base.py, and no method on it is required. A working minimal adapter is about forty lines:

from aac.adapters.base import AACAdapter, AdapterContext
from aac.protocol.schema import HarnessCapabilities, ModelDescriptor, ModelTier

class MyAgentAdapter(AACAdapter):
    adapter_id = "myagent"
    display_name = "MyAgent Adapter"
    version = "1.0.0"
    aac_api = ">=1.0,<2.0"
    harness_version = ">=1.0"
    integration_mechanism = "event-bridge"

    def detect(self) -> bool:
        return True          # cheap, side-effect free, never raises

    def get_capabilities(self) -> HarnessCapabilities:
        return HarnessCapabilities(
            models=[ModelDescriptor(id="myagent/small", provider="myagent",
                                    tier=ModelTier.SMALL)],
            supports_model_routing=True,
        )

Register it and check it loads:

aac adapter install ./my_adapter.py    # load + version-compatibility check
aac adapters                           # now listed

Permanent registration is a copy into <AAC data dir>/adapters/, or the aac.adapters entry-point route in your own package. Everything — the interface with every default, the compatibility-suite expectation, the version-declaration rules, and the "capability honesty" rule — is in ADAPTERS.md.

Benchmark

aac benchmark --tasks 30       # baseline (always-frontier) vs AAC, offline, seeded
make bench                     # or: .\make.ps1 bench

Two arms run the same simulated workload. The baseline always reaches for the frontier model and pays frontier pricing for every step; AAC drives the real AACController over the same tasks, starting cheap and escalating only on failure. Everything is deterministic — the one number that is measured rather than simulated is AAC's own controller overhead, and it is subtracted from every reported saving. If the overhead exceeds the savings, the report says so in a NET_SAVINGS_NEGATIVE warning instead of hiding it.

The earlier research results (the neural V1.1 controller, the 39.09% cost reduction, the OOD-robustness audits) are historical context and live under reports/. The universal runtime in src/aac/runtime/, src/aac/adapters/ and src/aac/control/ is what this README describes.

What AAC does NOT do

Being precise about this is the point of the project.

  • It does not execute tools itself. The harness does. Every adapter's execute_action() either hands the decision over (delegated-to-harness, advisory-hook) or refuses a resource the harness never advertised. DecisionPayload.resource is an opaque ID; the controller never emits tool-call JSON. A fabricated success would teach the controller that an impossible action works.
  • It does not manage credentials. No AAC code path reads, requests, stores, or forwards an API key. The OpenCode adapter lists providers and model ids from config; the MCP adapter lists MCP server names and transports and deliberately does not surface the rest of a server entry, because a server entry routinely holds secrets. aac install never touches auth.json.
  • It cannot control a harness that exposes no integration surface. Zed has no hook system. Warp has no hook system and additionally rejects localhost for custom inference endpoints, so a local proxy cannot attach. Cody's only endpoint override is instance-admin server-side. For such a harness AAC is observe-only, or absent — it will not pretend otherwise.
  • Per-step model control is unavailable on most harnesses. Verified evidence: Tabnine CLI is the only agent in the survey with a documented per-request model override (BeforeModel → hookSpecificOutput.llm_request). Kiro CLI changes the model per session via ACP session/set_model, explicitly not mid-turn. Claude Code hooks can block but not inject — a hook's stdout carries a verdict about the event that just fired, with no channel that says "instead of this, call that". Codex has no hook system at all. Consequently supports_model_routing is False for Claude Code, False for tool routing on Codex, and advisory everywhere else.
  • It is not a chatbot and not an agent. It does not plan, reason, or produce output. It allocates compute.
  • It cannot force a cache hit. Prompt-cache locality is determined by request content. AAC can improve the odds and must not report a projected saving as an achieved one.
  • It does not write decisions back to a subprocess agent. aac connect and aac proxy spawn the agent, read its events, and run the real controller on every one — but run_agent() never calls GenericAdapter.send(), so the ready-made proxy path gives you observation and metrics, not a decision-returning loop. A client that blocks waiting for a decision waits until AAC closes the pipe at the end of its run budget, then gets "no opinion". The agent still runs correctly. See PROTOCOL.md for the verified detail and the routes that do close the loop (the SDK, GenericAdapter.send(), or MCP).
  • It cannot see what the harness does not expose. Token counts and dollar costs are exposed to hooks by none of the shipped harnesses, so supports_token_accounting and supports_cost_accounting are False in every shipped adapter. AAC's cost numbers are estimates against AAC's own price table, labelled as such.
  • It does nothing at all without a model. If no models are discovered, aac doctor prints "no models discovered; AAC cannot route between models here" and the runtime sits in observe_only.

FAQ

Does AAC slow my agent down? It measures that, continuously, and never hides it. Decision latency is recorded per step, summarised as p50/p95 in aac status, and subtracted from every saving claim. The budget is runtime.controller_overhead_budget_ms (default 10 ms); over-budget decisions are counted, not hidden.

The honest detail is that the decision costs ~0.3 ms while starting a Python process costs ~300 ms. A harness that spawns a hook per event would pay that cold start every time, which is the exact failure the spec warns about. So AAC runs as a warm daemon:

aac serve          # keeps a runtime hot on 127.0.0.1

With aac serve running, the hook becomes a local round-trip in single-digit milliseconds and never imports pydantic at all. Without it, the hook falls back to an in-process runtime and enforces its own wall-clock budget (AAC_HOOK_BUDGET_MS, default 400 ms — sized for a cold start), staying silent past it. A slow hook is worse than no hook.

Does it phone home? No. telemetry.mode defaults to local-only; the experience store is local SQLite and never calls the network; the dashboard binds 127.0.0.1; the MCP server serves stdio. See SECURITY.md.

What happens if AAC crashes mid-run? The agent keeps going. decide() catches everything and returns FALLBACK_TO_HARNESS, which the harness treats as "AAC has no opinion, behave normally". aac.hook always exits 0.

Can AAC remove my configuration? Only files it can prove it wrote. Uninstall is gated on the _aac_managed marker (OpenCode), on recognising its own hook command (Claude Code), and on the exact [mcp_servers.aac] table span (Codex). A config AAC did not write is left byte-identical.

Does aac install need my consent? Yes. It shows a plan, then asks. --dry-run writes nothing at all, and --json refuses to assume a yes on your behalf.

Where is my data? <AAC data dir>/: %LOCALAPPDATA%\aac on Windows, ~/.local/share/aac elsewhere, overridable with $AAC_HOME. It holds aac.yaml, the experience database, the event log, and AAC's own backups. Nothing else on your machine is touched. Full key reference: CONFIGURATION.md.

Do I need a GPU, or an API key, to try it? No. aac demo is a self-contained, deterministic, offline simulation of the control loop, and the real AACController makes every decision in it.

License

MIT — see LICENSE.

About

Adaptive Agent Controller — learned neural routing to minimize LLM execution cost in agentic workflows (V0.1+V1+V1.1, YELLOW PASS, -39% cost vs baseline)

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages