ciagent 0.4.2__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.
- ciagent-0.5.0/CHANGELOG.md +87 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/PKG-INFO +2 -2
- {ciagent-0.4.2 → ciagent-0.5.0}/pyproject.toml +2 -2
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/cli.py +99 -17
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/correctness.py +48 -9
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/diff.py +14 -8
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/judge.py +64 -19
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/reporter.py +25 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/runner.py +10 -11
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/schema/spec_models.py +5 -1
- ciagent-0.4.2/CHANGELOG.md +0 -11
- {ciagent-0.4.2 → ciagent-0.5.0}/.gitignore +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/LICENSE +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/README.md +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/__init__.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/_version.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/adapters/__init__.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/adapters/base.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/adapters/generic.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/adapters/langgraph.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/adapters/openai_agents.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/assertions.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/baselines.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/capture.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/config.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/cost.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/diff_engine.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/__init__.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/cost.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/cost_estimator.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/metrics.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/mock_runner.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/parallel.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/path.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/results.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/engine/span_assertions.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/exceptions.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/loader.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/mocks.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/models.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/py.typed +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/pytest_plugin.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/report.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/runner.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/schema/__init__.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/schema/agentci_spec.schema.json +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/schema/generate_schema.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/templates/__init__.py +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/templates/agentci.yaml +0 -0
- {ciagent-0.4.2 → ciagent-0.5.0}/src/agentci/templates/github_action.yml.j2 +0 -0
- {ciagent-0.4.2 → 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.
|
|
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 ::
|
|
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.
|
|
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 ::
|
|
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
|
-
|
|
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:
|
|
411
|
-
temperature: 0
|
|
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:
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
"""
|
|
@@ -665,7 +746,7 @@ def _calibrate_spec_from_traces(
|
|
|
665
746
|
return queries
|
|
666
747
|
|
|
667
748
|
max_observed = max(observed_llm_calls)
|
|
668
|
-
calibrated_max = max(
|
|
749
|
+
calibrated_max = max(8, int(max_observed * 1.5))
|
|
669
750
|
|
|
670
751
|
for q in queries:
|
|
671
752
|
tags = q.get("tags") or []
|
|
@@ -981,7 +1062,7 @@ def init(hook, force, example, generate, agent_description, kb_path, run_mode, g
|
|
|
981
1062
|
"agent": "my-agent",
|
|
982
1063
|
"runner": runner_path,
|
|
983
1064
|
"version": 1.0,
|
|
984
|
-
"judge_config": {"model":
|
|
1065
|
+
"judge_config": {"model": _get_judge_model_for_spec(), "temperature": 0},
|
|
985
1066
|
"baseline_dir": "./baselines",
|
|
986
1067
|
"queries": queries,
|
|
987
1068
|
}
|
|
@@ -1000,8 +1081,8 @@ runner: "{runner_path}"
|
|
|
1000
1081
|
version: 1.0
|
|
1001
1082
|
|
|
1002
1083
|
judge_config:
|
|
1003
|
-
model:
|
|
1004
|
-
temperature: 0
|
|
1084
|
+
model: {_get_judge_model_for_spec()}
|
|
1085
|
+
temperature: 0
|
|
1005
1086
|
|
|
1006
1087
|
baseline_dir: ./baselines
|
|
1007
1088
|
|
|
@@ -1025,7 +1106,8 @@ runner: "{runner_path}"
|
|
|
1025
1106
|
version: 1.0
|
|
1026
1107
|
|
|
1027
1108
|
judge_config:
|
|
1028
|
-
model:
|
|
1109
|
+
model: {_get_judge_model_for_spec()}
|
|
1110
|
+
temperature: 0
|
|
1029
1111
|
|
|
1030
1112
|
baseline_dir: ./baselines
|
|
1031
1113
|
|
|
@@ -1166,8 +1248,8 @@ def bootstrap(queries, agent, runner, output, baseline_dir):
|
|
|
1166
1248
|
"runner": runner,
|
|
1167
1249
|
"version": 1.0,
|
|
1168
1250
|
"judge_config": {
|
|
1169
|
-
"model":
|
|
1170
|
-
"temperature": 0
|
|
1251
|
+
"model": _get_judge_model_for_spec(),
|
|
1252
|
+
"temperature": 0,
|
|
1171
1253
|
},
|
|
1172
1254
|
"baseline_dir": baseline_dir,
|
|
1173
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,17 +422,23 @@ 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
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
# Fallback: trace-level metadata
|
|
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
|
+
"""
|
|
433
432
|
meta_output = trace.metadata.get("final_output")
|
|
434
433
|
if meta_output is not None:
|
|
435
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
|
+
|
|
436
442
|
return ""
|
|
437
443
|
|
|
438
444
|
|
|
@@ -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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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,21 +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.
|
|
172
|
-
2.
|
|
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).
|
|
173
175
|
3. Empty string if nothing found.
|
|
174
176
|
"""
|
|
175
|
-
if not trace.spans:
|
|
176
|
-
return str(trace.metadata.get("final_output", ""))
|
|
177
|
-
|
|
178
|
-
last_span = trace.spans[-1]
|
|
179
|
-
output = last_span.output_data
|
|
180
|
-
if output is not None:
|
|
181
|
-
return output if isinstance(output, str) else str(output)
|
|
182
|
-
|
|
183
|
-
# Fallback: trace-level metadata (set by runners like demo_runner.py)
|
|
184
177
|
meta_output = trace.metadata.get("final_output")
|
|
185
178
|
if meta_output is not None:
|
|
186
179
|
return str(meta_output)
|
|
187
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
|
+
|
|
188
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,
|
ciagent-0.4.2/CHANGELOG.md
DELETED
|
@@ -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
|
|
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
|
|
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
|