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.
Files changed (161) hide show
  1. minima/__init__.py +5 -0
  2. minima/api/__init__.py +1 -0
  3. minima/api/auth.py +39 -0
  4. minima/api/errors.py +40 -0
  5. minima/api/routers/__init__.py +1 -0
  6. minima/api/routers/calibration.py +50 -0
  7. minima/api/routers/feedback.py +279 -0
  8. minima/api/routers/health.py +50 -0
  9. minima/api/routers/models.py +42 -0
  10. minima/api/routers/recommend.py +66 -0
  11. minima/api/routers/savings.py +55 -0
  12. minima/api/routers/strategies.py +33 -0
  13. minima/catalog/__init__.py +1 -0
  14. minima/catalog/data/capability_priors.json +210 -0
  15. minima/catalog/data/model_aliases.json +12 -0
  16. minima/catalog/merge.py +69 -0
  17. minima/catalog/refresh.py +54 -0
  18. minima/catalog/sources/__init__.py +1 -0
  19. minima/catalog/sources/litellm.py +19 -0
  20. minima/catalog/sources/openrouter.py +25 -0
  21. minima/catalog/store.py +86 -0
  22. minima/config.py +288 -0
  23. minima/deps.py +35 -0
  24. minima/llm/__init__.py +1 -0
  25. minima/llm/anthropic.py +106 -0
  26. minima/llm/base.py +196 -0
  27. minima/llm/gemini.py +124 -0
  28. minima/llm/registry.py +54 -0
  29. minima/logging.py +28 -0
  30. minima/main.py +109 -0
  31. minima/memory/__init__.py +1 -0
  32. minima/memory/adapter.py +572 -0
  33. minima/memory/keys.py +83 -0
  34. minima/memory/records.py +190 -0
  35. minima/memory/threadpool.py +41 -0
  36. minima/metrics/__init__.py +1 -0
  37. minima/metrics/calibration.py +415 -0
  38. minima/metrics/report.py +116 -0
  39. minima/metrics/savings.py +98 -0
  40. minima/recommender/__init__.py +1 -0
  41. minima/recommender/_pg_pool.py +38 -0
  42. minima/recommender/_redis_client.py +32 -0
  43. minima/recommender/aggregate.py +157 -0
  44. minima/recommender/classify.py +165 -0
  45. minima/recommender/decisionlog.py +505 -0
  46. minima/recommender/durablerefs.py +312 -0
  47. minima/recommender/engine.py +997 -0
  48. minima/recommender/escalation.py +83 -0
  49. minima/recommender/propensity.py +189 -0
  50. minima/recommender/recstore.py +368 -0
  51. minima/recommender/score.py +318 -0
  52. minima/recommender/types.py +166 -0
  53. minima/schemas/__init__.py +1 -0
  54. minima/schemas/common.py +73 -0
  55. minima/schemas/feedback.py +34 -0
  56. minima/schemas/models_catalog.py +36 -0
  57. minima/schemas/recommend.py +104 -0
  58. minima/schemas/savings.py +39 -0
  59. minima/schemas/strategies.py +57 -0
  60. minima/schemas/workflow.py +43 -0
  61. minima/seeding/__init__.py +1 -0
  62. minima/seeding/items.py +42 -0
  63. minima/seeding/llmrouterbench.py +232 -0
  64. minima/seeding/routerbench.py +141 -0
  65. minima/seeding/run_seed.py +56 -0
  66. minima/seeding/synthetic.py +70 -0
  67. minima/tenancy/__init__.py +8 -0
  68. minima/tenancy/context.py +37 -0
  69. minima/tenancy/passthrough.py +110 -0
  70. minima/version.py +3 -0
  71. minima_cli-0.4.9.dist-info/METADATA +275 -0
  72. minima_cli-0.4.9.dist-info/RECORD +161 -0
  73. minima_cli-0.4.9.dist-info/WHEEL +4 -0
  74. minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
  75. minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
  76. minima_client/__init__.py +19 -0
  77. minima_client/autocapture.py +101 -0
  78. minima_client/client.py +301 -0
  79. minima_client/errors.py +23 -0
  80. minima_harness/LICENSE_PI +32 -0
  81. minima_harness/__init__.py +16 -0
  82. minima_harness/agent/__init__.py +72 -0
  83. minima_harness/agent/agent.py +276 -0
  84. minima_harness/agent/events.py +124 -0
  85. minima_harness/agent/loop.py +311 -0
  86. minima_harness/agent/state.py +79 -0
  87. minima_harness/agent/tools.py +97 -0
  88. minima_harness/ai/__init__.py +66 -0
  89. minima_harness/ai/compat.py +71 -0
  90. minima_harness/ai/errors.py +96 -0
  91. minima_harness/ai/events.py +117 -0
  92. minima_harness/ai/openrouter_catalog.py +153 -0
  93. minima_harness/ai/provider_catalog.py +299 -0
  94. minima_harness/ai/provider_quirks.py +37 -0
  95. minima_harness/ai/providers/__init__.py +75 -0
  96. minima_harness/ai/providers/_common.py +48 -0
  97. minima_harness/ai/providers/anthropic.py +290 -0
  98. minima_harness/ai/providers/base.py +65 -0
  99. minima_harness/ai/providers/faux.py +173 -0
  100. minima_harness/ai/providers/google.py +221 -0
  101. minima_harness/ai/providers/openai_compat.py +278 -0
  102. minima_harness/ai/registry.py +184 -0
  103. minima_harness/ai/stream.py +82 -0
  104. minima_harness/ai/tools.py +51 -0
  105. minima_harness/ai/types.py +204 -0
  106. minima_harness/ai/usage.py +41 -0
  107. minima_harness/minima/__init__.py +40 -0
  108. minima_harness/minima/cache.py +102 -0
  109. minima_harness/minima/config.py +85 -0
  110. minima_harness/minima/goals.py +226 -0
  111. minima_harness/minima/judge.py +144 -0
  112. minima_harness/minima/mapping.py +147 -0
  113. minima_harness/minima/meter.py +143 -0
  114. minima_harness/minima/router.py +220 -0
  115. minima_harness/minima/runtime.py +544 -0
  116. minima_harness/minima/signals.py +195 -0
  117. minima_harness/session/__init__.py +14 -0
  118. minima_harness/session/format.py +35 -0
  119. minima_harness/session/store.py +236 -0
  120. minima_harness/tasks/__init__.py +17 -0
  121. minima_harness/tasks/task_set.py +78 -0
  122. minima_harness/tools/__init__.py +7 -0
  123. minima_harness/tools/_io.py +34 -0
  124. minima_harness/tools/bash.py +70 -0
  125. minima_harness/tools/builtin.py +23 -0
  126. minima_harness/tools/edit.py +50 -0
  127. minima_harness/tools/find.py +38 -0
  128. minima_harness/tools/grep.py +73 -0
  129. minima_harness/tools/ls.py +35 -0
  130. minima_harness/tools/read.py +38 -0
  131. minima_harness/tools/tasks.py +75 -0
  132. minima_harness/tools/write.py +36 -0
  133. minima_harness/tui/__init__.py +3 -0
  134. minima_harness/tui/analytics.py +111 -0
  135. minima_harness/tui/app.py +1927 -0
  136. minima_harness/tui/bridge.py +103 -0
  137. minima_harness/tui/cli.py +227 -0
  138. minima_harness/tui/clipboard.py +60 -0
  139. minima_harness/tui/commands.py +49 -0
  140. minima_harness/tui/compaction.py +17 -0
  141. minima_harness/tui/config_cli.py +141 -0
  142. minima_harness/tui/config_store.py +237 -0
  143. minima_harness/tui/context.py +93 -0
  144. minima_harness/tui/customize.py +95 -0
  145. minima_harness/tui/diff.py +53 -0
  146. minima_harness/tui/editor.py +43 -0
  147. minima_harness/tui/extensions.py +84 -0
  148. minima_harness/tui/extra_models.py +52 -0
  149. minima_harness/tui/history.py +71 -0
  150. minima_harness/tui/mubit.py +295 -0
  151. minima_harness/tui/overlays.py +593 -0
  152. minima_harness/tui/packages.py +59 -0
  153. minima_harness/tui/run_modes.py +66 -0
  154. minima_harness/tui/theme.py +77 -0
  155. minima_harness/tui/welcome.py +83 -0
  156. minima_harness/tui/widgets/__init__.py +3 -0
  157. minima_harness/tui/widgets/banner.py +38 -0
  158. minima_harness/tui/widgets/editor.py +83 -0
  159. minima_harness/tui/widgets/footer.py +73 -0
  160. minima_harness/tui/widgets/messages.py +151 -0
  161. 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