Skip to content

Repository files navigation

fuse

fuse is a terminal-based, multi-model AI agent harness: an interactive shell that drives LLMs (via a LiteLLM gateway) with tools, a hardened subagent runtime, human-in-the-loop permissions, MCP integration, and an embedded skill system.

Building

make build      # build the fuse binary
make install    # install to your GOPATH/bin
go test ./...   # run the test suite

Configuration lives in ~/.fuse/config.yml (with an optional per-repo .fuse.local.yml override). See internal/config for the full schema.

Loop observability

loop-serve-net can independently expose Prometheus metrics, OpenTelemetry traces, and payload-free structured logs. The source-controlled development-only reference stack provisions Prometheus, Grafana, an OTEL Collector, and Tempo (intentionally not Loki). See observability operations for cardinality limits, rotation, authenticated replay, and the Grafana-to-Tempo workflow.

Research

/research <query> produces a cited research report. It diversifies the question into several facets, fans out one subagent per facet to search the web and fetch the most promising pages, deduplicates sources, and synthesizes a single markdown report with [N] citation markers and a numbered source list. The flow is driven by the existing subagent runtime and an embedded research skill; a user skill named research shadows the built-in one. The skill is available in both the interactive shell (via /research) and one-shot mode: a one-shot task whose intent matches the skill's description (e.g. fuse "do a deep research on ...") loads and follows the skill automatically, because the model is instructed to call the skill tool first for any matching request.

Two built-in tools back the flow: web_search (query a search provider) and web_fetch (fetch a URL and extract the main article text, with a robots.txt gate on by default).

Search providers

You bring your own search API key. Provider resolution happens at first use and fails loudly if nothing is configured, in this order:

  1. an explicit research.provider in config (brave | tavily | custom)
  2. the BRAVE_SEARCH_API_KEY environment variable
  3. the TAVILY_API_KEY environment variable
  4. a configured [research.custom] block
  5. otherwise, a clear error naming all of the setup paths above

Brave Search (primary, recommended). Set BRAVE_SEARCH_API_KEY. Sign up at api-dashboard.search.brave.com (a card is required; roughly $5/month of free credits). fuse uses the Brave Search engine that also powers the web search in some other AI assistants; here you supply your own key directly. This README's Brave Search mention also serves as the attribution Brave's free credits ask for.

Tavily (alternative). Set TAVILY_API_KEY. Tavily returns extracted page content alongside results, which lets the research flow skip a separate fetch for sources whose returned content already suffices.

Custom / self-hosted (e.g. SearXNG). Configure [research.custom] with a URL template. For a SearXNG instance only the url is needed - the JSON field mappings default to SearXNG's format=json response shape:

research:
  custom:
    url: "https://searx.example.com/search?q={query}&format=json"

The [permissions] config block

permissions:
  mode: smart           # off | prompt-all | smart | auto (default: smart)
  session_allow: true   # whether the [s]ession "allow for this session" option appears
  auto_approve: []      # per-segment allow patterns (see note below): "bash:git *", ...
  always_prompt: []     # patterns demoted to always-prompt, e.g. "bash:git push*"
  disabled: []          # tool names fully disabled (never runnable), e.g. "web_fetch"
  auto:                 # auto-mode surface (only consulted when mode: auto)
    classifier_model: deepseek-flash   # alias that judges gray-area commands; NEVER a chat alias
    deny: []            # extra always-deny per-segment patterns, e.g. "bash:npm publish*"
    ask: []             # extra always-ask per-segment patterns (override an allow)
    fetch_deny: []      # host-glob floor: matched web_fetch hosts are denied before
                        # the classifier is ever consulted, e.g. ["*.internal.example"]
    fetch_ask: []       # host-glob floor: matched web_fetch hosts are forced to a
                        # human ask before the classifier is ever consulted
    allow_push: false   # true = `git push` auto-approves; false = it routes to the classifier
    write_roots: []     # extra dirs scoped like the workspace (e.g. ["/tmp"]); the
                        # per-session scratch dir under ~/.fuse/tmp/ is always included

# Per-project overrides (user-owned ~/.fuse/config.yml ONLY — see below).
# Absolute project path -> a permissions subtree that applies when the shell
# runs inside that path. "auto here, not there" without weakening any repo.
projects:
  /Users/me/work/trusted-app:
    permissions:
      mode: auto        # this project starts in auto; others keep the global default
      auto:
        classifier_model: deepseek-flash


