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 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
+ ]