Compare commits

..

1 Commits

Author SHA1 Message Date
Yeachan-Heo
cd01d0e387 Honor Claude config defaults across runtime sessions
The runtime now discovers both legacy and current Claude config files at
user and project scope, merges them in precedence order, and carries the
resolved model, permission mode, instruction files, and MCP server
configuration into session startup.

This keeps CLI defaults aligned with project policy and exposes configured
MCP tools without requiring manual flags.

Constraint: Must support both legacy .claude.json and current .claude/settings.json layouts
Constraint: Session startup must preserve CLI flag precedence over config defaults
Rejected: Read only project settings files | would ignore user-scoped defaults and MCP servers
Rejected: Delay MCP tool discovery until first tool call | model would not see configured MCP tools during planning
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep config precedence synchronized between prompt loading, session startup, and status reporting
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo test --workspace --all-features
Not-tested: Live remote MCP servers and interactive REPL session startup against external services
2026-04-01 00:36:32 +00:00
11 changed files with 663 additions and 1716 deletions

2
rust/Cargo.lock generated
View File

@@ -1431,12 +1431,10 @@ dependencies = [
name = "tools" name = "tools"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"api",
"reqwest", "reqwest",
"runtime", "runtime",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
] ]
[[package]] [[package]]

View File

@@ -102,13 +102,6 @@ cd rust
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace" cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
``` ```
Restrict enabled tools in an interactive session:
```bash
cd rust
cargo run -p rusty-claude-cli -- --allowedTools read,glob
```
### 2) REPL mode ### 2) REPL mode
Start the interactive shell: Start the interactive shell:
@@ -130,10 +123,6 @@ Inside the REPL, useful commands include:
/memory /memory
/config /config
/init /init
/diff
/version
/export notes.txt
/session list
/exit /exit
``` ```
@@ -180,10 +169,6 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
- `/config [env|hooks|model]` — inspect discovered Claude config - `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files - `/memory` — inspect loaded instruction memory files
- `/init` — create a starter `CLAUDE.md` - `/init` — create a starter `CLAUDE.md`
- `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL - `/exit` — leave the REPL
## Environment variables ## Environment variables

View File

