Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "veryfront",
"version": "0.1.625",
"version": "0.1.626",
"license": "Apache-2.0",
"nodeModulesDir": "auto",
"workspace": [
Expand Down
55 changes: 55 additions & 0 deletions src/agent/ag-ui/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,61 @@ describe("agent/ag-ui-handler", () => {
assertStringIncludes(body, '"delta":"hello from runtime"');
});

it("bridges direct tool data events into the AG-UI stream", async () => {
const testAgent = createTestAgent();
testAgent.agent.stream = async (input) => {
const publishDataEvent = input.context?.publishDataEvent;
if (typeof publishDataEvent === "function") {
await publishDataEvent({
type: "test.report",
name: "test.report",
value: { status: "ready" },
});
}

const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encodeDataStreamEvent({ type: "message-start", messageId: "assistant-msg-1" }),
);
controller.enqueue(encodeDataStreamEvent({ type: "text-start", id: "text-1" }));
controller.enqueue(
encodeDataStreamEvent({ type: "text-delta", id: "text-1", delta: "done" }),
);
controller.enqueue(encodeDataStreamEvent({ type: "text-end", id: "text-1" }));
controller.close();
},
});

return {
toDataStreamResponse: () =>
new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
}),
};
};

const handler = createAgUiHandler({ agent: testAgent.agent });
const response = await handler(
new Request("http://localhost/api/ag-ui", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [{
id: "msg-1",
role: "user",
parts: [{ type: "text", text: "hello" }],
}],
}),
}),
);

const body = await response.text();
assertStringIncludes(body, "event: Custom");
assertStringIncludes(body, '"name":"test.report"');
assertStringIncludes(body, '"status":"ready"');
});

