bbagents 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.
- bbagents/__init__.py +70 -0
- bbagents/compaction.py +38 -0
- bbagents/context.py +124 -0
- bbagents/core.py +49 -0
- bbagents/decision.py +225 -0
- bbagents/evaluation.py +114 -0
- bbagents/execution.py +216 -0
- bbagents/experiments.py +172 -0
- bbagents/governed_memory.py +85 -0
- bbagents/hooks.py +78 -0
- bbagents/integrations/__init__.py +1 -0
- bbagents/integrations/filesystem_skills.py +101 -0
- bbagents/integrations/langfuse.py +115 -0
- bbagents/integrations/langgraph.py +99 -0
- bbagents/integrations/langsmith.py +117 -0
- bbagents/integrations/ollama.py +85 -0
- bbagents/intervention.py +510 -0
- bbagents/learning.py +55 -0
- bbagents/lifecycle.py +168 -0
- bbagents/memory.py +157 -0
- bbagents/models.py +199 -0
- bbagents/observability.py +216 -0
- bbagents/optimization.py +47 -0
- bbagents/organization.py +40 -0
- bbagents/persistence.py +274 -0
- bbagents/planning.py +278 -0
- bbagents/promotion.py +67 -0
- bbagents/provenance.py +37 -0
- bbagents/py.typed +1 -0
- bbagents/recovery.py +184 -0
- bbagents/reference.py +5 -0
- bbagents/retrieval.py +122 -0
- bbagents/revisions.py +161 -0
- bbagents/runtime.py +218 -0
- bbagents/safety.py +85 -0
- bbagents/skills.py +57 -0
- bbagents/tools.py +61 -0
- bbagents-0.1.0.dist-info/METADATA +80 -0
- bbagents-0.1.0.dist-info/RECORD +42 -0
- bbagents-0.1.0.dist-info/WHEEL +5 -0
- bbagents-0.1.0.dist-info/licenses/LICENSE +22 -0
- bbagents-0.1.0.dist-info/top_level.txt +1 -0
bbagents/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""BrilBerry Agents: provider-neutral contracts for agent applications.
|
|
2
|
+
|
|
3
|
+
The root namespace intentionally contains only the most common execution path.
|
|
4
|
+
Import specialised APIs from their responsibility-specific modules, for example
|
|
5
|
+
``bbagents.intervention`` or ``bbagents.models``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .core import LanguageModel, Observation, TraceSink
|
|
9
|
+
from .decision import (
|
|
10
|
+
DecisionModel,
|
|
11
|
+
DecisionOutput,
|
|
12
|
+
DecisionRequest,
|
|
13
|
+
DecisionResult,
|
|
14
|
+
DecisionRuntime,
|
|
15
|
+
DecisionSchema,
|
|
16
|
+
DecisionUsage,
|
|
17
|
+
PythonTypeSchema,
|
|
18
|
+
)
|
|
19
|
+
from .planning import (
|
|
20
|
+
AgentState,
|
|
21
|
+
DependencyCycleError,
|
|
22
|
+
Plan,
|
|
23
|
+
PlanExecutor,
|
|
24
|
+
PlanStep,
|
|
25
|
+
StepStatus,
|
|
26
|
+
)
|
|
27
|
+
from .provenance import Provenance, TrustLevel
|
|
28
|
+
from .runtime import (
|
|
29
|
+
Action,
|
|
30
|
+
ActionResult,
|
|
31
|
+
ActionRuntime,
|
|
32
|
+
ActionStatus,
|
|
33
|
+
AuthorizationDecision,
|
|
34
|
+
AuthorizationStatus,
|
|
35
|
+
)
|
|
36
|
+
from .tools import FunctionTool, Tool, ToolCall, ToolRegistry
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Action",
|
|
40
|
+
"ActionResult",
|
|
41
|
+
"ActionRuntime",
|
|
42
|
+
"ActionStatus",
|
|
43
|
+
"AgentState",
|
|
44
|
+
"AuthorizationDecision",
|
|
45
|
+
"AuthorizationStatus",
|
|
46
|
+
"DecisionModel",
|
|
47
|
+
"DecisionOutput",
|
|
48
|
+
"DecisionRequest",
|
|
49
|
+
"DecisionResult",
|
|
50
|
+
"DecisionRuntime",
|
|
51
|
+
"DecisionSchema",
|
|
52
|
+
"DecisionUsage",
|
|
53
|
+
"DependencyCycleError",
|
|
54
|
+
"FunctionTool",
|
|
55
|
+
"LanguageModel",
|
|
56
|
+
"Observation",
|
|
57
|
+
"Plan",
|
|
58
|
+
"PlanExecutor",
|
|
59
|
+
"PlanStep",
|
|
60
|
+
"Provenance",
|
|
61
|
+
"PythonTypeSchema",
|
|
62
|
+
"StepStatus",
|
|
63
|
+
"Tool",
|
|
64
|
+
"ToolCall",
|
|
65
|
+
"ToolRegistry",
|
|
66
|
+
"TraceSink",
|
|
67
|
+
"TrustLevel",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
__version__ = "0.1.0"
|
bbagents/compaction.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Grade structured continuation probes rather than compression ratios."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .evaluation import Grade, TrialEvidence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class ContinuationObservation:
|
|
10
|
+
retained_constraints: frozenset[str]
|
|
11
|
+
proposed_action_ids: frozenset[str]
|
|
12
|
+
recovered_evidence_refs: frozenset[str]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class CompactionGrader:
|
|
17
|
+
required_constraints: frozenset[str]
|
|
18
|
+
completed_action_ids: frozenset[str]
|
|
19
|
+
required_evidence_refs: frozenset[str]
|
|
20
|
+
|
|
21
|
+
def grade(self, trial: TrialEvidence[ContinuationObservation | None]) -> Grade:
|
|
22
|
+
observation = trial.outcome
|
|
23
|
+
if observation is None:
|
|
24
|
+
return Grade(None, "No continuation evidence")
|
|
25
|
+
missing_constraints = (
|
|
26
|
+
self.required_constraints - observation.retained_constraints
|
|
27
|
+
)
|
|
28
|
+
repeated = self.completed_action_ids & observation.proposed_action_ids
|
|
29
|
+
missing_evidence = (
|
|
30
|
+
self.required_evidence_refs - observation.recovered_evidence_refs
|
|
31
|
+
)
|
|
32
|
+
passed = not (missing_constraints or repeated or missing_evidence)
|
|
33
|
+
return Grade(
|
|
34
|
+
passed,
|
|
35
|
+
f"Missing constraints: {sorted(missing_constraints)}; "
|
|
36
|
+
f"repeated actions: {sorted(repeated)}; missing evidence: {sorted(missing_evidence)}",
|
|
37
|
+
evidence_refs=tuple(sorted(observation.recovered_evidence_refs)),
|
|
38
|
+
)
|
bbagents/context.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Budgeted context assembly and state-projection contracts.
|
|
2
|
+
|
|
3
|
+
Applications decide which domain facts matter. This module provides the
|
|
4
|
+
portable mechanics for selecting, truncating, and optionally summarizing those
|
|
5
|
+
facts before they are passed to a model or another agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Protocol, Sequence, TypeVar
|
|
12
|
+
|
|
13
|
+
from .provenance import Provenance
|
|
14
|
+
|
|
15
|
+
StateT = TypeVar("StateT", contravariant=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ContextBudget:
|
|
20
|
+
"""Limits applied when assembling an execution context."""
|
|
21
|
+
|
|
22
|
+
max_items: int = 20
|
|
23
|
+
max_characters: int = 12_000
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
if self.max_items < 1:
|
|
27
|
+
raise ValueError("max_items must be positive")
|
|
28
|
+
if self.max_characters < 1:
|
|
29
|
+
raise ValueError("max_characters must be positive")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ContextItem:
|
|
34
|
+
"""One application-provided candidate for a model context."""
|
|
35
|
+
|
|
36
|
+
content: str
|
|
37
|
+
source: str
|
|
38
|
+
priority: int = 0
|
|
39
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
provenance: Provenance = field(
|
|
41
|
+
default_factory=lambda: Provenance.system("context")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Context:
|
|
47
|
+
"""A bounded, attributable view of application state."""
|
|
48
|
+
|
|
49
|
+
content: str
|
|
50
|
+
items: tuple[ContextItem, ...]
|
|
51
|
+
omitted_items: int = 0
|
|
52
|
+
summarized: bool = False
|
|
53
|
+
summary_sources: tuple[ContextItem, ...] = ()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class StateProjector(Protocol[StateT]):
|
|
57
|
+
"""Maps application state into ranked, attributable context candidates."""
|
|
58
|
+
|
|
59
|
+
def project(self, state: StateT) -> Sequence[ContextItem]: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ContextSummarizer(Protocol):
|
|
63
|
+
"""Compresses omitted context using an application-selected implementation."""
|
|
64
|
+
|
|
65
|
+
def summarize(
|
|
66
|
+
self,
|
|
67
|
+
items: Sequence[ContextItem],
|
|
68
|
+
*,
|
|
69
|
+
max_characters: int,
|
|
70
|
+
) -> str: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ContextBuilder:
|
|
74
|
+
"""Deterministically assembles a bounded context from ranked candidates."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
budget: ContextBudget,
|
|
79
|
+
*,
|
|
80
|
+
summarizer: ContextSummarizer | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.budget = budget
|
|
83
|
+
self.summarizer = summarizer
|
|
84
|
+
|
|
85
|
+
def build(self, items: Sequence[ContextItem]) -> Context:
|
|
86
|
+
ranked = sorted(enumerate(items), key=lambda pair: (-pair[1].priority, pair[0]))
|
|
87
|
+
selected: list[ContextItem] = []
|
|
88
|
+
omitted: list[ContextItem] = []
|
|
89
|
+
used = 0
|
|
90
|
+
for _, item in ranked:
|
|
91
|
+
separator = 1 if selected else 0
|
|
92
|
+
if (
|
|
93
|
+
len(selected) < self.budget.max_items
|
|
94
|
+
and used + separator + len(item.content) <= self.budget.max_characters
|
|
95
|
+
):
|
|
96
|
+
selected.append(item)
|
|
97
|
+
used += separator + len(item.content)
|
|
98
|
+
else:
|
|
99
|
+
omitted.append(item)
|
|
100
|
+
|
|
101
|
+
content = "\n".join(item.content for item in selected)
|
|
102
|
+
if not omitted or self.summarizer is None:
|
|
103
|
+
return Context(content, tuple(selected), omitted_items=len(omitted))
|
|
104
|
+
|
|
105
|
+
available = self.budget.max_characters - len(content) - (1 if content else 0)
|
|
106
|
+
if available < 1:
|
|
107
|
+
return Context(content, tuple(selected), omitted_items=len(omitted))
|
|
108
|
+
summary = self.summarizer.summarize(omitted, max_characters=available).strip()
|
|
109
|
+
if len(summary) > available:
|
|
110
|
+
summary = summary[:available]
|
|
111
|
+
if summary:
|
|
112
|
+
content = f"{content}\n{summary}" if content else summary
|
|
113
|
+
return Context(
|
|
114
|
+
content,
|
|
115
|
+
tuple(selected),
|
|
116
|
+
omitted_items=len(omitted),
|
|
117
|
+
summarized=bool(summary),
|
|
118
|
+
summary_sources=tuple(omitted) if summary else (),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def project(self, state: StateT, projector: StateProjector[StateT]) -> Context:
|
|
122
|
+
"""Build a context from an application's state projection."""
|
|
123
|
+
|
|
124
|
+
return self.build(projector.project(state))
|
bbagents/core.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Shared value objects and provider-neutral interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Mapping, Protocol, TypeVar
|
|
7
|
+
|
|
8
|
+
from .provenance import Provenance
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class Observation:
|
|
15
|
+
"""A fact returned by a tool or runtime component."""
|
|
16
|
+
|
|
17
|
+
source: str
|
|
18
|
+
content: str
|
|
19
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
provenance: Provenance = field(
|
|
21
|
+
default_factory=lambda: Provenance.system("runtime")
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LanguageModel(Protocol):
|
|
26
|
+
"""Minimal structured-completion interface adapted by the decision runtime."""
|
|
27
|
+
|
|
28
|
+
def complete(
|
|
29
|
+
self,
|
|
30
|
+
system: str,
|
|
31
|
+
user: str,
|
|
32
|
+
*,
|
|
33
|
+
response_format: Any | None = None,
|
|
34
|
+
) -> T | str: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TraceSink(Protocol):
|
|
38
|
+
"""Receives structured execution events."""
|
|
39
|
+
|
|
40
|
+
def record(
|
|
41
|
+
self, event: Any, payload: Mapping[str, Any] | None = None
|
|
42
|
+
) -> None: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NullTrace:
|
|
46
|
+
"""Trace sink for callers that do not need telemetry."""
|
|
47
|
+
|
|
48
|
+
def record(self, event: Any, payload: Mapping[str, Any] | None = None) -> None:
|
|
49
|
+
del event, payload
|
bbagents/decision.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Provider-neutral structured model decisions with validation and tracing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Generic, Mapping, Protocol, TypeVar
|
|
8
|
+
|
|
9
|
+
from .context import Context
|
|
10
|
+
from .core import LanguageModel, NullTrace, TraceSink
|
|
11
|
+
from .observability import TraceEventName
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
SchemaT = TypeVar("SchemaT", covariant=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class DecisionRequirements:
|
|
19
|
+
"""Quality and latency constraints used by an application model selector."""
|
|
20
|
+
|
|
21
|
+
minimum_quality: float = 0.0
|
|
22
|
+
maximum_latency_seconds: float | None = None
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if not 0 <= self.minimum_quality <= 1:
|
|
26
|
+
raise ValueError("minimum_quality must be between 0 and 1")
|
|
27
|
+
if (
|
|
28
|
+
self.maximum_latency_seconds is not None
|
|
29
|
+
and self.maximum_latency_seconds <= 0
|
|
30
|
+
):
|
|
31
|
+
raise ValueError("maximum_latency_seconds must be positive")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DecisionSchema(Protocol[SchemaT]):
|
|
35
|
+
"""Parse and validate provider output without imposing a schema dependency."""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str: ...
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def provider_format(self) -> Any | None: ...
|
|
42
|
+
|
|
43
|
+
def parse(self, value: Any) -> SchemaT: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class PythonTypeSchema(Generic[T]):
|
|
48
|
+
"""Schema adapter for ordinary Python runtime types and dataclasses."""
|
|
49
|
+
|
|
50
|
+
value_type: type[T]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
return self.value_type.__name__
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def provider_format(self) -> type[T] | None:
|
|
58
|
+
return None if self.value_type is str else self.value_type
|
|
59
|
+
|
|
60
|
+
def parse(self, value: Any) -> T:
|
|
61
|
+
if not isinstance(value, self.value_type):
|
|
62
|
+
raise TypeError(
|
|
63
|
+
"Model returned "
|
|
64
|
+
f"{type(value).__name__}, expected {self.value_type.__name__}"
|
|
65
|
+
)
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class DecisionUsage:
|
|
71
|
+
input_tokens: int | None = None
|
|
72
|
+
output_tokens: int | None = None
|
|
73
|
+
total_tokens: int | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class DecisionOutput(Generic[T]):
|
|
78
|
+
"""Provider adapter output with optional portable execution metadata."""
|
|
79
|
+
|
|
80
|
+
value: T
|
|
81
|
+
model_id: str | None = None
|
|
82
|
+
usage: DecisionUsage | None = None
|
|
83
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class DecisionRequest(Generic[T]):
|
|
88
|
+
"""A bounded request with an explicit output schema and application validator."""
|
|
89
|
+
|
|
90
|
+
task: str
|
|
91
|
+
system: str
|
|
92
|
+
context: Context
|
|
93
|
+
schema: DecisionSchema[T] | type[T]
|
|
94
|
+
requirements: DecisionRequirements = field(default_factory=DecisionRequirements)
|
|
95
|
+
validate: Callable[[T], bool] | None = None
|
|
96
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
def output_schema(self) -> DecisionSchema[T]:
|
|
99
|
+
return _coerce_schema(self.schema)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class DecisionResult(Generic[T]):
|
|
104
|
+
value: T
|
|
105
|
+
attempts: int
|
|
106
|
+
duration_seconds: float
|
|
107
|
+
model_id: str | None = None
|
|
108
|
+
usage: DecisionUsage | None = None
|
|
109
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DecisionModel(Protocol):
|
|
113
|
+
"""Produces a proposal; it does not execute application actions."""
|
|
114
|
+
|
|
115
|
+
def decide(self, request: DecisionRequest[T]) -> T | DecisionOutput[T]: ...
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class DecisionFailure(RuntimeError):
|
|
119
|
+
"""Raised after an invalid or failed model decision exhausts its retries."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, attempts: int, last_error: Exception) -> None:
|
|
122
|
+
self.attempts = attempts
|
|
123
|
+
self.last_error = last_error
|
|
124
|
+
super().__init__(f"Decision failed after {attempts} attempts: {last_error}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class LanguageModelDecisionAdapter:
|
|
128
|
+
"""Adapt the package's minimal completion protocol to a decision model."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self, model: LanguageModel, *, model_id: str | None = None
|
|
132
|
+
) -> None:
|
|
133
|
+
self.model = model
|
|
134
|
+
self.model_id = model_id
|
|
135
|
+
|
|
136
|
+
def decide(self, request: DecisionRequest[T]) -> DecisionOutput[Any]:
|
|
137
|
+
value = self.model.complete(
|
|
138
|
+
request.system,
|
|
139
|
+
_render_user_message(request),
|
|
140
|
+
response_format=request.output_schema().provider_format,
|
|
141
|
+
)
|
|
142
|
+
return DecisionOutput(value, model_id=self.model_id)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class DecisionRuntime:
|
|
146
|
+
"""Invoke a model with schema validation, retries, and portable metadata."""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
model: DecisionModel,
|
|
151
|
+
*,
|
|
152
|
+
trace: TraceSink | None = None,
|
|
153
|
+
model_id: str | None = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
self.model = model
|
|
156
|
+
self.trace = trace or NullTrace()
|
|
157
|
+
self.model_id = model_id
|
|
158
|
+
|
|
159
|
+
def decide(
|
|
160
|
+
self, request: DecisionRequest[T], *, retries: int = 0
|
|
161
|
+
) -> DecisionResult[T]:
|
|
162
|
+
started = time.monotonic()
|
|
163
|
+
schema = request.output_schema()
|
|
164
|
+
self.trace.record(
|
|
165
|
+
TraceEventName.DECISION_STARTED,
|
|
166
|
+
{
|
|
167
|
+
"task": request.task,
|
|
168
|
+
"schema": schema.name,
|
|
169
|
+
"context_item_count": len(request.context.items),
|
|
170
|
+
"context_omitted_items": request.context.omitted_items,
|
|
171
|
+
**dict(request.metadata),
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
error: Exception | None = None
|
|
175
|
+
for attempt in range(1, retries + 2):
|
|
176
|
+
try:
|
|
177
|
+
output = _coerce_output(self.model.decide(request))
|
|
178
|
+
value = schema.parse(output.value)
|
|
179
|
+
if request.validate is not None and not request.validate(value):
|
|
180
|
+
raise ValueError("Decision failed application validation")
|
|
181
|
+
result = DecisionResult(
|
|
182
|
+
value,
|
|
183
|
+
attempt,
|
|
184
|
+
time.monotonic() - started,
|
|
185
|
+
model_id=output.model_id or self.model_id,
|
|
186
|
+
usage=output.usage,
|
|
187
|
+
metadata=output.metadata,
|
|
188
|
+
)
|
|
189
|
+
self.trace.record(
|
|
190
|
+
TraceEventName.DECISION_COMPLETED,
|
|
191
|
+
{
|
|
192
|
+
"task": request.task,
|
|
193
|
+
"attempts": attempt,
|
|
194
|
+
"model_id": result.model_id,
|
|
195
|
+
**dict(request.metadata),
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
return result
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
error = exc
|
|
201
|
+
self.trace.record(
|
|
202
|
+
TraceEventName.DECISION_FAILED,
|
|
203
|
+
{
|
|
204
|
+
"task": request.task,
|
|
205
|
+
"attempt": attempt,
|
|
206
|
+
"error": str(exc),
|
|
207
|
+
**dict(request.metadata),
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
assert error is not None
|
|
211
|
+
raise DecisionFailure(retries + 1, error) from error
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _coerce_schema(schema: DecisionSchema[T] | type[T]) -> DecisionSchema[T]:
|
|
215
|
+
if isinstance(schema, type):
|
|
216
|
+
return PythonTypeSchema(schema)
|
|
217
|
+
return schema
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _coerce_output(value: T | DecisionOutput[T]) -> DecisionOutput[T]:
|
|
221
|
+
return value if isinstance(value, DecisionOutput) else DecisionOutput(value)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _render_user_message(request: DecisionRequest[Any]) -> str:
|
|
225
|
+
return f"Task:\n{request.task}\n\nContext:\n{request.context.content}"
|
bbagents/evaluation.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Portable evaluation evidence, graders, and repeated-trial reporting.
|
|
2
|
+
|
|
3
|
+
Applications run trials in isolated environments and provide actual outcome
|
|
4
|
+
snapshots. This module does not execute agents or infer state from their answers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from math import isfinite
|
|
9
|
+
from typing import Any, Generic, Mapping, Protocol, Sequence, TypeVar
|
|
10
|
+
|
|
11
|
+
from .observability import TraceEvent
|
|
12
|
+
|
|
13
|
+
OutcomeT = TypeVar("OutcomeT")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class TrialEvidence(Generic[OutcomeT]):
|
|
18
|
+
case_id: str
|
|
19
|
+
trial_id: str
|
|
20
|
+
response: str
|
|
21
|
+
transcript: tuple[TraceEvent, ...]
|
|
22
|
+
outcome: OutcomeT
|
|
23
|
+
configuration: Mapping[str, Any]
|
|
24
|
+
duration_seconds: float
|
|
25
|
+
cost: float | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not isfinite(self.duration_seconds) or self.duration_seconds < 0:
|
|
29
|
+
raise ValueError("duration_seconds must be finite and nonnegative")
|
|
30
|
+
if self.cost is not None and (not isfinite(self.cost) or self.cost < 0):
|
|
31
|
+
raise ValueError("cost must be finite and nonnegative")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Grade:
|
|
36
|
+
"""None means insufficient evidence, distinct from passing or failing."""
|
|
37
|
+
|
|
38
|
+
passed: bool | None
|
|
39
|
+
reason: str
|
|
40
|
+
score: float | None = None
|
|
41
|
+
evidence_refs: tuple[str, ...] = ()
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if self.score is not None and (
|
|
45
|
+
not isfinite(self.score) or not 0 <= self.score <= 1
|
|
46
|
+
):
|
|
47
|
+
raise ValueError("score must be finite and between 0 and 1")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Grader(Protocol[OutcomeT]):
|
|
51
|
+
def grade(self, trial: TrialEvidence[OutcomeT]) -> Grade: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class TrialGrades:
|
|
56
|
+
case_id: str
|
|
57
|
+
trial_id: str
|
|
58
|
+
grades: Mapping[str, Grade] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def passed(self) -> bool | None:
|
|
62
|
+
if any(grade.passed is False for grade in self.grades.values()):
|
|
63
|
+
return False
|
|
64
|
+
if not self.grades or any(
|
|
65
|
+
grade.passed is None for grade in self.grades.values()
|
|
66
|
+
):
|
|
67
|
+
return None
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def grade_trial(
|
|
72
|
+
trial: TrialEvidence[OutcomeT], graders: Mapping[str, Grader[OutcomeT]]
|
|
73
|
+
) -> TrialGrades:
|
|
74
|
+
return TrialGrades(
|
|
75
|
+
trial.case_id,
|
|
76
|
+
trial.trial_id,
|
|
77
|
+
{name: grader.grade(trial) for name, grader in graders.items()},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class TrialReport:
|
|
83
|
+
trials: int
|
|
84
|
+
passed: int
|
|
85
|
+
failed: int
|
|
86
|
+
insufficient: int
|
|
87
|
+
empirical_success_rate: float | None
|
|
88
|
+
cases_with_any_success: int
|
|
89
|
+
cases_with_all_successes: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def report_trials(results: Sequence[TrialGrades]) -> TrialReport:
|
|
93
|
+
"""Report observed rates only, without assuming independent repetitions."""
|
|
94
|
+
|
|
95
|
+
by_case: dict[str, list[bool | None]] = {}
|
|
96
|
+
seen: set[tuple[str, str]] = set()
|
|
97
|
+
for result in results:
|
|
98
|
+
identity = (result.case_id, result.trial_id)
|
|
99
|
+
if identity in seen:
|
|
100
|
+
raise ValueError(f"Duplicate trial: {identity}")
|
|
101
|
+
seen.add(identity)
|
|
102
|
+
by_case.setdefault(result.case_id, []).append(result.passed)
|
|
103
|
+
statuses = [result.passed for result in results]
|
|
104
|
+
passed = sum(status is True for status in statuses)
|
|
105
|
+
failed = sum(status is False for status in statuses)
|
|
106
|
+
return TrialReport(
|
|
107
|
+
len(results),
|
|
108
|
+
passed,
|
|
109
|
+
failed,
|
|
110
|
+
sum(status is None for status in statuses),
|
|
111
|
+
passed / len(results) if results else None,
|
|
112
|
+
sum(any(status is True for status in case) for case in by_case.values()),
|
|
113
|
+
sum(all(status is True for status in case) for case in by_case.values()),
|
|
114
|
+
)
|