A minimalist agent in ~500 lines of Ruby: ReAct loop + macOS Seatbelt sandbox + security approvals + MCP + Skills + persistent memory
Overview
LangDriver is a lightweight Agent runtime implemented in under 500 lines of Ruby, with ReAct, context compaction, persistent memory, Skills, MCP, Hooks, sandboxing, and permission approvals. Requires macOS and Ruby 3.0+ (uses Reline shipped with Ruby 3). The default config is already tuned for DeepSeek: - : use deepseek-v4-pro or deepseek-v4-flash. The compatibility names deepseek-chat / deepseek-reasoner will be retired on . - : DeepSeek v4 provides a 1M context window without an extra request field. Other providers may use different limits and field names; follow that provider's API documentation instead of copying DeepSeek values. - : declared via top-level reasoning_effort and thinking fields. LangDriver pipes the entire runtime.extra block into the request body verbatim. - : LangDriver uses the Chat Completions message/tool shape, but provider-specific models, fields, streaming details, and authentication may still require configuration or code changes.
README
LangDriver
LangDriver is a lightweight Agent runtime implemented in under 500 lines of Ruby, with ReAct, context compaction, persistent memory, Skills, MCP, Hooks, sandboxing, and permission approvals.
Install
Requires macOS and Ruby 3.0+ (uses Reline shipped with Ruby 3).
git clone https://github.com/kevin0x5/langdriver && cd langdriver
ruby --version # expect ruby 3.0+
Configure
1. API key
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
2. langdriver.yaml
The default config is already tuned for DeepSeek:
runtime:
model: deepseek-v4-pro # or deepseek-v4-flash (cheaper / faster)
compact_model: deepseek-v4-flash # used for async chat compaction; falls back to model
api_base: https://api.deepseek.com # DeepSeek endpoint; other providers may require request-field changes
api_key_env: DEEPSEEK_API_KEY
max_steps: 50
extra: # forwarded verbatim into chat/completions request body
max_tokens: 384000 # DeepSeek v4 single-call max output
reasoning_effort: max # recommended for complex agent tasks
thinking: { type: enabled } # chain-of-thought (DeepSeek-V3.2+ supports tool calls in thinking mode)
Notes:
- Model names: use
deepseek-v4-proordeepseek-v4-flash. The compatibility namesdeepseek-chat/deepseek-reasonerwill be retired on July 24, 2026. - Context limits: DeepSeek v4 provides a 1M context window without an extra request field. Other providers may use different limits and field names; follow that provider’s API documentation instead of copying DeepSeek values.
- Max thinking effort: declared via top-level
reasoning_effortandthinkingfields. LangDriver pipes the entireruntime.extrablock into the request body verbatim. - Switch provider: LangDriver uses the Chat Completions message/tool shape, but provider-specific models, fields, streaming details, and authentication may still require configuration or code changes. Remove unsupported DeepSeek-only fields such as
thinkingwhen appropriate.
3. Other declarative files (project root)
| File | Purpose |
|---|---|
profile.md |
Assistant persona / tone (system prompt) |
prompts.yaml |
Tunable prompt templates (system_suffix, compact) — delete to use built-in defaults |
memory.md |
Long-term project facts; auto-injected into system prompt |
summaries.md |
Auto-appended summaries (one block per 50 turns) |
skills.yaml |
Per-skill enable / trust / priority |
tools.yaml |
Permission ceiling for built-in file tools, external Skills, and the optional shell |
hooks.yaml |
after_user_prompt / after_tool_use reminder text |
mcp.yaml |
MCP server connections |
First run
ruby langdriver.rb
In the REPL:
> read sample.txt and summarize it in one sentence
The model calls read, observes the content, and answers directly. No task-specific workflow is hard-coded; the model chooses each next step from context and tool results.
REPL commands (slash prefix; bare names also work):
/exit exit
/clear clear in-session chat (memory & summaries persist)
/memory show current memory.md
/remember append a fact to memory.md
/forget wipe memory.md
/todos show current todo list
/skill list list installed skills
/skill inspect show effective permissions of a skill
/skill remove uninstall
/mcp list MCP-provided tools
One-shot task (no REPL):
ruby langdriver.rb "count lines in sample.txt"
Artifacts: .langdriver/artifacts/result.md. Execution log: .langdriver/logs/run.jsonl. Todos: .langdriver/todos.json.
What problem does this solve
LangDriver has one design rule: keep the Agent runtime small. Task-level planning stays with the model; the runtime focuses on protocol, state, capabilities, and execution.
The result is a complete local Agent loop — ReAct, memory, Skills, MCP, Hooks, and permission-controlled execution — without a separate orchestration layer for each task type.
Model reasoning + tool calls + observations → next step or final answer
| Design area | LangDriver implementation | Purpose |
|---|---|---|
| Planning | The model emits standard tool_calls at each step |
No separate Planner layer |
| Memory | Plain-text memory.md plus async summaries.md compaction |
Transparent, readable, and editable state |
| Prompts | profile.md plus two optional templates in prompts.yaml |
Behavior stays outside runtime code |
| Routing | JSON tool_calls dispatch directly to registered tools |
A short path from model decision to execution |
| Error recovery | Tool results return to the same ReAct loop | The next strategy is based on the actual observation |
Tools the model can call
LangDriver follows the DeepSeek tool calls spec: standard tools[] schema, streamed tool_calls aggregation by index, paired role:tool messages with tool_call_id, and full reasoning_content plus tool-history replay in thinking mode. A response may contain multiple tool calls; the current executor runs them in order.
| function | what it does | constraint |
|---|---|---|
read |
read file | path must be in tools.yaml read whitelist |
search |
ripgrep / grep | within read whitelist |
write |
write file | path must be in write whitelist |
use_skill |
fetch a Skill’s SKILL.md | informational |
run_skill_script |
execute a Skill script | external trust → Seatbelt sandbox |
shell |
bash commands | off by default; explicitly enabled in tools.yaml, independent of Skill trust, and run under a Seatbelt profile generated from the global read/write/network ceiling |
todo |
persistent todo list (add / done / list / clear) | stored in .langdriver/todos.json |
mcp____ |
MCP tool call | depends on the MCP server itself |
Generated function-name separators use __ instead of .; MCP tool names must also satisfy the model API’s naming rules (^[a-zA-Z0-9_-]+$).
Persistent memory & async compaction
LangDriver keeps long-term context across sessions, without vector stores or embeddings — just text injection.
system prompt =
profile.md
+ ## Memory ← memory.md (auto-extracted entries are line-deduplicated)
+ ## Recent Summaries ← last 10 blocks of summaries.md
+ (skill / mcp menu + tool-use guideline from prompts.yaml)
messages = [system] + last 50 turns + current user
How memory is populated:
/remember— explicit append- Auto-extraction during compaction (see below)
- Manual edits to
memory.md(plain markdown — gitignored by default; remove the gitignore entry if you want to share team-level facts via git)
Async compaction triggers every 50 completed turns:
- A background thread sends the oldest 50-turn slice to
compact_model(defaultdeepseek-v4-flash) - Returns strict JSON
{"summary": [...], "memories": [...]}per thecompacttemplate inprompts.yaml - Summary is appended to
summaries.md; memories are appended (line-deduped) tomemory.md - The next turn’s system prompt picks them up automatically
- Failure is retried up to twice, after 5s and then 10s; the final failure is logged to
run.jsonl
This keeps raw active context bounded and supports long-running sessions through lossy summaries. Compaction is not exact recall: only the first 500 characters of each message are included in a compaction batch, and only the latest 10 summary blocks are injected.
Multi-turn chat protocol
Follows the DeepSeek multi-turn contract:
- The latest 50 turns retain complete protocol history, including
reasoning_content,assistant.tool_calls, and pairedrole:toolresults - Uncompacted turns live in a separate buffer; each complete 50-turn batch is removed for compaction without relying on indexes into trimmed history
- Once compacted, older raw turns are represented by summaries and long-term memory while the latest 50 remain verbatim
/clear wipes the in-session chat (memory & summaries are not affected).
Install a community Skill
ruby langdriver.rb skill install
LangDriver clones and registers the Skill, then checks its requested write paths against tools.yaml. external Skill scripts run in Seatbelt; core Skill scripts run with host permissions.
ruby langdriver.rb skill inspect # show effective permissions
Security model
External Skill permissions come from three intersecting layers:
- What the Skill declares (
SKILL.mdfrontmatter):network: true,fs_read: ["~/Documents"] - Your local override (
skills.yaml): enablement, trust level, and narrower permissions - The runtime ceiling (
tools.yaml): allowed paths, environment variables, network, and command timeout
Effective permission is the intersection of all three. Network or out-of-bundle file access requires approval; an external Skill may not combine both.
Trust levels: core vs external
| Level | Behavior | When |
|---|---|---|
trust: core |
Skips the sandbox, no permission prompt | Locally maintained, reviewed code that you intentionally allow to run with host permissions |
trust: external |
Runs inside Seatbelt; network or out-of-bundle file access requires approval | Community and other external Skills |
core affects only that Skill’s scripts; it does not unlock the global shell. The shell must be explicitly enabled in tools.yaml and runs under its own generated profile. Core Skills and MCP servers are trusted integrations and should be reviewed before enabling.
Seatbelt sandbox (external Skills and explicitly enabled shell)
LangDriver gives external Skills and the explicitly enabled shell a dynamically generated deny-by-default profile with:
- Runtime system paths needed by the interpreter
- Reads and writes granted by the effective permission set
- Network access only when the effective permission set enables it
(version 1)
(deny default)
(deny file-read* (subpath "/path/to/home"))
(allow file-read* (subpath "/usr/lib") (subpath "/bin") (subpath "/usr/bin"))
(allow file-read* (subpath "/path/you/authorized"))
(allow network*) ; only present when the effective grant permits
Operations outside the generated profile are denied by Seatbelt at the syscall boundary.
Verify
ruby -c langdriver.rb
ruby test/test_langdriver.rb
ruby test/test_streaming.rb
test "$(wc -l < langdriver.rb)" -le 500
The suite covers thinking/tool-call stream aggregation and reasoning replay, complete protocol history, 50-turn compaction buffers, core/shell permission separation, Skill exit status, MCP environment isolation, and the 500-line hard limit. Actual Seatbelt syscall denial must be verified from a normal macOS process that is not already inside another sandbox.
License
MIT — see LICENSE.
Recommended Tools
Try a different keyword or remove a filter.
Install
npx skillfish add kevin0x5/langdriver