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,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
|