toolloop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolloop/__init__.py +60 -0
- toolloop/_types.py +88 -0
- toolloop/agent.py +345 -0
- toolloop/context.py +78 -0
- toolloop/hooks.py +74 -0
- toolloop/protocol/__init__.py +12 -0
- toolloop/protocol/base.py +50 -0
- toolloop/protocol/json_protocol.py +109 -0
- toolloop/provider.py +21 -0
- toolloop/subagent.py +34 -0
- toolloop/tools/__init__.py +20 -0
- toolloop/tools/definition.py +110 -0
- toolloop/tools/fs.py +99 -0
- toolloop/tools/search.py +48 -0
- toolloop/tools/shell.py +30 -0
- toolloop-0.1.0.dist-info/METADATA +238 -0
- toolloop-0.1.0.dist-info/RECORD +19 -0
- toolloop-0.1.0.dist-info/WHEEL +4 -0
- toolloop-0.1.0.dist-info/licenses/LICENSE +21 -0
toolloop/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""toolloop: agent loops for LLM providers without native tool use."""
|
|
2
|
+
|
|
3
|
+
from ._types import (
|
|
4
|
+
ControlError,
|
|
5
|
+
MaxIterationsExceeded,
|
|
6
|
+
Message,
|
|
7
|
+
ParseError,
|
|
8
|
+
ParseLoopError,
|
|
9
|
+
Role,
|
|
10
|
+
Status,
|
|
11
|
+
StepRecord,
|
|
12
|
+
ToolCallRecord,
|
|
13
|
+
)
|
|
14
|
+
from .agent import Agent, OnMax, RunResult
|
|
15
|
+
from .context import ContextManager, estimate_tokens
|
|
16
|
+
from .hooks import ControlMode, Decision, StepContext, ToolCallContext, ToolResultContext
|
|
17
|
+
from .protocol import (
|
|
18
|
+
FinalAnswer,
|
|
19
|
+
JsonToolProtocol,
|
|
20
|
+
ToolCallRequest,
|
|
21
|
+
ToolCalls,
|
|
22
|
+
ToolProtocol,
|
|
23
|
+
)
|
|
24
|
+
from .provider import Provider
|
|
25
|
+
from .subagent import subagent_tool
|
|
26
|
+
from .tools import STD_TOOLS, ToolDefinition, tool
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Agent",
|
|
32
|
+
"ContextManager",
|
|
33
|
+
"ControlError",
|
|
34
|
+
"ControlMode",
|
|
35
|
+
"Decision",
|
|
36
|
+
"FinalAnswer",
|
|
37
|
+
"MaxIterationsExceeded",
|
|
38
|
+
"Message",
|
|
39
|
+
"OnMax",
|
|
40
|
+
"ParseError",
|
|
41
|
+
"ParseLoopError",
|
|
42
|
+
"Provider",
|
|
43
|
+
"Role",
|
|
44
|
+
"RunResult",
|
|
45
|
+
"Status",
|
|
46
|
+
"StepContext",
|
|
47
|
+
"StepRecord",
|
|
48
|
+
"STD_TOOLS",
|
|
49
|
+
"ToolCallContext",
|
|
50
|
+
"ToolCallRecord",
|
|
51
|
+
"ToolCallRequest",
|
|
52
|
+
"ToolCalls",
|
|
53
|
+
"ToolDefinition",
|
|
54
|
+
"ToolProtocol",
|
|
55
|
+
"ToolResultContext",
|
|
56
|
+
"JsonToolProtocol",
|
|
57
|
+
"estimate_tokens",
|
|
58
|
+
"subagent_tool",
|
|
59
|
+
"tool",
|
|
60
|
+
]
|
toolloop/_types.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Core types shared across toolloop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Role(StrEnum):
|
|
11
|
+
"""Conversation role. Providers only ever see system/user/assistant."""
|
|
12
|
+
|
|
13
|
+
SYSTEM = "system"
|
|
14
|
+
USER = "user"
|
|
15
|
+
ASSISTANT = "assistant"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Message:
|
|
20
|
+
"""A plain-text chat message exchanged with the provider.
|
|
21
|
+
|
|
22
|
+
``kind`` is framework metadata (e.g. ``"observation"`` for tool results)
|
|
23
|
+
used by context management; providers must ignore it and render only
|
|
24
|
+
``role``/``content``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
role: Role
|
|
28
|
+
content: str
|
|
29
|
+
kind: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolloopError(Exception):
|
|
33
|
+
"""Base class for all toolloop errors."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ParseError(ToolloopError):
|
|
37
|
+
"""A model response could not be parsed into a valid envelope."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, reason: str, raw: str):
|
|
40
|
+
super().__init__(reason)
|
|
41
|
+
self.reason = reason
|
|
42
|
+
self.raw = raw
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ParseLoopError(ToolloopError):
|
|
46
|
+
"""Too many consecutive unparseable responses: auto-repair gave up."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ControlError(ToolloopError):
|
|
50
|
+
"""Invalid control-mode configuration (e.g. APPROVE without an approver)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MaxIterationsExceeded(ToolloopError):
|
|
54
|
+
"""``run()`` exhausted ``max_iterations`` with ``OnMax.RAISE``."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, steps: int, last_raw: str | None):
|
|
57
|
+
super().__init__(f"agent exceeded max iterations ({steps})")
|
|
58
|
+
self.steps = steps
|
|
59
|
+
self.last_raw = last_raw
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Status(StrEnum):
|
|
63
|
+
"""Outcome of an agent run."""
|
|
64
|
+
|
|
65
|
+
COMPLETED = "completed"
|
|
66
|
+
MAX_ITERATIONS = "max_iterations"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ToolCallRecord:
|
|
71
|
+
"""Audit record for one tool call (or its denial)."""
|
|
72
|
+
|
|
73
|
+
call_id: str
|
|
74
|
+
name: str
|
|
75
|
+
args: dict[str, Any]
|
|
76
|
+
status: str # "ok" | "error" | "denied"
|
|
77
|
+
result: str
|
|
78
|
+
duration: float = 0.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class StepRecord:
|
|
83
|
+
"""Audit record for one agent step (one provider call)."""
|
|
84
|
+
|
|
85
|
+
step: int
|
|
86
|
+
raw: str
|
|
87
|
+
kind: str # "tool_calls" | "final_answer" | "parse_error"
|
|
88
|
+
calls: list[ToolCallRecord] = field(default_factory=list)
|
toolloop/agent.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""The agent loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from time import perf_counter
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
|
|
14
|
+
from ._types import (
|
|
15
|
+
ControlError,
|
|
16
|
+
MaxIterationsExceeded,
|
|
17
|
+
Message,
|
|
18
|
+
ParseError,
|
|
19
|
+
ParseLoopError,
|
|
20
|
+
Role,
|
|
21
|
+
Status,
|
|
22
|
+
StepRecord,
|
|
23
|
+
ToolCallRecord,
|
|
24
|
+
)
|
|
25
|
+
from .context import ContextManager
|
|
26
|
+
from .hooks import (
|
|
27
|
+
ControlMode,
|
|
28
|
+
Decision,
|
|
29
|
+
StepContext,
|
|
30
|
+
ToolCallContext,
|
|
31
|
+
ToolResultContext,
|
|
32
|
+
)
|
|
33
|
+
from .protocol.base import FinalAnswer, ToolProtocol
|
|
34
|
+
from .protocol.json_protocol import JsonToolProtocol
|
|
35
|
+
from .tools.definition import ToolDefinition
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OnMax(StrEnum):
|
|
39
|
+
"""What to do when ``max_iterations`` is exhausted."""
|
|
40
|
+
|
|
41
|
+
RAISE = "raise" # raise MaxIterationsExceeded
|
|
42
|
+
WRAP_UP = "wrap_up" # one forced extra turn: "answer now"
|
|
43
|
+
PARTIAL = "partial" # return RunResult(status=MAX_ITERATIONS, output=None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RunResult:
|
|
48
|
+
"""Final outcome of ``Agent.run`` with a full audit trail."""
|
|
49
|
+
|
|
50
|
+
status: Status
|
|
51
|
+
output: Any = None
|
|
52
|
+
history: list[StepRecord] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Agent:
|
|
56
|
+
"""An autonomous loop: input -> tool calls -> final answer.
|
|
57
|
+
|
|
58
|
+
The provider never sees tool-use APIs. The protocol renders tool
|
|
59
|
+
instructions into the system prompt and parses tool calls out of the
|
|
60
|
+
model's plain-text responses; results are fed back as observations until
|
|
61
|
+
the model emits a ``final_answer`` envelope.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
provider,
|
|
67
|
+
tools: Sequence[ToolDefinition] = (),
|
|
68
|
+
*,
|
|
69
|
+
protocol: ToolProtocol | None = None,
|
|
70
|
+
system_prompt: str | None = None,
|
|
71
|
+
control: ControlMode = ControlMode.BYPASS,
|
|
72
|
+
on_step: Callable[[StepContext], Any] | None = None,
|
|
73
|
+
on_tool_call: Callable[[ToolCallContext], Any] | None = None,
|
|
74
|
+
on_tool_result: Callable[[ToolResultContext], Any] | None = None,
|
|
75
|
+
max_context_tokens: int | None = None,
|
|
76
|
+
max_parse_failures: int = 3,
|
|
77
|
+
max_tool_result_chars: int = 10_000,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.provider = provider
|
|
80
|
+
self.protocol = protocol or JsonToolProtocol()
|
|
81
|
+
self.system_prompt = system_prompt
|
|
82
|
+
self.control = control
|
|
83
|
+
self.on_step = on_step
|
|
84
|
+
self.on_tool_call = on_tool_call
|
|
85
|
+
self.on_tool_result = on_tool_result
|
|
86
|
+
self.max_parse_failures = max_parse_failures
|
|
87
|
+
self.max_tool_result_chars = max_tool_result_chars
|
|
88
|
+
self.registry: dict[str, ToolDefinition] = {t.name: t for t in tools}
|
|
89
|
+
if len(self.registry) != len(tools):
|
|
90
|
+
raise ValueError("duplicate tool names in Agent(tools=...)")
|
|
91
|
+
self.context = ContextManager(provider, max_context_tokens) if max_context_tokens else None
|
|
92
|
+
|
|
93
|
+
async def run(
|
|
94
|
+
self,
|
|
95
|
+
input: str,
|
|
96
|
+
*,
|
|
97
|
+
max_iterations: int = 25,
|
|
98
|
+
on_max: OnMax = OnMax.RAISE,
|
|
99
|
+
control: ControlMode | None = None,
|
|
100
|
+
output_model: type[BaseModel] | None = None,
|
|
101
|
+
) -> RunResult:
|
|
102
|
+
"""Run the loop until ``final_answer``, ``max_iterations`` or a hook stops it."""
|
|
103
|
+
mode = control or self.control
|
|
104
|
+
if mode is ControlMode.APPROVE and self.on_tool_call is None:
|
|
105
|
+
raise ControlError(
|
|
106
|
+
"control=APPROVE requires an on_tool_call hook to approve tool "
|
|
107
|
+
"calls; pass on_tool_call=... or use ControlMode.BYPASS"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
instructions = self.protocol.render_instructions(list(self.registry.values()))
|
|
111
|
+
system_content = (
|
|
112
|
+
f"{self.system_prompt}\n\n{instructions}" if self.system_prompt else instructions
|
|
113
|
+
)
|
|
114
|
+
messages: list[Message] = [
|
|
115
|
+
Message(Role.SYSTEM, system_content),
|
|
116
|
+
Message(Role.USER, input),
|
|
117
|
+
]
|
|
118
|
+
history: list[StepRecord] = []
|
|
119
|
+
parse_failures = 0
|
|
120
|
+
|
|
121
|
+
for step in range(1, max_iterations + 1):
|
|
122
|
+
raw = await self.provider.complete(messages)
|
|
123
|
+
messages.append(Message(Role.ASSISTANT, raw))
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
parsed = self.protocol.parse(raw)
|
|
127
|
+
except ParseError as exc:
|
|
128
|
+
parse_failures += 1
|
|
129
|
+
history.append(StepRecord(step=step, raw=raw, kind="parse_error"))
|
|
130
|
+
if parse_failures >= self.max_parse_failures:
|
|
131
|
+
await self._fire_step(step, messages, raw, "parse_error", [])
|
|
132
|
+
raise ParseLoopError(
|
|
133
|
+
f"{parse_failures} consecutive unparseable responses; "
|
|
134
|
+
f"last error: {exc.reason}"
|
|
135
|
+
) from exc
|
|
136
|
+
messages.append(
|
|
137
|
+
Message(
|
|
138
|
+
Role.USER,
|
|
139
|
+
f"Your last response could not be parsed: {exc.reason}\n"
|
|
140
|
+
"Respond again with a single JSON envelope "
|
|
141
|
+
"(tool_call or final_answer) and no other text.",
|
|
142
|
+
kind="observation",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
await self._fire_step(step, messages, raw, "parse_error", [])
|
|
146
|
+
continue
|
|
147
|
+
parse_failures = 0
|
|
148
|
+
|
|
149
|
+
if isinstance(parsed, FinalAnswer):
|
|
150
|
+
if output_model is not None:
|
|
151
|
+
value, error = _validate_output(parsed.output, output_model)
|
|
152
|
+
if error is not None:
|
|
153
|
+
history.append(StepRecord(step=step, raw=raw, kind="parse_error"))
|
|
154
|
+
messages.append(
|
|
155
|
+
Message(
|
|
156
|
+
Role.USER,
|
|
157
|
+
f"Your final_answer output is invalid: {error}\n"
|
|
158
|
+
"Respond again with a corrected final_answer envelope.",
|
|
159
|
+
kind="observation",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
await self._fire_step(step, messages, raw, "parse_error", [])
|
|
163
|
+
continue
|
|
164
|
+
history.append(StepRecord(step=step, raw=raw, kind="final_answer"))
|
|
165
|
+
await self._fire_step(step, messages, raw, "final_answer", [])
|
|
166
|
+
return RunResult(Status.COMPLETED, value, history)
|
|
167
|
+
history.append(StepRecord(step=step, raw=raw, kind="final_answer"))
|
|
168
|
+
await self._fire_step(step, messages, raw, "final_answer", [])
|
|
169
|
+
return RunResult(Status.COMPLETED, parsed.output, history)
|
|
170
|
+
|
|
171
|
+
call_records: list[ToolCallRecord] = []
|
|
172
|
+
observations: list[str] = []
|
|
173
|
+
for call in parsed.calls: # sequential in v1
|
|
174
|
+
record, observation = await self._run_call(step, call, mode)
|
|
175
|
+
call_records.append(record)
|
|
176
|
+
observations.append(f"[{record.call_id}] {observation}")
|
|
177
|
+
messages.append(Message(Role.USER, "\n".join(observations), kind="observation"))
|
|
178
|
+
history.append(StepRecord(step=step, raw=raw, kind="tool_calls", calls=call_records))
|
|
179
|
+
if self.context is not None:
|
|
180
|
+
messages = await self.context.manage(messages)
|
|
181
|
+
await self._fire_step(step, messages, raw, "tool_calls", call_records)
|
|
182
|
+
|
|
183
|
+
return await self._handle_max(on_max, max_iterations, messages, history, output_model)
|
|
184
|
+
|
|
185
|
+
async def _run_call(self, step: int, call, mode: ControlMode) -> tuple[ToolCallRecord, str]:
|
|
186
|
+
tooldef = self.registry.get(call.name)
|
|
187
|
+
args = dict(call.args)
|
|
188
|
+
dangerous = tooldef.dangerous if tooldef else False
|
|
189
|
+
|
|
190
|
+
decision: Decision | None = None
|
|
191
|
+
if self.on_tool_call is not None:
|
|
192
|
+
decision = await self.on_tool_call(
|
|
193
|
+
ToolCallContext(
|
|
194
|
+
step=step,
|
|
195
|
+
call_id=call.id,
|
|
196
|
+
name=call.name,
|
|
197
|
+
args=args,
|
|
198
|
+
dangerous=dangerous,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
allowed = mode is ControlMode.BYPASS
|
|
202
|
+
if decision is not None:
|
|
203
|
+
allowed = decision.allowed
|
|
204
|
+
if not allowed:
|
|
205
|
+
reason = (decision.reason if decision else "") or "denied by policy"
|
|
206
|
+
return await self._finish_call(
|
|
207
|
+
step,
|
|
208
|
+
ToolCallRecord(
|
|
209
|
+
call_id=call.id, name=call.name, args=args, status="denied", result=reason
|
|
210
|
+
),
|
|
211
|
+
f"DENIED: {reason}",
|
|
212
|
+
)
|
|
213
|
+
if decision is not None and decision.args is not None:
|
|
214
|
+
args = dict(decision.args)
|
|
215
|
+
|
|
216
|
+
if tooldef is None:
|
|
217
|
+
message = (
|
|
218
|
+
f"unknown tool {call.name!r}; available tools: {', '.join(sorted(self.registry))}"
|
|
219
|
+
)
|
|
220
|
+
return await self._finish_call(
|
|
221
|
+
step,
|
|
222
|
+
ToolCallRecord(
|
|
223
|
+
call_id=call.id,
|
|
224
|
+
name=call.name,
|
|
225
|
+
args=args,
|
|
226
|
+
status="error",
|
|
227
|
+
result=message,
|
|
228
|
+
),
|
|
229
|
+
f"ERROR: {message}",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
started = perf_counter()
|
|
233
|
+
ok, result = await tooldef.execute(args)
|
|
234
|
+
duration = perf_counter() - started
|
|
235
|
+
if len(result) > self.max_tool_result_chars:
|
|
236
|
+
result = result[: self.max_tool_result_chars] + "\n...[result truncated]"
|
|
237
|
+
return await self._finish_call(
|
|
238
|
+
step,
|
|
239
|
+
ToolCallRecord(
|
|
240
|
+
call_id=call.id,
|
|
241
|
+
name=call.name,
|
|
242
|
+
args=args,
|
|
243
|
+
status="ok" if ok else "error",
|
|
244
|
+
result=result,
|
|
245
|
+
duration=duration,
|
|
246
|
+
),
|
|
247
|
+
result if ok else f"ERROR: {result}",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def _finish_call(
|
|
251
|
+
self, step: int, record: ToolCallRecord, observation: str
|
|
252
|
+
) -> tuple[ToolCallRecord, str]:
|
|
253
|
+
if self.on_tool_result is not None:
|
|
254
|
+
await self.on_tool_result(
|
|
255
|
+
ToolResultContext(
|
|
256
|
+
step=step,
|
|
257
|
+
call_id=record.call_id,
|
|
258
|
+
name=record.name,
|
|
259
|
+
args=record.args,
|
|
260
|
+
status=record.status,
|
|
261
|
+
result=record.result,
|
|
262
|
+
duration=record.duration,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
return record, observation
|
|
266
|
+
|
|
267
|
+
async def _fire_step(
|
|
268
|
+
self,
|
|
269
|
+
step: int,
|
|
270
|
+
messages: list[Message],
|
|
271
|
+
raw: str,
|
|
272
|
+
kind: str,
|
|
273
|
+
calls: list[ToolCallRecord],
|
|
274
|
+
) -> None:
|
|
275
|
+
if self.on_step is not None:
|
|
276
|
+
await self.on_step(
|
|
277
|
+
StepContext(step=step, messages=messages, raw=raw, kind=kind, calls=list(calls))
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
async def _handle_max(
|
|
281
|
+
self,
|
|
282
|
+
on_max: OnMax,
|
|
283
|
+
max_iterations: int,
|
|
284
|
+
messages: list[Message],
|
|
285
|
+
history: list[StepRecord],
|
|
286
|
+
output_model: type[BaseModel] | None,
|
|
287
|
+
) -> RunResult:
|
|
288
|
+
if on_max is OnMax.WRAP_UP:
|
|
289
|
+
messages.append(
|
|
290
|
+
Message(
|
|
291
|
+
Role.USER,
|
|
292
|
+
"You have reached the maximum number of iterations. Respond NOW "
|
|
293
|
+
'with your final_answer envelope ({"type": "final_answer", '
|
|
294
|
+
'"output": ...}).',
|
|
295
|
+
kind="observation",
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
raw = await self.provider.complete(messages)
|
|
299
|
+
messages.append(Message(Role.ASSISTANT, raw))
|
|
300
|
+
try:
|
|
301
|
+
parsed = self.protocol.parse(raw)
|
|
302
|
+
except ParseError:
|
|
303
|
+
parsed = None
|
|
304
|
+
if isinstance(parsed, FinalAnswer):
|
|
305
|
+
value: Any = None
|
|
306
|
+
if output_model is None:
|
|
307
|
+
value = parsed.output
|
|
308
|
+
else:
|
|
309
|
+
value, error = _validate_output(parsed.output, output_model)
|
|
310
|
+
if error is not None:
|
|
311
|
+
value = None
|
|
312
|
+
if value is not None or output_model is None:
|
|
313
|
+
history.append(
|
|
314
|
+
StepRecord(step=max_iterations + 1, raw=raw, kind="final_answer")
|
|
315
|
+
)
|
|
316
|
+
return RunResult(Status.COMPLETED, value, history)
|
|
317
|
+
return RunResult(Status.MAX_ITERATIONS, None, history)
|
|
318
|
+
if on_max is OnMax.PARTIAL:
|
|
319
|
+
return RunResult(Status.MAX_ITERATIONS, None, history)
|
|
320
|
+
raise MaxIterationsExceeded(max_iterations, history[-1].raw if history else None)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _validate_output(output: Any, model: type[BaseModel]) -> tuple[BaseModel | None, str | None]:
|
|
324
|
+
value = output
|
|
325
|
+
if isinstance(value, str):
|
|
326
|
+
try:
|
|
327
|
+
value = json.loads(value)
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
return (
|
|
330
|
+
None,
|
|
331
|
+
"expected a JSON object matching the required schema, got a non-JSON string",
|
|
332
|
+
)
|
|
333
|
+
if not isinstance(value, dict):
|
|
334
|
+
return (
|
|
335
|
+
None,
|
|
336
|
+
f"expected a JSON object matching the required schema, got {type(value).__name__}",
|
|
337
|
+
)
|
|
338
|
+
try:
|
|
339
|
+
return model.model_validate(value), None
|
|
340
|
+
except ValidationError as exc:
|
|
341
|
+
problems = "; ".join(
|
|
342
|
+
f"{'.'.join(str(loc) for loc in error['loc'])}: {error['msg']}"
|
|
343
|
+
for error in exc.errors()
|
|
344
|
+
)
|
|
345
|
+
return None, f"schema validation failed ({problems})"
|
toolloop/context.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Context-window management: truncation of old observations + compaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from ._types import Message, Role
|
|
8
|
+
|
|
9
|
+
_SUMMARY_SYSTEM = (
|
|
10
|
+
"Summarize the following agent conversation compactly. Preserve: the "
|
|
11
|
+
"original task, key findings, decisions made, essential tool results, and "
|
|
12
|
+
"the current state. Reply with the summary only."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def estimate_tokens(messages: Sequence[Message]) -> int:
|
|
17
|
+
"""Rough token estimate (~4 chars per token, plus per-message overhead)."""
|
|
18
|
+
return sum(len(message.content) // 4 + 8 for message in messages)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ContextManager:
|
|
22
|
+
"""Keeps the conversation within ``max_tokens`` using a heuristic estimate.
|
|
23
|
+
|
|
24
|
+
Two stages, cheapest first: truncate the oldest tool observations to a
|
|
25
|
+
short preview, then compact by asking the provider itself to summarize the
|
|
26
|
+
middle of the conversation (the system prompt and the most recent messages
|
|
27
|
+
are always preserved).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
keep_recent_observations = 2
|
|
31
|
+
observation_head_chars = 200
|
|
32
|
+
|
|
33
|
+
def __init__(self, provider, max_tokens: int):
|
|
34
|
+
self.provider = provider
|
|
35
|
+
self.max_tokens = max_tokens
|
|
36
|
+
|
|
37
|
+
async def manage(self, messages: list[Message]) -> list[Message]:
|
|
38
|
+
if estimate_tokens(messages) <= self.max_tokens:
|
|
39
|
+
return messages
|
|
40
|
+
messages = self._truncate_observations(messages)
|
|
41
|
+
if estimate_tokens(messages) <= self.max_tokens:
|
|
42
|
+
return messages
|
|
43
|
+
return await self._compact(messages)
|
|
44
|
+
|
|
45
|
+
def _truncate_observations(self, messages: list[Message]) -> list[Message]:
|
|
46
|
+
out = list(messages)
|
|
47
|
+
indexes = [i for i, message in enumerate(out) if message.kind == "observation"]
|
|
48
|
+
preserved = set(indexes[-self.keep_recent_observations :])
|
|
49
|
+
for index in indexes: # oldest first, stop as soon as we fit
|
|
50
|
+
if index in preserved or estimate_tokens(out) <= self.max_tokens:
|
|
51
|
+
continue
|
|
52
|
+
head = out[index].content[: self.observation_head_chars]
|
|
53
|
+
replacement = f"{head}\n...[older observation truncated]"
|
|
54
|
+
out[index] = Message(out[index].role, replacement, out[index].kind)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
async def _compact(self, messages: list[Message]) -> list[Message]:
|
|
58
|
+
if len(messages) <= 3: # nothing meaningful to summarize
|
|
59
|
+
return messages
|
|
60
|
+
head = messages[0] # system prompt stays
|
|
61
|
+
tail = messages[-2:]
|
|
62
|
+
middle = messages[1 : len(messages) - len(tail)]
|
|
63
|
+
if not middle:
|
|
64
|
+
return messages
|
|
65
|
+
transcript = "\n".join(f"{m.role.value}: {m.content}" for m in middle)
|
|
66
|
+
summary = await self.provider.complete(
|
|
67
|
+
[
|
|
68
|
+
Message(Role.SYSTEM, _SUMMARY_SYSTEM),
|
|
69
|
+
Message(Role.USER, transcript),
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
budget_chars = max(
|
|
73
|
+
0,
|
|
74
|
+
(self.max_tokens - estimate_tokens([head, *tail])) * 4,
|
|
75
|
+
)
|
|
76
|
+
if len(summary) > budget_chars:
|
|
77
|
+
summary = summary[:budget_chars] + "\n...[summary truncated]"
|
|
78
|
+
return [head, Message(Role.USER, f"[conversation summary]\n{summary}"), *tail]
|
toolloop/hooks.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Async hooks and control modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._types import Message, ToolCallRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ControlMode(StrEnum):
|
|
13
|
+
"""Who is allowed to run tool calls.
|
|
14
|
+
|
|
15
|
+
- ``APPROVE``: default-deny. Every tool call must be explicitly allowed by
|
|
16
|
+
an ``on_tool_call`` hook (human-in-the-loop).
|
|
17
|
+
- ``BYPASS``: default-allow. Fully autonomous; hooks can still deny or
|
|
18
|
+
modify calls.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
APPROVE = "approve"
|
|
22
|
+
BYPASS = "bypass"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Decision:
|
|
27
|
+
"""Verdict of an ``on_tool_call`` hook."""
|
|
28
|
+
|
|
29
|
+
allowed: bool
|
|
30
|
+
args: dict[str, Any] | None = None # replacement args, when allowed
|
|
31
|
+
reason: str = ""
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def allow(cls, args: dict[str, Any] | None = None) -> Decision:
|
|
35
|
+
return cls(allowed=True, args=args)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def deny(cls, reason: str = "denied") -> Decision:
|
|
39
|
+
return cls(allowed=False, reason=reason)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class StepContext:
|
|
44
|
+
"""Context passed to ``on_step`` after each provider call is processed."""
|
|
45
|
+
|
|
46
|
+
step: int
|
|
47
|
+
messages: list[Message] # live conversation; read-only by convention
|
|
48
|
+
raw: str | None # the provider response for this step, if any
|
|
49
|
+
kind: str # "tool_calls" | "final_answer" | "parse_error"
|
|
50
|
+
calls: list[ToolCallRecord] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ToolCallContext:
|
|
55
|
+
"""Context passed to ``on_tool_call`` before execution."""
|
|
56
|
+
|
|
57
|
+
step: int
|
|
58
|
+
call_id: str
|
|
59
|
+
name: str
|
|
60
|
+
args: dict[str, Any]
|
|
61
|
+
dangerous: bool
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ToolResultContext:
|
|
66
|
+
"""Context passed to ``on_tool_result`` after execution (or denial)."""
|
|
67
|
+
|
|
68
|
+
step: int
|
|
69
|
+
call_id: str
|
|
70
|
+
name: str
|
|
71
|
+
args: dict[str, Any]
|
|
72
|
+
status: str # "ok" | "error" | "denied"
|
|
73
|
+
result: str
|
|
74
|
+
duration: float
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Tool-call protocols."""
|
|
2
|
+
|
|
3
|
+
from .base import FinalAnswer, ToolCallRequest, ToolCalls, ToolProtocol
|
|
4
|
+
from .json_protocol import JsonToolProtocol
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"FinalAnswer",
|
|
8
|
+
"ToolCallRequest",
|
|
9
|
+
"ToolCalls",
|
|
10
|
+
"ToolProtocol",
|
|
11
|
+
"JsonToolProtocol",
|
|
12
|
+
]
|