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,58 @@
1
+ """Embedding model: load once, encode on demand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from functools import lru_cache
7
+ from threading import Lock
8
+ from typing import Any, cast
9
+
10
+ import numpy as np
11
+ from fastembed import TextEmbedding
12
+ from numpy.typing import NDArray
13
+
14
+ from agentgraph.config import get_settings
15
+ from agentgraph.perf import timed
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # FastEmbed shares one ONNX session across the process. Concurrent inference on
20
+ # that session can stall, so all callers use the same process-wide lock.
21
+ _model_lock = Lock()
22
+
23
+
24
+ @lru_cache(maxsize=1)
25
+ def _get_model() -> TextEmbedding:
26
+ settings = get_settings()
27
+ logger.info("Loading embedding model: %s", settings.embedding_model)
28
+ return TextEmbedding(model_name=settings.embedding_model)
29
+
30
+
31
+ def _normalise(vec: NDArray[Any]) -> list[float]:
32
+ norm = float(np.linalg.norm(vec))
33
+ if norm > 0:
34
+ vec = vec / norm
35
+ return [float(value) for value in vec.tolist()]
36
+
37
+
38
+ def encode_passage(text: str) -> list[float]:
39
+ """Return a normalized passage embedding vector for indexed content."""
40
+ with timed("embedding.passage", characters=len(text)):
41
+ with _model_lock:
42
+ model = _get_model()
43
+ vec = cast(NDArray[Any], next(iter(model.passage_embed([text]))))
44
+ return _normalise(vec)
45
+
46
+
47
+ def encode_query(text: str) -> list[float]:
48
+ """Return a normalized query embedding vector for search text."""
49
+ with timed("embedding.query", characters=len(text)):
50
+ with _model_lock:
51
+ model = _get_model()
52
+ vec = cast(NDArray[Any], next(iter(model.query_embed(text))))
53
+ return _normalise(vec)
54
+
55
+
56
+ def encode(text: str) -> list[float]:
57
+ """Return a normalized passage embedding vector for backwards compatibility."""
58
+ return encode_passage(text)
@@ -0,0 +1,53 @@
1
+ """Connector fetch trigger — shared by CLI, MCP, and server API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from agentgraph.core.context import get_backend
8
+
9
+
10
+ async def fetch_entity(platform: str, resource_id: str) -> dict[str, Any]:
11
+ """Trigger a connector fetch for a known platform entity.
12
+
13
+ Resolves the entity type from the backend (falling back to Document), resets
14
+ synced_at so the connector treats the entity as stale, and runs a targeted
15
+ fetch. Returns counts of ingested entities/persons/edges.
16
+ """
17
+ from agentgraph.connectors.registry import get_connector
18
+ from agentgraph.graph.upsert import upsert_batch
19
+
20
+ connector = get_connector(platform)
21
+ if connector is None:
22
+ raise ValueError(f"No connector registered for platform '{platform}'")
23
+
24
+ backend = get_backend()
25
+ entity = await backend.get_entity_by_platform(platform, resource_id)
26
+ raw_entity_type = (entity or {}).get("entity_type") or "Document"
27
+ entity_type = str(raw_entity_type)
28
+ entity_meta = (entity or {}).get("metadata")
29
+ meta = cast(dict[str, Any], entity_meta) if isinstance(entity_meta, dict) else None
30
+
31
+ resource_id, resource_type = connector.normalise_fetch_id(resource_id, entity_type)
32
+
33
+ await backend.reset_synced_at(platform, resource_id)
34
+
35
+ batch = await connector.fetch(resource_type=resource_type, resource_id=resource_id, meta=meta)
36
+ await upsert_batch(batch)
37
+ return {
38
+ "entities": len(batch.entities),
39
+ "persons": len(batch.persons),
40
+ "edges": len(batch.edges),
41
+ }
42
+
43
+
44
+ async def fetch_entity_by_id(entity_id: str) -> dict[str, Any]:
45
+ """Trigger a connector fetch for an entity identified by its internal UUID.
46
+
47
+ Looks up platform and platform_entity_id from the backend, then delegates to
48
+ fetch_entity. Raises ValueError if the entity is not found.
49
+ """
50
+ ref = await get_backend().get_entity_platform_ref(entity_id)
51
+ if ref is None:
52
+ raise ValueError(f"Entity not found: {entity_id}")
53
+ return await fetch_entity(platform=ref[0], resource_id=ref[1])
agentgraph/graph/gc.py ADDED
@@ -0,0 +1,26 @@
1
+ """Garbage collection: remove entities not accessed within retention window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from agentgraph.config import get_settings
8
+ from agentgraph.core.context import get_backend
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ async def run_gc() -> int:
14
+ """
15
+ Delete entities where last_accessed < now() - retention_days.
16
+ Edges are removed via ON DELETE CASCADE.
17
+ Returns the total number of rows deleted.
18
+ """
19
+ settings = get_settings()
20
+ total = await get_backend().gc_entities(settings.retention_days)
21
+ logger.info(
22
+ "GC complete: removed %d entities (retention=%d days)",
23
+ total,
24
+ settings.retention_days,
25
+ )
26
+ return total
@@ -0,0 +1,63 @@
1
+ """Cross-platform URL reference linking.
2
+
3
+ Creates 'references' edges from a newly ingested entity to any known entities
4
+ it links to via URL in its content.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import re
11
+
12
+ from agentgraph.connectors.base import RESOURCE_TYPE_TO_ENTITY_TYPE, SourceReference
13
+ from agentgraph.core.context import get_backend
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _URL_RE = re.compile(r"https?://\S+")
18
+
19
+
20
+ async def link_entity_to_urls(platform_entity_id: str, platform: str, content: str) -> int:
21
+ """Create 'references' edges from an entity to any known entities it links to.
22
+
23
+ Extracts all URLs from content, classifies each via the router, and creates
24
+ an edge to any matching entity already in the graph.
25
+ Returns the number of edges created.
26
+ """
27
+ from agentgraph.server.router import classify_url
28
+
29
+ refs: list[SourceReference] = []
30
+ for url in _URL_RE.findall(content):
31
+ ref = classify_url(url.rstrip(".,)>\"'"))
32
+ if ref:
33
+ refs.append(ref)
34
+
35
+ if not refs:
36
+ return 0
37
+
38
+ backend = get_backend()
39
+ src_id = await backend.find_entity_id(platform, platform_entity_id)
40
+ if not src_id:
41
+ return 0
42
+
43
+ count = 0
44
+ seen: set[str] = set()
45
+ for ref in refs:
46
+ if ref.resource_id in seen:
47
+ continue
48
+ seen.add(ref.resource_id)
49
+
50
+ tgt_id = await backend.find_entity_id(ref.source, ref.resource_id)
51
+ if not tgt_id:
52
+ entity_type = RESOURCE_TYPE_TO_ENTITY_TYPE[ref.resource_type]
53
+ tgt_id = await backend.upsert_stub_entity(entity_type, ref.source, ref.resource_id)
54
+ logger.debug("Created stub entity %s/%s", ref.source, ref.resource_id)
55
+
56
+ await backend.insert_references_edge(src_id, tgt_id)
57
+ logger.debug(
58
+ "Linked %s/%s → %s/%s",
59
+ platform, platform_entity_id, ref.source, ref.resource_id,
60
+ )
61
+ count += 1
62
+
63
+ return count
@@ -0,0 +1,40 @@
1
+ """Person identity operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
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 unify_persons(
13
+ primary_entity_id: str,
14
+ duplicate_entity_ids: list[str],
15
+ ) -> dict[str, Any]:
16
+ """Merge duplicate Person entities into one canonical Person entity."""
17
+ primary = await _resolve_person(primary_entity_id)
18
+ duplicate_entities = [
19
+ await _resolve_person(entity_id) for entity_id in duplicate_entity_ids
20
+ ]
21
+ duplicate_ids = [
22
+ entity["id"]
23
+ for entity in duplicate_entities
24
+ if entity["id"] != primary["id"]
25
+ ]
26
+ updated = await get_backend().merge_person_entities(primary["id"], duplicate_ids)
27
+ return {
28
+ "primary": updated,
29
+ "merged_ids": duplicate_ids,
30
+ "merged_count": len(duplicate_ids),
31
+ }
32
+
33
+
34
+ async def _resolve_person(entity_id: str) -> EntityResult:
35
+ entity = await get_entity(entity_id)
36
+ if entity is None:
37
+ raise ValueError(f"Entity {entity_id!r} not found")
38
+ if entity["entity_type"] != "Person":
39
+ raise ValueError(f"Entity {entity_id!r} is not a Person")
40
+ return entity
@@ -0,0 +1,244 @@
1
+ """Shared graph query layer used by both MCP tools and CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from datetime import UTC, datetime, timedelta
8
+ from functools import lru_cache
9
+ from typing import Any, cast
10
+ from urllib.parse import urlparse
11
+
12
+ from agentgraph.core.context import get_backend
13
+ from agentgraph.core.storage import EdgeResult, EntityResult
14
+
15
+
16
+ @lru_cache(maxsize=256)
17
+ def _cached_query_embedding(query: str) -> tuple[float, ...]:
18
+ from agentgraph.graph.embeddings import encode_query
19
+
20
+ return tuple(encode_query(query))
21
+
22
+
23
+ def clear_query_embedding_cache() -> None:
24
+ _cached_query_embedding.cache_clear()
25
+
26
+
27
+ def _enrich_web_url(entities: list[EntityResult]) -> None:
28
+ """Populate metadata.web_url from the connector for entities that don't store it."""
29
+ from agentgraph.connectors.registry import get_connector
30
+
31
+ connectors: dict[str, Any] = {}
32
+ for entity in entities:
33
+ meta = entity.get("metadata")
34
+ metadata = cast(dict[str, Any], meta) if isinstance(meta, dict) else None
35
+ if metadata is not None and metadata.get("web_url"):
36
+ continue
37
+ platform = entity.get("platform")
38
+ if not isinstance(platform, str):
39
+ continue
40
+ if platform not in connectors:
41
+ connectors[platform] = get_connector(platform)
42
+ connector = connectors[platform]
43
+ if connector is None:
44
+ continue
45
+ platform_entity_id = entity.get("platform_entity_id")
46
+ url = connector.entity_url(platform_entity_id if isinstance(platform_entity_id, str) else "")
47
+ if url:
48
+ if metadata is None:
49
+ entity["metadata"] = {"web_url": url}
50
+ else:
51
+ metadata["web_url"] = url
52
+
53
+
54
+ async def search_entities(
55
+ query: str,
56
+ entity_types: list[str] | None = None,
57
+ limit: int = 10,
58
+ min_score: float = 0.03,
59
+ platform: str | None = None,
60
+ ) -> list[EntityResult]:
61
+ """Hybrid search: combines vector similarity with full-text via RRF."""
62
+ embedding = list(await asyncio.to_thread(_cached_query_embedding, query))
63
+ backend = get_backend()
64
+ results = await backend.search_entities(
65
+ embedding, query, entity_types, limit, min_score, platform=platform
66
+ )
67
+ _enrich_web_url(results)
68
+ if results:
69
+ ids = [r["id"] for r in results]
70
+ asyncio.create_task(backend.touch_last_accessed_by_ids(ids))
71
+ return results
72
+
73
+
74
+ async def get_entity(entity_id: str) -> EntityResult | None:
75
+ """Fetch a single entity by UUID, unambiguous UUID prefix, or platform ref.
76
+
77
+ Platform ref formats accepted:
78
+ - ``"{platform}/{platform_entity_id}"``
79
+ - ``"{platform}/{resource_type}/{platform_entity_id}"`` (resource_type ignored)
80
+ """
81
+ backend = get_backend()
82
+ entity: EntityResult | None
83
+ if len(entity_id) == 36 or (len(entity_id) == 32 and "-" not in entity_id):
84
+ entity = await backend.get_entity_by_id(entity_id)
85
+ elif "/" in entity_id:
86
+ parts = entity_id.split("/")
87
+ platform = parts[0]
88
+ pid = "/".join(parts[2:]) if len(parts) >= 3 else "/".join(parts[1:])
89
+ entity = await backend.get_entity_by_platform(platform, pid)
90
+ else:
91
+ # UUID prefix — must be unambiguous
92
+ results = await backend.get_entities_by_id_prefix(entity_id)
93
+ if len(results) > 1:
94
+ raise ValueError(
95
+ f"Ambiguous prefix {entity_id!r} matches {len(results)} entities"
96
+ )
97
+ entity = results[0] if results else None
98
+ if entity is not None:
99
+ _enrich_web_url([entity])
100
+ return entity
101
+
102
+
103
+ async def get_entity_by_url(url: str) -> EntityResult | None:
104
+ """Fetch a single existing entity by URL without fetching or creating it."""
105
+ from agentgraph.connectors.registry import bootstrap, get_connector
106
+ from agentgraph.server.router import classify_url, normalise_url_for_matching
107
+
108
+ normalised_url = normalise_url_for_matching(url)
109
+ bootstrap()
110
+ ref = classify_url(normalised_url)
111
+ backend = get_backend()
112
+
113
+ if ref is not None:
114
+ entity = await backend.get_entity_by_platform(ref.source, ref.resource_id)
115
+ if entity is not None:
116
+ _enrich_web_url([entity])
117
+ return entity
118
+
119
+ connector = get_connector("web")
120
+ web_ref = connector.resolve_url(normalised_url) if connector is not None else None
121
+ if web_ref is None:
122
+ return None
123
+
124
+ entity = await backend.get_entity_by_platform(web_ref.source, web_ref.resource_id)
125
+ if entity is None:
126
+ entity = await _get_web_entity_by_metadata_url(normalised_url)
127
+ if entity is not None:
128
+ _enrich_web_url([entity])
129
+ return entity
130
+
131
+
132
+ async def get_edges(
133
+ entity_id: str,
134
+ edge_type: str | None = None,
135
+ direction: str = "both",
136
+ ) -> list[EdgeResult]:
137
+ return await get_backend().get_edges(entity_id, edge_type, direction)
138
+
139
+
140
+ async def traverse_graph(
141
+ entity_id: str,
142
+ max_depth: int = 2,
143
+ ) -> dict[str, Any]:
144
+ return await get_backend().traverse_graph(entity_id, max_depth)
145
+
146
+
147
+ async def query_by_filter(
148
+ entity_type: str,
149
+ filters: dict[str, str],
150
+ limit: int = 50,
151
+ order_by: str = "last_accessed",
152
+ since: str | None = None,
153
+ authored_by_me: bool = False,
154
+ has_attachments: bool = False,
155
+ ) -> list[EntityResult]:
156
+ since_dt = parse_since(since) if since else None
157
+ authored_by: list[str] | None = _resolve_me() if authored_by_me else None
158
+ results = await get_backend().query_by_filter(
159
+ entity_type, filters, limit, order_by, since_dt, authored_by,
160
+ has_attachments=has_attachments,
161
+ )
162
+ _enrich_web_url(results)
163
+ return results
164
+
165
+
166
+ async def list_entities(
167
+ entity_types: list[str] | None = None,
168
+ platform: str | None = None,
169
+ since: str | None = None,
170
+ limit: int = 50,
171
+ ) -> list[EntityResult]:
172
+ since_dt = parse_since(since) if since else None
173
+ results = await get_backend().list_entities(entity_types, platform, since_dt, limit)
174
+ _enrich_web_url(results)
175
+ return results
176
+
177
+
178
+ async def list_entities_page(
179
+ entity_types: list[str] | None = None,
180
+ platform: str | None = None,
181
+ since: str | None = None,
182
+ limit: int = 50,
183
+ offset: int = 0,
184
+ order_by: str | None = "last_accessed",
185
+ order_dir: str = "desc",
186
+ ) -> tuple[list[EntityResult], int]:
187
+ since_dt = parse_since(since) if since else None
188
+ results, total = await get_backend().list_entities_page(
189
+ entity_types, platform, since_dt, limit, offset, order_by, order_dir
190
+ )
191
+ _enrich_web_url(results)
192
+ return results, total
193
+
194
+
195
+ async def get_edges_for_entities(entity_ids: list[str]) -> list[EdgeResult]:
196
+ return await get_backend().get_edges_for_entities(entity_ids)
197
+
198
+
199
+ async def get_entities_by_ids(entity_ids: list[str]) -> list[EntityResult]:
200
+ results = await get_backend().get_entities_by_ids(entity_ids)
201
+ _enrich_web_url(results)
202
+ return results
203
+
204
+
205
+ def _resolve_me() -> list[str] | None:
206
+ """Return the current user's canonical identifiers by polling registered connectors."""
207
+ from agentgraph.connectors.registry import get_all_connectors
208
+ user_ids: list[str] = []
209
+ for connector in get_all_connectors():
210
+ for user_id in type(connector).current_user_ids():
211
+ if user_id not in user_ids:
212
+ user_ids.append(user_id)
213
+ return user_ids or None
214
+
215
+
216
+ async def _get_web_entity_by_metadata_url(url: str) -> EntityResult | None:
217
+ backend = get_backend()
218
+ for key in ("url", "final_url"):
219
+ results = await backend.query_by_filter(
220
+ "Document",
221
+ {"platform": "web", key: url},
222
+ 1,
223
+ "updated_at",
224
+ None,
225
+ None,
226
+ )
227
+ if results:
228
+ return results[0]
229
+ return None
230
+
231
+
232
+ def is_http_url(target: str) -> bool:
233
+ parsed = urlparse(target)
234
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
235
+
236
+
237
+ def parse_since(since: str) -> datetime:
238
+ """Parse a relative duration (12h, 30m, 2d) or ISO timestamp string."""
239
+ m = re.fullmatch(r"(\d+)(h|m|d)", since.strip())
240
+ if m:
241
+ n, unit = int(m.group(1)), m.group(2)
242
+ delta = {"h": timedelta(hours=n), "m": timedelta(minutes=n), "d": timedelta(days=n)}[unit]
243
+ return datetime.now(UTC) - delta
244
+ return datetime.fromisoformat(since)
@@ -0,0 +1,49 @@
1
+ """Idempotent upsert layer for entities and edges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from agentgraph.connectors.base import EntityBatch
8
+ from agentgraph.core.context import get_backend
9
+
10
+
11
+ async def upsert_batch(batch: EntityBatch) -> None:
12
+ """Persist an EntityBatch to the graph, generating embeddings as needed."""
13
+ person_embeddings, entity_embeddings = await asyncio.to_thread(_build_embeddings, batch)
14
+
15
+ await get_backend().upsert_batch(batch, person_embeddings, entity_embeddings)
16
+ await _link_references(batch)
17
+
18
+
19
+ def _build_embeddings(
20
+ batch: EntityBatch,
21
+ ) -> tuple[dict[str, list[float] | None], dict[str, list[float] | None]]:
22
+ from agentgraph.graph.embeddings import encode_passage
23
+
24
+ person_embeddings: dict[str, list[float] | None] = {}
25
+ for p in batch.persons:
26
+ canonical_key = p.canonical_email or f"{p.platform}:{p.platform_user_id}"
27
+ text = " ".join(filter(None, [p.display_name, p.canonical_email]))
28
+ vec: list[float] | None = encode_passage(text) if text else None
29
+ person_embeddings[canonical_key] = vec
30
+ person_embeddings[p.platform_user_id] = vec # also indexed by user_id for edge resolution
31
+
32
+ entity_embeddings: dict[str, list[float] | None] = {}
33
+ for e in batch.entities:
34
+ if not e.is_stub and e.content:
35
+ text = f"{e.title or ''} {e.content}".strip()
36
+ entity_embeddings[e.platform_entity_id] = encode_passage(text)
37
+ else:
38
+ entity_embeddings[e.platform_entity_id] = None
39
+
40
+ return person_embeddings, entity_embeddings
41
+
42
+
43
+ async def _link_references(batch: EntityBatch) -> None:
44
+ """After a batch is persisted, create cross-platform 'references' edges."""
45
+ from agentgraph.graph.link import link_entity_to_urls
46
+
47
+ for entity in batch.entities:
48
+ if entity.content:
49
+ await link_entity_to_urls(entity.platform_entity_id, entity.platform, entity.content)
agentgraph/logging.py ADDED
@@ -0,0 +1,78 @@
1
+ """Logging setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from logging.handlers import RotatingFileHandler
8
+ from pathlib import Path
9
+
10
+ MAX_LOG_BYTES = 1024 * 1024
11
+ LOG_BACKUP_COUNT = 7
12
+ _AGENTGRAPH_HANDLER = "_agentgraph_handler"
13
+
14
+
15
+ def configure_logging(level: str = "INFO", log_file: str | Path | None = None) -> None:
16
+ log_level = getattr(logging, level.upper(), logging.INFO)
17
+
18
+ # Force-configure the root logger, overriding any handlers uvicorn already added.
19
+ root = logging.getLogger()
20
+ root.setLevel(log_level)
21
+
22
+ if log_file is not None:
23
+ path = Path(log_file).expanduser()
24
+ path.parent.mkdir(parents=True, exist_ok=True)
25
+ candidate_handler = next(
26
+ (candidate for candidate in root.handlers if getattr(candidate, _AGENTGRAPH_HANDLER, False)),
27
+ None,
28
+ )
29
+ handler = candidate_handler if isinstance(candidate_handler, RotatingFileHandler) else None
30
+ if handler is None:
31
+ handler = RotatingFileHandler(
32
+ path,
33
+ maxBytes=MAX_LOG_BYTES,
34
+ backupCount=LOG_BACKUP_COUNT,
35
+ encoding="utf-8",
36
+ )
37
+ setattr(handler, _AGENTGRAPH_HANDLER, True)
38
+ root.addHandler(handler)
39
+ elif Path(handler.baseFilename) != path:
40
+ root.removeHandler(handler)
41
+ handler.close()
42
+ handler = RotatingFileHandler(
43
+ path,
44
+ maxBytes=MAX_LOG_BYTES,
45
+ backupCount=LOG_BACKUP_COUNT,
46
+ encoding="utf-8",
47
+ )
48
+ setattr(handler, _AGENTGRAPH_HANDLER, True)
49
+ root.addHandler(handler)
50
+ handler.setLevel(log_level)
51
+ handler.setFormatter(
52
+ logging.Formatter(
53
+ fmt="%(asctime)s %(levelname)-8s %(name)s %(message)s",
54
+ datefmt="%H:%M:%S",
55
+ )
56
+ )
57
+ elif not root.handlers:
58
+ handler = logging.StreamHandler(sys.stdout)
59
+ handler.setFormatter(
60
+ logging.Formatter(
61
+ fmt="%(asctime)s %(levelname)-8s %(name)s %(message)s",
62
+ datefmt="%H:%M:%S",
63
+ )
64
+ )
65
+ root.addHandler(handler)
66
+ else:
67
+ for handler in root.handlers:
68
+ handler.setLevel(log_level)
69
+
70
+ # Quiet noisy third-party loggers regardless of level
71
+ for noisy in (
72
+ "httpx",
73
+ "httpcore",
74
+ "fastembed",
75
+ "uvicorn.access",
76
+ "googleapiclient.discovery_cache",
77
+ ):
78
+ logging.getLogger(noisy).setLevel(logging.WARNING)
File without changes