agentgraph-connector-feed 0.1.0__tar.gz
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_connector_feed-0.1.0/LICENSE +21 -0
- agentgraph_connector_feed-0.1.0/PKG-INFO +63 -0
- agentgraph_connector_feed-0.1.0/README.md +48 -0
- agentgraph_connector_feed-0.1.0/agentgraph_connector_feed.egg-info/PKG-INFO +63 -0
- agentgraph_connector_feed-0.1.0/agentgraph_connector_feed.egg-info/SOURCES.txt +13 -0
- agentgraph_connector_feed-0.1.0/agentgraph_connector_feed.egg-info/dependency_links.txt +1 -0
- agentgraph_connector_feed-0.1.0/agentgraph_connector_feed.egg-info/entry_points.txt +2 -0
- agentgraph_connector_feed-0.1.0/agentgraph_connector_feed.egg-info/requires.txt +2 -0
- agentgraph_connector_feed-0.1.0/agentgraph_connector_feed.egg-info/top_level.txt +1 -0
- agentgraph_connector_feed-0.1.0/agentgraph_feed_connector/__init__.py +221 -0
- agentgraph_connector_feed-0.1.0/agentgraph_feed_connector/config.py +47 -0
- agentgraph_connector_feed-0.1.0/pyproject.toml +51 -0
- agentgraph_connector_feed-0.1.0/setup.cfg +4 -0
- agentgraph_connector_feed-0.1.0/tests/test_connector.py +191 -0
- agentgraph_connector_feed-0.1.0/tests/test_lint.py +19 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon Wade
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentgraph-connector-feed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared mutation feed connector for AgentGraph
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/simonexmachina/agentgraph-connector-feed
|
|
7
|
+
Project-URL: Repository, https://github.com/simonexmachina/agentgraph-connector-feed
|
|
8
|
+
Project-URL: Issues, https://github.com/simonexmachina/agentgraph-connector-feed/issues
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: agentgraph-server<0.7,>=0.6.1
|
|
13
|
+
Requires-Dist: httpx<1.0,>=0.28
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# AgentGraph Feed Connector
|
|
17
|
+
|
|
18
|
+
The AgentGraph Feed Connector shares local observations, bookmark changes, and explicit
|
|
19
|
+
deletions through a separately deployed AgentGraph feed server.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Install the connector into the same Python environment as AgentGraph:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv pip install agentgraph-connector-feed
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The `0.1.x` connector releases support `agentgraph-server>=0.6.1,<0.7`.
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Configure the feed server base URL, then inspect the saved configuration:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
agentgraph connector feed configure http://localhost:8767
|
|
37
|
+
agentgraph connector feed status
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The configured URL must not include `/events` or `/events-feed`. The connector adds `/events`
|
|
41
|
+
and `/events/tail` when it publishes and polls respectively. For a deployed server behind a
|
|
42
|
+
reverse proxy, configure the URL at which those endpoints are exposed.
|
|
43
|
+
|
|
44
|
+
The connector publishes new local observations, bookmark changes, and explicit deletions as
|
|
45
|
+
best-effort HTTP requests. It polls the feed once per minute. Its first poll starts at the current
|
|
46
|
+
feed tail, so existing events are not imported.
|
|
47
|
+
|
|
48
|
+
The server must expose `POST /events`, `GET /events`, and `GET /events/tail`. The connector and
|
|
49
|
+
server communicate only through this HTTP API; the server is deployed and maintained separately.
|
|
50
|
+
|
|
51
|
+
## Security
|
|
52
|
+
|
|
53
|
+
The feed is unauthenticated and should only be exposed on a trusted network.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
uv sync --locked
|
|
59
|
+
uv run pytest
|
|
60
|
+
uv run pyright
|
|
61
|
+
uv run ruff check .
|
|
62
|
+
uv run ruff format --check .
|
|
63
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# AgentGraph Feed Connector
|
|
2
|
+
|
|
3
|
+
The AgentGraph Feed Connector shares local observations, bookmark changes, and explicit
|
|
4
|
+
deletions through a separately deployed AgentGraph feed server.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Install the connector into the same Python environment as AgentGraph:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
uv pip install agentgraph-connector-feed
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The `0.1.x` connector releases support `agentgraph-server>=0.6.1,<0.7`.
|
|
15
|
+
|
|
16
|
+
## Configuration
|
|
17
|
+
|
|
18
|
+
Configure the feed server base URL, then inspect the saved configuration:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
agentgraph connector feed configure http://localhost:8767
|
|
22
|
+
agentgraph connector feed status
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The configured URL must not include `/events` or `/events-feed`. The connector adds `/events`
|
|
26
|
+
and `/events/tail` when it publishes and polls respectively. For a deployed server behind a
|
|
27
|
+
reverse proxy, configure the URL at which those endpoints are exposed.
|
|
28
|
+
|
|
29
|
+
The connector publishes new local observations, bookmark changes, and explicit deletions as
|
|
30
|
+
best-effort HTTP requests. It polls the feed once per minute. Its first poll starts at the current
|
|
31
|
+
feed tail, so existing events are not imported.
|
|
32
|
+
|
|
33
|
+
The server must expose `POST /events`, `GET /events`, and `GET /events/tail`. The connector and
|
|
34
|
+
server communicate only through this HTTP API; the server is deployed and maintained separately.
|
|
35
|
+
|
|
36
|
+
## Security
|
|
37
|
+
|
|
38
|
+
The feed is unauthenticated and should only be exposed on a trusted network.
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
uv sync --locked
|
|
44
|
+
uv run pytest
|
|
45
|
+
uv run pyright
|
|
46
|
+
uv run ruff check .
|
|
47
|
+
uv run ruff format --check .
|
|
48
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentgraph-connector-feed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared mutation feed connector for AgentGraph
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/simonexmachina/agentgraph-connector-feed
|
|
7
|
+
Project-URL: Repository, https://github.com/simonexmachina/agentgraph-connector-feed
|
|
8
|
+
Project-URL: Issues, https://github.com/simonexmachina/agentgraph-connector-feed/issues
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: agentgraph-server<0.7,>=0.6.1
|
|
13
|
+
Requires-Dist: httpx<1.0,>=0.28
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# AgentGraph Feed Connector
|
|
17
|
+
|
|
18
|
+
The AgentGraph Feed Connector shares local observations, bookmark changes, and explicit
|
|
19
|
+
deletions through a separately deployed AgentGraph feed server.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Install the connector into the same Python environment as AgentGraph:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv pip install agentgraph-connector-feed
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The `0.1.x` connector releases support `agentgraph-server>=0.6.1,<0.7`.
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Configure the feed server base URL, then inspect the saved configuration:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
agentgraph connector feed configure http://localhost:8767
|
|
37
|
+
agentgraph connector feed status
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The configured URL must not include `/events` or `/events-feed`. The connector adds `/events`
|
|
41
|
+
and `/events/tail` when it publishes and polls respectively. For a deployed server behind a
|
|
42
|
+
reverse proxy, configure the URL at which those endpoints are exposed.
|
|
43
|
+
|
|
44
|
+
The connector publishes new local observations, bookmark changes, and explicit deletions as
|
|
45
|
+
best-effort HTTP requests. It polls the feed once per minute. Its first poll starts at the current
|
|
46
|
+
feed tail, so existing events are not imported.
|
|
47
|
+
|
|
48
|
+
The server must expose `POST /events`, `GET /events`, and `GET /events/tail`. The connector and
|
|
49
|
+
server communicate only through this HTTP API; the server is deployed and maintained separately.
|
|
50
|
+
|
|
51
|
+
## Security
|
|
52
|
+
|
|
53
|
+
The feed is unauthenticated and should only be exposed on a trusted network.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
uv sync --locked
|
|
59
|
+
uv run pytest
|
|
60
|
+
uv run pyright
|
|
61
|
+
uv run ruff check .
|
|
62
|
+
uv run ruff format --check .
|
|
63
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
agentgraph_connector_feed.egg-info/PKG-INFO
|
|
5
|
+
agentgraph_connector_feed.egg-info/SOURCES.txt
|
|
6
|
+
agentgraph_connector_feed.egg-info/dependency_links.txt
|
|
7
|
+
agentgraph_connector_feed.egg-info/entry_points.txt
|
|
8
|
+
agentgraph_connector_feed.egg-info/requires.txt
|
|
9
|
+
agentgraph_connector_feed.egg-info/top_level.txt
|
|
10
|
+
agentgraph_feed_connector/__init__.py
|
|
11
|
+
agentgraph_feed_connector/config.py
|
|
12
|
+
tests/test_connector.py
|
|
13
|
+
tests/test_lint.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentgraph_feed_connector
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""AgentGraph connector for a shared observation and bookmark feed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Any, ClassVar, cast
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from agentgraph.connectors.base import EntityBatch, FetchPolicy, ResourceType
|
|
12
|
+
from agentgraph.connectors.feed import (
|
|
13
|
+
FeedConnector,
|
|
14
|
+
MutationEvent,
|
|
15
|
+
suppress_feed_notifications,
|
|
16
|
+
)
|
|
17
|
+
from agentgraph.core.context import get_backend
|
|
18
|
+
from agentgraph.graph.bookmark import bookmark_url, set_entity_bookmark
|
|
19
|
+
from agentgraph.graph.delete import delete_platform_entity
|
|
20
|
+
from agentgraph.server.observation import record_observation
|
|
21
|
+
|
|
22
|
+
from agentgraph_feed_connector.config import (
|
|
23
|
+
FeedConfig,
|
|
24
|
+
load_feed_config,
|
|
25
|
+
save_feed_config,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_HTTP_TIMEOUT = httpx.Timeout(5.0)
|
|
31
|
+
_POLL_LIMIT = 100
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentGraphFeedConnector(FeedConnector):
|
|
35
|
+
source: ClassVar[str] = "feed"
|
|
36
|
+
fetch_policy: ClassVar[FetchPolicy] = FetchPolicy(stale_after_seconds=60)
|
|
37
|
+
poll_interval: ClassVar[timedelta | None] = timedelta(minutes=1)
|
|
38
|
+
appears_in_auth_status: ClassVar[bool] = False
|
|
39
|
+
auth_description: ClassVar[str | None] = (
|
|
40
|
+
"Shares local observations and bookmarks through an AgentGraph feed server."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def can_handle(self, url: str) -> bool:
|
|
44
|
+
_ = url
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
async def fetch(
|
|
48
|
+
self,
|
|
49
|
+
resource_type: ResourceType,
|
|
50
|
+
resource_id: str,
|
|
51
|
+
meta: dict[str, str] | None = None,
|
|
52
|
+
account_id: str | None = None,
|
|
53
|
+
) -> EntityBatch:
|
|
54
|
+
_ = (resource_type, resource_id, meta, account_id)
|
|
55
|
+
return EntityBatch()
|
|
56
|
+
|
|
57
|
+
async def publish_mutation(self, event: MutationEvent) -> None:
|
|
58
|
+
config = load_feed_config()
|
|
59
|
+
if config is None:
|
|
60
|
+
return
|
|
61
|
+
payload = event.model_dump(mode="json")
|
|
62
|
+
payload["origin_id"] = str(config.origin_id)
|
|
63
|
+
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT, trust_env=False) as client:
|
|
64
|
+
response = await client.post(f"{config.feed_url}/events", json=payload)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
|
|
67
|
+
async def poll(
|
|
68
|
+
self,
|
|
69
|
+
cursor: dict[str, Any],
|
|
70
|
+
account_id: str | None = None,
|
|
71
|
+
) -> tuple[EntityBatch, dict[str, Any]]:
|
|
72
|
+
_ = account_id
|
|
73
|
+
config = load_feed_config()
|
|
74
|
+
if config is None:
|
|
75
|
+
return EntityBatch(), cursor
|
|
76
|
+
|
|
77
|
+
logger.info("Polling feed server at %s", config.feed_url)
|
|
78
|
+
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT, trust_env=False) as client:
|
|
79
|
+
if "last_event_id" not in cursor:
|
|
80
|
+
response = await client.get(f"{config.feed_url}/events/tail")
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
tail_payload = cast(dict[str, Any], response.json())
|
|
83
|
+
return EntityBatch(), {"last_event_id": int(tail_payload["cursor"])}
|
|
84
|
+
|
|
85
|
+
since = int(cursor["last_event_id"])
|
|
86
|
+
response = await client.get(
|
|
87
|
+
f"{config.feed_url}/events",
|
|
88
|
+
params={"since": since, "limit": _POLL_LIMIT},
|
|
89
|
+
)
|
|
90
|
+
response.raise_for_status()
|
|
91
|
+
payload = cast(dict[str, Any], response.json())
|
|
92
|
+
events = cast(list[dict[str, Any]], payload.get("events", []))
|
|
93
|
+
|
|
94
|
+
next_cursor = since
|
|
95
|
+
for event in events:
|
|
96
|
+
await _apply_event(event, config)
|
|
97
|
+
next_cursor = int(event["sequence"])
|
|
98
|
+
return EntityBatch(), {"last_event_id": next_cursor}
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def run_cli_command(cls, args: list[str]) -> dict[str, Any]:
|
|
102
|
+
if not args or args[0] in {"--help", "help"}:
|
|
103
|
+
raise ValueError(cls.cli_help())
|
|
104
|
+
if args[0] == "status":
|
|
105
|
+
config = load_feed_config()
|
|
106
|
+
return {
|
|
107
|
+
"configured": config is not None,
|
|
108
|
+
"feed_url": config.feed_url if config else None,
|
|
109
|
+
"origin_id": str(config.origin_id) if config else None,
|
|
110
|
+
}
|
|
111
|
+
if args[0] != "configure":
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Unknown feed connector command {args[0]!r}\n{cls.cli_help()}"
|
|
114
|
+
)
|
|
115
|
+
config = _parse_configure_args(args[1:])
|
|
116
|
+
save_feed_config(config)
|
|
117
|
+
return {
|
|
118
|
+
"configured": True,
|
|
119
|
+
"feed_url": config.feed_url,
|
|
120
|
+
"origin_id": str(config.origin_id),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def cli_help(cls) -> str:
|
|
125
|
+
return (
|
|
126
|
+
"Usage: agentgraph connector feed configure <feed-url> "
|
|
127
|
+
"[--origin-id <uuid>]\n"
|
|
128
|
+
" or: agentgraph connector feed status"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _apply_event(event: dict[str, Any], config: FeedConfig) -> None:
|
|
133
|
+
kind = event.get("kind")
|
|
134
|
+
event_id = str(event["event_id"])
|
|
135
|
+
origin_id = UUID(str(event["origin_id"]))
|
|
136
|
+
target = cast(dict[str, Any], event["target"])
|
|
137
|
+
|
|
138
|
+
if kind == "observation":
|
|
139
|
+
if origin_id == config.origin_id:
|
|
140
|
+
return
|
|
141
|
+
url = target.get("url")
|
|
142
|
+
if not isinstance(url, str) or not url:
|
|
143
|
+
raise ValueError(f"Observation event {event_id} has no target URL")
|
|
144
|
+
meta_value = event.get("meta")
|
|
145
|
+
meta = (
|
|
146
|
+
cast(dict[str, str], meta_value) if isinstance(meta_value, dict) else None
|
|
147
|
+
)
|
|
148
|
+
with suppress_feed_notifications():
|
|
149
|
+
result = await record_observation(
|
|
150
|
+
url=url,
|
|
151
|
+
observation_duration_ms=int(event["observation_duration_ms"]),
|
|
152
|
+
observation_id=event_id,
|
|
153
|
+
observed=True,
|
|
154
|
+
meta=meta,
|
|
155
|
+
)
|
|
156
|
+
if result.get("status") == "error":
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
f"AgentGraph failed to apply observation event {event_id}"
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
platform = str(target["platform"])
|
|
163
|
+
platform_entity_id = str(target["platform_entity_id"])
|
|
164
|
+
with suppress_feed_notifications():
|
|
165
|
+
if kind == "bookmark":
|
|
166
|
+
await _apply_bookmark(
|
|
167
|
+
platform=platform,
|
|
168
|
+
platform_entity_id=platform_entity_id,
|
|
169
|
+
entity_type=str(target["entity_type"]),
|
|
170
|
+
url=target.get("url") if isinstance(target.get("url"), str) else None,
|
|
171
|
+
bookmarked=bool(event["bookmarked"]),
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
if kind == "tombstone":
|
|
175
|
+
await delete_platform_entity(platform, platform_entity_id)
|
|
176
|
+
return
|
|
177
|
+
raise ValueError(f"Unsupported feed event kind {kind!r}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _apply_bookmark(
|
|
181
|
+
*,
|
|
182
|
+
platform: str,
|
|
183
|
+
platform_entity_id: str,
|
|
184
|
+
entity_type: str,
|
|
185
|
+
url: str | None,
|
|
186
|
+
bookmarked: bool,
|
|
187
|
+
) -> None:
|
|
188
|
+
backend = get_backend()
|
|
189
|
+
entity = await backend.get_entity_by_platform(platform, platform_entity_id)
|
|
190
|
+
if entity is None and not bookmarked:
|
|
191
|
+
return
|
|
192
|
+
if entity is None and url is not None:
|
|
193
|
+
try:
|
|
194
|
+
await bookmark_url(url)
|
|
195
|
+
except Exception: # noqa: BLE001 - remote connector failures should create a stub.
|
|
196
|
+
logger.info("Could not fetch remote bookmark %s; creating a stub", url)
|
|
197
|
+
entity = await backend.get_entity_by_platform(platform, platform_entity_id)
|
|
198
|
+
if entity is None:
|
|
199
|
+
entity_id = await backend.upsert_stub_entity(
|
|
200
|
+
entity_type, platform, platform_entity_id
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
entity_id = str(entity["id"])
|
|
204
|
+
await set_entity_bookmark(entity_id, bookmarked)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _parse_configure_args(args: list[str]) -> FeedConfig:
|
|
208
|
+
if not args:
|
|
209
|
+
raise ValueError(AgentGraphFeedConnector.cli_help())
|
|
210
|
+
feed_url = args[0]
|
|
211
|
+
origin_id: UUID | None = None
|
|
212
|
+
index = 1
|
|
213
|
+
while index < len(args):
|
|
214
|
+
if args[index] != "--origin-id" or index + 1 >= len(args):
|
|
215
|
+
raise ValueError(AgentGraphFeedConnector.cli_help())
|
|
216
|
+
origin_id = UUID(args[index + 1])
|
|
217
|
+
index += 2
|
|
218
|
+
return FeedConfig.create(feed_url=feed_url, origin_id=origin_id)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = ["AgentGraphFeedConnector"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Connector-owned feed configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
from uuid import UUID, uuid4
|
|
10
|
+
|
|
11
|
+
from agentgraph.config import get_config_paths
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FeedConfig(BaseModel):
|
|
16
|
+
feed_url: str
|
|
17
|
+
origin_id: UUID
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def create(cls, feed_url: str, origin_id: UUID | None = None) -> FeedConfig:
|
|
21
|
+
normalised_url = feed_url.rstrip("/")
|
|
22
|
+
parsed = urlparse(normalised_url)
|
|
23
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
24
|
+
raise ValueError("Feed URL must be an absolute HTTP(S) URL")
|
|
25
|
+
return cls(feed_url=normalised_url, origin_id=origin_id or uuid4())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def feed_config_path() -> Path:
|
|
29
|
+
return get_config_paths()[0] / "feed.toml"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_feed_config() -> FeedConfig | None:
|
|
33
|
+
path = feed_config_path()
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return None
|
|
36
|
+
with path.open("rb") as handle:
|
|
37
|
+
return FeedConfig.model_validate(tomllib.load(handle))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_feed_config(config: FeedConfig) -> None:
|
|
41
|
+
path = feed_config_path()
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
content = (
|
|
44
|
+
f"feed_url = {json.dumps(config.feed_url)}\n"
|
|
45
|
+
f"origin_id = {json.dumps(str(config.origin_id))}\n"
|
|
46
|
+
)
|
|
47
|
+
path.write_text(content)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agentgraph-connector-feed"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared mutation feed connector for AgentGraph"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"agentgraph-server>=0.6.1,<0.7",
|
|
10
|
+
"httpx>=0.28,<1.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/simonexmachina/agentgraph-connector-feed"
|
|
15
|
+
Repository = "https://github.com/simonexmachina/agentgraph-connector-feed"
|
|
16
|
+
Issues = "https://github.com/simonexmachina/agentgraph-connector-feed/issues"
|
|
17
|
+
|
|
18
|
+
[project.entry-points."agentgraph.connectors"]
|
|
19
|
+
feed = "agentgraph_feed_connector:AgentGraphFeedConnector"
|
|
20
|
+
|
|
21
|
+
[tool.uv]
|
|
22
|
+
package = true
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["setuptools>=77"]
|
|
26
|
+
build-backend = "setuptools.build_meta"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
include = ["agentgraph_feed_connector*"]
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pyright>=1.1.408,<2",
|
|
34
|
+
"pytest>=8,<10",
|
|
35
|
+
"pytest-asyncio>=0.24,<2",
|
|
36
|
+
"ruff>=0.16,<1",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
asyncio_mode = "auto"
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
pythonpath = ["."]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py312"
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "W", "F", "I", "UP", "B", "SIM", "BLE", "RUF", "PYI"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint.isort]
|
|
51
|
+
known-first-party = ["agentgraph_feed_connector"]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, ClassVar, Self
|
|
7
|
+
from unittest.mock import AsyncMock, patch
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from agentgraph.connectors.feed import BookmarkMutation, MutationTarget
|
|
12
|
+
|
|
13
|
+
from agentgraph_feed_connector import AgentGraphFeedConnector, _apply_event
|
|
14
|
+
from agentgraph_feed_connector.config import (
|
|
15
|
+
FeedConfig,
|
|
16
|
+
load_feed_config,
|
|
17
|
+
save_feed_config,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Response:
|
|
22
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
23
|
+
self._payload = payload
|
|
24
|
+
|
|
25
|
+
def raise_for_status(self) -> None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def json(self) -> dict[str, Any]:
|
|
29
|
+
return self._payload
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Client:
|
|
33
|
+
responses: ClassVar[dict[str, dict[str, Any]]] = {}
|
|
34
|
+
posts: ClassVar[list[tuple[str, dict[str, Any]]]] = []
|
|
35
|
+
|
|
36
|
+
def __init__(self, **_: Any) -> None:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> Self:
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, *_: object) -> None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
async def get(self, url: str, **_: Any) -> _Response:
|
|
46
|
+
return _Response(self.responses[url])
|
|
47
|
+
|
|
48
|
+
async def post(self, url: str, json: dict[str, Any]) -> _Response:
|
|
49
|
+
self.posts.append((url, json))
|
|
50
|
+
return _Response({"id": 1, "duplicate": False})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def config() -> FeedConfig:
|
|
55
|
+
return FeedConfig(
|
|
56
|
+
feed_url="https://feed.example.test",
|
|
57
|
+
origin_id=UUID("00000000-0000-0000-0000-000000000001"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_config_round_trip(tmp_path: Path) -> None:
|
|
62
|
+
path = tmp_path / "feed.toml"
|
|
63
|
+
config = FeedConfig.create("https://feed.example.test/")
|
|
64
|
+
|
|
65
|
+
with patch("agentgraph_feed_connector.config.feed_config_path", return_value=path):
|
|
66
|
+
save_feed_config(config)
|
|
67
|
+
loaded = load_feed_config()
|
|
68
|
+
|
|
69
|
+
assert loaded == config
|
|
70
|
+
assert loaded is not None and loaded.feed_url == "https://feed.example.test"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_publish_adds_stable_origin(config: FeedConfig) -> None:
|
|
75
|
+
_Client.posts = []
|
|
76
|
+
event = BookmarkMutation(
|
|
77
|
+
target=MutationTarget(
|
|
78
|
+
platform="web",
|
|
79
|
+
platform_entity_id="https://example.com",
|
|
80
|
+
entity_type="Document",
|
|
81
|
+
resource_type="document",
|
|
82
|
+
url="https://example.com",
|
|
83
|
+
),
|
|
84
|
+
bookmarked=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
with (
|
|
88
|
+
patch("agentgraph_feed_connector.load_feed_config", return_value=config),
|
|
89
|
+
patch("agentgraph_feed_connector.httpx.AsyncClient", _Client),
|
|
90
|
+
):
|
|
91
|
+
await AgentGraphFeedConnector().publish_mutation(event)
|
|
92
|
+
|
|
93
|
+
assert _Client.posts[0][0] == "https://feed.example.test/events"
|
|
94
|
+
assert _Client.posts[0][1]["origin_id"] == str(config.origin_id)
|
|
95
|
+
assert _Client.posts[0][1]["kind"] == "bookmark"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.asyncio
|
|
99
|
+
async def test_first_poll_starts_at_feed_tail(
|
|
100
|
+
config: FeedConfig, caplog: pytest.LogCaptureFixture
|
|
101
|
+
) -> None:
|
|
102
|
+
_Client.responses = {"https://feed.example.test/events/tail": {"cursor": 42}}
|
|
103
|
+
|
|
104
|
+
caplog.set_level(logging.INFO, logger="agentgraph_feed_connector")
|
|
105
|
+
with (
|
|
106
|
+
patch("agentgraph_feed_connector.load_feed_config", return_value=config),
|
|
107
|
+
patch("agentgraph_feed_connector.httpx.AsyncClient", _Client),
|
|
108
|
+
):
|
|
109
|
+
batch, cursor = await AgentGraphFeedConnector().poll({})
|
|
110
|
+
|
|
111
|
+
assert batch.entities == []
|
|
112
|
+
assert cursor == {"last_event_id": 42}
|
|
113
|
+
assert "Polling feed server at https://feed.example.test" in caplog.messages
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_remote_observation_reuses_server_handler(config: FeedConfig) -> None:
|
|
118
|
+
event_id = uuid4()
|
|
119
|
+
event = {
|
|
120
|
+
"sequence": 7,
|
|
121
|
+
"event_id": str(event_id),
|
|
122
|
+
"origin_id": str(uuid4()),
|
|
123
|
+
"occurred_at": datetime.now(UTC).isoformat(),
|
|
124
|
+
"kind": "observation",
|
|
125
|
+
"target": {
|
|
126
|
+
"platform": "web",
|
|
127
|
+
"platform_entity_id": "https://example.com",
|
|
128
|
+
"entity_type": "Document",
|
|
129
|
+
"resource_type": "document",
|
|
130
|
+
"url": "https://example.com",
|
|
131
|
+
},
|
|
132
|
+
"observation_duration_ms": 3000,
|
|
133
|
+
"meta": {"source": "shared"},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
with patch(
|
|
137
|
+
"agentgraph_feed_connector.record_observation",
|
|
138
|
+
new=AsyncMock(return_value={"status": "accepted"}),
|
|
139
|
+
) as record:
|
|
140
|
+
await _apply_event(event, config)
|
|
141
|
+
|
|
142
|
+
record.assert_awaited_once_with(
|
|
143
|
+
url="https://example.com",
|
|
144
|
+
observation_duration_ms=3000,
|
|
145
|
+
observation_id=str(event_id),
|
|
146
|
+
observed=True,
|
|
147
|
+
meta={"source": "shared"},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.asyncio
|
|
152
|
+
async def test_self_observation_is_not_applied(config: FeedConfig) -> None:
|
|
153
|
+
event = {
|
|
154
|
+
"sequence": 8,
|
|
155
|
+
"event_id": str(uuid4()),
|
|
156
|
+
"origin_id": str(config.origin_id),
|
|
157
|
+
"kind": "observation",
|
|
158
|
+
"target": {"url": "https://example.com"},
|
|
159
|
+
"observation_duration_ms": 3000,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
with patch(
|
|
163
|
+
"agentgraph_feed_connector.record_observation", new=AsyncMock()
|
|
164
|
+
) as record:
|
|
165
|
+
await _apply_event(event, config)
|
|
166
|
+
|
|
167
|
+
record.assert_not_awaited()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_own_bookmark_is_replayed_for_feed_order(config: FeedConfig) -> None:
|
|
172
|
+
event = {
|
|
173
|
+
"sequence": 9,
|
|
174
|
+
"event_id": str(uuid4()),
|
|
175
|
+
"origin_id": str(config.origin_id),
|
|
176
|
+
"kind": "bookmark",
|
|
177
|
+
"target": {
|
|
178
|
+
"platform": "web",
|
|
179
|
+
"platform_entity_id": "https://example.com",
|
|
180
|
+
"entity_type": "Document",
|
|
181
|
+
"url": "https://example.com",
|
|
182
|
+
},
|
|
183
|
+
"bookmarked": False,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
with patch(
|
|
187
|
+
"agentgraph_feed_connector._apply_bookmark", new=AsyncMock()
|
|
188
|
+
) as apply_bookmark:
|
|
189
|
+
await _apply_event(event, config)
|
|
190
|
+
|
|
191
|
+
apply_bookmark.assert_awaited_once()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
PROJECT_ROOT = Path(__file__).parents[1]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_ruff() -> None:
|
|
11
|
+
for command in (("check", "."), ("format", "--check", ".")):
|
|
12
|
+
result = subprocess.run(
|
|
13
|
+
[sys.executable, "-m", "ruff", *command],
|
|
14
|
+
cwd=PROJECT_ROOT,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
check=False,
|
|
17
|
+
text=True,
|
|
18
|
+
)
|
|
19
|
+
assert result.returncode == 0, result.stdout + result.stderr
|