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 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
+ )