Skip to content

Repository files navigation

AshCode

CI PyPI Python License

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.

AshCode resolving a single prompt from the terminal: the model calls list_dir, the tool result streams back, and the agent answers

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


Contents


Why this exists

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.

Features

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.MD picked 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

Architecture

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
Loading

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.

Install

Requires Python 3.11+.

pip install ashcode
From 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.txt

Run it with python main.py in place of the ashcode command below.

Configure credentials:

cp .env.example .env
API_KEY=your-openrouter-api-key-here
BASE_URL=https://openrouter.ai/api/v1

Any 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.py lists free models that do.

Usage

Interactive:

ashcode

Single prompt:

ashcode "explain the retry logic in client/llm_client.py"

Against another directory:

ashcode --cwd ../other-project

Slash commands

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

Configuration

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", "."]

Approval policies

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.

Project instructions

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.

Extending

Custom tools

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.

Subagents

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,
)

Engineering notes

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.

Testing

pip install -r requirements-dev.txt
pytest -q

30 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.

Project layout

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

License

MIT

About

A terminal coding agent in Python — streaming agentic loop, 13 built-in tools, MCP servers, subagents, context compaction, and a configurable approval layer for shell and file operations.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages