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
23 changes: 21 additions & 2 deletions nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,7 @@ async def run(
model: str | None = None,
internal: bool = False,
images: list[dict[str, Any]] | None = None,
image_refs: list[dict[str, Any]] | None = None,
) -> str:
"""Run the agent for a user message and return the final text response.

Expand All @@ -1256,6 +1257,8 @@ async def run(
DB or shown in the UI.
images: Optional list of image dicts with keys ``type``,
``media_type``, and ``data`` (base64-encoded).
image_refs: Optional metadata about uploaded files for persisting
in the user message blocks column (web uploads only).
"""
# Serialize runs per session — messages for the same session wait
# in order instead of failing with "already running".
Expand All @@ -1281,6 +1284,7 @@ async def run(
return await self._run_inner(
session_id, user_message, source, channel, model,
internal=internal, images=images,
image_refs=image_refs,
)
finally:
self.sessions.mark_not_running(session_id)
Expand All @@ -1301,6 +1305,7 @@ async def _run_inner(
model: str | None,
internal: bool = False,
images: list[dict[str, Any]] | None = None,
image_refs: list[dict[str, Any]] | None = None,
) -> str:
# Ensure session exists in DB
await self.sessions.get_or_create(session_id, source=source)
Expand Down Expand Up @@ -1330,10 +1335,14 @@ async def _run_inner(
# Store user message in DB (note attached images for display)
db_text = user_message
if images:
suffix = f"\n[{len(images)} image(s) attached]"
db_text = (user_message + suffix) if user_message else suffix.strip()
# Count only image/pdf entries, not text_file entries
img_count = sum(1 for img in images if img.get("type") != "text_file")
if img_count:
suffix = f"\n[{img_count} image(s) attached]"
db_text = (user_message + suffix) if user_message else suffix.strip()
await self.sessions.add_message(
session_id, "user", db_text, channel=channel,
blocks=image_refs,
)

full_response_text = ""
Expand Down Expand Up @@ -1383,6 +1392,16 @@ async def _run_inner(
if query_text:
content_blocks.append({"type": "text", "text": query_text})
for img in images:
# Text files are inlined as text context blocks
if img.get("type") == "text_file":
fname = img.get("filename", "file")
content = img.get("content", "")
content_blocks.append({
"type": "text",
"text": f"--- Attached: {fname} ---\n{content}",
})
continue

# PDFs use "document" content block; images use "image"
block_type = "document" if img["media_type"] == "application/pdf" else "image"

Expand Down
33 changes: 32 additions & 1 deletion nerve/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2157,9 +2157,40 @@ async def session_hoa_execute(args: dict) -> dict:
parts.append(f"stderr:\n{result.stderr_log[:2000]}")
return _hoa_text("\n\n".join(parts))

_SEND_FILE_SCHEMA = {
"file_path": {"type": "string", "description": "Absolute path to the file to send to the user"},
}

@tool(
"send_file",
"Send a file to the user as a downloadable attachment in the chat. "
"The file will appear inline as a download card. Use this when the user asks "
"you to share, export, or send them a file.",
_SEND_FILE_SCHEMA,
)
async def session_send_file(args: dict) -> dict:
file_path = args.get("file_path", "")
if not file_path:
return {"content": [{"type": "text", "text": "Error: file_path is required."}]}

resolved = Path(file_path).resolve()
if not resolved.exists() or not resolved.is_file():
return {"content": [{"type": "text", "text": f"Error: file not found: {file_path}"}]}

# Security: must be within workspace
if _workspace and not str(resolved).startswith(str(_workspace.resolve())):
return {"content": [{"type": "text", "text": "Error: file must be within the workspace."}]}

filename = resolved.name
file_size = resolved.stat().st_size

# The tool_call block persists in DB — frontend renders it
# as an inline download card via SendFileBlock.
return {"content": [{"type": "text", "text": f"Sent file: {filename} ({file_size:,} bytes)"}]}

# Shared tools (don't need session context) + session-scoped tools
shared_tools = [t for t in ALL_TOOLS if t.name not in ("notify", "ask_user", "react", "send_sticker")]
session_tools: list[SdkMcpTool] = [session_notify, session_ask_user, session_react, session_send_sticker]
session_tools: list[SdkMcpTool] = [session_notify, session_ask_user, session_react, session_send_sticker, session_send_file]

# Only include houseofagents tools when enabled — saves context tokens otherwise
hoa_enabled = _config and _config.houseofagents.enabled
Expand Down
2 changes: 2 additions & 0 deletions nerve/db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from nerve.db.audit import AuditStore
from nerve.db.cron import CronStore
from nerve.db.files import FileStore
from nerve.db.mcp import McpStore
from nerve.db.messages import MessageStore
from nerve.db.migrations.runner import discover_migrations, run_migrations
Expand Down Expand Up @@ -47,6 +48,7 @@ class Database(
McpStore,
AuditStore,
UsageStore,
FileStore,
):
"""Async SQLite database wrapper.

Expand Down
59 changes: 59 additions & 0 deletions nerve/db/files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Uploaded file data access methods."""

from __future__ import annotations

import json
from datetime import datetime, timezone


class FileStore:
"""Mixin providing uploaded file CRUD operations."""

async def save_uploaded_file(
self,
file_id: str,
session_id: str,
filename: str,
media_type: str,
file_type: str,
file_size: int,
disk_path: str,
) -> None:
now = datetime.now(timezone.utc).isoformat()
await self.db.execute(
"""INSERT INTO uploaded_files (id, session_id, filename, media_type, file_type, file_size, disk_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(file_id, session_id, filename, media_type, file_type, file_size, disk_path, now),
)
await self.db.commit()

async def get_uploaded_file(self, file_id: str) -> dict | None:
async with self.db.execute(
"SELECT * FROM uploaded_files WHERE id = ?", (file_id,)
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None

async def get_uploaded_files_by_ids(self, file_ids: list[str]) -> list[dict]:
if not file_ids:
return []
placeholders = ",".join("?" for _ in file_ids)
async with self.db.execute(
f"SELECT * FROM uploaded_files WHERE id IN ({placeholders})",
file_ids,
) as cursor:
return [dict(row) async for row in cursor]

async def delete_uploaded_files(self, session_id: str) -> list[str]:
"""Delete all uploaded file records for a session. Returns disk paths for cleanup."""
async with self.db.execute(
"SELECT disk_path FROM uploaded_files WHERE session_id = ?",
(session_id,),
) as cursor:
paths = [row[0] async for row in cursor]
await self.db.execute(
"DELETE FROM uploaded_files WHERE session_id = ?",
(session_id,),
)
await self.db.commit()
return paths
22 changes: 22 additions & 0 deletions nerve/db/migrations/v022_uploaded_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""v022: Add uploaded_files table for web chat file management."""

from __future__ import annotations

import aiosqlite


async def up(db: aiosqlite.Connection) -> None:
await db.executescript("""
CREATE TABLE IF NOT EXISTS uploaded_files (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
filename TEXT NOT NULL,
media_type TEXT NOT NULL,
file_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
disk_path TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_uploaded_files_session
ON uploaded_files(session_id);
""")
7 changes: 6 additions & 1 deletion nerve/gateway/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def decode_token(token: str, jwt_secret: str) -> dict:


def get_token_from_request(request: Request) -> str:
"""Extract JWT token from cookie or Authorization header."""
"""Extract JWT token from cookie, Authorization header, or query param."""
# Try cookie first
token = request.cookies.get("nerve_token")
if token:
Expand All @@ -57,6 +57,11 @@ def get_token_from_request(request: Request) -> str:
if auth.startswith("Bearer "):
return auth[7:]

# Try query parameter (for <img src> and <a download> that can't set headers)
token = request.query_params.get("token")
if token:
return token

raise HTTPException(status_code=401, detail="Not authenticated")


Expand Down
2 changes: 2 additions & 0 deletions nerve/gateway/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
sources,
notifications,
houseofagents,
files,
)

__all__ = [
Expand All @@ -49,4 +50,5 @@ def register_all_routes() -> APIRouter:
router.include_router(sources.router)
router.include_router(notifications.router)
router.include_router(houseofagents.router)
router.include_router(files.router)
return router
Loading