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.
- aimai_kit/__init__.py +1 -0
- aimai_kit/agent/__init__.py +28 -0
- aimai_kit/agent/budget.py +118 -0
- aimai_kit/agent/loop.py +296 -0
- aimai_kit/agent/loopdetect.py +70 -0
- aimai_kit/agent/metrics.py +112 -0
- aimai_kit/agent/scripted.py +72 -0
- aimai_kit/agent/thread.py +142 -0
- aimai_kit/harness/__init__.py +38 -0
- aimai_kit/harness/compaction.py +126 -0
- aimai_kit/harness/context.py +133 -0
- aimai_kit/harness/memory.py +230 -0
- aimai_kit/harness/sandbox.py +97 -0
- aimai_kit/harness/segments.py +112 -0
- aimai_kit/harness/spill.py +134 -0
- aimai_kit/harness/stub_summarizer.py +81 -0
- aimai_kit/harness/subagent.py +162 -0
- aimai_kit/prompts/__init__.py +27 -0
- aimai_kit/prompts/blocks.py +89 -0
- aimai_kit/prompts/budget.py +238 -0
- aimai_kit/prompts/cli.py +448 -0
- aimai_kit/prompts/grounding.py +155 -0
- aimai_kit/prompts/guard.py +77 -0
- aimai_kit/prompts/pipeline.py +96 -0
- aimai_kit/prompts/registry.py +171 -0
- aimai_kit/prompts/schemas.py +247 -0
- aimai_kit/prompts/structured.py +196 -0
- aimai_kit/prompts/stub.py +203 -0
- aimai_kit/provider/__init__.py +1 -0
- aimai_kit/provider/adapters/__init__.py +51 -0
- aimai_kit/provider/adapters/_shared.py +37 -0
- aimai_kit/provider/adapters/anthropic_.py +185 -0
- aimai_kit/provider/adapters/azure_openai.py +64 -0
- aimai_kit/provider/adapters/gemini_.py +162 -0
- aimai_kit/provider/adapters/openai_.py +187 -0
- aimai_kit/provider/cli/__init__.py +1 -0
- aimai_kit/provider/cli/probe.py +313 -0
- aimai_kit/provider/client.py +45 -0
- aimai_kit/provider/counters.py +62 -0
- aimai_kit/provider/counting.py +135 -0
- aimai_kit/provider/determinism.py +63 -0
- aimai_kit/provider/errors.py +101 -0
- aimai_kit/provider/pricing.py +129 -0
- aimai_kit/provider/resilient.py +229 -0
- aimai_kit/provider/telemetry.py +147 -0
- aimai_kit/provider/types.py +192 -0
- aimai_kit/py.typed +0 -0
- aimai_kit/tools/__init__.py +36 -0
- aimai_kit/tools/decorator.py +175 -0
- aimai_kit/tools/evaluation.py +227 -0
- aimai_kit/tools/examples/__init__.py +12 -0
- aimai_kit/tools/examples/orders.py +237 -0
- aimai_kit/tools/executor.py +334 -0
- aimai_kit/tools/export.py +111 -0
- aimai_kit/tools/idempotency.py +86 -0
- aimai_kit/tools/registry.py +74 -0
- aimai_kit/tools/spec.py +97 -0
- aimai_kit-1.0.0.dist-info/METADATA +238 -0
- aimai_kit-1.0.0.dist-info/RECORD +62 -0
- aimai_kit-1.0.0.dist-info/WHEEL +4 -0
- aimai_kit-1.0.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|
aimai_kit/agent/loop.py
ADDED
|
@@ -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
|
+
)
|