diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index dbcc674e40..d8aafe0cf7 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -11,6 +11,7 @@ import { GetTrajectoryResponse, GitChangeDiff, GitChange, + GetMicroagentsResponse, GetMicroagentPromptResponse, } from "./open-hands.types"; import { openHands } from "./open-hands-axios"; @@ -395,6 +396,21 @@ class OpenHands { return data; } + /** + * Get the available microagents associated with a conversation + * @param conversationId The ID of the conversation + * @returns The available microagents associated with the conversation + */ + static async getMicroagents( + conversationId: string, + ): Promise { + const url = `${this.getConversationUrl(conversationId)}/microagents`; + const { data } = await openHands.get(url, { + headers: this.getConversationHeaders(), + }); + return data; + } + static async getMicroagentPrompt( conversationId: string, eventId: number, diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index 7cf0531303..25a3dbb00a 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -103,6 +103,22 @@ export interface GitChangeDiff { original: string; } +export interface InputMetadata { + name: string; + description: string; +} + +export interface Microagent { + name: string; + type: "repo" | "knowledge"; + content: string; + triggers: string[]; +} + +export interface GetMicroagentsResponse { + microagents: Microagent[]; +} + export interface GetMicroagentPromptResponse { status: string; prompt: string; diff --git a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx index 0ee1cd425c..13cebe1122 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx @@ -1,7 +1,9 @@ +import { useTranslation } from "react-i18next"; import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; import { cn } from "#/utils/utils"; import { ContextMenu } from "../context-menu/context-menu"; import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { I18nKey } from "#/i18n/declaration"; interface ConversationCardContextMenuProps { onClose: () => void; @@ -9,6 +11,7 @@ interface ConversationCardContextMenuProps { onEdit?: (event: React.MouseEvent) => void; onDisplayCost?: (event: React.MouseEvent) => void; onShowAgentTools?: (event: React.MouseEvent) => void; + onShowMicroagents?: (event: React.MouseEvent) => void; onDownloadViaVSCode?: (event: React.MouseEvent) => void; position?: "top" | "bottom"; } @@ -19,9 +22,11 @@ export function ConversationCardContextMenu({ onEdit, onDisplayCost, onShowAgentTools, + onShowMicroagents, onDownloadViaVSCode, position = "bottom", }: ConversationCardContextMenuProps) { + const { t } = useTranslation(); const ref = useClickOutsideElement(onClose); return ( @@ -68,6 +73,14 @@ export function ConversationCardContextMenu({ Show Agent Tools & Metadata )} + {onShowMicroagents && ( + + {t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)} + + )} ); } diff --git a/frontend/src/components/features/conversation-panel/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card.tsx index c3bcf25e2d..4938e4a931 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card.tsx @@ -11,6 +11,7 @@ import { import { EllipsisButton } from "./ellipsis-button"; import { ConversationCardContextMenu } from "./conversation-card-context-menu"; import { SystemMessageModal } from "./system-message-modal"; +import { MicroagentsModal } from "./microagents-modal"; import { cn } from "#/utils/utils"; import { BaseModal } from "../../shared/modals/base-modal/base-modal"; import { RootState } from "#/store"; @@ -59,6 +60,8 @@ export function ConversationCard({ const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view"); const [metricsModalVisible, setMetricsModalVisible] = React.useState(false); const [systemModalVisible, setSystemModalVisible] = React.useState(false); + const [microagentsModalVisible, setMicroagentsModalVisible] = + React.useState(false); const inputRef = React.useRef(null); const systemMessage = parsedEvents.find(isSystemMessage); @@ -142,6 +145,13 @@ export function ConversationCard({ setSystemModalVisible(true); }; + const handleShowMicroagents = ( + event: React.MouseEvent, + ) => { + event.stopPropagation(); + setMicroagentsModalVisible(true); + }; + React.useEffect(() => { if (titleMode === "edit") { inputRef.current?.focus(); @@ -225,6 +235,11 @@ export function ConversationCard({ ? handleShowAgentTools : undefined } + onShowMicroagents={ + showOptions && conversationId + ? handleShowMicroagents + : undefined + } position={variant === "compact" ? "top" : "bottom"} /> )} @@ -367,6 +382,13 @@ export function ConversationCard({ onClose={() => setSystemModalVisible(false)} systemMessage={systemMessage ? systemMessage.args : null} /> + + {microagentsModalVisible && ( + setMicroagentsModalVisible(false)} + conversationId={conversationId} + /> + )} ); } diff --git a/frontend/src/components/features/conversation-panel/microagents-modal.tsx b/frontend/src/components/features/conversation-panel/microagents-modal.tsx new file mode 100644 index 0000000000..88884b11a1 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/microagents-modal.tsx @@ -0,0 +1,142 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { I18nKey } from "#/i18n/declaration"; +import { useConversationMicroagents } from "#/hooks/query/use-conversation-microagents"; + +interface MicroagentsModalProps { + onClose: () => void; + conversationId: string | undefined; +} + +export function MicroagentsModal({ + onClose, + conversationId, +}: MicroagentsModalProps) { + const { t } = useTranslation(); + const [expandedAgents, setExpandedAgents] = useState>( + {}, + ); + + const { + data: microagents, + isLoading, + isError, + } = useConversationMicroagents({ + conversationId, + enabled: true, + }); + + const toggleAgent = (agentName: string) => { + setExpandedAgents((prev) => ({ + ...prev, + [agentName]: !prev[agentName], + })); + }; + + return ( + + +
+ +
+ +
+ {isLoading && ( +
+
+
+ )} + + {!isLoading && + (isError || !microagents || microagents.length === 0) && ( +
+

+ {isError + ? t(I18nKey.MICROAGENTS_MODAL$FETCH_ERROR) + : t(I18nKey.CONVERSATION$NO_MICROAGENTS)} +

+
+ )} + + {!isLoading && microagents && microagents.length > 0 && ( +
+ {microagents.map((agent) => { + const isExpanded = expandedAgents[agent.name] || false; + + return ( +
+ + + {isExpanded && ( +
+ {agent.triggers && agent.triggers.length > 0 && ( +
+

+ {t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)} +

+
+ {agent.triggers.map((trigger) => ( + + {trigger} + + ))} +
+
+ )} + +
+

+ {t(I18nKey.MICROAGENTS_MODAL$CONTENT)} +

+
+
+                              {agent.content ||
+                                t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
+                            
+
+
+
+ )} +
+ ); + })} +
+ )} +
+ + + ); +} diff --git a/frontend/src/hooks/query/use-conversation-microagents.ts b/frontend/src/hooks/query/use-conversation-microagents.ts new file mode 100644 index 0000000000..2a53c747ef --- /dev/null +++ b/frontend/src/hooks/query/use-conversation-microagents.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; + +interface UseConversationMicroagentsOptions { + conversationId: string | undefined; + enabled?: boolean; +} + +export const useConversationMicroagents = ({ + conversationId, + enabled = true, +}: UseConversationMicroagentsOptions) => + useQuery({ + queryKey: ["conversation", conversationId, "microagents"], + queryFn: async () => { + if (!conversationId) { + throw new Error("No conversation ID provided"); + } + const data = await OpenHands.getMicroagents(conversationId); + return data.microagents; + }, + enabled: !!conversationId && enabled, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 104fdcd827..9770feef91 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1,15 +1,5 @@ // this file generate by script, don't modify it manually!!! export enum I18nKey { - MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND", - MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT", - MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD", - MICROAGENT$WHERE_TO_PUT = "MICROAGENT$WHERE_TO_PUT", - MICROAGENT$ADD_TRIGGER = "MICROAGENT$ADD_TRIGGER", - MICROAGENT$WAIT_FOR_RUNTIME = "MICROAGENT$WAIT_FOR_RUNTIME", - MICROAGENT$ADDING_CONTEXT = "MICROAGENT$ADDING_CONTEXT", - MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION", - MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY", - STATUS$CONNECTING_TO_RUNTIME = "STATUS$CONNECTING_TO_RUNTIME", STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED", HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH", HOME$READ_THIS = "HOME$READ_THIS", @@ -376,6 +366,7 @@ export enum I18nKey { FEEDBACK$PUBLIC_LABEL = "FEEDBACK$PUBLIC_LABEL", FEEDBACK$PRIVATE_LABEL = "FEEDBACK$PRIVATE_LABEL", SIDEBAR$CONVERSATIONS = "SIDEBAR$CONVERSATIONS", + STATUS$CONNECTING_TO_RUNTIME = "STATUS$CONNECTING_TO_RUNTIME", STATUS$STARTING_RUNTIME = "STATUS$STARTING_RUNTIME", STATUS$STARTING_CONTAINER = "STATUS$STARTING_CONTAINER", STATUS$PREPARING_CONTAINER = "STATUS$PREPARING_CONTAINER", @@ -554,6 +545,16 @@ export enum I18nKey { TOS$CONTINUE = "TOS$CONTINUE", TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING", TIPS$CUSTOMIZE_MICROAGENT = "TIPS$CUSTOMIZE_MICROAGENT", + CONVERSATION$SHOW_MICROAGENTS = "CONVERSATION$SHOW_MICROAGENTS", + CONVERSATION$NO_MICROAGENTS = "CONVERSATION$NO_MICROAGENTS", + CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS", + MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE", + MICROAGENTS_MODAL$TRIGGERS = "MICROAGENTS_MODAL$TRIGGERS", + MICROAGENTS_MODAL$INPUTS = "MICROAGENTS_MODAL$INPUTS", + MICROAGENTS_MODAL$TOOLS = "MICROAGENTS_MODAL$TOOLS", + MICROAGENTS_MODAL$CONTENT = "MICROAGENTS_MODAL$CONTENT", + MICROAGENTS_MODAL$NO_CONTENT = "MICROAGENTS_MODAL$NO_CONTENT", + MICROAGENTS_MODAL$FETCH_ERROR = "MICROAGENTS_MODAL$FETCH_ERROR", TIPS$SETUP_SCRIPT = "TIPS$SETUP_SCRIPT", TIPS$VSCODE_INSTANCE = "TIPS$VSCODE_INSTANCE", TIPS$SAVE_WORK = "TIPS$SAVE_WORK", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index b95d91f48f..455cbd5932 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -8704,20 +8704,180 @@ "tr": "Hizmet Şartlarını kabul ederken hata oluştu" }, "TIPS$CUSTOMIZE_MICROAGENT": { - "en": "You can customize OpenHands for your repo using a microagent. Ask OpenHands to put a description of the repo, including how to run the code, into .openhands/microagents/repo.md.", - "ja": "マイクロエージェントを使用して、リポジトリ用にOpenHandsをカスタマイズできます。OpenHandsに、コードの実行方法を含むリポジトリの説明を.openhands/microagents/repo.mdに入れるよう依頼してください。", - "zh-CN": "您可以使用微代理为您的仓库自定义OpenHands。请OpenHands将仓库的描述(包括如何运行代码)放入.openhands/microagents/repo.md。", - "zh-TW": "您可以使用微代理為您的倉庫自定義OpenHands。請OpenHands將倉庫的描述(包括如何運行代碼)放入.openhands/microagents/repo.md。", - "ko-KR": "마이크로에이전트를 사용하여 저장소에 맞게 OpenHands를 사용자 정의할 수 있습니다. OpenHands에게 코드 실행 방법을 포함한 저장소 설명을 .openhands/microagents/repo.md에 넣도록 요청하세요.", - "no": "Du kan tilpasse OpenHands for ditt repo ved å bruke en mikroagent. Be OpenHands om å legge en beskrivelse av repoet, inkludert hvordan du kjører koden, i .openhands/microagents/repo.md.", - "it": "Puoi personalizzare OpenHands per il tuo repository utilizzando un microagente. Chiedi a OpenHands di inserire una descrizione del repository, incluso come eseguire il codice, in .openhands/microagents/repo.md.", - "pt": "Você pode personalizar o OpenHands para seu repositório usando um microagente. Peça ao OpenHands para colocar uma descrição do repositório, incluindo como executar o código, em .openhands/microagents/repo.md.", - "es": "Puede personalizar OpenHands para su repositorio utilizando un microagente. Pídale a OpenHands que ponga una descripción del repositorio, incluido cómo ejecutar el código, en .openhands/microagents/repo.md.", - "ar": "يمكنك تخصيص OpenHands لمستودعك باستخدام وكيل مصغر. اطلب من OpenHands وضع وصف للمستودع، بما في ذلك كيفية تشغيل الكود، في .openhands/microagents/repo.md.", - "fr": "Vous pouvez personnaliser OpenHands pour votre dépôt en utilisant un micro-agent. Demandez à OpenHands de mettre une description du dépôt, y compris comment exécuter le code, dans .openhands/microagents/repo.md.", - "tr": "Bir mikro ajan kullanarak deponuz için OpenHands'i özelleştirebilirsiniz. OpenHands'ten kodun nasıl çalıştırılacağı da dahil olmak üzere deponun açıklamasını .openhands/microagents/repo.md dosyasına koymasını isteyin.", - "de": "Sie können OpenHands für Ihr Repository mit einem Mikroagenten anpassen. Bitten Sie OpenHands, eine Beschreibung des Repositorys, einschließlich der Ausführung des Codes, in .openhands/microagents/repo.md zu platzieren.", - "uk": "Ви можете налаштувати OpenHands для вашого репозиторію за допомогою мікроагента. Попросіть OpenHands розмістити опис репозиторію, включаючи інструкції з запуску коду, у файлі .openhands/microagents/repo.md." + "en": "You can customize OpenHands for your repo using an available microagent. Ask OpenHands to put a description of the repo, including how to run the code, into .openhands/microagents/repo.md.", + "ja": "利用可能なマイクロエージェントを使用して、リポジトリ用にOpenHandsをカスタマイズできます。OpenHandsに、コードの実行方法を含むリポジトリの説明を.openhands/microagents/repo.mdに入れるよう依頼してください。", + "zh-CN": "您可以使用可用微代理为您的仓库自定义OpenHands。请OpenHands将仓库的描述(包括如何运行代码)放入.openhands/microagents/repo.md。", + "zh-TW": "您可以使用可用微代理為您的倉庫自定義OpenHands。請OpenHands將倉庫的描述(包括如何運行代碼)放入.openhands/microagents/repo.md。", + "ko-KR": "사용 가능한 마이크로에이전트를 사용하여 저장소에 맞게 OpenHands를 사용자 정의할 수 있습니다. OpenHands에게 코드 실행 방법을 포함한 저장소 설명을 .openhands/microagents/repo.md에 넣도록 요청하세요.", + "no": "Du kan tilpasse OpenHands for ditt repo ved å bruke en tilgjengelig mikroagent. Be OpenHands om å legge en beskrivelse av repoet, inkludert hvordan du kjører koden, i .openhands/microagents/repo.md.", + "it": "Puoi personalizzare OpenHands per il tuo repository utilizzando un microagente disponibile. Chiedi a OpenHands di inserire una descrizione del repository, incluso come eseguire il codice, in .openhands/microagents/repo.md.", + "pt": "Você pode personalizar o OpenHands para seu repositório usando um microagente disponível. Peça ao OpenHands para colocar uma descrição do repositório, incluindo como executar o código, em .openhands/microagents/repo.md.", + "es": "Puede personalizar OpenHands para su repositorio utilizando un microagente disponible. Pídale a OpenHands que ponga una descripción del repositorio, incluido cómo ejecutar el código, en .openhands/microagents/repo.md.", + "ar": "يمكنك تخصيص OpenHands لمستودعك باستخدام وكيل مصغر متاح. اطلب من OpenHands وضع وصف للمستودع، بما في ذلك كيفية تشغيل الكود، في .openhands/microagents/repo.md.", + "de": "Sie können OpenHands für Ihr Repository mit einem verfügbaren Mikroagenten anpassen. Bitten Sie OpenHands, eine Beschreibung des Repositorys, einschließlich der Ausführung des Codes, in .openhands/microagents/repo.md zu platzieren.", + "fr": "Vous pouvez personnaliser OpenHands pour votre dépôt en utilisant un micro-agent disponible. Demandez à OpenHands de mettre une description du dépôt, y compris comment exécuter le code, dans .openhands/microagents/repo.md.", + "tr": "Kullanılabilir bir mikro ajan kullanarak OpenHands'i deponuz için özelleştirebilirsiniz. OpenHands'ten deponun açıklamasını, kodun nasıl çalıştırılacağı dahil, .openhands/microagents/repo.md dosyasına koymasını isteyin.", + "uk": "Ви можете налаштувати OpenHands для свого репозиторію за допомогою доступного мікроагента. Попросіть OpenHands розмістити опис репозиторію, включаючи інформацію про те, як запустити код, у файлі .openhands/microagents/repo.md." + }, + "CONVERSATION$SHOW_MICROAGENTS": { + "en": "Show Available Microagents", + "ja": "利用可能なマイクロエージェントを表示", + "zh-CN": "显示可用微代理", + "zh-TW": "顯示可用微代理", + "ko-KR": "사용 가능한 마이크로에이전트 표시", + "no": "Vis tilgjengelige mikroagenter", + "ar": "عرض الوكلاء المصغرين المتاحة", + "de": "Verfügbare Mikroagenten anzeigen", + "fr": "Afficher les micro-agents disponibles", + "it": "Mostra microagenti disponibili", + "pt": "Mostrar microagentes disponíveis", + "es": "Mostrar microagentes disponibles", + "tr": "Kullanılabilir mikro ajanları göster", + "uk": "Показати доступних мікроагентів" + }, + "CONVERSATION$NO_MICROAGENTS": { + "en": "No available microagents found for this conversation.", + "ja": "この会話用の利用可能なマイクロエージェントが見つかりませんでした。", + "zh-CN": "未找到此对话的可用微代理。", + "zh-TW": "未找到此對話的可用微代理。", + "ko-KR": "이 대화에 대한 사용 가능한 마이크로에이전트를 찾을 수 없습니다.", + "no": "Ingen tilgjengelige mikroagenter funnet for denne samtalen.", + "ar": "لم يتم العثور على وكلاء مصغرين متاحة لهذه المحادثة.", + "de": "Keine verfügbaren Mikroagenten für dieses Gespräch gefunden.", + "fr": "Aucun micro-agent disponible trouvé pour cette conversation.", + "it": "Nessun microagente disponibile trovato per questa conversazione.", + "pt": "Nenhum microagente disponível encontrado para esta conversa.", + "es": "No se encontraron microagentes disponibles para esta conversación.", + "tr": "Bu konuşma için kullanılabilir mikro ajan bulunamadı.", + "uk": "Для цієї розмови не знайдено доступних мікроагентів." + }, + "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS": { + "en": "Failed to fetch available microagents", + "ja": "利用可能なマイクロエージェントの取得に失敗しました", + "zh-CN": "获取可用微代理失败", + "zh-TW": "獲取可用微代理失敗", + "ko-KR": "사용 가능한 마이크로에이전트를 가져오지 못했습니다", + "no": "Kunne ikke hente tilgjengelige mikroagenter", + "ar": "فشل في جلب الوكلاء المصغرين المتاحة", + "de": "Fehler beim Abrufen von verfügbaren Mikroagenten", + "fr": "Échec de la récupération des micro-agents disponibles", + "it": "Impossibile recuperare i microagenti disponibili", + "pt": "Falha ao buscar microagentes disponíveis", + "es": "Error al obtener microagentes disponibles", + "tr": "Kullanılabilir mikro ajanlar getirilemedi", + "uk": "Не вдалося отримати доступних мікроагентів" + }, + "MICROAGENTS_MODAL$TITLE": { + "en": "Available Microagents", + "ja": "利用可能なマイクロエージェント", + "zh-CN": "可用微代理", + "zh-TW": "可用微代理", + "ko-KR": "사용 가능한 마이크로에이전트", + "no": "Tilgjengelige mikroagenter", + "ar": "الوكلاء المصغرين المتاحة", + "de": "Verfügbare Mikroagenten", + "fr": "Micro-agents disponibles", + "it": "Microagenti disponibili", + "pt": "Microagentes disponíveis", + "es": "Microagentes disponibles", + "tr": "Kullanılabilir mikro ajanlar", + "uk": "Доступні мікроагенти" + }, + "MICROAGENTS_MODAL$TRIGGERS": { + "en": "Triggers", + "ja": "トリガー", + "zh-CN": "触发器", + "zh-TW": "觸發器", + "ko-KR": "트리거", + "no": "Utløsere", + "ar": "المحفزات", + "de": "Auslöser", + "fr": "Déclencheurs", + "it": "Trigger", + "pt": "Gatilhos", + "es": "Disparadores", + "tr": "Tetikleyiciler", + "uk": "Тригери" + }, + "MICROAGENTS_MODAL$INPUTS": { + "en": "Inputs", + "ja": "入力", + "zh-CN": "输入", + "zh-TW": "輸入", + "ko-KR": "입력", + "no": "Inndata", + "ar": "المدخلات", + "de": "Eingaben", + "fr": "Entrées", + "it": "Input", + "pt": "Entradas", + "es": "Entradas", + "tr": "Girdiler", + "uk": "Вхідні дані" + }, + "MICROAGENTS_MODAL$TOOLS": { + "en": "Tools", + "ja": "ツール", + "zh-CN": "工具", + "zh-TW": "工具", + "ko-KR": "도구", + "no": "Verktøy", + "ar": "الأدوات", + "de": "Werkzeuge", + "fr": "Outils", + "it": "Strumenti", + "pt": "Ferramentas", + "es": "Herramientas", + "tr": "Araçlar", + "uk": "Інструменти" + }, + "MICROAGENTS_MODAL$CONTENT": { + "en": "Content", + "ja": "コンテンツ", + "zh-CN": "内容", + "zh-TW": "內容", + "ko-KR": "콘텐츠", + "no": "Innhold", + "ar": "المحتوى", + "de": "Inhalt", + "fr": "Contenu", + "it": "Contenuto", + "pt": "Conteúdo", + "es": "Contenido", + "tr": "İçerik", + "uk": "Вміст" + }, + "MICROAGENTS_MODAL$NO_CONTENT": { + "en": "Microagent has no content", + "ja": "マイクロエージェントにコンテンツがありません", + "zh-CN": "微代理没有内容", + "zh-TW": "微代理沒有內容", + "ko-KR": "마이크로에이전트에 콘텐츠가 없습니다", + "no": "Mikroagenten har ikke innhold", + "ar": "الوكيل المصغر ليس لديه محتوى", + "de": "Mikroagent hat keinen Inhalt", + "fr": "Le micro-agent n'a pas de contenu", + "it": "Il microagente non ha contenuto", + "pt": "Microagente não tem conteúdo", + "es": "El microagente no tiene contenido", + "tr": "Mikroajanın içeriği yok", + "uk": "Мікроагент не має вмісту" + }, + "MICROAGENTS_MODAL$FETCH_ERROR": { + "en": "Failed to fetch microagents. Please try again later.", + "ja": "マイクロエージェントの取得に失敗しました。後でもう一度お試しください。", + "zh-CN": "获取微代理失败。请稍后再试。", + "zh-TW": "獲取微代理失敗。請稍後再試。", + "ko-KR": "마이크로에이전트를 가져오지 못했습니다. 나중에 다시 시도해 주세요.", + "no": "Kunne ikke hente mikroagenter. Prøv igjen senere.", + "ar": "فشل في جلب الوكلاء المصغرين. يرجى المحاولة مرة أخرى لاحقًا.", + "de": "Mikroagenten konnten nicht abgerufen werden. Bitte versuchen Sie es später erneut.", + "fr": "Échec de la récupération des micro-agents. Veuillez réessayer plus tard.", + "it": "Impossibile recuperare i microagenti. Riprova più tardi.", + "pt": "Falha ao buscar microagentes. Por favor, tente novamente mais tarde.", + "es": "Error al obtener microagentes. Por favor, inténtelo de nuevo más tarde.", + "tr": "Mikroajanlar getirilemedi. Lütfen daha sonra tekrar deneyin.", + "uk": "Не вдалося отримати мікроагентів. Будь ласка, спробуйте пізніше." }, "TIPS$SETUP_SCRIPT": { "en": "You can add .openhands/setup.sh to your repository to automatically run a setup script every time you start an OpenHands conversation.", diff --git a/openhands/server/conversation_manager/conversation_manager.py b/openhands/server/conversation_manager/conversation_manager.py index e1efb433b8..f8d1843d3f 100644 --- a/openhands/server/conversation_manager/conversation_manager.py +++ b/openhands/server/conversation_manager/conversation_manager.py @@ -9,6 +9,7 @@ from openhands.events.action import MessageAction from openhands.server.config.server_config import ServerConfig from openhands.server.data_models.agent_loop_info import AgentLoopInfo from openhands.server.monitoring import MonitoringListener +from openhands.server.session.agent_session import AgentSession from openhands.server.session.conversation import ServerConversation from openhands.storage.conversation.conversation_store import ConversationStore from openhands.storage.data_models.settings import Settings @@ -114,6 +115,17 @@ class ConversationManager(ABC): async def close_session(self, sid: str): """Close a session.""" + @abstractmethod + def get_agent_session(self, sid: str) -> AgentSession | None: + """Get the agent session for a given session ID. + + Args: + sid: The session ID. + + Returns: + The agent session, or None if not found. + """ + @abstractmethod async def get_agent_loop_info( self, user_id: str | None = None, filter_to_sids: set[str] | None = None diff --git a/openhands/server/conversation_manager/docker_nested_conversation_manager.py b/openhands/server/conversation_manager/docker_nested_conversation_manager.py index 2d2161a8dd..22fb55ca47 100644 --- a/openhands/server/conversation_manager/docker_nested_conversation_manager.py +++ b/openhands/server/conversation_manager/docker_nested_conversation_manager.py @@ -307,7 +307,9 @@ class DockerNestedConversationManager(ConversationManager): await asyncio.sleep(1) except Exception as e: - logger.warning('error_stopping_container', extra={"sid": sid, "error": str(e)}) + logger.warning( + 'error_stopping_container', extra={'sid': sid, 'error': str(e)} + ) container.stop() async def get_agent_loop_info( @@ -364,6 +366,15 @@ class DockerNestedConversationManager(ConversationManager): file_store=file_store, ) + def get_agent_session(self, sid: str): + """Get the agent session for a given session ID. + Args: + sid: The session ID. + Returns: + The agent session, or None if not found. + """ + raise ValueError('unsupported_operation') + async def _get_conversation_store(self, user_id: str | None) -> ConversationStore: conversation_store_class = self._conversation_store_class if not conversation_store_class: diff --git a/openhands/server/conversation_manager/standalone_conversation_manager.py b/openhands/server/conversation_manager/standalone_conversation_manager.py index c27aa03966..2082fdb3e0 100644 --- a/openhands/server/conversation_manager/standalone_conversation_manager.py +++ b/openhands/server/conversation_manager/standalone_conversation_manager.py @@ -15,7 +15,7 @@ from openhands.events.stream import EventStreamSubscriber, session_exists from openhands.server.config.server_config import ServerConfig from openhands.server.data_models.agent_loop_info import AgentLoopInfo from openhands.server.monitoring import MonitoringListener -from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE +from openhands.server.session.agent_session import AgentSession, WAIT_TIME_BEFORE_CLOSE from openhands.server.session.conversation import ServerConversation from openhands.server.session.session import ROOM_KEY, Session from openhands.storage.conversation.conversation_store import ConversationStore @@ -112,7 +112,7 @@ class StandaloneConversationManager(ConversationManager): end_time = time.time() logger.info( f'ServerConversation {c.sid} connected in {end_time - start_time} seconds', - extra={'session_id': sid} + extra={'session_id': sid}, ) self._active_conversations[sid] = (c, 1) return c @@ -356,6 +356,20 @@ class StandaloneConversationManager(ConversationManager): if session: await self._close_session(sid) + def get_agent_session(self, sid: str) -> AgentSession | None: + """Get the agent session for a given session ID. + + Args: + sid: The session ID. + + Returns: + The agent session, or None if not found. + """ + session = self._local_agent_loops_by_sid.get(sid) + if session: + return session.agent_session + return None + async def _close_session(self, sid: str): logger.info(f'_close_session:{sid}', extra={'session_id': sid}) diff --git a/openhands/server/routes/conversation.py b/openhands/server/routes/conversation.py index 1acf3ce992..5fbb1dc205 100644 --- a/openhands/server/routes/conversation.py +++ b/openhands/server/routes/conversation.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import JSONResponse +from pydantic import BaseModel from openhands.core.logger import openhands_logger as logger from openhands.events.event_filter import EventFilter @@ -9,12 +10,18 @@ from openhands.server.dependencies import get_dependencies from openhands.server.session.conversation import ServerConversation from openhands.server.shared import conversation_manager from openhands.server.utils import get_conversation +from openhands.microagent.types import InputMetadata +from openhands.memory.memory import Memory -app = APIRouter(prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies()) +app = APIRouter( + prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies() +) @app.get('/config') -async def get_remote_runtime_config(conversation: ServerConversation = Depends(get_conversation)) -> JSONResponse: +async def get_remote_runtime_config( + conversation: ServerConversation = Depends(get_conversation), +) -> JSONResponse: """Retrieve the runtime configuration. Currently, this is the session ID and runtime ID (if available). @@ -31,7 +38,9 @@ async def get_remote_runtime_config(conversation: ServerConversation = Depends(g @app.get('/vscode-url') -async def get_vscode_url(conversation: ServerConversation = Depends(get_conversation)) -> JSONResponse: +async def get_vscode_url( + conversation: ServerConversation = Depends(get_conversation), +) -> JSONResponse: """Get the VSCode URL. This endpoint allows getting the VSCode URL. @@ -61,7 +70,9 @@ async def get_vscode_url(conversation: ServerConversation = Depends(get_conversa @app.get('/web-hosts') -async def get_hosts(conversation: ServerConversation = Depends(get_conversation)) -> JSONResponse: +async def get_hosts( + conversation: ServerConversation = Depends(get_conversation), +) -> JSONResponse: """Get the hosts used by the runtime. This endpoint allows getting the hosts used by the runtime. @@ -143,7 +154,92 @@ async def search_events( @app.post('/events') -async def add_event(request: Request, conversation: ServerConversation = Depends(get_conversation)): +async def add_event( + request: Request, conversation: ServerConversation = Depends(get_conversation) +): data = request.json() await conversation_manager.send_to_event_stream(conversation.sid, data) return JSONResponse({'success': True}) + + +class MicroagentResponse(BaseModel): + """Response model for microagents endpoint.""" + + name: str + type: str + content: str + triggers: list[str] = [] + inputs: list[InputMetadata] = [] + tools: list[str] = [] + + +@app.get('/microagents') +async def get_microagents( + conversation: ServerConversation = Depends(get_conversation), +) -> JSONResponse: + """Get all microagents associated with the conversation. + + This endpoint returns all repository and knowledge microagents that are loaded for the conversation. + + Returns: + JSONResponse: A JSON response containing the list of microagents. + """ + try: + # Get the agent session for this conversation + agent_session = conversation_manager.get_agent_session(conversation.sid) + + if not agent_session: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': 'Agent session not found for this conversation'}, + ) + + # Access the memory to get the microagents + memory: Memory | None = agent_session.memory + if memory is None: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={ + 'error': 'Memory is not yet initialized for this conversation' + }, + ) + + # Prepare the response + microagents = [] + + # Add repo microagents + for name, agent in memory.repo_microagents.items(): + microagents.append( + MicroagentResponse( + name=name, + type='repo', + content=agent.content, + triggers=[], + inputs=agent.metadata.inputs, + tools=[server.name for server in agent.metadata.mcp_tools.stdio_servers] if agent.metadata.mcp_tools else [], + ) + ) + + # Add knowledge microagents + for name, agent in memory.knowledge_microagents.items(): + microagents.append( + MicroagentResponse( + name=name, + type='knowledge', + content=agent.content, + triggers=agent.triggers, + inputs=agent.metadata.inputs, + tools=[server.name for server in agent.metadata.mcp_tools.stdio_servers] if agent.metadata.mcp_tools else [], + ) + ) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content={'microagents': [m.dict() for m in microagents]}, + ) + except Exception as e: + logger.error(f'Error getting microagents: {e}') + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={'error': f'Error getting microagents: {e}'}, + ) diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index cc80cfcf91..5f1d1fa9f6 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -51,6 +51,7 @@ class AgentSession: controller: AgentController | None = None runtime: Runtime | None = None security_analyzer: SecurityAnalyzer | None = None + memory: Memory | None = None _starting: bool = False _started_at: float = 0 _closed: bool = False diff --git a/poetry.lock b/poetry.lock index 4813243d51..e088858268 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiofiles" @@ -400,7 +400,7 @@ description = "LTS Port of Python audioop" optional = false python-versions = ">=3.13" groups = ["main"] -markers = "python_version >= \"3.13\"" +markers = "python_version == \"3.13\"" files = [ {file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"}, {file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"}, @@ -1580,7 +1580,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\" or os_name == \"nt\"", dev = "os_name == \"nt\"", runtime = "sys_platform == \"win32\"", test = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "platform_system == \"Windows\" or os_name == \"nt\" or sys_platform == \"win32\"", dev = "os_name == \"nt\" or sys_platform == \"win32\"", runtime = "sys_platform == \"win32\"", test = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "comm" @@ -2974,8 +2974,8 @@ files = [ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" proto-plus = [ - {version = ">=1.22.3,<2.0.0dev"}, {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0dev"}, ] protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" @@ -2997,8 +2997,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0" grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = [ - {version = ">=1.22.3,<2.0.0"}, {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" requests = ">=2.18.0,<3.0.0" @@ -3017,6 +3017,7 @@ optional = false python-versions = ">=3.7" groups = ["main"] files = [ + {file = "google_api_python_client-2.171.0-py3-none-any.whl", hash = "sha256:c9c9b76f561e9d9ac14e54a9e2c0842876201d5b96e69e48f967373f0784cbe9"}, {file = "google_api_python_client-2.171.0.tar.gz", hash = "sha256:057a5c08d28463c6b9eb89746355de5f14b7ed27a65c11fdbf1d06c66bb66b23"}, ] @@ -3215,8 +3216,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" grpc-google-iam-v1 = ">=0.14.0,<1.0.0" proto-plus = [ - {version = ">=1.22.3,<2.0.0"}, {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, ] protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" @@ -3896,7 +3897,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" -groups = ["evaluation", "test"] +groups = ["dev", "evaluation", "test"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -6481,8 +6482,8 @@ files = [ [package.dependencies] googleapis-common-protos = ">=1.52,<2.0" grpcio = [ - {version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""}, {version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""}, ] opentelemetry-api = ">=1.15,<2.0" opentelemetry-exporter-otlp-proto-common = "1.34.0" @@ -6902,7 +6903,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["evaluation", "test"] +groups = ["dev", "evaluation", "test"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -7667,7 +7668,7 @@ version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main", "evaluation", "runtime", "test"] +groups = ["main", "dev", "evaluation", "runtime", "test"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -7820,7 +7821,7 @@ version = "8.4.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["evaluation", "test"] +groups = ["dev", "evaluation", "test"] files = [ {file = "pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e"}, {file = "pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6"}, @@ -9244,7 +9245,6 @@ files = [ {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] -markers = {evaluation = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] @@ -9487,7 +9487,7 @@ description = "Standard library aifc redistribution. \"dead battery\"." optional = false python-versions = "*" groups = ["main"] -markers = "python_version >= \"3.13\"" +markers = "python_version == \"3.13\"" files = [ {file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"}, {file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"}, @@ -9504,7 +9504,7 @@ description = "Standard library chunk redistribution. \"dead battery\"." optional = false python-versions = "*" groups = ["main"] -markers = "python_version >= \"3.13\"" +markers = "python_version == \"3.13\"" files = [ {file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"}, {file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"}, @@ -11666,4 +11666,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "c9a787a0d9eebd64f46961ad726ac0a75a7420f0fcb22f87f2bf689d1ccb389c" +content-hash = "beb348a8a07835531e5dba2c8807fdf68669fe239c630d0420dc204b3b2648ed" diff --git a/pyproject.toml b/pyproject.toml index c17cee202b..1db83baea5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ mypy = "1.16.0" pre-commit = "4.2.0" build = "*" types-setuptools = "*" +pytest = "^8.4.0" [tool.poetry.group.test] optional = true diff --git a/tests/unit/test_conversation_routes.py b/tests/unit/test_conversation_routes.py new file mode 100644 index 0000000000..e78c2e7643 --- /dev/null +++ b/tests/unit/test_conversation_routes.py @@ -0,0 +1,157 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.responses import JSONResponse + +from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent +from openhands.microagent.types import MicroagentMetadata, MicroagentType +from openhands.server.routes.conversation import get_microagents +from openhands.server.session.conversation import ServerConversation + + +@pytest.mark.asyncio +async def test_get_microagents(): + """Test the get_microagents function directly.""" + # Create mock microagents + from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig + + repo_microagent = RepoMicroagent( + name='test_repo', + content='This is a test repo microagent', + metadata=MicroagentMetadata( + name='test_repo', + type=MicroagentType.REPO_KNOWLEDGE, + inputs=[], # Empty inputs to match the expected behavior + mcp_tools=MCPConfig( + stdio_servers=[ + MCPStdioServerConfig(name='git', command='git'), + MCPStdioServerConfig(name='file_editor', command='editor'), + ] + ), + ), + source='test_source', + type=MicroagentType.REPO_KNOWLEDGE, + ) + + knowledge_microagent = KnowledgeMicroagent( + name='test_knowledge', + content='This is a test knowledge microagent', + metadata=MicroagentMetadata( + name='test_knowledge', + type=MicroagentType.KNOWLEDGE, + triggers=['test', 'knowledge'], + inputs=[], # Empty inputs to match the expected behavior + mcp_tools=MCPConfig( + stdio_servers=[ + MCPStdioServerConfig(name='search', command='search'), + MCPStdioServerConfig(name='fetch', command='fetch'), + ] + ), + ), + source='test_source', + type=MicroagentType.KNOWLEDGE, + ) + + # Mock the agent session and memory + mock_memory = MagicMock() + mock_memory.repo_microagents = {'test_repo': repo_microagent} + mock_memory.knowledge_microagents = {'test_knowledge': knowledge_microagent} + + mock_agent_session = MagicMock() + mock_agent_session.memory = mock_memory + + # Create a mock ServerConversation + mock_conversation = MagicMock(spec=ServerConversation) + mock_conversation.sid = 'test_sid' + + # Mock the conversation manager + with patch( + 'openhands.server.routes.conversation.conversation_manager' + ) as mock_manager: + # Set up the mocks + mock_manager.get_agent_session.return_value = mock_agent_session + + # Call the function directly + response = await get_microagents(conversation=mock_conversation) + + # Verify the response + assert isinstance(response, JSONResponse) + assert response.status_code == 200 + + # Parse the JSON content + content = json.loads(response.body) + assert 'microagents' in content + assert len(content['microagents']) == 2 + + # Check repo microagent + repo_agent = next(m for m in content['microagents'] if m['name'] == 'test_repo') + assert repo_agent['type'] == 'repo' + assert repo_agent['content'] == 'This is a test repo microagent' + assert repo_agent['triggers'] == [] + assert repo_agent['inputs'] == [] # Expect empty inputs + assert repo_agent['tools'] == ['git', 'file_editor'] + + # Check knowledge microagent + knowledge_agent = next( + m for m in content['microagents'] if m['name'] == 'test_knowledge' + ) + assert knowledge_agent['type'] == 'knowledge' + assert knowledge_agent['content'] == 'This is a test knowledge microagent' + assert knowledge_agent['triggers'] == ['test', 'knowledge'] + assert knowledge_agent['inputs'] == [] # Expect empty inputs + assert knowledge_agent['tools'] == ['search', 'fetch'] + + +@pytest.mark.asyncio +async def test_get_microagents_no_agent_session(): + """Test the get_microagents function when no agent session is found.""" + # Create a mock ServerConversation + mock_conversation = MagicMock(spec=ServerConversation) + mock_conversation.sid = 'test_sid' + + # Mock the conversation manager + with patch( + 'openhands.server.routes.conversation.conversation_manager' + ) as mock_manager: + # Set up the mocks + mock_manager.get_agent_session.return_value = None + + # Call the function directly + response = await get_microagents(conversation=mock_conversation) + + # Verify the response + assert isinstance(response, JSONResponse) + assert response.status_code == 404 + + # Parse the JSON content + content = json.loads(response.body) + assert 'error' in content + assert 'Agent session not found' in content['error'] + + +@pytest.mark.asyncio +async def test_get_microagents_exception(): + """Test the get_microagents function when an exception occurs.""" + # Create a mock ServerConversation + mock_conversation = MagicMock(spec=ServerConversation) + mock_conversation.sid = 'test_sid' + + # Mock the conversation manager + with patch( + 'openhands.server.routes.conversation.conversation_manager' + ) as mock_manager: + # Set up the mocks to raise an exception + mock_manager.get_agent_session.side_effect = Exception('Test exception') + + # Call the function directly + response = await get_microagents(conversation=mock_conversation) + + # Verify the response + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + + # Parse the JSON content + content = json.loads(response.body) + assert 'error' in content + assert 'Test exception' in content['error']