diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 7123624..555ad52 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -41,12 +41,12 @@ jobs: - name: Ruff lint run: uv run ruff check . - name: Mypy strict - run: uv run mypy --strict packages/agentforge-core/src packages/agentforge/src packages/agentforge-bedrock/src packages/agentforge-memory-sqlite/src packages/agentforge-memory-neo4j/src packages/agentforge-memory-surrealdb/src packages/agentforge-memory-postgres/src packages/agentforge-eval-geval/src packages/agentforge-otel/src packages/agentforge-testing/src packages/agentforge-guard-llmguard/src packages/agentforge-guard-presidio/src packages/agentforge-guard-nemo/src packages/agentforge-guard-llamaguard/src packages/agentforge-mcp/src packages/agentforge-chat/src packages/agentforge-chat-http/src packages/agentforge-chat-history-postgres/src packages/agentforge-chat-history-redis/src packages/agentforge-chat-slack/src packages/agentforge-a2a/src packages/agentforge-statsd/src packages/agentforge-langfuse/src packages/agentforge-phoenix/src packages/agentforge-evidently/src packages/agentforge-reranker-sentence-transformers/src packages/agentforge-reranker-cohere/src packages/agentforge-reranker-voyage/src packages/agentforge-reranker-mixedbread/src packages/agentforge-anthropic/src packages/agentforge-openai/src packages/agentforge-voyage/src packages/agentforge-litellm/src packages/agentforge-ollama/src + run: uv run mypy --strict packages/agentforge-core/src packages/agentforge/src packages/agentforge-bedrock/src packages/agentforge-memory-sqlite/src packages/agentforge-memory-neo4j/src packages/agentforge-memory-kuzu/src packages/agentforge-memory-surrealdb/src packages/agentforge-memory-postgres/src packages/agentforge-eval-geval/src packages/agentforge-otel/src packages/agentforge-testing/src packages/agentforge-guard-llmguard/src packages/agentforge-guard-presidio/src packages/agentforge-guard-nemo/src packages/agentforge-guard-llamaguard/src packages/agentforge-mcp/src packages/agentforge-chat/src packages/agentforge-chat-http/src packages/agentforge-chat-history-postgres/src packages/agentforge-chat-history-redis/src packages/agentforge-chat-slack/src packages/agentforge-a2a/src packages/agentforge-statsd/src packages/agentforge-langfuse/src packages/agentforge-phoenix/src packages/agentforge-evidently/src packages/agentforge-reranker-sentence-transformers/src packages/agentforge-reranker-cohere/src packages/agentforge-reranker-voyage/src packages/agentforge-reranker-mixedbread/src packages/agentforge-anthropic/src packages/agentforge-openai/src packages/agentforge-voyage/src packages/agentforge-litellm/src packages/agentforge-ollama/src - name: Bandit # -c pyproject.toml so [tool.bandit] (skips B101) is honoured; # without it, the conformance suite's asserts in # agentforge_core.testing.conformance trip B101. - run: uv run bandit -q -c pyproject.toml -r packages/agentforge-core/src packages/agentforge/src packages/agentforge-bedrock/src packages/agentforge-memory-sqlite/src packages/agentforge-memory-neo4j/src packages/agentforge-memory-surrealdb/src packages/agentforge-memory-postgres/src packages/agentforge-eval-geval/src packages/agentforge-otel/src packages/agentforge-testing/src packages/agentforge-guard-llmguard/src packages/agentforge-guard-presidio/src packages/agentforge-guard-nemo/src packages/agentforge-guard-llamaguard/src packages/agentforge-mcp/src packages/agentforge-chat/src packages/agentforge-chat-http/src packages/agentforge-chat-history-postgres/src packages/agentforge-chat-history-redis/src packages/agentforge-chat-slack/src packages/agentforge-a2a/src packages/agentforge-statsd/src packages/agentforge-langfuse/src packages/agentforge-phoenix/src packages/agentforge-evidently/src packages/agentforge-reranker-sentence-transformers/src packages/agentforge-reranker-cohere/src packages/agentforge-reranker-voyage/src packages/agentforge-reranker-mixedbread/src packages/agentforge-anthropic/src packages/agentforge-openai/src packages/agentforge-voyage/src packages/agentforge-litellm/src packages/agentforge-ollama/src + run: uv run bandit -q -c pyproject.toml -r packages/agentforge-core/src packages/agentforge/src packages/agentforge-bedrock/src packages/agentforge-memory-sqlite/src packages/agentforge-memory-neo4j/src packages/agentforge-memory-kuzu/src packages/agentforge-memory-surrealdb/src packages/agentforge-memory-postgres/src packages/agentforge-eval-geval/src packages/agentforge-otel/src packages/agentforge-testing/src packages/agentforge-guard-llmguard/src packages/agentforge-guard-presidio/src packages/agentforge-guard-nemo/src packages/agentforge-guard-llamaguard/src packages/agentforge-mcp/src packages/agentforge-chat/src packages/agentforge-chat-http/src packages/agentforge-chat-history-postgres/src packages/agentforge-chat-history-redis/src packages/agentforge-chat-slack/src packages/agentforge-a2a/src packages/agentforge-statsd/src packages/agentforge-langfuse/src packages/agentforge-phoenix/src packages/agentforge-evidently/src packages/agentforge-reranker-sentence-transformers/src packages/agentforge-reranker-cohere/src packages/agentforge-reranker-voyage/src packages/agentforge-reranker-mixedbread/src packages/agentforge-anthropic/src packages/agentforge-openai/src packages/agentforge-voyage/src packages/agentforge-litellm/src packages/agentforge-ollama/src test: name: Test (Linux, Python ${{ matrix.python-version }}) @@ -66,7 +66,7 @@ jobs: - name: Sync dependencies run: uv sync --all-extras --dev - name: Pytest unit - run: uv run pytest -q -m "not live" packages/agentforge-core/tests packages/agentforge/tests packages/agentforge-bedrock/tests/unit packages/agentforge-memory-sqlite/tests/unit packages/agentforge-memory-neo4j/tests/unit packages/agentforge-memory-surrealdb/tests/unit packages/agentforge-memory-postgres/tests/unit packages/agentforge-eval-geval/tests/unit packages/agentforge-otel/tests/unit packages/agentforge-testing/tests/unit packages/agentforge-guard-llmguard/tests/unit packages/agentforge-guard-presidio/tests/unit packages/agentforge-guard-nemo/tests/unit packages/agentforge-guard-llamaguard/tests/unit packages/agentforge-mcp/tests/unit packages/agentforge-chat/tests/unit packages/agentforge-chat-http/tests/unit packages/agentforge-chat-history-postgres/tests/unit packages/agentforge-chat-history-redis/tests/unit packages/agentforge-chat-slack/tests/unit packages/agentforge-a2a/tests/unit packages/agentforge-statsd/tests/unit packages/agentforge-langfuse/tests/unit packages/agentforge-phoenix/tests/unit packages/agentforge-evidently/tests/unit packages/agentforge-reranker-sentence-transformers/tests/unit packages/agentforge-reranker-cohere/tests/unit packages/agentforge-reranker-voyage/tests/unit packages/agentforge-reranker-mixedbread/tests/unit packages/agentforge-anthropic/tests/unit packages/agentforge-openai/tests/unit packages/agentforge-voyage/tests/unit packages/agentforge-litellm/tests/unit packages/agentforge-ollama/tests/unit tests/unit + run: uv run pytest -q -m "not live" packages/agentforge-core/tests packages/agentforge/tests packages/agentforge-bedrock/tests/unit packages/agentforge-memory-sqlite/tests/unit packages/agentforge-memory-neo4j/tests/unit packages/agentforge-memory-kuzu/tests/unit packages/agentforge-memory-surrealdb/tests/unit packages/agentforge-memory-postgres/tests/unit packages/agentforge-eval-geval/tests/unit packages/agentforge-otel/tests/unit packages/agentforge-testing/tests/unit packages/agentforge-guard-llmguard/tests/unit packages/agentforge-guard-presidio/tests/unit packages/agentforge-guard-nemo/tests/unit packages/agentforge-guard-llamaguard/tests/unit packages/agentforge-mcp/tests/unit packages/agentforge-chat/tests/unit packages/agentforge-chat-http/tests/unit packages/agentforge-chat-history-postgres/tests/unit packages/agentforge-chat-history-redis/tests/unit packages/agentforge-chat-slack/tests/unit packages/agentforge-a2a/tests/unit packages/agentforge-statsd/tests/unit packages/agentforge-langfuse/tests/unit packages/agentforge-phoenix/tests/unit packages/agentforge-evidently/tests/unit packages/agentforge-reranker-sentence-transformers/tests/unit packages/agentforge-reranker-cohere/tests/unit packages/agentforge-reranker-voyage/tests/unit packages/agentforge-reranker-mixedbread/tests/unit packages/agentforge-anthropic/tests/unit packages/agentforge-openai/tests/unit packages/agentforge-voyage/tests/unit packages/agentforge-litellm/tests/unit packages/agentforge-ollama/tests/unit tests/unit - name: Pytest integration run: uv run pytest -q -m "not live" packages/agentforge-bedrock/tests/integration tests/integration - name: Coverage diff --git a/.github/workflows/ci-mac.yml b/.github/workflows/ci-mac.yml index 0130b19..ce81822 100644 --- a/.github/workflows/ci-mac.yml +++ b/.github/workflows/ci-mac.yml @@ -34,7 +34,7 @@ jobs: - name: Sync dependencies run: uv sync --all-extras --dev - name: Pytest unit - run: uv run pytest -q -m "not live" packages/agentforge-core/tests packages/agentforge/tests packages/agentforge-bedrock/tests/unit packages/agentforge-memory-sqlite/tests/unit packages/agentforge-memory-neo4j/tests/unit packages/agentforge-memory-surrealdb/tests/unit packages/agentforge-memory-postgres/tests/unit packages/agentforge-eval-geval/tests/unit packages/agentforge-otel/tests/unit packages/agentforge-testing/tests/unit packages/agentforge-guard-llmguard/tests/unit packages/agentforge-guard-presidio/tests/unit packages/agentforge-guard-nemo/tests/unit packages/agentforge-guard-llamaguard/tests/unit packages/agentforge-mcp/tests/unit packages/agentforge-chat/tests/unit packages/agentforge-chat-http/tests/unit packages/agentforge-chat-history-postgres/tests/unit packages/agentforge-chat-history-redis/tests/unit packages/agentforge-chat-slack/tests/unit packages/agentforge-a2a/tests/unit packages/agentforge-statsd/tests/unit packages/agentforge-langfuse/tests/unit packages/agentforge-phoenix/tests/unit packages/agentforge-evidently/tests/unit packages/agentforge-reranker-sentence-transformers/tests/unit packages/agentforge-reranker-cohere/tests/unit packages/agentforge-reranker-voyage/tests/unit packages/agentforge-reranker-mixedbread/tests/unit packages/agentforge-anthropic/tests/unit packages/agentforge-openai/tests/unit packages/agentforge-voyage/tests/unit packages/agentforge-litellm/tests/unit packages/agentforge-ollama/tests/unit tests/unit + run: uv run pytest -q -m "not live" packages/agentforge-core/tests packages/agentforge/tests packages/agentforge-bedrock/tests/unit packages/agentforge-memory-sqlite/tests/unit packages/agentforge-memory-neo4j/tests/unit packages/agentforge-memory-kuzu/tests/unit packages/agentforge-memory-surrealdb/tests/unit packages/agentforge-memory-postgres/tests/unit packages/agentforge-eval-geval/tests/unit packages/agentforge-otel/tests/unit packages/agentforge-testing/tests/unit packages/agentforge-guard-llmguard/tests/unit packages/agentforge-guard-presidio/tests/unit packages/agentforge-guard-nemo/tests/unit packages/agentforge-guard-llamaguard/tests/unit packages/agentforge-mcp/tests/unit packages/agentforge-chat/tests/unit packages/agentforge-chat-http/tests/unit packages/agentforge-chat-history-postgres/tests/unit packages/agentforge-chat-history-redis/tests/unit packages/agentforge-chat-slack/tests/unit packages/agentforge-a2a/tests/unit packages/agentforge-statsd/tests/unit packages/agentforge-langfuse/tests/unit packages/agentforge-phoenix/tests/unit packages/agentforge-evidently/tests/unit packages/agentforge-reranker-sentence-transformers/tests/unit packages/agentforge-reranker-cohere/tests/unit packages/agentforge-reranker-voyage/tests/unit packages/agentforge-reranker-mixedbread/tests/unit packages/agentforge-anthropic/tests/unit packages/agentforge-openai/tests/unit packages/agentforge-voyage/tests/unit packages/agentforge-litellm/tests/unit packages/agentforge-ollama/tests/unit tests/unit - name: Pytest integration run: uv run pytest -q -m "not live" packages/agentforge-bedrock/tests/integration tests/integration diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 79dc297..a879795 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -34,6 +34,6 @@ jobs: - name: Sync dependencies run: uv sync --all-extras --dev - name: Pytest unit - run: uv run pytest -q -m "not live" packages/agentforge-core/tests packages/agentforge/tests packages/agentforge-bedrock/tests/unit packages/agentforge-memory-sqlite/tests/unit packages/agentforge-memory-neo4j/tests/unit packages/agentforge-memory-surrealdb/tests/unit packages/agentforge-memory-postgres/tests/unit packages/agentforge-eval-geval/tests/unit packages/agentforge-otel/tests/unit packages/agentforge-testing/tests/unit packages/agentforge-guard-llmguard/tests/unit packages/agentforge-guard-presidio/tests/unit packages/agentforge-guard-nemo/tests/unit packages/agentforge-guard-llamaguard/tests/unit packages/agentforge-mcp/tests/unit packages/agentforge-chat/tests/unit packages/agentforge-chat-http/tests/unit packages/agentforge-chat-history-postgres/tests/unit packages/agentforge-chat-history-redis/tests/unit packages/agentforge-chat-slack/tests/unit packages/agentforge-a2a/tests/unit packages/agentforge-statsd/tests/unit packages/agentforge-langfuse/tests/unit packages/agentforge-phoenix/tests/unit packages/agentforge-evidently/tests/unit packages/agentforge-reranker-sentence-transformers/tests/unit packages/agentforge-reranker-cohere/tests/unit packages/agentforge-reranker-voyage/tests/unit packages/agentforge-reranker-mixedbread/tests/unit packages/agentforge-anthropic/tests/unit packages/agentforge-openai/tests/unit packages/agentforge-voyage/tests/unit packages/agentforge-litellm/tests/unit packages/agentforge-ollama/tests/unit tests/unit + run: uv run pytest -q -m "not live" packages/agentforge-core/tests packages/agentforge/tests packages/agentforge-bedrock/tests/unit packages/agentforge-memory-sqlite/tests/unit packages/agentforge-memory-neo4j/tests/unit packages/agentforge-memory-kuzu/tests/unit packages/agentforge-memory-surrealdb/tests/unit packages/agentforge-memory-postgres/tests/unit packages/agentforge-eval-geval/tests/unit packages/agentforge-otel/tests/unit packages/agentforge-testing/tests/unit packages/agentforge-guard-llmguard/tests/unit packages/agentforge-guard-presidio/tests/unit packages/agentforge-guard-nemo/tests/unit packages/agentforge-guard-llamaguard/tests/unit packages/agentforge-mcp/tests/unit packages/agentforge-chat/tests/unit packages/agentforge-chat-http/tests/unit packages/agentforge-chat-history-postgres/tests/unit packages/agentforge-chat-history-redis/tests/unit packages/agentforge-chat-slack/tests/unit packages/agentforge-a2a/tests/unit packages/agentforge-statsd/tests/unit packages/agentforge-langfuse/tests/unit packages/agentforge-phoenix/tests/unit packages/agentforge-evidently/tests/unit packages/agentforge-reranker-sentence-transformers/tests/unit packages/agentforge-reranker-cohere/tests/unit packages/agentforge-reranker-voyage/tests/unit packages/agentforge-reranker-mixedbread/tests/unit packages/agentforge-anthropic/tests/unit packages/agentforge-openai/tests/unit packages/agentforge-voyage/tests/unit packages/agentforge-litellm/tests/unit packages/agentforge-ollama/tests/unit tests/unit - name: Pytest integration run: uv run pytest -q -m "not live" packages/agentforge-bedrock/tests/integration tests/integration diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c2c7a1..e4c0276 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,7 +76,7 @@ repos: # errors that file-scope mypy misses. - id: mypy-strict name: mypy --strict - entry: uv run mypy --strict packages/agentforge-core/src packages/agentforge/src packages/agentforge-bedrock/src packages/agentforge-memory-sqlite/src packages/agentforge-memory-neo4j/src packages/agentforge-memory-surrealdb/src packages/agentforge-memory-postgres/src packages/agentforge-eval-geval/src packages/agentforge-otel/src packages/agentforge-testing/src packages/agentforge-guard-llmguard/src packages/agentforge-guard-presidio/src packages/agentforge-guard-nemo/src packages/agentforge-guard-llamaguard/src packages/agentforge-mcp/src packages/agentforge-chat/src packages/agentforge-chat-http/src packages/agentforge-chat-history-postgres/src packages/agentforge-chat-history-redis/src packages/agentforge-chat-slack/src packages/agentforge-a2a/src packages/agentforge-statsd/src packages/agentforge-langfuse/src packages/agentforge-phoenix/src packages/agentforge-evidently/src packages/agentforge-reranker-sentence-transformers/src packages/agentforge-reranker-cohere/src packages/agentforge-reranker-voyage/src packages/agentforge-reranker-mixedbread/src packages/agentforge-anthropic/src packages/agentforge-openai/src packages/agentforge-voyage/src packages/agentforge-litellm/src packages/agentforge-ollama/src + entry: uv run mypy --strict packages/agentforge-core/src packages/agentforge/src packages/agentforge-bedrock/src packages/agentforge-memory-sqlite/src packages/agentforge-memory-neo4j/src packages/agentforge-memory-kuzu/src packages/agentforge-memory-surrealdb/src packages/agentforge-memory-postgres/src packages/agentforge-eval-geval/src packages/agentforge-otel/src packages/agentforge-testing/src packages/agentforge-guard-llmguard/src packages/agentforge-guard-presidio/src packages/agentforge-guard-nemo/src packages/agentforge-guard-llamaguard/src packages/agentforge-mcp/src packages/agentforge-chat/src packages/agentforge-chat-http/src packages/agentforge-chat-history-postgres/src packages/agentforge-chat-history-redis/src packages/agentforge-chat-slack/src packages/agentforge-a2a/src packages/agentforge-statsd/src packages/agentforge-langfuse/src packages/agentforge-phoenix/src packages/agentforge-evidently/src packages/agentforge-reranker-sentence-transformers/src packages/agentforge-reranker-cohere/src packages/agentforge-reranker-voyage/src packages/agentforge-reranker-mixedbread/src packages/agentforge-anthropic/src packages/agentforge-openai/src packages/agentforge-voyage/src packages/agentforge-litellm/src packages/agentforge-ollama/src language: system types: [python] pass_filenames: false @@ -84,7 +84,7 @@ repos: - id: bandit name: bandit security lint # -c pyproject.toml so bandit reads [tool.bandit] (skips B101 etc.) - entry: uv run bandit -q -c pyproject.toml -r packages/agentforge-core/src packages/agentforge/src packages/agentforge-bedrock/src packages/agentforge-memory-sqlite/src packages/agentforge-memory-neo4j/src packages/agentforge-memory-surrealdb/src packages/agentforge-memory-postgres/src packages/agentforge-eval-geval/src packages/agentforge-otel/src packages/agentforge-testing/src packages/agentforge-guard-llmguard/src packages/agentforge-guard-presidio/src packages/agentforge-guard-nemo/src packages/agentforge-guard-llamaguard/src packages/agentforge-mcp/src packages/agentforge-chat/src packages/agentforge-chat-http/src packages/agentforge-chat-history-postgres/src packages/agentforge-chat-history-redis/src packages/agentforge-chat-slack/src packages/agentforge-a2a/src packages/agentforge-statsd/src packages/agentforge-langfuse/src packages/agentforge-phoenix/src packages/agentforge-evidently/src packages/agentforge-reranker-sentence-transformers/src packages/agentforge-reranker-cohere/src packages/agentforge-reranker-voyage/src packages/agentforge-reranker-mixedbread/src packages/agentforge-anthropic/src packages/agentforge-openai/src packages/agentforge-voyage/src packages/agentforge-litellm/src packages/agentforge-ollama/src + entry: uv run bandit -q -c pyproject.toml -r packages/agentforge-core/src packages/agentforge/src packages/agentforge-bedrock/src packages/agentforge-memory-sqlite/src packages/agentforge-memory-neo4j/src packages/agentforge-memory-kuzu/src packages/agentforge-memory-surrealdb/src packages/agentforge-memory-postgres/src packages/agentforge-eval-geval/src packages/agentforge-otel/src packages/agentforge-testing/src packages/agentforge-guard-llmguard/src packages/agentforge-guard-presidio/src packages/agentforge-guard-nemo/src packages/agentforge-guard-llamaguard/src packages/agentforge-mcp/src packages/agentforge-chat/src packages/agentforge-chat-http/src packages/agentforge-chat-history-postgres/src packages/agentforge-chat-history-redis/src packages/agentforge-chat-slack/src packages/agentforge-a2a/src packages/agentforge-statsd/src packages/agentforge-langfuse/src packages/agentforge-phoenix/src packages/agentforge-evidently/src packages/agentforge-reranker-sentence-transformers/src packages/agentforge-reranker-cohere/src packages/agentforge-reranker-voyage/src packages/agentforge-reranker-mixedbread/src packages/agentforge-anthropic/src packages/agentforge-openai/src packages/agentforge-voyage/src packages/agentforge-litellm/src packages/agentforge-ollama/src language: system types: [python] pass_filenames: false @@ -94,7 +94,7 @@ repos: # are still empty. Real failures (1, 2, 3, 4) still block. - id: pytest-unit name: pytest unit - entry: bash -c 'uv run pytest -q -x -m "not live" packages/agentforge-core/tests packages/agentforge/tests packages/agentforge-bedrock/tests/unit packages/agentforge-memory-sqlite/tests/unit packages/agentforge-memory-neo4j/tests/unit packages/agentforge-memory-surrealdb/tests/unit packages/agentforge-memory-postgres/tests/unit packages/agentforge-eval-geval/tests/unit packages/agentforge-otel/tests/unit packages/agentforge-testing/tests/unit packages/agentforge-guard-llmguard/tests/unit packages/agentforge-guard-presidio/tests/unit packages/agentforge-guard-nemo/tests/unit packages/agentforge-guard-llamaguard/tests/unit packages/agentforge-mcp/tests/unit packages/agentforge-chat/tests/unit packages/agentforge-chat-http/tests/unit packages/agentforge-chat-history-postgres/tests/unit packages/agentforge-chat-history-redis/tests/unit packages/agentforge-chat-slack/tests/unit packages/agentforge-a2a/tests/unit packages/agentforge-statsd/tests/unit packages/agentforge-langfuse/tests/unit packages/agentforge-phoenix/tests/unit packages/agentforge-evidently/tests/unit packages/agentforge-reranker-sentence-transformers/tests/unit packages/agentforge-reranker-cohere/tests/unit packages/agentforge-reranker-voyage/tests/unit packages/agentforge-reranker-mixedbread/tests/unit packages/agentforge-anthropic/tests/unit packages/agentforge-openai/tests/unit packages/agentforge-voyage/tests/unit packages/agentforge-litellm/tests/unit packages/agentforge-ollama/tests/unit tests/unit; code=$?; [ $code -eq 0 ] || [ $code -eq 5 ] && exit 0 || exit $code' + entry: bash -c 'uv run pytest -q -x -m "not live" packages/agentforge-core/tests packages/agentforge/tests packages/agentforge-bedrock/tests/unit packages/agentforge-memory-sqlite/tests/unit packages/agentforge-memory-neo4j/tests/unit packages/agentforge-memory-kuzu/tests/unit packages/agentforge-memory-surrealdb/tests/unit packages/agentforge-memory-postgres/tests/unit packages/agentforge-eval-geval/tests/unit packages/agentforge-otel/tests/unit packages/agentforge-testing/tests/unit packages/agentforge-guard-llmguard/tests/unit packages/agentforge-guard-presidio/tests/unit packages/agentforge-guard-nemo/tests/unit packages/agentforge-guard-llamaguard/tests/unit packages/agentforge-mcp/tests/unit packages/agentforge-chat/tests/unit packages/agentforge-chat-http/tests/unit packages/agentforge-chat-history-postgres/tests/unit packages/agentforge-chat-history-redis/tests/unit packages/agentforge-chat-slack/tests/unit packages/agentforge-a2a/tests/unit packages/agentforge-statsd/tests/unit packages/agentforge-langfuse/tests/unit packages/agentforge-phoenix/tests/unit packages/agentforge-evidently/tests/unit packages/agentforge-reranker-sentence-transformers/tests/unit packages/agentforge-reranker-cohere/tests/unit packages/agentforge-reranker-voyage/tests/unit packages/agentforge-reranker-mixedbread/tests/unit packages/agentforge-anthropic/tests/unit packages/agentforge-openai/tests/unit packages/agentforge-voyage/tests/unit packages/agentforge-litellm/tests/unit packages/agentforge-ollama/tests/unit tests/unit; code=$?; [ $code -eq 0 ] || [ $code -eq 5 ] && exit 0 || exit $code' language: system always_run: true pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4a3b2..b76b986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ slice between two versions to see which of your filed issues a bump fixes. ### Added +- **feat-027 — embedded `KuzuGraphStore`.** A zero-ops, file-backed, in-process + `GraphStore` driver — `path: .ckg` and the store exists, no server (the graph + analogue of the SQLite `MemoryStore`). New `agentforge-memory-kuzu` package, + registered as `kuzu` under the `graph_stores` entry-point category. Implements + the locked `GraphStore` ABC and passes `run_graph_conformance`, so it's + swap-compatible with the Neo4j / SurrealDB drivers; makes the whole graph + + GraphRAG path testable offline. Mutations + `get_node`/`get_edges` use native + openCypher (`MERGE` upserts); `traverse`/`match` run as Python algorithms over + those primitives for exact contract semantics. `kuzu>=0.11.3` (native; ships + cp313 wheels for the CI matrix). Purely additive — existing configs and + drivers untouched. + - **enh-005 — directional GraphRAG expansion.** `retrieval.graph_expansion` (and the `GraphExpansion` value) gain `direction: in | out | any`, so graph expansion can follow **asymmetric** edges the right way — `in` for callers / diff --git a/docs/features/feat-027-embedded-graph-store.md b/docs/features/feat-027-embedded-graph-store.md index a7942f7..a250448 100644 --- a/docs/features/feat-027-embedded-graph-store.md +++ b/docs/features/feat-027-embedded-graph-store.md @@ -6,7 +6,7 @@ |---|---| | **ID** | feat-027 | | **Title** | `KuzuGraphStore` — embedded, file-backed `GraphStore` driver | -| **Status** | accepted (targeted at the 0.4 train) | +| **Status** | in-progress (implemented; targeting the 0.4 train) | | **Owner** | kjoshi | | **Created** | 2026-06-17 | | **Target version** | 0.4 | @@ -203,12 +203,34 @@ mirrors this 1:1 when scheduled. `get_edges(direction=...)` for the embedded path). ## 11. Implementation status (Python) -**Status: accepted, not yet implemented.** Suggested chunking when built: -1. Spec + catalogue row + roadmap pointer. -2. `KuzuGraphStore` class + entry-point registration + `from_path`/`from_config` - + conformance pass (offline). -3. Capability gating (`fulltext`/`transactions` as honoured) + runbook. -4. Status flip + catalogue + roadmap + CHANGELOG. +**Status: implemented (not yet released).** Shipped in `agentforge-memory-kuzu`: +- `KuzuGraphStore` over one generic node table `AfNode(id, labels, props)` + + one generic rel table `AfEdge(etype, props)` (labels/props as JSON strings + so the schemaless contract maps onto Kùzu's typed schema). `from_path` / + `from_config` factories + `__aenter__`/`__aexit__`/`close`. Registered as + `kuzu` under the `graph_stores` entry-point category. +- **Idempotent upserts** via Cypher `MERGE` (node on `id`, edge on + `(src,dst,etype)`); `add_edge` pre-checks both endpoints and raises + `ValueError` on an unknown node. `get_node`/`get_edges` are native Cypher. +- **`traverse` / `match` run as Python algorithms over those native + primitives** (mirroring `InMemoryGraphStore`): Kùzu's recursive-path Cypher + rejects a bound parameter inside an `all(...)` predicate over a recursive + variable (engine assertion), and client-side traversal keeps the path + semantics (per-hop prefixes, cycle avoidance) exactly contract-correct. +- **Concurrency:** Kùzu is embedded single-writer and its `Connection` isn't + thread-safe; every call is dispatched via `asyncio.to_thread` under an + `asyncio.Lock`, so access is serialised (documented constraint). +- `capabilities()` returns `{"cypher"}` (openCypher); `transactions`/`fulltext` + deferred until honoured. +- **Tests** (`tests/unit/test_kuzu_graph.py`): `run_graph_conformance` against + a real embedded DB in a temp dir (no server, offline) + persistence across + reopen, idempotency, edge-integrity, `get_edges` direction/filter, traverse + depth/cycle/edge-type, `match`, deletes. `kuzu>=0.11.3` ships cp313 wheels + for the CI matrix (Linux x86_64/aarch64, macOS, Windows). + +Remaining: flip Status → shipped + roadmap row when the 0.4 train tags. A +`KuzuVectorStore` (one file serving graph **and** vector) stays out of scope +(§9). ## 12. Runbook diff --git a/packages/agentforge-memory-kuzu/LICENSE b/packages/agentforge-memory-kuzu/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/agentforge-memory-kuzu/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/agentforge-memory-kuzu/NOTICE b/packages/agentforge-memory-kuzu/NOTICE new file mode 100644 index 0000000..bedf327 --- /dev/null +++ b/packages/agentforge-memory-kuzu/NOTICE @@ -0,0 +1,11 @@ +AgentForge +Copyright 2026 The AgentForge Authors + +This product includes software developed at the AgentForge project +(https://github.com/Scaffoldic/agentforge-py). + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/agentforge-memory-kuzu/README.md b/packages/agentforge-memory-kuzu/README.md new file mode 100644 index 0000000..4f41aa5 --- /dev/null +++ b/packages/agentforge-memory-kuzu/README.md @@ -0,0 +1,31 @@ +# agentforge-memory-kuzu + +Embedded, file-backed `GraphStore` for [AgentForge](https://github.com/Scaffoldic/agentforge-py), +backed by [Kùzu](https://kuzudb.com/) — a persistent property graph in a +single directory, in-process, no server (feat-027). + +It is the graph analogue of the SQLite `MemoryStore`: zero-ops local +development, CI, single-host deployments, and embedded products. The driver +implements the locked `GraphStore` contract and passes +`run_graph_conformance`, so it is swap-compatible with the Neo4j and +SurrealDB drivers. + +```python +from agentforge_memory_kuzu import KuzuGraphStore +from agentforge_core.values.graph import GraphNode, GraphEdge + +async with await KuzuGraphStore.from_path(".ckg") as store: + await store.add_node(GraphNode(id="a", labels=("Func",))) + await store.add_node(GraphNode(id="b", labels=("Func",))) + await store.add_edge(GraphEdge(src="b", dst="a", edge_type="CALLS")) + callers = await store.get_edges("a", edge_type="CALLS", direction="in") +``` + +Via YAML, anywhere a `graph_stores` driver is accepted: + +```yaml +store: + driver: kuzu + config: + path: .ckg # directory; created if absent +``` diff --git a/packages/agentforge-memory-kuzu/pyproject.toml b/packages/agentforge-memory-kuzu/pyproject.toml new file mode 100644 index 0000000..f445814 --- /dev/null +++ b/packages/agentforge-memory-kuzu/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "agentforge-memory-kuzu" +version = "0.3.1" +description = "Embedded, file-backed GraphStore for AgentForge (Kùzu)" +readme = "README.md" +requires-python = ">=3.13" +license = "Apache-2.0" +license-files = ["LICENSE"] +authors = [ + {name = "The AgentForge Authors"}, +] +keywords = ["ai", "agent", "graph", "kuzu", "cypher", "embedded", "knowledge-graph"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Topic :: Database", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +dependencies = [ + "agentforge-core ~= 0.3.1", + "kuzu>=0.11.3", +] + +[project.urls] +Homepage = "https://github.com/Scaffoldic/agentforge-py" +Repository = "https://github.com/Scaffoldic/agentforge-py" +Documentation = "https://github.com/Scaffoldic/agentforge-py" +Changelog = "https://github.com/Scaffoldic/agentforge-py/blob/main/CHANGELOG.md" +Issues = "https://github.com/Scaffoldic/agentforge-py/issues" + +[project.entry-points."agentforge.graph_stores"] +kuzu = "agentforge_memory_kuzu:KuzuGraphStore" + +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/agentforge_memory_kuzu"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/agentforge_memory_kuzu", + "tests", + "README.md", + "LICENSE", +] diff --git a/packages/agentforge-memory-kuzu/src/agentforge_memory_kuzu/__init__.py b/packages/agentforge-memory-kuzu/src/agentforge_memory_kuzu/__init__.py new file mode 100644 index 0000000..11651ce --- /dev/null +++ b/packages/agentforge-memory-kuzu/src/agentforge_memory_kuzu/__init__.py @@ -0,0 +1,28 @@ +"""AgentForge — Kùzu embedded graph driver (feat-027). + +Implements `GraphStore` over an embedded, file-backed Kùzu database: a +persistent property graph in a single directory, no server, no network — +the graph analogue of the SQLite `MemoryStore`. + +Per ADR-0014 the public surface is async; Kùzu's Python driver is +synchronous, so blocking calls are dispatched via `asyncio.to_thread`. +""" + +from __future__ import annotations + +# Version is sourced from the installed distribution metadata so it can +# never drift from pyproject.toml (bug-024). +from importlib.metadata import PackageNotFoundError as _PkgNotFound +from importlib.metadata import version as _dist_version + +from agentforge_memory_kuzu.graph import KuzuGraphStore + +try: + __version__ = _dist_version("agentforge-memory-kuzu") +except _PkgNotFound: # pragma: no cover - source tree without installed metadata + __version__ = "0.0.0+unknown" + +__all__ = [ + "KuzuGraphStore", + "__version__", +] diff --git a/packages/agentforge-memory-kuzu/src/agentforge_memory_kuzu/graph.py b/packages/agentforge-memory-kuzu/src/agentforge_memory_kuzu/graph.py new file mode 100644 index 0000000..60faffd --- /dev/null +++ b/packages/agentforge-memory-kuzu/src/agentforge_memory_kuzu/graph.py @@ -0,0 +1,341 @@ +"""`KuzuGraphStore` — embedded, file-backed `GraphStore` (feat-027). + +A persistent property graph in a single directory, in-process, no server +— the graph analogue of the SQLite `MemoryStore`. Implements the locked +`GraphStore` contract and passes `run_graph_conformance`. + +Design notes +------------ +- **Storage.** One generic node table `AfNode(id, labels, props)` and one + generic relationship table `AfEdge(etype, props)`, created lazily on + open. `labels` and `props` are JSON strings so arbitrary + schemaless `GraphNode`/`GraphEdge` shapes map onto Kùzu's typed schema + without per-shape table DDL. Upserts use Cypher `MERGE` (idempotent on + `id` for nodes, on `(src, dst, etype)` for edges). +- **`get_node` / `get_edges`** are native Cypher. +- **`traverse` / `match`** run as Python graph algorithms over those + native primitives — mirroring the reference `InMemoryGraphStore`. Kùzu's + recursive-path Cypher can't take a bound parameter inside an `all(...)` + predicate (engine assertion), and client-side traversal keeps the path + semantics (per-hop prefixes, cycle avoidance) exactly contract-correct. +- **Concurrency.** Kùzu is embedded single-writer and its `Connection` + isn't thread-safe; every call is dispatched to a worker thread + (`asyncio.to_thread`, since the driver is synchronous) under an + `asyncio.Lock` so access is serialised. Documented constraint. +""" + +from __future__ import annotations + +import asyncio +import json +from collections import deque +from pathlib import Path as FilePath +from typing import TYPE_CHECKING, Any + +import kuzu +from agentforge_core.contracts.graph_store import GraphStore +from agentforge_core.values.graph import GraphEdge, GraphNode, GraphPattern, Path + +if TYPE_CHECKING: + from typing import Literal + +_NODE_TABLE = ( + "CREATE NODE TABLE IF NOT EXISTS " + "AfNode(id STRING, labels STRING, props STRING, PRIMARY KEY(id))" +) +_EDGE_TABLE = ( + "CREATE REL TABLE IF NOT EXISTS AfEdge(FROM AfNode TO AfNode, etype STRING, props STRING)" +) + + +def _exec_collect(conn: Any, query: str, params: dict[str, Any] | None) -> list[list[Any]]: + """Execute `query` and drain its rows — runs inside a worker thread.""" + result = conn.execute(query, parameters=params) if params else conn.execute(query) + rows: list[list[Any]] = [] + while result.has_next(): + rows.append(result.get_next()) + return rows + + +class KuzuGraphStore(GraphStore): + """Embedded file-backed `GraphStore` backed by Kùzu.""" + + def __init__(self, *, database: Any, connection: Any) -> None: + self._db = database + self._conn: Any = connection + self._lock = asyncio.Lock() + + # -- construction -------------------------------------------------- + + @classmethod + async def from_path(cls, path: str | FilePath) -> KuzuGraphStore: + """Open or create an embedded graph database under `path`. + + The directory is created if absent; the schema is bootstrapped on + first open. Mirrors `SqliteMemoryStore.from_path`. + """ + target = FilePath(path) + + def _open() -> tuple[Any, Any]: + target.parent.mkdir(parents=True, exist_ok=True) + database = kuzu.Database(str(target)) + connection = kuzu.Connection(database) + connection.execute(_NODE_TABLE) + connection.execute(_EDGE_TABLE) + return database, connection + + database, connection = await asyncio.to_thread(_open) + return cls(database=database, connection=connection) + + @classmethod + async def from_config(cls, *, path: str | FilePath) -> KuzuGraphStore: + """Build from a `config:` block (`{path: .ckg}`).""" + return await cls.from_path(path) + + async def __aenter__(self) -> KuzuGraphStore: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.close() + + async def _query(self, query: str, params: dict[str, Any] | None = None) -> list[list[Any]]: + async with self._lock: + return await asyncio.to_thread(_exec_collect, self._conn, query, params) + + # -- writes -------------------------------------------------------- + + async def add_node(self, node: GraphNode) -> None: + await self._query( + "MERGE (n:AfNode {id: $id}) SET n.labels = $labels, n.props = $props", + { + "id": node.id, + "labels": json.dumps(list(node.labels)), + "props": json.dumps(dict(node.properties)), + }, + ) + + async def add_edge(self, edge: GraphEdge) -> None: + present = { + row[0] + for row in await self._query( + "MATCH (n:AfNode) WHERE n.id IN $ids RETURN n.id", + {"ids": [edge.src, edge.dst]}, + ) + } + missing = [nid for nid in (edge.src, edge.dst) if nid not in present] + if missing: + msg = f"add_edge: node(s) {missing} do not exist; add them before the edge" + raise ValueError(msg) + await self._query( + "MATCH (s:AfNode {id: $src}), (d:AfNode {id: $dst}) " + "MERGE (s)-[e:AfEdge {etype: $etype}]->(d) SET e.props = $props", + { + "src": edge.src, + "dst": edge.dst, + "etype": edge.edge_type, + "props": json.dumps(dict(edge.properties)), + }, + ) + + # -- reads --------------------------------------------------------- + + async def get_node(self, node_id: str) -> GraphNode | None: + rows = await self._query( + "MATCH (n:AfNode {id: $id}) RETURN n.id, n.labels, n.props", + {"id": node_id}, + ) + if not rows: + return None + return _row_to_node(rows[0]) + + async def get_edges( + self, + node_id: str, + *, + edge_type: str | None = None, + direction: Literal["out", "in", "any"] = "out", + ) -> list[GraphEdge]: + if direction == "any": + out = await self.get_edges(node_id, edge_type=edge_type, direction="out") + inc = await self.get_edges(node_id, edge_type=edge_type, direction="in") + return out + inc + + if direction == "out": + pattern = "(anchor:AfNode {id: $id})-[e:AfEdge]->(other:AfNode)" + ret = "anchor.id, other.id, e.etype, e.props" + else: + pattern = "(other:AfNode)-[e:AfEdge]->(anchor:AfNode {id: $id})" + ret = "other.id, anchor.id, e.etype, e.props" + where = " WHERE e.etype = $etype" if edge_type is not None else "" + params: dict[str, Any] = {"id": node_id} + if edge_type is not None: + params["etype"] = edge_type + rows = await self._query(f"MATCH {pattern}{where} RETURN {ret}", params) + return [_row_to_edge(row) for row in rows] + + async def traverse( + self, + start_id: str, + *, + edge_types: tuple[str, ...] | None = None, + max_depth: int = 3, + limit: int = 50, + ) -> list[Path]: + if max_depth < 1: + raise ValueError(f"max_depth must be >= 1, got {max_depth}") + if limit < 1: + raise ValueError(f"limit must be >= 1, got {limit}") + start = await self.get_node(start_id) + if start is None: + return [] + + results: list[Path] = [] + frontier: deque[tuple[str, list[GraphNode], list[GraphEdge]]] = deque( + [(start_id, [start], [])] + ) + while frontier and len(results) < limit: + current, path_nodes, path_edges = frontier.popleft() + if len(path_edges) >= max_depth: + continue + for edge in await self.get_edges(current, direction="out"): + if edge_types is not None and edge.edge_type not in edge_types: + continue + target = await self.get_node(edge.dst) + if target is None: + continue + if any(n.id == target.id for n in path_nodes): + continue # cycle avoidance — don't revisit a node in this path + new_nodes = [*path_nodes, target] + new_edges = [*path_edges, edge] + results.append(Path(nodes=tuple(new_nodes), edges=tuple(new_edges))) + if len(results) >= limit: + break + frontier.append((target.id, new_nodes, new_edges)) + return results[:limit] + + async def match(self, pattern: GraphPattern, *, limit: int = 50) -> list[Path]: + if limit < 1: + raise ValueError(f"limit must be >= 1, got {limit}") + node_filters = pattern.node_filters + seg0 = pattern.segments[0] + filt0 = node_filters[0] if node_filters else None + + all_nodes = [ + _row_to_node(row) + for row in await self._query("MATCH (n:AfNode) RETURN n.id, n.labels, n.props") + ] + results: list[Path] = [] + for node in all_nodes: + if len(results) >= limit: + break + if _node_matches(node, seg0.src_label, filt0): + await self._walk(node, pattern, 0, [node], [], results, limit) + return results[:limit] + + async def _walk( + self, + current: GraphNode, + pattern: GraphPattern, + seg_index: int, + nodes: list[GraphNode], + edges: list[GraphEdge], + results: list[Path], + limit: int, + ) -> None: + if len(results) >= limit: + return + if seg_index == len(pattern.segments): + results.append(Path(nodes=tuple(nodes), edges=tuple(edges))) + return + seg = pattern.segments[seg_index] + next_filter = pattern.node_filters[seg_index + 1] if pattern.node_filters else None + for edge in await self.get_edges( + current.id, edge_type=seg.edge_type, direction=seg.direction + ): + other_id = edge.dst if edge.src == current.id else edge.src + other = await self.get_node(other_id) + if other is None or not _node_matches(other, seg.dst_label, next_filter): + continue + await self._walk( + other, pattern, seg_index + 1, [*nodes, other], [*edges, edge], results, limit + ) + if len(results) >= limit: + return + + # -- deletes ------------------------------------------------------- + + async def delete_node(self, node_id: str, *, cascade: bool = False) -> bool: + exists = await self._query("MATCH (n:AfNode {id: $id}) RETURN n.id", {"id": node_id}) + if not exists: + return False + counted = await self._query( + "MATCH (n:AfNode {id: $id})-[e:AfEdge]-(:AfNode) RETURN count(e)", + {"id": node_id}, + ) + incident = int(counted[0][0]) if counted else 0 + if incident and not cascade: + msg = ( + f"delete_node: {node_id!r} has {incident} incident edge(s); " + f"pass cascade=True to remove them" + ) + raise ValueError(msg) + clause = "DETACH DELETE n" if cascade else "DELETE n" + await self._query(f"MATCH (n:AfNode {{id: $id}}) {clause}", {"id": node_id}) + return True + + async def delete_edge(self, src: str, dst: str, *, edge_type: str) -> bool: + match_clause = ( + "MATCH (s:AfNode {id: $src})-[e:AfEdge {etype: $etype}]->(d:AfNode {id: $dst})" + ) + params = {"src": src, "dst": dst, "etype": edge_type} + counted = await self._query(f"{match_clause} RETURN count(e)", params) + if not counted or int(counted[0][0]) == 0: + return False + await self._query(f"{match_clause} DELETE e", params) + return True + + # -- capabilities / lifecycle ------------------------------------- + + def capabilities(self) -> set[str]: + return {"cypher"} + + async def close(self) -> None: + async with self._lock: + conn, self._conn = self._conn, None + database, self._db = self._db, None + + def _shutdown() -> None: + for closeable in (conn, database): + closer = getattr(closeable, "close", None) + if callable(closer): + closer() + + await asyncio.to_thread(_shutdown) + + +def _row_to_node(row: list[Any]) -> GraphNode: + return GraphNode( + id=row[0], + labels=tuple(json.loads(row[1]) if row[1] else []), + properties=json.loads(row[2]) if row[2] else {}, + ) + + +def _row_to_edge(row: list[Any]) -> GraphEdge: + return GraphEdge( + src=row[0], + dst=row[1], + edge_type=row[2], + properties=json.loads(row[3]) if row[3] else {}, + ) + + +def _node_matches(node: GraphNode, label: str | None, props: dict[str, Any] | None) -> bool: + if label is not None and label not in node.labels: + return False + if props: + return all(node.properties.get(key) == value for key, value in props.items()) + return True + + +__all__ = ["KuzuGraphStore"] diff --git a/packages/agentforge-memory-kuzu/src/agentforge_memory_kuzu/py.typed b/packages/agentforge-memory-kuzu/src/agentforge_memory_kuzu/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/agentforge-memory-kuzu/tests/unit/test_kuzu_graph.py b/packages/agentforge-memory-kuzu/tests/unit/test_kuzu_graph.py new file mode 100644 index 0000000..3638039 --- /dev/null +++ b/packages/agentforge-memory-kuzu/tests/unit/test_kuzu_graph.py @@ -0,0 +1,175 @@ +"""Unit + conformance tests for `KuzuGraphStore` (feat-027). + +The headline is the cross-driver conformance suite run against a **real +embedded database in a temp dir** — no server, no credentials, fully +offline. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from agentforge_core.testing import run_graph_conformance +from agentforge_core.values.graph import GraphEdge, GraphNode, GraphPattern, GraphSegment +from agentforge_memory_kuzu import KuzuGraphStore + + +@pytest.fixture +async def store(tmp_path: Path): + s = await KuzuGraphStore.from_path(tmp_path / "graph.ckg") + try: + yield s + finally: + await s.close() + + +@pytest.mark.asyncio +async def test_graph_conformance(tmp_path: Path) -> None: + async with await KuzuGraphStore.from_path(tmp_path / "conf.ckg") as s: + await run_graph_conformance(s) + + +@pytest.mark.asyncio +async def test_from_path_persists_across_reopen(tmp_path: Path) -> None: + path = tmp_path / "persist.ckg" + store = await KuzuGraphStore.from_path(path) + await store.add_node(GraphNode(id="a", labels=("Doc",), properties={"topic": "ml"})) + await store.close() + + reopened = await KuzuGraphStore.from_path(path) + try: + node = await reopened.get_node("a") + assert node is not None + assert node.labels == ("Doc",) + assert node.properties == {"topic": "ml"} + finally: + await reopened.close() + + +@pytest.mark.asyncio +async def test_from_config_equivalent_to_from_path(tmp_path: Path) -> None: + store = await KuzuGraphStore.from_config(path=tmp_path / "cfg.ckg") + try: + await store.add_node(GraphNode(id="x")) + assert (await store.get_node("x")) is not None + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_add_node_is_idempotent_upsert(store: KuzuGraphStore) -> None: + await store.add_node(GraphNode(id="a", properties={"v": 1})) + await store.add_node(GraphNode(id="a", properties={"v": 2, "w": 3})) + node = await store.get_node("a") + assert node is not None + assert node.properties == {"v": 2, "w": 3} + + +@pytest.mark.asyncio +async def test_add_edge_unknown_endpoint_raises(store: KuzuGraphStore) -> None: + await store.add_node(GraphNode(id="a")) + with pytest.raises(ValueError, match="do not exist"): + await store.add_edge(GraphEdge(src="ghost", dst="a", edge_type="CITES")) + + +@pytest.mark.asyncio +async def test_get_edges_direction_and_filter(store: KuzuGraphStore) -> None: + for nid in ("a", "b", "c"): + await store.add_node(GraphNode(id=nid)) + await store.add_edge(GraphEdge(src="b", dst="a", edge_type="CITES")) + await store.add_edge(GraphEdge(src="c", dst="a", edge_type="AUTHORED")) + + out_b = await store.get_edges("b", direction="out") + assert [(e.src, e.dst, e.edge_type) for e in out_b] == [("b", "a", "CITES")] + + in_a = await store.get_edges("a", direction="in") + assert {(e.src, e.edge_type) for e in in_a} == {("b", "CITES"), ("c", "AUTHORED")} + + in_a_cites = await store.get_edges("a", direction="in", edge_type="CITES") + assert {e.src for e in in_a_cites} == {"b"} + + any_a = await store.get_edges("a", direction="any") + assert len(any_a) == 2 # 0 out + 2 in + assert await store.get_edges("missing") == [] + + +@pytest.mark.asyncio +async def test_edge_upsert_replaces_properties(store: KuzuGraphStore) -> None: + await store.add_node(GraphNode(id="a")) + await store.add_node(GraphNode(id="b")) + await store.add_edge(GraphEdge(src="a", dst="b", edge_type="CITES", properties={"w": 1})) + await store.add_edge(GraphEdge(src="a", dst="b", edge_type="CITES", properties={"w": 9})) + edges = await store.get_edges("a", direction="out") + assert len(edges) == 1 + assert edges[0].properties == {"w": 9} + + +@pytest.mark.asyncio +async def test_traverse_depth_and_cycle_safety(store: KuzuGraphStore) -> None: + for nid in ("a", "b", "c"): + await store.add_node(GraphNode(id=nid, properties={"text": nid})) + await store.add_edge(GraphEdge(src="a", dst="b", edge_type="CITES")) + await store.add_edge(GraphEdge(src="b", dst="c", edge_type="CITES")) + await store.add_edge(GraphEdge(src="c", dst="a", edge_type="CITES")) # cycle + + paths = await store.traverse("a", max_depth=2) + # every path bounded by depth; no path revisits the seed. + assert all(len(p.edges) <= 2 for p in paths) + assert any(p.nodes[-1].id == "c" for p in paths) # a→b→c reached + # edge-type filter excludes a foreign type + await store.add_node(GraphNode(id="d")) + await store.add_edge(GraphEdge(src="a", dst="d", edge_type="OTHER")) + cites_only = await store.traverse("a", edge_types=("CITES",), max_depth=1) + assert {p.nodes[-1].id for p in cites_only} == {"b"} + assert await store.traverse("ghost", max_depth=2) == [] + + +@pytest.mark.asyncio +async def test_traverse_rejects_bad_bounds(store: KuzuGraphStore) -> None: + await store.add_node(GraphNode(id="a")) + with pytest.raises(ValueError, match="max_depth"): + await store.traverse("a", max_depth=0) + with pytest.raises(ValueError, match="limit"): + await store.traverse("a", limit=0) + + +@pytest.mark.asyncio +async def test_match_pattern(store: KuzuGraphStore) -> None: + await store.add_node(GraphNode(id="a", labels=("Doc",))) + await store.add_node(GraphNode(id="b", labels=("Doc",))) + await store.add_node(GraphNode(id="c", labels=("Author",))) + await store.add_edge(GraphEdge(src="a", dst="b", edge_type="CITES")) + await store.add_edge(GraphEdge(src="a", dst="c", edge_type="AUTHORED")) + + pattern = GraphPattern( + segments=(GraphSegment(src_label="Doc", edge_type="CITES", dst_label="Doc"),) + ) + paths = await store.match(pattern, limit=10) + assert len(paths) == 1 + assert len(paths[0].nodes) == 2 + assert paths[0].edges[0].edge_type == "CITES" + assert (await store.match(pattern, limit=1)).__len__() <= 1 + with pytest.raises(ValueError, match="limit"): + await store.match(pattern, limit=0) + + +@pytest.mark.asyncio +async def test_delete_node_and_edge(store: KuzuGraphStore) -> None: + await store.add_node(GraphNode(id="a")) + await store.add_node(GraphNode(id="b")) + await store.add_edge(GraphEdge(src="a", dst="b", edge_type="CITES")) + + assert await store.delete_node("nope") is False + with pytest.raises(ValueError, match="incident edge"): + await store.delete_node("a", cascade=False) + assert await store.delete_node("a", cascade=True) is True + assert await store.get_node("a") is None + assert await store.get_edges("b", direction="in") == [] + assert await store.delete_edge("b", "x", edge_type="CITES") is False + + +def test_capabilities() -> None: + # Pure value check — capabilities() is sync and store-independent. + caps = KuzuGraphStore(database=None, connection=None).capabilities() + assert caps == {"cypher"} diff --git a/packages/agentforge/src/agentforge/cli/release_notes.json b/packages/agentforge/src/agentforge/cli/release_notes.json index 0f5de8e..f5b3b23 100644 --- a/packages/agentforge/src/agentforge/cli/release_notes.json +++ b/packages/agentforge/src/agentforge/cli/release_notes.json @@ -6,6 +6,13 @@ "date": null, "entries": { "Added": [ + { + "label": "feat-027 — embedded `KuzuGraphStore`.", + "closes": [], + "refs": [ + "feat-027" + ] + }, { "label": "enh-005 — directional GraphRAG expansion.", "closes": [], diff --git a/pyproject.toml b/pyproject.toml index 70b3026..54a87f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "agentforge-bedrock", "agentforge-memory-sqlite", "agentforge-memory-neo4j", + "agentforge-memory-kuzu", "agentforge-memory-surrealdb", "agentforge-memory-postgres", "agentforge-eval-geval", @@ -66,6 +67,7 @@ agentforge-py = { workspace = true } agentforge-bedrock = { workspace = true } agentforge-memory-sqlite = { workspace = true } agentforge-memory-neo4j = { workspace = true } +agentforge-memory-kuzu = { workspace = true } agentforge-memory-surrealdb = { workspace = true } agentforge-memory-postgres = { workspace = true } agentforge-eval-geval = { workspace = true } @@ -222,7 +224,7 @@ disable_error_code = ["no-any-unimported"] # Allow imports without stubs so mypy --strict still passes; the # driver's public surface is still typed at our layer. [[tool.mypy.overrides]] -module = ["neo4j", "neo4j.*", "surrealdb", "surrealdb.*"] +module = ["neo4j", "neo4j.*", "surrealdb", "surrealdb.*", "kuzu", "kuzu.*"] ignore_missing_imports = true follow_imports = "skip" disable_error_code = ["no-any-unimported"] @@ -230,7 +232,11 @@ disable_error_code = ["no-any-unimported"] # Same suppression on our own neo4j and surrealdb packages — driver # types leak Any into helper signatures we type ourselves. [[tool.mypy.overrides]] -module = ["agentforge_memory_neo4j.*", "agentforge_memory_surrealdb.*"] +module = [ + "agentforge_memory_neo4j.*", + "agentforge_memory_surrealdb.*", + "agentforge_memory_kuzu.*", +] disable_error_code = ["no-any-unimported"] # asyncpg and pgvector ship without py.typed markers. Allow imports @@ -481,6 +487,7 @@ testpaths = [ "packages/agentforge-bedrock/tests", "packages/agentforge-memory-sqlite/tests", "packages/agentforge-memory-neo4j/tests", + "packages/agentforge-memory-kuzu/tests", "packages/agentforge-memory-surrealdb/tests", "packages/agentforge-memory-postgres/tests", "packages/agentforge-eval-geval/tests", @@ -549,6 +556,7 @@ source = [ "packages/agentforge-bedrock/src", "packages/agentforge-memory-sqlite/src", "packages/agentforge-memory-neo4j/src", + "packages/agentforge-memory-kuzu/src", "packages/agentforge-memory-surrealdb/src", "packages/agentforge-memory-postgres/src", "packages/agentforge-eval-geval/src", diff --git a/uv.lock b/uv.lock index df6b6ae..9f9d9fb 100644 --- a/uv.lock +++ b/uv.lock @@ -30,6 +30,7 @@ members = [ "agentforge-langfuse", "agentforge-litellm", "agentforge-mcp", + "agentforge-memory-kuzu", "agentforge-memory-neo4j", "agentforge-memory-postgres", "agentforge-memory-sqlite", @@ -354,6 +355,21 @@ requires-dist = [ ] provides-extras = ["mcp"] +[[package]] +name = "agentforge-memory-kuzu" +version = "0.3.1" +source = { editable = "packages/agentforge-memory-kuzu" } +dependencies = [ + { name = "agentforge-core" }, + { name = "kuzu" }, +] + +[package.metadata] +requires-dist = [ + { name = "agentforge-core", editable = "packages/agentforge-core" }, + { name = "kuzu", specifier = ">=0.11.3" }, +] + [[package]] name = "agentforge-memory-neo4j" version = "0.3.1" @@ -869,6 +885,7 @@ dependencies = [ { name = "agentforge-langfuse" }, { name = "agentforge-litellm" }, { name = "agentforge-mcp" }, + { name = "agentforge-memory-kuzu" }, { name = "agentforge-memory-neo4j" }, { name = "agentforge-memory-postgres" }, { name = "agentforge-memory-sqlite" }, @@ -923,6 +940,7 @@ requires-dist = [ { name = "agentforge-langfuse", editable = "packages/agentforge-langfuse" }, { name = "agentforge-litellm", editable = "packages/agentforge-litellm" }, { name = "agentforge-mcp", editable = "packages/agentforge-mcp" }, + { name = "agentforge-memory-kuzu", editable = "packages/agentforge-memory-kuzu" }, { name = "agentforge-memory-neo4j", editable = "packages/agentforge-memory-neo4j" }, { name = "agentforge-memory-postgres", editable = "packages/agentforge-memory-postgres" }, { name = "agentforge-memory-sqlite", editable = "packages/agentforge-memory-sqlite" }, @@ -2667,6 +2685,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "kuzu" +version = "0.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/0c/f141a81485729a072dc527b474e7580d5632309c68ad1a5aa6ed9ac45387/kuzu-0.11.3.tar.gz", hash = "sha256:e7bea3ca30c4bb462792eedcaa7f2125c800b243bb4a872e1eedc16917c1967a", size = 19430620, upload-time = "2025-10-10T13:36:54.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/e4/2c0e222a9b0605745234fec2774a25dd2e472699931f683f15d28ab8c076/kuzu-0.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e20ab3e3b20ccf75219872feb86582f959e313eeb59f51131adf4c91ebfabe30", size = 4093664, upload-time = "2025-10-10T13:36:13.116Z" }, + { url = "https://files.pythonhosted.org/packages/88/05/3020ed9a0a7b492597f211f805233b77ef37266a23c27efc40bb7cb37402/kuzu-0.11.3-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:054479d3ce71410b8af2f5fa6aa37883db7fea5b25606af8d3bd7cf717aa5395", size = 4520498, upload-time = "2025-10-10T13:36:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/0f/66/1a502700a7f2863f8f60621a412a7074d7eda9e92f18fd1d8d86905aa4d3/kuzu-0.11.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:143a37f1ae38b6b4337ccfcf42fa4f779a897223fff9c6c29f1a5a5a86911300", size = 6795804, upload-time = "2025-10-10T13:36:16.55Z" }, + { url = "https://files.pythonhosted.org/packages/79/c2/1ea8cdc05946cb5906a7ebb451d7268e501ebb51ebecc0437969f8c07450/kuzu-0.11.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:548bfb3045d89bce1fbe89f4a890d636789671abfa80cbde2054c671e6069133", size = 7615668, upload-time = "2025-10-10T13:36:18.882Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c3/336d6181f8f50126cf3d7186b3c5479f9f49d973145f79bed45cf87a9bb7/kuzu-0.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:87bf6c369f182a59e5b8a38b3ca288b90fab2827577d9b0d2170a202c42bc8f5", size = 4714372, upload-time = "2025-10-10T13:36:20.877Z" }, + { url = "https://files.pythonhosted.org/packages/94/db/e7e6cada6dc924eb8939bd35c5f724f5de4fc430a64d6d9e71b75cd0c271/kuzu-0.11.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5c165bc5838059e498e8325939fc6bac075e1941157e8df6ebdd710135d43b", size = 6798556, upload-time = "2025-10-10T13:36:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/a0fa02134cb255c80b5ed5bb5f6130fbbc75a8ae8be4fd6ea6eb6bc8014b/kuzu-0.11.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88c73dfd3d6a1fb374031050b725236fa9dd9a95424b09b20086a3d274bed51f", size = 7620378, upload-time = "2025-10-10T13:36:24.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b7/2a4569984995f09476dbf1ef2e0a7298aa9fdb8896f2e8195d80e11786f4/kuzu-0.11.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2752a9e37adda6aef3bf041932ae3a1cf74ca7e893bbbacdd5e62b3ac6f8c2", size = 6795649, upload-time = "2025-10-10T13:36:26.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/13/df6e06a7d7506743c3a6cfbe50ee3f9d3fc58228e2a2fcbe7e74e7c17b00/kuzu-0.11.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86be7d113e4e2c6761b1701079af8aeffc04c6981517f2d6aa393e883cc46036", size = 7615882, upload-time = "2025-10-10T13:36:28.215Z" }, + { url = "https://files.pythonhosted.org/packages/32/85/c52c3b167edcc67da3b8788a20a2fb5b4f045060cbe1aed6121ce3ce83d3/kuzu-0.11.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d72ebd88e231b562e7a60ff88d200825d53e78a681bddd7f8d77b78126a5060c", size = 6798657, upload-time = "2025-10-10T13:36:29.935Z" }, + { url = "https://files.pythonhosted.org/packages/06/be/5b4ff168718165c2ff5848ab79e22ecce72ad00522afee6820d390cb0753/kuzu-0.11.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64c7ec822906bdee154eb38d93e64f184d8f94b30bbeaceaa252725f2b9efab3", size = 7620394, upload-time = "2025-10-10T13:36:31.69Z" }, +] + [[package]] name = "langchain-core" version = "1.4.0"