mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:28:47 +02:00
Middleware audit for CK 1.62 / MAF 1.10: retire ReconcileSnapshot, inject dropped AG-UI context
- ReconcileSnapshotMiddleware removed: MAF now reuses the streamed message id in MESSAGES_SNAPSHOT (verified in _agent_run.py at python-1.10.0 and live event logs); keeping the surgery duplicated user messages on a2ui follow-up runs. - beforeRequestMiddleware now injects RunAgentInput.context as a system message: MAF declares the field but never feeds it to the model, which starves the model of CopilotKit's A2UI catalog/guidelines. Remove when MAF consumes context upstream. - Workaround chain env-gated: CK_MIDDLEWARES=off runs bare for audits, CK_DEBUG_EVENTS=1 enables the event logger. - Kept: SuppressRenderToolText, StripModelArtifacts, DeduplicateToolCall, pairOrphanToolCalls (behavior-verified). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -191,70 +191,6 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
|
|
||||||
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
|
|
||||||
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
|
|
||||||
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
|
|
||||||
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
|
|
||||||
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
|
|
||||||
// by id then APPENDS unknown ids, so the browser ends up with two assistant
|
|
||||||
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
|
|
||||||
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
|
|
||||||
// the assistant-with-toolCalls message in state past the run). The right fix
|
|
||||||
// is to drop just the orphan text-only assistant message that has no streamed
|
|
||||||
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
run(input: any, next: any): Observable<any> {
|
|
||||||
const streamedTextIds = new Set<string>();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return new Observable<any>((observer) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const sub = (this as any).runNextWithState(input, next).subscribe({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
next: (eventWithState: any) => {
|
|
||||||
const event = eventWithState.event;
|
|
||||||
const type: string = event?.type ?? "";
|
|
||||||
|
|
||||||
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
|
|
||||||
streamedTextIds.add(String(event.messageId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === EventType.MESSAGES_SNAPSHOT) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
|
||||||
const filtered = msgs.filter((m) => {
|
|
||||||
if (m?.role !== "assistant") return true;
|
|
||||||
const id = String(m?.id ?? "");
|
|
||||||
const hasText = typeof m?.content === "string" && m.content.length > 0;
|
|
||||||
const hasToolCalls =
|
|
||||||
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
|
|
||||||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
|
|
||||||
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) {
|
|
||||||
return false; // drop orphan text-only assistant duplicate
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (filtered.length !== msgs.length) {
|
|
||||||
observer.next({ ...event, messages: filtered });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
observer.next(event);
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
error: (err: any) => observer.error(err),
|
|
||||||
complete: () => observer.complete(),
|
|
||||||
});
|
|
||||||
return () => sub.unsubscribe();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SuppressRenderToolTextMiddleware ──────────────────────────────────────────
|
// ── SuppressRenderToolTextMiddleware ──────────────────────────────────────────
|
||||||
// When the LLM emits text content in the same response as a render tool call
|
// When the LLM emits text content in the same response as a render tool call
|
||||||
// (render_a2ui or render_spending_summary), the text appears as a duplicate
|
// (render_a2ui or render_spending_summary), the text appears as a duplicate
|
||||||
@@ -356,16 +292,22 @@ app.all("/api/copilotkit/*", async (c) => {
|
|||||||
: {};
|
: {};
|
||||||
|
|
||||||
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
|
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these
|
||||||
|
// workarounds are still needed after CopilotKit/MAF upgrades.
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
if (process.env.CK_MIDDLEWARES !== "off") {
|
||||||
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
agent.use(new StripModelArtifactsMiddleware() as any);
|
agent.use(new StripModelArtifactsMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// ReconcileSnapshotMiddleware retired 2026-07-04: MAF 1.9+ reuses the
|
||||||
agent.use(new ReconcileSnapshotMiddleware() as any);
|
// streamed message id in MESSAGES_SNAPSHOT (verified in _agent_run.py at
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// python-1.10.0 and in live event logs), and keeping the surgery caused
|
||||||
|
// duplicated user messages on a2ui follow-up runs.
|
||||||
agent.use(new DeduplicateToolCallMiddleware() as any);
|
agent.use(new DeduplicateToolCallMiddleware() as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
}
|
||||||
|
if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {
|
||||||
agent.use(new DebugLogMiddleware() as any);
|
agent.use(new DebugLogMiddleware() as any);
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
const runtime = new CopilotRuntime({
|
const runtime = new CopilotRuntime({
|
||||||
agents: { wealthysmart: agent },
|
agents: { wealthysmart: agent },
|
||||||
@@ -375,19 +317,44 @@ app.all("/api/copilotkit/*", async (c) => {
|
|||||||
const ct = outbound.headers.get("content-type") ?? "";
|
const ct = outbound.headers.get("content-type") ?? "";
|
||||||
if (!ct.includes("application/json")) return;
|
if (!ct.includes("application/json")) return;
|
||||||
try {
|
try {
|
||||||
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
|
const body = (await outbound.clone().json()) as {
|
||||||
|
messages?: AGUIMessage[];
|
||||||
|
context?: { description?: string; value?: string }[];
|
||||||
|
};
|
||||||
if (!Array.isArray(body.messages)) return;
|
if (!Array.isArray(body.messages)) return;
|
||||||
const paired = pairOrphanToolCalls(body.messages);
|
let messages = pairOrphanToolCalls(body.messages);
|
||||||
if (paired.length === body.messages.length) return;
|
|
||||||
|
// MAF's AG-UI endpoint declares `context` on its request model but
|
||||||
|
// never feeds it to the model (agent_framework_ag_ui, checked at
|
||||||
|
// python-1.10.0). CopilotKit 1.60+ ships the A2UI catalog schema and
|
||||||
|
// guidelines in that context — without them the model emits ops the
|
||||||
|
// A2UI validator rejects and the surface never paints. Inject the
|
||||||
|
// context as a system message ourselves. Remove once MAF consumes
|
||||||
|
// RunAgentInput.context.
|
||||||
|
if (Array.isArray(body.context) && body.context.length > 0) {
|
||||||
|
const contextText = body.context
|
||||||
|
.map((c) => `## ${c.description ?? "Context"}\n${c.value ?? ""}`)
|
||||||
|
.join("\n\n");
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
id: `ctx-${Date.now()}`,
|
||||||
|
role: "system",
|
||||||
|
content: `Additional run context:\n\n${contextText}`,
|
||||||
|
} as AGUIMessage,
|
||||||
|
...messages,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages === body.messages) return;
|
||||||
return new Request(outbound.url, {
|
return new Request(outbound.url, {
|
||||||
method: outbound.method,
|
method: outbound.method,
|
||||||
headers: outbound.headers,
|
headers: outbound.headers,
|
||||||
body: JSON.stringify({ ...body, messages: paired }),
|
body: JSON.stringify({ ...body, messages }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Skipping the repair is survivable; doing it silently is not —
|
// Skipping the repair is survivable; doing it silently is not —
|
||||||
// orphan tool calls then fail downstream with no trace of why.
|
// orphan tool calls then fail downstream with no trace of why.
|
||||||
console.error("[copilotkit] orphan-tool-call repair skipped:", err);
|
console.error("[copilotkit] outbound request repair skipped:", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user