@@ -392,52 +392,8 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
let Some(token_set) = load_saved_oauth_token()? else { let Some(token_set) = load_saved_oauth_token()? else {
return Ok(None); return Ok(None);
}; };
resolve_saved_oauth_token_set(config, token_set).map(Some)
}
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
where
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
{
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
api_key,
bearer_token,
}),
None => Ok(AuthSource::ApiKey(api_key)),
};
}
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(AuthSource::BearerToken(bearer_token));
}
let Some(token_set) = load_saved_oauth_token()? else {
return Err(ApiError::MissingApiKey);
};
if !oauth_token_is_expired(&token_set) { if !oauth_token_is_expired(&token_set) {
return Ok(AuthSource::BearerToken(token_set.access_token)); return Ok(Some(token_set));
}
if token_set.refresh_token.is_none() {
return Err(ApiError::ExpiredOAuthToken);
}
let Some(config) = load_oauth_config()? else {
return Err(ApiError::Auth(
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
));
};
Ok(AuthSource::from(resolve_saved_oauth_token_set(
&config, token_set,
)?))
}
fn resolve_saved_oauth_token_set(
config: &OAuthConfig,
token_set: OAuthTokenSet,
) -> Result<OAuthTokenSet, ApiError> {
if !oauth_token_is_expired(&token_set) {
return Ok(token_set);
} }
let Some(refresh_token) = token_set.refresh_token.clone() else { let Some(refresh_token) = token_set.refresh_token.clone() else {
return Err(ApiError::ExpiredOAuthToken); return Err(ApiError::ExpiredOAuthToken);
@@ -447,28 +403,18 @@ fn resolve_saved_oauth_token_set(
client client
.refresh_oauth_token( .refresh_oauth_token(
config, config,
&OAuthRefreshRequest::from_config( &OAuthRefreshRequest::from_config(config, refresh_token, Some(token_set.scopes)),
config,
refresh_token,
Some(token_set.scopes.clone()),
),
) )
.await .await
})?; })?;
let resolved = OAuthTokenSet {
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token.or(token_set.refresh_token),
expires_at: refreshed.expires_at,
scopes: refreshed.scopes,
};
save_oauth_credentials(&runtime::OAuthTokenSet { save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: resolved.access_token.clone(), access_token: refreshed.access_token.clone(),
refresh_token: resolved.refresh_token.clone(), refresh_token: refreshed.refresh_token.clone(),
expires_at: resolved.expires_at, expires_at: refreshed.expires_at,
scopes: resolved.scopes.clone(), scopes: refreshed.scopes.clone(),
}) })
.map_err(ApiError::from)?; .map_err(ApiError::from)?;
Ok(resolved) Ok(Some(refreshed))
} }
fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError> fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError>
@@ -625,8 +571,8 @@ mod tests {
use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig}; use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
use crate::client::{ use crate::client::{
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token, now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient,
resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet, AuthSource, OAuthTokenSet,
}; };
use crate::types::{ContentBlockDelta, MessageRequest}; use crate::types::{ContentBlockDelta, MessageRequest};
@@ -814,95 +760,6 @@ mod tests {
std::fs::remove_dir_all(config_home).expect("cleanup temp dir"); std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
} }
#[test]
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "saved-access-token".to_string(),
refresh_token: Some("refresh".to_string()),
expires_at: Some(now_unix_timestamp() + 300),
scopes: vec!["scope:a".to_string()],
})
.expect("save oauth credentials");
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
.expect("startup auth");
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test]
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(1),
scopes: vec!["scope:a".to_string()],
})
.expect("save expired oauth credentials");
let error =
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
assert!(
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
);
let stored = runtime::load_oauth_credentials()
.expect("load stored credentials")
.expect("stored token set");
assert_eq!(stored.access_token, "expired-access-token");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test]
fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(1),
scopes: vec!["scope:a".to_string()],
})
.expect("save expired oauth credentials");
let token_url = spawn_token_server(
"{\"access_token\":\"refreshed-token\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
);
let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
.expect("resolve refreshed token")
.expect("token set present");
assert_eq!(resolved.access_token, "refreshed-token");
assert_eq!(resolved.refresh_token.as_deref(), Some("refresh-token"));
let stored = runtime::load_oauth_credentials()
.expect("load stored credentials")
.expect("stored token set");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test] #[test]
fn message_request_stream_helper_sets_stream_true() { fn message_request_stream_helper_sets_stream_true() {
let request = MessageRequest { let request = MessageRequest {

View File

@@ -4,8 +4,8 @@ mod sse;
mod types; mod types;
pub use client::{ pub use client::{
oauth_token_is_expired, resolve_saved_oauth_token, resolve_startup_auth_source, oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient, AuthSource, MessageStream,
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet, OAuthTokenSet,
}; };
pub use error::ApiError; pub use error::ApiError;
pub use sse::{parse_frame, SseParser}; pub use sse::{parse_frame, SseParser};

View File

@@ -408,7 +408,7 @@ mod tests {
.sum::<i32>(); .sum::<i32>();
Ok(total.to_string()) Ok(total.to_string())
}); });
let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite); let permission_policy = PermissionPolicy::new(PermissionMode::Prompt);
let system_prompt = SystemPromptBuilder::new() let system_prompt = SystemPromptBuilder::new()
.with_project_context(ProjectContext { .with_project_context(ProjectContext {
cwd: PathBuf::from("/tmp/project"), cwd: PathBuf::from("/tmp/project"),
@@ -487,7 +487,7 @@ mod tests {
Session::new(), Session::new(),
SingleCallApiClient, SingleCallApiClient,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::WorkspaceWrite), PermissionPolicy::new(PermissionMode::Prompt),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -536,7 +536,7 @@ mod tests {
session, session,
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::DangerFullAccess), PermissionPolicy::new(PermissionMode::Allow),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -563,7 +563,7 @@ mod tests {
Session::new(), Session::new(),
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::DangerFullAccess), PermissionPolicy::new(PermissionMode::Allow),
vec!["system".to_string()], vec!["system".to_string()],
); );
runtime.run_turn("a", None).expect("turn a"); runtime.run_turn("a", None).expect("turn a");

View File

@@ -1,29 +1,16 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionMode { pub enum PermissionMode {
ReadOnly, Allow,
WorkspaceWrite, Deny,
DangerFullAccess, Prompt,
}
impl PermissionMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
}
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequest { pub struct PermissionRequest {
pub tool_name: String, pub tool_name: String,
pub input: String, pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -44,41 +31,31 @@ pub enum PermissionOutcome {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionPolicy { pub struct PermissionPolicy {
active_mode: PermissionMode, default_mode: PermissionMode,
tool_requirements: BTreeMap<String, PermissionMode>, tool_modes: BTreeMap<String, PermissionMode>,
} }
impl PermissionPolicy { impl PermissionPolicy {
#[must_use] #[must_use]
pub fn new(active_mode: PermissionMode) -> Self { pub fn new(default_mode: PermissionMode) -> Self {
Self { Self {
active_mode, default_mode,
tool_requirements: BTreeMap::new(), tool_modes: BTreeMap::new(),
} }
} }
#[must_use] #[must_use]
pub fn with_tool_requirement( pub fn with_tool_mode(mut self, tool_name: impl Into<String>, mode: PermissionMode) -> Self {
mut self, self.tool_modes.insert(tool_name.into(), mode);
tool_name: impl Into<String>,
required_mode: PermissionMode,
) -> Self {
self.tool_requirements
.insert(tool_name.into(), required_mode);
self self
} }
#[must_use] #[must_use]
pub fn active_mode(&self) -> PermissionMode { pub fn mode_for(&self, tool_name: &str) -> PermissionMode {
self.active_mode self.tool_modes
}
#[must_use]
pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode {
self.tool_requirements
.get(tool_name) .get(tool_name)
.copied() .copied()
.unwrap_or(PermissionMode::DangerFullAccess) .unwrap_or(self.default_mode)
} }
#[must_use] #[must_use]
@@ -88,43 +65,23 @@ impl PermissionPolicy {
input: &str, input: &str,
mut prompter: Option<&mut dyn PermissionPrompter>, mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome { ) -> PermissionOutcome {
let current_mode = self.active_mode(); match self.mode_for(tool_name) {
let required_mode = self.required_mode_for(tool_name); PermissionMode::Allow => PermissionOutcome::Allow,
if current_mode >= required_mode { PermissionMode::Deny => PermissionOutcome::Deny {
return PermissionOutcome::Allow; reason: format!("tool '{tool_name}' denied by permission policy"),
} },
PermissionMode::Prompt => match prompter.as_mut() {
let request = PermissionRequest { Some(prompter) => match prompter.decide(&PermissionRequest {
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
input: input.to_string(), input: input.to_string(),
current_mode, }) {
required_mode,
};
if current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess
{
return match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow, PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason }, PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
}, },
None => PermissionOutcome::Deny { None => PermissionOutcome::Deny {
reason: format!( reason: format!("tool '{tool_name}' requires interactive approval"),
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
),
}, },
}; },
}
PermissionOutcome::Deny {
reason: format!(
"tool '{tool_name}' requires {} permission; current mode is {}",
required_mode.as_str(),
current_mode.as_str()
),
} }
} }
} }
@@ -136,92 +93,25 @@ mod tests {
PermissionPrompter, PermissionRequest, PermissionPrompter, PermissionRequest,
}; };
struct RecordingPrompter { struct AllowPrompter;
seen: Vec<PermissionRequest>,
allow: bool,
}
impl PermissionPrompter for RecordingPrompter { impl PermissionPrompter for AllowPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision { fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
self.seen.push(request.clone()); assert_eq!(request.tool_name, "bash");
if self.allow { PermissionPromptDecision::Allow
PermissionPromptDecision::Allow
} else {
PermissionPromptDecision::Deny {
reason: "not now".to_string(),
}
}
} }
} }
#[test] #[test]
fn allows_tools_when_active_mode_meets_requirement() { fn uses_tool_specific_overrides() {
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) let policy = PermissionPolicy::new(PermissionMode::Deny)
.with_tool_requirement("read_file", PermissionMode::ReadOnly) .with_tool_mode("bash", PermissionMode::Prompt);
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
assert_eq!(
policy.authorize("read_file", "{}", None),
PermissionOutcome::Allow
);
assert_eq!(
policy.authorize("write_file", "{}", None),
PermissionOutcome::Allow
);
}
#[test]
fn denies_read_only_escalations_without_prompt() {
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
assert!(matches!(
policy.authorize("write_file", "{}", None),
PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission")
));
assert!(matches!(
policy.authorize("bash", "{}", None),
PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission")
));
}
#[test]
fn prompts_for_workspace_write_to_danger_full_access_escalation() {
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter));
let outcome = policy.authorize("bash", "echo hi", Some(&mut AllowPrompter));
assert_eq!(outcome, PermissionOutcome::Allow); assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert_eq!(prompter.seen[0].tool_name, "bash");
assert_eq!(
prompter.seen[0].current_mode,
PermissionMode::WorkspaceWrite
);
assert_eq!(
prompter.seen[0].required_mode,
PermissionMode::DangerFullAccess
);
}
#[test]
fn honors_prompt_rejection_reason() {
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: false,
};
assert!(matches!( assert!(matches!(
policy.authorize("bash", "echo hi", Some(&mut prompter)), policy.authorize("edit", "x", None),
PermissionOutcome::Deny { reason } if reason == "not now" PermissionOutcome::Deny { .. }
)); ));
} }
} }

