Projeto do Agent Contas ORACLE
This commit is contained in:
Binary file not shown.
7
agent_framework_oci/tests/conftest.py
Normal file
7
agent_framework_oci/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'agent_framework' / 'src'))
|
||||
sys.path.insert(0, str(ROOT / 'agent_template_backend'))
|
||||
60
agent_framework_oci/tests/test_judge_transaction_sampling.py
Normal file
60
agent_framework_oci/tests/test_judge_transaction_sampling.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.judges.judge import JudgePipeline, JudgeResult
|
||||
|
||||
|
||||
class DummyJudge:
|
||||
async def evaluate(self, question, answer, context):
|
||||
return JudgeResult(name="dummy", score=1.0, passed=True, reason="ran")
|
||||
|
||||
|
||||
def pipeline(*, sample_rate=0.0, always=True):
|
||||
obj = object.__new__(JudgePipeline)
|
||||
obj.enabled = True
|
||||
obj.judges = [DummyJudge()]
|
||||
obj.sample_rate = sample_rate
|
||||
obj.always_run_for_transactional = always
|
||||
return obj
|
||||
|
||||
|
||||
def test_awaiting_confirmation_bypasses_sampling():
|
||||
p = pipeline(sample_rate=0.0, always=True)
|
||||
results = asyncio.run(p.evaluate_all("devolver", "confirma?", {
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"mcp_results": [{
|
||||
"tool_name": "solicitar_devolucao",
|
||||
"awaiting_confirmation": True,
|
||||
"transaction_status": "AWAITING_CONFIRMATION",
|
||||
"metadata": {"operation_type": "transactional"},
|
||||
}],
|
||||
}))
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_completed_transaction_bypasses_sampling_from_mcp_result():
|
||||
p = pipeline(sample_rate=0.0, always=True)
|
||||
results = asyncio.run(p.evaluate_all("sim", "protocolo DEV-1", {
|
||||
"mcp_results": [{
|
||||
"tool_name": "solicitar_devolucao",
|
||||
"ok": True,
|
||||
"metadata": {"operation_type": "transactional"},
|
||||
}],
|
||||
}))
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_non_transactional_turn_respects_zero_sample_rate():
|
||||
p = pipeline(sample_rate=0.0, always=True)
|
||||
results = asyncio.run(p.evaluate_all("pedido 123", "entregue", {
|
||||
"mcp_results": [{"tool_name": "consultar_pedido", "ok": True}],
|
||||
}))
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_transactional_detection_from_tool_policy():
|
||||
p = pipeline(sample_rate=0.0, always=True)
|
||||
results = asyncio.run(p.evaluate_all("sim", "feito", {
|
||||
"tool_policy_result": {"operation_type": "transactional"},
|
||||
}))
|
||||
assert len(results) == 1
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
from agent_framework.identity.mcp_mapper import MCPParameterMapper
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
def test_explicit_order_id_has_precedence_over_contract_key():
|
||||
mapper = MCPParameterMapper({
|
||||
"mcp_parameter_mapping": {
|
||||
"tools": {
|
||||
"consultar_pedido": {
|
||||
"map": {"contract_key": "order_id", "customer_key": "customer_id"},
|
||||
"extract": {"order_id": {"from": "message", "strategy": "llm", "type": "string"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
mapped = mapper.map(
|
||||
"consultar_pedido",
|
||||
{"contract_key": "3000131180", "customer_key": "11999999999"},
|
||||
extra_args={"order_id": "123"},
|
||||
)
|
||||
assert mapped["order_id"] == "123"
|
||||
assert mapped["customer_id"] == "11999999999"
|
||||
assert "extract" not in mapped
|
||||
|
||||
|
||||
class _FakeLLM:
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
assert "consultar pedido 123" in messages[0]["content"]
|
||||
assert kwargs["generation_name"] == "llm.mcp_parameter_extraction"
|
||||
return {"content": '{"order_id": "123"}'}
|
||||
|
||||
|
||||
class _FakeRouter:
|
||||
def parameter_extract_rules(self, tool_name):
|
||||
return {
|
||||
"order_id": {
|
||||
"from": "message",
|
||||
"strategy": "llm",
|
||||
"type": "string",
|
||||
"description": "Extraia o identificador do pedido.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _Runtime(AgentRuntimeMixin):
|
||||
def __init__(self):
|
||||
self.tool_router = _FakeRouter()
|
||||
self.llm = _FakeLLM()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_extracts_order_id_from_current_message():
|
||||
runtime = _Runtime()
|
||||
result = await runtime._extract_mcp_parameters(
|
||||
"consultar_pedido",
|
||||
{"contract_key": "3000131180"},
|
||||
{"user_text": "consultar pedido 123", "sanitized_input": "consultar pedido 123"},
|
||||
)
|
||||
assert result["order_id"] == "123"
|
||||
assert result["contract_key"] == "3000131180"
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework import observer
|
||||
from agent_framework.analytics import tim_sequence
|
||||
|
||||
|
||||
def test_sync_event_calls_share_one_stable_asyncio_loop(monkeypatch):
|
||||
seen_loop_ids: set[int] = set()
|
||||
seen_lock = threading.Lock()
|
||||
|
||||
async def fake_aevent(name: str, **kwargs):
|
||||
loop_id = id(asyncio.get_running_loop())
|
||||
with seen_lock:
|
||||
seen_loop_ids.add(loop_id)
|
||||
await asyncio.sleep(0.01)
|
||||
return {"eventType": name, "loop_id": loop_id}
|
||||
|
||||
monkeypatch.setattr(observer, "aevent", fake_aevent)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(lambda i: observer.event(f"IC.TEST.{i}"), range(24)))
|
||||
|
||||
assert len(seen_loop_ids) == 1
|
||||
assert {item["loop_id"] for item in results} == seen_loop_ids
|
||||
|
||||
|
||||
def test_memory_sequence_is_safe_across_independent_event_loops(monkeypatch):
|
||||
monkeypatch.setenv("PUBSUB_SEQUENCE_ENABLED", "true")
|
||||
monkeypatch.setenv("PUBSUB_SEQUENCE_PROVIDER", "memory")
|
||||
tim_sequence._memory_counters.clear()
|
||||
|
||||
def one_call(_: int) -> int | None:
|
||||
return asyncio.run(
|
||||
tim_sequence.next_sequence(
|
||||
"agent-a",
|
||||
"session-a",
|
||||
"transaction-cross-loop",
|
||||
)
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=12) as pool:
|
||||
values = list(pool.map(one_call, range(120)))
|
||||
|
||||
assert sorted(values) == list(range(1, 121))
|
||||
|
||||
|
||||
def test_mongo_ttl_index_guard_is_thread_safe_across_event_loops(monkeypatch):
|
||||
tim_sequence._mongo_index_checked = False
|
||||
monkeypatch.setenv("PUBSUB_SEQUENCE_MONGODB_URI", "mongodb://fake")
|
||||
|
||||
calls = 0
|
||||
calls_lock = threading.Lock()
|
||||
|
||||
class FakeCollection:
|
||||
def create_index(self, *args, **kwargs):
|
||||
nonlocal calls
|
||||
with calls_lock:
|
||||
calls += 1
|
||||
# Enlarge the contention window that previously exposed the
|
||||
# cross-event-loop asyncio.Lock issue.
|
||||
time.sleep(0.05)
|
||||
|
||||
class FakeDatabase:
|
||||
def __getitem__(self, name):
|
||||
return FakeCollection()
|
||||
|
||||
class FakeMongoClient:
|
||||
def __init__(self, uri):
|
||||
self.uri = uri
|
||||
|
||||
def __getitem__(self, name):
|
||||
return FakeDatabase()
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setitem(sys.modules, "pymongo", SimpleNamespace(MongoClient=FakeMongoClient))
|
||||
|
||||
def ensure_index(_: int) -> None:
|
||||
asyncio.run(tim_sequence._ensure_mongo_ttl_index_once(60))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
list(pool.map(ensure_index, range(16)))
|
||||
|
||||
assert calls == 1
|
||||
assert tim_sequence._mongo_index_checked is True
|
||||
50
agent_framework_oci/tests/test_performance_optimizations.py
Normal file
50
agent_framework_oci/tests/test_performance_optimizations.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
class Registry:
|
||||
def __init__(self):
|
||||
self.items={
|
||||
'consultar_pedido': SimpleNamespace(selection_keywords=['pedido','status do pedido']),
|
||||
'consultar_entrega': SimpleNamespace(selection_keywords=['entrega','rastreio']),
|
||||
}
|
||||
def get_tool(self,name): return self.items.get(name)
|
||||
|
||||
class Router:
|
||||
registry=Registry()
|
||||
def parameter_extract_rules(self, tool):
|
||||
return {'order_id': {'from':'message','type':'string','strategy':'hybrid','pattern':r'(?i)\bpedido\s+([A-Z0-9-]+)\b','group':1}}
|
||||
|
||||
class Runtime(AgentRuntimeMixin):
|
||||
tool_router=Router()
|
||||
llm=None
|
||||
settings=SimpleNamespace(SKIP_RAG_WHEN_MCP_SUFFICIENT=True)
|
||||
|
||||
|
||||
def test_selects_only_relevant_read_only_tool():
|
||||
r=Runtime()
|
||||
assert r._select_read_only_tools(['consultar_pedido','consultar_entrega'],'consultar pedido 123') == ['consultar_pedido']
|
||||
assert r._select_read_only_tools(['consultar_pedido','consultar_entrega'],'rastreio da entrega 123') == ['consultar_entrega']
|
||||
|
||||
|
||||
def test_hybrid_regex_does_not_require_llm():
|
||||
r=Runtime()
|
||||
state={'user_text':'consultar pedido 123','sanitized_input':'consultar pedido 123','context':{},'business_context':{}}
|
||||
out=asyncio.run(r._extract_mcp_parameters('consultar_pedido',{},state))
|
||||
assert out['order_id']=='123'
|
||||
|
||||
|
||||
def test_direct_answer_is_blocked_for_transactional_request():
|
||||
runtime = object.__new__(AgentRuntimeMixin)
|
||||
registry = SimpleNamespace(
|
||||
tools={"consultar_pedido": object(), "solicitar_devolucao": object()},
|
||||
get_tool=lambda name: {
|
||||
"consultar_pedido": SimpleNamespace(selection_keywords=["pedido"]),
|
||||
"solicitar_devolucao": SimpleNamespace(selection_keywords=["devolver pedido", "devolver", "devolução"]),
|
||||
}.get(name),
|
||||
)
|
||||
runtime.tool_router = SimpleNamespace(registry=registry)
|
||||
runtime._resolve_tool_execution_policy = lambda name, args=None: {"operation_type": "transactional" if name == "solicitar_devolucao" else "read_only"}
|
||||
state = {"user_text": "Quero devolver o pedido 123"}
|
||||
results = [{"ok": True, "tool_name": "consultar_pedido", "result": {"order_id": "123", "status": "ENTREGUE"}}]
|
||||
assert runtime.build_direct_mcp_answer(state, results, agent_label="OrdersAgent") is None
|
||||
@@ -0,0 +1,14 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
from agent_framework.routing.models import RouteDecision
|
||||
|
||||
|
||||
def test_explicit_keyword_shift_preempts_stickiness():
|
||||
d = RouteDecision(route="support_agent", agent="support_agent", intent="retail_support_exchange_return", method="keyword", metadata={"matched_keyword": "devolver pedido"})
|
||||
assert EnterpriseRouter._is_explicit_intent_shift(d) is True
|
||||
|
||||
|
||||
def test_short_generic_keyword_does_not_preempt():
|
||||
d = RouteDecision(route="x", agent="x", intent="x", method="keyword", metadata={"matched_keyword": "id"})
|
||||
assert EnterpriseRouter._is_explicit_intent_shift(d) is False
|
||||
90
agent_framework_oci/tests/test_transactional_tool_flow.py
Normal file
90
agent_framework_oci/tests/test_transactional_tool_flow.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.mcp.tool_policy import ToolPolicyRegistry
|
||||
|
||||
|
||||
def test_tool_policy_registry_reads_transactional_confirmation(tmp_path: Path):
|
||||
config = tmp_path / "tool_policies.yaml"
|
||||
config.write_text("""version: 1
|
||||
defaults:
|
||||
operation_type: read_only
|
||||
require_confirmation: false
|
||||
tool_policies:
|
||||
solicitar_devolucao:
|
||||
operation_type: transactional
|
||||
require_confirmation: true
|
||||
""", encoding="utf-8")
|
||||
policy = ToolPolicyRegistry(str(config)).get("solicitar_devolucao")
|
||||
assert policy is not None
|
||||
assert policy.operation_type == "transactional"
|
||||
assert policy.require_confirmation is True
|
||||
|
||||
|
||||
def test_runtime_source_contains_persisted_confirmation_contract():
|
||||
source = Path("libs/agent_framework/src/agent_framework/runtime/agent_runtime.py").read_text(encoding="utf-8")
|
||||
assert "pending_tool_call" in source
|
||||
assert "AWAITING_CONFIRMATION" in source
|
||||
assert "executed_after_confirmation" in source
|
||||
|
||||
import pytest
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
class _PolicyRouter:
|
||||
def __init__(self):
|
||||
from types import SimpleNamespace
|
||||
self.registry = SimpleNamespace(
|
||||
tools={"consultar_pedido": object(), "solicitar_devolucao": object()},
|
||||
get_tool=lambda name: {
|
||||
"consultar_pedido": SimpleNamespace(selection_keywords=["consultar pedido", "pedido"]),
|
||||
"solicitar_devolucao": SimpleNamespace(selection_keywords=["devolver pedido", "devolver", "devolução", "arrependimento"]),
|
||||
}.get(name),
|
||||
)
|
||||
|
||||
def resolve_execution_policy(self, tool_name, arguments=None):
|
||||
if tool_name == "solicitar_devolucao":
|
||||
return {"operation_type": "transactional", "require_confirmation": True, "policy_source": "test"}
|
||||
return {"operation_type": "read_only", "require_confirmation": False, "policy_source": "test"}
|
||||
|
||||
def validate_execution_policy(self, tool_name, arguments=None):
|
||||
policy = self.resolve_execution_policy(tool_name, arguments)
|
||||
if policy["require_confirmation"] and not (arguments or {}).get("confirmed"):
|
||||
return False, "Tool exige confirmação explícita antes da execução", policy
|
||||
return True, None, policy
|
||||
|
||||
|
||||
class _Runtime(AgentRuntimeMixin):
|
||||
def __init__(self):
|
||||
self.tool_router = _PolicyRouter()
|
||||
self.calls = []
|
||||
|
||||
async def _call_mcp_tool(self, tool_name, arguments, state):
|
||||
self.calls.append((tool_name, dict(arguments)))
|
||||
return {"ok": True, "tool_name": tool_name, "result": {"status": "ABERTO"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_waits_then_executes_after_confirmation():
|
||||
runtime = _Runtime()
|
||||
state = {
|
||||
"user_text": "Quero devolver o pedido 123 porque me arrependi",
|
||||
"sanitized_input": "Quero devolver o pedido 123 porque me arrependi",
|
||||
"mcp_tools": ["consultar_pedido", "solicitar_devolucao"],
|
||||
"route": "support_agent",
|
||||
"intent": "retail_support_exchange_return",
|
||||
}
|
||||
first = await runtime.execute_tools_for_intent(state)
|
||||
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
|
||||
assert state["pending_tool_call"]["tool_name"] == "solicitar_devolucao"
|
||||
assert state["pending_tool_call"]["arguments"]["order_id"] == "123"
|
||||
assert not any(name == "solicitar_devolucao" for name, _ in runtime.calls)
|
||||
assert first[-1]["awaiting_confirmation"] is True
|
||||
|
||||
state["user_text"] = "Sim, confirmo a devolução."
|
||||
state["sanitized_input"] = state["user_text"]
|
||||
second = await runtime.execute_tools_for_intent(state)
|
||||
assert state["transaction_status"] == "COMPLETED"
|
||||
assert state["pending_tool_call"] == {}
|
||||
assert runtime.calls[-1][0] == "solicitar_devolucao"
|
||||
assert runtime.calls[-1][1]["confirmed"] is True
|
||||
assert second[-1]["ok"] is True
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
agent_framework_oci/tests/unit/test_agent_runtime.py
Normal file
23
agent_framework_oci/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
agent_framework_oci/tests/unit/test_authentication.py
Normal file
50
agent_framework_oci/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
|
||||
@@ -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
agent_framework_oci/tests/unit/test_cache.py
Normal file
19
agent_framework_oci/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
agent_framework_oci/tests/unit/test_cache_distributed.py
Normal file
12
agent_framework_oci/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
agent_framework_oci/tests/unit/test_imports_compile.py
Normal file
5
agent_framework_oci/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
|
||||
@@ -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
agent_framework_oci/tests/unit/test_langgraph_telemetry.py
Normal file
29
agent_framework_oci/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'
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
agent_framework_oci/tests/unit/test_rag.py
Normal file
12
agent_framework_oci/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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
204
agent_framework_oci/tests/unit/test_semantic_route_stickiness.py
Normal file
204
agent_framework_oci/tests/unit/test_semantic_route_stickiness.py
Normal file
@@ -0,0 +1,204 @@
|
||||
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"
|
||||
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
agent_framework_oci/tests/unit/test_sse.py
Normal file
19
agent_framework_oci/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
agent_framework_oci/tests/unit/test_sse_replay_dedup.py
Normal file
30
agent_framework_oci/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()
|
||||
@@ -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
agent_framework_oci/tests/unit/test_token_cost_enterprise.py
Normal file
23
agent_framework_oci/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
agent_framework_oci/tests/unit/test_tool_policies.py
Normal file
98
agent_framework_oci/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
agent_framework_oci/tests/unit/test_transactional_workflows.py
Normal file
110
agent_framework_oci/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 == {}
|
||||
14
agent_framework_oci/tests/unit/test_workflow_static.py
Normal file
14
agent_framework_oci/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
|
||||
Reference in New Issue
Block a user