6 Commits

Author SHA1 Message Date
Roberto
fbd308d288 refactor(ws): rename agent_ids to scout_ids in device_hello frame
WsDeviceHello.agent_ids → scout_ids in Pydantic schema,
device_ws.py handler, and all test fixtures (test_device_ws,
test_ws_unified, test_memory_middleware). Also fixes stale
CloudAgentConfig reference in gmail.py docstring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:50:15 +02:00
Roberto
105cf52083 refactor(schemas): rename Agent* schemas and WS frame types to Scout*
Rename all Pydantic models referring to the scout subsystem:
AgentConfig → ScoutConfig, ContentTypeConfig → ScoutContentTypeConfig,
AgentCatalogItem → ScoutCatalogItem, AgentCreationCheckRequest/Response →
ScoutCreationCheckRequest/Response, AgentTriggerRequest → ScoutTriggerRequest,
AgentRunLogResponse → ScoutRunLogResponse.

LLM-helper agent schemas in app/agents/* are untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:58:14 +02:00
Roberto
c2b27d4fb7 refactor(core): rename agent_runner/session_buffer/registry to scout_* 2026-05-16 00:27:50 +02:00
Roberto
b92e72b685 refactor(routes): rename /agents and /agent-setup to /scouts and /scout-setup
Rename routes/agents.py → routes/scouts.py and routes/agent_setup.py →
routes/scout_setup.py. Update APIRouter prefix/tags in scouts.py to
/scouts and scouts. Update main.py router registration, device_ws.py
import, and test_journey_v2.py import/patch paths to use scout_setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:00:07 +02:00
Roberto
1ccb0282fe refactor(models): rename Agent classes to Scout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:52:29 +02:00
Roberto
1a20c11e86 feat(db): rename agents to scouts (alembic 007) 2026-05-15 23:36:28 +02:00
17 changed files with 225 additions and 184 deletions

View File

@@ -0,0 +1,41 @@
"""Rename agents to scouts.
Revision ID: 007
Revises: d6e3f4a5b6c7
Create Date: 2026-05-15
Renames the entire agents subsystem identifiers to scouts.
Pre-1.0 — no data preservation concerns beyond ALTER TABLE rename.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "007"
down_revision: Union[str, None] = "d6e3f4a5b6c7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Tables
op.rename_table("local_agent_configs", "local_scout_configs")
op.rename_table("cloud_agent_configs", "cloud_scout_configs")
op.rename_table("agent_run_logs", "scout_run_logs")
# Columns
op.alter_column("local_scout_configs", "agent_config", new_column_name="scout_config")
op.alter_column("scout_run_logs", "agent_id", new_column_name="scout_id")
op.alter_column("scout_run_logs", "agent_type", new_column_name="scout_type")
def downgrade() -> None:
op.alter_column("scout_run_logs", "scout_type", new_column_name="agent_type")
op.alter_column("scout_run_logs", "scout_id", new_column_name="agent_id")
op.alter_column("local_scout_configs", "scout_config", new_column_name="agent_config")
op.rename_table("scout_run_logs", "agent_run_logs")
op.rename_table("cloud_scout_configs", "cloud_agent_configs")
op.rename_table("local_scout_configs", "local_agent_configs")

View File

@@ -9,7 +9,7 @@ available during the WebSocket handshake).
Protocol: Protocol:
1. Client connects → JWT validated → connection accepted. 1. Client connects → JWT validated → connection accepted.
2. Client sends ``device_hello`` frame: ``{ type, device_id, agent_ids }``. 2. Client sends ``device_hello`` frame: ``{ type, device_id, scout_ids }``.
3. Backend registers the connection in ``DeviceConnectionManager``. 3. Backend registers the connection in ``DeviceConnectionManager``.
4. Session enters message dispatch loop + heartbeat. 4. Session enters message dispatch loop + heartbeat.
@@ -39,10 +39,10 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from jose import JWTError, jwt from jose import JWTError, jwt
from sqlalchemy import update from sqlalchemy import update
from app.api.routes.agent_setup import handle_journey_message, handle_journey_start from app.api.routes.scout_setup import handle_journey_message, handle_journey_start
from app.config.settings import settings from app.config.settings import settings
from app.core.agent_runner import trigger_pending_runs from app.core.scout_runner import trigger_pending_runs
from app.core.agent_session_buffer import session_buffer from app.core.scout_session_buffer import session_buffer
from app.core.brief_agent import run_home_brief, run_project_brief from app.core.brief_agent import run_home_brief, run_project_brief
from app.core.deep_agent import run_contextual_stream, run_home_stream, run_task_brief_research_stream from app.core.deep_agent import run_contextual_stream, run_home_stream, run_task_brief_research_stream
from app.core.output_formatter import extract_canvas_block from app.core.output_formatter import extract_canvas_block
@@ -51,7 +51,7 @@ from app.core.memory_middleware import MemoryMiddleware
from app.core.output_formatter import StreamFormatter from app.core.output_formatter import StreamFormatter
from app.core.ws_context import clear_client_executor, set_client_executor from app.core.ws_context import clear_client_executor, set_client_executor
from app.db import async_session from app.db import async_session
from app.models import AgentRunLog from app.models import ScoutRunLog
from app.schemas import WsFrameType, WsStreamEnd from app.schemas import WsFrameType, WsStreamEnd
from app.schemas.contextual import ContextualScope, render_scope_block from app.schemas.contextual import ContextualScope, render_scope_block
@@ -100,7 +100,7 @@ async def device_ws(websocket: WebSocket) -> None:
if hello.get("type") != WsFrameType.device_hello: if hello.get("type") != WsFrameType.device_hello:
raise ValueError("expected device_hello as first frame") raise ValueError("expected device_hello as first frame")
device_id: str = hello["device_id"] device_id: str = hello["device_id"]
agent_ids: list[str] = hello.get("agent_ids", []) scout_ids: list[str] = hello.get("scout_ids", [])
except (KeyError, ValueError, json.JSONDecodeError) as exc: except (KeyError, ValueError, json.JSONDecodeError) as exc:
logger.warning("device_ws: invalid device_hello from user=%s: %s", user_id, exc) logger.warning("device_ws: invalid device_hello from user=%s: %s", user_id, exc)
await websocket.close(code=1008) await websocket.close(code=1008)
@@ -109,10 +109,10 @@ async def device_ws(websocket: WebSocket) -> None:
# ── 3. Register connection ──────────────────────────────────────── # ── 3. Register connection ────────────────────────────────────────
device_manager.register(user_id, device_id, websocket) device_manager.register(user_id, device_id, websocket)
logger.info( logger.info(
"device_ws: connected user=%s device=%s agents=%s", "device_ws: connected user=%s device=%s scouts=%s",
user_id, user_id,
device_id, device_id,
agent_ids, scout_ids,
) )
# Trigger any overdue agent runs now that the device is connected. # Trigger any overdue agent runs now that the device is connected.
@@ -306,7 +306,7 @@ def get_session_buffer(user_id: str, session_id: str, channel: str = "contextual
Defined at module level so tests can monkeypatch it. Defined at module level so tests can monkeypatch it.
The channel kwarg is accepted for forward-compatibility. The channel kwarg is accepted for forward-compatibility.
""" """
from app.core.agent_session_buffer import ContextualBufferProxy # noqa: PLC0415 from app.core.scout_session_buffer import ContextualBufferProxy # noqa: PLC0415
return ContextualBufferProxy(session_buffer, user_id, session_id) return ContextualBufferProxy(session_buffer, user_id, session_id)
@@ -822,14 +822,14 @@ async def _heartbeat_loop(websocket: WebSocket) -> None:
# ── Disconnect cleanup ──────────────────────────────────────────────── # ── Disconnect cleanup ────────────────────────────────────────────────
async def _mark_runs_disconnected(user_id: str) -> None: async def _mark_runs_disconnected(user_id: str) -> None:
"""Mark all in-progress AgentRunLog rows as 'error' for this user.""" """Mark all in-progress ScoutRunLog rows as 'error' for this user."""
try: try:
async with async_session() as db: async with async_session() as db:
await db.execute( await db.execute(
update(AgentRunLog) update(ScoutRunLog)
.where( .where(
AgentRunLog.user_id == user_id, ScoutRunLog.user_id == user_id,
AgentRunLog.status == "running", ScoutRunLog.status == "running",
) )
.values( .values(
status="error", status="error",

View File

@@ -1,4 +1,4 @@
"""Chatbot Journey — WS-based guided conversation to build an AgentConfig. """Chatbot Journey — WS-based guided conversation to build an ScoutConfig.
The journey is driven entirely through WebSocket frames (no REST endpoints). The journey is driven entirely through WebSocket frames (no REST endpoints).
The device WS handler dispatches ``journey_start`` and ``journey_message`` The device WS handler dispatches ``journey_start`` and ``journey_message``
@@ -13,7 +13,7 @@ Journey flow:
3. FE sends ``journey_message`` frames for each user reply. 3. FE sends ``journey_message`` frames for each user reply.
4. Server appends the user message, calls the LLM (which may read files 4. Server appends the user message, calls the LLM (which may read files
via tools), and sends back a ``journey_reply``. via tools), and sends back a ``journey_reply``.
5. After 3-5 turns the LLM wraps up by emitting an ``AgentConfig`` JSON 5. After 3-5 turns the LLM wraps up by emitting an ``ScoutConfig`` JSON
block delimited by ``AGENT_CONFIG_START`` / ``AGENT_CONFIG_END``. block delimited by ``AGENT_CONFIG_START`` / ``AGENT_CONFIG_END``.
6. Server parses and validates the JSON with Pydantic, sends 6. Server parses and validates the JSON with Pydantic, sends
``journey_reply`` with ``done=True`` and the serialised config. ``journey_reply`` with ``done=True`` and the serialised config.
@@ -34,7 +34,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, Tool
from app.agents.filesystem_agent import make_directory_tools from app.agents.filesystem_agent import make_directory_tools
from app.core.langfuse_client import compile_prompt, extract_usage, get_langfuse, get_prompt_or_fallback, langfuse_context from app.core.langfuse_client import compile_prompt, extract_usage, get_langfuse, get_prompt_or_fallback, langfuse_context
from app.core.llm import get_agent_llm, model_for_agent from app.core.llm import get_agent_llm, model_for_agent
from app.schemas import AgentConfig from app.schemas import ScoutConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
_SESSION_TTL_SECONDS: int = 1800 # 30 minutes _SESSION_TTL_SECONDS: int = 1800 # 30 minutes
# Sentinel strings used to delimit the LLM-produced AgentConfig JSON. # Sentinel strings used to delimit the LLM-produced ScoutConfig JSON.
_CONFIG_START = "AGENT_CONFIG_START" _CONFIG_START = "AGENT_CONFIG_START"
_CONFIG_END = "AGENT_CONFIG_END" _CONFIG_END = "AGENT_CONFIG_END"
@@ -92,7 +92,7 @@ def get_journey_session(session_id: str, user_id: str) -> JourneySession | None:
_JOURNEY_SYSTEM_PROMPT = """\ _JOURNEY_SYSTEM_PROMPT = """\
You are a friendly assistant helping a freelancer configure a data-extraction agent. You are a friendly assistant helping a freelancer configure a data-extraction agent.
Your job is to understand what files the user has in their directory and produce a Your job is to understand what files the user has in their directory and produce a
structured AgentConfig JSON that the extraction agent will use as its instruction set. structured ScoutConfig JSON that the extraction agent will use as its instruction set.
You have access to file-system tools to explore the user's directory: You have access to file-system tools to explore the user's directory:
- list_directory: see folder structure and file names - list_directory: see folder structure and file names
@@ -122,7 +122,7 @@ Cover these topics based on what you discovered:
4. Date extraction (e.g. "by Friday" dueDate) 4. Date extraction (e.g. "by Friday" dueDate)
5. Exclusion rules (e.g. skip newsletters, skip files with no project match) 5. Exclusion rules (e.g. skip newsletters, skip files with no project match)
### Step 4 — Produce the AgentConfig JSON ### Step 4 — Produce the ScoutConfig JSON
Once you are 90% confident, output the final config between these exact markers Once you are 90% confident, output the final config between these exact markers
(each on its own line): (each on its own line):
@@ -168,7 +168,7 @@ def _build_system_prompt(
) -> tuple[str, Any]: ) -> tuple[str, Any]:
"""Return ``(compiled_system_prompt, langfuse_prompt_obj_or_None)``.""" """Return ``(compiled_system_prompt, langfuse_prompt_obj_or_None)``."""
existing_section = ( existing_section = (
"\nThe user already has the following AgentConfig — refine it based on their answers:\n" "\nThe user already has the following ScoutConfig — refine it based on their answers:\n"
f"```json\n{existing_config}\n```\n" f"```json\n{existing_config}\n```\n"
if existing_config if existing_config
else "" else ""
@@ -189,11 +189,11 @@ def _build_system_prompt(
return compiled, prompt_obj return compiled, prompt_obj
# ── AgentConfig extraction ──────────────────────────────────────────────── # ── ScoutConfig extraction ────────────────────────────────────────────────
def _extract_agent_config(text: str) -> str | None: def _extract_agent_config(text: str) -> str | None:
"""Return validated AgentConfig JSON string from between markers, or None. """Return validated ScoutConfig JSON string from between markers, or None.
Parses the JSON with Pydantic to ensure it conforms to the schema before Parses the JSON with Pydantic to ensure it conforms to the schema before
returning. Returns None if markers are absent or JSON is invalid. returning. Returns None if markers are absent or JSON is invalid.
@@ -206,10 +206,10 @@ def _extract_agent_config(text: str) -> str | None:
if not raw: if not raw:
return None return None
try: try:
parsed = AgentConfig.model_validate_json(raw) parsed = ScoutConfig.model_validate_json(raw)
return parsed.model_dump_json() return parsed.model_dump_json()
except Exception as exc: except Exception as exc:
logger.warning("agent_setup: failed to parse AgentConfig JSON: %s", exc) logger.warning("agent_setup: failed to parse ScoutConfig JSON: %s", exc)
return None return None
@@ -475,7 +475,7 @@ async def handle_journey_message(
if turns >= _MAX_TURNS: if turns >= _MAX_TURNS:
nudge_content = ( nudge_content = (
"[System: You have enough information. Please generate the final " "[System: You have enough information. Please generate the final "
f"AgentConfig JSON now, wrapped in {_CONFIG_START} / {_CONFIG_END} markers.]" f"ScoutConfig JSON now, wrapped in {_CONFIG_START} / {_CONFIG_END} markers.]"
) )
session.history.append({"role": "user", "content": nudge_content}) session.history.append({"role": "user", "content": nudge_content})

View File

@@ -1,12 +1,12 @@
"""Agent routes. """Scout routes.
Backend responsibilities are intentionally minimal: Backend responsibilities are intentionally minimal:
GET /agents/catalog static catalog for UI display GET /scouts/catalog static catalog for UI display
POST /agents/can-create billing eligibility check POST /scouts/can-create billing eligibility check
POST /agents/trigger trigger a local agent run POST /scouts/trigger trigger a local scout run
Agent configuration is owned by the Electron app and is not persisted Scout configuration is owned by the Electron app and is not persisted
in backend agent-config tables. in backend scout-config tables.
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,23 +24,23 @@ from pydantic import BaseModel
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.billing.tier_manager import FEATURES from app.billing.tier_manager import FEATURES
from app.core.agent_runner import is_agent_running, run_local_agent from app.core.scout_runner import is_agent_running, run_local_agent
from app.core.device_manager import device_manager from app.core.device_manager import device_manager
from app.core.note_summarizer import generate_note_summary from app.core.note_summarizer import generate_note_summary
from app.db import get_session from app.db import get_session
from app.models import AgentRunLog, LocalAgentConfig from app.models import ScoutRunLog, LocalScoutConfig
from app.schemas import ( from app.schemas import (
AgentCatalogItem, ScoutCatalogItem,
AgentCreationCheckRequest, ScoutCreationCheckRequest,
AgentCreationCheckResponse, ScoutCreationCheckResponse,
AgentRunLogResponse, ScoutRunLogResponse,
AgentTriggerRequest, ScoutTriggerRequest,
UserProfile, UserProfile,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/agents", tags=["agents"]) router = APIRouter(prefix="/scouts", tags=["scouts"])
# ── Datetime helpers ────────────────────────────────────────────────── # ── Datetime helpers ──────────────────────────────────────────────────
@@ -70,11 +70,11 @@ def _to_data_types(values: list[str]) -> list[str]:
return result return result
def _to_run_log_response(log: AgentRunLog) -> AgentRunLogResponse: def _to_run_log_response(log: ScoutRunLog) -> ScoutRunLogResponse:
return AgentRunLogResponse( return ScoutRunLogResponse(
id=log.id, id=log.id,
agent_id=log.agent_id, agent_id=log.scout_id,
agent_type=log.agent_type, # type: ignore[arg-type] agent_type=log.scout_type, # type: ignore[arg-type]
status=log.status, # type: ignore[arg-type] status=log.status, # type: ignore[arg-type]
items_processed=log.items_processed, items_processed=log.items_processed,
items_created=log.items_created, items_created=log.items_created,
@@ -108,9 +108,9 @@ async def _enforce_run_frequency(
hour=0, minute=0, second=0, microsecond=0 hour=0, minute=0, second=0, microsecond=0
) )
result = await db.execute( result = await db.execute(
select(func.count(AgentRunLog.id)).where( select(func.count(ScoutRunLog.id)).where(
AgentRunLog.user_id == user_id, ScoutRunLog.user_id == user_id,
AgentRunLog.started_at >= today_start, ScoutRunLog.started_at >= today_start,
) )
) )
runs_today: int = result.scalar_one() runs_today: int = result.scalar_one()
@@ -124,28 +124,28 @@ async def _enforce_run_frequency(
# ── Catalog ─────────────────────────────────────────────────────────── # ── Catalog ───────────────────────────────────────────────────────────
@router.get("/catalog", response_model=list[AgentCatalogItem]) @router.get("/catalog", response_model=list[ScoutCatalogItem])
async def get_agent_catalog( async def get_agent_catalog(
current_user: UserProfile = Depends(get_current_user), current_user: UserProfile = Depends(get_current_user),
) -> list[AgentCatalogItem]: ) -> list[ScoutCatalogItem]:
"""Return the static list of available agent types and their descriptions.""" """Return the static list of available agent types and their descriptions."""
return [ return [
AgentCatalogItem( ScoutCatalogItem(
type="local_directory", type="local_directory",
name="Local Directory Monitor", name="Local Directory Monitor",
description="Watches local directories, extracts data from files using AI", description="Watches local directories, extracts data from files using AI",
), ),
AgentCatalogItem( ScoutCatalogItem(
type="gmail", type="gmail",
name="Gmail Connector", name="Gmail Connector",
description="Scans Gmail inbox, extracts tasks/notes from emails", description="Scans Gmail inbox, extracts tasks/notes from emails",
), ),
AgentCatalogItem( ScoutCatalogItem(
type="teams", type="teams",
name="Microsoft Teams Connector", name="Microsoft Teams Connector",
description="Monitors Teams messages, extracts action items", description="Monitors Teams messages, extracts action items",
), ),
AgentCatalogItem( ScoutCatalogItem(
type="outlook", type="outlook",
name="Outlook Connector", name="Outlook Connector",
description="Scans Outlook inbox, extracts tasks/notes", description="Scans Outlook inbox, extracts tasks/notes",
@@ -153,11 +153,11 @@ async def get_agent_catalog(
] ]
@router.post("/can-create", response_model=AgentCreationCheckResponse) @router.post("/can-create", response_model=ScoutCreationCheckResponse)
async def can_create_agent( async def can_create_agent(
body: AgentCreationCheckRequest, body: ScoutCreationCheckRequest,
current_user: UserProfile = Depends(get_current_user), current_user: UserProfile = Depends(get_current_user),
) -> AgentCreationCheckResponse: ) -> ScoutCreationCheckResponse:
"""Check if the user can create one more agent based on billing tier. """Check if the user can create one more agent based on billing tier.
Since configuration is client-owned, the Electron app sends its current Since configuration is client-owned, the Electron app sends its current
@@ -165,7 +165,7 @@ async def can_create_agent(
""" """
limit: int = FEATURES.get(current_user.tier, FEATURES["free"])["batch_active"] limit: int = FEATURES.get(current_user.tier, FEATURES["free"])["batch_active"]
allowed = limit == -1 or body.active_agents < limit allowed = limit == -1 or body.active_agents < limit
return AgentCreationCheckResponse( return ScoutCreationCheckResponse(
allowed=allowed, allowed=allowed,
tier=current_user.tier, tier=current_user.tier,
active_agents=body.active_agents, active_agents=body.active_agents,
@@ -173,12 +173,12 @@ async def can_create_agent(
) )
@router.post("/trigger", response_model=AgentRunLogResponse, status_code=status.HTTP_202_ACCEPTED) @router.post("/trigger", response_model=ScoutRunLogResponse, status_code=status.HTTP_202_ACCEPTED)
async def trigger_agent_run( async def trigger_agent_run(
body: AgentTriggerRequest, body: ScoutTriggerRequest,
current_user: UserProfile = Depends(get_current_user), current_user: UserProfile = Depends(get_current_user),
db: AsyncSession = Depends(get_session), db: AsyncSession = Depends(get_session),
) -> AgentRunLogResponse: ) -> ScoutRunLogResponse:
"""Trigger a local agent run using client-provided configuration.""" """Trigger a local agent run using client-provided configuration."""
_enforce_agent_limit(current_user.tier, body.active_agents) _enforce_agent_limit(current_user.tier, body.active_agents)
await _enforce_run_frequency(current_user.tier, current_user.id, db) await _enforce_run_frequency(current_user.tier, current_user.id, db)
@@ -188,7 +188,7 @@ async def trigger_agent_run(
if body.last_run_at if body.last_run_at
else None else None
) )
config = LocalAgentConfig( config = LocalScoutConfig(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
user_id=current_user.id, user_id=current_user.id,
device_id=body.device_id, device_id=body.device_id,
@@ -196,7 +196,7 @@ async def trigger_agent_run(
directory_paths=[body.directory], directory_paths=[body.directory],
data_types=_to_data_types(body.what_to_extract), data_types=_to_data_types(body.what_to_extract),
prompt_template=body.custom_agent_prompt or "", prompt_template=body.custom_agent_prompt or "",
agent_config=body.agent_config, scout_config=body.agent_config,
file_extensions=[], file_extensions=[],
schedule_cron=body.batch_interval, schedule_cron=body.batch_interval,
enabled=True, enabled=True,
@@ -212,9 +212,9 @@ async def trigger_agent_run(
detail="Agent is already running. Only one run per agent is allowed at a time.", detail="Agent is already running. Only one run per agent is allowed at a time.",
) )
run_log = AgentRunLog( run_log = ScoutRunLog(
agent_id=stable_agent_id, scout_id=stable_agent_id,
agent_type="local", scout_type="local",
user_id=current_user.id, user_id=current_user.id,
status="running", status="running",
) )

View File

@@ -18,7 +18,7 @@ from app.agents.project_agent import PROJECT_TOOLS
from app.agents.relations_agent import make_query_relations_tool from app.agents.relations_agent import make_query_relations_tool
from app.agents.task_agent import TASK_TOOLS from app.agents.task_agent import TASK_TOOLS
from app.agents.timeline_agent import TIMELINE_TOOLS from app.agents.timeline_agent import TIMELINE_TOOLS
from app.core.agent_session_buffer import session_buffer from app.core.scout_session_buffer import session_buffer
from app.core.langfuse_client import compile_prompt, extract_usage, get_langfuse, get_prompt_or_fallback, langfuse_context from app.core.langfuse_client import compile_prompt, extract_usage, get_langfuse, get_prompt_or_fallback, langfuse_context
from app.core.llm import get_agent_llm, model_for_agent from app.core.llm import get_agent_llm, model_for_agent
from app.core.memory_middleware import MemoryMiddleware from app.core.memory_middleware import MemoryMiddleware

View File

@@ -48,7 +48,7 @@ from app.core.llm import get_agent_llm, model_for_agent
from app.core.preprocessors import detect_content_type, preprocess from app.core.preprocessors import detect_content_type, preprocess
from app.core.ws_context import clear_client_executor, execute_on_client, set_client_executor from app.core.ws_context import clear_client_executor, execute_on_client, set_client_executor
from app.db import async_session from app.db import async_session
from app.models import AgentRunLog, CloudAgentConfig, LocalAgentConfig from app.models import ScoutRunLog, CloudScoutConfig, LocalScoutConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -169,7 +169,7 @@ def _is_overdue(schedule_cron: str, last_run_at: datetime | None) -> bool:
next_run: datetime = cron.get_next(datetime) next_run: datetime = cron.get_next(datetime)
return now >= next_run return now >= next_run
except Exception as exc: except Exception as exc:
logger.warning("agent_runner: cannot parse cron %r: %s", schedule_cron, exc) logger.warning("scout_runner: cannot parse cron %r: %s", schedule_cron, exc)
return False return False
@@ -290,7 +290,7 @@ async def _run_agent_with_tools(
call_name = str(call.get("name", "")) call_name = str(call.get("name", ""))
call_args = call.get("args", {}) call_args = call.get("args", {})
logger.info( logger.info(
"agent_runner: tool_call name=%s args=%s", "scout_runner: tool_call name=%s args=%s",
call_name, call_name,
json.dumps(call_args, ensure_ascii=True)[:800], json.dumps(call_args, ensure_ascii=True)[:800],
) )
@@ -305,7 +305,7 @@ async def _run_agent_with_tools(
tool_output = await tool_fn.ainvoke(call_args) tool_output = await tool_fn.ainvoke(call_args)
logger.info( logger.info(
"agent_runner: tool_result name=%s output=%s", "scout_runner: tool_result name=%s output=%s",
call_name, call_name,
str(tool_output)[:200], str(tool_output)[:200],
) )
@@ -360,7 +360,7 @@ async def _scan_directories(
try: try:
result = await execute_on_client(action="list_directory", data={"path": path}) result = await execute_on_client(action="list_directory", data={"path": path})
except Exception as exc: except Exception as exc:
logger.warning("agent_runner: list_directory failed %r: %s", path, exc) logger.warning("scout_runner: list_directory failed %r: %s", path, exc)
return return
for entry in result.get("entries", []): for entry in result.get("entries", []):
entry_path = entry.get("path", "") entry_path = entry.get("path", "")
@@ -414,7 +414,7 @@ async def _fetch_projects() -> list[dict]:
result = await execute_on_client(action="select", table="projects") result = await execute_on_client(action="select", table="projects")
return result.get("rows", []) return result.get("rows", [])
except Exception as exc: except Exception as exc:
logger.warning("agent_runner: failed to fetch projects: %s", exc) logger.warning("scout_runner: failed to fetch projects: %s", exc)
return [] return []
@@ -442,7 +442,7 @@ async def _fetch_domain_entities(domain: str, project_id: str) -> list[dict]:
) )
return result.get("rows", []) return result.get("rows", [])
except Exception as exc: except Exception as exc:
logger.warning("agent_runner: failed to fetch %s: %s", domain, exc) logger.warning("scout_runner: failed to fetch %s: %s", domain, exc)
return [] return []
@@ -555,8 +555,8 @@ def _get_no_match_behavior(agent_config: dict) -> str:
async def run_local_agent( async def run_local_agent(
user_id: str, user_id: str,
config: LocalAgentConfig, config: LocalScoutConfig,
run_log: AgentRunLog, run_log: ScoutRunLog,
device_mgr: DeviceConnectionManager, device_mgr: DeviceConnectionManager,
run_context: dict | None = None, run_context: dict | None = None,
) -> None: ) -> None:
@@ -586,7 +586,7 @@ async def run_local_agent(
if not is_online: if not is_online:
logger.info( logger.info(
"agent_runner: skip run=%s — device %r offline for user=%s", "scout_runner: skip run=%s — device %r offline for user=%s",
run_id, run_id,
target_device_id or "<any>", target_device_id or "<any>",
user_id, user_id,
@@ -605,7 +605,7 @@ async def run_local_agent(
errors: list[str] = [] errors: list[str] = []
items_processed = 0 items_processed = 0
items_created = 0 items_created = 0
agent_config: dict = config.agent_config or {} agent_config: dict = config.scout_config or {}
processing_tools = _build_processing_tools(config.data_types) processing_tools = _build_processing_tools(config.data_types)
try: try:
@@ -616,7 +616,7 @@ async def run_local_agent(
last_run_at=config.last_run_at, last_run_at=config.last_run_at,
) )
logger.info( logger.info(
"agent_runner: run=%s found %d file(s) after filtering", run_id, len(file_paths) "scout_runner: run=%s found %d file(s) after filtering", run_id, len(file_paths)
) )
if not file_paths: if not file_paths:
@@ -641,7 +641,7 @@ async def run_local_agent(
raw_content: str = file_result.get("content", "") raw_content: str = file_result.get("content", "")
if not raw_content.strip(): if not raw_content.strip():
logger.debug( logger.debug(
"agent_runner: run=%s skipping empty file %r", run_id, file_path "scout_runner: run=%s skipping empty file %r", run_id, file_path
) )
continue continue
@@ -651,7 +651,7 @@ async def run_local_agent(
preprocessed = preprocess(content_type, raw_content) preprocessed = preprocess(content_type, raw_content)
logger.info( logger.info(
"agent_runner: run=%s file=%r content_type=%s clean_len=%d", "scout_runner: run=%s file=%r content_type=%s clean_len=%d",
run_id, file_path, content_type, len(preprocessed.clean_text), run_id, file_path, content_type, len(preprocessed.clean_text),
) )
@@ -711,19 +711,19 @@ async def run_local_agent(
projects_block = _format_projects(projects) projects_block = _format_projects(projects)
logger.info( logger.info(
"agent_runner: run=%s file=%r created=%d result=%s", "scout_runner: run=%s file=%r created=%d result=%s",
run_id, file_path, file_created, result_text[:200], run_id, file_path, file_created, result_text[:200],
) )
except Exception as exc: except Exception as exc:
errors.append(f"Error processing '{file_path}': {exc}") errors.append(f"Error processing '{file_path}': {exc}")
logger.error( logger.error(
"agent_runner: run=%s file=%r failed: %s", run_id, file_path, exc "scout_runner: run=%s file=%r failed: %s", run_id, file_path, exc
) )
except Exception as exc: except Exception as exc:
errors.append(f"Agent run failed: {exc}") errors.append(f"Agent run failed: {exc}")
logger.error("agent_runner: run=%s failed: %s", run_id, exc) logger.error("scout_runner: run=%s failed: %s", run_id, exc)
finally: finally:
_running_agents.discard(agent_id) _running_agents.discard(agent_id)
clear_client_executor() clear_client_executor()
@@ -744,7 +744,7 @@ async def run_local_agent(
errors=errors, errors=errors,
) )
logger.info( logger.info(
"agent_runner: run=%s done status=%s processed=%d created=%d errors=%d", "scout_runner: run=%s done status=%s processed=%d created=%d errors=%d",
run_id, run_id,
final_status, final_status,
items_processed, items_processed,
@@ -762,7 +762,7 @@ async def run_local_agent(
}) })
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"agent_runner: run=%s failed to send run_complete: %s", run_id, exc "scout_runner: run=%s failed to send run_complete: %s", run_id, exc
) )
@@ -773,8 +773,8 @@ _CLOUD_DEFAULT_LOOKBACK_DAYS: int = 7
async def run_cloud_agent( async def run_cloud_agent(
user_id: str, user_id: str,
config: CloudAgentConfig, config: CloudScoutConfig,
run_log: AgentRunLog, run_log: ScoutRunLog,
device_mgr: DeviceConnectionManager, device_mgr: DeviceConnectionManager,
) -> None: ) -> None:
"""Execute a cloud connector agent run end-to-end. """Execute a cloud connector agent run end-to-end.
@@ -797,7 +797,7 @@ async def run_cloud_agent(
# ── 1. Device online check ───────────────────────────────────────── # ── 1. Device online check ─────────────────────────────────────────
if not device_mgr.is_online(user_id): if not device_mgr.is_online(user_id):
logger.info( logger.info(
"agent_runner: skip cloud run=%s — no device online for user=%s", "scout_runner: skip cloud run=%s — no device online for user=%s",
run_id, run_id,
user_id, user_id,
) )
@@ -822,7 +822,7 @@ async def run_cloud_agent(
try: try:
credentials_info = decrypt_token(config.oauth_token_encrypted) credentials_info = decrypt_token(config.oauth_token_encrypted)
except ValueError as exc: except ValueError as exc:
logger.error("agent_runner: failed to decrypt OAuth token for agent %s: %s", config.id, exc) logger.error("scout_runner: failed to decrypt OAuth token for agent %s: %s", config.id, exc)
await _finalize_run( await _finalize_run(
run_log, run_log,
status="error", status="error",
@@ -868,7 +868,7 @@ async def run_cloud_agent(
raw_messages = [] raw_messages = []
except RuntimeError as exc: except RuntimeError as exc:
logger.error( logger.error(
"agent_runner: provider fetch failed for cloud agent %s: %s", config.id, exc "scout_runner: provider fetch failed for cloud agent %s: %s", config.id, exc
) )
await _finalize_run( await _finalize_run(
run_log, run_log,
@@ -881,7 +881,7 @@ async def run_cloud_agent(
return return
logger.info( logger.info(
"agent_runner: cloud agent %s fetched %d item(s) from %s for user=%s", "scout_runner: cloud agent %s fetched %d item(s) from %s for user=%s",
config.id, config.id,
len(raw_messages), len(raw_messages),
config.provider, config.provider,
@@ -941,16 +941,16 @@ async def run_cloud_agent(
new_encrypted = encrypt_token(refreshed) new_encrypted = encrypt_token(refreshed)
async with async_session() as db: async with async_session() as db:
cfg_result = await db.execute( cfg_result = await db.execute(
select(CloudAgentConfig).where(CloudAgentConfig.id == config.id) select(CloudScoutConfig).where(CloudScoutConfig.id == config.id)
) )
cfg_row = cfg_result.scalar_one_or_none() cfg_row = cfg_result.scalar_one_or_none()
if cfg_row: if cfg_row:
cfg_row.oauth_token_encrypted = new_encrypted cfg_row.oauth_token_encrypted = new_encrypted
await db.commit() await db.commit()
logger.debug("agent_runner: refreshed OAuth token persisted for agent %s", config.id) logger.debug("scout_runner: refreshed OAuth token persisted for agent %s", config.id)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"agent_runner: failed to persist refreshed token for agent %s: %s", "scout_runner: failed to persist refreshed token for agent %s: %s",
config.id, config.id,
exc, exc,
) )
@@ -974,7 +974,7 @@ async def run_cloud_agent(
config_type="cloud", config_type="cloud",
) )
logger.info( logger.info(
"agent_runner: cloud run=%s done status=%s processed=%d created=%d errors=%d", "scout_runner: cloud run=%s done status=%s processed=%d created=%d errors=%d",
run_id, run_id,
final_status, final_status,
items_processed, items_processed,
@@ -996,7 +996,7 @@ async def trigger_pending_runs(
Called as a background task from the device WS endpoint on ``device_hello``. Called as a background task from the device WS endpoint on ``device_hello``.
""" """
logger.info( logger.info(
"agent_runner: pending-run scan skipped for user=%s device=%s (client-owned agent config)", "scout_runner: pending-run scan skipped for user=%s device=%s (client-owned agent config)",
user_id, user_id,
device_id, device_id,
) )
@@ -1007,7 +1007,7 @@ async def trigger_pending_runs(
async def _finalize_run( async def _finalize_run(
run_log: AgentRunLog, run_log: ScoutRunLog,
*, *,
status: str, status: str,
items_processed: int = 0, items_processed: int = 0,
@@ -1031,14 +1031,14 @@ async def _finalize_run(
if update_config_last_run and config_id: if update_config_last_run and config_id:
if config_type == "local": if config_type == "local":
cfg_result = await db.execute( cfg_result = await db.execute(
select(LocalAgentConfig).where(LocalAgentConfig.id == config_id) select(LocalScoutConfig).where(LocalScoutConfig.id == config_id)
) )
cfg = cfg_result.scalar_one_or_none() cfg = cfg_result.scalar_one_or_none()
if cfg: if cfg:
cfg.last_run_at = now cfg.last_run_at = now
elif config_type == "cloud": elif config_type == "cloud":
cfg_result = await db.execute( cfg_result = await db.execute(
select(CloudAgentConfig).where(CloudAgentConfig.id == config_id) select(CloudScoutConfig).where(CloudScoutConfig.id == config_id)
) )
cfg = cfg_result.scalar_one_or_none() cfg = cfg_result.scalar_one_or_none()
if cfg: if cfg:
@@ -1047,5 +1047,5 @@ async def _finalize_run(
await db.commit() await db.commit()
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"agent_runner: failed to finalize run_log=%s: %s", run_log.id, exc "scout_runner: failed to finalize run_log=%s: %s", run_log.id, exc
) )

View File

@@ -8,7 +8,7 @@ blocking the event loop.
Token refresh is handled transparently: when the stored access token has Token refresh is handled transparently: when the stored access token has
expired, ``google.auth.transport.requests.Request`` will use the refresh expired, ``google.auth.transport.requests.Request`` will use the refresh
token to obtain a fresh one. The caller is responsible for persisting token to obtain a fresh one. The caller is responsible for persisting
any refreshed credentials back to ``CloudAgentConfig.oauth_token_encrypted`` any refreshed credentials back to ``CloudScoutConfig.oauth_token_encrypted``
(see ``agent_runner.run_cloud_agent``). (see ``agent_runner.run_cloud_agent``).
Credential dict shape (Google OAuth2): Credential dict shape (Google OAuth2):

View File

@@ -124,12 +124,12 @@ def create_app() -> FastAPI:
app.add_middleware(SanitizerMiddleware) app.add_middleware(SanitizerMiddleware)
app.add_middleware(TierRateLimitMiddleware) app.add_middleware(TierRateLimitMiddleware)
from app.api.routes import agents, auth, billing, chat, device_ws, memory from app.api.routes import scouts, auth, billing, chat, device_ws, memory
app.include_router(auth.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1")
app.include_router(chat.router, prefix="/api/v1") app.include_router(chat.router, prefix="/api/v1")
app.include_router(billing.router, prefix="/api/v1") app.include_router(billing.router, prefix="/api/v1")
app.include_router(agents.router, prefix="/api/v1") app.include_router(scouts.router, prefix="/api/v1")
app.include_router(device_ws.router, prefix="/api/v1") app.include_router(device_ws.router, prefix="/api/v1")
app.include_router(memory.router, prefix="/api/v1") app.include_router(memory.router, prefix="/api/v1")

View File

@@ -1,15 +1,15 @@
"""SQLAlchemy ORM models for all persistent tables. """SQLAlchemy ORM models for all persistent tables.
Only auth, billing, agent config, and memory data live here. Only auth, billing, scout config, and memory data live here.
User content (notes, tasks, etc.) lives exclusively on the client. User content (notes, tasks, etc.) lives exclusively on the client.
Table inventory: Table inventory:
users — account credentials + tier users — account credentials + tier
refresh_tokens — hashed refresh token store refresh_tokens — hashed refresh token store
subscriptions — Stripe subscription records subscriptions — Stripe subscription records
local_agent_configs — per-device batch agent configs local_scout_configs — per-device batch scout configs
cloud_agent_configs — OAuth-backed cloud agent configs cloud_scout_configs — OAuth-backed cloud scout configs
agent_run_logs — execution history for all agents scout_run_logs — execution history for all scouts
memory_core — per-user persistent key/value preferences (encrypted) memory_core — per-user persistent key/value preferences (encrypted)
memory_associative — per-user semantic memory with embeddings (encrypted) memory_associative — per-user semantic memory with embeddings (encrypted)
memory_episodic — per-user session summaries (encrypted) memory_episodic — per-user session summaries (encrypted)
@@ -158,8 +158,8 @@ class Subscription(Base):
user: Mapped[User] = relationship(back_populates="subscription") user: Mapped[User] = relationship(back_populates="subscription")
class LocalAgentConfig(Base): class LocalScoutConfig(Base):
__tablename__ = "local_agent_configs" __tablename__ = "local_scout_configs"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(
Uuid(as_uuid=False), primary_key=True, default=_uuid Uuid(as_uuid=False), primary_key=True, default=_uuid
@@ -172,7 +172,7 @@ class LocalAgentConfig(Base):
directory_paths: Mapped[list] = mapped_column(JSON, nullable=False, default=list) directory_paths: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
data_types: Mapped[list] = mapped_column(JSON, nullable=False, default=list) data_types: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
prompt_template: Mapped[str] = mapped_column(Text, nullable=False, default="") prompt_template: Mapped[str] = mapped_column(Text, nullable=False, default="")
agent_config: Mapped[dict | None] = mapped_column(JSON, nullable=True) scout_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
file_extensions: Mapped[list] = mapped_column(JSON, nullable=False, default=list) file_extensions: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
schedule_cron: Mapped[str] = mapped_column(String(100), nullable=False, default="0 */6 * * *") schedule_cron: Mapped[str] = mapped_column(String(100), nullable=False, default="0 */6 * * *")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
@@ -184,17 +184,17 @@ class LocalAgentConfig(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
) )
run_logs: Mapped[list[AgentRunLog]] = relationship( run_logs: Mapped[list["ScoutRunLog"]] = relationship(
back_populates="local_agent", back_populates="local_scout",
primaryjoin="and_(AgentRunLog.agent_id == LocalAgentConfig.id, AgentRunLog.agent_type == 'local')", primaryjoin="and_(ScoutRunLog.scout_id == LocalScoutConfig.id, ScoutRunLog.scout_type == 'local')",
foreign_keys="AgentRunLog.agent_id", foreign_keys="ScoutRunLog.scout_id",
cascade="all, delete-orphan", cascade="all, delete-orphan",
overlaps="run_logs,cloud_agent", overlaps="run_logs,cloud_scout",
) )
class CloudAgentConfig(Base): class CloudScoutConfig(Base):
__tablename__ = "cloud_agent_configs" __tablename__ = "cloud_scout_configs"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(
Uuid(as_uuid=False), primary_key=True, default=_uuid Uuid(as_uuid=False), primary_key=True, default=_uuid
@@ -218,25 +218,25 @@ class CloudAgentConfig(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
) )
run_logs: Mapped[list[AgentRunLog]] = relationship( run_logs: Mapped[list["ScoutRunLog"]] = relationship(
back_populates="cloud_agent", back_populates="cloud_scout",
primaryjoin="and_(AgentRunLog.agent_id == CloudAgentConfig.id, AgentRunLog.agent_type == 'cloud')", primaryjoin="and_(ScoutRunLog.scout_id == CloudScoutConfig.id, ScoutRunLog.scout_type == 'cloud')",
foreign_keys="AgentRunLog.agent_id", foreign_keys="ScoutRunLog.scout_id",
cascade="all, delete-orphan", cascade="all, delete-orphan",
overlaps="run_logs,local_agent", overlaps="run_logs,local_scout",
) )
class AgentRunLog(Base): class ScoutRunLog(Base):
__tablename__ = "agent_run_logs" __tablename__ = "scout_run_logs"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(
Uuid(as_uuid=False), primary_key=True, default=_uuid Uuid(as_uuid=False), primary_key=True, default=_uuid
) )
# Plain string — not a FK because it references either local_agent_configs or cloud_agent_configs # Plain string — not a FK because it references either local_scout_configs or cloud_scout_configs
# depending on agent_type. Query by (agent_id, agent_type) to locate the source config. # depending on scout_type. Query by (scout_id, scout_type) to locate the source config.
agent_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) scout_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
agent_type: Mapped[str] = mapped_column(AgentTypeEnum, nullable=False) scout_type: Mapped[str] = mapped_column(AgentTypeEnum, nullable=False)
user_id: Mapped[str] = mapped_column( user_id: Mapped[str] = mapped_column(
Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
) )
@@ -250,17 +250,17 @@ class AgentRunLog(Base):
) )
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
local_agent: Mapped[LocalAgentConfig | None] = relationship( local_scout: Mapped["LocalScoutConfig | None"] = relationship(
back_populates="run_logs", back_populates="run_logs",
primaryjoin="and_(AgentRunLog.agent_id == LocalAgentConfig.id, AgentRunLog.agent_type == 'local')", primaryjoin="and_(ScoutRunLog.scout_id == LocalScoutConfig.id, ScoutRunLog.scout_type == 'local')",
foreign_keys="AgentRunLog.agent_id", foreign_keys="ScoutRunLog.scout_id",
overlaps="run_logs,cloud_agent", overlaps="run_logs,cloud_scout",
) )
cloud_agent: Mapped[CloudAgentConfig | None] = relationship( cloud_scout: Mapped["CloudScoutConfig | None"] = relationship(
back_populates="run_logs", back_populates="run_logs",
primaryjoin="and_(AgentRunLog.agent_id == CloudAgentConfig.id, AgentRunLog.agent_type == 'cloud')", primaryjoin="and_(ScoutRunLog.scout_id == CloudScoutConfig.id, ScoutRunLog.scout_type == 'cloud')",
foreign_keys="AgentRunLog.agent_id", foreign_keys="ScoutRunLog.scout_id",
overlaps="run_logs,local_agent", overlaps="run_logs,local_scout",
) )

