From 9e24274d4268b8a2a9d418214bbffbac5ba5fdbc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 02:27:56 +0000 Subject: [PATCH] =?UTF-8?q?Las=20b=C3=BAsquedas=20ya=20no=20dejan=20result?= =?UTF-8?q?ados=20hu=C3=A9rfanos=20en=20el=20historial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuando el modelo pide buscar en internet junto con una tool nuestra, Anthropic busca hasta que le llegan nuestros resultados, y el resultado de la búsqueda llega en el siguiente mensaje del assistant. Al guardar, cada mensaje se revisaba solo: la búsqueda parecía colgada, se quitaba, y su resultado se quedaba sin búsqueda. El turno siguiente era un 400 ("unexpected tool_use_id found in code_execution_tool_result") y la sala ya no salía de ahí. Pasó en una sala real con una presentación. Ahora una búsqueda solo se da por colgada si su resultado no está en ningún lado del historial, y los resultados sin búsqueda se quitan. El historial también se limpia al mandarse, así que las salas que ya quedaron rotas se arreglan solas en su siguiente mensaje. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HJ5oT2Xm3VZMvbPAcgKbz4 --- server/src/agent/loop.ts | 37 +++++++++++++++++++++++++++++++++--- server/src/demos/internet.ts | 36 +++++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/server/src/agent/loop.ts b/server/src/agent/loop.ts index ea2f461..7314f66 100644 --- a/server/src/agent/loop.ts +++ b/server/src/agent/loop.ts @@ -644,7 +644,9 @@ export async function runAgent(opts: { const maxTokens = opts.maxTokens; const messages: Message[] = [ - ...opts.messages, + // Pasado por la misma limpieza que al guardar: un historial que quedó roto + // antes del arreglo (ver sinBusquedasColgadas) se manda ya sano. + ...sinBusquedasColgadas(opts.messages), { role: "user", // Las imágenes primero: las dos APIs recomiendan que el modelo vea antes @@ -1008,10 +1010,35 @@ export function notaDeBusqueda(bloques: Record[]): string { * Lo único que se quita es una búsqueda que se quedó sin correr (un turno que se * cerró a media pausa): con ella colgada, el siguiente mensaje sería un 400. Se * quita completa y queda una nota de lo que iba a buscar. + * + * "Sin correr" se mira en TODO el historial y no mensaje por mensaje. Cuando el + * modelo pide buscar junto con una tool nuestra, Anthropic busca hasta que le + * llegan nuestros resultados, y el resultado de la búsqueda llega en el + * SIGUIENTE mensaje del assistant. Antes se veía cada mensaje solo: la búsqueda + * parecía colgada, se quitaba, y su resultado se quedaba huérfano dos mensajes + * después. El turno siguiente de esa sala era un 400 ("unexpected tool_use_id + * found in code_execution_tool_result") y ya no salía de ahí. Pasó con un + * profe que pidió una presentación. + * + * Por eso también se quitan los resultados huérfanos: así un historial que ya + * se guardó roto se arregla solo la próxima vez que se manda. */ export function sinBusquedasColgadas(messages: Message[]): Message[] { + const pedidas = new Set(); + const resueltas = new Set(); + for (const m of messages) { + for (const c of m.content) { + if (c.type !== "crudo") continue; + if (c.bloque.type === "server_tool_use") pedidas.add(String(c.bloque.id)); + else if (c.bloque.tool_use_id != null) resueltas.add(String(c.bloque.tool_use_id)); + } + } + const colgada = (b: Record) => b.type === "server_tool_use" && !resueltas.has(String(b.id)); + const huerfano = (b: Record) => + b.type !== "server_tool_use" && b.tool_use_id != null && !pedidas.has(String(b.tool_use_id)); + return messages.map((m) => { - if (!servidorPendiente(m)) return m; + if (!m.content.some((c) => c.type === "crudo" && (colgada(c.bloque) || huerfano(c.bloque)))) return m; const content: ContentBlock[] = []; let tramo: Record[] = []; const cerrar = () => { @@ -1019,14 +1046,18 @@ export function sinBusquedasColgadas(messages: Message[]): Message[] { tramo = []; }; for (const c of m.content) { - if (c.type === "crudo") { + if (c.type === "crudo" && colgada(c.bloque)) { tramo.push(c.bloque); continue; } + // Su búsqueda ya no está: no se puede mandar, y no hay nada que contar. + if (c.type === "crudo" && huerfano(c.bloque)) continue; cerrar(); content.push(c); } cerrar(); + // Un mensaje vacío también es un 400. + if (content.length === 0) content.push({ type: "text", text: "(aquí hubo una búsqueda en internet que ya no está)" }); return { ...m, content }; }); } diff --git a/server/src/demos/internet.ts b/server/src/demos/internet.ts index 14fffc9..50df0e3 100644 --- a/server/src/demos/internet.ts +++ b/server/src/demos/internet.ts @@ -217,7 +217,7 @@ async function main() { { stop: "tool_use", content: [crudo(RESULTADO), { type: "tool_use", id: "tu_2", name: "read_file", input: { path: "nada.txt" } }] }, { stop: "end_turn", content: [{ type: "text", text: "Listo." }] }, ]); - await runAgent({ ...base, provider, verPreview: async () => capturaFalsa(), ve: true }); + const r6 = await runAgent({ ...base, provider, verPreview: async () => capturaFalsa(), ve: true }); const resultados = llamadas[1]?.at(-1); check("detecta la búsqueda pendiente", servidorPendiente({ role: "assistant", content: [crudo(USO)] })); check("y no cuando ya trae su resultado", !servidorPendiente({ role: "assistant", content: [crudo(USO), crudo(RESULTADO)] })); @@ -232,6 +232,37 @@ async function main() { siguiente?.role === "user" && siguiente.content.some((c) => c.type === "image"), JSON.stringify(siguiente?.content.map((c) => c.type)), ); + // El resultado llegó un mensaje después de su búsqueda: guardado, los dos + // se quedan. Antes la búsqueda se quitaba por "colgada" y el resultado + // quedaba huérfano: el turno siguiente era un 400 (pasó en una sala real). + const bloques = r6.messages.flatMap((m) => m.content.flatMap((c) => (c.type === "crudo" ? [c.bloque] : []))); + check( + "lo guardado conserva la búsqueda y su resultado, aunque lleguen en mensajes distintos", + bloques.some((b) => b.type === "server_tool_use" && b.id === "srvtoolu_1") && + bloques.some((b) => b.type === "web_search_tool_result" && b.tool_use_id === "srvtoolu_1"), + JSON.stringify(bloques.map((b) => b.type)), + ); + } + + console.log("\n6b. Un historial que ya se guardó roto se manda sano"); + { + // Como quedó en la sala: el resultado sin su búsqueda. + const roto: Message[] = [ + { role: "user", content: [{ type: "text", text: "haz la presentación" }] }, + { role: "assistant", content: [{ type: "text", text: "(aquí iba a buscar en internet, pero el turno se cerró antes)" }, { type: "tool_use", id: "tu_a", name: "read_file", input: { path: "x" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "tu_a", content: "x" }] }, + { role: "assistant", content: [crudo(RESULTADO), { type: "text", text: "Listo." }] }, + { role: "user", content: [{ type: "text", text: "no se pudo descargar" }] }, + { role: "assistant", content: [crudo(RESULTADO)] }, + ]; + const { provider, llamadas } = proveedor([{ stop: "end_turn", content: [{ type: "text", text: "Ya quedó." }] }]); + await runAgent({ ...base, provider, messages: roto }); + const mandado = llamadas[0] ?? []; + const huerfanos = mandado.flatMap((m) => m.content).filter((c) => c.type === "crudo"); + check("ya no lleva el resultado huérfano", huerfanos.length === 0, JSON.stringify(huerfanos)); + check("lo demás del mensaje se queda", JSON.stringify(mandado[3]?.content) === JSON.stringify([{ type: "text", text: "Listo." }])); + check("y ningún assistant queda vacío", mandado.every((m) => m.content.length > 0)); + check("no toca el historial original", roto[3]!.content.length === 2); } console.log("\n7. Lo que se guarda conserva las búsquedas, salvo las colgadas"); @@ -240,7 +271,8 @@ async function main() { { role: "user", content: [{ type: "text", text: "busca" }] }, { role: "assistant", content: [{ type: "text", text: "Busco." }, crudo(USO), crudo(RESULTADO), { type: "text", text: "Listo." }] }, { role: "user", content: [{ type: "text", text: "otra" }] }, - { role: "assistant", content: [{ type: "text", text: "Busco otra." }, crudo(USO)] }, + // Otra búsqueda, con su propio id (la API nunca repite uno). + { role: "assistant", content: [{ type: "text", text: "Busco otra." }, crudo({ ...USO, id: "srvtoolu_2" })] }, ]; const limpio = sinBusquedasColgadas(historial); check("una búsqueda completa se queda tal cual", JSON.stringify(limpio[1]) === JSON.stringify(historial[1]));