Skip to content

Commit 47759d9

Browse files
Merge branch 'main' into feat/claude-harness
2 parents f1adb2b + 28d7572 commit 47759d9

6 files changed

Lines changed: 79 additions & 217 deletions

File tree

go/adk/pkg/a2a/executor.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ type KAgentExecutorConfig struct {
3434
}
3535

3636
// KAgentExecutor keeps kagent's request/session glue around the upstream ADK
37-
// A2A executor. Event conversion and artifact streaming are delegated to ADK.
37+
// A2A executor.
3838
type KAgentExecutor struct {
3939
builtin a2asrv.AgentExecutor
4040
sessionService adksession.Service
@@ -105,7 +105,7 @@ func (u *userIDInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallCont
105105
}
106106

107107
// Execute applies kagent-specific request setup and delegates event generation
108-
// to the upstream ADK executor, which streams output as artifact updates.
108+
// to the upstream ADK executor.
109109
func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] {
110110
return func(yield func(a2atype.Event, error) bool) {
111111
if reqCtx.Message == nil {
@@ -175,13 +175,41 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorCon
175175
update.Status.Message.TaskID = update.TaskID
176176
update.Status.Message.ContextID = update.ContextID
177177
}
178+
// Work around upstream ADK's artifact-only event conversion: its callbacks can
179+
// mutate an artifact but cannot replace it with another A2A event. Do this before
180+
// a2a-go persists the update; remove when ADK exposes a general event converter.
181+
if update, ok := event.(*a2atype.TaskArtifactUpdateEvent); ok && artifactContainsToolEvent(update.Artifact) {
182+
message := a2atype.NewMessageForTask(a2atype.MessageRoleAgent, update, update.Artifact.Parts...)
183+
message.ID = string(update.Artifact.ID)
184+
message.Extensions = update.Artifact.Extensions
185+
message.Metadata = update.Artifact.Metadata
186+
status := a2atype.NewStatusUpdateEvent(update, a2atype.TaskStateWorking, message)
187+
status.Metadata = update.Metadata
188+
event = status
189+
}
178190
if !yield(event, err) {
179191
return
180192
}
181193
}
182194
}
183195
}
184196

197+
func artifactContainsToolEvent(artifact *a2atype.Artifact) bool {
198+
if artifact == nil {
199+
return false
200+
}
201+
for _, part := range artifact.Parts {
202+
if part == nil {
203+
continue
204+
}
205+
partType, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataTypeKey)
206+
if partType == A2ADataPartMetadataTypeFunctionCall || partType == A2ADataPartMetadataTypeFunctionResponse {
207+
return true
208+
}
209+
}
210+
return false
211+
}
212+
185213
// ensureSession ensures that a session exists for the given user and session ID.
186214
// If a session does not exist, it creates a new session with the given user and session ID.
187215
func (e *KAgentExecutor) ensureSession(ctx context.Context, message *a2atype.Message, userID, sessionID string) error {

go/adk/pkg/a2a/executor_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,31 @@ func TestKAgentExecutor_PreservesContentBearingLastChunk(t *testing.T) {
198198
}
199199
}
200200