View File

@@ -147,7 +147,7 @@ class WsDeviceHello(BaseModel):
type: Literal[WsFrameType.device_hello] = WsFrameType.device_hello type: Literal[WsFrameType.device_hello] = WsFrameType.device_hello
device_id: str device_id: str
agent_ids: list[str] = Field(default_factory=list) scout_ids: list[str] = Field(default_factory=list)
@@ -207,10 +207,10 @@ class WsStreamEnd(BaseModel):
mutations: list[dict[str, Any]] | None = None mutations: list[dict[str, Any]] | None = None
# ── Agent Config V2 ─────────────────────────────────────────────────── # ── Scout Config V2 ───────────────────────────────────────────────────
class ContentTypeConfig(BaseModel): class ScoutContentTypeConfig(BaseModel):
"""Per-type extraction config produced by the journey chatbot.""" """Per-type extraction config produced by the journey chatbot."""
id: str id: str
@@ -220,34 +220,34 @@ class ContentTypeConfig(BaseModel):
extraction_prompt: str extraction_prompt: str
class AgentConfig(BaseModel): class ScoutConfig(BaseModel):
"""Structured agent configuration (replaces freeform prompt_template).""" """Structured scout configuration (replaces freeform prompt_template)."""
content_types: list[ContentTypeConfig] = [] content_types: list[ScoutContentTypeConfig] = []
global_rules: list[str] = [] global_rules: list[str] = []
data_types: list[str] = [] data_types: list[str] = []
# ── Agent Catalog ───────────────────────────────────────────────────── # ── Scout Catalog ─────────────────────────────────────────────────────
class AgentCatalogItem(BaseModel): class ScoutCatalogItem(BaseModel):
type: str type: str
name: str name: str
description: str description: str
class AgentCreationCheckRequest(BaseModel): class ScoutCreationCheckRequest(BaseModel):
active_agents: int = Field(ge=0, default=0) active_agents: int = Field(ge=0, default=0)
class AgentCreationCheckResponse(BaseModel): class ScoutCreationCheckResponse(BaseModel):
allowed: bool allowed: bool
tier: BillingTier tier: BillingTier
active_agents: int active_agents: int
limit: int limit: int
class AgentTriggerRequest(BaseModel): class ScoutTriggerRequest(BaseModel):
directory: str = Field(min_length=1) directory: str = Field(min_length=1)
device_id: str = Field(default="") device_id: str = Field(default="")
agent_id: str | None = None # FE stable agent ID (electron-store UUID) agent_id: str | None = None # FE stable agent ID (electron-store UUID)
@@ -259,9 +259,9 @@ class AgentTriggerRequest(BaseModel):
last_run_at: int | None = None # epoch ms from FE — enables incremental scanning last_run_at: int | None = None # epoch ms from FE — enables incremental scanning
# ── Agent Run Log ───────────────────────────────────────────────────── # ── Scout Run Log ─────────────────────────────────────────────────────
class AgentRunLogResponse(BaseModel): class ScoutRunLogResponse(BaseModel):
id: str id: str
agent_id: str agent_id: str
agent_type: Literal["local", "cloud"] agent_type: Literal["local", "cloud"]

