Skip to content

Commit 6e86123

Browse files
Merge branch 'main' into feat/garak-scan-integration
2 parents 36c6f74 + b6098b3 commit 6e86123

26 files changed

Lines changed: 402 additions & 32 deletions

File tree

.github/workflows/integration-tests.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ jobs:
8383
with:
8484
ref: ${{ github.event.pull_request.head.sha || github.ref }}
8585
persist-credentials: false
86+
# Deliberate opt-in: this job installs and executes fork-authored
87+
# code (make install, pytest) with live provider API keys in scope.
88+
# Compensating controls: the `ci` environment requires a reviewer to
89+
# approve each run before secrets are injected (server-enforced,
90+
# per-run), the authorize job requires a fresh `labeled` event for
91+
# external contributors, and the immutable head.sha pin keeps the
92+
# reviewed commit identical to the executed one.
93+
allow-unsafe-pr-checkout: true
8694
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
8795
with:
8896
enable-cache: true
@@ -135,6 +143,9 @@ jobs:
135143
with:
136144
ref: ${{ github.event.pull_request.head.sha || github.ref }}
137145
persist-credentials: false
146+
# Deliberate opt-in — see test-agents-functional for the full
147+
# rationale and compensating controls.
148+
allow-unsafe-pr-checkout: true
138149
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
139150
with:
140151
enable-cache: true
@@ -173,6 +184,9 @@ jobs:
173184
with:
174185
ref: ${{ github.event.pull_request.head.sha || github.ref }}
175186
persist-credentials: false
187+
# Deliberate opt-in — see test-agents-functional for the full
188+
# rationale and compensating controls.
189+
allow-unsafe-pr-checkout: true
176190
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
177191
with:
178192
enable-cache: true

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,7 @@ mlruns
182182
local/
183183
*.local
184184
.claude/settings.local.json
185+
186+
# Agent tooling state (generated, machine-local)
187+
.deepeval/
188+
.superpowers/

libs/giskard-agents/src/giskard/agents/workflow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ async def execute(
166166

167167
async def _run_tools(self, chat: Chat[Any]) -> AsyncGenerator[ToolMessage, None]:
168168
if (
169-
not chat.last
169+
not chat.messages
170170
or not isinstance(chat.last, AssistantMessage)
171171
or not chat.last.tool_calls
172172
):

libs/giskard-agents/tests/test_workflow_steps.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,29 @@ async def test_run_returns_successful_chat():
120120

121121
assert not chat.failed
122122
assert chat.last.content == "World!"
123+
124+
125+
async def test_run_with_no_messages_does_not_raise_indexerror():
126+
"""A ChatWorkflow with no messages added must not crash with IndexError.
127+
128+
``_run_tools``'s guard checks ``not chat.last`` before checking whether
129+
there is anything to process, but ``Chat.last`` indexes ``messages[-1]``,
130+
which raises ``IndexError`` on an empty list instead of behaving falsy.
131+
"""
132+
gen = MagicMock(spec=BaseGenerator)
133+
gen.complete = AsyncMock(
134+
return_value=CompletionResponse(
135+
choices=[
136+
Choice(
137+
message=AssistantMessage(content="Hello!"),
138+
finish_reason="stop",
139+
index=0,
140+
)
141+
]
142+
)
143+
)
144+
145+
chat = await ChatWorkflow(generator=gen).run()
146+
147+
assert not chat.failed
148+
assert chat.last.content == "Hello!"

libs/giskard-checks/src/giskard/checks/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
SuiteResult,
4545
Target,
4646
TestCase,
47+
TestCaseError,
4748
TestCaseResult,
4849
Trace,
4950
resolve,
@@ -97,6 +98,7 @@
9798
"SuiteResult",
9899
"Target",
99100
"TestCase",
101+
"TestCaseError",
100102
"TestCaseResult",
101103
"Trace",
102104
"resolve",

libs/giskard-checks/src/giskard/checks/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Metric,
1111
ScenarioResult,
1212
SuiteResult,
13+
TestCaseError,
1314
TestCaseResult,
1415
)
1516
from .scenario import Scenario, Step
@@ -32,6 +33,7 @@
3233
"Metric",
3334
"ScenarioResult",
3435
"SuiteResult",
36+
"TestCaseError",
3537
"TestCaseResult",
3638
"TestCase",
3739
"InputGenerationException",

libs/giskard-checks/src/giskard/checks/core/extraction.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Child,
55
DatumInContext,
66
Descendants,
7+
Fields,
78
Intersect,
89
JSONPath,
910
Slice,
@@ -75,6 +76,9 @@ def _is_list_expression(expression: JSONPath) -> bool:
7576
if isinstance(expression, Slice | Union | Intersect):
7677
return True
7778

79+
if isinstance(expression, Fields):
80+
return len(expression.fields) > 1 or "*" in expression.fields
81+
7882
return False
7983

