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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
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