ciagent 0.4.1__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. ciagent-0.5.0/CHANGELOG.md +87 -0
  2. {ciagent-0.4.1 → ciagent-0.5.0}/PKG-INFO +2 -2
  3. {ciagent-0.4.1 → ciagent-0.5.0}/pyproject.toml +2 -2
  4. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/cli.py +171 -18
  5. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/correctness.py +48 -9
  6. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/diff.py +18 -8
  7. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/judge.py +64 -19
  8. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/reporter.py +25 -0
  9. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/runner.py +16 -12
  10. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/schema/spec_models.py +5 -1
  11. ciagent-0.4.1/CHANGELOG.md +0 -11
  12. {ciagent-0.4.1 → ciagent-0.5.0}/.gitignore +0 -0
  13. {ciagent-0.4.1 → ciagent-0.5.0}/LICENSE +0 -0
  14. {ciagent-0.4.1 → ciagent-0.5.0}/README.md +0 -0
  15. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/__init__.py +0 -0
  16. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/_version.py +0 -0
  17. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/adapters/__init__.py +0 -0
  18. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/adapters/base.py +0 -0
  19. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/adapters/generic.py +0 -0
  20. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/adapters/langgraph.py +0 -0
  21. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/adapters/openai_agents.py +0 -0
  22. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/assertions.py +0 -0
  23. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/baselines.py +0 -0
  24. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/capture.py +0 -0
  25. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/config.py +0 -0
  26. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/cost.py +0 -0
  27. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/diff_engine.py +0 -0
  28. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/__init__.py +0 -0
  29. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/cost.py +0 -0
  30. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/cost_estimator.py +0 -0
  31. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/metrics.py +0 -0
  32. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/mock_runner.py +0 -0
  33. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/parallel.py +0 -0
  34. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/path.py +0 -0
  35. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/results.py +0 -0
  36. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/engine/span_assertions.py +0 -0
  37. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/exceptions.py +0 -0
  38. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/loader.py +0 -0
  39. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/mocks.py +0 -0
  40. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/models.py +0 -0
  41. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/py.typed +0 -0
  42. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/pytest_plugin.py +0 -0
  43. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/report.py +0 -0
  44. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/runner.py +0 -0
  45. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/schema/__init__.py +0 -0
  46. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/schema/agentci_spec.schema.json +0 -0
  47. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/schema/generate_schema.py +0 -0
  48. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/templates/__init__.py +0 -0
  49. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/templates/agentci.yaml +0 -0
  50. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/templates/github_action.yml.j2 +0 -0
  51. {ciagent-0.4.1 → ciagent-0.5.0}/src/agentci/templates/pre_push_hook.sh.j2 +0 -0
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.5.0] - 2026-03-01
9
+
10
+ ### Added
11
+
12
+ #### Three-Layer Evaluation Engine
13
+ - **Correctness layer** (Layer 1 — hard fail): keyword matching, LLM-as-a-judge, safety checks, hallucination checks, regex/exact match, JSON schema validation
14
+ - **Path layer** (Layer 2 — soft warn): tool trajectory validation, loop detection (default `max_loops=3`), routing assertions, handoff expectations
15
+ - **Cost layer** (Layer 3 — soft warn): token budgets, cost caps, LLM call limits, latency thresholds
16
+ - `runner.py` orchestrates all three layers per query
17
+ - `parallel.py` for parallel query execution across specs
18
+
19
+ #### OR-Logic Keywords
20
+ - `any_expected_in_answer` field — at least one keyword must match (complementing `expected_in_answer` which requires all)
21
+
22
+ #### LLM Judge Enhancements
23
+ - `context_file` support in `JudgeRubric` — doc-grounded judging against reference documents
24
+ - `refutes_premise` flag — injects built-in premise-correction rubric for trick questions
25
+
26
+ #### Span Assertions
27
+ - `SpanAssertionSpec` schema model for span-level assertions
28
+ - `Span.attributes: dict[str, Any]` — OTel-style span-level data propagation
29
+ - Span-level LLM judge support
30
+
31
+ #### Mock Testing Mode
32
+ - `agentci test --mock` — generates synthetic traces, zero API cost
33
+ - `mock_runner.py` — synthetic trace generation from spec expectations
34
+ - `--golden-file` flag — load Q&A pairs from JSON/CSV for mock mode
35
+
36
+ #### Cost Estimator
37
+ - `cost_estimator.py` — pre-execution cost estimates with pricing table
38
+ - Cost estimate shown before live test runs; `--yes`/`-y` skips confirmation
39
+
40
+ #### CLI Improvements
41
+ - `agentci init --generate` — AI-assisted spec generation with guided interview
42
+ - `agentci doctor` — health-check command (spec, runner, API keys, deps, CI)
43
+ - Scan-first flow: auto-scan project before questions, show summary
44
+ - Agent type auto-detected via `_detect_agent_type_from_code()`
45
+ - Skeleton template generation with TODO placeholders for zero-API-key usage
46
+ - Context-aware "Next Steps" based on mode (mock vs live)
47
+ - Non-interactive flags: `--kb-path`, `--mode`
48
+
49
+ #### Diff Engine
50
+ - Three-tier diff engine for baseline comparison
51
+ - 11 `DiffType` categories including `ROUTING_CHANGED`, `GUARDRAILS_CHANGED`
52
+ - `agentci diff` CLI command
53
+
54
+ #### Reporting
55
+ - GitHub annotations with budget cap (`MAX_INLINE_ANNOTATIONS = 10` for warnings; errors uncapped)
56
+ - JSON output format
57
+ - Prometheus metrics export
58
+
59
+ #### Trace Helpers
60
+ - `Trace.called(tool)` / `never_called(tool)` / `loop_count(tool)` — readable assertion helpers
61
+ - `Trace.cost_under(usd)` / `llm_calls_under(n)` — budget assertion helpers
62
+ - `langgraph_trace(agent_name)` — context manager shortcut for LangGraph
63
+ - `TraceContext.attach(state)` — alias for `attach_langgraph_state()`
64
+
65
+ #### Adapters
66
+ - OpenAI Agents SDK adapter (`openai_agents.py`)
67
+ - LangGraph adapter (`langgraph.py`)
68
+
69
+ #### Other
70
+ - `python-dotenv` added as core dependency
71
+ - Deep KB sampling: 2000 chars/file for spec generation
72
+ - Progressive spec building: smoke queries (3) then full queries (10-12)
73
+ - Pytest plugin entry point (`pytest11: agentci`)
74
+ - GUARDRAIL span type, HANDOFF span type
75
+
76
+ ### Changed
77
+ - `PathSpec.max_loops` now defaults to `3` (was `None`)
78
+ - Development status upgraded from Alpha to Beta
79
+ - Package version bumped to 0.5.0
80
+
81
+ ## [0.4.1] - 2026-02-20
82
+
83
+ ### Added
84
+ - Initial project structure and core models
85
+ - Basic trace capture and assertion framework
86
+ - CLI scaffolding with `agentci init`
87
+ - PyPI publishing as `ciagent`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ciagent
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Continuous Integration for AI Agents. Catch cost spikes and logic regressions before production.
5
5
  Project-URL: Documentation, https://github.com/agentci-org/agentci#readme
