From e5c72427c4d791fcff85fd5fb3e580039b8461ca Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Fri, 22 May 2026 17:01:17 -0400 Subject: [PATCH 1/8] ui migration to v1 Signed-off-by: Jet Chiang review and update UI changes Signed-off-by: Jet Chiang pkg lock Signed-off-by: Jet Chiang fixes to ui Signed-off-by: Jet Chiang --- ui/jest.config.ts | 2 +- ui/jest.setup.ts | 5 + ui/package-lock.json | 11 +- ui/package.json | 2 +- .../[namespace]/[agentName]/route.ts | 2 + .../app/a2a/[namespace]/[agentName]/route.ts | 2 + ui/src/app/actions/sessions.ts | 2 +- ui/src/components/chat/AgentCallDisplay.tsx | 2 +- ui/src/components/chat/ChatInterface.tsx | 71 +- .../components/chat/ChatMessage.stories.tsx | 83 +-- ui/src/components/chat/ChatMessage.tsx | 13 +- ui/src/components/chat/ToolCallDisplay.tsx | 28 +- ui/src/lib/__tests__/messageHandlers.test.ts | 672 ++++-------------- ui/src/lib/a2aClient.ts | 36 +- ui/src/lib/auth.ts | 2 +- ui/src/lib/messageHandlers.ts | 199 +++--- ui/src/lib/statusUtils.ts | 43 +- ui/src/lib/utils.ts | 33 +- ui/src/mocks/handlers.ts | 84 ++- 19 files changed, 512 insertions(+), 780 deletions(-) diff --git a/ui/jest.config.ts b/ui/jest.config.ts index 9f47bc29c0..903ad1d66b 100644 --- a/ui/jest.config.ts +++ b/ui/jest.config.ts @@ -25,7 +25,7 @@ const config: Config = { ], // Transform ESM modules that Jest can't handle by default transformIgnorePatterns: [ - '/node_modules/(?!(uuid|@a2a-js)/)', + '/node_modules/(?!(uuid|@a2a-js|jose)/)', ], }; diff --git a/ui/jest.setup.ts b/ui/jest.setup.ts index be96e4bc92..72540a73ff 100644 --- a/ui/jest.setup.ts +++ b/ui/jest.setup.ts @@ -6,6 +6,11 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => 'test-uuid-v4'), })); +// @a2a-js/sdk's CJS bundle requires `jose` at module-load time. +// In Jest (CJS) this can trip on jose's ESM-only entrypoint, so we provide +// a minimal mock since UI tests do not exercise agent-card signature codepaths. +jest.mock('jose', () => ({})); + // Polyfill TextEncoder/TextDecoder for Node.js test environment global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder as typeof global.TextDecoder; diff --git a/ui/package-lock.json b/ui/package-lock.json index 174c173cfb..421cf5332c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,7 +8,7 @@ "name": "kagents-ui", "version": "0.1.0", "dependencies": { - "@a2a-js/sdk": "^0.3.13", + "@a2a-js/sdk": "^1.0.0-alpha.0", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -94,15 +94,16 @@ } }, "node_modules/@a2a-js/sdk": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.13.tgz", - "integrity": "sha512-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A==", + "version": "1.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-1.0.0-alpha.0.tgz", + "integrity": "sha512-2IeAMlgO4Xu1QbmNUvwyZe5F/vMzA7tiEt/1nLvCHptVpC6L7bIBUVL71fIdNi9TIGjLVz0ctVYgx8UkzpxeZA==", "license": "Apache-2.0", "dependencies": { + "jose": "^6.2.3", "uuid": "^11.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", diff --git a/ui/package.json b/ui/package.json index 1be1379b8e..4af4a7520d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,7 +17,7 @@ "chromatic": "chromatic --exit-zero-on-changes --storybook-build-dir storybook-static --project-token chpt_3e29f54d624610f" }, "dependencies": { - "@a2a-js/sdk": "^0.3.13", + "@a2a-js/sdk": "^1.0.0-alpha.0", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts b/ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts index 238ca77823..09f0cfacae 100644 --- a/ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts +++ b/ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getBackendUrl } from '@/lib/utils'; import { getAuthHeadersFromRequest, CORS_ALLOW_HEADERS } from '@/lib/auth'; +import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER } from '@a2a-js/sdk'; export async function POST( request: NextRequest, @@ -25,6 +26,7 @@ export async function POST( 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'User-Agent': 'kagent-ui', + [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION, }, body: JSON.stringify(a2aRequest), }); diff --git a/ui/src/app/a2a/[namespace]/[agentName]/route.ts b/ui/src/app/a2a/[namespace]/[agentName]/route.ts index 8f5eb9cc9b..7e92b4292a 100644 --- a/ui/src/app/a2a/[namespace]/[agentName]/route.ts +++ b/ui/src/app/a2a/[namespace]/[agentName]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getBackendUrl } from '@/lib/utils'; import { getAuthHeadersFromRequest, CORS_ALLOW_HEADERS } from '@/lib/auth'; +import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER } from '@a2a-js/sdk'; export async function POST( request: NextRequest, @@ -26,6 +27,7 @@ export async function POST( 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'User-Agent': 'kagent-ui', + [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION, }, body: JSON.stringify(a2aRequest), }); diff --git a/ui/src/app/actions/sessions.ts b/ui/src/app/actions/sessions.ts index 3f987076f8..cadceb717a 100644 --- a/ui/src/app/actions/sessions.ts +++ b/ui/src/app/actions/sessions.ts @@ -4,7 +4,7 @@ import { BaseResponse, CreateSessionRequest } from "@/types"; import { Session } from "@/types"; import { revalidatePath } from "next/cache"; import { fetchApi, createErrorResponse } from "./utils"; -import { Task } from "@a2a-js/sdk"; +import type { Task } from "@a2a-js/sdk"; /** * Deletes a session diff --git a/ui/src/components/chat/AgentCallDisplay.tsx b/ui/src/components/chat/AgentCallDisplay.tsx index 0c8dcd1bee..8d1bd4d19b 100644 --- a/ui/src/components/chat/AgentCallDisplay.tsx +++ b/ui/src/components/chat/AgentCallDisplay.tsx @@ -6,7 +6,7 @@ import { ChevronDown, ChevronUp, MessageSquare, Loader2, AlertCircle, CheckCircl import KagentLogo from "../kagent-logo"; import TokenStatsTooltip from "@/components/chat/TokenStatsTooltip"; import { getSubagentSessionWithEvents } from "@/app/actions/sessions"; -import { Message, Task } from "@a2a-js/sdk"; +import type { Message, Task } from "@a2a-js/sdk"; import { extractMessagesFromTasks } from "@/lib/messageHandlers"; import ChatMessage from "@/components/chat/ChatMessage"; diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index 778c501632..ad621bc8fb 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -27,10 +27,14 @@ import { kagentA2AClient } from "@/lib/a2aClient"; import { useChatRunInSandbox } from "@/components/chat/ChatAgentContext"; import { v4 as uuidv4 } from "uuid"; import { getStatusPlaceholder, mapA2AStateToStatus } from "@/lib/statusUtils"; -import { Message, DataPart, Task, TaskState } from "@a2a-js/sdk"; +import { Role, TaskState } from "@a2a-js/sdk"; +import type { Message, Task, StreamResponse } from "@a2a-js/sdk"; // Task states where the agent is actively processing — resubscribe to live stream. -const RESUBSCRIBE_TASK_STATES: TaskState[] = ["submitted", "working"]; +const RESUBSCRIBE_TASK_STATES: TaskState[] = [ + TaskState.TASK_STATE_SUBMITTED, + TaskState.TASK_STATE_WORKING, +]; interface ChatInterfaceProps { selectedAgentName: string; @@ -224,18 +228,9 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se pendingTurnStatsRef.current = undefined; // For new sessions or when no stored messages exist, show the user message immediately - const userMessage: Message = { - kind: "message", - messageId: uuidv4(), - role: "user", - parts: [{ - kind: "text", - text: userMessageText - }], - metadata: { - timestamp: Date.now() - } - }; + const userMessage: Message = createMessage(userMessageText, "user", { + additionalMetadata: { timestamp: Date.now() }, + }); // Add user message to streaming messages to show immediately // (will be replaced by server response that includes the user message) @@ -333,7 +328,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se for await (const event of stream) { startTimeout(); try { - handleMessageEvent(event as Message); + handleMessageEvent(event as StreamResponse); } catch (err) { console.error("Error handling stream event:", err); } @@ -549,18 +544,29 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const messageId = uuidv4(); const a2aMessage: Message = { - kind: "message", messageId, - role: "user", + role: Role.ROLE_USER, parts: [ - { kind: "data", data: decisionData, metadata: {} } as DataPart, - { kind: "text", text: displayText }, + { + content: { $case: "data", value: decisionData }, + metadata: {}, + filename: "", + mediaType: "application/json", + }, + { + content: { $case: "text", value: displayText }, + metadata: {}, + filename: "", + mediaType: "text/plain", + }, ], - contextId: currentSessionId, - taskId: approvalTaskId, + contextId: currentSessionId ?? "", + taskId: approvalTaskId ?? "", metadata: { timestamp: Date.now(), }, + extensions: [], + referenceTaskIds: [], }; await streamA2AMessage(a2aMessage, { @@ -686,20 +692,27 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const messageId = uuidv4(); const a2aMessage: Message = { - kind: "message", messageId, - role: "user", + role: Role.ROLE_USER, parts: [ { - kind: "data", - data: { decision_type: "approve", ask_user_answers: answers }, + content: { $case: "data", value: { decision_type: "approve", ask_user_answers: answers } }, + metadata: {}, + filename: "", + mediaType: "application/json", + }, + { + content: { $case: "text", value: "Answered questions" }, metadata: {}, - } as DataPart, - { kind: "text", text: "Answered questions" }, + filename: "", + mediaType: "text/plain", + }, ], - contextId: currentSessionId, - taskId: askUserTaskId, + contextId: currentSessionId ?? "", + taskId: askUserTaskId ?? "", metadata: { timestamp: Date.now() }, + extensions: [], + referenceTaskIds: [], }; streamA2AMessage(a2aMessage, { diff --git a/ui/src/components/chat/ChatMessage.stories.tsx b/ui/src/components/chat/ChatMessage.stories.tsx index 11a805ad9c..456c717e8f 100644 --- a/ui/src/components/chat/ChatMessage.stories.tsx +++ b/ui/src/components/chat/ChatMessage.stories.tsx @@ -21,20 +21,31 @@ const meta = { export default meta; type Story = StoryObj; -const createMessage = (overrides: Partial = {}): Message => ({ - kind: "message", +const makeTextPart = (text: string) => ({ + content: { $case: "text" as const, value: text }, + metadata: {}, + filename: "", + mediaType: "text/plain", +}); + +const createMessage = (overrides: Record = {}): Message => ({ messageId: "msg-123", - role: "agent", - parts: [{ kind: "text", text: "Default message content" }], + role: 2, + parts: [makeTextPart("Default message content")], + contextId: "", + taskId: "", + metadata: {}, + extensions: [], + referenceTaskIds: [], ...overrides, -}); +} as Message); export const UserMessage: Story = { args: { message: createMessage({ - role: "user", + role: 1, messageId: "user-msg-1", - parts: [{ kind: "text", text: "Hello, can you help me with this?" }], + parts: [makeTextPart("Hello, can you help me with this?")], }), allMessages: [], }, @@ -43,8 +54,8 @@ export const UserMessage: Story = { export const AgentMessage: Story = { args: { message: createMessage({ - role: "agent", - parts: [{ kind: "text", text: "Of course! I'd be happy to help you with that." }], + role: 2, + parts: [makeTextPart("Of course! I'd be happy to help you with that.")], }), allMessages: [], }, @@ -53,8 +64,8 @@ export const AgentMessage: Story = { export const AgentMessageWithTimestamp: Story = { args: { message: createMessage({ - role: "agent", - parts: [{ kind: "text", text: "Here's the response to your question." }], + role: 2, + parts: [makeTextPart("Here's the response to your question.")], metadata: { displaySource: "assistant", timestamp: Date.now(), @@ -67,18 +78,16 @@ export const AgentMessageWithTimestamp: Story = { export const MessageWithLongContent: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { - kind: "text", - text: `This is a much longer response that contains multiple paragraphs of information. + makeTextPart(`This is a much longer response that contains multiple paragraphs of information. The first paragraph explains the main concept. The second paragraph provides additional details and examples. The third paragraph concludes with a summary of the key points.`, - }, + ), ], }), allMessages: [], @@ -88,11 +97,9 @@ The third paragraph concludes with a summary of the key points.`, export const MessageWithMarkdown: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { - kind: "text", - text: `# Response Title + makeTextPart(`# Response Title Here's a **bold** statement and an *italic* one. @@ -106,7 +113,7 @@ const example = () => { return "code block"; }; \`\`\``, - }, + ), ], }), allMessages: [], @@ -116,11 +123,9 @@ const example = () => { export const MessageWithCodeBlocks: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { - kind: "text", - text: `Here's how to implement this feature: + makeTextPart(`Here's how to implement this feature: \`\`\`python def calculate_sum(numbers): @@ -140,7 +145,7 @@ const calculateSum = (numbers) => { const result = calculateSum([1, 2, 3, 4, 5]); console.log(result); \`\`\``, - }, + ), ], }), allMessages: [], @@ -150,8 +155,8 @@ console.log(result); export const MessageWithCustomDisplaySource: Story = { args: { message: createMessage({ - role: "agent", - parts: [{ kind: "text", text: "Response from custom agent" }], + role: 2, + parts: [makeTextPart("Response from custom agent")], metadata: { displaySource: "DataAnalyzer", }, @@ -163,8 +168,8 @@ export const MessageWithCustomDisplaySource: Story = { export const MessageWithAgentContext: Story = { args: { message: createMessage({ - role: "agent", - parts: [{ kind: "text", text: "Response from context agent" }], + role: 2, + parts: [makeTextPart("Response from context agent")], }), allMessages: [], agentContext: { @@ -177,9 +182,9 @@ export const MessageWithAgentContext: Story = { export const ShortUserMessage: Story = { args: { message: createMessage({ - role: "user", + role: 1, messageId: "user-msg-2", - parts: [{ kind: "text", text: "OK" }], + parts: [makeTextPart("OK")], }), allMessages: [], }, @@ -188,11 +193,9 @@ export const ShortUserMessage: Story = { export const AgentMessageWithTable: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { - kind: "text", - text: `Here's the data in table format: + makeTextPart(`Here's the data in table format: | Name | Score | Status | |------|-------|--------| @@ -200,7 +203,7 @@ export const AgentMessageWithTable: Story = { | Bob | 87 | Pass | | Charlie | 72 | Pass | | Diana | 65 | Fail |`, - }, + ), ], }), allMessages: [], @@ -210,10 +213,10 @@ export const AgentMessageWithTable: Story = { export const MessageWithMultipleParts: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { kind: "text", text: "First part of the message." }, - { kind: "text", text: "Second part of the message." }, + makeTextPart("First part of the message."), + makeTextPart("Second part of the message."), ], }), allMessages: [], diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx index cf71a65b6c..62469858ba 100644 --- a/ui/src/components/chat/ChatMessage.tsx +++ b/ui/src/components/chat/ChatMessage.tsx @@ -1,4 +1,5 @@ -import { Message, TextPart } from "@a2a-js/sdk"; +import type { Message } from "@a2a-js/sdk"; +import { Role } from "@a2a-js/sdk"; import { TruncatableText } from "@/components/chat/TruncatableText"; import ToolCallDisplay from "@/components/chat/ToolCallDisplay"; import AskUserDisplay, { AskUserQuestion } from "@/components/chat/AskUserDisplay"; @@ -32,10 +33,10 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr if (!message) return null; - const textParts = message.parts?.filter(part => part.kind === "text") || []; - const content = textParts.map(part => (part as TextPart).text).join(""); + const textParts = message.parts?.filter(part => part.content?.$case === "text") || []; + const content = textParts.map(part => part.content?.$case === "text" ? part.content.value : "").join(""); - const source = message.role === "user" ? "user" : "assistant"; + const source = message.role === Role.ROLE_USER ? "user" : "assistant"; const tokenStats = (message.metadata as Record | undefined)?.tokenStats as TokenStats | undefined; const messageId = message.messageId; @@ -78,7 +79,7 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr // Check for tool call parts (works for both stored and streaming messages) const hasToolCallParts = message.parts?.some(part => { - if (part.kind === "data" && part.metadata) { + if (part.content?.$case === "data" && part.metadata) { const partType = getMetadataValue(part.metadata as Record, "type"); return partType === "function_call" || partType === "function_response"; } @@ -130,7 +131,7 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr if (originalType === "ToolCallSummaryMessage") { const hasToolCalls = allMessages.some(msg => { return msg.parts?.some(part => { - if (part.kind === "data" && part.metadata) { + if (part.content?.$case === "data" && part.metadata) { const partType = getMetadataValue(part.metadata as Record, "type"); return partType === "function_call" || partType === "function_response"; } diff --git a/ui/src/components/chat/ToolCallDisplay.tsx b/ui/src/components/chat/ToolCallDisplay.tsx index 9ebaec3e7e..7b8819f086 100644 --- a/ui/src/components/chat/ToolCallDisplay.tsx +++ b/ui/src/components/chat/ToolCallDisplay.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { Message, TextPart } from "@a2a-js/sdk"; +import type { Message } from "@a2a-js/sdk"; import ToolDisplay, { ToolCallStatus } from "@/components/ToolDisplay"; import AgentCallDisplay, { AgentCallStatus } from "@/components/chat/AgentCallDisplay"; import { isAgentToolName } from "@/lib/utils"; @@ -29,7 +29,7 @@ interface ToolCallState { const isToolCallRequestMessage = (message: Message): boolean => { // Check data parts for type metadata first const hasDataParts = message.parts?.some(part => { - if (part.kind === "data" && part.metadata) { + if (part.content?.$case === "data" && part.metadata) { return getMetadataValue(part.metadata as Record, "type") === "function_call"; } return false; @@ -46,7 +46,7 @@ const isToolCallRequestMessage = (message: Message): boolean => { const isToolCallExecutionMessage = (message: Message): boolean => { const hasDataParts = message.parts?.some(part => { - if (part.kind === "data" && part.metadata) { + if (part.content?.$case === "data" && part.metadata) { return getMetadataValue(part.metadata as Record, "type") === "function_response"; } return false; @@ -70,13 +70,14 @@ const extractToolCallRequests = (message: Message): FunctionCall[] => { if (!isToolCallRequestMessage(message)) return []; // Check for stored task format first (data parts) - const dataParts = message.parts?.filter(part => part.kind === "data") || []; + const dataParts = message.parts?.filter(part => part.content?.$case === "data") || []; const functionCalls: FunctionCall[] = []; for (const part of dataParts) { if (part.metadata) { if (getMetadataValue(part.metadata as Record, "type") === "function_call") { - const data = part.data as unknown as FunctionCall; + const data = part.content?.$case === "data" ? part.content.value as unknown as FunctionCall : undefined; + if (!data) continue; // Skip ADK internal function calls (confirmation/auth) and ask_user (has its own display) if ( data.name === "adk_request_confirmation" || @@ -100,8 +101,8 @@ const extractToolCallRequests = (message: Message): FunctionCall[] => { } // Try streaming format (metadata or text content) - const textParts = message.parts?.filter(part => part.kind === "text") || []; - const content = textParts.map(part => (part as TextPart).text).join(""); + const textParts = message.parts?.filter(part => part.content?.$case === "text") || []; + const content = textParts.map(part => part.content?.$case === "text" ? part.content.value : "").join(""); try { // Tool call data might be stored as JSON in content or metadata @@ -123,13 +124,14 @@ const extractToolCallResults = (message: Message): ProcessedToolResultData[] => if (!isToolCallExecutionMessage(message)) return []; // Check for stored task format first (data parts) - const dataParts = message.parts?.filter(part => part.kind === "data") || []; + const dataParts = message.parts?.filter(part => part.content?.$case === "data") || []; const toolResults: ProcessedToolResultData[] = []; for (const part of dataParts) { if (part.metadata) { if (getMetadataValue(part.metadata as Record, "type") === "function_response") { - const data = part.data as unknown as ToolResponseData; + const data = part.content?.$case === "data" ? part.content.value as unknown as ToolResponseData : undefined; + if (!data) continue; // For agent tool responses we receive { result, subagent_session_id } as FunctionResponse.response. const textContent = normalizeToolResultToText(data); @@ -158,9 +160,9 @@ const extractToolCallResults = (message: Message): ProcessedToolResultData[] => } // Try streaming format (metadata or text content) - const textParts = message.parts?.filter(part => part.kind === "text") || []; + const textParts = message.parts?.filter(part => part.content?.$case === "text") || []; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const content = textParts.map(part => (part as any).text).join(""); + const content = textParts.map(part => part.content?.$case === "text" ? part.content.value : "").join(""); try { const metadata = message.metadata as ADKMetadata; @@ -257,9 +259,9 @@ const ToolCallDisplay = ({ currentMessage, allMessages, onApprove, onReject, pen let subagentSessionId: string | undefined = matchingCallData?.subagent_session_id; if (!subagentSessionId && isAgentToolName(request.name)) { const fcDataPart = message.parts?.find(p => - p.kind === "data" && p.metadata && + p.content?.$case === "data" && p.metadata && getMetadataValue(p.metadata as Record, "type") === "function_call" && - (p.data as Record)?.id === request.id + (p.content.value as Record)?.id === request.id ); subagentSessionId = fcDataPart?.metadata ? getMetadataValue(fcDataPart.metadata as Record, "subagent_session_id") diff --git a/ui/src/lib/__tests__/messageHandlers.test.ts b/ui/src/lib/__tests__/messageHandlers.test.ts index de95321abd..0aff12a568 100644 --- a/ui/src/lib/__tests__/messageHandlers.test.ts +++ b/ui/src/lib/__tests__/messageHandlers.test.ts @@ -1,362 +1,119 @@ -import { describe, test, expect } from '@jest/globals'; -import { v4 as uuidv4 } from 'uuid'; -import { Message, Task } from '@a2a-js/sdk'; +import { describe, test, expect } from "@jest/globals"; +import type { Message, StreamResponse, Task } from "@a2a-js/sdk"; import { + createMessage, + createMessageHandlers, extractMessagesFromTasks, extractTokenStatsFromTasks, - createMessage, - normalizeToolResultToText, getMetadataValue, + normalizeToolResultToText, type ToolResponseData, - type ADKMetadata, - createMessageHandlers, -} from '@/lib/messageHandlers'; -import type { TokenStats } from '@/types'; - -describe('messageHandlers helpers', () => { - test('normalizeToolResultToText handles string result', () => { - const data: ToolResponseData = { id: '1', name: 'tool', response: { result: 'hello' } }; - expect(normalizeToolResultToText(data)).toBe('hello'); - }); - - test('normalizeToolResultToText handles content array', () => { - const data: ToolResponseData = { id: '1', name: 'tool', response: { result: { content: [{ text: 'a' }, { text: 'b' }] } } } as any; - expect(normalizeToolResultToText(data)).toBe('ab'); - }); - - test('normalizeToolResultToText handles object fallback', () => { - const data: ToolResponseData = { id: '1', name: 'tool', response: { result: { foo: 'bar' } } } as any; - expect(normalizeToolResultToText(data)).toContain('foo'); - }); - - test('createMessage builds a message with metadata', () => { - const msg = createMessage('hi', 'assistant', { originalType: 'TextMessage', contextId: 'ctx', taskId: 'task' }); - expect(msg.kind).toBe('message'); - expect(msg.parts[0]).toEqual({ kind: 'text', text: 'hi' }); - expect((msg.metadata as any).originalType).toBe('TextMessage'); - expect(msg.contextId).toBe('ctx'); - expect(msg.taskId).toBe('task'); - }); - - test('extractMessagesFromTasks deduplicates messageIds', () => { - const mId = uuidv4(); - const tasks: any = [ - { history: [{ kind: 'message', messageId: mId }, { kind: 'message', messageId: mId }] }, - ]; - const out = extractMessagesFromTasks(tasks); - expect(out.length).toBe(1); - expect(out[0].messageId).toBe(mId); - }); - - test('extractMessagesFromTasks injects tokenStats into non-user agent messages only', () => { - const tasks = [ - { - history: [ - { kind: 'message', messageId: 'a1', role: 'agent', parts: [], - metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 3, candidatesTokenCount: 7 } } }, - { kind: 'message', messageId: 'u1', role: 'user', parts: [], - metadata: { kagent_usage_metadata: { totalTokenCount: 5, promptTokenCount: 2, candidatesTokenCount: 3 } } }, - { kind: 'message', messageId: 'a2', role: 'agent', parts: [], metadata: {} }, - ], - }, - ] as unknown as Task[]; - const messages = extractMessagesFromTasks(tasks); - // Agent message with usage metadata gets tokenStats injected - expect((messages[0].metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats) - .toEqual({ total: 10, prompt: 3, completion: 7 }); - // User message is NOT enriched even if it carries usage metadata - expect((messages[1].metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats) - .toBeUndefined(); - // Agent message without usage metadata is passed through unchanged - expect((messages[2].metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats) - .toBeUndefined(); - }); - - test('extractTokenStatsFromTasks sums usage across all history messages', () => { - const tasks: any = [ - { history: [{ kind: 'message', metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 3, candidatesTokenCount: 7 } } }] }, - { history: [{ kind: 'message', metadata: { kagent_usage_metadata: { totalTokenCount: 12, promptTokenCount: 1, candidatesTokenCount: 9 } } }] }, - ]; - const stats = extractTokenStatsFromTasks(tasks); - expect(stats.total).toBe(22); - expect(stats.prompt).toBe(4); - expect(stats.completion).toBe(16); - }); - - test('extractTokenStatsFromTasks skips history items without usage metadata', () => { - const tasks = [ - { history: [{ kind: 'message', messageId: uuidv4(), role: 'agent', parts: [], metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 3, candidatesTokenCount: 7 } } }] }, - { history: [{ kind: 'message', messageId: uuidv4(), role: 'agent', parts: [], metadata: {} }] }, - ] as unknown as Task[]; - const stats = extractTokenStatsFromTasks(tasks); - expect(stats.total).toBe(10); - expect(stats.prompt).toBe(3); - expect(stats.completion).toBe(7); - }); -}); - -describe('createMessageHandlers test', () => { - test('emits ToolCallRequestEvent + ToolCallExecutionEvent for non-agent tool', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - setChatStatus: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - // Simulate status-update with function_call to an agent tool - const statusUpdateCall: any = { - kind: 'status-update', - contextId: 'ctx', - taskId: 'task', - final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [ - { - kind: 'data', - data: { id: 'call_1', name: 'kagent__NS__k8s_agent', args: { request: 'list' } }, - metadata: { kagent_type: 'function_call' }, - }, - ], +} from "@/lib/messageHandlers"; + +function textPart(text: string) { + return { + content: { $case: "text" as const, value: text }, + metadata: {}, + filename: "", + mediaType: "text/plain", + }; +} + +function dataPart(value: unknown, metadata: Record) { + return { + content: { $case: "data" as const, value }, + metadata, + filename: "", + mediaType: "application/json", + }; +} + +describe("messageHandlers (v1 native types)", () => { + test("createMessage builds a v1 message", () => { + const msg = createMessage("hi", "assistant", { contextId: "ctx", taskId: "task" }); + expect(msg.messageId).toBeDefined(); + expect(msg.parts[0].content?.$case).toBe("text"); + expect(msg.parts[0].content?.$case === "text" ? msg.parts[0].content.value : "").toBe("hi"); + expect(msg.contextId).toBe("ctx"); + expect(msg.taskId).toBe("task"); + }); + + test("normalizeToolResultToText handles string response", () => { + const data: ToolResponseData = { id: "1", name: "tool", response: { result: "hello" } }; + expect(normalizeToolResultToText(data)).toBe("hello"); + }); + + test("extractTokenStatsFromTasks sums adk/kagent usage metadata", () => { + const task = { + id: "t1", + contextId: "ctx", + status: undefined, + artifacts: [], + history: [ + { + messageId: "m1", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [textPart("a")], + metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 3, candidatesTokenCount: 7 } }, + extensions: [], + referenceTaskIds: [], }, - }, - }; - - handlers.handleMessageEvent(statusUpdateCall); - - // Simulate status-update with function_response from agent - const statusUpdateResp: any = { - kind: 'status-update', - contextId: 'ctx', - taskId: 'task', - final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [ - { - kind: 'data', - data: { id: 'call_1', name: 'kagent__NS__k8s_agent', response: { result: 'ok' } }, - metadata: { kagent_type: 'function_response' }, - }, - ], + { + messageId: "m2", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [textPart("b")], + metadata: { adk_usage_metadata: { totalTokenCount: 5, promptTokenCount: 2, candidatesTokenCount: 3 } }, + extensions: [], + referenceTaskIds: [], }, - }, - }; - - handlers.handleMessageEvent(statusUpdateResp); - - expect(emitted.length).toBe(2); - expect((emitted[0].metadata as any).originalType).toBe('ToolCallRequestEvent'); - expect((emitted[1].metadata as any).originalType).toBe('ToolCallExecutionEvent'); - }); - - test('emits ToolCallRequestEvent + ToolCallExecutionEvent for non-agent tool', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - const statusUpdateCall: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { state: 'working', message: { role: 'agent', parts: [{ kind: 'data', data: { id: 'call_2', name: 'some_tool', args: { a: 1 } }, metadata: { kagent_type: 'function_call' } }] } } - }; - handlers.handleMessageEvent(statusUpdateCall); - - const statusUpdateResp: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { state: 'working', message: { role: 'agent', parts: [{ kind: 'data', data: { id: 'call_2', name: 'some_tool', response: { result: 'tool ok' } }, metadata: { kagent_type: 'function_response' } }] } } - }; - handlers.handleMessageEvent(statusUpdateResp); - - expect(emitted.length).toBe(2); - expect((emitted[0].metadata as any).originalType).toBe('ToolCallRequestEvent'); - expect((emitted[1].metadata as any).originalType).toBe('ToolCallExecutionEvent'); - }); - - test('final text message on status-update with text part', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - const statusWithText: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: true, - status: { state: 'working', message: { role: 'agent', parts: [{ kind: 'text', text: 'hello' }] } } - }; - handlers.handleMessageEvent(statusWithText); - - expect(emitted.length).toBe(1); - expect((emitted[0].metadata as any).originalType).toBe('TextMessage'); - expect((emitted[0].parts[0] as any).text).toBe('hello'); - }); - - test('artifact-update converts tool parts and appends summary', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - const artifactEvent: any = { - kind: 'artifact-update', contextId: 'ctx', taskId: 'task', lastChunk: true, - artifact: { - parts: [ - { kind: 'data', data: { id: 'call_3', name: 'some_tool', args: { q: 1 } }, metadata: { kagent_type: 'function_call' } }, - { kind: 'data', data: { id: 'call_3', name: 'some_tool', response: { result: 'out' } }, metadata: { kagent_type: 'function_response' } }, - ] - } - }; - handlers.handleMessageEvent(artifactEvent); - - // Expect: request, execution, summary (no text message since no text part) - expect(emitted.length).toBe(3); - expect((emitted[0].metadata as any).originalType).toBe('ToolCallRequestEvent'); - expect((emitted[1].metadata as any).originalType).toBe('ToolCallExecutionEvent'); - expect((emitted[2].metadata as any).originalType).toBe('ToolCallSummaryMessage'); - }); - - test('each invocation keeps its own token stats and session total accumulates correctly', () => { - const emitted: Message[] = []; - let capturedSessionTotal = { total: 0, prompt: 0, completion: 0 }; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - setSessionStats: (updater) => { capturedSessionTotal = updater(capturedSessionTotal); }, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - // Invocation 1: LLM decides to call a tool (usage arrives with the function_call) - const toolCallUpdate = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - metadata: { kagent_usage_metadata: { totalTokenCount: 5, promptTokenCount: 3, candidatesTokenCount: 2 } }, - status: { - state: 'working', - message: { - role: 'agent', - parts: [{ kind: 'data', data: { id: 'call_1', name: 'my_tool', args: {} }, metadata: { kagent_type: 'function_call' } }] - } - } - } as unknown as Message; - handlers.handleMessageEvent(toolCallUpdate); - - // Tool executes and returns a result - const toolResponseUpdate = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [{ kind: 'data', data: { id: 'call_1', name: 'my_tool', response: { result: 'ok' } }, metadata: { kagent_type: 'function_response' } }] - } - } - } as unknown as Message; - handlers.handleMessageEvent(toolResponseUpdate); - - // Invocation 2: LLM generates the final text response - const finalUpdate = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: true, - metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 7, candidatesTokenCount: 3 } }, - status: { - state: 'completed', - message: { role: 'agent', parts: [{ kind: 'text', text: 'done' }] } - } - } as unknown as Message; - handlers.handleMessageEvent(finalUpdate); - - const toolCallMsg = emitted.find(m => (m.metadata as ADKMetadata)?.originalType === 'ToolCallRequestEvent'); - const textMsg = emitted.find(m => (m.metadata as ADKMetadata)?.originalType === 'TextMessage'); - // Each invocation keeps its own stats — the tool call is not overwritten by the text response - expect((toolCallMsg?.metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats).toEqual({ total: 5, prompt: 3, completion: 2 }); - expect((textMsg?.metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats).toEqual({ total: 10, prompt: 7, completion: 3 }); - // Session total accumulates both invocations - expect(capturedSessionTotal).toEqual({ total: 15, prompt: 10, completion: 5 }); - }); - - test('HITL interrupt accumulates pending turn stats and clears them', () => { - const emitted: Message[] = []; - let capturedSessionTotal: TokenStats = { total: 0, prompt: 0, completion: 0 }; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - setChatStatus: () => {}, - setSessionStats: (updater) => { capturedSessionTotal = updater(capturedSessionTotal); }, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - // Status update: LLM decides to call a confirmation tool (HITL), usage arrives here - const hitlUpdate = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - metadata: { kagent_usage_metadata: { totalTokenCount: 8, promptTokenCount: 5, candidatesTokenCount: 3 } }, - status: { - state: 'input-required', - message: { - role: 'agent', - parts: [{ - kind: 'data', - data: { - name: 'adk_request_confirmation', - id: 'confirm_1', - args: { originalFunctionCall: { name: 'my_tool', args: { x: 1 }, id: 'call_1' } }, - }, - metadata: { kagent_type: 'function_call', kagent_is_long_running: true }, - }], + ], + metadata: undefined, + } as unknown as Task; + + expect(extractTokenStatsFromTasks([task])).toEqual({ total: 15, prompt: 5, completion: 10 }); + }); + + test("extractMessagesFromTasks converts function_call and function_response", () => { + const task = { + id: "t1", + contextId: "ctx", + status: undefined, + artifacts: [], + history: [ + { + messageId: "m1", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [dataPart({ id: "call1", name: "my_tool", args: { a: 1 } }, { adk_type: "function_call" })], + metadata: {}, + extensions: [], + referenceTaskIds: [], }, - }, - } as unknown as Message; - handlers.handleMessageEvent(hitlUpdate); + { + messageId: "m2", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [dataPart({ id: "call1", name: "my_tool", response: { result: "ok" } }, { adk_type: "function_response" })], + metadata: {}, + extensions: [], + referenceTaskIds: [], + }, + ], + metadata: undefined, + } as unknown as Task; - // Session stats should be accumulated at the HITL boundary (not at stream end) - expect(capturedSessionTotal).toEqual({ total: 8, prompt: 5, completion: 3 }); - // A ToolApprovalRequest message should have been emitted - const approvalMsg = emitted.find(m => (m.metadata as ADKMetadata)?.originalType === 'ToolApprovalRequest'); - expect(approvalMsg).toBeDefined(); + const out = extractMessagesFromTasks([task]); + expect((out[0].metadata as Record)?.originalType).toBe("ToolCallRequestEvent"); + expect((out[1].metadata as Record)?.originalType).toBe("ToolCallExecutionEvent"); }); -}); -describe('subagent_session_id propagation', () => { - // Shared handler factory for status-update / artifact-update tests - function makeHandlers() { + test("createMessageHandlers processes v1 stream status updates", () => { const emitted: Message[] = []; const handlers = createMessageHandlers({ setMessages: (updater) => { @@ -367,206 +124,39 @@ describe('subagent_session_id propagation', () => { setIsStreaming: () => {}, setStreamingContent: () => {}, setChatStatus: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, + agentContext: { namespace: "kagent", agentName: "agent" }, }); - return { emitted, handlers }; - } - - test('status-update: agent function_call with kagent_subagent_session_id in DataPart metadata emits toolCallData with subagent_session_id', () => { - const { emitted, handlers } = makeHandlers(); - - const statusUpdateCall: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [{ - kind: 'data', - data: { id: 'agent_call_1', name: 'kagent__NS__k8s_agent', args: { request: 'list pods' } }, - metadata: { kagent_type: 'function_call', kagent_subagent_session_id: 'sess-abc-123' }, - }], - }, - }, - }; - handlers.handleMessageEvent(statusUpdateCall); - - expect(emitted.length).toBe(1); - const meta = emitted[0].metadata as ADKMetadata; - expect(meta.originalType).toBe('ToolCallRequestEvent'); - expect(meta.toolCallData).toHaveLength(1); - expect(meta.toolCallData![0].subagent_session_id).toBe('sess-abc-123'); - }); - - test('status-update: agent function_response with subagent_session_id in response dict emits toolResultData with subagent_session_id', () => { - const { emitted, handlers } = makeHandlers(); - const statusUpdateResp: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [{ - kind: 'data', - data: { - id: 'agent_call_1', - name: 'kagent__NS__k8s_agent', - response: { result: 'done', subagent_session_id: 'sess-abc-123' }, + const statusUpdate = { + payload: { + $case: "statusUpdate", + value: { + contextId: "ctx", + taskId: "t1", + status: { + state: 2, + message: { + messageId: "m1", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [dataPart({ id: "call1", name: "tool", args: {} }, { adk_type: "function_call" })], + metadata: {}, + extensions: [], + referenceTaskIds: [], }, - metadata: { kagent_type: 'function_response' }, - }], - }, - }, - }; - handlers.handleMessageEvent(statusUpdateResp); - - const execMsg = emitted.find(m => (m.metadata as ADKMetadata)?.originalType === 'ToolCallExecutionEvent'); - expect(execMsg).toBeDefined(); - const resultData = (execMsg!.metadata as ADKMetadata).toolResultData!; - expect(resultData).toHaveLength(1); - expect(resultData[0].subagent_session_id).toBe('sess-abc-123'); - }); - - test('extractMessagesFromTasks: agent function_call DataPart with kagent_subagent_session_id emits toolCallData with subagent_session_id', () => { - const tasks = [{ - contextId: 'ctx', - id: 'task', - history: [{ - kind: 'message', - messageId: 'msg-1', - role: 'agent', - parts: [{ - kind: 'data', - data: { id: 'agent_call_3', name: 'kagent__NS__k8s_agent', args: { request: 'list nodes' } }, - metadata: { kagent_type: 'function_call', kagent_subagent_session_id: 'sess-history-456' }, - }], - metadata: {}, - }], - }] as unknown as Task[]; - - const messages = extractMessagesFromTasks(tasks); - expect(messages).toHaveLength(1); - const meta = messages[0].metadata as ADKMetadata; - expect(meta.originalType).toBe('ToolCallRequestEvent'); - expect(meta.toolCallData).toHaveLength(1); - expect(meta.toolCallData![0].subagent_session_id).toBe('sess-history-456'); - }); - - test('extractMessagesFromTasks: agent function_response DataPart with subagent_session_id in response dict emits toolResultData with subagent_session_id', () => { - const tasks = [{ - contextId: 'ctx', - id: 'task', - history: [{ - kind: 'message', - messageId: 'msg-3', - role: 'agent', - parts: [{ - kind: 'data', - data: { - id: 'agent_call_3', - name: 'kagent__NS__k8s_agent', - response: { result: 'nodes listed', subagent_session_id: 'sess-history-456' }, + timestamp: undefined, }, - metadata: { kagent_type: 'function_response' }, - }], - metadata: {}, - }], - }] as unknown as Task[]; - - const messages = extractMessagesFromTasks(tasks); - expect(messages).toHaveLength(1); - const meta = messages[0].metadata as ADKMetadata; - expect(meta.originalType).toBe('ToolCallExecutionEvent'); - expect(meta.toolResultData).toHaveLength(1); - expect(meta.toolResultData![0].subagent_session_id).toBe('sess-history-456'); - }); -}); - -describe('getMetadataValue', () => { - test('reads kagent_ prefixed key', () => { - expect(getMetadataValue({ kagent_type: 'function_call' }, 'type')).toBe('function_call'); - }); - - test('reads adk_ prefixed key', () => { - expect(getMetadataValue({ adk_type: 'function_call' }, 'type')).toBe('function_call'); - }); - - test('adk_ takes priority over kagent_ when both present', () => { - expect(getMetadataValue({ adk_type: 'adk_val', kagent_type: 'kagent_val' }, 'type')).toBe('adk_val'); - }); - - test('returns undefined for missing key', () => { - expect(getMetadataValue({ other: 'x' }, 'type')).toBeUndefined(); - }); - - test('returns undefined for null/undefined metadata', () => { - expect(getMetadataValue(null, 'type')).toBeUndefined(); - expect(getMetadataValue(undefined, 'type')).toBeUndefined(); - }); - - test('returns falsy values correctly (not undefined)', () => { - expect(getMetadataValue({ kagent_flag: false }, 'flag')).toBe(false); - expect(getMetadataValue({ adk_count: 0 }, 'count')).toBe(0); - expect(getMetadataValue({ kagent_text: '' }, 'text')).toBe(''); - }); -}); - -describe('dual-prefix integration', () => { - test('extractTokenStatsFromTasks works with adk_usage_metadata', () => { - const tasks: any = [ - { history: [{ kind: 'message', metadata: { adk_usage_metadata: { totalTokenCount: 20, promptTokenCount: 8, candidatesTokenCount: 12 } } }] }, - ]; - const stats = extractTokenStatsFromTasks(tasks); - expect(stats.total).toBe(20); - expect(stats.prompt).toBe(8); - expect(stats.completion).toBe(12); - }); - - test('status-update handler works with adk_type metadata on parts', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - setChatStatus: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - const statusUpdateCall: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [ - { kind: 'data', data: { id: 'call_adk', name: 'my_tool', args: { x: 1 } }, metadata: { adk_type: 'function_call' } }, - ], + metadata: {}, }, }, - }; - handlers.handleMessageEvent(statusUpdateCall); + } as unknown as StreamResponse; - const statusUpdateResp: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [ - { kind: 'data', data: { id: 'call_adk', name: 'my_tool', response: { result: 'done' } }, metadata: { adk_type: 'function_response' } }, - ], - }, - }, - }; - handlers.handleMessageEvent(statusUpdateResp); + handlers.handleMessageEvent(statusUpdate); + expect((emitted[0].metadata as Record)?.originalType).toBe("ToolCallRequestEvent"); + }); - expect(emitted.length).toBe(2); - expect((emitted[0].metadata as any).originalType).toBe('ToolCallRequestEvent'); - expect((emitted[1].metadata as any).originalType).toBe('ToolCallExecutionEvent'); + test("getMetadataValue checks adk_ first then kagent_", () => { + expect(getMetadataValue({ adk_type: "a", kagent_type: "k" }, "type")).toBe("a"); }); }); diff --git a/ui/src/lib/a2aClient.ts b/ui/src/lib/a2aClient.ts index 789f077b90..35f761bbe3 100644 --- a/ui/src/lib/a2aClient.ts +++ b/ui/src/lib/a2aClient.ts @@ -1,12 +1,23 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getBackendUrl } from "./utils"; import { v4 as uuidv4 } from 'uuid'; -import { MessageSendParams } from '@a2a-js/sdk'; +import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER, Message } from "@a2a-js/sdk"; +import type { Message as A2AMessage, StreamResponse } from "@a2a-js/sdk"; + +// A2A JSON-RPC methods +// NOTE: These are not exported by @a2a-js/sdk, so we need to define them here. +export const A2A_JSONRPC_METHODS = { + sendStreamingMessage: "SendStreamingMessage", + subscribeToTask: "SubscribeToTask", +} as const; export interface A2AJsonRpcRequest { jsonrpc: "2.0"; method: string; - params: MessageSendParams; + params: { + message: unknown; + metadata?: Record; + }; id: string | number; } @@ -28,11 +39,14 @@ export class KagentA2AClient { /** * Create JSON-RPC request for message streaming */ - createStreamingRequest(params: MessageSendParams): A2AJsonRpcRequest { + createStreamingRequest(params: { message: A2AMessage; metadata?: Record }): A2AJsonRpcRequest { return { jsonrpc: "2.0", - method: "message/stream", - params, + method: A2A_JSONRPC_METHODS.sendStreamingMessage, + params: { + ...params, + message: Message.toJSON(params.message), + }, id: uuidv4(), // A2A server requires an id field }; } @@ -44,10 +58,10 @@ export class KagentA2AClient { async sendMessageStream( namespace: string, agentName: string, - params: MessageSendParams, + params: { message: A2AMessage; metadata?: Record }, signal?: AbortSignal, runInSandbox = false - ): Promise> { + ): Promise> { const request = this.createStreamingRequest(params); const proxyUrl = runInSandbox ? `/a2a-sandboxes/${namespace}/${agentName}` @@ -58,6 +72,7 @@ export class KagentA2AClient { headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', + [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION, }, body: JSON.stringify(request), signal, @@ -91,7 +106,7 @@ export class KagentA2AClient { ): Promise> { const request = { jsonrpc: "2.0" as const, - method: "tasks/resubscribe", + method: A2A_JSONRPC_METHODS.subscribeToTask, params: { id: taskId }, id: uuidv4(), }; @@ -105,6 +120,7 @@ export class KagentA2AClient { headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', + [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION, }, body: JSON.stringify(request), signal, @@ -125,7 +141,7 @@ export class KagentA2AClient { /** * Process Server-Sent Events stream with proper event boundary detection */ - private async *processSSEStream(body: ReadableStream): AsyncIterable { + private async *processSSEStream(body: ReadableStream): AsyncIterable { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; @@ -158,7 +174,7 @@ export class KagentA2AClient { try { const eventData = JSON.parse(dataString); - yield eventData.result || eventData; + yield (eventData.result || eventData) as StreamResponse; } catch (error) { console.error("❌ Failed to parse SSE data:", error, dataString); } diff --git a/ui/src/lib/auth.ts b/ui/src/lib/auth.ts index aeeb2a4567..cd615a0b1d 100644 --- a/ui/src/lib/auth.ts +++ b/ui/src/lib/auth.ts @@ -72,7 +72,7 @@ function getAllowedHeadersFromEnv(): Set { * accepted from cross-origin browsers, even when the backend trusts them * server-to-server. */ -export const CORS_ALLOW_HEADERS = "Content-Type, Authorization, Accept"; +export const CORS_ALLOW_HEADERS = "Content-Type, Authorization, Accept, A2A-Version"; /** * Copy headers named in `allowed` from `getHeader` into a forwardable record. diff --git a/ui/src/lib/messageHandlers.ts b/ui/src/lib/messageHandlers.ts index 638337ae98..4b53d300f6 100644 --- a/ui/src/lib/messageHandlers.ts +++ b/ui/src/lib/messageHandlers.ts @@ -1,9 +1,41 @@ -import { Message, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TextPart, Part, DataPart } from "@a2a-js/sdk"; +import type { + Task, + TaskStatusUpdateEvent, + TaskArtifactUpdateEvent, + Part, + StreamResponse, +} from "@a2a-js/sdk"; +import { TaskState, Role, Message, roleFromJSON } from "@a2a-js/sdk"; import { v4 as uuidv4 } from "uuid"; -import { convertToUserFriendlyName, isAgentToolName, messageUtils } from "@/lib/utils"; +import { convertToUserFriendlyName, isAgentToolName, a2aPartUtils } from "@/lib/utils"; import { ApprovalDecision, AdkRequestConfirmationData, HitlPartInfo, ToolDecision, TokenStats, ChatStatus } from "@/types"; import { mapA2AStateToStatus } from "@/lib/statusUtils"; +function isInputRequiredState(state: TaskState | undefined): boolean { + return state === TaskState.TASK_STATE_INPUT_REQUIRED; +} + +function isUserRole(role: Role | string | number | undefined): boolean { + return roleFromJSON(role) === Role.ROLE_USER; +} + +function isTerminalState(state: TaskState | undefined): boolean { + return ( + state === TaskState.TASK_STATE_COMPLETED || + state === TaskState.TASK_STATE_FAILED || + state === TaskState.TASK_STATE_CANCELED || + state === TaskState.TASK_STATE_REJECTED + ); +} + +interface DataPart extends Part { + content: { $case: "data"; value: unknown }; +} + +interface TextPart extends Part { + content: { $case: "text"; value: string }; +} + // Helper functions for extracting data from stored tasks export function extractMessagesFromTasks(tasks: Task[]): Message[] { const messages: Message[] = []; @@ -18,8 +50,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { let lastSeenStats: TokenStats | undefined; for (let i = 0; i < task.history.length; i++) { - const historyItem = task.history[i]; - if (historyItem.kind !== "message") continue; + const historyItem = Message.fromJSON(task.history[i]); // Deduplicate by messageId to avoid showing the same message twice if (seenMessageIds.has(historyItem.messageId)) continue; @@ -31,7 +62,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { if (confirmationParts.length > 0) { // Find the decision that applies to THIS confirmation (first decision AFTER this message) const decision = findDecisionAfterIndex( - task.history as Array<{ kind?: string; role?: string; parts?: Part[] }>, + task.history as Array<{ role?: Role; parts?: Part[] }>, i ); @@ -52,7 +83,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { if (isUserDecisionMessage(historyItem)) continue; // User messages: push as-is (no tokenStats needed). - if (historyItem.role === "user") { + if (isUserRole(historyItem.role)) { messages.push(historyItem); continue; } @@ -69,17 +100,17 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { let hasConvertedParts = false; for (const part of historyItem.parts ?? []) { - if (part.kind !== "data") continue; - const dp = part as DataPart; + if (!isDataPart(part)) continue; + const dp = part; const partMeta = dp.metadata as Record | undefined; const partType = getMetadataValue(partMeta, "type"); if (partType === "function_call") { - const fcName = (dp.data as Record)?.name as string | undefined; + const fcName = (dp.content.value as Record)?.name as string | undefined; // Skip ADK internal calls — confirmations are handled above. if (fcName === "adk_request_confirmation" || fcName === "adk_request_credential") continue; - const toolData = dp.data as unknown as ToolCallData; + const toolData = dp.content.value as unknown as ToolCallData; // Agent calls get no initial tokenStats; child stats arrive later via // the function_response and are stamped on this card below. // Regular tool calls use the message's own invocation stats. @@ -104,7 +135,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { hasConvertedParts = true; } else if (partType === "function_response") { - const toolData = dp.data as unknown as ToolResponseData; + const toolData = dp.content.value as unknown as ToolResponseData; let frSubagentSessionId: string | undefined; if (isAgentToolName(toolData.name)) { const responseObj = toolData.response as Record | undefined; @@ -164,10 +195,10 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { /** Returns true if the message is a user HITL decision (approve/reject) or ask-user answer. */ function isUserDecisionMessage(message: Message): boolean { - if (message.role !== "user" || !message.parts) return false; + if (!isUserRole(message.role) || !message.parts) return false; return message.parts.some((p: Part) => { - if (p.kind !== "data") return false; - const data = (p as DataPart).data as Record | undefined; + if (!isDataPart(p)) return false; + const data = p.content.value as Record | undefined; return data?.decision_type != null; }); } @@ -186,7 +217,7 @@ export function extractApprovalMessagesFromTasks(tasks: Task[]): { messages: Mes for (const task of tasks) { const status = task.status; - if (status?.state !== "input-required" || !status?.message) continue; + if (!isInputRequiredState(status?.state) || !status?.message) continue; const confirmationParts = findConfirmationParts(status.message as Message); if (confirmationParts.length === 0) continue; @@ -204,13 +235,13 @@ export function extractApprovalMessagesFromTasks(tasks: Task[]): { messages: Mes function findConfirmationParts(message: Message): DataPart[] { if (!message.parts) return []; return message.parts.filter((part: Part) => { - if (part.kind !== "data") return false; - const dp = part as DataPart; + if (!isDataPart(part)) return false; + const dp = part; const meta = dp.metadata as Record | undefined; return ( getMetadataValue(meta, "type") === "function_call" && getMetadataValue(meta, "is_long_running") === true && - (dp.data as Record)?.name === "adk_request_confirmation" + (dp.content.value as Record)?.name === "adk_request_confirmation" ); }) as DataPart[]; } @@ -221,15 +252,15 @@ function findConfirmationParts(message: Message): DataPart[] { * if a task enters input-required multiple times. */ function findDecisionAfterIndex( - history: Array<{ kind?: string; role?: string; parts?: Part[] }>, + history: Array<{ role?: Role; parts?: Part[] }>, startIndex: number ): Record | undefined { for (let i = startIndex + 1; i < history.length; i++) { const item = history[i]; - if (item.kind !== "message" || item.role !== "user" || !item.parts) continue; + if (!isUserRole(item.role) || !item.parts) continue; for (const p of item.parts) { - if (p.kind !== "data") continue; - const data = (p as DataPart).data as Record | undefined; + if (!isDataPart(p)) continue; + const data = p.content.value as Record | undefined; if (data?.decision_type != null) { return data; } @@ -271,7 +302,7 @@ export function buildApprovalMessage( decisionData?: Record, tokenStats?: TokenStats ): Message { - const data = confPart.data as unknown as AdkRequestConfirmationData; + const data = confPart.content.value as unknown as AdkRequestConfirmationData; const origFc = data.args.originalFunctionCall; const toolId = origFc.id || data.id; @@ -387,8 +418,8 @@ export function extractTokenStatsFromTasks(tasks: Task[]): TokenStats { let total = 0, prompt = 0, completion = 0; for (const task of tasks) { for (const item of task.history ?? []) { - const msg = item as unknown as { kind?: string; role?: string; metadata?: Record; parts?: Part[] }; - if (msg.kind !== "message" || msg.role === "user") continue; + const msg = item as { role?: number; metadata?: Record; parts?: Part[] }; + if (isUserRole(msg.role)) continue; // Message-level usage (most agent messages carry this). const stats = getMessageTokenStats(msg.metadata); @@ -401,11 +432,11 @@ export function extractTokenStatsFromTasks(tasks: Task[]): TokenStats { // function_response from agent tools carries child-agent usage inside the // response dict rather than in message-level metadata — include it here. for (const part of msg.parts ?? []) { - if (part.kind !== "data") continue; - const dp = part as DataPart; + if (!isDataPart(part)) continue; + const dp = part; const partMeta = dp.metadata as Record | undefined; if (getMetadataValue(partMeta, "type") !== "function_response") continue; - const toolData = dp.data as unknown as ToolResponseData; + const toolData = dp.content.value as unknown as ToolResponseData; if (!isAgentToolName(toolData.name)) continue; const responseUsage = (toolData.response as Record | undefined)?.kagent_usage_metadata; if (!responseUsage) continue; @@ -537,11 +568,11 @@ export function normalizeToolResultToText(toolData: ToolResponseData): string { } function isTextPart(part: Part): part is TextPart { - return part.kind === "text"; + return a2aPartUtils.getCase(part) === "text"; } function isDataPart(part: Part): part is DataPart { - return part.kind === "data"; + return a2aPartUtils.getCase(part) === "data"; } function getSourceFromMetadata(metadata: ADKMetadata | undefined, fallback: string = "assistant"): string { @@ -577,20 +608,23 @@ export function createMessage( } = options; const message: Message = { - kind: "message", messageId, - role: source === "user" ? "user" : "agent", + role: source === "user" ? Role.ROLE_USER : Role.ROLE_AGENT, parts: [{ - kind: "text", - text: content + content: { $case: "text", value: content }, + metadata: undefined, + filename: "", + mediaType: "text/plain", }], - contextId, - taskId, + contextId: contextId ?? "", + taskId: taskId ?? "", metadata: { originalType, displaySource: source, ...additionalMetadata - } + }, + extensions: [], + referenceTaskIds: [], }; return message; } @@ -641,15 +675,15 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { const aggregatePartsToText = (parts: Part[]): string => { return parts.map((part: Part) => { if (isTextPart(part)) { - return part.text || ""; + return part.content.value || ""; } else if (isDataPart(part)) { try { - return JSON.stringify(part.data || ""); + return JSON.stringify(part.content.value || ""); } catch { - return String(part.data); + return String(part.content.value); } - } else if (part.kind === "file") { - return `[File: ${(part as { file?: { name?: string } }).file?.name || "unknown"}]`; + } else if (part.content?.$case === "raw" || part.content?.$case === "url") { + return `[File: ${part.filename || "unknown"}]`; } return String(part); }).join(""); @@ -765,7 +799,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } }; - const isUserMessage = (message: Message): boolean => message.role === "user"; + const isUserMessage = (message: Message): boolean => isUserRole(message.role); // Simple fallback source when metadata is not available const defaultAgentSource = handlers.agentContext @@ -800,7 +834,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { handlers.setMessages(prev => { const updated = [...prev]; for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].role === "user") break; + if (isUserRole(updated[i].role)) break; // Stop at an invocation boundary — everything before belongs to an // earlier LLM call and must not be tagged with this turn's stats. // ToolApprovalRequest: HITL boundary; ToolCallExecutionEvent: the @@ -817,11 +851,12 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } // Check for tool approval interrupt + const status = statusUpdate.status; if ( - statusUpdate.status.state === "input-required" && - statusUpdate.status.message + isInputRequiredState(status?.state) && + status?.message ) { - const confirmationParts = findConfirmationParts(statusUpdate.status.message as Message); + const confirmationParts = findConfirmationParts(status.message as Message); if (confirmationParts.length > 0) { for (const confPart of confirmationParts) { @@ -846,8 +881,8 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } // If the status update has a message, process it - if (statusUpdate.status.message) { - const message = statusUpdate.status.message; + if (status?.message) { + const message = status.message; // Skip user messages to avoid duplicates (they're already shown immediately) if (isUserMessage(message)) { @@ -857,10 +892,10 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { for (const part of message.parts) { if (isTextPart(part)) { - const textContent = part.text || ""; + const textContent = part.content.value || ""; const source = getSourceFromMetadata(adkMetadata, defaultAgentSource); - if (statusUpdate.final) { + if (isTerminalState(statusUpdate.status?.state)) { const displayMessage = createMessage( textContent, source, @@ -883,7 +918,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } } } else if (isDataPart(part)) { - const data = part.data; + const data = part.content.value; const partMetadata = part.metadata as ADKMetadata | undefined; const partType = getMetadataValue(partMetadata as Record, "type"); @@ -923,12 +958,12 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } } else { if (handlers.setChatStatus) { - const uiStatus = mapA2AStateToStatus(statusUpdate.status.state); + const uiStatus = mapA2AStateToStatus(status?.state); handlers.setChatStatus(uiStatus); } } - if (statusUpdate.final) { + if (isTerminalState(statusUpdate.status?.state)) { finalizeStreaming(); } } catch (error) { @@ -948,14 +983,15 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { // Add artifact content and convert tool parts to messages let artifactText = ""; const convertedMessages: Message[] = []; - for (const part of artifactUpdate.artifact.parts) { + const artifactParts = artifactUpdate.artifact?.parts ?? []; + for (const part of artifactParts) { if (isTextPart(part)) { - artifactText += part.text || ""; + artifactText += part.content.value || ""; continue; } if (isDataPart(part)) { const partMetadata = part.metadata as ADKMetadata | undefined; - const data = part.data; + const data = part.content.value; const source = getSourceFromMetadata(adkMetadata, defaultAgentSource); const partType = getMetadataValue(partMetadata as Record, "type"); @@ -1006,8 +1042,8 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } continue; } - if (part.kind === "file") { - artifactText += `[File: ${(part as { file?: { name?: string } }).file?.name || "unknown"}]`; + if (part.content?.$case === "raw" || part.content?.$case === "url") { + artifactText += `[File: ${part.filename || "unknown"}]`; continue; } artifactText += String(part); @@ -1062,7 +1098,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { const handleA2AMessage = (message: Message) => { const content = aggregatePartsToText(message.parts); - if (message.role !== "user") { + if (!isUserRole(message.role)) { const source = getSourceFromMetadata(message.metadata as ADKMetadata, defaultAgentSource); const displayMessage = createMessage( content, @@ -1082,30 +1118,27 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { appendMessage(message); }; - const handleMessageEvent = (message: Message) => { - if (messageUtils.isA2ATask(message)) { - handlers.setIsStreaming(true); - return; - } - - if (messageUtils.isA2ATaskStatusUpdate(message)) { - handleA2ATaskStatusUpdate(message); - return; + const handleMessageEvent = (streamEvent: StreamResponse) => { + const payload = streamEvent.payload; + if (!payload) return; + + switch (payload.$case) { + case "task": + handlers.setIsStreaming(true); + return; + case "statusUpdate": + handleA2ATaskStatusUpdate(payload.value); + return; + case "artifactUpdate": + handleA2ATaskArtifactUpdate(payload.value); + return; + case "message": + handleA2AMessage(payload.value); + return; + default: + console.warn("🤔 Unknown message type from A2A stream:", streamEvent); + return; } - - if (messageUtils.isA2ATaskArtifactUpdate(message)) { - handleA2ATaskArtifactUpdate(message); - return; - } - - if (messageUtils.isA2AMessage(message)) { - handleA2AMessage(message); - return; - } - - // If we get here, it's an unknown message type from the A2A stream - console.warn("🤔 Unknown message type from A2A stream:", message); - handleOtherMessage(message); }; return { diff --git a/ui/src/lib/statusUtils.ts b/ui/src/lib/statusUtils.ts index 4df1003e7f..1004763a83 100644 --- a/ui/src/lib/statusUtils.ts +++ b/ui/src/lib/statusUtils.ts @@ -6,24 +6,33 @@ export interface StatusInfo { placeholder: string; } -// Map A2A TaskState to our ChatStatus for UI purposes -export const mapA2AStateToStatus = (state: TaskState): ChatStatus => { +// Map strict v1 A2A TaskState enum values to our ChatStatus. +export const mapA2AStateToStatus = (state: TaskState | undefined): ChatStatus => { + if (state === TaskState.TASK_STATE_SUBMITTED) { + return "submitted"; + } + if (state === TaskState.TASK_STATE_WORKING) { + return "working"; + } + if (state === TaskState.TASK_STATE_INPUT_REQUIRED) { + return "input_required"; + } + if (state === TaskState.TASK_STATE_COMPLETED) { + return "ready"; + } + if ( + state === TaskState.TASK_STATE_CANCELED || + state === TaskState.TASK_STATE_FAILED || + state === TaskState.TASK_STATE_REJECTED + ) { + return "error"; + } + if (state === TaskState.TASK_STATE_AUTH_REQUIRED) { + return "auth_required"; + } + switch (state) { - case "submitted": - return "submitted"; - case "working": - return "working"; - case "input-required": - return "input_required"; - case "completed": - return "ready"; - case "canceled": - case "failed": - case "rejected": - return "error"; - case "auth-required": - return "auth_required"; - case "unknown": + case TaskState.TASK_STATE_UNSPECIFIED: default: return "thinking"; } diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index e2893d1960..89b60c3db7 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,7 +1,14 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { v4 as uuidv4 } from "uuid"; -import { Message as A2AMessage, Task as A2ATask, TaskStatusUpdateEvent as A2ATaskStatusUpdateEvent, TaskArtifactUpdateEvent as A2ATaskArtifactUpdateEvent } from "@a2a-js/sdk"; +import type { + Message as A2AMessage, + Task as A2ATask, + TaskStatusUpdateEvent as A2ATaskStatusUpdateEvent, + TaskArtifactUpdateEvent as A2ATaskArtifactUpdateEvent, + StreamResponse as A2AStreamResponse, + Part as A2APart, +} from "@a2a-js/sdk"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -149,20 +156,36 @@ export const createRFC1123ValidName = (parts: string[]): string => { }; export const messageUtils = { + isA2AStreamResponse(content: unknown): content is A2AStreamResponse { + return typeof content === "object" && content !== null && "payload" in content; + }, + isA2AMessage(content: unknown): content is A2AMessage { - return typeof content === "object" && content !== null && "kind" in content && content.kind === "message"; + return typeof content === "object" && content !== null && "messageId" in content && "parts" in content; }, isA2ATask(content: unknown): content is A2ATask { - return typeof content === "object" && content !== null && "kind" in content && content.kind === "task"; + return typeof content === "object" && content !== null && "id" in content && "history" in content; }, isA2ATaskStatusUpdate(content: unknown): content is A2ATaskStatusUpdateEvent { - return typeof content === "object" && content !== null && "kind" in content && content.kind === "status-update"; + return typeof content === "object" && content !== null && "taskId" in content && "status" in content; }, isA2ATaskArtifactUpdate(content: unknown): content is A2ATaskArtifactUpdateEvent { - return typeof content === "object" && content !== null && "kind" in content && content.kind === "artifact-update"; + return typeof content === "object" && content !== null && "taskId" in content && "artifact" in content; + }, +}; + +export const a2aPartUtils = { + getCase(part: A2APart): string | undefined { + return part.content?.$case; + }, + getText(part: A2APart): string { + return part.content?.$case === "text" ? part.content.value : ""; + }, + getData(part: A2APart): unknown | undefined { + return part.content?.$case === "data" ? part.content.value : undefined; }, }; diff --git a/ui/src/mocks/handlers.ts b/ui/src/mocks/handlers.ts index 66b737bbcb..6d796a7b41 100644 --- a/ui/src/mocks/handlers.ts +++ b/ui/src/mocks/handlers.ts @@ -1,6 +1,7 @@ import { http, HttpResponse, delay } from "msw"; import type { Session } from "@/types"; -import type { Task, TaskState } from "@a2a-js/sdk"; +import type { Task } from "@a2a-js/sdk"; +import type { TaskState } from "@a2a-js/sdk"; /** * The backend URL that fetchApi constructs requests against. @@ -39,25 +40,34 @@ export function createMockTask( messageId?: string; metadata?: Record; }>, - status: { state: TaskState } = { state: "completed" }, + status: { state: TaskState } = { state: 3 }, ): Task { return { id: taskId, contextId, - kind: "task", status, history: history.map((h, i) => ({ - kind: "message" as const, messageId: h.messageId ?? `${taskId}-msg-${i}`, - role: h.role, - parts: [{ kind: "text" as const, text: h.text }], + role: h.role === "user" ? 1 : 2, + parts: [{ + content: { $case: "text", value: h.text }, + metadata: {}, + filename: "", + mediaType: "text/plain", + }], + contextId, + taskId, metadata: { displaySource: h.role === "agent" ? "assistant" : undefined, timestamp: Date.now() - (history.length - i) * 60_000, ...h.metadata, }, + extensions: [], + referenceTaskIds: [], })), - }; + artifacts: [], + metadata: undefined, + } as unknown as Task; } /** @@ -75,73 +85,95 @@ export function createMockToolCallTask( return { id: taskId, contextId, - kind: "task", - status: { state: "completed" }, + status: { state: 3 }, history: [ // User message that triggered the tool call { - kind: "message" as const, messageId: `${taskId}-user`, - role: "user" as const, - parts: [{ kind: "text" as const, text: "Run the tool" }], + role: 1, + parts: [{ + content: { $case: "text", value: "Run the tool" }, + metadata: {}, + filename: "", + mediaType: "text/plain", + }], + contextId, + taskId, metadata: { timestamp: Date.now() - 120_000 }, + extensions: [], + referenceTaskIds: [], }, // Agent message with tool call request (DataPart) { - kind: "message" as const, messageId: `${taskId}-tool-call`, - role: "agent" as const, + role: 2, parts: [ { - kind: "data" as const, - data: { id: `call-${taskId}`, name: toolName, args: toolArgs }, + content: { $case: "data", value: { id: `call-${taskId}`, name: toolName, args: toolArgs } }, metadata: { adk_type: "function_call" }, + filename: "", + mediaType: "application/json", }, ], + contextId, + taskId, metadata: { displaySource: "assistant", timestamp: Date.now() - 90_000, }, + extensions: [], + referenceTaskIds: [], }, // Agent message with tool execution result (DataPart) { - kind: "message" as const, messageId: `${taskId}-tool-result`, - role: "agent" as const, + role: 2, parts: [ { - kind: "data" as const, - data: { + content: { $case: "data", value: { id: `call-${taskId}`, name: toolName, response: { result: toolResult, isError: false }, - }, + } }, metadata: { adk_type: "function_response" }, + filename: "", + mediaType: "application/json", }, ], + contextId, + taskId, metadata: { displaySource: "assistant", timestamp: Date.now() - 60_000, }, + extensions: [], + referenceTaskIds: [], }, // Final text response after tool execution { - kind: "message" as const, messageId: `${taskId}-final`, - role: "agent" as const, + role: 2, parts: [ { - kind: "text" as const, - text: `I used the **${toolName}** tool and here are the results:\n\n${toolResult}`, + content: { $case: "text", value: `I used the **${toolName}** tool and here are the results:\n\n${toolResult}` }, + metadata: {}, + filename: "", + mediaType: "text/plain", }, ], + contextId, + taskId, metadata: { displaySource: "assistant", timestamp: Date.now() - 30_000, }, + extensions: [], + referenceTaskIds: [], }, ], - }; + artifacts: [], + metadata: undefined, + } as unknown as Task; } // --------------------------------------------------------------------------- From 7aa8bc215f28a20256c077fd95501f20994d4771 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Fri, 22 May 2026 11:53:48 -0400 Subject: [PATCH 2/8] go adk runtime v1 migration Signed-off-by: Jet Chiang --- go/adk/cmd/main.go | 8 +- go/adk/examples/byo/main.go | 11 +- go/adk/pkg/a2a/agentcard.go | 12 +- go/adk/pkg/a2a/consts.go | 2 +- go/adk/pkg/a2a/converter.go | 69 ++-- go/adk/pkg/a2a/converter_test.go | 152 ++++---- go/adk/pkg/a2a/executor.go | 531 ++++++++++++++-------------- go/adk/pkg/a2a/hitl.go | 92 +++-- go/adk/pkg/a2a/hitl_test.go | 106 +++--- go/adk/pkg/a2a/server/server.go | 4 +- go/adk/pkg/app/app.go | 17 +- go/adk/pkg/app/app_test.go | 14 +- go/adk/pkg/config/config_loader.go | 2 +- go/adk/pkg/mcp/registry.go | 6 +- go/adk/pkg/mcp/registry_test.go | 5 +- go/adk/pkg/taskstore/store.go | 52 ++- go/adk/pkg/tools/remote_a2a_tool.go | 79 +++-- 17 files changed, 602 insertions(+), 560 deletions(-) diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index ad0d28d804..5a709e7f4f 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -8,7 +8,7 @@ import ( "strings" "time" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/go-logr/logr" "github.com/go-logr/zapr" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" @@ -195,11 +195,13 @@ func main() { Name: "go-adk-agent", Description: "Go-based Agent Development Kit", Version: "0.2.0", + SupportedInterfaces: []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("/", a2atype.TransportProtocolJSONRPC), + }, } } agentCard.Capabilities = a2atype.AgentCapabilities{ - Streaming: stream, - StateTransitionHistory: true, + Streaming: stream, } // Delegate server, task store, and remaining infrastructure to app.New. diff --git a/go/adk/examples/byo/main.go b/go/adk/examples/byo/main.go index 1de80c0c26..d382d2d14d 100644 --- a/go/adk/examples/byo/main.go +++ b/go/adk/examples/byo/main.go @@ -41,7 +41,7 @@ import ( "log" "os" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/go-logr/zapr" "github.com/kagent-dev/kagent/go/adk/pkg/app" "github.com/kagent-dev/kagent/go/adk/pkg/models" @@ -50,7 +50,7 @@ import ( "google.golang.org/adk/agent/llmagent" "google.golang.org/adk/agent/workflowagents/parallelagent" "google.golang.org/adk/runner" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" adksession "google.golang.org/adk/session" ) @@ -127,10 +127,11 @@ func main() { Name: "byo-parallel-agent", Description: "A BYO agent that runs creative and technical writers in parallel", Version: "1.0.0", - URL: "http://localhost:8082", + SupportedInterfaces: []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("http://localhost:8082", a2atype.TransportProtocolJSONRPC), + }, Capabilities: a2atype.AgentCapabilities{ - Streaming: stream, - StateTransitionHistory: true, + Streaming: stream, }, DefaultInputModes: []string{"text/plain"}, DefaultOutputModes: []string{"text/plain"}, diff --git a/go/adk/pkg/a2a/agentcard.go b/go/adk/pkg/a2a/agentcard.go index 6dfc7771be..4d6da1a1e2 100644 --- a/go/adk/pkg/a2a/agentcard.go +++ b/go/adk/pkg/a2a/agentcard.go @@ -1,9 +1,9 @@ package a2a import ( - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" adkagent "google.golang.org/adk/agent" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" ) // EnrichAgentCard populates the agent card with skills derived from the ADK @@ -22,8 +22,10 @@ func EnrichAgentCard(card *a2atype.AgentCard, agent adkagent.Agent) { card.Description = agent.Description() } - // Default to JSONRPC when no transport is explicitly configured. - if card.PreferredTransport == "" { - card.PreferredTransport = a2atype.TransportProtocolJSONRPC + // Default to JSONRPC when no interface is explicitly configured. + if len(card.SupportedInterfaces) == 0 { + card.SupportedInterfaces = []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("/", a2atype.TransportProtocolJSONRPC), + } } } diff --git a/go/adk/pkg/a2a/consts.go b/go/adk/pkg/a2a/consts.go index 950b8cd1ef..1087cea8f1 100644 --- a/go/adk/pkg/a2a/consts.go +++ b/go/adk/pkg/a2a/consts.go @@ -1,6 +1,6 @@ package a2a -import "google.golang.org/adk/server/adka2a" +import "google.golang.org/adk/server/adka2a/v2" const ( StateKeySessionName = "session_name" diff --git a/go/adk/pkg/a2a/converter.go b/go/adk/pkg/a2a/converter.go index c74dfe7784..8db33f1bb4 100644 --- a/go/adk/pkg/a2a/converter.go +++ b/go/adk/pkg/a2a/converter.go @@ -5,8 +5,8 @@ import ( "encoding/json" "maps" - a2atype "github.com/a2aproject/a2a-go/a2a" - "google.golang.org/adk/server/adka2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "google.golang.org/adk/server/adka2a/v2" adksession "google.golang.org/adk/session" "google.golang.org/genai" ) @@ -14,16 +14,16 @@ import ( // isEmptyDataPart returns true if the part is a DataPart with nil or empty Data. // The ADK processor emits such parts as cleanup signals for streaming partial // artifacts and as a fallback for unrecognized GenAI part types. -func isEmptyDataPart(part a2atype.Part) bool { - dp, ok := part.(a2atype.DataPart) - return ok && len(dp.Data) == 0 +func isEmptyDataPart(part *a2atype.Part) bool { + dp := asDataPart(part) + return dp != nil && len(dp) == 0 } // filterTextParts returns only TextParts from the given parts. func filterTextParts(parts a2atype.ContentParts) a2atype.ContentParts { var out a2atype.ContentParts for _, p := range parts { - if _, ok := p.(a2atype.TextPart); ok { + if p != nil && p.Text() != "" { out = append(out, p) } } @@ -56,7 +56,7 @@ func messageToGenAIContent(ctx context.Context, msg *a2atype.Message) (*genai.Co } // a2aPartConverter converts inbound A2A parts to GenAI parts. -func a2aPartConverter(_ context.Context, _ a2atype.Event, part a2atype.Part) (*genai.Part, error) { +func a2aPartConverter(_ context.Context, _ a2atype.Event, part *a2atype.Part) (*genai.Part, error) { dp := asDataPart(part) if dp == nil { // Text and file parts: delegate to ADK default. @@ -64,15 +64,15 @@ func a2aPartConverter(_ context.Context, _ a2atype.Event, part a2atype.Part) (*g } // DataPart with kagent_type metadata: convert explicitly. - if dp.Metadata != nil { - if _, has := dp.Metadata[GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)]; has { - return convertDataPartToGenAI(dp, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) + if part != nil && part.Metadata != nil { + if _, has := part.Metadata[GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)]; has { + return convertDataPartToGenAI(dp, part.Metadata, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) } } // DataPart with adk_type metadata (produced by the ADK itself): delegate. - if dp.Metadata != nil { - if _, has := dp.Metadata[adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey)]; has { + if part != nil && part.Metadata != nil { + if _, has := part.Metadata[adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey)]; has { return adka2a.ToGenAIPart(part) } } @@ -84,58 +84,55 @@ func a2aPartConverter(_ context.Context, _ a2atype.Event, part a2atype.Part) (*g // convertDataPartToGenAI converts a DataPart with a type metadata key // (either adk_type or kagent_type) back to GenAI for inbound message processing. -func convertDataPartToGenAI(p *a2atype.DataPart, typeKey string) (*genai.Part, error) { - if p == nil { +func convertDataPartToGenAI(data map[string]any, metadata map[string]any, typeKey string) (*genai.Part, error) { + if data == nil { return nil, nil } - partType, _ := p.Metadata[typeKey].(string) + partType, _ := metadata[typeKey].(string) switch partType { case A2ADataPartMetadataTypeFunctionCall: - name, _ := p.Data[PartKeyName].(string) - funcArgs, _ := p.Data[PartKeyArgs].(map[string]any) + name, _ := data[PartKeyName].(string) + funcArgs, _ := data[PartKeyArgs].(map[string]any) if name != "" { genaiPart := genai.NewPartFromFunctionCall(name, funcArgs) - if id, ok := p.Data[PartKeyID].(string); ok && id != "" { + if id, ok := data[PartKeyID].(string); ok && id != "" { genaiPart.FunctionCall.ID = id } return genaiPart, nil } case A2ADataPartMetadataTypeFunctionResponse: - name, _ := p.Data[PartKeyName].(string) - response, _ := p.Data[PartKeyResponse].(map[string]any) + name, _ := data[PartKeyName].(string) + response, _ := data[PartKeyResponse].(map[string]any) if name != "" { genaiPart := genai.NewPartFromFunctionResponse(name, response) - if id, ok := p.Data[PartKeyID].(string); ok && id != "" { + if id, ok := data[PartKeyID].(string); ok && id != "" { genaiPart.FunctionResponse.ID = id } return genaiPart, nil } } - return adka2a.ToGenAIPart(p) + return adka2a.ToGenAIPart(a2atype.NewDataPart(data)) } // stampSubagentSessionID adds kagent_subagent_session_id to function_call // DataParts when the tool name is present in subagentSessionIDs. // Part can be either a *a2atype.DataPart or a2atype.DataPart. -func stampSubagentSessionID(part a2atype.Part, subagentSessionIDs map[string]string) a2atype.Part { - switch p := part.(type) { - case *a2atype.DataPart: - cp := *p - stampSubagentSessionIDOnDataPart(&cp, subagentSessionIDs) - return cp - case a2atype.DataPart: - cp := p - stampSubagentSessionIDOnDataPart(&cp, subagentSessionIDs) - return cp - default: - return part +func stampSubagentSessionID(part *a2atype.Part, subagentSessionIDs map[string]string) *a2atype.Part { + if part == nil { + return nil } + stampSubagentSessionIDOnDataPart(part, subagentSessionIDs) + return part } -func stampSubagentSessionIDOnDataPart(dp *a2atype.DataPart, subagentSessionIDs map[string]string) { +func stampSubagentSessionIDOnDataPart(dp *a2atype.Part, subagentSessionIDs map[string]string) { if dp == nil || len(subagentSessionIDs) == 0 { return } + view := asDataPart(dp) + if view == nil { + return + } if dp.Metadata == nil { dp.Metadata = map[string]any{} } @@ -143,7 +140,7 @@ func stampSubagentSessionIDOnDataPart(dp *a2atype.DataPart, subagentSessionIDs m if partType != A2ADataPartMetadataTypeFunctionCall { return } - toolName, _ := dp.Data[PartKeyName].(string) + toolName, _ := view[PartKeyName].(string) if toolName == "" { return } diff --git a/go/adk/pkg/a2a/converter_test.go b/go/adk/pkg/a2a/converter_test.go index 43df600982..8f0c64b4e6 100644 --- a/go/adk/pkg/a2a/converter_test.go +++ b/go/adk/pkg/a2a/converter_test.go @@ -4,28 +4,33 @@ import ( "context" "testing" - a2atype "github.com/a2aproject/a2a-go/a2a" - "google.golang.org/adk/server/adka2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/genai" ) +func convDataPart(data map[string]any, metadata map[string]any) *a2atype.Part { + p := a2atype.NewDataPart(data) + if metadata != nil { + p.Metadata = metadata + } + return p +} + // --------------------------------------------------------------------------- // convertDataPartToGenAI // --------------------------------------------------------------------------- func TestConvertDataPartToGenAI_FunctionCall_KagentPrefix(t *testing.T) { - dp := &a2atype.DataPart{ - Data: map[string]any{ - "name": "my_func", - "args": map[string]any{"key": "value"}, - "id": "call_1", - }, - Metadata: map[string]any{ - GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall, - }, + data := map[string]any{ + "name": "my_func", + "args": map[string]any{"key": "value"}, + "id": "call_1", } - - part, err := convertDataPartToGenAI(dp, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) + meta := map[string]any{ + GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall, + } + part, err := convertDataPartToGenAI(data, meta, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -41,18 +46,15 @@ func TestConvertDataPartToGenAI_FunctionCall_KagentPrefix(t *testing.T) { } func TestConvertDataPartToGenAI_FunctionCall_AdkPrefix(t *testing.T) { - dp := &a2atype.DataPart{ - Data: map[string]any{ - "name": "my_func", - "args": map[string]any{"key": "value"}, - "id": "call_1", - }, - Metadata: map[string]any{ - adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall, - }, + data := map[string]any{ + "name": "my_func", + "args": map[string]any{"key": "value"}, + "id": "call_1", } - - part, err := convertDataPartToGenAI(dp, adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey)) + meta := map[string]any{ + adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall, + } + part, err := convertDataPartToGenAI(data, meta, adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -65,18 +67,15 @@ func TestConvertDataPartToGenAI_FunctionCall_AdkPrefix(t *testing.T) { } func TestConvertDataPartToGenAI_FunctionResponse(t *testing.T) { - dp := &a2atype.DataPart{ - Data: map[string]any{ - "name": "my_func", - "response": map[string]any{"result": "ok"}, - "id": "call_2", - }, - Metadata: map[string]any{ - GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, - }, + data := map[string]any{ + "name": "my_func", + "response": map[string]any{"result": "ok"}, + "id": "call_2", } - - part, err := convertDataPartToGenAI(dp, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) + meta := map[string]any{ + GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, + } + part, err := convertDataPartToGenAI(data, meta, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -92,7 +91,7 @@ func TestConvertDataPartToGenAI_FunctionResponse(t *testing.T) { } func TestConvertDataPartToGenAI_Nil(t *testing.T) { - part, err := convertDataPartToGenAI(nil, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) + part, err := convertDataPartToGenAI(nil, nil, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -102,14 +101,16 @@ func TestConvertDataPartToGenAI_Nil(t *testing.T) { } func TestConvertDataPartToGenAI_UnknownType(t *testing.T) { - dp := &a2atype.DataPart{ - Data: map[string]any{"foo": "bar"}, - Metadata: map[string]any{"kagent_type": "unknown_type"}, + part, err := convertDataPartToGenAI( + map[string]any{"foo": "bar"}, + map[string]any{"kagent_type": "unknown_type"}, + "kagent_type", + ) + if err != nil { + t.Fatalf("unexpected error for unknown part type: %v", err) } - - _, err := convertDataPartToGenAI(dp, "kagent_type") - if err == nil { - t.Fatal("expected error for unknown part type") + if part == nil { + t.Fatal("expected fallback GenAI part for unknown type") } } @@ -118,7 +119,7 @@ func TestConvertDataPartToGenAI_UnknownType(t *testing.T) { // --------------------------------------------------------------------------- func TestMessageToGenAIContent_TextPart(t *testing.T) { - msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.TextPart{Text: "hello"}) + msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hello")) content, err := messageToGenAIContent(context.Background(), msg) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -139,8 +140,8 @@ func TestMessageToGenAIContent_DropsUnrecognisedDataPart(t *testing.T) { // A DataPart with no recognised kagent_type metadata (e.g. a HITL decision // payload like {decision_type: "approve"}) should be dropped silently. msg := a2atype.NewMessage(a2atype.MessageRoleUser, - a2atype.TextPart{Text: "approving"}, - &a2atype.DataPart{Data: map[string]any{"decision_type": "approve"}}, + a2atype.NewTextPart("approving"), + convDataPart(map[string]any{"decision_type": "approve"}, nil), ) content, err := messageToGenAIContent(context.Background(), msg) if err != nil { @@ -157,16 +158,13 @@ func TestMessageToGenAIContent_DropsUnrecognisedDataPart(t *testing.T) { func TestMessageToGenAIContent_KagentTypeFunctionResponse(t *testing.T) { // A DataPart with kagent_type=function_response should be converted to GenAI. - dp := &a2atype.DataPart{ - Data: map[string]any{ - "name": "my_func", - "id": "call_1", - "response": map[string]any{"result": "ok"}, - }, - Metadata: map[string]any{ - GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, - }, - } + dp := convDataPart(map[string]any{ + "name": "my_func", + "id": "call_1", + "response": map[string]any{"result": "ok"}, + }, map[string]any{ + GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, + }) msg := a2atype.NewMessage(a2atype.MessageRoleUser, dp) content, err := messageToGenAIContent(context.Background(), msg) if err != nil { @@ -200,22 +198,18 @@ func TestMessageToGenAIContent_NilMessage(t *testing.T) { func TestStampSubagentSessionID_FunctionCallPart(t *testing.T) { subagentIDs := map[string]string{"k8s_agent": "session-abc"} - dp := &a2atype.DataPart{ - Data: map[string]any{ - PartKeyName: "k8s_agent", - PartKeyArgs: map[string]any{"request": "list pods"}, - }, - Metadata: map[string]any{ - adka2a.ToA2AMetaKey("type"): A2ADataPartMetadataTypeFunctionCall, - }, - } + dp := convDataPart(map[string]any{ + PartKeyName: "k8s_agent", + PartKeyArgs: map[string]any{"request": "list pods"}, + }, map[string]any{ + adka2a.ToA2AMetaKey("type"): A2ADataPartMetadataTypeFunctionCall, + }) updated := stampSubagentSessionID(dp, subagentIDs) - updatedDP, ok := updated.(a2atype.DataPart) - if !ok { - t.Fatalf("updated part type = %T, want a2atype.DataPart", updated) + if updated == nil { + t.Fatal("updated part is nil") } - sessionID, has := updatedDP.Metadata[GetKAgentMetadataKey("subagent_session_id")] + sessionID, has := updated.Metadata[GetKAgentMetadataKey("subagent_session_id")] if !has { t.Fatal("expected kagent_subagent_session_id in metadata, not found") } @@ -227,21 +221,17 @@ func TestStampSubagentSessionID_FunctionCallPart(t *testing.T) { func TestStampSubagentSessionID_UnknownTool(t *testing.T) { subagentIDs := map[string]string{"k8s_agent": "session-abc"} - dp := &a2atype.DataPart{ - Data: map[string]any{ - PartKeyName: "unknown_tool", - }, - Metadata: map[string]any{ - adka2a.ToA2AMetaKey("type"): A2ADataPartMetadataTypeFunctionCall, - }, - } + dp := convDataPart(map[string]any{ + PartKeyName: "unknown_tool", + }, map[string]any{ + adka2a.ToA2AMetaKey("type"): A2ADataPartMetadataTypeFunctionCall, + }) updated := stampSubagentSessionID(dp, subagentIDs) - updatedDP, ok := updated.(a2atype.DataPart) - if !ok { - t.Fatalf("updated part type = %T, want a2atype.DataPart", updated) + if updated == nil { + t.Fatal("updated part is nil") } - if _, ok := updatedDP.Metadata[GetKAgentMetadataKey("subagent_session_id")]; ok { + if _, ok := updated.Metadata[GetKAgentMetadataKey("subagent_session_id")]; ok { t.Error("expected no subagent_session_id for unknown tool") } } diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index 8ae11d3d69..af0c705665 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -3,13 +3,13 @@ package a2a import ( "context" "fmt" + "iter" "maps" "os" "strings" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" - "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/go-logr/logr" "github.com/kagent-dev/kagent/go/adk/pkg/auth" "github.com/kagent-dev/kagent/go/adk/pkg/models" @@ -19,7 +19,7 @@ import ( "go.opentelemetry.io/otel/attribute" adkagent "google.golang.org/adk/agent" "google.golang.org/adk/runner" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" ) const ( @@ -83,321 +83,327 @@ type userIDInterceptor struct { a2asrv.PassthroughCallInterceptor } -func (u *userIDInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallContext, _ *a2asrv.Request) (context.Context, error) { +func (u *userIDInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallContext, _ *a2asrv.Request) (context.Context, any, error) { if callCtx == nil { - return ctx, nil + return ctx, nil, nil } - meta := callCtx.RequestMeta() + meta := callCtx.ServiceParams() if meta == nil { - return ctx, nil + return ctx, nil, nil } vals, ok := meta.Get("x-user-id") if !ok || len(vals) == 0 || vals[0] == "" { - return ctx, nil + return ctx, nil, nil } // Set the authenticated user so downstream code picks up the real identity. - callCtx.User = &a2asrv.AuthenticatedUser{UserName: vals[0]} - return ctx, nil + callCtx.User = a2asrv.NewAuthenticatedUser(vals[0], nil) + return ctx, nil, nil } // Execute implements a2asrv.AgentExecutor. // It follows the Python _handle_request pattern: set up session, handle HITL, // convert inbound message, run the agent loop, and emit A2A events. -func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - if reqCtx.Message == nil { - return fmt.Errorf("A2A request message cannot be nil") - } +func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + if reqCtx.Message == nil { + yield(nil, fmt.Errorf("A2A request message cannot be nil")) + return + } - // 1. Derive userID / sessionID. - userID := "A2A_USER_" + reqCtx.ContextID - if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { - if callCtx.User != nil && callCtx.User.Name() != "" { - userID = callCtx.User.Name() + // 1. Derive userID / sessionID. + userID := "A2A_USER_" + reqCtx.ContextID + if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { + if callCtx.User != nil && callCtx.User.Name != "" { + userID = callCtx.User.Name + } } - } - sessionID := reqCtx.ContextID - - ctx = withBearerToken(ctx) - ctx = auth.WithUserID(ctx, userID) - - e.logger.Info("Execute", - "taskID", reqCtx.TaskID, - "contextID", reqCtx.ContextID, - "appName", e.appName, - "userID", userID, - ) - - // 2. Set up telemetry span attributes. - spanAttributes := map[string]string{ - "kagent.user_id": userID, - "gen_ai.task.id": string(reqCtx.TaskID), - "gen_ai.conversation.id": sessionID, - } - if e.appName != "" { - spanAttributes["kagent.app_name"] = e.appName - } - ctx = telemetry.SetKAgentSpanAttributes(ctx, spanAttributes) - ctx, invocationSpan := telemetry.StartInvocationSpan(ctx) - defer invocationSpan.End() + sessionID := reqCtx.ContextID + + ctx = withBearerToken(ctx) + ctx = auth.WithUserID(ctx, userID) + + e.logger.Info("Execute", + "taskID", reqCtx.TaskID, + "contextID", reqCtx.ContextID, + "appName", e.appName, + "userID", userID, + ) + + // 2. Set up telemetry span attributes. + spanAttributes := map[string]string{ + "kagent.user_id": userID, + "gen_ai.task.id": string(reqCtx.TaskID), + "gen_ai.conversation.id": sessionID, + } + if e.appName != "" { + spanAttributes["kagent.app_name"] = e.appName + } + ctx = telemetry.SetKAgentSpanAttributes(ctx, spanAttributes) + ctx, invocationSpan := telemetry.StartInvocationSpan(ctx) + defer invocationSpan.End() - telemetry.SetMessageMetadataAttributes(ctx, reqCtx.Message.Metadata) + telemetry.SetMessageMetadataAttributes(ctx, reqCtx.Message.Metadata) - // 3. Initialize skills session path. - if e.skillsDirectory != "" && sessionID != "" { - if _, err := skills.InitializeSessionPath(sessionID, e.skillsDirectory); err != nil { - e.logger.V(1).Info("Skills session path init failed (continuing)", - "error", err, "sessionID", sessionID) + // 3. Initialize skills session path. + if e.skillsDirectory != "" && sessionID != "" { + if _, err := skills.InitializeSessionPath(sessionID, e.skillsDirectory); err != nil { + e.logger.V(1).Info("Skills session path init failed (continuing)", + "error", err, "sessionID", sessionID) + } } - } - // 4. Create / lookup session via sessionService. - if e.sessionService != nil { - sess, err := e.sessionService.GetSession(ctx, e.appName, userID, sessionID) - if err != nil { - e.logger.V(1).Info("Session lookup failed, will create", "error", err, "sessionID", sessionID) - sess = nil - } - if sess == nil { - sessionName := extractSessionName(reqCtx.Message) - state := make(map[string]any) - if sessionName != "" { - state[StateKeySessionName] = sessionName + // 4. Create / lookup session via sessionService. + if e.sessionService != nil { + sess, err := e.sessionService.GetSession(ctx, e.appName, userID, sessionID) + if err != nil { + e.logger.V(1).Info("Session lookup failed, will create", "error", err, "sessionID", sessionID) + sess = nil } - // Propagate x-kagent-source so the session is tagged in the DB. - if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { - if meta := callCtx.RequestMeta(); meta != nil { - if vals, ok := meta.Get("x-kagent-source"); ok && len(vals) > 0 && vals[0] != "" { - state[StateKeySource] = vals[0] + if sess == nil { + sessionName := extractSessionName(reqCtx.Message) + state := make(map[string]any) + if sessionName != "" { + state[StateKeySessionName] = sessionName + } + // Propagate x-kagent-source so the session is tagged in the DB. + if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { + if meta := callCtx.ServiceParams(); meta != nil { + if vals, ok := meta.Get("x-kagent-source"); ok && len(vals) > 0 && vals[0] != "" { + state[StateKeySource] = vals[0] + } } } - } - if err = e.sessionService.CreateSession(ctx, e.appName, userID, state, sessionID); err != nil { - return fmt.Errorf("failed to create session: %w", err) + if err = e.sessionService.CreateSession(ctx, e.appName, userID, state, sessionID); err != nil { + yield(nil, fmt.Errorf("failed to create session: %w", err)) + return + } } } - } - - // 5. Detect HITL decision and build the resume message if needed. - inboundMessage := reqCtx.Message - if resumeMessage := BuildResumeHITLMessage(reqCtx.StoredTask, inboundMessage); resumeMessage != nil { - inboundMessage = resumeMessage - } - - // 6. Convert inbound message to *genai.Content using kagent a2aPartConverter. - content, err := messageToGenAIContent(ctx, inboundMessage) - if err != nil { - return fmt.Errorf("inbound message conversion failed: %w", err) - } - // 7. Use pre-built subagent session ID map (built by runner bundle). - subagentSessionIDs := e.subagentSessionIDs - - // 8. Create runner. - r, err := runner.New(e.runnerConfig) - if err != nil { - return fmt.Errorf("failed to create runner: %w", err) - } - - // 9. Emit initial events. - if reqCtx.StoredTask == nil { - // New task — emit submitted with the user's message - submitted := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateSubmitted, reqCtx.Message) - if err := queue.Write(ctx, submitted); err != nil { - return fmt.Errorf("failed to write submitted event: %w", err) + // 5. Detect HITL decision and build the resume message if needed. + inboundMessage := reqCtx.Message + if resumeMessage := BuildResumeHITLMessage(reqCtx.StoredTask, inboundMessage); resumeMessage != nil { + inboundMessage = resumeMessage } - } else if ExtractDecisionFromMessage(reqCtx.Message) != "" { - // a2a-go appends incoming message to task history before executor runs. - // See https://github.com/a2aproject/a2a-go/blob/v0.3.13/a2asrv/agentexec.go#L188 - // Remove the pre-appended copy and emit one decision status event. - dropPreAppendedDecisionFromHistory(reqCtx.StoredTask, reqCtx.Message) - decision := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, reqCtx.Message) - if err := queue.Write(ctx, decision); err != nil { - return fmt.Errorf("failed to write HITL decision status event: %w", err) + + // 6. Convert inbound message to *genai.Content using kagent a2aPartConverter. + content, err := messageToGenAIContent(ctx, inboundMessage) + if err != nil { + yield(nil, fmt.Errorf("inbound message conversion failed: %w", err)) + return } - } - // Base metadata carried on every event (app_name, user_id, session_id). - baseMeta := map[string]any{ - adka2a.ToA2AMetaKey("app_name"): e.appName, - adka2a.ToA2AMetaKey("user_id"): userID, - adka2a.ToA2AMetaKey("session_id"): sessionID, - } + // 7. Use pre-built subagent session ID map (built by runner bundle). + subagentSessionIDs := e.subagentSessionIDs - working := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, nil) - working.Metadata = maps.Clone(baseMeta) - if err := queue.Write(ctx, working); err != nil { - return fmt.Errorf("failed to write working event: %w", err) - } + // 8. Create runner. + r, err := runner.New(e.runnerConfig) + if err != nil { + yield(nil, fmt.Errorf("failed to create runner: %w", err)) + return + } - // 10. Run the agent event loop. - var runConfig adkagent.RunConfig - if e.stream { - runConfig.StreamingMode = adkagent.StreamingModeSSE - } + // 9. Emit initial events. + if reqCtx.StoredTask == nil { + // New task — emit submitted with the user's message + submitted := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateSubmitted, reqCtx.Message) + if !yield(submitted, nil) { + return + } + } else if ExtractDecisionFromMessage(reqCtx.Message) != "" { + // a2a-go appends incoming message to task history before executor runs. + // See https://github.com/a2aproject/a2a-go/blob/v0.3.13/a2asrv/agentexec.go#L188 + // Remove the pre-appended copy and emit one decision status event. + dropPreAppendedDecisionFromHistory(reqCtx.StoredTask, reqCtx.Message) + decision := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, reqCtx.Message) + if !yield(decision, nil) { + return + } + } - // State tracked across the event loop. - var ( - invocationID string - lastNonPartialParts a2atype.ContentParts - hitlParts a2atype.ContentParts - runErr error - ) - - for adkEvent, adkErr := range r.Run(ctx, userID, sessionID, content, runConfig) { - if adkErr != nil { - runErr = adkErr - break + // Base metadata carried on every event (app_name, user_id, session_id). + baseMeta := map[string]any{ + adka2a.ToA2AMetaKey("app_name"): e.appName, + adka2a.ToA2AMetaKey("user_id"): userID, + adka2a.ToA2AMetaKey("session_id"): sessionID, } - if adkEvent == nil { - continue + + working := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, nil) + working.Metadata = maps.Clone(baseMeta) + if !yield(working, nil) { + return } - // Track invocation ID from the first event that has one. - if adkEvent.InvocationID != "" && invocationID == "" { - invocationID = adkEvent.InvocationID - invocationSpan.SetAttributes(attribute.String("gcp.vertex.agent.invocation_id", invocationID)) + // 10. Run the agent event loop. + var runConfig adkagent.RunConfig + if e.stream { + runConfig.StreamingMode = adkagent.StreamingModeSSE } - // Build per-event metadata (inherits baseMeta + adds invocation_id, usage etc.). - eventMeta := buildEventMeta(baseMeta, adkEvent) + // State tracked across the event loop. + var ( + invocationID string + lastNonPartialParts a2atype.ContentParts + hitlParts a2atype.ContentParts + runErr error + ) + + for adkEvent, adkErr := range r.Run(ctx, userID, sessionID, content, runConfig) { + if adkErr != nil { + runErr = adkErr + break + } + if adkEvent == nil { + continue + } + + // Track invocation ID from the first event that has one. + if adkEvent.InvocationID != "" && invocationID == "" { + invocationID = adkEvent.InvocationID + invocationSpan.SetAttributes(attribute.String("gcp.vertex.agent.invocation_id", invocationID)) + } + + // Build per-event metadata (inherits baseMeta + adds invocation_id, usage etc.). + eventMeta := buildEventMeta(baseMeta, adkEvent) + + // Convert GenAI parts → A2A parts (with kagent stamping). + if adkEvent.Content == nil || len(adkEvent.Content.Parts) == 0 { + // Events with no content carry metadata only; still track invocationID/usage. + // Check for LLM error. + if adkEvent.ErrorCode != "" { + errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, + a2atype.NewTextPart(fmt.Sprintf("LLM error: %s %s", adkEvent.ErrorCode, adkEvent.ErrorMessage))) + failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) + failed.Metadata = eventMeta + yield(failed, nil) + return + } + continue + } - // Convert GenAI parts → A2A parts (with kagent stamping). - if adkEvent.Content == nil || len(adkEvent.Content.Parts) == 0 { - // Events with no content carry metadata only; still track invocationID/usage. - // Check for LLM error. + // Check for LLM error (even with content present). if adkEvent.ErrorCode != "" { errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, - a2atype.TextPart{Text: fmt.Sprintf("LLM error: %s %s", adkEvent.ErrorCode, adkEvent.ErrorMessage)}) + a2atype.NewTextPart(fmt.Sprintf("LLM error: %s %s", adkEvent.ErrorCode, adkEvent.ErrorMessage))) failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) - failed.Final = true failed.Metadata = eventMeta - return queue.Write(ctx, failed) + yield(failed, nil) + return } - continue - } - // Check for LLM error (even with content present). - if adkEvent.ErrorCode != "" { - errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, - a2atype.TextPart{Text: fmt.Sprintf("LLM error: %s %s", adkEvent.ErrorCode, adkEvent.ErrorMessage)}) - failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) - failed.Final = true - failed.Metadata = eventMeta - return queue.Write(ctx, failed) - } + // Convert parts. + var a2aParts a2atype.ContentParts + for _, genaiPart := range adkEvent.Content.Parts { + if genaiPart == nil { + continue + } + a2aPart, err := adka2a.ToA2APart(genaiPart, adkEvent.LongRunningToolIDs) + if err != nil { + continue + } + if isEmptyDataPart(a2aPart) { + continue + } + // Stamp kagent_subagent_session_id onto function_call DataParts. + if len(subagentSessionIDs) > 0 { + a2aPart = stampSubagentSessionID(a2aPart, subagentSessionIDs) + } + a2aParts = append(a2aParts, a2aPart) + } - // Convert parts. - var a2aParts a2atype.ContentParts - for _, genaiPart := range adkEvent.Content.Parts { - if genaiPart == nil { - continue + // Collect HITL (input_required) parts from LongRunningToolIDs. + isHITLEvent := len(adkEvent.LongRunningToolIDs) > 0 + if isHITLEvent { + hitlParts = append(hitlParts, a2aParts...) } - a2aPart, err := adka2a.ToA2APart(genaiPart, adkEvent.LongRunningToolIDs) - if err != nil { + + if len(a2aParts) == 0 { continue } - if isEmptyDataPart(a2aPart) { - continue + + if adkEvent.Partial { + // Partial event: emit as working status (text-only) for UI streaming. + // Note: Go ADK executor uses TaskArtifactUpdateEvent for partial events, + // so we don't need to emit a separate partial artifact update. + // However, this is done here in order to match the Python executor's behavior. + // Go ADK executor also uses different A2A response formats than Python ADK. + textOnly := filterTextParts(a2aParts) + if len(textOnly) > 0 { + mirrorMeta := maps.Clone(eventMeta) + mirrorMeta[adka2a.ToA2AMetaKey("partial")] = true + msg := a2atype.NewMessage(a2atype.MessageRoleAgent, textOnly...) + msg.Metadata = mirrorMeta + statusEv := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) + statusEv.Metadata = mirrorMeta + if !yield(statusEv, nil) { + return + } + } + } else { + mirrorParts := a2aParts + if len(hitlParts) == 0 { + // Only mirror when not accumulating HITL parts (those go into input_required). + msg := a2atype.NewMessage(a2atype.MessageRoleAgent, mirrorParts...) + msg.Metadata = maps.Clone(eventMeta) + statusEv := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) + statusEv.Metadata = maps.Clone(eventMeta) + if !yield(statusEv, nil) { + return + } + lastNonPartialParts = mirrorParts + } } - // Stamp kagent_subagent_session_id onto function_call DataParts. - if len(subagentSessionIDs) > 0 { - a2aPart = stampSubagentSessionID(a2aPart, subagentSessionIDs) + + // Break on confirmation events that have long-running tool IDs. + if isHITLEvent { + break } - a2aParts = append(a2aParts, a2aPart) } - // Collect HITL (input_required) parts from LongRunningToolIDs. - isHITLEvent := len(adkEvent.LongRunningToolIDs) > 0 - if isHITLEvent { - hitlParts = append(hitlParts, a2aParts...) + // 11. Emit final event. + finalMeta := maps.Clone(baseMeta) + if invocationID != "" { + finalMeta[adka2a.ToA2AMetaKey("invocation_id")] = invocationID } - if len(a2aParts) == 0 { - continue + if runErr != nil { + errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart(runErr.Error())) + failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) + failed.Metadata = finalMeta + yield(failed, nil) + return } - if adkEvent.Partial { - // Partial event: emit as working status (text-only) for UI streaming. - // Note: Go ADK executor uses TaskArtifactUpdateEvent for partial events, - // so we don't need to emit a separate partial artifact update. - // However, this is done here in order to match the Python executor's behavior. - // Go ADK executor also uses different A2A response formats than Python ADK. - textOnly := filterTextParts(a2aParts) - if len(textOnly) > 0 { - mirrorMeta := maps.Clone(eventMeta) - mirrorMeta[adka2a.ToA2AMetaKey("partial")] = true - msg := a2atype.NewMessage(a2atype.MessageRoleAgent, textOnly...) - msg.Metadata = mirrorMeta - statusEv := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) - statusEv.Metadata = mirrorMeta - if err := queue.Write(ctx, statusEv); err != nil { - return fmt.Errorf("failed to write partial status event: %w", err) - } - } - } else { - mirrorParts := a2aParts - if len(hitlParts) == 0 { - // Only mirror when not accumulating HITL parts (those go into input_required). - msg := a2atype.NewMessage(a2atype.MessageRoleAgent, mirrorParts...) - msg.Metadata = maps.Clone(eventMeta) - statusEv := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) - statusEv.Metadata = maps.Clone(eventMeta) - if err := queue.Write(ctx, statusEv); err != nil { - return fmt.Errorf("failed to write mirror status event: %w", err) - } - lastNonPartialParts = mirrorParts - } + if len(hitlParts) > 0 { + // input_required: the agent is waiting for HITL decisions. + hitlMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, hitlParts...) + inputRequired := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateInputRequired, hitlMsg) + inputRequired.Metadata = finalMeta + yield(inputRequired, nil) + return } - // Break on confirmation events that have long-running tool IDs. - if isHITLEvent { - break + // Final artifact update with lastChunk=true (if we have parts) and final completed status update (no message payload). + if len(lastNonPartialParts) > 0 { + finalArtifact := a2atype.NewArtifactEvent(reqCtx, lastNonPartialParts...) + finalArtifact.LastChunk = true + if !yield(finalArtifact, nil) { + return + } } - } - // 11. Emit final event. - finalMeta := maps.Clone(baseMeta) - if invocationID != "" { - finalMeta[adka2a.ToA2AMetaKey("invocation_id")] = invocationID + completed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCompleted, nil) + completed.Metadata = finalMeta + yield(completed, nil) } - - if runErr != nil { - errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: runErr.Error()}) - failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) - failed.Final = true - failed.Metadata = finalMeta - return queue.Write(ctx, failed) - } - - if len(hitlParts) > 0 { - // input_required: the agent is waiting for HITL decisions. - hitlMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, hitlParts...) - inputRequired := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateInputRequired, hitlMsg) - inputRequired.Final = true - inputRequired.Metadata = finalMeta - return queue.Write(ctx, inputRequired) - } - - // Final artifact update with lastChunk=true (if we have parts) and final completed status update (no message payload). - if len(lastNonPartialParts) > 0 { - finalArtifact := a2atype.NewArtifactEvent(reqCtx, lastNonPartialParts...) - finalArtifact.LastChunk = true - if err := queue.Write(ctx, finalArtifact); err != nil { - return fmt.Errorf("failed to write final artifact event: %w", err) - } - } - - completed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCompleted, nil) - completed.Final = true - completed.Metadata = finalMeta - return queue.Write(ctx, completed) } // Cancel implements a2asrv.AgentExecutor. -func (e *KAgentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - event := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCanceled, nil) - event.Final = true - return queue.Write(ctx, event) +func (e *KAgentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + event := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCanceled, nil) + yield(event, nil) + } } // extractSessionName extracts session name from the first text part of a message. @@ -406,11 +412,14 @@ func extractSessionName(message *a2atype.Message) string { return "" } for _, part := range message.Parts { - if tp, ok := part.(a2atype.TextPart); ok && tp.Text != "" { - if len(tp.Text) > sessionNameMaxLength { - return tp.Text[:sessionNameMaxLength] + "..." + if part == nil { + continue + } + if text := part.Text(); text != "" { + if len(text) > sessionNameMaxLength { + return text[:sessionNameMaxLength] + "..." } - return tp.Text + return text } } return "" @@ -423,7 +432,7 @@ func withBearerToken(ctx context.Context) context.Context { if !ok { return ctx } - meta := callCtx.RequestMeta() + meta := callCtx.ServiceParams() if meta == nil { return ctx } diff --git a/go/adk/pkg/a2a/hitl.go b/go/adk/pkg/a2a/hitl.go index e8e716b72e..58421b5ed5 100644 --- a/go/adk/pkg/a2a/hitl.go +++ b/go/adk/pkg/a2a/hitl.go @@ -4,7 +4,7 @@ import ( "encoding/json" "maps" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "google.golang.org/adk/tool/toolconfirmation" ) @@ -161,17 +161,16 @@ func GetKAgentMetadataKey(key string) string { return KAgentMetadataKeyPrefix + key } -// asDataPart extracts a *DataPart from an A2A Part, handling both value and -// pointer types. The a2a-go library may deserialize parts as either -// a2atype.DataPart (value) or *a2atype.DataPart (pointer). -func asDataPart(part a2atype.Part) *a2atype.DataPart { - switch p := part.(type) { - case *a2atype.DataPart: - return p - case a2atype.DataPart: - return &p +// asDataPart extracts map-backed data content from an A2A part. +func asDataPart(part *a2atype.Part) map[string]any { + if part == nil { + return nil } - return nil + data, ok := part.Data().(map[string]any) + if !ok { + return nil + } + return data } // ExtractDecisionFromMessage extracts a decision from an A2A message. @@ -182,7 +181,7 @@ func ExtractDecisionFromMessage(message *a2atype.Message) DecisionType { } for _, part := range message.Parts { if dataPart := asDataPart(part); dataPart != nil { - if decision, ok := dataPart.Data[KAgentHitlDecisionTypeKey].(string); ok { + if decision, ok := dataPart[KAgentHitlDecisionTypeKey].(string); ok { switch decision { case KAgentHitlDecisionTypeApprove: return DecisionApprove @@ -205,10 +204,10 @@ func ExtractBatchDecisionsFromMessage(message *a2atype.Message) map[string]Decis } for _, part := range message.Parts { dp := asDataPart(part) - if dp == nil || dp.Data[KAgentHitlDecisionTypeKey] != string(DecisionBatch) { + if dp == nil || dp[KAgentHitlDecisionTypeKey] != string(DecisionBatch) { continue } - return parseDecisionMap(dp.Data[KAgentHitlDecisionsKey]) + return parseDecisionMap(dp[KAgentHitlDecisionsKey]) } return nil } @@ -224,11 +223,11 @@ func ExtractRejectionReasonsFromMessage(message *a2atype.Message) map[string]str if dp == nil { continue } - decision, _ := dp.Data[KAgentHitlDecisionTypeKey].(string) + decision, _ := dp[KAgentHitlDecisionTypeKey].(string) if decision == string(DecisionBatch) { - return parseStringMap(dp.Data[KAgentHitlRejectionReasonsKey]) + return parseStringMap(dp[KAgentHitlRejectionReasonsKey]) } else if decision == KAgentHitlDecisionTypeReject { - if reason, _ := dp.Data["rejection_reason"].(string); reason != "" { + if reason, _ := dp["rejection_reason"].(string); reason != "" { return map[string]string{"*": reason} } } @@ -246,7 +245,7 @@ func ExtractAskUserAnswersFromMessage(message *a2atype.Message) []map[string]any if dp == nil { continue } - answers := parseAskUserAnswersValue(dp.Data[KAgentAskUserAnswersKey]) + answers := parseAskUserAnswersValue(dp[KAgentAskUserAnswersKey]) if len(answers) == 0 { continue } @@ -293,14 +292,14 @@ func HitlPartInfoFromDataPartData(data map[string]any) HitlPartInfo { func ExtractHitlInfoFromParts(parts a2atype.ContentParts) []HitlPartInfo { var result []HitlPartInfo for _, part := range parts { - dp := asDataPart(part) - if dp == nil || dp.Metadata == nil { + dpData := asDataPart(part) + if dpData == nil || part.Metadata == nil { continue } - partType, _ := ReadMetadataValue(dp.Metadata, A2ADataPartMetadataTypeKey) - isLR, _ := ReadMetadataValue(dp.Metadata, A2ADataPartMetadataIsLongRunningKey) + partType, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataTypeKey) + isLR, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataIsLongRunningKey) if partType == A2ADataPartMetadataTypeFunctionCall && isLR == true { - result = append(result, HitlPartInfoFromDataPartData(dp.Data)) + result = append(result, HitlPartInfoFromDataPartData(dpData)) } } return result @@ -322,30 +321,30 @@ func BuildConfirmationPayload(originalPayload, extra map[string]any) map[string] func ExtractPendingConfirmationsFromParts(parts a2atype.ContentParts) map[string]PendingConfirmation { pending := make(map[string]PendingConfirmation) for _, part := range parts { - dp := asDataPart(part) - if dp == nil || dp.Metadata == nil || dp.Data == nil { + dpData := asDataPart(part) + if dpData == nil || part.Metadata == nil { continue } - partType, _ := ReadMetadataValue(dp.Metadata, A2ADataPartMetadataTypeKey) - isLongRunning, _ := ReadMetadataValue(dp.Metadata, A2ADataPartMetadataIsLongRunningKey) + partType, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataTypeKey) + isLongRunning, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataIsLongRunningKey) if partType != A2ADataPartMetadataTypeFunctionCall || isLongRunning != true { continue } - name, _ := dp.Data[PartKeyName].(string) + name, _ := dpData[PartKeyName].(string) if name != toolconfirmation.FunctionCallName { continue } - confirmationID, _ := dp.Data[PartKeyID].(string) + confirmationID, _ := dpData[PartKeyID].(string) if confirmationID == "" { continue } - info := HitlPartInfoFromDataPartData(dp.Data) + info := HitlPartInfoFromDataPartData(dpData) var originalPayload map[string]any - if args, ok := dp.Data[PartKeyArgs].(map[string]any); ok { + if args, ok := dpData[PartKeyArgs].(map[string]any); ok { if tc, ok := args["toolConfirmation"].(map[string]any); ok { if payload, ok := tc["payload"].(map[string]any); ok { originalPayload = payload @@ -395,14 +394,14 @@ func ProcessHitlDecision( pending map[string]PendingConfirmation, decision DecisionType, message *a2atype.Message, -) []a2atype.Part { +) []*a2atype.Part { if len(pending) == 0 { return nil } // Ask-user answers take priority. if askUserAnswers := parseAskUserAnswersValue(extractMessageField(message, KAgentAskUserAnswersKey)); len(askUserAnswers) > 0 { - var parts []a2atype.Part + var parts []*a2atype.Part for fcID, pc := range pending { payload := ParseHitlConfirmationPayload(pc.OriginalPayload) payload.Answers = askUserAnswers @@ -418,7 +417,7 @@ func ProcessHitlDecision( if batchDecisions == nil { batchDecisions = map[string]DecisionType{} } - var parts []a2atype.Part + var parts []*a2atype.Part for fcID, pc := range pending { payload := ParseHitlConfirmationPayload(pc.OriginalPayload) var confirmed bool @@ -451,7 +450,7 @@ func ProcessHitlDecision( // Uniform approve/reject. confirmed := decision == DecisionApprove - var parts []a2atype.Part + var parts []*a2atype.Part for fcID, pc := range pending { payload := ParseHitlConfirmationPayload(pc.OriginalPayload) if !confirmed { @@ -463,22 +462,21 @@ func ProcessHitlDecision( } // buildConfirmationResponsePart builds the A2A DataPart for a ToolConfirmation FunctionResponse. -func buildConfirmationResponsePart(fcID string, confirmed bool, payload map[string]any) a2atype.Part { +func buildConfirmationResponsePart(fcID string, confirmed bool, payload map[string]any) *a2atype.Part { tc := toolconfirmation.ToolConfirmation{ Confirmed: confirmed, Payload: payload, } serialized, _ := json.Marshal(tc) - return a2atype.DataPart{ - Data: map[string]any{ - PartKeyName: toolconfirmation.FunctionCallName, - PartKeyID: fcID, - PartKeyResponse: map[string]any{"response": string(serialized)}, - }, - Metadata: map[string]any{ - GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, - }, - } + p := a2atype.NewDataPart(map[string]any{ + PartKeyName: toolconfirmation.FunctionCallName, + PartKeyID: fcID, + PartKeyResponse: map[string]any{"response": string(serialized)}, + }) + p.Metadata = map[string]any{ + GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, + } + return p } func extractMessageField(message *a2atype.Message, key string) any { @@ -490,7 +488,7 @@ func extractMessageField(message *a2atype.Message, key string) any { if dp == nil { continue } - if value, ok := dp.Data[key]; ok { + if value, ok := dp[key]; ok { return value } } diff --git a/go/adk/pkg/a2a/hitl_test.go b/go/adk/pkg/a2a/hitl_test.go index 1a7416bf14..56094a7f74 100644 --- a/go/adk/pkg/a2a/hitl_test.go +++ b/go/adk/pkg/a2a/hitl_test.go @@ -3,28 +3,36 @@ package a2a import ( "testing" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" ) +func dataPart(data map[string]any, metadata map[string]any) *a2atype.Part { + p := a2atype.NewDataPart(data) + if metadata != nil { + p.Metadata = metadata + } + return p +} + // --------------------------------------------------------------------------- // ExtractDecisionFromMessage // --------------------------------------------------------------------------- func TestExtractDecisionFromMessage_DataPart(t *testing.T) { approveData := map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove} - msg := a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: approveData}) + msg := a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(approveData, nil)) if got := ExtractDecisionFromMessage(msg); got != DecisionApprove { t.Errorf("approve DataPart = %q, want %q", got, DecisionApprove) } rejectData := map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject} - msg = a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: rejectData}) + msg = a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(rejectData, nil)) if got := ExtractDecisionFromMessage(msg); got != DecisionReject { t.Errorf("reject DataPart = %q, want %q", got, DecisionReject) } batchData := map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch} - msg = a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: batchData}) + msg = a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(batchData, nil)) if got := ExtractDecisionFromMessage(msg); got != DecisionBatch { t.Errorf("batch DataPart = %q, want %q", got, DecisionBatch) } @@ -39,13 +47,13 @@ func TestExtractDecisionFromMessage_EdgeCases(t *testing.T) { t.Errorf("empty parts = %q, want empty", got) } // Text-only message — no decision (text extraction removed) - msg = a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.TextPart{Text: "approve"}) + msg = a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("approve")) if got := ExtractDecisionFromMessage(msg); got != "" { t.Errorf("text-only message = %q, want empty (text extraction removed)", got) } // Unknown decision type msg = a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{KAgentHitlDecisionTypeKey: "unknown"}}) + dataPart(map[string]any{KAgentHitlDecisionTypeKey: "unknown"}, nil)) if got := ExtractDecisionFromMessage(msg); got != "" { t.Errorf("unknown decision = %q, want empty", got) } @@ -123,25 +131,25 @@ func TestExtractBatchDecisionsFromMessage(t *testing.T) { {name: "nil message", message: nil, want: nil}, { name: "valid batch", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch, KAgentHitlDecisionsKey: map[string]any{"call_1": "approve", "call_2": "reject"}, - }}), + }, nil)), want: map[string]DecisionType{"call_1": DecisionApprove, "call_2": DecisionReject}, }, { name: "invalid values filtered", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch, KAgentHitlDecisionsKey: map[string]any{"call_1": "approve", "call_2": "bad"}, - }}), + }, nil)), want: map[string]DecisionType{"call_1": DecisionApprove}, }, { name: "non-batch type returns nil", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove, - }}), + }, nil)), want: nil, }, } @@ -174,25 +182,25 @@ func TestExtractRejectionReasonsFromMessage(t *testing.T) { {name: "nil message", message: nil, want: nil}, { name: "uniform reject with reason", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject, "rejection_reason": "too dangerous", - }}), + }, nil)), want: map[string]string{"*": "too dangerous"}, }, { name: "uniform reject without reason returns nil", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject, - }}), + }, nil)), want: nil, }, { name: "batch with reasons", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch, KAgentHitlRejectionReasonsKey: map[string]any{"call_1": "policy violation"}, - }}), + }, nil)), want: map[string]string{"call_1": "policy violation"}, }, } @@ -217,18 +225,18 @@ func TestExtractRejectionReasonsFromMessage(t *testing.T) { // --------------------------------------------------------------------------- func TestExtractAskUserAnswersFromMessage(t *testing.T) { - msg := a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + msg := a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentAskUserAnswersKey: []any{map[string]any{"answer": []any{"yes"}}}, - }}) + }, nil)) got := ExtractAskUserAnswersFromMessage(msg) if len(got) != 1 { t.Errorf("len = %d, want 1", len(got)) } // Non-list value returns nil - msg = a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + msg = a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentAskUserAnswersKey: "not a list", - }}) + }, nil)) if got := ExtractAskUserAnswersFromMessage(msg); got != nil { t.Errorf("non-list = %v, want nil", got) } @@ -357,8 +365,8 @@ func TestBuildConfirmationPayload(t *testing.T) { func TestExtractPendingConfirmationsFromParts(t *testing.T) { parts := a2atype.ContentParts{ - &a2atype.DataPart{ - Data: map[string]any{ + dataPart( + map[string]any{ "name": "adk_request_confirmation", "id": "confirm_1", "args": map[string]any{ @@ -378,11 +386,11 @@ func TestExtractPendingConfirmationsFromParts(t *testing.T) { }, }, }, - Metadata: map[string]any{ + map[string]any{ "kagent_type": "function_call", "kagent_is_long_running": true, }, - }, + ), } pending := ExtractPendingConfirmationsFromParts(parts) @@ -408,8 +416,8 @@ func TestExtractPendingConfirmationsFromParts(t *testing.T) { func TestExtractHitlInfoFromParts_PointerDataPart(t *testing.T) { parts := a2atype.ContentParts{ - &a2atype.DataPart{ - Data: map[string]any{ + dataPart( + map[string]any{ "name": "adk_request_confirmation", "id": "confirm_1", "args": map[string]any{ @@ -420,11 +428,11 @@ func TestExtractHitlInfoFromParts_PointerDataPart(t *testing.T) { }, }, }, - Metadata: map[string]any{ + map[string]any{ "kagent_type": "function_call", "kagent_is_long_running": true, }, - }, + ), } got := ExtractHitlInfoFromParts(parts) @@ -448,8 +456,8 @@ func TestBuildResumeHITLMessage(t *testing.T) { State: a2atype.TaskStateInputRequired, Message: a2atype.NewMessage( a2atype.MessageRoleAgent, - &a2atype.DataPart{ - Data: map[string]any{ + dataPart( + map[string]any{ "name": "adk_request_confirmation", "id": "confirm_1", "args": map[string]any{ @@ -464,18 +472,18 @@ func TestBuildResumeHITLMessage(t *testing.T) { }, }, }, - Metadata: map[string]any{ + map[string]any{ "kagent_type": "function_call", "kagent_is_long_running": true, }, - }, + ), ), }, } incoming := a2atype.NewMessage( a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}}, + dataPart(map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}, nil), ) resume := BuildResumeHITLMessage(storedTask, incoming) @@ -491,11 +499,11 @@ func TestBuildResumeHITLMessage(t *testing.T) { t.Fatal("resume part is not a DataPart") return } - if dp.Data[PartKeyName] != "adk_request_confirmation" { - t.Fatalf("resume FunctionResponse name = %#v", dp.Data[PartKeyName]) + if dp[PartKeyName] != "adk_request_confirmation" { + t.Fatalf("resume FunctionResponse name = %#v", dp[PartKeyName]) } - if dp.Data[PartKeyID] != "confirm_1" { - t.Fatalf("resume FunctionResponse id = %#v", dp.Data[PartKeyID]) + if dp[PartKeyID] != "confirm_1" { + t.Fatalf("resume FunctionResponse id = %#v", dp[PartKeyID]) } } @@ -510,7 +518,7 @@ func TestProcessHitlDecision(t *testing.T) { t.Run("uniform approve", func(t *testing.T) { msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}}) + dataPart(map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}, nil)) parts := ProcessHitlDecision(pending, DecisionApprove, msg) if len(parts) != 1 { t.Fatalf("len = %d, want 1", len(parts)) @@ -520,17 +528,17 @@ func TestProcessHitlDecision(t *testing.T) { t.Fatal("part is not DataPart") return } - if dp.Data[PartKeyName] != "adk_request_confirmation" { - t.Errorf("name = %v", dp.Data[PartKeyName]) + if dp[PartKeyName] != "adk_request_confirmation" { + t.Errorf("name = %v", dp[PartKeyName]) } }) t.Run("uniform reject with reason", func(t *testing.T) { msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{ + dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject, "rejection_reason": "not safe", - }}) + }, nil)) parts := ProcessHitlDecision(pending, DecisionReject, msg) if len(parts) != 1 { t.Fatalf("len = %d, want 1", len(parts)) @@ -539,7 +547,7 @@ func TestProcessHitlDecision(t *testing.T) { t.Run("empty pending returns nil", func(t *testing.T) { msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}}) + dataPart(map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}, nil)) if parts := ProcessHitlDecision(map[string]PendingConfirmation{}, DecisionApprove, msg); parts != nil { t.Errorf("empty pending = %v, want nil", parts) } @@ -547,10 +555,10 @@ func TestProcessHitlDecision(t *testing.T) { t.Run("ask-user answers take priority", func(t *testing.T) { msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{ + dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove, KAgentAskUserAnswersKey: []any{map[string]any{"answer": []any{"yes"}}}, - }}) + }, nil)) parts := ProcessHitlDecision(pending, DecisionApprove, msg) if len(parts) != 1 { t.Fatalf("ask-user len = %d, want 1", len(parts)) @@ -563,10 +571,10 @@ func TestProcessHitlDecision(t *testing.T) { "fc_2": {OriginalID: "orig_2"}, } msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{ + dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch, KAgentHitlDecisionsKey: map[string]any{"orig_1": "approve", "orig_2": "reject"}, - }}) + }, nil)) parts := ProcessHitlDecision(pendingBatch, DecisionBatch, msg) if len(parts) != 2 { t.Fatalf("batch len = %d, want 2", len(parts)) diff --git a/go/adk/pkg/a2a/server/server.go b/go/adk/pkg/a2a/server/server.go index 9f2bb9bcfa..d1e7df94b6 100644 --- a/go/adk/pkg/a2a/server/server.go +++ b/go/adk/pkg/a2a/server/server.go @@ -10,8 +10,8 @@ import ( "syscall" "time" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/go-logr/logr" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) diff --git a/go/adk/pkg/app/app.go b/go/adk/pkg/app/app.go index 36d5fec69f..0618c3728c 100644 --- a/go/adk/pkg/app/app.go +++ b/go/adk/pkg/app/app.go @@ -8,8 +8,8 @@ import ( "strings" "time" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/go-logr/logr" "github.com/go-logr/zapr" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" @@ -121,7 +121,7 @@ func New(cfg AppConfig, executor a2asrv.AgentExecutor) (*KAgentApp, error) { } // Append the user-ID interceptor - handlerOpts = append(handlerOpts, a2asrv.WithCallInterceptor(a2a.UserIDCallInterceptor())) + handlerOpts = append(handlerOpts, a2asrv.WithCallInterceptors(a2a.UserIDCallInterceptor())) // Append any caller-supplied handler options. handlerOpts = append(handlerOpts, cfg.HandlerOpts...) @@ -195,11 +195,12 @@ func applyDefaults(cfg AppConfig) AppConfig { cfg.Logger = newDefaultLogger() } - // Ensure the agent card always advertises a transport so that A2A clients - // can select a compatible one. Without this, NewFromCard fails with - // "no compatible transports found: available transports - []". - if cfg.AgentCard.PreferredTransport == "" { - cfg.AgentCard.PreferredTransport = a2atype.TransportProtocolJSONRPC + // Ensure the agent card always advertises at least one interface so A2A + // clients can select a compatible endpoint/transport. + if len(cfg.AgentCard.SupportedInterfaces) == 0 { + cfg.AgentCard.SupportedInterfaces = []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("/", a2atype.TransportProtocolJSONRPC), + } } return cfg diff --git a/go/adk/pkg/app/app_test.go b/go/adk/pkg/app/app_test.go index 899fc0946f..0a08d0d89f 100644 --- a/go/adk/pkg/app/app_test.go +++ b/go/adk/pkg/app/app_test.go @@ -2,23 +2,23 @@ package app import ( "context" + "iter" "testing" "time" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" - "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" ) // fakeExecutor implements a2asrv.AgentExecutor for testing. type fakeExecutor struct{} -func (f *fakeExecutor) Execute(_ context.Context, _ *a2asrv.RequestContext, _ eventqueue.Queue) error { - return nil +func (f *fakeExecutor) Execute(_ context.Context, _ *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) {} } -func (f *fakeExecutor) Cancel(_ context.Context, _ *a2asrv.RequestContext, _ eventqueue.Queue) error { - return nil +func (f *fakeExecutor) Cancel(_ context.Context, _ *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) {} } var _ a2asrv.AgentExecutor = (*fakeExecutor)(nil) diff --git a/go/adk/pkg/config/config_loader.go b/go/adk/pkg/config/config_loader.go index 908f31247d..7aa20d6581 100644 --- a/go/adk/pkg/config/config_loader.go +++ b/go/adk/pkg/config/config_loader.go @@ -6,7 +6,7 @@ import ( "os" "path/filepath" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/adk" ) diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index 97cf20ea41..c4c07f94db 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -9,7 +9,7 @@ import ( "os" "time" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/go-logr/logr" "github.com/kagent-dev/kagent/go/adk/pkg/constants" "github.com/kagent-dev/kagent/go/api/adk" @@ -46,7 +46,7 @@ func allowedRequestHeaders(ctx context.Context, allowed []string) map[string]str if !ok { return nil } - meta := callCtx.RequestMeta() + meta := callCtx.ServiceParams() if meta == nil { return nil } @@ -280,7 +280,7 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro // A2A request independently of allowedHeaders. if rt.propagateToken { if callCtx, ok := a2asrv.CallContextFrom(req.Context()); ok { - if meta := callCtx.RequestMeta(); meta != nil { + if meta := callCtx.ServiceParams(); meta != nil { if vals, ok := meta.Get(constants.AuthorizationHeader); ok && len(vals) > 0 && vals[0] != "" { req.Header.Set(constants.AuthorizationHeader, vals[0]) } diff --git a/go/adk/pkg/mcp/registry_test.go b/go/adk/pkg/mcp/registry_test.go index 2931a94bd3..dd6e8cb48a 100644 --- a/go/adk/pkg/mcp/registry_test.go +++ b/go/adk/pkg/mcp/registry_test.go @@ -6,15 +6,14 @@ import ( "net/http/httptest" "testing" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv" ) // a2aCtx builds a context that carries an A2A CallContext with the given headers. // Keys are stored case-insensitively by NewRequestMeta, matching the behaviour // of a real A2A server. func a2aCtx(headers map[string][]string) context.Context { - meta := a2asrv.NewRequestMeta(headers) - ctx, _ := a2asrv.WithCallContext(context.Background(), meta) + ctx, _ := a2asrv.NewCallContext(context.Background(), a2asrv.NewServiceParams(headers)) return ctx } diff --git a/go/adk/pkg/taskstore/store.go b/go/adk/pkg/taskstore/store.go index 4b3bab2453..4c52a0d21b 100644 --- a/go/adk/pkg/taskstore/store.go +++ b/go/adk/pkg/taskstore/store.go @@ -9,7 +9,8 @@ import ( "net/http" "net/url" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2ataskstore "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore" ) // Constants for partial-event metadata keys (inlined to avoid import cycle). @@ -90,10 +91,9 @@ func cleanPartialArtifacts(artifacts []*a2atype.Artifact) []*a2atype.Artifact { return cleaned } -// Save implements a2asrv.TaskStore. -func (s *KAgentTaskStore) Save(ctx context.Context, task *a2atype.Task, _ a2atype.Event, _ *a2atype.Task, _ a2atype.TaskVersion) (a2atype.TaskVersion, error) { +func (s *KAgentTaskStore) saveTask(ctx context.Context, task *a2atype.Task) (a2ataskstore.TaskVersion, error) { if task == nil { - return a2atype.TaskVersionMissing, fmt.Errorf("task cannot be nil") + return a2ataskstore.TaskVersionMissing, fmt.Errorf("task cannot be nil") } // Work on a shallow copy so the caller's task is not mutated. @@ -107,59 +107,75 @@ func (s *KAgentTaskStore) Save(ctx context.Context, task *a2atype.Task, _ a2atyp taskJSON, err := json.Marshal(&taskCopy) if err != nil { - return a2atype.TaskVersionMissing, fmt.Errorf("failed to marshal task: %w", err) + return a2ataskstore.TaskVersionMissing, fmt.Errorf("failed to marshal task: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", s.BaseURL+"/api/tasks", bytes.NewReader(taskJSON)) if err != nil { - return a2atype.TaskVersionMissing, fmt.Errorf("failed to create save request: %w", err) + return a2ataskstore.TaskVersionMissing, fmt.Errorf("failed to create save request: %w", err) } req.Header.Set(headerContentType, contentTypeJSON) resp, err := s.Client.Do(req) if err != nil { - return a2atype.TaskVersionMissing, fmt.Errorf("failed to execute save task request: %w", err) + return a2ataskstore.TaskVersionMissing, fmt.Errorf("failed to execute save task request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) - return a2atype.TaskVersionMissing, fmt.Errorf("failed to save task: status %d, body: %s", resp.StatusCode, string(body)) + return a2ataskstore.TaskVersionMissing, fmt.Errorf("failed to save task: status %d, body: %s", resp.StatusCode, string(body)) } - return a2atype.TaskVersion(1), nil + return a2ataskstore.TaskVersionMissing, nil } -// Get implements a2asrv.TaskStore. -func (s *KAgentTaskStore) Get(ctx context.Context, taskID a2atype.TaskID) (*a2atype.Task, a2atype.TaskVersion, error) { +// Create implements taskstore.Store. +func (s *KAgentTaskStore) Create(ctx context.Context, task *a2atype.Task) (a2ataskstore.TaskVersion, error) { + return s.saveTask(ctx, task) +} + +// Update implements taskstore.Store. +func (s *KAgentTaskStore) Update(ctx context.Context, update *a2ataskstore.UpdateRequest) (a2ataskstore.TaskVersion, error) { + if update == nil { + return a2ataskstore.TaskVersionMissing, fmt.Errorf("update request cannot be nil") + } + return s.saveTask(ctx, update.Task) +} + +// Get implements taskstore.Store. +func (s *KAgentTaskStore) Get(ctx context.Context, taskID a2atype.TaskID) (*a2ataskstore.StoredTask, error) { req, err := http.NewRequestWithContext(ctx, "GET", s.BaseURL+"/api/tasks/"+url.PathEscape(string(taskID)), nil) if err != nil { - return nil, a2atype.TaskVersionMissing, fmt.Errorf("failed to create get request: %w", err) + return nil, fmt.Errorf("failed to create get request: %w", err) } resp, err := s.Client.Do(req) if err != nil { - return nil, a2atype.TaskVersionMissing, fmt.Errorf("failed to execute get task request: %w", err) + return nil, fmt.Errorf("failed to execute get task request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, a2atype.TaskVersionMissing, a2atype.ErrTaskNotFound + return nil, a2atype.ErrTaskNotFound } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, a2atype.TaskVersionMissing, fmt.Errorf("failed to get task: status %d, body: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("failed to get task: status %d, body: %s", resp.StatusCode, string(body)) } var wrapped KAgentTaskResponse if err := json.NewDecoder(resp.Body).Decode(&wrapped); err != nil { - return nil, a2atype.TaskVersionMissing, fmt.Errorf("failed to decode response: %w", err) + return nil, fmt.Errorf("failed to decode response: %w", err) } if wrapped.Data == nil { - return nil, a2atype.TaskVersionMissing, a2atype.ErrTaskNotFound + return nil, a2atype.ErrTaskNotFound } - return wrapped.Data, a2atype.TaskVersion(1), nil + return &a2ataskstore.StoredTask{ + Task: wrapped.Data, + Version: a2ataskstore.TaskVersionMissing, + }, nil } // List implements a2asrv.TaskStore. Listing is not supported against the KAgent task API. diff --git a/go/adk/pkg/tools/remote_a2a_tool.go b/go/adk/pkg/tools/remote_a2a_tool.go index 87afe88a8b..dd2c7bf2e2 100644 --- a/go/adk/pkg/tools/remote_a2a_tool.go +++ b/go/adk/pkg/tools/remote_a2a_tool.go @@ -8,10 +8,10 @@ import ( "strings" "sync" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" - "github.com/a2aproject/a2a-go/a2aclient/agentcard" - "github.com/a2aproject/a2a-go/a2asrv" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" "github.com/kagent-dev/kagent/go/adk/pkg/constants" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -27,11 +27,11 @@ type userIDForwardingInterceptor struct { a2aclient.PassthroughInterceptor } -func (u *userIDForwardingInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, error) { +func (u *userIDForwardingInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { if uid, ok := ctx.Value(userIDContextKey{}).(string); ok && uid != "" { - req.Meta.Append("x-user-id", uid) + req.ServiceParams.Append("x-user-id", uid) } - return ctx, nil + return ctx, nil, nil } // authzForwardingInterceptor forwards the Authorization header from the @@ -40,22 +40,37 @@ type authzForwardingInterceptor struct { a2aclient.PassthroughInterceptor } -func (a *authzForwardingInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, error) { +func (a *authzForwardingInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { callCtx, ok := a2asrv.CallContextFrom(ctx) if !ok { - return ctx, nil + return ctx, nil, nil } - meta := callCtx.RequestMeta() + meta := callCtx.ServiceParams() if meta == nil { - return ctx, nil + return ctx, nil, nil } - if len(req.Meta.Get(constants.AuthorizationHeader)) > 0 { - return ctx, nil + if len(req.ServiceParams.Get(constants.AuthorizationHeader)) > 0 { + return ctx, nil, nil } if vals, ok := meta.Get(constants.AuthorizationHeader); ok && len(vals) > 0 && vals[0] != "" { - req.Meta.Append(constants.AuthorizationHeader, vals[0]) + req.ServiceParams.Append(constants.AuthorizationHeader, vals[0]) } - return ctx, nil + return ctx, nil, nil +} + +// staticHeadersInterceptor appends static request headers to every call. +type staticHeadersInterceptor struct { + a2aclient.PassthroughInterceptor + headers map[string]string +} + +func (s *staticHeadersInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { + for k, v := range s.headers { + if v != "" { + req.ServiceParams.Append(k, v) + } + } + return ctx, nil, nil } // remoteA2AInput is the typed argument for the remote A2A function tool. @@ -142,19 +157,17 @@ func (s *remoteA2AState) ensureClient(ctx context.Context) (*a2aclient.Client, e a2aclient.WithJSONRPCTransport(s.httpClient), } // Always inject x-kagent-source: agent to mark this as an agent-originated call. - meta := a2aclient.CallMeta{} - meta.Append("x-kagent-source", "agent") - for k, v := range s.extraHeaders { - meta.Append(k, v) - } interceptors := []a2aclient.CallInterceptor{ - a2aclient.NewStaticCallMetaInjector(meta), + &staticHeadersInterceptor{headers: map[string]string{"x-kagent-source": "agent"}}, &userIDForwardingInterceptor{}, } + if len(s.extraHeaders) > 0 { + interceptors = append(interceptors, &staticHeadersInterceptor{headers: s.extraHeaders}) + } if s.propagateToken { interceptors = append(interceptors, &authzForwardingInterceptor{}) } - opts = append(opts, a2aclient.WithInterceptors(interceptors...)) + opts = append(opts, a2aclient.WithCallInterceptors(interceptors...)) client, err := a2aclient.NewFromCard(ctx, card, opts...) if err != nil { @@ -187,12 +200,12 @@ func (s *remoteA2AState) handleFirstCall(ctx tool.Context, requestText string) ( message := a2atype.NewMessage( a2atype.MessageRoleUser, - a2atype.TextPart{Text: requestText}, + a2atype.NewTextPart(requestText), ) message.ContextID = s.lastContextID sendCtx := context.WithValue(ctx, userIDContextKey{}, ctx.UserID()) - result, err := client.SendMessage(sendCtx, &a2atype.MessageSendParams{Message: message}) + result, err := client.SendMessage(sendCtx, &a2atype.SendMessageRequest{Message: message}) if err != nil { slog.Error("Remote agent request failed", "tool", s.name, "error", err) return map[string]any{"error": fmt.Sprintf("Remote agent '%s' request failed: %v", s.name, err)}, nil @@ -226,7 +239,7 @@ func (s *remoteA2AState) handleResume(ctx tool.Context) (map[string]any, error) TaskID: a2atype.TaskID(taskID), ContextID: contextID, Role: a2atype.MessageRoleUser, - Parts: a2atype.ContentParts{a2atype.DataPart{Data: decisionData}}, + Parts: a2atype.ContentParts{a2atype.NewDataPart(decisionData)}, } decisionType, _ := decisionData[a2a.KAgentHitlDecisionTypeKey].(string) @@ -242,7 +255,7 @@ func (s *remoteA2AState) handleResume(ctx tool.Context) (map[string]any, error) } sendCtx := context.WithValue(ctx, userIDContextKey{}, ctx.UserID()) - result, err := client.SendMessage(sendCtx, &a2atype.MessageSendParams{Message: message}) + result, err := client.SendMessage(sendCtx, &a2atype.SendMessageRequest{Message: message}) if err != nil { slog.Error("Remote agent resume failed", "tool", subagentName, "error", err) return map[string]any{"error": fmt.Sprintf("Remote agent '%s' resume failed: %v", subagentName, err)}, nil @@ -425,8 +438,11 @@ func extractTextFromTask(task *a2atype.Task) string { var texts []string for _, artifact := range task.Artifacts { for _, part := range artifact.Parts { - if tp, ok := part.(a2atype.TextPart); ok && tp.Text != "" { - texts = append(texts, tp.Text) + if part == nil { + continue + } + if text := part.Text(); text != "" { + texts = append(texts, text) } } } @@ -448,8 +464,11 @@ func extractTextFromMessage(message *a2atype.Message) string { } var texts []string for _, part := range message.Parts { - if tp, ok := part.(a2atype.TextPart); ok && tp.Text != "" { - texts = append(texts, tp.Text) + if part == nil { + continue + } + if text := part.Text(); text != "" { + texts = append(texts, text) } } return strings.Join(texts, "\n") From 808d820f2b1bb5c0db0158df84c638872039b79e Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Fri, 22 May 2026 11:30:38 -0400 Subject: [PATCH 3/8] python kagent-adk a2a v1 migration Signed-off-by: Jet Chiang --- python/packages/kagent-adk/pyproject.toml | 2 +- .../kagent-adk/src/kagent/adk/_a2a.py | 17 +- .../src/kagent/adk/_agent_executor.py | 140 ++++---------- .../src/kagent/adk/_remote_a2a_tool.py | 172 ++++++++-------- .../kagent/adk/converters/event_converter.py | 108 +++++++---- .../kagent/adk/converters/part_converter.py | 183 +++++++++--------- .../adk/converters/request_converter.py | 2 +- .../kagent-adk/src/kagent/adk/types.py | 4 +- .../converters/test_event_converter.py | 20 +- .../unittests/models/test_sap_ai_core.py | 1 - .../kagent-adk/tests/unittests/test_hitl.py | 42 ++-- .../tests/unittests/test_remote_a2a_tool.py | 128 ++++++------ .../src/kagent/core/a2a/__init__.py | 2 +- .../src/kagent/core/a2a/_consts.py | 25 ++- .../src/kagent/core/a2a/_hitl_utils.py | 162 ++++++++-------- .../src/kagent/core/a2a/_requests.py | 14 +- .../core/a2a/_task_result_aggregator.py | 25 +-- .../src/kagent/core/a2a/_task_store.py | 36 ++-- .../kagent-core/tests/test_hitl_utils.py | 61 +++--- python/uv.lock | 49 ++++- 20 files changed, 608 insertions(+), 585 deletions(-) diff --git a/python/packages/kagent-adk/pyproject.toml b/python/packages/kagent-adk/pyproject.toml index 55ef9047e1..e41b7fa2e9 100644 --- a/python/packages/kagent-adk/pyproject.toml +++ b/python/packages/kagent-adk/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "pydantic>=2.5.0", "typing-extensions>=4.8.0", "jsonref>=1.1.0", - "a2a-sdk>=0.3.23", + "a2a-sdk>=1.0.0", # Security: pin minimum versions for CVE fixes in transitive dependencies "urllib3>=2.6.3", # CVE-2025-66418, CVE-2025-66471, CVE-2026-21441: unbounded decompression DoS "filelock>=3.20.3", # CVE-2025-68146, CVE-2026-22701: TOCTOU symlink race condition diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py index 7b08e6f179..e85744f952 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py +++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py @@ -5,8 +5,11 @@ from typing import Any, Callable, List, Optional import httpx -from a2a.server.apps import A2AFastAPIApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import ( + create_agent_card_routes, + create_jsonrpc_routes, +) from a2a.server.tasks import InMemoryTaskStore from a2a.types import AgentCard from agentsts.adk import ADKSTSIntegration, ADKTokenPropagationPlugin @@ -147,15 +150,12 @@ def create_runner() -> Runner: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - max_content_length = get_a2a_max_content_length() - a2a_app = A2AFastAPIApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() faulthandler.enable() @@ -169,7 +169,8 @@ def create_runner() -> Runner: # Health check/readiness probe app.add_route("/health", methods=["GET"], route=health_check) app.add_route("/thread_dump", methods=["GET"], route=thread_dump) - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app diff --git a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py index 0ed10a3177..c24478a292 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py +++ b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py @@ -5,27 +5,21 @@ import logging import uuid from contextlib import suppress -from datetime import datetime, timezone from typing import Any, Awaitable, Callable, Optional +from a2a.server.agent_execution import AgentExecutor from a2a.server.agent_execution.context import RequestContext -from a2a.server.events.event_queue import EventQueue +from a2a.server.events.event_queue_v2 import EventQueue from a2a.types import ( Artifact, Message, Part, Role, + Task, TaskArtifactUpdateEvent, TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, -) -from google.adk.a2a.executor.a2a_agent_executor import ( - A2aAgentExecutor as UpstreamA2aAgentExecutor, -) -from google.adk.a2a.executor.a2a_agent_executor import ( - A2aAgentExecutorConfig as UpstreamA2aAgentExecutorConfig, ) from google.adk.events import Event, EventActions from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME @@ -34,6 +28,7 @@ from google.adk.tools.tool_confirmation import ToolConfirmation from google.adk.utils.context_utils import Aclosing from google.genai import types as genai_types +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import ( KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, @@ -44,67 +39,31 @@ extract_rejection_reasons_from_message, get_kagent_metadata_key, ) -from kagent.core.tracing._span_processor import ( - clear_kagent_span_attributes, - set_kagent_span_attributes, -) +from kagent.core.tracing._span_processor import clear_kagent_span_attributes, set_kagent_span_attributes from pydantic import BaseModel -from typing_extensions import override from ._mcp_toolset import is_anyio_cross_task_cancel_scope_error from ._remote_a2a_tool import SubagentSessionProvider from .converters.event_converter import convert_event_to_a2a_events, serialize_metadata_value -from .converters.part_converter import convert_a2a_part_to_genai_part, convert_genai_part_to_a2a_part from .converters.request_converter import convert_a2a_request_to_adk_run_args logger = logging.getLogger("kagent_adk." + __name__) +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + class A2aAgentExecutorConfig(BaseModel): """Configuration for the KAgent A2aAgentExecutor.""" stream: bool = False -def _kagent_request_converter(request, _part_converter=None): - """Adapter to match the upstream A2ARequestToAgentRunRequestConverter signature. - - Upstream expects (RequestContext, A2APartToGenAIPartConverter) -> AgentRunRequest. - Kagent's converter has a different signature, so this wraps it to satisfy - the upstream config type while still using kagent's own conversion logic. - """ - from google.adk.a2a.converters.request_converter import AgentRunRequest - - run_args = convert_a2a_request_to_adk_run_args(request, stream=False) - return AgentRunRequest( - user_id=run_args["user_id"], - session_id=run_args["session_id"], - new_message=run_args["new_message"], - run_config=run_args["run_config"], - ) - - -def _kagent_event_converter(event, invocation_context, task_id=None, context_id=None, _part_converter=None): - """Adapter to match the upstream AdkEventToA2AEventsConverter signature. - - Upstream expects (Event, InvocationContext, task_id, context_id, GenAIPartToA2APartConverter). - Kagent's converter doesn't take a part_converter arg, so this wraps it. - """ - return convert_event_to_a2a_events(event, invocation_context, task_id, context_id) - - -class A2aAgentExecutor(UpstreamA2aAgentExecutor): - """KAgent's A2A agent executor. - - Extends the upstream google-adk A2aAgentExecutor with: - - Per-request runner lifecycle (created fresh and closed after each request) - - OpenTelemetry span attribute management - - Enhanced error handling (Ollama-specific JSON parse errors, CancelledError) - - Partial event filtering to avoid duplicate aggregation during streaming - - Session naming from first message text - - Request header forwarding to session state - - Invocation ID tracking in final event metadata - """ +class A2aAgentExecutor(AgentExecutor): + """KAgent-owned A2A v1 agent executor bridge for Google ADK.""" def __init__( self, @@ -113,26 +72,11 @@ def __init__( config: Optional[A2aAgentExecutorConfig] = None, task_store=None, ): - # Build upstream config with kagent's custom converters - upstream_config = UpstreamA2aAgentExecutorConfig( - a2a_part_converter=convert_a2a_part_to_genai_part, - gen_ai_part_converter=convert_genai_part_to_a2a_part, - request_converter=_kagent_request_converter, - event_converter=_kagent_event_converter, - ) - super().__init__(runner=runner, config=upstream_config) + self._runner = runner self._kagent_config = config self._task_store = task_store - @override async def _resolve_runner(self) -> Runner: - """Resolve the runner from the callable. - - Unlike the upstream executor which caches a single Runner instance, - kagent always creates a fresh Runner per request. This is necessary - because MCP toolset connections are not shared between requests and - must be cleaned up after each execution. - """ if callable(self._runner): result = self._runner() @@ -150,18 +94,16 @@ async def _resolve_runner(self) -> Runner: f"Runner must be a Runner instance or a callable that returns a Runner, got {type(self._runner)}" ) - @override async def cancel(self, context: RequestContext, event_queue: EventQueue): """Cancel the execution.""" # TODO: Implement proper cancellation logic if needed raise NotImplementedError("Cancellation is not supported") - @override async def execute( self, context: RequestContext, event_queue: EventQueue, - ): + ) -> None: """Executes an A2A request and publishes updates to the event queue specified. It runs as following: * Takes the input from the A2A request @@ -203,7 +145,7 @@ async def _execute_impl( self, context: RequestContext, event_queue: EventQueue, - ): + ) -> None: if not context.message: raise ValueError("A2A request must have a message") @@ -227,15 +169,14 @@ async def _execute_impl( # for new task, create a task submitted event if not context.current_task: await event_queue.enqueue_event( - TaskStatusUpdateEvent( - task_id=context.task_id, + Task( + id=context.task_id, + context_id=context.context_id, status=TaskStatus( - state=TaskState.submitted, + state=TaskState.TASK_STATE_SUBMITTED, message=context.message, - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), ), - context_id=context.context_id, - final=False, ) ) @@ -327,16 +268,15 @@ async def _publish_failed_status_event( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=error_message))], + role=Role.ROLE_AGENT, + parts=[Part(text=error_message)], ), ), context_id=context.context_id, - final=True, ) ) except BaseException as enqueue_error: @@ -526,7 +466,7 @@ async def _handle_request( event_queue: EventQueue, runner: Runner, run_args: dict[str, Any], - ): + ) -> None: # ensure the session exists session = await self._prepare_session(context, run_args, runner) @@ -572,11 +512,10 @@ async def _handle_request( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, metadata=run_metadata.copy(), ) ) @@ -633,7 +572,7 @@ async def _handle_request( # publish the task result event - this is final if ( - task_result_aggregator.task_state == TaskState.working + task_result_aggregator.task_state == TaskState.TASK_STATE_WORKING and task_result_aggregator.task_status_message is not None and task_result_aggregator.task_status_message.parts ): @@ -655,11 +594,10 @@ async def _handle_request( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_COMPLETED, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=True, metadata=run_metadata, ) ) @@ -669,11 +607,10 @@ async def _handle_request( task_id=context.task_id, status=TaskStatus( state=task_result_aggregator.task_state, - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), message=task_result_aggregator.task_status_message, ), context_id=context.context_id, - final=True, metadata=run_metadata, ) ) @@ -693,14 +630,11 @@ async def _prepare_session(self, context: RequestContext, run_args: dict[str, An session_name = None if context.message and context.message.parts: for part in context.message.parts: - # A2A parts have a .root property that contains the actual part (TextPart, FilePart, etc.) - if isinstance(part, Part): - root_part = part.root - if isinstance(root_part, TextPart) and root_part.text: - # Take first 20 chars + "..." if longer (matching UI behavior) - text = root_part.text.strip() - session_name = text[:20] + ("..." if len(text) > 20 else "") - break + if part.HasField("text") and part.text: + # Take first 20 chars + "..." if longer (matching UI behavior) + text = part.text.strip() + session_name = text[:20] + ("..." if len(text) > 20 else "") + break state: dict[str, Any] = {"session_name": session_name} # Propagate source (e.g. "agent") so the session is tagged in the DB. diff --git a/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py b/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py index 3cbaabcb23..c182d68c6d 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py +++ b/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py @@ -18,18 +18,16 @@ import httpx from a2a.client import Client as A2AClient -from a2a.client.card_resolver import A2ACardResolver -from a2a.client.client import ClientConfig as A2AClientConfig -from a2a.client.client_factory import ClientFactory as A2AClientFactory -from a2a.client.errors import A2AClientHTTPError -from a2a.client.middleware import ClientCallContext, ClientCallInterceptor +from a2a.client import ClientCallContext, create_client +from a2a.client import ClientConfig as A2AClientConfig +from a2a.client.errors import A2AClientError from a2a.types import ( AgentCard, - DataPart, Role, + SendMessageRequest, + StreamResponse, Task, TaskState, - TextPart, ) from a2a.types import ( Message as A2AMessage, @@ -37,14 +35,13 @@ from a2a.types import ( Part as A2APart, ) -from a2a.types import ( - TransportProtocol as A2ATransport, -) from google.adk.agents.readonly_context import ReadonlyContext from google.adk.tools.base_tool import BaseTool from google.adk.tools.base_toolset import BaseToolset from google.adk.tools.tool_context import ToolContext from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict, ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, @@ -62,28 +59,6 @@ _EXTRA_HEADERS_CONTEXT_KEY = "_a2a_extra_headers" -class _SubagentInterceptor(ClientCallInterceptor): - """ - Injects the authenticated user's ID as an ``x-user-id`` HTTP header, - marks the request as originating from an agent call via - ``x-kagent-source: agent``, and forwards any pre-computed propagation - headers stored in the call context state under ``_EXTRA_HEADERS_CONTEXT_KEY``. - """ - - async def intercept(self, method_name, request_payload, http_kwargs, agent_card, context): - headers = dict(http_kwargs.get("headers", {})) - headers[_SOURCE_HEADER] = _SOURCE_SUBAGENT - - if context: - if _USER_ID_CONTEXT_KEY in context.state: - headers["x-user-id"] = context.state[_USER_ID_CONTEXT_KEY] - extra = context.state.get(_EXTRA_HEADERS_CONTEXT_KEY) - if extra: - headers.update(extra) - http_kwargs["headers"] = headers - return request_payload, http_kwargs - - def _extract_text_from_task(task: Task) -> str: """Extract text content from a completed task's artifacts or status message.""" # Prefer artifacts (the canonical result) @@ -92,9 +67,8 @@ def _extract_text_from_task(task: Task) -> str: for artifact in task.artifacts: if artifact.parts: for part in artifact.parts: - root = part.root if hasattr(part, "root") else part - if isinstance(root, TextPart) and root.text: - texts.append(root.text) + if part.HasField("text") and part.text: + texts.append(part.text) if texts: return "\n".join(texts) @@ -102,9 +76,8 @@ def _extract_text_from_task(task: Task) -> str: if task.status and task.status.message and task.status.message.parts: texts = [] for part in task.status.message.parts: - root = part.root if hasattr(part, "root") else part - if isinstance(root, TextPart) and root.text: - texts.append(root.text) + if part.HasField("text") and part.text: + texts.append(part.text) if texts: return "\n".join(texts) @@ -112,15 +85,10 @@ def _extract_text_from_task(task: Task) -> str: def _extract_usage_from_task(task: Task) -> Optional[dict]: - """Extract kagent_usage_metadata from a completed task. - - The A2A task_manager merges the final TaskStatusUpdateEvent.metadata into - task.metadata. The agent executor now adds the last LLM invocation's - usage_metadata to run_metadata before publishing the final event, so it - is available here for non-streaming callers like KAgentRemoteA2ATool. - """ + """Extract kagent_usage_metadata from a completed task.""" if task.metadata: - usage = task.metadata.get("kagent_usage_metadata") + metadata = MessageToDict(task.metadata) + usage = metadata.get("kagent_usage_metadata") if usage and isinstance(usage, dict): return usage return None @@ -164,7 +132,7 @@ def subagent_session_id(self) -> str | None: return self._last_context_id async def _ensure_client(self) -> A2AClient: - """Lazily resolve the agent card and initialize the A2A client.""" + """Lazily initialize the A2A client.""" if self._a2a_client is not None: return self._a2a_client @@ -174,30 +142,23 @@ async def _ensure_client(self) -> A2AClient: "Use KAgentRemoteA2AToolset to manage the client lifecycle." ) - # Resolve the agent card from URL - parsed = urlparse(self._agent_card_url) - base_url = f"{parsed.scheme}://{parsed.netloc}" - resolver = A2ACardResolver(httpx_client=self._httpx_client, base_url=base_url) - self._agent_card = await resolver.get_agent_card(relative_card_path=parsed.path) - - if not self._agent_card.url: - raise ValueError(f"Agent card for {self.name} has no RPC URL") - - # Auto-populate description from agent card if we don't have one - if not self.description and self._agent_card.description: - self.description = self._agent_card.description - - # Create the A2A client. config = A2AClientConfig( httpx_client=self._httpx_client, streaming=False, polling=False, - supported_transports=[A2ATransport.jsonrpc], ) - factory = A2AClientFactory(config=config) - self._a2a_client = factory.create( - self._agent_card, - interceptors=[_SubagentInterceptor()], + parsed = urlparse(self._agent_card_url) + if parsed.scheme and parsed.netloc: + base_url = f"{parsed.scheme}://{parsed.netloc}" + relative_card_path = parsed.path or None + else: + base_url = self._agent_card_url + relative_card_path = None + + self._a2a_client = await create_client( + base_url, + client_config=config, + relative_card_path=relative_card_path, ) return self._a2a_client @@ -216,12 +177,18 @@ def _get_declaration(self) -> genai_types.FunctionDeclaration: ) def _build_call_context(self, tool_context: ToolContext) -> ClientCallContext: - state: dict[str, Any] = {_USER_ID_CONTEXT_KEY: tool_context.session.user_id} + headers: dict[str, str] = { + _SOURCE_HEADER: _SOURCE_SUBAGENT, + _USER_ID_CONTEXT_KEY: tool_context.session.user_id, + } if self._header_provider: extra_headers = self._header_provider(tool_context) if extra_headers: - state[_EXTRA_HEADERS_CONTEXT_KEY] = extra_headers - return ClientCallContext(state=state) + headers.update(extra_headers) + return ClientCallContext( + state={_USER_ID_CONTEXT_KEY: tool_context.session.user_id}, + service_parameters=headers, + ) async def run_async(self, *, args: dict[str, Any], tool_context: ToolContext) -> Any: """Execute the remote agent tool. @@ -248,11 +215,11 @@ async def _handle_first_call(self, args: dict[str, Any], tool_context: ToolConte request_text = args.get("request", "") message = A2AMessage( message_id=str(uuid.uuid4()), - parts=[A2APart(root=TextPart(text=request_text))], - role=Role.user, - # Pass context_id for session continuity with stateful remote agents + parts=[A2APart(text=request_text)], + role=Role.ROLE_USER, context_id=self._last_context_id, ) + send_request = SendMessageRequest(message=message) # Forward the authenticated user ID so the subagent session is scoped # to the same user as the parent agent session. @@ -260,13 +227,20 @@ async def _handle_first_call(self, args: dict[str, Any], tool_context: ToolConte task: Optional[Task] = None try: - async for response in client.send_message(request=message, context=call_context): - if isinstance(response, tuple): - # ClientEvent: (Task, UpdateEvent | None) - task = response[0] - elif isinstance(response, A2AMessage): - return self._extract_text_from_message(response) - except A2AClientHTTPError as e: + async for chunk in client.send_message(request=send_request, context=call_context): + if not isinstance(chunk, StreamResponse): + continue + if chunk.HasField("task"): + task = chunk.task + elif chunk.HasField("status_update"): + task = Task( + id=chunk.status_update.task_id, + context_id=chunk.status_update.context_id, + status=chunk.status_update.status, + ) + elif chunk.HasField("message"): + return self._extract_text_from_message(chunk.message) + except A2AClientError as e: return f"Remote agent '{self.name}' request failed: {e}" except Exception as e: logger.error("Error calling remote agent %s: %s", self.name, e, exc_info=True) @@ -277,10 +251,10 @@ async def _handle_first_call(self, args: dict[str, Any], tool_context: ToolConte state = task.status.state if task.status else None - if state == TaskState.input_required: + if state == TaskState.TASK_STATE_INPUT_REQUIRED: return self._handle_input_required(task, tool_context) - if state == TaskState.failed: + if state == TaskState.TASK_STATE_FAILED: error_text = _extract_text_from_task(task) return error_text or f"Remote agent '{self.name}' failed." @@ -386,9 +360,10 @@ async def _handle_resume(self, tool_context: ToolContext) -> Any: message_id=str(uuid.uuid4()), task_id=task_id, context_id=context_id, - role=Role.user, - parts=[A2APart(root=DataPart(data=decision_data))], + role=Role.ROLE_USER, + parts=[A2APart(data=ParseDict(decision_data, Value()))], ) + send_request = SendMessageRequest(message=decision_message) logger.info( "Forwarding %s decision to subagent %s task %s", @@ -401,12 +376,20 @@ async def _handle_resume(self, tool_context: ToolContext) -> Any: call_context = self._build_call_context(tool_context) task: Optional[Task] = None try: - async for response in client.send_message(request=decision_message, context=call_context): - if isinstance(response, tuple): - task = response[0] - elif isinstance(response, A2AMessage): - return self._extract_text_from_message(response) - except A2AClientHTTPError as e: + async for chunk in client.send_message(request=send_request, context=call_context): + if not isinstance(chunk, StreamResponse): + continue + if chunk.HasField("task"): + task = chunk.task + elif chunk.HasField("status_update"): + task = Task( + id=chunk.status_update.task_id, + context_id=chunk.status_update.context_id, + status=chunk.status_update.status, + ) + elif chunk.HasField("message"): + return self._extract_text_from_message(chunk.message) + except A2AClientError as e: return f"Remote agent '{subagent_name}' resume failed: {e}" except Exception as e: logger.error("Error resuming remote agent %s: %s", subagent_name, e, exc_info=True) @@ -417,12 +400,10 @@ async def _handle_resume(self, tool_context: ToolContext) -> Any: state = task.status.state if task.status else None - if state == TaskState.input_required: - # The subagent has another HITL request (e.g. multiple tools needing - # approval in sequence). Surface it again. + if state == TaskState.TASK_STATE_INPUT_REQUIRED: return self._handle_input_required(task, tool_context) - if state == TaskState.failed: + if state == TaskState.TASK_STATE_FAILED: error_text = _extract_text_from_task(task) return error_text or f"Remote agent '{subagent_name}' failed after resume." @@ -444,9 +425,8 @@ def _extract_text_from_message(message: A2AMessage) -> str: return "" texts: list[str] = [] for part in message.parts: - root = part.root if hasattr(part, "root") else part - if isinstance(root, TextPart) and root.text: - texts.append(root.text) + if part.HasField("text") and part.text: + texts.append(part.text) return "\n".join(texts) diff --git a/python/packages/kagent-adk/src/kagent/adk/converters/event_converter.py b/python/packages/kagent-adk/src/kagent/adk/converters/event_converter.py index 92c4b664d6..c23b0b3a27 100644 --- a/python/packages/kagent-adk/src/kagent/adk/converters/event_converter.py +++ b/python/packages/kagent-adk/src/kagent/adk/converters/event_converter.py @@ -2,16 +2,17 @@ import logging import uuid -from datetime import datetime, timezone from typing import Any, Dict, List, Optional from a2a.server.events import Event as A2AEvent -from a2a.types import DataPart, Message, Role, Task, TaskState, TaskStatus, TaskStatusUpdateEvent, TextPart +from a2a.types import Message, Role, Task, TaskState, TaskStatus, TaskStatusUpdateEvent from a2a.types import Part as A2APart from google.adk.agents.invocation_context import InvocationContext from google.adk.events.event import Event from google.adk.flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import ( A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, @@ -32,15 +33,23 @@ logger = logging.getLogger("kagent_adk." + __name__) -def serialize_metadata_value(value: Any) -> str: +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + +def serialize_metadata_value(value: Any) -> Any: """Safely serializes metadata values to string format. Args: value: The value to serialize. Returns: - String representation of the value. + JSON-serializable representation of the value. """ + if hasattr(value, "DESCRIPTOR"): + return MessageToDict(value) if hasattr(value, "model_dump"): try: return value.model_dump(exclude_none=True, by_alias=True) @@ -69,8 +78,11 @@ def _get_context_metadata(event: Event, invocation_context: InvocationContext) - raise ValueError("Invocation context cannot be None") try: + partial = getattr(event, "partial", False) + if not isinstance(partial, bool): + partial = False metadata: Dict[str, Any] = { - get_kagent_metadata_key("adk_partial"): event.partial, + get_kagent_metadata_key("adk_partial"): partial, get_kagent_metadata_key("app_name"): invocation_context.app_name, get_kagent_metadata_key("user_id"): invocation_context.user_id, get_kagent_metadata_key("session_id"): invocation_context.session.id, @@ -122,15 +134,21 @@ def _process_long_running_tool(a2a_part: A2APart, event: Event) -> None: a2a_part: The A2A part to potentially mark as long-running. event: The ADK event containing long-running tool information. """ + if not event.long_running_tool_ids: + return + if not a2a_part.HasField("data"): + return + + metadata = MessageToDict(a2a_part.metadata) if a2a_part.metadata else {} if ( - isinstance(a2a_part.root, DataPart) - and event.long_running_tool_ids - and a2a_part.root.metadata - and a2a_part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) - == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - and a2a_part.root.data.get("id") in event.long_running_tool_ids + metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) + != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL ): - a2a_part.root.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)] = True + return + + part_data = MessageToDict(a2a_part.data) + if isinstance(part_data, dict) and part_data.get("id") in event.long_running_tool_ids: + a2a_part.metadata.update({get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True}) def _process_subagent_session_id(a2a_part: A2APart, subagent_session_ids: Dict[str, str]) -> None: @@ -144,22 +162,24 @@ def _process_subagent_session_id(a2a_part: A2APart, subagent_session_ids: Dict[s a2a_part: The A2A part to potentially stamp. subagent_session_ids: Mapping of tool name to pre-generated session ID. """ - if not isinstance(a2a_part.root, DataPart) or not a2a_part.root.metadata: + if not a2a_part.HasField("data"): return + metadata = MessageToDict(a2a_part.metadata) if a2a_part.metadata else {} if ( - a2a_part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) + metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL ): return - tool_name = a2a_part.root.data.get("name") if isinstance(a2a_part.root.data, dict) else None + part_data = MessageToDict(a2a_part.data) + tool_name = part_data.get("name") if isinstance(part_data, dict) else None if tool_name and tool_name in subagent_session_ids: - a2a_part.root.metadata[get_kagent_metadata_key("subagent_session_id")] = subagent_session_ids[tool_name] + a2a_part.metadata.update({get_kagent_metadata_key("subagent_session_id"): subagent_session_ids[tool_name]}) def convert_event_to_a2a_message( event: Event, invocation_context: InvocationContext, - role: Role = Role.agent, + role: Role = Role.ROLE_AGENT, subagent_session_ids: Optional[Dict[str, str]] = None, ) -> Optional[Message]: """Converts an ADK event to an A2A message. @@ -239,16 +259,15 @@ def _create_error_status_event( context_id=context_id, metadata=event_metadata, status=TaskStatus( - state=TaskState.failed, + state=TaskState.TASK_STATE_FAILED, message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(TextPart(text=error_message))], + role=Role.ROLE_AGENT, + parts=[A2APart(text=error_message)], metadata={get_kagent_metadata_key("error_code"): str(event.error_code)} if event.error_code else {}, ), - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), ), - final=False, ) @@ -273,35 +292,40 @@ def _create_status_update_event( A TaskStatusUpdateEvent with RUNNING state. """ status = TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), ) - if any( - part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) - == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - and part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)) is True - and part.root.data.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME - for part in message.parts - if part.root.metadata - ): - status.state = TaskState.auth_required - elif any( - part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) - == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - and part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)) is True - for part in message.parts - if part.root.metadata - ): - status.state = TaskState.input_required + has_auth_required = False + has_input_required = False + for part in message.parts: + if not part.HasField("data"): + continue + metadata = MessageToDict(part.metadata) if part.metadata else {} + if ( + metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) + != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + ): + continue + if metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)) is not True: + continue + payload = MessageToDict(part.data) + if isinstance(payload, dict) and payload.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME: + has_auth_required = True + break + has_input_required = True + + if has_auth_required: + status.state = TaskState.TASK_STATE_AUTH_REQUIRED + elif has_input_required: + status.state = TaskState.TASK_STATE_INPUT_REQUIRED return TaskStatusUpdateEvent( task_id=task_id, context_id=context_id, status=status, metadata=_get_context_metadata(event, invocation_context), - final=False, ) diff --git a/python/packages/kagent-adk/src/kagent/adk/converters/part_converter.py b/python/packages/kagent-adk/src/kagent/adk/converters/part_converter.py index c0e70e031e..f5fef90764 100644 --- a/python/packages/kagent-adk/src/kagent/adk/converters/part_converter.py +++ b/python/packages/kagent-adk/src/kagent/adk/converters/part_converter.py @@ -25,6 +25,8 @@ from a2a import types as a2a_types from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict, ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT, A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE, @@ -37,73 +39,85 @@ logger = logging.getLogger("kagent_adk." + __name__) +def _metadata_to_dict(part: a2a_types.Part) -> dict: + if not part.metadata: + return {} + metadata = MessageToDict(part.metadata) + return metadata if isinstance(metadata, dict) else {} + + +def _data_value_to_python(part: a2a_types.Part): + if not part.HasField("data"): + return None + return MessageToDict(part.data) + + def convert_a2a_part_to_genai_part( a2a_part: a2a_types.Part, ) -> Optional[genai_types.Part]: """Convert an A2A Part to a Google GenAI Part.""" - part = a2a_part.root - if isinstance(part, a2a_types.TextPart): - return genai_types.Part(text=part.text) - - if isinstance(part, a2a_types.FilePart): - if isinstance(part.file, a2a_types.FileWithUri): - return genai_types.Part( - file_data=genai_types.FileData(file_uri=part.file.uri, mime_type=part.file.mime_type) - ) + if a2a_part.HasField("text"): + return genai_types.Part(text=a2a_part.text) - elif isinstance(part.file, a2a_types.FileWithBytes): - return genai_types.Part( - inline_data=genai_types.Blob( - data=base64.b64decode(part.file.bytes), - mime_type=part.file.mime_type, - ) - ) - else: - logger.warning( - "Cannot convert unsupported file type: %s for A2A part: %s", - type(part.file), - a2a_part, + if a2a_part.HasField("url"): + return genai_types.Part( + file_data=genai_types.FileData(file_uri=a2a_part.url, mime_type=a2a_part.media_type or None) + ) + + if a2a_part.HasField("raw"): + return genai_types.Part( + inline_data=genai_types.Blob( + data=bytes(a2a_part.raw), + mime_type=a2a_part.media_type or None, ) - return None + ) - if isinstance(part, a2a_types.DataPart): + if a2a_part.HasField("data"): # Convert the Data Part to funcall and function response. # This is mainly for converting human in the loop and auth request and # response. # TODO once A2A defined how to suervice such information, migrate below # logic accordinlgy - if part.metadata and get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY) in part.metadata: + data_value = _data_value_to_python(a2a_part) + metadata = _metadata_to_dict(a2a_part) + if metadata and get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY) in metadata: if ( - part.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] + metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL ): - return genai_types.Part(function_call=genai_types.FunctionCall.model_validate(part.data, by_alias=True)) + if isinstance(data_value, dict): + return genai_types.Part( + function_call=genai_types.FunctionCall.model_validate(data_value, by_alias=True) + ) if ( - part.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] + metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] == A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE ): - return genai_types.Part( - function_response=genai_types.FunctionResponse.model_validate(part.data, by_alias=True) - ) + if isinstance(data_value, dict): + return genai_types.Part( + function_response=genai_types.FunctionResponse.model_validate(data_value, by_alias=True) + ) if ( - part.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] + metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] == A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT ): - return genai_types.Part( - code_execution_result=genai_types.CodeExecutionResult.model_validate(part.data, by_alias=True) - ) + if isinstance(data_value, dict): + return genai_types.Part( + code_execution_result=genai_types.CodeExecutionResult.model_validate(data_value, by_alias=True) + ) if ( - part.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] + metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] == A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE ): - return genai_types.Part( - executable_code=genai_types.ExecutableCode.model_validate(part.data, by_alias=True) - ) - return genai_types.Part(text=json.dumps(part.data)) + if isinstance(data_value, dict): + return genai_types.Part( + executable_code=genai_types.ExecutableCode.model_validate(data_value, by_alias=True) + ) + return genai_types.Part(text=json.dumps(data_value)) logger.warning( "Cannot convert unsupported part type: %s for A2A part: %s", - type(part), + type(a2a_part), a2a_part, ) return None @@ -115,37 +129,26 @@ def convert_genai_part_to_a2a_part( """Convert a Google GenAI Part to an A2A Part.""" if part.text: - a2a_part = a2a_types.TextPart(text=part.text) + a2a_part = a2a_types.Part(text=part.text) if part.thought is not None: - a2a_part.metadata = {get_kagent_metadata_key("thought"): part.thought} - return a2a_types.Part(root=a2a_part) + a2a_part.metadata.update({get_kagent_metadata_key("thought"): part.thought}) + return a2a_part if part.file_data: - return a2a_types.Part( - root=a2a_types.FilePart( - file=a2a_types.FileWithUri( - uri=part.file_data.file_uri, - mime_type=part.file_data.mime_type, - ) - ) - ) + return a2a_types.Part(url=part.file_data.file_uri, media_type=part.file_data.mime_type) if part.inline_data: - a2a_part = a2a_types.FilePart( - file=a2a_types.FileWithBytes( - bytes=base64.b64encode(part.inline_data.data).decode("utf-8"), - mime_type=part.inline_data.mime_type, - ) - ) + a2a_part = a2a_types.Part(raw=part.inline_data.data, media_type=part.inline_data.mime_type) if part.video_metadata: - a2a_part.metadata = { - get_kagent_metadata_key("video_metadata"): part.video_metadata.model_dump( - by_alias=True, exclude_none=True - ) - } - - return a2a_types.Part(root=a2a_part) + a2a_part.metadata.update( + { + get_kagent_metadata_key("video_metadata"): part.video_metadata.model_dump( + by_alias=True, exclude_none=True + ) + } + ) + return a2a_part # Convert the funcall and function response to A2A DataPart. # This is mainly for converting human in the loop and auth request and @@ -153,49 +156,41 @@ def convert_genai_part_to_a2a_part( # TODO once A2A defined how to suervice such information, migrate below # logic accordinlgy if part.function_call: + payload = part.function_call.model_dump(by_alias=True, exclude_none=True) return a2a_types.Part( - root=a2a_types.DataPart( - data=part.function_call.model_dump(by_alias=True, exclude_none=True), - metadata={ - get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - }, - ) + data=ParseDict(payload, Value()), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + }, ) if part.function_response: + payload = part.function_response.model_dump(by_alias=True, exclude_none=True) return a2a_types.Part( - root=a2a_types.DataPart( - data=part.function_response.model_dump(by_alias=True, exclude_none=True), - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE - }, - ) + data=ParseDict(payload, Value()), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE + }, ) if part.code_execution_result: + payload = part.code_execution_result.model_dump(by_alias=True, exclude_none=True) return a2a_types.Part( - root=a2a_types.DataPart( - data=part.code_execution_result.model_dump(by_alias=True, exclude_none=True), - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT - }, - ) + data=ParseDict(payload, Value()), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT + }, ) if part.executable_code: + payload = part.executable_code.model_dump(by_alias=True, exclude_none=True) return a2a_types.Part( - root=a2a_types.DataPart( - data=part.executable_code.model_dump(by_alias=True, exclude_none=True), - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE - }, - ) + data=ParseDict(payload, Value()), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE + }, ) logger.warning( diff --git a/python/packages/kagent-adk/src/kagent/adk/converters/request_converter.py b/python/packages/kagent-adk/src/kagent/adk/converters/request_converter.py index 88844daf68..8baa5f218a 100644 --- a/python/packages/kagent-adk/src/kagent/adk/converters/request_converter.py +++ b/python/packages/kagent-adk/src/kagent/adk/converters/request_converter.py @@ -29,7 +29,7 @@ def convert_a2a_request_to_adk_run_args( "session_id": request.context_id, "new_message": genai_types.Content( role="user", - parts=[convert_a2a_part_to_genai_part(part) for part in request.message.parts], + parts=[converted for part in request.message.parts if (converted := convert_a2a_part_to_genai_part(part))], ), "run_config": RunConfig(streaming_mode=StreamingMode.SSE if stream else StreamingMode.NONE), } diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 5e2f4a97af..5561dd8819 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -7,7 +7,6 @@ from google.adk.agents.callback_context import CallbackContext from google.adk.agents.llm_agent import ToolUnion from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_TIMEOUT from google.adk.models.anthropic_llm import Claude as ClaudeLLM from google.adk.models.google_llm import Gemini as GeminiLLM from google.adk.tools.mcp_tool import SseConnectionParams, StreamableHTTPConnectionParams @@ -30,6 +29,9 @@ # Proxy host header used for Gateway API routing when using a proxy PROXY_HOST_HEADER = "x-kagent-host" +AGENT_CARD_WELL_KNOWN_PATH = "/.well-known/agent.json" +DEFAULT_TIMEOUT = 30.0 + # Key used to store headers in session state HEADERS_STATE_KEY = "headers" diff --git a/python/packages/kagent-adk/tests/unittests/converters/test_event_converter.py b/python/packages/kagent-adk/tests/unittests/converters/test_event_converter.py index 6040a7e047..35b9f1a9fb 100644 --- a/python/packages/kagent-adk/tests/unittests/converters/test_event_converter.py +++ b/python/packages/kagent-adk/tests/unittests/converters/test_event_converter.py @@ -48,10 +48,12 @@ def test_convert_event_to_a2a_events(self): event1, invocation_context, task_id="test_task_1", context_id="test_context_1" ) error_events1 = [ - e for e in result1 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.failed + e for e in result1 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_FAILED ] working_events1 = [ - e for e in result1 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.working + e + for e in result1 + if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_WORKING ] assert len(error_events1) == 0, ( f"Expected no error events for STOP with empty content, got {len(error_events1)}" @@ -70,10 +72,12 @@ def test_convert_event_to_a2a_events(self): event2, invocation_context, task_id="test_task_2", context_id="test_context_2" ) error_events2 = [ - e for e in result2 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.failed + e for e in result2 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_FAILED ] working_events2 = [ - e for e in result2 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.working + e + for e in result2 + if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_WORKING ] assert len(error_events2) == 0, f"Expected no error events for STOP with empty parts, got {len(error_events2)}" assert len(working_events2) == 0, ( @@ -88,10 +92,12 @@ def test_convert_event_to_a2a_events(self): event3, invocation_context, task_id="test_task_3", context_id="test_context_3" ) error_events3 = [ - e for e in result3 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.failed + e for e in result3 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_FAILED ] working_events3 = [ - e for e in result3 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.working + e + for e in result3 + if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_WORKING ] assert len(error_events3) == 0, ( f"Expected no error events for STOP with missing content, got {len(error_events3)}" @@ -108,7 +114,7 @@ def test_convert_event_to_a2a_events(self): event4, invocation_context, task_id="test_task_4", context_id="test_context_4" ) error_events4 = [ - e for e in result4 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.failed + e for e in result4 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_FAILED ] assert len(error_events4) == 1, f"Expected 1 error event for MALFORMED_FUNCTION_CALL, got {len(error_events4)}" diff --git a/python/packages/kagent-adk/tests/unittests/models/test_sap_ai_core.py b/python/packages/kagent-adk/tests/unittests/models/test_sap_ai_core.py index aace9575a9..fab636b547 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_sap_ai_core.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_sap_ai_core.py @@ -17,7 +17,6 @@ _parse_orchestration_chunk, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/python/packages/kagent-adk/tests/unittests/test_hitl.py b/python/packages/kagent-adk/tests/unittests/test_hitl.py index 4ad2371de0..14782903bd 100644 --- a/python/packages/kagent-adk/tests/unittests/test_hitl.py +++ b/python/packages/kagent-adk/tests/unittests/test_hitl.py @@ -3,11 +3,13 @@ import json from unittest.mock import MagicMock -from a2a.types import DataPart, Message, Part, Role +from a2a.types import Message, Part, Role from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME from google.adk.sessions import Session from google.adk.tools.tool_confirmation import ToolConfirmation from google.genai import types as genai_types +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( KAGENT_ASK_USER_ANSWERS_KEY, KAGENT_HITL_DECISION_TYPE_APPROVE, @@ -266,7 +268,7 @@ def test_find_pending_confirmations_with_payload(): def _make_simple_message(parts=None) -> Message: """Create a minimal real Message for testing.""" return Message( - role=Role.user, + role=Role.ROLE_USER, message_id="test-msg", task_id="test-task", context_id="test-ctx", @@ -356,20 +358,21 @@ def test_process_hitl_decision_batch(): ] message = Message( - role=Role.user, + role=Role.ROLE_USER, message_id="msg1", task_id="task1", context_id="ctx1", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_BATCH, KAGENT_HITL_DECISIONS_KEY: { "orig123": KAGENT_HITL_DECISION_TYPE_APPROVE, "orig456": KAGENT_HITL_DECISION_TYPE_REJECT, }, - } + }, + Value(), ) ) ], @@ -408,17 +411,18 @@ def test_process_hitl_decision_uniform_reject_with_reason(): ] message = Message( - role=Role.user, + role=Role.ROLE_USER, message_id="msg1", task_id="task1", context_id="ctx1", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_REJECT, "rejection_reason": "Too risky", - } + }, + Value(), ) ) ], @@ -456,14 +460,14 @@ def test_process_hitl_decision_batch_with_per_tool_reason(): ] message = Message( - role=Role.user, + role=Role.ROLE_USER, message_id="msg1", task_id="task1", context_id="ctx1", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_BATCH, KAGENT_HITL_DECISIONS_KEY: { "orig123": KAGENT_HITL_DECISION_TYPE_APPROVE, @@ -472,7 +476,8 @@ def test_process_hitl_decision_batch_with_per_tool_reason(): KAGENT_HITL_REJECTION_REASONS_KEY: { "orig456": "Wrong environment", }, - } + }, + Value(), ) ) ], @@ -543,17 +548,18 @@ def test_process_hitl_decision_ask_user_answers(): answers = [{"answer": ["PostgreSQL"]}, {"answer": ["Auth", "Caching"]}] message = Message( - role=Role.user, + role=Role.ROLE_USER, message_id="msg1", task_id="task1", context_id="ctx1", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_ASK_USER_ANSWERS_KEY: answers, - } + }, + Value(), ) ) ], diff --git a/python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py b/python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py index 8682741bb2..04e7821cb7 100644 --- a/python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py +++ b/python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py @@ -4,17 +4,18 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx +from a2a.types import Message as A2AMessage +from a2a.types import Part as A2APart from a2a.types import ( - DataPart, Role, + StreamResponse, Task, TaskState, TaskStatus, - TextPart, ) -from a2a.types import Message as A2AMessage -from a2a.types import Part as A2APart from google.adk.tools.tool_confirmation import ToolConfirmation +from google.protobuf.json_format import MessageToDict, ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, @@ -26,7 +27,6 @@ KAgentRemoteA2ATool, KAgentRemoteA2AToolset, SubagentSessionProvider, - _SubagentInterceptor, ) # --------------------------------------------------------------------------- @@ -68,16 +68,14 @@ def _make_task(state: TaskState, text: str = "", hitl_data: list[dict] | None = for d in hitl_data: parts.append( A2APart( - root=DataPart( - data=d, - metadata={"adk_type": "function_call", "adk_is_long_running": True}, - ) + data=ParseDict(d, Value()), + metadata={"adk_type": "function_call", "adk_is_long_running": True}, ) ) elif text: - parts.append(A2APart(root=TextPart(text=text))) + parts.append(A2APart(text=text)) - status_message = A2AMessage(role=Role.agent, message_id="msg-1", parts=parts) if parts else None + status_message = A2AMessage(role=Role.ROLE_AGENT, message_id="msg-1", parts=parts) if parts else None return Task( id="task-1", context_id="ctx-1", @@ -100,13 +98,21 @@ def _make_hitl_task(tool_name: str = "delete_file", tool_call_id: str = "call_1" }, } ] - return _make_task(TaskState.input_required, hitl_data=hitl_data) + return _make_task(TaskState.TASK_STATE_INPUT_REQUIRED, hitl_data=hitl_data) async def _async_yield(*items) -> AsyncIterator: """Yield items from an async generator (simulates client.send_message).""" for item in items: - yield item + if isinstance(item, tuple): + task, _ = item + yield StreamResponse(task=task) + elif isinstance(item, A2AMessage): + yield StreamResponse(message=item) + elif isinstance(item, Task): + yield StreamResponse(task=item) + else: + yield item def _make_tool(*, httpx_client: httpx.AsyncClient | None = None) -> KAgentRemoteA2ATool: @@ -127,10 +133,27 @@ def _patch_client(tool: KAgentRemoteA2ATool, send_side_effect): p = patch.object(tool, "_ensure_client") mock_ensure = p.start() mock_client = MagicMock() + + async def _wrap_stream(iterable): + async for item in iterable: + if isinstance(item, tuple): + task, _ = item + yield StreamResponse(task=task) + elif isinstance(item, A2AMessage): + yield StreamResponse(message=item) + elif isinstance(item, Task): + yield StreamResponse(task=item) + else: + yield item + if callable(send_side_effect) and not isinstance(send_side_effect, MagicMock): - mock_client.send_message = send_side_effect + + def _invoke(*args, **kwargs): + return _wrap_stream(send_side_effect(*args, **kwargs)) + + mock_client.send_message = _invoke else: - mock_client.send_message = MagicMock(return_value=send_side_effect) + mock_client.send_message = MagicMock(return_value=_wrap_stream(send_side_effect)) mock_ensure.return_value = mock_client return p, mock_client @@ -141,40 +164,27 @@ def _approval_ctx(confirmed: bool, payload: dict | None = None, **kwargs) -> Moc # --------------------------------------------------------------------------- -# _SubagentInterceptor header propagation tests +# Call context header propagation tests # --------------------------------------------------------------------------- class TestSubagentInterceptorHeaderPropagation: - """Tests for header propagation in _SubagentInterceptor via context state.""" - - async def _call_intercept(self, interceptor, state: dict) -> dict: - from a2a.client.middleware import ClientCallContext - - ctx = ClientCallContext(state=state) - _, http_kwargs = await interceptor.intercept( - method_name="message/send", - request_payload={}, - http_kwargs={}, - agent_card=None, - context=ctx, - ) - return http_kwargs.get("headers", {}) - - async def test_forwards_extra_headers_from_context_state(self): - interceptor = _SubagentInterceptor() - headers = await self._call_intercept( - interceptor, - state={"x-user-id": "user1", "_a2a_extra_headers": {"authorization": "Bearer test-jwt"}}, + """Tests for header propagation via ClientCallContext.service_parameters.""" + + async def test_forwards_extra_headers_from_header_provider(self): + tool = KAgentRemoteA2ATool( + name="k8s_agent", + description="K8s subagent", + agent_card_url="http://k8s-agent/.well-known/agent.json", + header_provider=lambda _: {"authorization": "Bearer test-jwt"}, ) + headers = tool._build_call_context(MockToolContext(user_id="user1")).service_parameters or {} assert headers.get("authorization") == "Bearer test-jwt" + assert headers.get("x-user-id") == "user1" - async def test_no_extra_headers_without_state_key(self): - interceptor = _SubagentInterceptor() - headers = await self._call_intercept( - interceptor, - state={"x-user-id": "user1", "authorization": "Bearer test-jwt"}, - ) + async def test_no_extra_headers_without_header_provider(self): + tool = _make_tool() + headers = tool._build_call_context(MockToolContext(user_id="user1")).service_parameters or {} assert "authorization" not in headers @@ -189,7 +199,7 @@ class TestFirstCall: async def test_completed_task_returns_result_with_session_id(self): """Completed task returns dict with result text and subagent_session_id.""" tool = _make_tool() - task = _make_task(TaskState.completed, text="all done") + task = _make_task(TaskState.TASK_STATE_COMPLETED, text="all done") p, _ = _patch_client(tool, _async_yield((task, None))) try: result = await tool.run_async(args={"request": "do something"}, tool_context=MockToolContext()) @@ -204,9 +214,9 @@ async def test_direct_message_response_returns_text(self): """When remote agent returns an A2AMessage directly, result is plain text.""" tool = _make_tool() msg = A2AMessage( - role=Role.agent, + role=Role.ROLE_AGENT, message_id="m1", - parts=[A2APart(root=TextPart(text="direct reply"))], + parts=[A2APart(text="direct reply")], ) p, _ = _patch_client(tool, _async_yield(msg)) try: @@ -230,7 +240,7 @@ async def test_no_result_returns_fallback_string(self): async def test_failed_task_returns_error_text(self): """Failed tasks return the error text from the task status message.""" tool = _make_tool() - task = _make_task(TaskState.failed, text="something broke") + task = _make_task(TaskState.TASK_STATE_FAILED, text="something broke") p, _ = _patch_client(tool, _async_yield((task, None))) try: result = await tool.run_async(args={"request": "go"}, tool_context=MockToolContext()) @@ -242,7 +252,7 @@ async def test_failed_task_returns_error_text(self): async def test_context_id_sent_in_outgoing_message(self): """The tool's pre-generated context_id is sent on the outgoing A2A message.""" tool = _make_tool() - task = _make_task(TaskState.completed, text="ok") + task = _make_task(TaskState.TASK_STATE_COMPLETED, text="ok") sent: list[A2AMessage] = [] async def capture(*, request, **kw): @@ -255,12 +265,12 @@ async def capture(*, request, **kw): finally: p.stop() - assert sent[0].context_id == tool._last_context_id + assert sent[0].message.context_id == tool._last_context_id async def test_user_id_forwarded_in_call_context(self): """The parent session's user_id is forwarded via ClientCallContext.""" tool = _make_tool() - task = _make_task(TaskState.completed, text="ok") + task = _make_task(TaskState.TASK_STATE_COMPLETED, text="ok") captured_contexts: list = [] async def capture(*, request, context=None, **kw): @@ -341,7 +351,7 @@ async def _resume( ) -> tuple[Any, list[A2AMessage]]: """Run a resume and return (result, sent_messages).""" if response_task is None: - response_task = _make_task(TaskState.completed, text="ok") + response_task = _make_task(TaskState.TASK_STATE_COMPLETED, text="ok") sent: list[A2AMessage] = [] async def capture(*, request, **kw): @@ -362,26 +372,26 @@ async def test_approve_sends_approve_decision(self): tool, confirmed=True, payload=_RESUME_PAYLOAD, - response_task=_make_task(TaskState.completed, text="approved"), + response_task=_make_task(TaskState.TASK_STATE_COMPLETED, text="approved"), ) assert result["result"] == "approved" - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data[KAGENT_HITL_DECISION_TYPE_KEY] == KAGENT_HITL_DECISION_TYPE_APPROVE # Verify task_id and context_id are routed correctly - assert sent[0].task_id == "task-1" - assert sent[0].context_id == "ctx-1" + assert sent[0].message.task_id == "task-1" + assert sent[0].message.context_id == "ctx-1" async def test_reject_sends_reject_decision(self): tool = _make_tool() _, sent = await self._resume(tool, confirmed=False, payload=_RESUME_PAYLOAD) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data[KAGENT_HITL_DECISION_TYPE_KEY] == KAGENT_HITL_DECISION_TYPE_REJECT async def test_reject_with_reason(self): tool = _make_tool() payload = {**_RESUME_PAYLOAD, "rejection_reason": "Too risky"} _, sent = await self._resume(tool, confirmed=False, payload=payload) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data["rejection_reason"] == "Too risky" async def test_batch_decisions_forwarded(self): @@ -391,7 +401,7 @@ async def test_batch_decisions_forwarded(self): "batch_decisions": {"call_1": "approve", "call_2": "reject"}, } result, sent = await self._resume(tool, confirmed=True, payload=payload) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data[KAGENT_HITL_DECISION_TYPE_KEY] == KAGENT_HITL_DECISION_TYPE_BATCH assert data["decisions"] == {"call_1": "approve", "call_2": "reject"} @@ -403,7 +413,7 @@ async def test_batch_with_rejection_reasons(self): "rejection_reasons": {"call_2": "Too dangerous"}, } _, sent = await self._resume(tool, confirmed=True, payload=payload) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data["rejection_reasons"] == {"call_2": "Too dangerous"} async def test_ask_user_answers_forwarded(self): @@ -411,7 +421,7 @@ async def test_ask_user_answers_forwarded(self): tool = _make_tool() payload = {**_RESUME_PAYLOAD, "answers": ["yes", "42"]} _, sent = await self._resume(tool, confirmed=True, payload=payload) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data[KAGENT_HITL_DECISION_TYPE_KEY] == KAGENT_HITL_DECISION_TYPE_APPROVE assert data["ask_user_answers"] == ["yes", "42"] diff --git a/python/packages/kagent-core/src/kagent/core/a2a/__init__.py b/python/packages/kagent-core/src/kagent/core/a2a/__init__.py index 3de48d6350..63c200a9fc 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/__init__.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/__init__.py @@ -1,5 +1,4 @@ from ._config import get_a2a_max_content_length -from ._context import get_request_user_id, set_request_user_id from ._consts import ( A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT, @@ -18,6 +17,7 @@ get_kagent_metadata_key, read_metadata_value, ) +from ._context import get_request_user_id, set_request_user_id from ._hitl_utils import ( DecisionType, HitlPartInfo, diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_consts.py b/python/packages/kagent-core/src/kagent/core/a2a/_consts.py index 46a8fb5c04..63d780c431 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_consts.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_consts.py @@ -13,6 +13,20 @@ ADK_METADATA_KEY_PREFIX = "adk_" +def _normalize_metadata(metadata): + """Normalize protobuf Struct metadata into a plain dict when needed.""" + if not metadata: + return {} + if isinstance(metadata, dict): + return metadata + # Protobuf Struct behaves like a message object; convert safely for key access. + if hasattr(metadata, "DESCRIPTOR"): + from google.protobuf.json_format import MessageToDict + + return MessageToDict(metadata) + return {} + + def get_kagent_metadata_key(key: str) -> str: """Gets the A2A event metadata key for the given key. @@ -50,14 +64,15 @@ def read_metadata_value(metadata: dict | None, key: str, default=None): """ if not key: raise ValueError("Metadata key cannot be empty or None") - if not metadata: + normalized = _normalize_metadata(metadata) + if not normalized: return default adk_key = f"{ADK_METADATA_KEY_PREFIX}{key}" - if adk_key in metadata: - return metadata[adk_key] + if adk_key in normalized: + return normalized[adk_key] kagent_key = f"{KAGENT_METADATA_KEY_PREFIX}{key}" - if kagent_key in metadata: - return metadata[kagent_key] + if kagent_key in normalized: + return normalized[kagent_key] return default diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_hitl_utils.py b/python/packages/kagent-core/src/kagent/core/a2a/_hitl_utils.py index 29ef107be5..4187874b73 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_hitl_utils.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_hitl_utils.py @@ -9,11 +9,8 @@ import logging from typing import Any, Literal -from a2a.types import ( - DataPart, - Message, - Task, -) +from a2a.types import Message, Task +from google.protobuf.json_format import MessageToDict from pydantic import BaseModel, ConfigDict, Field from ._consts import ( @@ -33,6 +30,32 @@ logger = logging.getLogger(__name__) +def _to_python(value: Any) -> Any: + """Convert protobuf messages/values into plain Python values.""" + if hasattr(value, "DESCRIPTOR"): + return MessageToDict(value) + return value + + +def _extract_data_payload(part: Any) -> dict[str, Any] | None: + """Extract a structured payload from v1 or transitional part shapes.""" + if hasattr(part, "HasField") and part.HasField("data"): + data = _to_python(part.data) + return data if isinstance(data, dict) else None + + return None + + +def _extract_metadata(part: Any) -> dict[str, Any]: + """Extract metadata from v1 or transitional part shapes.""" + if hasattr(part, "metadata"): + metadata = _to_python(part.metadata) + if isinstance(metadata, dict): + return metadata + + return {} + + class OriginalFunctionCall(BaseModel): """The original tool function call that requires human approval. @@ -152,16 +175,12 @@ def extract_decision_from_message(message: Message | None) -> DecisionType | Non return None for part in message.parts: - # Access .root for RootModel union types - if not hasattr(part, "root"): + data = _extract_data_payload(part) + if not isinstance(data, dict): continue - - inner = part.root - - if isinstance(inner, DataPart): - decision = extract_decision_from_data_part(inner.data) - if decision: - return decision + decision = extract_decision_from_data_part(data) + if decision: + return decision return None @@ -188,37 +207,33 @@ def extract_batch_decisions_from_message(message: Message | None) -> dict[str, D return None for part in message.parts: - if not hasattr(part, "root"): + data = _extract_data_payload(part) + if not isinstance(data, dict): + continue + if data.get(KAGENT_HITL_DECISION_TYPE_KEY) != KAGENT_HITL_DECISION_TYPE_BATCH: continue - inner = part.root - - if isinstance(inner, DataPart): - data = inner.data - if data.get(KAGENT_HITL_DECISION_TYPE_KEY) != KAGENT_HITL_DECISION_TYPE_BATCH: - continue - - decisions = data.get(KAGENT_HITL_DECISIONS_KEY) - if isinstance(decisions, dict): - # Filter out invalid decisions - filtered: dict[str, DecisionType] = {} - for call_id, decision in decisions.items(): - # Ensure key type and decision value are valid - if not isinstance(call_id, str): - logger.warning("Ignoring HITL batch decision with non-string key: %r", call_id) - continue - if decision in ( - KAGENT_HITL_DECISION_TYPE_APPROVE, - KAGENT_HITL_DECISION_TYPE_REJECT, - ): - filtered[call_id] = decision - else: - logger.warning( - "Ignoring HITL batch decision with invalid value %r for call_id %r", - decision, - call_id, - ) - return filtered or None + decisions = data.get(KAGENT_HITL_DECISIONS_KEY) + if isinstance(decisions, dict): + # Filter out invalid decisions + filtered: dict[str, DecisionType] = {} + for call_id, decision in decisions.items(): + # Ensure key type and decision value are valid + if not isinstance(call_id, str): + logger.warning("Ignoring HITL batch decision with non-string key: %r", call_id) + continue + if decision in ( + KAGENT_HITL_DECISION_TYPE_APPROVE, + KAGENT_HITL_DECISION_TYPE_REJECT, + ): + filtered[call_id] = decision + else: + logger.warning( + "Ignoring HITL batch decision with invalid value %r for call_id %r", + decision, + call_id, + ) + return filtered or None return None @@ -242,27 +257,23 @@ def extract_rejection_reasons_from_message(message: Message | None) -> dict[str, return None for part in message.parts: - if not hasattr(part, "root"): + data = _extract_data_payload(part) + if not isinstance(data, dict): continue - - inner = part.root - - if isinstance(inner, DataPart): - data = inner.data - decision = data.get(KAGENT_HITL_DECISION_TYPE_KEY) - - if decision == KAGENT_HITL_DECISION_TYPE_BATCH: - reasons = data.get(KAGENT_HITL_REJECTION_REASONS_KEY) - if isinstance(reasons, dict): - filtered: dict[str, str] = {} - for call_id, reason in reasons.items(): - if isinstance(call_id, str) and isinstance(reason, str) and reason: - filtered[call_id] = reason - return filtered or None - elif decision == KAGENT_HITL_DECISION_TYPE_REJECT: - reason = data.get("rejection_reason") - if isinstance(reason, str) and reason: - return {"*": reason} + decision = data.get(KAGENT_HITL_DECISION_TYPE_KEY) + + if decision == KAGENT_HITL_DECISION_TYPE_BATCH: + reasons = data.get(KAGENT_HITL_REJECTION_REASONS_KEY) + if isinstance(reasons, dict): + filtered: dict[str, str] = {} + for call_id, reason in reasons.items(): + if isinstance(call_id, str) and isinstance(reason, str) and reason: + filtered[call_id] = reason + return filtered or None + elif decision == KAGENT_HITL_DECISION_TYPE_REJECT: + reason = data.get("rejection_reason") + if isinstance(reason, str) and reason: + return {"*": reason} return None @@ -283,16 +294,12 @@ def extract_ask_user_answers_from_message(message: Message | None) -> list[dict] return None for part in message.parts: - if not hasattr(part, "root"): + data = _extract_data_payload(part) + if not isinstance(data, dict): continue - - inner = part.root - - if isinstance(inner, DataPart): - data = inner.data - answers = data.get(KAGENT_ASK_USER_ANSWERS_KEY) - if isinstance(answers, list): - return answers + answers = data.get(KAGENT_ASK_USER_ANSWERS_KEY) + if isinstance(answers, list): + return answers return None @@ -317,12 +324,13 @@ def extract_hitl_info_from_task(task: Task) -> list[HitlPartInfo] | None: hitl_parts: list[HitlPartInfo] = [] for part in task.status.message.parts: - root = part.root if hasattr(part, "root") else part - if not isinstance(root, DataPart) or not root.metadata: + metadata = _extract_metadata(part) + data = _extract_data_payload(part) + if not metadata or not isinstance(data, dict): continue - part_type = read_metadata_value(root.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) - is_long_running = read_metadata_value(root.metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) + part_type = read_metadata_value(metadata, A2A_DATA_PART_METADATA_TYPE_KEY) + is_long_running = read_metadata_value(metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) if part_type == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL and is_long_running is True: - hitl_parts.append(HitlPartInfo.from_data_part_data(root.data)) + hitl_parts.append(HitlPartInfo.from_data_part_data(data)) return hitl_parts or None diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_requests.py b/python/packages/kagent-core/src/kagent/core/a2a/_requests.py index 13b36ffa9a..ca229c615b 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_requests.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_requests.py @@ -4,7 +4,7 @@ from a2a.server.agent_execution import RequestContext, SimpleRequestContextBuilder from a2a.server.context import ServerCallContext from a2a.server.tasks import TaskStore -from a2a.types import MessageSendParams, Task +from a2a.types import SendMessageRequest, Task from ._context import set_request_user_id @@ -37,11 +37,11 @@ def __init__(self, task_store: TaskStore): async def build( self, - params: MessageSendParams | None = None, + context: ServerCallContext, + params: SendMessageRequest | None = None, task_id: str | None = None, context_id: str | None = None, task: Task | None = None, - context: ServerCallContext | None = None, ) -> RequestContext: if context: headers = context.state.get("headers", {}) @@ -55,5 +55,11 @@ async def build( source = headers.get("x-kagent-source", None) if source: context.state["kagent_source"] = source - request_context = await super().build(params, task_id, context_id, task, context) + request_context = await super().build( + context=context, + params=params, + task_id=task_id, + context_id=context_id, + task=task, + ) return request_context diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_task_result_aggregator.py b/python/packages/kagent-core/src/kagent/core/a2a/_task_result_aggregator.py index 403be62fd4..b1aa209a33 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_task_result_aggregator.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_task_result_aggregator.py @@ -6,7 +6,7 @@ class TaskResultAggregator: """Aggregates the task status updates and provides the final task state.""" def __init__(self): - self._task_state = TaskState.working + self._task_state = TaskState.TASK_STATE_WORKING self._task_status_message = None def process_event(self, event: Event): @@ -18,24 +18,27 @@ def process_event(self, event: Event): - working """ if isinstance(event, TaskStatusUpdateEvent): - if event.status.state == TaskState.failed: - self._task_state = TaskState.failed + if event.status.state == TaskState.TASK_STATE_FAILED: + self._task_state = TaskState.TASK_STATE_FAILED self._task_status_message = event.status.message - elif event.status.state == TaskState.auth_required and self._task_state != TaskState.failed: - self._task_state = TaskState.auth_required + elif ( + event.status.state == TaskState.TASK_STATE_AUTH_REQUIRED + and self._task_state != TaskState.TASK_STATE_FAILED + ): + self._task_state = TaskState.TASK_STATE_AUTH_REQUIRED self._task_status_message = event.status.message - elif event.status.state == TaskState.input_required and self._task_state not in ( - TaskState.failed, - TaskState.auth_required, + elif event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED and self._task_state not in ( + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_AUTH_REQUIRED, ): - self._task_state = TaskState.input_required + self._task_state = TaskState.TASK_STATE_INPUT_REQUIRED self._task_status_message = event.status.message # final state is already recorded and make sure the intermediate state is # always working because other state may terminate the event aggregation # in a2a request handler - elif self._task_state == TaskState.working: + elif self._task_state == TaskState.TASK_STATE_WORKING: self._task_status_message = event.status.message - event.status.state = TaskState.working + event.status.state = TaskState.TASK_STATE_WORKING @property def task_state(self) -> TaskState: diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py b/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py index 134fa25e9a..45f87ca3b3 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py @@ -3,25 +3,12 @@ import httpx from a2a.server.tasks import TaskStore from a2a.types import Message, Task -from pydantic import BaseModel +from google.protobuf.json_format import MessageToDict, ParseDict from typing_extensions import override from kagent.core.a2a import read_metadata_value -class KAgentTaskResponse(BaseModel): - """Wrapper for KAgent controller API responses. - - The KAgent Go controller wraps all task responses in a StandardResponse envelope - with the format: {"error": bool, "data": T, "message": str}. - This model unwraps that envelope to extract the actual Task object. - """ - - error: bool - data: Task | None = None - message: str | None = None - - class KAgentTaskStore(TaskStore): """ A task store that persists A2A tasks to KAgent via REST API. @@ -62,10 +49,16 @@ async def save(self, task: Task, context=None) -> None: httpx.HTTPStatusError: If the API request fails """ # Clean any partial events from history before saving - history = task.history or [] - task.history = self._clean_partial_events(history) - - response = await self.client.post("/api/tasks", json=task.model_dump(mode="json")) + history = list(task.history or []) + clean_history = self._clean_partial_events(history) + if len(clean_history) != len(history): + del task.history[:] + task.history.extend(clean_history) + + response = await self.client.post( + "/api/tasks", + json=MessageToDict(task, preserving_proto_field_name=True), + ) response.raise_for_status() # Signal that save completed (event-based sync) @@ -92,8 +85,11 @@ async def get(self, task_id: str, context=None) -> Task | None: response.raise_for_status() # Unwrap the StandardResponse envelope from the Go controller - wrapped = KAgentTaskResponse.model_validate(response.json()) - return wrapped.data + wrapped = response.json() + data = wrapped.get("data") if isinstance(wrapped, dict) else None + if not isinstance(data, dict): + return None + return ParseDict(data, Task()) @override async def delete(self, task_id: str, context=None) -> None: diff --git a/python/packages/kagent-core/tests/test_hitl_utils.py b/python/packages/kagent-core/tests/test_hitl_utils.py index aa980d883a..7c66d515c9 100644 --- a/python/packages/kagent-core/tests/test_hitl_utils.py +++ b/python/packages/kagent-core/tests/test_hitl_utils.py @@ -1,6 +1,8 @@ """Tests for HITL utility functions in kagent.core.a2a._hitl_utils.""" -from a2a.types import DataPart, Message, Part, Role, Task, TaskState, TaskStatus +from a2a.types import Message, Part, Role, Task, TaskState, TaskStatus +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( KAGENT_HITL_DECISION_TYPE_APPROVE, @@ -25,11 +27,11 @@ def _make_message(*data_parts: dict) -> Message: """Build a Message with one or more DataPart dicts.""" return Message( - role=Role.user, + role=Role.ROLE_USER, message_id="test", task_id="task1", context_id="ctx1", - parts=[Part(DataPart(data=d)) for d in data_parts], + parts=[Part(data=ParseDict(d, Value())) for d in data_parts], ) @@ -39,22 +41,20 @@ def _make_hitl_task(*hitl_data_dicts: dict) -> Task: for d in hitl_data_dicts: parts.append( Part( - DataPart( - data=d, - metadata={ - "adk_type": "function_call", - "adk_is_long_running": True, - }, - ) + data=ParseDict(d, Value()), + metadata={ + "adk_type": "function_call", + "adk_is_long_running": True, + }, ) ) return Task( id="task-1", context_id="ctx-1", status=TaskStatus( - state=TaskState.input_required, + state=TaskState.TASK_STATE_INPUT_REQUIRED, message=Message( - role=Role.agent, + role=Role.ROLE_AGENT, message_id="msg-1", parts=parts, ), @@ -93,7 +93,7 @@ def test_none_message(self): assert extract_decision_from_message(None) is None def test_empty_parts(self): - msg = Message(role=Role.user, message_id="x", task_id="t", context_id="c", parts=[]) + msg = Message(role=Role.ROLE_USER, message_id="x", task_id="t", context_id="c", parts=[]) assert extract_decision_from_message(msg) is None def test_no_decision_key(self): @@ -465,7 +465,7 @@ def test_multiple_hitl_parts(self): assert result[1].tool_name == "write_file" def test_no_message(self): - task = Task(id="t", context_id="c", status=TaskStatus(state=TaskState.completed)) + task = Task(id="t", context_id="c", status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED)) assert extract_hitl_info_from_task(task) is None def test_no_parts(self): @@ -473,8 +473,8 @@ def test_no_parts(self): id="t", context_id="c", status=TaskStatus( - state=TaskState.input_required, - message=Message(role=Role.agent, message_id="m", parts=[]), + state=TaskState.TASK_STATE_INPUT_REQUIRED, + message=Message(role=Role.ROLE_AGENT, message_id="m", parts=[]), ), ) assert extract_hitl_info_from_task(task) is None @@ -485,16 +485,14 @@ def test_non_hitl_data_parts_skipped(self): id="t", context_id="c", status=TaskStatus( - state=TaskState.input_required, + state=TaskState.TASK_STATE_INPUT_REQUIRED, message=Message( - role=Role.agent, + role=Role.ROLE_AGENT, message_id="m", parts=[ Part( - DataPart( - data={"name": "some_function", "args": {}}, - metadata={"adk_type": "function_call"}, - ) + data=ParseDict({"name": "some_function", "args": {}}, Value()), + metadata={"adk_type": "function_call"}, ), ], ), @@ -508,25 +506,26 @@ def test_kagent_prefix_metadata(self): id="t", context_id="c", status=TaskStatus( - state=TaskState.input_required, + state=TaskState.TASK_STATE_INPUT_REQUIRED, message=Message( - role=Role.agent, + role=Role.ROLE_AGENT, message_id="m", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { "name": "adk_request_confirmation", "id": "conf_1", "args": { "originalFunctionCall": {"name": "delete_file", "args": {}, "id": "c1"}, }, }, - metadata={ - "kagent_type": "function_call", - "kagent_is_long_running": True, - }, - ) + Value(), + ), + metadata={ + "kagent_type": "function_call", + "kagent_is_long_running": True, + }, ), ], ), diff --git a/python/uv.lock b/python/uv.lock index e5a9e8be0d..9f6cf062b7 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -48,23 +48,26 @@ dev = [ [[package]] name = "a2a-sdk" -version = "0.3.23" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, { name = "google-api-core" }, + { name = "googleapis-common-protos" }, { name = "httpx" }, { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, { name = "protobuf" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/2fe24e0a85240a651006c12f79bdb37156adc760a96c44bc002ebda77916/a2a_sdk-0.3.23.tar.gz", hash = "sha256:7c46b8572c4633a2b41fced2833e11e62871e8539a5b3c782ba2ba1e33d213c2", size = 255265, upload-time = "2026-02-17T08:34:34.648Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/35/8b7ac94f405f57c591925fa0afc105a0f797151876fffa666b57722eefa9/a2a_sdk-1.0.3.tar.gz", hash = "sha256:c57ddd910aece4a426ae26b8f0d0e8e2f3271a6adde974078075e4f600aaf628", size = 367155, upload-time = "2026-05-13T06:52:33.929Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/20/77d119f19ab03449d3e6bc0b1f11296d593dae99775c1d891ab1e290e416/a2a_sdk-0.3.23-py3-none-any.whl", hash = "sha256:8c2f01dffbfdd3509eafc15c4684743e6ae75e69a5df5d6f87be214c948e7530", size = 145689, upload-time = "2026-02-17T08:34:33.263Z" }, + { url = "https://files.pythonhosted.org/packages/53/6f/ae79f8210f1ecd70e1c37c310a523b26f1d6da458d4c1365914bf1ea58e0/a2a_sdk-1.0.3-py3-none-any.whl", hash = "sha256:068e5b2ceb4e962ac61d9e1fd43ca0c1016b64f0c80d901f6e23420bc8a31a93", size = 235705, upload-time = "2026-05-13T06:52:31.88Z" }, ] [package.optional-dependencies] http-server = [ - { name = "fastapi" }, { name = "sse-starlette" }, { name = "starlette" }, ] @@ -247,6 +250,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, ] +[[package]] +name = "aiologic" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -919,6 +936,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + [[package]] name = "currency" version = "0.1.0" @@ -2217,6 +2247,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/43/ac6691c7b5aa7191c964a04ae926d2bb06d9297dba1f2287df5b85cb3715/json_repair-0.25.2-py3-none-any.whl", hash = "sha256:51d67295c3184b6c41a3572689661c6128cef6cfc9fb04db63130709adfc5bf0", size = 12740, upload-time = "2024-06-27T16:26:13.823Z" }, ] +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, +] + [[package]] name = "json5" version = "0.12.1" @@ -2330,7 +2369,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.23" }, + { name = "a2a-sdk", specifier = ">=1.0.0" }, { name = "agentsts-adk", editable = "packages/agentsts-adk" }, { name = "agentsts-core", editable = "packages/agentsts-core" }, { name = "aiofiles", specifier = ">=24.1.0" }, From 837808680ebf3595878879d50cd27e072fd2705c Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Tue, 26 May 2026 12:53:58 -0400 Subject: [PATCH 4/8] go/core changes from 0.10 to 0.11 Signed-off-by: Jet Chiang --- go/core/internal/a2a/a2a_registrar.go | 22 +------------------ .../translator/agent/manifest_builder.go | 13 ++--------- .../outputs/agent_with_a2a_config.json | 8 +++---- .../outputs/agent_with_allowed_headers.json | 8 +++---- .../testdata/outputs/agent_with_code.json | 8 +++---- .../outputs/agent_with_context_config.json | 8 +++---- .../agent_with_cross_namespace_tools.json | 8 +++---- .../outputs/agent_with_custom_sa.json | 8 +++---- .../outputs/agent_with_default_sa.json | 8 +++---- .../agent_with_embedding_provider.json | 8 +++---- .../outputs/agent_with_extra_containers.json | 8 +++---- .../outputs/agent_with_git_skills.json | 8 +++---- .../outputs/agent_with_http_toolserver.json | 8 +++---- .../outputs/agent_with_mcp_service.json | 8 +++---- .../testdata/outputs/agent_with_memory.json | 8 +++---- .../outputs/agent_with_nested_agent.json | 8 +++---- .../outputs/agent_with_passthrough.json | 8 +++---- .../outputs/agent_with_prompt_template.json | 8 +++---- .../testdata/outputs/agent_with_proxy.json | 8 +++---- .../agent_with_proxy_external_remotemcp.json | 8 +++---- .../outputs/agent_with_proxy_mcpserver.json | 8 +++---- ...t_with_proxy_mcpserver_custom_timeout.json | 8 +++---- .../outputs/agent_with_proxy_service.json | 8 +++---- .../outputs/agent_with_require_approval.json | 8 +++---- .../agent_with_scheduling_attributes.json | 8 +++---- .../outputs/agent_with_security_context.json | 8 +++---- .../testdata/outputs/agent_with_skills.json | 8 +++---- .../outputs/agent_with_streaming.json | 8 +++---- ...nt_with_system_message_from_configmap.json | 8 +++---- ...agent_with_system_message_from_secret.json | 8 +++---- .../testdata/outputs/anthropic_agent.json | 8 +++---- .../agent/testdata/outputs/basic_agent.json | 8 +++---- .../agent/testdata/outputs/bedrock_agent.json | 8 +++---- .../agent/testdata/outputs/ollama_agent.json | 8 +++---- .../testdata/outputs/tls-with-custom-ca.json | 8 +++---- .../outputs/tls-with-disabled-verify.json | 8 +++---- .../outputs/tls-with-system-cas-disabled.json | 8 +++---- .../controller/translator/agent/utils.go | 4 ++-- .../controller/translator/agent/utils_test.go | 4 ++-- go/core/internal/database/client_postgres.go | 19 +++++----------- 40 files changed, 153 insertions(+), 189 deletions(-) diff --git a/go/core/internal/a2a/a2a_registrar.go b/go/core/internal/a2a/a2a_registrar.go index 0355e8477c..0af9ff4f81 100644 --- a/go/core/internal/a2a/a2a_registrar.go +++ b/go/core/internal/a2a/a2a_registrar.go @@ -166,9 +166,7 @@ func (a *A2ARegistrar) upsertAgentHandler(ctx context.Context, agent v1alpha2.Ag httpClient := debugHTTPClient() client, err := a2aclient.NewFromEndpoints( ctx, - // TODO(0.11.0): Prefer A2A 1.0 interfaces by default once managed runtimes are v1-capable. - // Keep legacy fallback during rollout so old agent pods continue to serve traffic. - filterInterfacesByVersion(card.SupportedInterfaces, a2atype.ProtocolVersion("0.3")), + card.SupportedInterfaces, a2aclient.WithJSONRPCTransport(httpClient), // TODO(cleanup): Remove the compat transport after legacy runtimes are unsupported. a2aclient.WithCompatTransport( @@ -261,21 +259,3 @@ func cloneInterfacesWithURL(interfaces []*a2atype.AgentInterface, url string) [] } return result } - -// filterInterfacesByVersion filters the interfaces to only include the ones that match the given version. -// Currently, this is used to select the A2A 0.3 interface for managed agents. -func filterInterfacesByVersion(interfaces []*a2atype.AgentInterface, version a2atype.ProtocolVersion) []*a2atype.AgentInterface { - filtered := make([]*a2atype.AgentInterface, 0, len(interfaces)) - for _, i := range interfaces { - if i == nil { - continue - } - if i.ProtocolVersion == version { - filtered = append(filtered, i) - } - } - if len(filtered) > 0 { - return filtered - } - return interfaces -} diff --git a/go/core/internal/controller/translator/agent/manifest_builder.go b/go/core/internal/controller/translator/agent/manifest_builder.go index 466edfe104..19afc7f5c4 100644 --- a/go/core/internal/controller/translator/agent/manifest_builder.go +++ b/go/core/internal/controller/translator/agent/manifest_builder.go @@ -7,8 +7,6 @@ import ( "maps" a2a "github.com/a2aproject/a2a-go/v2/a2a" - "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" - "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/controller/translator/labels" @@ -59,7 +57,7 @@ func (a *adkApiTranslator) BuildManifest( outputs := &AgentOutputs{} manifestCtx := newManifestContext(agent, inputs.Deployment) - configSecret, err := a.buildConfigSecret(ctx, manifestCtx, inputs.Config, inputs.Sandbox, inputs.AgentCard, inputs.SecretHashBytes) + configSecret, err := a.buildConfigSecret(manifestCtx, inputs.Config, inputs.Sandbox, inputs.AgentCard, inputs.SecretHashBytes) if err != nil { return nil, err } @@ -127,7 +125,6 @@ func (m manifestContext) objectMeta() metav1.ObjectMeta { } func (a *adkApiTranslator) buildConfigSecret( - ctx context.Context, manifestCtx manifestContext, cfg *adk.AgentConfig, sandboxCfg *v1alpha2.SandboxConfig, @@ -149,13 +146,7 @@ func (a *adkApiTranslator) buildConfigSecret( cfgJSON = string(bCfg) } if card != nil { - // TODO(0.11.0): use the v1 agent card producer once managed runtimes no longer need legacy top-level fields. - producer := a2av0.NewStaticAgentCardProducer(card) - jsonProducer, ok := producer.(a2asrv.AgentCardJSONProducer) - if !ok { - return nil, fmt.Errorf("compat agent card producer does not support JSON serialization") - } - cardJSON, err := jsonProducer.CardJSON(ctx) + cardJSON, err := json.Marshal(card) if err != nil { return nil, err } diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_a2a_config.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_a2a_config.json index e2cb0e84b8..ab62c2c46a 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_a2a_config.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_a2a_config.json @@ -21,12 +21,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://a2a-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://a2a-agent.test:8080" } ], @@ -68,7 +68,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"a2a_agent\",\n \"version\": \"\",\n \"skills\": [\n {\n \"description\": \"Summarizes text\",\n \"id\": \"summarize\",\n \"name\": \"Summarize\",\n \"tags\": null\n }\n ],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://a2a-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://a2a-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://a2a-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://a2a-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://a2a-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"a2a_agent\",\"skills\":[{\"description\":\"Summarizes text\",\"id\":\"summarize\",\"name\":\"Summarize\",\"tags\":null}],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -138,7 +138,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "11777198347800362314" + "kagent.dev/config-hash": "14615753446055302326" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_allowed_headers.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_allowed_headers.json index acd88ecdc5..16f9373e03 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_allowed_headers.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_allowed_headers.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent.test:8080" } ], @@ -78,7 +78,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"http_tools\":[{\"params\":{\"url\":\"http://mcp-server.test:8080/mcp\",\"headers\":{}},\"tools\":[\"tool1\",\"tool2\"],\"allowed_headers\":[\"x-user-email\",\"x-tenant-id\"]}],\"stream\":false}" } }, @@ -148,7 +148,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "7055658815424891365" + "kagent.dev/config-hash": "16269820581163860186" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_code.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_code.json index c959a47d6f..df1cd717f8 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_code.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_code.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-code.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-code.test:8080" } ], @@ -70,7 +70,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_code\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-code.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-code.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-code.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-code.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-code.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_code\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"execute_code\":true,\"stream\":false}", "srt-settings.json": "{\"filesystem\":{\"allowWrite\":[\".\",\"/tmp\"],\"denyRead\":[],\"denyWrite\":[]},\"network\":{\"allowedDomains\":[],\"deniedDomains\":[]}}" } @@ -141,7 +141,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "13638639139652067632" + "kagent.dev/config-hash": "13760545813001232268" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_context_config.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_context_config.json index 16b91cefdf..e16da08dc6 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_context_config.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_context_config.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-context.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-context.test:8080" } ], @@ -80,7 +80,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"Agent with context management\",\n \"name\": \"agent_with_context\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-context.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-context.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-context.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-context.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-context.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"Agent with context management\",\"name\":\"agent_with_context\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"Agent with context management\",\"instruction\":\"You are a helpful assistant with context management enabled.\",\"stream\":false,\"context_config\":{\"compaction\":{\"compaction_interval\":5,\"overlap_size\":2,\"summarizer_model\":{\"type\":\"anthropic\",\"model\":\"claude-3-haiku\"},\"prompt_template\":\"Summarize the following conversation events concisely.\",\"token_threshold\":50000,\"event_retention_size\":10}}}" } }, @@ -150,7 +150,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "7947347043949539668" + "kagent.dev/config-hash": "499782941718855709" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_cross_namespace_tools.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_cross_namespace_tools.json index 283963cd96..4fd63133b4 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_cross_namespace_tools.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_cross_namespace_tools.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://source-agent.source-ns:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://source-agent.source-ns:8080" } ], @@ -84,7 +84,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"An agent that uses cross-namespace tools\",\n \"name\": \"source_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://source-agent.source-ns:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://source-agent.source-ns:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://source-agent.source-ns:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://source-agent.source-ns:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://source-agent.source-ns:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"An agent that uses cross-namespace tools\",\"name\":\"source_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"An agent that uses cross-namespace tools\",\"instruction\":\"You are an assistant with access to shared tools.\",\"http_tools\":[{\"params\":{\"url\":\"http://tools.tools-ns.svc:8080/mcp\",\"headers\":{\"Authorization\":\"tool-secret-token\"},\"timeout\":30},\"tools\":[\"list_resources\",\"get_resource\"]}],\"remote_agents\":[{\"name\":\"tools_ns__NS__tools_agent\",\"url\":\"http://tools-agent.tools-ns:8080\",\"description\":\"An agent that can be used as a cross-namespace tool\"}],\"stream\":false}" } }, @@ -154,7 +154,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "6928733945253389376" + "kagent.dev/config-hash": "15797809111184542787" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_custom_sa.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_custom_sa.json index 26de14963f..2bd119ad18 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_custom_sa.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_custom_sa.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-custom-sa.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-custom-sa.test:8080" } ], @@ -62,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_custom_sa\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-custom-sa.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-custom-sa.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-custom-sa.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-custom-sa.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-custom-sa.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_custom_sa\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -107,7 +107,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "4875306436828307681" + "kagent.dev/config-hash": "10485794827683908468" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_default_sa.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_default_sa.json index 1f8ba2ae2e..98512b82a3 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_default_sa.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_default_sa.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-default-sa.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-default-sa.test:8080" } ], @@ -62,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_default_sa\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-default-sa.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-default-sa.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-default-sa.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-default-sa.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-default-sa.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_default_sa\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -107,7 +107,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1952368464262477618" + "kagent.dev/config-hash": "5199784065421269511" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_embedding_provider.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_embedding_provider.json index d018d872cf..fa7e5ed271 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_embedding_provider.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_embedding_provider.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-cross-provider-memory.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-cross-provider-memory.test:8080" } ], @@ -68,7 +68,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_cross_provider_memory\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-cross-provider-memory.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-cross-provider-memory.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-cross-provider-memory.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-cross-provider-memory.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-cross-provider-memory.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_cross_provider_memory\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an assistant.\",\"stream\":false,\"memory\":{\"embedding\":{\"provider\":\"gemini_vertex_ai\",\"model\":\"text-embedding-005\"}}}" } }, @@ -138,7 +138,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "6685832133934502607" + "kagent.dev/config-hash": "15901675277527457660" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_extra_containers.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_extra_containers.json index 67a8e8a2f4..d9744ddbe5 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_extra_containers.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_extra_containers.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-extra-containers.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-extra-containers.test:8080" } ], @@ -62,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_extra_containers\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-extra-containers.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-extra-containers.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-extra-containers.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-extra-containers.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-extra-containers.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_extra_containers\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -132,7 +132,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "5942407443192129450" + "kagent.dev/config-hash": "6471144613458889352" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json index aa4b77207a..d60a6eefdf 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://git-skills-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://git-skills-agent.test:8080" } ], @@ -69,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"git_skills_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://git-skills-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://git-skills-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://git-skills-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://git-skills-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://git-skills-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"git_skills_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant with skills from git.\",\"stream\":false}", "srt-settings.json": "{\"filesystem\":{\"allowWrite\":[\".\",\"/tmp\"],\"denyRead\":[],\"denyWrite\":[]},\"network\":{\"allowedDomains\":[],\"deniedDomains\":[]}}" } @@ -140,7 +140,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "10177226668840890883" + "kagent.dev/config-hash": "14672456712258872975" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json index bd5f97ca4a..a0dd115d21 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent.test:8080" } ], @@ -77,7 +77,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a math toolserver. Focus on solving mathematical problems step by step.\",\"http_tools\":[{\"params\":{\"url\":\"http://localhost:8084/mcp\",\"headers\":{\"MATH\":\"sk-test-api-key\"},\"timeout\":30,\"sse_read_timeout\":300},\"tools\":[\"k8s_get_resources\"]}],\"stream\":false}" } }, @@ -147,7 +147,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "8643793714945604953" + "kagent.dev/config-hash": "18136359414159044696" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json index 440b385bac..3989c705a2 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent.test:8080" } ], @@ -73,7 +73,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a math toolserver. Focus on solving mathematical problems step by step.\",\"http_tools\":[{\"params\":{\"url\":\"http://toolserver.test:8084/mcp\",\"headers\":{}},\"tools\":[\"k8s_get_resources\"]}],\"stream\":false}" } }, @@ -143,7 +143,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "90499429102488578" + "kagent.dev/config-hash": "16462134276610745687" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_memory.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_memory.json index 940037c668..5aa1832bd5 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_memory.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_memory.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-memory.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-memory.test:8080" } ], @@ -73,7 +73,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_memory\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-memory.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-memory.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-memory.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-memory.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-memory.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_memory\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant with memory. Save important findings and use past context when relevant.\",\"stream\":false,\"memory\":{\"embedding\":{\"provider\":\"openai\",\"model\":\"text-embedding-3-small\"}}}" } }, @@ -143,7 +143,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "4675326514170066600" + "kagent.dev/config-hash": "13924285912897494970" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json index 4183fe912c..9eaa756965 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://parent-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://parent-agent.test:8080" } ], @@ -71,7 +71,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"parent_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://parent-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://parent-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://parent-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://parent-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://parent-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"parent_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a coordinating agent that can delegate tasks to specialists.\",\"remote_agents\":[{\"name\":\"test__NS__specialist_agent\",\"url\":\"http://specialist-agent.test:8080\",\"headers\":{\"FOO\":\"sup3rs3cr3t\"}}],\"stream\":false}" } }, @@ -141,7 +141,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "12309204394800027371" + "kagent.dev/config-hash": "9621822481042047309" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_passthrough.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_passthrough.json index 2cf6122979..3272f322ce 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_passthrough.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_passthrough.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://passthrough-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://passthrough-agent.test:8080" } ], @@ -64,7 +64,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"passthrough_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://passthrough-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://passthrough-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://passthrough-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://passthrough-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://passthrough-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"passthrough_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"api_key_passthrough\":true,\"base_url\":\"\",\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -134,7 +134,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "15376076214432010811" + "kagent.dev/config-hash": "9280821291572766210" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_prompt_template.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_prompt_template.json index 8a7f398b70..abdeead23b 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_prompt_template.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_prompt_template.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-prompt-template.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-prompt-template.test:8080" } ], @@ -75,7 +75,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"A Kubernetes troubleshooting agent\",\n \"name\": \"agent_with_prompt_template\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-prompt-template.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-prompt-template.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-prompt-template.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-prompt-template.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-prompt-template.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"A Kubernetes troubleshooting agent\",\"name\":\"agent_with_prompt_template\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"A Kubernetes troubleshooting agent\",\"instruction\":\"## Preamble\\nYou are a helpful Kubernetes assistant.\\n\\n\\nYou are agent-with-prompt-template, operating in test.\\nYour purpose: A Kubernetes troubleshooting agent\\n\\nAvailable tools: k8s_get_resources, k8s_describe_resource, \\n\\n## Safety Guidelines\\nNever delete resources without explicit user confirmation.\\n\\n\",\"http_tools\":[{\"params\":{\"url\":\"http://localhost:8084/mcp\",\"headers\":{},\"timeout\":30},\"tools\":[\"k8s_get_resources\",\"k8s_describe_resource\"]}],\"stream\":false}" } }, @@ -145,7 +145,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "2608374849770441844" + "kagent.dev/config-hash": "567473640196735869" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json index 666a1fb10b..c6a739d193 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-proxy.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-proxy.test:8080" } ], @@ -84,7 +84,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_proxy\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-proxy.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-proxy.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-proxy.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"x-kagent-host\":\"nested-agent.test\"}}],\"stream\":false}" } }, @@ -154,7 +154,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "6086147857331851037" + "kagent.dev/config-hash": "11893707133067697787" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json index 1dab97f4c6..8cb667a74d 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-proxy-external.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-proxy-external.test:8080" } ], @@ -73,7 +73,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_proxy_external\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-proxy-external.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-proxy-external.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-proxy-external.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy-external.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy-external.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy_external\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"https://external-mcp.example.com/mcp\",\"headers\":{}},\"tools\":[\"test-tool\"]}],\"stream\":false}" } }, @@ -143,7 +143,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "16116210188225830835" + "kagent.dev/config-hash": "9390285492022682518" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json index ce1409ab7c..c356088b35 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-proxy-mcpserver.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-proxy-mcpserver.test:8080" } ], @@ -76,7 +76,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_proxy_mcpserver\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-proxy-mcpserver.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-proxy-mcpserver.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-proxy-mcpserver.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy_mcpserver\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"test-mcp-server.test\"},\"timeout\":30},\"tools\":[\"test-tool\"]}],\"stream\":false}" } }, @@ -146,7 +146,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "699819535100768323" + "kagent.dev/config-hash": "18073633349913596204" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver_custom_timeout.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver_custom_timeout.json index 66199196a8..b740d4644c 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver_custom_timeout.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver_custom_timeout.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-proxy-mcpserver-timeout.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-proxy-mcpserver-timeout.test:8080" } ], @@ -76,7 +76,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_proxy_mcpserver_timeout\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-proxy-mcpserver-timeout.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-proxy-mcpserver-timeout.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-proxy-mcpserver-timeout.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy-mcpserver-timeout.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy-mcpserver-timeout.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy_mcpserver_timeout\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"test-mcp-server.test\"},\"timeout\":60},\"tools\":[\"test-tool\"]}],\"stream\":false}" } }, @@ -146,7 +146,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "5660639111005887260" + "kagent.dev/config-hash": "16803121309212337797" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json index 4a03d166d1..3066e8dc50 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-proxy-service.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-proxy-service.test:8080" } ], @@ -75,7 +75,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_proxy_service\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-proxy-service.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-proxy-service.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-proxy-service.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy-service.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy-service.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy_service\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"stream\":false}" } }, @@ -145,7 +145,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "2741762687963053248" + "kagent.dev/config-hash": "3987687246013681331" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_require_approval.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_require_approval.json index e592026a1e..da0d5d9047 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_require_approval.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_require_approval.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent.test:8080" } ], @@ -79,7 +79,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You help users manage files.\",\"http_tools\":[{\"params\":{\"url\":\"http://toolserver.test:8084/mcp\",\"headers\":{}},\"tools\":[\"read_file\",\"write_file\",\"delete_file\"],\"require_approval\":[\"delete_file\",\"write_file\"]}],\"stream\":false}" } }, @@ -149,7 +149,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "8217777934714265853" + "kagent.dev/config-hash": "7171063378866703186" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_scheduling_attributes.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_scheduling_attributes.json index fd9524ce33..5638102bf2 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_scheduling_attributes.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_scheduling_attributes.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-scheduling-attributes.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-scheduling-attributes.test:8080" } ], @@ -69,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_scheduling_attributes\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-scheduling-attributes.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-scheduling-attributes.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-scheduling-attributes.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-scheduling-attributes.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-scheduling-attributes.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_scheduling_attributes\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -139,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1301028021327278116" + "kagent.dev/config-hash": "13449223961033623786" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_security_context.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_security_context.json index 028a009568..bbc3654ee1 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_security_context.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_security_context.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-security-context.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-security-context.test:8080" } ], @@ -69,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_security_context\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-security-context.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-security-context.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-security-context.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-security-context.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-security-context.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_security_context\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -139,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "3654896060561973296" + "kagent.dev/config-hash": "14133779909752802977" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json index 91f1fe968d..20f4ed4a18 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://skills-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://skills-agent.test:8080" } ], @@ -69,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"skills_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://skills-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://skills-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://skills-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://skills-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://skills-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"skills_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}", "srt-settings.json": "{\"filesystem\":{\"allowWrite\":[\".\",\"/tmp\"],\"denyRead\":[],\"denyWrite\":[]},\"network\":{\"allowedDomains\":[],\"deniedDomains\":[]}}" } @@ -140,7 +140,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1709213079642646235" + "kagent.dev/config-hash": "7512006228141610661" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_streaming.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_streaming.json index ccc3ddc1c0..5acc570174 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_streaming.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_streaming.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://basic-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://basic-agent.test:8080" } ], @@ -69,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"basic_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://basic-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://basic-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://basic-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://basic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://basic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"basic_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":true}" } }, @@ -139,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "7925219030441964607" + "kagent.dev/config-hash": "14170329863092758119" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json index a8ad8d0127..40b52f1e01 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-configmap-system-message.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-configmap-system-message.test:8080" } ], @@ -62,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_configmap_system_message\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-configmap-system-message.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-configmap-system-message.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-configmap-system-message.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-configmap-system-message.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-configmap-system-message.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_configmap_system_message\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"Speak in the style of Shakespeare.\",\"stream\":false}" } }, @@ -132,7 +132,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "14676988129172891564" + "kagent.dev/config-hash": "4606283336682815811" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json index afb1453bec..3d37d08997 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://agent-with-secret-system-message.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://agent-with-secret-system-message.test:8080" } ], @@ -62,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"agent_with_secret_system_message\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://agent-with-secret-system-message.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://agent-with-secret-system-message.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://agent-with-secret-system-message.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-secret-system-message.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-secret-system-message.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_secret_system_message\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You will speak in the style of Shakespeare.\\n\",\"stream\":false}" } }, @@ -132,7 +132,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "13184379951838451524" + "kagent.dev/config-hash": "14985943387941658931" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json index abeecdbb99..f5c2f09d87 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://anthropic-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://anthropic-agent.test:8080" } ], @@ -61,7 +61,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"anthropic_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://anthropic-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://anthropic-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://anthropic-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://anthropic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://anthropic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"anthropic_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"anthropic\",\"model\":\"claude-3-sonnet-20240229\"},\"description\":\"\",\"instruction\":\"You are Claude, an AI assistant created by Anthropic.\",\"stream\":false}" } }, @@ -131,7 +131,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1266575541990203530" + "kagent.dev/config-hash": "7371281520668963991" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/basic_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/basic_agent.json index af7e049ef8..763b964ec2 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/basic_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/basic_agent.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://basic-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://basic-agent.test:8080" } ], @@ -69,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"basic_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://basic-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://basic-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://basic-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://basic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://basic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"basic_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -139,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "13011412982718834568" + "kagent.dev/config-hash": "10516081583410512" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/bedrock_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/bedrock_agent.json index 7f30e79f06..b06ecf30ba 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/bedrock_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/bedrock_agent.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://bedrock-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://bedrock-agent.test:8080" } ], @@ -62,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"bedrock_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://bedrock-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://bedrock-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://bedrock-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://bedrock-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://bedrock-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"bedrock_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"bedrock\",\"model\":\"us.anthropic.claude-sonnet-4-20250514-v1:0\",\"region\":\"us-east-1\"},\"description\":\"\",\"instruction\":\"You are a helpful AI assistant running on AWS Bedrock.\",\"stream\":false}" } }, @@ -132,7 +132,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "10296132164457756348" + "kagent.dev/config-hash": "17339702527694201771" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/ollama_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/ollama_agent.json index 409e5d175d..63a3590246 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/ollama_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/ollama_agent.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://ollama-agent.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://ollama-agent.test:8080" } ], @@ -69,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"ollama_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://ollama-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://ollama-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://ollama-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://ollama-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://ollama-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"ollama_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"ollama\",\"model\":\"llama3.2:latest\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"options\":{\"num_ctx\":\"2048\",\"temperature\":\"0.8\",\"top_p\":\"0.9\"}},\"description\":\"\",\"instruction\":\"You are a helpful AI assistant running locally via Ollama.\",\"stream\":false}" } }, @@ -139,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "16198809549325160576" + "kagent.dev/config-hash": "16802652292623058054" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-custom-ca.json b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-custom-ca.json index 2039cad5c1..8185499488 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-custom-ca.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-custom-ca.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://tls-agent-with-custom-ca.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://tls-agent-with-custom-ca.test:8080" } ], @@ -67,7 +67,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"tls_agent_with_custom_ca\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://tls-agent-with-custom-ca.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://tls-agent-with-custom-ca.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://tls-agent-with-custom-ca.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://tls-agent-with-custom-ca.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://tls-agent-with-custom-ca.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"tls_agent_with_custom_ca\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"tls_insecure_skip_verify\":false,\"tls_ca_cert_path\":\"/etc/ssl/certs/custom/ca.crt\",\"tls_disable_system_cas\":false,\"base_url\":\"https://internal-litellm.company.com\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant with custom CA support.\",\"stream\":false}" } }, @@ -137,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1274635499647008262" + "kagent.dev/config-hash": "1681567908902132752" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-disabled-verify.json b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-disabled-verify.json index 1eebea8aff..a04e9e2d55 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-disabled-verify.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-disabled-verify.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://tls-agent-with-disabled-verify.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://tls-agent-with-disabled-verify.test:8080" } ], @@ -66,7 +66,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"tls_agent_with_disabled_verify\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://tls-agent-with-disabled-verify.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://tls-agent-with-disabled-verify.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://tls-agent-with-disabled-verify.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://tls-agent-with-disabled-verify.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://tls-agent-with-disabled-verify.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"tls_agent_with_disabled_verify\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"tls_insecure_skip_verify\":true,\"tls_disable_system_cas\":false,\"base_url\":\"https://dev-litellm.local\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant in a development environment.\",\"stream\":false}" } }, @@ -136,7 +136,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "12364563615889863360" + "kagent.dev/config-hash": "1238192155064720782" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-system-cas-disabled.json b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-system-cas-disabled.json index 2dbb8c1eed..99a8befbe3 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-system-cas-disabled.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-system-cas-disabled.json @@ -15,12 +15,12 @@ "supportedInterfaces": [ { "protocolBinding": "JSONRPC", - "protocolVersion": "0.3", + "protocolVersion": "1.0", "url": "http://tls-agent-with-system-cas-disabled.test:8080" }, { "protocolBinding": "JSONRPC", - "protocolVersion": "1.0", + "protocolVersion": "0.3", "url": "http://tls-agent-with-system-cas-disabled.test:8080" } ], @@ -67,7 +67,7 @@ ] }, "stringData": { - "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"tls_agent_with_system_cas_disabled\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://tls-agent-with-system-cas-disabled.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://tls-agent-with-system-cas-disabled.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://tls-agent-with-system-cas-disabled.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://tls-agent-with-system-cas-disabled.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://tls-agent-with-system-cas-disabled.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"tls_agent_with_system_cas_disabled\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"tls_insecure_skip_verify\":false,\"tls_ca_cert_path\":\"/etc/ssl/certs/custom/ca.crt\",\"tls_disable_system_cas\":true,\"base_url\":\"https://corp-llm-gateway.internal\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant in a corporate environment.\",\"stream\":false}" } }, @@ -137,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "16193366991679050633" + "kagent.dev/config-hash": "1420314934818105053" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/utils.go b/go/core/internal/controller/translator/agent/utils.go index e633cc031d..f96dbfa462 100644 --- a/go/core/internal/controller/translator/agent/utils.go +++ b/go/core/internal/controller/translator/agent/utils.go @@ -17,12 +17,12 @@ func GetA2AAgentCard(agent v1alpha2.AgentObject) *a2atype.AgentCard { { URL: fmt.Sprintf("http://%s.%s:8080", agent.GetName(), agent.GetNamespace()), ProtocolBinding: a2atype.TransportProtocolJSONRPC, - ProtocolVersion: a2atype.ProtocolVersion("0.3"), + ProtocolVersion: a2atype.Version, }, { URL: fmt.Sprintf("http://%s.%s:8080", agent.GetName(), agent.GetNamespace()), ProtocolBinding: a2atype.TransportProtocolJSONRPC, - ProtocolVersion: a2atype.Version, + ProtocolVersion: a2atype.ProtocolVersion("0.3"), }, }, Capabilities: a2atype.AgentCapabilities{ diff --git a/go/core/internal/controller/translator/agent/utils_test.go b/go/core/internal/controller/translator/agent/utils_test.go index 61dd6af647..e78aea1a27 100644 --- a/go/core/internal/controller/translator/agent/utils_test.go +++ b/go/core/internal/controller/translator/agent/utils_test.go @@ -110,10 +110,10 @@ func TestGetA2AAgentCard(t *testing.T) { require.Len(t, card.SupportedInterfaces, 2) assert.Equal(t, tt.wantURL, card.SupportedInterfaces[0].URL) assert.Equal(t, a2atype.TransportProtocolJSONRPC, card.SupportedInterfaces[0].ProtocolBinding) - assert.Equal(t, a2atype.ProtocolVersion("0.3"), card.SupportedInterfaces[0].ProtocolVersion) + assert.Equal(t, a2atype.Version, card.SupportedInterfaces[0].ProtocolVersion) assert.Equal(t, tt.wantURL, card.SupportedInterfaces[1].URL) assert.Equal(t, a2atype.TransportProtocolJSONRPC, card.SupportedInterfaces[1].ProtocolBinding) - assert.Equal(t, a2atype.Version, card.SupportedInterfaces[1].ProtocolVersion) + assert.Equal(t, a2atype.ProtocolVersion("0.3"), card.SupportedInterfaces[1].ProtocolVersion) assert.Equal(t, tt.wantSkills, card.Skills) assert.Equal(t, []string{"text"}, card.DefaultInputModes) assert.Equal(t, []string{"text"}, card.DefaultOutputModes) diff --git a/go/core/internal/database/client_postgres.go b/go/core/internal/database/client_postgres.go index 01ab1b7067..4749932313 100644 --- a/go/core/internal/database/client_postgres.go +++ b/go/core/internal/database/client_postgres.go @@ -199,22 +199,17 @@ func (c *postgresClient) ListEventsForSession(ctx context.Context, sessionID, us // ── Tasks ───────────────────────────────────────────────────────────────────── -// TODO(0.11.0): Switch task writes to v1 storage format and remove legacy conversion from this write path. -// NOTE: We will still need to keep the read compatibility for legacy rows in 0.11.0 func (c *postgresClient) StoreTask(ctx context.Context, task *a2a.Task) error { - legacyTask, err := trpcv0.ToLegacyTask(task) - if err != nil { - return fmt.Errorf("failed to convert task to legacy format: %w", err) - } - data, err := json.Marshal(legacyTask) + data, err := json.Marshal(task) if err != nil { return fmt.Errorf("failed to serialize task: %w", err) } + protocolVersion := trpcv0.ProtocolVersionV1 return c.q.UpsertTask(ctx, dbgen.UpsertTaskParams{ ID: string(task.ID), Data: string(data), SessionID: strPtrIfNotEmpty(task.ContextID), - ProtocolVersion: nil, + ProtocolVersion: &protocolVersion, }) } @@ -248,19 +243,17 @@ func (c *postgresClient) DeleteTask(ctx context.Context, taskID string) error { // ── Push Notifications ──────────────────────────────────────────────────────── -// TODO(0.11.0): Switch push notification writes to v1 storage format and remove legacy conversion from this write path. -// NOTE: We will still need to keep the read compatibility for legacy rows in 0.11.0. func (c *postgresClient) StorePushNotification(ctx context.Context, config *a2a.PushConfig) error { - legacyConfig := trpcv0.ToLegacyPushConfig(config) - data, err := json.Marshal(legacyConfig) + data, err := json.Marshal(config) if err != nil { return fmt.Errorf("failed to serialize push notification: %w", err) } + protocolVersion := trpcv0.ProtocolVersionV1 return c.q.UpsertPushNotification(ctx, dbgen.UpsertPushNotificationParams{ ID: config.ID, TaskID: string(config.TaskID), Data: string(data), - ProtocolVersion: nil, + ProtocolVersion: &protocolVersion, }) } From c815f22abd08f291f85ebc26741943da5dd2484b Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Fri, 22 May 2026 11:54:10 -0400 Subject: [PATCH 5/8] migration cli cmd for 0.11.0 data migration to v1 Signed-off-by: Jet Chiang update migration cmd tests from archived branch Signed-off-by: Jet Chiang --- go/core/cli/cmd/kagent/main.go | 3 +- go/core/cli/internal/cli/migrate/migrate.go | 89 +++++++++ go/core/pkg/a2acompat/trpcv0/convert_test.go | 83 +------- go/core/pkg/a2acompat/trpcv0/fixtures.go | 83 ++++++++ go/core/pkg/a2amigration/runner.go | 196 +++++++++++++++++++ go/core/pkg/a2amigration/runner_test.go | 158 +++++++++++++++ 6 files changed, 531 insertions(+), 81 deletions(-) create mode 100644 go/core/cli/internal/cli/migrate/migrate.go create mode 100644 go/core/pkg/a2acompat/trpcv0/fixtures.go create mode 100644 go/core/pkg/a2amigration/runner.go create mode 100644 go/core/pkg/a2amigration/runner_test.go diff --git a/go/core/cli/cmd/kagent/main.go b/go/core/cli/cmd/kagent/main.go index 78b6c062e5..66e8e33c8d 100644 --- a/go/core/cli/cmd/kagent/main.go +++ b/go/core/cli/cmd/kagent/main.go @@ -10,6 +10,7 @@ import ( cli "github.com/kagent-dev/kagent/go/core/cli/internal/cli/agent" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/envdoc" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/mcp" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/migrate" "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/profiles" "github.com/kagent-dev/kagent/go/core/cli/internal/tui" @@ -449,7 +450,7 @@ Examples: runCmd.Flags().StringVar(&runCfg.ProjectDir, "project-dir", "", "Project directory (default: current directory)") runCmd.Flags().BoolVar(&runCfg.Build, "build", false, "Rebuild the Docker image before running") - rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, initCmd, buildCmd, deployCmd, addMcpCmd, runCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd()) + rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, initCmd, buildCmd, deployCmd, addMcpCmd, runCmd, migrate.NewCommand(), mcp.NewMCPCmd(), envdoc.NewEnvCmd()) return rootCmd } diff --git a/go/core/cli/internal/cli/migrate/migrate.go b/go/core/cli/internal/cli/migrate/migrate.go new file mode 100644 index 0000000000..eb6c3effd9 --- /dev/null +++ b/go/core/cli/internal/cli/migrate/migrate.go @@ -0,0 +1,89 @@ +package migrate + +import ( + "fmt" + + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/pkg/a2amigration" + "github.com/spf13/cobra" +) + +const ( + defaultPostgresURL = "postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres" +) + +type A2ADataOptions struct { + PostgresDatabaseURL string + PostgresDatabaseURLFile string + BatchSize int + DryRun bool + ConfirmBackup bool +} + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Run kagent data migrations", + } + cmd.AddCommand(newA2ADataCommand()) + return cmd +} + +func newA2ADataCommand() *cobra.Command { + opts := A2ADataOptions{ + PostgresDatabaseURL: defaultPostgresURL, + BatchSize: 100, + } + + cmd := &cobra.Command{ + Use: "a2a-v1", + Short: "Migrate stored A2A task and push notification data to v1", + Long: `Migrate stored A2A task and push notification JSON blobs from the legacy +trpc-a2a-go shape to the official A2A v1 shape. + +Stop kagent services and take a database backup before running without --dry-run.`, + RunE: func(cmd *cobra.Command, args []string) error { + if !opts.DryRun && !opts.ConfirmBackup { + return fmt.Errorf("refusing to migrate without --confirm-backup; run --dry-run first and confirm a database backup exists") + } + + url, err := database.ResolveURL(opts.PostgresDatabaseURL, opts.PostgresDatabaseURLFile) + if err != nil { + return err + } + db, err := database.Connect(cmd.Context(), &database.PostgresConfig{URL: url}) + if err != nil { + return err + } + defer db.Close() + + stats, err := a2amigration.Run(cmd.Context(), db, a2amigration.Options{ + BatchSize: opts.BatchSize, + DryRun: opts.DryRun, + Out: cmd.OutOrStdout(), + }) + if err != nil { + return err + } + + mode := "migration" + rowVerb := "migrated" + if opts.DryRun { + mode = "dry-run" + rowVerb = "would migrate" + } + fmt.Fprintf(cmd.OutOrStdout(), "A2A data migration complete (%s):\n", mode) + fmt.Fprintf(cmd.OutOrStdout(), " tasks: %d %s, %d skipped\n", stats.TasksMigrated, rowVerb, stats.TasksSkipped) + fmt.Fprintf(cmd.OutOrStdout(), " push notifications: %d %s, %d skipped\n", stats.PushNotificationsMigrated, rowVerb, stats.PushNotificationsSkipped) + fmt.Fprintf(cmd.OutOrStdout(), " already v1: %d\n", stats.AlreadyV1) + return nil + }, + } + + cmd.Flags().StringVar(&opts.PostgresDatabaseURL, "postgres-database-url", opts.PostgresDatabaseURL, "The URL of the PostgreSQL database.") + cmd.Flags().StringVar(&opts.PostgresDatabaseURLFile, "postgres-database-url-file", "", "Path to a file containing the PostgreSQL database URL. Takes precedence over --postgres-database-url.") + cmd.Flags().IntVar(&opts.BatchSize, "batch-size", opts.BatchSize, "Number of legacy rows to process per batch.") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Convert rows and report counts without writing changes.") + cmd.Flags().BoolVar(&opts.ConfirmBackup, "confirm-backup", false, "Confirm that kagent is stopped and a database backup exists.") + return cmd +} diff --git a/go/core/pkg/a2acompat/trpcv0/convert_test.go b/go/core/pkg/a2acompat/trpcv0/convert_test.go index 44576d41c4..1c974454ff 100644 --- a/go/core/pkg/a2acompat/trpcv0/convert_test.go +++ b/go/core/pkg/a2acompat/trpcv0/convert_test.go @@ -11,17 +11,17 @@ import ( ) func TestTaskJSONToV1JSON_ClusterTextTask(t *testing.T) { - task := mustConvertTaskJSONToV1(t, buildLegacyTextTaskFixture()) + task := mustConvertTaskJSONToV1(t, LegacyTextTaskFixture()) assertForwardTextTaskFixture(t, task) } func TestTaskJSONToV1JSON_ClusterDataTask(t *testing.T) { - task := mustConvertTaskJSONToV1(t, buildLegacyDataTaskFixture()) + task := mustConvertTaskJSONToV1(t, LegacyDataTaskFixture()) assertForwardDataTaskFixture(t, task) } func TestPushNotificationJSONToV1JSON(t *testing.T) { - cfg := mustConvertPushNotificationJSONToV1(t, buildLegacyPushConfigFixture()) + cfg := mustConvertPushNotificationJSONToV1(t, LegacyPushConfigFixture()) want := a2av1.PushConfig{ TaskID: "task-1", @@ -250,83 +250,6 @@ func mustFilePart(t *testing.T, part trpc.Part) trpc.FilePart { } } -func buildLegacyTextTaskFixture() trpc.Task { - return trpc.Task{ - ID: "019d49ab-6830-763c-9db6-1b6359228c4c", - Kind: trpc.KindTask, - ContextID: "ctx-text", - Status: trpc.TaskStatus{ - State: trpc.TaskStateCompleted, - Message: &trpc.Message{ - Kind: trpc.KindMessage, - MessageID: "msg-status-1", - Role: trpc.MessageRoleAgent, - Parts: []trpc.Part{ - trpc.TextPart{Kind: trpc.KindText, Text: "done"}, - }, - }, - }, - History: []trpc.Message{ - { - Kind: trpc.KindMessage, - MessageID: "msg-user-1", - Role: trpc.MessageRoleUser, - Parts: []trpc.Part{ - trpc.TextPart{Kind: trpc.KindText, Text: "hi"}, - }, - }, - }, - Artifacts: []trpc.Artifact{ - { - ArtifactID: "artifact-1", - Parts: []trpc.Part{ - trpc.TextPart{Kind: trpc.KindText, Text: "Hello! How can I assist you with Kubernetes today?"}, - }, - }, - }, - } -} - -func buildLegacyDataTaskFixture() trpc.Task { - return trpc.Task{ - ID: "task-data-1", - Kind: trpc.KindTask, - ContextID: "ctx-data", - Status: trpc.TaskStatus{ - State: trpc.TaskStateInputRequired, - Message: &trpc.Message{ - Kind: trpc.KindMessage, - MessageID: "msg-status-data-1", - Role: trpc.MessageRoleAgent, - Parts: []trpc.Part{ - trpc.DataPart{ - Kind: trpc.KindData, - Data: map[string]any{ - "name": "adk_request_confirmation", - }, - }, - }, - }, - }, - } -} - -func buildLegacyPushConfigFixture() trpc.TaskPushNotificationConfig { - cred := "cred" - return trpc.TaskPushNotificationConfig{ - TaskID: "task-1", - PushNotificationConfig: trpc.PushNotificationConfig{ - ID: "cfg-1", - URL: "https://callback.example", - Token: "tok", - Authentication: &trpc.AuthenticationInfo{ - Credentials: &cred, - Schemes: []string{"Bearer"}, - }, - }, - } -} - func buildV1RichTaskFixture() *a2av1.Task { ts := time.Date(2026, time.January, 2, 3, 4, 5, 123456000, time.UTC) taskID := a2av1.TaskID("task-v1-rich") diff --git a/go/core/pkg/a2acompat/trpcv0/fixtures.go b/go/core/pkg/a2acompat/trpcv0/fixtures.go new file mode 100644 index 0000000000..732723ff79 --- /dev/null +++ b/go/core/pkg/a2acompat/trpcv0/fixtures.go @@ -0,0 +1,83 @@ +package trpcv0 + +import trpc "trpc.group/trpc-go/trpc-a2a-go/protocol" + +// LegacyTextTaskFixture returns a representative trpc-a2a-go task used in migration tests. +func LegacyTextTaskFixture() trpc.Task { + return trpc.Task{ + ID: "019d49ab-6830-763c-9db6-1b6359228c4c", + Kind: trpc.KindTask, + ContextID: "ctx-text", + Status: trpc.TaskStatus{ + State: trpc.TaskStateCompleted, + Message: &trpc.Message{ + Kind: trpc.KindMessage, + MessageID: "msg-status-1", + Role: trpc.MessageRoleAgent, + Parts: []trpc.Part{ + trpc.TextPart{Kind: trpc.KindText, Text: "done"}, + }, + }, + }, + History: []trpc.Message{ + { + Kind: trpc.KindMessage, + MessageID: "msg-user-1", + Role: trpc.MessageRoleUser, + Parts: []trpc.Part{ + trpc.TextPart{Kind: trpc.KindText, Text: "hi"}, + }, + }, + }, + Artifacts: []trpc.Artifact{ + { + ArtifactID: "artifact-1", + Parts: []trpc.Part{ + trpc.TextPart{Kind: trpc.KindText, Text: "Hello! How can I assist you with Kubernetes today?"}, + }, + }, + }, + } +} + +// LegacyDataTaskFixture returns a trpc-a2a-go task with a data status message. +func LegacyDataTaskFixture() trpc.Task { + return trpc.Task{ + ID: "task-data-1", + Kind: trpc.KindTask, + ContextID: "ctx-data", + Status: trpc.TaskStatus{ + State: trpc.TaskStateInputRequired, + Message: &trpc.Message{ + Kind: trpc.KindMessage, + MessageID: "msg-status-data-1", + Role: trpc.MessageRoleAgent, + Parts: []trpc.Part{ + trpc.DataPart{ + Kind: trpc.KindData, + Data: map[string]any{ + "name": "adk_request_confirmation", + }, + }, + }, + }, + }, + } +} + +// LegacyPushConfigFixture returns a representative trpc-a2a-go push notification config. +func LegacyPushConfigFixture() trpc.TaskPushNotificationConfig { + cred := "cred" + return trpc.TaskPushNotificationConfig{ + TaskID: "task-1", + PushNotificationConfig: trpc.PushNotificationConfig{ + ID: "cfg-1", + URL: "https://callback.example", + Token: "tok", + Authentication: &trpc.AuthenticationInfo{ + Credentials: &cred, + Schemes: []string{"Bearer"}, + }, + }, + } +} diff --git a/go/core/pkg/a2amigration/runner.go b/go/core/pkg/a2amigration/runner.go new file mode 100644 index 0000000000..8349f5a6e5 --- /dev/null +++ b/go/core/pkg/a2amigration/runner.go @@ -0,0 +1,196 @@ +package a2amigration + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kagent-dev/kagent/go/core/pkg/a2acompat/trpcv0" +) + +const defaultBatchSize = 100 + +type Options struct { + BatchSize int + DryRun bool + Out io.Writer +} + +type Stats struct { + TasksMigrated int + TasksSkipped int + PushNotificationsMigrated int + PushNotificationsSkipped int + AlreadyV1 int +} + +func Run(ctx context.Context, db *pgxpool.Pool, opts Options) (Stats, error) { + if opts.BatchSize <= 0 { + opts.BatchSize = defaultBatchSize + } + + alreadyV1, err := countAlreadyV1(ctx, db) + if err != nil { + return Stats{}, err + } + stats := Stats{AlreadyV1: alreadyV1} + + if err := rejectUnknownVersions(ctx, db); err != nil { + return stats, err + } + + taskStats, err := migrateTable(ctx, db, tableConfig{ + name: "task", + selectSQL: `SELECT id, data FROM task +WHERE protocol_version IS NULL AND id > $1 +ORDER BY id +LIMIT $2`, + updateSQL: `UPDATE task +SET data = $1, protocol_version = $2, updated_at = NOW() +WHERE id = $3 AND data = $4 AND protocol_version IS NULL`, + convert: func(data string) ([]byte, error) { + return trpcv0.TaskJSONToV1JSON([]byte(data)) + }, + }, opts) + if err != nil { + return stats, err + } + stats.TasksMigrated = taskStats.migrated + stats.TasksSkipped = taskStats.skipped + + pushStats, err := migrateTable(ctx, db, tableConfig{ + name: "push_notification", + selectSQL: `SELECT id, data FROM push_notification +WHERE protocol_version IS NULL AND id > $1 +ORDER BY id +LIMIT $2`, + updateSQL: `UPDATE push_notification +SET data = $1, protocol_version = $2, updated_at = NOW() +WHERE id = $3 AND data = $4 AND protocol_version IS NULL`, + convert: func(data string) ([]byte, error) { + return trpcv0.PushNotificationJSONToV1JSON([]byte(data)) + }, + }, opts) + if err != nil { + return stats, err + } + stats.PushNotificationsMigrated = pushStats.migrated + stats.PushNotificationsSkipped = pushStats.skipped + + return stats, nil +} + +type tableConfig struct { + name string + selectSQL string + updateSQL string + convert func(data string) ([]byte, error) +} + +type tableStats struct { + migrated int + skipped int +} + +func migrateTable(ctx context.Context, db *pgxpool.Pool, cfg tableConfig, opts Options) (tableStats, error) { + var stats tableStats + lastID := "" + + for { + rows, err := db.Query(ctx, cfg.selectSQL, lastID, int32(opts.BatchSize)) + if err != nil { + return stats, fmt.Errorf("list %s rows: %w", cfg.name, err) + } + + var batch []rowData + for rows.Next() { + var row rowData + if err := rows.Scan(&row.id, &row.data); err != nil { + rows.Close() + return stats, fmt.Errorf("scan %s row: %w", cfg.name, err) + } + batch = append(batch, row) + } + if err := rows.Err(); err != nil { + rows.Close() + return stats, fmt.Errorf("iterate %s rows: %w", cfg.name, err) + } + rows.Close() + + if len(batch) == 0 { + return stats, nil + } + + for _, row := range batch { + lastID = row.id + converted, err := cfg.convert(row.data) + if err != nil { + return stats, fmt.Errorf("convert %s row %s: %w", cfg.name, row.id, err) + } + if opts.DryRun { + stats.migrated++ + continue + } + tag, err := db.Exec(ctx, cfg.updateSQL, string(converted), trpcv0.ProtocolVersionV1, row.id, row.data) + if err != nil { + return stats, fmt.Errorf("update %s row %s: %w", cfg.name, row.id, err) + } + if tag.RowsAffected() == 0 { + stats.skipped++ + continue + } + stats.migrated++ + } + } +} + +type rowData struct { + id string + data string +} + +func countAlreadyV1(ctx context.Context, db *pgxpool.Pool) (int, error) { + var count int + err := db.QueryRow(ctx, `SELECT + (SELECT COUNT(*) FROM task WHERE protocol_version = $1) + + (SELECT COUNT(*) FROM push_notification WHERE protocol_version = $1)`, trpcv0.ProtocolVersionV1).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count v1 rows: %w", err) + } + return count, nil +} + +func rejectUnknownVersions(ctx context.Context, db *pgxpool.Pool) error { + rows, err := db.Query(ctx, `SELECT table_name, protocol_version, count FROM ( + SELECT 'task' AS table_name, protocol_version, COUNT(*) AS count + FROM task + WHERE protocol_version IS NOT NULL AND protocol_version <> $1 + GROUP BY protocol_version + UNION ALL + SELECT 'push_notification' AS table_name, protocol_version, COUNT(*) AS count + FROM push_notification + WHERE protocol_version IS NOT NULL AND protocol_version <> $1 + GROUP BY protocol_version +) unknown_versions +ORDER BY table_name, protocol_version`, trpcv0.ProtocolVersionV1) + if err != nil { + return fmt.Errorf("check protocol versions: %w", err) + } + defer rows.Close() + + var unknown []error + for rows.Next() { + var tableName, version string + var count int + if err := rows.Scan(&tableName, &version, &count); err != nil { + return fmt.Errorf("scan unknown protocol version: %w", err) + } + unknown = append(unknown, fmt.Errorf("%s has %d row(s) with unsupported protocol_version %q", tableName, count, version)) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate unknown protocol versions: %w", err) + } + return errors.Join(unknown...) +} diff --git a/go/core/pkg/a2amigration/runner_test.go b/go/core/pkg/a2amigration/runner_test.go new file mode 100644 index 0000000000..9fe70af1a7 --- /dev/null +++ b/go/core/pkg/a2amigration/runner_test.go @@ -0,0 +1,158 @@ +package a2amigration + +import ( + "context" + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kagent-dev/kagent/go/core/internal/dbtest" + "github.com/kagent-dev/kagent/go/core/pkg/a2acompat/trpcv0" +) + +func TestRunMigratesA2AData(t *testing.T) { + if testing.Short() { + t.Skip("skipping database integration test in short mode") + } + + ctx := context.Background() + connStr := dbtest.StartT(ctx, t) + dbtest.MigrateT(t, connStr, false) + + db, err := pgxpool.New(ctx, connStr) + if err != nil { + t.Fatalf("connect database: %v", err) + } + t.Cleanup(db.Close) + + taskData := mustMarshalLegacyFixture(t, trpcv0.LegacyTextTaskFixture()) + pushData := mustMarshalLegacyFixture(t, trpcv0.LegacyPushConfigFixture()) + + _, err = db.Exec(ctx, `INSERT INTO task (id, data, session_id) VALUES ($1, $2, $3)`, "task-1", string(taskData), "session-1") + if err != nil { + t.Fatalf("insert task: %v", err) + } + _, err = db.Exec(ctx, `INSERT INTO push_notification (id, task_id, data) VALUES ($1, $2, $3)`, "push-1", "task-1", string(pushData)) + if err != nil { + t.Fatalf("insert push notification: %v", err) + } + + dryRunStats, err := Run(ctx, db, Options{DryRun: true, BatchSize: 1}) + if err != nil { + t.Fatalf("dry-run migration: %v", err) + } + if dryRunStats.TasksMigrated != 1 || dryRunStats.PushNotificationsMigrated != 1 { + t.Fatalf("dry-run stats = %+v", dryRunStats) + } + assertProtocolVersion(t, db, "task", "task-1", nil) + assertProtocolVersion(t, db, "push_notification", "push-1", nil) + + stats, err := Run(ctx, db, Options{BatchSize: 1}) + if err != nil { + t.Fatalf("migration: %v", err) + } + if stats.TasksMigrated != 1 || stats.PushNotificationsMigrated != 1 { + t.Fatalf("stats = %+v", stats) + } + assertProtocolVersion(t, db, "task", "task-1", ptr("1.0")) + assertProtocolVersion(t, db, "push_notification", "push-1", ptr("1.0")) + assertTaskLooksV1(t, db) + assertPushNotificationLooksV1(t, db) + + secondStats, err := Run(ctx, db, Options{BatchSize: 1}) + if err != nil { + t.Fatalf("second migration: %v", err) + } + if secondStats.TasksMigrated != 0 || secondStats.PushNotificationsMigrated != 0 || secondStats.AlreadyV1 != 2 { + t.Fatalf("second stats = %+v", secondStats) + } +} + +func TestRunRejectsUnknownProtocolVersion(t *testing.T) { + if testing.Short() { + t.Skip("skipping database integration test in short mode") + } + + ctx := context.Background() + connStr := dbtest.StartT(ctx, t) + dbtest.MigrateT(t, connStr, false) + + db, err := pgxpool.New(ctx, connStr) + if err != nil { + t.Fatalf("connect database: %v", err) + } + t.Cleanup(db.Close) + + _, err = db.Exec(ctx, `INSERT INTO task (id, data, protocol_version) VALUES ($1, $2, $3)`, "task-unknown", `{}`, "2.0") + if err != nil { + t.Fatalf("insert task: %v", err) + } + + _, err = Run(ctx, db, Options{}) + if err == nil { + t.Fatal("expected unknown protocol version error") + } +} + +func assertProtocolVersion(t *testing.T, db *pgxpool.Pool, table, id string, want *string) { + t.Helper() + var got *string + err := db.QueryRow(context.Background(), `SELECT protocol_version FROM `+table+` WHERE id = $1`, id).Scan(&got) + if err != nil { + t.Fatalf("query protocol_version: %v", err) + } + if want == nil { + if got != nil { + t.Fatalf("%s protocol_version = %q, want nil", table, *got) + } + return + } + if got == nil || *got != *want { + t.Fatalf("%s protocol_version = %v, want %q", table, got, *want) + } +} + +func assertTaskLooksV1(t *testing.T, db *pgxpool.Pool) { + t.Helper() + var text string + err := db.QueryRow(context.Background(), `SELECT data::jsonb #>> '{history,0,parts,0,text}' FROM task WHERE id = 'task-1'`).Scan(&text) + if err != nil { + t.Fatalf("query v1 task text part: %v", err) + } + if text != "hi" { + t.Fatalf("v1 task text part = %q", text) + } + var role string + err = db.QueryRow(context.Background(), `SELECT data::jsonb #>> '{history,0,role}' FROM task WHERE id = 'task-1'`).Scan(&role) + if err != nil { + t.Fatalf("query v1 task role: %v", err) + } + if role != "ROLE_USER" { + t.Fatalf("v1 task role = %q", role) + } +} + +func assertPushNotificationLooksV1(t *testing.T, db *pgxpool.Pool) { + t.Helper() + var scheme string + err := db.QueryRow(context.Background(), `SELECT data::jsonb #>> '{authentication,scheme}' FROM push_notification WHERE id = 'push-1'`).Scan(&scheme) + if err != nil { + t.Fatalf("query v1 push notification auth scheme: %v", err) + } + if scheme != "Bearer" { + t.Fatalf("v1 push notification auth scheme = %q", scheme) + } +} + +func mustMarshalLegacyFixture(t *testing.T, fixture any) []byte { + t.Helper() + data, err := json.Marshal(fixture) + if err != nil { + t.Fatalf("marshal legacy fixture: %v", err) + } + return data +} + +func ptr(s string) *string { + return &s +} From fb9545f9774de0a4fdc0b12f17691625d9d133c3 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Tue, 26 May 2026 17:20:46 -0400 Subject: [PATCH 6/8] python BYO libraries a2a v1 migration Signed-off-by: Jet Chiang --- .../kagent-adk/src/kagent/adk/_token.py | 2 +- .../packages/kagent-adk/src/kagent/adk/cli.py | 11 ++- .../tests/unittests/test_token_service.py | 19 ++++ .../src/kagent/core/a2a/_task_store.py | 69 ++++++++++++++- .../kagent-core/tests/test_task_store.py | 61 +++++++++++++ python/packages/kagent-crewai/pyproject.toml | 2 +- .../kagent-crewai/src/kagent/crewai/_a2a.py | 14 ++- .../src/kagent/crewai/_executor.py | 23 ++--- .../src/kagent/crewai/_listeners.py | 88 +++++++++---------- .../packages/kagent-langgraph/pyproject.toml | 2 +- .../src/kagent/langgraph/_a2a.py | 15 ++-- .../src/kagent/langgraph/_converters.py | 48 +++++----- .../src/kagent/langgraph/_executor.py | 55 ++++++------ python/packages/kagent-openai/pyproject.toml | 2 +- .../kagent-openai/src/kagent/openai/_a2a.py | 28 +++--- .../src/kagent/openai/_agent_executor.py | 27 +++--- .../src/kagent/openai/_event_converter.py | 78 ++++++++-------- python/uv.lock | 6 +- 18 files changed, 347 insertions(+), 203 deletions(-) create mode 100644 python/packages/kagent-adk/tests/unittests/test_token_service.py create mode 100644 python/packages/kagent-core/tests/test_task_store.py diff --git a/python/packages/kagent-adk/src/kagent/adk/_token.py b/python/packages/kagent-adk/src/kagent/adk/_token.py index 07fe74adbe..5cfaeed14a 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_token.py +++ b/python/packages/kagent-adk/src/kagent/adk/_token.py @@ -64,7 +64,7 @@ async def _refresh_token(self): async def _add_headers(self, request: httpx.Request): token = await self._get_token() - headers = {"X-Agent-Name": self.app_name} + headers = {"X-Agent-Name": self.app_name, "A2A-Version": "1.0"} if token: headers["Authorization"] = f"Bearer {token}" if user_id := get_request_user_id(): diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index e32d0aacbf..014f10e095 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -11,6 +11,7 @@ from agentsts.adk import ADKSTSIntegration, ADKTokenPropagationPlugin from google.adk.agents import BaseAgent from google.adk.cli.utils.agent_loader import AgentLoader +from google.protobuf.json_format import ParseDict from kagent.core import KAgentConfig, configure_logging, configure_tracing from . import AgentConfig, KAgentApp @@ -50,6 +51,10 @@ def maybe_add_skills_with_config(root_agent: BaseAgent, agent_config: Optional[A add_skills_tool_to_agent(skills_directory, root_agent) +def parse_agent_card(data: dict) -> AgentCard: + return ParseDict(data, AgentCard()) + + @app.command() def static( host: str = "127.0.0.1", @@ -65,7 +70,7 @@ def static( agent_config = AgentConfig.model_validate(config) with open(os.path.join(filepath, "agent-card.json"), "r") as f: agent_card = json.load(f) - agent_card = AgentCard.model_validate(agent_card) + agent_card = parse_agent_card(agent_card) plugins = None sts_integration = create_sts_integration() if sts_integration: @@ -171,7 +176,7 @@ def root_agent_factory() -> BaseAgent: with open(os.path.join(working_dir, name, "agent-card.json"), "r") as f: agent_card = json.load(f) - agent_card = AgentCard.model_validate(agent_card) + agent_card = parse_agent_card(agent_card) # Attempt to import optional user-defined lifespan(app) from the agent package lifespan = None @@ -239,7 +244,7 @@ def test( with open(os.path.join(filepath, "agent-card.json"), "r") as f: agent_card = json.load(f) - agent_card = AgentCard.model_validate(agent_card) + agent_card = parse_agent_card(agent_card) agent_config = AgentConfig.model_validate(config) asyncio.run(test_agent(agent_config, agent_card, task)) diff --git a/python/packages/kagent-adk/tests/unittests/test_token_service.py b/python/packages/kagent-adk/tests/unittests/test_token_service.py new file mode 100644 index 0000000000..ae12d16a7b --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_token_service.py @@ -0,0 +1,19 @@ +import httpx +import pytest + +from kagent.adk._token import KAgentTokenService + + +@pytest.mark.asyncio +async def test_add_headers_includes_a2a_version_and_identity(monkeypatch): + service = KAgentTokenService("test-agent") + service.token = "test-token" + monkeypatch.setattr("kagent.adk._token.get_request_user_id", lambda: "user-1") + + request = httpx.Request("GET", "http://kagent.local/api/tasks") + await service._add_headers(request) + + assert request.headers["A2A-Version"] == "1.0" + assert request.headers["X-Agent-Name"] == "test-agent" + assert request.headers["X-User-Id"] == "user-1" + assert request.headers["Authorization"] == "Bearer test-token" diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py b/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py index 45f87ca3b3..dc475bc3ba 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py @@ -1,13 +1,18 @@ import asyncio +import logging +from datetime import timezone import httpx from a2a.server.tasks import TaskStore -from a2a.types import Message, Task +from a2a.types import ListTasksRequest, ListTasksResponse, Message, Task from google.protobuf.json_format import MessageToDict, ParseDict from typing_extensions import override from kagent.core.a2a import read_metadata_value +logger = logging.getLogger(__name__) +DEFAULT_LIST_TASKS_PAGE_SIZE = 50 + class KAgentTaskStore(TaskStore): """ @@ -91,6 +96,68 @@ async def get(self, task_id: str, context=None) -> Task | None: return None return ParseDict(data, Task()) + @override + async def list(self, params: ListTasksRequest, context=None) -> ListTasksResponse: + """List tasks for a context (session) from KAgent. + + The controller exposes task listing under the session-scoped endpoint, + so ``params.context_id`` is required to fetch tasks. + """ + page_size = params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE + if not params.context_id: + return ListTasksResponse(tasks=[], page_size=page_size, total_size=0) + + response = await self.client.get(f"/api/sessions/{params.context_id}/tasks") + if response.status_code == 404: + return ListTasksResponse(tasks=[], page_size=page_size, total_size=0) + response.raise_for_status() + + wrapped = response.json() + data = wrapped.get("data") if isinstance(wrapped, dict) else None + if not isinstance(data, list): + return ListTasksResponse(tasks=[], page_size=page_size, total_size=0) + + tasks: list[Task] = [] + for item in data: + if not isinstance(item, dict): + continue + try: + tasks.append(ParseDict(item, Task())) + except Exception as err: + logger.warning("Failed to parse task from list response: %s", err) + + if params.status: + tasks = [task for task in tasks if task.status and task.status.state == params.status] + + if params.HasField("status_timestamp_after"): + after = params.status_timestamp_after.ToDatetime().astimezone(timezone.utc) + filtered: list[Task] = [] + for task in tasks: + if not task.status or not task.status.HasField("timestamp"): + continue + task_ts = task.status.timestamp.ToDatetime().astimezone(timezone.utc) + if task_ts >= after: + filtered.append(task) + tasks = filtered + + start = 0 + if params.page_token: + try: + start = max(0, int(params.page_token)) + except ValueError: + start = 0 + if start >= len(tasks): + return ListTasksResponse(tasks=[], page_size=page_size, total_size=len(tasks)) + + end = min(start + page_size, len(tasks)) + next_page_token = str(end) if end < len(tasks) else "" + return ListTasksResponse( + tasks=tasks[start:end], + page_size=page_size, + total_size=len(tasks), + next_page_token=next_page_token, + ) + @override async def delete(self, task_id: str, context=None) -> None: """Delete a task from KAgent. diff --git a/python/packages/kagent-core/tests/test_task_store.py b/python/packages/kagent-core/tests/test_task_store.py new file mode 100644 index 0000000000..822a13f03d --- /dev/null +++ b/python/packages/kagent-core/tests/test_task_store.py @@ -0,0 +1,61 @@ +import httpx +import pytest +from a2a.types import ListTasksRequest, Task, TaskState, TaskStatus +from google.protobuf.json_format import MessageToDict +from google.protobuf.timestamp_pb2 import Timestamp + +from kagent.core.a2a import KAgentTaskStore + + +@pytest.mark.asyncio +async def test_list_requires_context_id(): + client = httpx.AsyncClient(base_url="http://kagent.local") + store = KAgentTaskStore(client) + + resp = await store.list(ListTasksRequest()) + assert len(resp.tasks) == 0 + assert resp.total_size == 0 + + await client.aclose() + + +@pytest.mark.asyncio +async def test_list_filters_status_and_supports_paging(): + ts = Timestamp() + ts.GetCurrentTime() + task_working = Task( + id="t-working", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_WORKING, timestamp=ts), + ) + task_done = Task( + id="t-done", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED, timestamp=ts), + ) + payload = { + "data": [MessageToDict(task_working), MessageToDict(task_done)], + } + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/sessions/ctx-1/tasks" + return httpx.Response(200, json=payload) + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(base_url="http://kagent.local", transport=transport) + store = KAgentTaskStore(client) + + resp = await store.list( + ListTasksRequest( + context_id="ctx-1", + status=TaskState.TASK_STATE_WORKING, + page_size=1, + ) + ) + + assert resp.total_size == 1 + assert resp.page_size == 1 + assert len(resp.tasks) == 1 + assert resp.tasks[0].id == "t-working" + + await client.aclose() diff --git a/python/packages/kagent-crewai/pyproject.toml b/python/packages/kagent-crewai/pyproject.toml index 69cb3eb5a6..ad38b27af9 100644 --- a/python/packages/kagent-crewai/pyproject.toml +++ b/python/packages/kagent-crewai/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "pydantic>=2.0.0", "typing-extensions>=4.0.0", "uvicorn>=0.20.0", - "a2a-sdk[http-server]>=0.3.23", + "a2a-sdk[http-server]>=1.0.0", "kagent-core>=0.1.0", "opentelemetry-instrumentation-crewai>=0.47.3", "google-genai>=1.21.1" diff --git a/python/packages/kagent-crewai/src/kagent/crewai/_a2a.py b/python/packages/kagent-crewai/src/kagent/crewai/_a2a.py index 957047c1d8..3c1eadab3d 100644 --- a/python/packages/kagent-crewai/src/kagent/crewai/_a2a.py +++ b/python/packages/kagent-crewai/src/kagent/crewai/_a2a.py @@ -4,8 +4,8 @@ from typing import Union import httpx -from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.types import AgentCard from fastapi import FastAPI, Request from fastapi.responses import PlainTextResponse @@ -68,15 +68,12 @@ def build(self) -> FastAPI: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - max_content_length = get_a2a_max_content_length() - a2a_app = A2AStarletteApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() faulthandler.enable() app = FastAPI( @@ -94,6 +91,7 @@ def build(self) -> FastAPI: app.add_route("/health", methods=["GET"], route=def_health_check) app.add_route("/thread_dump", methods=["GET"], route=thread_dump) - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app diff --git a/python/packages/kagent-crewai/src/kagent/crewai/_executor.py b/python/packages/kagent-crewai/src/kagent/crewai/_executor.py index 56f8d3e017..a87a3f1330 100644 --- a/python/packages/kagent-crewai/src/kagent/crewai/_executor.py +++ b/python/packages/kagent-crewai/src/kagent/crewai/_executor.py @@ -14,7 +14,6 @@ from a2a.server.events.event_queue import EventQueue from a2a.types import ( Artifact, - DataPart, Message, Part, Role, @@ -22,8 +21,8 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) +from google.protobuf.json_format import MessageToDict from kagent.core.tracing._span_processor import ( clear_kagent_span_attributes, set_kagent_span_attributes, @@ -83,7 +82,7 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.submitted, + state=TaskState.TASK_STATE_SUBMITTED, message=context.message, timestamp=datetime.now(timezone.utc).isoformat(), ), @@ -96,7 +95,7 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), ), context_id=context.context_id, @@ -115,8 +114,10 @@ async def execute( inputs = None if context.message and context.message.parts: for part in context.message.parts: - if isinstance(part, DataPart): - inputs = part.root.data + if part.HasField("data"): + data_payload = MessageToDict(part.data) + if isinstance(data_payload, dict): + inputs = data_payload break if inputs is None: user_input = context.get_user_input() @@ -161,7 +162,7 @@ async def execute( context_id=context.context_id, artifact=Artifact( artifact_id=str(uuid.uuid4()), - parts=[Part(TextPart(text=result_text))], + parts=[Part(text=result_text)], ), ) ) @@ -169,7 +170,7 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, + state=TaskState.TASK_STATE_COMPLETED, timestamp=datetime.now(timezone.utc).isoformat(), ), context_id=context.context_id, @@ -183,12 +184,12 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, + state=TaskState.TASK_STATE_FAILED, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=str(e)))], + role=Role.ROLE_AGENT, + parts=[Part(text=str(e))], ), ), context_id=context.context_id, diff --git a/python/packages/kagent-crewai/src/kagent/crewai/_listeners.py b/python/packages/kagent-crewai/src/kagent/crewai/_listeners.py index 38378901c4..3e902f942f 100644 --- a/python/packages/kagent-crewai/src/kagent/crewai/_listeners.py +++ b/python/packages/kagent-crewai/src/kagent/crewai/_listeners.py @@ -6,15 +6,15 @@ from a2a.server.agent_execution.context import RequestContext from a2a.server.events.event_queue import EventQueue from a2a.types import ( - DataPart, Message, Part, Role, TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, @@ -58,12 +58,12 @@ def on_task_started(source: Any, event: TaskStartedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=f"Task started: {event.task.name}"))], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Task started: {event.task.name}")], ), ), context_id=self.context.context_id, @@ -79,12 +79,12 @@ def on_task_completed(source: Any, event: TaskCompletedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=f"Task completed: {event.task.name}\n"))], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Task completed: {event.task.name}\n")], ), ), context_id=self.context.context_id, @@ -99,16 +99,14 @@ def on_agent_execution_started(source: Any, event: AgentExecutionStartedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - TextPart( - text=f"Agent {event.agent.id} started working on task: {event.task_prompt}" - ) + text=f"Agent {event.agent.id} started working on task: {event.task_prompt}" ) ], ), @@ -126,12 +124,12 @@ def on_agent_execution_completed(source: Any, event: AgentExecutionCompletedEven TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=str(event.output)))], + role=Role.ROLE_AGENT, + parts=[Part(text=str(event.output))], ), ), context_id=self.context.context_id, @@ -146,25 +144,26 @@ def on_tool_usage_started(source: Any, event: ToolUsageStartedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - DataPart( - data={ + data=ParseDict( + { "id": event.tool_class, "name": event.tool_name, "args": event.tool_args, }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + }, ) ], ), @@ -181,25 +180,26 @@ def on_tool_usage_finished(source: Any, event: ToolUsageFinishedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - DataPart( - data={ + data=ParseDict( + { "id": event.tool_class, "name": event.tool_name, "response": event.output, }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, + }, ) ], ), @@ -216,16 +216,14 @@ def on_method_execution_started(source: Any, event: MethodExecutionStartedEvent) TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - TextPart( - text=f"Method {event.method_name} from flow {event.flow_name} started execution." - ) + text=f"Method {event.method_name} from flow {event.flow_name} started execution." ) ], ), @@ -242,16 +240,14 @@ def on_method_execution_finished(source: Any, event: MethodExecutionFinishedEven TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(timezone.utc).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - TextPart( - text=f"Method {event.method_name} from flow {event.flow_name} finished execution." - ) + text=f"Method {event.method_name} from flow {event.flow_name} finished execution." ) ], ), diff --git a/python/packages/kagent-langgraph/pyproject.toml b/python/packages/kagent-langgraph/pyproject.toml index d84417738b..4995cb2b5e 100644 --- a/python/packages/kagent-langgraph/pyproject.toml +++ b/python/packages/kagent-langgraph/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "pydantic>=2.0.0", "typing-extensions>=4.0.0", "uvicorn>=0.20.0", - "a2a-sdk>=0.3.23", + "a2a-sdk>=1.0.0", "kagent-core>=0.1.0", "langsmith[otel]>=0.4.30", ] diff --git a/python/packages/kagent-langgraph/src/kagent/langgraph/_a2a.py b/python/packages/kagent-langgraph/src/kagent/langgraph/_a2a.py index 0f3be75d54..8f740115b1 100644 --- a/python/packages/kagent-langgraph/src/kagent/langgraph/_a2a.py +++ b/python/packages/kagent-langgraph/src/kagent/langgraph/_a2a.py @@ -8,8 +8,8 @@ import logging import httpx -from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.types import AgentCard from fastapi import FastAPI, Request from fastapi.responses import PlainTextResponse @@ -102,16 +102,12 @@ def build(self) -> FastAPI: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - # Create A2A application - max_content_length = get_a2a_max_content_length() - a2a_app = A2AStarletteApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() # Enable fault handler for debugging faulthandler.enable() @@ -136,6 +132,7 @@ def build(self) -> FastAPI: app.add_route("/thread_dump", methods=["GET"], route=thread_dump) # Add A2A routes - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app diff --git a/python/packages/kagent-langgraph/src/kagent/langgraph/_converters.py b/python/packages/kagent-langgraph/src/kagent/langgraph/_converters.py index 9fb66358f7..4cd17e21f1 100644 --- a/python/packages/kagent-langgraph/src/kagent/langgraph/_converters.py +++ b/python/packages/kagent-langgraph/src/kagent/langgraph/_converters.py @@ -17,15 +17,15 @@ UTC = timezone.utc from a2a.types import ( - DataPart, Message, Part, Role, TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, @@ -76,27 +76,28 @@ async def _convert_langgraph_event_to_a2a( if isinstance(message, AIMessage): # Handle AI messages (assistant responses) - a2a_message = Message(message_id=str(uuid.uuid4()), role=Role.agent, parts=[]) + a2a_message = Message(message_id=str(uuid.uuid4()), role=Role.ROLE_AGENT, parts=[]) if message.content and isinstance(message.content, str) and message.content.strip(): - a2a_message.parts.append(Part(TextPart(text=message.content))) + a2a_message.parts.append(Part(text=message.content)) # Handle tool calls in AI messages if hasattr(message, "tool_calls") and message.tool_calls: for tool_call in message.tool_calls: a2a_message.parts.append( Part( - DataPart( - data={ + data=ParseDict( + { "id": tool_call["id"], "name": tool_call["name"], "args": tool_call["args"], }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + }, ) ) @@ -108,7 +109,7 @@ async def _convert_langgraph_event_to_a2a( TaskStatusUpdateEvent( task_id=task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(UTC).isoformat(), message=a2a_message, ), @@ -128,25 +129,26 @@ async def _convert_langgraph_event_to_a2a( TaskStatusUpdateEvent( task_id=task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(UTC).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - DataPart( - data={ + data=ParseDict( + { "id": message.tool_call_id, "name": message.name, "response": message.content, }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, + }, ) ], ), diff --git a/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py b/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py index 4663bd226a..b8b36e749b 100644 --- a/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py +++ b/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py @@ -28,7 +28,6 @@ from a2a.server.events.event_queue import EventQueue from a2a.types import ( Artifact, - DataPart, Message, Part, Role, @@ -36,8 +35,9 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, @@ -183,7 +183,7 @@ async def _stream_graph_events( # publish the task result event - this is final if ( - task_result_aggregator.task_state == TaskState.working + task_result_aggregator.task_state == TaskState.TASK_STATE_WORKING and task_result_aggregator.task_status_message is not None and task_result_aggregator.task_status_message.parts ): @@ -205,7 +205,7 @@ async def _stream_graph_events( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, + state=TaskState.TASK_STATE_COMPLETED, timestamp=datetime.now(UTC).isoformat(), ), context_id=context.context_id, @@ -279,8 +279,8 @@ async def _handle_interrupt( parts.append( Part( - DataPart( - data={ + data=ParseDict( + { "name": "adk_request_confirmation", "id": confirmation_id, "args": { @@ -296,13 +296,12 @@ async def _handle_interrupt( }, }, }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, - get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True, - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True, + }, ) ) @@ -310,11 +309,11 @@ async def _handle_interrupt( TaskStatusUpdateEvent( task_id=task_id, status=TaskStatus( - state=TaskState.input_required, + state=TaskState.TASK_STATE_INPUT_REQUIRED, timestamp=datetime.now(UTC).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=parts, ), ), @@ -335,7 +334,7 @@ def _is_resume_command(self, context: RequestContext) -> bool: if not context.current_task: return False - if context.current_task.status.state != TaskState.input_required: + if context.current_task.status.state != TaskState.TASK_STATE_INPUT_REQUIRED: return False # Check if message contains a decision @@ -442,7 +441,7 @@ async def _handle_resume( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(UTC).isoformat(), ), context_id=context.context_id, @@ -468,12 +467,12 @@ async def _handle_resume( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, + state=TaskState.TASK_STATE_FAILED, timestamp=datetime.now(UTC).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=f"Resume failed: {str(e)}"))], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Resume failed: {str(e)}")], ), ), context_id=context.context_id, @@ -510,7 +509,7 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.submitted, + state=TaskState.TASK_STATE_SUBMITTED, message=context.message, timestamp=datetime.now(UTC).isoformat(), ), @@ -527,7 +526,7 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(UTC).isoformat(), ), context_id=context.context_id, @@ -561,12 +560,12 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, + state=TaskState.TASK_STATE_FAILED, timestamp=datetime.now(UTC).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text="Execution timed out"))], + role=Role.ROLE_AGENT, + parts=[Part(text="Execution timed out")], ), ), context_id=context.context_id, @@ -584,12 +583,12 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, + state=TaskState.TASK_STATE_FAILED, timestamp=datetime.now(UTC).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=user_message))], + role=Role.ROLE_AGENT, + parts=[Part(text=user_message)], metadata={ get_kagent_metadata_key("error_type"): error_meta["error_type"], get_kagent_metadata_key("error_detail"): error_meta["error_detail"], diff --git a/python/packages/kagent-openai/pyproject.toml b/python/packages/kagent-openai/pyproject.toml index 9c0751e858..657b0f5f97 100644 --- a/python/packages/kagent-openai/pyproject.toml +++ b/python/packages/kagent-openai/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" dependencies = [ "openai>=1.72.0", "openai-agents>=0.4.0", - "a2a-sdk>=0.3.23", + "a2a-sdk>=1.0.0", "kagent-core>=0.1.0", "kagent-skills>=0.1.0", "httpx>=0.25.0", diff --git a/python/packages/kagent-openai/src/kagent/openai/_a2a.py b/python/packages/kagent-openai/src/kagent/openai/_a2a.py index 131a4283ac..02d5558aeb 100644 --- a/python/packages/kagent-openai/src/kagent/openai/_a2a.py +++ b/python/packages/kagent-openai/src/kagent/openai/_a2a.py @@ -12,8 +12,8 @@ from collections.abc import Callable import httpx -from a2a.server.apps import A2AFastAPIApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.server.tasks import InMemoryTaskStore from a2a.types import AgentCard from agents import Agent, set_default_openai_api, set_default_openai_client, set_tracing_disabled @@ -145,16 +145,12 @@ def build(self) -> FastAPI: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=kagent_task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - # Create A2A FastAPI application - max_content_length = get_a2a_max_content_length() - a2a_app = A2AFastAPIApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() # Enable fault handler faulthandler.enable() @@ -186,7 +182,8 @@ def build(self) -> FastAPI: app.add_route("/thread_dump", methods=["GET"], route=thread_dump) # Add A2A routes - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app @@ -218,16 +215,12 @@ def build_local(self) -> FastAPI: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - # Create A2A FastAPI application - max_content_length = get_a2a_max_content_length() - a2a_app = A2AFastAPIApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() # Enable fault handler faulthandler.enable() @@ -240,7 +233,8 @@ def build_local(self) -> FastAPI: app.add_route("/thread_dump", methods=["GET"], route=thread_dump) # Add A2A routes - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app diff --git a/python/packages/kagent-openai/src/kagent/openai/_agent_executor.py b/python/packages/kagent-openai/src/kagent/openai/_agent_executor.py index d1711fb23b..b4faa97014 100644 --- a/python/packages/kagent-openai/src/kagent/openai/_agent_executor.py +++ b/python/packages/kagent-openai/src/kagent/openai/_agent_executor.py @@ -37,7 +37,6 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) from agents.agent import Agent from agents.run import Runner @@ -139,8 +138,8 @@ async def _stream_agent_events( if hasattr(result, "final_output") and result.final_output: final_message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=str(result.final_output)))], + role=Role.ROLE_AGENT, + parts=[Part(text=str(result.final_output))], ) # Publish final artifact @@ -161,7 +160,7 @@ async def _stream_agent_events( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, + state=TaskState.TASK_STATE_COMPLETED, timestamp=datetime.now(UTC).isoformat(), ), context_id=context.context_id, @@ -171,7 +170,7 @@ async def _stream_agent_events( else: # No output - publish based on aggregator state if ( - task_result_aggregator.task_state == TaskState.working + task_result_aggregator.task_state == TaskState.TASK_STATE_WORKING and task_result_aggregator.task_status_message is not None and task_result_aggregator.task_status_message.parts ): @@ -190,7 +189,7 @@ async def _stream_agent_events( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, + state=TaskState.TASK_STATE_COMPLETED, timestamp=datetime.now(UTC).isoformat(), ), context_id=context.context_id, @@ -237,7 +236,7 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.submitted, + state=TaskState.TASK_STATE_SUBMITTED, message=context.message, timestamp=datetime.now(UTC).isoformat(), ), @@ -255,7 +254,7 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, timestamp=datetime.now(UTC).isoformat(), ), context_id=context.context_id, @@ -298,12 +297,12 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, + state=TaskState.TASK_STATE_FAILED, timestamp=datetime.now(UTC).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text="Execution timed out"))], + role=Role.ROLE_AGENT, + parts=[Part(text="Execution timed out")], ), ), context_id=context.context_id, @@ -319,12 +318,12 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, + state=TaskState.TASK_STATE_FAILED, timestamp=datetime.now(UTC).isoformat(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=f"Execution failed: {error_message}"))], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Execution failed: {error_message}")], metadata={ get_kagent_metadata_key("error_type"): type(e).__name__, get_kagent_metadata_key("error_detail"): error_message, diff --git a/python/packages/kagent-openai/src/kagent/openai/_event_converter.py b/python/packages/kagent-openai/src/kagent/openai/_event_converter.py index b1c92c7839..dc2f738c4d 100644 --- a/python/packages/kagent-openai/src/kagent/openai/_event_converter.py +++ b/python/packages/kagent-openai/src/kagent/openai/_event_converter.py @@ -19,15 +19,13 @@ from a2a.server.events import Event as A2AEvent from a2a.types import ( - DataPart, Message, + Part as A2APart, Role, TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) -from a2a.types import Part as A2APart from agents.items import MessageOutputItem, ToolCallItem, ToolCallOutputItem from agents.stream_events import ( AgentUpdatedStreamEvent, @@ -41,6 +39,8 @@ A2A_DATA_PART_METADATA_TYPE_KEY, get_kagent_metadata_key, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value logger = logging.getLogger(__name__) @@ -160,8 +160,8 @@ def _convert_message_output( message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(TextPart(text=text_content))], + role=Role.ROLE_AGENT, + parts=[A2APart(text=text_content)], metadata={ get_kagent_metadata_key("app_name"): app_name, get_kagent_metadata_key("event_type"): "message_output", @@ -172,7 +172,7 @@ def _convert_message_output( task_id=task_id, context_id=context_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, timestamp=datetime.now(UTC).isoformat(), ), @@ -228,17 +228,19 @@ def _convert_tool_call( "args": tool_arguments, } - data_part = DataPart( - data=function_data, - metadata={ - get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, - }, - ) - message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(data_part)], + role=Role.ROLE_AGENT, + parts=[ + A2APart( + data=ParseDict(function_data, Value()), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + }, + ) + ], metadata={ get_kagent_metadata_key("app_name"): app_name, get_kagent_metadata_key("event_type"): "tool_call", @@ -249,7 +251,7 @@ def _convert_tool_call( task_id=task_id, context_id=context_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, timestamp=datetime.now(UTC).isoformat(), ), @@ -289,17 +291,19 @@ def _convert_tool_output( "response": {"result": actual_output}, } - data_part = DataPart( - data=function_data, - metadata={ - get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, - }, - ) - message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(data_part)], + role=Role.ROLE_AGENT, + parts=[ + A2APart( + data=ParseDict(function_data, Value()), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, + }, + ) + ], metadata={ get_kagent_metadata_key("app_name"): app_name, get_kagent_metadata_key("event_type"): "tool_output", @@ -310,7 +314,7 @@ def _convert_tool_output( task_id=task_id, context_id=context_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, timestamp=datetime.now(UTC).isoformat(), ), @@ -346,17 +350,19 @@ def _convert_agent_updated_event( "args": {"target_agent": agent_name}, } - data_part = DataPart( - data=function_data, - metadata={ - get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, - }, - ) - message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(data_part)], + role=Role.ROLE_AGENT, + parts=[ + A2APart( + data=ParseDict(function_data, Value()), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + }, + ) + ], metadata={ get_kagent_metadata_key("app_name"): app_name, get_kagent_metadata_key("event_type"): "agent_handoff", @@ -368,7 +374,7 @@ def _convert_agent_updated_event( task_id=task_id, context_id=context_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, timestamp=datetime.now(UTC).isoformat(), ), diff --git a/python/uv.lock b/python/uv.lock index 9f6cf062b7..eb187dee39 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2463,7 +2463,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3.23" }, + { name = "a2a-sdk", extras = ["http-server"], specifier = ">=1.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "crewai", extras = ["tools"], specifier = ">=1.2.0" }, { name = "fastapi", specifier = ">=0.100.0" }, @@ -2507,7 +2507,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.23" }, + { name = "a2a-sdk", specifier = ">=1.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "fastapi", specifier = ">=0.100.0" }, { name = "httpx", specifier = ">=0.25.0" }, @@ -2552,7 +2552,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.23" }, + { name = "a2a-sdk", specifier = ">=1.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "fastapi", specifier = ">=0.100.0" }, { name = "httpx", specifier = ">=0.25.0" }, From 4a76753e5ca26999426d390ae85631669ee6f750 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Tue, 26 May 2026 17:43:45 -0400 Subject: [PATCH 7/8] kagent agents CLI update to a2a v1 Signed-off-by: Jet Chiang --- go/core/cli/internal/a2a/client.go | 91 ++++++++++++ go/core/cli/internal/cli/agent/invoke.go | 63 +++----- go/core/cli/internal/cli/agent/run.go | 16 +- go/core/cli/internal/cli/agent/utils.go | 26 ++-- go/core/cli/internal/tui/chat.go | 179 +++++++++++------------ go/core/cli/internal/tui/workspace.go | 34 +++-- ui/src/lib/a2aClient.ts | 14 +- 7 files changed, 245 insertions(+), 178 deletions(-) create mode 100644 go/core/cli/internal/a2a/client.go diff --git a/go/core/cli/internal/a2a/client.go b/go/core/cli/internal/a2a/client.go new file mode 100644 index 0000000000..084ea17f2c --- /dev/null +++ b/go/core/cli/internal/a2a/client.go @@ -0,0 +1,91 @@ +package a2a + +import ( + "context" + "fmt" + "net/http" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" + corea2a "github.com/kagent-dev/kagent/go/core/internal/a2a" +) + +// ClientOptions configures an official A2A v1 client for CLI use. +type ClientOptions struct { + HTTPClient *http.Client + Headers map[string]string + Timeout time.Duration +} + +// NewClient resolves an agent card and returns an A2A v1 client that sends A2A-Version: 1.0. +func NewClient(ctx context.Context, baseURL string, opts ClientOptions) (*a2aclient.Client, error) { + headers := make(map[string]string, len(opts.Headers)+1) + for k, v := range opts.Headers { + headers[k] = v + } + if _, ok := headers[a2atype.SvcParamVersion]; !ok { + headers[a2atype.SvcParamVersion] = string(a2atype.Version) + } + + httpClient := opts.HTTPClient + if httpClient == nil { + timeout := opts.Timeout + if timeout == 0 { + timeout = 60 * time.Second + } + httpClient = &http.Client{Timeout: timeout} + } + + resolver := agentcard.NewResolver(httpClient) + resolveOpts := make([]agentcard.ResolveOption, 0, len(headers)) + for k, v := range headers { + resolveOpts = append(resolveOpts, agentcard.WithRequestHeader(k, v)) + } + + card, err := resolver.Resolve(ctx, baseURL, resolveOpts...) + if err != nil { + return nil, fmt.Errorf("resolve agent card at %s: %w", baseURL, err) + } + + client, err := a2aclient.NewFromCard( + ctx, + card, + a2aclient.WithJSONRPCTransport(httpClient), + a2aclient.WithCallInterceptors(corea2a.NewStaticHeadersInterceptor(headers)), + ) + if err != nil { + return nil, fmt.Errorf("create A2A client for %s: %w", baseURL, err) + } + + return client, nil +} + +// StreamToChannel adapts a streaming A2A response to a channel for TUI consumption. +func StreamToChannel(ctx context.Context, client *a2aclient.Client, req *a2atype.SendMessageRequest) (<-chan a2atype.Event, error) { + ch := make(chan a2atype.Event) + go func() { + defer close(ch) + for event, err := range client.SendStreamingMessage(ctx, req) { + if err != nil { + return + } + if event != nil { + select { + case ch <- event: + case <-ctx.Done(): + return + } + } + } + }() + return ch, nil +} + +// V1RequestHeaders returns HTTP headers that select official A2A v1 wire format. +func V1RequestHeaders() map[string]string { + return map[string]string{ + a2atype.SvcParamVersion: string(a2atype.Version), + } +} diff --git a/go/core/cli/internal/cli/agent/invoke.go b/go/core/cli/internal/cli/agent/invoke.go index 067e6c8175..43e5b62a01 100644 --- a/go/core/cli/internal/cli/agent/invoke.go +++ b/go/core/cli/internal/cli/agent/invoke.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -9,11 +10,11 @@ import ( "strings" "time" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/api/v1alpha2" + clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" "github.com/kagent-dev/kagent/go/core/cli/internal/config" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) type InvokeCfg struct { @@ -80,26 +81,20 @@ func InvokeCmd(ctx context.Context, cfg *InvokeCfg) { return } - var a2aClientOpts []a2aclient.Option - a2aClientOpts = append(a2aClientOpts, a2aclient.WithTimeout(cfg.Config.Timeout)) - + clientOpts := clia2a.ClientOptions{Timeout: cfg.Config.Timeout} if cfg.Token != "" { - a2aClientOpts = append(a2aClientOpts, a2aclient.WithHTTPClient(&http.Client{ + clientOpts.HTTPClient = &http.Client{ + Timeout: cfg.Config.Timeout, Transport: &bearerTokenTransport{ base: http.DefaultTransport, token: cfg.Token, }, - })) + } } - var a2aClient *a2aclient.A2AClient - var err error + var a2aURL string if cfg.URLOverride != "" { - a2aClient, err = a2aclient.NewA2AClient(cfg.URLOverride, a2aClientOpts...) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating A2A client: %v\n", err) - return - } + a2aURL = cfg.URLOverride } else { if cfg.Agent == "" { fmt.Fprintln(os.Stderr, "Agent is required") @@ -118,55 +113,43 @@ func InvokeCmd(ctx context.Context, cfg *InvokeCfg) { return } - a2aURL := buildA2AURL(cfg.Config.KAgentURL, cfg.Config.Namespace, cfg.Agent, agentResponse.Data) - a2aClient, err = a2aclient.NewA2AClient(a2aURL, a2aClientOpts...) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating A2A client: %v\n", err) - return - } + a2aURL = buildA2AURL(cfg.Config.KAgentURL, cfg.Config.Namespace, cfg.Agent, agentResponse.Data) + } + + a2aClient, err := clia2a.NewClient(ctx, a2aURL, clientOpts) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating A2A client: %v\n", err) + return } - var sessionID *string + msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(task)) if cfg.Session != "" { - sessionID = &cfg.Session + msg.ContextID = cfg.Session } + req := &a2atype.SendMessageRequest{Message: msg} // Use A2A client to send message if cfg.Stream { ctx, cancel := context.WithTimeout(ctx, 300*time.Second) defer cancel() - result, err := a2aClient.StreamMessage(ctx, protocol.SendMessageParams{ - Message: protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - ContextID: sessionID, - Parts: []protocol.Part{protocol.NewTextPart(task)}, - }, - }) + ch, err := clia2a.StreamToChannel(ctx, a2aClient, req) if err != nil { fmt.Fprintf(os.Stderr, "Error invoking session: %v\n", err) return } - StreamA2AEvents(result, cfg.Config.Verbose) + StreamA2AEvents(ch, cfg.Config.Verbose) } else { ctx, cancel := context.WithTimeout(ctx, 300*time.Second) defer cancel() - result, err := a2aClient.SendMessage(ctx, protocol.SendMessageParams{ - Message: protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - ContextID: sessionID, - Parts: []protocol.Part{protocol.NewTextPart(task)}, - }, - }) + result, err := a2aClient.SendMessage(ctx, req) if err != nil { fmt.Fprintf(os.Stderr, "Error invoking session: %v\n", err) return } - jsn, err := result.MarshalJSON() + jsn, err := json.Marshal(result) if err != nil { fmt.Fprintf(os.Stderr, "Error marshaling result: %v\n", err) return diff --git a/go/core/cli/internal/cli/agent/run.go b/go/core/cli/internal/cli/agent/run.go index 3adb7c390c..9668deaf6b 100644 --- a/go/core/cli/internal/cli/agent/run.go +++ b/go/core/cli/internal/cli/agent/run.go @@ -9,11 +9,11 @@ import ( "path/filepath" "time" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" commonexec "github.com/kagent-dev/kagent/go/core/cli/internal/common/exec" "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/tui" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) type RunCfg struct { @@ -106,20 +106,16 @@ func RunCmd(ctx context.Context, cfg *RunCfg) error { fmt.Println("Launching chat interface...") // Generate a new session ID - sessionID := protocol.GenerateContextID() + sessionID := a2atype.NewContextID() // Create A2A client for local agent - a2aClient, err := a2aclient.NewA2AClient(agentURL, a2aclient.WithTimeout(cfg.Config.Timeout)) + a2aClient, err := clia2a.NewClient(ctx, agentURL, clia2a.ClientOptions{Timeout: cfg.Config.Timeout}) if err != nil { return fmt.Errorf("failed to create A2A client: %v", err) } - sendFn := func(ctx context.Context, params protocol.SendMessageParams) (<-chan protocol.StreamingMessageEvent, error) { - ch, err := a2aClient.StreamMessage(ctx, params) - if err != nil { - return nil, err - } - return ch, err + sendFn := func(ctx context.Context, req *a2atype.SendMessageRequest) (<-chan a2atype.Event, error) { + return clia2a.StreamToChannel(ctx, a2aClient, req) } // Launch TUI chat directly diff --git a/go/core/cli/internal/cli/agent/utils.go b/go/core/cli/internal/cli/agent/utils.go index 4ec6b53253..79dfdf3d34 100644 --- a/go/core/cli/internal/cli/agent/utils.go +++ b/go/core/cli/internal/cli/agent/utils.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "fmt" "io/fs" "os" @@ -16,7 +17,8 @@ import ( pygen "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/adk/python" "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "trpc.group/trpc-go/trpc-a2a-go/protocol" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" ) var ( @@ -89,23 +91,15 @@ func (p *PortForward) Stop() { // The kubectl process will terminate when the context is canceled } -func StreamA2AEvents(ch <-chan protocol.StreamingMessageEvent, verbose bool) { +func StreamA2AEvents(ch <-chan a2atype.Event, verbose bool) { + _ = verbose for event := range ch { - if verbose { - json, err := event.MarshalJSON() - if err != nil { - fmt.Fprintf(os.Stderr, "Error marshaling A2A event: %v\n", err) - continue - } - fmt.Fprintf(os.Stdout, "%+v\n", string(json)) - } else { - json, err := event.MarshalJSON() - if err != nil { - fmt.Fprintf(os.Stderr, "Error marshaling A2A event: %v\n", err) - continue - } - fmt.Fprintf(os.Stdout, "%+v\n", string(json)) + json, err := json.Marshal(event) + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling A2A event: %v\n", err) + continue } + fmt.Fprintf(os.Stdout, "%+v\n", string(json)) } fmt.Fprintln(os.Stdout) } diff --git a/go/core/cli/internal/tui/chat.go b/go/core/cli/internal/tui/chat.go index 78ce5daad7..16a77a5801 100644 --- a/go/core/cli/internal/tui/chat.go +++ b/go/core/cli/internal/tui/chat.go @@ -12,14 +12,14 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/utils" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/theme" "github.com/muesli/reflow/wordwrap" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) -// SendMessageFn abstracts the A2A client's StreamMessage method for easier testing. -type SendMessageFn func(ctx context.Context, params protocol.SendMessageParams) (<-chan protocol.StreamingMessageEvent, error) +// SendMessageFn abstracts the A2A client's SendStreamingMessage method for easier testing. +type SendMessageFn func(ctx context.Context, req *a2atype.SendMessageRequest) (<-chan a2atype.Event, error) // RunChat starts the TUI chat, blocking until the user exits. func RunChat(agentRef string, sessionID string, sendFn SendMessageFn, verbose bool) error { @@ -30,7 +30,7 @@ func RunChat(agentRef string, sessionID string, sendFn SendMessageFn, verbose bo } type a2aEventMsg struct { - Event protocol.StreamingMessageEvent + Event a2atype.Event } type streamDoneMsg struct{} @@ -63,7 +63,7 @@ type chatModel struct { spin spinner.Model send SendMessageFn - streamCh <-chan protocol.StreamingMessageEvent + streamCh <-chan a2atype.Event cancel context.CancelFunc streaming bool @@ -223,16 +223,11 @@ func (m *chatModel) submit(text string) tea.Cmd { ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel - params := protocol.SendMessageParams{ - Message: protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - ContextID: &m.sessionID, - Parts: []protocol.Part{protocol.NewTextPart(text)}, - }, - } + msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(text)) + msg.ContextID = m.sessionID + req := &a2atype.SendMessageRequest{Message: msg} - ch, err := m.send(ctx, params) + ch, err := m.send(ctx, req) if err != nil { m.appendError(err) m.streaming = false @@ -261,44 +256,39 @@ func (m *chatModel) appendUser(text string) { m.appendLine(theme.UserStyle().Render("You:") + " " + text) } -func (m *chatModel) appendEvent(ev protocol.StreamingMessageEvent) { - switch res := ev.Result.(type) { - case *protocol.TaskStatusUpdateEvent: - if res.Final { +func (m *chatModel) appendEvent(ev a2atype.Event) { + switch res := ev.(type) { + case *a2atype.TaskStatusUpdateEvent: + final := res.Status.State.Terminal() + if final { m.working = false m.updateStatus() + } else if res.Status.Timestamp != nil { + m.setWorkingTime(*res.Status.Timestamp) } else { - // Timestamp is RFC3339 string; parse to time for consistent elapsed display - if ts, err := time.Parse(time.RFC3339Nano, res.Status.Timestamp); err == nil { - m.setWorkingTime(ts) - } else { - m.setWorkingTime(time.Time{}) - } + m.setWorkingTime(time.Time{}) } if res.Status.Message != nil { - // Handle tool calls and results in the message - m.handleMessageParts(*res.Status.Message, res.Final) + m.handleMessageParts(res.Status.Message, final) } - case *protocol.TaskArtifactUpdateEvent: - // Render artifact content when the last chunk arrives - if res.LastChunk != nil && *res.LastChunk { + case *a2atype.TaskArtifactUpdateEvent: + if res.LastChunk { text := extractTextFromParts(res.Artifact.Parts) if strings.TrimSpace(text) != "" { m.appendLine(theme.AgentStyle().Render("Agent:") + "\n" + text) } } - case *protocol.Message: - m.handleMessageParts(*res, true) + case *a2atype.Message: + m.handleMessageParts(res, true) - case *protocol.Task: - // Show the last message in the task history + case *a2atype.Task: if len(res.History) > 0 { last := res.History[len(res.History)-1] m.handleMessageParts(last, true) } default: if m.verbose { - if b, err := ev.MarshalJSON(); err == nil { + if b, err := json.Marshal(ev); err == nil { m.appendLine(theme.AgentStyle().Render("Agent (raw):") + "\n" + string(b)) } } @@ -310,66 +300,74 @@ func (m *chatModel) appendError(err error) { } // handleMessageParts processes a message and displays text, tool calls, and tool results -func (m *chatModel) handleMessageParts(msg protocol.Message, shouldDisplay bool) { +func (m *chatModel) handleMessageParts(msg *a2atype.Message, shouldDisplay bool) { + if msg == nil { + return + } + var textParts []string var toolCalls []toolCall var toolResults []toolResult - // Process all parts for _, part := range msg.Parts { - if tp, ok := part.(*protocol.TextPart); ok { - textParts = append(textParts, tp.Text) - } else if dp, ok := part.(*protocol.DataPart); ok { - // Debug: log what we're seeing - if m.verbose { - if metaJSON, err := json.Marshal(dp.Metadata); err == nil { - m.appendLine(theme.DimStyle().Render(fmt.Sprintf("DEBUG: DataPart metadata: %s", string(metaJSON)))) - } - if dataJSON, err := json.Marshal(dp.Data); err == nil { - m.appendLine(theme.DimStyle().Render(fmt.Sprintf("DEBUG: DataPart data: %s", string(dataJSON)))) - } - } + if part == nil { + continue + } + if text := part.Text(); text != "" { + textParts = append(textParts, text) + continue + } - // Check if this is a tool call or tool result - if dp.Metadata == nil { - continue - } + data := part.Data() + if data == nil { + continue + } - typeVal, found := utils.GetMetadataValue(dp.Metadata, "type") - if !found { - continue + if m.verbose { + if metaJSON, err := json.Marshal(part.Metadata); err == nil { + m.appendLine(theme.DimStyle().Render(fmt.Sprintf("DEBUG: DataPart metadata: %s", string(metaJSON)))) } - kagentType, ok := typeVal.(string) - if !ok { - continue + if dataJSON, err := json.Marshal(data); err == nil { + m.appendLine(theme.DimStyle().Render(fmt.Sprintf("DEBUG: DataPart data: %s", string(dataJSON)))) } + } - dataMap, ok := dp.Data.(map[string]any) - if !ok { - continue - } + if part.Metadata == nil { + continue + } - switch kagentType { - case "function_call": - call := toolCall{ - Name: getString(dataMap, "name"), - ID: getString(dataMap, "id"), - Args: dataMap["args"], - } - toolCalls = append(toolCalls, call) - case "function_response": - result := toolResult{ - Name: getString(dataMap, "name"), - ID: getString(dataMap, "id"), - Response: dataMap["response"], - } - toolResults = append(toolResults, result) + typeVal, found := utils.GetMetadataValue(part.Metadata, "type") + if !found { + continue + } + kagentType, ok := typeVal.(string) + if !ok { + continue + } + + dataMap, ok := data.(map[string]any) + if !ok { + continue + } + + switch kagentType { + case "function_call": + call := toolCall{ + Name: getString(dataMap, "name"), + ID: getString(dataMap, "id"), + Args: dataMap["args"], } + toolCalls = append(toolCalls, call) + case "function_response": + result := toolResult{ + Name: getString(dataMap, "name"), + ID: getString(dataMap, "id"), + Response: dataMap["response"], + } + toolResults = append(toolResults, result) } } - // Always display tool calls and results as they happen (even if not final) - // Display tool calls for _, call := range toolCalls { var argsStr string if call.Args != nil { @@ -390,7 +388,6 @@ func (m *chatModel) handleMessageParts(msg protocol.Message, shouldDisplay bool) m.appendLine(display) } - // Display tool results for _, result := range toolResults { var responseStr string if result.Response != nil { @@ -411,12 +408,11 @@ func (m *chatModel) handleMessageParts(msg protocol.Message, shouldDisplay bool) m.appendLine(display) } - // Display text content (only on final or if explicitly requested) if shouldDisplay { text := strings.Join(textParts, "") if strings.TrimSpace(text) != "" { style := theme.UserStyle() - if msg.Role == protocol.MessageRoleAgent { + if msg.Role == a2atype.MessageRoleAgent { style = theme.AgentStyle() } m.appendLine(style.Render(fmt.Sprintf("%s:", msg.Role)) + "\n" + text) @@ -451,27 +447,25 @@ func (m *chatModel) SetInputVisible(visible bool) { m.showInput = visible } -// extractTextFromParts concatenates text from a slice of protocol.Part, stringifying non-text when reasonable. -func extractTextFromParts(parts []protocol.Part) string { +func extractTextFromParts(parts a2atype.ContentParts) string { b := strings.Builder{} for _, p := range parts { - if tp, ok := p.(*protocol.TextPart); ok { - b.WriteString(tp.Text) + if p == nil { continue } - - if dp, ok := p.(*protocol.DataPart); ok { - if jp, err := json.Marshal(dp.Data); err == nil { + if text := p.Text(); text != "" { + b.WriteString(text) + continue + } + if data := p.Data(); data != nil { + if jp, err := json.Marshal(data); err == nil { b.WriteString(string(jp)) } - continue } } return b.String() } -// styles now provided by theme package - type tickMsg time.Time func (m *chatModel) tick() tea.Cmd { @@ -502,7 +496,6 @@ func (m *chatModel) updateStatus() { } } -// getString safely extracts a string value from a map func getString(m map[string]any, key string) string { if val, ok := m[key]; ok { if str, ok := val.(string); ok { diff --git a/go/core/cli/internal/tui/workspace.go b/go/core/cli/internal/tui/workspace.go index 073dcc63d4..1a3a104a05 100644 --- a/go/core/cli/internal/tui/workspace.go +++ b/go/core/cli/internal/tui/workspace.go @@ -21,10 +21,10 @@ import ( "github.com/kagent-dev/kagent/go/core/cli/internal/tui/dialogs" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/keys" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/theme" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/internal/version" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) // RunWorkspace launches a split-pane TUI: sessions (left), chat (center), details (toggleable right). @@ -53,7 +53,7 @@ type loadAgentMsg struct { } type sessionSelectedMsg struct{ session *api.Session } type sessionHistoryLoadedMsg struct { - items []*protocol.Task + items []*a2atype.Task err error } type agentChosenMsg struct{ agent api.AgentResponse } @@ -291,14 +291,13 @@ func (m *workspaceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { continue } for _, mmsg := range task.History { - if mmsg.MessageID != "" { - if _, ok := seen[mmsg.MessageID]; ok { + if mmsg.ID != "" { + if _, ok := seen[mmsg.ID]; ok { continue } - seen[mmsg.MessageID] = struct{}{} + seen[mmsg.ID] = struct{}{} } - ev := protocol.StreamingMessageEvent{Result: &mmsg} - m.chat.appendEvent(ev) + m.chat.appendEvent(mmsg) } } } @@ -467,15 +466,13 @@ func (m *workspaceModel) startChat(loadHistory bool) tea.Cmd { a2aPath = "api/a2a-sandboxes" } a2aURL := fmt.Sprintf("%s/%s/%s", m.cfg.KAgentURL, a2aPath, m.agentRef) - client, err := a2aclient.NewA2AClient(a2aURL, - a2aclient.WithTimeout(m.cfg.Timeout), - ) + client, err := clia2a.NewClient(context.Background(), a2aURL, clia2a.ClientOptions{Timeout: m.cfg.Timeout}) if err != nil { m.details.WriteString("\nA2A error\n") return nil } - sendFn := func(ctx context.Context, params protocol.SendMessageParams) (<-chan protocol.StreamingMessageEvent, error) { - return client.StreamMessage(ctx, params) + sendFn := func(ctx context.Context, req *a2atype.SendMessageRequest) (<-chan a2atype.Event, error) { + return clia2a.StreamToChannel(ctx, client, req) } // Reset chat for new session if m.chat == nil { @@ -496,13 +493,20 @@ func (m *workspaceModel) startChat(loadHistory bool) tea.Cmd { func (m *workspaceModel) fetchSessionHistoryCmd(sessionID string) tea.Cmd { return func() tea.Msg { tasksURL := fmt.Sprintf("%s/api/sessions/%s/tasks?user_id=%s", m.cfg.KAgentURL, sessionID, "admin@kagent.dev") - resp, err := http.Get(tasksURL) //nolint:gosec + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, tasksURL, nil) + if err != nil { + return sessionHistoryLoadedMsg{items: nil, err: err} + } + for k, v := range clia2a.V1RequestHeaders() { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) //nolint:gosec if err != nil { return sessionHistoryLoadedMsg{items: nil, err: err} } defer resp.Body.Close() var payload struct { - Data []*protocol.Task `json:"data"` + Data []*a2atype.Task `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return sessionHistoryLoadedMsg{items: nil, err: err} diff --git a/ui/src/lib/a2aClient.ts b/ui/src/lib/a2aClient.ts index 35f761bbe3..c2365a6b87 100644 --- a/ui/src/lib/a2aClient.ts +++ b/ui/src/lib/a2aClient.ts @@ -172,12 +172,18 @@ export class KagentA2AClient { return; } + let eventData: Record; try { - const eventData = JSON.parse(dataString); - yield (eventData.result || eventData) as StreamResponse; - } catch (error) { - console.error("❌ Failed to parse SSE data:", error, dataString); + eventData = JSON.parse(dataString); + } catch { + console.error("❌ Failed to parse SSE data:", dataString); + continue; } + if (eventData.error) { + const err = eventData.error as { code?: number; message?: string }; + throw new Error(`A2A error ${err.code ?? "unknown"}: ${err.message ?? "unknown error"}`); + } + yield (eventData.result || eventData) as StreamResponse; } } } From 5d413ea33e5ec0ef72b9d70c9c614520ebd0e5de Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Wed, 27 May 2026 13:13:25 -0400 Subject: [PATCH 8/8] missed gomod Signed-off-by: Jet Chiang --- go/go.mod | 2 +- go/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go/go.mod b/go/go.mod index 4706abeaaf..d9b6138246 100644 --- a/go/go.mod +++ b/go/go.mod @@ -47,7 +47,7 @@ require ( go.uber.org/goleak v1.3.0 go.uber.org/zap v1.28.0 golang.org/x/text v0.37.0 - google.golang.org/adk v1.2.0 + google.golang.org/adk v1.3.0 google.golang.org/genai v1.57.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 diff --git a/go/go.sum b/go/go.sum index d9ebf2cc37..8212e922e9 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1029,6 +1029,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/adk v1.2.0 h1:MfQD1/GqPfIsFNBcozNykkjdqNIdCrPH/SNqKPZF/yM= google.golang.org/adk v1.2.0/go.mod h1:6QY5jQI7awU4WYtJqvyIkJQheCvqsGWweU6BX63USEc= +google.golang.org/adk v1.3.0 h1:paUr9uM2qANnMUAQ4ydMXMCnM1HtymhDYl8y7gnKvqs= +google.golang.org/adk v1.3.0/go.mod h1:R8tNFnI/eiBXHn7zJPJtqdiK/WXC+tVkyuZsXyNZXN4= google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM=