simple-agent-loop 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.
- simple_agent_loop-0.1.0/.claude/settings.local.json +7 -0
- simple_agent_loop-0.1.0/.env.example +1 -0
- simple_agent_loop-0.1.0/.gitignore +13 -0
- simple_agent_loop-0.1.0/.python-version +1 -0
- simple_agent_loop-0.1.0/GUIDE.md +195 -0
- simple_agent_loop-0.1.0/LICENSE +21 -0
- simple_agent_loop-0.1.0/PKG-INFO +229 -0
- simple_agent_loop-0.1.0/README.md +206 -0
- simple_agent_loop-0.1.0/agent_loop.js +237 -0
- simple_agent_loop-0.1.0/compressor.py +157 -0
- simple_agent_loop-0.1.0/derive_transform.py +289 -0
- simple_agent_loop-0.1.0/main.py +6 -0
- simple_agent_loop-0.1.0/message-specs.md +28 -0
- simple_agent_loop-0.1.0/pyproject.toml +36 -0
- simple_agent_loop-0.1.0/src/simple_agent_loop/__init__.py +459 -0
- simple_agent_loop-0.1.0/test-agent-loop.js +431 -0
- simple_agent_loop-0.1.0/test_agent_loop.py +51 -0
- simple_agent_loop-0.1.0/uv.lock +404 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ANTHROPIC_API_KEY=
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# How to Implement an Agent Loop
|
|
2
|
+
|
|
3
|
+
An agent loop lets a language model use tools repeatedly until it finishes a task. The model decides what to do, calls tools, sees results, and keeps going until it has an answer. This guide covers the core concepts and data structures needed to build one from scratch in any language.
|
|
4
|
+
|
|
5
|
+
## Core Data Structure: The Session
|
|
6
|
+
|
|
7
|
+
A session is a list of messages. Each message has a role or type and some content:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
{ role: "system", content: "..." } -- system prompt
|
|
11
|
+
{ role: "user", content: "..." } -- user input
|
|
12
|
+
{ role: "assistant", content: "..." } -- model text output
|
|
13
|
+
{ type: "thinking", content: "..." } -- model reasoning (if supported)
|
|
14
|
+
{ type: "tool_call", name: "...", id: "...", input: {...} }
|
|
15
|
+
{ type: "tool_result", id: "...", output: "..." }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This is a generic format. It does not match any specific API directly -- you translate to/from the API format at the boundary. This keeps your session representation clean and portable.
|
|
19
|
+
|
|
20
|
+
## 1. Initialize a Session
|
|
21
|
+
|
|
22
|
+
Create a session with a system prompt and the user's message:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
function init_session(system_prompt, user_prompt):
|
|
26
|
+
return {
|
|
27
|
+
messages: [
|
|
28
|
+
{ role: "system", content: system_prompt },
|
|
29
|
+
{ role: "user", content: user_prompt },
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 2. Convert to API Format
|
|
35
|
+
|
|
36
|
+
Most model APIs expect messages grouped by role, with tool calls and tool results packed into specific block structures. Write a conversion function that:
|
|
37
|
+
|
|
38
|
+
- Skips system messages (pass them separately)
|
|
39
|
+
- Groups consecutive `thinking`, `assistant`, and `tool_call` messages into a single assistant message with multiple content blocks
|
|
40
|
+
- Groups consecutive `tool_result` messages into a single user message with multiple content blocks
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
function to_api_messages(messages):
|
|
44
|
+
api_messages = []
|
|
45
|
+
assistant_blocks = []
|
|
46
|
+
tool_result_blocks = []
|
|
47
|
+
|
|
48
|
+
-- When hitting a boundary (user message, or switching between
|
|
49
|
+
-- assistant-side and tool-result-side), flush the accumulated
|
|
50
|
+
-- blocks into a single message.
|
|
51
|
+
|
|
52
|
+
for each message:
|
|
53
|
+
if system: skip
|
|
54
|
+
if user: flush both buffers, add user message
|
|
55
|
+
if assistant: flush tool results, add text block to assistant buffer
|
|
56
|
+
if thinking: flush tool results, add thinking block to assistant buffer
|
|
57
|
+
if tool_call: flush tool results, add tool_use block to assistant buffer
|
|
58
|
+
if tool_result: flush assistant buffer, add to tool result buffer
|
|
59
|
+
|
|
60
|
+
flush remaining buffers
|
|
61
|
+
return api_messages
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The key insight is that what your session stores as separate messages may need to be combined into a single API message with multiple content blocks.
|
|
65
|
+
|
|
66
|
+
## 3. Parse the Response
|
|
67
|
+
|
|
68
|
+
The model returns a response with content blocks. Parse each block back into your generic message format:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
function parse_response(api_response):
|
|
72
|
+
messages = []
|
|
73
|
+
for each block in api_response.content:
|
|
74
|
+
if block is thinking: append { type: "thinking", content: block.thinking }
|
|
75
|
+
if block is text: append { role: "assistant", content: block.text }
|
|
76
|
+
if block is tool_use: append { type: "tool_call", name: ..., id: ..., input: ... }
|
|
77
|
+
return messages
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 4. Execute Tool Calls
|
|
81
|
+
|
|
82
|
+
Tool handlers are just functions. Map tool names to handler functions, then execute them:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
function execute_tool_calls(tool_calls, tool_handlers):
|
|
86
|
+
results = []
|
|
87
|
+
for each tool_call:
|
|
88
|
+
handler = tool_handlers[tool_call.name]
|
|
89
|
+
try:
|
|
90
|
+
output = handler(tool_call.input)
|
|
91
|
+
catch error:
|
|
92
|
+
output = "Error: " + error.message
|
|
93
|
+
results.append({ type: "tool_result", id: tool_call.id, output: output })
|
|
94
|
+
return results
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Tool calls within a single response are independent of each other, so you can execute them in parallel (threads, promises, goroutines, etc).
|
|
98
|
+
|
|
99
|
+
## 5. The Loop
|
|
100
|
+
|
|
101
|
+
The agent loop ties everything together. Each iteration: call the model, add its messages to the session, check for tool calls, execute them, add results, repeat.
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
function agent_loop(invoke_model, tools, session, tool_handlers):
|
|
105
|
+
loop:
|
|
106
|
+
api_messages = to_api_messages(session.messages)
|
|
107
|
+
api_response = invoke_model(tools, api_messages)
|
|
108
|
+
|
|
109
|
+
new_messages = parse_response(api_response)
|
|
110
|
+
append new_messages to session
|
|
111
|
+
|
|
112
|
+
tool_calls = filter new_messages for type "tool_call"
|
|
113
|
+
if no tool_calls: break -- model is done
|
|
114
|
+
|
|
115
|
+
results = execute_tool_calls(tool_calls, tool_handlers)
|
|
116
|
+
append results to session
|
|
117
|
+
|
|
118
|
+
return session
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
That's the entire loop. The model drives the control flow -- it decides when to call tools and when to stop (by responding with just text and no tool calls).
|
|
122
|
+
|
|
123
|
+
## 6. Defining Tools
|
|
124
|
+
|
|
125
|
+
Tools are defined as schemas that tell the model what's available. Each tool has a name, description, and input schema:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
{
|
|
129
|
+
name: "read_file",
|
|
130
|
+
description: "Read the contents of a file at the given path.",
|
|
131
|
+
input_schema: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
path: { type: "string", description: "File path to read" }
|
|
135
|
+
},
|
|
136
|
+
required: ["path"]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Pass the tool definitions to the model on each call. The corresponding handler is just a function that takes the input and returns a string:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
function read_file(input):
|
|
145
|
+
return file_system.read(input.path)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## 7. Subagents
|
|
149
|
+
|
|
150
|
+
An agent can use other agents as tools. A subagent is just a tool handler that runs its own agent loop:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
function summarize(input):
|
|
154
|
+
sub_session = init_session(
|
|
155
|
+
"You are a summarizer. Output only the summary.",
|
|
156
|
+
input.text
|
|
157
|
+
)
|
|
158
|
+
result = agent_loop(invoke_model, [], sub_session, {})
|
|
159
|
+
return response(result).content
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Register it as a tool handler like any other function. The outer agent calls "summarize" as a tool, the handler spins up an inner agent loop, and returns the result. The outer agent sees a simple string tool result and has no idea a whole agent loop ran inside.
|
|
163
|
+
|
|
164
|
+
This composes naturally:
|
|
165
|
+
- A coordinator agent can dispatch to multiple specialist subagents
|
|
166
|
+
- Subagents can have their own tools (or no tools at all)
|
|
167
|
+
- Subagents can have different system prompts, models, or iteration limits
|
|
168
|
+
- You can run multiple subagents in parallel when the tool calls are independent
|
|
169
|
+
|
|
170
|
+
## 8. Context Management
|
|
171
|
+
|
|
172
|
+
Sessions grow over time. Old thinking blocks and tool call inputs can be large and become less relevant as the conversation progresses. You can compact them:
|
|
173
|
+
|
|
174
|
+
- Walk the message list backwards, counting assistant responses
|
|
175
|
+
- Once N assistant responses have appeared after a message, truncate its content to a short prefix
|
|
176
|
+
- Only compact thinking and tool_call messages -- keep user messages, assistant text, and tool results intact
|
|
177
|
+
- Mark compacted messages so you don't re-process them
|
|
178
|
+
|
|
179
|
+
This keeps the session under control during long-running agent loops without losing the structural flow of the conversation.
|
|
180
|
+
|
|
181
|
+
## Putting It Together
|
|
182
|
+
|
|
183
|
+
The full implementation is around 200 lines in most languages. The essential pieces:
|
|
184
|
+
|
|
185
|
+
| Component | Purpose |
|
|
186
|
+
|---|---|
|
|
187
|
+
| Session | Ordered list of generic messages |
|
|
188
|
+
| `to_api_messages` | Translate generic messages to API format |
|
|
189
|
+
| `parse_response` | Translate API response back to generic messages |
|
|
190
|
+
| `execute_tool_calls` | Run tool handlers, collect results |
|
|
191
|
+
| `agent_loop` | The loop: call model, execute tools, repeat |
|
|
192
|
+
|
|
193
|
+
Everything else -- logging, session forking, compaction, subagents -- is layered on top of these five pieces.
|
|
194
|
+
|
|
195
|
+
The model is the control flow. Your code just provides the loop scaffold and tool execution. The model decides what to do, when to use tools, and when to stop.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tom MacWright
|
|
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,229 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple-agent-loop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal agent loop for tool-using language models
|
|
5
|
+
Project-URL: Homepage, https://github.com/tmcw/agent-loop
|
|
6
|
+
Project-URL: Repository, https://github.com/tmcw/agent-loop
|
|
7
|
+
Author: Tom MacWright
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: anthropic
|
|
21
|
+
Requires-Dist: anthropic>=0.79.0; extra == 'anthropic'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# simple_agent_loop
|
|
25
|
+
|
|
26
|
+
A minimal agent loop for tool-using language models. ~250 lines. Handles
|
|
27
|
+
message format conversion, parallel tool execution, session compaction,
|
|
28
|
+
and subagent composition.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install simple-agent-loop
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import anthropic
|
|
40
|
+
import json
|
|
41
|
+
import simple_agent_loop as sal
|
|
42
|
+
|
|
43
|
+
client = anthropic.Anthropic() # uses ANTHROPIC_API_KEY env var
|
|
44
|
+
|
|
45
|
+
def invoke_model(tools, session):
|
|
46
|
+
kwargs = dict(
|
|
47
|
+
model="claude-sonnet-4-5",
|
|
48
|
+
max_tokens=16000,
|
|
49
|
+
messages=session["messages"],
|
|
50
|
+
)
|
|
51
|
+
if "system" in session:
|
|
52
|
+
kwargs["system"] = session["system"]
|
|
53
|
+
if tools:
|
|
54
|
+
kwargs["tools"] = tools
|
|
55
|
+
return client.messages.create(**kwargs).to_dict()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Hello World
|
|
59
|
+
|
|
60
|
+
No tools, single turn -- the model just responds:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
session = init_session(
|
|
64
|
+
system_prompt="You are a helpful assistant.",
|
|
65
|
+
user_prompt="Say hello in three languages.",
|
|
66
|
+
)
|
|
67
|
+
result = agent_loop(invoke_model, [], session, max_iterations=1)
|
|
68
|
+
print(response(result)["content"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Tool-Using Agent
|
|
72
|
+
|
|
73
|
+
Define tools as Anthropic tool schemas and provide handler functions. The
|
|
74
|
+
handler receives tool input as keyword arguments and returns a string.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import requests
|
|
78
|
+
|
|
79
|
+
tools = [
|
|
80
|
+
{
|
|
81
|
+
"name": "get_weather",
|
|
82
|
+
"description": "Get the current weather for a city.",
|
|
83
|
+
"input_schema": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"city": {"type": "string", "description": "City name"},
|
|
87
|
+
},
|
|
88
|
+
"required": ["city"],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def get_weather(city):
|
|
94
|
+
resp = requests.get(f"https://wttr.in/{city}?format=j1")
|
|
95
|
+
data = resp.json()["current_condition"][0]
|
|
96
|
+
return json.dumps({
|
|
97
|
+
"city": city,
|
|
98
|
+
"temp_c": data["temp_C"],
|
|
99
|
+
"description": data["weatherDesc"][0]["value"],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
session = init_session(
|
|
103
|
+
system_prompt="You answer weather questions. Use the get_weather tool.",
|
|
104
|
+
user_prompt="What's the weather in Tokyo and Paris?",
|
|
105
|
+
)
|
|
106
|
+
result = agent_loop(
|
|
107
|
+
invoke_model, tools, session,
|
|
108
|
+
tool_handlers={"get_weather": get_weather},
|
|
109
|
+
)
|
|
110
|
+
print(response(result)["content"])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The model will call get_weather twice (in parallel), see the results, and
|
|
114
|
+
respond with a summary. The loop runs until the model responds without
|
|
115
|
+
making any tool calls.
|
|
116
|
+
|
|
117
|
+
## Subagents
|
|
118
|
+
|
|
119
|
+
A subagent is a tool handler that runs its own agent loop. The outer agent
|
|
120
|
+
calls it like any tool and gets back a string result.
|
|
121
|
+
|
|
122
|
+
### Example: Text Compressor (compressor.py)
|
|
123
|
+
|
|
124
|
+
A coordinator agent iteratively compresses text using two subagents:
|
|
125
|
+
a shortener and a quality judge.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
# Subagent: compresses text
|
|
129
|
+
def shorten(text):
|
|
130
|
+
session = init_session(
|
|
131
|
+
system_prompt="Rewrite the text to half its length. Output ONLY the result.",
|
|
132
|
+
user_prompt=text,
|
|
133
|
+
)
|
|
134
|
+
result = agent_loop(invoke_model, [], session, name="shortener", max_iterations=1)
|
|
135
|
+
shortened = response(result)["content"]
|
|
136
|
+
ratio = len(shortened) / len(text)
|
|
137
|
+
return json.dumps({"compression_ratio": round(ratio, 3), "shortened_text": shortened})
|
|
138
|
+
|
|
139
|
+
# Subagent: judges compression quality
|
|
140
|
+
def judge(original, shortened):
|
|
141
|
+
session = init_session(
|
|
142
|
+
system_prompt=(
|
|
143
|
+
"Compare original and shortened text. Return ONLY JSON: "
|
|
144
|
+
'{"verdict": "acceptable", "reason": "..."} or '
|
|
145
|
+
'{"verdict": "too_lossy", "reason": "..."}'
|
|
146
|
+
),
|
|
147
|
+
user_prompt=f"ORIGINAL:\n{original}\n\nSHORTENED:\n{shortened}",
|
|
148
|
+
)
|
|
149
|
+
result = agent_loop(invoke_model, [], session, name="judge", max_iterations=1)
|
|
150
|
+
return response(result)["content"]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The coordinator has tools for `shorten` and `judge`, and its system prompt
|
|
154
|
+
tells it to loop: shorten, judge, stop if too_lossy or diminishing returns,
|
|
155
|
+
otherwise shorten again. Each subagent is a one-shot agent loop
|
|
156
|
+
(max_iterations=1) with no tools of its own.
|
|
157
|
+
|
|
158
|
+
### Example: Transform Rule Derivation (derive_transform.py)
|
|
159
|
+
|
|
160
|
+
A more complex example with four subagents and a coordinator. Given a
|
|
161
|
+
source text and target text, it derives general transformation rules and
|
|
162
|
+
specific info that together reproduce the target from the source.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# Subagent: applies rules + specific info to source text
|
|
166
|
+
def edit(text, rules, specific_info):
|
|
167
|
+
session = init_session(
|
|
168
|
+
system_prompt="Apply the rules to the text using the specific info. Output ONLY the result.",
|
|
169
|
+
user_prompt=f"SOURCE TEXT:\n{text}\n\nRULES:\n{rules}\n\nSPECIFIC INFO:\n{specific_info}",
|
|
170
|
+
)
|
|
171
|
+
result = agent_loop(invoke_model, [], session, name="editor", max_iterations=1)
|
|
172
|
+
return response(result)["content"]
|
|
173
|
+
|
|
174
|
+
# Subagent: scores how close the output is to the target
|
|
175
|
+
def judge_similarity(editor_output, target):
|
|
176
|
+
session = init_session(
|
|
177
|
+
system_prompt='Compare texts. Return JSON: {"score": 0-100, "differences": "..."}',
|
|
178
|
+
user_prompt=f"EDITOR OUTPUT:\n{editor_output}\n\nTARGET:\n{target}",
|
|
179
|
+
)
|
|
180
|
+
result = agent_loop(invoke_model, [], session, name="similarity-judge", max_iterations=1)
|
|
181
|
+
return response(result)["content"]
|
|
182
|
+
|
|
183
|
+
# Subagent: checks rules are abstract (no specific content leaked in)
|
|
184
|
+
def judge_generality(rules):
|
|
185
|
+
...
|
|
186
|
+
|
|
187
|
+
# Subagent: checks specific_info is a flat fact list
|
|
188
|
+
def judge_specific_info(specific_info):
|
|
189
|
+
...
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The coordinator calls `edit`, then calls all three judges in parallel,
|
|
193
|
+
refines based on scores, and repeats until all judges score above 90.
|
|
194
|
+
Tool calls within a single model response execute in parallel automatically.
|
|
195
|
+
|
|
196
|
+
## API Reference
|
|
197
|
+
|
|
198
|
+
### Session Management
|
|
199
|
+
|
|
200
|
+
- `init_session(system_prompt, user_prompt)` - Create a new session
|
|
201
|
+
- `extend_session(session, message)` - Append a message to the session
|
|
202
|
+
- `send(session, user_message)` - Add a user message to the session
|
|
203
|
+
- `fork_session(session)` - Deep copy a session for branching
|
|
204
|
+
- `response(session)` - Get the last assistant message, or None
|
|
205
|
+
|
|
206
|
+
### Agent Loop
|
|
207
|
+
|
|
208
|
+
- `agent_loop(invoke_model, tools, session, tool_handlers=None, name=None, max_iterations=None)`
|
|
209
|
+
- `invoke_model(tools, session)` - Function that calls the model API
|
|
210
|
+
- `tools` - List of Anthropic tool schemas ([] for no tools)
|
|
211
|
+
- `session` - Session dict from init_session
|
|
212
|
+
- `tool_handlers` - Dict mapping tool names to handler functions
|
|
213
|
+
- `name` - Agent name for log output
|
|
214
|
+
- `max_iterations` - Max model calls before stopping (None = unlimited)
|
|
215
|
+
- Returns the session with all messages appended
|
|
216
|
+
|
|
217
|
+
### Message Format
|
|
218
|
+
|
|
219
|
+
Messages use a generic format independent of any API:
|
|
220
|
+
|
|
221
|
+
{"role": "system", "content": "..."}
|
|
222
|
+
{"role": "user", "content": "..."}
|
|
223
|
+
{"role": "assistant", "content": "..."}
|
|
224
|
+
{"type": "thinking", "content": "..."}
|
|
225
|
+
{"type": "tool_call", "name": "...", "id": "...", "input": {...}}
|
|
226
|
+
{"type": "tool_result", "id": "...", "output": "..."}
|
|
227
|
+
|
|
228
|
+
Conversion to/from Anthropic API format is handled by `to_api_messages()`
|
|
229
|
+
and `parse_response()`.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# simple_agent_loop
|
|
2
|
+
|
|
3
|
+
A minimal agent loop for tool-using language models. ~250 lines. Handles
|
|
4
|
+
message format conversion, parallel tool execution, session compaction,
|
|
5
|
+
and subagent composition.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install simple-agent-loop
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import anthropic
|
|
17
|
+
import json
|
|
18
|
+
import simple_agent_loop as sal
|
|
19
|
+
|
|
20
|
+
client = anthropic.Anthropic() # uses ANTHROPIC_API_KEY env var
|
|
21
|
+
|
|
22
|
+
def invoke_model(tools, session):
|
|
23
|
+
kwargs = dict(
|
|
24
|
+
model="claude-sonnet-4-5",
|
|
25
|
+
max_tokens=16000,
|
|
26
|
+
messages=session["messages"],
|
|
27
|
+
)
|
|
28
|
+
if "system" in session:
|
|
29
|
+
kwargs["system"] = session["system"]
|
|
30
|
+
if tools:
|
|
31
|
+
kwargs["tools"] = tools
|
|
32
|
+
return client.messages.create(**kwargs).to_dict()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Hello World
|
|
36
|
+
|
|
37
|
+
No tools, single turn -- the model just responds:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
session = init_session(
|
|
41
|
+
system_prompt="You are a helpful assistant.",
|
|
42
|
+
user_prompt="Say hello in three languages.",
|
|
43
|
+
)
|
|
44
|
+
result = agent_loop(invoke_model, [], session, max_iterations=1)
|
|
45
|
+
print(response(result)["content"])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Tool-Using Agent
|
|
49
|
+
|
|
50
|
+
Define tools as Anthropic tool schemas and provide handler functions. The
|
|
51
|
+
handler receives tool input as keyword arguments and returns a string.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import requests
|
|
55
|
+
|
|
56
|
+
tools = [
|
|
57
|
+
{
|
|
58
|
+
"name": "get_weather",
|
|
59
|
+
"description": "Get the current weather for a city.",
|
|
60
|
+
"input_schema": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"city": {"type": "string", "description": "City name"},
|
|
64
|
+
},
|
|
65
|
+
"required": ["city"],
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
def get_weather(city):
|
|
71
|
+
resp = requests.get(f"https://wttr.in/{city}?format=j1")
|
|
72
|
+
data = resp.json()["current_condition"][0]
|
|
73
|
+
return json.dumps({
|
|
74
|
+
"city": city,
|
|
75
|
+
"temp_c": data["temp_C"],
|
|
76
|
+
"description": data["weatherDesc"][0]["value"],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
session = init_session(
|
|
80
|
+
system_prompt="You answer weather questions. Use the get_weather tool.",
|
|
81
|
+
user_prompt="What's the weather in Tokyo and Paris?",
|
|
82
|
+
)
|
|
83
|
+
result = agent_loop(
|
|
84
|
+
invoke_model, tools, session,
|
|
85
|
+
tool_handlers={"get_weather": get_weather},
|
|
86
|
+
)
|
|
87
|
+
print(response(result)["content"])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The model will call get_weather twice (in parallel), see the results, and
|
|
91
|
+
respond with a summary. The loop runs until the model responds without
|
|
92
|
+
making any tool calls.
|
|
93
|
+
|
|
94
|
+
## Subagents
|
|
95
|
+
|
|
96
|
+
A subagent is a tool handler that runs its own agent loop. The outer agent
|
|
97
|
+
calls it like any tool and gets back a string result.
|
|
98
|
+
|
|
99
|
+
### Example: Text Compressor (compressor.py)
|
|
100
|
+
|
|
101
|
+
A coordinator agent iteratively compresses text using two subagents:
|
|
102
|
+
a shortener and a quality judge.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
# Subagent: compresses text
|
|
106
|
+
def shorten(text):
|
|
107
|
+
session = init_session(
|
|
108
|
+
system_prompt="Rewrite the text to half its length. Output ONLY the result.",
|
|
109
|
+
user_prompt=text,
|
|
110
|
+
)
|
|
111
|
+
result = agent_loop(invoke_model, [], session, name="shortener", max_iterations=1)
|
|
112
|
+
shortened = response(result)["content"]
|
|
113
|
+
ratio = len(shortened) / len(text)
|
|
114
|
+
return json.dumps({"compression_ratio": round(ratio, 3), "shortened_text": shortened})
|
|
115
|
+
|
|
116
|
+
# Subagent: judges compression quality
|
|
117
|
+
def judge(original, shortened):
|
|
118
|
+
session = init_session(
|
|
119
|
+
system_prompt=(
|
|
120
|
+
"Compare original and shortened text. Return ONLY JSON: "
|
|
121
|
+
'{"verdict": "acceptable", "reason": "..."} or '
|
|
122
|
+
'{"verdict": "too_lossy", "reason": "..."}'
|
|
123
|
+
),
|
|
124
|
+
user_prompt=f"ORIGINAL:\n{original}\n\nSHORTENED:\n{shortened}",
|
|
125
|
+
)
|
|
126
|
+
result = agent_loop(invoke_model, [], session, name="judge", max_iterations=1)
|
|
127
|
+
return response(result)["content"]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The coordinator has tools for `shorten` and `judge`, and its system prompt
|
|
131
|
+
tells it to loop: shorten, judge, stop if too_lossy or diminishing returns,
|
|
132
|
+
otherwise shorten again. Each subagent is a one-shot agent loop
|
|
133
|
+
(max_iterations=1) with no tools of its own.
|
|
134
|
+
|
|
135
|
+
### Example: Transform Rule Derivation (derive_transform.py)
|
|
136
|
+
|
|
137
|
+
A more complex example with four subagents and a coordinator. Given a
|
|
138
|
+
source text and target text, it derives general transformation rules and
|
|
139
|
+
specific info that together reproduce the target from the source.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# Subagent: applies rules + specific info to source text
|
|
143
|
+
def edit(text, rules, specific_info):
|
|
144
|
+
session = init_session(
|
|
145
|
+
system_prompt="Apply the rules to the text using the specific info. Output ONLY the result.",
|
|
146
|
+
user_prompt=f"SOURCE TEXT:\n{text}\n\nRULES:\n{rules}\n\nSPECIFIC INFO:\n{specific_info}",
|
|
147
|
+
)
|
|
148
|
+
result = agent_loop(invoke_model, [], session, name="editor", max_iterations=1)
|
|
149
|
+
return response(result)["content"]
|
|
150
|
+
|
|
151
|
+
# Subagent: scores how close the output is to the target
|
|
152
|
+
def judge_similarity(editor_output, target):
|
|
153
|
+
session = init_session(
|
|
154
|
+
system_prompt='Compare texts. Return JSON: {"score": 0-100, "differences": "..."}',
|
|
155
|
+
user_prompt=f"EDITOR OUTPUT:\n{editor_output}\n\nTARGET:\n{target}",
|
|
156
|
+
)
|
|
157
|
+
result = agent_loop(invoke_model, [], session, name="similarity-judge", max_iterations=1)
|
|
158
|
+
return response(result)["content"]
|
|
159
|
+
|
|
160
|
+
# Subagent: checks rules are abstract (no specific content leaked in)
|
|
161
|
+
def judge_generality(rules):
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
# Subagent: checks specific_info is a flat fact list
|
|
165
|
+
def judge_specific_info(specific_info):
|
|
166
|
+
...
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The coordinator calls `edit`, then calls all three judges in parallel,
|
|
170
|
+
refines based on scores, and repeats until all judges score above 90.
|
|
171
|
+
Tool calls within a single model response execute in parallel automatically.
|
|
172
|
+
|
|
173
|
+
## API Reference
|
|
174
|
+
|
|
175
|
+
### Session Management
|
|
176
|
+
|
|
177
|
+
- `init_session(system_prompt, user_prompt)` - Create a new session
|
|
178
|
+
- `extend_session(session, message)` - Append a message to the session
|
|
179
|
+
- `send(session, user_message)` - Add a user message to the session
|
|
180
|
+
- `fork_session(session)` - Deep copy a session for branching
|
|
181
|
+
- `response(session)` - Get the last assistant message, or None
|
|
182
|
+
|
|
183
|
+
### Agent Loop
|
|
184
|
+
|
|
185
|
+
- `agent_loop(invoke_model, tools, session, tool_handlers=None, name=None, max_iterations=None)`
|
|
186
|
+
- `invoke_model(tools, session)` - Function that calls the model API
|
|
187
|
+
- `tools` - List of Anthropic tool schemas ([] for no tools)
|
|
188
|
+
- `session` - Session dict from init_session
|
|
189
|
+
- `tool_handlers` - Dict mapping tool names to handler functions
|
|
190
|
+
- `name` - Agent name for log output
|
|
191
|
+
- `max_iterations` - Max model calls before stopping (None = unlimited)
|
|
192
|
+
- Returns the session with all messages appended
|
|
193
|
+
|
|
194
|
+
### Message Format
|
|
195
|
+
|
|
196
|
+
Messages use a generic format independent of any API:
|
|
197
|
+
|
|
198
|
+
{"role": "system", "content": "..."}
|
|
199
|
+
{"role": "user", "content": "..."}
|
|
200
|
+
{"role": "assistant", "content": "..."}
|
|
201
|
+
{"type": "thinking", "content": "..."}
|
|
202
|
+
{"type": "tool_call", "name": "...", "id": "...", "input": {...}}
|
|
203
|
+
{"type": "tool_result", "id": "...", "output": "..."}
|
|
204
|
+
|
|
205
|
+
Conversion to/from Anthropic API format is handled by `to_api_messages()`
|
|
206
|
+
and `parse_response()`.
|