Spaces:
Sleeping
Sleeping
| """Integration tests for PydanticAI-backed ACE roles with real API calls. | |
| Requires AWS credentials for Bedrock access. | |
| Run with: uv run pytest tests/test_pydantic_ai_integration.py -v -s --no-cov | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import pytest | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| # Skip entire module if no API credentials | |
| pytestmark = pytest.mark.requires_api | |
| HAS_API = bool(os.environ.get("OPENAI_API_KEY")) | |
| if not HAS_API: | |
| pytest.skip("OPENAI_API_KEY not set", allow_module_level=True) | |
| from ace.core.outputs import ( | |
| AgentOutput, | |
| ReflectorOutput, | |
| SkillManagerOutput, | |
| ) | |
| from ace.core.skillbook import Skillbook, UpdateBatch | |
| from ace.implementations import Agent, Reflector, SkillManager | |
| from ace.runners.litellm import ACELiteLLM | |
| from ace.core.environments import Sample, SimpleEnvironment | |
| MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" | |
| class TestAgentRole: | |
| """Test Agent role produces valid structured output.""" | |
| def test_basic_question(self): | |
| agent = Agent(MODEL) | |
| sb = Skillbook() | |
| output = agent.generate( | |
| question="What is the capital of France?", | |
| context="Answer in one word.", | |
| skillbook=sb, | |
| ) | |
| assert isinstance(output, AgentOutput) | |
| assert len(output.reasoning) > 0, "reasoning should be non-empty" | |
| assert len(output.final_answer) > 0, "final_answer should be non-empty" | |
| assert ( | |
| "paris" in output.final_answer.lower() | |
| ), f"Expected 'Paris' in answer, got: {output.final_answer}" | |
| assert isinstance(output.skill_ids, list) | |
| assert "usage" in output.raw, f"raw should contain usage, got: {output.raw}" | |
| assert output.raw["usage"]["prompt_tokens"] > 0 | |
| assert output.raw["usage"]["completion_tokens"] > 0 | |
| print(f"\n Agent answer: {output.final_answer}") | |
| print(f" Usage: {output.raw['usage']}") | |
| def test_with_skillbook(self): | |
| agent = Agent(MODEL) | |
| sb = Skillbook() | |
| sb.add_skill( | |
| "math", | |
| "Use decomposition: break large multiplications into (a*10 + b) parts", | |
| skill_id="math-001", | |
| ) | |
| output = agent.generate( | |
| question="What is 17 × 23?", | |
| context="Show your work step by step.", | |
| skillbook=sb, | |
| ) | |
| assert isinstance(output, AgentOutput) | |
| assert len(output.final_answer) > 0 | |
| assert ( | |
| "391" in output.final_answer | |
| ), f"Expected '391' in answer, got: {output.final_answer}" | |
| print(f"\n Agent answer: {output.final_answer}") | |
| print(f" Reasoning excerpt: {output.reasoning[:300]}...") | |
| print(f" Cited skills: {output.skill_ids}") | |
| def test_with_reflection(self): | |
| agent = Agent(MODEL) | |
| sb = Skillbook() | |
| sb.add_skill( | |
| "physics", | |
| "Always include the unit when stating temperatures", | |
| skill_id="phys-001", | |
| ) | |
| output = agent.generate( | |
| question="What temperature does water boil at in Fahrenheit? Reply with just the number and unit.", | |
| context="This is a factual science question. Answer concisely.", | |
| skillbook=sb, | |
| reflection="Your previous answer was incorrect. The correct answer is 212°F at standard atmospheric pressure.", | |
| ) | |
| assert isinstance(output, AgentOutput) | |
| assert len(output.final_answer) > 0 | |
| # The reflection explicitly states 212°F — verify the model uses it | |
| full_text = f"{output.final_answer} {output.reasoning}" | |
| assert ( | |
| "212" in full_text | |
| ), f"Expected '212' somewhere in output, got answer: {output.final_answer}" | |
| print(f"\n Agent answer with reflection: {output.final_answer}") | |
| class TestReflectorRole: | |
| """Test Reflector role produces valid structured analysis.""" | |
| def test_correct_answer_reflection(self): | |
| reflector = Reflector(MODEL) | |
| sb = Skillbook() | |
| sb.add_skill("math", "Break down multiplication", skill_id="math-001") | |
| agent_output = AgentOutput( | |
| reasoning="Following [math-001], I decomposed 15×24 as 15×20 + 15×4 = 300 + 60 = 360", | |
| final_answer="360", | |
| skill_ids=["math-001"], | |
| ) | |
| output = reflector.reflect( | |
| question="What is 15 × 24?", | |
| agent_output=agent_output, | |
| skillbook=sb, | |
| ground_truth="360", | |
| feedback="Correct!", | |
| ) | |
| assert isinstance(output, ReflectorOutput) | |
| assert len(output.reasoning) > 0 | |
| assert len(output.correct_approach) > 0 | |
| assert len(output.key_insight) > 0 | |
| assert "usage" in output.raw | |
| print(f"\n Key insight: {output.key_insight}") | |
| def test_wrong_answer_reflection(self): | |
| reflector = Reflector(MODEL) | |
| sb = Skillbook() | |
| agent_output = AgentOutput( | |
| reasoning="I calculated 15×24 = 15×20 + 15×4 = 310 + 60 = 370", | |
| final_answer="370", | |
| ) | |
| output = reflector.reflect( | |
| question="What is 15 × 24?", | |
| agent_output=agent_output, | |
| skillbook=sb, | |
| ground_truth="360", | |
| feedback="Incorrect. The answer is 360.", | |
| ) | |
| assert isinstance(output, ReflectorOutput) | |
| assert len(output.error_identification) > 0, "Should identify the error" | |
| assert len(output.root_cause_analysis) > 0, "Should analyze root cause" | |
| print(f"\n Error identified: {output.error_identification[:200]}") | |
| print(f" Root cause: {output.root_cause_analysis[:200]}") | |
| print(f" Key insight: {output.key_insight[:200]}") | |
| class TestSkillManagerRole: | |
| """Test SkillManager role produces valid skillbook updates.""" | |
| def test_add_new_skill(self): | |
| sm = SkillManager(MODEL) | |
| sb = Skillbook() | |
| reflection = ReflectorOutput( | |
| reasoning="The agent failed because it didn't decompose the problem", | |
| error_identification="Tried to multiply directly without decomposition", | |
| root_cause_analysis="Missing strategy for breaking down multiplication", | |
| correct_approach="Use decomposition: 15×24 = 15×(20+4) = 300+60 = 360", | |
| key_insight="Break large multiplications into manageable parts", | |
| ) | |
| output = sm.update_skills( | |
| reflections=(reflection,), | |
| skillbook=sb, | |
| question_context="Mental arithmetic", | |
| progress="0/1 correct", | |
| ) | |
| assert isinstance(output, SkillManagerOutput) | |
| assert isinstance(output.update, UpdateBatch) | |
| assert len(output.update.reasoning) > 0 | |
| assert "usage" in output.raw | |
| print(f"\n Reasoning: {output.update.reasoning[:200]}") | |
| print(f" Operations: {len(output.update.operations)}") | |
| for op in output.update.operations: | |
| print( | |
| f" {op.type}: {(op.insight or op.issue)[:80] if (op.insight or op.issue) else 'N/A'}" | |
| ) | |
| def test_tag_existing_skill(self): | |
| sm = SkillManager(MODEL) | |
| sb = Skillbook() | |
| sb.add_skill( | |
| "math", | |
| "Use decomposition for multiplication", | |
| skill_id="math-001", | |
| ) | |
| reflection = ReflectorOutput( | |
| reasoning="The agent correctly applied decomposition strategy", | |
| correct_approach="Decomposition worked well", | |
| key_insight="Decomposition strategy is effective", | |
| ) | |
| output = sm.update_skills( | |
| reflections=(reflection,), | |
| skillbook=sb, | |
| question_context="Mental arithmetic", | |
| progress="1/1 correct", | |
| ) | |
| assert isinstance(output, SkillManagerOutput) | |
| print(f"\n Operations: {len(output.update.operations)}") | |
| for op in output.update.operations: | |
| print( | |
| f" {op.type} {op.skill_id or ''}: " | |
| f"{(op.insight or op.issue) or op.metadata}" | |
| ) | |
| class TestACELiteLLMIntegration: | |
| """Test the full ACELiteLLM flow with real API calls.""" | |
| def test_ask(self): | |
| ace = ACELiteLLM.from_model(MODEL) | |
| answer = ace.ask("What is 2 + 2?") | |
| assert "4" in answer, f"Expected '4' in answer, got: {answer}" | |
| print(f"\n ask() answer: {answer}") | |
| def test_ask_and_learn_from_feedback(self): | |
| ace = ACELiteLLM.from_model(MODEL) | |
| answer = ace.ask("What is the chemical symbol for gold?") | |
| print(f"\n Answer: {answer}") | |
| assert len(answer) > 0 | |
| result = ace.learn_from_feedback( | |
| feedback="Correct! Gold's symbol Au comes from the Latin 'aurum'.", | |
| ground_truth="Au", | |
| ) | |
| assert result is True, "learn_from_feedback should return True" | |
| print(f" Skills after learning: {len(ace.skillbook.skills())}") | |
| for skill in ace.skillbook.skills(): | |
| print(f" [{skill.id}] {skill.content[:80]}") | |
| def test_full_learning_pipeline(self): | |
| """End-to-end: learn from samples, verify skillbook grows.""" | |
| ace = ACELiteLLM.from_model(MODEL) | |
| env = SimpleEnvironment() | |
| samples = [ | |
| Sample( | |
| question="What is the speed of light in km/s?", | |
| ground_truth="approximately 300,000 km/s", | |
| ), | |
| ] | |
| results = ace.learn(samples, environment=env) | |
| assert len(results) == 1 | |
| assert results[0].error is None, f"Pipeline error: {results[0].error}" | |
| print(f"\n Pipeline completed. Skills: {len(ace.skillbook.skills())}") | |
| for skill in ace.skillbook.skills(): | |
| print(f" [{skill.id}] {skill.content[:80]}") | |
| def test_save_and_load_after_learning(self, tmp_path): | |
| """Skills survive save/load cycle.""" | |
| ace = ACELiteLLM.from_model(MODEL) | |
| answer = ace.ask("What is H2O?") | |
| ace.learn_from_feedback("Correct!", ground_truth="Water") | |
| path = str(tmp_path / "skillbook.json") | |
| skills_before = len(ace.skillbook.skills()) | |
| ace.save(path) | |
| ace2 = ACELiteLLM.from_model(MODEL, skillbook_path=path) | |
| assert len(ace2.skillbook.skills()) == skills_before | |
| print(f"\n Saved and loaded {skills_before} skills successfully") | |
| class TestRetryAndConsistency: | |
| """Test structured output consistency across multiple calls.""" | |
| def test_structured_output_consistency(self): | |
| """Multiple calls should always produce valid structured output.""" | |
| agent = Agent(MODEL) | |
| sb = Skillbook() | |
| questions = [ | |
| ("What is 7 × 8?", "56"), | |
| ("What is the capital of Japan?", "Tokyo"), | |
| ("Who wrote Romeo and Juliet?", "Shakespeare"), | |
| ] | |
| for q, expected in questions: | |
| output = agent.generate( | |
| question=q, | |
| context="Answer concisely.", | |
| skillbook=sb, | |
| ) | |
| assert isinstance(output, AgentOutput), f"Wrong type for '{q}'" | |
| assert len(output.reasoning) > 0, f"Empty reasoning for '{q}'" | |
| assert len(output.final_answer) > 0, f"Empty answer for '{q}'" | |
| assert isinstance(output.raw, dict), f"raw not dict for '{q}'" | |
| assert "usage" in output.raw, f"No usage in raw for '{q}'" | |
| assert ( | |
| expected.lower() in output.final_answer.lower() | |
| ), f"Expected '{expected}' in answer for '{q}', got: {output.final_answer}" | |
| print(f"\n Q: {q} -> A: {output.final_answer}") | |
| class TestRRStepIntegration: | |
| """Test the PydanticAI-based Recursive Reflector (RRStep) with real API calls.""" | |
| RR_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" | |
| def test_rr_basic_reflection(self): | |
| """RRStep.reflect returns a valid ReflectorOutput with non-empty fields.""" | |
| from ace.steps.rr_step import RRStep | |
| from ace.implementations.rr.config import RecursiveConfig | |
| config = RecursiveConfig( | |
| max_requests=15, | |
| timeout=15.0, | |
| ) | |
| rr = RRStep(model=self.RR_MODEL, config=config) | |
| sb = Skillbook() | |
| agent_out = AgentOutput( | |
| reasoning="I recall that the capital of Australia is Sydney because it is the largest city.", | |
| final_answer="Sydney", | |
| skill_ids=[], | |
| ) | |
| output = rr.reflect( | |
| question="What is the capital of Australia?", | |
| agent_output=agent_out, | |
| skillbook=sb, | |
| ground_truth="Canberra", | |
| feedback="Incorrect. The capital of Australia is Canberra, not Sydney.", | |
| ) | |
| assert isinstance( | |
| output, ReflectorOutput | |
| ), f"Expected ReflectorOutput, got {type(output)}" | |
| assert len(output.reasoning) > 0, "reasoning should be non-empty" | |
| assert len(output.key_insight) > 0, "key_insight should be non-empty" | |
| assert isinstance(output.raw, dict), "raw should be a dict" | |
| # Verify rr_trace metadata is populated | |
| rr_trace = output.raw.get("rr_trace", {}) | |
| assert isinstance(rr_trace, dict), "rr_trace should be a dict in raw" | |
| assert "total_iterations" in rr_trace, "rr_trace should have total_iterations" | |
| print(f"\n Reasoning: {output.reasoning[:300]}") | |
| print(f" Key insight: {output.key_insight[:200]}") | |
| print(f" RR trace: {rr_trace}") | |
| def test_rr_with_skillbook(self): | |
| """RRStep.reflect with a populated skillbook references or tags skills.""" | |
| from ace.steps.rr_step import RRStep | |
| from ace.implementations.rr.config import RecursiveConfig | |
| config = RecursiveConfig( | |
| max_requests=25, | |
| timeout=15.0, | |
| ) | |
| rr = RRStep(model=self.RR_MODEL, config=config) | |
| sb = Skillbook() | |
| sb.add_skill( | |
| "geography", | |
| "Always verify capital cities — the largest city is often not the capital", | |
| skill_id="geo-001", | |
| ) | |
| agent_out = AgentOutput( | |
| reasoning="The largest city in Brazil is Sao Paulo, so it must be the capital.", | |
| final_answer="Sao Paulo", | |
| skill_ids=[], | |
| ) | |
| output = rr.reflect( | |
| question="What is the capital of Brazil?", | |
| agent_output=agent_out, | |
| skillbook=sb, | |
| ground_truth="Brasilia", | |
| feedback="Incorrect. The capital of Brazil is Brasilia.", | |
| ) | |
| assert isinstance(output, ReflectorOutput) | |
| assert len(output.reasoning) > 0 | |
| assert len(output.key_insight) > 0 | |
| # The RR should produce a meaningful analysis referencing the | |
| # capital city error. | |
| full_text = f"{output.reasoning} {output.key_insight}" | |
| has_analysis = ( | |
| "capital" in full_text.lower() | |
| or "largest" in full_text.lower() | |
| or "brasilia" in full_text.lower() | |
| ) | |
| assert has_analysis, ( | |
| "Expected the reflector to analyze the capital city error. " | |
| f"reasoning={output.reasoning[:200]}" | |
| ) | |
| print(f"\n Key insight: {output.key_insight[:200]}") | |
| def test_rr_step_protocol(self): | |
| """RRStep used as a StepProtocol: __call__(ctx) populates reflections.""" | |
| from ace.steps.rr_step import RRStep | |
| from ace.implementations.rr.config import RecursiveConfig | |
| from ace.core.context import ACEStepContext, SkillbookView | |
| config = RecursiveConfig( | |
| max_requests=15, | |
| timeout=15.0, | |
| ) | |
| rr = RRStep(model=self.RR_MODEL, config=config) | |
| sb = Skillbook() | |
| trace = { | |
| "question": "What is 15 x 24?", | |
| "ground_truth": "360", | |
| "feedback": "Incorrect. The correct answer is 360.", | |
| "steps": [ | |
| { | |
| "role": "agent", | |
| "reasoning": "15 x 24 = 15 x 20 + 15 x 4 = 310 + 60 = 370", | |
| "answer": "370", | |
| "skill_ids": [], | |
| }, | |
| ], | |
| } | |
| ctx = ACEStepContext( | |
| trace=trace, | |
| skillbook=SkillbookView(sb), | |
| ) | |
| result_ctx = rr(ctx) | |
| assert result_ctx.reflections is not None, "reflections should be set" | |
| assert len(result_ctx.reflections) > 0, "reflections should be non-empty" | |
| for reflection in result_ctx.reflections: | |
| assert isinstance(reflection, ReflectorOutput) | |
| assert len(reflection.reasoning) > 0 | |
| print(f"\n Reflections count: {len(result_ctx.reflections)}") | |
| print(f" First reasoning: {result_ctx.reflections[0].reasoning[:300]}") | |
| print(f" First key_insight: {result_ctx.reflections[0].key_insight[:200]}") | |
| def test_rr_execute_code_tool_used(self): | |
| """Verify the agent uses execute_code (total_iterations > 0 in rr_trace).""" | |
| from ace.steps.rr_step import RRStep | |
| from ace.implementations.rr.config import RecursiveConfig | |
| config = RecursiveConfig( | |
| max_requests=15, | |
| timeout=15.0, | |
| ) | |
| rr = RRStep(model=self.RR_MODEL, config=config) | |
| sb = Skillbook() | |
| agent_out = AgentOutput( | |
| reasoning=( | |
| "I need to find the square root of 144. " | |
| "I think it might be 14 since 14 x 14 is close to 144." | |
| ), | |
| final_answer="14", | |
| skill_ids=[], | |
| ) | |
| output = rr.reflect( | |
| question="What is the square root of 144?", | |
| agent_output=agent_out, | |
| skillbook=sb, | |
| ground_truth="12", | |
| feedback="Incorrect. The square root of 144 is 12, not 14.", | |
| ) | |
| assert isinstance(output, ReflectorOutput) | |
| rr_trace = output.raw.get("rr_trace", {}) | |
| total_iterations = rr_trace.get("total_iterations", 0) | |
| assert total_iterations > 0, ( | |
| f"Expected execute_code to be called at least once " | |
| f"(total_iterations > 0), got {total_iterations}. " | |
| f"rr_trace={rr_trace}" | |
| ) | |
| print(f"\n Total iterations (execute_code calls): {total_iterations}") | |
| print(f" Timed out: {rr_trace.get('timed_out', 'N/A')}") | |
| print(f" Key insight: {output.key_insight[:200]}") | |
| print(f" Reasoning: {output.reasoning[:300]}") | |
| if __name__ == "__main__": | |
| pytest.main([__file__, "-v", "-s", "--no-cov"]) | |