mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-11 15:28:37 +00:00
* feat(agents): database-backed storage for custom agent definitions Add an agent_storage.backend switch (default file, behaviour-unchanged) with a db backend that stores each custom agent as a row in the shared SQL persistence layer, so a multi-instance deployment sees the same agents on every node (#4331, #4357). Introduces an AgentStore interface routing all read/write surfaces, an agents table + migration 0006, startup validation, and a file->db importer. Follows the thread_meta store / run_events backend-switch / 0003_scheduled_tasks migration patterns; no new dependency. * fix(agents): make db storage path production-ready (review round 1) Addresses review feedback on the db/sync agent-storage path: - sql.py: mirror the async engine's per-connection SQLite PRAGMAs on the sync engine (busy_timeout=30000, synchronous=NORMAL, foreign_keys=ON, WAL) so both engines behave identically against the shared DB; guard the engine cache with a lock (double-checked) so concurrent first-touch cannot build duplicate engines or register the connect listener twice. - routers/agents.py + routers/assistants_compat.py: offload the sync-store reads that ran on the event loop (list/get/check, update's pre-read + legacy guard + refresh, and assistants_compat's four list routes) via asyncio.to_thread — on db+postgres each was a network round trip stalling the loop. Writes were already offloaded. - file.py: translate the create() mkdir(exist_ok=False) race FileExistsError into AgentExistsError (router 409, matching SqlAgentStore's IntegrityError path); correct the _write docstring — per-file atomic replace, two commits sequential not transactional. Tests: sync-engine PRAGMA + engine-cache reuse assertions; file create-race -> AgentExistsError; strict Blockbuster anchor over the read endpoints so a regression back onto the loop fails CI. * fix(agents): address round-2 review on the db store path - update_agent tool: align the docstring/inline comment with FileAgentStore._write. Cross-field write atomicity is db-only; the file backend commits config then soul via two sequential os.replace (a crash between them can leave a fresh config.yaml beside a stale SOUL.md). The dropped partial-write *reporting* is an intentional tradeoff — the stage-then-replace safety is preserved (test_update_agent_soul_failure_does_not_replace_config still holds). - SqlAgentStore.update(): true upsert. Catch IntegrityError on the insert-on-missing branch, re-fetch and apply, so two concurrent first-time writes (e.g. two setup_agent handshakes) converge instead of surfacing a raw UNIQUE(user_id, name) violation as a 500. Symmetric with create(). - get_agent_store(): document the graph-subprocess config-resolution invariant (the except->file fallback is a genuine no-config path, not a mask for a misconfigured graph process) and pin it with two tests driving the real get_app_config() file resolution: db resolves from an on-disk config.yaml, file fallback when config is unresolvable. * test(agents): cover SqlAgentStore.update() write-race upsert recovery Mandatory-TDD test for the round-2 fix in 0680340a: two concurrent first-time update()s where the loser's insert hits UNIQUE(user_id, name). Deterministically forces the IntegrityError recovery path by making the first _row probe miss the committed winner, and asserts last-writer-wins instead of a surfaced 500.
148 lines
6.5 KiB
Python
148 lines
6.5 KiB
Python
"""Build the GitHub webhook → agent registry.
|
|
|
|
Indexes every custom agent that declares a ``github:`` block by the
|
|
``(repo, event)`` pairs it is interested in, across all owners. Agent discovery
|
|
and change detection go through the configured agent store
|
|
(:mod:`deerflow.persistence.agents`), so both the ``file`` backend (per-user
|
|
directories + the legacy shared layout) and the ``db`` backend (the shared
|
|
``agents`` table) are covered by the same code.
|
|
|
|
The dispatcher calls :func:`build_github_agent_registry` once per webhook
|
|
delivery. We avoid re-loading every agent on each call via a small cache keyed
|
|
on the store's :meth:`~deerflow.persistence.agents.base.AgentStore.signature`
|
|
change token: the file backend derives it from ``config.yaml`` mtimes (any
|
|
edit, addition, or deletion invalidates the cache transparently); the db backend
|
|
derives it from ``max(updated_at)`` + the row count. Operators who hand-edit a
|
|
``config.yaml`` see the change on the next webhook.
|
|
|
|
Cache invalidation caveat (file backend): mtime granularity on macOS HFS+ /
|
|
APFS is ~1 µs but on some filesystems (FAT, network shares with caching) it's
|
|
1 s. Two edits inside the same coarse-tick would look identical. For the
|
|
dispatch path that's fine — webhooks are rare relative to operator edits, and
|
|
the next non-coincident write reconciles.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from collections.abc import Hashable
|
|
from dataclasses import dataclass
|
|
|
|
from app.gateway.github.triggers import _resolved_trigger
|
|
from deerflow.config.agents_config import AgentConfig, GitHubTriggerConfig
|
|
from deerflow.persistence.agents import get_agent_store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GitHubAgentMatch:
|
|
"""One ``(user, agent, _resolved_trigger)`` row in the ``(repo, event)`` index.
|
|
|
|
The trigger is the binding override merged with per-event field defaults
|
|
(see :func:`app.gateway.github.triggers._resolved_trigger`), so the
|
|
dispatcher does not have to re-resolve it at fan-out time. Pre-resolving
|
|
here also folds the per-binding lookup out of the hot path: the registry
|
|
already chose the right binding for this ``(repo, event)``, so the
|
|
dispatcher's old "find the binding whose ``.repo`` matches" loop —
|
|
which silently dropped events when an agent had multiple bindings on
|
|
one repo (PR feedback R3) — disappears entirely. Single-binding-per-repo
|
|
is enforced upstream by :class:`GitHubAgentConfig`'s validator, so
|
|
each ``(repo, event)`` resolves to exactly one trigger per agent.
|
|
|
|
The ``github:`` block is read off ``agent.github`` (always non-None
|
|
here — the rebuild filters agents without one before constructing a
|
|
match), so we don't carry a separate ``github`` field.
|
|
"""
|
|
|
|
user_id: str
|
|
agent: AgentConfig
|
|
trigger: GitHubTriggerConfig
|
|
|
|
|
|
# Cache: (signature, registry). ``signature`` is the store's opaque change
|
|
# token — identical token → registry is still valid, skip the reload.
|
|
_Registry = dict[tuple[str, str], list[GitHubAgentMatch]]
|
|
_cache: tuple[Hashable, _Registry] | None = None
|
|
# Threading lock (not asyncio): build_github_agent_registry is invoked
|
|
# from asyncio.to_thread in the dispatcher, so the lock is acquired on
|
|
# the worker thread. A plain Lock is the right primitive here.
|
|
_cache_lock = threading.Lock()
|
|
|
|
|
|
def _build_index(agents: list[tuple[str, AgentConfig]]) -> _Registry:
|
|
"""Build the ``(repo, event)`` index from the store's agents.
|
|
|
|
Each ``(repo, event)`` slot stores :class:`GitHubAgentMatch` rows — the
|
|
user_id + AgentConfig + the trigger already resolved (binding override
|
|
merged with per-event field defaults). The dispatcher then only needs
|
|
to apply the trigger; it never re-walks ``bindings`` to find the right
|
|
one. Single-binding-per-repo is enforced by :class:`GitHubAgentConfig`'s
|
|
validator (an agent that fails to load is already dropped by the store).
|
|
"""
|
|
index: _Registry = {}
|
|
for user_id, cfg in agents:
|
|
if cfg.github is None:
|
|
continue
|
|
for binding in cfg.github.bindings:
|
|
for event, override in binding.triggers.items():
|
|
resolved = _resolved_trigger(event, {event: override})
|
|
if resolved is None:
|
|
# ``_resolved_trigger`` only returns None when the
|
|
# event is not in the dict we passed — by construction
|
|
# it is here, so this branch is unreachable. Keep the
|
|
# guard for type-checker happiness.
|
|
continue
|
|
index.setdefault((binding.repo, event), []).append(GitHubAgentMatch(user_id=user_id, agent=cfg, trigger=resolved))
|
|
return index
|
|
|
|
|
|
def build_github_agent_registry() -> _Registry:
|
|
"""Return ``{(repo, event): [GitHubAgentMatch, ...]}`` across all users.
|
|
|
|
Each agent appears in the index once per ``(repo, declared_event)`` pair,
|
|
with the per-event trigger pre-resolved by merging the binding override
|
|
with :data:`app.gateway.github.triggers.DEFAULT_TRIGGERS`. Events are
|
|
opt-in per binding: an agent only registers for the events it explicitly
|
|
lists under ``github.bindings[].triggers``. An agent that declares an
|
|
empty ``triggers:`` map (or omits it) registers for nothing and the
|
|
dispatcher will never fan a webhook out to it.
|
|
|
|
Warm path (no agents added/removed/edited since the last call) costs
|
|
only the store's ``signature()`` — cheap on both backends. Cold path
|
|
reloads every agent and refreshes the cache. The result is shared across
|
|
callers (returned by reference) since :class:`GitHubAgentMatch` is frozen
|
|
and the registry is intended as read-only.
|
|
"""
|
|
global _cache
|
|
store = get_agent_store()
|
|
with _cache_lock:
|
|
signature = store.signature()
|
|
if _cache is not None and _cache[0] == signature:
|
|
return _cache[1]
|
|
registry = _build_index(store.list_all())
|
|
_cache = (signature, registry)
|
|
return registry
|
|
|
|
|
|
def _invalidate_cache() -> None:
|
|
"""Drop the cached registry. Test-only helper."""
|
|
global _cache
|
|
with _cache_lock:
|
|
_cache = None
|
|
|
|
|
|
def lookup_agents(
|
|
registry: _Registry,
|
|
repo: str,
|
|
event: str,
|
|
) -> list[GitHubAgentMatch]:
|
|
"""Convenience: return the list of agent matches for ``(repo, event)``.
|
|
|
|
Each match carries the user, AgentConfig (with ``.github`` attached),
|
|
and the pre-resolved trigger config for this specific event, so the
|
|
caller does not need to walk the agent's ``bindings`` again.
|
|
"""
|
|
return registry.get((repo, event), [])
|