feat(local-agent-v2): step 4 — journey produces structured AgentConfig JSON
Replace freeform prompt_template output with validated AgentConfig JSON: - agent_setup.py: new system prompt (journey_system_v2), AGENT_CONFIG_START/END markers, _extract_agent_config() with Pydantic validation, updated handlers returning agent_config key; import AgentConfig from schemas - tests/test_journey_v2.py: 6 unit tests + 5 parametrized LLM eval cases following test_agent_runner_v2.py pattern; _run_journey uses set_client_executor/clear_client_executor mirroring device_ws - tests/fixtures/journey_v2/: cases.yaml + email_action.html + email_info.html - tests/conftest.py: add --journey-dir CLI option; remove S3/plugin fixtures (cleanup from microservices migration, already present in working tree) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"""Chatbot Journey — WS-based guided conversation to build an agent prompt_template.
|
||||
"""Chatbot Journey — WS-based guided conversation to build an AgentConfig.
|
||||
|
||||
The journey is driven entirely through WebSocket frames (no REST endpoints).
|
||||
The device WS handler dispatches ``journey_start`` and ``journey_message``
|
||||
frames to the functions exported here.
|
||||
|
||||
Journey flow:
|
||||
1. FE sends ``journey_start`` frame with basic agent config (directory,
|
||||
1. FE sends ``journey_start`` frame with basic agent info (directory,
|
||||
data_types, schedule).
|
||||
2. Server creates an in-memory session, sets up a WS executor so the
|
||||
setup LLM can use file-system tools, does a first directory scrape,
|
||||
@@ -13,10 +13,11 @@ Journey flow:
|
||||
3. FE sends ``journey_message`` frames for each user reply.
|
||||
4. Server appends the user message, calls the LLM (which may read files
|
||||
via tools), and sends back a ``journey_reply``.
|
||||
5. After 3-5 turns the LLM wraps up by emitting a ``prompt_template``
|
||||
block delimited by ``PROMPT_TEMPLATE_START`` / ``PROMPT_TEMPLATE_END``.
|
||||
6. Server parses the block, sends ``journey_reply`` with ``done=True``
|
||||
and the template. FE stores it locally.
|
||||
5. After 3-5 turns the LLM wraps up by emitting an ``AgentConfig`` JSON
|
||||
block delimited by ``AGENT_CONFIG_START`` / ``AGENT_CONFIG_END``.
|
||||
6. Server parses and validates the JSON with Pydantic, sends
|
||||
``journey_reply`` with ``done=True`` and the serialised config.
|
||||
FE stores it locally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,6 +35,7 @@ from app.agents.filesystem_agent import FILESYSTEM_TOOLS
|
||||
from app.config.settings import settings
|
||||
from app.core.langfuse_client import compile_prompt, extract_usage, get_langfuse, get_prompt_or_fallback
|
||||
from app.core.llm import get_llm
|
||||
from app.schemas import AgentConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,9 +43,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_SESSION_TTL_SECONDS: int = 1800 # 30 minutes
|
||||
|
||||
# Sentinel strings used to delimit the LLM-produced prompt_template.
|
||||
_TEMPLATE_START = "PROMPT_TEMPLATE_START"
|
||||
_TEMPLATE_END = "PROMPT_TEMPLATE_END"
|
||||
# Sentinel strings used to delimit the LLM-produced AgentConfig JSON.
|
||||
_CONFIG_START = "AGENT_CONFIG_START"
|
||||
_CONFIG_END = "AGENT_CONFIG_END"
|
||||
|
||||
# Minimum turns before we consider nudging the LLM to wrap up.
|
||||
_MIN_TURNS_BEFORE_NUDGE: int = 3
|
||||
@@ -86,61 +88,76 @@ def get_journey_session(session_id: str, user_id: str) -> JourneySession | None:
|
||||
return s
|
||||
|
||||
|
||||
# ── System prompt builder ─────────────────────────────────────────────────
|
||||
# ── System prompt ─────────────────────────────────────────────────────────
|
||||
|
||||
_JOURNEY_SYSTEM_PROMPT = """\
|
||||
You are a friendly assistant helping a freelancer configure a data-extraction agent.
|
||||
Your job is to understand exactly what data the user wants to extract from their
|
||||
local directory and produce a detailed prompt_template that a separate AI will use
|
||||
as its instruction set.
|
||||
|
||||
The extraction agent already has this base behaviour built in:
|
||||
- Reads each file using file-system tools.
|
||||
- Creates records (tasks, notes, timelines, projects) via CRUD tools.
|
||||
- Sets isAiSuggested=1 on every new record.
|
||||
- Only extracts data explicitly present in the files — it never invents information.
|
||||
The user's custom prompt is appended AFTER this base behaviour, so focus on
|
||||
what to look for and how to map it — not on the general extraction mechanics.
|
||||
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.
|
||||
|
||||
You have access to file-system tools to explore the user's directory:
|
||||
- list_directory: to see folder structure
|
||||
- read_file_content: to peek at file contents
|
||||
- get_file_metadata: to check file info
|
||||
- list_directory: see folder structure and file names
|
||||
- read_file_content: peek at a file's content
|
||||
- get_file_metadata: check file size, extension, dates
|
||||
|
||||
The user's configured directory is: {directory}
|
||||
Target data types: {data_types}
|
||||
|
||||
IMPORTANT — project assignment is handled automatically by the main agent runner
|
||||
before the custom prompt is ever used. You MUST NOT ask the user about projects,
|
||||
projectId, or how to link records to projects. Never include projectId logic or
|
||||
project creation instructions in the generated prompt_template.
|
||||
## Your process
|
||||
|
||||
Start by exploring the directory to understand its structure. Then ask concise,
|
||||
focused questions one at a time. Cover these topics (not necessarily in this order):
|
||||
1. The type and format of the source content (confirmed by your exploration).
|
||||
2. How fields should be mapped (e.g. filename → task title).
|
||||
3. Priority or status rules (e.g. "urgent" keyword → high priority).
|
||||
4. Any special handling, date extraction, or exclusions.
|
||||
### Step 1 — Explore the directory
|
||||
Use list_directory and read_file_content to understand what types of files are present
|
||||
(HTML emails, plain-text documents, CSVs, etc.).
|
||||
|
||||
Once you reach 90% confidence, output the final prompt_template between these exact
|
||||
markers on their own lines:
|
||||
### Step 2 — Identify content types
|
||||
For each distinct file type found, decide:
|
||||
- A short id (e.g. "email_html", "plain_text", "csv")
|
||||
- Which preprocessing handler to use: "email_html" for HTML emails, "generic" for everything else
|
||||
- A human-readable label and optional detection_hint
|
||||
|
||||
{template_start}
|
||||
<the complete extraction prompt here>
|
||||
{template_end}
|
||||
### Step 3 — Ask focused questions (one at a time)
|
||||
Cover these topics based on what you discovered:
|
||||
1. How to map content to entity types (task / note / timeline entry)
|
||||
2. Field mapping rules (e.g. email Subject → task title, filename → note title)
|
||||
3. Priority or status rules (e.g. "urgent" in subject → high priority)
|
||||
4. Date extraction (e.g. "by Friday" → dueDate)
|
||||
5. Exclusion rules (e.g. skip newsletters, skip files with no project match)
|
||||
|
||||
The prompt_template must be a self-contained instruction for an AI that reads files
|
||||
and must perform CRUD operations using tools to create records. It should specify:
|
||||
- What entity types to create (tasks, notes, timelines) — never projects.
|
||||
- How to map file content to record fields (camelCase: title, status, priority,
|
||||
dueDate, content, etc.) — never include projectId.
|
||||
- That isAiSuggested must be set to 1 on every new record.
|
||||
- Concrete examples of mappings based on what you discovered in the directory.
|
||||
### Step 4 — Produce the AgentConfig JSON
|
||||
Once you are ≥ 90% confident, output the final config between these exact markers
|
||||
(each on its own line):
|
||||
|
||||
{config_start}
|
||||
{{
|
||||
"content_types": [
|
||||
{{
|
||||
"id": "email_html",
|
||||
"label": "Email HTML",
|
||||
"detection_hint": "HTML file with From/To/Subject headers",
|
||||
"preprocessing": "email_html",
|
||||
"extraction_prompt": "Detailed extraction instructions for this content type..."
|
||||
}}
|
||||
],
|
||||
"global_rules": [
|
||||
"If the file cannot be matched to any project, do not create any entity."
|
||||
],
|
||||
"data_types": {data_types_json}
|
||||
}}
|
||||
{config_end}
|
||||
|
||||
## Rules for the extraction_prompt field
|
||||
- Describe when to create a task vs note vs timeline entry (be specific and concrete)
|
||||
- Include field mapping rules based on what you found in the directory
|
||||
- Include priority/status/date rules if applicable
|
||||
- Do NOT include projectId logic — the runner handles project assignment automatically
|
||||
- Do NOT mention isAiSuggested — the runner always sets it to 1
|
||||
|
||||
## Constraints
|
||||
- Never ask about projects, projectId, or how to link records to projects
|
||||
- Never include projectId or project creation logic in the generated config
|
||||
- Keep asking questions until ≥ 90% confident, then output the JSON immediately
|
||||
|
||||
{existing_section}\
|
||||
Keep asking clarifying questions until you are at least 90% confident you have
|
||||
enough information to generate an accurate prompt_template. Once you reach that
|
||||
confidence level, stop asking and produce the final template immediately.
|
||||
Begin by exploring the directory, then ask your first question.\
|
||||
"""
|
||||
|
||||
@@ -148,40 +165,53 @@ Begin by exploring the directory, then ask your first question.\
|
||||
def _build_system_prompt(
|
||||
directory: str,
|
||||
data_types: list[str],
|
||||
existing_template: str | None = None,
|
||||
existing_config: str | None = None,
|
||||
) -> tuple[str, Any]:
|
||||
"""Return ``(compiled_system_prompt, langfuse_prompt_obj_or_None)``."""
|
||||
existing_section = (
|
||||
f"\nThe user already has the following prompt_template — refine it based on their answers:\n"
|
||||
f"---\n{existing_template}\n---\n"
|
||||
if existing_template
|
||||
"\nThe user already has the following AgentConfig — refine it based on their answers:\n"
|
||||
f"```json\n{existing_config}\n```\n"
|
||||
if existing_config
|
||||
else ""
|
||||
)
|
||||
template, prompt_obj = get_prompt_or_fallback(
|
||||
"journey_system", _JOURNEY_SYSTEM_PROMPT
|
||||
"journey_system_v2", _JOURNEY_SYSTEM_PROMPT
|
||||
)
|
||||
compiled = compile_prompt(
|
||||
template,
|
||||
prompt_obj,
|
||||
directory=directory,
|
||||
data_types=", ".join(data_types),
|
||||
template_start=_TEMPLATE_START,
|
||||
template_end=_TEMPLATE_END,
|
||||
data_types_json=json.dumps(data_types),
|
||||
config_start=_CONFIG_START,
|
||||
config_end=_CONFIG_END,
|
||||
existing_section=existing_section,
|
||||
)
|
||||
return compiled, prompt_obj
|
||||
|
||||
|
||||
# ── Template extraction ───────────────────────────────────────────────────
|
||||
# ── AgentConfig extraction ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_template(text: str) -> str | None:
|
||||
"""Return the text between PROMPT_TEMPLATE_START and PROMPT_TEMPLATE_END, or None."""
|
||||
if _TEMPLATE_START not in text or _TEMPLATE_END not in text:
|
||||
def _extract_agent_config(text: str) -> str | None:
|
||||
"""Return validated AgentConfig JSON string from between markers, or None.
|
||||
|
||||
Parses the JSON with Pydantic to ensure it conforms to the schema before
|
||||
returning. Returns None if markers are absent or JSON is invalid.
|
||||
"""
|
||||
if _CONFIG_START not in text or _CONFIG_END not in text:
|
||||
return None
|
||||
start_idx = text.index(_CONFIG_START) + len(_CONFIG_START)
|
||||
end_idx = text.index(_CONFIG_END)
|
||||
raw = text[start_idx:end_idx].strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = AgentConfig.model_validate_json(raw)
|
||||
return parsed.model_dump_json()
|
||||
except Exception as exc:
|
||||
logger.warning("agent_setup: failed to parse AgentConfig JSON: %s", exc)
|
||||
return None
|
||||
start_idx = text.index(_TEMPLATE_START) + len(_TEMPLATE_START)
|
||||
end_idx = text.index(_TEMPLATE_END)
|
||||
return text[start_idx:end_idx].strip() or None
|
||||
|
||||
|
||||
# ── LLM call with tool support ───────────────────────────────────────────
|
||||
@@ -235,8 +265,7 @@ async def _call_llm_with_tools(
|
||||
lf.start_as_current_observation(
|
||||
as_type="span",
|
||||
name="journey-setup",
|
||||
user_id=user_id or None,
|
||||
session_id=session_id or None,
|
||||
metadata={"user_id": user_id or None, "session_id": session_id or None},
|
||||
input=history[-1]["content"] if history else "",
|
||||
)
|
||||
if lf else None
|
||||
@@ -318,12 +347,12 @@ async def handle_journey_start(
|
||||
agent_type = frame.get("agent_type", "local")
|
||||
directory = frame.get("directory", "")
|
||||
data_types = frame.get("data_types", [])
|
||||
existing_template = frame.get("existing_template")
|
||||
existing_config = frame.get("existing_config")
|
||||
|
||||
# Use the session_id provided by the FE so the reply matches the
|
||||
# listener key; fall back to a generated one if absent.
|
||||
session_id = frame.get("session_id") or str(uuid.uuid4())
|
||||
system_prompt, langfuse_prompt = _build_system_prompt(directory, data_types, existing_template)
|
||||
system_prompt, langfuse_prompt = _build_system_prompt(directory, data_types, existing_config)
|
||||
|
||||
session = JourneySession(
|
||||
session_id=session_id,
|
||||
@@ -335,10 +364,8 @@ async def handle_journey_start(
|
||||
langfuse_prompt=langfuse_prompt,
|
||||
)
|
||||
|
||||
# The LLM will explore the directory using FILESYSTEM_TOOLS via the
|
||||
# ws_context executor (already set by the WS handler before calling us).
|
||||
# Seed with an initial user message — some providers (e.g. GitHub Copilot)
|
||||
# require at least one user/input message to be present.
|
||||
# Seed with an initial user message — some providers require at least one
|
||||
# user/input message to be present.
|
||||
seed_history: list[dict[str, Any]] = [
|
||||
{"role": "user", "content": "Hi, I'm ready to set up my agent. Please explore my directory and ask me your first question."},
|
||||
]
|
||||
@@ -362,14 +389,14 @@ async def handle_journey_start(
|
||||
directory,
|
||||
)
|
||||
|
||||
# Check if the LLM produced the template on the first turn (unlikely but possible).
|
||||
prompt_template = _extract_template(ai_reply)
|
||||
done = prompt_template is not None
|
||||
# Check if the LLM produced the config on the first turn (unlikely but possible).
|
||||
agent_config = _extract_agent_config(ai_reply)
|
||||
done = agent_config is not None
|
||||
|
||||
display_message = ai_reply
|
||||
if done:
|
||||
display_message = (
|
||||
ai_reply[: ai_reply.index(_TEMPLATE_START)].strip()
|
||||
ai_reply[: ai_reply.index(_CONFIG_START)].strip()
|
||||
or "Here is your agent configuration. You can save it or continue refining."
|
||||
)
|
||||
_sessions.pop(session_id, None)
|
||||
@@ -379,7 +406,7 @@ async def handle_journey_start(
|
||||
"session_id": session_id,
|
||||
"message": display_message,
|
||||
"done": done,
|
||||
"prompt_template": prompt_template,
|
||||
"agent_config": agent_config,
|
||||
}
|
||||
|
||||
|
||||
@@ -402,7 +429,7 @@ async def handle_journey_message(
|
||||
"session_id": session_id,
|
||||
"message": "Journey session not found or expired. Please start a new setup.",
|
||||
"done": True,
|
||||
"prompt_template": None,
|
||||
"agent_config": None,
|
||||
}
|
||||
|
||||
# Append user turn.
|
||||
@@ -420,18 +447,17 @@ async def handle_journey_message(
|
||||
|
||||
session.history.append({"role": "assistant", "content": ai_reply})
|
||||
|
||||
# Check if the LLM produced the final template.
|
||||
prompt_template = _extract_template(ai_reply)
|
||||
done = prompt_template is not None
|
||||
# Check if the LLM produced the final config.
|
||||
agent_config = _extract_agent_config(ai_reply)
|
||||
done = agent_config is not None
|
||||
|
||||
# If the LLM didn't produce a template, nudge it once it has asked enough
|
||||
# questions (>= _MIN_TURNS_BEFORE_NUDGE) or hits the hard safety cap.
|
||||
# If the LLM didn't produce a config, nudge it once it hits the hard safety cap.
|
||||
if not done:
|
||||
turns = sum(1 for t in session.history if t["role"] == "user")
|
||||
if turns >= _MAX_TURNS:
|
||||
nudge_content = (
|
||||
"[System: You have enough information. Please generate the final "
|
||||
f"prompt_template now, wrapped in {_TEMPLATE_START} / {_TEMPLATE_END} markers.]"
|
||||
f"AgentConfig JSON now, wrapped in {_CONFIG_START} / {_CONFIG_END} markers.]"
|
||||
)
|
||||
session.history.append({"role": "user", "content": nudge_content})
|
||||
|
||||
@@ -445,16 +471,16 @@ async def handle_journey_message(
|
||||
)
|
||||
session.history.append({"role": "assistant", "content": nudge_reply})
|
||||
|
||||
prompt_template = _extract_template(nudge_reply)
|
||||
if prompt_template is not None:
|
||||
agent_config = _extract_agent_config(nudge_reply)
|
||||
if agent_config is not None:
|
||||
done = True
|
||||
ai_reply = nudge_reply
|
||||
|
||||
display_message = ai_reply
|
||||
if done:
|
||||
display_message = (
|
||||
ai_reply[: ai_reply.index(_TEMPLATE_START)].strip()
|
||||
if _TEMPLATE_START in ai_reply
|
||||
ai_reply[: ai_reply.index(_CONFIG_START)].strip()
|
||||
if _CONFIG_START in ai_reply
|
||||
else "Here is your agent configuration. You can save it or continue refining."
|
||||
)
|
||||
_sessions.pop(session_id, None)
|
||||
@@ -465,5 +491,5 @@ async def handle_journey_message(
|
||||
"session_id": session_id,
|
||||
"message": display_message,
|
||||
"done": done,
|
||||
"prompt_template": prompt_template,
|
||||
"agent_config": agent_config,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user