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.
- lantern_harness-0.1.0/LICENSE +21 -0
- lantern_harness-0.1.0/PKG-INFO +97 -0
- lantern_harness-0.1.0/README.md +66 -0
- lantern_harness-0.1.0/harness/__init__.py +53 -0
- lantern_harness-0.1.0/harness/agent.py +48 -0
- lantern_harness-0.1.0/harness/anomaly.py +175 -0
- lantern_harness-0.1.0/harness/context.py +100 -0
- lantern_harness-0.1.0/harness/contracts.py +67 -0
- lantern_harness-0.1.0/harness/core.py +459 -0
- lantern_harness-0.1.0/harness/flow_loader.py +296 -0
- lantern_harness-0.1.0/harness/governance.py +101 -0
- lantern_harness-0.1.0/harness/llm_step.py +36 -0
- lantern_harness-0.1.0/harness/routing.py +20 -0
- lantern_harness-0.1.0/harness/trace_viewer.py +39 -0
- lantern_harness-0.1.0/harness/tracing.py +130 -0
- lantern_harness-0.1.0/lantern_harness.egg-info/PKG-INFO +97 -0
- lantern_harness-0.1.0/lantern_harness.egg-info/SOURCES.txt +31 -0
- lantern_harness-0.1.0/lantern_harness.egg-info/dependency_links.txt +1 -0
- lantern_harness-0.1.0/lantern_harness.egg-info/requires.txt +9 -0
- lantern_harness-0.1.0/lantern_harness.egg-info/top_level.txt +1 -0
- lantern_harness-0.1.0/pyproject.toml +53 -0
- lantern_harness-0.1.0/setup.cfg +4 -0
- lantern_harness-0.1.0/tests/test_anomaly.py +203 -0
- lantern_harness-0.1.0/tests/test_context.py +392 -0
- lantern_harness-0.1.0/tests/test_contracts.py +130 -0
- lantern_harness-0.1.0/tests/test_flow_spec.py +164 -0
- lantern_harness-0.1.0/tests/test_governance.py +109 -0
- lantern_harness-0.1.0/tests/test_harness_facade.py +94 -0
- lantern_harness-0.1.0/tests/test_llm_agent_interface.py +277 -0
- lantern_harness-0.1.0/tests/test_llm_step.py +59 -0
- lantern_harness-0.1.0/tests/test_routing.py +326 -0
- lantern_harness-0.1.0/tests/test_runtime.py +109 -0
- 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
|