Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
75d3cd1
initial version of run sub recipe multiple times
lifeizhou-ap Jul 7, 2025
cb0e6a5
added tests, rename functions
lifeizhou-ap Jul 7, 2025
476651f
get sub recipe json output
lifeizhou-ap Jul 8, 2025
b50d245
added showing parallel running dashboard
lifeizhou-ap Jul 8, 2025
9ca6b25
better view
lifeizhou-ap Jul 8, 2025
7446692
clean up dashboard.rs
lifeizhou-ap Jul 8, 2025
b848409
fixed tests
lifeizhou-ap Jul 8, 2025
a02c056
fixed scenario that only run params are specified
lifeizhou-ap Jul 8, 2025
f7832da
display param values in the task list
lifeizhou-ap Jul 8, 2025
e601ca2
use stream to send the output to session
lifeizhou-ap Jul 8, 2025
4d11f37
refactored the files
lifeizhou-ap Jul 8, 2025
d8fdedd
better console ui and run sub recipe in no-session mode
lifeizhou-ap Jul 8, 2025
f1c38f9
added timeout in task execution
lifeizhou-ap Jul 8, 2025
54d5224
cleaned up some utils and added the error tool response
lifeizhou-ap Jul 9, 2025
47e769e
return task output if task times out
lifeizhou-ap Jul 9, 2025
85d5286
changed console output a bit
lifeizhou-ap Jul 9, 2025
2ccfb68
test fmt
lifeizhou-ap Jul 9, 2025
4453998
refactored the code
lifeizhou-ap Jul 9, 2025
c984764
Merge branch 'main' into lifei/run-sub-recipe-multiple-times
lifeizhou-ap Jul 9, 2025
c14da81
refactored parsing recipe
lifeizhou-ap Jul 9, 2025
a01d712
created taskExecutionNotificationEvent
lifeizhou-ap Jul 9, 2025
41c46a6
added tests
lifeizhou-ap Jul 9, 2025
2c0f24c
revert some unexpected deletion
lifeizhou-ap Jul 9, 2025
41fc7ca
removed initial worker size
lifeizhou-ap Jul 9, 2025
353c52a
removed worker config
lifeizhou-ap Jul 9, 2025
17a5213
fixed fmt
lifeizhou-ap Jul 9, 2025
4f52ab1
renamed types.rs to task_types.rs
lifeizhou-ap Jul 11, 2025
0e01630
feat: dynamic tasks (#3414)
wendytang Jul 15, 2025
adf378a
Revert "feat: dynamic tasks (#3414)"
lifeizhou-ap Jul 16, 2025
7dd8587
remove runs and timeout_in_seconds
lifeizhou-ap Jul 16, 2025
7922380
Merge branch 'main' into lifei/run-sub-recipe-multiple-times
lifeizhou-ap Jul 16, 2025
64c6240
renamed variable
lifeizhou-ap Jul 16, 2025
3c7238c
used task ids instead task payload as input schema for executor tool
lifeizhou-ap Jul 16, 2025
3d8782b
change parallel to sequential_when_repeated
lifeizhou-ap Jul 16, 2025
990e9ab
make sure same sub recipe with different params to run sequentially w…
lifeizhou-ap Jul 16, 2025
eb94b36
Merge branch 'main' into lifei/run-sub-recipe-multiple-times
lifeizhou-ap Jul 16, 2025
fad41f8
apply character-boundary-safe slicing
lifeizhou-ap Jul 16, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ ui/desktop/src/bin/goose_llm.dll
# Hermit
.hermit/

# Claude
.claude

debug_*.txt

# Docs
Expand Down
1 change: 1 addition & 0 deletions crates/goose-cli/src/recipes/extract_from_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub fn extract_recipe_info_from_cli(
path: recipe_file_path.to_string_lossy().to_string(),
name,
values: None,
sequential_when_repeated: true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This property name is a little confusing IMO, maybe use something like allow_concurrent?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @jamadeo

Thank you for the review and your time!

I thought of allow_concurrent, and i feel that it might have 2 concerns:

  1. allow_concurrent: false it might confuse people that the sub-recipe cannot be running concurrently with other sub-recipes. The ...when_repeated makes it clear that it cannot be run parallel when running repeatedly.

  2. By default the sub-recipe can be run in parallel. Usually if the boolean attribute is not specified, the default value is false. If allow_concurrent is specified, people might be think the allow_concurrent: false

Naming is usually hard. Happy to change to a better name.

};
all_sub_recipes.push(additional_sub_recipe);
}
Expand Down
23 changes: 21 additions & 2 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ mod export;
mod input;
mod output;
mod prompt;
mod task_execution_display;
mod thinking;

