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
97 changes: 97 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: CI

# Quality gate only — no deploy job here on purpose. Deploy stays manual
# on Coolify for now; this workflow's job is to make sure nothing merges
# that fails tests, lint, type-check, or the Docker build.

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
# Lint and typecheck are platform-independent — running once keeps this
# fast, and a formatting or typing error isn't OS-specific.
static:
name: Lint & Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
requirements.txt
requirements-dev.txt

- name: Install dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt
pip install ruff pyright

- name: Ruff lint
run: ruff check .

- name: Ruff format check
run: ruff format --check .

- name: Pyright type check
run: pyright

# Single Python version, single OS: this app only ever runs as a Linux
# container in production (see Dockerfile — python:3.12-slim-bookworm),
# unlike a published library that has to support whatever environment
# its consumers pick. A cross-platform/version matrix here would add CI
# time without covering any code path that actually ships.
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
requirements.txt
requirements-dev.txt

- name: Install dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt

- name: Pytest
run: pytest -v

# Proves the actual production artifact builds — the Dockerfile is what
# deploys (Coolify), and until this job existed nothing in CI verified
# it still builds after a change. Build only, never pushed anywhere.
docker-build:
name: Docker Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image (no push)
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: nullain-agent:ci
cache-from: type=gha
cache-to: type=gha,mode=max
87 changes: 60 additions & 27 deletions agent/attachments_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import logging
import os
import re
import threading
Expand All @@ -18,6 +19,8 @@

from agent.file_parse import ParsedAttachment

logger = logging.getLogger("nullain.attachments_store")

# Cache em memória (dev / fallback quando Neon off).
# LRU com tamanho máximo — evita leak em processo longo sem Neon.
_MEMORY_MAX_ENTRIES = 256
Expand All @@ -28,6 +31,11 @@
_pool: Any = None
_pool_lock = threading.Lock()

# Mantém referência forte às tasks fire-and-forget de save_attachment() —
# sem isso o event loop pode coletar a task antes dela terminar (RUF006).
# Cada task se auto-remove do set no done_callback.
_persist_tasks: set[Any] = set()


