From bbae121fec9e7bff9c21a3dce00caf2a15096e55 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 15:55:29 -0600 Subject: [PATCH] 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) --- frontend/server.ts | 125 +++++++++++++++++---------------------------- 1 file changed, 46 insertions(+), 79 deletions(-) diff --git a/frontend/server.ts b/frontend/server.ts index 7dc0ee7..3581eaa 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -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 { - const streamedTextIds = new Set(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new Observable((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 ────────────────────────────────────────── // 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 @@ -356,16 +292,22 @@ app.all("/api/copilotkit/*", async (c) => { : {}; const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent.use(new SuppressRenderToolTextMiddleware() as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent.use(new StripModelArtifactsMiddleware() as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent.use(new ReconcileSnapshotMiddleware() as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent.use(new DeduplicateToolCallMiddleware() as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent.use(new DebugLogMiddleware() as 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 StripModelArtifactsMiddleware() as any); + // ReconcileSnapshotMiddleware retired 2026-07-04: MAF 1.9+ reuses the + // streamed message id in MESSAGES_SNAPSHOT (verified in _agent_run.py at + // 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); + } + if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") { + agent.use(new DebugLogMiddleware() as any); + } + /* eslint-enable @typescript-eslint/no-explicit-any */ const runtime = new CopilotRuntime({ agents: { wealthysmart: agent }, @@ -375,19 +317,44 @@ app.all("/api/copilotkit/*", async (c) => { const ct = outbound.headers.get("content-type") ?? ""; if (!ct.includes("application/json")) return; 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; - const paired = pairOrphanToolCalls(body.messages); - if (paired.length === body.messages.length) return; + let messages = pairOrphanToolCalls(body.messages); + + // 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, { method: outbound.method, headers: outbound.headers, - body: JSON.stringify({ ...body, messages: paired }), + body: JSON.stringify({ ...body, messages }), }); } catch (err) { // Skipping the repair is survivable; doing it silently is not — // 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; } },