Skip to content
Merged
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
3 changes: 2 additions & 1 deletion agent_assembly/adapters/langchain/callback_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import importlib
import inspect
from typing import Any, Literal, Mapping, cast
from collections.abc import Mapping
from typing import Any, Literal, cast
from uuid import UUID

from agent_assembly.exceptions import ToolExecutionBlockedError
Expand Down
24 changes: 24 additions & 0 deletions agent_assembly/adapters/langgraph/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,24 @@ def _is_compiled_subgraph(node_executor: Any) -> bool:
return hasattr(node_executor, "nodes") and hasattr(node_executor, "invoke")


def _is_pregel_node_wrapper(node_executor: Any) -> bool:
"""Return True when node_executor looks like a langgraph PregelNode.

AAASM-4434: since langgraph's Pregel graph-compilation rewrite (the node-map
entries in a CompiledStateGraph.nodes dict stopped being directly-callable/
directly-invokable node executors and became ``PregelNode`` wrapper objects),
``PregelNode.invoke``/``.ainvoke`` are present but are *not* what the Pregel
runtime calls when executing a node — it dispatches through the wrapped
Runnable at ``PregelNode.bound`` instead. A node_executor with a `.bound`
attribute that itself exposes invoke/ainvoke is that shape; wrap `.bound`,
not the PregelNode itself, or the governance hooks silently never fire.
"""
bound = getattr(node_executor, "bound", None)
if bound is None:
return False
return callable(getattr(bound, "invoke", None)) or callable(getattr(bound, "ainvoke", None))


def _current_spawn_depth() -> int:
"""Return depth for a new spawn context: current depth + 1, or 1 if root."""
current = _SPAWN_CTX.get()
Expand Down Expand Up @@ -373,6 +391,12 @@ def _wrap_node_entry(
if _is_compiled_subgraph(node_executor):
return _wrap_subgraph_spawn_node(node_map, node_name, node_executor, process_agent_id)

# AAASM-4434: current langgraph node-map entries are PregelNode wrappers whose
# own .invoke/.ainvoke are never called by the Pregel runtime — it dispatches
# through PregelNode.bound. Wrap that inner Runnable instead, or hooks never fire.
if _is_pregel_node_wrapper(node_executor):
return _wrap_node_invoke_methods(node_name, node_executor.bound, callback_handler)

return _wrap_node_invoke_methods(node_name, node_executor, callback_handler)


Expand Down
7 changes: 4 additions & 3 deletions agent_assembly/adapters/mcp/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import importlib as importlib
import importlib.util
import inspect
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, Mapping
from typing import TYPE_CHECKING, Any, Literal

if TYPE_CHECKING:
from agent_assembly.exceptions import MCPToolBlockedError
Expand Down Expand Up @@ -301,7 +302,7 @@ async def patched_call_tool(self: Any, *args: Any, **kwargs: Any) -> Any:
return result

setattr(client_session_cls, _ORIGINAL_CALL_TOOL, original_call_tool)
setattr(client_session_cls, "call_tool", patched_call_tool)
client_session_cls.call_tool = patched_call_tool
setattr(client_session_cls, _PATCHED_FLAG, True)


Expand All @@ -311,7 +312,7 @@ def _revert_client_session_patch(client_session_cls: type[Any]) -> None:

original_call_tool = getattr(client_session_cls, _ORIGINAL_CALL_TOOL, None)
if callable(original_call_tool):
setattr(client_session_cls, "call_tool", original_call_tool)
client_session_cls.call_tool = original_call_tool

if hasattr(client_session_cls, _ORIGINAL_CALL_TOOL):
delattr(client_session_cls, _ORIGINAL_CALL_TOOL)
Expand Down
3 changes: 2 additions & 1 deletion agent_assembly/adapters/registry.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from importlib import metadata
from threading import Lock
from typing import Callable, Literal
from typing import Literal

from agent_assembly.adapters.agno.adapter import AgnoAdapter
from agent_assembly.adapters.base import FrameworkAdapter
Expand Down
4 changes: 2 additions & 2 deletions agent_assembly/cli/adapter_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ def _check_supported_versions(instance: FrameworkAdapter) -> AdapterValidationRe
)


def _check_register_hooks_signature(cls: type) -> AdapterValidationResult:
def _check_register_hooks_signature(cls: type[FrameworkAdapter]) -> AdapterValidationResult:
"""Check that register_hooks accepts a GovernanceInterceptor argument."""
register_hooks = getattr(cls, "register_hooks")
register_hooks = cls.register_hooks
sig = inspect.signature(register_hooks)
params = [p for name, p in sig.parameters.items() if name != "self"]
if not params:
Expand Down
7 changes: 3 additions & 4 deletions agent_assembly/core/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
import os
import re
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, Callable, Literal, Protocol
from typing import Any, Literal, Protocol

