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.
Files changed (33) hide show
  1. toolloop-0.1.0/.gitignore +25 -0
  2. toolloop-0.1.0/.zcode/plans/plan-sess_d9dbb53a-b8d6-4fea-bf5f-ad02e807cf56.md +47 -0
  3. toolloop-0.1.0/LICENSE +21 -0
  4. toolloop-0.1.0/PKG-INFO +238 -0
  5. toolloop-0.1.0/README.md +215 -0
  6. toolloop-0.1.0/brainstorms/2026-08-22-framework-ia-tool-use.md +79 -0
  7. toolloop-0.1.0/examples/anthropic_provider.py +37 -0
  8. toolloop-0.1.0/examples/coding_agent.py +55 -0
  9. toolloop-0.1.0/examples/openai_compat_provider.py +31 -0
  10. toolloop-0.1.0/pyproject.toml +41 -0
  11. toolloop-0.1.0/src/toolloop/__init__.py +60 -0
  12. toolloop-0.1.0/src/toolloop/_types.py +88 -0
  13. toolloop-0.1.0/src/toolloop/agent.py +345 -0
  14. toolloop-0.1.0/src/toolloop/context.py +78 -0
  15. toolloop-0.1.0/src/toolloop/hooks.py +74 -0
  16. toolloop-0.1.0/src/toolloop/protocol/__init__.py +12 -0
  17. toolloop-0.1.0/src/toolloop/protocol/base.py +50 -0
  18. toolloop-0.1.0/src/toolloop/protocol/json_protocol.py +109 -0
  19. toolloop-0.1.0/src/toolloop/provider.py +21 -0
  20. toolloop-0.1.0/src/toolloop/subagent.py +34 -0
  21. toolloop-0.1.0/src/toolloop/tools/__init__.py +20 -0
  22. toolloop-0.1.0/src/toolloop/tools/definition.py +110 -0
  23. toolloop-0.1.0/src/toolloop/tools/fs.py +99 -0
  24. toolloop-0.1.0/src/toolloop/tools/search.py +48 -0
  25. toolloop-0.1.0/src/toolloop/tools/shell.py +30 -0
  26. toolloop-0.1.0/tests/conftest.py +19 -0
  27. toolloop-0.1.0/tests/test_agent_loop.py +128 -0
  28. toolloop-0.1.0/tests/test_context.py +66 -0
  29. toolloop-0.1.0/tests/test_hooks_control.py +113 -0
  30. toolloop-0.1.0/tests/test_json_protocol.py +81 -0
  31. toolloop-0.1.0/tests/test_std_tools.py +81 -0
  32. toolloop-0.1.0/tests/test_subagent.py +37 -0
  33. 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.
@@ -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.
@@ -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")