mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): Display MCP tool name and arguments as JSON in MCPObservation when visualized in frontend (#8644)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
b0b5a6c2a1
commit
5e43dbadcb
@ -8,7 +8,7 @@
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.7.6'
|
||||
const PACKAGE_VERSION = '2.8.4'
|
||||
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@ -46,14 +46,6 @@ const getBrowseObservationContent = (event: BrowseObservation) => {
|
||||
return contentDetails;
|
||||
};
|
||||
|
||||
const getMcpObservationContent = (event: OpenHandsObservation): string => {
|
||||
let { content } = event;
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
return `**Output:**\n\`\`\`\n${content.trim() || i18n.t("OBSERVATION$MCP_NO_OUTPUT")}\n\`\`\``;
|
||||
};
|
||||
|
||||
const getRecallObservationContent = (event: RecallObservation): string => {
|
||||
let content = "";
|
||||
|
||||
@ -124,8 +116,6 @@ export const getObservationContent = (event: OpenHandsObservation): string => {
|
||||
return getCommandObservationContent(event);
|
||||
case "browse":
|
||||
return getBrowseObservationContent(event);
|
||||
case "mcp":
|
||||
return getMcpObservationContent(event);
|
||||
case "recall":
|
||||
return getRecallObservationContent(event);
|
||||
default:
|
||||
|
||||
@ -8,11 +8,13 @@ import {
|
||||
isOpenHandsObservation,
|
||||
isFinishAction,
|
||||
isRejectObservation,
|
||||
isMcpObservation,
|
||||
} from "#/types/core/guards";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import { ImageCarousel } from "../images/image-carousel";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { ErrorMessage } from "./error-message";
|
||||
import { MCPObservationContent } from "./mcp-observation-content";
|
||||
import { getObservationResult } from "./event-content-helpers/get-observation-result";
|
||||
import { getEventContent } from "./event-content-helpers/get-event-content";
|
||||
import { GenericEventMessage } from "./generic-event-message";
|
||||
@ -78,6 +80,19 @@ export function EventMessage({
|
||||
return <ChatMessage type="agent" message={event.content} />;
|
||||
}
|
||||
|
||||
if (isMcpObservation(event)) {
|
||||
return (
|
||||
<div>
|
||||
<GenericEventMessage
|
||||
title={getEventContent(event).title}
|
||||
details={<MCPObservationContent event={event} />}
|
||||
success={getObservationResult(event)}
|
||||
/>
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isOpenHandsAction(event) && hasThoughtProperty(event.args) && (
|
||||
|
||||
@ -10,7 +10,7 @@ import { ObservationResultStatus } from "./event-content-helpers/get-observation
|
||||
|
||||
interface GenericEventMessageProps {
|
||||
title: React.ReactNode;
|
||||
details: string;
|
||||
details: string | React.ReactNode;
|
||||
success?: ObservationResultStatus;
|
||||
}
|
||||
|
||||
@ -44,18 +44,21 @@ export function GenericEventMessage({
|
||||
{success && <SuccessIndicator status={success} />}
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{details}
|
||||
</Markdown>
|
||||
)}
|
||||
{showDetails &&
|
||||
(typeof details === "string" ? (
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{details}
|
||||
</Markdown>
|
||||
) : (
|
||||
details
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import ReactJsonView from "@microlink/react-json-view";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MCPObservation } from "#/types/core/observations";
|
||||
import { JSON_VIEW_THEME } from "#/utils/constants";
|
||||
|
||||
interface MCPObservationContentProps {
|
||||
event: MCPObservation;
|
||||
}
|
||||
|
||||
export function MCPObservationContent({ event }: MCPObservationContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Parse the content as JSON if possible
|
||||
let outputData: unknown;
|
||||
try {
|
||||
outputData = JSON.parse(event.content);
|
||||
} catch (e) {
|
||||
// If parsing fails, use the raw content
|
||||
outputData = event.content;
|
||||
}
|
||||
|
||||
const hasArguments =
|
||||
event.extras.arguments && Object.keys(event.extras.arguments).length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Arguments section */}
|
||||
{hasArguments && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-300">
|
||||
{t("MCP_OBSERVATION$ARGUMENTS")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[200px] shadow-inner">
|
||||
<ReactJsonView
|
||||
name={false}
|
||||
src={event.extras.arguments}
|
||||
theme={JSON_VIEW_THEME}
|
||||
collapsed={1}
|
||||
displayDataTypes={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-300">
|
||||
{t("MCP_OBSERVATION$OUTPUT")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[300px] shadow-inner">
|
||||
{typeof outputData === "object" && outputData !== null ? (
|
||||
<ReactJsonView
|
||||
name={false}
|
||||
src={outputData}
|
||||
theme={JSON_VIEW_THEME}
|
||||
collapsed={1}
|
||||
displayDataTypes={false}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{event.content.trim() || t("OBSERVATION$MCP_NO_OUTPUT")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,26 +6,7 @@ import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/b
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
// Custom JSON viewer theme that matches our application theme
|
||||
const jsonViewTheme = {
|
||||
base00: "transparent", // background
|
||||
base01: "#2d2d2d", // lighter background
|
||||
base02: "#4e4e4e", // selection background
|
||||
base03: "#6c6c6c", // comments, invisibles
|
||||
base04: "#969896", // dark foreground
|
||||
base05: "#d9d9d9", // default foreground
|
||||
base06: "#e8e8e8", // light foreground
|
||||
base07: "#ffffff", // light background
|
||||
base08: "#ff5370", // variables, red
|
||||
base09: "#f78c6c", // integers, orange
|
||||
base0A: "#ffcb6b", // booleans, yellow
|
||||
base0B: "#c3e88d", // strings, green
|
||||
base0C: "#89ddff", // support, cyan
|
||||
base0D: "#82aaff", // functions, blue
|
||||
base0E: "#c792ea", // keywords, purple
|
||||
base0F: "#ff5370", // deprecated, red
|
||||
};
|
||||
import { JSON_VIEW_THEME } from "#/utils/constants";
|
||||
|
||||
interface SystemMessageModalProps {
|
||||
isOpen: boolean;
|
||||
@ -207,8 +188,9 @@ export function SystemMessageModal({
|
||||
</h4>
|
||||
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<ReactJsonView
|
||||
name={false}
|
||||
src={parameters}
|
||||
theme={jsonViewTheme}
|
||||
theme={JSON_VIEW_THEME}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,8 @@ export enum I18nKey {
|
||||
EVENT$UNKNOWN_EVENT = "EVENT$UNKNOWN_EVENT",
|
||||
OBSERVATION$COMMAND_NO_OUTPUT = "OBSERVATION$COMMAND_NO_OUTPUT",
|
||||
OBSERVATION$MCP_NO_OUTPUT = "OBSERVATION$MCP_NO_OUTPUT",
|
||||
MCP_OBSERVATION$ARGUMENTS = "MCP_OBSERVATION$ARGUMENTS",
|
||||
MCP_OBSERVATION$OUTPUT = "MCP_OBSERVATION$OUTPUT",
|
||||
OBSERVATION$ERROR_PREFIX = "OBSERVATION$ERROR_PREFIX",
|
||||
TASK$ADDRESSING_TASK = "TASK$ADDRESSING_TASK",
|
||||
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
|
||||
|
||||
@ -175,6 +175,38 @@
|
||||
"de": "[MCP-Tool wurde ohne Ausgabe ausgeführt]",
|
||||
"uk": "[Інструмент MCP завершив виконання без виводу]"
|
||||
},
|
||||
"MCP_OBSERVATION$ARGUMENTS": {
|
||||
"en": "Arguments",
|
||||
"ja": "引数",
|
||||
"zh-CN": "参数",
|
||||
"zh-TW": "參數",
|
||||
"ko-KR": "인수",
|
||||
"no": "Argumenter",
|
||||
"it": "Argomenti",
|
||||
"pt": "Argumentos",
|
||||
"es": "Argumentos",
|
||||
"ar": "المعاملات",
|
||||
"fr": "Arguments",
|
||||
"tr": "Argümanlar",
|
||||
"de": "Argumente",
|
||||
"uk": "Аргументи"
|
||||
},
|
||||
"MCP_OBSERVATION$OUTPUT": {
|
||||
"en": "Output",
|
||||
"ja": "出力",
|
||||
"zh-CN": "输出",
|
||||
"zh-TW": "輸出",
|
||||
"ko-KR": "출력",
|
||||
"no": "Utdata",
|
||||
"it": "Output",
|
||||
"pt": "Saída",
|
||||
"es": "Salida",
|
||||
"ar": "المخرجات",
|
||||
"fr": "Sortie",
|
||||
"tr": "Çıktı",
|
||||
"de": "Ausgabe",
|
||||
"uk": "Вивід"
|
||||
},
|
||||
"OBSERVATION$ERROR_PREFIX": {
|
||||
"en": "error:",
|
||||
"ja": "エラー:",
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
CommandObservation,
|
||||
ErrorObservation,
|
||||
MCPObservation,
|
||||
OpenHandsObservation,
|
||||
} from "./observations";
|
||||
|
||||
@ -57,3 +58,8 @@ export const isRejectObservation = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is OpenHandsObservation =>
|
||||
isOpenHandsObservation(event) && event.observation === "user_rejected";
|
||||
|
||||
export const isMcpObservation = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is MCPObservation =>
|
||||
isOpenHandsObservation(event) && event.observation === "mcp";
|
||||
|
||||
@ -135,6 +135,7 @@ export interface MCPObservation extends OpenHandsObservationEvent<"mcp"> {
|
||||
source: "agent";
|
||||
extras: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -9,3 +9,22 @@ export const ASSET_FILE_TYPES = [
|
||||
".webm",
|
||||
".ogg",
|
||||
];
|
||||
|
||||
export const JSON_VIEW_THEME = {
|
||||
base00: "transparent", // background
|
||||
base01: "#2d2d2d", // lighter background
|
||||
base02: "#4e4e4e", // selection background
|
||||
base03: "#6c6c6c", // comments, invisibles
|
||||
base04: "#969896", // dark foreground
|
||||
base05: "#d9d9d9", // default foreground
|
||||
base06: "#e8e8e8", // light foreground
|
||||
base07: "#ffffff", // light background
|
||||
base08: "#ff5370", // variables, red
|
||||
base09: "#f78c6c", // integers, orange
|
||||
base0A: "#ffcb6b", // booleans, yellow
|
||||
base0B: "#c3e88d", // strings, green
|
||||
base0C: "#89ddff", // support, cyan
|
||||
base0D: "#82aaff", // functions, blue
|
||||
base0E: "#c792ea", // keywords, purple
|
||||
base0F: "#ff5370", // deprecated, red
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.schema import ObservationType
|
||||
from openhands.events.observation.observation import Observation
|
||||
@ -10,6 +11,7 @@ class MCPObservation(Observation):
|
||||
|
||||
observation: str = ObservationType.MCP
|
||||
name: str = '' # The name of the MCP tool that was called
|
||||
arguments: dict[str, Any] = field(default_factory=dict) # The arguments passed to the MCP tool
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
|
||||
@ -155,7 +155,9 @@ async def call_tool_mcp(mcp_clients: list[MCPClient], action: MCPAction) -> Obse
|
||||
logger.debug(f'MCP response: {response}')
|
||||
|
||||
return MCPObservation(
|
||||
content=json.dumps(response.model_dump(mode='json')), name=action.name
|
||||
content=json.dumps(response.model_dump(mode='json')),
|
||||
name=action.name,
|
||||
arguments=action.arguments,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -106,3 +106,40 @@ def test_mcp_action_with_complex_arguments():
|
||||
assert 'nested_arg' in message
|
||||
assert 'inner_key' in message
|
||||
assert 'inner_value' in message
|
||||
|
||||
|
||||
def test_mcp_observation_with_arguments():
|
||||
"""Test MCPObservation with arguments."""
|
||||
complex_args = {
|
||||
'simple_arg': 'value',
|
||||
'number_arg': 42,
|
||||
'boolean_arg': True,
|
||||
'nested_arg': {'inner_key': 'inner_value', 'inner_list': [1, 2, 3]},
|
||||
'list_arg': ['a', 'b', 'c'],
|
||||
}
|
||||
|
||||
observation = MCPObservation(
|
||||
content=json.dumps({'result': 'success', 'data': 'test data'}),
|
||||
name='test_tool',
|
||||
arguments=complex_args,
|
||||
)
|
||||
|
||||
assert observation.content == json.dumps({'result': 'success', 'data': 'test data'})
|
||||
assert observation.observation == ObservationType.MCP
|
||||
assert observation.name == 'test_tool'
|
||||
assert observation.arguments == complex_args
|
||||
assert observation.arguments['nested_arg']['inner_key'] == 'inner_value'
|
||||
assert observation.arguments['list_arg'] == ['a', 'b', 'c']
|
||||
|
||||
# Test serialization
|
||||
from openhands.events.serialization import event_to_dict
|
||||
|
||||
serialized = event_to_dict(observation)
|
||||
|
||||
assert serialized['observation'] == ObservationType.MCP
|
||||
assert serialized['content'] == json.dumps(
|
||||
{'result': 'success', 'data': 'test data'}
|
||||
)
|
||||
assert serialized['extras']['name'] == 'test_tool'
|
||||
assert serialized['extras']['arguments'] == complex_args
|
||||
assert serialized['extras']['arguments']['nested_arg']['inner_key'] == 'inner_value'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user