Skip to content

Repository files navigation

FEIN(FE!N): Hybrid local-and-cloud agent harness.

The world's first hybrid local-and-cloud agent harness you'll find addictive.

English · 日本語 · Español · 中文


Most agent harnesses buy every part of the loop from the same expensive vendor. Deciding what to do next, compressing a 3,000-line test log, and gating a risky command are treated as one job, priced as one job, and sent to one model.

FE!N splits the loop into slots and lets you bind a different model to each — so a frontier model does the thinking while a 3B model on your laptop does the reading. TypeScript, zero runtime dependencies, 189 tests.

import { Agent, Router, AnthropicPort, OllamaPort, defaultTools } from "fein";

const cloud = new AnthropicPort({ id: "cloud", model: "claude-sonnet-5",
                                  apiKey: process.env.ANTHROPIC_API_KEY,
                                  costPerMTokIn: 3, costPerMTokOut: 15 });
const local = new OllamaPort({ id: "local", model: "qwen2.5:3b" });

const router = new Router()
  .bind("think",   cloud)                          // decides what happens
  .bind("observe", local, { fallbacks: [cloud] })  // compresses observations
  .bind("verify",  cloud);                         // gates subagent mutations

await new Agent({ router, tools: defaultTools() }).run("Why is the test suite failing?");

The loop code does not change when you rebind. Same harness, all-cloud, all-local, or any mixture — with a ledger that tells you what the difference actually cost.

Try it in 30 seconds

No API key, no GPU, no network — every model in the demo is scripted, so what you're watching is the harness:

npm install && npm run demo
bindings
  think       cloud/sonnet-sim [cloud]
  observe     local/qwen3b-sim [local] -> cloud/sonnet-sim

[2] think · cloud/sonnet-sim cloud
A TypeScript project. Running the test suite to find the failure.
  tool shell(command: "npm test") via think
       ok $ npm test ok 1 - unit/parser handles case 1 …
  digest shell: 3100 → 43 tok (99% smaller · local/qwen3b-sim)
  cache: prefix stable — 3 msg reused, 2 new

ledger
calls 4  ·  $0.0024  ·  0.2s
  local    1 calls   $0.0000
  cloud    3 calls   $0.0024
  cache  hit 10.1%   saved $0.0011

The think model decided to run npm test itself — its authority is untouched — but it never saw the 330-line log. A local model compressed it to 43 tokens first. That saving compounds over every remaining turn, it reclaims context window, and the raw log never left the machine.

The slots

Slot Job Why it's separable
think Decide what happens next The hard reasoning. Keep it frontier.
observe Compress bulky output before the think model sees it Output smaller than input; the saving compounds; the raw data never leaves the machine
verify Gate a subagent's world-changing calls Rare, so it can afford to be expensive
title Name the session Trivial
execute Drive a delegated sub-task end to end The light tier of plan-execute delegation. Unbound by default — an unbound slot advertises nothing
advise One consultation when the driver is stuck Remote guidance, local control: the advisor never gets tools, only returns text. Unbound by default

The names are ReAct's. Thought → think, which also emits the Action — native tool calling fuses thought and action into one completion, and there is no action slot because tools are executed by code, not a model. Observation → observe. verify and title are control-plane roles ReAct doesn't have.

Any slot takes any model. Every slot takes a fallback chain, so a dead local runtime degrades that slot to the cloud rather than taking the session down. A port that fails twice in a row is demoted behind its alternates and probed every few calls — counted in calls, not wall-clock, so replays stay honest — which spares every subsequent call the doomed connection attempt.

Bindings are static by default; adaptive routing is opt-in per slot. A binding can carry a policy that switches on facts the loop reports, never on wall-clock or luck — so the same transcript re-derives the same decisions, and every decision lands in the trace and the ledger:

  • escalate-on-stuck (for think): the loop guard notices the model going in circles → same port, higher thinking effort. Never a mid-epoch port swap: prompt caches are keyed per model, and one model's signed reasoning blocks are a provider error when replayed to another. With restartTo, the ladder gains a top: once every rung is spent, the policy requests an early compaction and the new epoch restarts on the stronger port — the one boundary where a think-model swap is free, because the summary restart pays the cache cost anyway and the lens replays no reasoning across an epoch. The switch reads only epoch-frozen facts, so it provably cannot flip mid-epoch, and the old model writes the hand-off summary (it reads its own context at the cached rate).
  • escalate-on-reject (for observe): a local digest that fails the quality gate gets one retry on the cloud port. The observe slot's calls carry a fresh, small context each time, so this swap has no cache stake at all.
  • right-size (side slots only): trivially small requests go to the small model even when the slot's default is big.

The one safe place to re-point think itself is a subagent boundary — fresh context, nothing to break. Two ways to use it:

  • Static: set subagents.thinkSlot and every child runs on that binding.
  • Plan-execute (bind the execute slot to enable): the spawn tool gains a tier parameter the think model fills per step — "light" runs the whole sub-task on the execute binding, "heavy" on the think model's own. An acceptance parameter makes the planner state what "done" means, and the child reports against it. The per-spawn choice beats thinkSlot config, which beats inheritance.

Two lessons from everyone who tried this are enforced in code, not prose. A cheap model that starts going in circles rarely recovers, so a light-tier child stops on the first guard fire and reports what blocked it — the planner escalates, re-splits, or does the step itself; the harness never re-routes on its own. And delegation quality comes from decomposition, not from the model's raw judgment, so the tier guidance lives in the tool schema and a frozen prompt section that only exists when execute is bound — otherwise the whole feature costs zero tokens.

And the inversion: advise — the cheap model drives, the frontier model consults. Bind advise to a strong model and a stuck driver spends one bounded, tool-less consultation instead of grinding: the advisor sees the task, the guard's diagnosis, and a token-capped excerpt, and returns text that lands in the driver's context as an appended note. The driver keeps every tool and every decision — remote guidance, local control. Capped at two consultations per run; a fail-fast light-tier child gets exactly one reprieve before it bails. localFirstProfile() (or FEIN_PROFILE=local-first) ships this shape: everything local except advise and verify on the cloud — the measured trade is a local driver recovering roughly three-fifths of its gap to the frontier for a fraction of the frontier's cost.

// config: the object form of a binding carries the policy
"bind": {
  "think":   { "port": "cloud", "policy": { "kind": "escalate-on-stuck" } },
  "observe": { "port": "local", "policy": { "kind": "escalate-on-reject", "to": "cloud" } }
}

There was another slot and we deleted it. A toolformer turned the think model's one-line intent into concrete tool arguments. Measured, it cost +11 to +15 think-model output tokens on every call and saved zero — the intent has to carry the arguments verbatim, so it is structurally a superset of what it replaces. The write-up with numbers is in DESIGN.md. The lesson: delegate a stage only when the delegate can produce more than it was given, or knows something the caller does not.

Subagent or slot?

Not competitors — they differ in how much control you surrender:

Unit Fixed overhead What you give up
Subagent A whole task ~600–900 tok/spawn, fresh context, cold cache Every intermediate decision
Slot One stage of one decision ~150 tok Nothing

Reading forty files to find one symbol → subagent. "Run this exact command and its output is 20k tokens" → slot; you cannot hand that to a subagent without also handing over the choice of command. A few tool calls you could make yourself → neither.

Keeping the cache hot

