maque-core 1.3.4__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.

Potentially problematic release.


This version of maque-core might be problematic. Click here for more details.

@@ -0,0 +1,228 @@
1
+ """Agent — high-level interface to the agent framework.
2
+
3
+ Wraps the ReAct loop, LLM provider, and tool registry into a single
4
+ convenient class.
5
+
6
+ Multi-turn conversation support:
7
+ - The agent maintains a persistent ``messages`` list across calls.
8
+ - Each ``ask()`` appends the user message and lets the loop append
9
+ assistant/tool messages in-place.
10
+ - History is trimmed automatically when it exceeds ``max_history_turns``
11
+ to prevent context overflow.
12
+ - Use ``reset()`` to start a fresh conversation.
13
+
14
+ Error propagation:
15
+ - ``ask()`` raises :class:`AgentRunError` on LLM failure by default
16
+ (``raise_on_error=True``). This is the facade layer — callers get a
17
+ clean exception instead of having to inspect the response.
18
+ - On failure, all messages appended during the failed call are rolled
19
+ back so the conversation history stays clean (no orphan user message,
20
+ no partial assistant/tool messages).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import List, Optional
26
+
27
+ from ..contracts.llm import LLMProvider
28
+ from ..contracts.messages import Message, Role
29
+ from ..contracts.tool import ToolRegistry
30
+ from .budget import RunBudget
31
+ from .errors import AgentRunError
32
+ from .loop import react_loop
33
+
34
+
35
+ class Agent:
36
+ """High-level agent interface with multi-turn conversation memory.
37
+
38
+ Usage::
39
+
40
+ agent = Agent(llm=my_llm, registry=my_registry)
41
+ answer = agent.ask("What is 2 + 2?")
42
+ print(agent.latest_reasoning) # the most recent reasoning
43
+
44
+ # Multi-turn — history is preserved automatically
45
+ answer2 = agent.ask("And what about 3 + 3?")
46
+
47
+ # Start fresh
48
+ agent.reset()
49
+
50
+ Attributes:
51
+ latest_reasoning: The reasoning content from the most recent
52
+ LLM call. None if the last call produced no reasoning.
53
+ Only the latest reasoning is kept — it is overwritten on
54
+ each LLM call, not accumulated.
55
+ messages: The full conversation history (list of Message).
56
+ Includes system prompt, user messages, assistant responses,
57
+ and tool results. Modified in-place by ``ask()``.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ llm: LLMProvider,
63
+ registry: ToolRegistry,
64
+ system_prompt: str = "You are a helpful assistant.",
65
+ max_iterations: int = 10,
66
+ max_tool_calls: Optional[int] = None,
67
+ verbose: bool = False,
68
+ max_history_turns: int = 20,
69
+ include_reasoning_in_history: bool = True,
70
+ ):
71
+ self.llm = llm
72
+ self.registry = registry
73
+ self.system_prompt = system_prompt
74
+ self.max_iterations = max_iterations
75
+ self.max_tool_calls = max_tool_calls
76
+ self.verbose = verbose
77
+ self.max_history_turns = max_history_turns
78
+ self.include_reasoning_in_history = include_reasoning_in_history
79
+ self.latest_reasoning: Optional[str] = None
80
+
81
+ # Conversation history — persists across ask() calls for multi-turn.
82
+ # Starts with just the system prompt.
83
+ self.messages: List[Message] = [Message.system(system_prompt)]
84
+
85
+ def ask(
86
+ self,
87
+ question: str,
88
+ verbose: Optional[bool] = None,
89
+ raise_on_error: bool = True,
90
+ ) -> str:
91
+ """Ask the agent a question and get an answer.
92
+
93
+ The question is appended to the conversation history, and the
94
+ agent's response (including any tool calls and results) is
95
+ also appended. This means subsequent ``ask()`` calls carry
96
+ the full conversation context.
97
+
98
+ History is trimmed at the start of each call if it exceeds
99
+ ``max_history_turns``. The system prompt is always preserved.
100
+
101
+ After this call, ``self.latest_reasoning`` contains the
102
+ reasoning from the most recent LLM call (overwritten each time,
103
+ not accumulated across calls).
104
+
105
+ Error handling:
106
+ By default (``raise_on_error=True``), LLM failures raise
107
+ :class:`AgentRunError`. When an error occurs, **all messages
108
+ added during this call are rolled back** so the conversation
109
+ history stays clean — no orphan user message, no partial
110
+ assistant or tool messages.
111
+
112
+ Pass ``raise_on_error=False`` to get the old behaviour where
113
+ the error text is returned as the answer string.
114
+
115
+ Args:
116
+ question: The user's question.
117
+ verbose: Override the agent's verbose setting for this call.
118
+ raise_on_error: When ``True`` (default), raise
119
+ :class:`AgentRunError` on LLM failure and roll back
120
+ the history. When ``False``, return the error text as
121
+ the answer (legacy behaviour).
122
+
123
+ Returns:
124
+ The agent's answer string.
125
+
126
+ Raises:
127
+ AgentRunError: When the LLM call fails and ``raise_on_error``
128
+ is True. The conversation history is rolled back to
129
+ its pre-call state before the exception is raised.
130
+ """
131
+ v = verbose if verbose is not None else self.verbose
132
+
133
+ # Trim history before adding the new message
134
+ self._trim_history()
135
+
136
+ # Snapshot the current length so we can roll back on failure.
137
+ # This fixes the P2 "history pollution" bug where a failed call
138
+ # would leave an orphan user message in the history.
139
+ snapshot = len(self.messages)
140
+
141
+ # Append user message to the persistent history
142
+ self.messages.append(Message.user(question))
143
+
144
+ budget = RunBudget(
145
+ max_iterations=self.max_iterations,
146
+ max_tool_calls=self.max_tool_calls,
147
+ )
148
+
149
+ try:
150
+ answer, reasoning = react_loop(
151
+ llm=self.llm,
152
+ registry=self.registry,
153
+ messages=self.messages,
154
+ budget=budget,
155
+ verbose=v,
156
+ include_reasoning_in_history=self.include_reasoning_in_history,
157
+ raise_on_error=raise_on_error,
158
+ )
159
+ except Exception:
160
+ # Roll back all messages added during this call
161
+ # (user message + any assistant/tool messages from the loop).
162
+ del self.messages[snapshot:]
163
+ raise
164
+
165
+ self.latest_reasoning = reasoning
166
+ return answer
167
+
168
+ def reset(self) -> None:
169
+ """Reset the conversation history.
170
+
171
+ Clears all messages and re-initializes with just the system
172
+ prompt. Use this to start a fresh conversation without
173
+ creating a new Agent instance.
174
+ """
175
+ self.messages = [Message.system(self.system_prompt)]
176
+ self.latest_reasoning = None
177
+
178
+ def _trim_history(self) -> None:
179
+ """Trim conversation history to stay within max_history_turns.
180
+
181
+ Strategy:
182
+ - Always keep the system prompt (index 0).
183
+ - Count complete "turns" in the remaining messages. A turn is
184
+ a user message plus everything until the next user message
185
+ (assistant response + any tool calls + tool results).
186
+ - We reserve room for the upcoming new turn, so we trim to
187
+ ``max_history_turns - 1`` completed turns. After the new
188
+ user message is added and the loop runs, the total will be
189
+ at most ``max_history_turns``.
190
+ - If the number of completed turns exceeds the target, remove
191
+ the oldest complete turns from the middle (between system
192
+ prompt and the most recent turns).
193
+
194
+ This is called automatically at the start of each ``ask()``.
195
+ """
196
+ if self.max_history_turns <= 0:
197
+ return
198
+
199
+ # Target: keep at most (max - 1) completed turns before adding the new one
200
+ target_turns = self.max_history_turns - 1
201
+ if target_turns <= 0:
202
+ target_turns = 1 # always keep at least 1 turn of context if possible
203
+
204
+ # Find all user message indices (these mark turn boundaries)
205
+ # Skip index 0 which is the system prompt
206
+ user_indices = [
207
+ i for i, msg in enumerate(self.messages)
208
+ if i > 0 and msg.role == Role.USER
209
+ ]
210
+
211
+ current_turns = len(user_indices)
212
+ if current_turns <= target_turns:
213
+ return
214
+
215
+ # How many turns to remove from the oldest end
216
+ turns_to_remove = current_turns - target_turns
217
+
218
+ # The cut point is the start of the (turns_to_remove)-th user
219
+ # message from the beginning. We keep everything from that
220
+ # user message onward (plus the system prompt at index 0).
221
+ if turns_to_remove < len(user_indices):
222
+ cut_index = user_indices[turns_to_remove]
223
+ # Keep system prompt + messages from cut_index onward
224
+ self.messages = [self.messages[0]] + self.messages[cut_index:]
225
+
226
+ def list_tools(self) -> List[str]:
227
+ """Return the names of all available tools."""
228
+ return self.registry.tool_names
@@ -0,0 +1,69 @@
1
+ """Run budget — dual protection for agent runs.
2
+
3
+ Combines two limits:
4
+ 1. max_iterations — maximum number of LLM calls (reasoning + acting cycles)
5
+ 2. max_tool_calls — maximum number of tool invocations
6
+
7
+ Either limit being exhausted triggers a forced final answer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Optional
14
+
15
+
16
+ @dataclass
17
+ class RunBudget:
18
+ """Budget for a single agent run.
19
+
20
+ Attributes:
21
+ max_iterations: Maximum LLM calls. None = unlimited.
22
+ max_tool_calls: Maximum tool calls. None = unlimited.
23
+ iterations_used: Number of iterations consumed so far.
24
+ tool_calls_used: Number of tool calls consumed so far.
25
+ """
26
+
27
+ max_iterations: Optional[int] = 10
28
+ max_tool_calls: Optional[int] = None
29
+ iterations_used: int = 0
30
+ tool_calls_used: int = 0
31
+
32
+ def consume_iteration(self) -> bool:
33
+ """Consume one iteration. Returns True if within budget, False if exhausted."""
34
+ self.iterations_used += 1
35
+ if self.max_iterations is None:
36
+ return True
37
+ return self.iterations_used <= self.max_iterations
38
+
39
+ def consume_tool_call(self, count: int = 1) -> bool:
40
+ """Consume ``count`` tool calls. Returns True if within budget."""
41
+ self.tool_calls_used += count
42
+ if self.max_tool_calls is None:
43
+ return True
44
+ return self.tool_calls_used <= self.max_tool_calls
45
+
46
+ @property
47
+ def iterations_exhausted(self) -> bool:
48
+ """Whether the iteration limit has been reached."""
49
+ if self.max_iterations is None:
50
+ return False
51
+ return self.iterations_used >= self.max_iterations
52
+
53
+ @property
54
+ def tool_calls_exhausted(self) -> bool:
55
+ """Whether the tool call limit has been reached."""
56
+ if self.max_tool_calls is None:
57
+ return False
58
+ return self.tool_calls_used >= self.max_tool_calls
59
+
60
+ @property
61
+ def any_exhausted(self) -> bool:
62
+ """Whether any budget limit has been reached."""
63
+ return self.iterations_exhausted or self.tool_calls_exhausted
64
+
65
+ def __repr__(self) -> str:
66
+ return (
67
+ f"RunBudget(iterations={self.iterations_used}/{self.max_iterations}, "
68
+ f"tool_calls={self.tool_calls_used}/{self.max_tool_calls})"
69
+ )
@@ -0,0 +1,46 @@
1
+ """Kernel-level error types.
2
+
3
+ AgentRunError is the exception raised by the kernel loop when an LLM
4
+ call fails and ``raise_on_error`` is enabled. It carries both the
5
+ human-readable message and the raw :class:`LLMResponse` so upper
6
+ layers can inspect provider-level detail (model, usage, etc.) without
7
+ having to parse the error text.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Optional
13
+
14
+ if TYPE_CHECKING:
15
+ from ..contracts.messages import LLMResponse
16
+
17
+
18
+ class AgentRunError(Exception):
19
+ """Raised when the ReAct loop encounters an unrecoverable LLM error.
20
+
21
+ This is the kernel-layer error channel — it replaces the old pattern
22
+ where errors were stuffed into ``LLMResponse.content`` and callers
23
+ had to check ``response.is_error`` after every call.
24
+
25
+ Attributes:
26
+ message: Human-readable error description.
27
+ response: The raw :class:`LLMResponse` that carried the error.
28
+ ``None`` when the failure happened before a response was
29
+ obtained (e.g. the loop itself detected a structural problem).
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ message: str,
35
+ *,
36
+ response: Optional["LLMResponse"] = None,
37
+ ):
38
+ super().__init__(message)
39
+ self.message = message
40
+ self.response = response
41
+
42
+ def __str__(self) -> str:
43
+ return self.message
44
+
45
+
46
+ __all__ = ["AgentRunError"]
@@ -0,0 +1,320 @@
1
+ """ReAct loop — the core Reasoning + Acting cycle.
2
+
3
+ The loop:
4
+ 1. Send messages + tool schemas to LLM
5
+ 2. If LLM returns text → that's the answer (done)
6
+ 3. If LLM returns tool_calls → execute them, add results to messages, go to 1
7
+ 4. If budget exhausted → force final answer from the LLM
8
+
9
+ Multi-turn support:
10
+ - The loop can accept an existing messages list and append to it in-place,
11
+ so conversation history persists across multiple ``ask()`` calls.
12
+ - Reasoning from each LLM call can be stored on the assistant message
13
+ (controlled by ``include_reasoning_in_history``), giving the model
14
+ visibility into its own prior reasoning in subsequent turns.
15
+ - finish_reason='length' triggers one retry with a "please continue"
16
+ system message, so truncated outputs get completed.
17
+
18
+ Error propagation:
19
+ - By default (``raise_on_error=False``), LLM errors are returned inside
20
+ ``LLMResponse`` with ``error`` set — the legacy behaviour.
21
+ - When ``raise_on_error=True``, any LLM failure raises :class:`AgentRunError`
22
+ instead, so the error travels through the exception channel and the
23
+ caller doesn't have to inspect every response.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import List, Optional
29
+
30
+ from ..contracts.llm import LLMProvider
31
+ from ..contracts.messages import LLMResponse, Message, Observation, Role
32
+ from ..contracts.tool import ToolRegistry
33
+ from .budget import RunBudget
34
+ from .errors import AgentRunError
35
+
36
+
37
+ def react_loop(
38
+ llm: LLMProvider,
39
+ registry: ToolRegistry,
40
+ messages: List[Message],
41
+ budget: Optional[RunBudget] = None,
42
+ verbose: bool = False,
43
+ include_reasoning_in_history: bool = True,
44
+ raise_on_error: bool = False,
45
+ ) -> tuple:
46
+ """Run the ReAct (Reasoning + Acting) loop.
47
+
48
+ Args:
49
+ llm: The LLM provider to use.
50
+ registry: The tool registry to use for tool calls.
51
+ messages: Conversation history list. The loop appends new
52
+ messages to this list **in-place**, so callers can keep
53
+ using the same list across multiple turns for multi-turn
54
+ conversations. Must already contain system prompt and
55
+ user message(s).
56
+ budget: Optional run budget (iterations + tool calls).
57
+ verbose: If True, print each step to stdout.
58
+ include_reasoning_in_history: If True, store reasoning content
59
+ on each assistant message so it's visible in future turns.
60
+ If False, reasoning is only kept as ``latest_reasoning``
61
+ and not persisted in history (saves tokens).
62
+ raise_on_error: When ``False`` (default, backward-compatible),
63
+ LLM failures are returned as an :class:`LLMResponse` with
64
+ ``error`` set. When ``True``, they raise :class:`AgentRunError`
65
+ instead, propagating through the exception channel.
66
+
67
+ Returns:
68
+ A tuple of (final_answer: str, latest_reasoning: str | None).
69
+ latest_reasoning contains only the most recent LLM call's
70
+ reasoning content (overwritten each iteration, not accumulated).
71
+ """
72
+ if budget is None:
73
+ budget = RunBudget(max_iterations=10)
74
+
75
+ tool_budget_exhausted = False
76
+ latest_reasoning: Optional[str] = None
77
+
78
+ while True:
79
+ # Check iteration budget
80
+ if not budget.consume_iteration():
81
+ if verbose:
82
+ print(f"[budget] max iterations reached ({budget.max_iterations}), forcing final answer")
83
+ answer, reasoning = _force_final_answer(
84
+ llm, messages, reason="max_iterations_reached",
85
+ raise_on_error=raise_on_error,
86
+ )
87
+ if reasoning is not None:
88
+ latest_reasoning = reasoning
89
+ if include_reasoning_in_history and messages:
90
+ # Attach reasoning to the last assistant message
91
+ for msg in reversed(messages):
92
+ if msg.role == Role.ASSISTANT:
93
+ msg.reasoning = reasoning
94
+ break
95
+ return answer, latest_reasoning
96
+
97
+ # Get tool schemas
98
+ tool_schemas = None
99
+ if len(registry) > 0 and not tool_budget_exhausted:
100
+ from ..contracts.tool import ToolSpec
101
+ # Build ToolSpec list from registry
102
+ tool_specs = []
103
+ for name in registry.tool_names:
104
+ spec = registry.get_spec(name)
105
+ if spec:
106
+ tool_specs.append(spec)
107
+ tool_schemas = tool_specs
108
+
109
+ # Call LLM (with finish_reason length retry)
110
+ response = _chat_with_length_retry(
111
+ llm, messages, tool_schemas, budget, verbose,
112
+ raise_on_error=raise_on_error,
113
+ )
114
+
115
+ # Update latest reasoning (overwrite — only keep the most recent)
116
+ if response.reasoning is not None:
117
+ latest_reasoning = response.reasoning
118
+
119
+ if verbose:
120
+ print(f"[llm] content={response.content!r} tool_calls={len(response.tool_calls)}")
121
+ if response.reasoning:
122
+ print(f"[llm] reasoning={response.reasoning[:80]}...")
123
+
124
+ # Add assistant message to history
125
+ # When include_reasoning_in_history is True, reasoning is stored
126
+ # on the message so future turns can see the model's prior thinking.
127
+ assistant_msg = Message.assistant(
128
+ content=response.content,
129
+ tool_calls=response.tool_calls,
130
+ )
131
+ if include_reasoning_in_history and response.reasoning is not None:
132
+ assistant_msg.reasoning = response.reasoning
133
+ messages.append(assistant_msg)
134
+
135
+ # If no tool calls, we're done — return the content
136
+ if not response.tool_calls:
137
+ return response.content, latest_reasoning
138
+
139
+ # If tool budget is already exhausted but model still called tools → force final answer
140
+ if tool_budget_exhausted:
141
+ if verbose:
142
+ print("[budget] tool budget exhausted but model called tools, forcing final answer")
143
+ answer, reasoning = _force_final_answer(
144
+ llm, messages, reason="tool_budget_exhausted",
145
+ raise_on_error=raise_on_error,
146
+ )
147
+ if reasoning is not None:
148
+ latest_reasoning = reasoning
149
+ if include_reasoning_in_history and messages:
150
+ for msg in reversed(messages):
151
+ if msg.role == Role.ASSISTANT:
152
+ msg.reasoning = reasoning
153
+ break
154
+ return answer, latest_reasoning
155
+
156
+ # Execute all tool calls
157
+ for tool_call in response.tool_calls:
158
+ # Check tool budget before executing
159
+ if budget.max_tool_calls is not None and budget.tool_calls_used >= budget.max_tool_calls:
160
+ tool_budget_exhausted = True
161
+ # Add a tool message for the truncated call (Kernel layer fix:
162
+ # budget truncation补tool消息 — ensures the model sees a
163
+ # consistent tool_call → tool_result pair, otherwise the
164
+ # message history has unmatched tool_calls which breaks
165
+ # some model APIs).
166
+ messages.append(
167
+ Message.tool_result(
168
+ tool_call_id=tool_call.id,
169
+ tool_name=tool_call.name,
170
+ content=(
171
+ f"Tool call budget exhausted (max={budget.max_tool_calls}). "
172
+ "This call was not executed. Please provide your final answer "
173
+ "based on the information you already have."
174
+ ),
175
+ )
176
+ )
177
+ # Also add a system message explaining the budget situation
178
+ messages.append(
179
+ Message.system(
180
+ f"Tool call budget exhausted (max={budget.max_tool_calls}). "
181
+ "Please provide your final answer based on the information you already have."
182
+ )
183
+ )
184
+ if verbose:
185
+ print(f"[budget] tool call budget exhausted at {budget.max_tool_calls}")
186
+ break # stop executing more tool calls this round
187
+
188
+ observation = registry.execute(
189
+ tool_name=tool_call.name,
190
+ arguments=tool_call.arguments,
191
+ tool_call_id=tool_call.id,
192
+ )
193
+ budget.consume_tool_call()
194
+
195
+ if verbose:
196
+ status = "ERROR" if observation.is_error else "OK"
197
+ print(f"[tool] {tool_call.name}({tool_call.arguments}) → {status}: {observation.content[:80]}")
198
+
199
+ messages.append(observation.to_message())
200
+
201
+ # Continue the loop — the next iteration will give the model
202
+ # the tool results and let it decide what to do next
203
+
204
+
205
+ def _chat_with_length_retry(
206
+ llm: LLMProvider,
207
+ messages: List[Message],
208
+ tool_schemas: Optional[list],
209
+ budget: RunBudget,
210
+ verbose: bool,
211
+ raise_on_error: bool = False,
212
+ ) -> LLMResponse:
213
+ """Call the LLM once, with one retry if finish_reason is 'length'.
214
+
215
+ When the model hits the token limit (finish_reason='length') and
216
+ we still have iteration budget, append a system message asking the
217
+ model to continue, then call again. This gives truncated outputs
218
+ a chance to complete.
219
+
220
+ Max 1 retry to prevent infinite loops.
221
+
222
+ Error handling:
223
+ - When ``raise_on_error`` is True and the LLM returns an error
224
+ response (or the retry produces one), raise :class:`AgentRunError`
225
+ instead of returning the response.
226
+ """
227
+ response = llm.chat(messages, tools=tool_schemas)
228
+
229
+ # Error check — raise if enabled
230
+ if response.error is not None and raise_on_error:
231
+ raise AgentRunError(response.error, response=response)
232
+
233
+ # Only retry on length truncation, and only if we have budget left
234
+ if (
235
+ response.finish_reason == "length"
236
+ and budget.iterations_used < budget.max_iterations - 1
237
+ ):
238
+ if verbose:
239
+ print("[llm] output truncated (finish_reason=length), retrying with continue prompt")
240
+
241
+ # Append the truncated assistant message with a continue system prompt
242
+ # We need to temporarily add the truncated response + continue hint
243
+ truncated_msg = Message.assistant(
244
+ content=response.content,
245
+ tool_calls=response.tool_calls,
246
+ )
247
+ messages.append(truncated_msg)
248
+ messages.append(
249
+ Message.system("输出被截断,请继续生成你的回答。")
250
+ )
251
+
252
+ # Consume an iteration for the retry
253
+ budget.consume_iteration()
254
+
255
+ # Retry — this time without tools (we just want the continuation text)
256
+ # try/finally: the temporary messages must be popped even when
257
+ # llm.chat raises, otherwise the truncated message + continue
258
+ # prompt leak into the caller's history.
259
+ try:
260
+ retry_response = llm.chat(messages, tools=None)
261
+ finally:
262
+ # Remove the temporary messages we added — the caller will add
263
+ # the final assistant message itself. We combine the truncated
264
+ # content with the continuation.
265
+ messages.pop() # remove system continue message
266
+ messages.pop() # remove truncated assistant message
267
+
268
+ # Combine content from both calls
269
+ combined_content = response.content + retry_response.content
270
+
271
+ # Build combined response
272
+ combined = LLMResponse(
273
+ content=combined_content,
274
+ reasoning=retry_response.reasoning or response.reasoning,
275
+ tool_calls=retry_response.tool_calls or response.tool_calls,
276
+ model=retry_response.model or response.model,
277
+ usage=retry_response.usage,
278
+ finish_reason=retry_response.finish_reason,
279
+ error=retry_response.error,
280
+ )
281
+
282
+ # Error check on combined result — raise if enabled
283
+ if combined.error is not None and raise_on_error:
284
+ raise AgentRunError(combined.error, response=combined)
285
+
286
+ return combined
287
+
288
+ return response
289
+
290
+
291
+ def _force_final_answer(
292
+ llm: LLMProvider,
293
+ messages: List[Message],
294
+ reason: str,
295
+ raise_on_error: bool = False,
296
+ ) -> tuple:
297
+ """Force the model to produce a final text answer without tools.
298
+
299
+ Called when budget is exhausted or we need to wrap up.
300
+
301
+ Error handling:
302
+ - When ``raise_on_error`` is True and the LLM returns an error
303
+ response, raise :class:`AgentRunError` instead of returning.
304
+
305
+ Returns:
306
+ A tuple of (answer: str, reasoning: str | None).
307
+ """
308
+ messages.append(
309
+ Message.system(
310
+ f"Reached limit ({reason}). Please provide a concise final answer "
311
+ "based on the conversation so far, without calling any more tools."
312
+ )
313
+ )
314
+ response = llm.chat(messages, tools=None)
315
+
316
+ # Error check — raise if enabled
317
+ if response.error is not None and raise_on_error:
318
+ raise AgentRunError(response.error, response=response)
319
+
320
+ return response.content or "(no final answer generated)", response.reasoning