agent-learning 0.4.1__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.
- agent_learning/__init__.py +160 -0
- agent_learning/_version.py +3 -0
- agent_learning/capture.py +271 -0
- agent_learning/classifiers/__init__.py +37 -0
- agent_learning/classifiers/base.py +185 -0
- agent_learning/classifiers/router.py +236 -0
- agent_learning/classifiers/scorers/__init__.py +34 -0
- agent_learning/classifiers/scorers/_base.py +162 -0
- agent_learning/classifiers/scorers/adherence.py +26 -0
- agent_learning/classifiers/scorers/completion.py +26 -0
- agent_learning/classifiers/scorers/intent.py +25 -0
- agent_learning/cli.py +386 -0
- agent_learning/config.py +524 -0
- agent_learning/learners/__init__.py +6 -0
- agent_learning/learners/base.py +38 -0
- agent_learning/learners/reinforce.py +153 -0
- agent_learning/metrics/__init__.py +22 -0
- agent_learning/metrics/base.py +233 -0
- agent_learning/metrics/intent_resolution.py +50 -0
- agent_learning/metrics/registry.py +42 -0
- agent_learning/metrics/task_adherence.py +42 -0
- agent_learning/metrics/task_completion.py +54 -0
- agent_learning/policy/__init__.py +7 -0
- agent_learning/policy/base.py +59 -0
- agent_learning/policy/contextual_softmax.py +243 -0
- agent_learning/policy/softmax_bandit.py +157 -0
- agent_learning/py.typed +1 -0
- agent_learning/rewards/__init__.py +6 -0
- agent_learning/rewards/shaping.py +121 -0
- agent_learning/rewards/writer.py +130 -0
- agent_learning/scorers/__init__.py +187 -0
- agent_learning/scorers/base.py +49 -0
- agent_learning/scorers/llm/__init__.py +19 -0
- agent_learning/scorers/llm/_base.py +126 -0
- agent_learning/scorers/llm/adherence.py +21 -0
- agent_learning/scorers/llm/completion.py +21 -0
- agent_learning/scorers/llm/intent.py +21 -0
- agent_learning/scorers/nlp/__init__.py +16 -0
- agent_learning/scorers/nlp/_base.py +99 -0
- agent_learning/scorers/nlp/adherence.py +17 -0
- agent_learning/scorers/nlp/completion.py +17 -0
- agent_learning/scorers/nlp/intent.py +17 -0
- agent_learning/scorers/nlp_text/__init__.py +26 -0
- agent_learning/scorers/nlp_text/_base.py +234 -0
- agent_learning/scorers/nlp_text/adherence.py +94 -0
- agent_learning/scorers/nlp_text/completion.py +91 -0
- agent_learning/scorers/nlp_text/intent.py +59 -0
- agent_learning/scorers/slm/__init__.py +25 -0
- agent_learning/scorers/slm/_base.py +292 -0
- agent_learning/scorers/slm/adherence.py +98 -0
- agent_learning/scorers/slm/completion.py +111 -0
- agent_learning/scorers/slm/intent.py +80 -0
- agent_learning/scorers/stdlib/__init__.py +38 -0
- agent_learning/scorers/stdlib/_text.py +87 -0
- agent_learning/scorers/stdlib/adherence.py +157 -0
- agent_learning/scorers/stdlib/completion.py +117 -0
- agent_learning/scorers/stdlib/intent.py +182 -0
- agent_learning/storage/__init__.py +14 -0
- agent_learning/storage/base.py +156 -0
- agent_learning/storage/cosmos.py +506 -0
- agent_learning/storage/local.py +353 -0
- agent_learning/storage/memory.py +209 -0
- agent_learning/training/__init__.py +5 -0
- agent_learning/training/runner.py +172 -0
- agent_learning/types.py +507 -0
- agent_learning-0.4.1.dist-info/METADATA +82 -0
- agent_learning-0.4.1.dist-info/RECORD +71 -0
- agent_learning-0.4.1.dist-info/WHEEL +5 -0
- agent_learning-0.4.1.dist-info/entry_points.txt +2 -0
- agent_learning-0.4.1.dist-info/licenses/LICENSE +21 -0
- agent_learning-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Native reinforcement learning SDK for AI agents.
|
|
2
|
+
|
|
3
|
+
Replaces the agent-lightning LLM fine-tuning loop with a fully
|
|
4
|
+
native, in-process learner. The SDK is organised into five layers:
|
|
5
|
+
|
|
6
|
+
- ``agent_learning.types`` - durable record types (``Episode``,
|
|
7
|
+
``Reward``, ``PolicySnapshot``, ...).
|
|
8
|
+
- ``agent_learning.storage`` - pluggable persistence (Cosmos DB,
|
|
9
|
+
local file system, and in-memory).
|
|
10
|
+
- ``agent_learning.metrics`` - score-based metrics that wrap the
|
|
11
|
+
Azure AI Evaluation evaluators for Intent Resolution, Task
|
|
12
|
+
Adherence, and Task Completion.
|
|
13
|
+
- ``agent_learning.rewards`` - reward shaping + persistence.
|
|
14
|
+
- ``agent_learning.policy`` - discrete softmax bandit policy.
|
|
15
|
+
- ``agent_learning.learners`` - REINFORCE-with-baseline learner.
|
|
16
|
+
- ``agent_learning.training`` - end-to-end :class:`LearningRunner`.
|
|
17
|
+
|
|
18
|
+
Quick start::
|
|
19
|
+
|
|
20
|
+
from agent_learning import (
|
|
21
|
+
Action,
|
|
22
|
+
EpisodeCapture,
|
|
23
|
+
LearningRunner,
|
|
24
|
+
SoftmaxPolicy,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
actions = [Action(id="prompt_A"), Action(id="prompt_B")]
|
|
28
|
+
policy = SoftmaxPolicy.from_actions(actions, agent_id="dq", task_id="sales-summary")
|
|
29
|
+
|
|
30
|
+
# Capture
|
|
31
|
+
capture = EpisodeCapture()
|
|
32
|
+
decision = policy.choose()
|
|
33
|
+
ctx = capture.start(
|
|
34
|
+
"Tell me my Q3 sales summary",
|
|
35
|
+
task_id="sales-summary",
|
|
36
|
+
intent_summary="Summarize Q3 sales",
|
|
37
|
+
action_type="chat",
|
|
38
|
+
action_name=decision.action.id,
|
|
39
|
+
expected_outcome="An accurate Q3 sales summary",
|
|
40
|
+
policy_id=policy.snapshot().id,
|
|
41
|
+
policy_version=policy.snapshot().version,
|
|
42
|
+
action_id=decision.action.id,
|
|
43
|
+
action_logprob=decision.logprob,
|
|
44
|
+
)
|
|
45
|
+
# ... agent runs, records tool calls, produces output ...
|
|
46
|
+
episode = capture.end(
|
|
47
|
+
ctx,
|
|
48
|
+
assistant_output="...",
|
|
49
|
+
execution_status="completed",
|
|
50
|
+
result_summary="Returned the sales summary",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Train
|
|
54
|
+
runner = LearningRunner(policy=policy)
|
|
55
|
+
run = runner.run_offline_batch("dq", task_id="sales-summary", episode_limit=200)
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from ._version import __version__
|
|
59
|
+
from .capture import CaptureContext, EpisodeCapture, get_capture
|
|
60
|
+
from .classifiers import (
|
|
61
|
+
AdherenceScorer,
|
|
62
|
+
Classifier,
|
|
63
|
+
ClassifierResult,
|
|
64
|
+
CompletionScorer,
|
|
65
|
+
IntentScorer,
|
|
66
|
+
RouterClassifier,
|
|
67
|
+
)
|
|
68
|
+
from .config import (
|
|
69
|
+
CaptureConfig,
|
|
70
|
+
CosmosConfig,
|
|
71
|
+
ScoreConfig,
|
|
72
|
+
LearnerConfig,
|
|
73
|
+
ShapingConfig,
|
|
74
|
+
)
|
|
75
|
+
from .learners import Learner, LearnerResult, ReinforceLearner
|
|
76
|
+
from .metrics import (
|
|
77
|
+
IntentResolutionMetric,
|
|
78
|
+
MetricEvaluator,
|
|
79
|
+
MetricRequest,
|
|
80
|
+
TaskAdherenceMetric,
|
|
81
|
+
TaskCompletionMetric,
|
|
82
|
+
default_metrics,
|
|
83
|
+
evaluate_all,
|
|
84
|
+
)
|
|
85
|
+
from .policy import ContextualSoftmaxPolicy, Policy, SoftmaxPolicy
|
|
86
|
+
from .rewards import RewardShaper, RewardWriter, shape_episode_reward
|
|
87
|
+
from .storage import (
|
|
88
|
+
CosmosStore,
|
|
89
|
+
InMemoryStore,
|
|
90
|
+
LearningStore,
|
|
91
|
+
LocalFileStore,
|
|
92
|
+
get_default_store,
|
|
93
|
+
)
|
|
94
|
+
from .training import LearningRunner
|
|
95
|
+
from .types import (
|
|
96
|
+
Action,
|
|
97
|
+
AgentSummary,
|
|
98
|
+
AgentTaskSummary,
|
|
99
|
+
Episode,
|
|
100
|
+
MetricName,
|
|
101
|
+
MetricResult,
|
|
102
|
+
PolicySnapshot,
|
|
103
|
+
Reward,
|
|
104
|
+
RewardSource,
|
|
105
|
+
ToolCall,
|
|
106
|
+
TrainingRun,
|
|
107
|
+
TrainingStatus,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"Action",
|
|
112
|
+
"AdherenceScorer",
|
|
113
|
+
"AgentSummary",
|
|
114
|
+
"AgentTaskSummary",
|
|
115
|
+
"CaptureConfig",
|
|
116
|
+
"CaptureContext",
|
|
117
|
+
"Classifier",
|
|
118
|
+
"ClassifierResult",
|
|
119
|
+
"CompletionScorer",
|
|
120
|
+
"ContextualSoftmaxPolicy",
|
|
121
|
+
"CosmosConfig",
|
|
122
|
+
"CosmosStore",
|
|
123
|
+
"Episode",
|
|
124
|
+
"EpisodeCapture",
|
|
125
|
+
"InMemoryStore",
|
|
126
|
+
"IntentScorer",
|
|
127
|
+
"IntentResolutionMetric",
|
|
128
|
+
"ScoreConfig",
|
|
129
|
+
"Learner",
|
|
130
|
+
"LearnerConfig",
|
|
131
|
+
"LearnerResult",
|
|
132
|
+
"LearningRunner",
|
|
133
|
+
"LearningStore",
|
|
134
|
+
"LocalFileStore",
|
|
135
|
+
"MetricEvaluator",
|
|
136
|
+
"MetricName",
|
|
137
|
+
"MetricRequest",
|
|
138
|
+
"MetricResult",
|
|
139
|
+
"Policy",
|
|
140
|
+
"PolicySnapshot",
|
|
141
|
+
"ReinforceLearner",
|
|
142
|
+
"Reward",
|
|
143
|
+
"RewardShaper",
|
|
144
|
+
"RewardSource",
|
|
145
|
+
"RewardWriter",
|
|
146
|
+
"RouterClassifier",
|
|
147
|
+
"ShapingConfig",
|
|
148
|
+
"SoftmaxPolicy",
|
|
149
|
+
"TaskAdherenceMetric",
|
|
150
|
+
"TaskCompletionMetric",
|
|
151
|
+
"ToolCall",
|
|
152
|
+
"TrainingRun",
|
|
153
|
+
"TrainingStatus",
|
|
154
|
+
"__version__",
|
|
155
|
+
"default_metrics",
|
|
156
|
+
"evaluate_all",
|
|
157
|
+
"get_capture",
|
|
158
|
+
"get_default_store",
|
|
159
|
+
"shape_episode_reward",
|
|
160
|
+
]
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Episode capture helpers.
|
|
2
|
+
|
|
3
|
+
The capture hook is the orchestrator-facing entry point. It wraps a
|
|
4
|
+
single agent turn, lets the caller record tool calls during the
|
|
5
|
+
turn, and persists a complete :class:`Episode` at the end.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from .config import CaptureConfig
|
|
18
|
+
from .storage.base import LearningStore
|
|
19
|
+
from .storage.cosmos import get_default_store
|
|
20
|
+
from .types import Episode, ToolCall
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Conservative redaction patterns (mirrors lightning's set)
|
|
26
|
+
_REDACT_PATTERNS = [
|
|
27
|
+
(re.compile(r"(bearer\s+)[a-zA-Z0-9\-_\.]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
28
|
+
(re.compile(r"(api[_-]?key[\"\s:=]+)[a-zA-Z0-9\-_]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
29
|
+
(re.compile(r"(password[\"\s:=]+)[^\s\"]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
30
|
+
(re.compile(r"(secret[\"\s:=]+)[^\s\"]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
31
|
+
(re.compile(r"(token[\"\s:=]+)[a-zA-Z0-9\-_\.]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
32
|
+
(re.compile(r"(connection[_-]?string[\"\s:=]+)[^\s\"]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
33
|
+
(re.compile(r"(AccountKey=)[^;]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
34
|
+
(re.compile(r"(SharedAccessSignature=)[^;]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def redact(text: Optional[str]) -> Optional[str]:
|
|
39
|
+
"""Strip well-known secret patterns from a string."""
|
|
40
|
+
if not text:
|
|
41
|
+
return text
|
|
42
|
+
out = text
|
|
43
|
+
for pattern, replacement in _REDACT_PATTERNS:
|
|
44
|
+
out = pattern.sub(replacement, out)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class CaptureContext:
|
|
50
|
+
"""In-flight state for a single capture."""
|
|
51
|
+
|
|
52
|
+
episode_id: str
|
|
53
|
+
agent_id: str
|
|
54
|
+
start_time: float
|
|
55
|
+
user_input: str
|
|
56
|
+
system_message: Optional[str] = None
|
|
57
|
+
conversation_history: List[Dict[str, str]] = field(default_factory=list)
|
|
58
|
+
tool_calls: List[ToolCall] = field(default_factory=list)
|
|
59
|
+
policy_id: Optional[str] = None
|
|
60
|
+
policy_version: Optional[int] = None
|
|
61
|
+
action_id: Optional[str] = None
|
|
62
|
+
action_logprob: Optional[float] = None
|
|
63
|
+
context_features: Dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
model_deployment: Optional[str] = None
|
|
65
|
+
correlation_id: Optional[str] = None
|
|
66
|
+
session_id: Optional[str] = None
|
|
67
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
68
|
+
agent_name: Optional[str] = None
|
|
69
|
+
task_id: str = "default"
|
|
70
|
+
task_name: Optional[str] = None
|
|
71
|
+
intent_summary: Optional[str] = None
|
|
72
|
+
action_type: Optional[str] = None
|
|
73
|
+
action_name: Optional[str] = None
|
|
74
|
+
target: Optional[str] = None
|
|
75
|
+
input_summary: Optional[str] = None
|
|
76
|
+
expected_outcome: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EpisodeCapture:
|
|
80
|
+
"""Capture one episode at a time and persist it via the store."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
config: Optional[CaptureConfig] = None,
|
|
85
|
+
store: Optional[LearningStore] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self._config = config or CaptureConfig()
|
|
88
|
+
self._store = store
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Plumbing
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def store(self) -> LearningStore:
|
|
96
|
+
if self._store is None:
|
|
97
|
+
self._store = get_default_store()
|
|
98
|
+
return self._store
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def config(self) -> CaptureConfig:
|
|
102
|
+
return self._config
|
|
103
|
+
|
|
104
|
+
def is_enabled(self) -> bool:
|
|
105
|
+
return self._config.enabled
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Lifecycle
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def start(
|
|
112
|
+
self,
|
|
113
|
+
user_input: str,
|
|
114
|
+
*,
|
|
115
|
+
task_id: Optional[str] = None,
|
|
116
|
+
task_name: Optional[str] = None,
|
|
117
|
+
intent_summary: Optional[str] = None,
|
|
118
|
+
action_type: Optional[str] = None,
|
|
119
|
+
action_name: Optional[str] = None,
|
|
120
|
+
target: Optional[str] = None,
|
|
121
|
+
input_summary: Optional[str] = None,
|
|
122
|
+
expected_outcome: Optional[str] = None,
|
|
123
|
+
system_message: Optional[str] = None,
|
|
124
|
+
conversation_history: Optional[List[Dict[str, str]]] = None,
|
|
125
|
+
model_deployment: Optional[str] = None,
|
|
126
|
+
correlation_id: Optional[str] = None,
|
|
127
|
+
session_id: Optional[str] = None,
|
|
128
|
+
policy_id: Optional[str] = None,
|
|
129
|
+
policy_version: Optional[int] = None,
|
|
130
|
+
action_id: Optional[str] = None,
|
|
131
|
+
action_logprob: Optional[float] = None,
|
|
132
|
+
context_features: Optional[Dict[str, Any]] = None,
|
|
133
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
134
|
+
) -> CaptureContext:
|
|
135
|
+
return CaptureContext(
|
|
136
|
+
episode_id=str(uuid.uuid4()),
|
|
137
|
+
agent_id=self._config.agent_id,
|
|
138
|
+
agent_name=self._config.agent_name,
|
|
139
|
+
task_id=task_id or self._config.task_id,
|
|
140
|
+
task_name=task_name or self._config.task_name,
|
|
141
|
+
start_time=time.time(),
|
|
142
|
+
user_input=user_input,
|
|
143
|
+
intent_summary=intent_summary,
|
|
144
|
+
action_type=action_type,
|
|
145
|
+
action_name=action_name,
|
|
146
|
+
target=target,
|
|
147
|
+
input_summary=input_summary,
|
|
148
|
+
expected_outcome=expected_outcome,
|
|
149
|
+
system_message=system_message,
|
|
150
|
+
conversation_history=conversation_history or [],
|
|
151
|
+
policy_id=policy_id,
|
|
152
|
+
policy_version=policy_version,
|
|
153
|
+
action_id=action_id,
|
|
154
|
+
action_logprob=action_logprob,
|
|
155
|
+
context_features=context_features or {},
|
|
156
|
+
model_deployment=model_deployment,
|
|
157
|
+
correlation_id=correlation_id,
|
|
158
|
+
session_id=session_id,
|
|
159
|
+
metadata=metadata or {},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def record_tool_call(
|
|
163
|
+
self,
|
|
164
|
+
ctx: CaptureContext,
|
|
165
|
+
name: str,
|
|
166
|
+
arguments: Dict[str, Any],
|
|
167
|
+
result: Optional[str] = None,
|
|
168
|
+
*,
|
|
169
|
+
duration_ms: Optional[int] = None,
|
|
170
|
+
error: Optional[str] = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
if not self.is_enabled():
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
safe_args = arguments
|
|
176
|
+
if self._config.redact_secrets:
|
|
177
|
+
safe_args = {
|
|
178
|
+
k: redact(v) if isinstance(v, str) else v for k, v in arguments.items()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
safe_result = result
|
|
182
|
+
if safe_result and self._config.redact_secrets:
|
|
183
|
+
safe_result = redact(safe_result)
|
|
184
|
+
if safe_result and len(safe_result) > self._config.max_output_length:
|
|
185
|
+
safe_result = safe_result[: self._config.max_output_length] + "...[TRUNCATED]"
|
|
186
|
+
|
|
187
|
+
ctx.tool_calls.append(
|
|
188
|
+
ToolCall(
|
|
189
|
+
name=name,
|
|
190
|
+
arguments=safe_args or {},
|
|
191
|
+
result=safe_result,
|
|
192
|
+
duration_ms=duration_ms,
|
|
193
|
+
error=error,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def end(
|
|
198
|
+
self,
|
|
199
|
+
ctx: CaptureContext,
|
|
200
|
+
assistant_output: str,
|
|
201
|
+
*,
|
|
202
|
+
execution_status: Optional[str] = None,
|
|
203
|
+
result_summary: Optional[str] = None,
|
|
204
|
+
token_usage: Optional[Dict[str, int]] = None,
|
|
205
|
+
extra_metadata: Optional[Dict[str, Any]] = None,
|
|
206
|
+
) -> Optional[Episode]:
|
|
207
|
+
if not self.is_enabled():
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
latency_ms = int((time.time() - ctx.start_time) * 1000)
|
|
211
|
+
output = assistant_output
|
|
212
|
+
if output and self._config.redact_secrets:
|
|
213
|
+
output = redact(output) or output
|
|
214
|
+
|
|
215
|
+
metadata = dict(ctx.metadata)
|
|
216
|
+
if extra_metadata:
|
|
217
|
+
metadata.update(extra_metadata)
|
|
218
|
+
|
|
219
|
+
episode = Episode(
|
|
220
|
+
id=ctx.episode_id,
|
|
221
|
+
agent_id=ctx.agent_id,
|
|
222
|
+
agent_name=ctx.agent_name,
|
|
223
|
+
task_id=ctx.task_id,
|
|
224
|
+
task_name=ctx.task_name,
|
|
225
|
+
user_input=ctx.user_input,
|
|
226
|
+
assistant_output=output or "",
|
|
227
|
+
intent_summary=ctx.intent_summary,
|
|
228
|
+
action_type=ctx.action_type,
|
|
229
|
+
action_name=ctx.action_name,
|
|
230
|
+
target=ctx.target,
|
|
231
|
+
input_summary=ctx.input_summary,
|
|
232
|
+
expected_outcome=ctx.expected_outcome,
|
|
233
|
+
execution_status=execution_status,
|
|
234
|
+
result_summary=result_summary,
|
|
235
|
+
system_message=ctx.system_message,
|
|
236
|
+
conversation_history=ctx.conversation_history,
|
|
237
|
+
tool_calls=ctx.tool_calls,
|
|
238
|
+
policy_id=ctx.policy_id,
|
|
239
|
+
policy_version=ctx.policy_version,
|
|
240
|
+
action_id=ctx.action_id,
|
|
241
|
+
action_logprob=ctx.action_logprob,
|
|
242
|
+
context_features=ctx.context_features,
|
|
243
|
+
model_deployment=ctx.model_deployment,
|
|
244
|
+
correlation_id=ctx.correlation_id,
|
|
245
|
+
session_id=ctx.session_id,
|
|
246
|
+
request_latency_ms=latency_ms,
|
|
247
|
+
token_usage=token_usage,
|
|
248
|
+
metadata=metadata,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
self.store.store_episode(episode)
|
|
253
|
+
logger.info("Captured episode %s for agent %s", episode.id, episode.agent_id)
|
|
254
|
+
except Exception as exc: # pragma: no cover
|
|
255
|
+
logger.warning("Failed to persist episode %s: %s", episode.id, exc)
|
|
256
|
+
|
|
257
|
+
return episode
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
_default_capture: Optional[EpisodeCapture] = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_capture() -> EpisodeCapture:
|
|
264
|
+
"""Return a process-wide singleton capture hook."""
|
|
265
|
+
global _default_capture
|
|
266
|
+
if _default_capture is None:
|
|
267
|
+
_default_capture = EpisodeCapture()
|
|
268
|
+
return _default_capture
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
__all__ = ["CaptureContext", "EpisodeCapture", "get_capture", "redact"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Pure-Python classifiers for RL reward shaping pipelines.
|
|
2
|
+
|
|
3
|
+
The reward shaper in :mod:`agent_learning.rewards.shaping` combines
|
|
4
|
+
multiple classifier outputs into a scalar reward. This sub-package
|
|
5
|
+
ships the classifier implementations themselves:
|
|
6
|
+
|
|
7
|
+
- :class:`agent_learning.classifiers.router.RouterClassifier` —
|
|
8
|
+
multi-class classifier over a context vector. Used to route an
|
|
9
|
+
incoming request to one of a known set of class ids before the
|
|
10
|
+
policy chooses an action.
|
|
11
|
+
- :class:`agent_learning.classifiers.scorers.intent.IntentScorer`,
|
|
12
|
+
:class:`~agent_learning.classifiers.scorers.adherence.AdherenceScorer`,
|
|
13
|
+
and
|
|
14
|
+
:class:`~agent_learning.classifiers.scorers.completion.CompletionScorer`
|
|
15
|
+
— binary ``{pass, fail}`` classifiers over a ``(context, action)``
|
|
16
|
+
pair. Drop-in replacements for LLM-based scorers with the same
|
|
17
|
+
call surface.
|
|
18
|
+
|
|
19
|
+
All classes here are deterministic, dependency-free (stdlib only),
|
|
20
|
+
and JSON-serialisable via ``to_snapshot`` / ``from_snapshot``. The
|
|
21
|
+
fit loop is a plain mini-batch logistic regression so the
|
|
22
|
+
classifiers are reproducible across environments and easy to ship
|
|
23
|
+
through the same image as the rest of the SDK.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .base import Classifier, ClassifierResult
|
|
27
|
+
from .router import RouterClassifier
|
|
28
|
+
from .scorers import AdherenceScorer, CompletionScorer, IntentScorer
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AdherenceScorer",
|
|
32
|
+
"Classifier",
|
|
33
|
+
"ClassifierResult",
|
|
34
|
+
"CompletionScorer",
|
|
35
|
+
"IntentScorer",
|
|
36
|
+
"RouterClassifier",
|
|
37
|
+
]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Base types shared by the router and the three scorers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Dict, List, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ClassifierResult:
|
|
12
|
+
"""Output of any classifier in this package.
|
|
13
|
+
|
|
14
|
+
``label`` is the predicted class id (a ``str`` for the router,
|
|
15
|
+
``"pass"`` / ``"fail"`` for the scorers). ``confidence`` is the
|
|
16
|
+
model's calibrated probability for that label, in ``[0, 1]``.
|
|
17
|
+
``features`` exposes the per-feature contribution so callers can
|
|
18
|
+
persist a per-decision explainability trace.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
label: str
|
|
22
|
+
confidence: float
|
|
23
|
+
features: Dict[str, float] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Classifier(Protocol):
|
|
27
|
+
"""Common surface across the router and the three scorers.
|
|
28
|
+
|
|
29
|
+
Implementations are deterministic — given the same fit input
|
|
30
|
+
they produce the same weights, and given the same predict
|
|
31
|
+
input they produce the same :class:`ClassifierResult`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def fit(self, training_rows: List[dict]) -> "Classifier":
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def predict(self, features: Dict[str, float]) -> ClassifierResult:
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Pure-Python logistic regression primitives shared by the four classifiers
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sigmoid(x: float) -> float:
|
|
47
|
+
"""Numerically stable logistic function."""
|
|
48
|
+
if x >= 0.0:
|
|
49
|
+
z = math.exp(-x)
|
|
50
|
+
return 1.0 / (1.0 + z)
|
|
51
|
+
z = math.exp(x)
|
|
52
|
+
return z / (1.0 + z)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _softmax(logits: List[float]) -> List[float]:
|
|
56
|
+
"""Numerically stable softmax over ``logits``."""
|
|
57
|
+
if not logits:
|
|
58
|
+
return []
|
|
59
|
+
m = max(logits)
|
|
60
|
+
exps = [math.exp(v - m) for v in logits]
|
|
61
|
+
s = sum(exps)
|
|
62
|
+
if s == 0.0:
|
|
63
|
+
n = len(exps)
|
|
64
|
+
return [1.0 / n] * n
|
|
65
|
+
return [v / s for v in exps]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _dot(weights: List[float], features: List[float]) -> float:
|
|
69
|
+
"""Inner product of two equal-length vectors."""
|
|
70
|
+
total = 0.0
|
|
71
|
+
for w, x in zip(weights, features):
|
|
72
|
+
total += w * x
|
|
73
|
+
return total
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fit_binary_logreg(
|
|
77
|
+
rows: List[dict],
|
|
78
|
+
feature_dim: int,
|
|
79
|
+
*,
|
|
80
|
+
epochs: int = 20,
|
|
81
|
+
learning_rate: float = 0.10,
|
|
82
|
+
weight_decay: float = 1e-4,
|
|
83
|
+
batch_size: int = 256,
|
|
84
|
+
seed: int = 42,
|
|
85
|
+
) -> List[float]:
|
|
86
|
+
"""Fit a binary logistic regression with closed-form gradient.
|
|
87
|
+
|
|
88
|
+
Each row in ``rows`` must carry:
|
|
89
|
+
|
|
90
|
+
- ``"features"``: list[float] of length ``feature_dim + 1`` (the
|
|
91
|
+
last entry is the bias term — caller appends 1.0).
|
|
92
|
+
- ``"label"``: ``0`` or ``1``.
|
|
93
|
+
|
|
94
|
+
Returns a weight vector of length ``feature_dim + 1``. Uses
|
|
95
|
+
standard mini-batch gradient descent with an L2 penalty; pure
|
|
96
|
+
Python, no numpy.
|
|
97
|
+
"""
|
|
98
|
+
import random as _random
|
|
99
|
+
|
|
100
|
+
rng = _random.Random(seed)
|
|
101
|
+
n = feature_dim + 1 # bias column appended by caller
|
|
102
|
+
weights = [0.0] * n
|
|
103
|
+
indices = list(range(len(rows)))
|
|
104
|
+
if not indices:
|
|
105
|
+
return weights
|
|
106
|
+
|
|
107
|
+
for _epoch in range(epochs):
|
|
108
|
+
rng.shuffle(indices)
|
|
109
|
+
for start in range(0, len(indices), batch_size):
|
|
110
|
+
batch = indices[start:start + batch_size]
|
|
111
|
+
grad = [0.0] * n
|
|
112
|
+
for i in batch:
|
|
113
|
+
row = rows[i]
|
|
114
|
+
x = row["features"]
|
|
115
|
+
y = float(row["label"])
|
|
116
|
+
z = _dot(weights, x)
|
|
117
|
+
p = _sigmoid(z)
|
|
118
|
+
err = p - y
|
|
119
|
+
for j in range(n):
|
|
120
|
+
grad[j] += err * x[j]
|
|
121
|
+
inv_b = 1.0 / max(len(batch), 1)
|
|
122
|
+
for j in range(n):
|
|
123
|
+
grad[j] = grad[j] * inv_b + weight_decay * weights[j]
|
|
124
|
+
weights[j] -= learning_rate * grad[j]
|
|
125
|
+
return weights
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fit_multinomial_logreg(
|
|
129
|
+
rows: List[dict],
|
|
130
|
+
feature_dim: int,
|
|
131
|
+
num_classes: int,
|
|
132
|
+
*,
|
|
133
|
+
epochs: int = 20,
|
|
134
|
+
learning_rate: float = 0.10,
|
|
135
|
+
weight_decay: float = 1e-4,
|
|
136
|
+
batch_size: int = 256,
|
|
137
|
+
seed: int = 42,
|
|
138
|
+
) -> List[List[float]]:
|
|
139
|
+
"""Fit a multinomial logistic regression (softmax classifier).
|
|
140
|
+
|
|
141
|
+
Each row in ``rows`` must carry:
|
|
142
|
+
|
|
143
|
+
- ``"features"``: list[float] of length ``feature_dim + 1``.
|
|
144
|
+
- ``"label"``: integer in ``[0, num_classes)``.
|
|
145
|
+
|
|
146
|
+
Returns a ``num_classes x (feature_dim + 1)`` weight matrix.
|
|
147
|
+
"""
|
|
148
|
+
import random as _random
|
|
149
|
+
|
|
150
|
+
rng = _random.Random(seed)
|
|
151
|
+
n = feature_dim + 1
|
|
152
|
+
weights: List[List[float]] = [[0.0] * n for _ in range(num_classes)]
|
|
153
|
+
indices = list(range(len(rows)))
|
|
154
|
+
if not indices:
|
|
155
|
+
return weights
|
|
156
|
+
|
|
157
|
+
for _epoch in range(epochs):
|
|
158
|
+
rng.shuffle(indices)
|
|
159
|
+
for start in range(0, len(indices), batch_size):
|
|
160
|
+
batch = indices[start:start + batch_size]
|
|
161
|
+
grads: List[List[float]] = [[0.0] * n for _ in range(num_classes)]
|
|
162
|
+
for i in batch:
|
|
163
|
+
row = rows[i]
|
|
164
|
+
x = row["features"]
|
|
165
|
+
y = int(row["label"])
|
|
166
|
+
logits = [_dot(weights[k], x) for k in range(num_classes)]
|
|
167
|
+
probs = _softmax(logits)
|
|
168
|
+
for k in range(num_classes):
|
|
169
|
+
err = probs[k] - (1.0 if k == y else 0.0)
|
|
170
|
+
for j in range(n):
|
|
171
|
+
grads[k][j] += err * x[j]
|
|
172
|
+
inv_b = 1.0 / max(len(batch), 1)
|
|
173
|
+
for k in range(num_classes):
|
|
174
|
+
for j in range(n):
|
|
175
|
+
grads[k][j] = grads[k][j] * inv_b + weight_decay * weights[k][j]
|
|
176
|
+
weights[k][j] -= learning_rate * grads[k][j]
|
|
177
|
+
return weights
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
__all__ = [
|
|
181
|
+
"Classifier",
|
|
182
|
+
"ClassifierResult",
|
|
183
|
+
"fit_binary_logreg",
|
|
184
|
+
"fit_multinomial_logreg",
|
|
185
|
+
]
|