Skip to content

Commit 2fbac7d

Browse files
committed
feat: complete V1 with unit tests, integration tests, and pyproject.toml
- 82 tests passing: 7 unit test suites + 1 integration test suite - Budget, escalation, degrader, finalizer, optimizer, audit core modules tested - API integration tests using httpx AsyncClient + ASGITransport - pyproject.toml with ruff, mypy, and pytest configuration - Fix .github/workflow -> .github/workflows (GitHub Actions requires plural) - Fix audit singleton isolation in degrader tests via @patch - Extract _make_state() helper in finalizer tests - Targeted mypy overrides instead of blanket ignore_missing_imports
1 parent a439043 commit 2fbac7d

10 files changed

Lines changed: 541 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[project]
2+
name = "multi-agent"
3+
version = "0.1.0"
4+
description = "Budget-aware multi-agent task executor with BAMAS-adapted cost-tier optimizer"
5+
requires-python = ">=3.12"
6+
license = {text = "MIT"}
7+
8+
[tool.ruff]
9+
target-version = "py312"
10+
line-length = 120
11+
12+
[tool.ruff.lint]
13+
select = ["E", "F", "I", "UP"]
14+
ignore = ["E501"]
15+
16+
[tool.mypy]
17+
python_version = "3.12"
18+
strict = true
19+
20+
[[tool.mypy.overrides]]
21+
module = ["langchain_*", "langgraph.*", "langsmith.*", "redis.*", "aiosqlite.*", "orjson.*", "scipy.*"]
22+
ignore_missing_imports = true
23+
24+
[tool.pytest.ini_options]
25+
testpaths = ["tests"]
26+
filterwarnings = ["ignore::DeprecationWarning"]
27+
addopts = "--ignore=tests/stress_test.py"

tests/fixtures/__init__.py

Whitespace-only changes.

tests/integration/test_api.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import pytest
2+
from httpx import AsyncClient, ASGITransport
3+
from api.main import app
4+
5+
6+
@pytest.mark.anyio
7+
async def test_health():
8+
transport = ASGITransport(app=app)
9+
async with AsyncClient(transport=transport, base_url="http://test") as client:
10+
r = await client.get("/health")
11+
assert r.status_code == 200
12+
assert r.json()["status"] == "ok"
13+
14+
15+
@pytest.mark.anyio
16+
async def test_execute_returns_task_id():
17+
transport = ASGITransport(app=app)
18+
async with AsyncClient(transport=transport, base_url="http://test") as client:
19+
r = await client.post("/execute", json={"task": "What is 2+2?", "budget_usd": 0.05})
20+
assert r.status_code == 200
21+
data = r.json()
22+
assert "task_id" in data
23+
assert data["status"] == "pending"
24+
25+
26+
@pytest.mark.anyio
27+
async def test_get_task_not_found():
28+
transport = ASGITransport(app=app)
29+
async with AsyncClient(transport=transport, base_url="http://test") as client:
30+
r = await client.get("/tasks/nonexistent-id")
31+
assert r.status_code == 404
32+
33+
34+
@pytest.mark.anyio
35+
async def test_get_audit_not_found():
36+
transport = ASGITransport(app=app)
37+
async with AsyncClient(transport=transport, base_url="http://test") as client:
38+
r = await client.get("/audit/nonexistent-id")
39+
assert r.status_code == 404
40+
41+
42+
@pytest.mark.anyio
43+
async def test_execute_with_topology_override():
44+
transport = ASGITransport(app=app)
45+
async with AsyncClient(transport=transport, base_url="http://test") as client:
46+
r = await client.post("/execute", json={
47+
"task": "Write a function",
48+
"budget_usd": 0.10,
49+
"topology": "pipeline",
50+
})
51+
assert r.status_code == 200
52+
data = r.json()
53+
assert "task_id" in data