from agent_assembly.adapters.base import FrameworkAdapter
from agent_assembly.adapters.langchain.adapter import LangChainAdapter
Expand Down Expand Up @@ -58,9 +59,7 @@ def _validate_agent_id(agent_id: str) -> str:
a malformed socket path.
"""
if not _AGENT_ID_RE.fullmatch(agent_id):
raise ValueError(
f"invalid agent_id: {agent_id!r}; must match {_AGENT_ID_RE.pattern}"
)
raise ValueError(f"invalid agent_id: {agent_id!r}; must match {_AGENT_ID_RE.pattern}")
return agent_id


Expand Down
10 changes: 5 additions & 5 deletions agent_assembly/models/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from datetime import datetime
from typing import Any, Optional
from typing import Any

from pydantic import BaseModel, Field

Expand All @@ -13,7 +13,7 @@ class AgentConfig(BaseModel):

agent_id: str = Field(..., description="Unique identifier for the agent")
name: str = Field(..., description="Human-readable name for the agent")
description: Optional[str] = Field(None, description="Description of the agent")
description: str | None = Field(None, description="Description of the agent")
version: str = Field(default="0.1.0", description="Agent version")
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation timestamp")
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update timestamp")
Expand All @@ -24,7 +24,7 @@ class AgentState(BaseModel):

agent_id: str = Field(..., description="Unique identifier for the agent")
status: str = Field(default="idle", description="Current status of the agent")
last_activity: Optional[datetime] = Field(None, description="Last activity timestamp")
last_activity: datetime | None = Field(None, description="Last activity timestamp")
metadata: dict[str, Any] = Field(default_factory=dict, description="Additional state metadata")


Expand All @@ -33,5 +33,5 @@ class PolicyEvaluation(BaseModel):

action: str = Field(..., description="Action that was evaluated")
allowed: bool = Field(..., description="Whether the action is allowed")
reason: Optional[str] = Field(None, description="Reason for the decision")
policy_id: Optional[str] = Field(None, description="ID of the policy that made the decision")
reason: str | None = Field(None, description="Reason for the decision")
policy_id: str | None = Field(None, description="ID of the policy that made the decision")
108 changes: 82 additions & 26 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,28 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"pydantic>=2.0.0,<3.0.0",
"pydantic>=2.13.4,<3.0.0",
"httpx>=0.27.0,<1.0.0",
"typing-extensions>=4.0.0",
"typing-extensions>=4.16.0",
# AAASM-1654 (PR-E): OpControlSubscriber consumes PolicyService.OpControlStream
# via gRPC. Held to grpcio's modern stable line.
"grpcio>=1.66,<2",
"protobuf>=5,<8",
"grpcio>=1.82.1,<2",
# AAASM-4434 (dependency completeness re-audit): resolves to 6.33.6, not raw
# PyPI latest (7.35.1), even though >=5,<8 permitted 7.x. Confirmed this is a
# REAL transitive ceiling, not a stale "permissive floor" resolution: the
# `dev`/test group's pydantic-ai>=2.9.0 pulls in
# `pydantic-ai-slim[...,logfire,...]`, and logfire (<=4.37.0) pins
# `opentelemetry-exporter-otlp-proto-http<1.43.0`; every otlp-proto-http
# release in that range depends on an `opentelemetry-proto` that caps
# `protobuf<7.0`. Verified by temporarily setting `protobuf>=7.35.1,<8` and
# re-running `uv lock`, which fails with a resolver conflict rooted at
# pydantic-ai's logfire extra; a bare `uv lock --upgrade` (no floor change)
# also leaves protobuf pinned at 6.33.6. Re-audit once logfire raises its
# opentelemetry-exporter-otlp-proto-http ceiling above 1.43.0.
# AAASM-4434 (manifest transparency follow-up): floor tightened from the
# wide-open "5" to the exact resolved 6.33.6 so the manifest states what is
# actually locked, not just the outer bound of what's permitted.
"protobuf>=6.33.6,<8",
]

[project.optional-dependencies]
Expand All @@ -50,51 +65,75 @@ Repository = "https://github.com/AI-agent-assembly/python-sdk"

[dependency-groups]
dev = [
"coverage~=7.10",
"coverage~=7.15",
"python-dotenv>=1.0.1,<2",
# AAASM-1654 (PR-E): grpcio-tools provides protoc + Python plugin used by
# scripts/gen_proto.py to regenerate agent_assembly/proto/*_pb2*.py from
# the sibling agent-assembly/proto/ checkout.
"grpcio-tools>=1.66,<2",
# AAASM-4434 (dependency completeness re-audit): resolves to 1.81.1, not raw
# PyPI latest (1.82.1). Not a stale-resolution bug — grpcio-tools 1.82.1
# requires protobuf>=7.35.1, which conflicts with the protobuf<7.0 ceiling
# forced transitively by pydantic-ai's logfire extra (see the protobuf
# comment under [project].dependencies for the full evidence chain).
# Re-audit alongside protobuf once that transitive ceiling lifts.
"grpcio-tools>=1.81.1,<2",
{ include-group = "lint" },
{ include-group = "test" },
]
lint = [
"ruff>=0.1.0",
"mypy>=1.11,<3",
"ruff>=0.15.21",
"mypy>=2.2.0,<3",
]
test = [
"pytest>=8.1.1,<10",
"pytest-cov>=5.0.0,<8",
"pytest-rerunfailures>=14.0,<17",
"pytest-asyncio>=0.23.0,<2",
"pytest-benchmark>=4.0.0,<6",
"pytest>=9.1.1,<10",
"pytest-cov>=7.1.0,<8",
"pytest-rerunfailures>=16.4,<17",
"pytest-asyncio>=1.4.0,<2",
"pytest-benchmark>=5.2.3,<6",
# AAASM-2943: dev/test-only (NOT a runtime dependency). Installing the
# framework lets the `importorskip`-guarded Pydantic AI integration tests
# run in CI (the `dev` group includes `test`, and the integration-test job
# installs `dev`), so the function-tool governance regression is actually
# exercised instead of skipped.
"pydantic-ai>=0.3.0",
# AAASM-4434: floor raised 0.3.0 -> 2.0.0 — with an unbounded floor, `uv lock
# --upgrade` resolved pydantic-ai down to 0.7.2 rather than latest, because an
# unconstrained solve found a cheaper-to-satisfy graph at that older version.
# Raising the floor forces resolution to latest stable, which is verified
# compatible (protobuf still resolves within our >=5,<8 range, just at
# 6.33.6 instead of 7.x).
# AAASM-4434 (manifest transparency follow-up): floor tightened again to the
# exact resolved version (2.9.0) so the manifest doesn't understate what's
# actually locked.
"pydantic-ai>=2.9.0",
# AAASM-3528: dev/test-only (NOT a runtime dependency). The shipped OpenAI
# Agents framework is the top-level `agents` package (NOT `openai.agents`),
# and a tool runs via its per-instance `on_invoke_tool` coroutine (NOT a
# `FunctionTool.__call__`). Installing it lets the `importorskip`-guarded
# integration test drive a real tool call, so a regression to the old
# fail-open no-op patch is actually caught instead of silently skipped.
"openai-agents>=0.1.0",
# AAASM-4434 (manifest transparency follow-up): floor tightened from the
# wide-open 0.1.0 to the exact resolved version (0.18.2) so the manifest
# doesn't understate what's actually locked.
"openai-agents>=0.18.2",
# AAASM-3539: dev/test-only (NOT a runtime dependency). smolagents routes
# every tool execution through `smolagents.tools.Tool.__call__` (which calls
# `self.forward`); installing it lets the `importorskip`-guarded adapter
# tests drive a real `Tool` subclass, so a regression to a fail-open no-op
# patch is caught (deny must block `forward`) instead of silently skipped.
"smolagents>=1.0.0,<2.0.0",
# AAASM-4434 (manifest transparency follow-up): floor tightened from the
# wide-open 1.0.0 to the exact resolved version (1.26.0) so the manifest
# doesn't understate what's actually locked. Upper bound <2.0.0 unchanged.
"smolagents>=1.26.0,<2.0.0",
# AAASM-3537: dev/test-only (NOT a runtime dependency). Agno (formerly
# Phidata) runs every function-tool body through
# `agno.tools.function.FunctionCall.execute` / `aexecute`. Installing it lets
# the `importorskip`-guarded integration test drive a real `FunctionCall`, so
# the negative-control deny test fails if the patch ever regresses to a no-op
# instead of being silently skipped.
"agno>=2.0.0",
# AAASM-4434 (manifest transparency follow-up): floor tightened from the
# wide-open 2.0.0 to the exact resolved version (2.7.2) so the manifest
# doesn't understate what's actually locked.
"agno>=2.7.2",
# AAASM-3536: dev/test-only (NOT a runtime dependency). LlamaIndex routes
# tool execution through the concrete `FunctionTool.call` / `acall` methods;
# installing it lets the `importorskip`-guarded governance tests drive a
Expand All @@ -108,11 +147,25 @@ test = [
# by the SDK or its tests. No upstream fix exists (3.9.4 is the latest release;
# `first_patched_version: none`), so no lock upgrade removes it. Tracked in
# AAASM-4169; Dependabot #27 recommended for dismissal as dev/test-only.
"llama-index-core>=0.10.0",
# AAASM-4434 (manifest transparency follow-up): floor tightened from the
# wide-open 0.10.0 to the exact resolved version (0.14.23) so the manifest
# doesn't understate what's actually locked.
"llama-index-core>=0.14.23",
# AAASM-4434: dev/test-only (NOT a runtime dependency). The LangGraph adapter
# (agent_assembly/adapters/langgraph/) never hard-imports langgraph — it
# reflectively loads `langgraph.graph.state.StateGraph` via importlib and
# structurally duck-types compiled-graph node maps and ToolNode so it degrades
# gracefully when the framework isn't installed. That resilience previously
# meant the patch was never exercised against a *real* langgraph install.
# Pinning latest stable here lets the `importorskip`-guarded real-package test
# (test/integration/test_langgraph_real_package_smoke.py) actually compile and
# invoke a real StateGraph, proving the reflection-based patch still matches
# LangGraph 1.x's current API surface instead of only mocked SimpleNamespaces.
"langgraph>=1.2.9,<2",
]
pre-commit-ci = [
"pre-commit>=3.5.0,<5",
"pylint>=3.1.0,<5",
"pre-commit>=4.6.0,<5",
"pylint>=4.0.6,<5",
{ include-group = "lint" },
]
# AAASM-4034: dev/test-only (NOT a runtime dependency) and DELIBERATELY excluded
Expand All @@ -127,20 +180,23 @@ pre-commit-ci = [
# .venv/bin/python -m pytest test/unit/adapters/langchain/test_getattr_contract_with_langchain.py
langchain-test = [
{ include-group = "test" },
"langchain-core>=0.3.0,<1.5.0",
"langchain-core>=1.4.9,<1.5.0",
]
docs = [
"mkdocs>=1.6.0,<2",
"mkdocs-material>=9.5.0,<10",
"mkdocstrings>=0.24.0,<2",
"mkdocstrings-python>=1.10.0,<3",
"mkdocstrings>=1.0.5,<2",
"mkdocstrings-python>=2.0.5,<3",
"mike>=2.1.0,<3",
"mkdocs-autorefs>=1.0.0,<2",
"mkdocs-git-revision-date-localized-plugin>=1.2.0,<2",
"mkdocs-autorefs>=1.4.4,<2",
"mkdocs-git-revision-date-localized-plugin>=1.5.3,<2",
"mkdocs-git-authors-plugin>=0.9.0,<1",
# AAASM-4308: shared metadata macros for docs pages. Docs-only — MUST NOT
# be added to `[project].dependencies` (runtime install stays lean).
"mkdocs-macros-plugin>=1,<2",
# AAASM-4434 (manifest transparency follow-up): floor tightened from the
# wide-open 1 to the exact resolved version (1.5.0) so the manifest
# doesn't understate what's actually locked.
"mkdocs-macros-plugin>=1.5.0,<2",
]

[tool.uv]
Expand Down
22 changes: 21 additions & 1 deletion ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ exclude = [
"dist",
"docs_with_docusarus",
"docs",
# AAASM-4434: generated gRPC/protobuf stubs (scripts/gen_proto.py), committed
# verbatim; a CI drift check regenerates them and asserts no diff, so they
# must not be hand-edited to satisfy lint (mirrors the mypy.ini ignore for the
# same directory). The ruff 0.15 bump newly flags unused-argument findings in
# the generated grpc servicer stubs.
"agent_assembly/proto/",
]

line-length = 120
Expand Down Expand Up @@ -42,10 +48,24 @@ ignore = [

[lint.per-file-ignores]
"__init__.py" = ["F401"]
"test/**/*.py" = ["S101"]
# AAASM-4434: ARG002/ARG005 (unused method/lambda argument) are the same category
# already ignored repo-wide for plain functions as ARG001 — mock/callback test
# doubles routinely accept an argument only to match the interface they stand in
# for (monkeypatched hooks, callback signatures, fixture-injected params). The
# ruff 0.15 bump is what newly enabled these two rule IDs; scope the exception to
# test/ (unlike ARG001) so a genuinely-unused argument in production code under
# agent_assembly/ still gets flagged.
"test/**/*.py" = ["S101", "ARG002", "ARG005"]

[lint.isort]
known-first-party = ["agent_assembly"]
# AAASM-4434: the pre-commit `isort` hook (real isort, --profile=black) ships a
# bundled stdlib module list that includes CPython's own `test` package (Lib/
# test/), so it always classifies this repo's top-level `test.*` imports
# (e.g. `from test.bench.conftest import ...`) as standard-library. Ruff's isort
# implementation does not, so without this the two tools disagreed on where
# `test.*` imports belong and repeatedly re-sorted each other's output.
extra-standard-library = ["test"]

[format]
quote-style = "double"
Expand Down
Loading
Loading