lantern-harness 0.1.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 (33) hide show
  1. lantern_harness-0.1.0/LICENSE +21 -0
  2. lantern_harness-0.1.0/PKG-INFO +97 -0
  3. lantern_harness-0.1.0/README.md +66 -0
  4. lantern_harness-0.1.0/harness/__init__.py +53 -0
  5. lantern_harness-0.1.0/harness/agent.py +48 -0
  6. lantern_harness-0.1.0/harness/anomaly.py +175 -0
  7. lantern_harness-0.1.0/harness/context.py +100 -0
  8. lantern_harness-0.1.0/harness/contracts.py +67 -0
  9. lantern_harness-0.1.0/harness/core.py +459 -0
  10. lantern_harness-0.1.0/harness/flow_loader.py +296 -0
  11. lantern_harness-0.1.0/harness/governance.py +101 -0
  12. lantern_harness-0.1.0/harness/llm_step.py +36 -0
  13. lantern_harness-0.1.0/harness/routing.py +20 -0
  14. lantern_harness-0.1.0/harness/trace_viewer.py +39 -0
  15. lantern_harness-0.1.0/harness/tracing.py +130 -0
  16. lantern_harness-0.1.0/lantern_harness.egg-info/PKG-INFO +97 -0
  17. lantern_harness-0.1.0/lantern_harness.egg-info/SOURCES.txt +31 -0
  18. lantern_harness-0.1.0/lantern_harness.egg-info/dependency_links.txt +1 -0
  19. lantern_harness-0.1.0/lantern_harness.egg-info/requires.txt +9 -0
  20. lantern_harness-0.1.0/lantern_harness.egg-info/top_level.txt +1 -0
  21. lantern_harness-0.1.0/pyproject.toml +53 -0
  22. lantern_harness-0.1.0/setup.cfg +4 -0
  23. lantern_harness-0.1.0/tests/test_anomaly.py +203 -0
  24. lantern_harness-0.1.0/tests/test_context.py +392 -0
  25. lantern_harness-0.1.0/tests/test_contracts.py +130 -0
  26. lantern_harness-0.1.0/tests/test_flow_spec.py +164 -0
  27. lantern_harness-0.1.0/tests/test_governance.py +109 -0
  28. lantern_harness-0.1.0/tests/test_harness_facade.py +94 -0
  29. lantern_harness-0.1.0/tests/test_llm_agent_interface.py +277 -0
  30. lantern_harness-0.1.0/tests/test_llm_step.py +59 -0
  31. lantern_harness-0.1.0/tests/test_routing.py +326 -0
  32. lantern_harness-0.1.0/tests/test_runtime.py +109 -0
  33. lantern_harness-0.1.0/tests/test_tracing.py +129 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pranav Deshmukh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: lantern-harness
