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>
This commit is contained in:
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ 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 ScoutRunLog, LocalScoutConfig
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,8 +70,8 @@ def _to_data_types(values: list[str]) -> list[str]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _to_run_log_response(log: ScoutRunLog) -> AgentRunLogResponse:
|
def _to_run_log_response(log: ScoutRunLog) -> ScoutRunLogResponse:
|
||||||
return AgentRunLogResponse(
|
return ScoutRunLogResponse(
|
||||||
id=log.id,
|
id=log.id,
|
||||||
agent_id=log.scout_id,
|
agent_id=log.scout_id,
|
||||||
agent_type=log.scout_type, # type: ignore[arg-type]
|
agent_type=log.scout_type, # type: ignore[arg-type]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.2–4.5 (multi-turn conversations producing a full AgentConfig) are
|
Cases 4.2–4.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:
|
||||||
@@ -48,7 +48,7 @@ from app.api.routes.scout_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"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user