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:
Carlos Escalante
2026-07-04 15:55:29 -06:00
parent 033a920746
commit bbae121fec

View File

@@ -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
agent.use(new SuppressRenderToolTextMiddleware() as any); // workarounds are still needed after CopilotKit/MAF upgrades.
// eslint-disable-next-line @typescript-eslint/no-explicit-any /* eslint-disable @typescript-eslint/no-explicit-any */
agent.use(new StripModelArtifactsMiddleware() as any); if (process.env.CK_MIDDLEWARES !== "off") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any agent.use(new SuppressRenderToolTextMiddleware() as any);
agent.use(new ReconcileSnapshotMiddleware() 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 DeduplicateToolCallMiddleware() 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
agent.use(new DebugLogMiddleware() as any); // 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({ 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;
} }
}, },