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,244 @@
1
+ """Connector and auth-provider status formatting shared by CLI and MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import cast
8
+
9
+ from agentgraph.connectors.base import BaseConnector
10
+ from agentgraph.core.storage import StorageBackend
11
+
12
+
13
+ def auth_provider_key(connector: BaseConnector) -> str:
14
+ return getattr(connector, "auth_label", None) or connector.source
15
+
16
+
17
+ def connector_uses_auth(connector: BaseConnector) -> bool:
18
+ return getattr(type(connector), "appears_in_auth_status", True)
19
+
20
+
21
+ def auth_provider_connectors(
22
+ connectors: list[BaseConnector],
23
+ *,
24
+ include_non_auth: bool = False,
25
+ ) -> dict[str, list[BaseConnector]]:
26
+ grouped: dict[str, list[BaseConnector]] = {}
27
+ for connector in connectors:
28
+ if not include_non_auth and not connector_uses_auth(connector):
29
+ continue
30
+ grouped.setdefault(auth_provider_key(connector), []).append(connector)
31
+ return grouped
32
+
33
+
34
+ def run_auth_provider_flow(
35
+ connectors: list[BaseConnector],
36
+ provider: str,
37
+ *,
38
+ account_id: str | None = None,
39
+ add: bool = False,
40
+ args: list[str] | None = None,
41
+ ) -> None:
42
+ """Dispatch an authentication flow through a provider's representative connector."""
43
+ grouped = auth_provider_connectors(connectors)
44
+ members = grouped.get(provider)
45
+ if not members:
46
+ available = ", ".join(sorted(grouped))
47
+ raise ValueError(f"Unknown auth provider {provider!r}. Available: {available}")
48
+
49
+ connector_cls = type(members[0])
50
+ provider_args = args or []
51
+ auth_hook = getattr(connector_cls, "run_auth_flow_with_args", None)
52
+ if callable(auth_hook):
53
+ auth_hook(provider_args, account_id=account_id, add=add)
54
+ return
55
+
56
+ auth_flow = connector_cls.run_auth_flow
57
+ if "args" in inspect.signature(auth_flow).parameters:
58
+ auth_flow(account_id=account_id, add=add, args=provider_args)
59
+ return
60
+ if provider_args:
61
+ raise ValueError(
62
+ f"{provider!r} does not accept auth options: {' '.join(provider_args)}"
63
+ )
64
+ auth_flow()
65
+
66
+
67
+ def _list_accounts(connector: BaseConnector) -> list[object]:
68
+ list_accounts = getattr(type(connector), "list_accounts", None)
69
+ if callable(list_accounts):
70
+ return cast(list[object], list_accounts())
71
+ return []
72
+
73
+
74
+ async def _verify_auth(
75
+ connector: BaseConnector,
76
+ account_id: str | None = None,
77
+ ) -> tuple[str, str | None]:
78
+ verify_auth = type(connector).verify_auth
79
+ try:
80
+ return await verify_auth(account_id)
81
+ except TypeError:
82
+ return await verify_auth()
83
+
84
+
85
+ def _aggregate_auth_status(statuses: list[tuple[str, str | None]]) -> tuple[str, str | None]:
86
+ if any(status == "invalid" for status, _ in statuses):
87
+ return next(item for item in statuses if item[0] == "invalid")
88
+ if any(status == "ok" for status, _ in statuses):
89
+ return ("ok", f"{len(statuses)} account(s)")
90
+ return ("missing", None)
91
+
92
+
93
+ def _local_auth_status(
94
+ connector: BaseConnector,
95
+ accounts: list[object],
96
+ ) -> tuple[str, str | None]:
97
+ if accounts:
98
+ return ("ok", f"{len(accounts)} account(s)")
99
+ user = type(connector).get_authenticated_user()
100
+ if user is not None:
101
+ return ("ok", user)
102
+ return ("missing", None)
103
+
104
+
105
+ async def auth_provider_status_items(
106
+ connectors: list[BaseConnector],
107
+ *,
108
+ verify: bool = False,
109
+ include_non_auth: bool = False,
110
+ ) -> list[dict[str, object]]:
111
+ """Build JSON-serialisable auth-provider status rows."""
112
+ items: list[dict[str, object]] = []
113
+ for provider, members in auth_provider_connectors(
114
+ connectors,
115
+ include_non_auth=include_non_auth,
116
+ ).items():
117
+ representative = members[0]
118
+ accounts = _list_accounts(representative)
119
+ account_rows: list[dict[str, object]] = []
120
+ statuses: list[tuple[str, str | None]] = []
121
+ for account in accounts:
122
+ account_id = getattr(account, "account_id", None)
123
+ status, detail = (
124
+ await _verify_auth(representative, account_id)
125
+ if verify
126
+ else ("ok", getattr(account, "label", None))
127
+ )
128
+ account_rows.append({
129
+ "account_id": getattr(account, "account_id", None),
130
+ "label": getattr(account, "label", None),
131
+ "user_id": getattr(account, "user_id", None),
132
+ "workspace_id": getattr(account, "workspace_id", None),
133
+ "email": getattr(account, "email", None),
134
+ "auth_method": getattr(account, "auth_method", None),
135
+ "auth_status": status,
136
+ "auth_detail": detail,
137
+ })
138
+ statuses.append((status, detail))
139
+ auth_status, auth_detail = (
140
+ _aggregate_auth_status(statuses)
141
+ if statuses
142
+ else await _verify_auth(representative)
143
+ if verify
144
+ else _local_auth_status(representative, accounts)
145
+ )
146
+ connector_sources = [connector.source for connector in members]
147
+ items.append({
148
+ "provider": provider,
149
+ "description": f"Shared auth for {', '.join(connector_sources)}"
150
+ if len(connector_sources) > 1
151
+ else type(representative).auth_description or provider,
152
+ "connectors": connector_sources,
153
+ "shared": len(connector_sources) > 1,
154
+ "auth_status": auth_status,
155
+ "auth_detail": auth_detail,
156
+ "auth_verified": verify,
157
+ "accounts": account_rows,
158
+ })
159
+ return items
160
+
161
+
162
+ async def connector_status_items(
163
+ connectors: list[BaseConnector],
164
+ backend: StorageBackend,
165
+ *,
166
+ verify: bool = False,
167
+ ) -> list[dict[str, object]]:
168
+ """Build JSON-serialisable connector status rows."""
169
+ provider_items = await auth_provider_status_items(connectors, verify=verify)
170
+ provider_by_key = {str(item["provider"]): item for item in provider_items}
171
+
172
+ poll_delegators: dict[str, list[str]] = {}
173
+ for connector in connectors:
174
+ for delegated_source in type(connector).poll_delegates:
175
+ poll_delegators.setdefault(delegated_source, []).append(connector.source)
176
+
177
+ last_synced_by_platform = await backend.get_platforms_last_synced_at(
178
+ [connector.source for connector in connectors]
179
+ )
180
+
181
+ items: list[dict[str, object]] = []
182
+ for connector in connectors:
183
+ uses_auth = connector_uses_auth(connector)
184
+ provider = auth_provider_key(connector) if uses_auth else None
185
+ provider_item = provider_by_key[str(provider)] if provider is not None else None
186
+ interval = connector.poll_interval
187
+ polls = interval is not None
188
+ polled_by = sorted(poll_delegators.get(connector.source, []))
189
+ poll_delegates = list(type(connector).poll_delegates)
190
+ last_synced_at = last_synced_by_platform.get(connector.source)
191
+ items.append({
192
+ "source": connector.source,
193
+ "description": type(connector).auth_description,
194
+ "auth_provider": provider,
195
+ "shared_auth": bool(provider_item["shared"]) if provider_item is not None else False,
196
+ "auth_status": provider_item["auth_status"] if provider_item is not None else None,
197
+ "auth_detail": provider_item["auth_detail"] if provider_item is not None else None,
198
+ "auth_verified": provider_item["auth_verified"] if provider_item is not None else False,
199
+ "account_count": len(cast(list[dict[str, object]], provider_item["accounts"]))
200
+ if provider_item is not None
201
+ else 0,
202
+ "url_patterns": type(connector).url_patterns,
203
+ "polls": polls,
204
+ "poll_interval_seconds": int(interval.total_seconds()) if interval is not None else None,
205
+ "poll_delegates": poll_delegates,
206
+ "polled_by": polled_by,
207
+ "sync": _sync_label(polls, interval, polled_by, poll_delegates),
208
+ "last_synced_at": last_synced_at.isoformat() if last_synced_at is not None else None,
209
+ "last_sync": _last_sync_label(last_synced_at),
210
+ })
211
+ return items
212
+
213
+
214
+ def _sync_label(
215
+ polls: bool,
216
+ interval: timedelta | None,
217
+ polled_by: list[str],
218
+ poll_delegates: list[str],
219
+ ) -> str:
220
+ if polls:
221
+ assert interval is not None
222
+ label = f"polling every {_format_interval(int(interval.total_seconds()))}"
223
+ if poll_delegates:
224
+ label = f"{label} for {', '.join(poll_delegates)}"
225
+ return label
226
+ if polled_by:
227
+ return f"via {', '.join(polled_by)} poll"
228
+ return "on-demand"
229
+
230
+
231
+ def _last_sync_label(last_synced_at: datetime | None) -> str:
232
+ if last_synced_at is None:
233
+ return "never"
234
+ return last_synced_at.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%SZ")
235
+
236
+
237
+ def _format_interval(seconds: int) -> str:
238
+ if seconds % 3600 == 0:
239
+ hours = seconds // 3600
240
+ return f"{hours}h"
241
+ if seconds % 60 == 0:
242
+ minutes = seconds // 60
243
+ return f"{minutes}m"
244
+ return f"{seconds}s"
File without changes
@@ -0,0 +1,26 @@
1
+ """Module-level StorageBackend singleton."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentgraph.core.storage import StorageBackend
6
+
7
+ _backend: StorageBackend | None = None
8
+
9
+
10
+ def set_backend(backend: StorageBackend) -> None:
11
+ global _backend
12
+ _backend = backend
13
+
14
+
15
+ def clear_backend() -> None:
16
+ global _backend
17
+ _backend = None
18
+
19
+
20
+ def get_backend() -> StorageBackend:
21
+ if _backend is None:
22
+ raise RuntimeError(
23
+ "No storage backend initialized. "
24
+ "Call set_backend() before using the graph layer."
25
+ )
26
+ return _backend
@@ -0,0 +1,36 @@
1
+ """Runtime helpers for constructing configured backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncGenerator
6
+ from contextlib import asynccontextmanager
7
+ from typing import Any
8
+
9
+ from agentgraph.core.storage import StorageBackend
10
+
11
+
12
+ def create_backend() -> StorageBackend:
13
+ """Create the configured storage backend without initialising it."""
14
+ from agentgraph.backends import get_backend_class
15
+ from agentgraph.config import get_settings
16
+
17
+ settings = get_settings()
18
+ backend_class: Any = get_backend_class(settings.backend)
19
+ if settings.backend == "sqlite":
20
+ return backend_class(settings.backend_sqlite_path, settings.backend_sqlite_vector_mode)
21
+ return backend_class(settings)
22
+
23
+
24
+ @asynccontextmanager
25
+ async def backend_context() -> AsyncGenerator[StorageBackend, None]:
26
+ """Initialise the configured backend, set graph context, and close it."""
27
+ from agentgraph.core.context import clear_backend, set_backend
28
+
29
+ backend = create_backend()
30
+ await backend.initialize()
31
+ set_backend(backend)
32
+ try:
33
+ yield backend
34
+ finally:
35
+ await backend.close()
36
+ clear_backend()
@@ -0,0 +1,240 @@
1
+ """StorageBackend ABC — the persistence-agnostic interface for AgentGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from agentgraph.connectors.base import EntityBatch
10
+
11
+ EntityResult = dict[str, Any]
12
+ EdgeResult = dict[str, Any]
13
+
14
+
15
+ class StorageBackend(ABC):
16
+ """Persistence-agnostic interface for the AgentGraph knowledge store.
17
+
18
+ All methods are async. The backend is responsible for all SQL/storage
19
+ operations; the graph layer computes embeddings and passes them in.
20
+ """
21
+
22
+ # --- Lifecycle ---
23
+
24
+ @abstractmethod
25
+ async def initialize(self) -> None:
26
+ """Apply schema, run migrations, open connection pool."""
27
+ ...
28
+
29
+ @abstractmethod
30
+ async def close(self) -> None:
31
+ """Release all connections and resources."""
32
+ ...
33
+
34
+ # --- Write ---
35
+
36
+ @abstractmethod
37
+ async def upsert_batch(
38
+ self,
39
+ batch: EntityBatch,
40
+ person_embeddings: dict[str, list[float] | None],
41
+ entity_embeddings: dict[str, list[float] | None],
42
+ ) -> None:
43
+ """Atomically persist an EntityBatch.
44
+
45
+ person_embeddings: canonical_key (email or "platform:user_id") → vector
46
+ entity_embeddings: platform_entity_id → vector
47
+ """
48
+ ...
49
+
50
+ @abstractmethod
51
+ async def merge_person_entities(
52
+ self,
53
+ primary_entity_id: str,
54
+ duplicate_entity_ids: list[str],
55
+ ) -> EntityResult:
56
+ """Merge duplicate Person entities into primary_entity_id.
57
+
58
+ Backends must move all edges from duplicate persons to the primary person,
59
+ merge non-conflicting metadata onto the primary, delete the duplicates,
60
+ and return the updated primary entity.
61
+ """
62
+ ...
63
+
64
+ @abstractmethod
65
+ async def set_entity_bookmarked(
66
+ self,
67
+ entity_id: str,
68
+ bookmarked: bool,
69
+ ) -> EntityResult:
70
+ """Set or clear GC protection for an entity and return the updated entity."""
71
+ ...
72
+
73
+ @abstractmethod
74
+ async def delete_entity(self, entity_id: str) -> EntityResult:
75
+ """Delete an entity and return the deleted entity."""
76
+ ...
77
+
78
+ # --- Read: entities ---
79
+
80
+ @abstractmethod
81
+ async def search_entities(
82
+ self,
83
+ query_vec: list[float],
84
+ query_text: str,
85
+ entity_types: list[str] | None,
86
+ limit: int,
87
+ min_score: float,
88
+ platform: str | None = None,
89
+ ) -> list[EntityResult]: ...
90
+
91
+ @abstractmethod
92
+ async def get_entity_by_id(self, entity_id: str) -> EntityResult | None: ...
93
+
94
+ @abstractmethod
95
+ async def get_entities_by_ids(self, entity_ids: list[str]) -> list[EntityResult]: ...
96
+
97
+ @abstractmethod
98
+ async def get_entities_by_id_prefix(self, prefix: str) -> list[EntityResult]: ...
99
+
100
+ @abstractmethod
101
+ async def get_entity_by_platform(
102
+ self, platform: str, platform_entity_id: str
103
+ ) -> EntityResult | None: ...
104
+
105
+ @abstractmethod
106
+ async def list_entities(
107
+ self,
108
+ entity_types: list[str] | None,
109
+ platform: str | None,
110
+ since: datetime | None,
111
+ limit: int,
112
+ ) -> list[EntityResult]: ...
113
+
114
+ @abstractmethod
115
+ async def list_entities_page(
116
+ self,
117
+ entity_types: list[str] | None,
118
+ platform: str | None,
119
+ since: datetime | None,
120
+ limit: int,
121
+ offset: int,
122
+ order_by: str | None,
123
+ order_dir: str,
124
+ ) -> tuple[list[EntityResult], int]:
125
+ """Return an entity page and total count, optionally ordered by a supported field."""
126
+ ...
127
+
128
+ @abstractmethod
129
+ async def query_by_filter(
130
+ self,
131
+ entity_type: str,
132
+ filters: dict[str, str],
133
+ limit: int,
134
+ order_by: str,
135
+ since: datetime | None,
136
+ authored_by: list[str] | None,
137
+ has_attachments: bool = False,
138
+ ) -> list[EntityResult]: ...
139
+
140
+ # --- Read: edges ---
141
+
142
+ @abstractmethod
143
+ async def get_edges(
144
+ self,
145
+ entity_id: str,
146
+ edge_type: str | None,
147
+ direction: str,
148
+ ) -> list[EdgeResult]: ...
149
+
150
+ @abstractmethod
151
+ async def get_edges_for_entities(self, entity_ids: list[str]) -> list[EdgeResult]: ...
152
+
153
+ @abstractmethod
154
+ async def traverse_graph(
155
+ self, entity_id: str, max_depth: int
156
+ ) -> dict[str, Any]: ...
157
+
158
+ # --- Linking ---
159
+
160
+ @abstractmethod
161
+ async def find_entity_id(
162
+ self, platform: str, platform_entity_id: str
163
+ ) -> str | None: ...
164
+
165
+ @abstractmethod
166
+ async def upsert_stub_entity(
167
+ self,
168
+ entity_type: str,
169
+ platform: str,
170
+ platform_entity_id: str,
171
+ ) -> str:
172
+ """Insert a stub entity with synced_at=NULL. Returns internal UUID string."""
173
+ ...
174
+
175
+ @abstractmethod
176
+ async def insert_references_edge(self, source_id: str, target_id: str) -> None: ...
177
+
178
+ # --- GC ---
179
+
180
+ @abstractmethod
181
+ async def gc_entities(self, retention_days: int) -> int: ...
182
+
183
+ # --- Sync state ---
184
+
185
+ @abstractmethod
186
+ async def load_cursor(self, source: str) -> dict[str, Any]: ...
187
+
188
+ @abstractmethod
189
+ async def save_cursor(self, source: str, cursor: dict[str, Any]) -> None: ...
190
+
191
+ # --- Connector support ---
192
+
193
+ @abstractmethod
194
+ async def increment_dwell_time(
195
+ self, platform: str, platform_entity_id: str, dwell_ms: int
196
+ ) -> None:
197
+ """Increment cumulative dwell time for an entity."""
198
+ ...
199
+
200
+ @abstractmethod
201
+ async def get_last_synced_at(
202
+ self, platform: str, platform_entity_id: str
203
+ ) -> datetime | None: ...
204
+
205
+ @abstractmethod
206
+ async def get_platform_last_synced_at(self, platform: str) -> datetime | None: ...
207
+
208
+ async def get_platforms_last_synced_at(
209
+ self,
210
+ platforms: list[str],
211
+ ) -> dict[str, datetime | None]:
212
+ return {
213
+ platform: await self.get_platform_last_synced_at(platform)
214
+ for platform in platforms
215
+ }
216
+
217
+ @abstractmethod
218
+ async def reset_synced_at(
219
+ self, platform: str, platform_entity_id: str
220
+ ) -> None: ...
221
+
222
+ @abstractmethod
223
+ async def touch_last_accessed(
224
+ self, platform: str, platform_entity_id: str
225
+ ) -> None: ...
226
+
227
+ @abstractmethod
228
+ async def touch_last_accessed_by_ids(self, entity_ids: list[str]) -> None: ...
229
+
230
+ @abstractmethod
231
+ async def get_entity_type(
232
+ self, platform: str, platform_entity_id: str
233
+ ) -> str | None: ...
234
+
235
+ @abstractmethod
236
+ async def get_entity_platform_ref(
237
+ self, entity_id: str
238
+ ) -> tuple[str, str] | None:
239
+ """Return (platform, platform_entity_id) for an internal UUID, or None."""
240
+ ...
@@ -0,0 +1 @@
1
+ """Knowledge graph operations: upsert, identity resolution, GC."""
@@ -0,0 +1,87 @@
1
+ """Bookmark graph entities to protect them from garbage collection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlparse
6
+
7
+ from agentgraph.core.context import get_backend
8
+ from agentgraph.core.storage import EntityResult
9
+ from agentgraph.graph.query import get_entity
10
+
11
+
12
+ async def bookmark_entity(entity_id: str) -> EntityResult:
13
+ """Mark an entity as bookmarked by UUID, UUID prefix, or platform ref."""
14
+ return await set_entity_bookmark(entity_id, True)
15
+
16
+
17
+ async def set_entity_bookmark(entity_id: str, bookmarked: bool) -> EntityResult:
18
+ """Set bookmark state by UUID, UUID prefix, or platform ref."""
19
+ entity = await get_entity(entity_id)
20
+ if entity is None:
21
+ raise ValueError(f"Entity {entity_id!r} not found")
22
+ return await get_backend().set_entity_bookmarked(entity["id"], bookmarked)
23
+
24
+
25
+ async def bookmark_target(target: str) -> EntityResult:
26
+ """Bookmark an entity target or fetch and bookmark an http(s) URL."""
27
+ if _is_http_url(target):
28
+ return await bookmark_url(target)
29
+ return await bookmark_entity(target)
30
+
31
+
32
+ async def bookmark_url(url: str) -> EntityResult:
33
+ """Fetch a URL through its owning connector or the web fallback, then bookmark it."""
34
+ from agentgraph.connectors.registry import bootstrap, get_connector
35
+ from agentgraph.graph.upsert import upsert_batch
36
+ from agentgraph.server.router import classify_url
37
+
38
+ bootstrap()
39
+ ref = classify_url(url)
40
+ connector = get_connector(ref.source) if ref is not None else get_connector("web")
41
+ if connector is None:
42
+ raise ValueError("No connector available to fetch this URL")
43
+
44
+ resource_type = ref.resource_type if ref is not None else "document"
45
+ resource_id = ref.resource_id if ref is not None else url
46
+ batch = await connector.fetch(resource_type, resource_id)
47
+
48
+ backend = get_backend()
49
+ if ref is not None:
50
+ entity = await backend.get_entity_by_platform(ref.source, ref.resource_id)
51
+ else:
52
+ entity = None
53
+
54
+ if entity is None:
55
+ entity = await _find_batch_entity(batch)
56
+
57
+ if entity is None and batch.entities:
58
+ await upsert_batch(batch)
59
+ if ref is not None:
60
+ entity = await backend.get_entity_by_platform(ref.source, ref.resource_id)
61
+ else:
62
+ entity = await _find_batch_entity(batch)
63
+
64
+ if entity is None:
65
+ raise ValueError(f"URL {url!r} was fetched but no graph entity was created")
66
+ return await backend.set_entity_bookmarked(entity["id"], True)
67
+
68
+
69
+ async def _find_batch_entity(batch: object) -> EntityResult | None:
70
+ from agentgraph.connectors.base import EntityBatch
71
+
72
+ if not isinstance(batch, EntityBatch):
73
+ return None
74
+ backend = get_backend()
75
+ for candidate in batch.entities:
76
+ entity = await backend.get_entity_by_platform(
77
+ candidate.platform,
78
+ candidate.platform_entity_id,
79
+ )
80
+ if entity is not None:
81
+ return entity
82
+ return None
83
+
84
+
85
+ def _is_http_url(target: str) -> bool:
86
+ parsed = urlparse(target)
87
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
@@ -0,0 +1,17 @@
1
+ """Delete graph entities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agentgraph.core.context import get_backend
8
+ from agentgraph.graph.query import get_entity
9
+
10
+
11
+ async def delete_entity(target: str) -> dict[str, Any]:
12
+ """Delete an entity by UUID, UUID prefix, platform ref, or URL."""
13
+ entity = await get_entity(target)
14
+ if entity is None:
15
+ raise ValueError(f"Entity {target!r} not found")
16
+ deleted = await get_backend().delete_entity(entity["id"])
17
+ return {"deleted": True, "entity": deleted}
@@ -0,0 +1,35 @@
1
+ """Authenticated source-file downloads for graph entities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agentgraph.graph.query import get_entity
8
+
9
+
10
+ async def download_entity(entity_ref: str, output_path: str | None = None) -> dict[str, Any]:
11
+ """Download an entity's source file using its connector's stored auth."""
12
+ from agentgraph.connectors.registry import bootstrap, get_connector
13
+
14
+ entity = await get_entity(entity_ref)
15
+ if entity is None:
16
+ raise ValueError(f"Entity not found: {entity_ref}")
17
+
18
+ platform = str(entity["platform"])
19
+ platform_entity_id = str(entity["platform_entity_id"])
20
+ entity_type = str(entity["entity_type"])
21
+
22
+ bootstrap()
23
+ connector = get_connector(platform)
24
+ if connector is None:
25
+ raise ValueError(f"No connector registered for platform '{platform}'")
26
+
27
+ resource_id, resource_type = connector.normalise_fetch_id(platform_entity_id, entity_type)
28
+ try:
29
+ return await connector.download(
30
+ resource_type=resource_type,
31
+ resource_id=resource_id,
32
+ output_path=output_path,
33
+ )
34
+ except NotImplementedError as exc:
35
+ raise ValueError(str(exc)) from exc