Spaces:
Sleeping
Sleeping
| """Live SkillManager tests — exercise the agentic tool loop against a real model. | |
| Each scenario builds a real ``Skillbook``, constructs a real ``ReflectorOutput``, | |
| runs the agentic ``SkillManager``, then asserts what the tools mutated. Nothing | |
| here uses mocks for the LLM. | |
| Usage:: | |
| OPENAI_API_KEY=... uv run python test_sm_live.py | |
| Set ``LIVE_SM_MODEL`` to override the default model. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| import traceback | |
| from collections.abc import Callable | |
| from dataclasses import dataclass | |
| from typing import Any | |
| from dotenv import find_dotenv, load_dotenv | |
| load_dotenv(find_dotenv()) | |
| from ace.core.outputs import ReflectorOutput | |
| from ace.core.recursive_agent import AgenticConfig | |
| from ace.core.skillbook import Skillbook | |
| from ace.implementations.skill_manager import SkillManager | |
| MODEL = os.environ.get( | |
| "LIVE_SM_MODEL", "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" | |
| ) | |
| # Bedrock model aliases the user asked us to exercise. Set LIVE_SM_MODELS="" | |
| # to disable the cross-model matrix and use only LIVE_SM_MODEL. | |
| DEFAULT_MODELS = [ | |
| "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", | |
| "bedrock/us.anthropic.claude-sonnet-4-6", | |
| "bedrock/openai.gpt-oss-120b-1:0", | |
| "bedrock/minimax.minimax-m2.5", | |
| ] | |
| # ---------------------------------------------------------------------- | |
| # Helpers | |
| # ---------------------------------------------------------------------- | |
| class Result: | |
| name: str | |
| passed: bool | |
| detail: str | |
| ops: list[str] | |
| def _fmt_op(op: Any) -> str: | |
| bits = [op.type] | |
| if op.skill_id: | |
| bits.append(op.skill_id) | |
| if op.content: | |
| snippet = op.content[:60] + ("…" if len(op.content) > 60 else "") | |
| bits.append(repr(snippet)) | |
| if op.metadata: | |
| bits.append(str(op.metadata)) | |
| return " ".join(bits) | |
| def _run_case( | |
| name: str, | |
| *, | |
| skillbook_setup: Callable[[Skillbook], None], | |
| reflection: ReflectorOutput, | |
| injected_skill_ids: tuple[str, ...] = (), | |
| progress: str = "1/1", | |
| question_context: str = "", | |
| config: AgenticConfig | None = None, | |
| assertion: Callable[[Skillbook, list[Any]], str | None] = lambda sb, ops: None, | |
| ) -> Result: | |
| sb = Skillbook() | |
| skillbook_setup(sb) | |
| try: | |
| sm = SkillManager(MODEL, config=config or AgenticConfig(max_requests=15)) | |
| output = sm.update_skills( | |
| reflections=(reflection,), | |
| skillbook=sb, | |
| question_context=question_context, | |
| progress=progress, | |
| injected_skill_ids=injected_skill_ids, | |
| ) | |
| except Exception as e: | |
| return Result( | |
| name=name, | |
| passed=False, | |
| detail=f"SM crashed: {type(e).__name__}: {e}\n{traceback.format_exc()}", | |
| ops=[], | |
| ) | |
| ops = list(output.update.operations) | |
| error = assertion(sb, ops) | |
| ops_fmt = [_fmt_op(op) for op in ops] | |
| reason_preview = (output.update.reasoning or "")[:180] | |
| detail = ( | |
| f"{error} | reasoning={reason_preview!r}" | |
| if error | |
| else f"reasoning={reason_preview!r}" | |
| ) | |
| return Result( | |
| name=name, | |
| passed=error is None, | |
| detail=detail, | |
| ops=ops_fmt, | |
| ) | |
| def _print_result(r: Result) -> None: | |
| mark = "PASS" if r.passed else "FAIL" | |
| print(f"[{mark}] {r.name}") | |
| for op in r.ops: | |
| print(f" · {op}") | |
| # Always show detail on failure so we can spot patterns across models. | |
| if not r.passed or os.environ.get("LIVE_SM_VERBOSE"): | |
| print(f" detail: {r.detail}") | |
| print() | |
| # ---------------------------------------------------------------------- | |
| # Scenario builders | |
| # ---------------------------------------------------------------------- | |
| def failure_reflection(*, error: str, insight: str, reasoning: str = "") -> ReflectorOutput: | |
| return ReflectorOutput( | |
| reasoning=reasoning or f"Agent got the wrong answer. {error}", | |
| error_identification=error, | |
| root_cause_analysis="Agent applied an incorrect method / missed a step.", | |
| correct_approach="Describe the correct method with a concrete example.", | |
| key_insight=insight, | |
| ) | |
| def success_reflection(*, insight: str, reasoning: str = "") -> ReflectorOutput: | |
| return ReflectorOutput( | |
| reasoning=reasoning or "Agent answered correctly using a clean approach.", | |
| error_identification="none", | |
| root_cause_analysis="Clean execution; chosen strategy fit the problem.", | |
| correct_approach="Strategy worked; record so future runs reuse it.", | |
| key_insight=insight, | |
| ) | |
| # ---------------------------------------------------------------------- | |
| # Cases | |
| # ---------------------------------------------------------------------- | |
| def case_1_empty_sb_failure() -> Result: | |
| """Empty skillbook + failure → expect at least one ADD.""" | |
| refl = failure_reflection( | |
| error="Computed 15*24 as 310 instead of 360 via distributive property.", | |
| insight="When decomposing a product like a*(b+c), verify a*b and a*c before summing.", | |
| reasoning=( | |
| "Agent attempted 15*24 using distributive: 15*(20+4). Wrote 15*20=310. " | |
| "Should be 300. Off-by-ten arithmetic slip." | |
| ), | |
| ) | |
| return _run_case( | |
| "case_1_empty_sb_failure", | |
| skillbook_setup=lambda sb: None, | |
| reflection=refl, | |
| progress="1/10 correct", | |
| question_context="Mental arithmetic", | |
| assertion=lambda sb, ops: ( | |
| None | |
| if any(op.type == "ADD" for op in ops) and len(sb.skills()) >= 1 | |
| else f"expected >=1 ADD and >=1 skill in book; got ops={[op.type for op in ops]}, skills={len(sb.skills())}" | |
| ), | |
| ) | |
| def case_2_empty_sb_success() -> Result: | |
| """Empty skillbook + success with a specific insight → expect ADD.""" | |
| refl = success_reflection( | |
| insight=( | |
| "For factual 'capital of X' questions, answer with the capital only, " | |
| "no extra prose." | |
| ), | |
| reasoning=( | |
| "Agent answered 'Paris' to 'Capital of France?'. Clean factual lookup, " | |
| "no reasoning noise. Reusable pattern for factual single-entity questions." | |
| ), | |
| ) | |
| return _run_case( | |
| "case_2_empty_sb_success", | |
| skillbook_setup=lambda sb: None, | |
| reflection=refl, | |
| progress="8/10 correct", | |
| question_context="Factual trivia", | |
| assertion=lambda sb, ops: ( | |
| None | |
| if any(op.type == "ADD" for op in ops) | |
| else f"expected an ADD; got {[op.type for op in ops]}" | |
| ), | |
| ) | |
| def case_3_tag_helpful_for_injected() -> Result: | |
| """Injected skill + success → expect a tag_skill(+1) on the injected skill.""" | |
| injected_id = None | |
| def _setup(sb: Skillbook) -> None: | |
| nonlocal injected_id | |
| s = sb.add_skill( | |
| section="math", | |
| content="Use distributive property for mental multiplication: a*(b+c) = a*b + a*c.", | |
| ) | |
| injected_id = s.id | |
| refl = success_reflection( | |
| insight="Distributive decomposition works well for 2-digit multiplications.", | |
| reasoning=( | |
| f"Agent used the injected skill [{'INJECTED_ID'}] to solve 25*14 = " | |
| "25*(10+4) = 250+100 = 350. Correct answer. Strategy was directly useful." | |
| ), | |
| ) | |
| # We can't know injected_id until setup; patch inside the case runner | |
| sb = Skillbook() | |
| _setup(sb) | |
| assert injected_id is not None | |
| refl = ReflectorOutput( | |
| reasoning=( | |
| f"Agent used the injected skill {injected_id} to solve 25*14 = " | |
| "25*(10+4) = 250+100 = 350. Correct answer. Strategy was directly useful." | |
| ), | |
| error_identification="none", | |
| root_cause_analysis="Skill directly contributed to correct decomposition.", | |
| correct_approach="Reuse the distributive strategy for similar 2-digit tasks.", | |
| key_insight="Distributive decomposition works well for 2-digit multiplications.", | |
| ) | |
| try: | |
| sm = SkillManager(MODEL, config=AgenticConfig(max_requests=15)) | |
| output = sm.update_skills( | |
| reflections=(refl,), | |
| skillbook=sb, | |
| question_context="Mental arithmetic", | |
| progress="9/10 correct", | |
| injected_skill_ids=(injected_id,), | |
| ) | |
| except Exception as e: | |
| return Result( | |
| "case_3_tag_helpful_for_injected", | |
| False, | |
| f"crash: {e}", | |
| [], | |
| ) | |
| ops = list(output.update.operations) | |
| tagged_helpful = any( | |
| op.type == "TAG" | |
| and op.skill_id == injected_id | |
| and op.metadata.get("delta", 0) >= 1 | |
| for op in ops | |
| ) | |
| skill = sb.get_skill(injected_id) | |
| counter_ok = skill is not None and skill.helpful_count >= 1 | |
| passed = tagged_helpful and counter_ok | |
| return Result( | |
| "case_3_tag_helpful_for_injected", | |
| passed, | |
| f"tagged_helpful={tagged_helpful}, helpful_count={skill.helpful_count if skill else 'MISSING'}", | |
| [_fmt_op(op) for op in ops], | |
| ) | |
| def case_4_tag_harmful_for_injected() -> Result: | |
| """Injected skill misled the agent → expect tag_skill(-1) or REMOVE, and likely an UPDATE/ADD with a corrected rule.""" | |
| sb = Skillbook() | |
| bad = sb.add_skill( | |
| section="math", | |
| content="Always add numbers left-to-right without regrouping to save time.", | |
| ) | |
| refl = ReflectorOutput( | |
| reasoning=( | |
| f"Agent followed skill {bad.id} (left-to-right without regrouping) and got 48+37=75 " | |
| "instead of 85. The skill ignored carry. Skill directly caused the error." | |
| ), | |
| error_identification="Ignored carry when adding units column (8+7=15).", | |
| root_cause_analysis=( | |
| f"Injected skill {bad.id} instructed left-to-right without regrouping, which " | |
| "drops carries. Incorrect strategy." | |
| ), | |
| correct_approach=( | |
| "Add columns right-to-left and carry over into the next column when the sum " | |
| "exceeds 9." | |
| ), | |
| key_insight="Multi-digit addition requires carrying over units-column overflow.", | |
| ) | |
| try: | |
| sm = SkillManager(MODEL, config=AgenticConfig(max_requests=15)) | |
| output = sm.update_skills( | |
| reflections=(refl,), | |
| skillbook=sb, | |
| question_context="Mental arithmetic", | |
| progress="3/10 correct", | |
| injected_skill_ids=(bad.id,), | |
| ) | |
| except Exception as e: | |
| return Result( | |
| "case_4_tag_harmful_for_injected", | |
| False, | |
| f"crash: {e}", | |
| [], | |
| ) | |
| ops = list(output.update.operations) | |
| tagged_harmful = any( | |
| op.type == "TAG" and op.skill_id == bad.id and op.metadata.get("delta", 0) <= -1 | |
| for op in ops | |
| ) | |
| removed = any(op.type == "REMOVE" and op.skill_id == bad.id for op in ops) | |
| updated = any(op.type == "UPDATE" and op.skill_id == bad.id for op in ops) | |
| skill = sb.get_skill(bad.id) | |
| harmful_count = skill.harmful_count if skill is not None else -1 | |
| passed = tagged_harmful or removed or updated | |
| detail = ( | |
| f"tagged_harmful={tagged_harmful}, removed={removed}, updated={updated}, " | |
| f"harmful_count={harmful_count}" | |
| ) | |
| return Result( | |
| "case_4_tag_harmful_for_injected", | |
| passed, | |
| detail, | |
| [_fmt_op(op) for op in ops], | |
| ) | |
| def case_5_dedup_before_add() -> Result: | |
| """Near-duplicate skill already exists → SM should UPDATE or tag, not ADD a paraphrase.""" | |
| sb = Skillbook() | |
| existing = sb.add_skill( | |
| section="math", | |
| content="Use distributive property when multiplying two-digit numbers mentally.", | |
| ) | |
| # Reflection pattern is nearly the same as the existing skill | |
| refl = failure_reflection( | |
| error="Agent didn't decompose 18*25 and made a multi-step arithmetic error.", | |
| insight=( | |
| "Decompose two-digit multiplications with distributive property rather than " | |
| "computing directly: avoids long arithmetic mistakes." | |
| ), | |
| reasoning=( | |
| "Pattern: agents repeatedly make errors computing 2-digit multiplications " | |
| "directly. Decomposition prevents this." | |
| ), | |
| ) | |
| try: | |
| sm = SkillManager(MODEL, config=AgenticConfig(max_requests=15)) | |
| output = sm.update_skills( | |
| reflections=(refl,), | |
| skillbook=sb, | |
| question_context="Mental arithmetic", | |
| progress="4/10 correct", | |
| injected_skill_ids=(), | |
| ) | |
| except Exception as e: | |
| return Result("case_5_dedup_before_add", False, f"crash: {e}", []) | |
| ops = list(output.update.operations) | |
| added = [op for op in ops if op.type == "ADD"] | |
| updated_existing = any( | |
| op.type == "UPDATE" and op.skill_id == existing.id for op in ops | |
| ) | |
| # Pass if either (a) no ADD was created (dedup worked), or (b) the existing skill was UPDATEd | |
| passed = (len(added) == 0) or updated_existing | |
| detail = ( | |
| f"added={len(added)}, updated_existing={updated_existing}, " | |
| f"final_skills={len(sb.skills())}" | |
| ) | |
| return Result( | |
| "case_5_dedup_before_add", | |
| passed, | |
| detail, | |
| [_fmt_op(op) for op in ops], | |
| ) | |
| def case_6_remove_harmful_threshold() -> Result: | |
| """Skill with harmful_count=2 + new harmful evidence → SM should REMOVE or tag again to push to 3.""" | |
| sb = Skillbook() | |
| bad = sb.add_skill( | |
| section="api", | |
| content="Always send requests without retry logic to keep latency low.", | |
| ) | |
| # Pre-seed harmful_count=2 to simulate prior observations | |
| bad.harmful_count = 2 | |
| refl = ReflectorOutput( | |
| reasoning=( | |
| f"Skill {bad.id} caused a third transient-failure incident — request dropped on a " | |
| "flaky network path because retries were disabled." | |
| ), | |
| error_identification=( | |
| "Request dropped on transient network error; no retry, user saw a hard failure." | |
| ), | |
| root_cause_analysis=( | |
| f"Skill {bad.id} forbids retries even for transient errors — bad default for " | |
| "unreliable links." | |
| ), | |
| correct_approach=( | |
| "Retry transient errors (timeouts, 5xx, connection reset) with exponential " | |
| "backoff up to 3 attempts; only skip retry on idempotent-violating verbs." | |
| ), | |
| key_insight="Never disable retries unconditionally; distinguish transient vs terminal.", | |
| ) | |
| try: | |
| sm = SkillManager(MODEL, config=AgenticConfig(max_requests=15)) | |
| output = sm.update_skills( | |
| reflections=(refl,), | |
| skillbook=sb, | |
| question_context="API reliability", | |
| progress="ongoing", | |
| injected_skill_ids=(bad.id,), | |
| ) | |
| except Exception as e: | |
| return Result("case_6_remove_harmful_threshold", False, f"crash: {e}", []) | |
| ops = list(output.update.operations) | |
| removed = any(op.type == "REMOVE" and op.skill_id == bad.id for op in ops) | |
| bumped_to_three = any( | |
| op.type == "TAG" and op.skill_id == bad.id and op.metadata.get("delta", 0) <= -1 | |
| for op in ops | |
| ) | |
| skill = sb.get_skill(bad.id) | |
| final_harmful = skill.harmful_count if skill is not None else "(removed)" | |
| passed = removed or bumped_to_three | |
| detail = f"removed={removed}, bumped_harmful={bumped_to_three}, final_harmful_count={final_harmful}" | |
| return Result( | |
| "case_6_remove_harmful_threshold", | |
| passed, | |
| detail, | |
| [_fmt_op(op) for op in ops], | |
| ) | |
| def case_7_max_requests_one_shot() -> Result: | |
| """max_requests=1 degrades to one-shot — still produces a valid audit even if no mutations.""" | |
| refl = failure_reflection( | |
| error="Minor off-by-one in range loop.", | |
| insight="When iterating [a, b], confirm whether b is inclusive before coding the loop.", | |
| ) | |
| try: | |
| sm = SkillManager(MODEL, config=AgenticConfig(max_requests=1)) | |
| output = sm.update_skills( | |
| reflections=(refl,), | |
| skillbook=Skillbook(), | |
| question_context="Python coding", | |
| progress="1/1", | |
| injected_skill_ids=(), | |
| ) | |
| except Exception as e: | |
| return Result("case_7_max_requests_one_shot", False, f"crash: {e}", []) | |
| # Either: SM produced ops (succeeded in one shot) or returned an empty audit (budget exhausted gracefully). | |
| detail = ( | |
| f"reasoning={output.update.reasoning[:120]!r}, " | |
| f"ops={len(output.update.operations)}, timeout={output.raw.get('timeout', False)}" | |
| ) | |
| return Result( | |
| "case_7_max_requests_one_shot", | |
| True, # pass as long as it doesn't crash | |
| detail, | |
| [_fmt_op(op) for op in output.update.operations], | |
| ) | |
| def case_8_batch_reflections() -> Result: | |
| """Two reflections in one call — SM should process both.""" | |
| sb = Skillbook() | |
| r1 = failure_reflection( | |
| error="Division by zero not guarded.", | |
| insight="Always guard against zero divisor before dividing.", | |
| ) | |
| r2 = success_reflection( | |
| insight=( | |
| "Prefer collections.Counter over manual dict-increment for tallying " | |
| "frequencies — avoids KeyError paths." | |
| ), | |
| ) | |
| try: | |
| sm = SkillManager(MODEL, config=AgenticConfig(max_requests=20)) | |
| output = sm.update_skills( | |
| reflections=(r1, r2), | |
| skillbook=sb, | |
| question_context="Python coding basics", | |
| progress="5/10 correct", | |
| injected_skill_ids=(), | |
| ) | |
| except Exception as e: | |
| return Result("case_8_batch_reflections", False, f"crash: {e}", []) | |
| ops = list(output.update.operations) | |
| added = [op for op in ops if op.type == "ADD"] | |
| passed = len(added) >= 1 | |
| detail = f"adds={len(added)}, total_ops={len(ops)}, final_skills={len(sb.skills())}" | |
| return Result( | |
| "case_8_batch_reflections", | |
| passed, | |
| detail, | |
| [_fmt_op(op) for op in ops], | |
| ) | |
| def case_9_vague_reflection_no_op() -> Result: | |
| """Purely meta-commentary reflection → SM should produce no ADD (or degrade gracefully).""" | |
| refl = ReflectorOutput( | |
| reasoning="Agent should be more careful and think about things deeply.", | |
| error_identification="Not careful enough.", | |
| root_cause_analysis="Did not consider options.", | |
| correct_approach="Be careful. Remember to think about things.", | |
| key_insight="Consider carefully.", | |
| ) | |
| try: | |
| sm = SkillManager(MODEL, config=AgenticConfig(max_requests=15)) | |
| output = sm.update_skills( | |
| reflections=(refl,), | |
| skillbook=Skillbook(), | |
| question_context="General", | |
| progress="1/1", | |
| injected_skill_ids=(), | |
| ) | |
| except Exception as e: | |
| return Result("case_9_vague_reflection_no_op", False, f"crash: {e}", []) | |
| ops = list(output.update.operations) | |
| adds = [op for op in ops if op.type == "ADD"] | |
| # Pass if the SM declined to add vague skills. If it did ADD, inspect whether content is sharp — allow one if it rewrote it. | |
| detail = f"adds={len(adds)}, total_ops={len(ops)}, reasoning={output.update.reasoning[:140]!r}" | |
| passed = len(adds) == 0 | |
| return Result("case_9_vague_reflection_no_op", passed, detail, [_fmt_op(op) for op in ops]) | |
| # ---------------------------------------------------------------------- | |
| # Main | |
| # ---------------------------------------------------------------------- | |
| CASES: list[Callable[[], Result]] = [ | |
| case_1_empty_sb_failure, | |
| case_2_empty_sb_success, | |
| case_3_tag_helpful_for_injected, | |
| case_4_tag_harmful_for_injected, | |
| case_5_dedup_before_add, | |
| case_6_remove_harmful_threshold, | |
| case_7_max_requests_one_shot, | |
| case_8_batch_reflections, | |
| case_9_vague_reflection_no_op, | |
| ] | |
| def main() -> int: | |
| global MODEL | |
| models_env = os.environ.get("LIVE_SM_MODELS") | |
| if models_env is not None: | |
| models = [m.strip() for m in models_env.split(",") if m.strip()] | |
| elif os.environ.get("LIVE_SM_MODEL"): | |
| models = [MODEL] | |
| else: | |
| models = DEFAULT_MODELS | |
| matrix: dict[str, list[Result]] = {} | |
| for model_id in models: | |
| MODEL = model_id | |
| short = model_id.split("/")[-1] | |
| print(f"\n{'=' * 72}") | |
| print(f"Model: {short}") | |
| print("=" * 72 + "\n") | |
| results: list[Result] = [] | |
| for case in CASES: | |
| try: | |
| r = case() | |
| except Exception as e: | |
| r = Result( | |
| case.__name__, | |
| False, | |
| f"runner crash: {e}\n{traceback.format_exc()}", | |
| [], | |
| ) | |
| results.append(r) | |
| _print_result(r) | |
| matrix[short] = results | |
| # Summary matrix | |
| print("\n" + "=" * 72) | |
| print("SUMMARY") | |
| print("=" * 72) | |
| case_names = [c.__name__ for c in CASES] | |
| model_names = list(matrix.keys()) | |
| width = max((len(nm) for nm in case_names), default=10) | |
| print(f"{'case'.ljust(width)} " + " ".join(f"{m[:22]:<22}" for m in model_names)) | |
| for i, case_name in enumerate(case_names): | |
| row = [case_name.ljust(width)] | |
| for m in model_names: | |
| r = matrix[m][i] | |
| row.append(("PASS" if r.passed else "FAIL").ljust(22)) | |
| print(" ".join(row)) | |
| # Totals | |
| print() | |
| for m in model_names: | |
| p = sum(1 for r in matrix[m] if r.passed) | |
| print(f" {m[:50]:<50} {p}/{len(CASES)} passed") | |
| total_passed = sum( | |
| sum(1 for r in matrix[m] if r.passed) for m in model_names | |
| ) | |
| total = len(CASES) * len(model_names) | |
| print(f"\nTotal: {total_passed}/{total} across {len(model_names)} model(s)") | |
| return 0 if total_passed == total else 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |