Skip to main content

recursive/
lib.rs

1//! Recursive: a minimal, orthogonal, self-improving coding agent kernel.
2//!
3//! The kernel is intentionally tiny:
4//!   - `Message` is the only data primitive shared across the system.
5//!   - `ChatProvider` abstracts model backends (HTTP, mock, future local...).
6//!   - `Tool` abstracts side effects the model can request.
7//!   - `Agent` is a thin loop that wires them together.
8//!
9//! Everything else is opt-in. New capabilities are added by implementing
10//! `Tool` or `ChatProvider`, never by editing the loop.
11//!
12//! # Lint policy
13//!
14//! `unwrap()` / `expect()` are banned in production code (AGENTS.md invariant #5).
15//! Test code relaxes this via `#![cfg_attr(test, allow(...))]` below.
16//! Any remaining production exception carries `#[allow(..., reason = "...")]`.
17#![deny(clippy::unwrap_used, clippy::expect_used)]
18#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
19
20pub mod acp;
21pub mod agent;
22pub(crate) mod atomic;
23pub mod checkpoint;
24pub mod checkpoint_log;
25pub mod compact;
26pub mod config;
27pub mod config_file;
28pub mod coordinator;
29pub mod cost;
30pub mod error;
31pub mod event;
32pub mod hooks;
33#[cfg(feature = "http")]
34pub mod http;
35pub mod kernel;
36pub mod llm;
37pub mod logging;
38#[cfg(feature = "mcp")]
39pub mod mcp;
40#[cfg(feature = "mcp")]
41pub mod mcp_server;
42pub mod memory;
43pub mod message;
44pub mod migrate;
45pub mod multi;
46pub mod paths;
47pub mod permissions;
48pub mod providers;
49pub mod providers_cache;
50pub mod rewind;
51pub mod run_core;
52pub mod runtime;
53pub mod runtime_goal;
54pub mod session;
55pub mod skills;
56pub(crate) mod skills_injector;
57pub mod storage;
58pub mod system_prompt;
59pub mod tasks;
60#[cfg(feature = "coordinator-mode")]
61pub(crate) mod team;
62pub mod tool_set_provider;
63pub mod tools;
64pub mod transcript;
65#[cfg(feature = "weixin")]
66pub mod weixin;
67
68pub use agent::{FinishReason, PermissionDecision};
69pub use checkpoint::{CheckpointId, CheckpointInfo, RestoreStats, ShadowRepo};
70pub use checkpoint_log::{
71    read_log as read_checkpoint_log, truncate_to_turn as truncate_checkpoint_log,
72    CheckpointLogWriter, CheckpointRecord, TouchedVia,
73};
74pub use compact::reinject::{build_file_reinjector_from_env, build_skill_reinjector_from_env};
75pub use compact::{Compactor, FileReinjector, Microcompactor, SkillReinjector};
76pub use config::Config;
77pub use error::{Error, Result};
78pub use event::{AgentEvent, ChannelSink, CompositeSink, EventSink, NullSink};
79pub use hooks::ExternalHookRunner;
80pub use hooks::ToolTimingHook;
81pub use hooks::{Hook, HookAction, HookEvent, HookRegistry, SdkHookForwarder};
82pub use kernel::{AgentKernel, AgentKernelBuilder, TurnContext, TurnOutcome};
83pub use llm::{
84    context_window_tokens_for_model, default_compact_threshold_chars, estimate_tokens, pricing_for,
85    ChatProvider, Completion, ContextBreakdown, ModelPricing, RetryPolicy, TokenUsage, ToolCall,
86    ToolSpec,
87};
88#[cfg(feature = "mcp")]
89pub use mcp::{
90    discover_mcp_servers, load_mcp_config, new_elicitation_slot, ElicitationHandler,
91    ElicitationRequest, McpClient, McpPrompt, McpPromptArgument, McpPromptMessage, McpResource,
92    McpResourceContent, McpServer, McpServerConfig, McpTool, McpToolSpec, ServerCapabilities,
93    SharedElicitationHandler,
94};
95#[cfg(feature = "mcp")]
96pub use mcp_server::{McpServerManager, McpServerRunner};
97pub use message::{Message, Role};
98pub use migrate::{migrate_workspace, MigrateReport};
99pub use multi::{
100    register_subagent_if_enabled, AgentMessage, AgentPool, AgentRole, MessageBus, MessageType,
101    SharedMemory,
102};
103pub use paths::{
104    legacy_paths_in_workspace, user_data_dir, user_scratchpad_path, user_sessions_dir,
105    user_shadow_git_dir, user_workspace_dir, workspace_hash,
106};
107pub use permissions::auto_classifier::{AutoClassifier, DenialTracker};
108pub use permissions::PermissionMode;
109pub use permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
110pub use permissions::{RuleBehavior, SharedPermissions};
111pub use providers::{
112    all_presets, all_presets_dynamic, all_presets_effective, find_preset, find_preset_effective,
113    find_preset_extended, ModelSpec, ProviderPreset,
114};
115pub use rewind::{
116    apply_rewind, checkpoint_log_path, detect_conflicts, plan_rewind, ConflictReport, RewindPlan,
117    RewindResult,
118};
119pub use runtime::{AgentRuntime, AgentRuntimeBuilder, RuntimeOutcome};
120pub use session::OrphanToolCall;
121pub use session::SessionFile;
122pub use session::SessionPersistenceSink;
123pub use session::SessionReader;
124pub use session::SessionStatus;
125pub use session::SessionWriter;
126pub use session::{
127    entry_to_message, truncate_transcript_to_turn, SessionLock, SessionMeta, TranscriptEntry,
128    TruncateStats,
129};
130pub use skills::{
131    discover_skills, skill_index, skills_for_injection, Skill, SkillMode, SkillParam, SkillRef,
132    SkillScript, SkillSection,
133};
134#[cfg(feature = "cloud-runtime")]
135pub use storage::RedisSessionStore;
136#[cfg(feature = "cloud-runtime")]
137pub use storage::S3StorageBackend;
138pub use storage::{
139    AgentCheckpointState, LocalStorageBackend, NoopSessionStore, SessionStore, StorageBackend,
140};
141pub use system_prompt::{assemble_system_prompt, AssembledPrompt, PromptSegments};
142pub use tool_set_provider::{
143    LocalToolSetProvider, PolicyToolSetProvider, SandboxMode, ToolSetProvider,
144};
145pub use tools::policy_sandbox::{FsPolicy, PolicyConfig, ShellPolicy};
146pub use tools::PermissionHook;
147pub use tools::{
148    build_standard_tools, build_standard_tools_with_roots, new_shared_sandbox_roots, AccessTier,
149    AuditMeta, EnterPlanModeTool, ExitPlanModeTool, PlanApprovalGate, PlanApprovalResult,
150    PlanModeRequestGate, PlanModeRequestResult, ReadFileState, RequestPlanModeTool,
151    SharedSandboxRoots, TodoItem, TodoStatus, TodoWriteTool, Tool, ToolDispatch, ToolRegistry,
152    ToolSideEffect, TouchedFiles, AUDIT_ERR_MAX_BYTES,
153};
154pub use transcript::{TranscriptFile, TranscriptMeta};
155
156#[cfg(any(test, feature = "test-utils"))]
157pub mod test_util;
158
159/// Safely truncate a string to at most `max_bytes` bytes without splitting
160/// a multi-byte UTF-8 character. Returns the full string if it's already
161/// within the limit.
162#[inline]
163pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
164    if s.len() <= max_bytes {
165        return s;
166    }
167    let end = max_bytes.min(s.len());
168    &s[..rewind_to_char_boundary(s, end)]
169}
170
171/// Walk `end` back to a UTF-8 char boundary.
172///
173/// `end > 0` is behavior-equivalent to `end >= 0` for `usize` (index 0 is
174/// always a boundary), so the comparison mutant is skipped rather than
175/// chased with an unobservable test.
176#[cfg_attr(test, mutants::skip)]
177fn rewind_to_char_boundary(s: &str, mut end: usize) -> usize {
178    while end > 0 && !s.is_char_boundary(end) {
179        end -= 1;
180    }
181    end
182}
183
184#[cfg(test)]
185mod tests {
186    use super::truncate_str;
187
188    #[test]
189    fn truncate_str_short_string_returns_unchanged() {
190        assert_eq!(truncate_str("hello", 10), "hello");
191    }
192
193    #[test]
194    fn truncate_str_exact_length_returns_unchanged() {
195        assert_eq!(truncate_str("hello", 5), "hello");
196    }
197
198    #[test]
199    fn truncate_str_over_limit_returns_prefix() {
200        assert_eq!(truncate_str("hello world", 5), "hello");
201    }
202
203    #[test]
204    fn truncate_str_empty_string() {
205        assert_eq!(truncate_str("", 10), "");
206    }
207
208    #[test]
209    fn truncate_str_zero_max_returns_empty() {
210        assert_eq!(truncate_str("hello", 0), "");
211    }
212
213    #[test]
214    fn truncate_str_multibyte_does_not_split_char() {
215        // "日" is 3 bytes (UTF-8: 0xE6 0x97 0xA5)
216        // "日本語" = 9 bytes; truncating at 4 bytes must not split 0xE6 0x97 (non-boundary)
217        let s = "日本語";
218        let truncated = truncate_str(s, 4);
219        // Valid UTF-8: must be 3 bytes (one full "日") or 0 bytes
220        assert!(s.is_char_boundary(truncated.len()));
221        assert_eq!(truncated, "日");
222    }
223
224    #[test]
225    fn truncate_str_ascii_one_byte_over() {
226        assert_eq!(truncate_str("abcdef", 5), "abcde");
227    }
228}