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.
@@ -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,2 @@
1
+ [agentgraph.connectors]
2
+ feed = agentgraph_feed_connector:AgentGraphFeedConnector
@@ -0,0 +1,2 @@
1
+ agentgraph-server<0.7,>=0.6.1
2
+ httpx<1.0,>=0.28
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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