agent-aimee 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_aimee-0.1.0.dist-info/METADATA +240 -0
- agent_aimee-0.1.0.dist-info/RECORD +15 -0
- agent_aimee-0.1.0.dist-info/WHEEL +4 -0
- aimee/__init__.py +41 -0
- aimee/agent.py +381 -0
- aimee/client.py +192 -0
- aimee/config.py +57 -0
- aimee/hooks.py +46 -0
- aimee/prompt.py +52 -0
- aimee/skills.py +95 -0
- aimee/tools/__init__.py +37 -0
- aimee/tools/base.py +75 -0
- aimee/tools/bash.py +50 -0
- aimee/tools/fs.py +127 -0
- aimee/types.py +171 -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,15 @@
|
|
|
1
|
+
aimee/__init__.py,sha256=ZtclDBHRGGGnoECef8e8xKy3XZDUTL3T_j43fZCiVw0,832
|
|
2
|
+
aimee/agent.py,sha256=nAnt45Je66mGBgBF66UtJYFiHib57Kifb3KB-la1rYw,14609
|
|
3
|
+
aimee/client.py,sha256=Hbvs4sYvlOcbGoJkWNoCwe43ouPYjJKxXYGZhG6fl_I,7047
|
|
4
|
+
aimee/config.py,sha256=dzTnM9fOY3kCJ3tzBxP5AvjWNJlTl2iyOnNdTW3mnxo,2060
|
|
5
|
+
aimee/hooks.py,sha256=BXgBnO62J24SU7_qABhAsP821e0Fwt9n5KXe3L9k9c8,1692
|
|
6
|
+
aimee/prompt.py,sha256=N7ndgD7UNR5DJd1tAn6nKtg8d6ZtiieXXulIR8lU1yw,1809
|
|
7
|
+
aimee/skills.py,sha256=yW8P5ioTEX0l8eeMNVtrbw00aBNk6AiEn9Mpm9nk99g,2996
|
|
8
|
+
aimee/types.py,sha256=_fXETvcVA3NBi00BHXt1B3kLA4cQJMM2KM8PsrmD3kY,4813
|
|
9
|
+
aimee/tools/__init__.py,sha256=qAard2VHkbfrUpuhHkPSEvIBHVlKQAOWmfMTVS_SCM0,1356
|
|
10
|
+
aimee/tools/base.py,sha256=GYZeXz43HUv8EORJ0aqjZYb5YfVTr7YpcquapYSawtw,2500
|
|
11
|
+
aimee/tools/bash.py,sha256=50fGNHFJYSTMX1YSo6ixhWXY5Wb6Bip-80QdDFNdlno,1741
|
|
12
|
+
aimee/tools/fs.py,sha256=ILSeC1l9uX5XicgJWqGZxm5rbqqRgJEIR8fJ-ad7dUs,4680
|
|
13
|
+
agent_aimee-0.1.0.dist-info/METADATA,sha256=q6qgyDrqRnf69KQFUCBKWkKe1gCI3bKlpuJDvkfo68g,9202
|
|
14
|
+
agent_aimee-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
agent_aimee-0.1.0.dist-info/RECORD,,
|
aimee/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Aimee: a very minimal LLM agent loop with tools and skills."""
|
|
2
|
+
|
|
3
|
+
from aimee.agent import Aimee, AimeeSession
|
|
4
|
+
from aimee.client import OpenAIClient
|
|
5
|
+
from aimee.config import AimeeConfig
|
|
6
|
+
from aimee.skills import Skill, load_skills, parse_frontmatter
|
|
7
|
+
from aimee.tools.base import Tool, ToolContext
|
|
8
|
+
from aimee.types import (
|
|
9
|
+
AimeeError,
|
|
10
|
+
ChatResponse,
|
|
11
|
+
Message,
|
|
12
|
+
RunReport,
|
|
13
|
+
StreamDelta,
|
|
14
|
+
TokenUsage,
|
|
15
|
+
ToolCall,
|
|
16
|
+
ToolCallDelta,
|
|
17
|
+
ToolDecision,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Aimee",
|
|
22
|
+
"AimeeConfig",
|
|
23
|
+
"AimeeError",
|
|
24
|
+
"AimeeSession",
|
|
25
|
+
"ChatResponse",
|
|
26
|
+
"Message",
|
|
27
|
+
"OpenAIClient",
|
|
28
|
+
"RunReport",
|
|
29
|
+
"Skill",
|
|
30
|
+
"StreamDelta",
|
|
31
|
+
"TokenUsage",
|
|
32
|
+
"Tool",
|
|
33
|
+
"ToolCall",
|
|
34
|
+
"ToolCallDelta",
|
|
35
|
+
"ToolContext",
|
|
36
|
+
"ToolDecision",
|
|
37
|
+
"load_skills",
|
|
38
|
+
"parse_frontmatter",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
aimee/agent.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""The agent loop and the Aimee facade (sync + async entry points)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from aimee.client import OpenAIClient
|
|
13
|
+
from aimee.config import AimeeConfig
|
|
14
|
+
from aimee.hooks import call_hook, fire
|
|
15
|
+
from aimee.prompt import build_system_prompt
|
|
16
|
+
from aimee.skills import load_skills
|
|
17
|
+
from aimee.tools.base import Tool, ToolContext, validate_tools
|
|
18
|
+
from aimee.types import (
|
|
19
|
+
ROLE_SYSTEM,
|
|
20
|
+
AimeeError,
|
|
21
|
+
ChatResponse,
|
|
22
|
+
Message,
|
|
23
|
+
RunReport,
|
|
24
|
+
TokenUsage,
|
|
25
|
+
ToolCall,
|
|
26
|
+
ToolDecision,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_MAX_TURN_NOTE = "Stopped: max turns reached."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Aimee:
|
|
33
|
+
"""A minimal LLM agent: model + tools + skills + callback hooks.
|
|
34
|
+
|
|
35
|
+
Sync usage (works in any script, even inside a running event loop)::
|
|
36
|
+
|
|
37
|
+
agent = Aimee(config, tools=basic_tools())
|
|
38
|
+
report = agent.run("Summarize AGENTS.md")
|
|
39
|
+
|
|
40
|
+
Async usage::
|
|
41
|
+
|
|
42
|
+
report = await Aimee(config, tools=basic_tools()).run_async("...")
|
|
43
|
+
|
|
44
|
+
`client` accepts anything with `chat()`/`stream_chat()` (dependency
|
|
45
|
+
injection for tests); otherwise an OpenAIClient is created lazily and
|
|
46
|
+
reused across runs. Release resources with `close()`/`aclose()` or by
|
|
47
|
+
using the agent as a context manager.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
config: AimeeConfig | None = None,
|
|
53
|
+
*,
|
|
54
|
+
client: Any = None,
|
|
55
|
+
tools: Iterable[Tool] = (),
|
|
56
|
+
hooks: Iterable[Any] = (),
|
|
57
|
+
stream: bool = True,
|
|
58
|
+
):
|
|
59
|
+
self.config = config or AimeeConfig()
|
|
60
|
+
self._client = client
|
|
61
|
+
self._owns_client = client is None
|
|
62
|
+
self._client_closed = False
|
|
63
|
+
self._loop_thread: _LoopThread | None = None
|
|
64
|
+
self.tools: list[Tool] = list(tools)
|
|
65
|
+
self.hooks: list[Any] = list(hooks)
|
|
66
|
+
self.stream = stream
|
|
67
|
+
|
|
68
|
+
# -- registration ------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def add_tool(self, tool: Tool) -> Aimee:
|
|
71
|
+
"""Register one tool; returns self for chaining."""
|
|
72
|
+
self.tools.append(tool)
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def add_hook(self, hook: Any) -> Aimee:
|
|
76
|
+
"""Register a hook object (any subset of the on_* methods); chainable."""
|
|
77
|
+
self.hooks.append(hook)
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
# -- entry points ------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def run(self, task: str) -> RunReport:
|
|
83
|
+
"""Run the agent (stateless: no memory between runs). Sync; see run_async."""
|
|
84
|
+
return self._sync_submit(self.run_async(task))
|
|
85
|
+
|
|
86
|
+
async def run_async(self, task: str) -> RunReport:
|
|
87
|
+
"""Run the agent (stateless: no memory between runs). Async."""
|
|
88
|
+
await self._ensure_client()
|
|
89
|
+
return await self._run(task)
|
|
90
|
+
|
|
91
|
+
def session(self) -> AimeeSession:
|
|
92
|
+
"""Start a conversation session that keeps chat history across runs."""
|
|
93
|
+
return AimeeSession(self)
|
|
94
|
+
|
|
95
|
+
def _sync_submit(self, coro: Any) -> Any:
|
|
96
|
+
"""Run a coroutine on the agent's persistent background event loop."""
|
|
97
|
+
if self._loop_thread is None:
|
|
98
|
+
self._loop_thread = _LoopThread()
|
|
99
|
+
return self._loop_thread.submit(coro)
|
|
100
|
+
|
|
101
|
+
async def _run_with_history(self, task: str, history: list[Message]) -> RunReport:
|
|
102
|
+
"""One run whose messages accumulate in `history` (session support)."""
|
|
103
|
+
await self._ensure_client()
|
|
104
|
+
return await self._run(task, history=history)
|
|
105
|
+
|
|
106
|
+
async def _ensure_client(self) -> None:
|
|
107
|
+
"""Create the managed model client on first use, or again after aclose()."""
|
|
108
|
+
if self._client is None or (self._owns_client and self._client_closed):
|
|
109
|
+
self._client = OpenAIClient(self.config)
|
|
110
|
+
self._owns_client = True
|
|
111
|
+
self._client_closed = False
|
|
112
|
+
|
|
113
|
+
async def aclose(self) -> None:
|
|
114
|
+
"""Close the model client Aimee created itself (no-op for injected clients)."""
|
|
115
|
+
if self._owns_client and self._client is not None and not self._client_closed:
|
|
116
|
+
await self._client.aclose()
|
|
117
|
+
self._client_closed = True
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
"""Stop the sync background loop; close an owned client while the loop is up."""
|
|
122
|
+
loop_thread = self._loop_thread
|
|
123
|
+
self._loop_thread = None
|
|
124
|
+
if loop_thread is None:
|
|
125
|
+
return
|
|
126
|
+
if self._owns_client and self._client is not None and not self._client_closed:
|
|
127
|
+
loop_thread.submit(self._client.aclose())
|
|
128
|
+
self._client_closed = True
|
|
129
|
+
loop_thread.stop()
|
|
130
|
+
|
|
131
|
+
async def __aenter__(self) -> Aimee:
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
async def __aexit__(self, *exc) -> None:
|
|
135
|
+
await self.aclose()
|
|
136
|
+
|
|
137
|
+
# -- the loop ----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async def _run(self, task: str, history: list[Message] | None = None) -> RunReport:
|
|
140
|
+
tools = validate_tools(self.tools)
|
|
141
|
+
ctx = ToolContext(config=self.config, roots=self.config.resolved_roots())
|
|
142
|
+
skills = load_skills(self.config.skills_dirs) if self.config.skills_dirs else []
|
|
143
|
+
if history is None:
|
|
144
|
+
messages: list[Message] = [
|
|
145
|
+
Message.system(build_system_prompt(self.config, skills)),
|
|
146
|
+
Message.user(task),
|
|
147
|
+
]
|
|
148
|
+
else:
|
|
149
|
+
# Session mode: append this turn onto the accumulated conversation.
|
|
150
|
+
if not history or history[0].role != ROLE_SYSTEM:
|
|
151
|
+
history.insert(0, Message.system(build_system_prompt(self.config, skills)))
|
|
152
|
+
history.append(Message.user(task))
|
|
153
|
+
messages = history
|
|
154
|
+
usage = TokenUsage()
|
|
155
|
+
tool_call_count = 0
|
|
156
|
+
turns = 0
|
|
157
|
+
truncated = False
|
|
158
|
+
final_text = ""
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
while True:
|
|
162
|
+
if turns >= self.config.max_turns:
|
|
163
|
+
truncated = True
|
|
164
|
+
break
|
|
165
|
+
turns += 1
|
|
166
|
+
response = await self._request_turn(messages, tools)
|
|
167
|
+
if response.usage:
|
|
168
|
+
usage.add(response.usage)
|
|
169
|
+
await fire(self.hooks, "on_turn", turns, response)
|
|
170
|
+
|
|
171
|
+
if not response.has_tool_calls:
|
|
172
|
+
messages.append(Message.assistant(response.content or ""))
|
|
173
|
+
final_text = response.content
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
messages.append(_assistant_message(response))
|
|
177
|
+
for tool_call in response.tool_calls:
|
|
178
|
+
tool_call_count += 1
|
|
179
|
+
result, args_used = await self._dispatch_tool(tool_call, tools, ctx)
|
|
180
|
+
await fire(self.hooks, "on_tool_result", tool_call.name, args_used, result)
|
|
181
|
+
messages.append(Message.tool_result(tool_call.id, result))
|
|
182
|
+
except AimeeError as e:
|
|
183
|
+
await fire(self.hooks, "on_error", e)
|
|
184
|
+
raise
|
|
185
|
+
|
|
186
|
+
report = RunReport(
|
|
187
|
+
final_text=final_text or (_MAX_TURN_NOTE if truncated else ""),
|
|
188
|
+
turns=turns,
|
|
189
|
+
tool_calls=tool_call_count,
|
|
190
|
+
usage=usage,
|
|
191
|
+
truncated=truncated,
|
|
192
|
+
messages=messages,
|
|
193
|
+
)
|
|
194
|
+
await fire(self.hooks, "on_done", report)
|
|
195
|
+
return report
|
|
196
|
+
|
|
197
|
+
async def _request_turn(self, messages: list[Message], tools: dict[str, Tool]) -> ChatResponse:
|
|
198
|
+
"""One model request; streams deltas to on_delta hooks when enabled."""
|
|
199
|
+
tool_list = list(tools.values()) if tools else None
|
|
200
|
+
if not self.stream:
|
|
201
|
+
return await self._client.chat(messages, tools=tool_list)
|
|
202
|
+
|
|
203
|
+
content_parts: list[str] = []
|
|
204
|
+
slots: dict[int, dict[str, str]] = {}
|
|
205
|
+
finish_reason: str | None = None
|
|
206
|
+
usage: TokenUsage | None = None
|
|
207
|
+
async for delta in self._client.stream_chat(messages, tools=tool_list):
|
|
208
|
+
if delta.content:
|
|
209
|
+
content_parts.append(delta.content)
|
|
210
|
+
await fire(self.hooks, "on_delta", delta.content)
|
|
211
|
+
for tc in delta.tool_calls:
|
|
212
|
+
slot = slots.setdefault(tc.index, {"id": "", "name": "", "args": ""})
|
|
213
|
+
if tc.id:
|
|
214
|
+
slot["id"] = tc.id
|
|
215
|
+
if tc.name:
|
|
216
|
+
slot["name"] = tc.name
|
|
217
|
+
slot["args"] += tc.arguments
|
|
218
|
+
if delta.finish_reason:
|
|
219
|
+
finish_reason = delta.finish_reason
|
|
220
|
+
if delta.usage:
|
|
221
|
+
usage = usage or TokenUsage()
|
|
222
|
+
usage.add(delta.usage)
|
|
223
|
+
|
|
224
|
+
tool_calls = [_assemble_tool_call(idx, slot) for idx, slot in sorted(slots.items())]
|
|
225
|
+
return ChatResponse(
|
|
226
|
+
content="".join(content_parts),
|
|
227
|
+
tool_calls=tool_calls,
|
|
228
|
+
usage=usage,
|
|
229
|
+
finish_reason=finish_reason,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async def _dispatch_tool(
|
|
233
|
+
self, tool_call: ToolCall, tools: dict[str, Tool], ctx: ToolContext
|
|
234
|
+
) -> tuple[str, dict[str, Any]]:
|
|
235
|
+
"""Apply the approval hook, execute the tool, and report the outcome."""
|
|
236
|
+
decision = await self._decide(tool_call.name, tool_call.arguments)
|
|
237
|
+
if decision is not None and not decision.allowed:
|
|
238
|
+
return f"Denied by client: {decision.reason or 'no reason given'}", tool_call.arguments
|
|
239
|
+
args = (
|
|
240
|
+
decision.args
|
|
241
|
+
if decision is not None and decision.args is not None
|
|
242
|
+
else tool_call.arguments
|
|
243
|
+
)
|
|
244
|
+
tool = tools.get(tool_call.name)
|
|
245
|
+
if tool is None:
|
|
246
|
+
result = f"Error: unknown tool: {tool_call.name!r}"
|
|
247
|
+
else:
|
|
248
|
+
result = await self._execute_tool(tool, args, ctx)
|
|
249
|
+
return result, args
|
|
250
|
+
|
|
251
|
+
async def _decide(self, name: str, args: dict[str, Any]) -> ToolDecision | None:
|
|
252
|
+
"""First non-None ToolDecision from the hooks wins; None means allow."""
|
|
253
|
+
for hook in self.hooks:
|
|
254
|
+
decision = await call_hook(hook, "on_tool_call", name, args)
|
|
255
|
+
if decision is not None:
|
|
256
|
+
return decision
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
async def _execute_tool(self, tool: Tool, args: dict[str, Any], ctx: ToolContext) -> str:
|
|
260
|
+
"""Run a tool handler; errors become model-visible text + on_error."""
|
|
261
|
+
try:
|
|
262
|
+
result = tool.handler(args, ctx)
|
|
263
|
+
if inspect.isawaitable(result):
|
|
264
|
+
result = await result
|
|
265
|
+
return result if isinstance(result, str) else str(result)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
await fire(self.hooks, "on_error", e)
|
|
268
|
+
return f"Error: {type(e).__name__}: {e}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class AimeeSession:
|
|
272
|
+
"""A multi-turn conversation on one agent; chat history persists across runs.
|
|
273
|
+
|
|
274
|
+
Unlike `Aimee.run()` (stateless), each `run()` here appends to a shared
|
|
275
|
+
history, so the model sees the whole prior conversation::
|
|
276
|
+
|
|
277
|
+
session = agent.session()
|
|
278
|
+
session.run("hello")
|
|
279
|
+
session.run("and now...") # the model remembers the first turn
|
|
280
|
+
|
|
281
|
+
A session is bound to one agent (sharing its tools/hooks/config), but an
|
|
282
|
+
agent can hold many independent sessions at once.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self, agent: Aimee) -> None:
|
|
286
|
+
self._agent = agent
|
|
287
|
+
self._history: list[Message] = []
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def history(self) -> list[Message]:
|
|
291
|
+
"""A snapshot of the conversation so far (system message first)."""
|
|
292
|
+
return list(self._history)
|
|
293
|
+
|
|
294
|
+
def run(self, task: str) -> RunReport:
|
|
295
|
+
"""Run one turn of the conversation (sync)."""
|
|
296
|
+
return self._agent._sync_submit(self.run_async(task))
|
|
297
|
+
|
|
298
|
+
async def run_async(self, task: str) -> RunReport:
|
|
299
|
+
"""Run one turn of the conversation (async)."""
|
|
300
|
+
return await self._agent._run_with_history(task, self._history)
|
|
301
|
+
|
|
302
|
+
def clear(self) -> None:
|
|
303
|
+
"""Forget the conversation; a fresh system prompt is built next run."""
|
|
304
|
+
self._history.clear()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _assistant_message(response: ChatResponse) -> Message:
|
|
308
|
+
"""Assistant message in OpenAI wire form (content + raw tool calls)."""
|
|
309
|
+
tool_calls = [
|
|
310
|
+
{
|
|
311
|
+
"id": tc.id,
|
|
312
|
+
"type": "function",
|
|
313
|
+
"function": {
|
|
314
|
+
"name": tc.name,
|
|
315
|
+
"arguments": tc.raw_arguments or json.dumps(tc.arguments),
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
for tc in response.tool_calls
|
|
319
|
+
]
|
|
320
|
+
return Message.assistant(response.content or "", tool_calls)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _assemble_tool_call(index: int, slot: dict[str, str]) -> ToolCall:
|
|
324
|
+
"""Join streamed argument fragments and parse the final JSON object."""
|
|
325
|
+
raw = slot["args"]
|
|
326
|
+
try:
|
|
327
|
+
args = json.loads(raw) if raw.strip() else {}
|
|
328
|
+
except json.JSONDecodeError as e:
|
|
329
|
+
raise AimeeError(f"Model returned invalid JSON tool arguments: {raw!r}") from e
|
|
330
|
+
if not isinstance(args, dict):
|
|
331
|
+
raise AimeeError(f"Model returned non-object tool arguments: {raw!r}")
|
|
332
|
+
return ToolCall(id=slot["id"], name=slot["name"], arguments=args, raw_arguments=raw)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class _LoopThread:
|
|
336
|
+
"""Persistent background event-loop thread for the sync API.
|
|
337
|
+
|
|
338
|
+
All `Aimee.run()` calls share one loop, so the owned HTTP client stays
|
|
339
|
+
on a single event loop (httpx clients are not reusable across loops).
|
|
340
|
+
Works even when the caller has a running event loop (Jupyter, async
|
|
341
|
+
frameworks, Tk). The coroutine's exception, if any, is re-raised.
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
def __init__(self) -> None:
|
|
345
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
346
|
+
self._thread: threading.Thread | None = None
|
|
347
|
+
self._ready = threading.Event()
|
|
348
|
+
|
|
349
|
+
def submit(self, coro: Any) -> Any:
|
|
350
|
+
"""Run a coroutine on the background loop; block until it finishes."""
|
|
351
|
+
if self._loop is None or not self._loop.is_running():
|
|
352
|
+
self._start()
|
|
353
|
+
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
|
354
|
+
|
|
355
|
+
def _start(self) -> None:
|
|
356
|
+
loop = asyncio.new_event_loop()
|
|
357
|
+
self._loop = loop
|
|
358
|
+
self._ready.clear()
|
|
359
|
+
thread = threading.Thread(
|
|
360
|
+
target=self._run_loop, args=(loop,), name="aimee-loop", daemon=True
|
|
361
|
+
)
|
|
362
|
+
self._thread = thread
|
|
363
|
+
thread.start()
|
|
364
|
+
if not self._ready.wait(timeout=10):
|
|
365
|
+
raise AimeeError("Aimee background event loop failed to start")
|
|
366
|
+
|
|
367
|
+
def _run_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
368
|
+
asyncio.set_event_loop(loop)
|
|
369
|
+
loop.call_soon(self._ready.set)
|
|
370
|
+
loop.run_forever()
|
|
371
|
+
|
|
372
|
+
def stop(self) -> None:
|
|
373
|
+
"""Stop the loop and its thread; safe to call more than once."""
|
|
374
|
+
if self._loop is not None and self._loop.is_running():
|
|
375
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
376
|
+
if self._thread is not None:
|
|
377
|
+
self._thread.join(timeout=5)
|
|
378
|
+
if self._loop is not None:
|
|
379
|
+
self._loop.close()
|
|
380
|
+
self._loop = None
|
|
381
|
+
self._thread = None
|