tests/unit/test_audit.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import pytest
2+
from core.audit import AuditTrail, get_audit_trail
3+
4+
5+
class TestAuditTrail:
6+
def test_record_and_get(self):
7+
audit = AuditTrail()
8+
audit.record("task-1", "test_event", {"key": "value"})
9+
events = audit.get_task_audit("task-1")
10+
assert len(events) == 1
11+
assert events[0]["event_type"] == "test_event"
12+
assert events[0]["detail"]["key"] == "value"
13+
assert events[0]["task_id"] == "task-1"
14+
15+
def test_get_empty_for_unknown_task(self):
16+
audit = AuditTrail()
17+
assert audit.get_task_audit("nonexistent") == []
18+
19+
def test_record_topology_decision(self):
20+
audit = AuditTrail()
21+
audit.record_topology_decision(
22+
task_id="t1",
23+
topology="pipeline",
24+
model_tiers={"planner": "standard"},
25+
budget=80.0,
26+
rationale="code task",
27+
alternatives=[],
28+
)
29+
events = audit.get_task_audit("t1")
30+
assert len(events) == 1
31+
assert events[0]["event_type"] == "topology_decision"
32+
assert events[0]["detail"]["topology"] == "pipeline"
33+
34+
def test_record_budget_band(self):
35+
audit = AuditTrail()
36+
audit.record_budget_band("t2", "tier_downgrade", 25.0, "Downgrading tiers")
37+
events = audit.get_task_audit("t2")
38+
assert events[0]["event_type"] == "budget_band_crossed"
39+
assert events[0]["detail"]["band"] == "tier_downgrade"
40+
41+
def test_record_degradation(self):
42+
audit = AuditTrail()
43+
audit.record_degradation("t3", "ensemble", "fanout", "budget low")
44+
events = audit.get_task_audit("t3")
45+
assert events[0]["event_type"] == "structural_degradation"
46+
assert events[0]["detail"]["from_topology"] == "ensemble"
47+
assert events[0]["detail"]["to_topology"] == "fanout"
48+
49+
def test_multiple_tasks_isolated(self):
50+
audit = AuditTrail()
51+
audit.record("t1", "event_a", {})
52+
audit.record("t2", "event_b", {})
53+
assert len(audit.get_task_audit("t1")) == 1
54+
assert len(audit.get_task_audit("t2")) == 1
55+
56+
def test_to_json(self):
57+
audit = AuditTrail()
58+
audit.record("t1", "event", {"key": "val"})
59+
json_str = audit.to_json("t1")
60+
assert "event" in json_str
61+
assert "key" in json_str
62+
63+
def test_timestamp_present(self):
64+
audit = AuditTrail()
65+
audit.record("t1", "event", {})
66+
assert "timestamp" in audit.get_task_audit("t1")[0]
67+
68+
69+
class TestGetAuditTrailSingleton:
70+
def test_returns_same_instance(self):
71+
a = get_audit_trail()
72+
b = get_audit_trail()
73+
assert a is b

tests/unit/test_budget.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import pytest
2+
from core.budget import BudgetTracker, BudgetBand
3+
4+
5+
class TestBudgetBands:
6+
def test_healthy_band_below_70(self):
7+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.5)
8+
assert bt.spent_pct == 50.0
9+
assert bt.get_band() == BudgetBand.HEALTHY
10+
11+
def test_tier_downgrade_band_70_to_90(self):
12+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.8)
13+
assert bt.get_band() == BudgetBand.TIER_DOWNGRADE
14+
15+
def test_structural_degrade_band_90_to_100(self):
16+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
17+
assert bt.get_band() == BudgetBand.STRUCTURAL_DEGRADE
18+
19+
def test_critical_band_at_100(self):
20+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=1.0)
21+
assert bt.get_band() == BudgetBand.CRITICAL
22+
23+
def test_critical_band_over_100(self):
24+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=1.5)
25+
assert bt.get_band() == BudgetBand.CRITICAL
26+
27+
28+
class TestCanAffordTier:
29+
def test_healthy_affords_all(self):
30+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
31+
assert bt.can_afford_tier("cheap") is True
32+
assert bt.can_afford_tier("standard") is True
33+
assert bt.can_afford_tier("frontier") is True
34+
35+
def test_tier_downgrade_no_frontier(self):
36+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.8)
37+
assert bt.can_afford_tier("cheap") is True
38+
assert bt.can_afford_tier("standard") is True
39+
assert bt.can_afford_tier("frontier") is False
40+
41+
def test_structural_degrade_only_cheap(self):
42+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
43+
assert bt.can_afford_tier("cheap") is True
44+
assert bt.can_afford_tier("standard") is False
45+
assert bt.can_afford_tier("frontier") is False
46+
47+
def test_critical_affords_nothing(self):
48+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=1.0)
49+
assert bt.can_afford_tier("cheap") is False
50+
assert bt.can_afford_tier("standard") is False
51+
assert bt.can_afford_tier("frontier") is False
52+
53+
54+
class TestGetAllowedTiers:
55+
def test_healthy_all_tiers(self):
56+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
57+
assert bt.get_allowed_tiers() == ["cheap", "standard", "frontier"]
58+
59+
def test_tier_downgrade_two_tiers(self):
60+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.8)
61+
assert bt.get_allowed_tiers() == ["cheap", "standard"]
62+
63+
def test_structural_degrade_one_tier(self):
64+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
65+
assert bt.get_allowed_tiers() == ["cheap"]
66+
67+
def test_critical_one_tier(self):
68+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=1.0)
69+
assert bt.get_allowed_tiers() == ["cheap"]
70+
71+
72+
class TestShouldSkipJudge:
73+
def test_healthy_no_skip(self):
74+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
75+
assert bt.should_skip_judge() is False
76+
77+
def test_tier_downgrade_no_skip(self):
78+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.8)
79+
assert bt.should_skip_judge() is False
80+
81+
def test_structural_degrade_skip(self):
82+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
83+
assert bt.should_skip_judge() is True
84+
85+
def test_critical_skip(self):
86+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=1.0)
87+
assert bt.should_skip_judge() is True
88+
89+
90+
class TestRecordUsage:
91+
def test_record_usage_increments(self):
92+
bt = BudgetTracker(max_cost_usd=1.0)
93+
bt.record_usage(tokens=100, cost=0.05)
94+
assert bt.consumed_tokens == 100
95+
assert bt.consumed_cost == 0.05
96+
97+
def test_record_usage_accumulates(self):
98+
bt = BudgetTracker(max_cost_usd=1.0)
99+
bt.record_usage(tokens=100, cost=0.05)
100+
bt.record_usage(tokens=200, cost=0.10)
101+
assert bt.consumed_tokens == 300
102+
assert bt.consumed_cost == pytest.approx(0.15)
103+
104+
105+
class TestGetDegradedTopology:
106+
def test_ensemble_to_fanout(self):
107+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
108+
assert bt.get_degraded_topology("ensemble") == "fanout"
109+
110+
def test_fanout_to_supervisor(self):
111+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
112+
assert bt.get_degraded_topology("fanout") == "supervisor"
113+
114+
def test_supervisor_to_pipeline(self):
115+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
116+
assert bt.get_degraded_topology("supervisor") == "pipeline"
117+
118+
def test_pipeline_to_single(self):
119+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
120+
assert bt.get_degraded_topology("pipeline") == "single"
121+
122+
def test_single_stays_single(self):
123+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
124+
assert bt.get_degraded_topology("single") == "single"

