Project

lyman

0.0
There's a lot of open issues
A composable agentic harness, delivered as code you own — a pure generator in the shadcn/ui mold, built on shifty
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0
~> 0.6
 Project Readme

Lyman

A composable agentic harness — and a framework for building harnesses — in Ruby, built on shifty.

Lyman is for people building agentic workflows on models they run themselves: open-weight models on Ollama, LM Studio, vLLM, llama.cpp — or models they've trained. If that's you, you start with less data, less tooling maturity, and less margin for error than the frontier labs. Your one real edge is iteration speed. Lyman exists to maximize it.

Lyman, as in the Lyman series — a nod to the physicist, and to redshift, which is in turn a nod to Ruby and the shifty gem. A series is also, fittingly, a sequence of discrete lines — like the stages of a pipeline.

lyman new, then a first chat with a tool-using local model

CI

The idea

An agent harness is a pipeline. Not as a metaphor — literally:

pipeline =
  source_worker { rounds.shift }                                             |
  Lyman::Workers.chat_completion(base_url:, model:, tools:)                  |
  relay_worker { |c| c.pending_tool_calls.empty? ? c.finish : c }            |
  Lyman::Workers.tool_execution(handlers)                                    |
  side_worker { |c| rounds << c unless c.finished? }                         |
  filter_worker { |c| c.finished? }

That's not pseudocode — it's the working heart of harness/repl.rb, a tool-using chat agent for local models. Every stage is a plain shifty worker. Want logging? Splice in a side_worker — anywhere. Want a guardrail? A filter_worker. Want to swap Ollama for vLLM, or native tool-calling for prompt-and-parse emulation on a model that lacks it? Replace one worker; nothing else changes. There are no hooks, because there's nothing to hook into — the whole loop is already open.

Two principles drive every design decision:

  1. Legibility. One paradigm — a pipeline of workers — applied everywhere, at every level.
  2. Guts on the outside. Nothing important happens anywhere you can't see, name, or splice into. This is deliberately the opposite of the frontier-SDK ethos, where the agent loop is a black box you configure from the edges.

And a razor for when they're in tension with convenience: favor whatever lets a user see and change behavior faster. For a data-poor tinkerer, transparency isn't an aesthetic — it's what makes fast iteration possible.

Quick start

You'll need Ruby ≥ 3.2 (CI runs the suite on 3.2, 3.3, 3.4, and 4.0; this repo pins its own Ruby in mise.toml, your project doesn't have to — see docs/design/ruby-version-policy.md) and a local model server speaking the OpenAI-compatible API — Ollama and LM Studio both work. Use a model with native tool support (e.g. qwen3.5, gemma4, mistral).

bundle install
bundle exec ruby harness/repl.rb
lyman ⇢ gemma4:latest @ http://localhost:11434/v1

you> what time is it?

gemma4:latest> ✻ The user wants the current time. I have a current_time tool…

  ⚙ current_time {}
  ✓ current_time → 2026-07-08 18:13:29 EDT

gemma4:latest> It's 6:13 PM on Wednesday, July 8th.

The ✻ aside is a streamed preview of the model's thinking; the ⚙ and ✓ lines are side workers showing a tool call and its result — and the reply after them means two model round-trips completed inside a single pull on the pipeline. Point it elsewhere with LYMAN_BASE_URL and LYMAN_MODEL.