def _memory_set(key: str, value: dict[str, Any]) -> None:
with _MEMORY_LOCK:
Expand Down Expand Up @@ -72,10 +80,10 @@ async def _get_pool():
return _pool
try:
from psycopg_pool import AsyncConnectionPool
except ImportError:
except ImportError as exc:
raise RuntimeError(
"psycopg não instalado. Rode: pip install 'psycopg[binary]'"
)
"psycopg-pool não instalado. Rode: pip install psycopg-pool"
) from exc
url = _get_database_url()
_pool = AsyncConnectionPool(
conninfo=url,
Expand All @@ -91,25 +99,23 @@ async def _get_pool():
async def _execute(sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
"""Executa query e retorna linhas como dicts."""
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(sql, params or [])
try:
rows = await cur.fetchall()
except Exception: # noqa: BLE001
return []
if not rows:
return []
cols = [desc[0] for desc in cur.description or []]
return [dict(zip(cols, row)) for row in rows]
async with pool.connection() as conn, conn.cursor() as cur:
await cur.execute(sql, params or [])
try:
rows = await cur.fetchall()
except Exception: # noqa: BLE001
return []
if not rows:
return []
cols = [desc[0] for desc in cur.description or []]
return [dict(zip(cols, row, strict=False)) for row in rows]


async def _execute_write(sql: str, params: list[Any] | None = None) -> None:
"""Executa INSERT/UPDATE/DELETE."""
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(sql, params or [])
async with pool.connection() as conn, conn.cursor() as cur:
await cur.execute(sql, params or [])


def save_attachment(
Expand Down Expand Up @@ -150,20 +156,22 @@ def save_attachment(

if not neon_configured():
payload["warning"] = (
"Neon não configurado — arquivo parseado em memória "
"(não persistido no banco)."
"Neon não configurado — arquivo parseado em memória (não persistido no banco)."
)
return payload

# Agenda persistência async no event loop
try:
loop = asyncio.get_running_loop()
loop.create_task(_persist_async(file_id, parsed, user_id, thread_id))
task = loop.create_task(_persist_async(file_id, parsed, user_id, thread_id))
_persist_tasks.add(task)
task.add_done_callback(_persist_tasks.discard)
payload["persisted"] = True
except RuntimeError:
# Sem event loop (ex.: CLI mode) — faz sync via thread
try:
import asyncio as _asyncio

_asyncio.run(_persist_async(file_id, parsed, user_id, thread_id))
payload["persisted"] = True
except Exception as exc: # noqa: BLE001
Expand Down Expand Up @@ -236,8 +244,17 @@ def fetch_attachments_by_ids(
for item in result:
_memory_set(str(item["fileId"]), item)
out.append(item)
except Exception: # noqa: BLE001
pass
except Exception:
# Read failure: the turn still has whatever was cached in
# memory, so degrade instead of failing the whole /api/chat
# request — but a silent drop here previously left no trace
# if Neon was actually down/misconfigured.
logger.warning(
"Falha ao buscar anexos no Neon (missing=%s, user_id=%s)",
missing,
user_id,
exc_info=True,
)

# preserva ordem pedida
by_id = {str(x.get("fileId")): x for x in out}
Expand All @@ -255,15 +272,20 @@ async def _fetch_from_neon(
# psycopg não suporta IN com lista dinâmica facilmente via parâmetros
# Usamos ANY($1::uuid[]) com cast
placeholders = ", ".join(f"${i + 1}" for i in range(len(file_ids)))
params = file_ids + [user_id]
params = [*file_ids, user_id]

rows = await _execute(
# S608: the f-string only interpolates `$1, $2, ...` positional
# placeholders and a param count — never `file_ids`/`user_id`
# content, which travel through `params` below and stay
# properly parameterized. Nothing attacker-controlled reaches
# the SQL string itself.
f"""
SELECT id, filename, file_type, file_size, mime_type,
extracted_text, char_count, is_truncated, storage_path, user_id
FROM chat_attachments
WHERE id IN ({placeholders}) AND user_id = ${len(file_ids) + 1}
""",
""", # noqa: S608
params,
)

Expand Down Expand Up @@ -310,6 +332,17 @@ async def delete_attachments_by_thread(thread_id: str, user_id: str) -> None:
"DELETE FROM chat_attachments WHERE thread_id = $1 AND user_id = $2",
[thread_id, user_id],
)
except Exception: # noqa: BLE001
pass

except Exception:
# A failed delete leaves attachments orphaned in Neon with no
# trace anywhere — log before re-raising so the failure is at
# least visible. The caller (web/server.py's delete_thread
# route) already wraps this call in its own try/except and
# always returns {"ok": True} regardless, so re-raising here
# does not change observable API behavior.
logger.error(
"Falha ao deletar anexos no Neon (thread_id=%s, user_id=%s)",
thread_id,
user_id,
exc_info=True,
)
raise
8 changes: 4 additions & 4 deletions agent/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
# por agent/external_mcp.py e agent/zuckpay.py para tokens/config) já é o
# padrão para estado persistente deste app — um subdiretório próprio evita
# misturar arquivos livres do agente com esse estado sensível.
_AGENT_WORKSPACE_ROOT_PATH = Path(os.getenv("NULLAIN_DATA_DIR", "data")).resolve() / "agent_workspace"
_AGENT_WORKSPACE_ROOT_PATH = (
Path(os.getenv("NULLAIN_DATA_DIR", "data")).resolve() / "agent_workspace"
)
_AGENT_WORKSPACE_ROOT_PATH.mkdir(parents=True, exist_ok=True)
_AGENT_WORKSPACE_ROOT = str(_AGENT_WORKSPACE_ROOT_PATH)

Expand Down Expand Up @@ -428,9 +430,7 @@ async def stream_bridge_events(
delimitadores `<<<UNTRUSTED_CONTEXT_BEGIN/END>>>`, então isso é seguro
mesmo indo como parte do mesmo prompt.
"""
agent = await get_or_build_bridge_agent(
thread_id, settings, user_email=user_email, model=model
)
agent = await get_or_build_bridge_agent(thread_id, settings, user_email=user_email, model=model)

ctx = (system_context or "").strip()
full_message = f"{ctx}\n\n{message}" if ctx else message
Expand Down
4 changes: 1 addition & 3 deletions agent/bridge_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,7 @@ def _registered_tool_from_pydantic(
)


def register_zuckpay_tools(
registry: ToolRegistry, *, user_email: str | None
) -> list[str]:
def register_zuckpay_tools(registry: ToolRegistry, *, user_email: str | None) -> list[str]:
"""Registra as 4 tools ZuckPay, com `user_email` capturado por closure.

Reaproveita as funções `_create_pix`/`_create_spei`/`_payment_status`/
Expand Down
28 changes: 7 additions & 21 deletions agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,7 @@ def sandbox_enabled(self) -> bool:
@property
def sandbox_allowed_package_set(self) -> frozenset[str]:
raw = self.sandbox_allowed_packages or ""
return frozenset(
p.strip().lower()
for p in raw.split(",")
if p.strip()
)
return frozenset(p.strip().lower() for p in raw.split(",") if p.strip())

@property
def sandbox_aio_enabled(self) -> bool:
Expand All @@ -148,8 +144,7 @@ def llm_api_key(self) -> str:
if self.llm_provider == "xai":
if not self.xai_api_key:
raise ValueError(
"XAI_API_KEY não definida. "
"Crie uma em https://console.x.ai e coloque no .env"
"XAI_API_KEY não definida. Crie uma em https://console.x.ai e coloque no .env"
)
return self.xai_api_key
if self.llm_provider == "openai":
Expand All @@ -160,8 +155,7 @@ def llm_api_key(self) -> str:
)
return self.openai_api_key
raise ValueError(
f"LLM_PROVIDER inválido: {self.llm_provider!r}. "
"Use 'ollama', 'xai' ou 'openai'."
f"LLM_PROVIDER inválido: {self.llm_provider!r}. Use 'ollama', 'xai' ou 'openai'."
)

@property
Expand Down Expand Up @@ -194,9 +188,7 @@ def get_settings() -> Settings:
"OLLAMA_BASE_URL",
defaults.get("base_url") or "https://ollama.com/v1",
).strip(),
composio_mcp_url=os.getenv(
"COMPOSIO_MCP_URL", "https://connect.composio.dev/mcp"
).strip(),
composio_mcp_url=os.getenv("COMPOSIO_MCP_URL", "https://connect.composio.dev/mcp").strip(),
composio_consumer_api_key=consumer_key,
composio_user_id=os.getenv("COMPOSIO_USER_ID", "default").strip(),
# Apps/MCPs ativos no Composio (ícones no rail direito da UI)
Expand All @@ -213,20 +205,14 @@ def get_settings() -> Settings:
wavespeed_api_key=os.getenv("WAVESPEED_API_KEY", "").strip(),
# Groq Vision (Llama 3.2 11B) — pre-pass de imagens anexadas
groq_api_key=os.getenv("GROQ_API_KEY", "").strip(),
groq_vision_model=os.getenv(
"GROQ_VISION_MODEL", "llama-3.2-11b-vision-preview"
).strip(),
groq_vision_model=os.getenv("GROQ_VISION_MODEL", "llama-3.2-11b-vision-preview").strip(),
# Sandbox code interpreter
sandbox_provider=os.getenv("SANDBOX_PROVIDER", "").strip().lower(),
sandbox_e2b_api_key=os.getenv("E2B_API_KEY", "").strip(),
sandbox_docker_image=os.getenv(
"SANDBOX_DOCKER_IMAGE", "python:3.11-slim"
).strip(),
sandbox_docker_image=os.getenv("SANDBOX_DOCKER_IMAGE", "python:3.11-slim").strip(),
sandbox_timeout_sec=int(os.getenv("SANDBOX_TIMEOUT_SEC", "120")),
sandbox_max_file_size_mb=int(os.getenv("SANDBOX_MAX_FILE_SIZE_MB", "50")),
sandbox_allowed_packages=os.getenv(
"SANDBOX_ALLOWED_PACKAGES", ""
).strip(),
sandbox_allowed_packages=os.getenv("SANDBOX_ALLOWED_PACKAGES", "").strip(),
# AIO Sandbox (SANDBOX_PROVIDER=aio) — endpoint HTTP do container
sandbox_aio_url=os.getenv("SANDBOX_URL", "http://localhost:8080").strip(),
sandbox_aio_api_key=os.getenv("SANDBOX_API_KEY", "").strip(),
Expand Down
Loading
Loading