Lifei/sub recipe desktop temp (#3576)

This commit is contained in:
Lifei Zhou
2025-07-24 23:37:24 +10:00
committed by GitHub
parent a52e11afc1
commit e682940929
14 changed files with 255 additions and 113 deletions
@@ -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};
@@ -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)
@@ -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(
@@ -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', " "),
@@ -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");
@@ -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");
+3
View File
@@ -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,
@@ -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;
+36 -1
View File
@@ -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;
@@ -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;
@@ -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,
@@ -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,
@@ -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)
}
@@ -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;
@@ -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;
@@ -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))
}
}
}
@@ -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');
@@ -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;
@@ -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;
@@ -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(""), "");
}
}
+58
View File
@@ -13,6 +13,39 @@
"version": "1.1.0"
},
"paths": {
"/agent/add_sub_recipes": {
"post": {
"tags": [
"super::routes::agent"
],
"operationId": "add_sub_recipes",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddSubRecipesRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "added sub recipes to agent successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddSubRecipesResponse"
}
}
}
},
"401": {
"description": "Unauthorized - invalid secret key"
}
}
}
},
"/agent/tools": {
"get": {
"tags": [
@@ -1033,6 +1066,31 @@
},
"components": {
"schemas": {
"AddSubRecipesRequest": {
"type": "object",
"required": [
"sub_recipes"
],
"properties": {
"sub_recipes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SubRecipe"
}
}
}
},
"AddSubRecipesResponse": {
"type": "object",
"required": [
"success"
],
"properties": {
"success": {
"type": "boolean"
}
}
},
"Annotations": {
"type": "object",
"properties": {
+12 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RecoverConfigData, RecoverConfigResponse, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ValidateConfigData, ValidateConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateRecipeData, CreateRecipeResponse2, DecodeRecipeData, DecodeRecipeResponse2, EncodeRecipeData, EncodeRecipeResponse2, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import type { AddSubRecipesData, AddSubRecipesResponse2, GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RecoverConfigData, RecoverConfigResponse, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ValidateConfigData, ValidateConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateRecipeData, CreateRecipeResponse2, DecodeRecipeData, DecodeRecipeResponse2, EncodeRecipeData, EncodeRecipeResponse2, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -18,6 +18,17 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
meta?: Record<string, unknown>;
};
export const addSubRecipes = <ThrowOnError extends boolean = false>(options: Options<AddSubRecipesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<AddSubRecipesResponse2, unknown, ThrowOnError>({
url: '/agent/add_sub_recipes',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const getTools = <ThrowOnError extends boolean = false>(options?: Options<GetToolsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetToolsResponse, unknown, ThrowOnError>({
url: '/agent/tools',
+31
View File
@@ -1,5 +1,13 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AddSubRecipesRequest = {
sub_recipes: Array<SubRecipe>;
};
export type AddSubRecipesResponse = {
success: boolean;
};
export type Annotations = {
audience?: Array<Role>;
priority?: number;
@@ -777,6 +785,29 @@ export type UpsertPermissionsQuery = {
tool_permissions: Array<ToolPermission>;
};
export type AddSubRecipesData = {
body: AddSubRecipesRequest;
path?: never;
query?: never;
url: '/agent/add_sub_recipes';
};
export type AddSubRecipesErrors = {
/**
* Unauthorized - invalid secret key
*/
401: unknown;
};
export type AddSubRecipesResponses = {
/**
* added sub recipes to agent successfully
*/
200: AddSubRecipesResponse;
};
export type AddSubRecipesResponse2 = AddSubRecipesResponses[keyof AddSubRecipesResponses];
export type GetToolsData = {
body?: never;
path?: never;
-1
View File
@@ -197,7 +197,6 @@ if (process.platform === 'win32') {
const protocolUrl = commandLine.find((arg) => arg.startsWith('goose://'));
if (protocolUrl) {
const parsedUrl = new URL(protocolUrl);
// If it's a bot/recipe URL, handle it directly by creating a new window
if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
app.whenReady().then(async () => {
@@ -0,0 +1,10 @@
import { addSubRecipes, SubRecipe } from '../api';
export async function addSubRecipesToAgent(subRecipes: SubRecipe[]) {
const add_sub_recipe_response = await addSubRecipes({ body: { sub_recipes: subRecipes } });
if (add_sub_recipe_response.error) {
console.warn(`Failed to add sub recipes: ${add_sub_recipe_response.error}`);
} else {
console.log('Added sub recipes');
}
}
+36 -13
View File
@@ -10,7 +10,13 @@ import { extractExtensionConfig } from '../components/settings/extensions/utils'
import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
// TODO: remove when removing migration logic
import { toastService } from '../toasts';
import { ExtensionQuery, addExtension as apiAddExtension } from '../api';
import {
ExtensionQuery,
RecipeParameter,
SubRecipe,
addExtension as apiAddExtension,
} from '../api';
import { addSubRecipesToAgent } from '../recipe/add_sub_recipe_on_agent';
export interface Provider {
id: string; // Lowercase key (e.g., "openai")
@@ -71,15 +77,19 @@ const substituteParameters = (text: string, params: Record<string, string>): str
*/
export const updateSystemPromptWithParameters = async (
recipeParameters: Record<string, string>,
recipeConfig?: { instructions?: string | null }
recipeConfig?: {
instructions?: string | null;
sub_recipes?: SubRecipe[] | null;
parameters?: RecipeParameter[] | null;
}
): Promise<void> => {
const subRecipes = recipeConfig?.sub_recipes;
try {
const originalInstructions = recipeConfig?.instructions;
if (!originalInstructions) {
return;
}
// Substitute parameters in the instructions
const substitutedInstructions = substituteParameters(originalInstructions, recipeParameters);
@@ -103,6 +113,16 @@ export const updateSystemPromptWithParameters = async (
} catch (error) {
console.error('Error updating system prompt with parameters:', error);
}
if (subRecipes && subRecipes?.length > 0) {
for (const subRecipe of subRecipes) {
if (subRecipe.values) {
for (const key in subRecipe.values) {
subRecipe.values[key] = substituteParameters(subRecipe.values[key], recipeParameters);
}
}
}
await addSubRecipesToAgent(subRecipes);
}
};
/**
@@ -192,9 +212,16 @@ export const initializeSystem = async (
// Get recipeConfig directly here
const recipeConfig = window.appConfig?.get?.('recipe');
const botPrompt = (recipeConfig as { instructions?: string })?.instructions;
const recipe_instructions = (recipeConfig as { instructions?: string })?.instructions;
const responseConfig = (recipeConfig as { response?: { json_schema?: unknown } })?.response;
const subRecipes = (recipeConfig as { sub_recipes?: SubRecipe[] })?.sub_recipes;
const parameters = (recipeConfig as { parameters?: RecipeParameter[] })?.parameters;
const hasParameters = parameters && parameters?.length > 0;
const hasSubRecipes = subRecipes && subRecipes?.length > 0;
let prompt = desktopPrompt;
if (!hasParameters && recipe_instructions) {
prompt = `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${recipe_instructions}`;
}
// Extend the system prompt with desktop-specific information
const response = await fetch(getApiUrl('/agent/prompt'), {
method: 'POST',
@@ -203,21 +230,17 @@ export const initializeSystem = async (
'X-Secret-Key': getSecretKey(),
},
body: JSON.stringify({
extension: botPrompt
? `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${botPrompt}`
: desktopPrompt,
extension: prompt,
}),
});
if (!response.ok) {
console.warn(`Failed to extend system prompt: ${response.statusText}`);
} else {
console.log('Extended system prompt with desktop-specific information');
if (botPrompt) {
console.log('Added custom bot prompt to system prompt');
}
}
if (!hasParameters && hasSubRecipes) {
await addSubRecipesToAgent(subRecipes);
}
// Configure session with response config if present
if (responseConfig?.json_schema) {
const sessionConfigResponse = await fetch(getApiUrl('/agent/session_config'), {