How it works, briefly

  • The item is the conversation. What flows through the pipeline is a Conversation — an append-only series of elements (system, user, reasoning, assistant, tool_call, tool_result) plus the control data workers consult (finished?, pending_tool_calls). A turn carries its whole conversation with it; that's what makes it a conversation.
  • State lives in the shell. The enclosing scope — the shell — is just state plus a process: the conversation, a queue, and a deliberately boring loop. A human REPL is one shell; an autonomous email-triager is another. Lyman is turn-shaped, not chat-shaped.
  • Three archetype harnesses prove it. The same circuit ships inside three shells: a repl (a human drives the loop), a daemon (loop forever on an inbound event stream), and a script (take one work item at launch, process it, halt). Only the shell differs — see docs/design/harness-archetypes.md.
  • The agentic loop is a circuit, not a special construct. Shifty pipelines are strictly linear, so the model⇄tool cycle is built from stock parts: a source reading from a queue, a side worker re-enqueueing unfinished rounds, and a filter at the tail that only lets finished turns escape. Demand does the rest — N model⇄tool rounds happen inside one shift, with no loop written anywhere. Full reasoning in docs/design/circuit-pattern.md.
  • Dependencies stay inside the workers that need them. The HTTP client lives in exactly one file. Use your favorite gems — in such a way that only the worker requiring one knows it exists.
  • File primitives ship planted, not hand-rolled. Lyman::Tools.search_files(root:) (literal, case-insensitive content search, or a bare file-name/glob lookup when no pattern is given) and Lyman::Tools.read_file(root:) (line-numbered, optionally by range) are stdlib-only and planted by lyman new alongside current_time. Both are confined to root — every path is resolved with File.realpath, so .. traversal and symlinks pointing outside it are refused — and every failure comes back as a message the model can read rather than an exception.
  • Patching is a planted tool with a check seam. lib/lyman/tools/patch.rb holds two factories, one per patch format: Lyman::Tools.search_replace (exact search-and-replace blocks, the default; robust for models that can't reproduce line numbers) and Lyman::Tools.apply_diff (unified diff, hunks placed by context). Wiring one is choosing the format. Each applies one patch to one root-confined file, then runs check: — a command like "bundle exec standardrb" (the touched path appended, run as an argv, never through a shell), a callable, or nil — and reports one word on a pass, the findings on a failure. It isn't handed to the main model; the file editor agent (below) builds it internally.
  • An agent-as-tool is a script shell inside a handler, not a second framework. A tool handler that builds a fresh conversation, runs a small circuit with its own tools and its own max_rounds runaway guard, and returns the final answer is the script archetype, invoked in-process — the same pattern as harness/compactor.rb, one level down. harness/agents/file_reader.rb (planted by lyman new) is the first: given a path/glob and an optional query, it delegates to a small model only when there's something to extract, and returns just the excerpts, outline, or answer asked for — so a large file's text is read once, by a disposable sub-conversation, and never carried in the main context. It's runnable standalone (ruby harness/agents/file_reader.rb PATH [QUERY]) for tuning its prompt directly. harness/agents/file_editor.rb (also planted by new) is its write-side twin: given a prompt describing a change, its sub-agent finds the code, patches it with search_replace, and may fix its own check failures — but the tests run in a plain stage after its circuit finishes, so the agent structurally cannot see or "fix" them. The caller gets a short report: what changed, any check failures left, and test output only if the tests failed. Standalone: ruby harness/agents/file_editor.rb "REQUEST" (edits files for real).
  • Persistence is an opt-in splice, not a built-in. Lyman::Store (SQLite, confined to its own file) gives a conversation a durable, addressable, full-text-searchable home; Lyman::Workers.store_append is the one side_worker that wires it into a circuit. Neither is planted by lyman new — add them with lyman add store when a harness needs to survive a restart or be queried.
  • Context management is a wiring choice, not a mode. Lyman::Abridgement ships deterministic, model-free policies — SuppressPriorReasoning, StubToolResults — that shape only the wire view chat_completion sends; the element series is untouched, so nothing abridged away is gone. A policy is a plain object (call(conversation) -> conversation, lambdas welcome) picked when wiring the chat completion worker, and Abridgement.over_budget can gate one on the transport's own usage report, stamped onto the conversation after each reply.
  • Compaction is a handoff, not a stall. A compaction sidecar (harness/compactor.rb, lyman add compactor) is a daemon-archetype shell running in its own thread beside your harness: one side worker, Lyman::Workers.compaction_feed, hands it each new element, and a small fast model keeps a ledger — facts, decisions, open items, each citing the addresses of the elements it summarizes. When the shell decides context is too big, Lyman::Compaction.request hands back a new conversation (ledger plus the last turn verbatim, parent_id pointing at the original) almost instantly, because the ledger was already current.
  • Over-compaction is recoverable, not fatal. Lyman::Tools.recall(store:) lets the model re-expand what a stub or a ledger entry points at — by address (conv:abc#17-23) or by plain-word search — bounded so a wide recall can't blow the very context it's meant to relieve. Opt-in with lyman add recall_tool once a store is planted.

The longer story — mission, principles, architecture decisions, open questions — is in docs/vision.md.

Documentation

The wiki is the guided tour:

The wiki's source of truth is the wiki/ directory in this repo — documentation changes ride through pull requests, then publish with wiki/publish.sh. For design intent, the docs/ design notes remain authoritative.

Is lyman a good fit for you?

Probably yes, if:

  • You're building a narrow, purpose-built agent — an email triager, a log summarizer, a domain-specific assistant — tailored to specific problems and specific models, and you want to iterate on it fast.
  • You run models locally or self-hosted and want to build a harness that treats that as the primary case, not an afterthought.
  • You want to understand and modify your whole agent loop, not configure the edges of someone else's.
  • You like Ruby, or at least like the idea of an agent harness you can read top to bottom in a single coding session.

Probably not, if:

  • You want a no-code / low-code agent builder. Lyman is a developer's tool.
  • You want feature parity with frontier-model harnesses and popular coding agents. Lyman isn't chasing checklists — it's chasing the shortest path from "what if the workflow did this?" to finding out.
  • You want a batteries-included platform with every integration bundled. Lyman's bet is that shifty makes those things easy enough to build yourself that shipping them all would be beside the point.

The generator

Lyman is delivered as a pure generator, in the spirit of shadcn/ui: the gem's whole job is to plant legible, manifest-tracked modules into your project, individually upgradeable and yours to extend — or eject and adopt outright. No runtime framework to call into; your harness's only runtime dependency is shifty. "Guts on the outside" extends to whose tree the code lives in. The reasoning is written down in docs/design/deployment.md.

lyman new my-agent      # scaffold a project: harness, workers, manifest
lyman list              # what lyman installs, and this project's status
lyman update            # refresh pristine modules; halt (never clobber) on modified ones
lyman diff conversation # your changes, and upstream's since you planted
lyman eject conversation# take ownership; lyman stops managing it
lyman doctor            # smoke-test the pipeline against a stub transport

.lyman/manifest.yml records what was planted, at which version, with which content hash — so update knows pristine from modified, ejected modules get upstream-change advisories instead of merges, and your harness script is never touched at all: it's yours from day one.

Status

Early and moving. What exists today: the vision, circuit-pattern, harness-archetypes, and deployment design docs, the core Conversation item (an append-only element series), chat-completion and tool-execution workers, an optional SQLite Store with lineage and full-text search, the search_files/read_file file primitives, the search_replace/apply_diff patch tool with its post-edit check, the agent-as-tool pattern with its first two agents (harness/agents/file_reader.rb and harness/agents/file_editor.rb), deterministic wire-time Abridgement policies plus transport usage stamping, the three archetype harnesses (repl, daemon, script) working against live local models, and the generator CLI (new / add / update / eject / diff / doctor / list) with a Minitest suite behind it. Published to rubygems.org — install it with:

gem install lyman
lyman new my-agent

On deck: tool-call fan-out.

License

MIT