From 7253f6fe72ede83c457ad619ef42d8348479f3b4 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Thu, 9 Apr 2026 00:40:16 +0200 Subject: [PATCH] testing journey agent creation --- app/agents/filesystem_agent.py | 109 +++++++++++++++++++++++++++++++++ app/agents/note_agent.py | 15 ----- app/agents/project_agent.py | 16 ----- app/agents/task_agent.py | 17 ----- app/agents/timeline_agent.py | 14 ----- app/api/routes/agent_setup.py | 32 +++++++--- 6 files changed, 133 insertions(+), 70 deletions(-) diff --git a/app/agents/filesystem_agent.py b/app/agents/filesystem_agent.py index 8e6018c..e7cf600 100644 --- a/app/agents/filesystem_agent.py +++ b/app/agents/filesystem_agent.py @@ -7,12 +7,31 @@ handles actual disk I/O and responds with ``tool_result`` frames. from __future__ import annotations +import os +import re +from pathlib import Path from typing import Any from langchain_core.tools import tool from app.core.ws_context import execute_on_client +# Max characters returned by read_file_content in journey (exploration) tools. +# The journey only needs to understand file structure, not full content. +_JOURNEY_READ_MAX_CHARS: int = 4000 + + +def _resolve_path(path: str, base: str) -> str: + """Resolve *path* against *base* when *path* is relative. + + The LLM often passes ``"."`` meaning "the configured directory". + Without this, Electron resolves ``"."`` relative to its own CWD instead + of the user's chosen directory. + """ + if os.path.isabs(path): + return path + return str(Path(base) / path) + @tool async def list_directory(path: str) -> str: @@ -83,3 +102,93 @@ FILESYSTEM_TOOLS: list[Any] = [ read_file_content, get_file_metadata, ] + + +def make_directory_tools(base_directory: str) -> list[Any]: + """Return filesystem tools that resolve relative paths against *base_directory*. + + Use this instead of ``FILESYSTEM_TOOLS`` whenever you know the user's target + directory upfront (e.g., journey setup sessions). Relative paths like ``"."`` + from the LLM are resolved to the correct absolute path before being sent to + the Electron client, preventing it from falling back to its own CWD. + """ + + def _compact_for_journey(raw: str) -> str: + """Strip HTML noise and truncate for journey exploration. + + The journey LLM only needs to understand file structure (headers, + first paragraphs). Full CSS/style blocks are pure noise that eat + up context window budget. + """ + text = re.sub(r"]*>.*?", "", raw, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r"", "", text, flags=re.DOTALL) + if len(text) > _JOURNEY_READ_MAX_CHARS: + text = text[:_JOURNEY_READ_MAX_CHARS] + "\n[…truncated for exploration]" + return text + + @tool + async def list_directory(path: str) -> str: # noqa: F811 + """List files and folders in a local directory on the user's device. + + Returns a formatted listing of entries with name, type (file/directory), + and full path. + """ + resolved = _resolve_path(path, base_directory) + result = await execute_on_client( + action="list_directory", + data={"path": resolved}, + ) + entries: list[dict[str, Any]] = result.get("entries", []) + if not entries: + return f"Directory '{resolved}' is empty or does not exist." + lines: list[str] = [] + for entry in entries: + entry_type = entry.get("type", "unknown") + entry_name = entry.get("name", "") + entry_path = entry.get("path", "") + lines.append(f"- [{entry_type}] {entry_name} ({entry_path})") + return f"Directory listing for '{resolved}' ({len(entries)} entries):\n" + "\n".join(lines) + + @tool + async def read_file_content(path: str) -> str: # noqa: F811 + """Read the text content of a local file on the user's device. + + Returns the file content as a string. Large files may be truncated + by the Electron client. + """ + resolved = _resolve_path(path, base_directory) + result = await execute_on_client( + action="read_file_content", + data={"path": resolved}, + ) + content: str = result.get("content", "") + if not content: + return f"File '{resolved}' is empty or could not be read." + return _compact_for_journey(content) + + @tool + async def get_file_metadata(path: str) -> str: # noqa: F811 + """Get metadata for a local file: size, creation date, modification date, extension. + + Returns a formatted summary of the file's metadata. + """ + resolved = _resolve_path(path, base_directory) + result = await execute_on_client( + action="get_file_metadata", + data={"path": resolved}, + ) + size = result.get("size", "unknown") + created = result.get("createdAt", "unknown") + modified = result.get("modifiedAt", "unknown") + extension = result.get("extension", "unknown") + name = result.get("name", resolved) + return ( + f"File: {name}\n" + f" Extension: {extension}\n" + f" Size: {size} bytes\n" + f" Created: {created}\n" + f" Modified: {modified}" + ) + + return [list_directory, read_file_content, get_file_metadata] diff --git a/app/agents/note_agent.py b/app/agents/note_agent.py index cae644b..3698b06 100644 --- a/app/agents/note_agent.py +++ b/app/agents/note_agent.py @@ -18,21 +18,6 @@ _UUID_RE = re.compile( def _is_uuid(value: str) -> bool: return bool(_UUID_RE.match(value)) -NOTE_SYSTEM_PROMPT = ( - "You are a note-taking assistant. You help users create, retrieve, update,\n" - "and delete Markdown notes in their workspace.\n\n" - "Rules:\n" - " - content is always Markdown; preserve formatting when updating\n" - " - project_id is optional; link a note to a project when mentioned\n" - " - When updating, call get_note first if you need to read existing content\n" - " before appending or replacing sections\n" - " - list_notes without project_id returns all notes; scope with project_id\n" - " when the user is working within a specific project\n" - " - project_id must be a UUID; if you only know a project name, do not pass it as project_id\n" - " - Do not fabricate note content — reflect what the user provides or what\n" - " is already in the note (retrieved via get_note)." -) - @tool async def list_notes(project_id: str = "") -> str: diff --git a/app/agents/project_agent.py b/app/agents/project_agent.py index a07da0e..9f8f452 100644 --- a/app/agents/project_agent.py +++ b/app/agents/project_agent.py @@ -8,22 +8,6 @@ from langchain_core.tools import tool from app.core.ws_context import execute_on_client -PROJECT_SYSTEM_PROMPT = ( - "You are a project management assistant. You help users create, find,\n" - "update, and archive projects in their workspace.\n\n" - "Rules:\n" - " - status must be one of: active, archived\n" - " - client_id is optional; link to a client only when explicitly mentioned\n" - " - ai_summary is populated only when the user asks for a project summary;\n" - " derive it from context data — do not fabricate content\n" - " - Use list_projects for scoped queries; list_all_projects only when the\n" - " user wants a complete cross-client view including archived projects\n" - " - get_project requires a project UUID; resolve the ID first by calling\n" - " list_projects if you only have a project name\n" - " - Prefer archiving (update_project status=archived) over deletion;\n" - " only call delete_project when the user explicitly confirms deletion." -) - @tool async def list_projects( diff --git a/app/agents/task_agent.py b/app/agents/task_agent.py index 5be4632..1a3880f 100644 --- a/app/agents/task_agent.py +++ b/app/agents/task_agent.py @@ -18,23 +18,6 @@ _UUID_RE = re.compile( def _is_uuid(value: str) -> bool: return bool(_UUID_RE.match(value)) -TASK_SYSTEM_PROMPT = ( - "You are a task management assistant for a project workspace.\n" - "You create, update, list, and track tasks and their comments.\n\n" - "Rules:\n" - " - status must be one of: todo, in_progress, done\n" - " - priority must be one of: high, medium, low\n" - " - due_date is a Unix timestamp in milliseconds; convert human dates\n" - " - assignees is a JSON-encoded array of strings (e.g. '[\"Alice\",\"Bob\"]')\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" - " did not explicitly request; 0 otherwise\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" - " - For update_task, use -1 for integer fields you do not want to change\n" - " - Always confirm the action in plain, user-friendly language." -) - # ── Task tools ──────────────────────────────────────────────────────── diff --git a/app/agents/timeline_agent.py b/app/agents/timeline_agent.py index 4c7a217..f7fb52a 100644 --- a/app/agents/timeline_agent.py +++ b/app/agents/timeline_agent.py @@ -17,20 +17,6 @@ _UUID_RE = re.compile( def _is_uuid(value: str) -> bool: return bool(_UUID_RE.match(value)) -TIMELINE_SYSTEM_PROMPT = ( - "You are a project timeline assistant. Timelines are milestone dates that\n" - "track progress on a project — they are not calendar events.\n\n" - "Rules:\n" - " - project_id is REQUIRED for every create; confirm with the user if unknown\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" - " - is_ai_suggested: 1 when proactively proposing a timeline, 0 otherwise\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" - " - Listing without a project_id returns all timelines across projects\n" - " - Always echo the title and formatted date in your confirmation." -) - @tool async def list_timelines(project_id: str = "") -> str: diff --git a/app/api/routes/agent_setup.py b/app/api/routes/agent_setup.py index c1e063c..b54cea7 100644 --- a/app/api/routes/agent_setup.py +++ b/app/api/routes/agent_setup.py @@ -31,7 +31,7 @@ from typing import Any from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage -from app.agents.filesystem_agent import FILESYSTEM_TOOLS +from app.agents.filesystem_agent import make_directory_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 @@ -273,7 +273,7 @@ async def _call_llm_with_tools( _span = _span_ctx.__enter__() if _span_ctx else None try: - for _ in range(_MAX_TOOL_STEPS): + for step in range(_MAX_TOOL_STEPS): _gen_ctx = ( lf.start_as_current_observation( as_type="generation", @@ -290,12 +290,24 @@ async def _call_llm_with_tools( _gen.update(output=_as_text(response.content), usage=extract_usage(response)) _gen_ctx.__exit__(None, None, None) + resp_text = _as_text(response.content) + + # Guard against empty responses (e.g. model returned finish_reason + # 'error' which LiteLLM maps to 'stop' with empty content). + if not response.tool_calls and not resp_text.strip(): + logger.warning( + "agent_setup: journey LLM returned empty response at step %d — retrying", + step, + ) + # Drop the empty AIMessage so we don't pollute history, and retry. + continue + messages.append(response) if not response.tool_calls: if _span: - _span.update(output=_as_text(response.content)) - return _as_text(response.content) + _span.update(output=resp_text) + return resp_text for call in response.tool_calls: call_name = str(call.get("name", "")) @@ -324,7 +336,10 @@ async def _call_llm_with_tools( final_text = _as_text(final.content) if _span: _span.update(output=final_text) - return final_text + return final_text or ( + "Sorry, I had trouble processing the files. " + "Could you try again? If the issue persists, the files might be too large for me to analyse." + ) finally: if _span_ctx: _span_ctx.__exit__(None, None, None) @@ -372,7 +387,7 @@ async def handle_journey_start( ai_reply = await _call_llm_with_tools( system_prompt=system_prompt, history=seed_history, - tools=list(FILESYSTEM_TOOLS), + tools=make_directory_tools(directory), user_id=user_id, session_id=session_id, langfuse_prompt=langfuse_prompt, @@ -436,10 +451,11 @@ async def handle_journey_message( session.history.append({"role": "user", "content": message}) # Call the LLM with tools. + session_tools = make_directory_tools(session.directory) ai_reply = await _call_llm_with_tools( system_prompt=session.system_prompt, history=session.history, - tools=list(FILESYSTEM_TOOLS), + tools=session_tools, user_id=session.user_id, session_id=session_id, langfuse_prompt=session.langfuse_prompt, @@ -464,7 +480,7 @@ async def handle_journey_message( nudge_reply = await _call_llm_with_tools( system_prompt=session.system_prompt, history=session.history, - tools=list(FILESYSTEM_TOOLS), + tools=session_tools, user_id=session.user_id, session_id=session_id, langfuse_prompt=session.langfuse_prompt,