Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,4 @@ jobs:
python -m pytest -v
-m integration
tests/test_integration.py
tests/test_tools_integration.py
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

Agentic GraphRAG: a framework-neutral agent toolkit
(`graphrag_sdk.tools`) so any agent framework — pydantic-ai, LangGraph,
CrewAI, MCP — can expose a knowledge graph as tools. Purely additive;
no breaking changes.

### Added

#### Agentic GraphRAG toolkit (`graphrag_sdk.tools`)

- **`GraphRAGToolkit`** — framework-neutral async agent surface over
`GraphRAG`: `search()`, `answer()`, `schema()`, `entity()`, guarded
`cypher_read()`, `remember()`, `flush()`; knobs `finalize_policy=`
(`"manual"`/`"on_write"`/`"never"`), `read_only=`, `include=`; generic
`call(name, arguments)` dispatch for adapters; `for_tenant(...)` for
tenant-scoped graphs with owned-connection lifecycle (`async with`).
- **`GraphRAGToolkit.tool_specs()`** — machine-readable tool definitions
(name, LLM-ready description, JSON-Schema input, output hint); the
single source of truth for agent-framework adapters and MCP servers.
- **Typed tool results** — pydantic models (`SearchResult`,
`AnswerResult`, `SchemaResult`, `CypherResult`, `EntityResult`,
`RememberResult`) with deterministic, budget-bounded
`to_llm_text(max_chars=...)` rendering: truncation only at item
boundaries with an explicit `…(N more)` marker, control characters
stripped. Answers carry `document_id`/`chunk_id` citations.
- **`ReadOnlyViolation`** — raised by the fail-closed Cypher guard
(write keywords, non-allowlisted procedures, multi-statement; lexer-
based string/comment handling, NFKC-normalized scan) and by write
tools on `read_only=True` toolkits.
- **`MultiPathRetrieval` provenance** — retrieval results now expose
`metadata["provenance"]` (entity ids, kept chunk ids/texts/document
paths in rerank order, fact and relationship strings); section
content is byte-unchanged.
- New docs page `docs/agentic.md`; example
`examples/11_agent_toolkit.py`; `jsonschema` added to the dev extra.

### Fixed

- **`graphrag_sdk.__version__`** — was stale at `"1.2.0"`; now matches
the packaged version and is guarded by a pyproject drift test (the
value is stamped into graphs as `__GraphRAGConfig__.sdk_version`).

## [1.3.0] - 2026-06-04