View File

@@ -2,7 +2,7 @@ use std::io::{self, Write};
use std::path::PathBuf; use std::path::PathBuf;
use crate::args::{OutputFormat, PermissionMode}; use crate::args::{OutputFormat, PermissionMode};
use crate::input::{LineEditor, ReadOutcome}; use crate::input::LineEditor;
use crate::render::{Spinner, TerminalRenderer}; use crate::render::{Spinner, TerminalRenderer};
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary}; use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
@@ -111,21 +111,16 @@ impl CliApp {
} }
pub fn run_repl(&mut self) -> io::Result<()> { pub fn run_repl(&mut self) -> io::Result<()> {
let mut editor = LineEditor::new(" ", Vec::new()); let editor = LineEditor::new(" ");
println!("Rusty Claude CLI interactive mode"); println!("Rusty Claude CLI interactive mode");
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline."); println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
loop { while let Some(input) = editor.read_line()? {
match editor.read_line()? { if input.trim().is_empty() {
ReadOutcome::Submit(input) => { continue;
if input.trim().is_empty() {
continue;
}
self.handle_submission(&input, &mut io::stdout())?;
}
ReadOutcome::Cancel => continue,
ReadOutcome::Exit => break,
} }
self.handle_submission(&input, &mut io::stdout())?;
} }
Ok(()) Ok(())

View File

@@ -1,8 +1,9 @@
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp}; use crossterm::cursor::MoveToColumn;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::queue; use crossterm::queue;
use crossterm::style::Print;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -84,124 +85,21 @@ impl InputBuffer {
self.buffer.clear(); self.buffer.clear();
self.cursor = 0; self.cursor = 0;
} }
pub fn replace(&mut self, value: impl Into<String>) {
self.buffer = value.into();
self.cursor = self.buffer.len();
}
#[must_use]
fn current_command_prefix(&self) -> Option<&str> {
if self.cursor != self.buffer.len() {
return None;
}
let prefix = &self.buffer[..self.cursor];
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
return None;
}
Some(prefix)
}
pub fn complete_slash_command(&mut self, candidates: &[String]) -> bool {
let Some(prefix) = self.current_command_prefix() else {
return false;
};
let matches = candidates
.iter()
.filter(|candidate| candidate.starts_with(prefix))
.map(String::as_str)
.collect::<Vec<_>>();
if matches.is_empty() {
return false;
}
let replacement = longest_common_prefix(&matches);
if replacement == prefix {
return false;
}
self.replace(replacement);
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedBuffer {
lines: Vec<String>,
cursor_row: u16,
cursor_col: u16,
}
impl RenderedBuffer {
#[must_use]
pub fn line_count(&self) -> usize {
self.lines.len()
}
fn write(&self, out: &mut impl Write) -> io::Result<()> {
for (index, line) in self.lines.iter().enumerate() {
if index > 0 {
writeln!(out)?;
}
write!(out, "{line}")?;
}
Ok(())
}
#[cfg(test)]
#[must_use]
pub fn lines(&self) -> &[String] {
&self.lines
}
#[cfg(test)]
#[must_use]
pub fn cursor_position(&self) -> (u16, u16) {
(self.cursor_row, self.cursor_col)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadOutcome {
Submit(String),
Cancel,
Exit,
} }
pub struct LineEditor { pub struct LineEditor {
prompt: String, prompt: String,
continuation_prompt: String,
history: Vec<String>,
history_index: Option<usize>,
draft: Option<String>,
completions: Vec<String>,
} }
impl LineEditor { impl LineEditor {
#[must_use] #[must_use]
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self { pub fn new(prompt: impl Into<String>) -> Self {
Self { Self {
prompt: prompt.into(), prompt: prompt.into(),
continuation_prompt: String::from("> "),
history: Vec::new(),
history_index: None,
draft: None,
completions,
} }
} }
pub fn push_history(&mut self, entry: impl Into<String>) { pub fn read_line(&self) -> io::Result<Option<String>> {
let entry = entry.into();
if entry.trim().is_empty() {
return;
}
self.history.push(entry);
self.history_index = None;
self.draft = None;
}
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() { if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return self.read_line_fallback(); return self.read_line_fallback();
} }
@@ -209,43 +107,29 @@ impl LineEditor {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
let mut input = InputBuffer::new(); let mut input = InputBuffer::new();
let mut rendered_lines = 1usize; self.redraw(&mut stdout, &input)?;
self.redraw(&mut stdout, &input, rendered_lines)?;
loop { loop {
let event = event::read()?; let event = event::read()?;
if let Event::Key(key) = event { if let Event::Key(key) = event {
match self.handle_key(key, &mut input) { match Self::handle_key(key, &mut input) {
EditorAction::Continue => { EditorAction::Continue => self.redraw(&mut stdout, &input)?,
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
}
EditorAction::Submit => { EditorAction::Submit => {
disable_raw_mode()?; disable_raw_mode()?;
writeln!(stdout)?; writeln!(stdout)?;
self.history_index = None; return Ok(Some(input.as_str().to_owned()));
self.draft = None;
return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
} }
EditorAction::Cancel => { EditorAction::Cancel => {
disable_raw_mode()?; disable_raw_mode()?;
writeln!(stdout)?; writeln!(stdout)?;
self.history_index = None; return Ok(None);
self.draft = None;
return Ok(ReadOutcome::Cancel);
}
EditorAction::Exit => {
disable_raw_mode()?;
writeln!(stdout)?;
self.history_index = None;
self.draft = None;
return Ok(ReadOutcome::Exit);
} }
} }
} }
} }
} }
fn read_line_fallback(&self) -> io::Result<ReadOutcome> { fn read_line_fallback(&self) -> io::Result<Option<String>> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
write!(stdout, "{}", self.prompt)?; write!(stdout, "{}", self.prompt)?;
stdout.flush()?; stdout.flush()?;
@@ -253,32 +137,22 @@ impl LineEditor {
let mut buffer = String::new(); let mut buffer = String::new();
let bytes_read = io::stdin().read_line(&mut buffer)?; let bytes_read = io::stdin().read_line(&mut buffer)?;
if bytes_read == 0 { if bytes_read == 0 {
return Ok(ReadOutcome::Exit); return Ok(None);
} }
while matches!(buffer.chars().last(), Some('\n' | '\r')) { while matches!(buffer.chars().last(), Some('\n' | '\r')) {
buffer.pop(); buffer.pop();
} }
Ok(ReadOutcome::Submit(buffer)) Ok(Some(buffer))
} }
#[allow(clippy::too_many_lines)] fn handle_key(key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
match key { match key {
KeyEvent { KeyEvent {
code: KeyCode::Char('c'), code: KeyCode::Char('c'),
modifiers, modifiers,
.. ..
} if modifiers.contains(KeyModifiers::CONTROL) => { } if modifiers.contains(KeyModifiers::CONTROL) => EditorAction::Cancel,
if input.as_str().is_empty() {
EditorAction::Exit
} else {
input.clear();
self.history_index = None;
self.draft = None;
EditorAction::Cancel
}
}
KeyEvent { KeyEvent {
code: KeyCode::Char('j'), code: KeyCode::Char('j'),
modifiers, modifiers,
@@ -320,25 +194,6 @@ impl LineEditor {
input.move_right(); input.move_right();
EditorAction::Continue EditorAction::Continue
} }
KeyEvent {
code: KeyCode::Up, ..
} => {
self.navigate_history_up(input);
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Down,
..
} => {
self.navigate_history_down(input);
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
input.complete_slash_command(&self.completions);
EditorAction::Continue
}
KeyEvent { KeyEvent {
code: KeyCode::Home, code: KeyCode::Home,
.. ..
@@ -356,8 +211,6 @@ impl LineEditor {
code: KeyCode::Esc, .. code: KeyCode::Esc, ..
} => { } => {
input.clear(); input.clear();
self.history_index = None;
self.draft = None;
EditorAction::Cancel EditorAction::Cancel
} }
KeyEvent { KeyEvent {
@@ -366,74 +219,22 @@ impl LineEditor {
.. ..
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => { } if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
input.insert(ch); input.insert(ch);
self.history_index = None;
self.draft = None;
EditorAction::Continue EditorAction::Continue
} }
_ => EditorAction::Continue, _ => EditorAction::Continue,
} }
} }
fn navigate_history_up(&mut self, input: &mut InputBuffer) { fn redraw(&self, out: &mut impl Write, input: &InputBuffer) -> io::Result<()> {
if self.history.is_empty() { let display = input.as_str().replace('\n', "\\n\n> ");
return;
}
match self.history_index {
Some(0) => {}
Some(index) => {
let next_index = index - 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
}
None => {
self.draft = Some(input.as_str().to_owned());
let next_index = self.history.len() - 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
}
}
}
fn navigate_history_down(&mut self, input: &mut InputBuffer) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.history.len() {
let next_index = index + 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
return;
}
input.replace(self.draft.take().unwrap_or_default());
self.history_index = None;
}
fn redraw(
&self,
out: &mut impl Write,
input: &InputBuffer,
previous_line_count: usize,
) -> io::Result<usize> {
let rendered = render_buffer(&self.prompt, &self.continuation_prompt, input);
if previous_line_count > 1 {
queue!(out, MoveUp(saturating_u16(previous_line_count - 1)))?;
}
queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown),)?;
rendered.write(out)?;
queue!( queue!(
out, out,
MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
MoveToColumn(0), MoveToColumn(0),
Clear(ClearType::CurrentLine),
Print(&self.prompt),
Print(display),
)?; )?;
if rendered.cursor_row > 0 { out.flush()
queue!(out, MoveDown(rendered.cursor_row))?;
}
queue!(out, MoveToColumn(rendered.cursor_col))?;
out.flush()?;
Ok(rendered.line_count())
} }
} }
@@ -442,76 +243,11 @@ enum EditorAction {
Continue, Continue,
Submit, Submit,
Cancel, Cancel,
Exit,
}
#[must_use]
pub fn render_buffer(
prompt: &str,
continuation_prompt: &str,
input: &InputBuffer,
) -> RenderedBuffer {
let before_cursor = &input.as_str()[..input.cursor];
let cursor_row = saturating_u16(before_cursor.chars().filter(|ch| *ch == '\n').count());
let cursor_line = before_cursor.rsplit('\n').next().unwrap_or_default();
let cursor_prompt = if cursor_row == 0 {
prompt
} else {
continuation_prompt
};
let cursor_col = saturating_u16(cursor_prompt.chars().count() + cursor_line.chars().count());
let mut lines = Vec::new();
for (index, line) in input.as_str().split('\n').enumerate() {
let prefix = if index == 0 {
prompt
} else {
continuation_prompt
};
lines.push(format!("{prefix}{line}"));
}
if lines.is_empty() {
lines.push(prompt.to_string());
}
RenderedBuffer {
lines,
cursor_row,
cursor_col,
}
}
#[must_use]
fn longest_common_prefix(values: &[&str]) -> String {
let Some(first) = values.first() else {
return String::new();
};
let mut prefix = (*first).to_string();
for value in values.iter().skip(1) {
while !value.starts_with(&prefix) {
prefix.pop();
if prefix.is_empty() {
break;
}
}
}
prefix
}
#[must_use]
fn saturating_u16(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{render_buffer, InputBuffer, LineEditor}; use super::InputBuffer;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test] #[test]
fn supports_basic_line_editing() { fn supports_basic_line_editing() {
@@ -530,119 +266,4 @@ mod tests {
assert_eq!(input.as_str(), "hix"); assert_eq!(input.as_str(), "hix");
assert_eq!(input.cursor(), 2); assert_eq!(input.cursor(), 2);
} }
#[test]
fn completes_unique_slash_command() {
let mut input = InputBuffer::new();
for ch in "/he".chars() {
input.insert(ch);
}
assert!(input.complete_slash_command(&[
"/help".to_string(),
"/hello".to_string(),
"/status".to_string(),
]));
assert_eq!(input.as_str(), "/hel");
assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()]));
assert_eq!(input.as_str(), "/help");
}
#[test]
fn ignores_completion_when_prefix_is_not_a_slash_command() {
let mut input = InputBuffer::new();
for ch in "hello".chars() {
input.insert(ch);
}
assert!(!input.complete_slash_command(&["/help".to_string()]));
assert_eq!(input.as_str(), "hello");
}
#[test]
fn history_navigation_restores_current_draft() {
let mut editor = LineEditor::new(" ", vec![]);
editor.push_history("/help");
editor.push_history("status report");
let mut input = InputBuffer::new();
for ch in "draft".chars() {
input.insert(ch);
}
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
assert_eq!(input.as_str(), "status report");
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
assert_eq!(input.as_str(), "/help");
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
assert_eq!(input.as_str(), "status report");
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
assert_eq!(input.as_str(), "draft");
}
#[test]
fn tab_key_completes_from_editor_candidates() {
let mut editor = LineEditor::new(
" ",
vec![
"/help".to_string(),
"/status".to_string(),
"/session".to_string(),
],
);
let mut input = InputBuffer::new();
for ch in "/st".chars() {
input.insert(ch);
}
let _ = editor.handle_key(key(KeyCode::Tab), &mut input);
assert_eq!(input.as_str(), "/status");
}
#[test]
fn renders_multiline_buffers_with_continuation_prompt() {
let mut input = InputBuffer::new();
for ch in "hello\nworld".chars() {
if ch == '\n' {
input.insert_newline();
} else {
input.insert(ch);
}
}
let rendered = render_buffer(" ", "> ", &input);
assert_eq!(
rendered.lines(),
&[" hello".to_string(), "> world".to_string()]
);
assert_eq!(rendered.cursor_position(), (1, 7));
}
#[test]
fn ctrl_c_exits_only_when_buffer_is_empty() {
let mut editor = LineEditor::new(" ", vec![]);
let mut empty = InputBuffer::new();
assert!(matches!(
editor.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut empty,
),
super::EditorAction::Exit
));
let mut filled = InputBuffer::new();
filled.insert('x');
assert!(matches!(
editor.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut filled,
),
super::EditorAction::Cancel
));
assert!(filled.as_str().is_empty());
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,10 @@ license.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
api = { path = "../api" }
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread"] }
[lints] [lints]
workspace = true workspace = true