6
6
  Project-URL: Repository, https://github.com/agentci-org/agentci.git
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/agentci-org/agentci/issues
8
8
  License: Apache-2.0
9
9
  License-File: LICENSE
10
10
  Keywords: agents,ai,ci-cd,llm,regression,testing
11
- Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Framework :: Pytest
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Topic :: Software Development :: Testing
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "ciagent"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Continuous Integration for AI Agents. Catch cost spikes and logic regressions before production."
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
7
7
  requires-python = ">=3.10"
8
8
  keywords = ["ai", "agents", "testing", "llm", "ci-cd", "regression"]
9
9
  classifiers = [
10
- "Development Status :: 3 - Alpha",
10
+ "Development Status :: 4 - Beta",
11
11
  "Intended Audience :: Developers",
12
12
  "Topic :: Software Development :: Testing",
13
13
  "Framework :: Pytest",
@@ -369,15 +369,21 @@ def _generate_skeleton_spec(
369
369
  - query: "TODO: Replace with an in-scope question your KB can answer"
370
370
  description: "Happy-path KB retrieval"
371
371
  correctness:
372
- expected_in_answer: ["TODO: expected keyword"]
372
+ any_expected_in_answer: ["TODO: keyword1", "TODO: keyword2"]
373
+ cost:
374
+ max_llm_calls: 8
373
375
 
374
376
  - query: "What is the weather today?"
375
377
  description: "Out-of-scope question — agent should decline"
376
378
  path:
377
379
  max_tool_calls: 0
380
+ cost:
381
+ max_llm_calls: 2
378
382
 
379
383
  - query: "Hello"
380
384
  description: "Boundary — greeting / off-topic"
385
+ cost:
386
+ max_llm_calls: 2
381
387
  """
382
388
  elif agent_type == "tool":
383
389
  for tool in list(detected_tools)[:5]:
@@ -386,12 +392,16 @@ def _generate_skeleton_spec(
386
392
  description: "Tool usage test — {tool}"
387
393
  path:
388
394
  expected_tools: [{tool}]
395
+ cost:
396
+ max_llm_calls: 8
389
397
  """
390
398
  queries_yaml += """
391
399
  - query: "What is the weather today?"
392
400
  description: "Out-of-scope — should not use tools"
393
401
  path:
394
402
  max_tool_calls: 0
403
+ cost:
404
+ max_llm_calls: 2
395
405
  """
396
406
  else: # conversational
397
407
  queries_yaml += """
@@ -407,8 +417,8 @@ runner: "{runner_path}"
407
417
  version: 1.0
408
418
 
409
419
  judge_config:
410
- model: openai:gpt-4o-mini
411
- temperature: 0.1
420
+ model: {_get_judge_model_for_spec()}
421
+ temperature: 0
412
422
 
413
423
  baseline_dir: ./baselines
414
424
 
@@ -450,6 +460,15 @@ def _build_next_steps(run_mode: str, created_workflow: bool, has_queries: bool)
450
460
  return steps
451
461
 
452
462
 
463
+ def _get_judge_model_for_spec() -> str:
464
+ """Pick the best judge model for generated specs based on available API keys."""
465
+ if os.environ.get("ANTHROPIC_API_KEY"):
466
+ return "claude-sonnet-4-6"
467
+ if os.environ.get("OPENAI_API_KEY"):
468
+ return "gpt-4o"
469
+ return "claude-sonnet-4-6"
470
+
471
+
453
472
  def _generate_queries(context: dict, runner_path: str, interview: dict | None = None) -> list[dict]:
454
473
  """Call an LLM (Anthropic or OpenAI) to generate test queries from project context.
455
474
 
@@ -538,13 +557,75 @@ For each query, produce a YAML block with:
538
557
  - description: one sentence explaining what this tests
539
558
  - tags: list of tags (smoke, in-scope, out-of-scope, edge-case, etc.)
540
559
  - path: expected_tools list OR max_tool_calls: 0 for decline cases
541
- - correctness: expected_in_answer keywords if deterministic, or llm_judge rule
542
- - cost: max_llm_calls budget
543
-
544
- IMPORTANT: All expected_in_answer values MUST come directly from the knowledge base
545
- content or golden Q&A pairs above. Do NOT invent or hallucinate facts, numbers, or
546
- specific details. If a fact is not present in the provided content, use llm_judge
547
- with a descriptive rule instead of expected_in_answer.
560
+ - correctness: use any_expected_in_answer (OR logic) for list-type answers,
561
+ expected_in_answer (AND logic) only when ALL terms are essential, or llm_judge rule
562
+ - cost: max_llm_calls budget (default 8 for in-scope queries)
563
+
564
+ JUDGE RULE GUIDELINES:
565
+ Write judge rules that evaluate whether the response is HELPFUL and ACCURATE,
566
+ not whether it follows a rigid script. The agent is a documentation assistant,
567
+ not an exam candidate.
568
+
569
+ 1. THRESHOLD SELECTION:
570
+ - Use 0.7 for happy-path in-scope queries (agent should answer well)
571
+ - Use 0.8 for out-of-scope decline queries (agent must clearly decline)
572
+ - Use 0.7 for edge cases, compound, and mixed-intent queries (more tolerance)
573
+
574
+ 2. PARTIAL KNOWLEDGE:
575
+ For compound queries where one sub-question IS answerable from the KB and
576
+ another is NOT, the judge rule must accept:
577
+ - Answering the KB-covered part accurately
578
+ - Gracefully declining or saying "I don't have that information" for the rest
579
+ Do NOT require the agent to "address both parts" — require it to be accurate
580
+ on what it can answer and honest about what it cannot.
581
+
582
+ 3. RULE TONE:
583
+ - Use "should" instead of "must" for non-critical criteria
584
+ - Focus on what makes a GOOD response, not a checklist of requirements
585
+ - Avoid rules that penalize the agent for being honest about knowledge gaps
586
+
587
+ 4. KEYWORD CHECKS (expected_in_answer vs any_expected_in_answer):
588
+ All keyword values MUST come directly from the knowledge base content or
589
+ golden Q&A pairs above. Do NOT invent or hallucinate facts.
590
+ If a fact is not in the provided content, use llm_judge instead.
591
+
592
+ - Use `any_expected_in_answer` (OR logic) when the query expects a LIST or
593
+ enumeration (e.g., "What tools are available?" → any ONE tool name suffices).
594
+ This is the PREFERRED default for most keyword checks.
595
+ - Use `expected_in_answer` (AND logic) ONLY when ALL terms are essential to a
596
+ correct answer (e.g., "What is the install command?" → both "pip" and "install"
597
+ must appear).
598
+ - When in doubt, prefer `any_expected_in_answer` — it is more resilient to
599
+ agent paraphrasing and partial answers.
600
+
601
+ 5. COST BUDGET:
602
+ Set max_llm_calls to 8 for in-scope queries (RAG agents typically use 4-8
603
+ LLM calls per query). Use 2-3 for out-of-scope/greeting queries.
604
+
605
+ EXAMPLES OF GOOD vs BAD JUDGE RULES:
606
+
607
+ BAD (too strict, penalizes honest agents):
608
+ rule: "The agent must address both the refund question and the support
609
+ contact question with specific information from the documentation."
610
+ threshold: 0.8
611
+
612
+ GOOD (accepts partial knowledge):
613
+ rule: "The agent should answer based on retrieved documentation. If the KB
614
+ covers the topic, the answer should be accurate. If the KB does not
615
+ cover a sub-question, the agent should honestly say it doesn't have
616
+ that information rather than inventing an answer."
617
+ threshold: 0.7
618
+
619
+ BAD (requires specific phrasing):
620
+ rule: "The agent must state it is an AgentCI documentation assistant and
621
+ list its exact capabilities."
622
+ threshold: 0.85
623
+
624
+ GOOD (evaluates helpfulness):
625
+ rule: "The agent should respond with a friendly greeting and indicate
626
+ readiness to help with AgentCI questions. It should not fabricate
627
+ any AgentCI facts unprompted."
628
+ threshold: 0.7
548
629
 
549
630
  Respond ONLY with valid YAML (a list of query objects, no surrounding text).
550
631
  """
@@ -627,6 +708,62 @@ def _generate_full_queries(
627
708
  return _generate_queries(context, runner_path, interview=full_interview)
628
709
 
629
710
 
711
+ def _calibrate_spec_from_traces(
712
+ queries: list[dict],
713
+ runner_fn,
714
+ console,
715
+ ) -> list[dict]:
716
+ """Run 1-2 sample queries against the real agent to calibrate max_llm_calls.
717
+
718
+ Only called in live mode. Picks up to 2 in-scope queries, runs them,
719
+ measures actual LLM call counts, and sets max_llm_calls with 50% headroom.
720
+ Falls back gracefully if the runner fails.
721
+ """
722
+ candidates = [
723
+ q for q in queries
724
+ if "out-of-scope" not in (q.get("tags") or [])
725
+ and "greeting" not in (q.get("tags") or [])
726
+ ]
727
+ sample = candidates[:2] if len(candidates) >= 2 else candidates[:1]
728
+ if not sample:
729
+ return queries
730
+
731
+ console.print("\n[bold cyan]Calibrating cost budgets...[/]")
732
+ observed_llm_calls: list[int] = []
733
+
734
+ for q_spec in sample:
735
+ try:
736
+ trace = runner_fn(q_spec["query"])
737
+ llm_count = sum(len(s.llm_calls) for s in trace.spans)
738
+ observed_llm_calls.append(llm_count)
739
+ query_preview = q_spec["query"][:50]
740
+ console.print(f" [dim]{query_preview}...[/] → {llm_count} LLM calls")
741
+ except Exception:
742
+ continue
743
+
744
+ if not observed_llm_calls:
745
+ console.print("[yellow]Calibration skipped:[/] no traces captured")
746
+ return queries
747
+
748
+ max_observed = max(observed_llm_calls)
749
+ calibrated_max = max(8, int(max_observed * 1.5))
750
+
751
+ for q in queries:
752
+ tags = q.get("tags") or []
753
+ cost = q.get("cost") or {}
754
+ if "out-of-scope" in tags or "greeting" in tags:
755
+ cost["max_llm_calls"] = max(2, min(observed_llm_calls))
756
+ else:
757
+ cost["max_llm_calls"] = calibrated_max
758
+ q["cost"] = cost
759
+
760
+ console.print(
761
+ f"[green]✓[/] Calibrated: max_llm_calls = {calibrated_max} "
762
+ f"(observed max: {max_observed})"
763
+ )
764
+ return queries
765
+
766
+
630
767
  @click.group()
631
768
  @click.version_option(version=__version__, prog_name="agentci")
632
769
  def cli():
@@ -673,6 +810,10 @@ def init(hook, force, example, generate, agent_description, kb_path, run_mode, g
673
810
  "Agent type is now auto-detected from code."
674
811
  )
675
812
 
813
+ # Track whether we generated real queries vs skeleton
814
+ has_queries = True
815
+ interview: dict = {}
816
+
676
817
  # 0. Generate agentci_spec.yaml
677
818
  spec_dest = Path("agentci_spec.yaml")
678
819
  if spec_dest.exists() and not force:
@@ -680,9 +821,7 @@ def init(hook, force, example, generate, agent_description, kb_path, run_mode, g
680
821
  else:
681
822
  console.print("\n[bold green]AgentCI Setup[/]")
682
823
 
683
- # Track whether we generated real queries vs skeleton
684
824
  has_queries = False
685
- interview: dict = {}
686
825
 
687
826
  if generate:
688
827
  import yaml as _yaml
@@ -903,6 +1042,19 @@ def init(hook, force, example, generate, agent_description, kb_path, run_mode, g
903
1042
  )
904
1043
  has_queries = False
905
1044
 
1045
+ # ── Calibrate cost budgets (live mode only) ─────────────
1046
+ if (
1047
+ queries
1048
+ and isinstance(queries, list)
1049
+ and interview.get("run_mode") == "live"
1050
+ ):
1051
+ try:
1052
+ from .engine.parallel import resolve_runner
1053
+ runner_fn = resolve_runner(runner_path)
1054
+ queries = _calibrate_spec_from_traces(queries, runner_fn, console)
1055
+ except Exception as e:
1056
+ console.print(f"[yellow]Calibration skipped:[/] {e}")
1057
+
906
1058
  # ── Write spec ───────────────────────────────────────────
907
1059
  if queries and isinstance(queries, list):
908
1060
  console.print(f"[green]✓[/] {len(queries)} test queries ready")
@@ -910,7 +1062,7 @@ def init(hook, force, example, generate, agent_description, kb_path, run_mode, g
910
1062
  "agent": "my-agent",
911
1063
  "runner": runner_path,
912
1064
  "version": 1.0,
913
- "judge_config": {"model": "openai:gpt-4o-mini", "temperature": 0.1},
1065
+ "judge_config": {"model": _get_judge_model_for_spec(), "temperature": 0},
914
1066
  "baseline_dir": "./baselines",
915
1067
  "queries": queries,
916
1068
  }
@@ -929,8 +1081,8 @@ runner: "{runner_path}"
929
1081
  version: 1.0
930
1082
 
931
1083
  judge_config:
932
- model: openai:gpt-4o-mini
933
- temperature: 0.1
1084
+ model: {_get_judge_model_for_spec()}
1085
+ temperature: 0
934
1086
 
935
1087
  baseline_dir: ./baselines
936
1088
 
@@ -954,7 +1106,8 @@ runner: "{runner_path}"
954
1106
  version: 1.0
955
1107
 
956
1108
  judge_config:
957
- model: openai:gpt-4o-mini
1109
+ model: {_get_judge_model_for_spec()}
1110
+ temperature: 0
958
1111
 
959
1112
  baseline_dir: ./baselines
960
1113
 
@@ -1095,8 +1248,8 @@ def bootstrap(queries, agent, runner, output, baseline_dir):
1095
1248
  "runner": runner,
1096
1249
  "version": 1.0,
1097
1250
  "judge_config": {
1098
- "model": "openai:gpt-4o-mini",
1099
- "temperature": 0.1
1251
+ "model": _get_judge_model_for_spec(),
1252
+ "temperature": 0,
1100
1253
  },
1101
1254
  "baseline_dir": baseline_dir,
1102
1255
  "queries": []
@@ -47,6 +47,8 @@ def evaluate_correctness(
47
47
  spec: CorrectnessSpec,
48
48
  trace: Optional[Any] = None,
49
49
  judge_config: Optional[dict[str, Any]] = None,
50
+ query: Optional[str] = None,
51
+ spec_dir: Optional[str] = None,
50
52
  ) -> LayerResult:
51
53
  """Evaluate correctness assertions against an agent answer.
52
54
 
@@ -55,6 +57,8 @@ def evaluate_correctness(
55
57
  spec: CorrectnessSpec defining what to check.
56
58
  trace: Optional raw trace object (used for context in judge calls).
57
59
  judge_config: Global judge settings (model, temperature, ensemble).
60
+ query: Optional original user query for judge evaluation context.
61
+ spec_dir: Directory of the spec file (for context_file resolution).
58
62
 
59
63
  Returns:
60
64
  LayerResult with PASS or FAIL status.
@@ -74,11 +78,12 @@ def evaluate_correctness(
74
78
  effective_judges.extend(spec.llm_judge)
75
79
 
76
80
  for rubric in effective_judges:
77
- result = _run_judge_safe(answer, rubric, judge_config, trace)
81
+ result = _run_judge_safe(answer, rubric, judge_config, trace, query, spec_dir)
78
82
  key = f"judge_{rubric.rule[:40]}"
79
83
  details[key] = result
80
84
  if not result.get("passed", False):
81
- failures.append(f"Judge failed: {rubric.rule[:80]}")
85
+ score = result.get("score", "?")
86
+ failures.append(f"Judge failed (score: {score}): {rubric.rule[:80]}")
82
87
  else:
83
88
  score = result.get("score", "")
84
89
  threshold = getattr(rubric, "threshold", "")
@@ -86,7 +91,7 @@ def evaluate_correctness(
86
91
  pass_messages.append(f"Premise correction verified by judge{score_str}")
87
92
 
88
93
  if spec.safety_check and not failures:
89
- result = _run_judge_safe(answer, spec.safety_check, judge_config, trace)
94
+ result = _run_judge_safe(answer, spec.safety_check, judge_config, trace, query, spec_dir)
90
95
  details["safety"] = result
91
96
  if not result.get("passed", False):
92
97
  failures.append(f"Safety check failed: {spec.safety_check.rule}")
@@ -112,12 +117,35 @@ def evaluate_correctness(
112
117
  "all_found": not missing,
113
118
  }
114
119
  if missing:
120
+ answer_preview = answer[:200] + ("..." if len(answer) > 200 else "")
115
121
  for term in missing:
116
- failures.append(f"Expected '{term}' not found in answer")
122
+ failures.append(
123
+ f"Expected '{term}' not found in answer"
124
+ f"\n Agent said: \"{answer_preview}\""
125
+ )
117
126
  else:
118
127
  found_str = ", ".join(f'"{t}"' for t in spec.expected_in_answer)
119
128
  pass_messages.append(f"Found keywords: {found_str}")
120
129
 
130
+ # ── 1b. any_expected_in_answer (case-insensitive, OR logic) ─────────────
131
+ if spec.any_expected_in_answer:
132
+ found_any = [t for t in spec.any_expected_in_answer if t.lower() in answer.lower()]
133
+ details["any_expected_in_answer"] = {
134
+ "checked": spec.any_expected_in_answer,
135
+ "found": found_any,
136
+ "any_found": bool(found_any),
137
+ }
138
+ if not found_any:
139
+ terms_str = ", ".join(f'"{t}"' for t in spec.any_expected_in_answer)
140
+ answer_preview = answer[:200] + ("..." if len(answer) > 200 else "")
141
+ failures.append(
142
+ f"None of [{terms_str}] found in answer"
143
+ f"\n Agent said: \"{answer_preview}\""
144
+ )
145
+ else:
146
+ found_str = ", ".join(f'"{t}"' for t in found_any)
147
+ pass_messages.append(f"Found keyword (any-of): {found_str}")
148
+
121
149
  # ── 2. not_in_answer (case-insensitive exclusion) ───────────────────────
122
150
  if spec.not_in_answer:
123
151
  found = [t for t in spec.not_in_answer if t.lower() in answer.lower()]
@@ -164,11 +192,20 @@ def evaluate_correctness(
164
192
  if not failures:
165
193
  if spec.llm_judge:
166
194
  for rubric in spec.llm_judge:
167
- result = _run_judge_safe(answer, rubric, judge_config, trace)
195
+ result = _run_judge_safe(answer, rubric, judge_config, trace, query, spec_dir)
168
196
  key = f"judge_{rubric.rule[:40]}"
169
197
  details[key] = result
170
198
  if not result.get("passed", False):
171
- failures.append(f"Judge failed: {rubric.rule}")
199
+ # Check if there was an actual API/execution error
200
+ if "error" in result:
201
+ failures.append(f"Judge error: {result['error']}")
202
+ else:
203
+ score = result.get("score", "?")
204
+ rationale = result.get("rationale", "")
205
+ msg = f"Judge failed (score: {score}): {rubric.rule}"
206
+ if rationale:
207
+ msg += f"\n Rationale: {rationale}"
208
+ failures.append(msg)
172
209
  else:
173
210
  score = result.get("score", "")
174
211
  threshold = getattr(rubric, "threshold", "")
@@ -176,7 +213,7 @@ def evaluate_correctness(
176
213
  pass_messages.append(f"LLM judge passed{score_str}")
177
214
 
178
215
  if spec.safety_check and not failures:
179
- result = _run_judge_safe(answer, spec.safety_check, judge_config, trace)
216
+ result = _run_judge_safe(answer, spec.safety_check, judge_config, trace, query, spec_dir)
180
217
  details["safety"] = result
181
218
  if not result.get("passed", False):
182
219
  failures.append(f"Safety check failed: {spec.safety_check.rule}")
@@ -184,7 +221,7 @@ def evaluate_correctness(
184
221
  pass_messages.append("Safety check passed")
185
222
 
186
223
  if spec.hallucination_check and not failures:
187
- result = _run_judge_safe(answer, spec.hallucination_check, judge_config, trace)
224
+ result = _run_judge_safe(answer, spec.hallucination_check, judge_config, trace, query, spec_dir)
188
225
  details["hallucination"] = result
189
226
  if not result.get("passed", False):
190
227
  failures.append("Hallucination check failed")
@@ -226,11 +263,13 @@ def _run_judge_safe(
226
263
  rubric: Any,
227
264
  judge_config: Optional[dict[str, Any]],
228
265
  trace: Any,
266
+ query: Optional[str] = None,
267
+ spec_dir: Optional[str] = None,
229
268
  ) -> dict[str, Any]:
230
269
  """Run a judge call, catching errors and returning a failure dict if needed."""
231
270
  from agentci.engine.judge import run_judge
232
271
  try:
233
- return run_judge(answer=answer, rubric=rubric, config=judge_config)
272
+ return run_judge(answer=answer, rubric=rubric, config=judge_config, query=query, spec_dir=spec_dir)
234
273
  except Exception as e:
235
274
  return {
236
275
  "passed": False,
@@ -422,14 +422,24 @@ def _generate_legacy_diffs(baseline: "Trace", compare: "Trace") -> list[Any]:
422
422
 
423
423
 
424
424
  def _extract_answer(trace: "Trace") -> str:
425
- """Extract final text answer from the last span's output."""
426
- if not trace.spans:
427
- return ""
428
- last = trace.spans[-1]
429
- out = last.output_data
430
- if out is None:
431
- return ""
432
- return out if isinstance(out, str) else str(out)
425
+ """Extract the agent's final text answer from the trace.
426
+
427
+ Strategy:
428
+ 1. trace.metadata["final_output"] (explicitly set by runner).
429
+ 2. Last span's output_data (fallback for runners that don't set metadata).
430
+ 3. Empty string if nothing found.
431
+ """
432
+ meta_output = trace.metadata.get("final_output")
433
+ if meta_output is not None:
434
+ return str(meta_output)
435
+
436
+ if trace.spans:
437
+ last = trace.spans[-1]
438
+ out = last.output_data
439
+ if out is not None:
440
+ return out if isinstance(out, str) else str(out)
441
+
442
+ return ""
433
443
 
434
444
 
435
445
  def _format_value(before: Any, after: Any, label: str) -> str:
@@ -115,6 +115,7 @@ def run_judge(
115
115
  config: Optional[dict[str, Any]] = None,
116
116
  context: Optional[str] = None,
117
117
  spec_dir: Optional[str] = None,
118
+ query: Optional[str] = None,
118
119
  ) -> dict[str, Any]:
119
120
  """Execute an LLM-as-a-judge evaluation with safeguards.
120
121
 
@@ -124,6 +125,7 @@ def run_judge(
124
125
  config: Optional judge config dict (model, temperature, ensemble).
125
126
  context: Optional retrieved context for grounding checks.
126
127
  spec_dir: Directory of the spec file, used to resolve context_file paths.
128
+ query: Optional original user query for evaluation context.
127
129
 
128
130
  Returns:
129
131
  dict with keys: passed, score, label, rationale, model
@@ -139,20 +141,15 @@ def run_judge(
139
141
  effective_context = _load_context_file(rubric.context_file, spec_dir)
140
142
 
141
143
  system_prompt = _build_judge_system_prompt(rubric)
142
- user_prompt = _build_judge_user_prompt(answer, rubric, effective_context)
143
-
144
- import sys
145
- print("====== LLM JUDGE PROMPT ======", file=sys.stderr)
146
- print("SYSTEM:", system_prompt, file=sys.stderr)
147
- print("USER:", user_prompt, file=sys.stderr)
148
- print("==============================", file=sys.stderr)
144
+ user_prompt = _build_judge_user_prompt(answer, rubric, effective_context, query)
149
145
 
150
146
  if ensemble_cfg.get("enabled", False):
151
147
  return _run_ensemble(system_prompt, user_prompt, ensemble_cfg, rubric)
152
148
 
153
149
  verdict = _call_judge(model, system_prompt, user_prompt, temperature)
154
150
  threshold_score = _score_threshold(rubric.threshold)
155
- passed = verdict.score >= threshold_score
151
+ # Pass if score meets threshold OR judge explicitly labeled as "pass"
152
+ passed = verdict.score >= threshold_score or verdict.label == "pass"
156
153
 
157
154
  return {
158
155
  "passed": passed,
@@ -255,10 +252,14 @@ def _call_anthropic(model: str, system: str, user: str, temperature: float) -> s
255
252
  "Set it to use LLM-as-a-judge evaluation."
256
253
  )
257
254
 
255
+ # Strip prefix if present
256
+ if model.startswith("anthropic:"):
257
+ model = model.split(":", 1)[1]
258
+
258
259
  client = anthropic.Anthropic(api_key=api_key)
259
260
  response = client.messages.create(
260
261
  model=model,
261
- max_tokens=256,
262
+ max_tokens=1024,
262
263
  temperature=temperature,
263
264
  system=system,
264
265
  messages=[{"role": "user", "content": user}],
@@ -281,11 +282,15 @@ def _call_openai(model: str, system: str, user: str, temperature: float) -> str:
281
282
  "OPENAI_API_KEY environment variable not set."
282
283
  )
283
284
 
285
+ # Strip prefix if present
286
+ if model.startswith("openai:"):
287
+ model = model.split(":", 1)[1]
288
+
284
289
  client = openai.OpenAI(api_key=api_key)
285
290
  response = client.chat.completions.create(
286
291
  model=model,
287
292
  temperature=temperature,
288
- max_tokens=256,
293
+ max_tokens=1024,
289
294
  messages=[
290
295
  {"role": "system", "content": system},
291
296
  {"role": "user", "content": user},
@@ -295,25 +300,53 @@ def _call_openai(model: str, system: str, user: str, temperature: float) -> str:
295
300
 
296
301
 
297
302
  def _parse_verdict(raw: str) -> JudgeVerdict:
298
- """Parse LLM response into a JudgeVerdict, with fallback extraction."""
303
+ """Parse LLM response into a JudgeVerdict, with robust fallback extraction."""
304
+ import re
305
+
299
306
  text = raw.strip()
300
- # Try to extract JSON block if wrapped in markdown
307
+
308
+ # Strategy 1: Extract JSON from markdown code block
301
309
  if "```" in text:
302
- import re
303
310
  match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
304
311
  if match:
305
312
  text = match.group(1)
306
313
 
314
+ # Strategy 2: Direct JSON parse
307
315
  try:
308
316
  data = json.loads(text)
309
317
  return JudgeVerdict(**data)
310
318
  except Exception:
311
- # Fallback: treat as fail with explanation
312
- return JudgeVerdict(
313
- score=1,
314
- label="fail",
315
- rationale=f"Failed to parse judge response: {raw[:200]}",
316
- )
319
+ pass
320
+
321
+ # Strategy 3: Find first JSON object containing "score" (handles preamble text)
322
+ match = re.search(r"\{[^{}]*\"score\"[^{}]*\}", text, re.DOTALL)
323
+ if match:
324
+ try:
325
+ data = json.loads(match.group(0))
326
+ return JudgeVerdict(**data)
327
+ except Exception:
328
+ pass
329
+
330
+ # Strategy 4: Extract fields via regex as last resort (handles truncated JSON)
331
+ score_match = re.search(r"\"score\"\s*:\s*(\d)", text)
332
+ label_match = re.search(r"\"label\"\s*:\s*\"(pass|fail|borderline)\"", text)
333
+ rationale_match = re.search(r"\"rationale\"\s*:\s*\"([^\"]{1,500})", text)
334
+ if score_match:
335
+ score = int(score_match.group(1))
336
+ if label_match:
337
+ label = label_match.group(1)
338
+ else:
339
+ # Label missing (likely truncated) — infer from score
340
+ label = "pass" if score >= 3 else "fail"
341
+ rationale = rationale_match.group(1) if rationale_match else "Extracted via regex fallback"
342
+ return JudgeVerdict(score=score, label=label, rationale=rationale)
343
+
344
+ # Final fallback: treat as fail with raw response for debugging
345
+ return JudgeVerdict(
346
+ score=1,
347
+ label="fail",
348
+ rationale=f"Failed to parse judge response: {raw[:300]}",
349
+ )
317
350
 
318
351
 
319
352
  # ── Prompt Builders ────────────────────────────────────────────────────────────
@@ -331,6 +364,15 @@ def _build_judge_system_prompt(rubric: JudgeRubric) -> str:
331
364
  prompt += "\nSCORING ANCHORS:\n"
332
365
  for anchor in rubric.scale:
333
366
  prompt += f" {anchor}\n"
367
+ else:
368
+ prompt += (
369
+ "\nSCORING ANCHORS:\n"
370
+ " 1: Completely fails to address the rubric criteria\n"
371
+ " 2: Partially addresses the rubric but with significant issues\n"
372
+ " 3: Addresses the rubric with some gaps or minor inaccuracies\n"
373
+ " 4: Addresses the rubric well with only trivial omissions\n"
374
+ " 5: Perfectly addresses all rubric criteria\n"
375
+ )
334
376
  if rubric.few_shot_examples:
335
377
  prompt += "\nEXAMPLES:\n"
336
378
  for ex in rubric.few_shot_examples:
@@ -344,9 +386,12 @@ def _build_judge_user_prompt(
344
386
  answer: str,
345
387
  rubric: JudgeRubric,
346
388
  context: Optional[str],
389
+ query: Optional[str] = None,
347
390
  ) -> str:
348
391
  """Build the user-turn prompt for the judge."""
349
392
  parts = []
393
+ if query:
394
+ parts.append(f"USER QUERY:\n{query}\n")
350
395
  if context and rubric.context_file:
351
396
  # Doc-grounded judging: instruct judge to use ONLY the reference document
352
397
  parts.append(
@@ -150,6 +150,7 @@ def _emit_console(results: list[QueryResult]) -> None:
150
150
  _print_layer("COST", r.cost, fail_icon="⚠️", pass_icon="💰", warn_icon="⚠️")
151
151
 
152
152
  if r.hard_fail and getattr(r, "trace", None):
153
+ _print_answer_preview(r.trace)
153
154
  _print_trace_summary(r.trace)
154
155
 
155
156
  total = len(results)
@@ -159,6 +160,30 @@ def _emit_console(results: list[QueryResult]) -> None:
159
160
  print(f"\n{'=' * 60}")
160
161
  print(f"Results: {passed}/{total} passed | {warned} warnings | {failed} failures")
161
162
 
163
+ def _print_answer_preview(trace: Any) -> None:
164
+ """Show a truncated preview of the extracted answer for failed queries."""
165
+ answer = ""
166
+ meta_output = getattr(trace, "metadata", {}).get("final_output")
167
+ if meta_output is not None:
168
+ answer = str(meta_output)
169
+ elif getattr(trace, "spans", None):
170
+ last_span = trace.spans[-1]
171
+ output = getattr(last_span, "output_data", None)
172
+ if output is not None:
173
+ answer = str(output)
174
+
175
+ if not answer:
176
+ print(" [ANSWER] (empty — no answer extracted from trace)")
177
+ return
178
+
179
+ # Collapse whitespace for compact display
180
+ preview = " ".join(answer.split())
181
+ max_len = 300
182
+ if len(preview) > max_len:
183
+ preview = preview[:max_len] + "..."
184
+ print(f" [ANSWER] {preview}")
185
+
186
+
162
187
  def _print_trace_summary(trace: Any) -> None:
163
188
  from rich.console import Console
164
189
  from rich.panel import Panel
@@ -52,6 +52,8 @@ def evaluate_query(
52
52
  spec=query.correctness,
53
53
  trace=trace,
54
54
  judge_config=judge_config,
55
+ query=query.query,
56
+ spec_dir=spec_dir,
55
57
  )
56
58
  else:
57
59
  correctness = LayerResult(
@@ -168,16 +170,18 @@ def _extract_answer(trace: "Trace") -> str:
168
170
  """Extract the agent's final text answer from the trace.
169
171
 
170
172
  Strategy:
171
- 1. Last span's output_data if it's a string.
172
- 2. str(output_data) for non-string types.
173
- 3. Empty string if no spans.
173
+ 1. trace.metadata["final_output"] (explicitly set by runner).
174
+ 2. Last span's output_data (fallback for runners that don't set metadata).
175
+ 3. Empty string if nothing found.
174
176
  """
175
- if not trace.spans:
176
- return ""
177
- last_span = trace.spans[-1]
178
- output = last_span.output_data
179
- if output is None:
180
- return ""
181
- if isinstance(output, str):
182
- return output
183
- return str(output)
177
+ meta_output = trace.metadata.get("final_output")
178
+ if meta_output is not None:
179
+ return str(meta_output)
180
+
181
+ if trace.spans:
182
+ last_span = trace.spans[-1]
183
+ output = last_span.output_data
184
+ if output is not None:
185
+ return output if isinstance(output, str) else str(output)
186
+
187
+ return ""
@@ -147,7 +147,11 @@ class CorrectnessSpec(BaseModel):
147
147
  """Layer 1: Hard pass/fail. Any failure blocks the CI pipeline."""
148
148
  expected_in_answer: Optional[list[str]] = Field(
149
149
  None,
150
- description="Strings that must appear in the answer (case-insensitive)",
150
+ description="Strings that must ALL appear in the answer (case-insensitive, AND logic)",
151
+ )
152
+ any_expected_in_answer: Optional[list[str]] = Field(
153
+ None,
154
+ description="At least ONE of these strings must appear in the answer (case-insensitive, OR logic)",
151
155
  )
152
156
  not_in_answer: Optional[list[str]] = Field(
153
157
  None,
@@ -1,11 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project will be documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
9
-
10
- ### Added
11
- - Initial project structure and setup.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes