aimai-kit 1.0.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.
Files changed (62) hide show
  1. aimai_kit/__init__.py +1 -0
  2. aimai_kit/agent/__init__.py +28 -0
  3. aimai_kit/agent/budget.py +118 -0
  4. aimai_kit/agent/loop.py +296 -0
  5. aimai_kit/agent/loopdetect.py +70 -0
  6. aimai_kit/agent/metrics.py +112 -0
  7. aimai_kit/agent/scripted.py +72 -0
  8. aimai_kit/agent/thread.py +142 -0
  9. aimai_kit/harness/__init__.py +38 -0
  10. aimai_kit/harness/compaction.py +126 -0
  11. aimai_kit/harness/context.py +133 -0
  12. aimai_kit/harness/memory.py +230 -0
  13. aimai_kit/harness/sandbox.py +97 -0
  14. aimai_kit/harness/segments.py +112 -0
  15. aimai_kit/harness/spill.py +134 -0
  16. aimai_kit/harness/stub_summarizer.py +81 -0
  17. aimai_kit/harness/subagent.py +162 -0
  18. aimai_kit/prompts/__init__.py +27 -0
  19. aimai_kit/prompts/blocks.py +89 -0
  20. aimai_kit/prompts/budget.py +238 -0
  21. aimai_kit/prompts/cli.py +448 -0
  22. aimai_kit/prompts/grounding.py +155 -0
  23. aimai_kit/prompts/guard.py +77 -0
  24. aimai_kit/prompts/pipeline.py +96 -0
  25. aimai_kit/prompts/registry.py +171 -0
  26. aimai_kit/prompts/schemas.py +247 -0
  27. aimai_kit/prompts/structured.py +196 -0
  28. aimai_kit/prompts/stub.py +203 -0
  29. aimai_kit/provider/__init__.py +1 -0
  30. aimai_kit/provider/adapters/__init__.py +51 -0
  31. aimai_kit/provider/adapters/_shared.py +37 -0
  32. aimai_kit/provider/adapters/anthropic_.py +185 -0
  33. aimai_kit/provider/adapters/azure_openai.py +64 -0
  34. aimai_kit/provider/adapters/gemini_.py +162 -0
  35. aimai_kit/provider/adapters/openai_.py +187 -0
  36. aimai_kit/provider/cli/__init__.py +1 -0
  37. aimai_kit/provider/cli/probe.py +313 -0
  38. aimai_kit/provider/client.py +45 -0
  39. aimai_kit/provider/counters.py +62 -0
  40. aimai_kit/provider/counting.py +135 -0
  41. aimai_kit/provider/determinism.py +63 -0
  42. aimai_kit/provider/errors.py +101 -0
  43. aimai_kit/provider/pricing.py +129 -0
  44. aimai_kit/provider/resilient.py +229 -0
  45. aimai_kit/provider/telemetry.py +147 -0
  46. aimai_kit/provider/types.py +192 -0
  47. aimai_kit/py.typed +0 -0
  48. aimai_kit/tools/__init__.py +36 -0
  49. aimai_kit/tools/decorator.py +175 -0
  50. aimai_kit/tools/evaluation.py +227 -0
  51. aimai_kit/tools/examples/__init__.py +12 -0
  52. aimai_kit/tools/examples/orders.py +237 -0
  53. aimai_kit/tools/executor.py +334 -0
  54. aimai_kit/tools/export.py +111 -0
  55. aimai_kit/tools/idempotency.py +86 -0
  56. aimai_kit/tools/registry.py +74 -0
  57. aimai_kit/tools/spec.py +97 -0
  58. aimai_kit-1.0.0.dist-info/METADATA +238 -0
  59. aimai_kit-1.0.0.dist-info/RECORD +62 -0
  60. aimai_kit-1.0.0.dist-info/WHEEL +4 -0
  61. aimai_kit-1.0.0.dist-info/entry_points.txt +3 -0
  62. aimai_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
aimai_kit/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """aimai-kit — a framework-free LLM engineering toolkit."""
@@ -0,0 +1,28 @@
1
+ """Agent layer: a bounded loop over the tool layer.
2
+
3
+ The loop owns very little on purpose. Tool execution, request construction,
4
+ telemetry and loop detection each live behind their own seam, so each can be
5
+ tested without starting a run — and so the loop stays short enough to read in
6
+ one sitting.
7
+ """
8
+
9
+ from .budget import Budgets, Spend, StopReason
10
+ from .loop import Agent, RunResult
11
+ from .loopdetect import LoopDetector
12
+ from .metrics import RunMetrics, run_metrics
13
+ from .scripted import ScriptedClient
14
+ from .thread import Thread, TraceEvent
15
+
16
+ __all__ = [
17
+ "Agent",
18
+ "Budgets",
19
+ "LoopDetector",
20
+ "RunMetrics",
21
+ "RunResult",
22
+ "ScriptedClient",
23
+ "Spend",
24
+ "StopReason",
25
+ "Thread",
26
+ "TraceEvent",
27
+ "run_metrics",
28
+ ]
@@ -0,0 +1,118 @@
1
+ """Four budgets and one stop reason.
2
+
3
+ An agent that runs until it decides it is finished is not a system, it is a
4
+ bet. Four budgets bound it, and they bound different failure modes:
5
+
6
+ steps a loop that makes progress but never converges
7
+ tokens a loop that grows its own context until the window bursts
8
+ cost the one the finance team asks about
9
+ seconds the one the user experiences
10
+
11
+ Three of them are cheap to check and one is not: cost requires a pricing
12
+ catalog, so it is optional. A missing catalog does not silently disable the
13
+ budget — `Spend.usd` simply stays at zero and the cost budget never fires,
14
+ which is visible in the metrics.
15
+
16
+ `StopReason` is a closed set because it is what the caller branches on. Half
17
+ of these values mean "the work is not done"; only `finished` means it is.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from decimal import Decimal
24
+ from enum import StrEnum
25
+
26
+ __all__ = ["Budgets", "Spend", "StopReason"]
27
+
28
+
29
+ class StopReason(StrEnum):
30
+ """Why a run ended. Exactly one value per run."""
31
+
32
+ FINISHED = "finished"
33
+ STEP_BUDGET = "step_budget"
34
+ TOKEN_BUDGET = "token_budget"
35
+ COST_BUDGET = "cost_budget"
36
+ TIME_BUDGET = "time_budget"
37
+ LOOP_DETECTED = "loop_detected"
38
+ TOOL_ERROR_CEILING = "tool_error_ceiling"
39
+ NEEDS_APPROVAL = "needs_approval"
40
+ CANCELLED = "cancelled"
41
+
42
+ @property
43
+ def is_success(self) -> bool:
44
+ return self is StopReason.FINISHED
45
+
46
+ @property
47
+ def is_budget(self) -> bool:
48
+ return self in {
49
+ StopReason.STEP_BUDGET,
50
+ StopReason.TOKEN_BUDGET,
51
+ StopReason.COST_BUDGET,
52
+ StopReason.TIME_BUDGET,
53
+ }
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Budgets:
58
+ """The ceilings for one run.
59
+
60
+ `max_seconds` is wall-clock, not CPU: what bounds a user's patience is
61
+ the clock, and most of the time here is spent waiting on a network call
62
+ anyway.
63
+
64
+ Leaving a budget at `None` disables it. That is allowed but should be
65
+ deliberate — an agent with no step budget will eventually find an input
66
+ that makes it run forever.
67
+ """
68
+
69
+ max_steps: int | None = 12
70
+ max_tokens: int | None = 120_000
71
+ max_usd: Decimal | None = None
72
+ max_seconds: float | None = 120.0
73
+
74
+ def exceeded_by(self, spend: Spend) -> StopReason | None:
75
+ """Which budget, if any, this spend has already crossed.
76
+
77
+ Checked BEFORE a step, not after: knowing you are over budget after
78
+ spending the money is an audit, not a budget.
79
+ """
80
+ if self.max_steps is not None and spend.steps >= self.max_steps:
81
+ return StopReason.STEP_BUDGET
82
+ if self.max_tokens is not None and spend.tokens >= self.max_tokens:
83
+ return StopReason.TOKEN_BUDGET
84
+ if self.max_usd is not None and spend.usd >= self.max_usd:
85
+ return StopReason.COST_BUDGET
86
+ if self.max_seconds is not None and spend.seconds >= self.max_seconds:
87
+ return StopReason.TIME_BUDGET
88
+ return None
89
+
90
+
91
+ @dataclass
92
+ class Spend:
93
+ """What a run has consumed so far."""
94
+
95
+ steps: int = 0
96
+ tokens: int = 0
97
+ usd: Decimal = field(default_factory=lambda: Decimal(0))
98
+ seconds: float = 0.0
99
+ tool_errors: int = 0
100
+
101
+ def as_dict(self) -> dict[str, object]:
102
+ return {
103
+ "steps": self.steps,
104
+ "tokens": self.tokens,
105
+ "usd": str(self.usd),
106
+ "seconds": round(self.seconds, 3),
107
+ "tool_errors": self.tool_errors,
108
+ }
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: dict) -> Spend:
112
+ return cls(
113
+ steps=int(data.get("steps", 0)),
114
+ tokens=int(data.get("tokens", 0)),
115
+ usd=Decimal(str(data.get("usd", "0"))),
116
+ seconds=float(data.get("seconds", 0.0)),
117
+ tool_errors=int(data.get("tool_errors", 0)),
118
+ )
@@ -0,0 +1,296 @@
1
+ """The agent loop — framework-free, four budgets, one stop reason.
2
+
3
+ The loop itself is short, and that is the design. Everything it could have
4
+ absorbed lives elsewhere:
5
+
6
+ tool execution the tool layer's executor (five gates, timeouts)
7
+ request building the prompt layer (registry, budget, cache prefix)
8
+ telemetry the provider layer's resilient client
9
+ loop detection a separate object with its own tests
10
+
11
+ What is left here is the part that is genuinely about the loop: check the
12
+ budget, call the model, hand any tool calls to the executor, feed the results
13
+ back, repeat.
14
+
15
+ Two decisions are worth reading the code for.
16
+
17
+ **Every tool call gets a result.** Even a refused one, even a timed-out one.
18
+ An assistant turn that requested three calls and received two results leaves
19
+ the conversation in a shape most providers reject, and the ones that do not
20
+ reject it produce confused output. The loop makes half-turns impossible.
21
+
22
+ **A budget stop gets one last turn without tools.** Cutting the run dead at
23
+ the ceiling leaves the user with nothing. Giving the model one final call with
24
+ tools disabled produces a partial answer that says what is missing, which is
25
+ almost always more useful — and the cost of that last turn has to be inside
26
+ the ceiling you chose, not on top of it.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import time
33
+ from collections.abc import Callable, Sequence
34
+ from dataclasses import dataclass
35
+
36
+ from ..provider.client import LLMClient
37
+ from ..provider.counters import COUNTERS
38
+ from ..provider.pricing import ModelPricing
39
+ from ..provider.types import ChatRequest, Role, ToolCall
40
+ from ..tools.executor import CallContext, ToolExecutor
41
+ from ..tools.export import export_for
42
+ from ..tools.idempotency import call_signature
43
+ from .budget import Budgets, StopReason
44
+ from .loopdetect import REPEAT_WARNING
45
+ from .thread import Thread, TraceEvent
46
+
47
+ __all__ = ["Agent", "RunResult"]
48
+
49
+
50
+ @dataclass
51
+ class RunResult:
52
+ """What a finished run produced. The thread carries the details."""
53
+
54
+ thread: Thread
55
+ stop_reason: StopReason
56
+ answer: str
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ return self.stop_reason is StopReason.FINISHED
61
+
62
+
63
+ class Agent:
64
+ """A stateless agent. All state lives in the `Thread` passed to `run`.
65
+
66
+ Stateless means one instance can serve concurrent requests, and it means
67
+ a run can be reconstructed from its thread alone rather than from an
68
+ object graph that no longer exists.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ client: LLMClient,
74
+ executor: ToolExecutor,
75
+ *,
76
+ system: str = "",
77
+ budgets: Budgets | None = None,
78
+ pricing: dict[str, ModelPricing] | None = None,
79
+ allowlist: Sequence[str] | None = None,
80
+ max_tool_errors: int = 4,
81
+ max_output_tokens: int = 2048,
82
+ on_event: Callable[[TraceEvent], None] | None = None,
83
+ now: Callable[[], float] = time.monotonic,
84
+ ) -> None:
85
+ self.client = client
86
+ self.executor = executor
87
+ self.system = system
88
+ self.budgets = budgets or Budgets()
89
+ self.pricing = pricing or {}
90
+ self.allowlist = allowlist
91
+ self.max_tool_errors = max_tool_errors
92
+ self.max_output_tokens = max_output_tokens
93
+ self.on_event = on_event
94
+ self._now = now
95
+
96
+ # --- internals --------------------------------------------------------
97
+
98
+ def _emit(
99
+ self, thread: Thread, kind: str, detail: str = "", *, ok: bool = True
100
+ ) -> None:
101
+ event = thread.record(kind, detail, ok=ok)
102
+ if self.on_event:
103
+ self.on_event(event)
104
+
105
+ def _tool_declarations(self) -> list[dict]:
106
+ visible = self.executor.registry.visible(self.allowlist)
107
+ return export_for(self.client.provider, visible)
108
+
109
+ def _charge(self, thread: Thread, result) -> None:
110
+ """Accounting happens in ONE place in the loop, not per call site."""
111
+ thread.spend.tokens += result.usage.total_tokens
112
+ price = self.pricing.get(result.model)
113
+ if price is not None:
114
+ thread.spend.usd += price.cost(
115
+ result.usage.input_tokens,
116
+ result.usage.output_tokens,
117
+ result.usage.cached_input_tokens,
118
+ )
119
+
120
+ def _request(self, thread: Thread, *, with_tools: bool) -> ChatRequest:
121
+ return ChatRequest(
122
+ messages=list(thread.messages),
123
+ system=self.system or None,
124
+ max_output_tokens=self.max_output_tokens,
125
+ tools=self._tool_declarations() if with_tools else (),
126
+ tool_choice=None if with_tools else "none",
127
+ operation="agent",
128
+ )
129
+
130
+ def _final_turn(self, thread: Thread, reason: StopReason) -> StopReason:
131
+ """One last call with tools disabled, so the user gets something.
132
+
133
+ The model is told what happened and asked to answer with what it has.
134
+ A partial answer that names the gap beats an empty response, and it
135
+ beats an answer that pretends the gap is not there.
136
+ """
137
+ thread.add(
138
+ Role.USER,
139
+ "You have reached the limit for this task "
140
+ f"({reason.value}). Do not call any more tools. Answer with what "
141
+ "you already have, and state explicitly what is still missing.",
142
+ )
143
+ try:
144
+ result = self.client.complete(self._request(thread, with_tools=False))
145
+ except Exception as error: # noqa: BLE001 - the run is already ending
146
+ self._emit(thread, "final_turn", str(error), ok=False)
147
+ return reason
148
+ self._charge(thread, result)
149
+ thread.add(Role.ASSISTANT, result.text)
150
+ thread.answer = result.text
151
+ # `tool_choice="none"` should make this impossible. If a provider
152
+ # ignores it, the run ends with an empty answer and that has to be
153
+ # visible in the trace rather than look like a normal stop.
154
+ self._emit(
155
+ thread,
156
+ "final_turn",
157
+ reason.value,
158
+ ok=bool(result.text) and not result.wants_tools,
159
+ )
160
+ return reason
161
+
162
+ def _run_tool_calls(
163
+ self, thread: Thread, calls: Sequence[ToolCall], ctx: CallContext
164
+ ) -> StopReason | None:
165
+ """Execute a turn's tool calls and feed every result back.
166
+
167
+ Returns a stop reason when the turn itself ends the run (a loop, an
168
+ approval gate, or too many tool errors); otherwise None.
169
+ """
170
+ pairs = [(c.name, c.arguments) for c in calls]
171
+ results = self.executor.call_many(pairs, ctx, allowlist=self.allowlist)
172
+
173
+ stop: StopReason | None = None
174
+ for call, result in zip(calls, results, strict=True):
175
+ signature = call_signature(call.name, call.arguments)
176
+ occurrences = thread.detector.observe(signature)
177
+
178
+ content = result.content
179
+ if thread.detector.should_warn(occurrences):
180
+ # The warning goes INTO the tool result, where the model is
181
+ # already looking, rather than into a separate system message
182
+ # it may not weigh the same way.
183
+ content += REPEAT_WARNING
184
+ self._emit(thread, "repeat_warning", call.name)
185
+ elif thread.detector.should_stop(occurrences):
186
+ stop = stop or StopReason.LOOP_DETECTED
187
+ self._emit(thread, "loop_detected", call.name, ok=False)
188
+
189
+ if not result.ok:
190
+ thread.spend.tool_errors += 1
191
+ if result.error_code == "needs_approval":
192
+ thread.pending_approval = signature
193
+ stop = stop or StopReason.NEEDS_APPROVAL
194
+
195
+ if result.ok and self.executor.registry.get(call.name) is not None:
196
+ spec = self.executor.registry.get(call.name)
197
+ if spec and spec.side_effect:
198
+ thread.tool_results[signature] = result.content
199
+
200
+ # Every call gets a result, including refused and failed ones.
201
+ thread.add(
202
+ Role.TOOL,
203
+ json.dumps(
204
+ {
205
+ "tool_call_id": call.id,
206
+ "tool": call.name,
207
+ "ok": result.ok,
208
+ "error_code": result.error_code,
209
+ "content": content,
210
+ },
211
+ ensure_ascii=False,
212
+ ),
213
+ )
214
+ self._emit(
215
+ thread,
216
+ "tool_result",
217
+ f"{call.name}:{result.error_code or 'ok'}",
218
+ ok=result.ok,
219
+ )
220
+
221
+ if thread.spend.tool_errors >= self.max_tool_errors:
222
+ stop = stop or StopReason.TOOL_ERROR_CEILING
223
+ return stop
224
+
225
+ # --- public API -------------------------------------------------------
226
+
227
+ def run(
228
+ self, thread: Thread, task: str | None = None, *, ctx: CallContext | None = None
229
+ ) -> RunResult:
230
+ """Run until the task is done or a budget stops it.
231
+
232
+ Passing an existing thread resumes it; that is what makes checkpoint
233
+ recovery ordinary rather than special.
234
+ """
235
+ ctx = ctx or CallContext()
236
+ started = self._now()
237
+ if task:
238
+ thread.goal = thread.goal or task
239
+ thread.add(Role.USER, task)
240
+
241
+ stop: StopReason | None = None
242
+ while True:
243
+ thread.spend.seconds = self._now() - started
244
+ over = self.budgets.exceeded_by(thread.spend)
245
+ if over is not None:
246
+ COUNTERS.increment("agent_budget_stops_total", reason=over.value)
247
+ stop = self._final_turn(thread, over)
248
+ break
249
+
250
+ thread.step += 1
251
+ thread.spend.steps += 1
252
+ self._emit(thread, "step", f"step {thread.step}")
253
+
254
+ try:
255
+ result = self.client.complete(self._request(thread, with_tools=True))
256
+ except Exception as error: # noqa: BLE001 - surface, do not crash
257
+ self._emit(thread, "model_error", str(error), ok=False)
258
+ stop = StopReason.CANCELLED
259
+ break
260
+
261
+ self._charge(thread, result)
262
+
263
+ if not result.wants_tools:
264
+ thread.add(Role.ASSISTANT, result.text)
265
+ thread.answer = result.text
266
+ stop = StopReason.FINISHED
267
+ self._emit(thread, "finished", "no further tool calls")
268
+ break
269
+
270
+ thread.add(
271
+ Role.ASSISTANT,
272
+ result.text
273
+ or json.dumps(
274
+ [
275
+ {"tool": c.name, "arguments": c.arguments}
276
+ for c in result.tool_calls
277
+ ],
278
+ ensure_ascii=False,
279
+ ),
280
+ )
281
+ turn_stop = self._run_tool_calls(thread, result.tool_calls, ctx)
282
+ if turn_stop is not None:
283
+ if turn_stop is StopReason.NEEDS_APPROVAL:
284
+ stop = turn_stop
285
+ else:
286
+ stop = self._final_turn(thread, turn_stop)
287
+ break
288
+
289
+ thread.spend.seconds = self._now() - started
290
+ thread.stop_reason = (stop or StopReason.FINISHED).value
291
+ COUNTERS.increment("agent_runs_total", reason=thread.stop_reason)
292
+ return RunResult(
293
+ thread=thread,
294
+ stop_reason=StopReason(thread.stop_reason),
295
+ answer=thread.answer,
296
+ )
@@ -0,0 +1,70 @@
1
+ """Signature-based loop detection.
2
+
3
+ The failure mode this catches is specific and common: an agent calls
4
+ `get_order(id=42)`, does not like the answer, and calls `get_order(id=42)`
5
+ again. Nothing errors. Every step looks healthy. The budget drains.
6
+
7
+ Detection needs a stable identity for "the same call", which the tool layer
8
+ already provides — the call signature is the tool name plus normalized
9
+ arguments, so argument reordering does not disguise a repeat.
10
+
11
+ The response is graduated, and that matters:
12
+
13
+ 2nd occurrence a warning is injected INTO the tool result
14
+ 3rd occurrence the run stops with `loop_detected`
15
+
16
+ Warning before stopping is the useful part. A model that is told "this call
17
+ returned the same result as before; try a different approach" frequently does
18
+ try a different approach, and the run completes. Stopping at the second
19
+ occurrence would kill runs that were about to recover; never stopping turns a
20
+ stuck run into a full budget burn.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+
27
+ __all__ = ["LoopDetector", "REPEAT_WARNING"]
28
+
29
+ REPEAT_WARNING = (
30
+ "\n\n[note: this exact call was already made earlier in this run and "
31
+ "returned the same result. Repeating it will not produce new information. "
32
+ "Use a different tool, different arguments, or answer with what you have.]"
33
+ )
34
+
35
+
36
+ @dataclass
37
+ class LoopDetector:
38
+ """Counts repeated call signatures within one run."""
39
+
40
+ warn_at: int = 2
41
+ stop_at: int = 3
42
+ counts: dict[str, int] = field(default_factory=dict)
43
+
44
+ def observe(self, signature: str) -> int:
45
+ """Record an occurrence and return how many times it has been seen."""
46
+ self.counts[signature] = self.counts.get(signature, 0) + 1
47
+ return self.counts[signature]
48
+
49
+ def should_warn(self, occurrences: int) -> bool:
50
+ return self.warn_at <= occurrences < self.stop_at
51
+
52
+ def should_stop(self, occurrences: int) -> bool:
53
+ return occurrences >= self.stop_at
54
+
55
+ @property
56
+ def repeated_signatures(self) -> list[str]:
57
+ return [sig for sig, count in self.counts.items() if count > 1]
58
+
59
+ def as_dict(self) -> dict[str, int]:
60
+ return dict(self.counts)
61
+
62
+ @classmethod
63
+ def from_dict(
64
+ cls, data: dict, *, warn_at: int = 2, stop_at: int = 3
65
+ ) -> LoopDetector:
66
+ return cls(
67
+ warn_at=warn_at,
68
+ stop_at=stop_at,
69
+ counts={k: int(v) for k, v in data.items()},
70
+ )
@@ -0,0 +1,112 @@
1
+ """Run metrics, and the order in which to read them.
2
+
3
+ Seven numbers, and reading them in the wrong order wastes days. The order is:
4
+
5
+ 1. loop_rate are runs getting stuck?
6
+ 2. budget_stop_rate are they being cut off?
7
+ 3. recovery_rate do they survive a tool failure?
8
+
9
+ Loop rate comes first because a loop inflates everything downstream: steps,
10
+ cost, and the budget stop rate. Tuning budgets while the loop rate is high
11
+ means raising the ceiling on wasted work.
12
+
13
+ Budget stop rate comes second. A high rate with a low loop rate means the
14
+ budget is genuinely too tight; the same rate with a high loop rate means it is
15
+ doing its job.
16
+
17
+ Recovery rate comes last and is the most interesting: it is the share of runs
18
+ that hit a tool error and still finished. A low recovery rate says the error
19
+ messages the tool layer produces are not actionable — which is a prompt and
20
+ tool-description problem, not a budget one.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import statistics
26
+ from collections.abc import Sequence
27
+ from dataclasses import dataclass
28
+ from decimal import Decimal
29
+
30
+ from .budget import StopReason
31
+ from .thread import Thread
32
+
33
+ __all__ = ["RunMetrics", "run_metrics"]
34
+
35
+
36
+ @dataclass
37
+ class RunMetrics:
38
+ runs: int = 0
39
+ completion_rate: float = 0.0
40
+ steps_p50: float = 0.0
41
+ steps_p95: float = 0.0
42
+ tool_error_rate: float = 0.0
43
+ loop_rate: float = 0.0
44
+ budget_stop_rate: float = 0.0
45
+ recovery_rate: float = 0.0
46
+ mean_usd: str = "0"
47
+
48
+ def as_dict(self) -> dict[str, object]:
49
+ return {
50
+ "runs": self.runs,
51
+ "completion_rate": self.completion_rate,
52
+ "steps_p50": self.steps_p50,
53
+ "steps_p95": self.steps_p95,
54
+ "tool_error_rate": self.tool_error_rate,
55
+ "loop_rate": self.loop_rate,
56
+ "budget_stop_rate": self.budget_stop_rate,
57
+ "recovery_rate": self.recovery_rate,
58
+ "mean_usd": self.mean_usd,
59
+ }
60
+
61
+
62
+ def _percentile(values: Sequence[float], p: int) -> float:
63
+ if not values:
64
+ return 0.0
65
+ ordered = sorted(values)
66
+ index = max(0, min(len(ordered) - 1, round(p / 100 * len(ordered)) - 1))
67
+ return ordered[index]
68
+
69
+
70
+ def run_metrics(threads: Sequence[Thread]) -> RunMetrics:
71
+ """Aggregate a batch of finished runs."""
72
+ if not threads:
73
+ return RunMetrics()
74
+
75
+ total = len(threads)
76
+ steps = [float(t.step) for t in threads]
77
+ finished = sum(t.stop_reason == StopReason.FINISHED for t in threads)
78
+ looped = sum(t.stop_reason == StopReason.LOOP_DETECTED for t in threads)
79
+ budget_stopped = sum(
80
+ StopReason(t.stop_reason).is_budget for t in threads if t.stop_reason
81
+ )
82
+
83
+ tool_events = [e for t in threads for e in t.trace if e.kind == "tool_result"]
84
+ tool_errors = sum(not e.ok for e in tool_events)
85
+
86
+ # A run "recovered" if it saw a tool error and still finished. Runs that
87
+ # never hit an error are excluded from the denominator — including them
88
+ # would make the rate a measure of how rarely tools fail.
89
+ had_errors = [
90
+ t for t in threads if any(e.kind == "tool_result" and not e.ok for e in t.trace)
91
+ ]
92
+ recovered = sum(t.stop_reason == StopReason.FINISHED for t in had_errors)
93
+
94
+ mean_usd = (
95
+ sum((t.spend.usd for t in threads), Decimal(0)) / Decimal(total)
96
+ if total
97
+ else Decimal(0)
98
+ )
99
+
100
+ return RunMetrics(
101
+ runs=total,
102
+ completion_rate=round(finished / total, 3),
103
+ steps_p50=round(statistics.median(steps), 2),
104
+ steps_p95=round(_percentile(steps, 95), 2),
105
+ tool_error_rate=(
106
+ round(tool_errors / len(tool_events), 3) if tool_events else 0.0
107
+ ),
108
+ loop_rate=round(looped / total, 3),
109
+ budget_stop_rate=round(budget_stopped / total, 3),
110
+ recovery_rate=round(recovered / len(had_errors), 3) if had_errors else 1.0,
111
+ mean_usd=f"{mean_usd:.6f}",
112
+ )