diff --git a/app/core/brief_agent.py b/app/core/brief_agent.py index 7fcd00f..954f890 100644 --- a/app/core/brief_agent.py +++ b/app/core/brief_agent.py @@ -21,6 +21,7 @@ from app.core.deep_agent import ( _relational_memory_injection, _run_single_agent_stream, _trace_id_from_context, + build_brief_multi_project_manifest, ) from app.core.langfuse_client import compile_prompt, get_prompt_or_fallback @@ -159,6 +160,8 @@ async def run_home_brief( Yields (event_type, data) tuples identical to _run_single_agent_stream. Do NOT post-process output through _normalize_tagged_list_lines. """ + from app.agents.folder_agent import FOLDER_TOOLS + trace_id = _trace_id_from_context(context) today = date.today().isoformat() language = _resolve_language(context) @@ -171,7 +174,10 @@ async def run_home_brief( if today not in system_prompt: system_prompt += f"\nToday is {today}." - tools = _build_read_tools(user_id, trace_id) + brief_manifest = await build_brief_multi_project_manifest() + system_prompt = system_prompt + ("\n\n" + brief_manifest if brief_manifest else "") + + tools = [*_build_read_tools(user_id, trace_id), *FOLDER_TOOLS] async for event in _run_single_agent_stream( user_id=user_id, system_prompt=system_prompt, diff --git a/app/core/deep_agent.py b/app/core/deep_agent.py index e5ac86e..1388c77 100644 --- a/app/core/deep_agent.py +++ b/app/core/deep_agent.py @@ -110,6 +110,35 @@ async def _fetch_project_manifest(project_id: str) -> dict | None: return None +async def build_brief_multi_project_manifest() -> str: + """Build a compact multi-project manifest for the daily brief agent. + + Calls execute_on_client('list_projects_with_folder_manifests') and keeps + the top 5 most-recently-modified files per project. + """ + try: + result = await execute_on_client( + action="list_projects_with_folder_manifests", + data={}, + ) + except Exception: + return "" + projects = (result or {}).get("projects") or [] + if not projects: + return "" + blocks: list[str] = [""] + for p in projects: + files = sorted(p.get("files", []), key=lambda f: f.get("mtimeMs", 0), reverse=True)[:5] + if not files: + continue + blocks.append(f"project: {p.get('projectName','?')} [{p.get('projectId','?')}]") + blocks.append(f" path: {p.get('folderPath','?')} (scanned {p.get('lastScannedAt','?')})") + for f in files: + blocks.append(f" - /{f['relPath']} [{f.get('kind','text')}] {f.get('summary','')}") + blocks.append("") + return "\n".join(blocks) + + def _datetime_context_injection(context: dict[str, Any]) -> str: """Build a comprehensive DATE CONTEXT block with pre-computed ms-epoch boundaries for common ranges.""" fp = context.get("format_prefs") diff --git a/tests/test_manifest_injection.py b/tests/test_manifest_injection.py index 2405b77..7bb6e48 100644 --- a/tests/test_manifest_injection.py +++ b/tests/test_manifest_injection.py @@ -1,7 +1,13 @@ from __future__ import annotations +from unittest.mock import AsyncMock, patch + +import pytest + from app.core.deep_agent import format_folder_manifest, MANIFEST_TOKEN_BUDGET +pytestmark = pytest.mark.asyncio + def test_format_folder_manifest_basic(): manifest = { @@ -33,3 +39,31 @@ def test_format_folder_manifest_truncates_past_budget(): def test_format_folder_manifest_null_returns_empty(): assert format_folder_manifest(None) == "" assert format_folder_manifest({"files": []}) == "" + + +async def test_brief_multi_project_manifest_top_5_per_project(): + fake_response = [ + { + "projectId": "p1", "projectName": "Acme", "folderPath": "/a", + "lastScannedAt": "now", + "files": [ + {"relPath": f"f{i}.md", "kind": "text", "summary": "s", "mtimeMs": i} + for i in range(10) + ], + }, + { + "projectId": "p2", "projectName": "Beta", "folderPath": "/b", + "lastScannedAt": "now", + "files": [{"relPath": "x.md", "kind": "text", "summary": "s", "mtimeMs": 1}], + }, + ] + with patch( + "app.core.deep_agent.execute_on_client", + new=AsyncMock(return_value={"projects": fake_response}), + ): + from app.core.deep_agent import build_brief_multi_project_manifest + out = await build_brief_multi_project_manifest() + # Project 1 has 10 files, only top 5 by mtimeMs should appear + assert out.count("[p1]") <= 5 + # Project 2 has 1 file, must appear + assert "[p2]" in out or "Beta" in out