agent-aimee 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.
- agent_aimee-0.1.0/.gitignore +8 -0
- agent_aimee-0.1.0/PKG-INFO +240 -0
- agent_aimee-0.1.0/PLAN.md +136 -0
- agent_aimee-0.1.0/README.md +220 -0
- agent_aimee-0.1.0/examples/AGENTS.md +9 -0
- agent_aimee-0.1.0/examples/JOKE.md +5 -0
- agent_aimee-0.1.0/examples/basic.py +112 -0
- agent_aimee-0.1.0/examples/custom_tool.py +97 -0
- agent_aimee-0.1.0/examples/secondary/secret-note.txt +2 -0
- agent_aimee-0.1.0/examples/skills/greeting/SKILL.md +8 -0
- agent_aimee-0.1.0/pyproject.toml +49 -0
- agent_aimee-0.1.0/src/aimee/__init__.py +41 -0
- agent_aimee-0.1.0/src/aimee/agent.py +381 -0
- agent_aimee-0.1.0/src/aimee/client.py +192 -0
- agent_aimee-0.1.0/src/aimee/config.py +57 -0
- agent_aimee-0.1.0/src/aimee/hooks.py +46 -0
- agent_aimee-0.1.0/src/aimee/prompt.py +52 -0
- agent_aimee-0.1.0/src/aimee/skills.py +95 -0
- agent_aimee-0.1.0/src/aimee/tools/__init__.py +37 -0
- agent_aimee-0.1.0/src/aimee/tools/base.py +75 -0
- agent_aimee-0.1.0/src/aimee/tools/bash.py +50 -0
- agent_aimee-0.1.0/src/aimee/tools/fs.py +127 -0
- agent_aimee-0.1.0/src/aimee/types.py +171 -0
- agent_aimee-0.1.0/tests/test_agent.py +410 -0
- agent_aimee-0.1.0/tests/test_client.py +260 -0
- agent_aimee-0.1.0/tests/test_hooks.py +59 -0
- agent_aimee-0.1.0/tests/test_prompt.py +75 -0
- agent_aimee-0.1.0/tests/test_session.py +126 -0
- agent_aimee-0.1.0/tests/test_skills.py +102 -0
- agent_aimee-0.1.0/tests/test_tools.py +220 -0
- agent_aimee-0.1.0/uv.lock +258 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-aimee
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A very minimal LLM agent loop with tools and skills
|
|
5
|
+
Project-URL: Homepage, https://github.com/Fortyseven/AgentAImee
|
|
6
|
+
Project-URL: Source Code, https://github.com/Fortyseven/AgentAImee
|
|
7
|
+
Project-URL: Issues, https://github.com/Fortyseven/AgentAImee/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# AgentAImee
|
|
22
|
+
|
|
23
|
+
A very minimal LLM agent loop for Python, with tools and skills. One runtime
|
|
24
|
+
dependency (`httpx`). Drop it into an existing project; you own the config,
|
|
25
|
+
the tools, and the behavior. Aimee gives you the loop, the OpenAI-compatible
|
|
26
|
+
client, skill discovery, AGENTS.md support, and callback hooks.
|
|
27
|
+
|
|
28
|
+
- **Model**: any OpenAI-compatible `chat/completions` endpoint via
|
|
29
|
+
`OPENAI_API_BASE` + `OPENAI_API_KEY` (no `openai` package).
|
|
30
|
+
- **Tools**: built-in `read`, `write`, `edit`, `bash` — **all opt-in** — plus
|
|
31
|
+
your own tools.
|
|
32
|
+
- **Skills**: `SKILL.md` directories; a catalog is injected into the system
|
|
33
|
+
prompt and the agent reads the full skill on demand.
|
|
34
|
+
- **Hooks**: extend the run with callbacks — stream tokens, approve/modify
|
|
35
|
+
tool calls, observe turns, handle errors.
|
|
36
|
+
- **Sync or async**: `agent.run(task)` works in any script; `await
|
|
37
|
+
agent.run_async(task)` for async code.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv add agent-aimee # or: pip install agent-aimee
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from aimee import Aimee, AimeeConfig # module name is `aimee`
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv sync
|
|
53
|
+
uv run pytest -q # test suite (no network needed)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export OPENAI_API_BASE=https://api.openai.com/v1 # or any compatible gateway
|
|
60
|
+
export OPENAI_API_KEY=sk-...
|
|
61
|
+
uv run python examples/basic.py "your task here" # or --repl
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The example wires everything up: multi-root workspace, a skills directory,
|
|
65
|
+
AGENTS.md, three basic tools, and console hooks.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
from aimee import Aimee, AimeeConfig
|
|
72
|
+
from aimee.tools import basic_tools
|
|
73
|
+
|
|
74
|
+
config = AimeeConfig(
|
|
75
|
+
roots=[Path.cwd(), Path.home() / ".myapp"], # workspace roots (first = primary)
|
|
76
|
+
skills_dirs=[Path.home() / ".myapp" / "skills"],
|
|
77
|
+
model="gpt-4o-mini", # default: "default"
|
|
78
|
+
)
|
|
79
|
+
agent = Aimee(config, tools=basic_tools()) # read, write, edit, bash
|
|
80
|
+
# agent = Aimee(config, tools=[read()]) # or just the tools you want
|
|
81
|
+
|
|
82
|
+
report = agent.run("Summarize AGENTS.md") # sync (also safe inside a running loop)
|
|
83
|
+
# report = await agent.run_async("...") # async
|
|
84
|
+
|
|
85
|
+
print(report.final_text, report.turns, report.tool_calls, report.usage)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`RunReport` fields: `final_text`, `turns`, `tool_calls`, `usage` (tokens,
|
|
89
|
+
when the provider reports them), `truncated` (max turns hit), `messages`
|
|
90
|
+
(full transcript).
|
|
91
|
+
|
|
92
|
+
## Sessions (multi-turn memory)
|
|
93
|
+
|
|
94
|
+
`agent.run(task)` is stateless — every run starts fresh. For a conversation
|
|
95
|
+
that remembers, create a session:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
session = agent.session()
|
|
99
|
+
session.run("hello, who am I talking to?")
|
|
100
|
+
session.run("what did I just ask?") # the model sees the whole prior exchange
|
|
101
|
+
|
|
102
|
+
session.history # snapshot: [system, user, assistant, user, assistant, ...]
|
|
103
|
+
session.clear() # start over (a fresh system prompt is built on the next run)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- A session is bound to one agent (sharing its tools/hooks/config); one agent
|
|
107
|
+
can hold many independent sessions at once.
|
|
108
|
+
- `session.run()` / `await session.run_async()` mirror `Aimee.run()` /
|
|
109
|
+
`run_async()` — same hooks fire, same `RunReport` comes back (with `messages`
|
|
110
|
+
being the full session history).
|
|
111
|
+
- Tool calls and their results are part of the history too, so the model can
|
|
112
|
+
reference earlier tool output in later turns.
|
|
113
|
+
- History is unbounded by design: long conversations will eventually hit the
|
|
114
|
+
model's context limit — `session.clear()` resets when that gets close.
|
|
115
|
+
(`history` returns a snapshot; subclass or wrap the session for custom
|
|
116
|
+
trimming strategies.)
|
|
117
|
+
|
|
118
|
+
## Configuration (`AimeeConfig`)
|
|
119
|
+
|
|
120
|
+
| Field | Default | Meaning |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `roots` | `[Path.cwd()]` | Workspace roots, in priority order (see below). All paths are configurable here. |
|
|
123
|
+
| `model` | `"default"` | Model name passed to the endpoint. |
|
|
124
|
+
| `api_base` | `$OPENAI_API_BASE` → `https://api.openai.com/v1` | Endpoint base URL. |
|
|
125
|
+
| `api_key` | `$OPENAI_API_KEY` | Bearer token. |
|
|
126
|
+
| `system_prompt` | built-in minimal prompt | Replaces the base prompt. |
|
|
127
|
+
| `agents_md` | nearest `AGENTS.md` walking up from `roots` | Explicit AGENTS.md path. |
|
|
128
|
+
| `skills_dirs` | `[]` | Directories containing `SKILL.md` skill folders. |
|
|
129
|
+
| `max_turns` | `30` | Model-call budget per run. |
|
|
130
|
+
| `temperature` / `max_tokens` | `None` | Passed through when set. |
|
|
131
|
+
| `bash_timeout` | `120` | Default shell timeout (seconds). |
|
|
132
|
+
| `output_limit` | `100_000` | Tool output truncation (chars). |
|
|
133
|
+
|
|
134
|
+
**Multi-root rules.** `roots[0]` is the primary root. `read`/`edit` resolve
|
|
135
|
+
relative paths against roots in order (first existing file wins); `write`
|
|
136
|
+
updates the first root that contains the file, and creates new files under
|
|
137
|
+
the primary root; `bash` runs with the primary root as cwd; AGENTS.md
|
|
138
|
+
discovery walks up from each root in order until one is found.
|
|
139
|
+
|
|
140
|
+
## Tools
|
|
141
|
+
|
|
142
|
+
Tools are OpenAI function-calling specs plus a handler:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from aimee import Tool
|
|
146
|
+
|
|
147
|
+
Tool(
|
|
148
|
+
name="current_time",
|
|
149
|
+
description="Get the current local date and time (ISO format).",
|
|
150
|
+
parameters={"type": "object", "properties": {}}, # JSON Schema
|
|
151
|
+
handler=lambda args, ctx: "...", # sync or async; returns str
|
|
152
|
+
)
|
|
153
|
+
agent.add_tool(tool)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`ctx` is a `ToolContext` giving handlers the same workspace view as the
|
|
157
|
+
built-ins: `ctx.roots`, `ctx.resolve(path, must_exist=...)`,
|
|
158
|
+
`ctx.config.bash_timeout`, `ctx.config.output_limit`.
|
|
159
|
+
|
|
160
|
+
Built-ins (opt-in): `basic_tools()` → all four, `basic_tools(["read", "edit"])`
|
|
161
|
+
→ a subset, or import individually: `from aimee.tools import read, write, edit, bash`.
|
|
162
|
+
|
|
163
|
+
| Tool | Behavior |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `read(path, offset?, limit?)` | Numbered lines (paged), or directory listing. |
|
|
166
|
+
| `write(path, content)` | Create/overwrite; parent dirs created. |
|
|
167
|
+
| `edit(path, old_text, new_text)` | Exact-string replace; `old_text` must match exactly once. |
|
|
168
|
+
| `bash(command, timeout?)` | Shell in the primary root; `[exit N]` + combined output, truncated. |
|
|
169
|
+
|
|
170
|
+
> **Security note.** `bash` has no built-in approval. The `on_tool_call` hook
|
|
171
|
+
> is the safety mechanism — see `examples/custom_tool.py` for a hook that
|
|
172
|
+
> denies commands containing `rm `. Don't enable `bash` without one.
|
|
173
|
+
|
|
174
|
+
## Hooks
|
|
175
|
+
|
|
176
|
+
Register any object defining any subset of these methods (sync or async),
|
|
177
|
+
via `Aimee(..., hooks=[...])` or `agent.add_hook(obj)`:
|
|
178
|
+
|
|
179
|
+
| Hook | Purpose |
|
|
180
|
+
| --- | --- |
|
|
181
|
+
| `on_delta(chunk: str)` | One streamed chunk of assistant text (streaming on by default). |
|
|
182
|
+
| `on_tool_call(name, args) -> ToolDecision \| None` | Approval gate. `None` = allow; `ToolDecision.deny(reason)` blocks (reason goes to the model); `ToolDecision.modify(new_args)` replaces args. First non-None decision wins. |
|
|
183
|
+
| `on_tool_result(name, args, result)` | After a tool finished (including denials/errors). |
|
|
184
|
+
| `on_turn(turn, response)` | After each completed model response. |
|
|
185
|
+
| `on_error(error)` | Tool errors (run continues, error text goes to the model) and API errors (run aborts). |
|
|
186
|
+
| `on_done(report)` | Once, when the run finishes. |
|
|
187
|
+
|
|
188
|
+
## Skills
|
|
189
|
+
|
|
190
|
+
A skill is a directory with a `SKILL.md`:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
myapp/skills/
|
|
194
|
+
└── pdf-extract/
|
|
195
|
+
└── SKILL.md
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```markdown
|
|
199
|
+
---
|
|
200
|
+
name: pdf-extract
|
|
201
|
+
description: Extract text from PDF files. Use when the task involves PDFs.
|
|
202
|
+
---
|
|
203
|
+
Full instructions here. The agent reads this file (via the read tool)
|
|
204
|
+
before using the skill.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Set `skills_dirs=[...]`; Aimee injects only the catalog (name, description,
|
|
208
|
+
path) into the system prompt, so skills cost almost nothing until used.
|
|
209
|
+
Frontmatter is parsed minimally (flat `key: value`, no PyYAML).
|
|
210
|
+
|
|
211
|
+
## AGENTS.md
|
|
212
|
+
|
|
213
|
+
If `agents_md` is unset, Aimee finds the nearest `AGENTS.md` walking up from
|
|
214
|
+
each root (in root order) and includes it under a "Project instructions"
|
|
215
|
+
heading. Give an explicit path to override.
|
|
216
|
+
|
|
217
|
+
## Tests
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
uv run pytest -q
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The suite is fully offline: the HTTP client is tested against
|
|
224
|
+
`httpx.MockTransport`, and the agent loop against a scripted fake model.
|
|
225
|
+
|
|
226
|
+
## Project layout
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
src/aimee/
|
|
230
|
+
agent.py # the loop + Aimee facade (sync/async)
|
|
231
|
+
client.py # OpenAI-compatible client (chat + SSE streaming)
|
|
232
|
+
config.py # AimeeConfig
|
|
233
|
+
hooks.py # hook dispatch (sync/async)
|
|
234
|
+
prompt.py # system prompt assembly (base + AGENTS.md + skills)
|
|
235
|
+
skills.py # SKILL.md discovery + catalog
|
|
236
|
+
types.py # messages, responses, reports
|
|
237
|
+
tools/ # base primitives + built-in read/write/edit/bash
|
|
238
|
+
examples/ # basic.py, custom_tool.py + workspace fixtures
|
|
239
|
+
tests/ # offline test suite
|
|
240
|
+
```
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Aimee — Minimal LLM Agent Loop Library (Python)
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
New Python library **Aimee**: a very minimal LLM agent loop supporting tools and skills, designed to be dropped into *any* existing Python project. The client owns configuration and tool management; Aimee provides the loop, the OpenAI-compatible client, skills, AGENTS.md support, and callback hooks for extension.
|
|
6
|
+
|
|
7
|
+
### Decisions (confirmed with user)
|
|
8
|
+
|
|
9
|
+
- **Deps**: one runtime dep — `httpx`. OpenAI integration implemented by hand (chat completions + SSE streaming), no `openai` package.
|
|
10
|
+
- **API shape**: sync-first public API over an async core. `Aimee.run(task)` works in any script (runs the async core on a dedicated background event-loop thread, so it also works inside an already-running loop). `await aimee.run_async(task)` for async callers. Tools and hooks may be sync *or* async callables.
|
|
11
|
+
- **Streaming**: token-level `on_delta` hook (SSE deltas) *plus* turn-level complete messages.
|
|
12
|
+
- **Skills**: `SKILL.md` dirs (YAML frontmatter `name`/`description` + markdown body). Aimee scans configurable skill dirs, injects a **catalog** (name — description — path) into the system prompt, agent reads the full file on demand via `read`. Frontmatter parsed by hand (2-line `key: value` parser) — no PyYAML dep.
|
|
13
|
+
- **Python**: 3.10+.
|
|
14
|
+
- **Multi-root workspace**: `roots: list[Path]` (default `[Path.cwd()]`) — clients can add secondary locations like `~/.myapp`; per-root resolution rules defined in config section. (rev 2)
|
|
15
|
+
- **Model**: default `model="default"` (many OpenAI-compatible gateways route on that name). (rev 2)
|
|
16
|
+
- **Tool selection**: built-in tools are individually importable factories (`read()`, `write()`, `edit()`, `bash()`) plus `basic_tools(names=None)` for subsets. (rev 2)
|
|
17
|
+
|
|
18
|
+
## Approach
|
|
19
|
+
|
|
20
|
+
### Package layout (`src` layout, uv-managed, hatchling)
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pyproject.toml # deps: httpx; dev: pytest, ruff
|
|
24
|
+
README.md
|
|
25
|
+
src/aimee/
|
|
26
|
+
__init__.py # exports: Aimee, AimeeConfig, Tool, ToolContext, RunReport, ToolDecision, Skill, load_skills
|
|
27
|
+
types.py # Message, ChatResponse, TokenUsage, RunReport, ToolDecision
|
|
28
|
+
config.py # AimeeConfig (dataclass)
|
|
29
|
+
client.py # OpenAIClient (httpx): chat() + stream chat, SSE parsing, auth header
|
|
30
|
+
agent.py # async agent loop + Aimee sync/async facade
|
|
31
|
+
hooks.py # hook protocols + sync/async dispatch helper
|
|
32
|
+
skills.py # SKILL.md discovery + frontmatter parse + catalog rendering
|
|
33
|
+
prompt.py # system prompt assembly: base + AGENTS.md + skills catalog
|
|
34
|
+
tools/
|
|
35
|
+
__init__.py # read(), write(), edit(), bash() factories + basic_tools(names=None) (all opt-in)
|
|
36
|
+
base.py # Tool dataclass (name, description, parameters JSON-schema, handler) + ToolContext
|
|
37
|
+
fs.py # read, write, edit
|
|
38
|
+
bash.py # bash
|
|
39
|
+
examples/
|
|
40
|
+
basic.py # drop-in example: config, basic tools, skills, AGENTS.md, prints deltas, mini REPL
|
|
41
|
+
custom_tool.py # custom tool registration + tool-approval hook demo
|
|
42
|
+
tests/
|
|
43
|
+
test_client.py
|
|
44
|
+
test_agent.py
|
|
45
|
+
test_tools.py
|
|
46
|
+
test_skills.py
|
|
47
|
+
test_prompt.py
|
|
48
|
+
test_hooks.py
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Core design
|
|
52
|
+
|
|
53
|
+
**`AimeeConfig`** (dataclass, all optional with sane defaults):
|
|
54
|
+
- `roots: list[Path] = [Path.cwd()]` — one or more workspace roots. `roots[0]` is the primary root; extra roots (e.g. `~/.myapp`) extend where the agent may operate. Resolution semantics: `read`/`edit` try each root in order and use the first where the file **exists**; `write` uses the first root where the file exists, else `roots[0]`; `bash` runs with `cwd=roots[0]`; AGENTS.md walk-up tries each root in order (first found wins)
|
|
55
|
+
- `model: str = "default"`
|
|
56
|
+
- `api_base: str | None` — defaults to env `OPENAI_API_BASE`
|
|
57
|
+
- `api_key: str | None` — defaults to env `OPENAI_API_KEY`
|
|
58
|
+
- `system_prompt: str | None` — client-provided base prompt (replaces built-in minimal one)
|
|
59
|
+
- `agents_md: Path | None = None` — explicit AGENTS.md; else walk up from each `roots` entry in order (first found wins, stop at filesystem root)
|
|
60
|
+
- `skills_dirs: list[Path] = []`
|
|
61
|
+
- `max_turns: int = 30`
|
|
62
|
+
- `temperature: float | None = None`, `max_tokens: int | None = None`
|
|
63
|
+
- `bash_timeout: int = 120`, `output_limit: int = 100_000` (tool output truncation)
|
|
64
|
+
|
|
65
|
+
**`OpenAIClient`** (`client.py`, httpx):
|
|
66
|
+
- `POST {api_base}/chat/completions` with `Authorization: Bearer {key}`, model, messages, tools (OpenAI function-calling schema).
|
|
67
|
+
- `chat(...)` → full `ChatResponse`; `stream_chat(...)` → iterator of SSE deltas (hand-parsed: `data:` lines, `[DONE]`, content/tool_call arg deltas accumulated per index).
|
|
68
|
+
- HTTP errors → `AimeeError` with status + body snippet. No retries (keep minimal; client can wrap).
|
|
69
|
+
|
|
70
|
+
**`Aimee` facade** (`agent.py`):
|
|
71
|
+
- `__init__(config=None, *, client=None, tools=(), hooks=())` — `client` injectable (test seam).
|
|
72
|
+
- `add_tool(Tool)`, `add_hook(obj)`; `run(task) -> RunReport` (sync), `async run_async(task) -> RunReport`.
|
|
73
|
+
- Loop: build system prompt once (base + AGENTS.md + skills catalog) → append user message → call model with tools → if `tool_calls`: dispatch each (approval hook → execute → `role:"tool"` result message) → repeat until no tool calls or `max_turns` (→ report `truncated=True`).
|
|
74
|
+
- Tool execution errors are returned to the model as `Error: ...` content (so it can recover) **and** fired to `on_error`.
|
|
75
|
+
- `RunReport`: `final_text`, `turns`, `tool_calls` count, `usage` (accumulated `TokenUsage` when provider supplies it), `truncated`, `messages` (full transcript).
|
|
76
|
+
|
|
77
|
+
**Hooks** (`hooks.py`) — duck-typed; register any object exposing any subset of:
|
|
78
|
+
- `on_delta(chunk: str)` — streaming tokens
|
|
79
|
+
- `on_tool_call(name, args: dict) -> ToolDecision | None` — approval gate: `None`/`allow()` = run; `ToolDecision.deny(reason)` = skip, reason goes to model; `ToolDecision.modify(new_args)` = run with replaced args
|
|
80
|
+
- `on_tool_result(name, args, result: str)`
|
|
81
|
+
- `on_turn(turn: int, response: ChatResponse)`
|
|
82
|
+
- `on_error(error: Exception)`
|
|
83
|
+
- `on_done(report: RunReport)`
|
|
84
|
+
- All may be sync or async; dispatch helper handles both.
|
|
85
|
+
|
|
86
|
+
**Built-in tools** (all opt-in; each tool is a parameterless factory, and clients can pick exactly what they want):
|
|
87
|
+
- `from aimee.tools import read, write, edit, bash, basic_tools`
|
|
88
|
+
- `Aimee(tools=[read()])` — just `read`; `Aimee(tools=basic_tools())` — all four; `Aimee(tools=basic_tools(["read", "edit"]))` — named subset.
|
|
89
|
+
- Handler signature is `handler(args: dict, ctx: ToolContext) -> str`; Aimee builds `ToolContext` (roots, output_limit, bash_timeout, config) per call, so built-ins and client custom tools share the same workspace view.
|
|
90
|
+
- `read(path, offset=1, limit=2000)` — numbered lines, relative paths resolve per the multi-root rule above, truncation notice when capped.
|
|
91
|
+
- `write(path, content)` — create/overwrite, mkdir parents.
|
|
92
|
+
- `edit(path, old_text, new_text)` — exact string replace; `old_text` must match **exactly once** (error message lists match count otherwise).
|
|
93
|
+
- `bash(command, timeout?)` — run in `roots[0]`, shell, combined stdout+stderr truncated to `output_limit`, returns `[exit N]\n<output>`. No built-in approval — the `on_tool_call` hook is the safety mechanism (documented).
|
|
94
|
+
|
|
95
|
+
**System prompt** (`prompt.py`): built-in minimal base (identity + "use tools when needed") or `config.system_prompt`, then `# AGENTS.md` section (explicit path, else first found walking up from `roots` in order) under a heading, then `# Skills` catalog block:
|
|
96
|
+
```
|
|
97
|
+
- name — description
|
|
98
|
+
path/to/SKILL.md (read before use)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Tests (pytest)
|
|
102
|
+
|
|
103
|
+
- `test_client.py` — `httpx.MockTransport`: request shape, auth header, base URL join; `ChatResponse` parse incl. tool_calls; SSE stream delta accumulation (content + tool arg deltas); HTTP error → `AimeeError`.
|
|
104
|
+
- `test_agent.py` — scripted fake model (inject via `client=`): no-tools → done in 1 turn; tool call → result → second turn → done; denied tool call returns reason to model; `max_turns` truncation; usage accumulation; `run()` (sync) and `run_async()` parity.
|
|
105
|
+
- `test_tools.py` — `tmp_path`: read (numbering, offset/limit, missing file), write (parents created, overwrite), edit (unique match, no-match, multi-match error), bash (echo, nonzero exit, cwd, timeout), multi-root resolution (read/edit across `roots`, write defaulting to `roots[0]`).
|
|
106
|
+
- `test_skills.py` — frontmatter parse (valid, missing description, no frontmatter), discovery across multiple dirs, catalog rendering.
|
|
107
|
+
- `test_prompt.py` — AGENTS.md explicit path, walk-up discovery (tmp tree), nearest-wins, skills block present/absent, custom system_prompt replacement.
|
|
108
|
+
- `test_hooks.py` — sync + async hooks all dispatched; deny/modify decisions.
|
|
109
|
+
|
|
110
|
+
### Examples
|
|
111
|
+
|
|
112
|
+
- `examples/basic.py` — reads `OPENAI_API_BASE`/`KEY` from env, builds `AimeeConfig` (`roots=[Path.cwd(), Path.home()/".myapp"]`-style multi-root config, skills dir pointing at a `skills/` folder next to the example containing one tiny SKILL.md, `AGENTS.md` next to it), registers `basic_tools(["read", "write", "edit"])`, an `on_delta` hook that prints tokens, runs one task (or `--repl` for a tiny stdin loop), prints `RunReport`.
|
|
113
|
+
- `examples/custom_tool.py` — registers a single custom tool via `Tool(...)` using `ToolContext`, plus an `on_tool_call` hook that denies `bash` calls containing a marker string; prints report.
|
|
114
|
+
|
|
115
|
+
## Files to modify
|
|
116
|
+
|
|
117
|
+
All new (empty repo): tree above. No git repo exists yet — initialize `git init` + `.gitignore` (uv: `.venv`, `__pycache__`, `dist/`) as step 1.
|
|
118
|
+
|
|
119
|
+
## Steps
|
|
120
|
+
|
|
121
|
+
- [x] 1. `git init`, `.gitignore`, `pyproject.toml` (uv, hatchling, `httpx>=0.27`, pytest+ruff dev group), `uv sync`
|
|
122
|
+
- [x] 2. `types.py` + `config.py`
|
|
123
|
+
- [x] 3. `client.py` (OpenAIClient, SSE parsing) + `test_client.py`
|
|
124
|
+
- [x] 4. `hooks.py` + `prompt.py` + `agent.py` (loop + facade) + `test_agent.py`, `test_prompt.py`, `test_hooks.py`
|
|
125
|
+
- [x] 5. `skills.py` + `test_skills.py`
|
|
126
|
+
- [x] 6. `tools/` (base, fs, bash) + `test_tools.py`
|
|
127
|
+
- [x] 7. `examples/basic.py` + `examples/custom_tool.py` (+ their `AGENTS.md`/skill fixtures)
|
|
128
|
+
- [x] 8. `README.md` (quickstart, config/hook/tool/skill reference, security note on bash)
|
|
129
|
+
- [x] 9. `uv run ruff check` + `ruff format`, full pytest green
|
|
130
|
+
|
|
131
|
+
## Verification
|
|
132
|
+
|
|
133
|
+
- `uv run pytest -q` — all green.
|
|
134
|
+
- `uv run ruff check .` + `uv run ruff format --check .` clean.
|
|
135
|
+
- Manual (needs real key): `OPENAI_API_BASE=... OPENAI_API_KEY=... uv run python examples/basic.py "List the skills you have and read AGENTS.md, then summarize both."` — expect skill catalog + AGENTS.md content reflected in answer; `--repl` for interactive.
|
|
136
|
+
- `uv build` produces wheel + sdist without error (drop-in readiness check).
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# AgentAImee
|
|
2
|
+
|
|
3
|
+
A very minimal LLM agent loop for Python, with tools and skills. One runtime
|
|
4
|
+
dependency (`httpx`). Drop it into an existing project; you own the config,
|
|
5
|
+
the tools, and the behavior. Aimee gives you the loop, the OpenAI-compatible
|
|
6
|
+
client, skill discovery, AGENTS.md support, and callback hooks.
|
|
7
|
+
|
|
8
|
+
- **Model**: any OpenAI-compatible `chat/completions` endpoint via
|
|
9
|
+
`OPENAI_API_BASE` + `OPENAI_API_KEY` (no `openai` package).
|
|
10
|
+
- **Tools**: built-in `read`, `write`, `edit`, `bash` — **all opt-in** — plus
|
|
11
|
+
your own tools.
|
|
12
|
+
- **Skills**: `SKILL.md` directories; a catalog is injected into the system
|
|
13
|
+
prompt and the agent reads the full skill on demand.
|
|
14
|
+
- **Hooks**: extend the run with callbacks — stream tokens, approve/modify
|
|
15
|
+
tool calls, observe turns, handle errors.
|
|
16
|
+
- **Sync or async**: `agent.run(task)` works in any script; `await
|
|
17
|
+
agent.run_async(task)` for async code.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv add agent-aimee # or: pip install agent-aimee
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from aimee import Aimee, AimeeConfig # module name is `aimee`
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Development
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv sync
|
|
33
|
+
uv run pytest -q # test suite (no network needed)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
export OPENAI_API_BASE=https://api.openai.com/v1 # or any compatible gateway
|
|
40
|
+
export OPENAI_API_KEY=sk-...
|
|
41
|
+
uv run python examples/basic.py "your task here" # or --repl
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The example wires everything up: multi-root workspace, a skills directory,
|
|
45
|
+
AGENTS.md, three basic tools, and console hooks.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from aimee import Aimee, AimeeConfig
|
|
52
|
+
from aimee.tools import basic_tools
|
|
53
|
+
|
|
54
|
+
config = AimeeConfig(
|
|
55
|
+
roots=[Path.cwd(), Path.home() / ".myapp"], # workspace roots (first = primary)
|
|
56
|
+
skills_dirs=[Path.home() / ".myapp" / "skills"],
|
|
57
|
+
model="gpt-4o-mini", # default: "default"
|
|
58
|
+
)
|
|
59
|
+
agent = Aimee(config, tools=basic_tools()) # read, write, edit, bash
|
|
60
|
+
# agent = Aimee(config, tools=[read()]) # or just the tools you want
|
|
61
|
+
|
|
62
|
+
report = agent.run("Summarize AGENTS.md") # sync (also safe inside a running loop)
|
|
63
|
+
# report = await agent.run_async("...") # async
|
|
64
|
+
|
|
65
|
+
print(report.final_text, report.turns, report.tool_calls, report.usage)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`RunReport` fields: `final_text`, `turns`, `tool_calls`, `usage` (tokens,
|
|
69
|
+
when the provider reports them), `truncated` (max turns hit), `messages`
|
|
70
|
+
(full transcript).
|
|
71
|
+
|
|
72
|
+
## Sessions (multi-turn memory)
|
|
73
|
+
|
|
74
|
+
`agent.run(task)` is stateless — every run starts fresh. For a conversation
|
|
75
|
+
that remembers, create a session:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
session = agent.session()
|
|
79
|
+
session.run("hello, who am I talking to?")
|
|
80
|
+
session.run("what did I just ask?") # the model sees the whole prior exchange
|
|
81
|
+
|
|
82
|
+
session.history # snapshot: [system, user, assistant, user, assistant, ...]
|
|
83
|
+
session.clear() # start over (a fresh system prompt is built on the next run)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- A session is bound to one agent (sharing its tools/hooks/config); one agent
|
|
87
|
+
can hold many independent sessions at once.
|
|
88
|
+
- `session.run()` / `await session.run_async()` mirror `Aimee.run()` /
|
|
89
|
+
`run_async()` — same hooks fire, same `RunReport` comes back (with `messages`
|
|
90
|
+
being the full session history).
|
|
91
|
+
- Tool calls and their results are part of the history too, so the model can
|
|
92
|
+
reference earlier tool output in later turns.
|
|
93
|
+
- History is unbounded by design: long conversations will eventually hit the
|
|
94
|
+
model's context limit — `session.clear()` resets when that gets close.
|
|
95
|
+
(`history` returns a snapshot; subclass or wrap the session for custom
|
|
96
|
+
trimming strategies.)
|
|
97
|
+
|
|
98
|
+
## Configuration (`AimeeConfig`)
|
|
99
|
+
|
|
100
|
+
| Field | Default | Meaning |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| `roots` | `[Path.cwd()]` | Workspace roots, in priority order (see below). All paths are configurable here. |
|
|
103
|
+
| `model` | `"default"` | Model name passed to the endpoint. |
|
|
104
|
+
| `api_base` | `$OPENAI_API_BASE` → `https://api.openai.com/v1` | Endpoint base URL. |
|
|
105
|
+
| `api_key` | `$OPENAI_API_KEY` | Bearer token. |
|
|
106
|
+
| `system_prompt` | built-in minimal prompt | Replaces the base prompt. |
|
|
107
|
+
| `agents_md` | nearest `AGENTS.md` walking up from `roots` | Explicit AGENTS.md path. |
|
|
108
|
+
| `skills_dirs` | `[]` | Directories containing `SKILL.md` skill folders. |
|
|
109
|
+
| `max_turns` | `30` | Model-call budget per run. |
|
|
110
|
+
| `temperature` / `max_tokens` | `None` | Passed through when set. |
|
|
111
|
+
| `bash_timeout` | `120` | Default shell timeout (seconds). |
|
|
112
|
+
| `output_limit` | `100_000` | Tool output truncation (chars). |
|
|
113
|
+
|
|
114
|
+
**Multi-root rules.** `roots[0]` is the primary root. `read`/`edit` resolve
|
|
115
|
+
relative paths against roots in order (first existing file wins); `write`
|
|
116
|
+
updates the first root that contains the file, and creates new files under
|
|
117
|
+
the primary root; `bash` runs with the primary root as cwd; AGENTS.md
|
|
118
|
+
discovery walks up from each root in order until one is found.
|
|
119
|
+
|
|
120
|
+
## Tools
|
|
121
|
+
|
|
122
|
+
Tools are OpenAI function-calling specs plus a handler:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from aimee import Tool
|
|
126
|
+
|
|
127
|
+
Tool(
|
|
128
|
+
name="current_time",
|
|
129
|
+
description="Get the current local date and time (ISO format).",
|
|
130
|
+
parameters={"type": "object", "properties": {}}, # JSON Schema
|
|
131
|
+
handler=lambda args, ctx: "...", # sync or async; returns str
|
|
132
|
+
)
|
|
133
|
+
agent.add_tool(tool)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`ctx` is a `ToolContext` giving handlers the same workspace view as the
|
|
137
|
+
built-ins: `ctx.roots`, `ctx.resolve(path, must_exist=...)`,
|
|
138
|
+
`ctx.config.bash_timeout`, `ctx.config.output_limit`.
|
|
139
|
+
|
|
140
|
+
Built-ins (opt-in): `basic_tools()` → all four, `basic_tools(["read", "edit"])`
|
|
141
|
+
→ a subset, or import individually: `from aimee.tools import read, write, edit, bash`.
|
|
142
|
+
|
|
143
|
+
| Tool | Behavior |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `read(path, offset?, limit?)` | Numbered lines (paged), or directory listing. |
|
|
146
|
+
| `write(path, content)` | Create/overwrite; parent dirs created. |
|
|
147
|
+
| `edit(path, old_text, new_text)` | Exact-string replace; `old_text` must match exactly once. |
|
|
148
|
+
| `bash(command, timeout?)` | Shell in the primary root; `[exit N]` + combined output, truncated. |
|
|
149
|
+
|
|
150
|
+
> **Security note.** `bash` has no built-in approval. The `on_tool_call` hook
|
|
151
|
+
> is the safety mechanism — see `examples/custom_tool.py` for a hook that
|
|
152
|
+
> denies commands containing `rm `. Don't enable `bash` without one.
|
|
153
|
+
|
|
154
|
+
## Hooks
|
|
155
|
+
|
|
156
|
+
Register any object defining any subset of these methods (sync or async),
|
|
157
|
+
via `Aimee(..., hooks=[...])` or `agent.add_hook(obj)`:
|
|
158
|
+
|
|
159
|
+
| Hook | Purpose |
|
|
160
|
+
| --- | --- |
|
|
161
|
+
| `on_delta(chunk: str)` | One streamed chunk of assistant text (streaming on by default). |
|
|
162
|
+
| `on_tool_call(name, args) -> ToolDecision \| None` | Approval gate. `None` = allow; `ToolDecision.deny(reason)` blocks (reason goes to the model); `ToolDecision.modify(new_args)` replaces args. First non-None decision wins. |
|
|
163
|
+
| `on_tool_result(name, args, result)` | After a tool finished (including denials/errors). |
|
|
164
|
+
| `on_turn(turn, response)` | After each completed model response. |
|
|
165
|
+
| `on_error(error)` | Tool errors (run continues, error text goes to the model) and API errors (run aborts). |
|
|
166
|
+
| `on_done(report)` | Once, when the run finishes. |
|
|
167
|
+
|
|
168
|
+
## Skills
|
|
169
|
+
|
|
170
|
+
A skill is a directory with a `SKILL.md`:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
myapp/skills/
|
|
174
|
+
└── pdf-extract/
|
|
175
|
+
└── SKILL.md
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```markdown
|
|
179
|
+
---
|
|
180
|
+
name: pdf-extract
|
|
181
|
+
description: Extract text from PDF files. Use when the task involves PDFs.
|
|
182
|
+
---
|
|
183
|
+
Full instructions here. The agent reads this file (via the read tool)
|
|
184
|
+
before using the skill.
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Set `skills_dirs=[...]`; Aimee injects only the catalog (name, description,
|
|
188
|
+
path) into the system prompt, so skills cost almost nothing until used.
|
|
189
|
+
Frontmatter is parsed minimally (flat `key: value`, no PyYAML).
|
|
190
|
+
|
|
191
|
+
## AGENTS.md
|
|
192
|
+
|
|
193
|
+
If `agents_md` is unset, Aimee finds the nearest `AGENTS.md` walking up from
|
|
194
|
+
each root (in root order) and includes it under a "Project instructions"
|
|
195
|
+
heading. Give an explicit path to override.
|
|
196
|
+
|
|
197
|
+
## Tests
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
uv run pytest -q
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The suite is fully offline: the HTTP client is tested against
|
|
204
|
+
`httpx.MockTransport`, and the agent loop against a scripted fake model.
|
|
205
|
+
|
|
206
|
+
## Project layout
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
src/aimee/
|
|
210
|
+
agent.py # the loop + Aimee facade (sync/async)
|
|
211
|
+
client.py # OpenAI-compatible client (chat + SSE streaming)
|
|
212
|
+
config.py # AimeeConfig
|
|
213
|
+
hooks.py # hook dispatch (sync/async)
|
|
214
|
+
prompt.py # system prompt assembly (base + AGENTS.md + skills)
|
|
215
|
+
skills.py # SKILL.md discovery + catalog
|
|
216
|
+
types.py # messages, responses, reports
|
|
217
|
+
tools/ # base primitives + built-in read/write/edit/bash
|
|
218
|
+
examples/ # basic.py, custom_tool.py + workspace fixtures
|
|
219
|
+
tests/ # offline test suite
|
|
220
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Example workspace
|
|
2
|
+
|
|
3
|
+
This is the Aimee example workspace. A few ground rules:
|
|
4
|
+
|
|
5
|
+
- `secondary/` is a second workspace root (multi-root demo). It holds
|
|
6
|
+
`secret-note.txt` — read it when asked.
|
|
7
|
+
- Safety: never run destructive shell commands (anything containing `rm `).
|
|
8
|
+
The client in `custom_tool.py` also enforces this via an approval hook.
|
|
9
|
+
- Be concise.
|