`mode: auto` runs commands without a human prompt when they are provably safe.
It is layered: a bash command is split into its simple-command **segments**
(across `&&`, `||`, `;`, `|`, newlines, and the body of `bash -c`/`sh -c`), and
**each segment is evaluated independently** — static deny/ask rules first, then
the read-only safe list, then path/egress heuristics, and only genuinely
ambiguous segments reach the `classifier_model`. Deny beats ask beats allow; a
command that cannot be parsed fails closed.

**`web_fetch` known-good hosts auto-approve with no prompt and no classifier
call.** A hardcoded seed of reputable hosts (docs sites, code hosting, etc.)
plus the bundled top-sites popularity set auto-approve `web_fetch` at the
floor, before the classifier is ever consulted — this is a zero-review GET of
any path/query under that host. `auto.fetch_deny` and `auto.fetch_ask` sit
ahead of that floor and override it: a host matching `fetch_deny` is always
denied and a host matching `fetch_ask` always forces a human prompt,
regardless of the seed or popularity list.

**`auto_approve` (and `auto.deny`/`auto.ask`) are per-segment, not first-token
prefixes.** A pattern only approves the segment it matches, so `git status &&
rm -rf ~` is **not** auto-approved by `bash:git *`: the `git status` segment
matches and the `rm -rf ~` segment does not, and one un-approved segment denies
the whole command. There is no way to whitelist a leading `git` into approving a
trailing `rm`. Wrapping (`sh -c "rm x"`), command substitution (`$(...)` /
backticks), env-assignment prefixes (`FOO=bar cmd`), and path-qualified argv0
(`/usr/bin/rm`) all fail closed rather than slip past a prefix match.

**Trust boundary.** The permission-*loosening* keys — `mode`, `session_allow`,
`auto_approve`, and the entire `auto` block — are honored **only** from the
trusted `~/.fuse/config.yml`. A repo-plantable `.fuse.local.yml` cannot weaken
the gate: those keys are ignored there (with a startup warning), so a checked-in
file cannot flip a clone into `auto` mode or self-approve. Only the *tightening*
keys `always_prompt` and `disabled` take effect from `.fuse.local.yml`. Set
anything that grants trust in your own `~/.fuse/config.yml`.

**Per-project trust (`projects:`).** To grant "auto here, not there" you can key
a `permissions:` subtree by **absolute project path** under a `projects:` map.
When the shell starts inside a path that equals — or is a descendant of — one of
those keys, that entry's permission subtree is merged in as **trusted** (the full
subtree, `mode` and the whole `auto` block included), layered above the global
`permissions:` and below the tighten-only `.fuse.local.yml`. When several keys
are ancestors of the current directory, the **longest (most specific) key wins**;
matching is by whole path segments, so a key `…/b` never matches a directory
under `…/bc`, and symlinked working directories are resolved to their real path
before matching. Because this is pure loosening, the map is honored **only** from
your own `~/.fuse/config.yml` — a `projects:` block planted in a repo's
`.fuse.local.yml` is ignored and named in the startup warning, exactly like every
other loosening key, so a checked-in file still cannot flip a clone into `auto`.

**Switching mode in-session.** `permissions.mode` is only the **startup
default** — the permission mode is a live session surface you can flip without
restarting, and the interactive shell shows the active mode in its status line
(e.g. `mode: auto`):

- **Shift+Tab** toggles between the two everyday postures, `smart` ⇄ `auto`.
  From `prompt-all` or `off`, the first Shift+Tab lands on `smart`, and
  Shift+Tab thereafter toggles `smart` ⇄ `auto`.
- **`/mode`** (bare) prints the active mode and lists all four options;
  **`/mode <name>`** sets any of `smart`, `auto`, `prompt-all`, `off` directly.
  An unknown name is rejected with a usage line and leaves the mode unchanged.

A switch takes effect on the **next turn** — the gate is rebuilt per turn at the
current session mode, so a flip into `auto` immediately governs the following
tool calls (no restart, no stale gate). If you switch into `auto` but the
gateway has no classifier configured, the status line marks the mode
**degraded** (`mode: auto` with a degraded marker) — the deterministic rules and
read-only safe list still apply, but gray-area commands fail closed to a prompt
rather than reaching a classifier.

