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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ jobs:
- run: npm run demo:politicas
- run: npm run demo:actividad
- run: npm run demo:modo-de-sala
- run: npm run demo:ver-base
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"demo:cuentas": "npm run demo:cuentas -w server",
"demo:cripto": "npm run demo:cripto -w server",
"demo:modo-de-sala": "npm run demo:modo-de-sala -w server",
"demo:ver-base": "npm run demo:ver-base -w server",
"demo:modo-persistido": "npm run demo:modo-persistido -w server",
"demo:sala-al-escribir": "npm run demo:sala-al-escribir -w server",
"demo:carga": "npm run demo:carga -w server --",
Expand Down
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"demo:variables": "tsx src/demos/variables.ts",
"demo:politicas": "tsx src/demos/politicas.ts",
"demo:actividad": "tsx src/demos/actividad.ts",
"demo:ver-base": "tsx src/demos/ver-base.ts",
"demo:publicar": "tsx src/demos/publicar.ts",
"demo:cuentas": "tsx src/demos/cuentas.ts",
"demo:cripto": "tsx src/demos/cripto.ts",
Expand Down
7 changes: 7 additions & 0 deletions server/src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ créalo con bash: es tu trabajo, no preguntes por dónde empezar.
conectó su base: úsalas desde el código de la app para leer y escribir filas,
y usa la tool sql para crear las tablas y sus políticas. Con esa tool tienes
todo lo que necesitas, así que NO pidas contraseñas ni llaves a nadie.
Antes de crear o cambiar tablas, y antes de escribir código que las use, mira
con ver_base qué hay: no adivines nombres de tablas ni de columnas. Con ella
también puedes revisar filas para comprobar que algo se guardó; son datos de
personas, así que no los copies al chat ni a archivos si no hace falta.
Si no están, di que se conecta desde el panel de Variables, con el botón de
Supabase, y ofrece dejar la app andando con datos de prueba mientras tanto.
- Trabajando contra Supabase hay tres reglas que NO se negocian:
Expand Down Expand Up @@ -349,6 +353,8 @@ export async function runAgent(opts: {
runner: ToolContext["runner"];
/** Con qué cambia el esquema de la base, si la sala conectó una. */
ejecutarSql?: ToolContext["ejecutarSql"];
/** Leer la base de la sala en solo lectura (tool ver_base). */
leerBase?: ToolContext["leerBase"];
/**
* El historial tal como va, para que sobreviva si el turno LANZA.
*
Expand Down Expand Up @@ -378,6 +384,7 @@ export async function runAgent(opts: {
workspaceDir,
runner: opts.runner,
ejecutarSql: opts.ejecutarSql,
leerBase: opts.leerBase,
emit: callbacks.onToolEvent,
agentId: opts.agentId,
onWaitStart: opts.onWaitStart,
Expand Down
8 changes: 7 additions & 1 deletion server/src/agent/tools/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ export interface ToolContext {
* Y sobre todo: la credencial NUNCA entra al contenedor. El agente pide que se
* corra el SQL, y quien lo corre es el server con el permiso de la sala.
*/
ejecutarSql?: (sql: string) => Promise<void>;
ejecutarSql?: (sql: string) => Promise<unknown>;
/**
* Lee la base de la sala en modo solo lectura y devuelve las filas, o nada si
* no hay base. Mismo trato que `ejecutarSql`: capacidad, no credencial. Quien
* garantiza que no escribe es Supabase (`read_only`), no la tool.
*/
leerBase?: (sql: string) => Promise<unknown>;
}

export type ToolEvent =
Expand Down
3 changes: 2 additions & 1 deletion server/src/agent/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { fsTools } from "./fs.js";
import { bashTool } from "./bash.js";
import { usarAdjuntoTool } from "./adjuntos.js";
import { sqlTool } from "./sql.js";
import { verBaseTool } from "./ver-base.js";

export * from "./base.js";

/** Todas las tools del agente. */
export const allTools: Tool[] = [...fsTools, bashTool, usarAdjuntoTool, sqlTool];
export const allTools: Tool[] = [...fsTools, bashTool, usarAdjuntoTool, sqlTool, verBaseTool];

/** Registro por nombre, para despachar los tool_use. */
export const toolRegistry = new Map<string, Tool>(allTools.map((t) => [t.spec.name, t]));
Expand Down
16 changes: 14 additions & 2 deletions server/src/agent/tools/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export const sqlTool: Tool = {
"Toda tabla nueva nace con RLS activo, así que crea también sus políticas o la app " +
"no va a poder leer nada. Nunca desactives RLS, y no escribas políticas using (true) " +
"ni with check (true) para escribir: la base tiene login anónimo, así que cada fila " +
"puede compararse con auth.uid().",
"puede compararse con auth.uid(). Para ver qué tablas y columnas hay, o revisar filas, " +
"usa ver_base.",
input_schema: {
type: "object",
properties: {
Expand Down Expand Up @@ -76,7 +77,18 @@ export const sqlTool: Tool = {
);
}

await ctx.ejecutarSql(sql);
try {
await ctx.ejecutarSql(sql);
} catch (err) {
// Lo que diga Postgres (una tabla que ya existe, una columna que no) es
// justo lo que el agente necesita para corregir. Como `error inesperado`
// parecía una falla de Multi, no de su SQL.
const msg = err instanceof Error ? err.message : String(err);
throw new ToolError(
`la base rechazó el SQL y no se guardó migración: ${msg}. Revisa la estructura ` +
"actual con ver_base antes de reintentar.",
);
}

// La migración se guarda DESPUÉS de que corrió, no antes: un archivo que
// describe un cambio que falló es peor que no tenerlo, porque quien rehaga
Expand Down
171 changes: 171 additions & 0 deletions server/src/agent/tools/ver-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { type Tool, ToolError } from "./base.js";
import { CONSULTA_ESQUEMA } from "../../supabase.js";

/**
* Ver la base que la sala conectó: su estructura, o las filas de una consulta.
*
* Por qué existe: la tool `sql` solo cambia la estructura y contesta "listo".
* Sin ver qué tablas y columnas hay, el agente las adivinaba y escribía código
* contra columnas que no existían, sobre todo en un proyecto adoptado, donde
* `migraciones/` está vacía aunque la base no. Tampoco podía comprobar si algo
* se guardó.
*
* Nunca escribe. Quien lo garantiza es Supabase, que corre todo en una
* transacción de solo lectura; lo que se revisa aquí es solo para contestar
* claro antes de gastar una llamada. Por eso tampoco deja migración.
*/

/** Cuántas filas llegan al agente, como mucho. */
export const MAX_FILAS = 50;
/** Y cuánto texto: una fila puede traer un JSON enorme. */
const MAX_CARACTERES = 8000;

export const verBaseTool: Tool = {
spec: {
name: "ver_base",
description:
"Mira la base de datos de la sala, sin cambiar nada. Sin `consulta`, devuelve la " +
"estructura: tablas, columnas, llaves, si tienen RLS y sus políticas. Úsalo ANTES de " +
"crear o cambiar tablas y antes de escribir código que las use, en vez de adivinar " +
`columnas. Con \`consulta\` (un SELECT), devuelve hasta ${MAX_FILAS} filas: sirve para ` +
"comprobar que algo se guardó o contar registros. No pasa por la app: lo que ves aquí " +
"puede no ser lo que la app ve, porque la app pasa por las políticas de RLS.",
input_schema: {
type: "object",
properties: {
consulta: {
type: "string",
description: "Opcional. Un SELECT (o WITH … SELECT) para ver filas. Sin esto, la estructura.",
},
},
},
},
async run(input, ctx) {
if (!ctx.leerBase) {
throw new ToolError(
"esta sala no tiene base de datos conectada. Se conecta desde el panel de Variables, " +
"con el botón de Supabase. Dilo en el chat y sigue con lo que sí puedas hacer.",
);
}
const consulta = typeof input.consulta === "string" ? input.consulta.trim() : "";

if (!consulta) {
return describirEsquema(await leer(ctx.leerBase, CONSULTA_ESQUEMA));
}

if (!esDeLectura(consulta)) {
throw new ToolError(
"ver_base solo lee: la consulta tiene que empezar con SELECT, WITH, EXPLAIN, SHOW, " +
"TABLE o VALUES. Para cambiar la estructura usa la tool sql; las filas de la app " +
"las escribe el código de la app.",
);
}
return describirFilas(await leer(ctx.leerBase, conTope(consulta)));
},
};

async function leer(leerBase: (sql: string) => Promise<unknown>, sql: string): Promise<unknown> {
try {
return await leerBase(sql);
} catch (err) {
// Lo que diga Postgres le sirve al agente para corregir la consulta. Como
// `error inesperado` parecía una falla de Multi, no de su SQL.
const msg = err instanceof Error ? err.message : String(err);
throw new ToolError(`la base respondió con error, no se leyó nada: ${msg}`);
}
}

/** Quita comentarios y espacios del principio, que no cambian qué es la consulta. */
function sinComentariosAlInicio(sql: string): string {
let s = sql.trimStart();
for (;;) {
if (s.startsWith("--")) s = s.slice(s.indexOf("\n") + 1 || s.length).trimStart();
else if (s.startsWith("/*")) s = s.slice((s.indexOf("*/") + 2) || s.length).trimStart();
else return s;
}
}

export function esDeLectura(sql: string): boolean {
return /^(select|with|explain|show|table|values)\b/i.test(sinComentariosAlInicio(sql));
}

/**
* Le pone tope a una consulta de un solo SELECT, para que una tabla grande no
* viaje entera solo para recortarla aquí. Se pide una fila de más para saber si
* había más. Lo que no se puede envolver (un EXPLAIN, varias sentencias) va tal
* cual y se recorta al llegar.
*/
export function conTope(sql: string): string {
const limpia = sinComentariosAlInicio(sql).replace(/;\s*$/, "");
if (limpia.includes(";") || !/^(select|with)\b/i.test(limpia)) return sql;
return `select * from (\n${limpia}\n) as consulta limit ${MAX_FILAS + 1}`;
}

export function describirFilas(respuesta: unknown): string {
const filas = Array.isArray(respuesta) ? respuesta : [];
if (filas.length === 0) return "0 filas.";
const vistas = filas.slice(0, MAX_FILAS);
let texto = vistas.map((f) => JSON.stringify(f)).join("\n");
if (texto.length > MAX_CARACTERES) texto = texto.slice(0, MAX_CARACTERES) + "\n… (recortado)";
const cuantas =
filas.length > MAX_FILAS
? `más de ${MAX_FILAS} filas; aquí van las primeras ${MAX_FILAS}. Filtra o agrega para ver el resto.`
: `${filas.length} ${filas.length === 1 ? "fila" : "filas"}.`;
return `${cuantas}\n${texto}`;
}

interface Columna {
nombre: string;
tipo: string;
nulo: boolean;
default: string | null;
}
interface Politica {
nombre: string;
para: string;
roles: string[] | string | null;
using: string | null;
check: string | null;
}
interface TablaDeEsquema {
tabla: string;
rls: boolean;
rls_forzado: boolean;
columnas: Columna[];
llaves: string[];
politicas: Politica[];
}

/** La estructura, en el texto más corto que el agente pueda usar sin adivinar. */
export function describirEsquema(respuesta: unknown): string {
const tablas = (Array.isArray(respuesta) ? respuesta : []) as TablaDeEsquema[];
if (tablas.length === 0) return "La base no tiene tablas todavía (esquema public vacío).";

const bloques = tablas.map((t) => {
const rls = t.rls_forzado ? "RLS forzado" : t.rls ? "RLS activo" : "SIN RLS";
const lineas = [`${t.tabla} (${rls})`];
for (const c of t.columnas ?? []) {
const partes = [` ${c.nombre} ${c.tipo}`];
if (!c.nulo) partes.push("not null");
if (c.default) partes.push(`default ${c.default}`);
lineas.push(partes.join(" "));
}
for (const k of t.llaves ?? []) lineas.push(` ${k}`);
const politicas = t.politicas ?? [];
if (politicas.length === 0 && t.rls) {
lineas.push(" sin políticas: con RLS así, la app no puede leer ni escribir nada");
}
for (const p of politicas) {
const roles = Array.isArray(p.roles) ? p.roles.join(",") : (p.roles ?? "").replace(/[{}]/g, "");
let linea = ` política "${p.nombre}" ${p.para} to ${roles || "public"}`;
if (p.using) linea += ` using (${p.using})`;
if (p.check) linea += ` with check (${p.check})`;
lineas.push(linea);
}
return lineas.join("\n");
});

let texto = `${tablas.length} ${tablas.length === 1 ? "tabla" : "tablas"} en public:\n\n${bloques.join("\n\n")}`;
if (texto.length > MAX_CARACTERES * 2) texto = texto.slice(0, MAX_CARACTERES * 2) + "\n… (recortado)";
return texto;
}
1 change: 1 addition & 0 deletions server/src/demos/actividad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ check("leer ShareDialog.tsx", leer.tipo === "leer" && leer.archivo === "ShareDia
check("y el detalle guarda la ruta completa", leer.detalle === "src/components/ShareDialog.tsx");
const sql = accionDeTool("sql", { sql: "create table x ()", descripcion: "crea-tabla-x" });
check("sql es cambiar la base", sql.tipo === "baseDeDatos");
check("ver_base es mirarla", accionDeTool("ver_base", {}).tipo === "verBase");
check("una tool desconocida no se pierde", accionDeTool("nueva_tool", {}).detalle === "nueva_tool");

console.log(`\n${pass} pasaron, ${fail} fallaron\n`);
Expand Down
Loading
Loading