A terminal coding agent in Python — streaming agentic loop, 12 built-in tools, MCP servers, subagents, context compaction, and a configurable approval layer for shell and file operations.
AshCode reads and writes files, runs shell commands, searches the web, and delegates work to scoped subagents — driven by any OpenAI-compatible model endpoint. Built from scratch to understand what actually goes into a coding agent: not just the tool-calling loop, but context management under a finite window, safety gating on destructive operations, and recovery when the model gets stuck.
Single-shot mode. One prompt, one answer — the agent picks its own tools and exits.
python main.py "list the files in this directory"
Video demos: ▶ reading through a codebase · ▶ creating and deleting a file
- Why this exists
- Features
- Architecture
- Install
- Usage
- Configuration
- Extending
- Engineering notes
- Testing
- Project layout
Wrapping a chat completion in a while loop gets you a demo. The gap between
that and a usable agent is made of unglamorous problems, and this project is an
attempt to solve them properly rather than skip them:
- The context window fills up. A real session blows past the limit in twenty minutes. AshCode compacts history into a structured summary at 80% capacity and separately clears stale tool outputs, so long sessions degrade gracefully instead of erroring.
- Models get stuck. They will call the same failing tool forever. A signature-based loop detector catches exact repeats and short cycles, then injects a corrective prompt.
- Agents run destructive commands. Six approval policies gate mutating operations, with an interactive confirmation path and diff previews before file writes.
- Broad exploration destroys context. Subagents run with their own turn budget and a restricted tool set, returning a summary instead of the twenty file reads it took to produce it.
- Streaming makes parsing a state machine. Tool calls arrive fragmented across SSE chunks and have to be reassembled correctly — see Engineering notes.
Agent loop
- Fully streaming — text renders token by token while tool calls are collected
- Parallel tool calls within a turn
- Typed event stream (
AgentEvent) decoupling the loop from any renderer - Configurable turn budget with graceful exhaustion
12 built-in tools
read_file write_file edit_file apply_patch |
file operations with unified-diff previews |
list_dir glob grep |
navigation and search |
shell |
command execution with timeout and env scrubbing |
web_search web_fetch |
web access |
memory todo |
cross-turn state |
Context management
- Automatic compaction into a structured summary at 80% of the window
- Tool-output pruning outside a protected recent window
- Per-turn and cumulative token accounting
Safety
- Six approval policies:
on-request,on-failure,auto,auto-edit,never,yolo - Regex classification of shell commands into dangerous and known-safe sets
- Writes outside the working directory always require confirmation
- Secrets scrubbed from the subprocess environment by pattern (
*KEY*,*TOKEN*,*SECRET*)
Extensibility
- MCP servers over stdio and HTTP/SSE, surfaced through the same registry as builtins
- Custom tools auto-discovered from
.ai-agent/tools/*.py - Lifecycle hooks around agent runs and tool calls
AGENT.MDpicked up from the working directory as project instructions
Sessions
- Save, list and resume sessions; create and restore checkpoints
- Full message history and token totals preserved across restarts
flowchart TB
CLI["CLI / TUI<br/><i>renders events</i>"]
Agent["Agent<br/><i>async generator, yields typed events</i>"]
Client["LLMClient<br/><i>SSE → typed stream events</i>"]
Context["ContextManager<br/><i>compaction + pruning</i>"]
Registry["ToolRegistry<br/><i>validate → hook → approve → execute</i>"]
Approval["ApprovalManager"]
Builtin["12 builtin tools"]
MCP["MCP servers"]
Sub["Subagents<br/><i>nested Agent, scoped tools</i>"]
CLI -->|"user message"| Agent
Agent -->|"AgentEvent stream"| CLI
Agent <--> Client
Agent <--> Context
Agent --> Registry
Registry --> Approval
Approval -.->|"confirm?"| CLI
Registry --> Builtin
Registry --> MCP
Registry --> Sub
Sub -.->|"recurses"| Agent
The load-bearing decision is that Agent.run() is an async generator that
yields typed events and knows nothing about the terminal. The TUI is one
consumer; a subagent is another, consuming the same events programmatically and
discarding the rendering. Adding a web frontend or a non-interactive CI runner
means writing a new consumer, not touching the loop.
The second is that tools are self-describing. A tool declares a Pydantic schema, which is converted to an OpenAI function schema automatically. Adding a tool requires no change to the agent loop, the registry, or the prompt — which is what makes auto-discovery of user-supplied tools a ~40 line feature rather than a redesign.
Requires Python 3.11+.
pip install ashcodeFrom source, to hack on it
git clone https://github.com/AuthRan/AshCode.git
cd AshCode
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txtRun it with python main.py in place of the ashcode command below.
Configure credentials:
cp .env.example .envAPI_KEY=your-openrouter-api-key-here
BASE_URL=https://openrouter.ai/api/v1Any OpenAI-compatible endpoint works — OpenRouter, OpenAI, Together, or a local
llama.cpp or vLLM server. Point BASE_URL at it and set model.name.
Note: the model must support tool calling. On OpenRouter,
scripts/find_free_tool_models.pylists free models that do.
Interactive:
ashcodeSingle prompt:
ashcode "explain the retry logic in client/llm_client.py"Against another directory:
ashcode --cwd ../other-project| Command | |
|---|---|
/help |
show commands |
/config |
current model, policy, working directory, turn budget |
/model [name] |
show or switch model mid-session |
/approval [policy] |
show or switch approval policy mid-session |
/tools |
list every registered tool |
/mcp |
MCP server connection status |
/stats |
turn count and token usage |
/clear |
reset conversation and loop detector |
/save · /sessions · /resume <id> |
session persistence |
/checkpoint · /restore <id> |
checkpoints within a session |
/exit |
quit |
Config is TOML, merged from two layers — a user-level file, then a
project-level .ai-agent/config.toml that overrides it. Secrets stay in .env
and never enter the config file.
hooks_enabled = true
max_turns = 100
approval = "on-request"
[model]
name = "anthropic/claude-sonnet-4.5"
temperature = 0
context_window = 200000
# scrub matching variables from the shell tool's environment
[shell_environment]
exclude_patterns = ["*KEY*", "*TOKEN*", "*SECRET*"]
# run a script around agent runs and tool calls
[[hooks]]
name = "lint_after_edit"
trigger = "after_tool"
command = "python ./scripts/lint.py"
# expose an MCP server's tools to the agent
[mcp_servers.filesystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "."]| Policy | Behaviour |
|---|---|
on-request |
prompt for anything not recognised as safe (default) |
on-failure |
prompt only after a command fails |
auto |
approve commands, prompt for writes outside the working directory |
auto-edit |
approve file edits, prompt for other commands |
never |
run known-safe commands only, reject the rest, never prompt |
yolo |
approve everything |
Dangerous commands (rm -rf /, mkfs, dd if=, fork bombs, curl | sh) are
rejected outright under every policy except yolo.
Command classification is pattern matching, not a sandbox — it raises the cost of an accident, it does not contain a determined one. Run untrusted workloads in a container.
Drop an AGENT.MD in the working directory and its contents are loaded into
the system prompt — conventions, architecture notes, things the agent should
know before touching the code.
Any Tool subclass in .ai-agent/tools/*.py is discovered and registered at
startup. No registration call, no config entry:
from pydantic import BaseModel, Field
from tools.base import Tool, ToolInvocation, ToolResult, ToolKind
class RunTestsParams(BaseModel):
path: str = Field(".", description="Directory or file to test")
class RunTestsTool(Tool):
name = "run_tests"
description = "Run the pytest suite and return the summary."
kind = ToolKind.SHELL
schema = RunTestsParams
async def execute(self, invocation: ToolInvocation) -> ToolResult:
params = RunTestsParams(**invocation.params)
...
return ToolResult.success_result(output)kind determines whether the tool is treated as mutating, and therefore
whether it passes through the approval gate.
Defined declaratively with their own tool allowlist and turn budget. Ships with
codebase_investigator (read-only exploration) and code_reviewer:
SubagentDefinition(
name="codebase_investigator",
description="Investigates the codebase to answer questions about structure and implementation",
goal_prompt="You are a codebase investigation specialist. Do NOT modify any files.",
allowed_tools=["read_file", "grep", "glob", "list_dir"],
max_turns=20,
)Three problems from building this that were more interesting than expected.
Streaming tool calls are a reassembly problem. A provider sends one tool
call across many SSE chunks — the first carries the id and function name, every
chunk after it carries a fragment of the argument JSON. Accumulating those
fragments one branch too deep meant only the opening fragment survived, and
tools were invoked with {}. The fix is trivial; noticing that "tool rejected
my arguments" was actually "arguments never arrived" is the part worth
remembering. Now covered by regression tests that replay a fragmented stream.
Safety code has to fail closed. The approval gate was a sequence of checks
ending in return APPROVED, so any path that didn't explicitly catch something
fell through to permissive — and shell commands, which declare no affected file
paths, took exactly that path. Every non-blocklisted command ran without
prompting. Had the default been NEEDS_CONFIRMATION, the same structural bug
would have produced an annoying agent instead of an unsafe one. The tests now
assert the negative case: that specific commands do prompt.
A timeout that depends on progress isn't a timeout. Subagent timeouts were
enforced by comparing a deadline between yielded events, which only works while
the subagent is producing events. The case a timeout exists for — blocked on a
network read — produces none. asyncio.wait_for cancels from the outside
instead of asking the coroutine to check on itself.
pip install -r requirements-dev.txt
pytest -q30 tests over the stream parser, the approval gate and the request payload — the three places where a silent bug is most expensive. Each fix was verified by running its new tests against the pre-fix code first and confirming they fail, since a regression test that passes before the fix isn't testing the regression.
CI runs the suite on Python 3.11, 3.12 and 3.13 on every push and pull request, plus an import check that catches side effects at import time.
agent/ agentic loop, session state, typed events, persistence
client/ streaming LLM client, SSE → typed stream events
tools/ tool ABC, registry, 12 builtins, MCP integration, subagents
context/ context window management, compaction, loop detection
safety/ approval policies and command classification
config/ Pydantic config schema and layered TOML loader
hooks/ lifecycle hook execution
prompts/ system prompt assembly
ui/ Rich terminal UI
tests/ regression tests
MIT