use crate::session::task_execution_display::TASK_EXECUTION_NOTIFICATION_TYPE;

pub use self::export::message_to_markdown;
pub use builder::{build_session, SessionBuilderConfig, SessionSettings};
use console::Color;
Expand All @@ -17,6 +20,8 @@ use goose::permission::PermissionConfirmation;
use goose::providers::base::Provider;
pub use goose::session::Identifier;
use goose::utils::safe_truncate;
use std::io::Write;
use task_execution_display::format_task_execution_notification;

use anyhow::{Context, Result};
use completion::GooseCompleter;
Expand Down Expand Up @@ -1004,7 +1009,7 @@ impl Session {
match method.as_str() {
"notifications/message" => {
let data = o.get("data").unwrap_or(&Value::Null);
let (formatted_message, subagent_id, _notification_type) = match data {
let (formatted_message, subagent_id, message_notification_type) = match data {
Value::String(s) => (s.clone(), None, None),
Value::Object(o) => {
// Check for subagent notification structure first
Expand Down Expand Up @@ -1055,6 +1060,8 @@ impl Session {
} else if let Some(Value::String(output)) = o.get("output") {
// Fallback for other MCP notification types
(output.to_owned(), None, None)
} else if let Some(result) = format_task_execution_notification(data) {
result
} else {
(data.to_string(), None, None)
}
Expand All @@ -1073,7 +1080,19 @@ impl Session {
} else {
progress_bars.log(&formatted_message);
}
} else {
} else if let Some(ref notification_type) = message_notification_type {
if notification_type == TASK_EXECUTION_NOTIFICATION_TYPE {
if interactive {
let _ = progress_bars.hide();
print!("{}", formatted_message);
std::io::stdout().flush().unwrap();
} else {
print!("{}", formatted_message);
std::io::stdout().flush().unwrap();
}
}
}
else {
// Non-subagent notification, display immediately with compact spacing
if interactive {
let _ = progress_bars.hide();
Expand Down
247 changes: 247 additions & 0 deletions crates/goose-cli/src/session/task_execution_display/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
use goose::agents::sub_recipe_execution_tool::lib::TaskStatus;
use goose::agents::sub_recipe_execution_tool::notification_events::{
TaskExecutionNotificationEvent, TaskInfo,
};
use serde_json::Value;
use std::sync::atomic::{AtomicBool, Ordering};

#[cfg(test)]
mod tests;

const CLEAR_SCREEN: &str = "\x1b[2J\x1b[H";
const MOVE_TO_PROGRESS_LINE: &str = "\x1b[4;1H";
const CLEAR_TO_EOL: &str = "\x1b[K";
const CLEAR_BELOW: &str = "\x1b[J";
pub const TASK_EXECUTION_NOTIFICATION_TYPE: &str = "task_execution";

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::Object(obj) => {
if let Some(partial_output) = obj.get("partial_output").and_then(|v| v.as_str()) {
format!("Partial output: {}", partial_output)
} else {
serde_json::to_string_pretty(obj).unwrap_or_default()
}
}
Value::Array(arr) => serde_json::to_string_pretty(arr).unwrap_or_default(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Null => "null".to_string(),
}
}

fn process_output_for_display(output: &str) -> String {
const MAX_OUTPUT_LINES: usize = 2;
const OUTPUT_PREVIEW_LENGTH: usize = 100;

let lines: Vec<&str> = output.lines().collect();
let recent_lines = if lines.len() > MAX_OUTPUT_LINES {
&lines[lines.len() - MAX_OUTPUT_LINES..]
} else {
&lines
};

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
}

pub fn format_task_execution_notification(
data: &Value,
) -> Option<(String, Option<String>, Option<String>)> {
if let Ok(event) = serde_json::from_value::<TaskExecutionNotificationEvent>(data.clone()) {
return Some(match event {
TaskExecutionNotificationEvent::LineOutput { output, .. } => (
format!("{}\n", output),
None,
Some(TASK_EXECUTION_NOTIFICATION_TYPE.to_string()),
),
TaskExecutionNotificationEvent::TasksUpdate { .. } => {
let formatted_display = format_tasks_update_from_event(&event);
(
formatted_display,
None,
Some(TASK_EXECUTION_NOTIFICATION_TYPE.to_string()),
)
}
TaskExecutionNotificationEvent::TasksComplete { .. } => {
let formatted_summary = format_tasks_complete_from_event(&event);
(
formatted_summary,
None,
Some(TASK_EXECUTION_NOTIFICATION_TYPE.to_string()),
)
}
});
}
None
}

fn format_tasks_update_from_event(event: &TaskExecutionNotificationEvent) -> String {
if let TaskExecutionNotificationEvent::TasksUpdate { stats, tasks } = event {
let mut display = String::new();

if !INITIAL_SHOWN.swap(true, Ordering::SeqCst) {
display.push_str(CLEAR_SCREEN);
display.push_str("🎯 Task Execution Dashboard\n");
display.push_str("═══════════════════════════\n\n");
} else {
display.push_str(MOVE_TO_PROGRESS_LINE);
}

display.push_str(&format!(
"📊 Progress: {} total | ⏳ {} pending | 🏃 {} running | ✅ {} completed | ❌ {} failed",
stats.total, stats.pending, stats.running, stats.completed, stats.failed
));
display.push_str(&format!("{}\n\n", CLEAR_TO_EOL));

let mut sorted_tasks = tasks.clone();
sorted_tasks.sort_by(|a, b| a.id.cmp(&b.id));

for task in sorted_tasks {
display.push_str(&format_task_display(&task));
}

display.push_str(CLEAR_BELOW);
display
} else {
String::new()
}
}

fn format_tasks_complete_from_event(event: &TaskExecutionNotificationEvent) -> String {
if let TaskExecutionNotificationEvent::TasksComplete {
stats,
failed_tasks,
} = event
{
let mut summary = String::new();
summary.push_str("Execution Complete!\n");
summary.push_str("═══════════════════════\n");

summary.push_str(&format!("Total Tasks: {}\n", stats.total));
summary.push_str(&format!("✅ Completed: {}\n", stats.completed));
summary.push_str(&format!("❌ Failed: {}\n", stats.failed));
summary.push_str(&format!("📈 Success Rate: {:.1}%\n", stats.success_rate));

if !failed_tasks.is_empty() {
summary.push_str("\n❌ Failed Tasks:\n");
for task in failed_tasks {
summary.push_str(&format!(" • {}\n", task.name));
if let Some(error) = &task.error {
summary.push_str(&format!(" Error: {}\n", error));
}
}
}

summary.push_str("\n📝 Generating summary...\n");
summary
} else {
String::new()
}
}

fn format_task_display(task: &TaskInfo) -> String {
let mut task_display = String::new();

let status_icon = match task.status {
TaskStatus::Pending => "⏳",
TaskStatus::Running => "🏃",
TaskStatus::Completed => "✅",
TaskStatus::Failed => "❌",
};

task_display.push_str(&format!(
"{} {} ({}){}\n",
status_icon, task.task_name, task.task_type, CLEAR_TO_EOL
));

if !task.task_metadata.is_empty() {
task_display.push_str(&format!(
" 📋 Parameters: {}{}\n",
task.task_metadata, CLEAR_TO_EOL
));
}

if let Some(duration_secs) = task.duration_secs {
task_display.push_str(&format!(" ⏱️ {:.1}s{}\n", duration_secs, CLEAR_TO_EOL));
}

if matches!(task.status, TaskStatus::Running) && !task.current_output.trim().is_empty() {
let processed_output = process_output_for_display(&task.current_output);
if !processed_output.is_empty() {
task_display.push_str(&format!(" 💬 {}{}\n", processed_output, CLEAR_TO_EOL));
}
}

if matches!(task.status, TaskStatus::Completed) {
if let Some(result_data) = &task.result_data {
let result_preview = format_result_data_for_display(result_data);
if !result_preview.is_empty() {
task_display.push_str(&format!(" 📄 {}{}\n", result_preview, CLEAR_TO_EOL));
}
}
}

if matches!(task.status, TaskStatus::Failed) {
if let Some(error) = &task.error {
let error_preview = truncate_with_ellipsis(error, 80);
task_display.push_str(&format!(
" ⚠️ {}{}\n",
error_preview.replace('\n', " "),
CLEAR_TO_EOL
));
}
}

task_display.push_str(&format!("{}\n", CLEAR_TO_EOL));
task_display
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rendering of subagents is something we should do an overall think about at some later stage as its a complicated experience to design.

Loading
Loading