First commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/unit/__pycache__/test_cache.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/unit/__pycache__/test_cache.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/unit/__pycache__/test_rag.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/unit/__pycache__/test_rag.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/unit/__pycache__/test_sse.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/unit/__pycache__/test_sse.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
tests/unit/test_agent_runtime.py
Normal file
23
tests/unit/test_agent_runtime.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from app.agents.runtime import AgentRuntimeMixin
|
||||
|
||||
class DummyAgent(AgentRuntimeMixin):
|
||||
def __init__(self):
|
||||
self.settings = SimpleNamespace(CACHE_TTL_SECONDS=10)
|
||||
self.calls = 0
|
||||
self.cache = None
|
||||
self.rag_service = None
|
||||
self.telemetry = None
|
||||
class LLM:
|
||||
async def ainvoke(inner, messages):
|
||||
self.calls += 1
|
||||
return 'ok'
|
||||
self.llm = LLM()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_runtime_without_cache_invokes_llm():
|
||||
agent = DummyAgent()
|
||||
answer = await agent._invoke_llm_cached({'user_text':'oi'}, 'dummy', [{'role':'user','content':'oi'}])
|
||||
assert answer == 'ok'
|
||||
assert agent.calls == 1
|
||||
50
tests/unit/test_authentication.py
Normal file
50
tests/unit/test_authentication.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent_framework.security.authentication import ApiKeyAuthenticationProvider, BasicAuthenticationProvider
|
||||
from agent_framework.security.middleware import AuthenticationMiddleware
|
||||
|
||||
|
||||
def _basic(value: str) -> str:
|
||||
return "Basic " + base64.b64encode(value.encode()).decode()
|
||||
|
||||
|
||||
def test_basic_authentication_protects_endpoint_and_keeps_health_public():
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
provider=BasicAuthenticationProvider("tia", "sha256:" + hashlib.sha256(b"secret").hexdigest()),
|
||||
public_paths=["/health"],
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected():
|
||||
return {"status": "protected"}
|
||||
|
||||
client = TestClient(app)
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.get("/protected").status_code == 401
|
||||
assert client.get("/protected", headers={"Authorization": _basic("tia:wrong")}).status_code == 401
|
||||
assert client.get("/protected", headers={"Authorization": _basic("tia:secret")}).status_code == 200
|
||||
|
||||
|
||||
def test_api_key_authentication():
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthenticationMiddleware, provider=ApiKeyAuthenticationProvider("plain:key-123"))
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected():
|
||||
return {"status": "ok"}
|
||||
|
||||
client = TestClient(app)
|
||||
assert client.get("/protected").status_code == 401
|
||||
assert client.get("/protected", headers={"x-api-key": "key-123"}).status_code == 200
|
||||
69
tests/unit/test_authentication_policies.py
Normal file
69
tests/unit/test_authentication_policies.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent_framework.security import (
|
||||
AuthenticationPolicy,
|
||||
BasicAuthenticationProvider,
|
||||
NoAuthenticationProvider,
|
||||
PolicyAuthenticationMiddleware,
|
||||
)
|
||||
|
||||
|
||||
def _basic(client_id: str, secret: str) -> str:
|
||||
value = base64.b64encode(f"{client_id}:{secret}".encode()).decode()
|
||||
return f"Basic {value}"
|
||||
|
||||
|
||||
def test_policy_middleware_public_protected_and_default_deny():
|
||||
app = FastAPI()
|
||||
policies = [
|
||||
AuthenticationPolicy("public", NoAuthenticationProvider(), paths=("/health",)),
|
||||
AuthenticationPolicy(
|
||||
"messages",
|
||||
BasicAuthenticationProvider("tia", "plain:secret"),
|
||||
paths=("/gateway/*",),
|
||||
),
|
||||
]
|
||||
app.add_middleware(PolicyAuthenticationMiddleware, policies=policies)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/gateway/message")
|
||||
async def message(request: Request):
|
||||
return {"subject": request.state.auth_principal.subject}
|
||||
|
||||
@app.get("/unknown")
|
||||
async def unknown():
|
||||
return {"unexpected": True}
|
||||
|
||||
client = TestClient(app)
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.get("/gateway/message").status_code == 401
|
||||
authenticated = client.get("/gateway/message", headers={"Authorization": _basic("tia", "secret")})
|
||||
assert authenticated.status_code == 200
|
||||
assert authenticated.json()["subject"] == "tia"
|
||||
assert client.get("/unknown").status_code == 401
|
||||
|
||||
|
||||
def test_policy_method_filter():
|
||||
app = FastAPI()
|
||||
policies = [AuthenticationPolicy("post-only", NoAuthenticationProvider(), paths=("/resource",), methods=frozenset({"POST"}))]
|
||||
app.add_middleware(PolicyAuthenticationMiddleware, policies=policies)
|
||||
|
||||
@app.get("/resource")
|
||||
async def get_resource():
|
||||
return {"method": "GET"}
|
||||
|
||||
@app.post("/resource")
|
||||
async def post_resource():
|
||||
return {"method": "POST"}
|
||||
|
||||
client = TestClient(app)
|
||||
assert client.post("/resource").status_code == 200
|
||||
assert client.get("/resource").status_code == 401
|
||||
19
tests/unit/test_cache.py
Normal file
19
tests/unit/test_cache.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
from agent_framework.cache.cache import InMemoryCache, DistributedCache
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_in_memory_cache_ttl_expires():
|
||||
cache = InMemoryCache()
|
||||
await cache.set('k', {'v': 1}, ttl_seconds=1)
|
||||
assert await cache.get('k') == {'v': 1}
|
||||
await asyncio.sleep(1.05)
|
||||
assert await cache.get('k') is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_distributed_cache_promotes_l2_to_l1():
|
||||
l1, l2 = InMemoryCache(), InMemoryCache()
|
||||
cache = DistributedCache(l1, l2)
|
||||
await l2.set('x', 'from-l2')
|
||||
assert await cache.get('x') == 'from-l2'
|
||||
assert await l1.get('x') == 'from-l2'
|
||||
12
tests/unit/test_cache_distributed.py
Normal file
12
tests/unit/test_cache_distributed.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import pytest
|
||||
from agent_framework.cache.cache import DistributedCache, InMemoryCache
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_distributed_cache_populates_l1_from_l2():
|
||||
l1 = InMemoryCache(); l2 = InMemoryCache()
|
||||
await l2.set("k", {"v": 1})
|
||||
cache = DistributedCache(l1, l2)
|
||||
assert await cache.get("k") == {"v": 1}
|
||||
await l2.delete("k")
|
||||
assert await cache.get("k") == {"v": 1}
|
||||
5
tests/unit/test_imports_compile.py
Normal file
5
tests/unit/test_imports_compile.py
Normal file
@@ -0,0 +1,5 @@
|
||||
def test_core_imports():
|
||||
import agent_framework.cache.cache
|
||||
import agent_framework.rag.rag_service
|
||||
import agent_framework.checkpoints.langgraph_saver
|
||||
import agent_framework.observability.telemetry
|
||||
64
tests/unit/test_langfuse_openai_correlation_guardrail.py
Normal file
64
tests/unit/test_langfuse_openai_correlation_guardrail.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.guardrails.calibrated.llm_client import GuardrailLLMClient
|
||||
from agent_framework.llm.providers import OCICompatibleOpenAIProvider
|
||||
from agent_framework.observability.context import (
|
||||
clear_observability_context,
|
||||
get_observability_context,
|
||||
set_observability_context,
|
||||
)
|
||||
|
||||
|
||||
def test_openai_langfuse_wrapper_is_disabled_inside_active_framework_trace(monkeypatch):
|
||||
"""A correlated request must never create a standalone OpenAI-generation trace."""
|
||||
clear_observability_context()
|
||||
set_observability_context(request_id="req-123", trace_id="trace-123")
|
||||
monkeypatch.setenv("ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION", "true")
|
||||
|
||||
provider = OCICompatibleOpenAIProvider.__new__(OCICompatibleOpenAIProvider)
|
||||
provider.telemetry = None # compatibility path: no Telemetry explicitly injected
|
||||
settings = SimpleNamespace(
|
||||
ENABLE_LANGFUSE=True,
|
||||
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=True,
|
||||
)
|
||||
|
||||
fake_openai = ModuleType("openai")
|
||||
class AsyncOpenAI:
|
||||
pass
|
||||
AsyncOpenAI.__module__ = "openai"
|
||||
fake_openai.AsyncOpenAI = AsyncOpenAI
|
||||
monkeypatch.setitem(sys.modules, "openai", fake_openai)
|
||||
|
||||
client_cls = provider._resolve_async_openai(settings)
|
||||
|
||||
# Standard OpenAI client = framework owns observability/correlation.
|
||||
assert client_cls.__module__.startswith("openai")
|
||||
assert "langfuse" not in client_cls.__module__
|
||||
clear_observability_context()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guardrail_sync_bridge_preserves_observability_context_in_worker(monkeypatch):
|
||||
"""Legacy sync guardrail bridge must carry request/trace ContextVars to its worker."""
|
||||
clear_observability_context()
|
||||
set_observability_context(request_id="req-guardrail", trace_id="trace-guardrail")
|
||||
|
||||
import agent_framework.guardrails.framework_llm_client as framework_client
|
||||
|
||||
async def fake_classifier(llm, task, payload, **kwargs):
|
||||
ctx = get_observability_context()
|
||||
return {"request_id": ctx.request_id, "trace_id": ctx.trace_id, "task": task}
|
||||
|
||||
monkeypatch.setattr(framework_client, "classify_with_framework_llm", fake_classifier)
|
||||
|
||||
result = GuardrailLLMClient._run_framework_classifier("TOX", {"text": "ok"})
|
||||
|
||||
assert result["request_id"] == "req-guardrail"
|
||||
assert result["trace_id"] == "trace-guardrail"
|
||||
assert result["task"] == "TOX"
|
||||
clear_observability_context()
|
||||
160
tests/unit/test_langgraph_checkpoint_interrupt_controlled.py
Normal file
160
tests/unit/test_langgraph_checkpoint_interrupt_controlled.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.checkpoints.langgraph_saver import (
|
||||
RepositoryCheckpointSaver,
|
||||
_TYPED_SERDE_MARKER,
|
||||
_decode_checkpoint_value,
|
||||
_encode_checkpoint_value,
|
||||
_encode_pending_write_value,
|
||||
)
|
||||
|
||||
|
||||
Interrupt = type("Interrupt", (), {})
|
||||
Interrupt.__module__ = "langgraph.types"
|
||||
|
||||
|
||||
class FakeSerde:
|
||||
def dumps_typed(self, value):
|
||||
assert type(value).__module__ == "langgraph.types"
|
||||
assert type(value).__qualname__ == "Interrupt"
|
||||
return "msgpack", b"interrupt-payload"
|
||||
|
||||
def loads_typed(self, typed):
|
||||
type_name, payload = typed
|
||||
assert type_name == "msgpack"
|
||||
assert payload == b"interrupt-payload"
|
||||
return Interrupt()
|
||||
|
||||
|
||||
class MemoryRepo:
|
||||
def __init__(self):
|
||||
self.value = None
|
||||
|
||||
async def put(self, thread_id, value):
|
||||
self.value = value
|
||||
|
||||
async def get_latest(self, thread_id):
|
||||
return self.value
|
||||
|
||||
|
||||
def test_checkpoint_interrupt_only_in_special_channel_round_trips():
|
||||
serde = FakeSerde()
|
||||
raw_interrupt = Interrupt()
|
||||
checkpoint = {
|
||||
"id": "cp1",
|
||||
"channel_values": {
|
||||
"__root__": {
|
||||
"__interrupt__": [raw_interrupt],
|
||||
"business": {"ok": True},
|
||||
},
|
||||
"ordinary": {"value": 1},
|
||||
},
|
||||
}
|
||||
|
||||
encoded = _encode_checkpoint_value(serde, checkpoint)
|
||||
leaf = encoded["channel_values"]["__root__"]["__interrupt__"][0]
|
||||
assert leaf[_TYPED_SERDE_MARKER] is True
|
||||
assert leaf["type"] == "msgpack"
|
||||
assert base64.b64decode(leaf["data"]) == b"interrupt-payload"
|
||||
assert encoded["channel_values"]["ordinary"] == {"value": 1}
|
||||
|
||||
decoded = _decode_checkpoint_value(serde, encoded)
|
||||
assert type(decoded["channel_values"]["__root__"]["__interrupt__"][0]).__module__ == "langgraph.types"
|
||||
assert decoded["channel_values"]["ordinary"] == {"value": 1}
|
||||
|
||||
|
||||
def test_checkpoint_interrupt_outside_special_channel_is_rejected():
|
||||
serde = FakeSerde()
|
||||
checkpoint = {
|
||||
"id": "cp1",
|
||||
"channel_values": {"ordinary": [Interrupt()]},
|
||||
}
|
||||
with pytest.raises(TypeError, match="não serializável"):
|
||||
_encode_checkpoint_value(serde, checkpoint)
|
||||
|
||||
|
||||
def test_checkpoint_unknown_object_inside_interrupt_channel_is_rejected():
|
||||
serde = FakeSerde()
|
||||
|
||||
class Unknown:
|
||||
pass
|
||||
|
||||
checkpoint = {
|
||||
"id": "cp1",
|
||||
"channel_values": {"__root__": {"__interrupt__": [Unknown()]}},
|
||||
}
|
||||
with pytest.raises(TypeError, match="não serializável"):
|
||||
_encode_checkpoint_value(serde, checkpoint)
|
||||
|
||||
|
||||
def test_plain_checkpoint_keeps_plain_json_shape():
|
||||
serde = FakeSerde()
|
||||
checkpoint = {
|
||||
"id": "cp1",
|
||||
"channel_values": {"x": {"nested": [1, "a", True, None]}},
|
||||
"channel_versions": {"x": "1"},
|
||||
}
|
||||
encoded = _encode_checkpoint_value(serde, checkpoint)
|
||||
assert encoded == checkpoint
|
||||
assert _TYPED_SERDE_MARKER not in json.dumps(encoded)
|
||||
|
||||
|
||||
def test_pending_write_interrupt_requires_interrupt_branch():
|
||||
serde = FakeSerde()
|
||||
encoded = _encode_pending_write_value(
|
||||
serde,
|
||||
{"__interrupt__": [Interrupt()]},
|
||||
path="$.pending_writes[t].__root__",
|
||||
)
|
||||
assert encoded["__interrupt__"][0][_TYPED_SERDE_MARKER] is True
|
||||
|
||||
with pytest.raises(TypeError, match="não serializável"):
|
||||
_encode_pending_write_value(
|
||||
serde,
|
||||
{"ordinary": [Interrupt()]},
|
||||
path="$.pending_writes[t].__root__",
|
||||
)
|
||||
|
||||
|
||||
def test_aput_encodes_only_checkpoint_interrupt_and_keeps_other_sections_strict():
|
||||
repo = MemoryRepo()
|
||||
settings = SimpleNamespace(CHECKPOINT_REPOSITORY_PROVIDER="memory")
|
||||
saver = RepositoryCheckpointSaver(settings, repository=repo)
|
||||
saver.serde = FakeSerde()
|
||||
|
||||
checkpoint = {
|
||||
"id": "cp1",
|
||||
"channel_values": {"__root__": {"__interrupt__": [Interrupt()]}},
|
||||
}
|
||||
|
||||
import asyncio
|
||||
asyncio.run(saver.aput(
|
||||
{"configurable": {"thread_id": "t1"}},
|
||||
checkpoint,
|
||||
{"source": "test"},
|
||||
{"x": 1},
|
||||
))
|
||||
|
||||
assert repo.value["metadata"] == {"source": "test"}
|
||||
assert repo.value["new_versions"] == {"x": 1}
|
||||
assert repo.value["checkpoint"]["channel_values"]["__root__"]["__interrupt__"][0][_TYPED_SERDE_MARKER] is True
|
||||
|
||||
|
||||
def test_aput_does_not_enable_typed_serde_for_metadata():
|
||||
repo = MemoryRepo()
|
||||
settings = SimpleNamespace(CHECKPOINT_REPOSITORY_PROVIDER="memory")
|
||||
saver = RepositoryCheckpointSaver(settings, repository=repo)
|
||||
saver.serde = FakeSerde()
|
||||
|
||||
import asyncio
|
||||
with pytest.raises(TypeError, match="metadata"):
|
||||
asyncio.run(saver.aput(
|
||||
{"configurable": {"thread_id": "t1"}},
|
||||
{"id": "cp1", "channel_values": {"x": 1}},
|
||||
{"bad": Interrupt()},
|
||||
{},
|
||||
))
|
||||
325
tests/unit/test_langgraph_checkpoint_runtime_config.py
Normal file
325
tests/unit/test_langgraph_checkpoint_runtime_config.py
Normal file
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.checkpoints.langgraph_saver import (
|
||||
RepositoryCheckpointSaver,
|
||||
_durable_config,
|
||||
)
|
||||
|
||||
|
||||
class _Repo:
|
||||
def __init__(self) -> None:
|
||||
self.saved = None
|
||||
|
||||
async def put(self, thread_id, checkpoint):
|
||||
self.saved = (thread_id, checkpoint)
|
||||
|
||||
async def get_latest(self, thread_id):
|
||||
return self.saved[1] if self.saved and self.saved[0] == thread_id else None
|
||||
|
||||
|
||||
def test_private_pregel_runtime_is_not_durable() -> None:
|
||||
config = {
|
||||
"tags": ["x"],
|
||||
"configurable": {
|
||||
"thread_id": "t1",
|
||||
"tenant": "default",
|
||||
"__pregel_runtime": "stringified-runtime",
|
||||
"__pregel_store": "stringified-store",
|
||||
},
|
||||
}
|
||||
|
||||
cleaned = _durable_config(config)
|
||||
|
||||
assert cleaned == {
|
||||
"tags": ["x"],
|
||||
"configurable": {"thread_id": "t1", "tenant": "default"},
|
||||
}
|
||||
assert "__pregel_runtime" in config["configurable"]
|
||||
|
||||
|
||||
def test_saver_strips_private_runtime_before_put_and_restore() -> None:
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": "t1",
|
||||
"__pregel_runtime": "Runtime(...) persisted by JSON backend",
|
||||
}
|
||||
}
|
||||
checkpoint = {"id": "cp1", "v": 1}
|
||||
|
||||
returned = asyncio.run(saver.aput(config, checkpoint, {}, {}))
|
||||
assert returned["configurable"] == {"thread_id": "t1", "checkpoint_ns": "", "checkpoint_id": "cp1"}
|
||||
assert repo.saved is not None
|
||||
persisted = repo.saved[1]
|
||||
assert "__pregel_runtime" not in persisted["config"]["configurable"]
|
||||
|
||||
# Also protects legacy records already stored with a stringified runtime.
|
||||
persisted["config"]["configurable"]["__pregel_runtime"] = "legacy-string"
|
||||
restored = saver._make_tuple(persisted)
|
||||
restored_config = restored.config if hasattr(restored, "config") else restored["config"]
|
||||
assert "__pregel_runtime" not in restored_config["configurable"]
|
||||
|
||||
|
||||
def test_nested_pregel_runtime_is_removed_from_checkpoint_and_pending_writes() -> None:
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
config = {"configurable": {"thread_id": "t-nested"}}
|
||||
checkpoint = {
|
||||
"id": "cp-nested",
|
||||
"v": 1,
|
||||
"channel_values": {
|
||||
"__pregel_tasks": {"durable": True},
|
||||
"task": {
|
||||
"config": {
|
||||
"configurable": {
|
||||
"thread_id": "t-nested",
|
||||
"__pregel_runtime": "Runtime(...) nested",
|
||||
"business_key": "kept",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
asyncio.run(saver.aput(config, checkpoint, {}, {}))
|
||||
asyncio.run(
|
||||
saver.aput_writes(
|
||||
config,
|
||||
[
|
||||
(
|
||||
"tasks",
|
||||
{
|
||||
"nested": {
|
||||
"configurable": {
|
||||
"__pregel_runtime": "Runtime(...) write",
|
||||
"subject": "TIM Fashion",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
"task-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert repo.saved is not None
|
||||
persisted = repo.saved[1]
|
||||
assert persisted["checkpoint"]["channel_values"]["__pregel_tasks"] == {"durable": True}
|
||||
task_config = persisted["checkpoint"]["channel_values"]["task"]["config"]["configurable"]
|
||||
assert "__pregel_runtime" not in task_config
|
||||
assert task_config["business_key"] == "kept"
|
||||
pending_cfg = persisted["pending_writes"][0]["value"]["nested"]["configurable"]
|
||||
assert "__pregel_runtime" not in pending_cfg
|
||||
assert pending_cfg["subject"] == "TIM Fashion"
|
||||
|
||||
|
||||
def test_restore_rebuilds_fresh_canonical_config_instead_of_rebinding_stored_config() -> None:
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
legacy_payload = {
|
||||
"thread_id": "thread-legacy",
|
||||
"checkpoint_id": "cp-legacy",
|
||||
"config": {
|
||||
"tags": ["old-run"],
|
||||
"configurable": {
|
||||
"thread_id": "thread-legacy",
|
||||
"checkpoint_ns": "wf",
|
||||
"checkpoint_id": "cp-legacy",
|
||||
"tenant": "default",
|
||||
"__pregel_runtime": "stringified-runtime",
|
||||
"__pregel_store": "stringified-store",
|
||||
},
|
||||
},
|
||||
"checkpoint": {"id": "cp-legacy", "v": 1},
|
||||
"metadata": {},
|
||||
}
|
||||
asyncio.run(repo.put("thread-legacy", legacy_payload))
|
||||
|
||||
# Simulate a new invocation. Runtime/private keys supplied by a previous run
|
||||
# must never be rebound from the persisted checkpoint tuple.
|
||||
request = {
|
||||
"tags": ["new-run"],
|
||||
"configurable": {
|
||||
"thread_id": "thread-legacy",
|
||||
"checkpoint_ns": "wf",
|
||||
"__pregel_runtime": "request-runtime-placeholder",
|
||||
},
|
||||
}
|
||||
restored = asyncio.run(saver.aget_tuple(request))
|
||||
restored_config = restored.config if hasattr(restored, "config") else restored["config"]
|
||||
|
||||
assert restored_config == {
|
||||
"configurable": {
|
||||
"thread_id": "thread-legacy",
|
||||
"checkpoint_ns": "wf",
|
||||
"checkpoint_id": "cp-legacy",
|
||||
}
|
||||
}
|
||||
assert "tags" not in restored_config
|
||||
assert "tenant" not in restored_config["configurable"]
|
||||
assert "__pregel_runtime" not in restored_config["configurable"]
|
||||
|
||||
|
||||
def test_legacy_checkpoint_structural_json_strings_are_recovered() -> None:
|
||||
from agent_framework.checkpoints.langgraph_saver import _normalize_checkpoint, _normalize_config
|
||||
|
||||
legacy = {
|
||||
"v": 1,
|
||||
"id": "cp-json",
|
||||
"channel_values": '{"state":{"ok":true}}',
|
||||
"channel_versions": '{"state":"0001"}',
|
||||
"versions_seen": '{"node":"{\\"state\\":\\"0001\\"}"}',
|
||||
}
|
||||
normalized = _normalize_checkpoint(legacy)
|
||||
assert normalized["channel_values"] == {"state": {"ok": True}}
|
||||
assert normalized["channel_versions"] == {"state": "0001"}
|
||||
assert normalized["versions_seen"] == {"node": {"state": "0001"}}
|
||||
|
||||
cfg = _normalize_config({"configurable": '{"thread_id":"t1","checkpoint_ns":""}'})
|
||||
assert cfg["configurable"] == {"thread_id": "t1", "checkpoint_ns": ""}
|
||||
|
||||
|
||||
def test_checkpoint_serialization_never_silently_stringifies_unknown_objects() -> None:
|
||||
import pytest
|
||||
from agent_framework.checkpoints.langgraph_saver import _strict_json_value
|
||||
|
||||
class RuntimeLike:
|
||||
def override(self):
|
||||
return self
|
||||
|
||||
with pytest.raises(TypeError, match="Checkpoint contém valor não serializável"):
|
||||
_strict_json_value({"channel_values": {"bad": RuntimeLike()}}, path="$.checkpoint")
|
||||
|
||||
|
||||
def test_typed_serializer_bridge_is_limited_to_langgraph_interrupt_pending_write() -> None:
|
||||
import base64
|
||||
import json
|
||||
|
||||
Interrupt = type("Interrupt", (), {"__module__": "langgraph.types"})
|
||||
|
||||
class FakeSerde:
|
||||
def dumps_typed(self, value):
|
||||
assert type(value).__module__ == "langgraph.types"
|
||||
assert type(value).__qualname__ == "Interrupt"
|
||||
return ("fake_interrupt", json.dumps({"value": value.value}).encode("utf-8"))
|
||||
|
||||
def loads_typed(self, payload):
|
||||
type_name, raw = payload
|
||||
assert type_name == "fake_interrupt"
|
||||
obj = Interrupt()
|
||||
obj.value = json.loads(raw.decode("utf-8"))["value"]
|
||||
return obj
|
||||
|
||||
interrupt = Interrupt()
|
||||
interrupt.value = "pause"
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
saver.serde = FakeSerde()
|
||||
config = {"configurable": {"thread_id": "typed-thread"}}
|
||||
|
||||
asyncio.run(saver.aput(config, {"id": "cp-typed", "channel_values": {}}, {}, {}))
|
||||
# Mirrors the real LangGraph shape observed in the runtime log:
|
||||
# pending_writes -> __root__ -> __interrupt__ -> [Interrupt(...)]
|
||||
asyncio.run(saver.aput_writes(
|
||||
config,
|
||||
[("__root__", {"__interrupt__": [interrupt]})],
|
||||
"task-typed",
|
||||
))
|
||||
|
||||
persisted = repo.saved[1]
|
||||
stored = persisted["pending_writes"][0]["value"]["__interrupt__"][0]
|
||||
assert stored["__agent_framework_langgraph_typed__"] is True
|
||||
assert stored["type"] == "fake_interrupt"
|
||||
assert base64.b64decode(stored["data"]).decode("utf-8") == '{"value": "pause"}'
|
||||
# Critical compatibility assertion: checkpoint/metadata/new_versions remain plain JSON.
|
||||
assert persisted["checkpoint"] == {"id": "cp-typed", "channel_values": {}, "channel_versions": {}, "versions_seen": {}}
|
||||
assert persisted["metadata"] == {}
|
||||
|
||||
restored = saver._make_tuple(persisted)
|
||||
pending = restored.pending_writes if hasattr(restored, "pending_writes") else restored["pending_writes"]
|
||||
assert pending[0][0] == "task-typed"
|
||||
assert pending[0][1] == "__root__"
|
||||
restored_interrupt = pending[0][2]["__interrupt__"][0]
|
||||
assert type(restored_interrupt).__module__ == "langgraph.types"
|
||||
assert type(restored_interrupt).__qualname__ == "Interrupt"
|
||||
assert restored_interrupt.value == "pause"
|
||||
|
||||
|
||||
def test_non_interrupt_non_json_pending_write_still_fails_loudly() -> None:
|
||||
class RuntimeLike:
|
||||
pass
|
||||
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
config = {"configurable": {"thread_id": "bad-pending-thread"}}
|
||||
asyncio.run(saver.aput(config, {"id": "cp-bad", "channel_values": {}}, {}, {}))
|
||||
|
||||
with pytest.raises(TypeError, match="Checkpoint contém valor não serializável"):
|
||||
asyncio.run(saver.aput_writes(config, [("__root__", {"bad": RuntimeLike()})], "task-bad"))
|
||||
|
||||
|
||||
def test_non_json_checkpoint_does_not_fall_back_to_typed_serde() -> None:
|
||||
class RuntimeLike:
|
||||
pass
|
||||
|
||||
class ExplodingSerde:
|
||||
def dumps_typed(self, value):
|
||||
raise AssertionError("global typed serde fallback must not be used")
|
||||
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
saver.serde = ExplodingSerde()
|
||||
config = {"configurable": {"thread_id": "bad-checkpoint-thread"}}
|
||||
|
||||
with pytest.raises(TypeError, match="Checkpoint contém valor não serializável"):
|
||||
asyncio.run(saver.aput(
|
||||
config,
|
||||
{"id": "cp-bad", "channel_values": {"bad": RuntimeLike()}},
|
||||
{},
|
||||
{},
|
||||
))
|
||||
|
||||
|
||||
def test_json_pending_writes_remain_plain_json() -> None:
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
config = {"configurable": {"thread_id": "plain-thread"}}
|
||||
|
||||
asyncio.run(saver.aput(config, {"id": "cp-plain", "channel_values": {}}, {}, {}))
|
||||
asyncio.run(saver.aput_writes(config, [("result", {"ok": True})], "task-plain"))
|
||||
|
||||
persisted = repo.saved[1]
|
||||
assert persisted["pending_writes"][0]["value"] == {"ok": True}
|
||||
|
||||
|
||||
def test_stale_pending_write_cannot_replace_newer_checkpoint() -> None:
|
||||
"""A delayed write for cp1 must never make cp1 latest after cp2 exists."""
|
||||
repo = _Repo()
|
||||
saver = RepositoryCheckpointSaver(SimpleNamespace(), repository=repo)
|
||||
|
||||
cfg0 = {"configurable": {"thread_id": "tx-thread"}}
|
||||
cp1_cfg = asyncio.run(saver.aput(cfg0, {"id": "cp1", "v": 1, "channel_values": {"transaction_status": "COMPLETED"}}, {}, {}))
|
||||
cp2_cfg = asyncio.run(saver.aput(cp1_cfg, {"id": "cp2", "v": 1, "channel_values": {"transaction_status": "AWAITING_CONFIRMATION", "confirmation_required": True}}, {}, {}))
|
||||
|
||||
# Simulates aput_writes from the older cp1 finishing after cp2 was persisted.
|
||||
asyncio.run(saver.aput_writes(cp1_cfg, [("result", {"old": True})], "late-task"))
|
||||
|
||||
assert repo.saved is not None
|
||||
latest = repo.saved[1]
|
||||
assert latest["checkpoint_id"] == "cp2"
|
||||
assert latest["checkpoint"]["channel_values"]["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert "pending_writes" not in latest or not any(
|
||||
isinstance(item, dict) and item.get("task_id") == "late-task"
|
||||
for item in latest.get("pending_writes", [])
|
||||
)
|
||||
|
||||
# A write for the actual latest checkpoint is still accepted.
|
||||
asyncio.run(saver.aput_writes(cp2_cfg, [("result", {"new": True})], "current-task"))
|
||||
latest = repo.saved[1]
|
||||
assert latest["checkpoint_id"] == "cp2"
|
||||
assert any(item.get("task_id") == "current-task" for item in latest.get("pending_writes", []))
|
||||
14
tests/unit/test_langgraph_checkpoint_saver.py
Normal file
14
tests/unit/test_langgraph_checkpoint_saver.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.checkpoints.langgraph_saver import RepositoryCheckpointSaver
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_checkpoint_saver_put_get(tmp_path):
|
||||
settings = SimpleNamespace(CHECKPOINT_REPOSITORY_PROVIDER='sqlite', SQLITE_DB_PATH=str(tmp_path/'db.sqlite'))
|
||||
saver = RepositoryCheckpointSaver(settings)
|
||||
config = {'configurable': {'thread_id': 't1'}}
|
||||
next_config = await saver.aput(config, {'id': 'cp1', 'channel_values': {'x': 1}}, {'source': 'test'}, {})
|
||||
assert next_config['configurable']['checkpoint_id'] == 'cp1'
|
||||
tup = await saver.aget_tuple(config)
|
||||
checkpoint = tup.checkpoint if hasattr(tup, 'checkpoint') else tup['checkpoint']
|
||||
assert checkpoint['id'] == 'cp1'
|
||||
29
tests/unit/test_langgraph_telemetry.py
Normal file
29
tests/unit/test_langgraph_telemetry.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
|
||||
|
||||
class FakeTelemetry:
|
||||
def __init__(self): self.events=[]
|
||||
async def event(self, name, payload=None, kind='event'):
|
||||
self.events.append((name, payload or {}, kind))
|
||||
def span(self, name, **attrs):
|
||||
class CM:
|
||||
async def __aenter__(self_inner): return None
|
||||
async def __aexit__(self_inner, exc_type, exc, tb): return False
|
||||
return CM()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langgraph_node_emits_started_completed():
|
||||
telemetry = FakeTelemetry()
|
||||
tracer = LangGraphDeepTelemetry(telemetry)
|
||||
async with tracer.node('router', {'session_id': 's1'}):
|
||||
pass
|
||||
names = [e[0] for e in telemetry.events]
|
||||
assert 'langgraph.node.started' in names
|
||||
assert 'langgraph.node.completed' in names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langgraph_edge_event():
|
||||
telemetry = FakeTelemetry()
|
||||
tracer = LangGraphDeepTelemetry(telemetry)
|
||||
await tracer.edge('routing', 'billing', {'session_id': 's1'}, {'confidence': 0.9})
|
||||
assert telemetry.events[0][0] == 'langgraph.edge.selected'
|
||||
106
tests/unit/test_llm_rich_response.py
Normal file
106
tests/unit/test_llm_rich_response.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.llm.base import LLMProvider
|
||||
from agent_framework.llm.providers import (
|
||||
MockLLMProvider,
|
||||
OCISDKProvider,
|
||||
_extract_reasoning_content,
|
||||
_extract_openai_message_content,
|
||||
_extract_finish_reason,
|
||||
)
|
||||
from agent_framework.llm.types import LLMResponse
|
||||
|
||||
|
||||
class LegacyOnlyProvider(LLMProvider):
|
||||
async def ainvoke(self, messages, **kwargs) -> str:
|
||||
return "legacy-answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_provider_gets_rich_response_fallback_without_breaking_contract():
|
||||
provider = LegacyOnlyProvider()
|
||||
|
||||
legacy = await provider.ainvoke([{"role": "user", "content": "hello"}])
|
||||
rich = await provider.ainvoke_response([{"role": "user", "content": "hello"}])
|
||||
|
||||
assert legacy == "legacy-answer"
|
||||
assert isinstance(legacy, str)
|
||||
assert isinstance(rich, LLMResponse)
|
||||
assert rich.content == legacy
|
||||
assert rich.reasoning_content is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_ainvoke_remains_string_and_rich_api_is_opt_in():
|
||||
provider = MockLLMProvider()
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
|
||||
legacy = await provider.ainvoke(messages)
|
||||
rich = await provider.ainvoke_response(messages)
|
||||
|
||||
assert isinstance(legacy, str)
|
||||
assert legacy == rich.content
|
||||
assert rich.provider == "mock"
|
||||
assert rich.model == "mock-llm"
|
||||
assert rich.reasoning_content is None
|
||||
assert rich.usage["total_tokens"] > 0
|
||||
|
||||
|
||||
def test_openai_compatible_reasoning_content_attribute_is_extracted():
|
||||
message = SimpleNamespace(content="answer", reasoning_content="model reasoning")
|
||||
assert _extract_reasoning_content(message) == "model reasoning"
|
||||
|
||||
|
||||
def test_openai_compatible_reasoning_content_model_extra_is_extracted():
|
||||
message = SimpleNamespace(content="answer", model_extra={"reasoning_content": "extra reasoning"})
|
||||
assert _extract_reasoning_content(message) == "extra reasoning"
|
||||
|
||||
|
||||
def test_missing_reasoning_content_is_none():
|
||||
message = SimpleNamespace(content="answer")
|
||||
assert _extract_reasoning_content(message) is None
|
||||
|
||||
|
||||
def test_oci_sdk_reasoning_is_extracted_from_choice_message():
|
||||
message = SimpleNamespace(content="answer", reasoning_content="oci reasoning")
|
||||
choice = SimpleNamespace(message=message)
|
||||
chat_response = SimpleNamespace(choices=[choice])
|
||||
response = SimpleNamespace(data=SimpleNamespace(chat_response=chat_response))
|
||||
|
||||
assert OCISDKProvider._extract_reasoning_content(response) == "oci reasoning"
|
||||
|
||||
|
||||
def test_openai_compatible_plain_string_content_is_preserved():
|
||||
message = SimpleNamespace(content='{"decision":"KEEP"}')
|
||||
assert _extract_openai_message_content(message) == '{"decision":"KEEP"}'
|
||||
|
||||
|
||||
def test_openai_compatible_list_content_is_joined():
|
||||
message = SimpleNamespace(content=[
|
||||
SimpleNamespace(text='{"decision":'),
|
||||
{"text": '"KEEP"}'},
|
||||
])
|
||||
assert _extract_openai_message_content(message) == '{"decision":"KEEP"}'
|
||||
|
||||
|
||||
def test_openai_compatible_dict_message_and_content_are_supported():
|
||||
message = {"content": [{"text": "{\"ok\":true}"}]}
|
||||
assert _extract_openai_message_content(message) == '{"ok":true}'
|
||||
|
||||
|
||||
def test_openai_compatible_unknown_content_fails_closed_to_empty_string():
|
||||
message = SimpleNamespace(content=object())
|
||||
assert _extract_openai_message_content(message) == ""
|
||||
|
||||
|
||||
def test_openai_compatible_reasoning_is_not_used_as_answer_fallback():
|
||||
message = SimpleNamespace(content=None, reasoning_content='{"decision":"KEEP"}')
|
||||
assert _extract_openai_message_content(message) == ""
|
||||
assert _extract_reasoning_content(message) == '{"decision":"KEEP"}'
|
||||
|
||||
|
||||
def test_finish_reason_is_extracted_from_object_and_dict():
|
||||
assert _extract_finish_reason(SimpleNamespace(finish_reason="length")) == "length"
|
||||
assert _extract_finish_reason({"finish_reason": "stop"}) == "stop"
|
||||
44
tests/unit/test_long_term_memory_autonomous.py
Normal file
44
tests/unit/test_long_term_memory_autonomous.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.memory.long_term_store import (
|
||||
InMemoryLongTermMemoryStore,
|
||||
OracleAutonomousLongTermMemoryStore,
|
||||
SQLiteLongTermMemoryStore,
|
||||
create_long_term_memory_store,
|
||||
)
|
||||
|
||||
|
||||
def settings(provider: str):
|
||||
return SimpleNamespace(
|
||||
LONG_TERM_MEMORY_PROVIDER=provider,
|
||||
LONG_TERM_MEMORY_SQLITE_PATH=":memory:",
|
||||
LONG_TERM_MEMORY_TABLE="agentfw_long_term_memory",
|
||||
LONG_TERM_MEMORY_ORACLE_TABLE=None,
|
||||
ADB_USER="user",
|
||||
ADB_PASSWORD="password",
|
||||
ADB_DSN="service_high",
|
||||
ADB_WALLET_LOCATION=None,
|
||||
ADB_WALLET_PASSWORD=None,
|
||||
ADB_TABLE_PREFIX="AGENTFW",
|
||||
)
|
||||
|
||||
|
||||
def test_factory_memory():
|
||||
assert isinstance(create_long_term_memory_store(settings("memory")), InMemoryLongTermMemoryStore)
|
||||
|
||||
|
||||
def test_factory_sqlite():
|
||||
assert isinstance(create_long_term_memory_store(settings("sqlite")), SQLiteLongTermMemoryStore)
|
||||
|
||||
|
||||
def test_factory_autonomous():
|
||||
store = create_long_term_memory_store(settings("autonomous"))
|
||||
assert isinstance(store, OracleAutonomousLongTermMemoryStore)
|
||||
assert store.table == "AGENTFW_LONG_TERM_MEMORY"
|
||||
|
||||
|
||||
def test_factory_oracle_alias():
|
||||
assert isinstance(
|
||||
create_long_term_memory_store(settings("oracle")),
|
||||
OracleAutonomousLongTermMemoryStore,
|
||||
)
|
||||
219
tests/unit/test_multi_intent_planner.py
Normal file
219
tests/unit/test_multi_intent_planner.py
Normal file
@@ -0,0 +1,219 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
from agent_framework.routing.models import IntentDefinition
|
||||
from agent_framework.routing.multi_intent import MultiIntentPlanner
|
||||
|
||||
|
||||
def _planner():
|
||||
return MultiIntentPlanner([
|
||||
IntentDefinition(name="cancel", agent="orders", priority=10, keywords=["cancelar pedido"], mcp_tools=["cancelar_pedido"]),
|
||||
IntentDefinition(name="invoice", agent="billing", priority=20, keywords=["segunda via"], mcp_tools=["consultar_fatura"]),
|
||||
], {
|
||||
"enabled": True,
|
||||
}, transactional_tools={"cancelar_pedido"})
|
||||
|
||||
|
||||
def test_builds_validated_plan_and_keeps_primary_first():
|
||||
plan = _planner().plan("quero cancelar pedido e preciso da segunda via")
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
assert plan.operations[1].disposition == "execute"
|
||||
assert plan.operations[1].tools == ["consultar_fatura"]
|
||||
|
||||
|
||||
def test_confirmation_with_secondary_does_not_lose_confirmation():
|
||||
plan = _planner().plan_confirmation_secondary(
|
||||
"sim e preciso da segunda via", primary_intent="cancel", primary_agent="orders"
|
||||
)
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
assert MultiIntentPlanner.public_messages(plan) == []
|
||||
|
||||
|
||||
def test_transactional_intent_is_primary_even_when_mentioned_second():
|
||||
plan = _planner().plan("preciso da segunda via e quero cancelar pedido")
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
|
||||
|
||||
def test_multiword_keyword_accepts_intermediate_qualifier_without_yaml_variant():
|
||||
plan = _planner().plan("quero cancelar meu pedido e preciso da segunda via")
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
|
||||
|
||||
def test_first_of_multiple_transactions_is_primary_and_next_is_deferred():
|
||||
planner = MultiIntentPlanner([
|
||||
IntentDefinition(name="invoice", agent="billing", priority=30, keywords=["fatura"], mcp_tools=["consultar_fatura"]),
|
||||
IntentDefinition(name="return", agent="support", priority=20, keywords=["devolver pedido"], mcp_tools=["solicitar_devolucao"]),
|
||||
IntentDefinition(name="cancel", agent="orders", priority=10, keywords=["cancelar pedido"], mcp_tools=["cancelar_pedido"]),
|
||||
], transactional_tools={"solicitar_devolucao", "cancelar_pedido"})
|
||||
|
||||
plan = planner.plan(
|
||||
"quero consultar minha fatura. quero devolver pedido. quero cancelar pedido"
|
||||
)
|
||||
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["return", "invoice", "cancel"]
|
||||
assert [item.disposition for item in plan.operations] == ["execute", "execute", "defer"]
|
||||
|
||||
|
||||
def test_sentence_boundary_does_not_leak_previous_intent_into_knowledge_query():
|
||||
planner = MultiIntentPlanner([
|
||||
IntentDefinition(name="invoice", agent="billing", priority=20, keywords=["fatura"]),
|
||||
IntentDefinition(name="knowledge", agent="support", priority=30, keywords=["tarifação"]),
|
||||
])
|
||||
plan = planner.plan(
|
||||
"quero ver minha fatura e o valor. depois gostaria de saber como funciona a tarifação do plano"
|
||||
)
|
||||
assert plan is not None
|
||||
knowledge = next(item for item in plan.operations if item.intent == "knowledge")
|
||||
assert knowledge.source_text == "depois gostaria de saber como funciona a tarifação do plano"
|
||||
assert not knowledge.source_text.startswith("valor")
|
||||
|
||||
|
||||
def test_does_not_plan_single_intent_or_invent_unknown_intent():
|
||||
assert _planner().plan("quero cancelar pedido") is None
|
||||
assert _planner().plan("quero cancelar pedido e saber o clima") is None
|
||||
|
||||
|
||||
def test_known_primary_with_actionable_unknown_creates_off_context_topic():
|
||||
plan = _planner().plan("quero cancelar pedido e alterar meu CNPJ")
|
||||
assert plan is not None
|
||||
assert plan.operations[1].intent == "off_context"
|
||||
assert plan.operations[1].disposition == "unsupported"
|
||||
|
||||
|
||||
def test_confirmation_with_unknown_secondary_keeps_confirmation():
|
||||
plan = _planner().plan_confirmation_secondary(
|
||||
"sim e altere meu CNPJ", primary_intent="cancel", primary_agent="orders"
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan.operations[0].intent == "cancel"
|
||||
assert plan.operations[1].intent == "off_context"
|
||||
|
||||
|
||||
def test_single_word_keyword_does_not_match_a_longer_word_prefix():
|
||||
planner = MultiIntentPlanner([
|
||||
IntentDefinition(name="cancel", agent="orders", priority=10, keywords=["cancela"]),
|
||||
IntentDefinition(name="invoice", agent="billing", priority=20, keywords=["segunda via"]),
|
||||
])
|
||||
plan = planner.plan("sim, pode cancelar e preciso da segunda via")
|
||||
assert plan is not None
|
||||
assert all(item.intent != "cancel" for item in plan.operations)
|
||||
assert [item.intent for item in plan.operations] == ["invoice", "off_context"]
|
||||
|
||||
|
||||
def test_semantic_fallback_hydrates_ownership_from_configured_intents():
|
||||
plan = _planner().plan_from_semantic({
|
||||
"confidence": 0.92,
|
||||
"operations": [
|
||||
{"intent": "invoice", "source_text": "me encaminhe o boleto"},
|
||||
{"intent": "cancel", "source_text": "não quero mais o pedido"},
|
||||
],
|
||||
})
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
assert plan.operations[0].agent == "orders"
|
||||
assert plan.operations[0].tools == ["cancelar_pedido"]
|
||||
|
||||
|
||||
def test_semantic_fallback_rejects_invented_intent_and_low_confidence():
|
||||
assert _planner().plan_from_semantic({
|
||||
"confidence": 0.99,
|
||||
"operations": [
|
||||
{"intent": "cancel", "source_text": "não quero mais o pedido"},
|
||||
{"intent": "invented", "source_text": "faça outra coisa"},
|
||||
],
|
||||
}) is None
|
||||
assert _planner().plan_from_semantic({
|
||||
"confidence": 0.2,
|
||||
"operations": [
|
||||
{"intent": "cancel", "source_text": "não quero mais o pedido"},
|
||||
{"intent": "invoice", "source_text": "me encaminhe o boleto"},
|
||||
],
|
||||
}) is None
|
||||
|
||||
|
||||
def test_semantic_confirmation_preserves_primary_transaction():
|
||||
plan = _planner().plan_confirmation_from_semantic(
|
||||
{
|
||||
"confidence": 0.9,
|
||||
"operations": [
|
||||
{"intent": "invoice", "source_text": "também gostaria do boleto"},
|
||||
],
|
||||
},
|
||||
primary_intent="cancel",
|
||||
primary_agent="orders",
|
||||
)
|
||||
assert plan is not None
|
||||
assert [item.intent for item in plan.operations] == ["cancel", "invoice"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_uses_semantic_multi_intent_only_after_deterministic_miss(tmp_path):
|
||||
routing = tmp_path / "routing.yaml"
|
||||
routing.write_text("""
|
||||
router:
|
||||
fallback_agent: billing
|
||||
intents:
|
||||
- name: cancel
|
||||
agent: orders
|
||||
description: Encerrar uma encomenda existente
|
||||
keywords: [cancelar pedido]
|
||||
mcp_tools: [cancelar_pedido]
|
||||
- name: invoice
|
||||
agent: billing
|
||||
description: Obter documento de cobrança ou boleto
|
||||
keywords: [segunda via]
|
||||
mcp_tools: [consultar_fatura]
|
||||
""", encoding="utf-8")
|
||||
policies = tmp_path / "tool_policies.yaml"
|
||||
policies.write_text("""
|
||||
defaults:
|
||||
operation_type: read_only
|
||||
tool_policies:
|
||||
cancelar_pedido:
|
||||
operation_type: transactional
|
||||
require_confirmation: true
|
||||
""", encoding="utf-8")
|
||||
|
||||
class SemanticMultiIntentLLM:
|
||||
calls = 0
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
self.calls += 1
|
||||
assert kwargs["generation_name"] == "llm.router.multi_intent"
|
||||
return json.dumps({
|
||||
"confidence": 0.94,
|
||||
"operations": [
|
||||
{"intent": "cancel", "source_text": "encerrar minha encomenda"},
|
||||
{"intent": "invoice", "source_text": "encaminhe o boleto"},
|
||||
],
|
||||
})
|
||||
|
||||
llm = SemanticMultiIntentLLM()
|
||||
router = EnterpriseRouter(SimpleNamespace(
|
||||
ROUTING_CONFIG_PATH=str(routing),
|
||||
TOOL_POLICIES_PATH=str(policies),
|
||||
ENABLE_LLM_ROUTER=True,
|
||||
ENABLE_ROUTE_STICKINESS=False,
|
||||
), llm=llm)
|
||||
decision = await router.route({
|
||||
"user_text": "quero encerrar minha encomenda e encaminhe o boleto",
|
||||
"context": {},
|
||||
})
|
||||
|
||||
assert llm.calls == 1
|
||||
assert decision.method == "llm"
|
||||
assert decision.intent == "cancel"
|
||||
assert decision.agent == "orders"
|
||||
assert decision.metadata["multi_intent_classifier"] == "llm"
|
||||
assert [
|
||||
item["intent"]
|
||||
for item in decision.metadata["multi_intent_plan"]["operations"]
|
||||
] == ["cancel", "invoice"]
|
||||
322
tests/unit/test_pending_topics_isolation.py
Normal file
322
tests/unit/test_pending_topics_isolation.py
Normal file
@@ -0,0 +1,322 @@
|
||||
import pytest
|
||||
|
||||
from agent_framework.routing.pending_topics import drain_pending_topics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secondary_topic_does_not_inherit_primary_transaction_state():
|
||||
captured = {}
|
||||
|
||||
async def billing_agent(state):
|
||||
captured.update(state)
|
||||
return {
|
||||
"answer": "Aqui está a sua segunda via.",
|
||||
"mcp_results": [{"tool_name": "consultar_faturas", "ok": True}],
|
||||
"rag": {"provider": "kbdb", "attempted": True, "status": "executed", "document_count": 1},
|
||||
"rag_context": "Documento recuperado da base de conhecimento.",
|
||||
}
|
||||
|
||||
state = {
|
||||
"answer": "Você confirma o cancelamento do Tamboro?",
|
||||
"sanitized_input": "quero cancelar Tamboro e quero minha segunda via",
|
||||
"confirmation_required": True,
|
||||
"active_transaction": {"tool_name": "cancelar_vas_avulso"},
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"selected_tool_call": {"tool_name": "cancelar_vas_avulso"},
|
||||
"pending_tool_call": {"tool_name": "cancelar_vas_avulso"},
|
||||
"pending_domain_workflow": {"workflow_name": "cancelamento_vas_avulso"},
|
||||
"missing_parameters": ["subject"],
|
||||
"next_state": "WAITING_CONTESTACAO_CONFIRMATION",
|
||||
"mcp_results": [{"tool_name": "consultar_vas"}],
|
||||
"pending_topics": [{
|
||||
"operation_id": "op-2",
|
||||
"intent": "contas_invoice_query",
|
||||
"agent": "faturas_agent",
|
||||
"domain": "telecom_contas",
|
||||
"tools": ["consultar_faturas"],
|
||||
"source_text": "quero minha segunda via",
|
||||
"disposition": "execute",
|
||||
"status": "pending",
|
||||
}],
|
||||
}
|
||||
|
||||
result = await drain_pending_topics(
|
||||
state, state["answer"], {"faturas_agent": billing_agent}
|
||||
)
|
||||
|
||||
assert captured["active_transaction"] is None
|
||||
assert captured["selected_tool_call"] == {}
|
||||
assert captured["pending_tool_call"] == {}
|
||||
assert captured["pending_domain_workflow"] is None
|
||||
assert captured["next_state"] is None
|
||||
assert captured["sanitized_input"] == "quero minha segunda via"
|
||||
assert captured["mcp_results"] == []
|
||||
assert captured["mcp_tools"] == ["consultar_faturas"]
|
||||
assert captured["available_mcp_tools"] == ["consultar_faturas"]
|
||||
assert captured["domain"] == "telecom_contas"
|
||||
assert result.answer.startswith("Você confirma o cancelamento")
|
||||
assert result.answer.endswith("Aqui está a sua segunda via.")
|
||||
assert [item["agent"] for item in result.agent_responses] == ["agent", "faturas_agent"]
|
||||
assert result.agent_responses[0]["primary"] is True
|
||||
assert result.agent_responses[1]["primary"] is False
|
||||
assert result.pending_topics == []
|
||||
assert result.mcp_results == [
|
||||
{"tool_name": "consultar_vas"},
|
||||
{"tool_name": "consultar_faturas", "ok": True},
|
||||
]
|
||||
assert result.rag_results[0]["metadata"]["provider"] == "kbdb"
|
||||
assert result.rag_results[0]["context"] == "Documento recuperado da base de conhecimento."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deferred_transaction_is_promoted_after_primary_completes():
|
||||
async def cancellation_agent(state):
|
||||
assert state["user_text"] == "quero cancelar meu pedido"
|
||||
return {
|
||||
"answer": "Você confirma o cancelamento do pedido PED-1001?",
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"confirmation_required": True,
|
||||
"pending_tool_call": {
|
||||
"tool_name": "cancelar_pedido",
|
||||
"arguments": {"order_id": "PED-1001"},
|
||||
},
|
||||
"active_transaction": {
|
||||
"tool_name": "cancelar_pedido",
|
||||
"status": "AWAITING_CONFIRMATION",
|
||||
},
|
||||
}
|
||||
|
||||
state = {
|
||||
"answer": "A devolução foi registrada com sucesso.",
|
||||
"route": "support_agent",
|
||||
"active_agent": "support_agent",
|
||||
"intent": "retail_support_exchange_return",
|
||||
"transaction_status": "COMPLETED",
|
||||
"pending_topics": [{
|
||||
"operation_id": "op-3",
|
||||
"intent": "retail_order_cancel",
|
||||
"agent": "orders_agent",
|
||||
"domain": "retail",
|
||||
"tools": ["consultar_pedido", "cancelar_pedido"],
|
||||
"source_text": "quero cancelar meu pedido",
|
||||
"disposition": "defer",
|
||||
"status": "pending",
|
||||
}],
|
||||
}
|
||||
|
||||
result = await drain_pending_topics(
|
||||
state, state["answer"], {"orders_agent": cancellation_agent}
|
||||
)
|
||||
|
||||
assert result.pending_topics == []
|
||||
assert result.state_patch["route"] == "orders_agent"
|
||||
assert result.state_patch["intent"] == "retail_order_cancel"
|
||||
assert result.state_patch["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert result.state_patch["pending_tool_call"]["tool_name"] == "cancelar_pedido"
|
||||
assert result.agent_responses[-1]["primary"] is True
|
||||
assert result.agent_responses[-1]["agent"] == "orders_agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_agent_responses_do_not_leak_into_new_turn_projection():
|
||||
state = {
|
||||
"answer": "Lista atual de serviços.",
|
||||
"route": "vas_agent",
|
||||
"active_agent": "vas_agent",
|
||||
"intent": "contas_vas_information",
|
||||
"agent_responses": [
|
||||
{"agent": "contestacao_agent", "answer": "Você confirma Tamboro e Paramount+?", "primary": True, "status": "completed"},
|
||||
{"operation_id": "op-2", "agent": "faturas_agent", "answer": "Resposta antiga de fatura.", "primary": False, "status": "completed"},
|
||||
],
|
||||
"pending_topics": [],
|
||||
}
|
||||
result = await drain_pending_topics(state, state["answer"], {})
|
||||
assert [r["answer"] for r in result.agent_responses] == ["Lista atual de serviços."]
|
||||
assert result.agent_responses[0]["agent"] == "vas_agent"
|
||||
assert result.agent_responses[0]["primary"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_secondary_result_is_kept_as_durable_operation_result():
|
||||
async def billing_agent(state):
|
||||
return {
|
||||
"answer": "Fatura consultada.",
|
||||
"mcp_results": [{"tool_name": "consultar_faturas", "ok": True, "invoice_id": "INV-1"}],
|
||||
}
|
||||
state = {
|
||||
"answer": "Cancelamento concluÃdo.",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "contas_vas_cancel",
|
||||
"operation_results": {
|
||||
"op-1": {"operation_id": "op-1", "status": "completed", "answer": "Cancelamento concluÃdo."}
|
||||
},
|
||||
"pending_topics": [{
|
||||
"operation_id": "op-2", "intent": "contas_invoice_query", "agent": "faturas_agent",
|
||||
"domain": "telecom_contas", "tools": ["consultar_faturas"],
|
||||
"source_text": "consulte minha fatura", "disposition": "execute", "status": "pending",
|
||||
}],
|
||||
}
|
||||
result = await drain_pending_topics(state, state["answer"], {"faturas_agent": billing_agent})
|
||||
assert result.operation_results["op-1"]["answer"] == "Cancelamento concluÃdo."
|
||||
assert result.operation_results["op-2"]["status"] == "completed"
|
||||
assert result.operation_results["op-2"]["mcp_results"][0]["invoice_id"] == "INV-1"
|
||||
assert [r["answer"] for r in result.agent_responses] == ["Cancelamento concluÃdo.", "Fatura consultada."]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_primary_operation_is_not_promoted_while_waiting_confirmation():
|
||||
state = {
|
||||
"answer": "Você confirma o cancelamento?",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "contas_vas_cancel",
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"confirmation_required": True,
|
||||
"pending_tool_call": {"tool_name": "cancelar_vas_avulso", "arguments": {"subject": "Tamboro"}},
|
||||
"multi_intent_plan": {
|
||||
"plan_id": "mip-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "contas_vas_cancel", "agent": "contestacao_agent", "domain": "telecom_contas", "tools": ["cancelar_vas_avulso"], "source_text": "cancele tamboro"},
|
||||
],
|
||||
},
|
||||
"operation_results": {},
|
||||
"pending_topics": [],
|
||||
}
|
||||
result = await drain_pending_topics(state, state["answer"], {})
|
||||
assert "op-1" not in result.operation_results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_primary_operation_is_promoted_and_keeps_previous_results():
|
||||
state = {
|
||||
"answer": "Tamboro cancelado; Paramount+ não encontrado.",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "state:WAITING_CONTESTACAO_CONFIRMATION",
|
||||
"transaction_status": "COMPLETED",
|
||||
"multi_intent_plan": {
|
||||
"plan_id": "mip-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "contas_vas_cancel", "agent": "contestacao_agent", "domain": "telecom_contas", "tools": ["cancelar_vas_avulso"], "source_text": "cancele tamboro e paramount"},
|
||||
{"operation_id": "op-2", "intent": "contas_invoice_query", "agent": "faturas_agent", "domain": "telecom_contas", "tools": ["consultar_faturas"], "source_text": "consulte minha fatura"},
|
||||
],
|
||||
},
|
||||
"operation_results": {
|
||||
"op-2": {"operation_id": "op-2", "status": "completed", "answer": "Fatura consultada."}
|
||||
},
|
||||
"mcp_results": [
|
||||
{
|
||||
"tool_name": "cancelar_vas_avulso",
|
||||
"ok": True,
|
||||
"result": {
|
||||
"status": "COMPLETED",
|
||||
"output": {
|
||||
"cancelar_vas_avulso": {
|
||||
"results": [
|
||||
{"subject": "Tamboro Mensal", "success": True, "protocol": "123"},
|
||||
{"subject": "Paramount+", "success": False, "reason": "service_not_found"},
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"transaction_evidence": [{"transaction_id": "tx-1", "status": "COMPLETED"}],
|
||||
"pending_topics": [],
|
||||
}
|
||||
result = await drain_pending_topics(state, state["answer"], {})
|
||||
assert result.operation_results["op-2"]["answer"] == "Fatura consultada."
|
||||
primary = result.operation_results["op-1"]
|
||||
assert primary["plan_id"] == "mip-1"
|
||||
assert primary["status"] == "completed"
|
||||
assert primary["mcp_results"][0]["result"]["output"]["cancelar_vas_avulso"]["results"][1]["reason"] == "service_not_found"
|
||||
assert primary["transaction_evidence"][0]["transaction_id"] == "tx-1"
|
||||
assert [item["answer"] for item in result.agent_responses] == ["Tamboro cancelado; Paramount+ não encontrado."]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persisted_plan_does_not_rehydrate_completed_secondary_topics_on_next_turn():
|
||||
calls = {"faturas": 0, "suporte": 0}
|
||||
|
||||
async def billing_agent(state):
|
||||
calls["faturas"] += 1
|
||||
return {"answer": "Fatura executada novamente - ERRO."}
|
||||
|
||||
async def support_agent(state):
|
||||
calls["suporte"] += 1
|
||||
return {"answer": "RAG executado novamente - ERRO."}
|
||||
|
||||
plan = {
|
||||
"plan_id": "mip-continue-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "contas_vas_cancel", "agent": "contestacao_agent", "domain": "telecom_contas", "tools": ["cancelar_vas_avulso"], "source_text": "cancele tamboro"},
|
||||
{"operation_id": "op-2", "intent": "contas_invoice_query", "agent": "faturas_agent", "domain": "telecom_contas", "tools": ["consultar_faturas"], "source_text": "consulte minha fatura", "disposition": "execute", "status": "pending"},
|
||||
{"operation_id": "op-3", "intent": "contas_knowledge", "agent": "suporte_contas_agent", "domain": "telecom_contas", "tools": [], "source_text": "explique infinity pos", "disposition": "execute", "status": "pending"},
|
||||
],
|
||||
}
|
||||
state = {
|
||||
"answer": "Cancelamento concluído.",
|
||||
"route": "contestacao_agent",
|
||||
"active_agent": "contestacao_agent",
|
||||
"intent": "state:WAITING_CONTESTACAO_CONFIRMATION",
|
||||
"transaction_status": "COMPLETED",
|
||||
"route_decision": {"route": "contestacao_agent", "intent": "state:WAITING_CONTESTACAO_CONFIRMATION", "metadata": {}},
|
||||
"multi_intent_plan": plan,
|
||||
# Empty is authoritative: op-2/op-3 were already drained on turn 1.
|
||||
"pending_topics": [],
|
||||
"operation_results": {
|
||||
"op-2": {"operation_id": "op-2", "plan_id": "mip-continue-1", "agent": "faturas_agent", "intent": "contas_invoice_query", "domain": "telecom_contas", "source_text": "consulte minha fatura", "status": "completed", "answer": "Fatura consultada no turno anterior."},
|
||||
"op-3": {"operation_id": "op-3", "plan_id": "mip-continue-1", "agent": "suporte_contas_agent", "intent": "contas_knowledge", "domain": "telecom_contas", "source_text": "explique infinity pos", "status": "completed", "answer": "Infinity explicado no turno anterior."},
|
||||
},
|
||||
"mcp_results": [{"tool_name": "cancelar_vas_avulso", "ok": True, "result": {"status": "COMPLETED"}}],
|
||||
}
|
||||
|
||||
result = await drain_pending_topics(
|
||||
state,
|
||||
state["answer"],
|
||||
{"faturas_agent": billing_agent, "suporte_contas_agent": support_agent},
|
||||
)
|
||||
|
||||
assert calls == {"faturas": 0, "suporte": 0}
|
||||
assert [item["answer"] for item in result.agent_responses] == ["Cancelamento concluído."]
|
||||
assert result.operation_results["op-2"]["answer"] == "Fatura consultada no turno anterior."
|
||||
assert result.operation_results["op-3"]["answer"] == "Infinity explicado no turno anterior."
|
||||
assert result.operation_results["op-1"]["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_queued_topic_is_idempotently_skipped_but_result_remains_available_to_processing():
|
||||
calls = 0
|
||||
|
||||
async def billing_agent(state):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"answer": "não deveria executar"}
|
||||
|
||||
plan = {
|
||||
"plan_id": "mip-idempotent-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "primary", "agent": "primary_agent", "domain": "test", "tools": [], "source_text": "principal"},
|
||||
{"operation_id": "op-2", "intent": "contas_invoice_query", "agent": "faturas_agent", "domain": "telecom_contas", "tools": ["consultar_faturas"], "source_text": "consulte minha fatura", "disposition": "execute", "status": "pending"},
|
||||
],
|
||||
}
|
||||
existing = {
|
||||
"operation_id": "op-2", "plan_id": "mip-idempotent-1", "agent": "faturas_agent",
|
||||
"intent": "contas_invoice_query", "domain": "telecom_contas", "source_text": "consulte minha fatura",
|
||||
"status": "completed", "answer": "Fatura já consultada.",
|
||||
"mcp_results": [{"result": {"invoice_id": "INV-1"}}],
|
||||
}
|
||||
state = {
|
||||
"answer": "Resposta atual.",
|
||||
"route": "primary_agent", "active_agent": "primary_agent", "intent": "primary",
|
||||
"multi_intent_plan": plan,
|
||||
"pending_topics": [dict(plan["operations"][1])],
|
||||
"operation_results": {"op-2": existing},
|
||||
}
|
||||
|
||||
result = await drain_pending_topics(state, state["answer"], {"faturas_agent": billing_agent})
|
||||
|
||||
assert calls == 0
|
||||
assert result.operation_results["op-2"]["mcp_results"][0]["result"]["invoice_id"] == "INV-1"
|
||||
assert [item["answer"] for item in result.agent_responses] == ["Resposta atual."]
|
||||
assert result.pending_topics == []
|
||||
60
tests/unit/test_pubsub_analytics_publisher.py
Normal file
60
tests/unit/test_pubsub_analytics_publisher.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.analytics.providers.pubsub import PubSubAnalyticsPublisher
|
||||
|
||||
|
||||
class _FakeFuture:
|
||||
def result(self, timeout: float | None = None) -> str:
|
||||
return "fake-message-id"
|
||||
|
||||
|
||||
class _FakePublisherClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, bytes, dict[str, str]]] = []
|
||||
|
||||
def publish(self, topic_path: str, *, data: bytes, **kwargs: str) -> _FakeFuture:
|
||||
self.calls.append((topic_path, data, kwargs))
|
||||
return _FakeFuture()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pubsub_publisher(monkeypatch: pytest.MonkeyPatch) -> PubSubAnalyticsPublisher:
|
||||
client = _FakePublisherClient()
|
||||
pubsub_v1 = types.ModuleType("google.cloud.pubsub_v1")
|
||||
pubsub_v1.PublisherClient = lambda: client # type: ignore[attr-defined]
|
||||
google_cloud = types.ModuleType("google.cloud")
|
||||
google_cloud.pubsub_v1 = pubsub_v1 # type: ignore[attr-defined]
|
||||
google = types.ModuleType("google")
|
||||
google.cloud = google_cloud # type: ignore[attr-defined]
|
||||
|
||||
monkeypatch.setitem(sys.modules, "google", google)
|
||||
monkeypatch.setitem(sys.modules, "google.cloud", google_cloud)
|
||||
monkeypatch.setitem(sys.modules, "google.cloud.pubsub_v1", pubsub_v1)
|
||||
monkeypatch.setenv("PUBSUB_EXCLUDED_EVENT_TYPES", "GRL.NATIVE_OUTPUT_GUARDRAILS")
|
||||
monkeypatch.setenv("PUBSUB_PAYLOAD_MODE", "legacy")
|
||||
|
||||
publisher = PubSubAnalyticsPublisher(topic_path="projects/test/topics/analytics")
|
||||
publisher.client = client
|
||||
return publisher
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_excluded_event_is_not_sent_to_pubsub(pubsub_publisher: PubSubAnalyticsPublisher) -> None:
|
||||
await pubsub_publisher.publish("GRL.NATIVE_OUTPUT_GUARDRAILS", {"session_id": "session-1"})
|
||||
|
||||
assert pubsub_publisher.client.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_excluded_event_is_sent_to_pubsub(pubsub_publisher: PubSubAnalyticsPublisher) -> None:
|
||||
await pubsub_publisher.publish("GRL.002", {"session_id": "session-1"})
|
||||
|
||||
assert len(pubsub_publisher.client.calls) == 1
|
||||
topic_path, _, attributes = pubsub_publisher.client.calls[0]
|
||||
assert topic_path == "projects/test/topics/analytics"
|
||||
assert attributes["event_type"] == "GRL.002"
|
||||
12
tests/unit/test_rag.py
Normal file
12
tests/unit/test_rag.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.rag.rag_service import RagService
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_service_retrieves_relevant_document():
|
||||
settings = SimpleNamespace(VECTOR_STORE_PROVIDER='memory', GRAPH_STORE_PROVIDER='memory', RAG_TOP_K=2)
|
||||
rag = RagService(settings)
|
||||
await rag.add_documents(['fatura alta por roaming internacional', 'pedido de troca de aparelho'], namespace='billing')
|
||||
result = await rag.retrieve('minha fatura veio alta', namespace='billing')
|
||||
assert result.documents
|
||||
assert 'fatura' in result.as_prompt_context().lower()
|
||||
185
tests/unit/test_rag_kbdb_provider.py
Normal file
185
tests/unit/test_rag_kbdb_provider.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from types import SimpleNamespace
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.rag.rag_service import RagService
|
||||
|
||||
|
||||
def _settings(**overrides):
|
||||
data = dict(
|
||||
RAG_PROVIDER="kbdb", RAG_TOP_K=5,
|
||||
KBDB_DB_USER="kb_user", KBDB_DB_PASSWORD="kb_pwd", KBDB_DB_DSN="kb_tp",
|
||||
KBDB_DB_WALLET_LOCATION=None, KBDB_DB_WALLET_PASSWORD=None,
|
||||
ADB_USER=None, ADB_PASSWORD=None, ADB_DSN=None,
|
||||
ADB_WALLET_LOCATION=None, ADB_WALLET_PASSWORD=None,
|
||||
KBDB_SEARCH_TYPE="hybrid", KBDB_NODE_EXPANSION=True,
|
||||
KBDB_NODE_MAX_RELATED=8, KBDB_GRAPH_CROSS_REF=False,
|
||||
KBDB_MAX_CROSS_REF_HOPS=1, KBDB_DOCUMENT_TYPE="customer_safe",
|
||||
KBDB_METADATA_JSON=None, KBDB_MIN_SCORE=None,
|
||||
KBDB_IDENTIFY_DOCUMENT=True, KBDB_STORE_QUERY=True, KBDB_IDENTIFY_TOP_N=3,
|
||||
)
|
||||
data.update(overrides)
|
||||
return SimpleNamespace(**data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kbdb_provider_adapts_serving_envelope(monkeypatch):
|
||||
service = RagService(_settings())
|
||||
|
||||
def fake_search(query, k):
|
||||
return {
|
||||
"search_type": "hybrid", "confidence": "high", "low_confidence": False,
|
||||
"top_score": 78.4, "warnings": [],
|
||||
"seeds": [{"unit_id": 11, "rank": 1, "score": 78.4}],
|
||||
"units": [
|
||||
{"unit_id": 10, "content": "passo anterior", "provenance": "parent"},
|
||||
{"unit_id": 11, "content": "resposta principal", "provenance": "seed"},
|
||||
],
|
||||
"documents": [{"document_id": 7, "title": "Politica"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(service._kbdb, "_search_sync", fake_search)
|
||||
result = await service.retrieve("qual a regra?", namespace="billing_agent")
|
||||
|
||||
assert [d.id for d in result.documents] == ["10", "11"]
|
||||
assert result.documents[1].score == 78.4
|
||||
assert result.metadata["provider"] == "kbdb"
|
||||
assert result.metadata["confidence"] == "high"
|
||||
assert "resposta principal" in result.as_prompt_context()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kbdb_provider_is_serving_only():
|
||||
service = RagService(_settings())
|
||||
with pytest.raises(RuntimeError, match="serving-only"):
|
||||
await service.add_documents(["texto"])
|
||||
|
||||
|
||||
def test_kbdb_connection_uses_same_wallet_semantics_without_adb_fallback(monkeypatch):
|
||||
import sys
|
||||
from agent_framework.rag.kbdb_service import KbdbRagService
|
||||
|
||||
captured = {}
|
||||
|
||||
class Defaults:
|
||||
fetch_lobs = True
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
captured["closed"] = True
|
||||
|
||||
class FakeOracleDb:
|
||||
defaults = Defaults()
|
||||
|
||||
@staticmethod
|
||||
def connect(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Connection()
|
||||
|
||||
monkeypatch.setitem(sys.modules, "oracledb", FakeOracleDb)
|
||||
settings = _settings(
|
||||
KBDB_DB_USER="kb_user",
|
||||
KBDB_DB_PASSWORD="kb_pwd",
|
||||
KBDB_DB_DSN="kb_tp",
|
||||
KBDB_DB_WALLET_LOCATION="/wallet/kb",
|
||||
KBDB_DB_WALLET_PASSWORD="wallet_pwd",
|
||||
ADB_USER="framework_user",
|
||||
ADB_PASSWORD="framework_pwd",
|
||||
ADB_DSN="framework_high",
|
||||
ADB_WALLET_LOCATION="/wallet/framework",
|
||||
ADB_WALLET_PASSWORD="framework_wallet_pwd",
|
||||
)
|
||||
|
||||
service = KbdbRagService(settings)
|
||||
with service._connect():
|
||||
pass
|
||||
|
||||
assert captured["user"] == "kb_user"
|
||||
assert captured["password"] == "kb_pwd"
|
||||
assert captured["dsn"] == "kb_tp"
|
||||
assert captured["config_dir"] == "/wallet/kb"
|
||||
assert captured["wallet_location"] == "/wallet/kb"
|
||||
assert captured["wallet_password"] == "wallet_pwd"
|
||||
assert captured["closed"] is True
|
||||
|
||||
|
||||
def test_kbdb_does_not_fallback_to_framework_adb_credentials():
|
||||
from agent_framework.rag.kbdb_service import KbdbRagService
|
||||
|
||||
settings = _settings(
|
||||
KBDB_DB_USER=None,
|
||||
KBDB_DB_PASSWORD=None,
|
||||
KBDB_DB_DSN=None,
|
||||
ADB_USER="framework_user",
|
||||
ADB_PASSWORD="framework_pwd",
|
||||
ADB_DSN="framework_high",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="KBDB_DB_USER"):
|
||||
KbdbRagService(settings)
|
||||
|
||||
|
||||
def test_kbdb_discovers_number_flags_and_clob_output(monkeypatch):
|
||||
import sys
|
||||
from agent_framework.rag.kbdb_service import KbdbRagService
|
||||
|
||||
captured = {}
|
||||
|
||||
class Var:
|
||||
def __init__(self, value=None):
|
||||
self.value = value
|
||||
|
||||
def getvalue(self):
|
||||
return self.value
|
||||
|
||||
class Cursor:
|
||||
def execute(self, statement):
|
||||
captured["signature_query"] = statement
|
||||
|
||||
def fetchall(self):
|
||||
names = [
|
||||
("P_SEARCH_TYPE", "VARCHAR2"), ("P_QUERY", "VARCHAR2"),
|
||||
("P_TOP_K", "NUMBER"), ("P_NODE_EXPANSION", "NUMBER"),
|
||||
("P_NODE_MAX_RELATED", "NUMBER"), ("P_DOCUMENT_TYPE", "VARCHAR2"),
|
||||
("P_METADATA", "CLOB"), ("P_IDENTIFY_DOCUMENT", "NUMBER"),
|
||||
("P_STORE_QUERY", "NUMBER"), ("P_IDENTIFY_TOP_N", "NUMBER"),
|
||||
("P_RESULT", "CLOB"),
|
||||
]
|
||||
return [
|
||||
(name, index, "OUT" if index == 11 else "IN", data_type, None)
|
||||
for index, (name, data_type) in enumerate(names, start=1)
|
||||
]
|
||||
|
||||
def var(self, data_type, size=None):
|
||||
captured["out_type"] = data_type
|
||||
return Var('{"units": [], "seeds": []}')
|
||||
|
||||
def callproc(self, name, binds):
|
||||
captured["procedure"] = name
|
||||
captured["binds"] = binds
|
||||
|
||||
class Connection:
|
||||
def cursor(self):
|
||||
return Cursor()
|
||||
|
||||
class FakeOracleDb:
|
||||
DB_TYPE_JSON = "JSON"
|
||||
DB_TYPE_CLOB = "CLOB"
|
||||
DB_TYPE_VARCHAR = "VARCHAR"
|
||||
|
||||
service = KbdbRagService(_settings())
|
||||
|
||||
@contextmanager
|
||||
def connect():
|
||||
yield Connection()
|
||||
|
||||
monkeypatch.setitem(sys.modules, "oracledb", FakeOracleDb)
|
||||
monkeypatch.setattr(service, "_connect", connect)
|
||||
|
||||
result = service._search_sync("como funciona?", 5)
|
||||
|
||||
assert result == {"units": [], "seeds": []}
|
||||
assert captured["procedure"] == "PKG_KB_SERVING.search_knowledge_base"
|
||||
assert captured["binds"][3] == 1
|
||||
assert captured["binds"][7:10] == [1, 1, 3]
|
||||
assert captured["out_type"] == "CLOB"
|
||||
6
tests/unit/test_rag_oracle_sql_generation.py
Normal file
6
tests/unit/test_rag_oracle_sql_generation.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from agent_framework.rag.graph_store import InMemoryGraphStore
|
||||
|
||||
|
||||
def test_inmemory_graph_has_pgql_method_for_interface_parity():
|
||||
graph = InMemoryGraphStore()
|
||||
assert hasattr(graph, "pgql")
|
||||
288
tests/unit/test_rag_runtime_grounding.py
Normal file
288
tests/unit/test_rag_runtime_grounding.py
Normal file
@@ -0,0 +1,288 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.rag.rag_service import RagResult
|
||||
from agent_framework.rag.vector_store import VectorDocument
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
class DummyRag:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def retrieve(self, query, *, namespace="default", graph_node=None, rewrite=False, k=None):
|
||||
self.calls.append((query, namespace, graph_node, rewrite))
|
||||
return RagResult(
|
||||
query=query,
|
||||
documents=[VectorDocument(id="kb-1", content="Tarifação documentada", metadata={}, score=0.9)],
|
||||
graph_neighbors=[],
|
||||
latency_ms=3,
|
||||
metadata={"provider": "kbdb", "confidence": "high", "low_confidence": False},
|
||||
)
|
||||
|
||||
|
||||
class Runtime(AgentRuntimeMixin):
|
||||
pass
|
||||
|
||||
|
||||
def _runtime(**settings):
|
||||
rt = Runtime()
|
||||
base = dict(
|
||||
RAG_PROVIDER="kbdb",
|
||||
SKIP_RAG_WHEN_MCP_SUFFICIENT=True,
|
||||
ENABLE_RAG_QUERY_REWRITE=False,
|
||||
ENABLE_RAG_CONTEXT_COMPRESSION=False,
|
||||
RAG_GROUNDED_ONLY=False,
|
||||
KBDB_GROUNDED_ONLY=True,
|
||||
LONG_TERM_MEMORY_INJECT_CONTEXT=False,
|
||||
)
|
||||
base.update(settings)
|
||||
rt.settings = SimpleNamespace(**base)
|
||||
rt.rag_service = DummyRag()
|
||||
rt.guardrail_pipeline = None
|
||||
return rt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_mcp_does_not_skip_rag_without_explicit_sufficiency():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "Como funciona a tarifação do plano Infinity Pós?",
|
||||
"sanitized_input": "Como funciona a tarifação do plano Infinity Pós?",
|
||||
"mcp_results": [{"ok": True, "tool_name": "qualquer_tool", "result": {"plano": "Controle 50GB"}}],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["provider"] == "kbdb"
|
||||
assert metadata["status"] == "executed"
|
||||
assert metadata["document_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_skips_only_when_mcp_explicitly_declares_sufficiency():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "qual é meu plano?",
|
||||
"sanitized_input": "qual é meu plano?",
|
||||
"mcp_results": [{"ok": True, "result": {"plano": "Controle 50GB", "rag_sufficient": True}}],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert context == ""
|
||||
assert not rt.rag_service.calls
|
||||
assert metadata["reason"] == "mcp_explicitly_sufficient"
|
||||
|
||||
|
||||
def test_kbdb_build_messages_injects_grounding_policy():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"user_text": "Como funciona?",
|
||||
"sanitized_input": "Como funciona?",
|
||||
"context": {},
|
||||
"business_context": {},
|
||||
}
|
||||
messages = rt.build_messages(
|
||||
state,
|
||||
system_prompt="system",
|
||||
mcp_results=[{"ok": True, "result": {"plano": "Controle"}}],
|
||||
rag_context="",
|
||||
rag_metadata={"provider": "kbdb", "enabled": True, "status": "empty", "document_count": 0},
|
||||
)
|
||||
user = next(m["content"] for m in messages if m["role"] == "user")
|
||||
assert "Política de grounding obrigatória" in user
|
||||
assert "Não complete lacunas usando conhecimento paramétrico" in user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("workflow_status", ["COMPLETED", "FAILED"])
|
||||
async def test_rag_skips_when_all_turn_workflows_are_terminal(workflow_status):
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"tool_name": "cancelar_vas_avulso",
|
||||
"result": {
|
||||
"execution_id": "wf-1",
|
||||
"workflow_name": "cancelamento_vas_avulso",
|
||||
"status": workflow_status,
|
||||
"output": {"answer": "resultado operacional"},
|
||||
},
|
||||
"metadata": {
|
||||
"workflow_name": "cancelamento_vas_avulso",
|
||||
"workflow_status": workflow_status,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert context == ""
|
||||
assert not rt.rag_service.calls
|
||||
assert metadata["status"] == "skipped"
|
||||
assert metadata["reason"] == "all_turn_workflows_terminal"
|
||||
assert metadata["workflow_statuses"] == [workflow_status]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_still_runs_when_any_turn_workflow_is_not_terminal():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-1", "workflow_name": "w1", "status": "COMPLETED"},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
},
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-2", "workflow_name": "w2", "status": "RUNNING"},
|
||||
"metadata": {"workflow_status": "RUNNING"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["status"] == "executed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_requires_rag_overrides_terminal_workflow_skip():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {
|
||||
"execution_id": "wf-1",
|
||||
"workflow_name": "w1",
|
||||
"status": "COMPLETED",
|
||||
"requires_rag": True,
|
||||
"rag_query": "explicar regra de negócio",
|
||||
},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert rt.rag_service.calls[0][0] == "explicar regra de negócio"
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["required_by_tool"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_workflow_does_not_skip_rag_when_operation_is_pending():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"pending_topics": [
|
||||
{"operation_id": "op-2", "intent": "invoice_query", "status": "pending"}
|
||||
],
|
||||
"operation_results": {},
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-1", "workflow_name": "w1", "status": "COMPLETED"},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["status"] == "executed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_workflow_does_not_skip_rag_when_knowledge_operation_is_unresolved():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"multi_intent_plan": {
|
||||
"plan_id": "mip-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "cancel", "status": "completed"},
|
||||
{"operation_id": "op-2", "intent": "account_knowledge", "status": "pending"},
|
||||
],
|
||||
},
|
||||
# op-1 está resolvida, op-2 ainda não possui resultado terminal.
|
||||
"operation_results": {"op-1": {"status": "completed"}},
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-1", "workflow_name": "w1", "status": "COMPLETED"},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert rt.rag_service.calls
|
||||
assert "Tarifação documentada" in context
|
||||
assert metadata["status"] == "executed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_operation_results_prevent_stale_plan_from_counting_as_pending():
|
||||
rt = _runtime()
|
||||
state = {
|
||||
"agent_id": "telecom_contas",
|
||||
"user_text": "sim",
|
||||
"sanitized_input": "sim",
|
||||
"multi_intent_plan": {
|
||||
"plan_id": "mip-1",
|
||||
"operations": [
|
||||
{"operation_id": "op-1", "intent": "cancel", "status": "pending"},
|
||||
{"operation_id": "op-2", "intent": "invoice_query", "status": "pending"},
|
||||
{"operation_id": "op-3", "intent": "account_knowledge", "status": "pending"},
|
||||
],
|
||||
},
|
||||
"operation_results": {
|
||||
"op-1": {"status": "completed"},
|
||||
"op-2": {"status": "completed"},
|
||||
"op-3": {"status": "completed"},
|
||||
},
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {"execution_id": "wf-1", "workflow_name": "w1", "status": "COMPLETED"},
|
||||
"metadata": {"workflow_status": "COMPLETED"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context, metadata = await rt._retrieve_rag_context(state)
|
||||
|
||||
assert context == ""
|
||||
assert not rt.rag_service.calls
|
||||
assert metadata["status"] == "skipped"
|
||||
assert metadata["reason"] == "all_turn_workflows_terminal"
|
||||
39
tests/unit/test_readme_feature_catalog.py
Normal file
39
tests/unit/test_readme_feature_catalog.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _local_links(path: Path) -> list[str]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
return [
|
||||
target for target in re.findall(r"\[[^]]*\]\(([^)]+)\)", text)
|
||||
if not target.startswith(("http://", "https://", "#", "mailto:"))
|
||||
]
|
||||
|
||||
|
||||
def test_main_readmes_have_no_broken_local_links():
|
||||
for name in ("README.md", "README_en.md"):
|
||||
readme = ROOT / name
|
||||
for target in _local_links(readme):
|
||||
relative = unquote(target.split("#", 1)[0])
|
||||
assert not relative or (readme.parent / relative).exists(), (name, target)
|
||||
|
||||
|
||||
def test_every_feature_guide_is_listed_in_corresponding_main_readme():
|
||||
pairs = (("pt-BR", "README.md"), ("en", "README_en.md"))
|
||||
for language, readme_name in pairs:
|
||||
readme = (ROOT / readme_name).read_text(encoding="utf-8")
|
||||
guides = sorted((ROOT / "docs" / "features" / language).glob("[0-9][0-9]_*.md"))
|
||||
assert guides
|
||||
for guide in guides:
|
||||
expected = f"docs/features/{language}/{guide.name}"
|
||||
assert expected in readme, (readme_name, expected)
|
||||
|
||||
|
||||
def test_portuguese_and_english_feature_catalogs_are_symmetric():
|
||||
pt = {path.name for path in (ROOT / "docs" / "features" / "pt-BR").glob("*.md")}
|
||||
en = {path.name for path in (ROOT / "docs" / "features" / "en").glob("*.md")}
|
||||
assert pt == en
|
||||
48
tests/unit/test_resilient_checkpointer.py
Normal file
48
tests/unit/test_resilient_checkpointer.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import pytest
|
||||
|
||||
from agent_framework.checkpoints.checkpoint_repository import (
|
||||
CheckpointRecoveryError,
|
||||
InMemoryCheckpointRepository,
|
||||
ResilientCheckpointRepository,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_envelope_and_recovery_skips_corrupt_latest():
|
||||
raw = InMemoryCheckpointRepository()
|
||||
repo = ResilientCheckpointRepository(raw, compact_every=100, keep_last=10, recovery_scan_limit=5)
|
||||
|
||||
await repo.put("thread-1", {"checkpoint_id": "ok-1", "checkpoint": {"id": "ok-1", "value": 1}})
|
||||
await repo.put("thread-1", {"checkpoint_id": "ok-2", "checkpoint": {"id": "ok-2", "value": 2}})
|
||||
|
||||
# Simula corrupção no último registro persistido.
|
||||
raw._data["thread-1"][-1]["payload"]["checkpoint"]["value"] = 999
|
||||
|
||||
recovered = await repo.get_latest("thread-1")
|
||||
assert recovered["checkpoint_id"] == "ok-1"
|
||||
assert recovered["checkpoint"]["value"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compaction_keeps_last_n_checkpoints():
|
||||
raw = InMemoryCheckpointRepository()
|
||||
repo = ResilientCheckpointRepository(raw, compact_every=1, keep_last=3, recovery_scan_limit=10)
|
||||
|
||||
for i in range(7):
|
||||
await repo.put("thread-compact", {"checkpoint_id": f"cp-{i}", "checkpoint": {"id": f"cp-{i}"}})
|
||||
|
||||
assert len(raw._data["thread-compact"]) <= 3
|
||||
latest = await repo.get_latest("thread-compact")
|
||||
assert latest["checkpoint_id"] == "cp-6"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovery_raises_when_only_corrupt_checkpoints_exist():
|
||||
raw = InMemoryCheckpointRepository()
|
||||
repo = ResilientCheckpointRepository(raw, compact_every=100, keep_last=10, recovery_scan_limit=5)
|
||||
|
||||
await repo.put("thread-bad", {"checkpoint_id": "bad", "checkpoint": {"id": "bad", "value": 1}})
|
||||
raw._data["thread-bad"][-1]["payload"]["checkpoint"]["value"] = 2
|
||||
|
||||
with pytest.raises(CheckpointRecoveryError):
|
||||
await repo.get_latest("thread-bad")
|
||||
207
tests/unit/test_semantic_route_stickiness.py
Normal file
207
tests/unit/test_semantic_route_stickiness.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.routing.continuity import SemanticRouteContinuity
|
||||
from agent_framework.routing.models import IntentDefinition
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, response: dict | str):
|
||||
self.response = response
|
||||
self.calls = []
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
self.calls.append((messages, kwargs))
|
||||
if isinstance(self.response, str):
|
||||
return self.response
|
||||
return json.dumps(self.response)
|
||||
|
||||
|
||||
class FakeTelemetry:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
async def event(self, name, payload):
|
||||
self.events.append((name, payload))
|
||||
|
||||
|
||||
def settings(**overrides):
|
||||
values = {
|
||||
"ENABLE_ROUTE_STICKINESS": True,
|
||||
"ROUTE_STICKINESS_LLM_PROFILE": "route_continuity",
|
||||
"ROUTE_STICKINESS_CONFIDENCE_THRESHOLD": 0.90,
|
||||
"ROUTE_STICKINESS_HISTORY_TURNS": 2,
|
||||
"ROUTE_STICKINESS_MAX_TOKENS": 80,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def intents():
|
||||
return [
|
||||
IntentDefinition(
|
||||
name="product_services_information",
|
||||
agent="product_agent",
|
||||
description="Planos, serviços, benefícios e mudança de plano.",
|
||||
),
|
||||
IntentDefinition(
|
||||
name="billing_invoice_explanation",
|
||||
agent="billing_agent",
|
||||
description="Faturas, pagamentos, cobranças e contestação.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def state(message="o que está incluso?"):
|
||||
return {
|
||||
"session_id": "s1",
|
||||
"active_agent": "product_agent",
|
||||
"intent": "product_services_information",
|
||||
"domain": "telecom",
|
||||
"route_decision": {
|
||||
"intent": "product_services_information",
|
||||
"domain": "telecom",
|
||||
"mcp_tools": ["consultar_plano"],
|
||||
},
|
||||
"history": [
|
||||
{"role": "user", "content": "qual é o meu plano?"},
|
||||
{"role": "assistant", "content": "Seu plano atual é Controle 50GB."},
|
||||
],
|
||||
"user_text": message,
|
||||
"sanitized_input": message,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continue_bypasses_router_without_regex_rules():
|
||||
llm = FakeLLM({"decision": "CONTINUE", "confidence": 0.97, "reason": "Continua o assunto do plano."})
|
||||
telemetry = FakeTelemetry()
|
||||
policy = SemanticRouteContinuity(settings(), llm, telemetry)
|
||||
|
||||
decision = await policy.evaluate(state(), intents=intents())
|
||||
|
||||
assert decision is not None
|
||||
assert decision.agent == "product_agent"
|
||||
assert decision.method == "continuity"
|
||||
assert decision.metadata["route_bypassed"] is True
|
||||
assert llm.calls[0][1]["profile_name"] == "route_continuity"
|
||||
# LLM tuning belongs to llm_profiles.yaml; continuity must not override it.
|
||||
assert "max_tokens" not in llm.calls[0][1]
|
||||
assert "temperature" not in llm.calls[0][1]
|
||||
prompt = json.loads(llm.calls[0][0][1]["content"])
|
||||
assert prompt["current_message"] == "o que está incluso?"
|
||||
assert "product_agent" not in prompt["other_agents"]
|
||||
assert telemetry.events[-1][1]["route_bypassed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_result_falls_back_to_enterprise_router():
|
||||
llm = FakeLLM({"decision": "ROUTE", "confidence": 0.98, "reason": "Novo assunto de cobrança."})
|
||||
policy = SemanticRouteContinuity(settings(), llm)
|
||||
|
||||
decision = await policy.evaluate(
|
||||
state("agora quero contestar uma cobrança"), intents=intents()
|
||||
)
|
||||
|
||||
assert decision is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_low_confidence_continue_falls_back_safely():
|
||||
llm = FakeLLM({"decision": "CONTINUE", "confidence": 0.70, "reason": "Possível continuidade."})
|
||||
policy = SemanticRouteContinuity(settings(), llm)
|
||||
|
||||
assert await policy.evaluate(state(), intents=intents()) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_output_falls_back_safely():
|
||||
llm = FakeLLM("not-json")
|
||||
policy = SemanticRouteContinuity(settings(), llm)
|
||||
|
||||
assert await policy.evaluate(state(), intents=intents()) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_active_agent_still_classifies_global_session_actions():
|
||||
llm = FakeLLM({"decision": "ROUTE", "confidence": 1.0})
|
||||
policy = SemanticRouteContinuity(settings(), llm)
|
||||
current = state()
|
||||
current.pop("active_agent")
|
||||
|
||||
assert await policy.evaluate(current, intents=intents()) is None
|
||||
assert len(llm.calls) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_human_handoff_is_returned_as_global_route():
|
||||
llm = FakeLLM({
|
||||
"decision": "HUMAN_HANDOFF",
|
||||
"confidence": 0.99,
|
||||
"reason": "O usuário pediu atendimento humano.",
|
||||
})
|
||||
policy = SemanticRouteContinuity(settings(), llm)
|
||||
|
||||
decision = await policy.evaluate(
|
||||
state("quero falar com um atendente"), intents=intents()
|
||||
)
|
||||
|
||||
assert decision is not None
|
||||
assert decision.route == "human_handoff"
|
||||
assert decision.agent == "human_handoff"
|
||||
assert decision.intent == "human_handoff"
|
||||
assert decision.handoff is True
|
||||
assert decision.metadata["session_control"] == "HUMAN_HANDOFF"
|
||||
assert decision.metadata["route_bypassed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_session_is_returned_as_global_route():
|
||||
llm = FakeLLM({
|
||||
"decision": "END_SESSION",
|
||||
"confidence": 0.98,
|
||||
"reason": "O usuário informou que não precisa continuar.",
|
||||
})
|
||||
policy = SemanticRouteContinuity(settings(), llm)
|
||||
|
||||
decision = await policy.evaluate(state("obrigado, era só isso"), intents=intents())
|
||||
|
||||
assert decision is not None
|
||||
assert decision.route == "end_session"
|
||||
assert decision.agent == "end_session"
|
||||
assert decision.intent == "end_session"
|
||||
assert decision.handoff is False
|
||||
assert decision.metadata["session_control"] == "END_SESSION"
|
||||
assert decision.metadata["route_bypassed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_session_actions_work_without_active_agent():
|
||||
llm = FakeLLM({
|
||||
"decision": "HUMAN_HANDOFF",
|
||||
"confidence": 0.97,
|
||||
"reason": "Solicitação explícita de pessoa.",
|
||||
})
|
||||
policy = SemanticRouteContinuity(settings(), llm)
|
||||
current = state("quero uma pessoa")
|
||||
current.pop("active_agent")
|
||||
|
||||
decision = await policy.evaluate(current, intents=intents())
|
||||
|
||||
assert decision is not None
|
||||
assert decision.route == "human_handoff"
|
||||
assert len(llm.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continue_without_active_agent_falls_back_to_router():
|
||||
llm = FakeLLM({"decision": "CONTINUE", "confidence": 0.99})
|
||||
policy = SemanticRouteContinuity(settings(), llm)
|
||||
current = state()
|
||||
current.pop("active_agent")
|
||||
|
||||
assert await policy.evaluate(current, intents=intents()) is None
|
||||
assert len(llm.calls) == 1
|
||||
19
tests/unit/test_sse.py
Normal file
19
tests/unit/test_sse.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.sse.events import SSEEvent, SSEHub
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_event_encoding():
|
||||
encoded = SSEEvent(event='message', data={'text':'ok'}, id=10).encode()
|
||||
assert 'id: 10' in encoded
|
||||
assert 'event: message' in encoded
|
||||
assert 'data: {"text": "ok"}' in encoded
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_hub_emit_and_replay(tmp_path):
|
||||
settings = SimpleNamespace(SQLITE_DB_PATH=str(tmp_path/'db.sqlite'), SSE_KEEPALIVE_SECONDS=0.1, SSE_EVENT_REPLAY_LIMIT=10, SESSION_REPOSITORY_PROVIDER='sqlite', SSE_STORE_PROVIDER='sqlite')
|
||||
hub = SSEHub(settings)
|
||||
eid = await hub.emit('s1', 'flow.start', {'a': 1})
|
||||
replayed = await hub.replay('s1', 0)
|
||||
assert replayed[0].id == eid
|
||||
assert replayed[0].event == 'flow.start'
|
||||
30
tests/unit/test_sse_replay_dedup.py
Normal file
30
tests/unit/test_sse_replay_dedup.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.sse.events import SSEHub, SSEEvent
|
||||
|
||||
|
||||
class MemorySSEStore:
|
||||
def __init__(self):
|
||||
self.rows=[]; self.next_id=1
|
||||
def append_sse_event(self, session_id, event, payload):
|
||||
row={"id": self.next_id, "session_id": session_id, "event_name": event, "payload": payload}
|
||||
self.next_id += 1; self.rows.append(row); return row["id"]
|
||||
def list_sse_events(self, session_id, after_id, limit):
|
||||
return [r for r in self.rows if r["session_id"] == session_id and r["id"] > after_id][:limit]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_skips_live_event_already_replayed():
|
||||
hub = SSEHub(SimpleNamespace(SSE_KEEPALIVE_SECONDS=0.01, SSE_EVENT_REPLAY_LIMIT=100, SQLITE_DB_PATH=':memory:', SESSION_REPOSITORY_PROVIDER='sqlite'), telemetry=None)
|
||||
hub.store = MemorySSEStore()
|
||||
eid = await hub.emit("s1", "message.responded", {"text":"ok"})
|
||||
# Same event remains in live queue and is also in replay store.
|
||||
gen = hub.subscribe("s1", 0)
|
||||
chunks=[]
|
||||
chunks.append(await gen.__anext__()) # replay event
|
||||
chunks.append(await gen.__anext__()) # connected
|
||||
chunks.append(await gen.__anext__()) # keepalive, not duplicated event
|
||||
assert chunks[0].startswith(f"id: {eid}")
|
||||
assert "message.responded" in chunks[0]
|
||||
assert "message.responded" not in chunks[2]
|
||||
await gen.aclose()
|
||||
289
tests/unit/test_telemetry_langfuse_compact.py
Normal file
289
tests/unit/test_telemetry_langfuse_compact.py
Normal file
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.analytics.providers.langfuse import LangfuseAnalyticsPublisher
|
||||
from agent_framework.observability.context import clear_observability_context, set_observability_context
|
||||
from agent_framework.observability.telemetry import Telemetry
|
||||
|
||||
|
||||
class Settings:
|
||||
ENABLE_LANGFUSE = False
|
||||
ENABLE_OTEL = False
|
||||
LANGFUSE_TRACE_MODE = "compact"
|
||||
|
||||
|
||||
class FakeObservation:
|
||||
_next_id = 1
|
||||
|
||||
def __init__(self, kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.id = f"obs-{FakeObservation._next_id}"
|
||||
self.trace_id = "trace-123"
|
||||
FakeObservation._next_id += 1
|
||||
self.updates = []
|
||||
self.trace_updates = []
|
||||
self.trace_io_updates = []
|
||||
|
||||
def update(self, **kwargs):
|
||||
self.updates.append(kwargs)
|
||||
|
||||
def update_trace(self, **kwargs):
|
||||
self.trace_updates.append(kwargs)
|
||||
|
||||
def set_trace_io(self, **kwargs):
|
||||
self.trace_io_updates.append(kwargs)
|
||||
|
||||
|
||||
class FakeContextManager:
|
||||
def __init__(self, observation):
|
||||
self.observation = observation
|
||||
|
||||
def __enter__(self):
|
||||
return self.observation
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class FakePropagationContext:
|
||||
def __init__(self, owner, kwargs):
|
||||
self.owner = owner
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __enter__(self):
|
||||
self.owner.propagations.append(self.kwargs)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class FakeLangfuse:
|
||||
def __init__(self, *, legacy_api: bool = False):
|
||||
self.observations = []
|
||||
self.propagations = []
|
||||
self.trace_updates = []
|
||||
self.flush_count = 0
|
||||
self.api = FakeApi() if legacy_api else None
|
||||
|
||||
def start_as_current_observation(self, **kwargs):
|
||||
observation = FakeObservation(kwargs)
|
||||
self.observations.append(observation)
|
||||
return FakeContextManager(observation)
|
||||
|
||||
def propagate_attributes(self, **kwargs):
|
||||
return FakePropagationContext(self, kwargs)
|
||||
|
||||
def update_current_trace(self, **kwargs):
|
||||
self.trace_updates.append(kwargs)
|
||||
|
||||
def flush(self):
|
||||
self.flush_count += 1
|
||||
|
||||
|
||||
class FakeIngestionResponse:
|
||||
errors = []
|
||||
successes = []
|
||||
|
||||
|
||||
class FakeIngestion:
|
||||
def __init__(self):
|
||||
self.batches = []
|
||||
|
||||
def batch(self, *, batch, metadata=None):
|
||||
self.batches.append({"batch": batch, "metadata": metadata})
|
||||
return FakeIngestionResponse()
|
||||
|
||||
|
||||
class FakeApi:
|
||||
def __init__(self):
|
||||
self.ingestion = FakeIngestion()
|
||||
|
||||
|
||||
def telemetry_with_fake_langfuse(*, legacy_api: bool = False):
|
||||
FakeObservation._next_id = 1
|
||||
telemetry = Telemetry(Settings())
|
||||
telemetry.enabled = True
|
||||
telemetry.langfuse = FakeLangfuse(legacy_api=legacy_api)
|
||||
return telemetry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_keeps_root_output_and_shows_ic_aga_noc_as_spans():
|
||||
clear_observability_context()
|
||||
telemetry = telemetry_with_fake_langfuse()
|
||||
|
||||
async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True) as span:
|
||||
await telemetry.event("IC.INTERNAL", {"step": "visible"}, kind="ic")
|
||||
await telemetry.event("NOC.001", {"step": "visible"}, kind="noc")
|
||||
await telemetry.event("AGA.010", {"step": "visible"}, kind="ic")
|
||||
span.set_output({"answer": "ok"})
|
||||
|
||||
names = [obs.kwargs["name"] for obs in telemetry.langfuse.observations]
|
||||
assert names == ["agent.gateway_message", "IC.INTERNAL", "NOC.001", "AGA.010"]
|
||||
|
||||
root = telemetry.langfuse.observations[0]
|
||||
assert root.updates[-1]["input"] == {"request": "cms"}
|
||||
assert root.updates[-1]["output"] == {"answer": "ok"}
|
||||
assert root.trace_io_updates[-1] == {"input": {"request": "cms"}, "output": {"answer": "ok"}}
|
||||
for observation in telemetry.langfuse.observations[1:]:
|
||||
assert observation.kwargs.get("trace_context") is None
|
||||
assert observation.kwargs["as_type"] == "span"
|
||||
|
||||
ic = telemetry.langfuse.observations[1]
|
||||
assert ic.kwargs["input"]["step"] == "visible"
|
||||
assert ic.updates[-1]["input"]["step"] == "visible"
|
||||
assert ic.updates[-1]["output"] == {"status": "ok"}
|
||||
assert telemetry.langfuse.propagations[-1]["trace_name"] == "agent.gateway_message"
|
||||
|
||||
aggregated = root.updates[-1]["metadata"]["aggregated_events"]
|
||||
assert [event["name"] for event in aggregated] == ["IC.INTERNAL", "NOC.001", "AGA.010"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_control_event_is_a_span_and_not_a_trace_tag():
|
||||
clear_observability_context()
|
||||
set_observability_context(request_id="req-1", trace_id="req-1", session_id="s1")
|
||||
langfuse = FakeLangfuse()
|
||||
publisher = LangfuseAnalyticsPublisher(langfuse=langfuse)
|
||||
envelope = {
|
||||
"eventType": "IC.ORDER_CONFIRMED",
|
||||
"source": "agent_framework",
|
||||
"payload": {"tag": "IC.ORDER_CONFIRMED", "order_id": "order-1"},
|
||||
"metadata": {"ic": True},
|
||||
}
|
||||
|
||||
await publisher.publish("IC.ORDER_CONFIRMED", envelope)
|
||||
|
||||
assert [obs.kwargs["name"] for obs in langfuse.observations] == ["IC.ORDER_CONFIRMED"]
|
||||
observation = langfuse.observations[0]
|
||||
assert observation.kwargs["as_type"] == "span"
|
||||
assert observation.kwargs["input"] == envelope
|
||||
assert observation.updates[-1]["output"] == {"published": True}
|
||||
assert len(langfuse.trace_updates) == 1
|
||||
assert "tags" not in langfuse.trace_updates[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_generation_records_io_model_parameters_and_usage_details():
|
||||
clear_observability_context()
|
||||
telemetry = telemetry_with_fake_langfuse()
|
||||
|
||||
async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True):
|
||||
async with telemetry.generation_span(
|
||||
name="llm.test",
|
||||
model="test-model",
|
||||
input=[{"role": "user", "content": "ping"}],
|
||||
metadata={"profile_name": "test"},
|
||||
model_parameters={"temperature": 0.2, "max_tokens": 100},
|
||||
) as generation:
|
||||
generation.set_output("pong")
|
||||
generation.set_usage({"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2, "cost_usd": 0.01})
|
||||
|
||||
generation = telemetry.langfuse.observations[1]
|
||||
assert generation.kwargs["name"] == "llm.test"
|
||||
assert generation.kwargs["as_type"] == "generation"
|
||||
assert generation.kwargs["input"] == [{"role": "user", "content": "ping"}]
|
||||
assert generation.kwargs["model"] == "test-model"
|
||||
assert generation.kwargs["model_parameters"] == {"temperature": 0.2, "max_tokens": 100}
|
||||
assert "usage" not in generation.kwargs
|
||||
assert "usage_details" not in generation.kwargs
|
||||
assert generation.updates[-1]["input"] == [{"role": "user", "content": "ping"}]
|
||||
assert generation.updates[-1]["output"] == "pong"
|
||||
assert generation.updates[-1]["usage_details"] == {"input": 1, "output": 1}
|
||||
assert generation.updates[-1]["cost_details"] == {"total": 0.01}
|
||||
assert generation.kwargs.get("trace_context") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_io_fallback_updates_same_root_and_generation_observations():
|
||||
clear_observability_context()
|
||||
telemetry = telemetry_with_fake_langfuse(legacy_api=True)
|
||||
|
||||
async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True) as root:
|
||||
async with telemetry.generation_span(
|
||||
name="llm.test",
|
||||
model="test-model",
|
||||
input=[{"role": "user", "content": "ping"}],
|
||||
model_parameters={"temperature": 0.2},
|
||||
) as generation:
|
||||
generation.set_output("pong")
|
||||
generation.set_usage({"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5})
|
||||
root.set_output({"answer": "ok"})
|
||||
|
||||
batches = telemetry.langfuse.api.ingestion.batches
|
||||
assert [event.type for item in batches for event in item["batch"]] == ["generation-update", "span-update"]
|
||||
|
||||
generation_event = batches[0]["batch"][0]
|
||||
assert generation_event.body.id == "obs-2"
|
||||
assert generation_event.body.trace_id == "trace-123"
|
||||
assert generation_event.body.input == [{"role": "user", "content": "ping"}]
|
||||
assert generation_event.body.output == "pong"
|
||||
assert generation_event.body.usage_details == {"input": 2, "output": 3}
|
||||
|
||||
root_event = batches[1]["batch"][0]
|
||||
assert root_event.body.id == "obs-1"
|
||||
assert root_event.body.trace_id == "trace-123"
|
||||
assert root_event.body.input == {"request": "cms"}
|
||||
assert root_event.body.output == {"answer": "ok"}
|
||||
assert len(telemetry.langfuse.observations) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_v4_module_level_propagation_sets_native_session_context():
|
||||
"""SDK v4 propagation must receive the business session id natively."""
|
||||
clear_observability_context()
|
||||
telemetry = telemetry_with_fake_langfuse()
|
||||
calls = []
|
||||
|
||||
def v4_propagate_attributes(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return FakePropagationContext(telemetry.langfuse, kwargs)
|
||||
|
||||
# Simulates ``from langfuse import propagate_attributes`` from SDK v4.
|
||||
telemetry._langfuse_propagate_attributes = v4_propagate_attributes
|
||||
|
||||
async with telemetry.span(
|
||||
"agent.gateway_message",
|
||||
session_id="default:telecom_contas:session-123",
|
||||
user_id="11999999999",
|
||||
agent_id="telecom_contas",
|
||||
tenant_id="default",
|
||||
input={"message": "hello"},
|
||||
tags=["agent:telecom_contas"],
|
||||
_root_span=True,
|
||||
):
|
||||
await telemetry.event("IC.TEST", {"ok": True}, kind="ic")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["session_id"] == "default:telecom_contas:session-123"
|
||||
assert calls[0]["user_id"] == "11999999999"
|
||||
assert calls[0]["trace_name"] == "agent.gateway_message"
|
||||
assert calls[0]["metadata"]["agent_id"] == "telecom_contas"
|
||||
assert calls[0]["metadata"]["tenant_id"] == "default"
|
||||
assert calls[0]["tags"] == ["agent:telecom_contas"]
|
||||
|
||||
# Keep the legacy update as a compatibility fallback, but the v4 path above
|
||||
# is now the authoritative way to materialize native sessionId.
|
||||
root = telemetry.langfuse.observations[0]
|
||||
assert any(
|
||||
update.get("session_id") == "default:telecom_contas:session-123"
|
||||
for update in root.trace_updates
|
||||
)
|
||||
|
||||
|
||||
def test_trace_attribute_propagation_keeps_legacy_client_method_fallback():
|
||||
telemetry = telemetry_with_fake_langfuse()
|
||||
telemetry._langfuse_propagate_attributes = None
|
||||
|
||||
cm = telemetry._start_trace_attribute_propagation(
|
||||
"agent.gateway_message",
|
||||
{
|
||||
"session_id": "legacy-session",
|
||||
"user_id": "legacy-user",
|
||||
"agent_id": "legacy-agent",
|
||||
},
|
||||
)
|
||||
assert cm is not None
|
||||
with cm:
|
||||
pass
|
||||
assert telemetry.langfuse.propagations[-1]["session_id"] == "legacy-session"
|
||||
23
tests/unit/test_token_cost_enterprise.py
Normal file
23
tests/unit/test_token_cost_enterprise.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.observability.token_cost import TokenUsageCollector, TokenUsage
|
||||
|
||||
|
||||
def test_token_usage_extracts_cached_and_reasoning_tokens():
|
||||
usage = TokenUsage.from_openai_usage({
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 500,
|
||||
"total_tokens": 1600,
|
||||
"prompt_tokens_details": {"cached_tokens": 250},
|
||||
"completion_tokens_details": {"reasoning_tokens": 100},
|
||||
})
|
||||
assert usage.prompt_tokens == 1000
|
||||
assert usage.cached_tokens == 250
|
||||
assert usage.reasoning_tokens == 100
|
||||
assert usage.total_tokens == 1600
|
||||
|
||||
|
||||
def test_cost_tracker_uses_model_prices_json():
|
||||
settings = SimpleNamespace(MODEL_PRICES_JSON='{"my-model":{"input_per_1m":"1","output_per_1m":"2","cached_input_per_1m":"0.1"}}', USD_BRL_RATE='5')
|
||||
enriched = TokenUsageCollector(settings).enrich("my-model", {"prompt_tokens": 1000, "completion_tokens": 1000, "prompt_tokens_details": {"cached_tokens": 500}})
|
||||
assert enriched["cost_usd"] > 0
|
||||
assert abs(enriched["cost_brl"] - enriched["cost_usd"] * 5) < 1e-9
|
||||
98
tests/unit/test_tool_policies.py
Normal file
98
tests/unit/test_tool_policies.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.mcp.tool_policy import ToolPolicyRegistry
|
||||
from agent_framework.mcp.tool_router import MCPToolRouter
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, tool=None):
|
||||
self.tool = tool
|
||||
|
||||
def get_tool(self, _name):
|
||||
return self.tool
|
||||
|
||||
|
||||
def _router(policy_registry, legacy=None):
|
||||
router = MCPToolRouter.__new__(MCPToolRouter)
|
||||
router.tool_policies = policy_registry
|
||||
router.registry = _Registry(legacy)
|
||||
return router
|
||||
|
||||
|
||||
def test_missing_policy_file_preserves_legacy_behavior(tmp_path):
|
||||
policies = ToolPolicyRegistry(str(tmp_path / "missing.yaml"))
|
||||
legacy = SimpleNamespace(
|
||||
tool_type="action",
|
||||
requires=["order_id"],
|
||||
confirmation_required=True,
|
||||
execution_policy={},
|
||||
)
|
||||
router = _router(policies, legacy)
|
||||
|
||||
allowed, reason, metadata = router.validate_execution_policy("alterar", {"order_id": "42"})
|
||||
|
||||
assert allowed is False
|
||||
assert "confirmação" in reason
|
||||
assert metadata["operation_type"] == "transactional"
|
||||
assert metadata["policy_source"] == "tools.yaml"
|
||||
|
||||
|
||||
def test_read_only_policy_executes_without_confirmation(tmp_path):
|
||||
path = tmp_path / "tool_policies.yaml"
|
||||
path.write_text(
|
||||
"tool_policies:\n consultar:\n operation_type: read_only\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
router = _router(ToolPolicyRegistry(str(path)))
|
||||
|
||||
allowed, reason, metadata = router.validate_execution_policy("consultar", {})
|
||||
|
||||
assert allowed is True
|
||||
assert reason is None
|
||||
assert metadata["operation_type"] == "read_only"
|
||||
|
||||
|
||||
def test_transactional_policy_requires_literal_boolean_confirmation(tmp_path):
|
||||
path = tmp_path / "tool_policies.yaml"
|
||||
path.write_text(
|
||||
"tool_policies:\n cancelar:\n operation_type: transactional\n require_confirmation: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
router = _router(ToolPolicyRegistry(str(path)))
|
||||
|
||||
denied, _, _ = router.validate_execution_policy("cancelar", {"confirmed": "true"})
|
||||
allowed, reason, metadata = router.validate_execution_policy("cancelar", {"confirmed": True})
|
||||
|
||||
assert denied is False
|
||||
assert allowed is True
|
||||
assert reason is None
|
||||
assert metadata["policy_source"] == "tool_policies.yaml"
|
||||
|
||||
|
||||
def test_requires_confirmation_alias_is_supported(tmp_path):
|
||||
path = tmp_path / "tool_policies.yaml"
|
||||
path.write_text(
|
||||
"tool_policies:\n alterar:\n type: transactional\n requires_confirmation: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
policy = ToolPolicyRegistry(str(path)).get("alterar")
|
||||
|
||||
assert policy.operation_type == "transactional"
|
||||
assert policy.require_confirmation is True
|
||||
|
||||
|
||||
def test_workflow_execution_policy_is_exposed(tmp_path):
|
||||
path = tmp_path / "tool_policies.yaml"
|
||||
path.write_text(
|
||||
"""defaults:\n operation_type: read_only\ntool_policies:\n refund:\n operation_type: transactional\n require_confirmation: true\n execution:\n mode: workflow\n workflow: refund_order\n version: 2\n""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
registry = ToolPolicyRegistry(str(path))
|
||||
policy = registry.get("refund")
|
||||
assert policy is not None
|
||||
assert policy.execution.mode == "workflow"
|
||||
assert policy.execution.workflow == "refund_order"
|
||||
assert policy.execution.version == 2
|
||||
110
tests/unit/test_transactional_workflows.py
Normal file
110
tests/unit/test_transactional_workflows.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.workflows import (
|
||||
FileWorkflowRepository,
|
||||
WorkflowActionRegistry,
|
||||
WorkflowRuntime,
|
||||
WorkflowToolExecutor,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deterministic_workflow_routes_and_caches(tmp_path: Path):
|
||||
(tmp_path / "refund.active.yaml").write_text("version: 1\n", encoding="utf-8")
|
||||
(tmp_path / "refund.v1.yaml").write_text(
|
||||
"""name: refund
|
||||
version: 1
|
||||
start: validate
|
||||
nodes:
|
||||
- id: validate
|
||||
action: validate
|
||||
input: {order_id: $.input.order_id}
|
||||
- id: execute
|
||||
action: execute
|
||||
input: {order_id: $.input.order_id}
|
||||
edges:
|
||||
- from: validate
|
||||
to: execute
|
||||
when: {path: $.nodes.validate.valid, equals: true}
|
||||
- from: validate
|
||||
to: END
|
||||
when: {path: $.nodes.validate.valid, equals: false}
|
||||
- from: execute
|
||||
to: END
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
actions = WorkflowActionRegistry()
|
||||
actions.register("validate", lambda params, state: {"valid": params["order_id"] == "123"})
|
||||
actions.register("execute", lambda params, state: {"protocol": "P-1"})
|
||||
runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=actions)
|
||||
|
||||
ok = await runtime.arun("refund", {"order_id": "123"})
|
||||
assert ok.status == "COMPLETED"
|
||||
assert ok.output["execute"]["protocol"] == "P-1"
|
||||
assert len(runtime._compiled) == 1
|
||||
|
||||
rejected = await runtime.arun("refund", {"order_id": "999"})
|
||||
assert rejected.status == "COMPLETED"
|
||||
assert "execute" not in rejected.output
|
||||
assert len(runtime._compiled) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_policy_adapter_runs_only_workflow_mode(tmp_path: Path):
|
||||
(tmp_path / "job.active.yaml").write_text("version: 1\n", encoding="utf-8")
|
||||
(tmp_path / "job.v1.yaml").write_text(
|
||||
"""name: job
|
||||
version: 1
|
||||
start: one
|
||||
nodes:
|
||||
- id: one
|
||||
action: one
|
||||
edges:
|
||||
- from: one
|
||||
to: END
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
actions = WorkflowActionRegistry()
|
||||
actions.register("one", lambda params, state: {"ok": True})
|
||||
adapter = WorkflowToolExecutor(WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=actions))
|
||||
assert await adapter.execute_from_policy(tool_name="x", arguments={}, policy={"execution": {"mode": "direct_tool"}}) is None
|
||||
result = await adapter.execute_from_policy(tool_name="x", arguments={}, policy={"execution": {"mode": "workflow", "workflow": "job", "version": "active"}})
|
||||
assert result["status"] == "COMPLETED"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offline_regression_mode_forces_deterministic_backend_even_if_langgraph_is_available(tmp_path: Path, monkeypatch):
|
||||
(tmp_path / "offline.active.yaml").write_text("version: 1\n", encoding="utf-8")
|
||||
(tmp_path / "offline.v1.yaml").write_text(
|
||||
"""name: offline
|
||||
version: 1
|
||||
start: one
|
||||
nodes:
|
||||
- id: one
|
||||
action: one
|
||||
edges:
|
||||
- from: one
|
||||
to: END
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
actions = WorkflowActionRegistry()
|
||||
actions.register("one", lambda params, state: {"ok": True})
|
||||
runtime = WorkflowRuntime(
|
||||
FileWorkflowRepository(tmp_path),
|
||||
actions=actions,
|
||||
allow_deterministic_fallback=True,
|
||||
)
|
||||
|
||||
def _must_not_compile(_definition):
|
||||
raise AssertionError("offline regression mode must not compile LangGraph")
|
||||
|
||||
monkeypatch.setattr(runtime, "_compile", _must_not_compile)
|
||||
result = await runtime.arun("offline", {})
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.output["one"]["ok"] is True
|
||||
assert runtime._compiled == {}
|
||||
90
tests/unit/test_tuning_pending_topics_sync.py
Normal file
90
tests/unit/test_tuning_pending_topics_sync.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_all_tuning_backend_graphs_support_pending_topics():
|
||||
graphs = sorted((ROOT / "Tuning-Performance").glob("**/app/workflows/agent_graph.py"))
|
||||
assert graphs
|
||||
for path in graphs:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "from agent_framework.routing.pending_topics import drain_pending_topics" in source, path
|
||||
assert '"pending_topics": drained.pending_topics' in source, path
|
||||
assert '"handled_topics": drained.handled_topics' in source, path
|
||||
assert '"mcp_results": drained.mcp_results' in source, path
|
||||
assert '"rag_results": drained.rag_results' in source, path
|
||||
assert 'list(drained.mcp_results) + list(drained.rag_results)' in source, path
|
||||
|
||||
|
||||
def test_all_tuning_backend_states_expose_pending_topics():
|
||||
states = sorted((ROOT / "Tuning-Performance").glob("**/app/state.py"))
|
||||
assert states
|
||||
for path in states:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "pending_topics: list[dict[str, Any]]" in source, path
|
||||
assert "handled_topics: list[dict[str, Any]]" in source, path
|
||||
assert "rag_results: list[dict[str, Any]]" in source, path
|
||||
|
||||
|
||||
def test_canonical_templates_delegate_drain_to_framework_helper():
|
||||
for template in ("agent_template_backend", "agent_template_backend_day_zero"):
|
||||
graph = ROOT / "templates" / template / "app" / "workflows" / "agent_graph.py"
|
||||
state = ROOT / "templates" / template / "app" / "state.py"
|
||||
graph_source = graph.read_text(encoding="utf-8")
|
||||
state_source = state.read_text(encoding="utf-8")
|
||||
assert "from agent_framework.routing.pending_topics import drain_pending_topics" in graph_source
|
||||
assert "drained = await drain_pending_topics(" in graph_source
|
||||
assert '"pending_topics": drained.pending_topics' in graph_source
|
||||
assert '"handled_topics": drained.handled_topics' in graph_source
|
||||
assert '"mcp_results": drained.mcp_results' in graph_source
|
||||
assert '"rag_results": drained.rag_results' in graph_source
|
||||
assert 'list(drained.mcp_results) + list(drained.rag_results)' in graph_source
|
||||
assert "MultiIntentPlanner.public_messages" not in graph_source
|
||||
assert "pending_topics: list[dict[str, Any]]" in state_source
|
||||
assert "handled_topics: list[dict[str, Any]]" in state_source
|
||||
assert "rag_results: list[dict[str, Any]]" in state_source
|
||||
|
||||
|
||||
def test_all_rag_agents_expose_metadata_and_context_as_state_evidence():
|
||||
roots = [ROOT / "templates", ROOT / "Tuning-Performance"]
|
||||
agents = sorted(
|
||||
path
|
||||
for root in roots
|
||||
for path in root.glob("**/app/agents/*.py")
|
||||
if '"rag": rag_metadata' in path.read_text(encoding="utf-8")
|
||||
)
|
||||
assert agents
|
||||
for path in agents:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert '"rag_context": rag_context' in source, path
|
||||
assert '"rag_results": [{' in source, path
|
||||
|
||||
|
||||
def test_templates_and_tuning_expose_rag_results_in_api_and_telemetry():
|
||||
roots = [ROOT / "templates", ROOT / "Tuning-Performance"]
|
||||
mains = sorted(path for root in roots for path in root.glob("**/app/main.py"))
|
||||
graphs = sorted(path for root in roots for path in root.glob("**/app/workflows/agent_graph.py"))
|
||||
assert mains and graphs
|
||||
for path in mains:
|
||||
assert '"rag_results": result.get("rag_results")' in path.read_text(encoding="utf-8"), path
|
||||
for path in graphs:
|
||||
assert '"rag_results": state.get("rag_results", [])' in path.read_text(encoding="utf-8"), path
|
||||
|
||||
|
||||
def test_all_tuning_envs_expose_complete_kbdb_configuration():
|
||||
envs = sorted((ROOT / "Tuning-Performance").glob("**/.env.example"))
|
||||
assert envs
|
||||
required = (
|
||||
"RAG_PROVIDER=standard",
|
||||
"KBDB_DB_USER=",
|
||||
"KBDB_DB_DSN=",
|
||||
"KBDB_IDENTIFY_DOCUMENT=true",
|
||||
"KBDB_STORE_QUERY=true",
|
||||
"KBDB_IDENTIFY_TOP_N=3",
|
||||
"KBDB_GROUNDED_ONLY=true",
|
||||
)
|
||||
for path in envs:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for setting in required:
|
||||
assert setting in source, (path, setting)
|
||||
26
tests/unit/test_workflow_runtime_diagnostics.py
Normal file
26
tests/unit/test_workflow_runtime_diagnostics.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from agent_framework.workflows.runtime import _exception_details, _type_shape
|
||||
|
||||
|
||||
def _boom():
|
||||
raise AttributeError("'str' object has no attribute 'override'")
|
||||
|
||||
|
||||
def test_exception_details_contains_traceback():
|
||||
try:
|
||||
_boom()
|
||||
except Exception as exc:
|
||||
details = _exception_details(exc, runtime_context={"phase": "ainvoke"})
|
||||
assert details["type"] == "AttributeError"
|
||||
assert "_boom" in details["traceback"]
|
||||
assert "override" in details["traceback"]
|
||||
assert details["runtime_diagnostics"]["phase"] == "ainvoke"
|
||||
|
||||
|
||||
def test_type_shape_exposes_types_not_values():
|
||||
shape = _type_shape({"configurable": {"thread_id": "secret-thread", "__pregel_runtime": "bad-runtime"}})
|
||||
text = repr(shape)
|
||||
assert "thread_id" in text
|
||||
assert "__pregel_runtime" in text
|
||||
assert "secret-thread" not in text
|
||||
assert "bad-runtime" not in text
|
||||
assert "str" in text
|
||||
14
tests/unit/test_workflow_static.py
Normal file
14
tests/unit/test_workflow_static.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOW = ROOT / 'agent_template_backend' / 'app' / 'workflows' / 'agent_graph.py'
|
||||
|
||||
def test_workflow_uses_framework_checkpointer_not_memory_saver():
|
||||
src = WORKFLOW.read_text()
|
||||
assert 'create_langgraph_checkpointer(self.settings)' in src
|
||||
assert 'MemorySaver()' not in src
|
||||
|
||||
def test_workflow_wraps_nodes_with_langgraph_telemetry():
|
||||
src = WORKFLOW.read_text()
|
||||
assert 'self._node("input_guardrails", self.input_guardrails)' in src
|
||||
assert 'async with self.langgraph_telemetry.node(name, state)' in src
|
||||
218
tests/unit/test_workflow_terminal_snapshot_semantics.py
Normal file
218
tests/unit/test_workflow_terminal_snapshot_semantics.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.workflows import FileWorkflowRepository, WorkflowActionRegistry, WorkflowRuntime
|
||||
|
||||
|
||||
def _write_workflow(tmp_path: Path) -> None:
|
||||
(tmp_path / "terminal.active.yaml").write_text("version: 1\n", encoding="utf-8")
|
||||
(tmp_path / "terminal.v1.yaml").write_text(
|
||||
"""name: terminal
|
||||
version: 1
|
||||
start: finish
|
||||
nodes:
|
||||
- id: finish
|
||||
action: finish
|
||||
edges:
|
||||
- from: finish
|
||||
to: END
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _terminal_state(execution_id: str) -> dict:
|
||||
return {
|
||||
"execution_id": execution_id,
|
||||
"workflow_name": "terminal",
|
||||
"workflow_version": 1,
|
||||
"input": {},
|
||||
"nodes": {"finish": {"success": True}},
|
||||
"vars": {"finish": {"success": True}},
|
||||
"output": {"success": True},
|
||||
"trace": [{"node": "finish", "action": "finish", "attempt": 1, "status": "COMPLETED"}],
|
||||
"current_node": "finish",
|
||||
}
|
||||
|
||||
|
||||
class _FakeGraph:
|
||||
def __init__(self, state: dict, snapshot):
|
||||
self.state = state
|
||||
self.snapshot = snapshot
|
||||
|
||||
async def ainvoke(self, *args, **kwargs):
|
||||
return self.state
|
||||
|
||||
async def aget_state(self, config):
|
||||
return self.snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arun_truthy_next_without_interrupt_is_completed_when_definition_is_terminal(tmp_path: Path, monkeypatch):
|
||||
_write_workflow(tmp_path)
|
||||
runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry())
|
||||
state = _terminal_state("exec-1")
|
||||
# Regression shape observed in production: LangGraph still exposes a truthy
|
||||
# next, but there is no real interrupt and the current node routes to END.
|
||||
snapshot = SimpleNamespace(next=("finish__continue",), tasks=(), values=state)
|
||||
monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot))
|
||||
|
||||
result = await runtime.arun("terminal", {}, execution_id="exec-1")
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.pause is None
|
||||
assert result.state["current_node"] == "finish"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aresume_truthy_next_without_interrupt_is_completed_when_definition_is_terminal(tmp_path: Path, monkeypatch):
|
||||
_write_workflow(tmp_path)
|
||||
runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry())
|
||||
state = _terminal_state("exec-2")
|
||||
snapshot = SimpleNamespace(next=("finish__continue",), tasks=(), values=state)
|
||||
monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot))
|
||||
# aresume imports langgraph.types.Command before invoking the compiled graph.
|
||||
langgraph_module = ModuleType("langgraph")
|
||||
types_module = ModuleType("langgraph.types")
|
||||
class _Command:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
types_module.Command = _Command
|
||||
monkeypatch.setitem(sys.modules, "langgraph", langgraph_module)
|
||||
monkeypatch.setitem(sys.modules, "langgraph.types", types_module)
|
||||
|
||||
result = await runtime.aresume("terminal", "exec-2", "sim")
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.pause is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_interrupt_still_has_precedence_over_structural_terminal(tmp_path: Path, monkeypatch):
|
||||
_write_workflow(tmp_path)
|
||||
runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry())
|
||||
state = _terminal_state("exec-3")
|
||||
interrupt = SimpleNamespace(value={"node": "finish", "expected_input": {"key": "confirm"}})
|
||||
task = SimpleNamespace(interrupts=(interrupt,))
|
||||
snapshot = SimpleNamespace(next=("finish__pause",), tasks=(task,), values=state)
|
||||
monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot))
|
||||
|
||||
result = await runtime.arun("terminal", {}, execution_id="exec-3")
|
||||
|
||||
assert result.status == "PAUSED"
|
||||
assert result.pause == {"node": "finish", "expected_input": {"key": "confirm"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_nonterminal_without_interrupt_fails_closed_instead_of_faking_pause(tmp_path: Path, monkeypatch):
|
||||
(tmp_path / "nonterminal.active.yaml").write_text("version: 1\n", encoding="utf-8")
|
||||
(tmp_path / "nonterminal.v1.yaml").write_text(
|
||||
"""name: nonterminal
|
||||
version: 1
|
||||
start: one
|
||||
nodes:
|
||||
- id: one
|
||||
action: one
|
||||
- id: two
|
||||
action: two
|
||||
edges:
|
||||
- from: one
|
||||
to: two
|
||||
- from: two
|
||||
to: END
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry())
|
||||
state = {
|
||||
"execution_id": "exec-4",
|
||||
"workflow_name": "nonterminal",
|
||||
"workflow_version": 1,
|
||||
"input": {},
|
||||
"nodes": {"one": {"success": True}},
|
||||
"vars": {},
|
||||
"output": {},
|
||||
"trace": [{"node": "one", "action": "one", "attempt": 1, "status": "COMPLETED"}],
|
||||
"current_node": "one",
|
||||
}
|
||||
snapshot = SimpleNamespace(next=("two",), tasks=(), values=state)
|
||||
monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot))
|
||||
|
||||
result = await runtime.arun("nonterminal", {}, execution_id="exec-4")
|
||||
|
||||
assert result.status == "FAILED"
|
||||
assert "trabalho pendente sem interrupt real" in (result.error or "")
|
||||
assert result.pause is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persisted_interrupt_in_snapshot_values_is_real_pause(tmp_path: Path, monkeypatch):
|
||||
"""LangGraph may persist interrupts in values['__interrupt__'] only.
|
||||
|
||||
Regression: this shape used to be mistaken for non-terminal pending work
|
||||
when snapshot.next pointed at a framework-generated ``__pause`` node.
|
||||
"""
|
||||
_write_workflow(tmp_path)
|
||||
runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry())
|
||||
state = _terminal_state("exec-values-interrupt")
|
||||
pause_payload = {
|
||||
"node": "finish",
|
||||
"prompt": "Confirma?",
|
||||
"expected_input": {"key": "resposta_usuario", "allowed_values": ["SIM", "NAO"]},
|
||||
}
|
||||
state["__interrupt__"] = [{"value": pause_payload, "id": "pause-1"}]
|
||||
# current_node is deliberately non-terminal so the PAUSED decision must
|
||||
# come from the persisted interrupt, not structural-terminal detection.
|
||||
state["current_node"] = None
|
||||
snapshot = SimpleNamespace(next=("finish__pause",), tasks=(), values=state)
|
||||
monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot))
|
||||
|
||||
result = await runtime.arun("terminal", {}, execution_id="exec-values-interrupt")
|
||||
|
||||
assert result.status == "PAUSED"
|
||||
assert result.pause == pause_payload
|
||||
assert result.error is None
|
||||
|
||||
|
||||
def test_snapshot_interrupts_deduplicates_task_and_persisted_shapes(tmp_path: Path):
|
||||
_write_workflow(tmp_path)
|
||||
runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry())
|
||||
payload = {"node": "finish", "expected_input": {"key": "confirm"}}
|
||||
task = SimpleNamespace(interrupts=(SimpleNamespace(value=payload),))
|
||||
snapshot = SimpleNamespace(
|
||||
tasks=(task,),
|
||||
values={"__interrupt__": [{"value": payload, "id": "same-pause"}]},
|
||||
interrupts=(),
|
||||
)
|
||||
|
||||
assert runtime._snapshot_interrupts(snapshot) == [payload]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aresume_ignores_stale_interrupt_after_advancing_to_terminal_node(tmp_path: Path, monkeypatch):
|
||||
_write_workflow(tmp_path)
|
||||
runtime = WorkflowRuntime(FileWorkflowRepository(tmp_path), actions=WorkflowActionRegistry())
|
||||
state = _terminal_state("exec-stale")
|
||||
# Shape observed after resuming invoice_explanation: the durable snapshot can
|
||||
# still expose the interrupt from the old pause although current_node has
|
||||
# already advanced to the terminal handoff/finalization node.
|
||||
stale = SimpleNamespace(value={"node": "old_pause", "prompt": "old prompt"})
|
||||
task = SimpleNamespace(interrupts=(stale,))
|
||||
snapshot = SimpleNamespace(next=("finish__continue",), tasks=(task,), values=state)
|
||||
monkeypatch.setattr(runtime, "_compile", lambda definition: _FakeGraph(state, snapshot))
|
||||
|
||||
langgraph_module = ModuleType("langgraph")
|
||||
types_module = ModuleType("langgraph.types")
|
||||
class _Command:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
types_module.Command = _Command
|
||||
monkeypatch.setitem(sys.modules, "langgraph", langgraph_module)
|
||||
monkeypatch.setitem(sys.modules, "langgraph.types", types_module)
|
||||
|
||||
result = await runtime.aresume("terminal", "exec-stale", "nao")
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.pause is None
|
||||
assert result.state["current_node"] == "finish"
|
||||
Reference in New Issue
Block a user