toolloop 0.1.0__tar.gz
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-0.1.0/.gitignore +25 -0
- toolloop-0.1.0/.zcode/plans/plan-sess_d9dbb53a-b8d6-4fea-bf5f-ad02e807cf56.md +47 -0
- toolloop-0.1.0/LICENSE +21 -0
- toolloop-0.1.0/PKG-INFO +238 -0
- toolloop-0.1.0/README.md +215 -0
- toolloop-0.1.0/brainstorms/2026-08-22-framework-ia-tool-use.md +79 -0
- toolloop-0.1.0/examples/anthropic_provider.py +37 -0
- toolloop-0.1.0/examples/coding_agent.py +55 -0
- toolloop-0.1.0/examples/openai_compat_provider.py +31 -0
- toolloop-0.1.0/pyproject.toml +41 -0
- toolloop-0.1.0/src/toolloop/__init__.py +60 -0
- toolloop-0.1.0/src/toolloop/_types.py +88 -0
- toolloop-0.1.0/src/toolloop/agent.py +345 -0
- toolloop-0.1.0/src/toolloop/context.py +78 -0
- toolloop-0.1.0/src/toolloop/hooks.py +74 -0
- toolloop-0.1.0/src/toolloop/protocol/__init__.py +12 -0
- toolloop-0.1.0/src/toolloop/protocol/base.py +50 -0
- toolloop-0.1.0/src/toolloop/protocol/json_protocol.py +109 -0
- toolloop-0.1.0/src/toolloop/provider.py +21 -0
- toolloop-0.1.0/src/toolloop/subagent.py +34 -0
- toolloop-0.1.0/src/toolloop/tools/__init__.py +20 -0
- toolloop-0.1.0/src/toolloop/tools/definition.py +110 -0
- toolloop-0.1.0/src/toolloop/tools/fs.py +99 -0
- toolloop-0.1.0/src/toolloop/tools/search.py +48 -0
- toolloop-0.1.0/src/toolloop/tools/shell.py +30 -0
- toolloop-0.1.0/tests/conftest.py +19 -0
- toolloop-0.1.0/tests/test_agent_loop.py +128 -0
- toolloop-0.1.0/tests/test_context.py +66 -0
- toolloop-0.1.0/tests/test_hooks_control.py +113 -0
- toolloop-0.1.0/tests/test_json_protocol.py +81 -0
- toolloop-0.1.0/tests/test_std_tools.py +81 -0
- toolloop-0.1.0/tests/test_subagent.py +37 -0
- toolloop-0.1.0/tests/test_tools.py +75 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
|
|
8
|
+
# Environments
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
|
|
12
|
+
# Tooling caches
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# uv (libraries don't ship lockfiles)
|
|
20
|
+
uv.lock
|
|
21
|
+
|
|
22
|
+
# Editors
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
*.swp
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Plano de implementação — toolloop v0.1.0 (atualizado)
|
|
2
|
+
|
|
3
|
+
Mesmo plano aprovado, com o ajuste de granularidade dos modos de controle: **ControlMode com exatamente 2 opções — `APPROVE` (default-deny: toda tool call precisa ser liberada por um hook `on_tool_call`) e `BYPASS` (default-allow: autônomo, hooks ainda podem vetar/modificar)**. Sem modo intermediário. A renomeação da pasta `~/Projects/openFlow` → `~/Projects/toolloop` está confirmada no scaffold.
|
|
4
|
+
|
|
5
|
+
## 1. Scaffold e identidade
|
|
6
|
+
|
|
7
|
+
- Renomear pasta (levando `brainstorms/`), `git init -b main`, atualizar o brainstorm com a decisão dos 2 modos (fonte da verdade).
|
|
8
|
+
- `pyproject.toml` (hatchling): `toolloop` 0.1.0, **Python ≥3.11**, runtime dep única **pydantic ≥2**; dev: `pytest`, `pytest-asyncio`, `ruff`. Gerenciado com **uv**. **MIT**.
|
|
9
|
+
- Layout `src/`: `_types` (mensagens, exceções, Status), `provider` (Protocol async), `tools/` (`definition.py` com `@tool` + std toolset `shell/fs/search`), `protocol/` (`base.py` ABC + `json_protocol.py`), `agent` (loop + RunResult), `hooks` (3 hooks + ControlMode), `context` (truncation + compaction), `subagent`, `examples/`, `tests/`.
|
|
10
|
+
|
|
11
|
+
## 2. Contratos centrais
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
class Provider(Protocol):
|
|
15
|
+
async def complete(self, messages: Sequence[Message]) -> str: ...
|
|
16
|
+
|
|
17
|
+
@tool # async fn; nome=função, desc=docstring, schema via type hints (pydantic)
|
|
18
|
+
|
|
19
|
+
class Agent:
|
|
20
|
+
def __init__(self, provider, tools=(), *, protocol=None, system_prompt=None,
|
|
21
|
+
control=ControlMode.BYPASS, on_step=None, on_tool_call=None,
|
|
22
|
+
on_tool_result=None, max_context_tokens=None): ...
|
|
23
|
+
async def run(self, input, *, max_iterations=25, on_max=OnMax.RAISE,
|
|
24
|
+
control=None, output_model=None) -> RunResult
|
|
25
|
+
# RunResult: output (validado se output_model), status (COMPLETED|MAX_ITERATIONS), history completo
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- Envelope JSON: `{"type":"tool_call","calls":[{id?,name,args}]}` | `{"type":"final_answer","output":...}`; parse tolerante (fenced/cru, último bloco válido vence); auto-repair com limite de falhas consecutivas; calls executadas **sequencialmente**.
|
|
29
|
+
- Hooks: `on_step`/`on_tool_call`→`Decision.allow/deny`/`on_tool_result`; BYPASS mantém observabilidade; APPROVE sem hook `on_tool_call` → erro de configuração claro no `run()`.
|
|
30
|
+
- Contexto: heurística chars/4; truncation de observações antigas; compaction via sumarização pelo próprio provider (preserva system + mensagens recentes); safety-net de tamanho por resultado; toolset com resultados compactos por design.
|
|
31
|
+
- `subagent_tool(agent)` no core; toolset padrão com `bash` marcada `dangerous`.
|
|
32
|
+
|
|
33
|
+
## 3. Milestones (commits por grupo)
|
|
34
|
+
|
|
35
|
+
1. Scaffold + config + git
|
|
36
|
+
2. Core (`_types`, `provider`, `tools`)
|
|
37
|
+
3. `protocol/json_protocol`
|
|
38
|
+
4. `agent` (loop, políticas de max_iterations, histórico)
|
|
39
|
+
5. `hooks` + ControlMode APPROVE/BYPASS
|
|
40
|
+
6. `context` + `subagent`
|
|
41
|
+
7. Toolset padrão (6 tools)
|
|
42
|
+
8. `examples/` + README real
|
|
43
|
+
9. Suite de testes (FakeProvider scriptado: happy path, auto-repair, veto, modos, políticas de estouro, compaction, subagent, tools em tmp_path) + ruff limpo + tag `v0.1.0`
|
|
44
|
+
|
|
45
|
+
## 4. Fora do escopo v1 (roadmap no README)
|
|
46
|
+
|
|
47
|
+
Streaming, tool calls paralelas, CLI (`init`/`test`), adapters publicados como extras.
|
toolloop-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 apavanello
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
toolloop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: toolloop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent loop framework for LLM providers without native tool use
|
|
5
|
+
Author: apavanello
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agents,ai,framework,llm,tool-use
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: pydantic>=2.7
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# toolloop
|
|
25
|
+
|
|
26
|
+
**Agent loops for LLM providers without native tool use.**
|
|
27
|
+
|
|
28
|
+
`toolloop` is a Python framework for building autonomous agents — tool use,
|
|
29
|
+
exploration, coding — on top of any LLM endpoint, even (especially) the ones
|
|
30
|
+
whose SDKs never exposed a `tools` parameter. If you can send messages and get
|
|
31
|
+
text back, you can run an agent on it.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
┌─────────────────────────────────────────────┐
|
|
35
|
+
│ │
|
|
36
|
+
input ──▶ │ system prompt (tool instructions) │
|
|
37
|
+
│ + conversation history │
|
|
38
|
+
▼ │
|
|
39
|
+
┌───────────┐ {"type":"tool_call", ...} ┌────────────┐
|
|
40
|
+
│ provider │ ────────────────────────────▶ │ tool runs │
|
|
41
|
+
│ (yours) │ └────────────┘
|
|
42
|
+
└───────────┘ │
|
|
43
|
+
│ │ observation
|
|
44
|
+
│ {"type":"final_answer", ...} ▼
|
|
45
|
+
▼ back to provider
|
|
46
|
+
output
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Why
|
|
50
|
+
|
|
51
|
+
Plenty of real-world LLM access goes through proprietary corporate SDKs that
|
|
52
|
+
proxy the big providers (Anthropic, OpenAI, Kimi, DeepSeek, ...) but strip or
|
|
53
|
+
never implemented the tool-use layer. The models behind them are perfectly
|
|
54
|
+
capable of agentic work — the SDK just won't carry function calls.
|
|
55
|
+
|
|
56
|
+
`toolloop` solves this at the application layer:
|
|
57
|
+
|
|
58
|
+
- **Bring your own provider.** The framework never manages providers. The
|
|
59
|
+
whole contract is one async method: `complete(messages) -> str`.
|
|
60
|
+
- **Tools over plain text.** Tool schemas are rendered into the system prompt;
|
|
61
|
+
tool calls are parsed out of the model's text responses. Parse errors are
|
|
62
|
+
fed back to the model (auto-repair) until it gets the envelope right.
|
|
63
|
+
- **Loop until satisfied.** Given an input, the agent calls tools, receives
|
|
64
|
+
observations, and iterates until it emits a `final_answer`.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
Requires Python 3.11+.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install toolloop # once published
|
|
72
|
+
# or, from source:
|
|
73
|
+
uv sync --extra dev
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Quickstart (no LLM needed)
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import asyncio
|
|
80
|
+
|
|
81
|
+
from toolloop import Agent, tool
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@tool
|
|
85
|
+
async def add(a: int, b: int) -> int:
|
|
86
|
+
"""Add two numbers."""
|
|
87
|
+
return a + b
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DemoProvider:
|
|
91
|
+
"""A scripted provider standing in for your real one."""
|
|
92
|
+
|
|
93
|
+
def __init__(self):
|
|
94
|
+
self.turns = [
|
|
95
|
+
'{"type": "tool_call", "calls": [{"id": "c1", "name": "add", "args": {"a": 2, "b": 3}}]}',
|
|
96
|
+
'{"type": "final_answer", "output": "2 + 3 = 5"}',
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
async def complete(self, messages):
|
|
100
|
+
return self.turns.pop(0)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
agent = Agent(DemoProvider(), tools=[add])
|
|
104
|
+
result = asyncio.run(agent.run("how much is 2 + 3?"))
|
|
105
|
+
print(result.output) # 2 + 3 = 5
|
|
106
|
+
print(result.status) # Status.COMPLETED
|
|
107
|
+
print(result.history[0].calls[0].result) # 5
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Bring your own provider
|
|
111
|
+
|
|
112
|
+
Implement one async method and you are done — SDK, plain HTTP, whatever:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
class MyCorporateProxyProvider:
|
|
116
|
+
async def complete(self, messages):
|
|
117
|
+
response = await my_corporate_sdk.chat(
|
|
118
|
+
[{"role": m.role.value, "content": m.content} for m in messages]
|
|
119
|
+
)
|
|
120
|
+
return response.text
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Reference adapters (OpenAI-compatible endpoints and Anthropic) live in
|
|
124
|
+
[`examples/`](examples/) as copy-paste code, not dependencies.
|
|
125
|
+
|
|
126
|
+
## Tour
|
|
127
|
+
|
|
128
|
+
### Defining tools
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
@tool # name = function, schema = type hints
|
|
132
|
+
async def search_docs(query: str, limit: int = 5) -> str:
|
|
133
|
+
"""Search the internal documentation."""
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
@tool(dangerous=True) # flagged for approval hooks
|
|
137
|
+
async def run_migration(env: str) -> str:
|
|
138
|
+
"""Run the database migration."""
|
|
139
|
+
...
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Arguments are validated with pydantic; invalid arguments and raised exceptions
|
|
143
|
+
become error observations the model repairs from — they never crash the loop.
|
|
144
|
+
|
|
145
|
+
### Running agents
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
result = await agent.run(
|
|
149
|
+
"summarize open PRs",
|
|
150
|
+
max_iterations=25,
|
|
151
|
+
on_max=OnMax.WRAP_UP, # or RAISE (default) or PARTIAL
|
|
152
|
+
output_model=Summary, # pydantic model: validated structured output
|
|
153
|
+
)
|
|
154
|
+
result.status # Status.COMPLETED | Status.MAX_ITERATIONS
|
|
155
|
+
result.output # str, or a validated Summary instance
|
|
156
|
+
result.history # full audit trail of every step and call
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Control modes and hooks
|
|
160
|
+
|
|
161
|
+
Two modes, configurable on the `Agent` and overridable per `run()`:
|
|
162
|
+
|
|
163
|
+
- **`ControlMode.BYPASS`** (default) — autonomous; hooks may still veto or
|
|
164
|
+
rewrite calls.
|
|
165
|
+
- **`ControlMode.APPROVE`** — default-deny; every tool call must be allowed by
|
|
166
|
+
an `on_tool_call` hook (human-in-the-loop).
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
async def gatekeeper(ctx) -> Decision:
|
|
170
|
+
if ctx.dangerous:
|
|
171
|
+
answer = input(f"allow {ctx.name}({ctx.args})? [y/N] ")
|
|
172
|
+
return Decision.allow() if answer == "y" else Decision.deny("no")
|
|
173
|
+
return Decision.allow()
|
|
174
|
+
|
|
175
|
+
agent = Agent(provider, tools=STD_TOOLS, control=ControlMode.APPROVE, on_tool_call=gatekeeper)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`on_step` and `on_tool_result` hooks give you full observability (audit,
|
|
179
|
+
logging, tracing) in both modes.
|
|
180
|
+
|
|
181
|
+
### Context management
|
|
182
|
+
|
|
183
|
+
Set `max_context_tokens` and the agent keeps the conversation within budget:
|
|
184
|
+
old tool observations are truncated first, then the middle of the conversation
|
|
185
|
+
is compacted via summarization by the provider itself. The standard toolset
|
|
186
|
+
already returns compact results by design (a write tool confirms the size it
|
|
187
|
+
wrote, it does not echo the content).
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
agent = Agent(provider, tools=STD_TOOLS, max_context_tokens=16_000)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Subagents
|
|
194
|
+
|
|
195
|
+
Wrap an agent as a tool: it explores with its own isolated context and only
|
|
196
|
+
its final answer comes back.
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from toolloop import subagent_tool
|
|
200
|
+
|
|
201
|
+
researcher = Agent(provider, tools=[search_docs])
|
|
202
|
+
agent = Agent(provider, tools=[subagent_tool(researcher), write_file])
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Standard toolset (optional)
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from toolloop import STD_TOOLS
|
|
209
|
+
# bash, read_file, write_file, edit_file, list_files, grep
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Pure-Python coding-agent toolset; import it or ignore it — the core knows
|
|
213
|
+
nothing about it.
|
|
214
|
+
|
|
215
|
+
## Testing your agents
|
|
216
|
+
|
|
217
|
+
The provider contract is one method, so deterministic tests are trivial:
|
|
218
|
+
script a fake provider with canned responses (see `tests/conftest.py` in this
|
|
219
|
+
repo) and assert on `result.history` — no LLM, no flakes.
|
|
220
|
+
|
|
221
|
+
## Project
|
|
222
|
+
|
|
223
|
+
- License: MIT
|
|
224
|
+
- Python: 3.11+
|
|
225
|
+
- Dependencies: pydantic (only)
|
|
226
|
+
- Roadmap: streaming, parallel tool calls, a small CLI (`init`, `test`), ...
|
|
227
|
+
|
|
228
|
+
## How it works
|
|
229
|
+
|
|
230
|
+
1. Tool schemas and the JSON envelope format are rendered into the system
|
|
231
|
+
prompt by a pluggable `ToolProtocol` (default: `JsonToolProtocol`).
|
|
232
|
+
2. The agent calls the provider and parses the response envelope:
|
|
233
|
+
`tool_call` (a list of calls, executed sequentially in v1) or
|
|
234
|
+
`final_answer`.
|
|
235
|
+
3. Tool results are appended as observations; parse/validation errors are fed
|
|
236
|
+
back so the model can repair its own output.
|
|
237
|
+
4. The loop ends on `final_answer`, on `max_iterations` (per the configured
|
|
238
|
+
policy), or when a hook denies everything.
|
toolloop-0.1.0/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# toolloop
|
|
2
|
+
|
|
3
|
+
**Agent loops for LLM providers without native tool use.**
|
|
4
|
+
|
|
5
|
+
`toolloop` is a Python framework for building autonomous agents — tool use,
|
|
6
|
+
exploration, coding — on top of any LLM endpoint, even (especially) the ones
|
|
7
|
+
whose SDKs never exposed a `tools` parameter. If you can send messages and get
|
|
8
|
+
text back, you can run an agent on it.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌─────────────────────────────────────────────┐
|
|
12
|
+
│ │
|
|
13
|
+
input ──▶ │ system prompt (tool instructions) │
|
|
14
|
+
│ + conversation history │
|
|
15
|
+
▼ │
|
|
16
|
+
┌───────────┐ {"type":"tool_call", ...} ┌────────────┐
|
|
17
|
+
│ provider │ ────────────────────────────▶ │ tool runs │
|
|
18
|
+
│ (yours) │ └────────────┘
|
|
19
|
+
└───────────┘ │
|
|
20
|
+
│ │ observation
|
|
21
|
+
│ {"type":"final_answer", ...} ▼
|
|
22
|
+
▼ back to provider
|
|
23
|
+
output
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
Plenty of real-world LLM access goes through proprietary corporate SDKs that
|
|
29
|
+
proxy the big providers (Anthropic, OpenAI, Kimi, DeepSeek, ...) but strip or
|
|
30
|
+
never implemented the tool-use layer. The models behind them are perfectly
|
|
31
|
+
capable of agentic work — the SDK just won't carry function calls.
|
|
32
|
+
|
|
33
|
+
`toolloop` solves this at the application layer:
|
|
34
|
+
|
|
35
|
+
- **Bring your own provider.** The framework never manages providers. The
|
|
36
|
+
whole contract is one async method: `complete(messages) -> str`.
|
|
37
|
+
- **Tools over plain text.** Tool schemas are rendered into the system prompt;
|
|
38
|
+
tool calls are parsed out of the model's text responses. Parse errors are
|
|
39
|
+
fed back to the model (auto-repair) until it gets the envelope right.
|
|
40
|
+
- **Loop until satisfied.** Given an input, the agent calls tools, receives
|
|
41
|
+
observations, and iterates until it emits a `final_answer`.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
Requires Python 3.11+.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install toolloop # once published
|
|
49
|
+
# or, from source:
|
|
50
|
+
uv sync --extra dev
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quickstart (no LLM needed)
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
|
|
58
|
+
from toolloop import Agent, tool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@tool
|
|
62
|
+
async def add(a: int, b: int) -> int:
|
|
63
|
+
"""Add two numbers."""
|
|
64
|
+
return a + b
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DemoProvider:
|
|
68
|
+
"""A scripted provider standing in for your real one."""
|
|
69
|
+
|
|
70
|
+
def __init__(self):
|
|
71
|
+
self.turns = [
|
|
72
|
+
'{"type": "tool_call", "calls": [{"id": "c1", "name": "add", "args": {"a": 2, "b": 3}}]}',
|
|
73
|
+
'{"type": "final_answer", "output": "2 + 3 = 5"}',
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
async def complete(self, messages):
|
|
77
|
+
return self.turns.pop(0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
agent = Agent(DemoProvider(), tools=[add])
|
|
81
|
+
result = asyncio.run(agent.run("how much is 2 + 3?"))
|
|
82
|
+
print(result.output) # 2 + 3 = 5
|
|
83
|
+
print(result.status) # Status.COMPLETED
|
|
84
|
+
print(result.history[0].calls[0].result) # 5
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Bring your own provider
|
|
88
|
+
|
|
89
|
+
Implement one async method and you are done — SDK, plain HTTP, whatever:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
class MyCorporateProxyProvider:
|
|
93
|
+
async def complete(self, messages):
|
|
94
|
+
response = await my_corporate_sdk.chat(
|
|
95
|
+
[{"role": m.role.value, "content": m.content} for m in messages]
|
|
96
|
+
)
|
|
97
|
+
return response.text
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Reference adapters (OpenAI-compatible endpoints and Anthropic) live in
|
|
101
|
+
[`examples/`](examples/) as copy-paste code, not dependencies.
|
|
102
|
+
|
|
103
|
+
## Tour
|
|
104
|
+
|
|
105
|
+
### Defining tools
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
@tool # name = function, schema = type hints
|
|
109
|
+
async def search_docs(query: str, limit: int = 5) -> str:
|
|
110
|
+
"""Search the internal documentation."""
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
@tool(dangerous=True) # flagged for approval hooks
|
|
114
|
+
async def run_migration(env: str) -> str:
|
|
115
|
+
"""Run the database migration."""
|
|
116
|
+
...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Arguments are validated with pydantic; invalid arguments and raised exceptions
|
|
120
|
+
become error observations the model repairs from — they never crash the loop.
|
|
121
|
+
|
|
122
|
+
### Running agents
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
result = await agent.run(
|
|
126
|
+
"summarize open PRs",
|
|
127
|
+
max_iterations=25,
|
|
128
|
+
on_max=OnMax.WRAP_UP, # or RAISE (default) or PARTIAL
|
|
129
|
+
output_model=Summary, # pydantic model: validated structured output
|
|
130
|
+
)
|
|
131
|
+
result.status # Status.COMPLETED | Status.MAX_ITERATIONS
|
|
132
|
+
result.output # str, or a validated Summary instance
|
|
133
|
+
result.history # full audit trail of every step and call
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Control modes and hooks
|
|
137
|
+
|
|
138
|
+
Two modes, configurable on the `Agent` and overridable per `run()`:
|
|
139
|
+
|
|
140
|
+
- **`ControlMode.BYPASS`** (default) — autonomous; hooks may still veto or
|
|
141
|
+
rewrite calls.
|
|
142
|
+
- **`ControlMode.APPROVE`** — default-deny; every tool call must be allowed by
|
|
143
|
+
an `on_tool_call` hook (human-in-the-loop).
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
async def gatekeeper(ctx) -> Decision:
|
|
147
|
+
if ctx.dangerous:
|
|
148
|
+
answer = input(f"allow {ctx.name}({ctx.args})? [y/N] ")
|
|
149
|
+
return Decision.allow() if answer == "y" else Decision.deny("no")
|
|
150
|
+
return Decision.allow()
|
|
151
|
+
|
|
152
|
+
agent = Agent(provider, tools=STD_TOOLS, control=ControlMode.APPROVE, on_tool_call=gatekeeper)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`on_step` and `on_tool_result` hooks give you full observability (audit,
|
|
156
|
+
logging, tracing) in both modes.
|
|
157
|
+
|
|
158
|
+
### Context management
|
|
159
|
+
|
|
160
|
+
Set `max_context_tokens` and the agent keeps the conversation within budget:
|
|
161
|
+
old tool observations are truncated first, then the middle of the conversation
|
|
162
|
+
is compacted via summarization by the provider itself. The standard toolset
|
|
163
|
+
already returns compact results by design (a write tool confirms the size it
|
|
164
|
+
wrote, it does not echo the content).
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
agent = Agent(provider, tools=STD_TOOLS, max_context_tokens=16_000)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Subagents
|
|
171
|
+
|
|
172
|
+
Wrap an agent as a tool: it explores with its own isolated context and only
|
|
173
|
+
its final answer comes back.
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from toolloop import subagent_tool
|
|
177
|
+
|
|
178
|
+
researcher = Agent(provider, tools=[search_docs])
|
|
179
|
+
agent = Agent(provider, tools=[subagent_tool(researcher), write_file])
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Standard toolset (optional)
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from toolloop import STD_TOOLS
|
|
186
|
+
# bash, read_file, write_file, edit_file, list_files, grep
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Pure-Python coding-agent toolset; import it or ignore it — the core knows
|
|
190
|
+
nothing about it.
|
|
191
|
+
|
|
192
|
+
## Testing your agents
|
|
193
|
+
|
|
194
|
+
The provider contract is one method, so deterministic tests are trivial:
|
|
195
|
+
script a fake provider with canned responses (see `tests/conftest.py` in this
|
|
196
|
+
repo) and assert on `result.history` — no LLM, no flakes.
|
|
197
|
+
|
|
198
|
+
## Project
|
|
199
|
+
|
|
200
|
+
- License: MIT
|
|
201
|
+
- Python: 3.11+
|
|
202
|
+
- Dependencies: pydantic (only)
|
|
203
|
+
- Roadmap: streaming, parallel tool calls, a small CLI (`init`, `test`), ...
|
|
204
|
+
|
|
205
|
+
## How it works
|
|
206
|
+
|
|
207
|
+
1. Tool schemas and the JSON envelope format are rendered into the system
|
|
208
|
+
prompt by a pluggable `ToolProtocol` (default: `JsonToolProtocol`).
|
|
209
|
+
2. The agent calls the provider and parses the response envelope:
|
|
210
|
+
`tool_call` (a list of calls, executed sequentially in v1) or
|
|
211
|
+
`final_answer`.
|
|
212
|
+
3. Tool results are appended as observations; parse/validation errors are fed
|
|
213
|
+
back so the model can repair its own output.
|
|
214
|
+
4. The loop ends on `final_answer`, on `max_iterations` (per the configured
|
|
215
|
+
policy), or when a hook denies everything.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# toolloop — Framework Python de agent loop sem tool use nativo: Brainstorm / Discovery Notes
|
|
2
|
+
Date: 2026-08-22 · Goal: Definir um framework Python que facilite a criação de ferramentas/agentes que usam IA, onde o provider (abstraido) não suporta tool use nativamente.
|
|
3
|
+
|
|
4
|
+
## Summary / key decisions
|
|
5
|
+
- **O que é**: framework de **agent loop** (harness de agente autônomo, estilo Claude Code), não só emulação de tool use. Loop central: dada uma entrada X, o modelo usa as tools disponíveis em loop (uso ferramental, exploratório e codificatório) **até ficar satisfeito**, produzindo a saída final.
|
|
6
|
+
- **Problema real**: SDKs privativos corporativos que fazem proxy de providers grandes (Anthropic, OpenAI, Kimi, DeepSeek etc.) mas têm uma camada corporativa que **não expõe tools use** ("OpenRouter corporativo"). Modelos por trás são fortes; a limitação é do SDK.
|
|
7
|
+
- **Provider**: framework **não gerencia providers**. Contrato mínimo async: `async def complete(messages) -> str` (Protocol). Sem streaming no contrato v1. Usuário traz SDK próprio/código próprio. Adapters OpenAI-compatível e Anthropic apenas como `examples/`, não dependência.
|
|
8
|
+
- **Protocolo de tools plugável** (renderizador de system prompt + parser de saída); default = **bloco JSON**; **auto-repair**: erro de parse/validação volta ao modelo como observação.
|
|
9
|
+
- **Envelope explícito com discriminador**: `{"type": "tool_call", "calls": [...], ...}` ou `{"type": "final_answer", "output": ...}`. Loop termina só com `final_answer` (pode carregar saída estruturada validada). Campo `calls` é lista desde o v1 (forward-compatible), mas **v1 executa sequencialmente**; paralelização no roadmap. `max_iterations` configurável com política de estouro (erro / forçar wrap-up / retornar parcial com status).
|
|
10
|
+
- **Definição de tools**: `@tool` em funções **async**; nome = função, descrição = docstring, schema via type hints + **pydantic** (validação alimenta auto-repair). Retorno `str` ou serializável; exceções viram observação de erro por padrão (configurável).
|
|
11
|
+
- **Toolset opcional** `toolloop.tools`: `bash`, `read_file`, `write_file`, `edit_file`, `grep`/`search`, `list_files`. Core permanece agnóstico.
|
|
12
|
+
- **Hooks async de primeira classe**: `on_step` (por turno), `on_tool_call` (pré-execução; veto/modificação/aprovação → human-in-the-loop), `on_tool_result` (pós-execução). Resultado de `run()` carrega histórico completo.
|
|
13
|
+
- **Modos de controle: exatamente 2** (decisão final do usuário ao aprovar o plano) — `APPROVE` (default-deny: toda tool call precisa ser liberada pelo hook `on_tool_call`) e `BYPASS` (default-allow: autônomo; hooks ainda podem vetar/modificar). Configuráveis na instanciação e sobrescrevíveis no `run()`. Sem modo intermediário.
|
|
14
|
+
- **Gestão de contexto como harnesses modernos**: `max_context_tokens` opcional com truncation de tool results antigos + compaction por sumarização; resultados de tools **compactos por design** (confiança na sub-execução, estilo a2a); safety-net de tamanho por resultado; tool **`subagent` no core v1** (contexto isolado, devolve só `final_answer`).
|
|
15
|
+
- **Identidade**: nome **toolloop** (PyPI livre, sem colisões conceituais — `openloop` colide com thu-nmrc/openloop; `openflow` colide com projeto SDN de rede). **Open source, MIT**. Python 3.11+, pyproject + uv, pytest com provider fake em memória, ruff. v1 é **library pura** (sem CLI); roadmap: CLI básico (`init`, `test`, etc.).
|
|
16
|
+
|
|
17
|
+
## Q&A log
|
|
18
|
+
### Q0 — contexto adicional (antes da Q1)
|
|
19
|
+
- Asked: (usuário forneceu contexto espontaneamente)
|
|
20
|
+
- Captured: "simular o comportamento de agentes autônomos como harnesses ou claws" — facilitar o loop de uso ferramental, exploratório e codificatório; dado input X, o modelo usa as tools em loop até ficar satisfeito e provê a saída.
|
|
21
|
+
- Flags: interpretação "harnesses/claws" = agentes estilo Claude Code — **resolvida implicitamente** pelas respostas seguintes (toolset coding, modos de controle, "como harnesses modernos fazem").
|
|
22
|
+
|
|
23
|
+
### Q1 — Providers alvo / quem gerencia o provider
|
|
24
|
+
- Asked: Quais providers/modelos concretos suportar primeiro? Foco em endpoints OpenAI-compatible?
|
|
25
|
+
- Captured: Compatível com OpenAI Chat Completions e Anthropic **como referência**, mas o framework **não deve gerenciar o provider diretamente**. O utilizador fica livre para usar o provider que desejar — SDK próprio ou código próprio.
|
|
26
|
+
- Decisão: core 100% provider-agnostic; só um contrato mínimo (mensagens → texto), sem depender de nenhum SDK.
|
|
27
|
+
- Flags: adaptadores prontos opcionais → **resolvido na Q10** (examples/, não dependência).
|
|
28
|
+
|
|
29
|
+
### Q2 — Contrato do provider (sync/async, streaming)
|
|
30
|
+
- Asked: Contrato síncrono ou assíncrono? Streaming no contrato mínimo?
|
|
31
|
+
- Captured: "async então" — confirmado **async-first**.
|
|
32
|
+
- Decisão: Protocol async único (`async def complete(messages) -> str`). Streaming fora do contrato v1 (extensão futura).
|
|
33
|
+
|
|
34
|
+
### Q3 — Formato da tool call emulado + parsing
|
|
35
|
+
- Asked: Como o modelo expressa tool call em texto (ReAct / JSON / XML)? Plugável?
|
|
36
|
+
- Captured: Confirmou plugável + JSON default + auto-repair. Contexto adicional: alvo **não** são modelos pequenos — são **SDKs privativos corporativos** proxyando providers grandes sem expor tools use ("OpenRouter corporativo").
|
|
37
|
+
- Decisão: protocolo de tools plugável (par system-prompt-renderer + output-parser); default = bloco JSON; auto-repair devolve erros de parse/validação como observação. Modelos fortes → JSON de primeira é o caso comum.
|
|
38
|
+
|
|
39
|
+
### Q4 — Condição de parada e saída final
|
|
40
|
+
- Asked: Envelope explícito com discriminador vs. ausência de tool call = final (ReAct)?
|
|
41
|
+
- Captured: "confirmo".
|
|
42
|
+
- Decisão: envelope JSON com `type` — `tool_call` ou `final_answer` (com saída estruturada validada). Loop termina só com `final_answer`. `max_iterations` com política configurável (erro / wrap-up forçado / parcial com status).
|
|
43
|
+
|
|
44
|
+
### Q5 — API de definição de tools
|
|
45
|
+
- Asked: Decorator + type hints + pydantic, ou zero-dep com schema manual?
|
|
46
|
+
- Captured: "Sim".
|
|
47
|
+
- Decisão: `@tool` em funções async; schema por type hints via pydantic (gera JSON schema do prompt e valida args → alimenta auto-repair). Retorno `str`/serializável; exceções → observação de erro por padrão. Pydantic no core.
|
|
48
|
+
|
|
49
|
+
### Q6 — Toolset embutido vs. tools só do utilizador
|
|
50
|
+
- Asked: Framework embarca tools prontas? Quais?
|
|
51
|
+
- Captured: "Sim pode incluir".
|
|
52
|
+
- Decisão: core agnóstico; módulo opcional `toolloop.tools` com `bash`, `read_file`, `write_file`, `edit_file`, `grep`/`search`, `list_files`.
|
|
53
|
+
|
|
54
|
+
### Q7 — Hooks de observabilidade/controle + histórico
|
|
55
|
+
- Asked: Confirmar 3 hooks + histórico completo no resultado?
|
|
56
|
+
- Captured: "Sim, porém importante ter a opção (na chamada ou no instanciamento) de **bypass ou Full Control**".
|
|
57
|
+
- Decisão: hooks `on_step` / `on_tool_call` (veto/modifica/aprova) / `on_tool_result`; histórico completo no resultado. **Modos de controle** bypass ↔ full control, configuráveis na instanciação e/ou no `run()`; níveis intermediários possíveis (ex.: aprovar só tools perigosas).
|
|
58
|
+
- Flags: nome/enum dos modos e granularidade (por tool? categoria?) -> definir na implementação.
|
|
59
|
+
|
|
60
|
+
### Q8 — Gestão de contexto / tamanho do histórico
|
|
61
|
+
- Asked: v1 sem gestão, truncation, ou compaction?
|
|
62
|
+
- Captured: "como harnesses modernos fazem" + ideia própria: chamada de tool quase **a2a**, separada do contexto principal, retornando só o relevante (tool de escrita não retorna o que escreveu) — "confiança na sub-execução" (usuário duvidava que fosse funcional).
|
|
63
|
+
- Decisão: truncation de tool results antigos + compaction por sumarização (`max_context_tokens` opcional). Ideia a2a **aceita** (é o pattern sub-agent de harnesses reais): (1) toolset retorna compacto por design; (2) safety-net de tamanho por resultado; (3) tool `subagent` com contexto isolado devolvendo só `final_answer`.
|
|
64
|
+
|
|
65
|
+
### Q9 — Identidade e distribuição (nome, OSS, licença, tooling)
|
|
66
|
+
- Asked: Nome? Open source? Licença?
|
|
67
|
+
- Captured: nome proposto "openloop" (pasta openFlow foi criada errada — openflow já existe no mercado); aberto a sugestões; OSS "que pede apenas créditos".
|
|
68
|
+
- Checagem: PyPI — `openloop` e `toolloop` livres; `open-loop`, `agentloop`, `loopkit`, `pyopenloop` ocupados. GitHub thu-nmrc/openloop = mesmo conceito (colisão).
|
|
69
|
+
- Decisão: **nome = toolloop** (escolha do usuário após ver colisões). **MIT**. Python 3.11+, uv, pytest + provider fake, ruff.
|
|
70
|
+
- Flags: renomear pasta `~/Projects/openFlow` → `~/Projects/toolloop` no scaffold.
|
|
71
|
+
|
|
72
|
+
### Q10 — Escopo final (paralelismo, CLI, subagent)
|
|
73
|
+
- Asked: (1) tool calls paralelas? (2) CLI no v1? (3) subagent no core v1?
|
|
74
|
+
- Captured: "1. Ok / 2. Ok, no roadmap adicionar um cli básico (init, test, etc) / 3. Sim".
|
|
75
|
+
- Decisão: envelope nasce com `"calls": [...]` (lista) mas v1 executa **sequencialmente** (paralelização no roadmap); v1 **library pura** + `examples/` com adapters; **roadmap: CLI básico** (`init`, `test`); `subagent` **no core v1**.
|
|
76
|
+
|
|
77
|
+
## Open flags (pending input)
|
|
78
|
+
- ~~Nome/enum e granularidade dos modos de controle~~ -> **resolvido**: `ControlMode.APPROVE | ControlMode.BYPASS` (2 opções, decisão do usuário na aprovação do plano)
|
|
79
|
+
- ~~Renomear pasta~~ -> **executado**: pasta agora é `~/Projects/toolloop` (2026-08-22)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Provider adapter for the Anthropic Messages API.
|
|
2
|
+
|
|
3
|
+
Install the SDK with: pip install anthropic
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from anthropic import AsyncAnthropic
|
|
9
|
+
|
|
10
|
+
from toolloop import Message
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnthropicProvider:
|
|
14
|
+
"""Implements toolloop's one-method provider contract."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, model: str = "claude-sonnet-4-5", max_tokens: int = 4096) -> None:
|
|
17
|
+
self.client = AsyncAnthropic()
|
|
18
|
+
self.model = model
|
|
19
|
+
self.max_tokens = max_tokens
|
|
20
|
+
|
|
21
|
+
async def complete(self, messages: list[Message]) -> str:
|
|
22
|
+
system = "\n\n".join(m.content for m in messages if m.role.value == "system")
|
|
23
|
+
conversation = [
|
|
24
|
+
{
|
|
25
|
+
"role": "assistant" if m.role.value == "assistant" else "user",
|
|
26
|
+
"content": m.content,
|
|
27
|
+
}
|
|
28
|
+
for m in messages
|
|
29
|
+
if m.role.value != "system"
|
|
30
|
+
]
|
|
31
|
+
response = await self.client.messages.create(
|
|
32
|
+
model=self.model,
|
|
33
|
+
max_tokens=self.max_tokens,
|
|
34
|
+
system=system,
|
|
35
|
+
messages=conversation,
|
|
36
|
+
)
|
|
37
|
+
return "".join(block.text for block in response.content if block.type == "text")
|