3
+ Version: 0.1.0
4
+ Summary: A domain-agnostic execution harness for AI agents: crash-resume, typed contracts, tracing, governance, and dynamic routing for any agent you already built.
5
+ Author: Pranav Deshmukh
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/pranav-deshmukh/lantern
8
+ Project-URL: Repository, https://github.com/pranav-deshmukh/lantern
9
+ Project-URL: Issues, https://github.com/pranav-deshmukh/lantern/issues
10
+ Keywords: agents,ai-agents,orchestration,llm,workflow,observability,governance
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: pydantic<3,>=2
23
+ Requires-Dist: pyyaml<7,>=6
24
+ Requires-Dist: openai<3,>=1
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == "dev"
27
+ Requires-Dist: ruff<1,>=0.6; extra == "dev"
28
+ Requires-Dist: build>=1; extra == "dev"
29
+ Requires-Dist: twine>=5; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # lantern-harness
33
+
34
+ A domain-agnostic execution harness for AI agents. Bring an agent you already
35
+ built — a custom Python class, a LangGraph app, a CLI tool — and get
36
+ crash-resume, typed contracts between steps, automatic tracing, human-approval
37
+ gates on risky actions, and dynamic loop-back, without rewriting the agent
38
+ around this library.
39
+
40
+ ```bash
41
+ pip install lantern-harness
42
+ ```
43
+
44
+ ## Quickstart — wrap an agent you already have
45
+
46
+ ```python
47
+ from harness import Harness
48
+
49
+ class MyAgent:
50
+ def run(self, input):
51
+ return f"processed: {input}"
52
+
53
+ harness = Harness(MyAgent(), trace_path="trace.jsonl")
54
+ result = harness.run("some task")
55
+ ```
56
+
57
+ That's it — your `MyAgent` class needed zero changes, zero imports from this
58
+ library, and zero inheritance. It now has automatic crash-resume (via
59
+ `checkpoint_path`), tracing, and retries for free.
60
+
61
+ ## What this gives you
62
+
63
+ - **Crash-resume**: atomic checkpointing means a crash mid-run resumes from
64
+ the last completed step, not from zero.
65
+ - **Typed contracts**: validate a step's input/output against a Pydantic
66
+ model; a bad handoff fails immediately with a clear error, not three steps
67
+ later as a mystery.
68
+ - **Automatic tracing**: every step's input, output, duration, and
69
+ success/failure is recorded to a structured JSONL trace — no manual
70
+ logging calls needed.
71
+ - **Governance**: gate specific risky actions behind a human-approval policy.
72
+ A denial is provably excluded from the retry loop and still fully audited.
73
+ - **Dynamic routing**: a step can jump back to any earlier named step with
74
+ `Goto(target, payload)` — the pattern behind "verifier failed, retry an
75
+ earlier step with feedback," bounded by a configurable jump limit.
76
+ - **Declarative YAML flows**: define a multi-step flow as data, not code —
77
+ the same runtime executes any flow shape you describe.
78
+ - **Auditable context injection**: attach rules/skills files to a step; their
79
+ content and SHA-256 hash are recorded in the trace, so you can prove
80
+ exactly which version of a rules file was in effect for any run.
81
+
82
+ ## What this is *not*
83
+
84
+ This is not an agent-building framework — it doesn't give your agent
85
+ reasoning, memory, or tool-calling logic. It's the operational layer
86
+ *underneath* whatever agent you already built: think Temporal or Airflow for
87
+ agents, not LangChain or LangGraph.
88
+
89
+ It also does not *enforce* that an agent follows injected rules/skills
90
+ content — it guarantees the content was delivered and audits which version
91
+ was used. Compliance is the agent/prompt's responsibility.
92
+
93
+ ## Learn more
94
+
95
+ Full documentation, architecture notes, and real end-to-end demos (an
96
+ automated code reviewer, an adaptive multi-agent coding pipeline) live in the
97
+ main repository: https://github.com/pranav-deshmukh/lantern
@@ -0,0 +1,66 @@
1
+ # lantern-harness
2
+
3
+ A domain-agnostic execution harness for AI agents. Bring an agent you already
4
+ built — a custom Python class, a LangGraph app, a CLI tool — and get
5
+ crash-resume, typed contracts between steps, automatic tracing, human-approval
6
+ gates on risky actions, and dynamic loop-back, without rewriting the agent
7
+ around this library.
8
+
9
+ ```bash
10
+ pip install lantern-harness
11
+ ```
12
+
13
+ ## Quickstart — wrap an agent you already have
14
+
15
+ ```python
16
+ from harness import Harness
17
+
18
+ class MyAgent:
19
+ def run(self, input):
20
+ return f"processed: {input}"
21
+
22
+ harness = Harness(MyAgent(), trace_path="trace.jsonl")
23
+ result = harness.run("some task")
24
+ ```
25
+
26
+ That's it — your `MyAgent` class needed zero changes, zero imports from this
27
+ library, and zero inheritance. It now has automatic crash-resume (via
28
+ `checkpoint_path`), tracing, and retries for free.
29
+
30
+ ## What this gives you
31
+
32
+ - **Crash-resume**: atomic checkpointing means a crash mid-run resumes from
33
+ the last completed step, not from zero.
34
+ - **Typed contracts**: validate a step's input/output against a Pydantic
35
+ model; a bad handoff fails immediately with a clear error, not three steps
36
+ later as a mystery.
37
+ - **Automatic tracing**: every step's input, output, duration, and
38
+ success/failure is recorded to a structured JSONL trace — no manual
39
+ logging calls needed.
40
+ - **Governance**: gate specific risky actions behind a human-approval policy.
41
+ A denial is provably excluded from the retry loop and still fully audited.
42
+ - **Dynamic routing**: a step can jump back to any earlier named step with
43
+ `Goto(target, payload)` — the pattern behind "verifier failed, retry an
44
+ earlier step with feedback," bounded by a configurable jump limit.
45
+ - **Declarative YAML flows**: define a multi-step flow as data, not code —
46
+ the same runtime executes any flow shape you describe.
47
+ - **Auditable context injection**: attach rules/skills files to a step; their
48
+ content and SHA-256 hash are recorded in the trace, so you can prove
49
+ exactly which version of a rules file was in effect for any run.
50
+
51
+ ## What this is *not*
52
+
53
+ This is not an agent-building framework — it doesn't give your agent
54
+ reasoning, memory, or tool-calling logic. It's the operational layer
55
+ *underneath* whatever agent you already built: think Temporal or Airflow for
56
+ agents, not LangChain or LangGraph.
57
+
58
+ It also does not *enforce* that an agent follows injected rules/skills
59
+ content — it guarantees the content was delivered and audits which version
60
+ was used. Compliance is the agent/prompt's responsibility.
61
+
62
+ ## Learn more
63
+
64
+ Full documentation, architecture notes, and real end-to-end demos (an
65
+ automated code reviewer, an adaptive multi-agent coding pipeline) live in the
66
+ main repository: https://github.com/pranav-deshmukh/lantern
@@ -0,0 +1,53 @@
1
+ """Public API for the minimal multi-step execution engine."""
2
+
3
+ from .agent import Agent, Harness
4
+ from .context import (
5
+ ContextBundle,
6
+ ExecutionContext,
7
+ MissingContextFileError,
8
+ load_context_files,
9
+ )
10
+ from .contracts import Contract, ContractViolationError
11
+ from .core import (
12
+ Flow,
13
+ GotoTargetError,
14
+ MaxJumpsExceeded,
15
+ Runtime,
16
+ Step,
17
+ StepExecutionError,
18
+ )
19
+ from .routing import Goto
20
+ from .flow_loader import FlowLoadError, load_flow
21
+ from .governance import PolicyDecision, PolicyEngine, PolicyRule, PolicyViolation
22
+ from .anomaly import Baseline, find_anomalies
23
+ from .trace_viewer import load_traces, print_trace
24
+ from .tracing import Tracer
25
+
26
+ __all__ = [
27
+ "Agent",
28
+ "Baseline",
29
+ "ContextBundle",
30
+ "Contract",
31
+ "ContractViolationError",
32
+ "ExecutionContext",
33
+ "Flow",
34
+ "FlowLoadError",
35
+ "Goto",
36
+ "GotoTargetError",
37
+ "Harness",
38
+ "MaxJumpsExceeded",
39
+ "MissingContextFileError",
40
+ "PolicyDecision",
41
+ "PolicyEngine",
42
+ "PolicyRule",
43
+ "PolicyViolation",
44
+ "Runtime",
45
+ "Step",
46
+ "StepExecutionError",
47
+ "Tracer",
48
+ "find_anomalies",
49
+ "load_context_files",
50
+ "load_flow",
51
+ "load_traces",
52
+ "print_trace",
53
+ ]
@@ -0,0 +1,48 @@
1
+ """Beginner-friendly façade over the Runtime/Flow/Step primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ from .core import Flow, Runtime, Step
8
+
9
+
10
+ class Agent(Protocol):
11
+ """Documentary protocol for a single-input agent.
12
+
13
+ This is duck-typing documentation only. An agent does NOT need to inherit
14
+ from this protocol, import it, or reference any Lantern type in its own
15
+ code — any object with a ``run(input)`` method (or any plain callable)
16
+ satisfies the expectation at runtime.
17
+ """
18
+
19
+ def run(self, input: Any) -> Any: ...
20
+
21
+
22
+ class Harness:
23
+ """Wrap a single agent/callable or an existing :class:`Flow` behind ``run``.
24
+
25
+ ``Harness(agent)`` builds a one-step flow around ``agent.run`` (or the
26
+ callable itself) and delegates to :class:`Runtime`. ``Harness(flow)`` wraps
27
+ a multi-step flow directly. All :class:`Runtime` keyword options are
28
+ accepted and forwarded unchanged, so checkpointing, tracing, retries, run
29
+ ids, and jump limits all keep working.
30
+ """
31
+
32
+ def __init__(self, agent_or_flow: Any, **runtime_options: Any) -> None:
33
+ if isinstance(agent_or_flow, Flow):
34
+ self._flow = agent_or_flow
35
+ elif callable(getattr(agent_or_flow, "run", None)):
36
+ self._flow = Flow([Step("agent", agent_or_flow.run)])
37
+ elif callable(agent_or_flow):
38
+ self._flow = Flow([Step("agent", agent_or_flow)])
39
+ else:
40
+ raise TypeError(
41
+ "Harness expects an object with a callable .run() method, a "
42
+ "plain callable, or a Flow instance."
43
+ )
44
+ self._runtime = Runtime(**runtime_options)
45
+
46
+ def run(self, input: Any) -> Any:
47
+ """Run the wrapped agent/flow with ``input`` and return its result."""
48
+ return self._runtime.run(self._flow, input)
@@ -0,0 +1,175 @@
1
+ """Cross-run baseline comparison and anomaly detection from JSONL traces.
2
+
3
+ A trace record produced by :class:`Tracer` contains ``run_id``, ``step_name``,
4
+ ``duration_ms``, ``succeeded``, and ``error``. This module compares one new
5
+ trace against statistics learned from past successful traces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import statistics
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .trace_viewer import load_traces
16
+
17
+
18
+ class Baseline:
19
+ """Duration statistics for step names observed in past successful runs.
20
+
21
+ A *successful run* is a ``run_id`` whose every record has ``succeeded``
22
+ equal to ``True``. Failed runs (or runs containing any failed attempt) are
23
+ excluded from the baseline because their timings describe broken work and
24
+ would skew the "normal" duration statistics.
25
+ """
26
+
27
+ def __init__(self, trace_files: Iterable[str | Path]) -> None:
28
+ self._durations: dict[str, list[float]] = {}
29
+ successful_runs = 0
30
+
31
+ for trace_file in trace_files:
32
+ for run_records in _group_by_run(load_traces(trace_file)).values():
33
+ if not _is_successful_run(run_records):
34
+ continue
35
+ successful_runs += 1
36
+ for record in run_records:
37
+ step_name = record.get("step_name")
38
+ duration = record.get("duration_ms")
39
+ if not isinstance(step_name, str) or not isinstance(
40
+ duration, (int, float)
41
+ ):
42
+ continue
43
+ self._durations.setdefault(step_name, []).append(float(duration))
44
+
45
+ if successful_runs == 0:
46
+ raise ValueError(
47
+ "Baseline requires at least one fully successful run; "
48
+ "none of the provided trace files contained one."
49
+ )
50
+
51
+ # Sample standard deviation (n-1) is used because the traces are a
52
+ # sample of all possible runs. A single data point has no spread, so
53
+ # its standard deviation is stored as 0.0 rather than dividing by zero.
54
+ self._averages = {
55
+ name: statistics.mean(durations)
56
+ for name, durations in self._durations.items()
57
+ }
58
+ self._stddevs = {
59
+ name: statistics.stdev(durations) if len(durations) >= 2 else 0.0
60
+ for name, durations in self._durations.items()
61
+ }
62
+
63
+ @property
64
+ def known_step_names(self) -> frozenset[str]:
65
+ """Return the set of step names observed in successful baseline runs."""
66
+ return frozenset(self._durations)
67
+
68
+ def is_known(self, step_name: str) -> bool:
69
+ """Return whether ``step_name`` appeared in any successful baseline run."""
70
+ return step_name in self._durations
71
+
72
+ def sample_count(self, step_name: str) -> int:
73
+ """Return how many successful occurrences were recorded for ``step_name``."""
74
+ return len(self._durations.get(step_name, ()))
75
+
76
+ def average_duration_ms(self, step_name: str) -> float | None:
77
+ """Return the average duration in milliseconds for ``step_name``."""
78
+ return self._averages.get(step_name)
79
+
80
+ def stddev_duration_ms(self, step_name: str) -> float | None:
81
+ """Return the standard deviation of durations for ``step_name``."""
82
+ return self._stddevs.get(step_name)
83
+
84
+
85
+ def find_anomalies(new_trace_path: str | Path, baseline: Baseline) -> list[dict[str, Any]]:
86
+ """Compare a new trace against ``baseline`` and return anomaly findings.
87
+
88
+ Each finding is a dict with ``step_name``, ``reason``, and ``severity``.
89
+ Severity scheme:
90
+
91
+ * ``"critical"`` — the step failed (``succeeded`` is not ``True``).
92
+ * ``"warning"`` — the step succeeded but took more than 2 standard
93
+ deviations longer than its baseline average (only checked when the
94
+ baseline has at least 2 data points for that step, since a standard
95
+ deviation is not meaningful from a single point).
96
+ * ``"info"`` — the step name was not seen in any baseline run.
97
+
98
+ A failed record is reported only as a failure (``critical``): a failed
99
+ attempt's duration is not comparable to successful baseline timings, and a
100
+ failure already demands attention, so extra "slow"/"unrecognized" findings
101
+ would only add noise.
102
+ """
103
+ if not isinstance(baseline, Baseline):
104
+ raise TypeError("baseline must be a Baseline instance.")
105
+
106
+ findings: list[dict[str, Any]] = []
107
+ for record in load_traces(new_trace_path):
108
+ step_name = record.get("step_name")
109
+ if not isinstance(step_name, str) or not step_name:
110
+ continue
111
+
112
+ if record.get("succeeded") is not True:
113
+ error = record.get("error") or "no error recorded"
114
+ findings.append(
115
+ {
116
+ "step_name": step_name,
117
+ "reason": f"Step '{step_name}' failed: {error}",
118
+ "severity": "critical",
119
+ }
120
+ )
121
+ continue
122
+
123
+ if not baseline.is_known(step_name):
124
+ findings.append(
125
+ {
126
+ "step_name": step_name,
127
+ "reason": f"Step '{step_name}' was not seen in any baseline run.",
128
+ "severity": "info",
129
+ }
130
+ )
131
+ continue
132
+
133
+ if baseline.sample_count(step_name) >= 2:
134
+ duration = record.get("duration_ms")
135
+ if isinstance(duration, (int, float)):
136
+ average = baseline.average_duration_ms(step_name)
137
+ stddev = baseline.stddev_duration_ms(step_name)
138
+ if (
139
+ average is not None
140
+ and stddev is not None
141
+ and duration > average + 2 * stddev
142
+ ):
143
+ findings.append(
144
+ {
145
+ "step_name": step_name,
146
+ "reason": (
147
+ f"Step '{step_name}' took {duration:.3f} ms, more than "
148
+ f"2 standard deviations above its baseline average of "
149
+ f"{average:.3f} ms."
150
+ ),
151
+ "severity": "warning",
152
+ }
153
+ )
154
+
155
+ return findings
156
+
157
+
158
+ def _group_by_run(
159
+ records: Iterable[dict[str, Any]],
160
+ ) -> dict[str, list[dict[str, Any]]]:
161
+ """Group trace records by their ``run_id``, preserving first-seen order."""
162
+ runs: dict[str, list[dict[str, Any]]] = {}
163
+ for record in records:
164
+ run_id = record.get("run_id")
165
+ if run_id is None:
166
+ continue
167
+ runs.setdefault(run_id, []).append(record)
168
+ return runs
169
+
170
+
171
+ def _is_successful_run(records: list[dict[str, Any]]) -> bool:
172
+ """Return whether every record in a run reports ``succeeded`` as ``True``."""
173
+ return bool(records) and all(
174
+ record.get("succeeded") is True for record in records
175
+ )
@@ -0,0 +1,100 @@
1
+ """Agent Context Bundling: mechanical injection plus auditability.
2
+
3
+ Lantern guarantees that a step's declared context files were actually read,
4
+ delivered to any function that opts in via a ``context`` parameter, and
5
+ recorded in the trace (file paths plus SHA-256 hashes). It does NOT guarantee
6
+ that the agent complied with the content of those files — that is the
7
+ agent/prompt's responsibility. Nothing in this module implies rule-following
8
+ enforcement.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ class MissingContextFileError(FileNotFoundError):
20
+ """Raised when a declared context file cannot be found."""
21
+
22
+ def __init__(self, path: str) -> None:
23
+ self.path = path
24
+ super().__init__(f"Context file not found: {path}")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ExecutionContext:
29
+ """Execution metadata that is separate from a step's user input.
30
+
31
+ The harness owns this object and passes it to a function only when that
32
+ function explicitly opts in by declaring a ``context`` parameter. Simple
33
+ functions and BYO agents never see it. New kinds of context (memory,
34
+ experience, trace, metadata) can be added here without changing the
35
+ user-facing input contract.
36
+ """
37
+
38
+ rules: ContextBundle | None = None
39
+ skills: ContextBundle | None = None
40
+ memory: ContextBundle | None = None
41
+ experience: ContextBundle | None = None
42
+ metadata: dict[str, Any] = field(default_factory=dict)
43
+
44
+ def as_text(self) -> str:
45
+ """Concatenate all available context files into one string."""
46
+ sections: list[str] = []
47
+ for bundle in (self.rules, self.skills, self.memory, self.experience):
48
+ if bundle is not None:
49
+ sections.append(bundle.as_text())
50
+ return "\n\n".join(sections)
51
+
52
+ def audit_entries(self) -> list[dict[str, str]]:
53
+ """Return the combined ``(path, hash)`` entries for tracing."""
54
+ entries: list[dict[str, str]] = []
55
+ for bundle in (self.rules, self.skills, self.memory, self.experience):
56
+ if bundle is not None:
57
+ entries.extend(bundle.audit_entries())
58
+ return entries
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ContextBundle:
63
+ """The contents and hashes of a set of context files.
64
+
65
+ ``files`` is a list of ``(file_path, content, content_hash)`` tuples. The
66
+ hash is the SHA-256 hex digest of the UTF-8 encoded file content.
67
+ """
68
+
69
+ files: list[tuple[str, str, str]]
70
+
71
+ def as_text(self) -> str:
72
+ """Concatenate all files with clear per-file separators."""
73
+ return "\n\n".join(
74
+ f"--- {file_path} ---\n{content}"
75
+ for file_path, content, _ in self.files
76
+ )
77
+
78
+ def audit_entries(self) -> list[dict[str, str]]:
79
+ """Return ``(path, hash)`` pairs for trace auditability."""
80
+ return [
81
+ {"path": file_path, "hash": content_hash}
82
+ for file_path, _, content_hash in self.files
83
+ ]
84
+
85
+
86
+ def load_context_files(paths: list[str]) -> ContextBundle:
87
+ """Read each file and compute its SHA-256 hash.
88
+
89
+ Raises :class:`MissingContextFileError` naming the exact path if any file
90
+ does not exist.
91
+ """
92
+ files: list[tuple[str, str, str]] = []
93
+ for path_str in paths:
94
+ path = Path(path_str)
95
+ if not path.is_file():
96
+ raise MissingContextFileError(path_str)
97
+ content = path.read_text(encoding="utf-8")
98
+ content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
99
+ files.append((path_str, content, content_hash))
100
+ return ContextBundle(files)
@@ -0,0 +1,67 @@
1
+ """Pydantic-backed input and output contracts for steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import types
6
+ from dataclasses import dataclass
7
+ from typing import Any, Union, get_origin
8
+
9
+ from pydantic import BaseModel, TypeAdapter, ValidationError
10
+
11
+
12
+ def is_base_model_type(model: Any) -> bool:
13
+ """Return whether ``model`` is a Pydantic ``BaseModel`` subclass."""
14
+ return isinstance(model, type) and issubclass(model, BaseModel)
15
+
16
+
17
+ def is_union_type(model: Any) -> bool:
18
+ """Return whether ``model`` is a typing ``Union`` of multiple types."""
19
+ return get_origin(model) is Union or isinstance(model, types.UnionType)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Contract:
24
+ """The Pydantic models that define a step's input and output shapes."""
25
+
26
+ input_model: type[BaseModel]
27
+ output_model: Any
28
+
29
+ def __post_init__(self) -> None:
30
+ if not is_base_model_type(self.input_model):
31
+ raise TypeError("input_model must be a Pydantic BaseModel subclass.")
32
+ if not is_base_model_type(self.output_model) and not is_union_type(
33
+ self.output_model
34
+ ):
35
+ raise TypeError(
36
+ "output_model must be a Pydantic BaseModel subclass or a Union of them."
37
+ )
38
+
39
+
40
+ class ContractViolationError(ValueError):
41
+ """Raised when a step value does not satisfy one side of its contract."""
42
+
43
+ def __init__(self, step_name: str, direction: str, cause: ValidationError) -> None:
44
+ self.step_name = step_name
45
+ self.direction = direction
46
+ self.cause = cause
47
+ super().__init__(
48
+ f"Contract violation in step '{step_name}' for {direction}: {cause}"
49
+ )
50
+
51
+
52
+ def validate_value(
53
+ value: Any,
54
+ model: Any,
55
+ *,
56
+ step_name: str,
57
+ direction: str,
58
+ ) -> Any:
59
+ """Validate a value and add step context to Pydantic validation errors.
60
+
61
+ ``model`` may be a ``BaseModel`` subclass or a ``Union`` (which can include
62
+ dataclasses such as :class:`Goto`); ``TypeAdapter`` validates both.
63
+ """
64
+ try:
65
+ return TypeAdapter(model).validate_python(value)
66
+ except ValidationError as error:
67
+ raise ContractViolationError(step_name, direction, error) from error