View File

@@ -35,7 +35,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import yaml import yaml
from app.core.agent_runner import ( from app.core.scout_runner import (
_format_metadata, _format_metadata,
_format_projects, _format_projects,
_get_extraction_rules, _get_extraction_rules,
@@ -44,7 +44,7 @@ from app.core.agent_runner import (
) )
from app.core.device_manager import DeviceConnectionManager from app.core.device_manager import DeviceConnectionManager
from app.core.langfuse_client import get_langfuse from app.core.langfuse_client import get_langfuse
from app.models import AgentRunLog, LocalAgentConfig from app.models import ScoutRunLog, LocalScoutConfig
from tests.conftest import TEST_USER_IDS from tests.conftest import TEST_USER_IDS
# ── Constants ───────────────────────────────────────────────────────────── # ── Constants ─────────────────────────────────────────────────────────────
@@ -127,8 +127,8 @@ def _make_config(
agent_config: dict | None = None, agent_config: dict | None = None,
directory: str = "/emails", directory: str = "/emails",
device_id: str = "dev-001", device_id: str = "dev-001",
) -> LocalAgentConfig: ) -> LocalScoutConfig:
return LocalAgentConfig( return LocalScoutConfig(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
user_id=_USER_ID, user_id=_USER_ID,
device_id=device_id, device_id=device_id,
@@ -136,7 +136,7 @@ def _make_config(
directory_paths=[directory], directory_paths=[directory],
data_types=["tasks", "notes", "timelines"], data_types=["tasks", "notes", "timelines"],
prompt_template="", prompt_template="",
agent_config=agent_config or _AGENT_CONFIG, scout_config=agent_config or _AGENT_CONFIG,
file_extensions=[".html", ".eml"], file_extensions=[".html", ".eml"],
schedule_cron="0 */6 * * *", schedule_cron="0 */6 * * *",
enabled=True, enabled=True,
@@ -144,11 +144,11 @@ def _make_config(
) )
def _make_run_log(agent_id: str) -> AgentRunLog: def _make_run_log(agent_id: str) -> ScoutRunLog:
return AgentRunLog( return ScoutRunLog(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
agent_id=agent_id, scout_id=agent_id,
agent_type="local", scout_type="local",
user_id=_USER_ID, user_id=_USER_ID,
status="running", status="running",
started_at=datetime.now(timezone.utc), started_at=datetime.now(timezone.utc),
@@ -271,7 +271,7 @@ async def test_2_9_device_offline():
run_log = _make_run_log(config.id) run_log = _make_run_log(config.id)
mgr = _make_manager(online=False) mgr = _make_manager(online=False)
with patch("app.core.agent_runner._finalize_run", new_callable=AsyncMock) as mock_fin: with patch("app.core.scout_runner._finalize_run", new_callable=AsyncMock) as mock_fin:
await run_local_agent(_USER_ID, config, run_log, mgr) await run_local_agent(_USER_ID, config, run_log, mgr)
_, kwargs = mock_fin.call_args _, kwargs = mock_fin.call_args
@@ -295,8 +295,8 @@ async def test_2_10_empty_file():
projects=[_PROJECTS["alpha"]], projects=[_PROJECTS["alpha"]],
) )
with patch("app.core.agent_runner._make_agent_executor", return_value=executor), \ with patch("app.core.scout_runner._make_agent_executor", return_value=executor), \
patch("app.core.agent_runner._finalize_run", new_callable=AsyncMock) as mock_fin: patch("app.core.scout_runner._finalize_run", new_callable=AsyncMock) as mock_fin:
await run_local_agent(_USER_ID, config, run_log, mgr) await run_local_agent(_USER_ID, config, run_log, mgr)
_, kwargs = mock_fin.call_args _, kwargs = mock_fin.call_args
@@ -326,9 +326,9 @@ async def test_2_8_items_created_count():
_tool_calls_out.extend(["create_task", "create_note", "update_task"]) _tool_calls_out.extend(["create_task", "create_note", "update_task"])
return "Done." return "Done."
with patch("app.core.agent_runner._make_agent_executor", return_value=executor), \ with patch("app.core.scout_runner._make_agent_executor", return_value=executor), \
patch("app.core.agent_runner._run_agent_with_tools", side_effect=mock_run_agent), \ patch("app.core.scout_runner._run_agent_with_tools", side_effect=mock_run_agent), \
patch("app.core.agent_runner._finalize_run", new_callable=AsyncMock) as mock_fin: patch("app.core.scout_runner._finalize_run", new_callable=AsyncMock) as mock_fin:
await run_local_agent(_USER_ID, config, run_log, mgr) await run_local_agent(_USER_ID, config, run_log, mgr)
_, kwargs = mock_fin.call_args _, kwargs = mock_fin.call_args
@@ -377,8 +377,8 @@ async def test_eval_runner(runner_case, pytestconfig):
) if lf else nullcontext() ) if lf else nullcontext()
with obs_ctx as obs: with obs_ctx as obs:
with patch("app.core.agent_runner._make_agent_executor", return_value=executor), \ with patch("app.core.scout_runner._make_agent_executor", return_value=executor), \
patch("app.core.agent_runner._finalize_run", new_callable=AsyncMock) as mock_fin: patch("app.core.scout_runner._finalize_run", new_callable=AsyncMock) as mock_fin:
await run_local_agent(_USER_ID, config, run_log, mgr) await run_local_agent(_USER_ID, config, run_log, mgr)
_, kwargs = mock_fin.call_args _, kwargs = mock_fin.call_args