View File

@@ -3,17 +3,10 @@ use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use api::{
resolve_startup_auth_source, AnthropicClient, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
ToolDefinition, ToolResultContentBlock,
};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use runtime::{ use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock, GrepSearchInput,
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -52,7 +45,6 @@ pub struct ToolSpec {
pub name: &'static str, pub name: &'static str,
pub description: &'static str, pub description: &'static str,
pub input_schema: Value, pub input_schema: Value,
pub required_permission: PermissionMode,
} }
#[must_use] #[must_use]
@@ -74,7 +66,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["command"], "required": ["command"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::DangerFullAccess,
}, },
ToolSpec { ToolSpec {
name: "read_file", name: "read_file",
@@ -89,7 +80,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["path"], "required": ["path"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "write_file", name: "write_file",
@@ -103,7 +93,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["path", "content"], "required": ["path", "content"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "edit_file", name: "edit_file",
@@ -119,7 +108,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["path", "old_string", "new_string"], "required": ["path", "old_string", "new_string"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "glob_search", name: "glob_search",
@@ -133,7 +121,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["pattern"], "required": ["pattern"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "grep_search", name: "grep_search",
@@ -159,7 +146,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["pattern"], "required": ["pattern"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "WebFetch", name: "WebFetch",
@@ -174,7 +160,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["url", "prompt"], "required": ["url", "prompt"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "WebSearch", name: "WebSearch",
@@ -195,7 +180,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["query"], "required": ["query"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "TodoWrite", name: "TodoWrite",
@@ -223,7 +207,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["todos"], "required": ["todos"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "Skill", name: "Skill",
@@ -237,12 +220,10 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["skill"], "required": ["skill"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "Agent", name: "Agent",
description: description: "Launch a specialized agent task and persist its handoff metadata.",
"Launch and execute a specialized child agent conversation with bounded recursion.",
input_schema: json!({ input_schema: json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -250,13 +231,11 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"prompt": { "type": "string" }, "prompt": { "type": "string" },
"subagent_type": { "type": "string" }, "subagent_type": { "type": "string" },
"name": { "type": "string" }, "name": { "type": "string" },
"model": { "type": "string" }, "model": { "type": "string" }
"max_depth": { "type": "integer", "minimum": 0 }
}, },
"required": ["description", "prompt"], "required": ["description", "prompt"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::DangerFullAccess,
}, },
ToolSpec { ToolSpec {
name: "ToolSearch", name: "ToolSearch",
@@ -270,7 +249,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["query"], "required": ["query"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "NotebookEdit", name: "NotebookEdit",
@@ -287,7 +265,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["notebook_path"], "required": ["notebook_path"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "Sleep", name: "Sleep",
@@ -300,7 +277,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["duration_ms"], "required": ["duration_ms"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "SendUserMessage", name: "SendUserMessage",
@@ -321,7 +297,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["message", "status"], "required": ["message", "status"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "Config", name: "Config",
@@ -337,7 +312,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["setting"], "required": ["setting"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "StructuredOutput", name: "StructuredOutput",
@@ -346,7 +320,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"type": "object", "type": "object",
"additionalProperties": true "additionalProperties": true
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "REPL", name: "REPL",
@@ -361,7 +334,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["code", "language"], "required": ["code", "language"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::DangerFullAccess,
}, },
ToolSpec { ToolSpec {
name: "PowerShell", name: "PowerShell",
@@ -377,7 +349,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["command"], "required": ["command"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::DangerFullAccess,
}, },
] ]
} }
@@ -588,7 +559,6 @@ struct AgentInput {
subagent_type: Option<String>, subagent_type: Option<String>,
name: Option<String>, name: Option<String>,
model: Option<String>, model: Option<String>,
max_depth: Option<usize>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -722,16 +692,6 @@ struct AgentOutput {
subagent_type: Option<String>, subagent_type: Option<String>,
model: Option<String>, model: Option<String>,
status: String, status: String,
#[serde(rename = "maxDepth")]
max_depth: usize,
#[serde(rename = "depth")]
depth: usize,
#[serde(rename = "result")]
result: Option<String>,
#[serde(rename = "assistantMessages")]
assistant_messages: Vec<String>,
#[serde(rename = "toolResults")]
tool_results: Vec<AgentToolResult>,
#[serde(rename = "outputFile")] #[serde(rename = "outputFile")]
output_file: String, output_file: String,
#[serde(rename = "manifestFile")] #[serde(rename = "manifestFile")]
@@ -740,15 +700,6 @@ struct AgentOutput {
created_at: String, created_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AgentToolResult {
#[serde(rename = "toolName")]
tool_name: String,
output: String,
#[serde(rename = "isError")]
is_error: bool,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct ToolSearchOutput { struct ToolSearchOutput {
matches: Vec<String>, matches: Vec<String>,
@@ -1360,14 +1311,6 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
return Err(String::from("prompt must not be empty")); return Err(String::from("prompt must not be empty"));
} }
let depth = current_agent_depth()?;
let max_depth = input.max_depth.unwrap_or(3);
if depth >= max_depth {
return Err(format!(
"Agent max_depth exceeded: current depth {depth} reached limit {max_depth}"
));
}
let agent_id = make_agent_id(); let agent_id = make_agent_id();
let output_dir = agent_store_dir()?; let output_dir = agent_store_dir()?;
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
@@ -1381,31 +1324,35 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
.filter(|name| !name.is_empty()) .filter(|name| !name.is_empty())
.unwrap_or_else(|| slugify_agent_name(&input.description)); .unwrap_or_else(|| slugify_agent_name(&input.description));
let created_at = iso8601_now(); let created_at = iso8601_now();
let model = input.model.clone().or_else(agent_default_model);
let child_result = with_agent_depth(depth + 1, || { let output_contents = format!(
run_child_agent_conversation(&input.prompt, model.clone(), max_depth) "# Agent Task
})?;
- id: {}
- name: {}
- description: {}
- subagent_type: {}
- created_at: {}
## Prompt
{}
",
agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
);
std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
let manifest = AgentOutput { let manifest = AgentOutput {
agent_id, agent_id,
name: agent_name, name: agent_name,
description: input.description, description: input.description,
subagent_type: Some(normalized_subagent_type), subagent_type: Some(normalized_subagent_type),
model, model: input.model,
status: String::from("completed"), status: String::from("queued"),
max_depth,
depth,
result: child_result.result.clone(),
assistant_messages: child_result.assistant_messages.clone(),
tool_results: child_result.tool_results.clone(),
output_file: output_file.display().to_string(), output_file: output_file.display().to_string(),
manifest_file: manifest_file.display().to_string(), manifest_file: manifest_file.display().to_string(),
created_at, created_at,
}; };
let output_contents = render_agent_output(&manifest);
std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
std::fs::write( std::fs::write(
&manifest_file, &manifest_file,
serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?, serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?,
@@ -1415,461 +1362,6 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
Ok(manifest) Ok(manifest)
} }
#[derive(Debug, Clone)]
struct ChildConversationResult {
result: Option<String>,
assistant_messages: Vec<String>,
tool_results: Vec<AgentToolResult>,
}
fn run_child_agent_conversation(
prompt: &str,
model: Option<String>,
_max_depth: usize,
) -> Result<ChildConversationResult, String> {
let mut runtime = ConversationRuntime::new(
Session::new(),
build_agent_api_client(model.unwrap_or_else(default_agent_model))?,
AgentToolExecutor,
agent_permission_policy(),
build_agent_system_prompt()?,
)
.with_max_iterations(16);
let summary = runtime
.run_turn(prompt, None)
.map_err(|error| error.to_string())?;
let assistant_messages = summary
.assistant_messages
.iter()
.filter_map(extract_message_text)
.collect::<Vec<_>>();
let tool_results = summary
.tool_results
.iter()
.filter_map(extract_agent_tool_result)
.collect::<Vec<_>>();
let result = assistant_messages.last().cloned();
Ok(ChildConversationResult {
result,
assistant_messages,
tool_results,
})
}
fn render_agent_output(output: &AgentOutput) -> String {
let mut lines = vec![
"# Agent Task".to_string(),
String::new(),
format!("- id: {}", output.agent_id),
format!("- name: {}", output.name),
format!("- description: {}", output.description),
format!(
"- subagent_type: {}",
output.subagent_type.as_deref().unwrap_or("general-purpose")
),
format!("- status: {}", output.status),
format!("- depth: {}", output.depth),
format!("- max_depth: {}", output.max_depth),
format!("- created_at: {}", output.created_at),
String::new(),
"## Result".to_string(),
String::new(),
output
.result
.clone()
.unwrap_or_else(|| String::from("<no final assistant text>")),
];
if !output.tool_results.is_empty() {
lines.push(String::new());
lines.push("## Tool Results".to_string());
lines.push(String::new());
lines.extend(output.tool_results.iter().map(|result| {
format!(
"- {} [{}]: {}",
result.tool_name,
if result.is_error { "error" } else { "ok" },
result.output
)
}));
}
lines.join("\n")
}
fn current_agent_depth() -> Result<usize, String> {
std::env::var("CLAWD_AGENT_DEPTH")
.ok()
.map(|value| {
value
.parse::<usize>()
.map_err(|error| format!("invalid CLAWD_AGENT_DEPTH: {error}"))
})
.transpose()
.map(|value| value.unwrap_or(0))
}
fn with_agent_depth<T>(depth: usize, f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
let previous = std::env::var("CLAWD_AGENT_DEPTH").ok();
std::env::set_var("CLAWD_AGENT_DEPTH", depth.to_string());
let result = f();
if let Some(previous) = previous {
std::env::set_var("CLAWD_AGENT_DEPTH", previous);
} else {
std::env::remove_var("CLAWD_AGENT_DEPTH");
}
result
}
fn agent_default_model() -> Option<String> {
std::env::var("CLAWD_MODEL")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn default_agent_model() -> String {
agent_default_model().unwrap_or_else(|| String::from("claude-sonnet-4-20250514"))
}
fn build_agent_system_prompt() -> Result<Vec<String>, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let date = std::env::var("CLAWD_CURRENT_DATE").unwrap_or_else(|_| String::from("2026-04-01"));
load_system_prompt(cwd, &date, std::env::consts::OS, "unknown")
.map_err(|error| error.to_string())
}
fn agent_permission_policy() -> PermissionPolicy {
mvp_tool_specs().into_iter().fold(
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
)
}
struct AgentToolExecutor;
impl ToolExecutor for AgentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
execute_tool(tool_name, &value).map_err(ToolError::new)
}
}
enum AgentApiClient {
Scripted(ScriptedAgentApiClient),
Anthropic(AnthropicAgentApiClient),
}
impl ApiClient for AgentApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
match self {
Self::Scripted(client) => client.stream(request),
Self::Anthropic(client) => client.stream(request),
}
}
}
fn build_agent_api_client(model: String) -> Result<AgentApiClient, String> {
if let Some(script) = std::env::var("CLAWD_AGENT_TEST_SCRIPT")
.ok()
.filter(|value| !value.trim().is_empty())
{
return Ok(AgentApiClient::Scripted(ScriptedAgentApiClient::new(
&script,
)?));
}
Ok(AgentApiClient::Anthropic(AnthropicAgentApiClient::new(
model,
)?))
}
struct AnthropicAgentApiClient {
runtime: tokio::runtime::Runtime,
client: AnthropicClient,
model: String,
}
impl AnthropicAgentApiClient {
fn new(model: String) -> Result<Self, String> {
Ok(Self {
runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
client: AnthropicClient::from_auth(resolve_agent_auth_source()?),
model,
})
}
}
impl ApiClient for AnthropicAgentApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: 32,
messages: convert_agent_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| {
request.system_prompt.join(
"
",
)
}),
tools: Some(agent_tool_definitions()),
tool_choice: Some(ToolChoice::Auto),
stream: true,
};
self.runtime.block_on(async {
let mut stream = self
.client
.stream_message(&message_request)
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None;
let mut saw_stop = false;
while let Some(event) = stream
.next_event()
.await
.map_err(|error| RuntimeError::new(error.to_string()))?
{
match event {
ApiStreamEvent::MessageStart(start) => {
push_agent_output_blocks(
start.message.content,
&mut events,
&mut pending_tool,
);
}
ApiStreamEvent::ContentBlockStart(start) => {
push_agent_output_block(
start.content_block,
&mut events,
&mut pending_tool,
);
}
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
ContentBlockDelta::TextDelta { text } => {
if !text.is_empty() {
events.push(AssistantEvent::TextDelta(text));
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = &mut pending_tool {
input.push_str(&partial_json);
}
}
},
ApiStreamEvent::ContentBlockStop(_) => {
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: delta.usage.input_tokens,
output_tokens: delta.usage.output_tokens,
cache_creation_input_tokens: delta.usage.cache_creation_input_tokens,
cache_read_input_tokens: delta.usage.cache_read_input_tokens,
}));
}
ApiStreamEvent::MessageStop(_) => {
saw_stop = true;
events.push(AssistantEvent::MessageStop);
}
}
}
if !saw_stop {
events.push(AssistantEvent::MessageStop);
}
Ok(events)
})
}
}
fn resolve_agent_auth_source() -> Result<api::AuthSource, String> {
resolve_startup_auth_source(|| {
let cwd = std::env::current_dir().map_err(api::ApiError::from)?;
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
})?;
Ok(config.oauth().cloned())
})
.map_err(|error| error.to_string())
}
fn agent_tool_definitions() -> Vec<ToolDefinition> {
mvp_tool_specs()
.into_iter()
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
})
.collect()
}
fn convert_agent_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
messages
.iter()
.filter_map(|message| {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content = message
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
content,
})
})
.collect()
}
fn push_agent_output_blocks(
blocks: Vec<OutputContentBlock>,
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
) {
for block in blocks {
push_agent_output_block(block, events, pending_tool);
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
}
fn push_agent_output_block(
block: OutputContentBlock,
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
) {
match block {
OutputContentBlock::Text { text } => {
if !text.is_empty() {
events.push(AssistantEvent::TextDelta(text));
}
}
OutputContentBlock::ToolUse { id, name, input } => {
*pending_tool = Some((id, name, input.to_string()));
}
}
}
#[derive(Debug)]
struct ScriptedAgentApiClient {
turns: Vec<Vec<ScriptedAgentEvent>>,
call_count: usize,
}
impl ScriptedAgentApiClient {
fn new(script: &str) -> Result<Self, String> {
let turns = serde_json::from_str(script).map_err(|error| error.to_string())?;
Ok(Self {
turns,
call_count: 0,
})
}
}
impl ApiClient for ScriptedAgentApiClient {
fn stream(&mut self, _request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
if self.call_count >= self.turns.len() {
return Err(RuntimeError::new("scripted agent client exhausted"));
}
let events = self.turns[self.call_count]
.iter()
.map(ScriptedAgentEvent::to_runtime_event)
.chain(std::iter::once(AssistantEvent::MessageStop))
.collect();
self.call_count += 1;
Ok(events)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ScriptedAgentEvent {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: Value,
},
}
impl ScriptedAgentEvent {
fn to_runtime_event(&self) -> AssistantEvent {
match self {
Self::Text { text } => AssistantEvent::TextDelta(text.clone()),
Self::ToolUse { id, name, input } => AssistantEvent::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.to_string(),
},
}
}
}
fn extract_message_text(message: &ConversationMessage) -> Option<String> {
let text = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<String>();
(!text.is_empty()).then_some(text)
}
fn extract_agent_tool_result(message: &ConversationMessage) -> Option<AgentToolResult> {
message.blocks.iter().find_map(|block| match block {
ContentBlock::ToolResult {
tool_name,
output,
is_error,
..
} => Some(AgentToolResult {
tool_name: tool_name.clone(),
output: output.clone(),
is_error: *is_error,
}),
_ => None,
})
}
#[allow(clippy::needless_pass_by_value)] #[allow(clippy::needless_pass_by_value)]
fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput { fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
let deferred = deferred_tool_specs(); let deferred = deferred_tool_specs();
@@ -3251,28 +2743,12 @@ mod tests {
} }
#[test] #[test]
fn agent_executes_child_conversation_and_persists_results() { fn agent_persists_handoff_metadata() {
let _guard = env_lock() let _guard = env_lock()
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner); .unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = temp_path("agent-store"); let dir = temp_path("agent-store");
std::env::set_var("CLAWD_AGENT_STORE", &dir); std::env::set_var("CLAWD_AGENT_STORE", &dir);
std::env::set_var(
"CLAWD_AGENT_TEST_SCRIPT",
serde_json::to_string(&vec![
vec![json!({
"type": "tool_use",
"id": "tool-1",
"name": "StructuredOutput",
"input": {"ok": true, "items": [1, 2, 3]}
})],
vec![json!({
"type": "text",
"text": "Child agent completed successfully."
})],
])
.expect("script json"),
);
let result = execute_tool( let result = execute_tool(
"Agent", "Agent",
@@ -3284,35 +2760,22 @@ mod tests {
}), }),
) )
.expect("Agent should succeed"); .expect("Agent should succeed");
std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
std::env::remove_var("CLAWD_AGENT_STORE"); std::env::remove_var("CLAWD_AGENT_STORE");
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
assert_eq!(output["name"], "ship-audit"); assert_eq!(output["name"], "ship-audit");
assert_eq!(output["subagentType"], "Explore"); assert_eq!(output["subagentType"], "Explore");
assert_eq!(output["status"], "completed"); assert_eq!(output["status"], "queued");
assert_eq!(output["depth"], 0); assert!(output["createdAt"].as_str().is_some());
assert_eq!(output["maxDepth"], 3);
assert_eq!(output["result"], "Child agent completed successfully.");
assert_eq!(output["toolResults"][0]["toolName"], "StructuredOutput");
assert_eq!(output["toolResults"][0]["isError"], false);
let manifest_file = output["manifestFile"].as_str().expect("manifest file"); let manifest_file = output["manifestFile"].as_str().expect("manifest file");
let output_file = output["outputFile"].as_str().expect("output file"); let output_file = output["outputFile"].as_str().expect("output file");
let contents = std::fs::read_to_string(output_file).expect("agent file exists"); let contents = std::fs::read_to_string(output_file).expect("agent file exists");
let manifest_contents = let manifest_contents =
std::fs::read_to_string(manifest_file).expect("manifest file exists"); std::fs::read_to_string(manifest_file).expect("manifest file exists");
assert!(contents.contains("Child agent completed successfully.")); assert!(contents.contains("Audit the branch"));
assert!(contents.contains("StructuredOutput [ok]")); assert!(contents.contains("Check tests and outstanding work."));
assert!(manifest_contents.contains("\"subagentType\": \"Explore\"")); assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
std::env::set_var(
"CLAWD_AGENT_TEST_SCRIPT",
serde_json::to_string(&vec![vec![json!({
"type": "text",
"text": "Normalized alias check."
})]])
.expect("script json"),
);
let normalized = execute_tool( let normalized = execute_tool(
"Agent", "Agent",
&json!({ &json!({
@@ -3322,19 +2785,10 @@ mod tests {
}), }),
) )
.expect("Agent should normalize built-in aliases"); .expect("Agent should normalize built-in aliases");
std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
let normalized_output: serde_json::Value = let normalized_output: serde_json::Value =
serde_json::from_str(&normalized).expect("valid json"); serde_json::from_str(&normalized).expect("valid json");
assert_eq!(normalized_output["subagentType"], "Explore"); assert_eq!(normalized_output["subagentType"], "Explore");
std::env::set_var(
"CLAWD_AGENT_TEST_SCRIPT",
serde_json::to_string(&vec![vec![json!({
"type": "text",
"text": "Name normalization check."
})]])
.expect("script json"),
);
let named = execute_tool( let named = execute_tool(
"Agent", "Agent",
&json!({ &json!({
@@ -3344,14 +2798,13 @@ mod tests {
}), }),
) )
.expect("Agent should normalize explicit names"); .expect("Agent should normalize explicit names");
std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT");
let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json"); let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json");
assert_eq!(named_output["name"], "ship-audit"); assert_eq!(named_output["name"], "ship-audit");
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }
#[test] #[test]
fn agent_rejects_blank_required_fields_and_enforces_max_depth() { fn agent_rejects_blank_required_fields() {
let missing_description = execute_tool( let missing_description = execute_tool(
"Agent", "Agent",
&json!({ &json!({
@@ -3371,22 +2824,6 @@ mod tests {
) )
.expect_err("blank prompt should fail"); .expect_err("blank prompt should fail");
assert!(missing_prompt.contains("prompt must not be empty")); assert!(missing_prompt.contains("prompt must not be empty"));
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::env::set_var("CLAWD_AGENT_DEPTH", "1");
let depth_error = execute_tool(
"Agent",
&json!({
"description": "Nested agent",
"prompt": "Do nested work.",
"max_depth": 1
}),
)
.expect_err("max depth should fail");
std::env::remove_var("CLAWD_AGENT_DEPTH");
assert!(depth_error.contains("max_depth exceeded"));
} }
#[test] #[test]