201+
func TestKAgentExecutor_EmitsToolEventsAsStatusMessages(t *testing.T) {
202+
reqCtx := &a2asrv.ExecutorContext{TaskID: "task-1", ContextID: "ctx-1"}
203+
reqCtx.Message = a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hi"))
204+
toolPart := a2atype.NewDataPart(map[string]any{PartKeyName: "search"})
205+
toolPart.Metadata = map[string]any{GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall}
206+
tool := a2atype.NewArtifactEvent(reqCtx, toolPart)
207+
text := a2atype.NewArtifactEvent(reqCtx, a2atype.NewTextPart("done"))
208+
executor := &KAgentExecutor{builtin: &recordingExecutor{events: []a2atype.Event{tool, text}}, logger: logr.Discard()}
209+
210+
var got []a2atype.Event
211+
for event, err := range executor.Execute(t.Context(), reqCtx) {
212+
if err != nil {
213+
t.Fatal(err)
214+
}
215+
got = append(got, event)
216+
}
217+
status, ok := got[0].(*a2atype.TaskStatusUpdateEvent)
218+
if !ok || status.Status.State != a2atype.TaskStateWorking || status.Status.Message.ID != string(tool.Artifact.ID) || status.Status.Message.Parts[0] != toolPart {
219+
t.Fatalf("tool event = %#v, want working status message", got[0])
220+
}
221+
if got[1] != text {
222+
t.Fatalf("text event = %#v, want original artifact", got[1])
223+
}
224+
}
225+
201226
func TestKAgentExecutor_StreamsArtifactsThroughUpstreamExecutor(t *testing.T) {
202227
const (
203228
appName = "test-app"

ui/src/api/chat/a2aGrpcChatClient.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,27 @@ describe("A2AGrpcChatClient.history", () => {
554554
});
555555
}
556556

557+
it("replays protocol history before deliverable artifacts", async () => {
558+
serveTasks([
559+
{
560+
id: "task-1",
561+
contextId: CONVERSATION.id,
562+
status: { state: TaskState.COMPLETED, timestamp: { seconds: 1767225600n } },
563+
history: [
564+
{ messageId: "u0", role: Role.USER, parts: [text("ask me a question")] },
565+
{ messageId: "a0", role: Role.AGENT, parts: [data({ name: "ask_user" })] },
566+
{ messageId: "a1", role: Role.AGENT, parts: [text("Which topic?")] },
567+
{ messageId: "u1", role: Role.USER, parts: [text("Personal development")] },
568+
{ messageId: "a2", role: Role.AGENT, parts: [data({ name: "ask_user" })] },
569+
],
570+
artifacts: [{ artifactId: "result", parts: [text("Thank you for sharing.")] }],
571+
},
572+
]);
573+
574+
const { messages } = await new A2AGrpcChatClient().history(CONVERSATION);
575+
expect(messages.map((message) => message.id)).toEqual(["u0", "a0", "a1", "u1", "a2", "result"]);
576+
});
577+
557578
it("keeps a tool call and its result apart when replaying", async () => {
558579
// Consecutive agent messages carrying data parts and no text at all: comparing
559580
// text made them look identical and dropped the result, so a replayed

ui/src/api/chat/a2aGrpcChatClient.ts

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ import {
7676
type PendingRequest,
7777
} from "./hitl";
7878
import { agentInstanceShareToken } from "../shareToken";
79-
import { interleaveTaskMessages } from "./transcriptOrder";
8079
import { serviceClient } from "../transport";
8180
import type {
8281
ChatClient,
@@ -777,57 +776,28 @@ export function messagesFromTask(task: A2ATask): ChatMessage[] {
777776
});
778777
};
779778

780-
/*
781-
* Sorted into three, because the gateway hands back two lists with no way to
782-
* interleave them — see `interleaveTaskMessages`, which does the inferring and
783-
* carries the reasoning.
784-
*
785-
* The split has to happen here rather than after conversion: what marks a reader
786-
* turn as an answer is the HITL metadata on the A2A message, and a `ChatMessage`
787-
* does not carry it.
788-
*/
789-
const openingCount = messages.length;
790-
const answerAt: number[] = [];
791779
for (const message of task.history) {
792-
if (isAskUserResponse(message)) answerAt.push(messages.length);
793780
push(message);
794781
}
795782
if (task.status?.message) push(task.status.message);
796783

797-
const fromHistory = messages.slice(openingCount);
798-
const answered = new Set(answerAt.map((index) => index - openingCount));
799-
const answers = fromHistory.filter((_, index) => answered.has(index));
800-
const opening = [
801-
...messages.slice(0, openingCount),
802-
...fromHistory.filter((_, index) => !answered.has(index)),
803-
];
804-
805784
// An artifact repeating text already in the history is the same reply arriving
806785
// twice, exactly as it is on a live stream.
807786
const shown = new Set(messages.map((message) => textOf(message.parts)));
808-
const agent: ChatMessage[] = [];
809787
for (const artifact of task.artifacts) {
810788
const parts = toParts(artifact.parts);
811789
const body = textOf(parts);
812790
if (parts.length === 0 || (body !== "" && shown.has(body))) continue;
813-
agent.push({
791+
messages.push({
814792
// Derived, for the reason given against the message id above: an unnamed
815793
// artifact renamed on every read is an artifact the merge cannot recognise.
816-
id: artifact.artifactId || `${task.id || "task"}-artifact-${messages.length + agent.length}`,
794+
id: artifact.artifactId || `${task.id || "task"}-artifact-${messages.length}`,
817795
role: "agent",
818796
parts,
819797
createdAt,
820798
taskId: task.id || undefined,
821799
});
822800
}
823801

824-
return interleaveTaskMessages(opening, answers, agent);
825-
}
826-
827-
/** Whether a reader's turn is answering an `ask_user` rather than opening a task. */
828-
function isAskUserResponse(message: A2AMessage): boolean {
829-
const carried = (message.metadata as Record<string, unknown> | undefined)?.[
830-
HITL_EXTENSION_URI
831-
] as { type?: unknown } | undefined;
832-
return carried?.type === "ask_user_response";
802+
return messages;
833803
}

ui/src/api/chat/transcriptOrder.test.ts

Lines changed: 0 additions & 89 deletions
This file was deleted.

ui/src/api/chat/transcriptOrder.ts

Lines changed: 0 additions & 93 deletions
This file was deleted.

0 commit comments

Comments
 (0)