Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 4 additions & 49 deletions crates/goose-cli/src/session/task_execution_display/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use goose::agents::subagent_execution_tool::lib::TaskStatus;
use goose::agents::subagent_execution_tool::notification_events::{
TaskExecutionNotificationEvent, TaskInfo,
};
use goose::utils::safe_truncate;
use serde_json::Value;
use std::sync::atomic::{AtomicBool, Ordering};

Expand All @@ -18,7 +19,7 @@ static INITIAL_SHOWN: AtomicBool = AtomicBool::new(false);

fn format_result_data_for_display(result_data: &Value) -> String {
match result_data {
Value::String(s) => strip_ansi_codes(s),
Value::String(s) => s.to_string(),
Value::Object(obj) => {
if let Some(partial_output) = obj.get("partial_output").and_then(|v| v.as_str()) {
format!("Partial output: {}", partial_output)
Expand All @@ -45,53 +46,7 @@ fn process_output_for_display(output: &str) -> String {
};

let clean_output = recent_lines.join(" ... ");
let stripped = strip_ansi_codes(&clean_output);
truncate_with_ellipsis(&stripped, OUTPUT_PREVIEW_LENGTH)
}

fn truncate_with_ellipsis(text: &str, max_len: usize) -> String {
if text.len() > max_len {
let mut end = max_len.saturating_sub(3);
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &text[..end])
} else {
text.to_string()
}
}

fn strip_ansi_codes(text: &str) -> String {
let mut result = String::new();
let mut chars = text.chars();

while let Some(ch) = chars.next() {
if ch == '\x1b' {
if let Some(next_ch) = chars.next() {
if next_ch == '[' {
// This is an ANSI escape sequence, consume until alphabetic character
loop {
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => break,
Some(_) => continue,
None => break,
}
}
} else {
// Not an ANSI sequence, keep both characters
result.push(ch);
result.push(next_ch);
}
} else {
// End of string after \x1b
result.push(ch);
}
} else {
result.push(ch);
}
}

result
safe_truncate(&clean_output, OUTPUT_PREVIEW_LENGTH)
}

pub fn format_task_execution_notification(
Expand Down Expand Up @@ -233,7 +188,7 @@ fn format_task_display(task: &TaskInfo) -> String {

if matches!(task.status, TaskStatus::Failed) {
if let Some(error) = &task.error {
let error_preview = truncate_with_ellipsis(error, 80);
let error_preview = safe_truncate(error, 80);
task_display.push_str(&format!(
" ⚠️ {}{}\n",
error_preview.replace('\n', " "),
Expand Down
41 changes: 4 additions & 37 deletions crates/goose-cli/src/session/task_execution_display/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,6 @@ use goose::agents::subagent_execution_tool::notification_events::{
};
use serde_json::json;

#[test]
fn test_strip_ansi_codes() {
assert_eq!(strip_ansi_codes("hello world"), "hello world");
assert_eq!(strip_ansi_codes("\x1b[31mred text\x1b[0m"), "red text");
assert_eq!(
strip_ansi_codes("\x1b[1;32mbold green\x1b[0m"),
"bold green"
);
assert_eq!(
strip_ansi_codes("normal\x1b[33myellow\x1b[0mnormal"),
"normalyellownormal"
);
assert_eq!(strip_ansi_codes("\x1bhello"), "\x1bhello");
assert_eq!(strip_ansi_codes("hello\x1b"), "hello\x1b");
assert_eq!(strip_ansi_codes(""), "");
}

#[test]
fn test_truncate_with_ellipsis() {
assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
assert_eq!(truncate_with_ellipsis("hello world", 8), "hello...");
assert_eq!(truncate_with_ellipsis("hello", 3), "...");
assert_eq!(truncate_with_ellipsis("hello", 2), "...");
assert_eq!(truncate_with_ellipsis("hello", 1), "...");
assert_eq!(truncate_with_ellipsis("", 5), "");
}

#[test]
fn test_process_output_for_display() {
assert_eq!(process_output_for_display("hello world"), "hello world");
Expand All @@ -49,20 +21,15 @@ fn test_process_output_for_display() {
assert!(result.len() <= 100);
assert!(result.ends_with("..."));

let ansi_output = "\x1b[31mred line 1\x1b[0m\n\x1b[32mgreen line 2\x1b[0m";
let result = process_output_for_display(ansi_output);
assert_eq!(result, "red line 1 ... green line 2");

assert_eq!(process_output_for_display(""), "");
}

#[test]
fn test_format_result_data_for_display() {
let string_val = json!("hello world");
assert_eq!(format_result_data_for_display(&string_val), "hello world");

let ansi_string = json!("\x1b[31mred text\x1b[0m");
assert_eq!(format_result_data_for_display(&ansi_string), "red text");
assert_eq!(
format_result_data_for_display(&json!("red text")),
"red text"
);

assert_eq!(format_result_data_for_display(&json!(true)), "true");
assert_eq!(format_result_data_for_display(&json!(false)), "false");
Expand Down
3 changes: 3 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ derive_utoipa!(ResourceContents as ResourceContentsSchema);
super::routes::config_management::providers,
super::routes::config_management::upsert_permissions,
super::routes::agent::get_tools,
super::routes::agent::add_sub_recipes,
super::routes::reply::confirm_permission,
super::routes::context::manage_context,
super::routes::session::list_sessions,
Expand Down Expand Up @@ -394,6 +395,8 @@ derive_utoipa!(ResourceContents as ResourceContentsSchema);
goose::recipe::SubRecipe,
goose::agents::types::RetryConfig,
goose::agents::types::SuccessCheck,
super::routes::agent::AddSubRecipesRequest,
super::routes::agent::AddSubRecipesResponse,
))
)]
pub struct ApiDoc;
Expand Down
37 changes: 36 additions & 1 deletion crates/goose-server/src/routes/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use axum::{
routing::{get, post},
Json, Router,
};
use goose::config::Config;
use goose::config::PermissionManager;
use goose::model::ModelConfig;
use goose::providers::create;
Expand All @@ -15,6 +14,7 @@ use goose::{
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
config::permission::PermissionLevel,
};
use goose::{config::Config, recipe::SubRecipe};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
Expand All @@ -35,6 +35,16 @@ struct ExtendPromptResponse {
success: bool,
}

#[derive(Deserialize, utoipa::ToSchema)]
pub struct AddSubRecipesRequest {
sub_recipes: Vec<SubRecipe>,
}

#[derive(Serialize, utoipa::ToSchema)]
pub struct AddSubRecipesResponse {
success: bool,
}

#[derive(Deserialize)]
struct ProviderFile {
name: String,
Expand Down Expand Up @@ -88,6 +98,30 @@ async fn get_versions() -> Json<VersionsResponse> {
})
}

#[utoipa::path(
post,
path = "/agent/add_sub_recipes",
request_body = AddSubRecipesRequest,
responses(
(status = 200, description = "added sub recipes to agent successfully", body = AddSubRecipesResponse),
(status = 401, description = "Unauthorized - invalid secret key"),
),
)]
async fn add_sub_recipes(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<AddSubRecipesRequest>,
) -> Result<Json<AddSubRecipesResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;

let agent = state
.get_agent()
.await
.map_err(|_| StatusCode::PRECONDITION_FAILED)?;
agent.add_sub_recipes(payload.sub_recipes.clone()).await;
Ok(Json(AddSubRecipesResponse { success: true }))
}

async fn extend_prompt(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Expand Down Expand Up @@ -318,5 +352,6 @@ pub fn routes(state: Arc<AppState>) -> Router {
post(update_router_tool_selector),
)
.route("/agent/session_config", post(update_session_config))
.route("/agent/add_sub_recipes", post(add_sub_recipes))
.with_state(state)
}
10 changes: 1 addition & 9 deletions crates/goose/src/agents/subagent_execution_tool/executor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,7 @@ async fn collect_results(
expected_count: usize,
) -> Vec<TaskResult> {
let mut results = Vec::new();
while let Some(mut result) = result_rx.recv().await {
// Truncate data to 650 chars if needed
if let Some(data) = result.data.as_mut() {
if let Some(data_str) = data.as_str() {
if data_str.len() > 650 {
*data = serde_json::Value::String(format!("{}...", &data_str[..650]));
}
}
}
while let Some(result) = result_rx.recv().await {
task_execution_tracker
.complete_task(&result.task_id, result.clone())
.await;
Expand Down
4 changes: 3 additions & 1 deletion crates/goose/src/agents/subagent_execution_tool/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use tokio_util::sync::CancellationToken;

use crate::agents::subagent_execution_tool::task_execution_tracker::TaskExecutionTracker;
use crate::agents::subagent_execution_tool::task_types::{Task, TaskResult, TaskStatus};
use crate::agents::subagent_execution_tool::utils::strip_ansi_codes;
use crate::agents::subagent_handler::run_complete_subagent_task;
use crate::agents::subagent_task_config::TaskConfig;

Expand Down Expand Up @@ -70,7 +71,7 @@ async fn get_task_result(
if success {
process_output(stdout_output)
} else {
Err(format!("Command failed:\n{}", stderr_output))
Err(format!("Command failed:\n{}", &stderr_output))
}
}
}
Expand Down Expand Up @@ -224,6 +225,7 @@ fn spawn_output_reader(
let mut buffer = String::new();
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
let line = strip_ansi_codes(&line);
buffer.push_str(&line);
buffer.push('\n');

Expand Down
33 changes: 33 additions & 0 deletions crates/goose/src/agents/subagent_execution_tool/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,38 @@ pub fn count_by_status(tasks: &HashMap<String, TaskInfo>) -> (usize, usize, usiz
(total, pending, running, completed, failed)
}

pub fn strip_ansi_codes(text: &str) -> String {
let mut result = String::new();
let mut chars = text.chars();

while let Some(ch) = chars.next() {
if ch == '\x1b' {
if let Some(next_ch) = chars.next() {
if next_ch == '[' {
// This is an ANSI escape sequence, consume until alphabetic character
loop {
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => break,
Some(_) => continue,
None => break,
}
}
} else {
// Not an ANSI sequence, keep both characters
result.push(ch);
result.push(next_ch);
}
} else {
// End of string after \x1b
result.push(ch);
}
} else {
result.push(ch);
}
}

result
}

#[cfg(test)]
mod tests;
25 changes: 24 additions & 1 deletion crates/goose/src/agents/subagent_execution_tool/utils/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::agents::subagent_execution_tool::task_types::{Task, TaskInfo, TaskStatus};
use crate::agents::subagent_execution_tool::utils::{count_by_status, get_task_name};
use crate::agents::subagent_execution_tool::utils::{
count_by_status, get_task_name, strip_ansi_codes,
};
use serde_json::json;
use std::collections::HashMap;

Expand Down Expand Up @@ -152,3 +154,24 @@ mod count_by_status {
);
}
}

mod strip_ansi_codes {
use super::*;

#[test]
fn test_strip_ansi_codes() {
assert_eq!(strip_ansi_codes("hello world"), "hello world");
assert_eq!(strip_ansi_codes("\x1b[31mred text\x1b[0m"), "red text");
assert_eq!(
strip_ansi_codes("\x1b[1;32mbold green\x1b[0m"),
"bold green"
);
assert_eq!(
strip_ansi_codes("normal\x1b[33myellow\x1b[0mnormal"),
"normalyellownormal"
);
assert_eq!(strip_ansi_codes("\x1bhello"), "\x1bhello");
assert_eq!(strip_ansi_codes("hello\x1b"), "hello\x1b");
assert_eq!(strip_ansi_codes(""), "");
}
}
Loading
Loading