agentidem 1.0.0__py3-none-any.whl

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 (52) hide show
  1. agentidem/__init__.py +33 -0
  2. agentidem/cli/__init__.py +6 -0
  3. agentidem/cli/app.py +43 -0
  4. agentidem/cli/commands/__init__.py +0 -0
  5. agentidem/cli/commands/record.py +100 -0
  6. agentidem/cli/commands/replay.py +134 -0
  7. agentidem/cli/commands/test.py +110 -0
  8. agentidem/cli/commands/version.py +12 -0
  9. agentidem/cli/constants.py +1 -0
  10. agentidem/cli/loader.py +52 -0
  11. agentidem/cli/output/__init__.py +12 -0
  12. agentidem/cli/output/console.py +97 -0
  13. agentidem/detection/__init__.py +19 -0
  14. agentidem/detection/duplicates.py +99 -0
  15. agentidem/detection/models.py +19 -0
  16. agentidem/detection/normalization.py +86 -0
  17. agentidem/errors.py +41 -0
  18. agentidem/faults/__init__.py +40 -0
  19. agentidem/faults/base.py +23 -0
  20. agentidem/faults/case.py +14 -0
  21. agentidem/faults/context.py +42 -0
  22. agentidem/faults/injector.py +53 -0
  23. agentidem/faults/plan.py +19 -0
  24. agentidem/faults/runner.py +193 -0
  25. agentidem/faults/scenarios/__init__.py +15 -0
  26. agentidem/faults/scenarios/before_operation_failure.py +32 -0
  27. agentidem/faults/scenarios/duplicate_delivery.py +26 -0
  28. agentidem/faults/scenarios/lost_ack.py +30 -0
  29. agentidem/faults/suite.py +215 -0
  30. agentidem/invariants/__init__.py +14 -0
  31. agentidem/invariants/api.py +17 -0
  32. agentidem/invariants/evaluator.py +45 -0
  33. agentidem/invariants/models.py +10 -0
  34. agentidem/runtime/__init__.py +9 -0
  35. agentidem/runtime/context.py +23 -0
  36. agentidem/runtime/decorators.py +111 -0
  37. agentidem/runtime/operations.py +198 -0
  38. agentidem/runtime/runner.py +76 -0
  39. agentidem/testing/__init__.py +17 -0
  40. agentidem/testing/models.py +45 -0
  41. agentidem/testing/runner.py +156 -0
  42. agentidem/testing/serialization.py +98 -0
  43. agentidem/trace/__init__.py +19 -0
  44. agentidem/trace/models.py +56 -0
  45. agentidem/trace/recorder.py +24 -0
  46. agentidem/trace/serialization.py +22 -0
  47. agentidem-1.0.0.dist-info/METADATA +272 -0
  48. agentidem-1.0.0.dist-info/RECORD +52 -0
  49. agentidem-1.0.0.dist-info/WHEEL +5 -0
  50. agentidem-1.0.0.dist-info/entry_points.txt +2 -0
  51. agentidem-1.0.0.dist-info/licenses/LICENSE +51 -0
  52. agentidem-1.0.0.dist-info/top_level.txt +1 -0
