agentgraph-server 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentgraph/__init__.py +1 -0
- agentgraph/auth/__init__.py +0 -0
- agentgraph/auth/credentials.py +224 -0
- agentgraph/backends/__init__.py +50 -0
- agentgraph/backends/sqlite/__init__.py +1 -0
- agentgraph/backends/sqlite/backend.py +1471 -0
- agentgraph/backends/sqlite/vector.py +142 -0
- agentgraph/cli.py +721 -0
- agentgraph/cli_query.py +519 -0
- agentgraph/config.py +90 -0
- agentgraph/connectors/__init__.py +0 -0
- agentgraph/connectors/base.py +455 -0
- agentgraph/connectors/registry.py +78 -0
- agentgraph/connectors/status.py +244 -0
- agentgraph/core/__init__.py +0 -0
- agentgraph/core/context.py +26 -0
- agentgraph/core/runtime.py +36 -0
- agentgraph/core/storage.py +240 -0
- agentgraph/graph/__init__.py +1 -0
- agentgraph/graph/bookmark.py +87 -0
- agentgraph/graph/delete.py +17 -0
- agentgraph/graph/download.py +35 -0
- agentgraph/graph/embeddings.py +58 -0
- agentgraph/graph/fetch.py +53 -0
- agentgraph/graph/gc.py +26 -0
- agentgraph/graph/link.py +63 -0
- agentgraph/graph/person.py +40 -0
- agentgraph/graph/query.py +244 -0
- agentgraph/graph/upsert.py +49 -0
- agentgraph/logging.py +78 -0
- agentgraph/mcp/__init__.py +0 -0
- agentgraph/mcp/server.py +811 -0
- agentgraph/perf.py +43 -0
- agentgraph/server/__init__.py +0 -0
- agentgraph/server/app.py +133 -0
- agentgraph/server/cli_api.py +708 -0
- agentgraph/server/dwell.py +79 -0
- agentgraph/server/graph_api.py +46 -0
- agentgraph/server/router.py +47 -0
- agentgraph/server/sync.py +247 -0
- agentgraph/skills.py +93 -0
- agentgraph_server-0.5.0.data/data/.agents/skills/graph/SKILL.md +159 -0
- agentgraph_server-0.5.0.data/data/.agents/skills/slack-auth/SKILL.md +92 -0
- agentgraph_server-0.5.0.dist-info/METADATA +286 -0
- agentgraph_server-0.5.0.dist-info/RECORD +49 -0
- agentgraph_server-0.5.0.dist-info/WHEEL +5 -0
- agentgraph_server-0.5.0.dist-info/entry_points.txt +2 -0
- agentgraph_server-0.5.0.dist-info/licenses/LICENSE +21 -0
- agentgraph_server-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Base connector interface and shared batch types."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
4
|
+
# pyright: reportUnknownArgumentType=false
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from typing import Any, ClassVar, Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
# All resource_type values understood by the connector layer.
|
|
17
|
+
# Each value maps to a distinct fetch strategy within a connector.
|
|
18
|
+
ResourceType = Literal["channel", "dm", "document", "folder", "message", "spreadsheet", "thread"]
|
|
19
|
+
|
|
20
|
+
# All valid entity_type values stored in the DB.
|
|
21
|
+
ENTITY_TYPES: tuple[str, ...] = (
|
|
22
|
+
"Channel",
|
|
23
|
+
"Document",
|
|
24
|
+
"Folder",
|
|
25
|
+
"Message",
|
|
26
|
+
"Person",
|
|
27
|
+
"Spreadsheet",
|
|
28
|
+
"Thread",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Broad URL extractor — classify_url does fine-grained matching
|
|
32
|
+
_URL_RE = re.compile(r"https?://\S+")
|
|
33
|
+
|
|
34
|
+
# Maps ResourceType values to entity_type strings stored in the DB
|
|
35
|
+
RESOURCE_TYPE_TO_ENTITY_TYPE: dict[str, str] = {
|
|
36
|
+
"channel": "Channel",
|
|
37
|
+
"dm": "Channel",
|
|
38
|
+
"document": "Document",
|
|
39
|
+
"folder": "Folder",
|
|
40
|
+
"message": "Message",
|
|
41
|
+
"spreadsheet": "Spreadsheet",
|
|
42
|
+
"thread": "Thread",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SourceReference:
|
|
48
|
+
source: str
|
|
49
|
+
resource_type: ResourceType
|
|
50
|
+
resource_id: str
|
|
51
|
+
fetch_meta: dict[str, str] | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ConnectorCommandEffects:
|
|
56
|
+
"""Side effects requested after a connector command succeeds."""
|
|
57
|
+
|
|
58
|
+
poll: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PersonRecord(BaseModel):
|
|
62
|
+
platform: str
|
|
63
|
+
platform_user_id: str
|
|
64
|
+
platform_username: str | None = None
|
|
65
|
+
canonical_email: str | None = None
|
|
66
|
+
display_name: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EntityRecord(BaseModel):
|
|
70
|
+
entity_type: str # 'Message' | 'Document' | 'Channel' | 'Task'
|
|
71
|
+
platform: str
|
|
72
|
+
platform_entity_id: str
|
|
73
|
+
title: str | None = None
|
|
74
|
+
content: str | None = None
|
|
75
|
+
created_at: datetime | None = None
|
|
76
|
+
updated_at: datetime | None = None
|
|
77
|
+
metadata: dict[str, str | int | float | bool | None] = {}
|
|
78
|
+
is_stub: bool = False # True → placeholder pending a full fetch; preserves synced_at=NULL
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class EdgeRecord(BaseModel):
|
|
82
|
+
edge_type: str # 'authored' | 'posted_in' | 'replied_to' | 'mentions'
|
|
83
|
+
source_platform_entity_id: str | None = None
|
|
84
|
+
source_platform_user_id: str | None = None
|
|
85
|
+
target_platform_entity_id: str | None = None
|
|
86
|
+
target_platform_user_id: str | None = None
|
|
87
|
+
platform: str
|
|
88
|
+
properties: dict[str, str | int | float | bool | None] = {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class EntityBatch(BaseModel):
|
|
92
|
+
entities: list[EntityRecord] = []
|
|
93
|
+
edges: list[EdgeRecord] = []
|
|
94
|
+
persons: list[PersonRecord] = []
|
|
95
|
+
|
|
96
|
+
def add_stubs_from(self, entity: EntityRecord) -> None:
|
|
97
|
+
"""Scan entity content for recognisable URLs and append stub EntityRecords and
|
|
98
|
+
'references' EdgeRecords to this batch.
|
|
99
|
+
|
|
100
|
+
Connectors call this after building each content-bearing entity so that
|
|
101
|
+
linked resources from other platforms are visible in the graph before they
|
|
102
|
+
are fetched. Stub entities are inserted with synced_at=NULL so the
|
|
103
|
+
relevant connector will do a full fetch when the resource is next visited.
|
|
104
|
+
"""
|
|
105
|
+
if not entity.content:
|
|
106
|
+
return
|
|
107
|
+
from agentgraph.server.router import classify_url
|
|
108
|
+
|
|
109
|
+
seen: set[str] = set()
|
|
110
|
+
for raw_url in _URL_RE.findall(entity.content):
|
|
111
|
+
ref = classify_url(raw_url)
|
|
112
|
+
if ref is None:
|
|
113
|
+
continue
|
|
114
|
+
key = f"{ref.source}/{ref.resource_id}"
|
|
115
|
+
if key in seen:
|
|
116
|
+
continue
|
|
117
|
+
# Skip self-references (e.g. a doc linking to itself)
|
|
118
|
+
if ref.source == entity.platform and ref.resource_id == entity.platform_entity_id:
|
|
119
|
+
continue
|
|
120
|
+
seen.add(key)
|
|
121
|
+
self.entities.append(EntityRecord(
|
|
122
|
+
entity_type=RESOURCE_TYPE_TO_ENTITY_TYPE[ref.resource_type],
|
|
123
|
+
platform=ref.source,
|
|
124
|
+
platform_entity_id=ref.resource_id,
|
|
125
|
+
is_stub=True,
|
|
126
|
+
))
|
|
127
|
+
self.edges.append(EdgeRecord(
|
|
128
|
+
edge_type="references",
|
|
129
|
+
source_platform_entity_id=entity.platform_entity_id,
|
|
130
|
+
target_platform_entity_id=ref.resource_id,
|
|
131
|
+
platform="cross",
|
|
132
|
+
))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def get_known_channel_syncs(
|
|
136
|
+
platform: str,
|
|
137
|
+
account_id: str | None = None,
|
|
138
|
+
) -> list[tuple[str, datetime | None]]:
|
|
139
|
+
"""Return (platform_entity_id, synced_at) for known Channel entities on a platform."""
|
|
140
|
+
from agentgraph.core.context import get_backend
|
|
141
|
+
|
|
142
|
+
entities = await get_backend().list_entities(
|
|
143
|
+
entity_types=["Channel"],
|
|
144
|
+
platform=platform,
|
|
145
|
+
since=None,
|
|
146
|
+
limit=10_000,
|
|
147
|
+
)
|
|
148
|
+
result: list[tuple[str, datetime | None]] = []
|
|
149
|
+
for entity in entities:
|
|
150
|
+
metadata = entity.get("metadata")
|
|
151
|
+
if (
|
|
152
|
+
account_id
|
|
153
|
+
and isinstance(metadata, dict)
|
|
154
|
+
and metadata.get("account_id") not in (None, account_id)
|
|
155
|
+
):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
platform_entity_id = entity.get("platform_entity_id")
|
|
159
|
+
if not isinstance(platform_entity_id, str):
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
synced_at_value = entity.get("synced_at")
|
|
163
|
+
synced_at = (
|
|
164
|
+
datetime.fromisoformat(synced_at_value)
|
|
165
|
+
if isinstance(synced_at_value, str) and synced_at_value
|
|
166
|
+
else None
|
|
167
|
+
)
|
|
168
|
+
result.append((platform_entity_id, synced_at))
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ConnectorAccount(BaseModel):
|
|
173
|
+
account_id: str
|
|
174
|
+
label: str
|
|
175
|
+
auth_group: str
|
|
176
|
+
source: str
|
|
177
|
+
user_id: str | None = None
|
|
178
|
+
workspace_id: str | None = None
|
|
179
|
+
email: str | None = None
|
|
180
|
+
auth_method: str | None = None
|
|
181
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class FetchPolicy:
|
|
186
|
+
"""Encapsulates refresh policy decisions for a resource."""
|
|
187
|
+
|
|
188
|
+
FIRST_VISIT = "first_visit"
|
|
189
|
+
INCREMENTAL = "incremental"
|
|
190
|
+
FRESH = "fresh"
|
|
191
|
+
|
|
192
|
+
def __init__(self, stale_after_seconds: int) -> None:
|
|
193
|
+
self.stale_after = timedelta(seconds=stale_after_seconds)
|
|
194
|
+
|
|
195
|
+
def decide(self, last_synced_at: datetime | None) -> str:
|
|
196
|
+
"""
|
|
197
|
+
Return FIRST_VISIT, INCREMENTAL, or FRESH based on last sync time.
|
|
198
|
+
- FIRST_VISIT: never synced
|
|
199
|
+
- INCREMENTAL: synced but data is stale
|
|
200
|
+
- FRESH: synced recently, only update last_accessed
|
|
201
|
+
"""
|
|
202
|
+
if last_synced_at is None:
|
|
203
|
+
return self.FIRST_VISIT
|
|
204
|
+
age = datetime.now(UTC) - last_synced_at
|
|
205
|
+
if age > self.stale_after:
|
|
206
|
+
return self.INCREMENTAL
|
|
207
|
+
return self.FRESH
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class BaseConnector(ABC):
|
|
211
|
+
source: ClassVar[str] # platform name, e.g. "slack" — must be set by subclass
|
|
212
|
+
fetch_policy: ClassVar[FetchPolicy] # staleness policy — must be set by subclass
|
|
213
|
+
is_generic_url_fallback: ClassVar[bool] = False
|
|
214
|
+
"""True for broad fallback connectors that should not claim URLs during discovery."""
|
|
215
|
+
|
|
216
|
+
poll_interval: ClassVar[timedelta | None] = None
|
|
217
|
+
"""Interval between background poll() calls. None disables polling for this connector."""
|
|
218
|
+
|
|
219
|
+
poll_delegates: ClassVar[list[str]] = []
|
|
220
|
+
"""Connector sources refreshed indirectly by this connector's poll() implementation."""
|
|
221
|
+
|
|
222
|
+
url_patterns: ClassVar[list[str]] = []
|
|
223
|
+
"""Chrome match-pattern strings (e.g. "https://mail.google.com/*") that identify URLs
|
|
224
|
+
this connector can handle. Used by the browser extension to decide which tabs to watch."""
|
|
225
|
+
|
|
226
|
+
# Auth integration — override in subclasses that support interactive auth.
|
|
227
|
+
# auth_label deduplicates across connectors that share credentials (e.g. all Google connectors).
|
|
228
|
+
auth_label: ClassVar[str | None] = None
|
|
229
|
+
auth_description: ClassVar[str | None] = None
|
|
230
|
+
onboard_prompt: ClassVar[str | None] = None
|
|
231
|
+
appears_in_auth_status: ClassVar[bool] = True
|
|
232
|
+
"""True for connectors backed by user/provider credentials.
|
|
233
|
+
|
|
234
|
+
Connectors that only need configuration, or no setup at all, should leave
|
|
235
|
+
`agentgraph auth status` to real authentication providers.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def run_auth_flow(
|
|
240
|
+
cls,
|
|
241
|
+
account_id: str | None = None,
|
|
242
|
+
add: bool = False,
|
|
243
|
+
args: list[str] | None = None,
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Run the interactive authentication flow for this connector."""
|
|
246
|
+
raise NotImplementedError(f"{cls.__name__} does not have an auth flow")
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def run_auth_flow_with_args(
|
|
250
|
+
cls,
|
|
251
|
+
args: list[str],
|
|
252
|
+
account_id: str | None = None,
|
|
253
|
+
add: bool = False,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Parse connector-owned auth arguments and run authentication."""
|
|
256
|
+
cls.run_auth_flow(account_id=account_id, add=add, args=args)
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def get_authenticated_user(cls) -> str | None:
|
|
260
|
+
"""Return a display string for the currently authenticated user, or None."""
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
@classmethod
|
|
264
|
+
def list_accounts(cls) -> list[ConnectorAccount]:
|
|
265
|
+
"""Return the authenticated accounts known to this connector."""
|
|
266
|
+
user = cls.get_authenticated_user()
|
|
267
|
+
if user is None:
|
|
268
|
+
return []
|
|
269
|
+
return [ConnectorAccount(
|
|
270
|
+
account_id=cls.source,
|
|
271
|
+
label=user,
|
|
272
|
+
auth_group=cls.auth_label or cls.source,
|
|
273
|
+
source=cls.source,
|
|
274
|
+
user_id=user,
|
|
275
|
+
email=user if "@" in user else None,
|
|
276
|
+
)]
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
async def verify_auth(cls, account_id: str | None = None) -> tuple[str, str | None]:
|
|
280
|
+
"""Check whether stored credentials are valid.
|
|
281
|
+
|
|
282
|
+
Returns a (status, detail) tuple where status is one of:
|
|
283
|
+
- "ok": credentials present and (where verified) accepted by the platform
|
|
284
|
+
- "missing": no credentials stored
|
|
285
|
+
- "invalid": credentials present but rejected by the platform
|
|
286
|
+
|
|
287
|
+
The default implementation only checks credential presence via
|
|
288
|
+
get_authenticated_user(). Override in subclasses that want to make a
|
|
289
|
+
live API call (e.g. /users/@me) to detect token resets or expiry.
|
|
290
|
+
"""
|
|
291
|
+
user = cls.get_authenticated_user()
|
|
292
|
+
if user is None:
|
|
293
|
+
return ("missing", None)
|
|
294
|
+
return ("ok", user)
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def run_cli_command(cls, args: list[str]) -> dict[str, Any]:
|
|
298
|
+
"""Run a connector-owned CLI command.
|
|
299
|
+
|
|
300
|
+
Core dispatches to this hook generically via `agentgraph connector <source> ...`.
|
|
301
|
+
Connectors own their command names, argument parsing, and behaviour.
|
|
302
|
+
"""
|
|
303
|
+
_ = args
|
|
304
|
+
raise NotImplementedError(f"{cls.source} does not expose connector commands")
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def cli_help(cls) -> str:
|
|
308
|
+
"""Return help text for connector-owned CLI commands."""
|
|
309
|
+
return f"{cls.source} does not expose connector commands"
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def format_cli_result(cls, result: dict[str, Any]) -> str:
|
|
313
|
+
"""Return human-readable output for a connector-owned CLI command result."""
|
|
314
|
+
import json
|
|
315
|
+
|
|
316
|
+
return json.dumps(result, indent=2, default=str)
|
|
317
|
+
|
|
318
|
+
@classmethod
|
|
319
|
+
def command_effects(
|
|
320
|
+
cls,
|
|
321
|
+
args: list[str],
|
|
322
|
+
result: dict[str, Any],
|
|
323
|
+
) -> ConnectorCommandEffects:
|
|
324
|
+
"""Return side effects for core to run after a command succeeds."""
|
|
325
|
+
_ = (args, result)
|
|
326
|
+
return ConnectorCommandEffects()
|
|
327
|
+
|
|
328
|
+
def normalise_fetch_id(
|
|
329
|
+
self, resource_id: str, entity_type: str
|
|
330
|
+
) -> tuple[str, ResourceType]:
|
|
331
|
+
"""Map a stored resource_id + entity_type to the (id, resource_type) that fetch() expects.
|
|
332
|
+
|
|
333
|
+
The default maps entity_type to ResourceType using the standard table and
|
|
334
|
+
returns resource_id unchanged. Connectors override this when their stored IDs
|
|
335
|
+
differ from their fetchable IDs (e.g. Discord message IDs encode the channel).
|
|
336
|
+
"""
|
|
337
|
+
resource_type_map: dict[str, ResourceType] = {
|
|
338
|
+
"Document": "document",
|
|
339
|
+
"Folder": "folder",
|
|
340
|
+
"Spreadsheet": "spreadsheet",
|
|
341
|
+
"Channel": "channel",
|
|
342
|
+
"Message": "message",
|
|
343
|
+
"Thread": "thread",
|
|
344
|
+
}
|
|
345
|
+
return resource_id, resource_type_map.get(entity_type, "document") # type: ignore[return-value]
|
|
346
|
+
|
|
347
|
+
@classmethod
|
|
348
|
+
def current_user_id(cls) -> str | None:
|
|
349
|
+
"""Return the canonical identifier for the authenticated user on this platform.
|
|
350
|
+
|
|
351
|
+
The value returned must match the platform_entity_id stored on the user's
|
|
352
|
+
Person entity (i.e. canonical_email, or "platform:user_id" if no email).
|
|
353
|
+
Used by --mine filtering. Returns None if not authenticated or not applicable.
|
|
354
|
+
"""
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
@classmethod
|
|
358
|
+
def current_user_ids(cls) -> list[str]:
|
|
359
|
+
"""Return all canonical identifiers for authenticated users on this platform."""
|
|
360
|
+
user_id = cls.current_user_id()
|
|
361
|
+
return [user_id] if user_id else []
|
|
362
|
+
|
|
363
|
+
def poll_account_ids(self) -> list[str | None]:
|
|
364
|
+
"""Return the account IDs that should receive background polling."""
|
|
365
|
+
accounts = type(self).list_accounts()
|
|
366
|
+
return [account.account_id for account in accounts] or [None]
|
|
367
|
+
|
|
368
|
+
@abstractmethod
|
|
369
|
+
def can_handle(self, url: str) -> bool: ...
|
|
370
|
+
|
|
371
|
+
def resolve_url(self, url: str) -> SourceReference | None:
|
|
372
|
+
"""Return the fetchable resource behind a URL, if this connector owns it."""
|
|
373
|
+
_ = url
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
async def resolve_observation_url(self, url: str) -> SourceReference | None:
|
|
377
|
+
"""Resolve a URL observed by the browser extension.
|
|
378
|
+
|
|
379
|
+
Connectors can override this when resolution requires an async lookup or
|
|
380
|
+
fetch metadata that must accompany a targeted fetch. The default keeps
|
|
381
|
+
existing synchronous URL resolvers usable for observations.
|
|
382
|
+
"""
|
|
383
|
+
return self.resolve_url(url)
|
|
384
|
+
|
|
385
|
+
async def observation_url_patterns(self) -> list[str]:
|
|
386
|
+
"""Return browser observation patterns, including connector-derived ones."""
|
|
387
|
+
return self.url_patterns
|
|
388
|
+
|
|
389
|
+
@abstractmethod
|
|
390
|
+
async def fetch(
|
|
391
|
+
self,
|
|
392
|
+
resource_type: ResourceType,
|
|
393
|
+
resource_id: str,
|
|
394
|
+
meta: dict[str, str] | None = None,
|
|
395
|
+
account_id: str | None = None,
|
|
396
|
+
) -> EntityBatch: ...
|
|
397
|
+
|
|
398
|
+
async def ingest(self, account_id: str | None = None) -> EntityBatch:
|
|
399
|
+
"""Run a one-shot bulk ingest of all available historical data for this connector.
|
|
400
|
+
|
|
401
|
+
Override in connectors that support a full-history sweep beyond what poll() covers
|
|
402
|
+
on first run (e.g. fetching all labels, not just inbox). The default no-ops so that
|
|
403
|
+
connectors which don't need this remain unchanged.
|
|
404
|
+
"""
|
|
405
|
+
return EntityBatch()
|
|
406
|
+
|
|
407
|
+
async def poll(
|
|
408
|
+
self,
|
|
409
|
+
cursor: dict[str, Any],
|
|
410
|
+
account_id: str | None = None,
|
|
411
|
+
) -> tuple[EntityBatch, dict[str, Any]]:
|
|
412
|
+
"""Fetch all changes since cursor for background sync.
|
|
413
|
+
|
|
414
|
+
cursor is {} on first call. Return (batch, updated_cursor).
|
|
415
|
+
The SyncEngine persists the cursor between calls and upserts the returned batch.
|
|
416
|
+
Connectors that handle upserting internally (e.g. by calling fetch()) should
|
|
417
|
+
return an empty EntityBatch.
|
|
418
|
+
"""
|
|
419
|
+
return EntityBatch(), cursor
|
|
420
|
+
|
|
421
|
+
def entity_url(self, platform_entity_id: str) -> str | None:
|
|
422
|
+
"""Return the canonical web URL for an entity given its platform_entity_id.
|
|
423
|
+
|
|
424
|
+
Used to populate metadata.web_url for entities that don't store it at
|
|
425
|
+
ingest time. Return None if the URL cannot be derived from the ID alone
|
|
426
|
+
(e.g. it requires metadata like guild_id or team_id).
|
|
427
|
+
"""
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
async def download(
|
|
431
|
+
self,
|
|
432
|
+
resource_type: ResourceType,
|
|
433
|
+
resource_id: str,
|
|
434
|
+
output_path: str | None = None,
|
|
435
|
+
) -> dict[str, Any]:
|
|
436
|
+
"""Download an entity's source file using the connector's stored auth.
|
|
437
|
+
|
|
438
|
+
Connectors that expose downloadable files should override this method
|
|
439
|
+
and return metadata including the written path.
|
|
440
|
+
"""
|
|
441
|
+
raise NotImplementedError(f"{self.source} does not support authenticated downloads")
|
|
442
|
+
|
|
443
|
+
async def enrich_results(self, entities: list[dict[str, Any]]) -> None:
|
|
444
|
+
"""Mutate query/search result entities with connector-owned presentation fixes.
|
|
445
|
+
|
|
446
|
+
This hook lets connectors refresh short-lived metadata or add derived
|
|
447
|
+
fields before entities are returned to clients. The default no-ops.
|
|
448
|
+
"""
|
|
449
|
+
_ = entities
|
|
450
|
+
|
|
451
|
+
async def last_synced_at(self, resource_id: str) -> datetime | None:
|
|
452
|
+
"""Return the most recent synced_at for a platform entity, or None."""
|
|
453
|
+
from agentgraph.core.context import get_backend
|
|
454
|
+
|
|
455
|
+
return await get_backend().get_last_synced_at(self.source, resource_id)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Connector registry: maps source names to connector instances.
|
|
2
|
+
|
|
3
|
+
Built-in connectors are discovered via Python entry points
|
|
4
|
+
(``agentgraph.connectors`` group). Third-party connectors can be installed
|
|
5
|
+
as separate packages that declare the same entry point group.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.metadata
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from agentgraph.connectors.base import BaseConnector
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_registry: dict[str, BaseConnector] = {}
|
|
19
|
+
_entry_points: dict[str, importlib.metadata.EntryPoint] = {}
|
|
20
|
+
_bootstrapped = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register(connector: BaseConnector) -> None:
|
|
24
|
+
_registry[connector.source] = connector
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_connector(source: str) -> BaseConnector | None:
|
|
28
|
+
bootstrap()
|
|
29
|
+
if source not in _registry and source in _entry_points:
|
|
30
|
+
_load_entry_point(source, _entry_points[source])
|
|
31
|
+
return _registry.get(source)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def registered_sources() -> list[str]:
|
|
35
|
+
bootstrap()
|
|
36
|
+
return list(dict.fromkeys([*_registry.keys(), *_entry_points.keys()]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_all_connectors() -> list[BaseConnector]:
|
|
40
|
+
bootstrap()
|
|
41
|
+
for name, ep in list(_entry_points.items()):
|
|
42
|
+
if name not in _registry:
|
|
43
|
+
_load_entry_point(name, ep)
|
|
44
|
+
return list(_registry.values())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def bootstrap() -> None:
|
|
48
|
+
"""Discover connector entry points without importing connector packages."""
|
|
49
|
+
global _bootstrapped
|
|
50
|
+
if _bootstrapped:
|
|
51
|
+
return
|
|
52
|
+
_bootstrapped = True
|
|
53
|
+
|
|
54
|
+
for ep in _connector_entry_points():
|
|
55
|
+
_entry_points.setdefault(ep.name, ep)
|
|
56
|
+
|
|
57
|
+
if not _entry_points and not _registry:
|
|
58
|
+
logger.warning(
|
|
59
|
+
"No connectors discovered. Install connector packages (e.g. pip install agentgraph[all]) "
|
|
60
|
+
"or declare entry points in the 'agentgraph.connectors' group."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _connector_entry_points() -> Iterable[importlib.metadata.EntryPoint]:
|
|
65
|
+
return importlib.metadata.entry_points(group="agentgraph.connectors")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_entry_point(name: str, ep: importlib.metadata.EntryPoint) -> None:
|
|
69
|
+
try:
|
|
70
|
+
connector_class: type[BaseConnector] = ep.load()
|
|
71
|
+
connector = connector_class()
|
|
72
|
+
register(connector)
|
|
73
|
+
logger.debug("Loaded connector %r from %s", ep.name, ep.value)
|
|
74
|
+
if connector.source != name:
|
|
75
|
+
_entry_points.pop(name, None)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
_entry_points.pop(name, None)
|
|
78
|
+
logger.warning("Failed to load connector %r: %s", ep.name, exc)
|