it("runs beforeStream before direct AG-UI streaming", async () => {
const testAgent = createTestAgent();
const handler = createAgUiHandler({
Expand Down
44 changes: 41 additions & 3 deletions src/agent/ag-ui/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ import {
mapRuntimeEventToAgUi,
} from "#veryfront/internal-agents/ag-ui-sse.ts";
import { streamDataStreamEvents } from "../streaming/data-stream.ts";
import {
createToolExecutionDataEventBridgeStream,
type ToolExecutionDataEventPublisher,
} from "../streaming/tool-execution-data-event-bridge.ts";
import {
type AgUiBeforeStream,
applyBeforeStreamResult,
extractLastUserText,
} from "../service/before-stream.ts";
import type { ToolExecutionDataEvent } from "#veryfront/tool/types.ts";
import {
type AgUiRequest,
normalizeAgUiMessages,
Expand Down Expand Up @@ -49,6 +54,29 @@ function generateRunId(): string {
return `run_${crypto.randomUUID().replaceAll("-", "")}`;
}

function createToolDataEventBridge() {
const pendingEvents: ToolExecutionDataEvent[] = [];
let publishDataEvent: ToolExecutionDataEventPublisher = (event) => {
pendingEvents.push(event);
};

return {
publishDataEvent: (event: ToolExecutionDataEvent) => publishDataEvent(event),
wrapStream(baseStream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
return createToolExecutionDataEventBridgeStream({
baseStream,
installPublisher(nextPublishDataEvent) {
publishDataEvent = nextPublishDataEvent;
while (pendingEvents.length > 0) {
const event = pendingEvents.shift();
if (event) publishDataEvent(event);
}
},
});
},
};
}

function buildStreamContext(
request: AgUiRequest,
baseContext: Record<string, unknown>,
Expand Down Expand Up @@ -213,20 +241,25 @@ async function createAgUiDirectStreamResponse(

await agent.clearMemory();

const toolDataEvents = createToolDataEventBridge();
const result = await agent.stream({
messages,
context: finalContext,
context: {
...finalContext,
publishDataEvent: toolDataEvents.publishDataEvent,
},
...(request.model ? { model: request.model } : {}),
...(request.maxOutputTokens ? { maxOutputTokens: request.maxOutputTokens } : {}),
});

const upstream = result.toDataStreamResponse();
const upstreamBody = upstream.body ? toolDataEvents.wrapStream(upstream.body) : upstream.body;
return await createAgUiStreamResponse({
agentId: agent.id,
request,
runId,
threadId,
upstreamBody: upstream.body,
upstreamBody,
upstreamStatus: upstream.status,
upstreamStatusText: upstream.statusText,
});
Expand Down Expand Up @@ -271,14 +304,19 @@ async function createAgUiInjectedToolsStreamResponse(
});

let upstreamBody: ReadableStream<Uint8Array>;
const toolDataEvents = createToolDataEventBridge();
try {
upstreamBody = await runtime.stream(
messages,
finalContext,
{
...finalContext,
publishDataEvent: toolDataEvents.publishDataEvent,
},
undefined,
request.model,
request.maxOutputTokens,
);
upstreamBody = toolDataEvents.wrapStream(upstreamBody);
} catch (error) {
sessionManager.failRun(runId);
throw error;
Expand Down
10 changes: 9 additions & 1 deletion src/agent/ag-ui/runtime-chat-stream-encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,16 @@ export function createAgUiRuntimeChatStreamEncoder(
});
return events;
}
default:
default: {
if (!event.type.startsWith("data-")) {
return events;
}
events.push({
type: event.type as `data-${string}`,
data: event.data,
});
return events;
}
}
},
};
Expand Down
54 changes: 51 additions & 3 deletions src/agent/react/use-chat/streaming/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ChatMessagePart, ChatToolPart } from "#veryfront/agent/react/use-c
import { createAssistantMessage, generateClientId } from "#veryfront/agent/react/use-chat/utils.ts";
import { buildCurrentParts } from "#veryfront/agent/react/use-chat/streaming/parts-builder.ts";
import type {
OrderedMessagePart,
OrderedReasoning,
OrderedStep,
OrderedToolCall,
Expand All @@ -21,6 +22,7 @@ interface StreamingState {
reasoningBlocks: Map<string, OrderedReasoning>;
steps: Map<number, OrderedStep>;
messageParts: ChatMessagePart[];
dataParts: OrderedMessagePart[];
currentTextId: string;
messageId: string;
partOrderCounter: number;
Expand All @@ -34,6 +36,7 @@ function createStreamingState(): StreamingState {
reasoningBlocks: new Map(),
steps: new Map(),
messageParts: [],
dataParts: [],
currentTextId: "",
messageId: "",
partOrderCounter: 0,
Expand All @@ -50,7 +53,13 @@ export async function handleStreamingResponse(
const state = createStreamingState();

const getBuildParts = (): ChatMessagePart[] =>
buildCurrentParts(state.textBlocks, state.reasoningBlocks, state.toolCalls, state.steps);
buildCurrentParts(
state.textBlocks,
state.reasoningBlocks,
state.toolCalls,
state.steps,
state.dataParts,
);

let buffer = "";

Expand Down Expand Up @@ -101,7 +110,13 @@ export async function handleAgUiStreamingResponse(
const state = createStreamingState();

const getBuildParts = (): ChatMessagePart[] =>
buildCurrentParts(state.textBlocks, state.reasoningBlocks, state.toolCalls, state.steps);
buildCurrentParts(
state.textBlocks,
state.reasoningBlocks,
state.toolCalls,
state.steps,
state.dataParts,
);

const processDecodedEvents = (events: ChatStreamEvent[]) => {
for (const event of events) {
Expand Down Expand Up @@ -203,6 +218,15 @@ function processStreamEvent(
return;

default:
if (typeof parsed.type === "string" && parsed.type.startsWith("data-")) {
handleDataPart(
{ type: parsed.type, data: parsed.data },
state,
onUpdate,
getBuildParts,
);
onData(parsed.data);
}
return;
}
}
Expand Down Expand Up @@ -289,7 +313,9 @@ function processChatStreamEvent(

default:
if (event.type.startsWith("data-")) {
onData((event as { data: unknown }).data);
const data = (event as { data: unknown }).data;
handleDataPart({ type: event.type, data }, state, onUpdate, getBuildParts);
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
onData(data);
}
return;
}
Expand All @@ -301,6 +327,7 @@ function handleStart(parsed: Record<string, unknown>, state: StreamingState): vo
state.toolCalls.clear();
state.reasoningBlocks.clear();
state.messageParts.length = 0;
state.dataParts.length = 0;
}

function handleTextStart(parsed: Record<string, unknown>, state: StreamingState): void {
Expand Down Expand Up @@ -351,6 +378,27 @@ function handleTextEnd(parsed: Record<string, unknown>, state: StreamingState):
}
}

function handleDataPart(
parsed: { type: string; data?: unknown },
state: StreamingState,
onUpdate: StreamingCallbacks["onUpdate"],
getBuildParts: () => ChatMessagePart[],
): void {
if (!state.messageId) {
state.messageId = generateClientId("msg");
}

state.dataParts.push({
order: state.partOrderCounter++,
part: {
type: parsed.type as `data-${string}`,
data: parsed.data,
},
});

onUpdate?.(getBuildParts(), state.messageId);
}

function handleToolInputStart(
parsed: Record<string, unknown>,
state: StreamingState,
Expand Down
19 changes: 18 additions & 1 deletion src/agent/react/use-chat/streaming/parts-builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { ChatMessagePart, ChatToolPart } from "../types.ts";
import type { OrderedReasoning, OrderedStep, OrderedToolCall, TextBlock } from "./types.ts";
import type {
OrderedMessagePart,
OrderedReasoning,
OrderedStep,
OrderedToolCall,
TextBlock,
} from "./types.ts";

interface OrderedPart {
order: number;
Expand All @@ -11,18 +17,29 @@ export function buildCurrentParts(
reasoningBlocks: Map<string, OrderedReasoning>,
toolCalls: Map<string, OrderedToolCall>,
steps?: Map<number, OrderedStep>,
extraParts?: OrderedMessagePart[],
): ChatMessagePart[] {
const orderedParts: OrderedPart[] = [];

addTextParts(orderedParts, textBlocks);
addReasoningParts(orderedParts, reasoningBlocks);
addToolParts(orderedParts, toolCalls);
if (steps) addStepParts(orderedParts, steps);
if (extraParts) addExtraParts(orderedParts, extraParts);

orderedParts.sort((a, b) => a.order - b.order);
return orderedParts.map(({ part }) => part);
}

function addExtraParts(
orderedParts: OrderedPart[],
extraParts: OrderedMessagePart[],
): void {
for (const { order, part } of extraParts) {
orderedParts.push({ order, part });
}
}

function addTextParts(
orderedParts: OrderedPart[],
textBlocks: Map<string, TextBlock>,
Expand Down
1 change: 1 addition & 0 deletions src/agent/react/use-chat/streaming/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ export interface OrderedStep {

export type OrderedToolCall = StreamingToolCall & { order: number };
export type OrderedReasoning = StreamingReasoning & { order: number };
export type OrderedMessagePart = { order: number; part: ChatMessagePart };
2 changes: 2 additions & 0 deletions src/agent/react/use-chat/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
ChatDataPart,
ChatDynamicToolPart,
ChatMessage,
ChatMessagePart,
Expand All @@ -23,6 +24,7 @@ export type BrowserInferenceStatus =
| "error";

export type {
ChatDataPart,
ChatDynamicToolPart,
ChatMessage,
ChatMessagePart,
Expand Down
Loading
Loading