lantern-harness 0.1.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.
harness/__init__.py ADDED
@@ -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
+ ]
harness/agent.py ADDED
@@ -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)
harness/anomaly.py ADDED
@@ -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
+ )
harness/context.py ADDED
@@ -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)
harness/contracts.py ADDED
@@ -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