mirror of
https://github.com/instructkr/claw-code.git
synced 2026-07-02 22:50:48 -04:00
Compare commits
1 Commits
rcc/tools
...
cd01d0e387
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd01d0e387 |
2
rust/Cargo.lock
generated
2
rust/Cargo.lock
generated
@@ -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]]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 { .. }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user