Hybrid execution creates a hazard pure-cloud harnesses don't have: it is very easy to save tokens in a way that costs more than it saves, by rewriting history the provider had already cached. FE!N treats prefix stability as an invariant, not an aspiration:

  • Render monotonicity — every render strictly extends the last. PrefixGuard hashes each render and reports a break the moment one happens, attributed to the slot that caused it. Cache misses become reproducible bugs, not a bill.
  • Checked prompt sections — the system prompt is assembled from named parts with declared volatility, and SectionGuard catches a "frozen" section that changed. PrefixGuard says the prefix broke at message 4; SectionGuard says the identity section changed between turns. The second is actionable.
  • Lookback-aware anchors — a breakpoint reaches back only 20 content blocks, and one turn with six parallel tool calls is thirteen. Two such turns put the previous anchor out of reach and you pay full price forever, silently.
  • Append instead of editregisterDeferred + surfaceTool() add a tool mid-session without touching the tool block; injectContext() adds operator context as a system-role message rather than editing the system prompt.
  • Epochs, not sliding windows — dropping old messages shifts every token after them and misses on every subsequent turn, forever.
  • Ordered concurrency — parallel tool results append in call order, never completion order, so the transcript never depends on machine timing.

Bounded observations

Two mechanisms, deliberately layered, because the free one should run first.

Spill (model-free): oversized tool output is written to .fein/spill/ and replaced with a head/tail preview plus a path the model can grep. Lossless, idempotent, never exceeds its cap, never grows.

Digest (one inference): a local model compresses the full text semantically.

They are complementary, and the fixture proves it — a 332-line log with the one failure on line 241: the preview misses it, the observe model finds it. So both run, and the lens prefers digest → preview → raw. Spill also fixes the observe model's worst property: a summary that dropped a detail now has a route back to the source.

Digestion is chunked to the observe model's context window, and the chunk cap is locality-aware — a local observe model reads 16 chunks (marginal cost is wall-clock), a cloud one declines chunked work outright, because spill already bounded the damage for free. That constant is the hybrid argument in miniature.

ReAct

A local model can drive, not just assist. ReactPort wraps any text-only model and presents a native tool-calling interface, so the loop never learns ReAct exists — it moves tools into the prompt, rewrites history into the Thought/Action/Observation transcript the model speaks, stops generation before the model can invent its own Observation:, and repairs malformed output locally.

That last point is the classic ReAct failure and it is silent: left alone, a model will happily write Observation: the file contains… and reason about a result no tool produced. The fix is mechanical — a stop sequence — not a polite request.

Steering

Type while it works. Your line lands at the next turn boundary, never mid-turn: injecting between an Action and its Observation would hand the model a user message where a tool result belongs. A second concurrent run() is refused, because two writers interleaving on the transcript make message order depend on scheduling — which breaks the cache intermittently and undebuggably.

Loop hygiene

A ReAct loop rarely fails by crashing. It fails by continuing — calling the same tool, getting the same answer, reasoning about it again. Every turn looks reasonable; only the sequence is insane, and the model cannot see its own loop from the inside.

LoopGuard catches repeats, oscillation (A→B→A→B), and stalling. The discriminator is same call, same result — repeating a call whose answer changed is legitimate (polling a build, retrying a flake), so it never fires on real work. Each problem warns once; a guard that repeats itself is another loop.

Running out of turns forces a real answer with no tools offered rather than returning a leftover fragment. Removing the capability is a guarantee; asking is a request.

Beyond the loop

All discovered from the workspace — nothing requires a config file.

Durable sessions (node:sqlite, no dependency). fein chat --resume <id> replays them. Compaction is a fork: the epoch spawns a child seeded by the summary, the parent keeps every event, the link is recorded. "Compacted" means relocated, not lost. A session interrupted between a tool call and its result is repaired on resume — otherwise it is not merely degraded but permanently unresumable, since every provider rejects an unanswered tool call.

Recall — FTS5 search across every prior session, exposed as session_search rather than auto-injected behind the model's back. Tool output is deliberately not indexed, so recall returns decisions instead of log lines.

Identity vs. convention~/.fein/SOUL.md is who the agent is; it is yours, so it is trusted. A SOUL.md in the repo is fenced like any project file, because the trust boundary is who can write the file, not what it's called.

Skills — reusable procedures as Markdown. The index lives in the frozen prompt; bodies load on demand. Loading every body up front burns tokens on unused skills and means writing a skill invalidates every cached conversation.