### The `[research]` config block

```yaml
research:
  provider: ""          # "" = auto (Brave env -> Tavily env -> custom); or brave | tavily | custom
  max_queries: 5        # facet count hint for the research flow
  max_results: 5        # results per web_search call
  max_content_kb: 50    # web_fetch content truncation (KB, word-boundary)
  respect_robots: true  # honor robots.txt in web_fetch; set false for dev/testing
  custom:
    url: ""             # template with {query} / {count}; empty = not configured
    headers: {}         # optional request headers
    results_path: results   # dotted path to the results array (SearXNG default)
    title_field: title      # per-result field mappings (SearXNG defaults)
    url_field: url
    snippet_field: content

The [agents] config block

agents:
  max_spawns: 64        # tree-global spawn budget: the total number of child
                        # agents one root turn may create, ever. The runtime
                        # appends a line like `agent budget: 7/64 used (57
                        # remaining)` to every spawn_agent result so the model
                        # can stop before it fans out too wide, and refuses the
                        # spawn outright once the ceiling is reached. Bounds
                        # runaway fan-out in the research flow. Once the budget is
                        # exhausted the spawn_agent tool is stripped from the
                        # model's schema for the rest of the session (a permanent
                        # brake, since the tree is append-only).
  max_concurrent: 16    # live-concurrency cap: the number of child agents that
                        # may RUN at once, bounded by a semaphore, independently
                        # of the total max_spawns budget. When the active child
                        # count (running + pending) reaches this cap the
                        # spawn_agent tool is stripped for that turn and reappears
                        # once children finish (a reversible brake). A negative
                        # value is clamped back to the default.

Observing the research flow — research-probe

The research flow is emergent and prompt-driven: the model diversifies a question into facets, fans out one subagent per facet, and each child searches and fetches on its own. That is hard to watch in the interactive shell (it scrolls past). research-probe runs the real flow — the embedded research skill, the real web_search/web_fetch tools against your configured provider, the real spawn_agent fan-out, talking to the live gateway — headless and fully recorded, then prints an inspectable digest:

export TAVILY_API_KEY=...        # or BRAVE_SEARCH_API_KEY
fuse research-probe --model kimi "What is Litestream and how does it back up SQLite?"

It prints the spawn tree (root + one node per facet, with status), the census of searches and fetches, every unique search query and fetched URL, and the root's final synthesized report — or a clear "did not synthesize a report" line when the flow fans out but never converges. Flags: --model <alias> picks the driver model, --trace <file> also writes the raw gateway request/response JSON, and --timeout <dur> bounds the whole run (default 3m). Nothing about the agents is faked — the recorder is just an agent.Renderer layered over the production wiring, so what you see is exactly what /research does.

Networked loop-control — loop-serve-net (Connect/protobuf)

fuse loop-serve-net exposes the same policy-free multi-loop runtime that loop-server serves over stdio, but over the network as a Connect service (fuse.loop.v1, IDL in proto/fuse/loop/v1/loop.proto). It is browser-reachable over HTTP/2 with no proxy and speaks Connect, gRPC, and gRPC-Web (served over h2c):

fuse loop-serve-net --addr 127.0.0.1:8787

Authentication and multi-tenancy (change #49)

Unlike the stdio loop-server and the local CLI paths, this networked binding requires a bearer token on every request — identity and authorization live at the Connect edge, never in the policy-free runtime seam. Present the credential as an Authorization: Bearer <token> header; a missing or unknown token is rejected with Unauthenticated before the handler runs.

The token→principal map and the owner-liveness lease TTL come from the trusted ~/.fuse/config.yml loop_server: block (a credential-bearing surface — a repo-plantable .fuse.local.yml cannot mint tokens):

loop_server:
  lease_ttl: "30s"            # owner-liveness lease; empty ⇒ 30s runtime default
  auth:
    - token: s3cr3t-alice     # the bearer credential a client presents
      tenant: acme            # isolation boundary; empty ⇒ the _default tenant
      subject: alice          # authorization subject, recorded as a loop's owner
      observability_operator: true # grants global logging reload/reopen
    - token: s3cr3t-bob
      tenant: acme
      subject: bob

Each principal resolves to a tenant (the isolation boundary every store/registry op is keyed by) and a subject (recorded as a loop's owner). Authorization is then:

  • Tenant spoof — a request whose wire tenant differs from the token's tenant is rejected PermissionDenied.
  • Cross-tenant — reaching a loop under a different tenant returns NotFound (the tenant-scoped resolve simply misses it; a tenant cannot even learn another tenant's loop exists).
  • Cross-owner — a same-tenant caller that is not the loop's recorded owner is rejected PermissionDenied.

The lease TTL is the owner-liveness window: the owning instance renews the lease while a loop is live, and a loop whose lease has expired is treated as abandoned and re-ownable by a cold instance on reconnect (so a crashed owner does not strand a loop). Reconnecting a dropped Observe is just re-opening Observe(from_seq) with the same token — the stream dedups replay/live at the watermark, so there is no loss or duplication across the handoff.

If loop_server.auth is empty, a single built-in dev token (fuse-dev-token, mapped to the _default tenant) is synthesized so local development works out of the box — the server still authenticates every request; it never runs open. Configure real tokens for any shared or deployed server.

The service is three RPCs:

  • StartLoop / Send — unary. The authoritative tenant is the authenticated token's; the wire tenant field, when set, must match it (a mismatch is a spoof, see above).
  • Observe(from_seq) — server-streaming history-then-live: it replays durable history since from_seq, then live-tails, deduping the replay/live overlap at the watermark and flagging a gap when a sequence hole is detected. A reconnecting client (any instance, resolved cross-instance via the durable store) simply re-opens Observe from its last-seen seq — this single stream subsumes both the live tail and catch-up replay. Idle streams receive periodic keepalive frames so a parked loop survives a gateway idle timeout.

The wire stubs are generate-and-commit (make proto, requires buf + protoc-gen-go/protoc-gen-connect-go on PATH and cd proto && npm ci for the TS protoc-gen-es plugin); committed Go stubs live in internal/loopwire/v1 and TS stubs in proto/gen/ts.

This replaces the earlier JSON-over-WebSocket wire (change #48, ADR-0032 superseded): there is no /ws or /loops/{id}/events route anymore.

MCP on every binding, with the real per-principal identity (change #59)

MCP tool execution is attached on every loop binding — the shell, the one-shot CLI, the research probe, and both loop-servers — through one composition-root helper, mcpAttach(cfg, w) in cmd/fuse. It resolves, together, the identity-propagation egress seam (mcp.WithCredentialSource, when an identity-propagating MCP server is configured — change #52) and the complete-mediation TargetMediator gate, so no binding can wire a manager without also wiring the egress credential seam and the target-allowlist gate. A misconfiguration is logged loud at startup, never silent.

On the loop-server paths this is first-class and per-loop: each hosted loop gets its own mcp.Manager, its tools registered into that loop's own cloned tool registry, and the manager closed at loop teardown — two concurrent loops never share a manager, a registry, or a credential. Crucially, the loop-server threads the real authenticated principal the Connect edge resolved (sub = the initiating user, act = fuse) into the egress, so each outbound MCP tools/call presents a short-lived, audience-bound, per-principal delegation token minted for that user and tenant — never a shared static token and never the _default shim. The minted token lives only on the outbound transport header: it never appears in the event stream, the durable store, logs, or model context.

The identity travels as a context value stamped by the composition root (toolidentity.WithPrincipal) through the runtime's policy-free LoopContext / LoopTeardown seams — internal/runtime imports no auth, MCP, or cmd/fuse type (ADR-0030 preserved). A permanent CI acceptance lane (cmd/fuse/loop_serve_net_rentals_acceptance_test.go) drives two authenticated principals concurrently through the loop-server binding against a real stateful rentals MCP server (internal/mcpdemo/rentals, importable by Wander), under -race, asserting the full #52 checklist — distinct per-principal tokens, downstream 403 as a distinguishable tool error, per-principal write isolation, wrong-audience rejection, complete mediation of an undeclared target even under auto-approve, and no credential leak — so a regression kills the build.

About

Multi-model AI agent harness in Go: a terminal coding agent and a hostable, durable agent-loop runtime — contained bash, layered permissions, MCP, skills, web research, and Go/TS SDKs.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages