Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions server/src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1008,25 +1010,54 @@ export function notaDeBusqueda(bloques: Record<string, unknown>[]): 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<string>();
const resueltas = new Set<string>();
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<string, unknown>) => b.type === "server_tool_use" && !resueltas.has(String(b.id));
const huerfano = (b: Record<string, unknown>) =>
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<string, unknown>[] = [];
const cerrar = () => {
if (tramo.length) content.push({ type: "text", text: notaDeBusqueda(tramo) });
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 };
});
}
Expand Down
36 changes: 34 additions & 2 deletions server/src/demos/internet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)] }));
Expand All @@ -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");
Expand All @@ -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]));
Expand Down
Loading