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.
- maque_core/__init__.py +28 -0
- maque_core/config.py +80 -0
- maque_core/contracts/__init__.py +17 -0
- maque_core/contracts/llm.py +47 -0
- maque_core/contracts/messages.py +264 -0
- maque_core/contracts/tool.py +121 -0
- maque_core/factory.py +105 -0
- maque_core/kernel/__init__.py +8 -0
- maque_core/kernel/agent.py +228 -0
- maque_core/kernel/budget.py +69 -0
- maque_core/kernel/errors.py +46 -0
- maque_core/kernel/loop.py +320 -0
- maque_core/llm/__init__.py +39 -0
- maque_core/llm/errors.py +258 -0
- maque_core/llm/mock.py +91 -0
- maque_core/llm/openai_compatible.py +309 -0
- maque_core/tools/__init__.py +19 -0
- maque_core/tools/_global_registry.py +38 -0
- maque_core/tools/decorator.py +256 -0
- maque_core/tools/discovery.py +105 -0
- maque_core/tools/executor.py +147 -0
- maque_core/tools/impl/__init__.py +1 -0
- maque_core/tools/impl/add_tool.py +17 -0
- maque_core/tools/impl/echo_tool.py +19 -0
- maque_core/tools/registry.py +299 -0
- maque_core-1.3.4.dist-info/METADATA +612 -0
- maque_core-1.3.4.dist-info/RECORD +31 -0
- maque_core-1.3.4.dist-info/WHEEL +5 -0
- maque_core-1.3.4.dist-info/entry_points.txt +2 -0
- maque_core-1.3.4.dist-info/licenses/LICENSE +21 -0
- maque_core-1.3.4.dist-info/top_level.txt +1 -0
|
@@ -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
|