feat: 支持 Agent 运行刷新恢复

- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

- 支持正式聊天与草稿试运行刷新重连和权威终态恢复

- 完善显式取消、owner 丢失、容量限制与故障测试
This commit is contained in:
2026-08-20 11:18:10 +08:00
parent 310fc1fb58
commit fa07134cf8
36 changed files with 4876 additions and 322 deletions

View File

@@ -1,29 +1,66 @@
import type {
AgentSubscriber,
Message,
RunAgentInput,
State,
} from '@ag-ui/client';
import type { Message } from '@ag-ui/client';
import { EventSchemas, EventType, HttpAgent, randomUUID } from '@ag-ui/client';
import { EventSchemas, EventType, randomUUID } from '@ag-ui/client';
import { events } from 'fetch-event-stream';
import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
export interface EasyFlowAguiRunOptions {
forwardedProps?: Record<string, unknown>;
onEvent: (event: AguiEvent) => void;
onMessagesChanged?: (messages: ReadonlyArray<Readonly<Message>>) => void;
onStateChanged?: (state: Readonly<State>) => void;
onCursor?: (cursor: number) => void;
onEvent: (event: AguiEvent) => Promise<void> | void;
runId?: string;
threadId: string;
url: string;
userMessage: Message;
}
export interface EasyFlowAguiResumeOptions {
afterCursor?: number;
onCursor?: (cursor: number) => void;
onEvent: (event: AguiEvent) => Promise<void> | void;
runId: string;
}
export type AguiEvent = ReturnType<(typeof EventSchemas)['parse']>;
interface ActiveAguiRun {
aborted: boolean;
agent: HttpAgent;
abort: () => void;
}
/** 带 HTTP 状态码的 AG-UI 传输异常。 */
export class EasyFlowAguiHttpError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'EasyFlowAguiHttpError';
}
}
/** 页面事件投影失败,重放同一游标无法自行恢复。 */
export class EasyFlowAguiProjectionError extends Error {
constructor(public readonly projectionCause: unknown) {
super(
projectionCause instanceof Error
? projectionCause.message
: 'Agent 事件投影失败',
);
this.name = 'EasyFlowAguiProjectionError';
}
}
async function projectEvent(
onEvent: (event: AguiEvent) => Promise<void> | void,
event: AguiEvent,
) {
try {
await onEvent(event);
} catch (error) {
throw new EasyFlowAguiProjectionError(error);
}
}
function toTransportJson<T>(value: T): T {
@@ -35,73 +72,142 @@ function toTransportJson<T>(value: T): T {
/**
* EasyFlow 的无头 AG-UI 运行客户端。
*
* <p>SDK 持有标准 messages/state页面只消费投影回调。每次运行都会在出站边界再次裁剪
* <p>客户端以最小标准 AG-UI 请求体启动运行,页面只消费投影回调。出站边界固定裁剪
* tools、context、state 和历史消息,服务端仍会独立执行同样的安全校验。</p>
*/
export class EasyFlowAguiClient {
private activeRun?: ActiveAguiRun;
abort() {
this.detach();
}
async cancel(runId: string) {
const requestUrl = `/api/v1/agent/agui/run/${encodeURIComponent(runId)}/cancel`;
const response = await fetch(resolveApiUrl(requestUrl), {
headers: createEventStreamHeaders(requestUrl),
method: 'POST',
});
if (!response.ok) {
throw new EasyFlowAguiHttpError(
await responseError(response),
response.status,
);
}
const body = (await response.json()) as {
errorCode?: number;
message?: string;
};
if (body.errorCode !== undefined && body.errorCode !== 0) {
throw new Error(body.message || '停止 Agent 运行失败');
}
this.detach();
}
detach() {
if (!this.activeRun) return;
this.activeRun.aborted = true;
this.activeRun.agent.abortRun();
this.activeRun.abort();
this.activeRun = undefined;
}
async run(options: EasyFlowAguiRunOptions) {
this.abort();
const requestUrl = options.url;
const agent = new EasyFlowHttpAgent({
headers: createEventStreamHeaders(requestUrl),
initialMessages: [options.userMessage],
threadId: options.threadId,
url: resolveApiUrl(requestUrl),
});
const activeRun: ActiveAguiRun = { aborted: false, agent };
async resume(options: EasyFlowAguiResumeOptions) {
this.detach();
const controller = new AbortController();
const activeRun: ActiveAguiRun = {
abort: () => controller.abort(),
aborted: false,
};
this.activeRun = activeRun;
let terminalReceived = false;
let cancelledReceived = false;
const subscriber: AgentSubscriber = {
onEvent: ({ event }) => {
try {
const requestUrl = `/api/v1/agent/agui/run/${encodeURIComponent(options.runId)}/events?after=${Math.max(0, options.afterCursor || 0)}`;
const response = await fetch(resolveApiUrl(requestUrl), {
headers: createEventStreamHeaders(requestUrl),
method: 'GET',
signal: controller.signal,
});
await assertSseResponse(response);
for await (const message of events(response, controller.signal)) {
if (activeRun.aborted || !message.data) continue;
const event = EventSchemas.parse(JSON.parse(message.data));
await projectEvent(options.onEvent, event as AguiEvent);
const cursor = Number(message.id || 0);
if (Number.isSafeInteger(cursor) && cursor > 0) {
options.onCursor?.(cursor);
}
if (
event.type === EventType.RUN_FINISHED ||
event.type === EventType.RUN_ERROR
) {
terminalReceived = true;
}
if (
event.type === EventType.RUN_ERROR &&
event.code === 'RUN_CANCELLED'
) {
cancelledReceived = true;
}
options.onEvent(event as AguiEvent);
},
onMessagesChanged: ({ messages }) => {
options.onMessagesChanged?.(messages);
},
onStateChanged: ({ state }) => {
options.onStateChanged?.(state);
},
}
if (!activeRun.aborted && !terminalReceived) {
throw new Error('Agent 事件流缺少终态,请重试');
}
} catch (error) {
if (activeRun.aborted) return;
throw error;
} finally {
if (this.activeRun === activeRun) {
this.activeRun = undefined;
}
}
}
async run(options: EasyFlowAguiRunOptions) {
this.detach();
const requestUrl = options.url;
const controller = new AbortController();
const activeRun: ActiveAguiRun = {
abort: () => controller.abort(),
aborted: false,
};
this.activeRun = activeRun;
let terminalReceived = false;
try {
await agent.runAgent(
{
const response = await fetch(resolveApiUrl(requestUrl), {
body: JSON.stringify({
context: [],
forwardedProps: options.forwardedProps
? toTransportJson(options.forwardedProps)
: undefined,
runId: `run_${randomUUID()}`,
messages: [toTransportJson(options.userMessage)],
runId: options.runId || createAguiRunId(),
state: {},
threadId: options.threadId,
tools: [],
},
subscriber,
);
}),
headers: createEventStreamHeaders(requestUrl),
method: 'POST',
signal: controller.signal,
});
await assertSseResponse(response);
for await (const message of events(response, controller.signal)) {
if (activeRun.aborted || !message.data) continue;
const event = EventSchemas.parse(JSON.parse(message.data));
if (
event.type === EventType.RUN_FINISHED ||
event.type === EventType.RUN_ERROR
) {
terminalReceived = true;
}
await projectEvent(options.onEvent, event as AguiEvent);
const cursor = Number(message.id || 0);
if (Number.isSafeInteger(cursor) && cursor > 0) {
options.onCursor?.(cursor);
}
}
if (!terminalReceived) {
if (activeRun.aborted) return;
throw new Error('Agent 事件流缺少终态,请重试');
}
} catch (error) {
if (activeRun.aborted || cancelledReceived) return;
if (activeRun.aborted) return;
if (terminalReceived && !(error instanceof EasyFlowAguiProjectionError)) {
return;
}
throw error;
} finally {
if (this.activeRun === activeRun) {
@@ -111,17 +217,26 @@ export class EasyFlowAguiClient {
}
}
class EasyFlowHttpAgent extends HttpAgent {
protected override requestInit(input: RunAgentInput): RequestInit {
const latestUserMessage = [...input.messages]
.reverse()
.find((message) => message.role === 'user');
return super.requestInit({
...input,
context: [],
messages: latestUserMessage ? [latestUserMessage] : [],
state: {},
tools: [],
});
export function createAguiRunId() {
return `run_${randomUUID()}`;
}
async function assertSseResponse(response: Response) {
const contentType = response.headers.get('content-type') || '';
if (!response.ok || !contentType.includes('text/event-stream')) {
throw new EasyFlowAguiHttpError(
await responseError(response),
response.status,
);
}
}
async function responseError(response: Response) {
try {
const body = await response.clone().json();
return body?.message || body?.error || `HTTP ${response.status}`;
} catch {
const text = await response.text();
return text.trim() || `HTTP ${response.status}`;
}
}