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.
Files changed (49) hide show
  1. agentgraph/__init__.py +1 -0
  2. agentgraph/auth/__init__.py +0 -0
  3. agentgraph/auth/credentials.py +224 -0
  4. agentgraph/backends/__init__.py +50 -0
  5. agentgraph/backends/sqlite/__init__.py +1 -0
  6. agentgraph/backends/sqlite/backend.py +1471 -0
  7. agentgraph/backends/sqlite/vector.py +142 -0
  8. agentgraph/cli.py +721 -0
  9. agentgraph/cli_query.py +519 -0
  10. agentgraph/config.py +90 -0
  11. agentgraph/connectors/__init__.py +0 -0
  12. agentgraph/connectors/base.py +455 -0
  13. agentgraph/connectors/registry.py +78 -0
  14. agentgraph/connectors/status.py +244 -0
  15. agentgraph/core/__init__.py +0 -0
  16. agentgraph/core/context.py +26 -0
  17. agentgraph/core/runtime.py +36 -0
  18. agentgraph/core/storage.py +240 -0
  19. agentgraph/graph/__init__.py +1 -0
  20. agentgraph/graph/bookmark.py +87 -0
  21. agentgraph/graph/delete.py +17 -0
  22. agentgraph/graph/download.py +35 -0
  23. agentgraph/graph/embeddings.py +58 -0
  24. agentgraph/graph/fetch.py +53 -0
  25. agentgraph/graph/gc.py +26 -0
  26. agentgraph/graph/link.py +63 -0
  27. agentgraph/graph/person.py +40 -0
  28. agentgraph/graph/query.py +244 -0
  29. agentgraph/graph/upsert.py +49 -0
  30. agentgraph/logging.py +78 -0
  31. agentgraph/mcp/__init__.py +0 -0
  32. agentgraph/mcp/server.py +811 -0
  33. agentgraph/perf.py +43 -0
  34. agentgraph/server/__init__.py +0 -0
  35. agentgraph/server/app.py +133 -0
  36. agentgraph/server/cli_api.py +708 -0
  37. agentgraph/server/dwell.py +79 -0
  38. agentgraph/server/graph_api.py +46 -0
  39. agentgraph/server/router.py +47 -0
  40. agentgraph/server/sync.py +247 -0
  41. agentgraph/skills.py +93 -0
  42. agentgraph_server-0.5.0.data/data/.agents/skills/graph/SKILL.md +159 -0
  43. agentgraph_server-0.5.0.data/data/.agents/skills/slack-auth/SKILL.md +92 -0
  44. agentgraph_server-0.5.0.dist-info/METADATA +286 -0
  45. agentgraph_server-0.5.0.dist-info/RECORD +49 -0
  46. agentgraph_server-0.5.0.dist-info/WHEEL +5 -0
  47. agentgraph_server-0.5.0.dist-info/entry_points.txt +2 -0
  48. agentgraph_server-0.5.0.dist-info/licenses/LICENSE +21 -0
  49. 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)