12 KiB
Phase 2 — deepagents adapter over the core
Build after Phase 1. Umbrella:
00-umbrella-plan.md. Shape: ADR 0002. The first driving adapter: deepagents talking to the framework-agnostic core (KnowledgeStore) over the real git working tree, replacing the read-side fake (KBPostgresBackend). Agent tools are unchanged.Status: file-op path implemented (2026-07-28); flip-safe as of 2026-07-30. Working-copy lifecycle in the core,
GitTreeBackendadapter, resolver + tool routing behindKNOWLEDGE_STORE_ENABLED. The C2 citation envelope onread_fileis deferred to its own post-flip PR — it is not a flip blocker; what was, theread_filedescription promising a citation envelope the git path never renders, is fixed (see work item 4).
Objective
Give the agent real file ops (ls/glob/grep/read/write/edit) on a per-turn private worktree instead of path_resolver + a DB folder walk + the runtime.state overlay — as a thin deepagents adapter, not a bespoke filesystem reimplementation. The adapter is deepagents' own FilesystemBackend pointed at the turn's worktree; the core is invoked once, at the end-of-turn commit.
Locked model
- This is an adapter, not the core. The core (
KnowledgeStore) stays framework-agnostic (imports no deepagents). Only this adapter knows aboutBackendProtocol. It adds no storage logic of its own. - In-turn writes live in a per-turn private worktree (decided 2026-07-28; C6 records this model). Git's own answer to parallel writers: one working tree per writer, never a shared checkout. The worktree is created lazily on the turn's first KB tool call (read or write; measured ~90 ms checkout for 500 docs, once per turn) and serves both reads and writes for the whole turn — one code path, read-your-own-writes by construction, no state overlay, no merge logic. Abort/crash = delete the worktree; a janitor removes orphans.
- End-of-turn commit = diff, not snapshot.
porcelain.status(worktree)(the detached HEAD is the base revision) → touched paths →writes/removeschange set →store.transaction()under the Redis lock, on top of the current head. Parallel turns' committed work on untouched files is never reverted; same-file overlap is per-file last-writer-wins with the loser preserved in history.ponytail:ceilings = no three-way merge (upgrade path: git's own), and a mid-turn checkpoint fork/replay does not restore uncommitted worktree files — accepted, no such feature exists. - Reuse the existing direct-disk backend; don't reimplement the framework. Implemented as SurfSense's own
MultiRootLocalFolderBackendwith one mount,("documents", <working copy>)— it already ships the whole tool surface (als_info/aread/awrite/aedit/aglob_info/agrep_raw/alist_tree/amove/adelete_file/armdir) plus root-confined path resolution, which deepagents'FilesystemBackend+CompositeBackendwould have covered only partially (no move/delete/rmdir/list_tree). - Same tool interface. Upstream
WriteResult/EditResult/FileInfo/GrepMatchshapes — no extra fields. path_resolverpath computation retires for flagged document workspaces — folder walk + collision suffixing → real repo paths (filename rules still reused, C1). The repo has one projected root.GitTreeBackendmounts the working copy'sdocuments/subtree for generic KB file tools, and the artifact service writes deliverable bodies inside it (documents/Artifacts/) through the same path allocator while owning its own API and blob lifecycle. Commit projection therefore has one row/chunk domain to maintain.- Selected via the resolver behind
KNOWLEDGE_STORE_ENABLED.
Citation model — decided 2026-07-28 (full contract: C2)
Raw reads from the worktree; one citation pattern through both doors. Every KB surface (search excerpt or full read) renders as true-document-line-numbered text in one envelope with the handle in the opening tag (cite="[n]"); the agent cites [n:Lx-Ly] using the line numbers it sees. Registry entries are self-contained {path, revision, title} — no chunk ids, markers never enter file bytes. Retires aload_document/render_full_document's chunk-render read path for flagged workspaces.
Work items
-
✅ Working-copy lifecycle in the core (git vocabulary stays behind the port):
open_working_copy/diff_working_copy/discard_working_copy/prune_working_copieson the port, facade, and engine (dulwich.worktree). Copies live at{root}/.working_copies/{workspace_id}/{copy_id}(store_path.pyowns the layout). An empty store yields a bare directory (git cannot worktree an unborn HEAD); its diff walks the tree. -
✅ New
.../filesystem/backends/git_tree.py—GitTreeBackend: lazy mount of the turn's working copy, opened on the first KB tool call. Copy id =thread-{root thread}(langgraph serializes turns per thread; a copy left by a crashed turn is committed with the thread's next turn — recovery semantics; abandoned copies are janitored). The copy is scoped to the turn, not to the actor: subagents run under a namespaced{parent}::task:{tool_call_id}thread id (one segment per nesting level), sothread_working_copy_idresolves the root segment and every actor in the turn — orchestrator and nested subagents alike — shares one copy. That is also what keeps one turn to one revision, which the receipts and citation revisions assume.open_working_copyserializes its check-then-create behind a module lock, so parallel subagents reopen rather than race. -
✅ Wired into
resolver.py::build_backend_resolverbehindKNOWLEDGE_STORE_ENABLED; mutation tools (rm/rmdir/move_file/mkdir) routeGitTreeBackenddown the existing direct-op branches instead of cloud state-staging — as dowrite_file/edit_file, which stage as a side effect of a successful write and were missed in the first pass (see the canary findings below). Root-cause fix along the way:mkdirwas a silent no-op on every direct backend whilewriterefuses missing parents — added realmkdirtoLocalFolderBackend(+ multi-root passthrough) and made the tool surface backend errors. -
⏳
read_fileC2 citation envelope + normalizer[n:Lx-Ly]support (today the flagged path returns the raw line-numbered read with no envelope).Flip-safety split off and shipped (2026-07-30); the envelope itself is deferred to its own post-flip PR. The envelope is not a flip blocker — search citations are unaffected, since
search_knowledge_basereads thechunksrows the indexer writes and resolves through/documents/by-chunk/{id}unchanged. The description was:select_descriptionignored itsmodeargument and told every mode that reads come back as<document … view="full">with[n]-labelled passages, adding "cite the same[n]you would use fromsearch_knowledge_base". OnlyKBPostgresBackendrenders that envelope, so on a flipped workspace the model was promised labels it never sees while holding search ordinals in context — a mis-citation surface (right ordinal, wrong source), which is worse than the missing citation it looks like. Now split by read format rather than by cloud-vs-desktop: the envelope text for cloud-on-Postgres, and a raw-file text that forbids reusing a search ordinal for everything else. This also fixed desktop-local, which had carried the same wrong description since before the flag existed.knowledge_store_enabledthreadsstack.py→ subagent deps →build_filesystem_mw→ middleware, and already keys the compiled-graph cache.Remaining, as a separate PR, in four slices: (1) the envelope + registry entries carrying
{path, revision, title, numbered}+ normalizer[n:Lx-Ly]parsing with a fail-closed strip; (2) the line range carried through to a new payload, aread_as_ofresolution endpoint, and the frontend panel — this slice is cross-stack, sincesurfsense_webresolves citations only by numeric chunk id today and C2's currency is(path, revision, lines); (3) search excerpts through the same envelope using the stored spans; (4) the span-fill job for legacyNULL-span chunks. Slices 3 and 4 depend on the flip: spans exist only where the git indexer has run, and C2 defines the fill job as flipped-only, matching against the git blob because post-flip that is the source of truth. -
✅ Janitor scheduling — shipped with Phase 3: daily Celery beat task via
knowledge_store/janitor.py(see03-commit-write-path.md).
Canary findings (2026-07-30)
The first live agent turn on the canary workspace exposed two defects in the same write path, neither of which the suite caught.
A delegated write never reached git. The subagent wrote into copy thread-21::task:call_x while the end-of-turn commit, which only ever knows the parent thread, diffed thread-21. diff_working_copy raised FileNotFoundError, the commit returned None silently, and the copy leaked because discard_working_copy was never reached. Delegation is the normal path for agent writes, so most writes were affected. Fixed by scoping the copy to the turn's root thread (item 2). The tests missed it because all of them build the copy id by hand — open_working_copy(f"thread-{THREAD_ID}") — so none exercised the two sides deriving the id from a thread. The regression test now writes through GitTreeBackend with a ::task: runtime and commits with the parent id.
The legacy path silently covered for it. write_file and edit_file set dirty_paths under is_cloud with no backend check, so kb_persistence recorded the same write into Postgres (creates=1 in the turn log) and the document appeared in the UI. That masked the first defect — nothing errored — and fixing the copy id alone would have converted the silent loss into double writes: one revision plus a legacy document git never hears about. dirty_paths is one of five keys the legacy commit triggers on (with staged_dirs, pending_moves, pending_deletes, pending_dir_deletes); with all six mutating tools guarded, none is reachable under the git backend, so the legacy commit is a true no-op on a flipped workspace. files stays — it is the in-turn read cache, not a trigger.
Canary fallout was one Postgres-only document and one orphaned worktree. The worktree was discarded; the document needs no repair, since the seeder adopts it into git on the next flip.
Tests
- ✅ Lifecycle: open at current revision / reopen in place / bare-dir on empty store / isolated parallel copies / net diff (adds, edits, deletes) / discard / age-based prune. (
tests/unit/knowledge_store/) - ✅ Adapter: writes land on the turn's copy; tool calls share one copy; a subagent's write is visible to the orchestrator; committed content readable and deletable; mkdir→write; move; non-
/documentspaths rejected; per-thread isolation. (tests/unit/middleware/test_git_tree_backend.py) - ✅ A delegated write (namespaced
::task:thread id) lands in the parent turn's revision, and its copy is discarded rather than orphaned. (tests/integration/knowledge_store/test_commit_turn.py) - ✅
write_file/edit_filestage nothing for the legacy commit under the git backend, while a workspace still on the old path keeps staging. (tests/unit/middleware/test_git_tree_tool_staging.py) - ✅ Resolver returns the git-tree adapter only when the flag is on; falls back to
KBPostgresBackend/StateBackendotherwise. - ⏳
read_fileenvelope: raw line-numbered file, onecite="[n]"handle in the opening tag, no markers in file bytes; normalizer resolves[n:Lx-Ly]clamped to the document length at that revision. - ✅
read_filedescription matches what each mode returns: cloud-on-Postgres is told about theview="full"envelope, while git-native and desktop are told their reads carry no[n]and must not reuse a search ordinal. (tests/unit/middleware/test_read_file_description.py)
Out of scope
- End-of-turn commit → Phase 3. Index refresh (vector-store-sync consumer) → Phase 4.
- Desktop-local (
MultiRootLocalFolderBackend) path — unchanged. - Other adapters (KB REST API, MCP) — deferred (ADR 0002, YAGNI).