agentidem/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ from agentidem.invariants import invariant
2
+ from agentidem.runtime import (
3
+ read,
4
+ run_traced,
5
+ run_traced_async,
6
+ write,
7
+ )
8
+ from agentidem.testing import TestReport, test_agent, test_agent_async
9
+ from agentidem.trace import (
10
+ ObservationStatus,
11
+ OperationKind,
12
+ OperationStatus,
13
+ Trace,
14
+ TraceOperation,
15
+ )
16
+
17
+ __version__ = "1.0.0"
18
+
19
+ __all__ = [
20
+ "ObservationStatus",
21
+ "OperationKind",
22
+ "OperationStatus",
23
+ "TestReport",
24
+ "Trace",
25
+ "TraceOperation",
26
+ "invariant",
27
+ "read",
28
+ "run_traced",
29
+ "run_traced_async",
30
+ "test_agent",
31
+ "test_agent_async",
32
+ "write",
33
+ ]
@@ -0,0 +1,6 @@
1
+ from agentidem.cli.app import app
2
+
3
+
4
+ __all__ = [
5
+ "app",
6
+ ]
agentidem/cli/app.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from agentidem.cli.commands.record import record_command
6
+ from agentidem.cli.commands.test import test_command
7
+ from agentidem.cli.commands.replay import replay_command
8
+ from agentidem.cli.commands.version import version_command
9
+
10
+ app = typer.Typer(
11
+ name="agentidem",
12
+ help="Reliability testing for state-changing AI agents.",
13
+ no_args_is_help=True,
14
+ )
15
+
16
+
17
+ app.command(
18
+ "test",
19
+ help="Test an agent for unsafe side effects.",
20
+ )(test_command)
21
+
22
+
23
+ app.command(
24
+ "record",
25
+ help="Run an agent once and record its execution trace.",
26
+ )(record_command)
27
+ app.command(
28
+ "version",
29
+ help="Show the AgentIdem CLI version.",
30
+ )(version_command)
31
+
32
+
33
+ app.command(
34
+ "replay",
35
+ help="Re-run a recorded trace and compare the execution.",
36
+ )(replay_command)
37
+
38
+
39
+ @app.callback()
40
+ def main() -> None:
41
+ """
42
+ AgentIdem command-line interface.
43
+ """
File without changes
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ from collections.abc import Awaitable, Callable
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ import typer
10
+
11
+ from agentidem.cli.loader import (
12
+ TargetLoadError,
13
+ load_target,
14
+ )
15
+ from agentidem.cli.output import (
16
+ render_target_error,
17
+ )
18
+ from agentidem.errors import TracedExecutionError
19
+ from agentidem.runtime.runner import (
20
+ run_traced,
21
+ run_traced_async,
22
+ )
23
+ from agentidem.trace.models import Trace
24
+ from agentidem.trace.serialization import save_trace
25
+
26
+
27
+ def _record_target(
28
+ target: str,
29
+ func: Callable[..., Any],
30
+ ) -> Trace:
31
+ if inspect.iscoroutinefunction(func):
32
+ async_func = cast(
33
+ Callable[..., Awaitable[Any]],
34
+ func,
35
+ )
36
+
37
+ _, trace = asyncio.run(
38
+ run_traced_async(
39
+ target,
40
+ async_func,
41
+ )
42
+ )
43
+
44
+ return trace
45
+
46
+ _, trace = run_traced(
47
+ target,
48
+ func,
49
+ )
50
+
51
+ return trace
52
+
53
+
54
+ def record_command(
55
+ target: str = typer.Argument(
56
+ ...,
57
+ help="Python target in module.path:function format.",
58
+ ),
59
+ out: Path = typer.Option(
60
+ Path("trace.json"),
61
+ "--out",
62
+ help="Write the recorded trace to a JSON file.",
63
+ ),
64
+ ) -> None:
65
+ """
66
+ Run an agent once and record its execution trace.
67
+ """
68
+ try:
69
+ func = load_target(target)
70
+
71
+ except TargetLoadError as exc:
72
+ render_target_error(str(exc))
73
+ raise typer.Exit(code=2) from exc
74
+
75
+ try:
76
+ trace = _record_target(
77
+ target,
78
+ func,
79
+ )
80
+
81
+ except TracedExecutionError as exc:
82
+ save_trace(
83
+ exc.trace,
84
+ out,
85
+ )
86
+
87
+ render_target_error(
88
+ f"{type(exc.cause).__name__}: {exc.cause}"
89
+ )
90
+
91
+ raise typer.Exit(code=2) from exc
92
+
93
+ save_trace(
94
+ trace,
95
+ out,
96
+ )
97
+
98
+ typer.echo(
99
+ f"Recorded trace to {out}"
100
+ )
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ from collections.abc import Awaitable, Callable
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ import typer
10
+
11
+ from agentidem.cli.loader import (
12
+ TargetLoadError,
13
+ load_target,
14
+ )
15
+ from agentidem.cli.output import render_target_error
16
+ from agentidem.errors import TracedExecutionError
17
+ from agentidem.runtime.runner import (
18
+ run_traced,
19
+ run_traced_async,
20
+ )
21
+ from agentidem.trace.models import Trace, TraceOperation
22
+ from agentidem.trace.serialization import load_trace
23
+
24
+
25
+ def _operation_signature(
26
+ operation: TraceOperation,
27
+ ) -> tuple[Any, ...]:
28
+ return (
29
+ operation.name,
30
+ operation.kind,
31
+ operation.args,
32
+ operation.identity,
33
+ operation.result,
34
+ operation.error,
35
+ operation.status,
36
+ operation.observation,
37
+ )
38
+
39
+
40
+ def _traces_match(
41
+ expected: Trace,
42
+ actual: Trace,
43
+ ) -> bool:
44
+ if expected.target != actual.target:
45
+ return False
46
+
47
+ if len(expected.operations) != len(actual.operations):
48
+ return False
49
+
50
+ return all(
51
+ _operation_signature(expected_operation)
52
+ == _operation_signature(actual_operation)
53
+ for expected_operation, actual_operation in zip(
54
+ expected.operations,
55
+ actual.operations,
56
+ strict=True,
57
+ )
58
+ )
59
+
60
+
61
+ def _run_replay_target(
62
+ target: str,
63
+ func: Callable[..., Any],
64
+ ) -> Trace:
65
+ if inspect.iscoroutinefunction(func):
66
+ async_func = cast(
67
+ Callable[..., Awaitable[Any]],
68
+ func,
69
+ )
70
+
71
+ try:
72
+ _, trace = asyncio.run(
73
+ run_traced_async(
74
+ target,
75
+ async_func,
76
+ )
77
+ )
78
+ except TracedExecutionError as exc:
79
+ return exc.trace
80
+
81
+ return trace
82
+
83
+ try:
84
+ _, trace = run_traced(
85
+ target,
86
+ func,
87
+ )
88
+ except TracedExecutionError as exc:
89
+ return exc.trace
90
+
91
+ return trace
92
+
93
+
94
+ def replay_command(
95
+ trace_path: Path = typer.Argument(
96
+ ...,
97
+ help="Path to a recorded AgentIdem trace.",
98
+ ),
99
+ ) -> None:
100
+ """
101
+ Re-run a recorded target and compare its execution trace.
102
+ """
103
+ try:
104
+ expected_trace = load_trace(trace_path)
105
+ except Exception as exc:
106
+ render_target_error(
107
+ f"Failed to load trace '{trace_path}': {exc}"
108
+ )
109
+ raise typer.Exit(code=2) from exc
110
+
111
+ try:
112
+ func = load_target(expected_trace.target)
113
+ except TargetLoadError as exc:
114
+ render_target_error(str(exc))
115
+ raise typer.Exit(code=2) from exc
116
+
117
+ actual_trace = _run_replay_target(
118
+ expected_trace.target,
119
+ func,
120
+ )
121
+
122
+ if _traces_match(
123
+ expected_trace,
124
+ actual_trace,
125
+ ):
126
+ typer.echo(
127
+ f"REPLAY MATCH: {expected_trace.target}"
128
+ )
129
+ raise typer.Exit(code=0)
130
+
131
+ typer.echo(
132
+ f"REPLAY MISMATCH: {expected_trace.target}"
133
+ )
134
+ raise typer.Exit(code=1)
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ from collections.abc import Awaitable, Callable
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import typer
11
+
12
+ from agentidem import (
13
+ test_agent,
14
+ test_agent_async,
15
+ )
16
+ from agentidem.cli.loader import (
17
+ TargetLoadError,
18
+ load_target,
19
+ )
20
+ from agentidem.cli.output import (
21
+ render_target_error,
22
+ render_test_report,
23
+ )
24
+ from agentidem.testing.models import TestReport
25
+ from agentidem.testing.serialization import (
26
+ report_to_dict,
27
+ save_report,
28
+ )
29
+
30
+
31
+ def _run_target(
32
+ target: str,
33
+ func: Callable[..., Any],
34
+ ) -> TestReport:
35
+ if inspect.iscoroutinefunction(func):
36
+ async_func = cast(
37
+ Callable[..., Awaitable[Any]],
38
+ func,
39
+ )
40
+
41
+ return asyncio.run(
42
+ test_agent_async(
43
+ target,
44
+ async_func,
45
+ )
46
+ )
47
+
48
+ return test_agent(
49
+ target,
50
+ func,
51
+ )
52
+
53
+
54
+ def test_command(
55
+ target: str = typer.Argument(
56
+ ...,
57
+ help="Python target in module.path:function format.",
58
+ ),
59
+ out: Path | None = typer.Option(
60
+ None,
61
+ "--out",
62
+ help="Write the test report to a JSON file.",
63
+ ),
64
+ json_output: bool = typer.Option(
65
+ False,
66
+ "--json",
67
+ help="Print the test report as JSON.",
68
+ ),
69
+ ) -> None:
70
+ """
71
+ Test an agent for retry and side effect safety.
72
+ """
73
+ try:
74
+ func = load_target(target)
75
+
76
+ except TargetLoadError as exc:
77
+ render_target_error(str(exc))
78
+ raise typer.Exit(code=2) from exc
79
+
80
+ report = _run_target(
81
+ target,
82
+ func,
83
+ )
84
+
85
+ if out is not None:
86
+ save_report(
87
+ report,
88
+ out,
89
+ )
90
+
91
+ if json_output:
92
+ typer.echo(
93
+ json.dumps(
94
+ report_to_dict(report),
95
+ indent=2,
96
+ )
97
+ )
98
+ else:
99
+ render_test_report(
100
+ target,
101
+ report,
102
+ )
103
+
104
+ if report.baseline_failure is not None:
105
+ raise typer.Exit(code=2)
106
+
107
+ if report.safe:
108
+ raise typer.Exit(code=0)
109
+
110
+ raise typer.Exit(code=1)
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from agentidem.cli.constants import AGENTIDEM_CLI_VERSION
6
+
7
+
8
+ def version_command() -> None:
9
+ """
10
+ Show the AgentIdem CLI version.
11
+ """
12
+ typer.echo(AGENTIDEM_CLI_VERSION)
@@ -0,0 +1 @@
1
+ AGENTIDEM_CLI_VERSION = "v1.0.0"
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import sys
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class TargetLoadError(RuntimeError):
11
+ pass
12
+
13
+
14
+ def load_target(target: str) -> Callable[..., Any]:
15
+ if ":" not in target:
16
+ raise TargetLoadError(
17
+ "Target must use the format 'module.path:function'."
18
+ )
19
+
20
+ module_name, object_name = target.split(":", 1)
21
+
22
+ if not module_name:
23
+ raise TargetLoadError("Target module cannot be empty.")
24
+
25
+ if not object_name:
26
+ raise TargetLoadError("Target function cannot be empty.")
27
+
28
+ cwd = str(Path.cwd())
29
+
30
+ if cwd not in sys.path:
31
+ sys.path.insert(0, cwd)
32
+
33
+ try:
34
+ module = importlib.import_module(module_name)
35
+ except Exception as exc:
36
+ raise TargetLoadError(
37
+ f"Failed to import module '{module_name}': {exc}"
38
+ ) from exc
39
+
40
+ try:
41
+ obj = getattr(module, object_name)
42
+ except AttributeError as exc:
43
+ raise TargetLoadError(
44
+ f"Module '{module_name}' has no attribute '{object_name}'."
45
+ ) from exc
46
+
47
+ if not callable(obj):
48
+ raise TargetLoadError(
49
+ f"Target '{target}' is not callable."
50
+ )
51
+
52
+ return obj
@@ -0,0 +1,12 @@
1
+ from agentidem.cli.output.console import (
2
+ render_baseline_failure,
3
+ render_target_error,
4
+ render_test_report,
5
+ )
6
+
7
+
8
+ __all__ = [
9
+ "render_baseline_failure",
10
+ "render_target_error",
11
+ "render_test_report",
12
+ ]
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from agentidem.testing.models import TestReport
7
+
8
+
9
+ console = Console()
10
+ error_console = Console(stderr=True)
11
+
12
+
13
+ def render_test_report(
14
+ target: str,
15
+ report: TestReport,
16
+ ) -> None:
17
+ if report.baseline_failure is not None:
18
+ render_baseline_failure(
19
+ target,
20
+ report,
21
+ )
22
+ return
23
+
24
+ status = "SAFE" if report.safe else "UNSAFE"
25
+
26
+ console.print()
27
+ console.print(
28
+ f"[bold]{status}[/bold] {target}"
29
+ )
30
+
31
+ console.print(
32
+ f"Fault cases: {report.fault_count} "
33
+ f"Unsafe: {report.unsafe_count}"
34
+ )
35
+
36
+ if not report.results:
37
+ return
38
+
39
+ table = Table(
40
+ title="Fault scenarios",
41
+ show_header=True,
42
+ header_style="bold",
43
+ )
44
+
45
+ table.add_column("Scenario")
46
+ table.add_column("Status")
47
+ table.add_column("Execution")
48
+ table.add_column("Findings", justify="right")
49
+
50
+ for result in report.results:
51
+ scenario_status = (
52
+ "SAFE"
53
+ if result.safe
54
+ else "UNSAFE"
55
+ )
56
+
57
+ execution_status = (
58
+ "FAILED"
59
+ if result.failed
60
+ else "COMPLETED"
61
+ )
62
+
63
+ table.add_row(
64
+ result.case.scenario,
65
+ scenario_status,
66
+ execution_status,
67
+ str(len(result.findings)),
68
+ )
69
+
70
+ console.print(table)
71
+
72
+
73
+ def render_baseline_failure(
74
+ target: str,
75
+ report: TestReport,
76
+ ) -> None:
77
+ failure = report.baseline_failure
78
+
79
+ if failure is None:
80
+ return
81
+
82
+ error_console.print()
83
+ error_console.print(
84
+ f"[bold]BASELINE FAILED[/bold] {target}"
85
+ )
86
+
87
+ error_console.print(
88
+ f"{failure.error_type}: {failure.message}"
89
+ )
90
+
91
+
92
+ def render_target_error(
93
+ message: str,
94
+ ) -> None:
95
+ error_console.print(
96
+ f"[bold]ERROR[/bold] {message}"
97
+ )
@@ -0,0 +1,19 @@
1
+ from agentidem.detection.duplicates import (
2
+ DuplicateWrite,
3
+ detect_duplicate_write_findings,
4
+ detect_duplicate_writes,
5
+ duplicate_write_to_finding,
6
+ )
7
+ from agentidem.detection.models import (
8
+ Finding,
9
+ FindingSeverity,
10
+ )
11
+
12
+ __all__ = [
13
+ "DuplicateWrite",
14
+ "Finding",
15
+ "FindingSeverity",
16
+ "detect_duplicate_write_findings",
17
+ "detect_duplicate_writes",
18
+ "duplicate_write_to_finding",
19
+ ]