View File

@@ -22,7 +22,7 @@ import pytest
from app.core.device_manager import DeviceConnectionManager from app.core.device_manager import DeviceConnectionManager
from app.db import get_session from app.db import get_session
from app.main import app from app.main import app
from app.models import AgentRunLog from app.models import ScoutRunLog
from tests.conftest import TEST_USER_IDS, make_jwt from tests.conftest import TEST_USER_IDS, make_jwt
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -33,9 +33,9 @@ _FREE_UID = TEST_USER_IDS["free"]
_PRO_UID = TEST_USER_IDS["pro"] _PRO_UID = TEST_USER_IDS["pro"]
def _device_hello(device_id: str = "dev-001", agent_ids: list[str] | None = None) -> str: def _device_hello(device_id: str = "dev-001", scout_ids: list[str] | None = None) -> str:
return json.dumps( return json.dumps(
{"type": "device_hello", "device_id": device_id, "agent_ids": agent_ids or []} {"type": "device_hello", "device_id": device_id, "scout_ids": scout_ids or []}
) )
@@ -262,10 +262,10 @@ async def test_mark_runs_disconnected_updates_db(db_session):
user_id = TEST_USER_IDS["free"] user_id = TEST_USER_IDS["free"]
run_log = AgentRunLog( run_log = ScoutRunLog(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
agent_id=str(uuid.uuid4()), scout_id=str(uuid.uuid4()),
agent_type="local", scout_type="local",
user_id=user_id, user_id=user_id,
status="running", status="running",
started_at=datetime.now(timezone.utc), started_at=datetime.now(timezone.utc),
@@ -280,7 +280,7 @@ async def test_mark_runs_disconnected_updates_db(db_session):
# Verify through the same session factory. # Verify through the same session factory.
async with _TestSessionLocal() as s: async with _TestSessionLocal() as s:
result = await s.execute( result = await s.execute(
select(AgentRunLog).where(AgentRunLog.id == run_log.id) select(ScoutRunLog).where(ScoutRunLog.id == run_log.id)
) )
updated = result.scalar_one_or_none() updated = result.scalar_one_or_none()

View File

@@ -1,6 +1,6 @@
"""Tests for Local Agent V2 journey setup (Step 4). """Tests for Local Agent V2 journey setup (Step 4).
Covers the chatbot journey that produces a structured AgentConfig JSON Covers the chatbot journey that produces a structured ScoutConfig JSON
instead of a freeform prompt_template string. instead of a freeform prompt_template string.
Unit tests (no LLM) Unit tests (no LLM)
@@ -16,7 +16,7 @@ Eval test (real LLM + Langfuse scoring)
---------------------------------------- ----------------------------------------
4.1 Journey start explores directory → first reply contains a question 4.1 Journey start explores directory → first reply contains a question
Cases 4.24.5 (multi-turn conversations producing a full AgentConfig) are Cases 4.24.5 (multi-turn conversations producing a full ScoutConfig) are
non-deterministic and tested manually — results tracked in Langfuse. non-deterministic and tested manually — results tracked in Langfuse.
Run: Run:
@@ -37,7 +37,7 @@ from unittest.mock import patch
import pytest import pytest
import yaml import yaml
from app.api.routes.agent_setup import ( from app.api.routes.scout_setup import (
_CONFIG_END, _CONFIG_END,
_CONFIG_START, _CONFIG_START,
_MAX_TURNS, _MAX_TURNS,
@@ -48,7 +48,7 @@ from app.api.routes.agent_setup import (
) )
from app.core.langfuse_client import get_langfuse from app.core.langfuse_client import get_langfuse
from app.core.ws_context import clear_client_executor, set_client_executor from app.core.ws_context import clear_client_executor, set_client_executor
from app.schemas import AgentConfig from app.schemas import ScoutConfig
from tests.conftest import TEST_USER_IDS from tests.conftest import TEST_USER_IDS
# ── Constants ───────────────────────────────────────────────────────────── # ── Constants ─────────────────────────────────────────────────────────────
@@ -179,7 +179,7 @@ def _evaluate_case(case: dict, reply: dict) -> tuple[float, str]:
def test_4_6a_extract_valid_json(): def test_4_6a_extract_valid_json():
"""_extract_agent_config: valid JSON between markers → returns serialised config.""" """_extract_agent_config: valid JSON between markers → returns serialised config."""
config = AgentConfig( config = ScoutConfig(
content_types=[], content_types=[],
global_rules=["No project = no entity"], global_rules=["No project = no entity"],
data_types=["tasks"], data_types=["tasks"],
@@ -187,7 +187,7 @@ def test_4_6a_extract_valid_json():
text = f"Some preamble\n{_CONFIG_START}\n{config.model_dump_json()}\n{_CONFIG_END}\nTrailing" text = f"Some preamble\n{_CONFIG_START}\n{config.model_dump_json()}\n{_CONFIG_END}\nTrailing"
result = _extract_agent_config(text) result = _extract_agent_config(text)
assert result is not None assert result is not None
parsed = AgentConfig.model_validate_json(result) parsed = ScoutConfig.model_validate_json(result)
assert parsed.global_rules == ["No project = no entity"] assert parsed.global_rules == ["No project = no entity"]
@@ -230,7 +230,7 @@ async def test_4_6f_nudge_uses_new_markers():
# Return plain text — no markers — to trigger the nudge path. # Return plain text — no markers — to trigger the nudge path.
return "I still need more information from you." return "I still need more information from you."
from app.api.routes.agent_setup import JourneySession from app.api.routes.scout_setup import JourneySession
fake_session = JourneySession( fake_session = JourneySession(
session_id=session_id, session_id=session_id,
@@ -248,7 +248,7 @@ async def test_4_6f_nudge_uses_new_markers():
_sessions[session_id] = fake_session _sessions[session_id] = fake_session
try: try:
with patch("app.api.routes.agent_setup._call_llm_with_tools", side_effect=_mock_llm): with patch("app.api.routes.scout_setup._call_llm_with_tools", side_effect=_mock_llm):
await handle_journey_message(_USER_ID, { await handle_journey_message(_USER_ID, {
"session_id": session_id, "session_id": session_id,
"message": "one more message to trigger nudge", "message": "one more message to trigger nudge",

View File

@@ -322,7 +322,7 @@ def test_home_request_calls_memory_middleware(client):
): ):
with client.websocket_connect(f"/api/v1/ws/device?token={token}") as ws: with client.websocket_connect(f"/api/v1/ws/device?token={token}") as ws:
ws.send_text(json.dumps({ ws.send_text(json.dumps({
"type": "device_hello", "device_id": "dev-mem", "agent_ids": [] "type": "device_hello", "device_id": "dev-mem", "scout_ids": []
})) }))
ws.send_text(json.dumps({ ws.send_text(json.dumps({
"type": "home_request", "type": "home_request",

View File

@@ -58,7 +58,7 @@ def test_home_request_produces_stream_frames(client):
with patch("app.api.routes.device_ws.run_home_stream", side_effect=_mock_home_stream): with patch("app.api.routes.device_ws.run_home_stream", side_effect=_mock_home_stream):
with client.websocket_connect(f"/api/v1/ws/device?token={token}") as ws: with client.websocket_connect(f"/api/v1/ws/device?token={token}") as ws:
ws.send_text(json.dumps({ ws.send_text(json.dumps({
"type": "device_hello", "device_id": "dev-1", "agent_ids": [] "type": "device_hello", "device_id": "dev-1", "scout_ids": []
})) }))
ws.send_text(json.dumps({ ws.send_text(json.dumps({
"type": "home_request", "type": "home_request",
@@ -85,7 +85,7 @@ def test_home_request_request_id_propagated(client):
with patch("app.api.routes.device_ws.run_home_stream", side_effect=_stream): with patch("app.api.routes.device_ws.run_home_stream", side_effect=_stream):
with client.websocket_connect(f"/api/v1/ws/device?token={token}") as ws: with client.websocket_connect(f"/api/v1/ws/device?token={token}") as ws:
ws.send_text(json.dumps({ ws.send_text(json.dumps({
"type": "device_hello", "device_id": "dev-3", "agent_ids": [] "type": "device_hello", "device_id": "dev-3", "scout_ids": []
})) }))
ws.send_text(json.dumps({ ws.send_text(json.dumps({
"type": "home_request", "type": "home_request",
@@ -106,7 +106,7 @@ def test_tool_result_dispatch_silent_on_unknown_id(client):
with patch("app.api.routes.device_ws._HEARTBEAT_INTERVAL", 0.05): with patch("app.api.routes.device_ws._HEARTBEAT_INTERVAL", 0.05):
with client.websocket_connect(f"/api/v1/ws/device?token={token}") as ws: with client.websocket_connect(f"/api/v1/ws/device?token={token}") as ws:
ws.send_text(json.dumps({ ws.send_text(json.dumps({
"type": "device_hello", "device_id": "dev-4", "agent_ids": [] "type": "device_hello", "device_id": "dev-4", "scout_ids": []
})) }))
ws.send_text(json.dumps({ ws.send_text(json.dumps({
"type": "tool_result", "id": "no-such-id", "ok": True "type": "tool_result", "id": "no-such-id", "ok": True