Construyendo a hey-frank #4 — Agents y streaming con Google ADK
Hoy, cuarta receta del set para construir una app agéntica.
Crédito: estas notas están fuertemente basadas en la serie Building a Production-Ready Agent Stack de Bedir Tapkan (bedirtapkan.com). Gracias por el material original; acá lo adapté a mi stack (Gemini/ADK + Firebase).
Partamos sin más preámbulos!
El mapa mental: las piezas de Google ADK
Antes de escribir código, conviene entender las 5 piezas con las que ADK arma un agente. Son pocas y encajan limpio:
Agent— la definición del agente: un modelo (Gemini) + sus instrucciones (el system prompt)- sus tools. Es el “quién es y qué sabe hacer”. No corre nada por sí solo; es la receta.
- Tools — funciones Python normales que el agente puede llamar (ej. “dame la hora”). ADK las envuelve solas a partir de su firma y su docstring; no hay decorador obligatorio.
SessionService— la memoria. Administra las conversaciones, cada una identificada por la tripleta(app_name, user_id, session_id). Importante: la sesión de ADK ya lleva eluser_idadentro, así que ADK sabe de quién es cada conversación. Hay dos versiones:InMemory...(se pierde al reiniciar, sirve para probar) yDatabase...(persiste en Postgres).Runner— el motor que corre el agente. Le das un mensaje y él ejecuta el loop: llama al modelo, si el modelo pide una tool la ejecuta, vuelve a llamar al modelo, y así hasta tener la respuesta. Tú no escribes ese loop — ADK lo maneja.Event— cada cosa que ocurre durante un run llega como un objetoEvent: un pedacito de texto, una llamada a tool, su resultado, el conteo de tokens. ElRunnerte entrega un stream de Events que tú vas leyendo.
Cómo se conectan, en una frase:
Agent (modelo + prompt + tools) + SessionService (memoria) → Runner → stream de Events
Y una distinción que ordena toda la receta: unas piezas son de ADK, otras son tuyas.
- De ADK:
Agent,Runner,SessionService, losEvent(el cerebro y su loop). - Tuyo: el endpoint SSE que expone todo eso por HTTP — auth por cookie, query params, y el
contrato de eventos
token/tool_call/usage/doneque tu frontend espera. Eso lo decides tú, ADK no se mete.
Paso 1 — Dependencias
📋 Comando — desde backend/:
uv add google-adk
Paso 2 — Config de Gemini
Por qué una sola fuente de config: no creamos un core/config.py aparte para el agente — eso
sería duplicar config. Sumamos los dos campos del agente a la clase Settings que ya existe en
core/settings.py. Una sola fuente de verdad, importada igual en todo el backend.
📋 Agregar a core/settings.py (junto al resto de campos, en minúsculas como el resto):
# Agente (Gemini / ADK)
gemini_model: str = "gemini-2.5-flash"
agent_max_turns: int = 10
# Nota: GOOGLE_API_KEY NO va acá. ADK la lee del entorno del SO directamente,
# no del objeto settings (ver el ⚠️ de abajo).
📋 backend/.env:
GOOGLE_API_KEY=...tu-key-de-google-ai-studio...
GEMINI_MODEL=gemini-2.5-flash
⚠️ Gotcha — modelo y cuota (verificado jun 2026):
gemini-2.0-flashestá retirado → da404 NOT_FOUND(“model is no longer available”). Usa un modelo actual:gemini-2.5-flash(ogemini-2.5-flash-lite/gemini-flash-latest).- 429 RESOURCE_EXHAUSTED con
limit: 0al primer run = tu proyecto no tiene free tier para ese modelo. No es un rate limit pasajero (esperar no ayuda). Fix: genera la key en Google AI Studio (auto-provisiona free tier) y/o habilita billing en el proyecto. El 429 desaparece cuando la cuota deja de ser 0.
Paso 3 — Modelo Message, migración y repo
Por qué: input y output cuestan distinto; para calcular costo real los guardamos por separado.
📋 Agregar al modelo Message en persistence/models.py (junto al tokens que ya existe):
tokens: Mapped[int] = mapped_column(Integer, default=0) # total
input_tokens: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
output_tokens: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
📋 Actualizar repositories/message_repo.create() :
async def create(self, db, session_id, role, content,
tool_name=None, trace_id=None, span_id=None,
tokens=0, input_tokens=0, output_tokens=0) -> Message: # ← nuevos params
message = Message(session_id=session_id, role=role, content=content,
tool_name=tool_name, trace_id=trace_id, span_id=span_id,
tokens=tokens, input_tokens=input_tokens, output_tokens=output_tokens)
db.add(message); await db.flush(); await db.refresh(message)
return message
📋 Migración:
make migrate-create msg="add input_tokens and output_tokens to messages"
# revisa el archivo generado, luego:
make migrate
Paso 4 — Las tools (agents/agent_assistant/tools.py)
Por qué: en ADK una tool es una función Python normal con type hints y docstring; el SDK la envuelve sola. No hay decorador obligatorio.
📋 Código:
from datetime import datetime
from zoneinfo import ZoneInfo
DEFAULT_TZ = ZoneInfo("America/Santiago")
def get_current_time() -> dict:
"""Get the current time in HH:MM:SS format, in America/Santiago timezone."""
return {"time": datetime.now(DEFAULT_TZ).strftime("%H:%M:%S (%Z)")}
def get_current_date() -> dict:
"""Get the current date in YYYY-MM-DD format, in America/Santiago timezone."""
return {"date": datetime.now(DEFAULT_TZ).strftime("%Y-%m-%d")}
💡 ADK recomienda que las tools devuelvan un
dict(no un string), con una clave descriptiva. El docstring sigue siendo lo que el modelo lee para decidir cuándo usarla.
Paso 5 — El system prompt
Por qué: el system prompt es el “carácter” del agente — define quién es, cómo responde, y cuándo
usar sus tools. El agent.py (Paso 6) lo lee con load_system_prompt(), así que este archivo tiene
que existir o el agente se cae al construirse. En ADK se pasa como instruction (singular).
📋 Código — backend/app/agents/agent_assistant/prompts/system.md:
Eres "hey-frank", un asistente conversacional útil, directo y amable.
## Cómo respondes
- Responde en el idioma del usuario (español por defecto).
- Sé conciso: ve al grano, sin relleno ni disculpas innecesarias.
- Si no sabes algo o no tienes una tool para resolverlo, dilo con honestidad.
## Tus herramientas
- `get_current_time`: úsala cuando el usuario pregunte la hora actual.
- `get_current_date`: úsala cuando el usuario pregunte la fecha de hoy.
No inventes la hora ni la fecha: para eso usa siempre las tools. La zona horaria de referencia es
America/Santiago.
El docstring de cada tool (Paso 4) es lo que el modelo lee para decidir cuándo llamarla; este prompt le da el contexto general. Ajústalo a gusto — es lo más fácil de iterar.
Paso 6 — Construir el agente (agents/agent_assistant/agent.py)
📋 Código:
from pathlib import Path
from google.adk.agents import Agent # en algunas versiones se importa como LlmAgent
from app.core.settings import settings
from .tools import get_current_date, get_current_time
def load_system_prompt() -> str:
return (Path(__file__).parent / "prompts" / "system.md").read_text()
def build_agent() -> Agent:
return Agent(
name="assistant",
model=settings.gemini_model, # "gemini-2.5-flash"
instruction=load_system_prompt(), # ojo: "instruction", no "instructions"
tools=[get_current_time, get_current_date],
)
💡 Ojo con tres detalles según tu versión de ADK:
Agent(alias deLlmAgent), el parámetroinstruction(singular), y que el modelo se pase como string. La key se toma del entorno (GOOGLE_API_KEY), no se inyecta a mano.
Paso 7 — Session service + runner (core/agents.py)
Por qué: ADK no maneja “una sesión por conversación” como un objeto suelto que tú creas y pasas;
usa un SessionService central que administra todas las conversaciones, cada una identificada por
la tripleta (app_name, user_id, session_id). Como el user_id va dentro de esa identidad, ADK
sabe a qué usuario pertenece cada sesión sin que tú lleves el scoping por fuera. El Runner se
apoya en este servicio para recuperar el historial en cada turno.
En puertos y adaptadores: el agente (Gemini/ADK) es un adaptador de salida detrás de un “puerto de LLM” — por eso vive aislado en
app/agents/y es swappable. El endpoint de streaming (Paso 8) es un adaptador de entrada.
📋 Código:
from google.adk.runners import Runner
from google.adk.sessions import DatabaseSessionService
from app.core.settings import settings
APP_NAME = "agent_stack"
# ADK crea sus propias tablas (sessions, events, app_states...) y la suya `sessions`
# COLISIONA con la tabla `sessions` de la app. Por eso le damos una BD aparte (misma
# instancia de Postgres, otra base: `adk`). Derivamos la URL de la principal.
_adk_db_url = str(settings.database_url).rsplit("/", 1)[0] + "/adk"
_session_service = DatabaseSessionService(db_url=_adk_db_url)
def get_session_service() -> DatabaseSessionService:
return _session_service
def build_runner(agent) -> Runner:
return Runner(agent=agent, app_name=APP_NAME, session_service=get_session_service())
⚠️ Gotcha importante — colisión de tablas. Si apuntas
DatabaseSessionServicea tu misma base (settings.database_url), revienta al primer run con algo como:column "app_name" referenced in foreign key constraint does not exist(al crear la tablaevents). La razón: ADK quiere crear sus propias tablassessions/events/app_states, pero ya existe tu tablasessions(con otra forma), y los FK de ADK chocan. Fix: darle a ADK una BD separada. Créala una vez:docker compose exec db psql -U postgres -c "CREATE DATABASE adk;"y apunta el service a ella (el código de arriba ya lo hace, derivando
.../adkde tudatabase_url). Así las tablas de ADK viven aisladas de las tuyas.
DatabaseSessionServiceconstruye su engine concreate_async_enginey unasync_sessionmaker— es async nativo. Le pasas la URL con+asyncpgtal cual; no hay que convertirla a sync. Para una primera prueba sin persistencia (y sin crear la BDadk) sirvefrom google.adk.sessions import InMemorySessionService→InMemorySessionService(), que no crea tablas.
Paso 8 — El endpoint de streaming (api/stream.py)
Por qué: este es el paso donde todo se junta. El usuario escribe algo y necesitamos:
- (1) correr el agente, y
- (2) que la respuesta llegue al navegador palabra por palabra en vez de esperar el bloque completo. Eso se hace con SSE (Server-Sent Events): una respuesta HTTP que se mantiene abierta y va empujando líneas de a poco.
El archivo tiene dos mitades, y conviene verlas separadas:
- El endpoint HTTP (
@router.get("/")) — la parte tuya, no de ADK. Se encarga de la auth (por cookie de stream, no JWT — recuerda queEventSourceno manda headers), chequear elOrigin(anti-CSRF), confirmar que la sesión es del usuario, guardar el mensaje del usuario, y abrir elStreamingResponse. generate_agent_events— la parte que habla con ADK. Es un generador async: corre elRunner, va recibiendo el stream deEventdel agente, y traduce cadaEventde ADK al formato de eventos SSE que tu frontend entiende (token,tool_call,usage,done…).
La idea clave: ADK te da el loop y los
Event; tu trabajo acá es mapear esosEventa tu contrato SSE y manejar la conexión HTTP. Vamos a leer el código por esas dos mitades.
📋 Código — backend/app/api/stream.py (completo, versión ADK):
import json
import logging
from typing import AsyncGenerator
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from google.genai import types
from google.adk.agents.run_config import RunConfig, StreamingMode # la ruta puede variar según la versión
from app.agents.agent_assistant.agent import build_agent
from app.core.agents import APP_NAME, build_runner, get_session_service
from app.core.auth import get_user_from_stream_cookie
from app.core.database import get_db
from app.core.settings import settings
from app.domain.services.user_service import user_service
from app.persistence.repositories.message_repo import message_repo
from app.persistence.repositories.session_repo import session_repo
logger = logging.getLogger("agent")
router = APIRouter(prefix="/stream", tags=["streaming"])
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
async def generate_agent_events(session_id, message_content, user_id, db):
try:
runner = build_runner(build_agent())
svc = get_session_service()
uid, sid = str(user_id), str(session_id)
# ADK exige que la sesión exista antes de correr
existing = await svc.get_session(app_name=APP_NAME, user_id=uid, session_id=sid)
if not existing:
await svc.create_session(app_name=APP_NAME, user_id=uid, session_id=sid)
new_message = types.Content(role="user", parts=[types.Part(text=message_content)])
final_text, usage = "", None
async for event in runner.run_async(
user_id=uid, session_id=sid, new_message=new_message,
run_config=RunConfig(streaming_mode=StreamingMode.SSE),
):
# 1) texto (los chunks parciales traen event.partial = True)
if event.content and event.content.parts:
text = "".join(p.text or "" for p in event.content.parts)
if text:
if getattr(event, "partial", False):
yield _sse("token", {"delta": text})
else:
final_text = text
# 2) tool calls / resultados
for fc in event.get_function_calls():
yield _sse("tool_call", {"name": fc.name, "args": json.dumps(fc.args)})
for fr in event.get_function_responses():
yield _sse("tool_result", {"result": str(fr.response)})
# 3) usage (viene en el/los eventos del modelo)
if getattr(event, "usage_metadata", None):
usage = event.usage_metadata
in_tok = getattr(usage, "prompt_token_count", 0) or 0
out_tok = getattr(usage, "candidates_token_count", 0) or 0
total = getattr(usage, "total_token_count", 0) or 0
msg = await message_repo.create(
db, session_id=session_id, role="assistant", content=final_text,
tokens=total, input_tokens=in_tok, output_tokens=out_tok,
)
yield _sse("usage", {"input_tokens": in_tok, "output_tokens": out_tok, "total_tokens": total})
yield _sse("done", {"message_id": msg.id, "session_id": session_id})
except Exception as e:
logger.exception("agent_run_failed")
yield _sse("error", {"code": type(e).__name__, "message": str(e)})
@router.get("/")
async def stream_agent_response(
request: Request,
session_id: int,
message: str,
subject: str = Depends(get_user_from_stream_cookie), # cookie, no JWT
db: AsyncSession = Depends(get_db),
):
"""SSE endpoint. Requiere la cookie de stream (llamar POST /auth/session antes)."""
origin = request.headers.get("origin")
if origin not in (settings.cors_origins or []):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Origin not allowed")
user = await user_service.get_or_create_from_subject(db, subject)
session = await session_repo.get_by_id(db, session_id)
if not session or session.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Session not found")
await message_repo.create(db, session_id=session_id, role="user", content=message)
return StreamingResponse(
generate_agent_events(session_id, message, user.id, db),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive",
"X-Accel-Buffering": "no"},
)
Los eventos SSE que recibe el frontend: token, tool_call/tool_result, usage, done, error.
El endpoint @router.get("/") (arriba) es agnóstico del proveedor: auth por cookie, CSRF por
Origin, autorización de sesión, persistir el mensaje del usuario y StreamingResponse. Lo único
específico de ADK es generate_agent_events.
Paso 8.5 — Resiliencia: retry ante rate limits (429/503)
Por qué: los modelos fallan a veces por rate limit (429) o por estar sobrecargados (503). Son errores transitorios: reintentar un par de veces con espera suele resolverlos. Pero hay una trampa con el streaming: una vez que empezaste a mandar tokens, no puedes reintentar sin duplicar la salida. Así que la regla es reintentar solo si el error pega antes del primer token.
📋 Helper en core/agents.py (qué error vale la pena reintentar):
AGENT_MAX_RETRIES = 3 # nº de reintentos antes de rendirse
AGENT_RETRY_BASE_DELAY = 2.0 # segundos; backoff exponencial: 2, 4, 8...
def is_retryable_error(e: Exception) -> bool:
"""Transitorio = rate limit (429) o sobrecarga (503). ADK envuelve el rate limit en
`_ResourceExhaustedError`, que no siempre trae `.code`; de ahí el fallback por nombre/mensaje."""
if getattr(e, "code", None) in (429, 503):
return True
return "ResourceExhausted" in type(e).__name__ or "RESOURCE_EXHAUSTED" in str(e).upper()
📋 En generate_agent_events (Paso 8): envuelve el run en un loop de reintento, con un flag
streamed_any que marca si ya emitiste algo:
attempt = 0
while True:
streamed_any = False
final_text, usage = "", None
try:
# ... crear sesión si no existe ...
async for event in runner.run_async(...):
# cada vez que hagas yield de un token/tool: streamed_any = True
...
# ... persistir + yield usage + yield done ...
return
except Exception as e:
# reintenta SOLO si es transitorio Y todavía no streameamos nada
if is_retryable_error(e) and not streamed_any and attempt < AGENT_MAX_RETRIES:
attempt += 1
await asyncio.sleep(AGENT_RETRY_BASE_DELAY * (2 ** (attempt - 1))) # 2, 4, 8...
continue
yield _sse("error", {"code": type(e).__name__, "message": str(e)})
return
El caveat del streaming (lo advertía el template original): el retry con decorador (tipo
tenacity) sirve para una llamada no-streaming (Runner.run), donde el error se levanta dentro de un soloawait. Para streaming (run_async), el error aparece al iterar, posiblemente a mitad de la respuesta — por eso el retry va dentro del generador y solo antes del primer token (not streamed_any). Si el fallo es a mitad de stream, se emiteevent: errory listo.⚠️ Nota: al reintentar se vuelve a llamar
run_asynccon el mismo mensaje. Si ADK alcanzó a registrar el turno del usuario en su sesión antes de fallar, podría duplicarse en el contexto. Para una app de dev es aceptable; en producción querrías idempotencia más fina.
Paso 9 — Registrar los routers
📋 Agregar a backend/app/main.py:
from app.api import stream, usage
app.include_router(stream.router)
app.include_router(usage.router)
Paso 10 — Usage tracking (api/usage.py)
Por qué: como en el Paso 8 guardamos input_tokens/output_tokens por cada mensaje del agente,
ahora podemos sumarlos y estimar el costo. Solo necesitas poner los precios de Gemini (USD por
1M de tokens, input y output por separado) en las constantes de abajo.
📋 Código — backend/app/api/usage.py:
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies import get_session
from app.core.auth import get_current_user_id
from app.domain.services.user_service import user_service
from app.persistence.models import Message, Session
router = APIRouter(prefix="/api/usage", tags=["usage"])
# ⚠️ Ajusta a los precios de Gemini (USD por 1M tokens)
INPUT_PRICE_PER_1M = 0.10
OUTPUT_PRICE_PER_1M = 0.40
def _estimate_cost(input_tokens: int, output_tokens: int) -> float:
cost = (input_tokens / 1_000_000) * INPUT_PRICE_PER_1M
cost += (output_tokens / 1_000_000) * OUTPUT_PRICE_PER_1M
return round(cost, 4)
@router.get("/summary")
async def get_usage_summary(
db: AsyncSession = Depends(get_session),
subject: str = Depends(get_current_user_id),
):
user = await user_service.get_or_create_from_subject(db, subject)
query = (
select(
func.coalesce(func.sum(Message.input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(Message.output_tokens), 0).label("output_tokens"),
func.coalesce(func.sum(Message.tokens), 0).label("total_tokens"),
func.count(Message.id).label("message_count"),
)
.join(Session, Message.session_id == Session.id)
.where(Session.user_id == user.id, Message.role == "assistant")
)
row = (await db.execute(query)).first()
return {
"total_input_tokens": row.input_tokens,
"total_output_tokens": row.output_tokens,
"total_tokens": row.total_tokens,
"message_count": row.message_count,
"estimated_cost_usd": _estimate_cost(row.input_tokens, row.output_tokens),
}
@router.get("/by-session/{session_id}")
async def get_session_usage(
session_id: int,
db: AsyncSession = Depends(get_session),
subject: str = Depends(get_current_user_id),
):
user = await user_service.get_or_create_from_subject(db, subject)
session = (await db.execute(
select(Session).where(Session.id == session_id, Session.user_id == user.id)
)).scalar_one_or_none()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
query = select(
func.coalesce(func.sum(Message.input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(Message.output_tokens), 0).label("output_tokens"),
func.coalesce(func.sum(Message.tokens), 0).label("total_tokens"),
func.count(Message.id).label("message_count"),
).where(Message.session_id == session_id, Message.role == "assistant")
row = (await db.execute(query)).first()
return {
"session_id": session_id,
"total_input_tokens": row.input_tokens,
"total_output_tokens": row.output_tokens,
"total_tokens": row.total_tokens,
"message_count": row.message_count,
"estimated_cost_usd": _estimate_cost(row.input_tokens, row.output_tokens),
}
Paso 11 — Probar el agente (end-to-end)
Vamos de lo más fácil a lo más completo. Las primeras pruebas no necesitan ni HTTP ni cookie. Hazlas en orden.
A) Las tools solas (5 segundos)
Las tools son funciones Python normales: pruébalas sin nada de ADK. Desde infra/:
docker compose exec backend uv run python -c \
"from app.agents.agent_assistant.tools import get_current_time, get_current_date; \
print(get_current_time()); print(get_current_date())"
Deberías ver algo como {'time': '14:03:22 (-04)'} y {'date': '2026-06-28'}. Si esto falla, es un
problema de la tool, no del agente.
B) El agente, sin HTTP (la prueba clave)
Esta es la más importante: corre el agente directo con un Runner + InMemorySessionService (sin
BD, sin cookie, sin SSE) e imprime los Event crudos.
📋 backend/scripts/try_agent.py:
import asyncio
from google.genai import types
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from app.agents.agent_assistant.agent import build_agent
APP_NAME = "agent_stack"
async def main():
svc = InMemorySessionService()
await svc.create_session(app_name=APP_NAME, user_id="dev", session_id="s1")
runner = Runner(agent=build_agent(), app_name=APP_NAME, session_service=svc)
msg = types.Content(role="user", parts=[types.Part(text="¿Qué hora es?")])
async for event in runner.run_async(user_id="dev", session_id="s1", new_message=msg):
if event.content and event.content.parts:
text = "".join(p.text or "" for p in event.content.parts)
if text:
print(f"[texto partial={getattr(event, 'partial', None)}] {text!r}")
for fc in event.get_function_calls():
print(f"[tool_call] {fc.name} args={fc.args}")
for fr in event.get_function_responses():
print(f"[tool_result] {fr.response}")
if getattr(event, "usage_metadata", None):
print(f"[usage] {event.usage_metadata}")
if __name__ == "__main__":
asyncio.run(main())
Como scripts/ se hornea en la imagen (no es volumen), tras crear el archivo reconstruye el
backend, y corre:
cd infra && docker compose up -d --build backend
docker compose exec backend uv run python -m scripts.try_agent
💡 Si vas a iterar mucho en scripts de prueba, móntalo como volumen en el compose (
- ../backend/scripts:/app/scripts, como hiciste conalembic/) y te ahorras el rebuild cada vez.
Qué mirar: que aparezca un [tool_call] get_current_time, luego un [tool_result], después el
texto final con la hora, y un [usage] con los conteos. Si los nombres de los campos que imprime
[usage] no son prompt_token_count/candidates_token_count/total_token_count, ajústalos en el
Paso 8.
C) El endpoint SSE completo (con cookie)
Ahora sí, el camino real. La cookie de stream sale de POST /auth/session, que a su vez necesita
un ID token de Firebase en el Bearer. Lo primero es conseguir ese token.
Paso 0 — conseguir el ID token ($TOKEN)
De dónde sale el token depende de qué proveedor activaste en la receta 03:
-
Solo Google (sin frontend que te lo dé fácil): sácalo del navegador. Con tu
App.tsxde prueba corriendo enlocalhost:5173, haz login con Google y abre la consola del navegador (DevTools → Console). Importa tu helper y pídelo:// en la consola del navegador, ya logueado: const { getToken } = await import('/src/auth.ts') console.log(await getToken()) // copia el string largo que empieza con eyJ...(O agrega temporalmente un
console.log(await getToken())a un botón delApp.tsx.) -
Email/Password activado: sin navegador, pídelo a la API REST de Firebase con tu
apiKey:curl "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=TU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"email":"test@test.com","password":"test1234","returnSecureToken":true}' # copia el campo "idToken" de la respuesta
Guarda ese token en una variable de shell (dura ~1 h):
export TOKEN="eyJ...pega-aquí-el-id-token..."
Pasos 1–3 — generar la cookie y abrir el stream (desde infra/ o tu shell):
# 1) ID token → cookie de stream (la guardamos en un cookie jar local: cookies.txt)
curl -c cookies.txt -X POST http://localhost:8000/auth/session \
-H "Authorization: Bearer $TOKEN"
# → debe responder 204. Revisa cookies.txt: tiene una línea con stream_session.
# 2) crear una sesión (usa el Bearer, NO la cookie) y anota el id que devuelve
curl -X POST http://localhost:8000/api/sessions/ \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"prueba agente"}'
# 3) abrir el stream (manda la cookie con -b + header Origin OBLIGATORIO; -N = sin buffer)
curl -N -b cookies.txt -H "Origin: http://localhost:5173" -G \
--data-urlencode "session_id=1" \
--data-urlencode "message=hola, ¿qué hora es?" \
http://localhost:8000/stream/
El
-c cookies.txt(paso 1) guarda la cookie que devuelve el server; el-b cookies.txt(paso 3) la manda de vuelta. Es el equivalente por terminal a lo que el navegador hace solo. Ajusta elsession_id=1al id real que te devolvió el paso 2.
Deberías ver fluir los eventos SSE:
event: tool_call
data: {"name": "get_current_time", ...}
event: token
data: {"delta": "Son las "}
...
event: usage
data: {"input_tokens": 120, "output_tokens": 18, "total_tokens": 138}
event: done
data: {"message_id": 42, "session_id": 1}
Gotchas que pegan acá:
- 500 en el paso 2 con
'str' object cannot be interpreted as an integer(en el INSERT desessions.user_id): te falta traducirsubject → user.iden los handlers de la receta 03 (Paso 8). Elsubjectes el uid string de Firebase; eluser_idde la BD es entero. Cada handler debe haceruser = await user_service.get_or_create_from_subject(db, subject)y pasaruser.id, nosubject. Aplica a los 6 handlers desessions.py/messages.py.- 403 “Origin not allowed”: falta o no coincide el header
Origin(debe estar en tucors_origins).- 401: la cookie no viajó (revisa el paso 1, o que el
Path=/stream/calce).
D) Desde el frontend (opcional)
El App.tsx de prueba (receta 03) se puede extender: tras el login + POST /auth/session, abre un
EventSource a /stream/?session_id=...&message=... y pinta los token a medida que llegan.
EventSource manda la cookie solo (por eso el bridge de la 03). Esta es la prueba “de verdad”, pero
para verificar el agente las pruebas A–C alcanzan.
Checklist de “agente listo”
- A — las tools devuelven su
dict. - B —
try_agent.pyimprimetool_call→tool_result→ texto final →usagecon conteos. - Los nombres de
usage_metadatacalzan con los del Paso 8 (o los ajustaste). - C — el
curl -Nfluyetoken/tool_call/usage/doney termina sinerror. - El mensaje del agente quedó guardado en
messages(coninput_tokens/output_tokens). -
GET /api/usage/summary(con Bearer) devuelve tokens y costo estimado.
Conceptos clave de ADK (lo que conviene recordar)
- El SessionService administra el scoping. La sesión lleva
app_name + user_id + session_id, así que ADK conoce al usuario dueño de cada conversación. Aun así, autorizas la sesión en tu endpoint antes de correr el agente (no confíes solo en ADK para la seguridad). - Hay que crear la sesión antes de correr (
create_session): ADK no la crea sola en el primer uso. Por eso el Paso 8 haceget_session→ si no existe,create_session. - Los
Eventson un único tipo de objeto que describe todo lo que pasa en el run:content.parts(el texto),partial(si es un chunk parcial o el texto final),get_function_calls()/get_function_responses()(tools),usage_metadata(tokens),is_final_response(). Lees todos los eventos del mismo modo. - Gemini “thinking”:
gemini-2.5-flashrazona antes de responder, y esos tokens vienen aparte enusage_metadata.thoughts_token_count(verificado: vimosthoughts_token_count=38en una corrida). Ojo para el costo: ese conteo no está dentro decandidates_token_count(el output visible), así que si quieres el costo real del output tienes que sumarcandidates + thoughts. - DB async (verificado): tu app usa
asyncpg(async) y elDatabaseSessionServicede ADK 2.3.0 también es async por dentro (create_async_engine), así que comparten driver sin fricción: le pasas tudatabase_urlcon+asyncpgtal cual.