Rewrite run_local_agent: code-based flow, concurrency guard, remove isApproved

- Replace LLM-driven triage with code-based directory scan and project fetch
- Two-step LLM approach: Step 1 classifies file→project+domains, Step 2 processes with tools
- Add domain descriptions to Step 1 prompt for better extraction accuracy
- Add _running_agents set for per-agent concurrency guard (one running instance per agent)
- Return 409 from route before DB write when agent already running
- Remove is_approved from task_agent create/update tools and system prompt
- Remove is_approved from timeline_agent create/update tools and system prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-03-20 22:21:30 +01:00
parent 6c450805cb
commit 58bc6efd4b
4 changed files with 423 additions and 246 deletions

View File

@@ -29,7 +29,7 @@ TASK_SYSTEM_PROMPT = (
" - project_id is optional; link to a project when the user mentions one\n" " - project_id is optional; link to a project when the user mentions one\n"
" - is_ai_suggested: 1 only when proactively proposing a task the user\n" " - is_ai_suggested: 1 only when proactively proposing a task the user\n"
" did not explicitly request; 0 otherwise\n" " did not explicitly request; 0 otherwise\n"
" - is_approved defaults to 0; set to 1 only when the user confirms\n" " - is_ai_suggested: 1 only when proactively proposing a task the user did not explicitly request; 0 otherwise\n"
" - Use list_tasks_due_today for 'what's due today' queries\n" " - Use list_tasks_due_today for 'what's due today' queries\n"
" - For update_task, use -1 for integer fields you do not want to change\n" " - For update_task, use -1 for integer fields you do not want to change\n"
" - Always confirm the action in plain, user-friendly language." " - Always confirm the action in plain, user-friendly language."
@@ -79,7 +79,6 @@ async def create_task(
due_date: int = 0, due_date: int = 0,
project_id: str = "", project_id: str = "",
is_ai_suggested: int = 0, is_ai_suggested: int = 0,
is_approved: int = 0,
) -> str: ) -> str:
"""Create a new task. """Create a new task.
title: task title (required) title: task title (required)
@@ -90,7 +89,6 @@ async def create_task(
due_date: Unix timestamp in milliseconds; 0 means no due date due_date: Unix timestamp in milliseconds; 0 means no due date
project_id: optional UUID of the parent project project_id: optional UUID of the parent project
is_ai_suggested: 1 if proactively suggested, 0 if user-requested is_ai_suggested: 1 if proactively suggested, 0 if user-requested
is_approved: 0 until the user confirms; 1 when confirmed
""" """
result = await execute_on_client( result = await execute_on_client(
action="insert", action="insert",
@@ -104,7 +102,6 @@ async def create_task(
"dueDate": due_date or None, "dueDate": due_date or None,
"projectId": project_id or None, "projectId": project_id or None,
"isAiSuggested": is_ai_suggested, "isAiSuggested": is_ai_suggested,
"isApproved": is_approved,
}, },
) )
row = result["row"] row = result["row"]
@@ -124,12 +121,10 @@ async def update_task(
assignees: str = "", assignees: str = "",
due_date: int = -1, due_date: int = -1,
project_id: str = "", project_id: str = "",
is_approved: int = -1,
) -> str: ) -> str:
"""Update fields on an existing task. Only pass fields you want to change. """Update fields on an existing task. Only pass fields you want to change.
task_id: the task's UUID (required) task_id: the task's UUID (required)
due_date: -1 means unchanged; 0 clears the due date; any positive value sets it due_date: -1 means unchanged; 0 clears the due date; any positive value sets it
is_approved: -1 means unchanged; 0 or 1 sets the value
""" """
updates: dict[str, Any] = {} updates: dict[str, Any] = {}
if title: if title:
@@ -146,8 +141,6 @@ async def update_task(
updates["dueDate"] = due_date or None updates["dueDate"] = due_date or None
if project_id: if project_id:
updates["projectId"] = project_id updates["projectId"] = project_id
if is_approved != -1:
updates["isApproved"] = is_approved
result = await execute_on_client( result = await execute_on_client(
action="update", action="update",
table="tasks", table="tasks",

View File

@@ -25,7 +25,7 @@ TIMELINE_SYSTEM_PROMPT = (
" - For listing, project_id must be a UUID; never pass plain names as project_id\n" " - For listing, project_id must be a UUID; never pass plain names as project_id\n"
" - date is a Unix timestamp in milliseconds; convert human-readable dates\n" " - date is a Unix timestamp in milliseconds; convert human-readable dates\n"
" - is_ai_suggested: 1 when proactively proposing a timeline, 0 otherwise\n" " - is_ai_suggested: 1 when proactively proposing a timeline, 0 otherwise\n"
" - is_approved: 0 until the user explicitly confirms; then 1\n" " - is_ai_suggested: 1 when proactively proposing a timeline, 0 otherwise\n"
" - For update_timeline, use -1 for integer fields you do not want to change\n" " - For update_timeline, use -1 for integer fields you do not want to change\n"
" - Listing without a project_id returns all timelines across projects\n" " - Listing without a project_id returns all timelines across projects\n"
" - Always echo the title and formatted date in your confirmation." " - Always echo the title and formatted date in your confirmation."
@@ -54,14 +54,12 @@ async def create_timeline(
title: str, title: str,
date: int, date: int,
is_ai_suggested: int = 0, is_ai_suggested: int = 0,
is_approved: int = 0,
) -> str: ) -> str:
"""Create a project timeline (milestone). """Create a project timeline (milestone).
project_id: REQUIRED UUID of the parent project project_id: REQUIRED UUID of the parent project
title: descriptive name for the milestone title: descriptive name for the milestone
date: Unix timestamp in milliseconds date: Unix timestamp in milliseconds
is_ai_suggested: 1 if proactively suggested, 0 if user-requested is_ai_suggested: 1 if proactively suggested, 0 if user-requested
is_approved: 0 until the user confirms
""" """
result = await execute_on_client( result = await execute_on_client(
action="insert", action="insert",
@@ -71,7 +69,6 @@ async def create_timeline(
"title": title, "title": title,
"date": date, "date": date,
"isAiSuggested": is_ai_suggested, "isAiSuggested": is_ai_suggested,
"isApproved": is_approved,
}, },
) )
row = result["row"] row = result["row"]
@@ -83,20 +80,16 @@ async def update_timeline(
timeline_id: str, timeline_id: str,
title: str = "", title: str = "",
date: int = -1, date: int = -1,
is_approved: int = -1,
) -> str: ) -> str:
"""Update a timeline. Only pass fields that should change. """Update a timeline. Only pass fields that should change.
timeline_id: UUID of the timeline (required) timeline_id: UUID of the timeline (required)
date: -1 means unchanged; any other value sets the new date (ms timestamp) date: -1 means unchanged; any other value sets the new date (ms timestamp)
is_approved: -1 means unchanged; 0 or 1 sets the approval state
""" """
updates: dict[str, Any] = {} updates: dict[str, Any] = {}
if title: if title:
updates["title"] = title updates["title"] = title
if date != -1: if date != -1:
updates["date"] = date updates["date"] = date
if is_approved != -1:
updates["isApproved"] = is_approved
result = await execute_on_client( result = await execute_on_client(
action="update", action="update",
table="timelines", table="timelines",

View File

@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
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 run_local_agent from app.core.agent_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.db import get_session from app.db import get_session
from app.models import AgentRunLog, LocalAgentConfig from app.models import AgentRunLog, LocalAgentConfig
@@ -193,6 +193,12 @@ async def trigger_agent_run(
# Use the FE's stable agent_id if provided, fall back to the ephemeral config id. # Use the FE's stable agent_id if provided, fall back to the ephemeral config id.
stable_agent_id = body.agent_id or config.id stable_agent_id = body.agent_id or config.id
if is_agent_running(stable_agent_id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Agent is already running. Only one run per agent is allowed at a time.",
)
run_log = AgentRunLog( run_log = AgentRunLog(
agent_id=stable_agent_id, agent_id=stable_agent_id,
agent_type="local", agent_type="local",

View File

@@ -2,11 +2,12 @@
Drives two agent types: Drives two agent types:
* **Local directory agent** — two-phase execution that mirrors the * **Local directory agent** — two-step execution per file:
``deep_agent.py`` tool-calling pattern. Phase 1 (Triage) explores the Step 1 (Classification) uses code to fetch all projects and asks the LLM
user's directory via file-system tools and groups files by project. to identify which project the file belongs to and which domains are relevant.
Phase 2 (Processing) reads full file contents and performs CRUD Step 2 (Processing) fetches existing entities for that project/domains via
operations using the standard entity tools (tasks, notes, etc.). code and runs an LLM with tools — existing data in context enforces
update-first naturally.
* **Cloud connector agent** — fetches data from third-party APIs (Gmail, * **Cloud connector agent** — fetches data from third-party APIs (Gmail,
Teams, Outlook) and pushes extracted items to Electron. Teams, Outlook) and pushes extracted items to Electron.
@@ -43,19 +44,30 @@ 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.device_manager import DeviceConnectionManager from app.core.device_manager import DeviceConnectionManager
from app.core.llm import get_llm from app.core.llm import get_llm
from app.core.ws_context import clear_client_executor, 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 AgentRunLog, CloudAgentConfig, LocalAgentConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Concurrency guard ─────────────────────────────────────────────────────
# Tracks agent IDs that currently have a run in progress.
# Prevents multiple simultaneous runs of the same agent within a single process.
_running_agents: set[str] = set()
def is_agent_running(agent_id: str) -> bool:
"""Return ``True`` if *agent_id* already has a run in progress."""
return agent_id in _running_agents
# ── Timeouts ─────────────────────────────────────────────────────────────── # ── Timeouts ───────────────────────────────────────────────────────────────
# Max seconds to wait for a single tool-call round-trip (FE → BE). # Max seconds to wait for a single tool-call round-trip (FE → BE).
_TOOL_CALL_TIMEOUT: int = 30 _TOOL_CALL_TIMEOUT: int = 30
# Max LLM reasoning steps per phase. # Max LLM reasoning steps for Step 2 processing.
_MAX_TRIAGE_STEPS: int = 10
_MAX_PROCESSING_STEPS: int = 12 _MAX_PROCESSING_STEPS: int = 12
# Max directory recursion depth during scan.
_MAX_SCAN_DEPTH: int = 5
# ── Data-type to tool mapping ───────────────────────────────────────────── # ── Data-type to tool mapping ─────────────────────────────────────────────
@@ -66,46 +78,72 @@ _DATA_TYPE_TOOLS: dict[str, list[Any]] = {
"timelines": TIMELINE_TOOLS, "timelines": TIMELINE_TOOLS,
} }
# ── Triage prompt ───────────────────────────────────────────────────────── # ── Step 1: Classification prompt ─────────────────────────────────────────
_TRIAGE_SYSTEM_PROMPT = """\ _DOMAIN_DESCRIPTIONS: dict[str, str] = {
You are a file triage assistant for a freelance project management tool. "tasks": (
Your job is to explore a local directory on the user's device, understand its "Action items, to-dos, deliverables — anything that describes work to be done, "
structure, and group files by project context. "assigned to someone, or tracked with a due date or status."
),
"notes": (
"Documentation, meeting notes, summaries, reference material — "
"written content meant to be read and referenced rather than acted on."
),
"timelines": (
"Project milestones, deadlines, scheduled events — "
"specific dates that mark a point in the progress of a project."
),
"projects": (
"High-level project entities — only relevant if the file clearly introduces "
"a new project or updates the scope of an existing one."
),
}
You have access to these tools: _STEP1_SYSTEM_PROMPT = """\
- list_directory: to map folder structure You are a file classifier for a freelance project management tool.
- get_file_metadata: to check creation/modification dates
- read_file_content: to read brief snippets when needed for categorisation
- list_projects / list_all_projects / get_project: to fetch existing projects
from the user's workspace and match files to them
Instructions: Given a file's content and a list of existing projects, your job is to:
1. Start by calling list_directory on the configured root path. 1. Identify which project this file belongs to (or "standalone" if none match).
2. Explore subdirectories as needed to understand the structure. 2. Identify which data domains are relevant to extract from this file,
3. Use get_file_metadata to check modification dates. Skip files that have limited to the allowed domains listed below.
NOT been modified since: {last_run_at}.
4. Call list_all_projects to get the user's existing projects.
5. Match files to existing projects by name, folder structure, or content hints.
6. If files don't match any existing project, group them under "standalone".
{custom_prompt_section} Domain definitions (only consider domains in the allowed list):
{domain_definitions}
Target entity types to extract: {data_types} Respond ONLY with a JSON object — no markdown, no explanation:
File extensions to consider: {file_extensions}
When you have finished exploring, output ONLY a JSON object (no markdown {{"project_id": "<uuid> or standalone", "domains": ["tasks", "notes"]}}
fences, no explanation) mapping project IDs or "standalone" to file path
arrays:
{{"<project_id>": ["<file_path>", ...], "standalone": ["<file_path>", ...]}} Existing projects:
{projects_list}
Return ONLY the JSON object as your final message.
""" """
# ── Processing prompt ───────────────────────────────────────────────────── # ── Step 2: Processing prompt ─────────────────────────────────────────────
_PROCESSING_BASE_PROMPT = """\ _PROCESSING_SYSTEM_PROMPT = """\
You are a data extraction assistant for a freelance project management tool.
Your task is to read the file content provided and create or update records
using the available tools.
IMPORTANT — update-first rules:
The existing records below are the source of truth.
If an existing record semantically matches the content (by title, topic,
or context), update it instead of creating a duplicate.
Only create a new record when no existing match is found.
Set isAiSuggested=1 on all new records.
{existing_context}
Project context: {project_context}
Target domains: {data_types}
{custom_prompt_section}
"""
# ── Cloud processing prompt (kept separate for cloud agent) ───────────────
_CLOUD_PROCESSING_PROMPT = """\
You are a data extraction and management assistant for a freelance project You are a data extraction and management assistant for a freelance project
management tool. management tool.
@@ -124,26 +162,6 @@ Your task:
4. Do NOT invent data. Only extract what is clearly present in the files. 4. Do NOT invent data. Only extract what is clearly present in the files.
5. If a file contains no relevant data for the target entity types, skip it. 5. If a file contains no relevant data for the target entity types, skip it.
Update-first rules (apply in this order):
Tasks:
- Call list_tasks to find a match by title or context.
- If found: call add_task_comment (author "Adiuva"), update_task to set
assignees, state (ToDo / In Progress / Completed), or other fields.
- If NOT found: call create_task with isAiSuggested=1, isApproved=0.
Timelines:
- Call list_timelines to find a match by title or date.
- If found: call update_timeline to edit fields or mark it complete.
- If NOT found: call create_timeline with isAiSuggested=1, isApproved=0.
Notes:
- Call list_notes to find a match by title or topic, then get_note to
read its current content.
- If found: call update_note with the merged content.
- If NOT found: call create_note with isAiSuggested=1, isApproved=0.
Projects:
- Call list_all_projects to check for a match first.
- Only call create_project if the information is clearly significant and
no existing project matches. Set isAiSuggested=1, isApproved=0.
{project_context} {project_context}
Files to process: Files to process:
@@ -168,7 +186,6 @@ def _is_overdue(schedule_cron: str, last_run_at: datetime | None) -> bool:
try: try:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if last_run_at is None: if last_run_at is None:
# Validate the expression before deciding this is overdue.
croniter(schedule_cron, now) croniter(schedule_cron, now)
return True return True
ts = last_run_at ts = last_run_at
@@ -179,7 +196,7 @@ def _is_overdue(schedule_cron: str, last_run_at: datetime | None) -> bool:
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("agent_runner: cannot parse cron %r: %s", schedule_cron, exc)
return False # Fail-safe: don't trigger if expression is invalid. return False
# ── WS executor for agent context ───────────────────────────────────────── # ── WS executor for agent context ─────────────────────────────────────────
@@ -207,7 +224,7 @@ def _make_agent_executor(
return _executor return _executor
# ── LLM tool-calling loop (mirrors deep_agent._run_single_agent) ────────── # ── LLM tool-calling loop ─────────────────────────────────────────────────
def _as_text(content: Any) -> str: def _as_text(content: Any) -> str:
@@ -235,11 +252,7 @@ async def _run_agent_with_tools(
tools: list[Any], tools: list[Any],
max_steps: int, max_steps: int,
) -> str: ) -> str:
"""Run an LLM agent with tool-calling, returning the final text response. """Run an LLM agent with tool-calling, returning the final text response."""
Follows the same pattern as ``deep_agent._run_single_agent``:
bind tools → invoke → handle tool calls → repeat until final text.
"""
llm = get_llm() llm = get_llm()
llm_with_tools = llm.bind_tools(tools) llm_with_tools = llm.bind_tools(tools)
messages: list[Any] = [ messages: list[Any] = [
@@ -247,7 +260,6 @@ async def _run_agent_with_tools(
HumanMessage(content=user_message), HumanMessage(content=user_message),
] ]
tool_calls_count = 0
tool_map = {tool_def.name: tool_def for tool_def in tools} tool_map = {tool_def.name: tool_def for tool_def in tools}
for _ in range(max_steps): for _ in range(max_steps):
@@ -258,7 +270,6 @@ async def _run_agent_with_tools(
return _as_text(response.content) return _as_text(response.content)
for call in response.tool_calls: for call in response.tool_calls:
tool_calls_count += 1
call_id = str(call.get("id", "")) call_id = str(call.get("id", ""))
call_name = str(call.get("name", "")) call_name = str(call.get("name", ""))
call_args = call.get("args", {}) call_args = call.get("args", {})
@@ -277,47 +288,19 @@ async def _run_agent_with_tools(
logger.info( logger.info(
"agent_runner: tool_result name=%s output=%s", "agent_runner: tool_result name=%s output=%s",
call_name, call_name,
str(tool_output)[:1200], str(tool_output)[:200],
) )
messages.append(ToolMessage(content=str(tool_output), tool_call_id=call["id"])) messages.append(ToolMessage(content=str(tool_output), tool_call_id=call["id"]))
# Fallback: exceeded max steps, get final response without tools.
final = await llm.ainvoke(messages) final = await llm.ainvoke(messages)
return _as_text(final.content) return _as_text(final.content)
# ── Triage map parser ─────────────────────────────────────────────────────
def _parse_triage_map(raw: str) -> dict[str, list[str]] | None:
"""Extract the JSON triage map from the LLM's final response."""
text = raw.strip()
# Try direct parse first.
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return {k: v for k, v in parsed.items() if isinstance(v, list)}
except json.JSONDecodeError:
pass
# Try extracting JSON from markdown fences or surrounding text.
import re
match = re.search(r"\{[\s\S]*\}", text)
if match:
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, dict):
return {k: v for k, v in parsed.items() if isinstance(v, list)}
except json.JSONDecodeError:
pass
return None
# ── Tool list builder ───────────────────────────────────────────────────── # ── Tool list builder ─────────────────────────────────────────────────────
def _build_processing_tools(data_types: list[str]) -> list[Any]: def _build_processing_tools(data_types: list[str]) -> list[Any]:
"""Build the tool list for Phase 2 based on user's data_types selection.""" """Build the tool list for processing based on user's data_types selection."""
tools: list[Any] = list(FILESYSTEM_TOOLS) tools: list[Any] = list(FILESYSTEM_TOOLS)
for dt in data_types: for dt in data_types:
dt_tools = _DATA_TYPE_TOOLS.get(dt) dt_tools = _DATA_TYPE_TOOLS.get(dt)
@@ -326,7 +309,223 @@ def _build_processing_tools(data_types: list[str]) -> list[Any]:
return tools return tools
# ── Local agent runner (two-phase) ───────────────────────────────────────── # ── Code-based directory scanner ─────────────────────────────────────────
async def _scan_directories(
paths: list[str],
extensions: list[str],
last_run_at: datetime | None,
) -> list[str]:
"""Walk directories via WS tool calls and return filtered file paths.
Recursion is capped at ``_MAX_SCAN_DEPTH``. Files are filtered by
extension (if configured) and by modification date (if ``last_run_at``
is set). Fails open: if metadata cannot be read, the file is included.
"""
all_files: list[str] = []
ext_set = {e.lstrip(".").lower() for e in extensions} if extensions else set()
async def _walk(path: str, depth: int) -> None:
if depth > _MAX_SCAN_DEPTH:
return
try:
result = await execute_on_client(action="list_directory", data={"path": path})
except Exception as exc:
logger.warning("agent_runner: list_directory failed %r: %s", path, exc)
return
for entry in result.get("entries", []):
entry_path = entry.get("path", "")
if not entry_path:
continue
if entry.get("type") == "directory":
await _walk(entry_path, depth + 1)
elif entry.get("type") == "file":
if ext_set:
dot_pos = entry_path.rfind(".")
file_ext = entry_path[dot_pos + 1:].lower() if dot_pos != -1 else ""
if file_ext not in ext_set:
continue
all_files.append(entry_path)
for root in paths:
await _walk(root, depth=0)
if last_run_at is None:
return all_files
# Filter by modification date.
last_run_ms = int(last_run_at.timestamp() * 1000)
filtered: list[str] = []
for file_path in all_files:
try:
meta = await execute_on_client(action="get_file_metadata", data={"path": file_path})
modified_at = meta.get("modifiedAt")
if modified_at is None:
filtered.append(file_path)
continue
if isinstance(modified_at, (int, float)):
mod_ms = int(modified_at)
else:
mod_ms = int(datetime.fromisoformat(str(modified_at)).timestamp() * 1000)
if mod_ms > last_run_ms:
filtered.append(file_path)
except Exception:
filtered.append(file_path) # fail-open
return filtered
# ── Code-based entity fetchers ────────────────────────────────────────────
async def _fetch_projects() -> list[dict]:
"""Fetch all projects from the Electron client via WS."""
try:
result = await execute_on_client(action="select", table="projects")
return result.get("rows", [])
except Exception as exc:
logger.warning("agent_runner: failed to fetch projects: %s", exc)
return []
_DOMAIN_TABLE: dict[str, str] = {
"tasks": "tasks",
"notes": "notes",
"timelines": "timelines",
"projects": "projects",
}
async def _fetch_domain_entities(domain: str, project_id: str) -> list[dict]:
"""Fetch existing rows for a domain, scoped to a project where applicable."""
table = _DOMAIN_TABLE.get(domain)
if not table:
return []
filters: dict[str, Any] = {}
if project_id != "standalone" and domain != "projects":
filters["projectId"] = project_id
try:
result = await execute_on_client(
action="select",
table=table,
filters=filters if filters else None,
)
return result.get("rows", [])
except Exception as exc:
logger.warning("agent_runner: failed to fetch %s: %s", domain, exc)
return []
def _format_entities_for_context(domain: str, rows: list[dict]) -> str:
"""Format existing entity rows as a readable context block for the LLM.
Includes enough detail per record for the LLM to make a confident
update-vs-create decision without overwhelming the context.
Note content is truncated to 200 chars to stay within token budget.
"""
if not rows:
return f"No existing {domain}."
lines: list[str] = []
for r in rows:
if domain == "tasks":
desc = r.get("description") or ""
desc_part = f"{desc[:120]}" if desc else ""
assignee = r.get("assignee") or r.get("assignees") or ""
due = r.get("dueDate") or r.get("due_date") or ""
meta = ", ".join(filter(None, [
f"priority: {r.get('priority', '')}" if r.get("priority") else "",
f"assignee: {assignee}" if assignee else "",
f"due: {due}" if due else "",
]))
lines.append(
f" - [{r.get('status', '?')}] {r.get('title', '')}{desc_part}"
f" ({meta}, id: {r['id']})"
)
elif domain == "notes":
snippet = (r.get("content") or "")[:200].replace("\n", " ")
snippet_part = f"\n Preview: {snippet}" if snippet else ""
lines.append(
f" - {r.get('title', '')} (id: {r['id']}){snippet_part}"
)
elif domain == "timelines":
lines.append(
f" - {r.get('title', '')} date={r.get('date', '')} (id: {r['id']})"
)
elif domain == "projects":
summary = (r.get("aiSummary") or r.get("ai_summary") or "")[:120]
summary_part = f"{summary}" if summary else ""
lines.append(
f" - {r.get('name', '')} [{r.get('status', '')}]{summary_part}"
f" (id: {r['id']})"
)
return f"Existing {domain}:\n" + "\n".join(lines)
# ── Step 1: LLM file classifier ───────────────────────────────────────────
async def _classify_file(
file_path: str,
file_content: str,
projects: list[dict],
config_data_types: list[str],
) -> tuple[str, list[str]]:
"""Call the LLM to classify a file by project and relevant domains.
Returns ``(project_id_or_"standalone", domains)``.
Falls back to ``("standalone", config_data_types)`` on any error.
"""
fallback = ("standalone", list(config_data_types))
if not file_content.strip():
return fallback
projects_list = "\n".join(
f" - {p.get('name', '')} (id: {p['id']}, status: {p.get('status', '')})"
for p in projects
) or " (none — all files are standalone)"
domain_definitions = "\n".join(
f" - {d}: {_DOMAIN_DESCRIPTIONS[d]}"
for d in config_data_types
if d in _DOMAIN_DESCRIPTIONS
)
system = _STEP1_SYSTEM_PROMPT.format(
domain_definitions=domain_definitions,
projects_list=projects_list,
)
llm = get_llm()
try:
response = await llm.ainvoke([
SystemMessage(content=system),
HumanMessage(content=f"File: {file_path}\n\nContent:\n{file_content[:4000]}"),
])
raw = _as_text(response.content).strip()
# Strip markdown fences if the model wraps the JSON.
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
parsed = json.loads(raw.strip())
project_id: str = str(parsed.get("project_id") or "standalone")
domains: list[str] = [
d for d in parsed.get("domains", [])
if d in config_data_types
]
if not domains:
domains = list(config_data_types)
return project_id, domains
except Exception as exc:
logger.warning(
"agent_runner: step1 classification failed for %r: %s", file_path, exc
)
return fallback
# ── Local agent runner (two-step per file) ────────────────────────────────
async def run_local_agent( async def run_local_agent(
@@ -336,24 +535,28 @@ async def run_local_agent(
device_mgr: DeviceConnectionManager, device_mgr: DeviceConnectionManager,
run_context: dict | None = None, run_context: dict | None = None,
) -> None: ) -> None:
"""Execute a local directory agent run using two-phase LLM-with-tools. """Execute a local directory agent run using a two-step approach per file.
Phase 1 — Triage: Step 1 — Classification (code + 1 LLM call per file, no tools):
Explore the directory structure, check metadata, match files to Code scans directories and fetches all projects via WS.
existing projects. Output: a JSON map of project → file paths. For each file, LLM identifies the project and relevant domains.
Phase 2 — Processing: Step 2 — Processing (code + 1 LLM call per file, with tools):
For each project group, read full file contents and perform CRUD Code fetches existing entities for the identified project/domains.
operations using the standard entity tools. LLM receives file content + existing entities in context and uses
tools to update existing records or create new ones.
""" """
run_id = run_log.id run_id = run_log.id
agent_id = (run_context or {}).get("agent_id") or config.id
_running_agents.add(agent_id)
# ── Device online check ───────────────────────────────────────── # ── Device online check ─────────────────────────────────────────
target_device_id = config.device_id.strip() if isinstance(config.device_id, str) else "" target_device_id = config.device_id.strip() if isinstance(config.device_id, str) else ""
if target_device_id: is_online = (
is_online = device_mgr.is_online(user_id, target_device_id) device_mgr.is_online(user_id, target_device_id)
else: if target_device_id
is_online = device_mgr.is_online(user_id) else device_mgr.is_online(user_id)
)
if not is_online: if not is_online:
logger.info( logger.info(
@@ -377,116 +580,112 @@ async def run_local_agent(
items_processed = 0 items_processed = 0
items_created = 0 items_created = 0
custom_section = (
f"User instructions:\n{config.prompt_template}"
if config.prompt_template
else ""
)
try: try:
# ── Phase 1: Triage ───────────────────────────────────────── # ── Code: scan directories ───────────────────────────────────
logger.info("agent_runner: run=%s phase=triage start user=%s", run_id, user_id) logger.info("agent_runner: run=%s scanning directories user=%s", run_id, user_id)
file_paths = await _scan_directories(
last_run_str = "never (process all files)" paths=config.directory_paths,
if config.last_run_at: extensions=config.file_extensions or [],
last_run_str = config.last_run_at.isoformat() last_run_at=config.last_run_at,
)
custom_section = "" logger.info(
if config.prompt_template: "agent_runner: run=%s found %d file(s) after filtering", run_id, len(file_paths)
custom_section = f"User instructions:\n{config.prompt_template}"
file_ext_str = ", ".join(config.file_extensions) if config.file_extensions else "all"
triage_prompt = _TRIAGE_SYSTEM_PROMPT.format(
last_run_at=last_run_str,
custom_prompt_section=custom_section,
data_types=", ".join(config.data_types),
file_extensions=file_ext_str,
) )
directory_paths = config.directory_paths if not file_paths:
triage_user_msg = ( await _finalize_run(run_log, status="success", items_processed=0, items_created=0)
f"Explore these directories and produce the triage map:\n"
f"{json.dumps(directory_paths, ensure_ascii=False)}"
)
triage_tools: list[Any] = list(FILESYSTEM_TOOLS) + list(PROJECT_TOOLS)
triage_response = await _run_agent_with_tools(
system_prompt=triage_prompt,
user_message=triage_user_msg,
tools=triage_tools,
max_steps=_MAX_TRIAGE_STEPS,
)
triage_map = _parse_triage_map(triage_response)
if not triage_map:
errors.append(f"Triage phase failed to produce a valid file map: {triage_response[:500]}")
await _finalize_run(run_log, status="error", errors=errors)
return return
logger.info( # ── Code: fetch all projects once ────────────────────────────
"agent_runner: run=%s triage complete groups=%d total_files=%d", projects = await _fetch_projects()
run_id,
len(triage_map),
sum(len(files) for files in triage_map.values()),
)
# ── Phase 2: Processing (per group) ───────────────────────── # ── Per-file processing ──────────────────────────────────────
processing_tools = _build_processing_tools(config.data_types) processing_tools = _build_processing_tools(config.data_types)
for group_key, file_paths in triage_map.items(): for file_path in file_paths:
if not file_paths:
continue
logger.info(
"agent_runner: run=%s phase=processing group=%s files=%d",
run_id,
group_key,
len(file_paths),
)
# Build project context for the LLM.
if group_key == "standalone":
project_context = "These files are not associated with any existing project."
else:
project_context = f"These files belong to project ID: {group_key}. Use this project_id when creating records."
file_list_str = "\n".join(f"- {fp}" for fp in file_paths)
processing_prompt = _PROCESSING_BASE_PROMPT.format(
data_types=", ".join(config.data_types),
project_context=project_context,
file_list=file_list_str,
custom_prompt_section=custom_section,
)
items_processed += len(file_paths)
try: try:
# Read file content via code.
file_result = await execute_on_client(
action="read_file_content", data={"path": file_path}
)
file_content: str = file_result.get("content", "")
if not file_content:
logger.debug("agent_runner: run=%s skipping empty file %r", run_id, file_path)
continue
items_processed += 1
# Step 1 — classify file.
project_id, domains = await _classify_file(
file_path=file_path,
file_content=file_content,
projects=projects,
config_data_types=config.data_types,
)
logger.info(
"agent_runner: run=%s file=%r → project=%s domains=%s",
run_id,
file_path,
project_id,
domains,
)
# Step 2 — fetch existing entities for this project + domains.
existing_blocks: list[str] = []
for domain in domains:
rows = await _fetch_domain_entities(domain, project_id)
existing_blocks.append(_format_entities_for_context(domain, rows))
existing_context = "\n\n".join(existing_blocks)
if project_id == "standalone":
project_context = "This file is not associated with any existing project."
else:
project_context = (
f"This file belongs to project ID: {project_id}. "
"Use this project_id when creating records."
)
system_prompt = _PROCESSING_SYSTEM_PROMPT.format(
existing_context=existing_context,
project_context=project_context,
data_types=", ".join(domains),
custom_prompt_section=custom_section,
)
result_text = await _run_agent_with_tools( result_text = await _run_agent_with_tools(
system_prompt=processing_prompt, system_prompt=system_prompt,
user_message="Process the listed files now.", user_message=(
f"Process this file and extract relevant information.\n\n"
f"File: {file_path}\n\nContent:\n{file_content}"
),
tools=processing_tools, tools=processing_tools,
max_steps=_MAX_PROCESSING_STEPS, max_steps=_MAX_PROCESSING_STEPS,
) )
logger.info( logger.info(
"agent_runner: run=%s group=%s processing_result=%s", "agent_runner: run=%s file=%r result=%s",
run_id, run_id,
group_key, file_path,
result_text[:500], result_text[:200],
) )
# Count created items by scanning tool call results.
# The tools themselves handle creation; we estimate from the
# summary. A more precise count would require intercepting
# tool results, but the summary is sufficient for the run log.
except Exception as exc: except Exception as exc:
errors.append(f"Processing error for group '{group_key}': {exc}") errors.append(f"Error processing '{file_path}': {exc}")
logger.error( logger.error(
"agent_runner: run=%s group=%s processing failed: %s", "agent_runner: run=%s file=%r failed: %s", run_id, file_path, exc
run_id,
group_key,
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("agent_runner: run=%s failed: %s", run_id, exc)
finally: finally:
_running_agents.discard(agent_id)
clear_client_executor() clear_client_executor()
# ── Finalise ──────────────────────────────────────────────────── # ── Finalise ────────────────────────────────────────────────────
@@ -503,9 +702,6 @@ async def run_local_agent(
items_processed=items_processed, items_processed=items_processed,
items_created=items_created, items_created=items_created,
errors=errors, errors=errors,
update_config_last_run=False,
config_id=config.id,
config_type="local",
) )
logger.info( logger.info(
"agent_runner: run=%s done status=%s processed=%d errors=%d", "agent_runner: run=%s done status=%s processed=%d errors=%d",
@@ -515,8 +711,7 @@ async def run_local_agent(
len(errors), len(errors),
) )
# Notify the Electron client that the run is complete so it can close # Notify Electron that the run is complete.
# the run record in its local SQLite.
if run_context and device_mgr.is_online(user_id): if run_context and device_mgr.is_online(user_id):
try: try:
await device_mgr.send_frame(user_id, { await device_mgr.send_frame(user_id, {
@@ -525,12 +720,13 @@ async def run_local_agent(
"status": final_status, "status": final_status,
}) })
except Exception as exc: except Exception as exc:
logger.warning("agent_runner: run=%s failed to send run_complete: %s", run_id, exc) logger.warning(
"agent_runner: run=%s failed to send run_complete: %s", run_id, exc
)
# ── Cloud agent runner ───────────────────────────────────────────────────── # ── Cloud agent runner ─────────────────────────────────────────────────────
# Default lookback window when an agent has never run before.
_CLOUD_DEFAULT_LOOKBACK_DAYS: int = 7 _CLOUD_DEFAULT_LOOKBACK_DAYS: int = 7
@@ -544,8 +740,7 @@ async def run_cloud_agent(
Steps: Steps:
1. Verify the user's device is online — results are pushed to Electron 1. Verify the user's device is online.
via WS tool-call frames. If no device is connected, abort.
2. Decrypt the stored OAuth token from ``config.oauth_token_encrypted``. 2. Decrypt the stored OAuth token from ``config.oauth_token_encrypted``.
3. Instantiate the provider client (Gmail or MS Graph). 3. Instantiate the provider client (Gmail or MS Graph).
4. Fetch messages/emails since ``config.last_run_at`` (or 7 days ago for 4. Fetch messages/emails since ``config.last_run_at`` (or 7 days ago for
@@ -598,11 +793,7 @@ async def run_cloud_agent(
try: try:
provider = get_provider(config.provider, credentials_info) provider = get_provider(config.provider, credentials_info)
except ValueError as exc: except ValueError as exc:
await _finalize_run( await _finalize_run(run_log, status="error", errors=[str(exc)])
run_log,
status="error",
errors=[str(exc)],
)
return return
# ── 4. Fetch messages ───────────────────────────────────────────── # ── 4. Fetch messages ─────────────────────────────────────────────
@@ -636,9 +827,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", "agent_runner: provider fetch failed for cloud agent %s: %s", config.id, exc
config.id,
exc,
) )
await _finalize_run( await _finalize_run(
run_log, run_log,
@@ -664,9 +853,11 @@ async def run_cloud_agent(
try: try:
processing_tools = _build_processing_tools(config.data_types) processing_tools = _build_processing_tools(config.data_types)
custom_section = "" custom_section = (
if config.prompt_template: f"User instructions:\n{config.prompt_template}"
custom_section = f"User instructions:\n{config.prompt_template}" if config.prompt_template
else ""
)
for msg in raw_messages: for msg in raw_messages:
content_text = msg.as_text content_text = msg.as_text
@@ -674,7 +865,7 @@ async def run_cloud_agent(
continue continue
items_processed += 1 items_processed += 1
processing_prompt = _PROCESSING_BASE_PROMPT.format( processing_prompt = _CLOUD_PROCESSING_PROMPT.format(
data_types=", ".join(config.data_types), data_types=", ".join(config.data_types),
project_context="Determine the appropriate project from the message context.", project_context="Determine the appropriate project from the message context.",
file_list=f"Message from {config.provider} (id: {msg.id})", file_list=f"Message from {config.provider} (id: {msg.id})",
@@ -708,7 +899,11 @@ async def run_cloud_agent(
await db.commit() await db.commit()
logger.debug("agent_runner: refreshed OAuth token persisted for agent %s", config.id) logger.debug("agent_runner: refreshed OAuth token persisted for agent %s", config.id)
except Exception as exc: except Exception as exc:
logger.warning("agent_runner: failed to persist refreshed token for agent %s: %s", config.id, exc) logger.warning(
"agent_runner: failed to persist refreshed token for agent %s: %s",
config.id,
exc,
)
# ── 8. Finalise ──────────────────────────────────────────────────── # ── 8. Finalise ────────────────────────────────────────────────────
if errors and items_created == 0: if errors and items_created == 0:
@@ -749,12 +944,6 @@ async def trigger_pending_runs(
"""Dispatch any overdue agent runs after an Electron device connects. """Dispatch any overdue agent runs after an Electron device connects.
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``.
Scheduling rules:
* **Local agents**: only triggered when ``config.device_id == device_id``.
* **Cloud agents**: triggered on any connected device (no device binding).
* Runs execute **sequentially** to avoid flooding the WS connection.
""" """
logger.info( logger.info(
"agent_runner: pending-run scan skipped for user=%s device=%s (client-owned agent config)", "agent_runner: pending-run scan skipped for user=%s device=%s (client-owned agent config)",
@@ -778,11 +967,7 @@ async def _finalize_run(
config_id: str | None = None, config_id: str | None = None,
config_type: str | None = None, config_type: str | None = None,
) -> None: ) -> None:
"""Persist the run outcome and optionally update ``LocalAgentConfig.last_run_at``. """Persist the run outcome and optionally update ``last_run_at`` on the config."""
Uses a fresh DB session so this is safe to call from background tasks
after the original request session has closed.
"""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
try: try:
async with async_session() as db: async with async_session() as db: