Covers: complete type signatures, data flow, coding standards, testing requirements, documentation standards, anti-patterns, CLI reference, and prioritized remaining work.
15 KiB
Slug Code — Agent & Developer Reference
FOSS Rust rewrite of Claude Code. Pluggable LLM backend targeting OpenAI-compatible APIs (vLLM primary). Binary: slug. Package: slug-code. Rust 2024 edition.
Repo layout: master = original TypeScript Claude Code source (reference only). rust-rewrite = this Rust implementation (all work happens here).
Module Map
src/
├── main.rs CLI entry (clap). Wires all modules. Handles session flags.
├── config/mod.rs Config struct + PermissionMode enum. TOML + CLI + env.
├── provider/
│ ├── mod.rs Provider trait + OpenAIProvider (SSE streaming impl)
│ └── types.rs ChatMessage, Role, ToolCall, StreamEvent, StreamChunk
├── agent/mod.rs Core loop: user input → LLM stream → tool exec → repeat
├── tools/
│ ├── mod.rs Tool trait + ToolRegistry (HashMap<name, Box<dyn Tool>>)
│ ├── bash.rs BashTool — runs via `bash -c`, captures stdout+stderr
│ ├── read.rs ReadTool — reads file with line numbers, offset+limit
│ ├── write.rs WriteTool — writes file, creates parent dirs
│ ├── edit.rs EditTool — exact string replacement, enforces uniqueness
│ ├── glob.rs GlobTool — globwalk, max_depth 20, sorted output
│ └── grep.rs GrepTool — prefers rg, falls back to grep, 250 line cap
├── permissions/mod.rs PermissionHandler. Glob allow/deny. ask/yolo/sandbox/allowEdits.
├── slugmd/mod.rs Loads SLUG.md hierarchy every turn, 40K char budget.
├── session/mod.rs JSONL persistence at ~/.slug/sessions/. SessionManager.
├── hooks/mod.rs HookManager. 5 events, command+prompt types. .slug/hooks.json.
├── compact/mod.rs Compactor. ToolResultTrim→Truncate. /compact command.
└── tui/mod.rs Interactive REPL. Wires agent+session+hooks. Slash commands.
Key Types
provider/types.rs
ChatMessage { role: Role, content: Option<String>, tool_calls: Option<Vec<ToolCall>>, tool_call_id: Option<String> }
// Constructors: ChatMessage::system(&str), ::user(&str), ::assistant(Option<String>, Option<Vec<ToolCall>>), ::tool_result(&str, &str)
Role: System | User | Assistant | Tool // serde: lowercase
ToolCall { id: String, call_type: String, function: FunctionCall }
FunctionCall { name: String, arguments: String } // arguments is JSON string
StreamEvent: Text(String) | ToolCallDelta(ToolCallDelta) | Finish | Done
ToolCallDelta { index: usize, id: Option<String>, name: Option<String>, arguments_delta: Option<String> }
provider/mod.rs
trait Provider: Send + Sync {
fn stream_chat(&self, messages: &[ChatMessage], tools: &[ToolDefinition])
-> Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send + '_>>;
}
// OpenAIProvider implements Provider. POST /chat/completions with stream:true.
// SSE parsing: buffer bytes → split on \n → strip "data: " prefix → parse StreamChunk.
// Tool calls assembled via ToolCallAccumulator keyed by delta.index.
tools/mod.rs
trait Tool: Send + Sync {
fn definition(&self) -> ToolDefinition;
fn execute(&self, args: &Value) -> Result<String>;
}
ToolDefinition { name: String, description: String, parameters: Value } // parameters is JSON Schema
// ToolRegistry: HashMap<String, Box<dyn Tool>>. execute() returns Result<String> — errors become tool result strings in agent.
config/mod.rs
Config { endpoint, api_key, model, system_prompt, max_tokens: u32, temperature: Option<f32>, max_tool_rounds: usize, permission_mode: PermissionMode }
// Defaults: endpoint="http://localhost:8000/v1", model="default", max_tokens=4096, max_tool_rounds=50
// Load order: TOML file → with_cli_overrides()
PermissionMode: Ask | Yolo | AllowEdits | Sandbox(String)
// serde: { "type": "ask" } / { "type": "yolo" } / { "type": "allowedits" } / { "type": "sandbox", ... }
permissions/mod.rs
PermissionRequest: Bash { command: &str } | FileWrite { path: &str } | FileEdit { path: &str }
// check() order: deny rules → allow rules → mode fallback
// Glob patterns: "Bash(npm *)", "Edit(src/**)", "Write(tests/**)"
// Settings loaded from ~/.slug/settings.json then .slug/settings.json (merged, project extends global)
// glob_match(pattern, text): ** matches across /, * does not
session/mod.rs
SessionManager { sessions_dir: PathBuf }
Session { session_id: String, messages: Vec<ChatMessage> }
SessionMeta { session_id, created_at, last_used_at, working_directory, model, summary }
// Storage: ~/.slug/sessions/{id}.jsonl (one ChatMessage per line) + {id}.meta.json
// Methods: create_session(), load_session(&str), save_message(&str, &ChatMessage), list_sessions(), get_latest_session(), fork_session(&str)
hooks/mod.rs
HookEvent: PreToolUse { tool_name, args } | PostToolUse { tool_name, args, result } | UserPromptSubmit { prompt } | SessionStart | SessionEnd
HookAction: Command { command: String } | Prompt { content: String }
HookResult { additional_context: Option<String>, blocked: bool, block_reason: Option<String> }
// HookManager::new() loads ~/.slug/settings.json + .slug/hooks.json
// fire(&HookEvent) → HookResult. Command hooks: sh -c, 30s timeout, non-zero = blocked.
// Config: { "hooks": [{ "event": "PreToolUse", "tool_filter": "bash", "action": { "type": "command", "command": "..." } }] }
compact/mod.rs
CompactionStrategy: Full | Truncate | ToolResultTrim
Compactor { max_context_tokens: usize } // default 200_000
// estimate_tokens(text) = chars / 4
// needs_compaction() = estimated > 80% of max
// ToolResultTrim: replace Tool role messages older than last 5 with "[result truncated — N chars]"
// Truncate: keep system prompt + last 10 user-turn groups
// extract_session_memory(): structured summary of task, files, errors, decisions
slugmd/mod.rs
load_slug_context() -> String
// Load order (all optional, concatenated with headers):
// 1. ~/.slug/SLUG.md "# Global Rules"
// 2. ./SLUG.md "# Project Rules"
// 3. .slug/rules/*.md "# Rule: {filename}" (sorted alphabetically)
// 4. ./SLUG.local.md "# Local Notes (private)"
// 40,000 char hard limit — truncates at line boundary with warning appended
// Returns "" if no files exist (no-op path, no overhead)
// NEVER stored in Agent.messages — reloaded from disk on every LLM call
agent/mod.rs
Agent { provider, tools, permissions, messages: Vec<ChatMessage>, max_rounds: usize }
// new() → seeds messages with [ChatMessage::system(config.system_prompt)]
// new_with_history() → prepends system prompt then extends with prior_messages
// messages() → &[ChatMessage] (used for session saving)
// stream_turn(input, on_text) → streams LLM response, executes tools, loops
// run_once(prompt) → non-interactive, returns final text
// compact() → ToolResultTrim first, then Truncate if still over threshold
// messages_with_slug_context() → [slug_context_system_msg, ...self.messages] (rebuilt each call)
// execute_with_permission(name, args) → checks PermissionHandler before calling ToolRegistry
// Tool execution: read/glob/grep = always allowed. bash/write/edit = permission checked.
Data Flow
User input
→ tui::run()
→ hook_mgr.fire(UserPromptSubmit) // can block or inject context
→ agent.stream_turn(input, on_text)
→ messages.push(ChatMessage::user(input))
→ messages_with_slug_context() // fresh SLUG.md prepended every call
→ provider.stream_chat(messages, tool_defs)
→ POST /v1/chat/completions (stream: true)
→ SSE parse → yield StreamEvent::{Text, ToolCallDelta, Finish, Done}
→ on_text(chunk) called for each Text event // prints to stdout in TUI
→ ToolCallAccumulators assembled from deltas
→ if tool_calls not empty:
→ messages.push(assistant msg with tool_calls)
→ for each tool_call:
→ execute_with_permission(name, args)
→ permissions.check(request) // deny→allow→mode
→ tools.execute(name, args) // ToolRegistry dispatch
→ messages.push(tool_result)
→ loop (up to max_tool_rounds)
→ if no tool_calls: messages.push(assistant msg), return
→ session_mgr.save_message() for each new message
Coding Standards
Error Handling
- Use
?for propagation everywhere. Nounwrap()outside tests. - Return
anyhow::Result<T>from all fallible functions. - Tool execution errors are caught and returned as the tool result string — never propagate to agent loop.
- Use
anyhow::bail!("message")for early returns with context. - Use
anyhow::anyhow!("message")to construct errors.
Async
- All async code uses
tokio. Runtime is#[tokio::main]in main.rs. - Streams use
async_stream::stream!macro with explicityield Ok(...)/yield Err(...). - Never
yield Errinsideasync_streamusing?— yield the error explicitly and return. - Provider
stream_chatreturnsPin<Box<dyn Stream<Item = Result<StreamEvent>> + Send + '_>>.
Serialization
- All wire types (ChatMessage, ToolCall, SessionMeta) derive
Serialize + Deserialize. skip_serializing_if = "Option::is_none"on optional fields to keep JSON clean.- Tool arguments arrive as
serde_json::Value— extract with.as_str(),.as_u64()etc, never index directly. - Config uses
#[serde(tag = "type", rename_all = "lowercase")]for PermissionMode enum.
Adding a New Tool
- Create
src/tools/{name}.rsimplementingTooltrait. - Add
mod {name};tosrc/tools/mod.rs. - Register:
registry.register(Box::new({name}::{Name}Tool));inToolRegistry::new(). - Permission: if mutating (writes files, runs code), add a
PermissionRequestvariant and check inagent::execute_with_permission. - Write unit tests in the same file under
#[cfg(test)].
Adding a New Provider
- Create
src/provider/{name}.rsimplementingProvidertrait. pub usefromsrc/provider/mod.rs.- Wire into
main.rsbehind a config flag or CLI arg. - Must return
Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send + '_>>. - Normalize provider-specific formats to
StreamEvent— the agent loop is provider-agnostic.
Testing Standards
Testing is non-negotiable. Every module must have tests. Agents: do not consider a task complete without writing tests.
Required Tests Per Module
- Tools: Test each tool with valid args, missing args, and error cases (file not found, bad pattern, etc.)
- Provider: Test
build_request_bodywith and without tools, with and without temperature. - Permissions: Test each PermissionMode, glob pattern matching, deny overrides allow, sandbox path checking.
- Session: Test create/save/load round-trip, fork copies messages, list_sessions returns sorted.
- Hooks: Test command hook execution, blocked result on non-zero exit, prompt hook injection.
- Compact: Test ToolResultTrim preserves last 5, Truncate preserves system prompt + last 10 groups, estimate_tokens.
- Slugmd: Test 40K truncation, missing files are skipped, section headers are present.
Test Patterns
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir; // use tempfile crate for file-based tests
#[test]
fn test_edit_tool_not_found() {
let tool = EditTool;
let args = serde_json::json!({ "file_path": "/nonexistent", "old_string": "x", "new_string": "y" });
assert!(tool.execute(&args).is_err());
}
}
- Use
tempfile::TempDirfor any test touching the filesystem. - Test tool errors produce
Err, not panics. - Test permission glob matching exhaustively — it's a custom implementation.
- Integration tests go in
tests/at crate root.
Documentation Standards
Every public item must have a doc comment. Agents: do not submit code without doc comments on public API.
/// One-line summary.
///
/// Longer explanation if the behavior is non-obvious.
/// Include what errors are returned and under what conditions.
pub fn load_slug_context() -> String { ... }
///for public items (rendered by rustdoc).//for implementation comments explaining why, not what.- Mark unfinished areas with
// TODO(feature): descriptionnot just// TODO. - Module-level
//! ...doc comments required for everymod.rs.
CLI Reference
slug [OPTIONS]
-e, --endpoint <URL> LLM API base URL [env: SLUG_ENDPOINT]
-k, --api-key <KEY> API key [env: SLUG_API_KEY]
-m, --model <NAME> Model name [env: SLUG_MODEL]
-p, --prompt <TEXT> Non-interactive single prompt
-c, --config <PATH> Config file path
--system-prompt <TEXT> Override system prompt
--yolo Skip all permission prompts
--sandbox <DIR> Auto-approve ops within directory
--allow-edits Auto-approve file edits in cwd
--continue Resume most recent session
--resume <ID> Resume specific session
--fork-session <ID> Fork session into new branch
Config file: ~/.slug/config.toml
Permission rules: ~/.slug/settings.json or .slug/settings.json
Hooks: .slug/hooks.json
SLUG.md files: ~/.slug/SLUG.md, ./SLUG.md, .slug/rules/*.md, ./SLUG.local.md
Sessions: ~/.slug/sessions/{id}.jsonl
What's Not Done Yet (Remaining Work)
High priority:
- Subagent parallelism — fork (shared cache), teammate (file mailbox), worktree (isolated branch)
- Ratatui TUI — proper terminal UI with markdown rendering, scrollback, syntax highlighting
- Retry with exponential backoff — 10 retries, jitter, model fallback on repeated 5xx
- Stream interruption — Escape key aborts active stream cleanly, context preserved
- Wire PreToolUse/PostToolUse hooks into agent::execute_with_permission
- Concurrent tool execution — read/glob/grep run in parallel, bash/write/edit serial
Medium priority:
- MCP client — connect to MCP servers, deferred tool loading
- Computer use (screenshot, click, type)
- Auto-compact trigger when context approaches limit
Low priority:
- Session search / conversation history browser
- Anthropic provider —
/v1/messagesformat, different streaming protocol - Google Gemini provider
Anti-Patterns — Never Do These
- No
unwrap()orexpect()in non-test code. - Never store SLUG.md content in
Agent.messages— it must be reloaded every turn. - Never add analytics, telemetry, or tracking of any kind.
- Never add Anthropic-internal features (feature flags, bridge mode, CCR, Kairos, etc.).
- Do not add backwards-compat shims — just change the code.
- Do not add error handling for impossible cases — trust internal invariants.
- Do not add abstractions for one-time use — three similar lines beat a premature helper.
- Tool errors should return
Err(...)— the agent catches them and turns them into tool result strings. Don't swallow errors silently. - Permission checks must happen in
agent::execute_with_permission— tools themselves are unaware of permissions.