minima-cli 0.4.9__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.
- minima/__init__.py +5 -0
- minima/api/__init__.py +1 -0
- minima/api/auth.py +39 -0
- minima/api/errors.py +40 -0
- minima/api/routers/__init__.py +1 -0
- minima/api/routers/calibration.py +50 -0
- minima/api/routers/feedback.py +279 -0
- minima/api/routers/health.py +50 -0
- minima/api/routers/models.py +42 -0
- minima/api/routers/recommend.py +66 -0
- minima/api/routers/savings.py +55 -0
- minima/api/routers/strategies.py +33 -0
- minima/catalog/__init__.py +1 -0
- minima/catalog/data/capability_priors.json +210 -0
- minima/catalog/data/model_aliases.json +12 -0
- minima/catalog/merge.py +69 -0
- minima/catalog/refresh.py +54 -0
- minima/catalog/sources/__init__.py +1 -0
- minima/catalog/sources/litellm.py +19 -0
- minima/catalog/sources/openrouter.py +25 -0
- minima/catalog/store.py +86 -0
- minima/config.py +288 -0
- minima/deps.py +35 -0
- minima/llm/__init__.py +1 -0
- minima/llm/anthropic.py +106 -0
- minima/llm/base.py +196 -0
- minima/llm/gemini.py +124 -0
- minima/llm/registry.py +54 -0
- minima/logging.py +28 -0
- minima/main.py +109 -0
- minima/memory/__init__.py +1 -0
- minima/memory/adapter.py +572 -0
- minima/memory/keys.py +83 -0
- minima/memory/records.py +190 -0
- minima/memory/threadpool.py +41 -0
- minima/metrics/__init__.py +1 -0
- minima/metrics/calibration.py +415 -0
- minima/metrics/report.py +116 -0
- minima/metrics/savings.py +98 -0
- minima/recommender/__init__.py +1 -0
- minima/recommender/_pg_pool.py +38 -0
- minima/recommender/_redis_client.py +32 -0
- minima/recommender/aggregate.py +157 -0
- minima/recommender/classify.py +165 -0
- minima/recommender/decisionlog.py +505 -0
- minima/recommender/durablerefs.py +312 -0
- minima/recommender/engine.py +997 -0
- minima/recommender/escalation.py +83 -0
- minima/recommender/propensity.py +189 -0
- minima/recommender/recstore.py +368 -0
- minima/recommender/score.py +318 -0
- minima/recommender/types.py +166 -0
- minima/schemas/__init__.py +1 -0
- minima/schemas/common.py +73 -0
- minima/schemas/feedback.py +34 -0
- minima/schemas/models_catalog.py +36 -0
- minima/schemas/recommend.py +104 -0
- minima/schemas/savings.py +39 -0
- minima/schemas/strategies.py +57 -0
- minima/schemas/workflow.py +43 -0
- minima/seeding/__init__.py +1 -0
- minima/seeding/items.py +42 -0
- minima/seeding/llmrouterbench.py +232 -0
- minima/seeding/routerbench.py +141 -0
- minima/seeding/run_seed.py +56 -0
- minima/seeding/synthetic.py +70 -0
- minima/tenancy/__init__.py +8 -0
- minima/tenancy/context.py +37 -0
- minima/tenancy/passthrough.py +110 -0
- minima/version.py +3 -0
- minima_cli-0.4.9.dist-info/METADATA +275 -0
- minima_cli-0.4.9.dist-info/RECORD +161 -0
- minima_cli-0.4.9.dist-info/WHEEL +4 -0
- minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
- minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
- minima_client/__init__.py +19 -0
- minima_client/autocapture.py +101 -0
- minima_client/client.py +301 -0
- minima_client/errors.py +23 -0
- minima_harness/LICENSE_PI +32 -0
- minima_harness/__init__.py +16 -0
- minima_harness/agent/__init__.py +72 -0
- minima_harness/agent/agent.py +276 -0
- minima_harness/agent/events.py +124 -0
- minima_harness/agent/loop.py +311 -0
- minima_harness/agent/state.py +79 -0
- minima_harness/agent/tools.py +97 -0
- minima_harness/ai/__init__.py +66 -0
- minima_harness/ai/compat.py +71 -0
- minima_harness/ai/errors.py +96 -0
- minima_harness/ai/events.py +117 -0
- minima_harness/ai/openrouter_catalog.py +153 -0
- minima_harness/ai/provider_catalog.py +299 -0
- minima_harness/ai/provider_quirks.py +37 -0
- minima_harness/ai/providers/__init__.py +75 -0
- minima_harness/ai/providers/_common.py +48 -0
- minima_harness/ai/providers/anthropic.py +290 -0
- minima_harness/ai/providers/base.py +65 -0
- minima_harness/ai/providers/faux.py +173 -0
- minima_harness/ai/providers/google.py +221 -0
- minima_harness/ai/providers/openai_compat.py +278 -0
- minima_harness/ai/registry.py +184 -0
- minima_harness/ai/stream.py +82 -0
- minima_harness/ai/tools.py +51 -0
- minima_harness/ai/types.py +204 -0
- minima_harness/ai/usage.py +41 -0
- minima_harness/minima/__init__.py +40 -0
- minima_harness/minima/cache.py +102 -0
- minima_harness/minima/config.py +85 -0
- minima_harness/minima/goals.py +226 -0
- minima_harness/minima/judge.py +144 -0
- minima_harness/minima/mapping.py +147 -0
- minima_harness/minima/meter.py +143 -0
- minima_harness/minima/router.py +220 -0
- minima_harness/minima/runtime.py +544 -0
- minima_harness/minima/signals.py +195 -0
- minima_harness/session/__init__.py +14 -0
- minima_harness/session/format.py +35 -0
- minima_harness/session/store.py +236 -0
- minima_harness/tasks/__init__.py +17 -0
- minima_harness/tasks/task_set.py +78 -0
- minima_harness/tools/__init__.py +7 -0
- minima_harness/tools/_io.py +34 -0
- minima_harness/tools/bash.py +70 -0
- minima_harness/tools/builtin.py +23 -0
- minima_harness/tools/edit.py +50 -0
- minima_harness/tools/find.py +38 -0
- minima_harness/tools/grep.py +73 -0
- minima_harness/tools/ls.py +35 -0
- minima_harness/tools/read.py +38 -0
- minima_harness/tools/tasks.py +75 -0
- minima_harness/tools/write.py +36 -0
- minima_harness/tui/__init__.py +3 -0
- minima_harness/tui/analytics.py +111 -0
- minima_harness/tui/app.py +1927 -0
- minima_harness/tui/bridge.py +103 -0
- minima_harness/tui/cli.py +227 -0
- minima_harness/tui/clipboard.py +60 -0
- minima_harness/tui/commands.py +49 -0
- minima_harness/tui/compaction.py +17 -0
- minima_harness/tui/config_cli.py +141 -0
- minima_harness/tui/config_store.py +237 -0
- minima_harness/tui/context.py +93 -0
- minima_harness/tui/customize.py +95 -0
- minima_harness/tui/diff.py +53 -0
- minima_harness/tui/editor.py +43 -0
- minima_harness/tui/extensions.py +84 -0
- minima_harness/tui/extra_models.py +52 -0
- minima_harness/tui/history.py +71 -0
- minima_harness/tui/mubit.py +295 -0
- minima_harness/tui/overlays.py +593 -0
- minima_harness/tui/packages.py +59 -0
- minima_harness/tui/run_modes.py +66 -0
- minima_harness/tui/theme.py +77 -0
- minima_harness/tui/welcome.py +83 -0
- minima_harness/tui/widgets/__init__.py +3 -0
- minima_harness/tui/widgets/banner.py +38 -0
- minima_harness/tui/widgets/editor.py +83 -0
- minima_harness/tui/widgets/footer.py +73 -0
- minima_harness/tui/widgets/messages.py +151 -0
- minima_harness/tui/widgets/status.py +57 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""The agent loop — a port of PI's ``agentLoop`` async generator.
|
|
2
|
+
|
|
3
|
+
Runs turn after turn: stream the model -> emit message events -> if it requested tools,
|
|
4
|
+
execute them (parallel via anyio, with before/afterToolCall hooks) -> append tool
|
|
5
|
+
results -> continue. Steering/follow-up queues are drained between turns. Emits the full
|
|
6
|
+
PI event taxonomy so any subscriber can render the run.
|
|
7
|
+
|
|
8
|
+
Tool-execution events are replayed in completion order (the loop awaits the whole batch,
|
|
9
|
+
then yields the buffered events); final ``toolResult`` messages are appended in assistant
|
|
10
|
+
source order. This is correct and deterministic for the harness's needs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import AsyncIterator
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import anyio
|
|
20
|
+
from pydantic import ValidationError
|
|
21
|
+
|
|
22
|
+
from minima_harness.agent.events import (
|
|
23
|
+
AgentEndEvent,
|
|
24
|
+
AgentStartEvent,
|
|
25
|
+
MessageEndEvent,
|
|
26
|
+
MessageStartEvent,
|
|
27
|
+
MessageUpdateEvent,
|
|
28
|
+
ToolExecutionEndEvent,
|
|
29
|
+
ToolExecutionStartEvent,
|
|
30
|
+
ToolExecutionUpdateEvent,
|
|
31
|
+
TurnEndEvent,
|
|
32
|
+
TurnStartEvent,
|
|
33
|
+
)
|
|
34
|
+
from minima_harness.agent.state import AgentLoopConfig, AgentState
|
|
35
|
+
from minima_harness.agent.tools import (
|
|
36
|
+
AfterToolCallContext,
|
|
37
|
+
BeforeToolCallContext,
|
|
38
|
+
ToolResult,
|
|
39
|
+
error_result,
|
|
40
|
+
find_agent_tool,
|
|
41
|
+
)
|
|
42
|
+
from minima_harness.ai.stream import stream as default_stream
|
|
43
|
+
from minima_harness.ai.types import Context, Message, Tool
|
|
44
|
+
|
|
45
|
+
_PendingTool = tuple[Any, Any, Any] # (tool_call, AgentTool, validated_params)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def agent_loop(
|
|
49
|
+
prompts: list[Message],
|
|
50
|
+
state: AgentState,
|
|
51
|
+
config: AgentLoopConfig,
|
|
52
|
+
) -> AsyncIterator[Any]:
|
|
53
|
+
"""Run the agent over ``prompts`` appended to ``state``, yielding AgentEvents."""
|
|
54
|
+
if state.model is None:
|
|
55
|
+
raise ValueError("AgentState.model must be set before running the loop")
|
|
56
|
+
|
|
57
|
+
yield AgentStartEvent()
|
|
58
|
+
|
|
59
|
+
for prompt in prompts:
|
|
60
|
+
state.messages.append(prompt)
|
|
61
|
+
yield MessageStartEvent(message=prompt)
|
|
62
|
+
yield MessageEndEvent(message=prompt)
|
|
63
|
+
|
|
64
|
+
stream_fn = config.stream_fn or default_stream
|
|
65
|
+
turns = 0
|
|
66
|
+
while turns < config.max_turns:
|
|
67
|
+
turns += 1
|
|
68
|
+
yield TurnStartEvent()
|
|
69
|
+
|
|
70
|
+
llm_messages = await _prepare_messages(state, config)
|
|
71
|
+
ctx = Context(
|
|
72
|
+
system_prompt=state.system_prompt,
|
|
73
|
+
messages=llm_messages,
|
|
74
|
+
tools=[
|
|
75
|
+
Tool(name=t.name, description=t.description, parameters=t.parameters)
|
|
76
|
+
for t in state.tools
|
|
77
|
+
],
|
|
78
|
+
)
|
|
79
|
+
options = _stream_options(config)
|
|
80
|
+
s = stream_fn(state.model, ctx, options=options)
|
|
81
|
+
yield MessageStartEvent(message=None)
|
|
82
|
+
async for stream_event in s:
|
|
83
|
+
yield MessageUpdateEvent(assistant_message_event=stream_event)
|
|
84
|
+
assistant = await s.result()
|
|
85
|
+
state.streaming_message = assistant
|
|
86
|
+
state.messages.append(assistant)
|
|
87
|
+
yield MessageEndEvent(message=assistant)
|
|
88
|
+
|
|
89
|
+
if assistant.stop_reason == "error":
|
|
90
|
+
state.error_message = assistant.error_message or "provider error"
|
|
91
|
+
yield TurnEndEvent(message=assistant, tool_results=[])
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
tool_calls = assistant.tool_calls if assistant.stop_reason == "toolUse" else []
|
|
95
|
+
results: list[tuple[Any, ToolResult, bool]] = []
|
|
96
|
+
if tool_calls:
|
|
97
|
+
async for ev in _execute_tool_calls(tool_calls, config, state, results):
|
|
98
|
+
yield ev
|
|
99
|
+
for tc, result, is_error in results:
|
|
100
|
+
tr = Message(
|
|
101
|
+
role="toolResult",
|
|
102
|
+
tool_call_id=tc.id,
|
|
103
|
+
tool_name=tc.name,
|
|
104
|
+
content=result.content,
|
|
105
|
+
is_error=is_error,
|
|
106
|
+
)
|
|
107
|
+
state.messages.append(tr)
|
|
108
|
+
yield MessageStartEvent(message=tr)
|
|
109
|
+
yield MessageEndEvent(message=tr)
|
|
110
|
+
|
|
111
|
+
yield TurnEndEvent(message=assistant, tool_results=[r for _, r, _ in results])
|
|
112
|
+
|
|
113
|
+
if results and all(r.terminate for _, r, _ in results):
|
|
114
|
+
break
|
|
115
|
+
if config.should_stop_after_turn is not None and await config.should_stop_after_turn(
|
|
116
|
+
assistant, [r for _, r, _ in results], state, state.messages
|
|
117
|
+
):
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
injected = _pop_queue(state.steering, state.steering_mode)
|
|
121
|
+
if injected:
|
|
122
|
+
for m in injected:
|
|
123
|
+
state.messages.append(m)
|
|
124
|
+
yield MessageStartEvent(message=m)
|
|
125
|
+
yield MessageEndEvent(message=m)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if not tool_calls:
|
|
129
|
+
injected = _pop_queue(state.follow_up, state.follow_up_mode)
|
|
130
|
+
if injected:
|
|
131
|
+
for m in injected:
|
|
132
|
+
state.messages.append(m)
|
|
133
|
+
yield MessageStartEvent(message=m)
|
|
134
|
+
yield MessageEndEvent(message=m)
|
|
135
|
+
continue
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
state.turns_taken = turns
|
|
139
|
+
state.streaming_message = None
|
|
140
|
+
yield AgentEndEvent(messages=list(state.messages))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def agent_loop_continue(state: AgentState, config: AgentLoopConfig) -> AsyncIterator[Any]:
|
|
144
|
+
"""Resume from existing context (last message must be user or toolResult)."""
|
|
145
|
+
if state.messages:
|
|
146
|
+
last = state.messages[-1]
|
|
147
|
+
if last.role == "assistant":
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"agent_loop_continue requires the last message to be user or toolResult"
|
|
150
|
+
)
|
|
151
|
+
# Trick: agent_loop is an async generator; forward its yields.
|
|
152
|
+
async for ev in agent_loop([], state, config): # pragma: no cover - delegated
|
|
153
|
+
yield ev
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Helpers
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _drop_failed_calls(messages: list[Message]) -> list[Message]:
|
|
162
|
+
"""Never send a failed call's assistant to a provider.
|
|
163
|
+
|
|
164
|
+
A provider error is swallowed into an assistant with ``stop_reason == "error"`` and
|
|
165
|
+
(usually) empty content, which the loop appends to history before breaking. Left in the
|
|
166
|
+
context it makes the *next* request invalid — e.g. Anthropic rejects an empty text block
|
|
167
|
+
with ``400 "messages: text content blocks must be non-empty"`` — so a single provider
|
|
168
|
+
hiccup would wedge the whole session. Filtering here protects every consumer of the loop
|
|
169
|
+
(not just :class:`MinimaAgent`) and resumed sessions alike.
|
|
170
|
+
"""
|
|
171
|
+
return [m for m in messages if getattr(m, "stop_reason", None) != "error"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _prepare_messages(state: AgentState, config: AgentLoopConfig) -> list[Message]:
|
|
175
|
+
messages = _drop_failed_calls(state.messages)
|
|
176
|
+
if config.transform_context is not None:
|
|
177
|
+
messages = await config.transform_context(messages, None)
|
|
178
|
+
return config.convert_to_llm(messages)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _stream_options(config: AgentLoopConfig) -> dict[str, Any]:
|
|
182
|
+
opts: dict[str, Any] = dict(config.stream_options or {})
|
|
183
|
+
opts["thinking"] = config.thinking_level != "off"
|
|
184
|
+
if config.thinking_level != "off":
|
|
185
|
+
budget = (config.thinking_budgets or {}).get(config.thinking_level)
|
|
186
|
+
if budget is not None:
|
|
187
|
+
opts["thinking_budget"] = budget
|
|
188
|
+
if config.session_id:
|
|
189
|
+
opts["session_id"] = config.session_id
|
|
190
|
+
return opts
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _execute_tool_calls(
|
|
194
|
+
tool_calls: list[Any],
|
|
195
|
+
config: AgentLoopConfig,
|
|
196
|
+
state: AgentState,
|
|
197
|
+
out_results: list[tuple[Any, ToolResult, bool]],
|
|
198
|
+
) -> AsyncIterator[Any]:
|
|
199
|
+
"""Preflight (yield tool_execution_start) then execute; yield updates + ends.
|
|
200
|
+
|
|
201
|
+
Appends results to ``out_results`` in assistant SOURCE order; buffered events are
|
|
202
|
+
replayed in completion order.
|
|
203
|
+
"""
|
|
204
|
+
plan: list[_PendingTool] = []
|
|
205
|
+
for tc in tool_calls:
|
|
206
|
+
state.pending_tool_calls.add(tc.id)
|
|
207
|
+
tool = find_agent_tool(state.tools, tc.name)
|
|
208
|
+
if tool is None:
|
|
209
|
+
yield ToolExecutionStartEvent(tool_call_id=tc.id, tool_name=tc.name, args=None)
|
|
210
|
+
res = error_result(f"Unknown tool: {tc.name}")
|
|
211
|
+
yield ToolExecutionEndEvent(tool_call_id=tc.id, result=res, is_error=True)
|
|
212
|
+
out_results.append((tc, res, True))
|
|
213
|
+
state.pending_tool_calls.discard(tc.id)
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
params = tool.parameters.model_validate(tc.arguments)
|
|
217
|
+
except ValidationError as exc:
|
|
218
|
+
yield ToolExecutionStartEvent(tool_call_id=tc.id, tool_name=tc.name, args=None)
|
|
219
|
+
res = error_result(_format_validation_error(exc))
|
|
220
|
+
yield ToolExecutionEndEvent(tool_call_id=tc.id, result=res, is_error=True)
|
|
221
|
+
out_results.append((tc, res, True))
|
|
222
|
+
state.pending_tool_calls.discard(tc.id)
|
|
223
|
+
continue
|
|
224
|
+
yield ToolExecutionStartEvent(tool_call_id=tc.id, tool_name=tc.name, args=params)
|
|
225
|
+
if config.before_tool_call is not None:
|
|
226
|
+
decision = await config.before_tool_call(
|
|
227
|
+
BeforeToolCallContext(tool_call=tc, args=params, context=state)
|
|
228
|
+
)
|
|
229
|
+
if decision is not None and decision.block:
|
|
230
|
+
res = error_result(decision.reason or "blocked by beforeToolCall")
|
|
231
|
+
yield ToolExecutionEndEvent(tool_call_id=tc.id, result=res, is_error=True)
|
|
232
|
+
out_results.append((tc, res, True))
|
|
233
|
+
state.pending_tool_calls.discard(tc.id)
|
|
234
|
+
continue
|
|
235
|
+
plan.append((tc, tool, params))
|
|
236
|
+
|
|
237
|
+
sequential = config.tool_execution == "sequential" or any(
|
|
238
|
+
t.execution_mode == "sequential" for _, t, _ in plan
|
|
239
|
+
)
|
|
240
|
+
completion: list[tuple[Any, list, ToolResult, bool]] = []
|
|
241
|
+
if sequential:
|
|
242
|
+
for tc, tool, params in plan:
|
|
243
|
+
completion.append(await _run_one(tc, tool, params, config, state))
|
|
244
|
+
else:
|
|
245
|
+
async with anyio.create_task_group() as tg:
|
|
246
|
+
|
|
247
|
+
async def runner(tc: Any, tool: Any, params: Any) -> None:
|
|
248
|
+
completion.append(await _run_one(tc, tool, params, config, state))
|
|
249
|
+
|
|
250
|
+
for tc, tool, params in plan:
|
|
251
|
+
tg.start_soon(runner, tc, tool, params)
|
|
252
|
+
|
|
253
|
+
by_id: dict[str, tuple[Any, ToolResult, bool]] = {}
|
|
254
|
+
for tc, updates, result, is_error in completion:
|
|
255
|
+
for upd in updates:
|
|
256
|
+
yield upd
|
|
257
|
+
yield ToolExecutionEndEvent(tool_call_id=tc.id, result=result, is_error=is_error)
|
|
258
|
+
state.pending_tool_calls.discard(tc.id)
|
|
259
|
+
by_id[tc.id] = (tc, result, is_error)
|
|
260
|
+
|
|
261
|
+
for tc, _, _ in plan: # source order
|
|
262
|
+
out_results.append(by_id[tc.id])
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def _run_one(
|
|
266
|
+
tc: Any, tool: Any, params: Any, config: AgentLoopConfig, state: AgentState
|
|
267
|
+
) -> tuple[Any, list, ToolResult, bool]:
|
|
268
|
+
updates: list[Any] = []
|
|
269
|
+
|
|
270
|
+
def on_update(partial: Any) -> None:
|
|
271
|
+
updates.append(ToolExecutionUpdateEvent(tool_call_id=tc.id, partial=partial))
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
result = await tool.execute(tc.id, params, None, on_update)
|
|
275
|
+
is_error = False
|
|
276
|
+
except Exception as exc: # noqa: BLE001 - surface as tool error, not a raise
|
|
277
|
+
result = error_result(str(exc))
|
|
278
|
+
is_error = True
|
|
279
|
+
|
|
280
|
+
if config.after_tool_call is not None:
|
|
281
|
+
ar = await config.after_tool_call(
|
|
282
|
+
AfterToolCallContext(tool_call=tc, result=result, is_error=is_error, context=state)
|
|
283
|
+
)
|
|
284
|
+
if ar is not None:
|
|
285
|
+
if ar.terminate:
|
|
286
|
+
result.terminate = True
|
|
287
|
+
if ar.details is not None:
|
|
288
|
+
result.details = {**result.details, **ar.details}
|
|
289
|
+
if ar.content is not None:
|
|
290
|
+
result.content = ar.content
|
|
291
|
+
|
|
292
|
+
return tc, updates, result, is_error
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _format_validation_error(exc: ValidationError) -> str:
|
|
296
|
+
parts = []
|
|
297
|
+
for err in exc.errors():
|
|
298
|
+
loc = ".".join(str(x) for x in err["loc"]) or "<root>"
|
|
299
|
+
parts.append(f"{loc}: {err['msg']}")
|
|
300
|
+
return "; ".join(parts)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _pop_queue(queue: deque[Message], mode: str) -> list[Message]:
|
|
304
|
+
"""Pop messages per mode: one for ``one-at-a-time``, all for ``all``."""
|
|
305
|
+
if not queue:
|
|
306
|
+
return []
|
|
307
|
+
if mode == "one-at-a-time":
|
|
308
|
+
return [queue.popleft()]
|
|
309
|
+
drained = list(queue)
|
|
310
|
+
queue.clear()
|
|
311
|
+
return drained
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Agent run state and loop config.
|
|
2
|
+
|
|
3
|
+
``AgentState`` is both the observable state (read via ``agent.state``) and the mutable
|
|
4
|
+
context threaded through :func:`agent_loop` — it carries messages, tools, and the
|
|
5
|
+
steering/follow-up queues the Agent pushes into. Loop config is split out so it can be
|
|
6
|
+
rebuilt per run (the Agent holds the knobs; the loop receives a frozen snapshot).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import deque
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from minima_harness.agent.tools import (
|
|
17
|
+
AfterToolCall,
|
|
18
|
+
BeforeToolCall,
|
|
19
|
+
ThinkingLevel,
|
|
20
|
+
ToolExecutionMode,
|
|
21
|
+
)
|
|
22
|
+
from minima_harness.ai.types import AssistantMessage, Message, Model
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class AgentState:
|
|
27
|
+
"""Mutable run state shared between the Agent and the loop."""
|
|
28
|
+
|
|
29
|
+
system_prompt: str | None = None
|
|
30
|
+
model: Model | None = None
|
|
31
|
+
thinking_level: ThinkingLevel = "off"
|
|
32
|
+
tools: list = field(default_factory=list) # list[AgentTool]
|
|
33
|
+
messages: list[Message] = field(default_factory=list)
|
|
34
|
+
# Streaming flags (observable).
|
|
35
|
+
is_streaming: bool = False
|
|
36
|
+
streaming_message: AssistantMessage | None = None
|
|
37
|
+
pending_tool_calls: set[str] = field(default_factory=set)
|
|
38
|
+
error_message: str | None = None
|
|
39
|
+
# Turns actually run in the last agent_loop (token-yield signal: a cheap model that
|
|
40
|
+
# takes many turns to resolve can cost more than one frontier turn). Set by the loop.
|
|
41
|
+
turns_taken: int = 0
|
|
42
|
+
# Queues the Agent pushes into mid-run; drained between turns by the loop.
|
|
43
|
+
steering: deque[Message] = field(default_factory=deque)
|
|
44
|
+
follow_up: deque[Message] = field(default_factory=deque)
|
|
45
|
+
steering_mode: str = "one-at-a-time"
|
|
46
|
+
follow_up_mode: str = "one-at-a-time"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# (messages) -> messages to send to the LLM (filter custom types, prune, etc.)
|
|
50
|
+
ConvertToLlm = Callable[[list[Message]], list[Message]]
|
|
51
|
+
# (messages, signal) -> messages (optional compaction/injection before convert_to_llm)
|
|
52
|
+
TransformContext = Callable[[list[Message], object | None], Awaitable[list[Message]]]
|
|
53
|
+
# Run after a turn settles; return True to stop gracefully (e.g. before compaction).
|
|
54
|
+
ShouldStopAfterTurn = Callable[[AssistantMessage, list, AgentState, list[Message]], Awaitable[bool]]
|
|
55
|
+
StreamFn = Callable[..., Any]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class AgentLoopConfig:
|
|
60
|
+
"""Snapshot of loop behaviour handed to :func:`agent_loop`."""
|
|
61
|
+
|
|
62
|
+
model: Model
|
|
63
|
+
convert_to_llm: ConvertToLlm
|
|
64
|
+
tool_execution: ToolExecutionMode = "parallel"
|
|
65
|
+
before_tool_call: BeforeToolCall | None = None
|
|
66
|
+
after_tool_call: AfterToolCall | None = None
|
|
67
|
+
transform_context: TransformContext | None = None
|
|
68
|
+
should_stop_after_turn: ShouldStopAfterTurn | None = None
|
|
69
|
+
thinking_budgets: dict[str, int] | None = None
|
|
70
|
+
thinking_level: ThinkingLevel = "off"
|
|
71
|
+
max_turns: int = 50
|
|
72
|
+
session_id: str | None = None
|
|
73
|
+
stream_fn: StreamFn | None = None
|
|
74
|
+
stream_options: dict[str, Any] | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def default_convert_to_llm(messages: list[Message]) -> list[Message]:
|
|
78
|
+
"""Drop anything the LLM can't ingest (keeps user/assistant/toolResult)."""
|
|
79
|
+
return [m for m in messages if m.role in ("user", "assistant", "toolResult")]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Agent tools and execution hooks — port of PI's ``AgentTool`` + before/afterToolCall.
|
|
2
|
+
|
|
3
|
+
Tools declare parameters as a pydantic model (the TypeBox analogue); ``execute`` is an
|
|
4
|
+
async callable ``(tool_call_id, params, signal, on_update) -> ToolResult``. Validation
|
|
5
|
+
errors and thrown exceptions become tool-error results fed back to the model so it can
|
|
6
|
+
retry (matching PI).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from minima_harness.ai.types import ContentBlock, TextContent, ToolCall
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from minima_harness.agent.state import AgentState
|
|
21
|
+
|
|
22
|
+
ToolExecutionMode = Literal["parallel", "sequential"]
|
|
23
|
+
ThinkingLevel = Literal["off", "minimal", "low", "medium", "high", "xhigh"]
|
|
24
|
+
|
|
25
|
+
# on_update(partial_result) -> None; called mid-execution for streaming progress.
|
|
26
|
+
ToolUpdate = Callable[[Any], None]
|
|
27
|
+
ToolExecute = Callable[[str, BaseModel, object | None, ToolUpdate | None], Awaitable["ToolResult"]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class ToolResult:
|
|
32
|
+
"""What a tool returns. ``content`` goes to the model; ``details`` are app-facing."""
|
|
33
|
+
|
|
34
|
+
content: list[ContentBlock]
|
|
35
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
# Hint to skip the automatic follow-up LLM call. Only honoured when every finalized
|
|
37
|
+
# tool result in the batch also sets terminate=True.
|
|
38
|
+
terminate: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class AgentTool:
|
|
43
|
+
name: str
|
|
44
|
+
description: str
|
|
45
|
+
parameters: type[BaseModel]
|
|
46
|
+
execute: ToolExecute
|
|
47
|
+
execution_mode: ToolExecutionMode | None = None
|
|
48
|
+
label: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Hook types
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class BeforeToolCallContext:
|
|
58
|
+
tool_call: ToolCall
|
|
59
|
+
args: BaseModel # validated params
|
|
60
|
+
context: AgentState
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class BeforeToolCallResult:
|
|
65
|
+
block: bool = False
|
|
66
|
+
reason: str = ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class AfterToolCallContext:
|
|
71
|
+
tool_call: ToolCall
|
|
72
|
+
result: ToolResult
|
|
73
|
+
is_error: bool
|
|
74
|
+
context: AgentState
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class AfterToolCallResult:
|
|
79
|
+
terminate: bool = False
|
|
80
|
+
details: dict[str, Any] | None = None
|
|
81
|
+
content: list[ContentBlock] | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
BeforeToolCall = Callable[[BeforeToolCallContext], Awaitable[BeforeToolCallResult | None]]
|
|
85
|
+
AfterToolCall = Callable[[AfterToolCallContext], Awaitable[AfterToolCallResult | None]]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_agent_tool(tools: list[AgentTool], name: str) -> AgentTool | None:
|
|
89
|
+
for t in tools:
|
|
90
|
+
if t.name == name:
|
|
91
|
+
return t
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def error_result(message: str) -> ToolResult:
|
|
96
|
+
"""A standard error tool result (single text block)."""
|
|
97
|
+
return ToolResult(content=[TextContent(text=message)])
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""minima_harness.ai — lean Python port of @earendil-works/pi-ai (unified LLM API)."""
|
|
2
|
+
|
|
3
|
+
from minima_harness.ai.events import Event
|
|
4
|
+
from minima_harness.ai.registry import (
|
|
5
|
+
all_models,
|
|
6
|
+
get_model,
|
|
7
|
+
get_models,
|
|
8
|
+
get_providers,
|
|
9
|
+
register_model,
|
|
10
|
+
try_get_model,
|
|
11
|
+
)
|
|
12
|
+
from minima_harness.ai.stream import Stream, complete, stream
|
|
13
|
+
from minima_harness.ai.tools import (
|
|
14
|
+
ToolParamError,
|
|
15
|
+
UnknownToolError,
|
|
16
|
+
find_tool,
|
|
17
|
+
validate_tool_call,
|
|
18
|
+
)
|
|
19
|
+
from minima_harness.ai.types import (
|
|
20
|
+
AssistantMessage,
|
|
21
|
+
Context,
|
|
22
|
+
Cost,
|
|
23
|
+
ImageContent,
|
|
24
|
+
Message,
|
|
25
|
+
Modality,
|
|
26
|
+
Model,
|
|
27
|
+
ModelCost,
|
|
28
|
+
TextContent,
|
|
29
|
+
ThinkingContent,
|
|
30
|
+
Tool,
|
|
31
|
+
ToolCall,
|
|
32
|
+
Usage,
|
|
33
|
+
)
|
|
34
|
+
from minima_harness.ai.usage import attach_cost, cost_for
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AssistantMessage",
|
|
38
|
+
"Context",
|
|
39
|
+
"Cost",
|
|
40
|
+
"Event",
|
|
41
|
+
"ImageContent",
|
|
42
|
+
"Message",
|
|
43
|
+
"Model",
|
|
44
|
+
"ModelCost",
|
|
45
|
+
"Modality",
|
|
46
|
+
"Stream",
|
|
47
|
+
"TextContent",
|
|
48
|
+
"ThinkingContent",
|
|
49
|
+
"Tool",
|
|
50
|
+
"ToolCall",
|
|
51
|
+
"ToolParamError",
|
|
52
|
+
"UnknownToolError",
|
|
53
|
+
"Usage",
|
|
54
|
+
"all_models",
|
|
55
|
+
"attach_cost",
|
|
56
|
+
"complete",
|
|
57
|
+
"cost_for",
|
|
58
|
+
"find_tool",
|
|
59
|
+
"get_model",
|
|
60
|
+
"get_models",
|
|
61
|
+
"get_providers",
|
|
62
|
+
"register_model",
|
|
63
|
+
"stream",
|
|
64
|
+
"try_get_model",
|
|
65
|
+
"validate_tool_call",
|
|
66
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Cross-provider message compatibility.
|
|
2
|
+
|
|
3
|
+
Assistant messages produced by one provider (e.g. Anthropic thinking blocks) cannot
|
|
4
|
+
always be replayed verbatim into another provider's request. The transform here mirrors
|
|
5
|
+
PI's rule: thinking blocks become ``<thinking>...</thinking>`` tagged text when the
|
|
6
|
+
target api differs from the source; text, tool calls and tool results pass through.
|
|
7
|
+
|
|
8
|
+
Each provider still owns its *to-wire* mapping (anthropic ``tool_use`` blocks vs google
|
|
9
|
+
``function_call`` parts vs openai ``tool_calls``). This module only normalizes the
|
|
10
|
+
provider-agnostic :class:`~minima_harness.ai.types.Message` list beforehand.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, cast
|
|
16
|
+
|
|
17
|
+
from minima_harness.ai.types import Message, TextContent
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
|
|
22
|
+
from minima_harness.ai.types import AssistantMessage
|
|
23
|
+
|
|
24
|
+
_THINK_OPEN = "<thinking>"
|
|
25
|
+
_THINK_CLOSE = "</thinking>"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def thinking_to_text(message: AssistantMessage) -> AssistantMessage:
|
|
29
|
+
"""Return a copy with every ThinkingContent block folded into tagged text.
|
|
30
|
+
|
|
31
|
+
Thinking blocks are replaced in place by a TextContent wrapping the thinking in
|
|
32
|
+
``<thinking>`` tags; adjacent ordering is preserved so the conversation still reads
|
|
33
|
+
naturally to a foreign model.
|
|
34
|
+
"""
|
|
35
|
+
new_content: list = []
|
|
36
|
+
if isinstance(message.content, str): # pragma: no cover - coerced upstream
|
|
37
|
+
return message
|
|
38
|
+
for block in message.content:
|
|
39
|
+
if hasattr(block, "thinking"):
|
|
40
|
+
new_content.append(TextContent(text=f"{_THINK_OPEN}{block.thinking}{_THINK_CLOSE}"))
|
|
41
|
+
else:
|
|
42
|
+
new_content.append(block)
|
|
43
|
+
new = message.model_copy(update={"content": new_content})
|
|
44
|
+
return new
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def source_api_of(message: AssistantMessage) -> str | None:
|
|
48
|
+
"""Infer the api that produced ``message`` from its ``model`` id (registry lookup)."""
|
|
49
|
+
if not message.model:
|
|
50
|
+
return None
|
|
51
|
+
from minima_harness.ai.registry import find_model_by_id
|
|
52
|
+
|
|
53
|
+
model = find_model_by_id(message.model)
|
|
54
|
+
return model.api if model is not None else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_for_target(messages: Iterable[Message], target_api: str) -> list[Message]:
|
|
58
|
+
"""Cross-provider normalize a message list before to-wire mapping for ``target_api``.
|
|
59
|
+
|
|
60
|
+
Assistant messages whose source api differs from ``target_api`` have their thinking
|
|
61
|
+
blocks converted to tagged text; everything else is returned unchanged.
|
|
62
|
+
"""
|
|
63
|
+
out: list[Message] = []
|
|
64
|
+
for m in messages:
|
|
65
|
+
if m.role == "assistant":
|
|
66
|
+
asst = cast("AssistantMessage", m)
|
|
67
|
+
source = source_api_of(asst)
|
|
68
|
+
if source is not None and source != target_api:
|
|
69
|
+
m = thinking_to_text(asst)
|
|
70
|
+
out.append(m)
|
|
71
|
+
return out
|