Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions echo/docs/agentic-chat-design.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 13 additions & 7 deletions echo/server/dembrane/api/agentic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 17 additions & 3 deletions echo/server/tests/api/test_agentic_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand All @@ -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
Expand Down
Loading