8084

libs/giskard-checks/src/giskard/checks/core/result.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,8 @@ def __rich_console__(
355355
yield Rule(status["title"], style=f"{status['color']} bold")
356356

357357
for step in self.steps:
358+
if step.error is not None:
359+
yield step.error.rich_line(status["color"])
358360
for result in step.results:
359361
yield from result.__rich_console__(console, options)
360362

@@ -382,6 +384,28 @@ class TestCaseStatus(str, Enum):
382384
SKIP = "skip"
383385

384386

387+
class TestCaseError(BaseModel, frozen=True):
388+
"""Captures why a test case failed to execute."""
389+
390+
message: str
391+
exception_type: str
392+
traceback: str | None = None
393+
phase: str | None = None
394+
395+
def summary(self) -> str:
396+
"""One-line description: ``<ExceptionType>[ during <phase>]: <message>``."""
397+
phase = f" during {self.phase}" if self.phase else ""
398+
return f"{self.exception_type}{phase}: {self.message}"
399+
400+
def rich_line(self, color: str) -> str:
401+
"""Rich-markup row rendering this error under a step, in the given color."""
402+
return (
403+
f"[{color} bold]Test case[/{color} bold]\t"
404+
f"[{color}]ERROR[/{color}]\t"
405+
f"{self.summary()}"
406+
)
407+
408+
385409
class TestCaseResult(BaseResult, frozen=True):
386410
"""Immutable summary of a test case execution with full run history.
387411
@@ -396,6 +420,9 @@ class TestCaseResult(BaseResult, frozen=True):
396420
this step added before its checks ran. ``None`` when the step added no
397421
interactions (e.g. skipped). Consumers (such as the Giskard Hub upload
398422
flow) use this to attribute check results to a specific interaction.
423+
error : TestCaseError | None
424+
Execution error that prevented the test case from running normally,
425+
such as an input-generation failure.
399426
status : TestCaseStatus
400427
Aggregated outcome of the test case derived from its results.
401428
passed : bool
@@ -417,11 +444,19 @@ class TestCaseResult(BaseResult, frozen=True):
417444
"interacts before checks ran; None when no interactions were added."
418445
),
419446
)
447+
error: TestCaseError | None = Field(
448+
default=None,
449+
description=(
450+
"Execution error that prevented this test case from running normally."
451+
),
452+
)
420453

421454
@computed_field
422455
@property
423456
def status(self) -> TestCaseStatus:
424457
"""The status of the test case."""
458+
if self.error is not None:
459+
return TestCaseStatus.ERROR
425460
if not self.results:
426461
return TestCaseStatus.PASS
427462

@@ -470,6 +505,8 @@ def format_failures(self) -> list[str]:
470505
the check name/kind and the failure reason.
471506
"""
472507
failure_messages: list[str] = []
508+
if self.error is not None:
509+
failure_messages.append(f"Test case ERRORED: {self.error.summary()}")
473510
for result in self.results:
474511
if result.failed or result.errored:
475512
check_name: str = result.details.get(
@@ -508,11 +545,15 @@ def __rich_console__(
508545
status = STATUS_MAPPING[self.status]
509546
yield Rule(status["title"], style=f"{status['color']} bold")
510547

548+
if self.error is not None:
549+
yield self.error.rich_line(status["color"])
550+
511551
for result in self.results:
512552
yield from result.__rich_console__(console, options)
513553

514554
status_counts = {
515-
"error": sum(1 for r in self.results if r.errored),
555+
"error": sum(1 for r in self.results if r.errored)
556+
+ (1 if self.error is not None else 0),
516557
"fail": sum(1 for r in self.results if r.failed),
517558
"skip": sum(1 for r in self.results if r.skipped),
518559
"pass": sum(1 for r in self.results if r.passed),

libs/giskard-checks/src/giskard/checks/scenarios/runner.py

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import time
9+
import traceback
910
from typing import Any, cast
1011

1112
from giskard.core import (
@@ -18,7 +19,7 @@
1819
from .._telemetry_props import scenario_shape_properties
1920
from ..core import Trace
2021
from ..core.interaction import Interact
21-
from ..core.result import CheckResult, ScenarioResult, TestCaseResult
22+
from ..core.result import CheckResult, ScenarioResult, TestCaseError, TestCaseResult
2223
from ..core.scenario import Scenario, Step
2324
from ..core.testcase import TestCase
2425
from ..core.types import Target
@@ -74,6 +75,22 @@ def _resolve_trace_type[InputType, OutputType, TraceType: Trace[Any, Any]](
7475
return cast(type[TraceType], inferred if inferred is not None else Trace)
7576

7677

78+
def _skipped_check_results_for_step[InputType, OutputType, TraceType: Trace[Any, Any]](
79+
step: Step[InputType, OutputType, TraceType], message: str
80+
) -> list[CheckResult]:
81+
return [
82+
CheckResult.skip(
83+
message=message,
84+
details={
85+
"check_kind": check.kind,
86+
"check_name": check.name,
87+
"check_description": check.description,
88+
},
89+
)
90+
for check in step.checks
91+
]
92+
93+
7794
class ScenarioRunner:
7895
"""Execute scenarios by running their steps sequentially.
7996
@@ -133,7 +150,32 @@ async def _run_once[InputType, OutputType, TraceType: Trace[Any, Any]](
133150
)
134151

135152
for step in steps:
136-
trace = await trace.with_interactions(*step.interacts)
153+
try:
154+
for interaction in step.interacts:
155+
trace = await trace.with_interaction(interaction)
156+
except Exception as e:
157+
if not return_exception:
158+
raise
159+
160+
step_result = TestCaseResult(
161+
results=_skipped_check_results_for_step(
162+
step,
163+
"Checks were skipped due to input generation failure",
164+
),
165+
duration_ms=int((time.perf_counter() - start_time) * 1000),
166+
last_interaction_index=(
167+
len(trace.interactions) - 1 if trace.interactions else None
168+
),
169+
error=TestCaseError(
170+
message=str(e),
171+
exception_type=type(e).__name__,
172+
traceback=traceback.format_exc(),
173+
phase="input_generation",
174+
),
175+
)
176+
steps_results.append(step_result)
177+
break
178+
137179
last_interaction_index = (
138180
len(trace.interactions) - 1 if trace.interactions else None
139181
)
@@ -160,17 +202,10 @@ async def _run_once[InputType, OutputType, TraceType: Trace[Any, Any]](
160202
)
161203
for i in range(len(steps_results), len(steps)):
162204
step_result = TestCaseResult(
163-
results=[
164-
CheckResult.skip(
165-
message=f"Step {i + 1} was skipped due to previous failure",
166-
details={
167-
"check_kind": check.kind,
168-
"check_name": check.name,
169-
"check_description": check.description,
170-
},
171-
)
172-
for check in steps[i].checks
173-
],
205+
results=_skipped_check_results_for_step(
206+
steps[i],
207+
f"Step {i + 1} was skipped due to previous failure",
208+
),
174209
duration_ms=0,
175210
last_interaction_index=skipped_last_interaction_index,
176211
)

libs/giskard-checks/tests/core/test_extraction.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
"""Unit tests for JSONPath validation in extraction.py."""
22

33
import pytest
4-
from giskard.checks.core.extraction import JSONPathStr, _validate_jsonpath_syntax
4+
from giskard.checks.core.extraction import (
5+
JSONPathStr,
6+
_validate_jsonpath_syntax,
7+
resolve,
8+
)
9+
from giskard.checks.core.interaction import Interaction, Trace
510
from pydantic import BaseModel, ValidationError
611

712

@@ -75,6 +80,44 @@ def test_valid_descendants_operator(self):
7580
assert _validate_jsonpath_syntax("trace..outputs") == "trace..outputs"
7681

7782

83+
class TestResolveDotWildcard:
84+
"""Tests for resolve()'s handling of dot-wildcard/multi-field JSONPath expressions.
85+
86+
A dot-wildcard (``trace.last.metadata.*``) or multi-field selector must always
87+
resolve to a list, regardless of how many keys it actually matches -- otherwise
88+
ComparisonCheck's match=any/all/none (which requires list/set/tuple) breaks
89+
whenever the matched dict happens to have exactly one key.
90+
"""
91+
92+
def test_wildcard_with_single_match_returns_a_list_not_a_bare_scalar(self):
93+
trace = Trace[str, str](
94+
interactions=[
95+
Interaction(inputs="hi", outputs="hello", metadata={"only_key": "v1"})
96+
]
97+
)
98+
assert resolve(trace, "trace.last.metadata.*") == ["v1"]
99+
100+
def test_wildcard_with_multiple_matches_returns_a_list(self):
101+
trace = Trace[str, str](
102+
interactions=[
103+
Interaction(
104+
inputs="hi",
105+
outputs="hello",
106+
metadata={"k1": "v1", "k2": "v2"},
107+
)
108+
]
109+
)
110+
result = resolve(trace, "trace.last.metadata.*")
111+
assert isinstance(result, list)
112+
assert sorted(result) == ["v1", "v2"]
113+
114+
def test_non_wildcard_single_field_still_returns_a_bare_scalar(self):
115+
trace = Trace[str, str](
116+
interactions=[Interaction(inputs="hi", outputs="hello")]
117+
)
118+
assert resolve(trace, "trace.last.outputs") == "hello"
119+
120+
78121
class TestJSONPathStrAnnotatedType:
79122
"""Tests for JSONPathStr as a Pydantic Annotated field type."""
80123

0 commit comments

Comments
 (0)