Hooks — functions and/or executables in .fein/hooks/<event>/. beforeTool can deny; a hook that can only observe is a logging system, not a safety mechanism. Observability hooks that throw are ignored; a beforeTool hook that throws fails closed.

Subagents — depth capped in code, and a SpawnBudget shared by reference across the whole tree. A per-agent limit is not a limit: breadth^depth growth measured 40 agents from a "cap" of 3.

Scheduled jobs — durable POSIX cron under the same permission machinery as interactive work, read-only unless you pass --write. No backfill: a laptop closed overnight wakes to zero pending runs, not eleven.

fein chat [--resume <id>]     fein run "<prompt>"     fein demo
fein sessions list | show <id> | search <q> | lineage <id>
fein skills list | show <name>          fein hooks
fein cron list | add | rm | enable | disable | runs | run | serve

Workspace

~/.fein/SOUL.md                     who the agent is (trusted, tier 1)
.fein/sessions.db  .fein/jobs.db    durable sessions + scheduled jobs
.fein/skills/  .fein/hooks/<event>/ skills + lifecycle hooks
.fein/spill/                        bulky tool output, retrievable
AGENTS.md | CLAUDE.md | SOUL.md     project context (fenced, tier 2)

Layout

src/
  core/        types · transcript (append-only log) · loop · guards · steering
  context/     lens + PrefixGuard · spill · repair
  models/      router · react-port · providers/{anthropic,openai,ollama,scripted}
  steps/       observe · verify · subagent · react · prompts · sections
  tools/       registry · builtin · edit/glob/grep
  cache/       limits (breakpoints, lookback, minimums) · keeper
  session/     store (SQLite+FTS5) · persist · search-tool
  skills/      hooks/      schedule/      telemetry/ledger
  config/      profiles · workspace        cli/       bench/

See ARCHITECTURE.md for why these boundaries, and DESIGN.md for the reasoning behind each rule — including an honest list of what is still unsolved.

Tests and benchmark

npm test               # 189 tests
npm run bench          # offline, deterministic, free — mechanism cost
npm run bench:live     # real models — the correctness question

The benchmark prices each mechanism against a control on tasks chosen so each has a case it should win and a case where it can only cost. Measured: the observe model is 88% cheaper on its case, 43% more expensive where it cannot help, netting −58% across four tasks. It paid for itself immediately by catching a bug where the observe model ran, billed, and had its output silently discarded.

Requires Node ≥ 22.5 (for built-in node:sqlite).


References

FE!N was built after reading four open-source harnesses side by side. All are MIT-licensed. No code was copied — the value was in design decisions, and every adoption is a fresh implementation with its own invariants and tests. COMPARISON.md documents what was taken, what was declined, and what survived contact unchanged.

  • deepseek-harness — "everything is a plugin." Taught us spill (bounded preview + retrieval locator, model-free), model-free result pruning, loop-hygiene guards, and the principle that a canonical order matters because it is a cache prefix.
  • pi — layered agent packages. Taught us the turn as a first-class concept (one assistant response plus its tool calls) and a nested event taxonomy.
  • nanobot — a deliberately small, readable core. Taught us steering (mid-turn message injection via a queue rather than a racing second run), typed turns, and the defensive passes that make a persisted history safe to replay — which surfaced a real bug where an interrupted session was permanently unresumable.
  • hermes-agent — sessions as infrastructure, deep context engineering. Taught us named prompt sections (which turned our own headline invariant from a convention into a checked one), a rotation-stable cache scope derived from the compaction lineage root, using the provider's real reported usage instead of a character estimate, and bounded error-body reads.

Also informed by the published behavior of Claude Code and Codex, and by Anthropic's prompt-caching documentation for the breakpoint, lookback, TTL, and minimum-prefix rules encoded in src/cache/limits.ts.

License

MIT © Ziboyan Wang

About

The world's first hybrid local-and-cloud agent harness you'll find addictive.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages