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.
Files changed (71) hide show
  1. agent_learning/__init__.py +160 -0
  2. agent_learning/_version.py +3 -0
  3. agent_learning/capture.py +271 -0
  4. agent_learning/classifiers/__init__.py +37 -0
  5. agent_learning/classifiers/base.py +185 -0
  6. agent_learning/classifiers/router.py +236 -0
  7. agent_learning/classifiers/scorers/__init__.py +34 -0
  8. agent_learning/classifiers/scorers/_base.py +162 -0
  9. agent_learning/classifiers/scorers/adherence.py +26 -0
  10. agent_learning/classifiers/scorers/completion.py +26 -0
  11. agent_learning/classifiers/scorers/intent.py +25 -0
  12. agent_learning/cli.py +386 -0
  13. agent_learning/config.py +524 -0
  14. agent_learning/learners/__init__.py +6 -0
  15. agent_learning/learners/base.py +38 -0
  16. agent_learning/learners/reinforce.py +153 -0
  17. agent_learning/metrics/__init__.py +22 -0
  18. agent_learning/metrics/base.py +233 -0
  19. agent_learning/metrics/intent_resolution.py +50 -0
  20. agent_learning/metrics/registry.py +42 -0
  21. agent_learning/metrics/task_adherence.py +42 -0
  22. agent_learning/metrics/task_completion.py +54 -0
  23. agent_learning/policy/__init__.py +7 -0
  24. agent_learning/policy/base.py +59 -0
  25. agent_learning/policy/contextual_softmax.py +243 -0
  26. agent_learning/policy/softmax_bandit.py +157 -0
  27. agent_learning/py.typed +1 -0
  28. agent_learning/rewards/__init__.py +6 -0
  29. agent_learning/rewards/shaping.py +121 -0
  30. agent_learning/rewards/writer.py +130 -0
  31. agent_learning/scorers/__init__.py +187 -0
  32. agent_learning/scorers/base.py +49 -0
  33. agent_learning/scorers/llm/__init__.py +19 -0
  34. agent_learning/scorers/llm/_base.py +126 -0
  35. agent_learning/scorers/llm/adherence.py +21 -0
  36. agent_learning/scorers/llm/completion.py +21 -0
  37. agent_learning/scorers/llm/intent.py +21 -0
  38. agent_learning/scorers/nlp/__init__.py +16 -0
  39. agent_learning/scorers/nlp/_base.py +99 -0
  40. agent_learning/scorers/nlp/adherence.py +17 -0
  41. agent_learning/scorers/nlp/completion.py +17 -0
  42. agent_learning/scorers/nlp/intent.py +17 -0
  43. agent_learning/scorers/nlp_text/__init__.py +26 -0
  44. agent_learning/scorers/nlp_text/_base.py +234 -0
  45. agent_learning/scorers/nlp_text/adherence.py +94 -0
  46. agent_learning/scorers/nlp_text/completion.py +91 -0
  47. agent_learning/scorers/nlp_text/intent.py +59 -0
  48. agent_learning/scorers/slm/__init__.py +25 -0
  49. agent_learning/scorers/slm/_base.py +292 -0
  50. agent_learning/scorers/slm/adherence.py +98 -0
  51. agent_learning/scorers/slm/completion.py +111 -0
  52. agent_learning/scorers/slm/intent.py +80 -0
  53. agent_learning/scorers/stdlib/__init__.py +38 -0
  54. agent_learning/scorers/stdlib/_text.py +87 -0
  55. agent_learning/scorers/stdlib/adherence.py +157 -0
  56. agent_learning/scorers/stdlib/completion.py +117 -0
  57. agent_learning/scorers/stdlib/intent.py +182 -0
  58. agent_learning/storage/__init__.py +14 -0
  59. agent_learning/storage/base.py +156 -0
  60. agent_learning/storage/cosmos.py +506 -0
  61. agent_learning/storage/local.py +353 -0
  62. agent_learning/storage/memory.py +209 -0
  63. agent_learning/training/__init__.py +5 -0
  64. agent_learning/training/runner.py +172 -0
  65. agent_learning/types.py +507 -0
  66. agent_learning-0.4.1.dist-info/METADATA +82 -0
  67. agent_learning-0.4.1.dist-info/RECORD +71 -0
  68. agent_learning-0.4.1.dist-info/WHEEL +5 -0
  69. agent_learning-0.4.1.dist-info/entry_points.txt +2 -0
  70. agent_learning-0.4.1.dist-info/licenses/LICENSE +21 -0
  71. agent_learning-0.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,153 @@
1
+ """REINFORCE-style policy gradient learner with an EMA value baseline.
2
+
3
+ The native learner consumes (episode, aggregate reward) pairs and
4
+ applies one batched gradient step per call. The gradient of a
5
+ softmax bandit's logit ``z_a`` w.r.t. the log-likelihood of action
6
+ ``a`` is ``1_{action == a} - π(a)``, so the policy-gradient update
7
+ for one episode is:
8
+
9
+ Δz_a = lr * (R - b) * (1_{action == a} - π(a))
10
+
11
+ We accumulate that delta over the batch, average by the batch size,
12
+ add a small entropy bonus to keep exploration alive, and update the
13
+ EMA baseline ``b`` afterwards. Importance sampling weights are
14
+ applied when the snapshot's policy version is older than the
15
+ in-memory policy version, to keep offline updates unbiased without
16
+ exploding gradients (the weight is clipped).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from typing import Dict, Iterable, List, Optional
23
+
24
+ from ..config import LearnerConfig
25
+ from ..policy.base import Policy
26
+ from ..policy.softmax_bandit import SoftmaxPolicy
27
+ from ..types import Episode, Reward, RewardSource
28
+ from .base import Learner, LearnerResult
29
+
30
+
31
+ class ReinforceLearner(Learner):
32
+ """REINFORCE-with-baseline learner for :class:`SoftmaxPolicy`."""
33
+
34
+ def __init__(self, config: Optional[LearnerConfig] = None) -> None:
35
+ self._config = config or LearnerConfig()
36
+
37
+ @property
38
+ def config(self) -> LearnerConfig:
39
+ return self._config
40
+
41
+ def update(
42
+ self,
43
+ policy: Policy,
44
+ episodes: Iterable[Episode],
45
+ rewards: Iterable[Reward],
46
+ ) -> LearnerResult:
47
+ if not isinstance(policy, SoftmaxPolicy):
48
+ raise TypeError("ReinforceLearner requires a SoftmaxPolicy")
49
+
50
+ # Index rewards by episode id - we only consume aggregate rewards
51
+ episode_list = list(episodes)
52
+ aggregate_rewards: Dict[str, float] = {}
53
+ for r in rewards:
54
+ if r.source != RewardSource.AGGREGATE:
55
+ continue
56
+ # Keep the most recent aggregate per episode (sorted by created_at desc)
57
+ current = aggregate_rewards.get(r.episode_id)
58
+ if current is None:
59
+ aggregate_rewards[r.episode_id] = r.value
60
+
61
+ snapshot = policy.snapshot()
62
+ baseline_before = snapshot.baseline
63
+ probs_now = policy.probabilities()
64
+ action_ids = [a.id for a in snapshot.actions]
65
+ action_index = {aid: idx for idx, aid in enumerate(action_ids)}
66
+
67
+ deltas: Dict[str, float] = {aid: 0.0 for aid in action_ids}
68
+ used = 0
69
+ reward_sum = 0.0
70
+ entropy = -sum(p * math.log(max(p, 1e-12)) for p in probs_now)
71
+
72
+ for episode in episode_list:
73
+ if episode.action_id is None or episode.action_id not in action_index:
74
+ continue
75
+ reward_value = aggregate_rewards.get(episode.id)
76
+ if reward_value is None:
77
+ continue
78
+ advantage = reward_value - baseline_before
79
+
80
+ # Importance sampling weight when the episode was logged under a
81
+ # behaviour policy that differs from the target policy.
82
+ iw = self._importance_weight(episode, probs_now, action_index)
83
+ advantage *= iw
84
+
85
+ chosen_idx = action_index[episode.action_id]
86
+ for idx, aid in enumerate(action_ids):
87
+ indicator = 1.0 if idx == chosen_idx else 0.0
88
+ grad = indicator - probs_now[idx]
89
+ deltas[aid] += advantage * grad
90
+
91
+ # Entropy regulariser pushes towards the maximum-entropy
92
+ # distribution: its gradient w.r.t. logit i is -log p_i - H.
93
+ for idx, aid in enumerate(action_ids):
94
+ deltas[aid] += self._config.entropy_bonus * (-math.log(max(probs_now[idx], 1e-12)) - entropy)
95
+
96
+ used += 1
97
+ reward_sum += reward_value
98
+
99
+ if used == 0:
100
+ return LearnerResult(
101
+ episodes_used=0,
102
+ mean_reward=0.0,
103
+ baseline_before=baseline_before,
104
+ baseline_after=baseline_before,
105
+ logit_deltas={aid: 0.0 for aid in action_ids},
106
+ extra={"reason": "no actionable episodes"},
107
+ )
108
+
109
+ # Average gradients across the batch and scale by learning rate
110
+ for aid in deltas:
111
+ deltas[aid] = self._config.learning_rate * deltas[aid] / used
112
+
113
+ # Update EMA baseline using the mean batch reward
114
+ mean_reward = reward_sum / used
115
+ decay = self._config.baseline_decay
116
+ baseline_after = decay * baseline_before + (1.0 - decay) * mean_reward
117
+
118
+ policy.apply_update(
119
+ deltas,
120
+ baseline=baseline_after,
121
+ episodes_seen=snapshot.episodes_seen + used,
122
+ )
123
+
124
+ return LearnerResult(
125
+ episodes_used=used,
126
+ mean_reward=mean_reward,
127
+ baseline_before=baseline_before,
128
+ baseline_after=baseline_after,
129
+ logit_deltas=deltas,
130
+ extra={"entropy": entropy},
131
+ )
132
+
133
+ # ------------------------------------------------------------------
134
+ # Helpers
135
+ # ------------------------------------------------------------------
136
+
137
+ def _importance_weight(
138
+ self,
139
+ episode: Episode,
140
+ probs_now: List[float],
141
+ action_index: Dict[str, int],
142
+ ) -> float:
143
+ """Return clipped π_target / π_behaviour for an episode."""
144
+ if episode.action_logprob is None or episode.action_id is None:
145
+ return 1.0
146
+ idx = action_index[episode.action_id]
147
+ p_target = max(probs_now[idx], 1e-12)
148
+ p_behaviour = max(math.exp(episode.action_logprob), 1e-12)
149
+ ratio = p_target / p_behaviour
150
+ return min(ratio, self._config.importance_clip)
151
+
152
+
153
+ __all__ = ["ReinforceLearner"]
@@ -0,0 +1,22 @@
1
+ """Score-based evaluation metrics for native RL reward shaping.
2
+
3
+ Each metric is a thin wrapper around an evaluator from
4
+ ``azure-ai-evaluation``. The wrapper normalises the raw evaluator score
5
+ into the ``[0, 1]`` range expected by the reward shaper.
6
+ """
7
+
8
+ from .base import MetricEvaluator, MetricRequest
9
+ from .intent_resolution import IntentResolutionMetric
10
+ from .task_adherence import TaskAdherenceMetric
11
+ from .task_completion import TaskCompletionMetric
12
+ from .registry import default_metrics, evaluate_all
13
+
14
+ __all__ = [
15
+ "IntentResolutionMetric",
16
+ "MetricEvaluator",
17
+ "MetricRequest",
18
+ "TaskAdherenceMetric",
19
+ "TaskCompletionMetric",
20
+ "default_metrics",
21
+ "evaluate_all",
22
+ ]
@@ -0,0 +1,233 @@
1
+ """Base classes for native score-based metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from ..config import ScoreConfig
12
+ from ..types import Episode, MetricName, MetricResult
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class MetricRequest:
19
+ """Input passed to a metric evaluator.
20
+
21
+ The fields mirror the prompty schemas used by azure-ai-evaluation:
22
+ ``query`` is the user message (or the full conversation history
23
+ formatted as a string), ``response`` is the agent reply, and
24
+ ``tool_calls`` / ``system_message`` are populated when required by
25
+ the underlying evaluator.
26
+ """
27
+
28
+ query: str
29
+ response: str
30
+ system_message: Optional[str] = None
31
+ tool_calls: Optional[str] = None
32
+ tool_definitions: Optional[str] = None
33
+ extra: Dict[str, Any] = field(default_factory=dict)
34
+
35
+ @classmethod
36
+ def from_episode(cls, episode: Episode) -> "MetricRequest":
37
+ """Build a metric request from an :class:`Episode`."""
38
+ query = _format_query(episode)
39
+ return cls(
40
+ query=query,
41
+ response=episode.assistant_output or "",
42
+ system_message=episode.system_message,
43
+ tool_calls=_format_tool_calls(episode),
44
+ tool_definitions=episode.metadata.get("tool_definitions"),
45
+ )
46
+
47
+
48
+ def _format_query(episode: Episode) -> str:
49
+ """Render the conversation history as the ``query`` field.
50
+
51
+ The Azure prompts accept a free-form ``query`` string. We prefer
52
+ the structured conversation history when present, falling back to
53
+ the single ``user_input`` field.
54
+ """
55
+ if episode.conversation_history:
56
+ lines: List[str] = []
57
+ for msg in episode.conversation_history:
58
+ role = msg.get("role", "user").upper()
59
+ content = msg.get("content", "")
60
+ lines.append(f"{role}: {content}")
61
+ if episode.user_input and (
62
+ not lines or not lines[-1].lower().startswith("user:")
63
+ ):
64
+ lines.append(f"USER: {episode.user_input}")
65
+ return "\n".join(lines)
66
+ return episode.user_input or ""
67
+
68
+
69
+ def _format_tool_calls(episode: Episode) -> str:
70
+ """Render tool calls as a JSON string consumable by the scorer."""
71
+ if not episode.tool_calls:
72
+ return "[]"
73
+ payload = [
74
+ {
75
+ "name": tc.name,
76
+ "arguments": tc.arguments,
77
+ "result": tc.result,
78
+ "error": tc.error,
79
+ }
80
+ for tc in episode.tool_calls
81
+ ]
82
+ return json.dumps(payload, ensure_ascii=False)
83
+
84
+
85
+ class MetricEvaluator(ABC):
86
+ """Abstract base for all native scoring metrics."""
87
+
88
+ #: Stable identifier - subclasses must override.
89
+ NAME: MetricName
90
+
91
+ def __init__(
92
+ self,
93
+ score_config: Optional[ScoreConfig] = None,
94
+ *,
95
+ evaluator: Optional[Any] = None,
96
+ ) -> None:
97
+ self._score_config = score_config or ScoreConfig()
98
+ self._evaluator = evaluator # Allow injection for tests
99
+
100
+ # ------------------------------------------------------------------
101
+ # Construction hooks (subclasses override _build_evaluator)
102
+ # ------------------------------------------------------------------
103
+
104
+ @abstractmethod
105
+ def _build_evaluator(self) -> Any:
106
+ """Instantiate the underlying azure-ai-evaluation evaluator."""
107
+
108
+ @abstractmethod
109
+ def _normalize(self, raw: Dict[str, Any]) -> Optional[float]:
110
+ """Map the evaluator's raw output to a ``[0, 1]`` score."""
111
+
112
+ @abstractmethod
113
+ def _build_kwargs(self, request: MetricRequest) -> Dict[str, Any]:
114
+ """Build the call kwargs for the underlying evaluator."""
115
+
116
+ # ------------------------------------------------------------------
117
+ # Public API
118
+ # ------------------------------------------------------------------
119
+
120
+ @property
121
+ def evaluator(self) -> Any:
122
+ """The lazily-constructed underlying evaluator."""
123
+ if self._evaluator is None:
124
+ if not self._score_config.enabled:
125
+ raise RuntimeError(
126
+ "Score model is not configured. Set AGENT_LEARNING_SCORE_ENDPOINT "
127
+ "and AGENT_LEARNING_SCORE_DEPLOYMENT (or pass an explicit "
128
+ "ScoreConfig) before evaluating metrics."
129
+ )
130
+ self._evaluator = self._build_evaluator()
131
+ return self._evaluator
132
+
133
+ def evaluate(self, request: MetricRequest) -> MetricResult:
134
+ """Run the underlying scorer and return a normalised result.
135
+
136
+ Errors are caught and returned as ``status=skipped`` so that a
137
+ flaky scorer does not poison the learner. Callers that want
138
+ strict semantics can inspect ``result.status``.
139
+ """
140
+ if not (request.query and request.response):
141
+ return MetricResult(
142
+ metric=self.NAME,
143
+ score=None,
144
+ normalized=None,
145
+ status="skipped",
146
+ reason="query or response is empty",
147
+ evaluator=self._score_config.azure_deployment or None,
148
+ )
149
+
150
+ try:
151
+ kwargs = self._build_kwargs(request)
152
+ raw = self.evaluator(**kwargs)
153
+ if not isinstance(raw, dict):
154
+ raw = dict(raw)
155
+ except Exception as exc: # pragma: no cover - scoring runtime errors
156
+ logger.warning("Metric %s failed: %s", self.NAME.value, exc)
157
+ return MetricResult(
158
+ metric=self.NAME,
159
+ score=None,
160
+ normalized=None,
161
+ status="skipped",
162
+ reason=f"evaluator error: {exc}",
163
+ evaluator=self._score_config.azure_deployment or None,
164
+ )
165
+
166
+ return self._build_result(raw)
167
+
168
+ # ------------------------------------------------------------------
169
+ # Result extraction
170
+ # ------------------------------------------------------------------
171
+
172
+ def _build_result(self, raw: Dict[str, Any]) -> MetricResult:
173
+ score = _extract_score(raw, self.NAME)
174
+ normalized = self._normalize(raw)
175
+ status = "skipped" if score is None else "completed"
176
+
177
+ properties = raw.get("properties") if isinstance(raw.get("properties"), dict) else None
178
+ reason_keys = (
179
+ f"{self.NAME.value}_reason",
180
+ "reason",
181
+ )
182
+ reason: Optional[str] = None
183
+ for key in reason_keys:
184
+ if isinstance(raw.get(key), str):
185
+ reason = raw[key]
186
+ break
187
+
188
+ return MetricResult(
189
+ metric=self.NAME,
190
+ score=score,
191
+ normalized=normalized,
192
+ status=status,
193
+ reason=reason,
194
+ properties=properties,
195
+ evaluator=self._score_config.azure_deployment or None,
196
+ metadata={"raw": _safe_subset(raw)},
197
+ )
198
+
199
+
200
+ def _extract_score(raw: Dict[str, Any], name: MetricName) -> Optional[float]:
201
+ """Return the numeric score from a scorer response, regardless of key."""
202
+ keys = (
203
+ "score",
204
+ f"{name.value}_score",
205
+ f"{name.value}",
206
+ )
207
+ for key in keys:
208
+ if key in raw and raw[key] is not None:
209
+ try:
210
+ return float(raw[key])
211
+ except (TypeError, ValueError):
212
+ continue
213
+ return None
214
+
215
+
216
+ def _safe_subset(raw: Dict[str, Any]) -> Dict[str, Any]:
217
+ """Trim the raw score dict to a Cosmos-safe size for persistence."""
218
+ keep_keys = {
219
+ "score",
220
+ "status",
221
+ "reason",
222
+ "properties",
223
+ "intent_resolution_score",
224
+ "intent_resolution_reason",
225
+ "task_adherence_score",
226
+ "task_adherence_reason",
227
+ "task_completion_score",
228
+ "task_completion_reason",
229
+ }
230
+ return {k: v for k, v in raw.items() if k in keep_keys}
231
+
232
+
233
+ __all__ = ["MetricEvaluator", "MetricRequest"]
@@ -0,0 +1,50 @@
1
+ """Intent resolution metric wrapper.
2
+
3
+ Score range from the underlying scorer is ``1`` (very poor) to ``5``
4
+ (excellent). We normalise to ``[0, 1]`` via ``(score - 1) / 4`` so
5
+ that a perfect intent resolution becomes a maximum positive reward
6
+ signal after shaping.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, Optional
12
+
13
+ from ..types import MetricName
14
+ from .base import MetricEvaluator, MetricRequest
15
+
16
+
17
+ class IntentResolutionMetric(MetricEvaluator):
18
+ """Wraps :class:`azure.ai.evaluation.IntentResolutionEvaluator`."""
19
+
20
+ NAME = MetricName.INTENT_RESOLUTION
21
+
22
+ def _build_evaluator(self) -> Any:
23
+ from azure.ai.evaluation import IntentResolutionEvaluator # type: ignore
24
+
25
+ return IntentResolutionEvaluator(
26
+ model_config=self._score_config.to_model_config(),
27
+ threshold=self._score_config.threshold,
28
+ )
29
+
30
+ def _build_kwargs(self, request: MetricRequest) -> Dict[str, Any]:
31
+ kwargs: Dict[str, Any] = {
32
+ "query": request.query,
33
+ "response": request.response,
34
+ }
35
+ if request.tool_definitions is not None:
36
+ kwargs["tool_definitions"] = request.tool_definitions
37
+ return kwargs
38
+
39
+ def _normalize(self, raw: Dict[str, Any]) -> Optional[float]:
40
+ # The scorer produces an integer 1..5
41
+ from .base import _extract_score # type: ignore
42
+
43
+ score = _extract_score(raw, self.NAME)
44
+ if score is None:
45
+ return None
46
+ clamped = max(1.0, min(5.0, score))
47
+ return (clamped - 1.0) / 4.0
48
+
49
+
50
+ __all__ = ["IntentResolutionMetric"]
@@ -0,0 +1,42 @@
1
+ """Convenience helpers to evaluate an episode across all default metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable, List, Optional
6
+
7
+ from ..config import ScoreConfig
8
+ from ..types import Episode, MetricResult
9
+ from .base import MetricEvaluator, MetricRequest
10
+ from .intent_resolution import IntentResolutionMetric
11
+ from .task_adherence import TaskAdherenceMetric
12
+ from .task_completion import TaskCompletionMetric
13
+
14
+
15
+ def default_metrics(score_config: Optional[ScoreConfig] = None) -> List[MetricEvaluator]:
16
+ """Return the three native metrics wired with the same score config."""
17
+ cfg = score_config or ScoreConfig()
18
+ return [
19
+ IntentResolutionMetric(cfg),
20
+ TaskAdherenceMetric(cfg),
21
+ TaskCompletionMetric(cfg),
22
+ ]
23
+
24
+
25
+ def evaluate_all(
26
+ episode: Episode,
27
+ metrics: Optional[Iterable[MetricEvaluator]] = None,
28
+ *,
29
+ score_config: Optional[ScoreConfig] = None,
30
+ ) -> List[MetricResult]:
31
+ """Evaluate one episode against every supplied metric.
32
+
33
+ Each metric is given its own try/except inside :meth:`evaluate`,
34
+ so a single failing scorer will not prevent the others from
35
+ producing scores.
36
+ """
37
+ metric_list = list(metrics) if metrics is not None else default_metrics(score_config)
38
+ request = MetricRequest.from_episode(episode)
39
+ return [m.evaluate(request) for m in metric_list]
40
+
41
+
42
+ __all__ = ["default_metrics", "evaluate_all"]
@@ -0,0 +1,42 @@
1
+ """Task adherence metric wrapper.
2
+
3
+ The underlying scorer emits a binary score ``0`` (material failure) or
4
+ ``1`` (pass). It is already in ``[0, 1]`` so normalisation is a no-op.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Optional
10
+
11
+ from ..types import MetricName
12
+ from .base import MetricEvaluator, MetricRequest
13
+
14
+
15
+ class TaskAdherenceMetric(MetricEvaluator):
16
+ """Wraps :class:`azure.ai.evaluation.TaskAdherenceEvaluator`."""
17
+
18
+ NAME = MetricName.TASK_ADHERENCE
19
+
20
+ def _build_evaluator(self) -> Any:
21
+ from azure.ai.evaluation import TaskAdherenceEvaluator # type: ignore
22
+
23
+ return TaskAdherenceEvaluator(model_config=self._score_config.to_model_config())
24
+
25
+ def _build_kwargs(self, request: MetricRequest) -> Dict[str, Any]:
26
+ return {
27
+ "query": request.query,
28
+ "response": request.response,
29
+ "system_message": request.system_message or "",
30
+ "tool_calls": request.tool_calls or "[]",
31
+ }
32
+
33
+ def _normalize(self, raw: Dict[str, Any]) -> Optional[float]:
34
+ from .base import _extract_score # type: ignore
35
+
36
+ score = _extract_score(raw, self.NAME)
37
+ if score is None:
38
+ return None
39
+ return max(0.0, min(1.0, score))
40
+
41
+
42
+ __all__ = ["TaskAdherenceMetric"]
@@ -0,0 +1,54 @@
1
+ """Task completion metric wrapper.
2
+
3
+ The underlying scorer emits ``1`` (task completed) or ``0`` (task
4
+ failed or incomplete). It is already in ``[0, 1]``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Optional
10
+
11
+ from ..types import MetricName
12
+ from .base import MetricEvaluator, MetricRequest
13
+
14
+
15
+ class TaskCompletionMetric(MetricEvaluator):
16
+ """Wraps the (experimental) ``TaskCompletionEvaluator`` if available.
17
+
18
+ Some releases of ``azure-ai-evaluation`` ship ``TaskCompletionEvaluator``
19
+ and others ship ``CompletenessEvaluator``. We try the former first
20
+ and fall back to the latter so the SDK stays compatible across
21
+ minor versions.
22
+ """
23
+
24
+ NAME = MetricName.TASK_COMPLETION
25
+
26
+ def _build_evaluator(self) -> Any:
27
+ try: # pragma: no cover - both paths are exercised in CI matrix
28
+ from azure.ai.evaluation import TaskCompletionEvaluator # type: ignore
29
+
30
+ return TaskCompletionEvaluator(model_config=self._score_config.to_model_config())
31
+ except ImportError:
32
+ from azure.ai.evaluation import CompletenessEvaluator # type: ignore
33
+
34
+ return CompletenessEvaluator(model_config=self._score_config.to_model_config())
35
+
36
+ def _build_kwargs(self, request: MetricRequest) -> Dict[str, Any]:
37
+ kwargs: Dict[str, Any] = {
38
+ "query": request.query,
39
+ "response": request.response,
40
+ }
41
+ if request.tool_definitions is not None:
42
+ kwargs["tool_definitions"] = request.tool_definitions
43
+ return kwargs
44
+
45
+ def _normalize(self, raw: Dict[str, Any]) -> Optional[float]:
46
+ from .base import _extract_score # type: ignore
47
+
48
+ score = _extract_score(raw, self.NAME)
49
+ if score is None:
50
+ return None
51
+ return max(0.0, min(1.0, score))
52
+
53
+
54
+ __all__ = ["TaskCompletionMetric"]
@@ -0,0 +1,7 @@
1
+ """Policy abstractions and the default softmax bandit policy."""
2
+
3
+ from .base import Policy
4
+ from .contextual_softmax import ContextualSoftmaxPolicy
5
+ from .softmax_bandit import SoftmaxPolicy
6
+
7
+ __all__ = ["ContextualSoftmaxPolicy", "Policy", "SoftmaxPolicy"]
@@ -0,0 +1,59 @@
1
+ """Abstract policy interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from typing import Any, List, Optional, Sequence
8
+
9
+ from ..types import Action, PolicySnapshot
10
+
11
+
12
+ @dataclass
13
+ class Decision:
14
+ """Output of :meth:`Policy.choose`: which action and with what probability."""
15
+
16
+ action: Action
17
+ logprob: float
18
+ probabilities: List[float] # Probability assigned to every action (same order as snapshot.actions)
19
+
20
+
21
+ class Policy(ABC):
22
+ """Abstract base class for native RL policies.
23
+
24
+ The Policy is stateless from the caller's perspective: the durable
25
+ state lives in the :class:`PolicySnapshot` returned by
26
+ :meth:`snapshot`. Callers persist the snapshot themselves; the
27
+ Policy never talks to the store directly.
28
+
29
+ Concrete policies may use an optional ``state`` argument on
30
+ :meth:`choose` to condition action selection on contextual features
31
+ (a request embedding, a session summary, etc.). Marginal policies
32
+ such as :class:`SoftmaxPolicy` ignore the argument.
33
+ """
34
+
35
+ @abstractmethod
36
+ def choose(self, state: Optional[Any] = None) -> Decision:
37
+ """Sample an action according to the current parameters.
38
+
39
+ Args:
40
+ state: Optional contextual feature vector. Contextual
41
+ policies (e.g. :class:`ContextualSoftmaxPolicy`) require
42
+ a 1-D array of length ``feature_dim``. Marginal policies
43
+ ignore the argument.
44
+ """
45
+
46
+ @abstractmethod
47
+ def actions(self) -> Sequence[Action]:
48
+ """Return the action space."""
49
+
50
+ @abstractmethod
51
+ def snapshot(self) -> PolicySnapshot:
52
+ """Return a deep-copyable snapshot of the policy state."""
53
+
54
+ @abstractmethod
55
+ def apply_update(self, deltas: dict, *, baseline: float, episodes_seen: int) -> None:
56
+ """Apply per-action logit deltas and update bookkeeping."""
57
+
58
+
59
+ __all__ = ["Decision", "Policy"]