tests/unit/test_degrader.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import pytest
2+
from unittest.mock import patch
3+
from core.degrader import degrade_topology, TOPOLOGY_DEGRADATION_CHAIN
4+
from core.budget import BudgetTracker, BudgetBand
5+
from core.audit import AuditTrail
6+
7+
8+
def _fresh_audit() -> AuditTrail:
9+
return AuditTrail()
10+
11+
12+
class TestDegradeTopology:
13+
def test_healthy_band_returns_same(self):
14+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.0)
15+
result = degrade_topology(bt, "ensemble", "task-1")
16+
assert result == "ensemble"
17+
18+
def test_tier_downgrade_returns_same(self):
19+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.8)
20+
result = degrade_topology(bt, "supervisor", "task-2")
21+
assert result == "supervisor"
22+
23+
def test_structural_degrade_ensemble_to_fanout(self):
24+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
25+
result = degrade_topology(bt, "ensemble", "task-3")
26+
assert result == "fanout"
27+
28+
def test_structural_degrade_fanout_to_supervisor(self):
29+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
30+
result = degrade_topology(bt, "fanout", "task-4")
31+
assert result == "supervisor"
32+
33+
def test_structural_degrade_supervisor_to_pipeline(self):
34+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
35+
result = degrade_topology(bt, "supervisor", "task-5")
36+
assert result == "pipeline"
37+
38+
def test_structural_degrade_pipeline_to_single(self):
39+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
40+
result = degrade_topology(bt, "pipeline", "task-6")
41+
assert result == "single"
42+
43+
def test_structural_degrade_single_stays_single(self):
44+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
45+
result = degrade_topology(bt, "single", "task-7")
46+
assert result == "single"
47+
48+
def test_structural_degrade_unknown_to_single(self):
49+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
50+
result = degrade_topology(bt, "unknown_topology", "task-8")
51+
assert result == "single"
52+
53+
def test_critical_band_returns_single(self):
54+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=1.0)
55+
result = degrade_topology(bt, "ensemble", "task-9")
56+
assert result == "single"
57+
58+
def test_degradation_chain_order(self):
59+
assert TOPOLOGY_DEGRADATION_CHAIN == ["ensemble", "fanout", "supervisor", "pipeline", "single"]
60+
61+
@patch("core.degrader.get_audit_trail")
62+
def test_audit_records_on_structural_degrade(self, mock_get_audit):
63+
audit = _fresh_audit()
64+
mock_get_audit.return_value = audit
65+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=0.95)
66+
degrade_topology(bt, "ensemble", "task-audit-1")
67+
events = audit.get_task_audit("task-audit-1")
68+
event_types = [e["event_type"] for e in events]
69+
assert "budget_band_crossed" in event_types
70+
assert "structural_degradation" in event_types
71+
72+
@patch("core.degrader.get_audit_trail")
73+
def test_audit_records_on_critical(self, mock_get_audit):
74+
audit = _fresh_audit()
75+
mock_get_audit.return_value = audit
76+
bt = BudgetTracker(max_cost_usd=1.0, consumed_cost=1.0)
77+
degrade_topology(bt, "ensemble", "task-audit-2")
78+
events = audit.get_task_audit("task-audit-2")
79+
assert any(e["event_type"] == "budget_band_crossed" for e in events)

0 commit comments

Comments
 (0)