diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 886f2465a..1c3078715 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,14 @@ jobs: dockerfile: Dockerfile tag: dbr-echo-server build_args: "" + # Agent service. Also built by deploy-testing.yml for the testing + # env; without this entry no main-SHA agent tag ever exists, so the + # echo-next and prod agent deployments ImagePullBackOff. + - name: dbr-echo-agent + context: ./echo/agent + dockerfile: Dockerfile + tag: dbr-echo-agent + build_args: "" steps: - name: Checkout Code diff --git a/echo/docs/agentic-chat-design.md b/echo/docs/agentic-chat-design.md new file mode 100644 index 000000000..ea63f2b44 --- /dev/null +++ b/echo/docs/agentic-chat-design.md @@ -0,0 +1,94 @@ +# Agentic chat as the default: integration design + +Status: draft for review. Owner: Sameer. Written 2026-07-02 from a research pass over +the live PR (#573), the current runtime on main, the Dembrane/sam agent harness, and +an evaluation of VFS/git substrates (agentfs, git-remote-s3, mirage, vercel/eve). + +## Goal + +Agentic chat becomes the default chat architecture. Users can still constrain the +agent to specific conversations (today's deep dive collapses into a scoped agentic +mode). The agent gains a per-workspace virtual file system containing the product +docs and meta-skills, acts within the logged-in user's auth boundary (including +writes like project settings and portal editor), auto-onboards new users, and serves +as primary support. Model: gemini-3.5-flash. + +## Decisions taken + +1. Runtime stays LangGraph plus CopilotKit. vercel/eve is a competing Node agent + backend, not a frontend for a Python agent; CopilotKit has first-class LangGraph + support and works with Vite. assistant-ui is the fallback if CopilotKit chafes. +2. PR #573 is harvested in slices, not merged wholesale (it is 4 months old and + CONFLICTING). Worth taking: server-side grep with chunk-level citations, citation + rendering, agentic title generation, test suites. Not taking: the raw Vertex + Anthropic model swap. +3. Agent model goes through the LiteLLM router config (gemini-3.5-flash), replacing + both main's raw GEMINI_API_KEY client and the PR's raw Vertex Anthropic client. + If a stronger reviewer tier is added later, the cheap model never decides to + escalate on its own (scheduled or user-explicit only). +4. VFS substrate is boring and portable: one git repo per workspace, materialized + lazily on pod-local disk; durability by pushing to S3-compatible object storage + (git-remote-s3 after a locking spike against Spaces, else ~50 lines of DIY git + bundle shipping); one writer per workspace enforced with the existing Redis. + agentfs and mirage stay on the watch list (promising, too immature). Spaces has + no S3 object versioning, so app-level git is the honest versioning layer anyway. + +## VFS layout + + /docs read-only mount of the product docs corpus (features/, users/, nl-NL) + /skills meta-skills as markdown with YAML frontmatter (name, description, + when_to_use); catalog goes in the system prompt, bodies are read + lazily by the agent (the sam pattern) + /workspace agent-writable artifacts: notes, drafts, onboarding state + +Isolation = which roots get mounted for a session, resolved by the same workspace +membership resolver the BFF uses. Workspace-level first; org-shared and per-user +mounts are later additions, not new architecture. + +Agent tools: fs_read, fs_grep (ripgrep), fs_write (commit per turn). + +## Patterns adopted from Dembrane/sam + +- capabilities/skills/scope split: identity and always-on rules hot-loaded; skills + exposed as a frontmatter catalog with lazily read bodies. +- Per-tool-call audit trail: extend project_agentic_run_event with a tool-audit + event type; feed it back into recovery prompts as ground truth. +- Silent-exit gate: a deterministic runtime check that the agent produced a + user-visible answer after its last tool call, with a structured respond contract. + Prose explains; the runtime enforces. + +## Access control + +- Agentic endpoints move from the legacy creator-owner check to the v2 ladder: + resolve_project_access(...).require("chat:use") for runs (done in this branch). +- Write tools call the existing BFF endpoints, which already enforce project:update + under a user bearer: PATCH /v2/bff/projects/{id} (project settings plus all portal + editor fields) and /v2/bff/tags CRUD. No new permission system. +- Open issue: a 600s run can outlive the bearer JWT; validate at run start and + surface a clean re-auth instead of failing mid-turn. + +## Phases + +- Phase 0 (unblocks everything, this branch): agent image in the main build matrix; + authz ladder swap; router-based model config; align gitops agent env with the code + that actually ships. +- Phase 1 (echo-next): ENABLE_AGENTIC_CHAT = byEnv({production: false}, true); + harvest #573 slices; docs mount plus first meta-skills (onboarding, project setup, + support playbook). +- Phase 2 (parity): conversation scoping as an agent constraint (reads pinned + conversations from chat context); suggestions and title parity; free-tier gates + already exist. +- Phase 3 (default flip): new chats default to agentic; the mode selector becomes a + scope control; overview/deep dive remain as legacy rendering for existing chats + (mode is immutable per chat, so migration is clean). + +## Known gaps and follow-ups + +- CI runs mypy and ruff but not the server pytest suite; 4 tests in + tests/api/test_agentic_api.py fail on main today. Wire pytest into CI. +- gitops values.yaml agent block still carries PR-573 era env (LLM_MODEL + claude-opus-4-6); must be aligned when the model slice lands. +- No docs-serving endpoint exists yet; the VFS slice introduces the mount and tools. +- "Best practices from tasks": open product question whether the support skill also + inspects live project state (stuck reports, unconfigured portal) via a read-only + health tool, or only advises from docs. diff --git a/echo/server/dembrane/api/agentic.py b/echo/server/dembrane/api/agentic.py index 07a98af83..ba4b6d83e 100644 --- a/echo/server/dembrane/api/agentic.py +++ b/echo/server/dembrane/api/agentic.py @@ -93,12 +93,18 @@ def _require_agent_token(auth: DirectusSession) -> str: return auth.access_token -def _assert_project_authorized(project: dict[str, Any], auth: DirectusSession) -> None: - owner_user_id = project.get("directus_user_id") +async def _assert_project_access(project_id: str, auth: DirectusSession) -> None: + """v2 access gate shared with the chat BFF: any workspace member whose + role grants chat:use can drive the agent (the old check required the + project creator, which 403'd members the read tools already serve). + Staff admins bypass the app-layer model (they may have no app_user row). + Non-members get 404, matching the ladder's don't-confirm-existence rule.""" if auth.is_admin: return - if owner_user_id != auth.user_id: - raise HTTPException(status_code=403, detail="Not authorized for this project") + from dembrane.api.v2.bff._access import resolve_project_access + + access = await resolve_project_access(project_id, auth) + access.require("chat:use") def _assert_run_authorized(run: dict[str, Any], auth: DirectusSession) -> None: @@ -515,7 +521,7 @@ async def create_run( logger.warning("Project %s not found while creating run", body.project_id) raise HTTPException(status_code=404, detail="Project not found") from exc - _assert_project_authorized(project, auth) + await _assert_project_access(body.project_id, auth) # Matrix §8: agentic analysis is a host-side operation → Pilot hard-block. from dembrane.api.v2.middleware import check_no_pilot_block_for_project @@ -627,12 +633,12 @@ async def list_project_conversations( _require_agent_token(auth) try: - project = await run_in_thread_pool(project_service.get_by_id_or_raise, project_id) + await run_in_thread_pool(project_service.get_by_id_or_raise, project_id) except Exception as exc: # noqa: BLE001 logger.warning("Project %s not found while listing project conversations", project_id) raise HTTPException(status_code=404, detail="Project not found") from exc - _assert_project_authorized(project, auth) + await _assert_project_access(project_id, auth) return await run_in_thread_pool( _list_project_conversations_for_agent, project_id=project_id, diff --git a/echo/server/tests/api/test_agentic_api.py b/echo/server/tests/api/test_agentic_api.py index acc59b85c..0c20f592a 100644 --- a/echo/server/tests/api/test_agentic_api.py +++ b/echo/server/tests/api/test_agentic_api.py @@ -1,13 +1,15 @@ from __future__ import annotations +from types import SimpleNamespace from typing import Any, AsyncIterator from contextlib import asynccontextmanager import pytest from httpx import AsyncClient, ASGITransport -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException import dembrane.api.agentic as agentic_api +from dembrane.api.v2.bff import _access as bff_access from tests.agentic.fakes import InMemoryDirectus from dembrane.api.agentic import AgenticRouter from dembrane.service.agentic import AgenticRunService @@ -171,6 +173,18 @@ async def _build_api_client( app.include_router(AgenticRouter, prefix="/api/agentic") monkeypatch.setattr(agentic_api, "project_service", _FakeProjectService(owner_by_project_id)) + + async def _fake_resolve_project_access(project_id: str, auth: Any) -> Any: + owner_entry = owner_by_project_id.get(project_id) + owner_id = ( + owner_entry.get("directus_user_id") if isinstance(owner_entry, dict) else owner_entry + ) + if owner_entry is None or owner_id != auth.user_id: + # Ladder semantics: non-members get 404, not 403. + raise HTTPException(status_code=404, detail="Project not found") + return SimpleNamespace(require=lambda _policy: None, role="owner", project={}) + + monkeypatch.setattr(bff_access, "resolve_project_access", _fake_resolve_project_access) monkeypatch.setattr(agentic_api, "agentic_run_service", run_service) if chat_service is not None: monkeypatch.setattr(agentic_api, "chat_service", chat_service) @@ -524,7 +538,7 @@ async def test_list_project_conversations_returns_expected_shape(monkeypatch) -> @pytest.mark.asyncio -async def test_list_project_conversations_rejects_unauthorized_user(monkeypatch) -> None: +async def test_list_project_conversations_hides_project_from_non_members(monkeypatch) -> None: run_service = AgenticRunService(directus_client=InMemoryDirectus()) session = _make_session(user_id="user-2") @@ -536,7 +550,7 @@ async def test_list_project_conversations_rejects_unauthorized_user(monkeypatch) ) as client: response = await client.get("/api/agentic/projects/project-1/conversations") - assert response.status_code == 403 + assert response.status_code == 404 @pytest.mark.asyncio