Ontology discovery (#271): bootstrap an ontology straight from a
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,24 @@ guards the default.
| Retrieval | Relationship expansion | DB |
| Retrieval | Cosine reranking | Local |

> 💡 Every answer is traceable to its source chunks via `MENTIONS` edges. Pass `return_context=True` to `completion()` to get the retrieval trail alongside the answer.
> 💡 Every answer is traceable to its source chunks via `MENTIONED_IN` edges. Pass `return_context=True` to `completion()` to get the retrieval trail alongside the answer.

---

## Use with Agents

`graphrag_sdk.tools` turns any `GraphRAG` into a set of agent tools — typed results with `document_id`/`chunk_id` citations, a guarded read-only Cypher escape hatch, and machine-readable `tool_specs()` that adapters (pydantic-ai, LangGraph, MCP) generate their tool definitions from:

```python
from graphrag_sdk.tools import GraphRAGToolkit

toolkit = GraphRAGToolkit(rag) # wrap any GraphRAG
result = await toolkit.search("Who works at Acme?") # typed, citation-ready
print(result.to_llm_text()) # prompt-ready rendering
specs = toolkit.tool_specs() # JSON-Schema tool definitions
```

See the [Agentic GraphRAG guide](docs/agentic.md) and [`examples/11_agent_toolkit.py`](graphrag_sdk/examples/11_agent_toolkit.py).

---

Expand All @@ -208,6 +225,7 @@ guards the default.
| 4 | [Custom Provider](graphrag_sdk/examples/04_custom_provider.py) | Plug in any LLM or embedder behind a clean interface |
| 5 | [Notebook Demo](graphrag_sdk/examples/05_notebook_demo.ipynb) | An interactive walkthrough that shows the provenance trail |
| 7 | [Incremental Updates](graphrag_sdk/examples/07_incremental_updates.py) | `update`, `delete_document`, and `apply_changes` for CI-driven graph syncs |
| 11 | [Agent Toolkit](graphrag_sdk/examples/11_agent_toolkit.py) | Expose your graph as agent tools with citations and guarded Cypher |

---

Expand All @@ -233,7 +251,7 @@ guards the default.
- 🎉 2026-04: Version 1.0 is released with a new set of benchmarks based on a year's worth of research and customer PoCs
- 📦 Still on the v0.x API? Pin the legacy release: `pip install graphrag-sdk==0.8.2`
- 2026-Q2: Production observability; expand ingestion support — tables, structured data
- 2026-Q3: Introduce Agentic GraphRAG; complete PDF ingestion
- 2026-Q3: Introduce Agentic GraphRAG (✅ `graphrag_sdk.tools` agent toolkit); complete PDF ingestion
- 2026-Q4: Smarter retrieval — dynamic traversal, temporal graph

---
Expand Down
168 changes: 168 additions & 0 deletions docs/agentic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Agentic GraphRAG

`graphrag_sdk.tools` gives any agent framework — pydantic-ai, LangGraph, CrewAI,
MCP clients — a small, stable set of async operations over a knowledge graph.
Every operation returns a typed pydantic model that renders itself into
compact, deterministic plain text for LLM consumption, and the whole surface
is described by machine-readable [`tool_specs()`](#tool_specs-for-adapter-authors)
so adapters never hand-copy names, descriptions, or schemas.

## Quickstart

```python
import asyncio

from graphrag_sdk import ConnectionConfig, GraphRAG, LiteLLM, LiteLLMEmbedder
from graphrag_sdk.tools import GraphRAGToolkit


async def main():
rag = GraphRAG(
connection=ConnectionConfig(host="localhost", graph_name="agent_demo"),
llm=LiteLLM(model="openai/gpt-5.5"),
embedder=LiteLLMEmbedder(model="openai/text-embedding-3-large", dimensions=256),
embedding_dimension=256,
)
toolkit = GraphRAGToolkit(rag) # finalize_policy="manual" by default

await toolkit.remember("Alice is a software engineer at Acme Corp.")
await toolkit.flush() # dedup + embeddings + indexes (expensive — see policies)

result = await toolkit.search("Who works at Acme?")
print(result.to_llm_text()) # prompt-ready text with ids for citations
print(result.model_dump_json()) # or full structured output

answer = await toolkit.answer("Who works at Acme?")
print(answer.answer, answer.citations)

await rag.close()


asyncio.run(main())
```

## Tools

| Tool name | Method | When to use |
|---|---|---|
| `graph_search` | `search(query, *, top_k=8, expand_hops=1, include_chunks=True)` | Retrieval only — ranked entities, relations, facts, and source chunks the host LLM composes from. The default agent mode; cite with the returned `document_id`/`chunk_id`. |
| `graph_answer` | `answer(question, *, top_k=8)` | Full RAG: the SDK's `completion()` pipeline plus provenance citations. One-shot Q&A. |
| `graph_schema` | `schema()` | Entity labels, relation types, directional patterns, and live counts. Call once up front to plan queries. |
| `graph_entity` | `entity(name, *, hops=1)` | Entity card: best name match, properties, neighbors up to `hops`, source documents, and "nearby" alternative matches. |
| `cypher_read` | `cypher_read(query, params=None, *, limit=100, timeout_ms=5000)` | Guarded read-only Cypher for aggregations and precise filters search cannot express. |
| `graph_remember` | `remember(text, *, document_id=None)` | Store new text into the graph (agent memory, fact capture). |
| `graph_flush` | `flush()` | Run finalization after a write session (only advertised under the `"manual"` policy). |

All methods are async and validate their arguments through the same pydantic
input models that generate the JSON Schemas in `tool_specs()`. Every result
model supports `model_dump()` / `model_dump_json()` for structured consumers
and `to_llm_text(max_chars=4000)` for prompt building — deterministic,
truncated only at item boundaries with an explicit `…(N more)` marker, and
control-character-stripped.

## `tool_specs()` for adapter authors

`tool_specs()` is the single source of truth. Adapters and MCP servers must
generate their tool definitions from it — names, LLM-facing descriptions, and
input JSON Schemas are never duplicated downstream.

```python
for spec in toolkit.tool_specs():
print(spec.name) # "graph_search"
print(spec.description) # when-to-use guidance written for the model
print(spec.input_schema) # JSON Schema (draft 2020-12, additionalProperties: false)
print(spec.output_hint) # one-line shape hint for the result
```

Dispatch generically with `call()` — it validates arguments against the
tool's input model and enforces the same gates as `tool_specs()`:

```python
result = await toolkit.call("graph_search", {"query": "Who works at Acme?", "top_k": 5})
```

Two knobs shape the advertised surface:

- `read_only=True` — `graph_remember`/`graph_flush` are removed from
`tool_specs()` and raise `ReadOnlyViolation` if invoked anyway.
- `include=["graph_search", "graph_schema"]` — advertise a subset. `include`
filters `tool_specs()` and `call()`; direct method calls still work.

## Finalize policies

`GraphRAG.finalize()` runs cross-document entity deduplication, entity and
relationship embeddings, and index creation.

!!! warning "finalize() is O(graph size)"
Finalization cost grows with the whole graph, not with the size of the
last write. `finalize_policy="on_write"` (finalize after **every**
`remember`) is for demos and tiny graphs only.

| Policy | Behavior |
|---|---|
| `"manual"` (default) | Writes accumulate; call `flush()` once at the end of a write session. `graph_flush` is advertised in `tool_specs()`. |
| `"on_write"` | Every `remember()` finalizes (`RememberResult.finalized=True`). `flush()` is a no-op and is not advertised. |
| `"never"` | The toolkit never finalizes; `flush()` raises `ConfigError`. You own the `rag.finalize()` lifecycle. |

## Security: treat agent Cypher as untrusted input

Natural-language-to-Cypher — whether generated by your model or typed by an
agent — is **model-generated, untrusted input**. `cypher_read` is guarded:

- Write clauses (`CREATE`, `MERGE`, `DELETE`, `DETACH`, `SET`, `REMOVE`,
`DROP`, `FOREACH`, `LOAD CSV`) are rejected with `ReadOnlyViolation`
naming the offending token. Detection is case-insensitive, tolerant of
comments/whitespace, immune to string-literal smuggling (a single-pass
lexer masks literals and strips comments), and runs on both the raw and
the NFKC-normalized query text.
- `CALL` is allowed only for read-safe procedures:
`db.labels`, `db.relationshipTypes`, `db.propertyKeys`, `db.indexes`,
`db.idx.fulltext.queryNodes`, `db.idx.fulltext.queryRelationships`,
`db.idx.vector.queryNodes`, `db.idx.vector.queryRelationships`
(full-name match — `db.idx.fulltext.createNodeIndex` is a write and is
rejected). `CALL { … }` subqueries are permitted; their contents are
scanned like everything else.
- A `LIMIT {limit}` is appended when the query has none, and `timeout_ms`
is enforced server-side per query. The connection layer retries transient
failures (`ConnectionConfig.retry_count`, default 3), so worst-case wall
time is about `retry_count × timeout_ms`.
- Node/edge values returned by `cypher_read` are converted to JSON-safe
data with bulky internals (`embedding`, `source_chunk_ids`) stripped.

!!! tip "Defense in depth"
The guard is one layer. In production, point agent toolkits at read-only
replicas or construct them with `read_only=True`; give write access only
to toolkits that need it.

All strings rendered by `to_llm_text()` pass the SDK's control-character
sanitizer, so tool output cannot smuggle terminal escapes into prompts.

## Multi-tenancy

Bind a toolkit to a tenant-scoped graph with `for_tenant`. It derives
`graph_name = f"{base.graph_name}__{tenant_id}"` (tenant ids are validated
against `^[A-Za-z0-9_-]{1,64}$`), builds a dedicated `GraphRAG` the toolkit
owns, and closes it on `aclose()` / `async with`:

```python
async with GraphRAGToolkit.for_tenant(
base_config, "acme", llm=llm, embedder=embedder
) as toolkit:
await toolkit.search("...")
```

## Limitations

- `AnswerResult.cypher_used` is currently always `None`; the experimental
text-to-Cypher retrieval path does not yet surface its generated query.
The field exists for forward compatibility.
- `search()`/`answer()` drive a toolkit-tuned `MultiPathRetrieval` so that
`top_k` and citations behave predictably. A custom `retrieval_strategy`
configured on the `GraphRAG` instance is **not** used by the toolkit —
call `rag.completion()` / `rag.retrieve()` directly when you need it.
- Citations come from the retrieval pipeline's provenance
(`RetrieverResult.metadata["provenance"]`), which lists the context the
model actually saw — in relevance order, capped at `top_k`.

See [`examples/11_agent_toolkit.py`](https://github.com/FalkorDB/GraphRAG-SDK/blob/main/graphrag_sdk/examples/11_agent_toolkit.py)
for a runnable end-to-end script.
2 changes: 1 addition & 1 deletion docs/incremental-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ Steps 2 and 3 are why `apply_changes()` does not call it automatically: they sca

## End-to-End Example

A full runnable example lives in [`graphrag_sdk/examples/07_incremental_updates.py`](../graphrag_sdk/examples/07_incremental_updates.py). It exercises:
A full runnable example lives in [`graphrag_sdk/examples/07_incremental_updates.py`](https://github.com/FalkorDB/GraphRAG-SDK/blob/main/graphrag_sdk/examples/07_incremental_updates.py). It exercises:

- Initial ingest with a stable `document_id`
- No-op update (content-hash short-circuit)
Expand Down
91 changes: 91 additions & 0 deletions graphrag_sdk/examples/11_agent_toolkit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
GraphRAG SDK -- Agent Toolkit
=============================
The framework-neutral agent surface: wrap a GraphRAG in GraphRAGToolkit,
store a few facts, then exercise every tool an agent would call --
graph_schema, graph_search, graph_entity, graph_answer, cypher_read --
and print the machine-readable tool_specs() adapters consume.

Prerequisites:
docker run -p 6379:6379 falkordb/falkordb
pip install graphrag-sdk[litellm]
export OPENAI_API_KEY="sk-..."

More providers: see docs/providers.md (mirror 01_quickstart.py's setup).
"""

import asyncio
import json
import os

from graphrag_sdk import ConnectionConfig, GraphRAG, LiteLLM, LiteLLMEmbedder
from graphrag_sdk.tools import GraphRAGToolkit, ReadOnlyViolation

if not os.getenv("OPENAI_API_KEY"):
raise SystemExit("Set OPENAI_API_KEY before running this example.")

FACTS = [
"Alice Johnson is a software engineer at Acme Corp in London.",
"Bob Smith is the CTO of Acme Corp and Alice's manager.",
"Acme Corp is headquartered in Berlin and builds cloud infrastructure.",
]


async def main():
llm = LiteLLM(model="openai/gpt-5.5")
embedder = LiteLLMEmbedder(model="openai/text-embedding-3-large", dimensions=256)

rag = GraphRAG(
connection=ConnectionConfig(host="localhost", graph_name="agent_toolkit_demo"),
llm=llm,
embedder=embedder,
embedding_dimension=256,
)
toolkit = GraphRAGToolkit(rag, finalize_policy="manual")

try:
# 1. Write path: remember facts, then flush once (finalize is O(graph size))
for i, fact in enumerate(FACTS):
stored = await toolkit.remember(fact, document_id=f"fact-{i}")
print(stored.to_llm_text(), "\n")
await toolkit.flush()

# 2. graph_schema -- what does the graph contain?
schema = await toolkit.schema()
print("=== graph_schema ===\n" + schema.to_llm_text(max_chars=800), "\n")

# 3. graph_search -- typed retrieval, no generation (the default agent mode)
search = await toolkit.search("Who works at Acme Corp?", top_k=5)
print("=== graph_search ===\n" + search.to_llm_text(max_chars=1200), "\n")

# 4. graph_entity -- one entity's card
entity = await toolkit.entity("Alice Johnson", hops=2)
print("=== graph_entity ===\n" + entity.to_llm_text(max_chars=800), "\n")

# 5. graph_answer -- full RAG with citations
answer = await toolkit.answer("Who is the CTO of Acme Corp?")
print("=== graph_answer ===\n" + answer.to_llm_text(max_chars=800), "\n")

# 6. cypher_read -- guarded read-only escape hatch
rows = await toolkit.cypher_read(
"MATCH (e:__Entity__) RETURN e.name ORDER BY e.name LIMIT 5"
)
print("=== cypher_read ===\n" + rows.to_llm_text(max_chars=600), "\n")

# ... and the guard rejecting a write attempt:
try:
await toolkit.cypher_read("CREATE (n:Hack) RETURN n")
except ReadOnlyViolation as exc:
print(f"Guard blocked write: {exc} (token={exc.offending_token})\n")

# 7. tool_specs() -- what adapters (pydantic-ai, MCP, ...) consume
spec = toolkit.tool_specs()[0]
print("=== tool_specs()[0] ===")
print(json.dumps(spec.model_dump(), indent=2)[:600], "...")
finally:
await rag.delete_all() # demo cleanup: drop the example graph
await rag.close()


if __name__ == "__main__":
asyncio.run(main())
1 change: 1 addition & 0 deletions graphrag_sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ dev = [
"pytest-cov>=5.0",
"ruff>=0.4",
"mypy>=1.10",
"jsonschema>=4.0",
]

[project.urls]
Expand Down
Loading