diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 87fa79766d35..2a5911826227 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -12,7 +12,7 @@ use goose::conversation::message::Message; use goose::conversation::Conversation; use goose::model::ModelConfig; use goose::providers::create; -use goose::recipe::Response; +use goose::recipe::{Recipe, Response}; use goose::session; use goose::session::SessionMetadata; use goose::{ @@ -81,6 +81,7 @@ pub struct UpdateRouterToolSelectorRequest { #[derive(Deserialize, utoipa::ToSchema)] pub struct StartAgentRequest { working_dir: String, + recipe: Option, } #[derive(Deserialize, utoipa::ToSchema)] @@ -116,15 +117,8 @@ async fn start_agent( State(state): State>, headers: HeaderMap, Json(payload): Json, -) -> Result, (StatusCode, Json)> { - verify_secret_key(&headers, &state).map_err(|_| { - ( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse { - error: "Unauthorized - Invalid or missing API key".to_string(), - }), - ) - })?; +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; state.reset().await; @@ -143,9 +137,18 @@ async fn start_agent( accumulated_input_tokens: Some(0), accumulated_output_tokens: Some(0), extension_data: Default::default(), + recipe: payload.recipe, + }; + + let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) { + Ok(path) => path, + Err(_) => return Err(StatusCode::BAD_REQUEST), }; let conversation = Conversation::empty(); + session::storage::save_messages_with_metadata(&session_path, &metadata, &conversation) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(StartAgentResponse { session_id, metadata, diff --git a/crates/goose/src/context_mgmt/auto_compact.rs b/crates/goose/src/context_mgmt/auto_compact.rs index b5dbf46fc516..0783a8257d8d 100644 --- a/crates/goose/src/context_mgmt/auto_compact.rs +++ b/crates/goose/src/context_mgmt/auto_compact.rs @@ -270,6 +270,7 @@ mod tests { accumulated_input_tokens: Some(50), accumulated_output_tokens: Some(50), extension_data: crate::session::ExtensionData::new(), + recipe: None, } } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index c256f684ed0b..88fe9df4cc83 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1299,6 +1299,7 @@ async fn run_scheduled_job_internal( accumulated_input_tokens: None, accumulated_output_tokens: None, extension_data: crate::session::ExtensionData::new(), + recipe: None, }; if let Err(e_fb) = crate::session::storage::save_messages_with_metadata( &session_file_path, diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index b51cae6ec42c..66f761469198 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -8,6 +8,7 @@ use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::providers::base::Provider; +use crate::recipe::Recipe; use crate::session::extension_data::ExtensionData; use crate::utils::safe_truncate; use anyhow::Result; @@ -69,6 +70,8 @@ pub struct SessionMetadata { /// Extension data containing extension states #[serde(default)] pub extension_data: ExtensionData, + + pub recipe: Option, } // Custom deserializer to handle old sessions without working_dir @@ -91,6 +94,7 @@ impl<'de> Deserialize<'de> for SessionMetadata { working_dir: Option, #[serde(default)] extension_data: ExtensionData, + recipe: Option, } let helper = Helper::deserialize(deserializer)?; @@ -113,6 +117,7 @@ impl<'de> Deserialize<'de> for SessionMetadata { accumulated_output_tokens: helper.accumulated_output_tokens, working_dir, extension_data: helper.extension_data, + recipe: helper.recipe, }) } } @@ -138,6 +143,7 @@ impl SessionMetadata { accumulated_input_tokens: None, accumulated_output_tokens: None, extension_data: ExtensionData::new(), + recipe: None, } } } diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index 8fc851c35473..6b02870898e4 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -412,5 +412,6 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> accumulated_input_tokens: Some(50), accumulated_output_tokens: Some(50), extension_data: Default::default(), + recipe: None, } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4603a853c1f7..961794b001c2 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3364,6 +3364,14 @@ "description": "The number of output tokens used in the session. Retrieved from the provider's last usage.", "nullable": true }, + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/Recipe" + } + ], + "nullable": true + }, "schedule_id": { "type": "string", "description": "ID of the schedule that triggered this session, if any", @@ -3416,6 +3424,14 @@ "working_dir" ], "properties": { + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/Recipe" + } + ], + "nullable": true + }, "working_dir": { "type": "string" } diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index b73fe725e298..458814821757 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -317,6 +317,10 @@ export default function App() { const [viewType, setViewType] = useState(null); const [resumeSessionId, setResumeSessionId] = useState(null); + const [recipeFromAppConfig, setRecipeFromAppConfig] = useState( + (window.appConfig?.get('recipe') as Recipe) || null + ); + useEffect(() => { const urlParams = new URLSearchParams(window.location.search); @@ -349,6 +353,7 @@ export default function App() { const resetChatIfNecessary = useCallback(() => { if (chat.messages.length > 0) { setResumeSessionId(null); + setRecipeFromAppConfig(null); resetChat(); } }, [resetChat, chat.messages.length]); @@ -371,11 +376,9 @@ export default function App() { return; } - const recipeConfig = (window.appConfig?.get('recipe') || undefined) as Recipe; - const stateData: PairRouteState = { resumeSessionId: resumeSessionId || undefined, - recipeConfig: recipeConfig, + recipeConfig: recipeFromAppConfig || undefined, }; (async () => { try { @@ -389,7 +392,7 @@ export default function App() { } })(); - if (resumeSessionId || (recipeConfig && typeof recipeConfig === 'object')) { + if (resumeSessionId || recipeFromAppConfig) { window.location.hash = '#/pair'; window.history.replaceState(stateData, '', '#/pair'); return; @@ -401,9 +404,9 @@ export default function App() { window.history.replaceState({}, '', '#/'); } } else { - if (viewType === 'recipeEditor' && recipeConfig) { + if (viewType === 'recipeEditor' && recipeFromAppConfig) { window.location.hash = '#/recipe-editor'; - window.history.replaceState({ config: recipeConfig }, '', '#/recipe-editor'); + window.history.replaceState({ config: recipeFromAppConfig }, '', '#/recipe-editor'); } else { const routeMap: Record = { chat: '#/', @@ -427,6 +430,7 @@ export default function App() { } } }, [ + recipeFromAppConfig, resetChat, loadCurrentChat, setAgentWaitingMessage, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index c030f68cdada..e9eed4e9b5f9 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -712,6 +712,7 @@ export type SessionMetadata = { * The number of output tokens used in the session. Retrieved from the provider's last usage. */ output_tokens?: number | null; + recipe?: Recipe | null; /** * ID of the schedule that triggered this session, if any */ @@ -737,6 +738,7 @@ export type Settings = { }; export type StartAgentRequest = { + recipe?: Recipe | null; working_dir: string; }; diff --git a/ui/desktop/src/components/pair.tsx b/ui/desktop/src/components/pair.tsx index 1e0b62e77f32..bcc3c0e37285 100644 --- a/ui/desktop/src/components/pair.tsx +++ b/ui/desktop/src/components/pair.tsx @@ -47,14 +47,11 @@ export default function Pair({ const [recipeResetOverride, setRecipeResetOverride] = useState(false); const [loadingChat, setLoadingChat] = useState(false); - const recipeJson = JSON.stringify(routeState.recipeConfig); - useEffect(() => { const initializeFromState = async () => { setLoadingChat(true); try { const chat = await loadCurrentChat({ - recipeConfig: routeState.recipeConfig, resumeSessionId: routeState.resumeSessionId, setAgentWaitingMessage, }); @@ -78,7 +75,6 @@ export default function Pair({ loadCurrentChat, routeState.resumeSessionId, routeState.recipeConfig, - recipeJson, // TODO: Hacky object comparison, but works for now ]); // Followed by sending the initialMessage if we have one. This will happen @@ -114,10 +110,13 @@ export default function Pair({ console.log('Message submitted:', message); }; - const initialValue = - messageToSubmit || - (agentState === 'initialized' && !recipeResetOverride ? recipeInitialPrompt : undefined) || - undefined; + const recipePrompt = + agentState === 'initialized' && + !recipeResetOverride && + chat.messages.length === 0 && + recipeInitialPrompt; + + const initialValue = messageToSubmit || recipePrompt || undefined; const customChatInputProps = { // Pass initial message from Hub or recipe prompt diff --git a/ui/desktop/src/contexts/ChatContext.tsx b/ui/desktop/src/contexts/ChatContext.tsx index 49d7f4674653..92e0a8d58593 100644 --- a/ui/desktop/src/contexts/ChatContext.tsx +++ b/ui/desktop/src/contexts/ChatContext.tsx @@ -14,8 +14,6 @@ interface ChatContextType { hasActiveSession: boolean; setRecipeConfig: (recipe: Recipe | null) => void; clearRecipeConfig: () => void; - setRecipeParameters: (parameters: Record | null) => void; - clearRecipeParameters: () => void; // Draft functionality draft: string; setDraft: (draft: string) => void; @@ -63,7 +61,7 @@ export const ChatProvider: React.FC = ({ messages: [], messageHistoryIndex: 0, recipeConfig: null, // Clear recipe when resetting chat - recipeParameters: null, // Clear parameters when resetting chat + recipeParameters: null, // Clear when resetting chat }); // Clear draft when resetting chat clearDraft(); @@ -83,20 +81,6 @@ export const ChatProvider: React.FC = ({ }); }; - const setRecipeParameters = (parameters: Record | null) => { - setChat({ - ...chat, - recipeParameters: parameters, - }); - }; - - const clearRecipeParameters = () => { - setChat({ - ...chat, - recipeParameters: null, - }); - }; - const hasActiveSession = chat.messages.length > 0; const value: ChatContextType = { @@ -106,8 +90,6 @@ export const ChatProvider: React.FC = ({ hasActiveSession, setRecipeConfig, clearRecipeConfig, - setRecipeParameters, - clearRecipeParameters, draft, setDraft, clearDraft, diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 0b43a36ba802..511cb0b00115 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -1,7 +1,6 @@ import { useState, useCallback, useRef } from 'react'; import { useConfig } from '../components/ConfigContext'; import { ChatType } from '../types/chat'; -import { Recipe } from '../recipe'; import { initializeSystem } from '../utils/providerUtils'; import { initializeCostDatabase } from '../utils/costDatabase'; import { @@ -13,10 +12,10 @@ import { resumeAgent, startAgent, validateConfig, + Recipe, } from '../api'; import { COST_TRACKING_ENABLED } from '../updates'; import { convertApiMessageToFrontendMessage } from '../components/context_management'; -import { fetchSessionDetails } from '../sessions'; export enum AgentState { UNINITIALIZED = 'uninitialized', @@ -61,21 +60,25 @@ export function useAgent(): UseAgentReturn { const currentChat = useCallback( async (initContext: InitializationContext): Promise => { if (agentIsInitialized && sessionId) { - const sessionDetails = await fetchSessionDetails(sessionId); - - const chat: ChatType = { - sessionId: sessionDetails.sessionId, - title: sessionDetails.metadata.description || 'Chat Session', + const agentResponse = await resumeAgent({ + body: { + session_id: sessionId, + }, + throwOnError: true, + }); + + const agentSessionInfo = agentResponse.data; + const sessionMetadata = agentSessionInfo.metadata; + let chat: ChatType = { + sessionId: agentSessionInfo.session_id, + title: sessionMetadata.recipe?.title || sessionMetadata.description, messageHistoryIndex: 0, - messages: sessionDetails.messages, + messages: agentSessionInfo.messages.map((message: ApiMessage) => + convertApiMessageToFrontendMessage(message, true, true) + ), + recipeConfig: sessionMetadata.recipe, }; - // TODO(Douwe): we should store the recipe config on the server so not needed here: - if (initContext.recipeConfig) { - chat.title = initContext.recipeConfig.title || chat.title; - chat.recipeConfig = initContext.recipeConfig; - } - return chat; } @@ -108,6 +111,7 @@ export function useAgent(): UseAgentReturn { : await startAgent({ body: { working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, + recipe: initContext.recipeConfig, }, throwOnError: true, }); @@ -145,20 +149,17 @@ export function useAgent(): UseAgentReturn { } } + const sessionMetadata = agentSessionInfo.metadata; let initChat: ChatType = { sessionId: agentSessionInfo.session_id, - title: agentSessionInfo.metadata.description, + title: sessionMetadata.recipe?.title || sessionMetadata.description, messageHistoryIndex: 0, messages: agentSessionInfo.messages.map((message: ApiMessage) => convertApiMessageToFrontendMessage(message, true, true) ), + recipeConfig: sessionMetadata.recipe, }; - if (initContext.recipeConfig) { - initChat.title = initContext.recipeConfig.title || initChat.title; - initChat.recipeConfig = initContext.recipeConfig; - } - setAgentState(AgentState.INITIALIZED); return initChat; diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index 0c4868467154..db8f025ac036 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -14,6 +14,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = const [recipeAccepted, setRecipeAccepted] = useState(false); const [hasSecurityWarnings, setHasSecurityWarnings] = useState(false); + const [recipeParameters, setRecipeParameters] = useState | null>(null); + const chatContext = useChatContext(); const messages = chat.messages; @@ -24,29 +26,7 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = messagesRef.current = messages; }, [messages]); - // Get recipeConfig from multiple sources with priority: - // 1. Chat context (persisted recipe) - // 2. Passed recipeConfig parameter - // 3. App config (from deeplinks) - const finalRecipeConfig = useMemo(() => { - if (chatContext?.chat.recipeConfig !== undefined) { - return chatContext.chat.recipeConfig; - } - - if (recipeConfig !== undefined) { - return recipeConfig; - } - - const appRecipeConfig = window.appConfig.get('recipe') as Recipe | null; - if (appRecipeConfig) { - return appRecipeConfig; - } - return null; - }, [chatContext, recipeConfig]); - - const recipeParameters = useMemo(() => { - return chatContext?.chat.recipeParameters || null; - }, [chatContext?.chat.recipeParameters]); + const finalRecipeConfig = chat.recipeConfig; useEffect(() => { if (!chatContext?.setRecipeConfig) return; @@ -89,18 +69,18 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = checkRecipeAcceptance(); }, [finalRecipeConfig]); + const requiresParameters = !!finalRecipeConfig?.parameters?.length; + const hasParameters = !!recipeParameters; + const hasMessages = messages.length > 0; useEffect(() => { // If we have parameters and they haven't been set yet, open the modal. - if ( - finalRecipeConfig?.parameters && - finalRecipeConfig.parameters.length > 0 && - recipeAccepted - ) { - if (!recipeParameters) { + console.log('should open modal', requiresParameters, hasParameters, recipeAccepted); + if (requiresParameters && recipeAccepted) { + if (!hasParameters && !hasMessages) { setIsParameterModalOpen(true); } } - }, [finalRecipeConfig, recipeParameters, recipeAccepted]); + }, [requiresParameters, hasParameters, recipeAccepted, hasMessages]); useEffect(() => { setReadyForAutoUserPrompt(true); @@ -122,9 +102,7 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = }, [finalRecipeConfig, recipeParameters, recipeAccepted]); const handleParameterSubmit = async (inputValues: Record) => { - if (chatContext?.setRecipeParameters) { - chatContext.setRecipeParameters(inputValues); - } + setRecipeParameters(inputValues); setIsParameterModalOpen(false); try { @@ -257,16 +235,6 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = }; }, []); - const resetRecipe = () => { - chatContext?.setRecipeConfig(null); - chatContext?.setRecipeParameters(null); - setRecipeAccepted(false); - setIsParameterModalOpen(false); - setIsRecipeWarningModalOpen(false); - setRecipeError(null); - setHasSecurityWarnings(false); - }; - return { recipeConfig: finalRecipeConfig, initialPrompt, @@ -284,6 +252,5 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) = handleRecipeAccept, handleRecipeCancel, hasSecurityWarnings, - resetRecipe, }; };