tokenloop 0.1.2__tar.gz → 0.1.3__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.
- {tokenloop-0.1.2 → tokenloop-0.1.3}/.gitignore +2 -1
- tokenloop-0.1.3/PKG-INFO +188 -0
- tokenloop-0.1.3/README.md +173 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/pyproject.toml +2 -1
- tokenloop-0.1.3/tests/test_images.py +90 -0
- tokenloop-0.1.3/tests/test_state.py +58 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/__init__.py +12 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/cli.py +1 -1
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/compact.py +8 -1
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/loop.py +5 -3
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/run.py +3 -1
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/stream.py +5 -3
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/types.py +57 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/provider/anthropic.py +26 -1
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/provider/openai.py +23 -1
- tokenloop-0.1.3/tokenloop/state.py +127 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/uv.lock +1 -1
- tokenloop-0.1.2/.pypi.md +0 -0
- tokenloop-0.1.2/PKG-INFO +0 -50
- tokenloop-0.1.2/README.md +0 -35
- {tokenloop-0.1.2 → tokenloop-0.1.3}/.mcp.json +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/docs/HITL_PARITY.md +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/basic_chat.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/session_state.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/explain-topic/SKILL.md +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/explain-topic/references/rust,md +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/weather/SKILL.md +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/weather/output_schema.json +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/weather/scripts/weather.sh +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/smoke_test.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/streaming.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/tool.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/weather_agent.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tests/test_hitl_resume.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tests/test_hitl_skipped.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tests/test_run_control_service.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/compression/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/compression/base.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/compression/simple.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/compression/window.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/context.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/dispatch.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/events.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/prompt.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/registry.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/retry.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/session.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/shared.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/guardrails/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/guardrails/base.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/hitl/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/hitl/base.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/mcp/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/mcp/client.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/mcp/manager.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/provider/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/provider/base.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/session_service/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/session_service/base.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/session_service/memory.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/skills/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/skills/base.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/skills/manager.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/stream/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/stream/agui.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/stream/sink.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/stream/tracing.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/base.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/__init__.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/bash_tool.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/executor.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/file_store.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/read_tool.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/update_state.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/hooks.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/registry.py +0 -0
- {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/schema.py +0 -0
tokenloop-0.1.3/PKG-INFO
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tokenloop
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Project-URL: Homepage, https://github.com/RahulDas-dev/loop
|
|
6
|
+
Project-URL: Repository, https://github.com/RahulDas-dev/loop
|
|
7
|
+
Project-URL: Releases, https://github.com/RahulDas-dev/loop/releases
|
|
8
|
+
Author-email: RahulDas-dev <r.das699@gmail.com>
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: ag-ui-protocol>=0.1.20
|
|
11
|
+
Requires-Dist: anthropic>=0.125.0
|
|
12
|
+
Requires-Dist: mcp>=2.0.0
|
|
13
|
+
Requires-Dist: openai>=2.48.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# ⚡ tokenloop
|
|
17
|
+
|
|
18
|
+
[](https://pypi.org/project/tokenloop/)
|
|
19
|
+
[](https://pypi.org/project/tokenloop/)
|
|
20
|
+
|
|
21
|
+
A protocol-driven, model-agnostic agent loop for Python. `AgentLoop` runs
|
|
22
|
+
the prompt → model → tool-call → model loop for you — streaming, retries,
|
|
23
|
+
human-in-the-loop pauses, history compaction, and session persistence
|
|
24
|
+
included — while every swappable piece (provider, tool, guardrail, approver,
|
|
25
|
+
compressor, session store) is a plain `typing.Protocol`, not a base class
|
|
26
|
+
you're forced to inherit from.
|
|
27
|
+
|
|
28
|
+
`tokenloop` is also an active, ongoing **parity port of a companion Rust
|
|
29
|
+
crate** — much of the module layout is deliberately file-for-file with its
|
|
30
|
+
Rust counterpart (`core/loop.py` ↔ `agent/agent.rs`, `core/stream.py` ↔
|
|
31
|
+
`agent/stream.rs`, `core/registry.py` ↔ `run/registry.rs`, and so on), which
|
|
32
|
+
is why you'll find "Python counterpart of ..." in a lot of the source
|
|
33
|
+
docstrings. A few Rust features are deliberately **not** ported yet — see
|
|
34
|
+
[Deliberately not ported](#deliberately-not-ported-yet) below.
|
|
35
|
+
|
|
36
|
+
## Philosophy
|
|
37
|
+
|
|
38
|
+
- **Protocol-driven, not inheritance-driven.** `LLMProvider`, `Tool`,
|
|
39
|
+
`Guardrail`, `Approver`, `Compressor`, `SessionService`, `ToolHook` — every
|
|
40
|
+
one of these is a structural `Protocol`. Anything with the right shape
|
|
41
|
+
works; you never subclass a framework base class to plug something in.
|
|
42
|
+
- **`AgentLoop` is stateless; `Session` isn't.** Build one `AgentLoop`, reuse
|
|
43
|
+
it for every conversation and every concurrent run. All per-conversation
|
|
44
|
+
state — history, app/user/tmp state, past runs — lives in a `Session`,
|
|
45
|
+
loaded and saved through a `SessionService` keyed by `session_id`. Nothing
|
|
46
|
+
conversational is ever held on the `AgentLoop` object itself.
|
|
47
|
+
- **Two different lifetimes, two different objects.** `RunContext` is the
|
|
48
|
+
*live* object threaded through hooks and tools during one `run()`/
|
|
49
|
+
`astream()` call — it's never persisted. `RunRecord` is what actually gets
|
|
50
|
+
written to `Session.runs` once a run finishes: an immutable summary, not a
|
|
51
|
+
working object.
|
|
52
|
+
- **Every event tells you who and when.** All 20+ event types share one
|
|
53
|
+
`BaseEvent` (`run_id`, `session_id`, `agent_id`, `created_at`) — full run
|
|
54
|
+
lifecycle (`RunStarted`/`RunPaused`/`RunError`/`RunCompleted`), per-turn
|
|
55
|
+
model events, and per-tool-call events, all correlatable.
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
- **Core loop** — `AgentLoop` runs a real, working turn loop
|
|
60
|
+
(`astream()`; `run()` just drains it and returns the final message):
|
|
61
|
+
automatic retry with exponential backoff on 408/409/429/5xx, best-effort
|
|
62
|
+
history compaction, a `RunControlService` for pausing/resuming/cancelling
|
|
63
|
+
a run by `run_id`, and a working interactive CLI (`agent.as_cli()`) for
|
|
64
|
+
local dev/testing.
|
|
65
|
+
- **Providers** — `LLMProvider` is a `Protocol`; ships with working
|
|
66
|
+
`AnthropicProvider` and `OpenAIProvider` implementations, both lazily
|
|
67
|
+
imported so you only pay for the SDK you actually use.
|
|
68
|
+
- **Tools** — `FunctionTool` builds a JSON Schema straight from a plain
|
|
69
|
+
Python function's signature (stdlib `inspect`/`typing` reflection, no
|
|
70
|
+
Pydantic). Tool calls in one turn dispatch concurrently. Ships with four
|
|
71
|
+
builtin tools: `update_state`, `BashTool`, `Read`, and `FileStore`.
|
|
72
|
+
- **Human-in-the-Loop** — a tool can pause a run for `Confirmation`,
|
|
73
|
+
`InputRequired`, or `ExternalExecution`; an `Approver` can `ALLOW`/`DENY`/
|
|
74
|
+
`AUDIT` any tool call; `RunMode.INTERACTIVE` vs `NON_INTERACTIVE` controls
|
|
75
|
+
whether pausing is even allowed; full resume support built in.
|
|
76
|
+
- **Image input** — attach `ImageBase64`/`ImageUrl` sources to a user turn
|
|
77
|
+
(`image_from_file()` reads and encodes a local file); both providers
|
|
78
|
+
serialize them into their own content-block format, and context
|
|
79
|
+
compaction accounts for their token cost.
|
|
80
|
+
- **Guardrails** — `pre_hooks` validate the user's message *before* the
|
|
81
|
+
model is ever called; `post_hooks` validate the assistant's response.
|
|
82
|
+
Both also get a fire-and-forget `on_completion` for logging/eval that
|
|
83
|
+
never adds latency to the run.
|
|
84
|
+
- **Compression** — `SimpleCompressor` trims oldest messages under a token
|
|
85
|
+
budget (tool-call/result pairing preserved); `HistoryWindow` separately
|
|
86
|
+
caps the *persisted* session history by message or run count.
|
|
87
|
+
- **Skills** — disk-based, compatible with Anthropic's Agent Skills layout
|
|
88
|
+
(a `SKILL.md` per skill); every discovered skill resolves into a single
|
|
89
|
+
`invoke_skill` meta-tool rather than one tool per skill.
|
|
90
|
+
- **MCP** — a client-side bridge (`MCPToolProvider`/`MCPManager`) adapts any
|
|
91
|
+
MCP server's tools onto the same `Tool` protocol as everything else.
|
|
92
|
+
- **Sessions** — `SessionService` protocol, with an `InMemorySessionService`
|
|
93
|
+
included (single-process — bring your own for multi-replica deployments).
|
|
94
|
+
- **State** — a standalone `State` utility with delta tracking and an RFC
|
|
95
|
+
6902 JSON-Patch `diff()`, for building your own state-sync pipeline
|
|
96
|
+
(not wired into `RunContext`/the AG-UI bridge, which stay plain dicts and
|
|
97
|
+
full-snapshot-only today).
|
|
98
|
+
- **Streaming & observability** — every run emits a typed `LoopEvent`
|
|
99
|
+
stream; an `AGUIBridge` translates it into AG-UI protocol events for
|
|
100
|
+
frontend consumption, and a zero-dependency `SpanCollector` turns it into
|
|
101
|
+
spans for whatever tracing backend you want to export to.
|
|
102
|
+
|
|
103
|
+
## Installation
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install tokenloop
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`ag-ui-protocol`, `anthropic`, `mcp`, and `openai` are currently all
|
|
110
|
+
unconditional dependencies — one install gets you both providers and the
|
|
111
|
+
AG-UI/MCP protocol surface. (No optional extras exist yet.)
|
|
112
|
+
|
|
113
|
+
## Quickstart
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import asyncio
|
|
117
|
+
import os
|
|
118
|
+
|
|
119
|
+
from tokenloop import AgentLoop
|
|
120
|
+
from tokenloop.provider.anthropic import AnthropicProvider
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def main() -> None:
|
|
124
|
+
agent = AgentLoop(
|
|
125
|
+
AnthropicProvider(model=os.environ["ANTHROPIC_MODEL"]),
|
|
126
|
+
instruction="You are a concise, helpful assistant.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Interactive REPL, good for trying things out:
|
|
130
|
+
await agent.as_cli(None)
|
|
131
|
+
|
|
132
|
+
# Or drive it programmatically:
|
|
133
|
+
# message = await agent.run("session-1", "run-1", "What's 2 + 2?")
|
|
134
|
+
# print(message.content)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
asyncio.run(main())
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Adding a tool is just a plain Python function:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
def get_weather(city: str) -> str:
|
|
145
|
+
"""Get the current weather for a city."""
|
|
146
|
+
...
|
|
147
|
+
|
|
148
|
+
agent.add_tool(get_weather)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
See `examples/` for more — tool calling, session state, streaming events,
|
|
152
|
+
and a skills-based agent.
|
|
153
|
+
|
|
154
|
+
## Testing
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
uv run pytest -v # 35 passed, 0 failed, 0 skipped
|
|
158
|
+
uv run ruff check # all checks pass
|
|
159
|
+
uv run python examples/smoke_test.py # offline, no API key needed
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The test suite covers HITL resume (all three pause kinds), a HITL-skipped
|
|
163
|
+
tool not aborting the rest of its batch, `RunControlService`
|
|
164
|
+
(pause/resume/cancel), image serialization for both providers, and
|
|
165
|
+
`State`'s delta-tracking/diff behavior. `examples/smoke_test.py` exercises
|
|
166
|
+
the tool-call round trip and a blocking guardrail end-to-end against a fake
|
|
167
|
+
provider — no network or API key required.
|
|
168
|
+
|
|
169
|
+
## Documentation
|
|
170
|
+
|
|
171
|
+
There's no separate docs site yet — this README is it, plus:
|
|
172
|
+
|
|
173
|
+
- [`docs/HITL_PARITY.md`](docs/HITL_PARITY.md) — a deep dive into the
|
|
174
|
+
HITL/`RunControlService` design and its parity with the Rust crate.
|
|
175
|
+
- [Repository](https://github.com/RahulDas-dev/loop)
|
|
176
|
+
- [Releases](https://github.com/RahulDas-dev/loop/releases)
|
|
177
|
+
|
|
178
|
+
## Deliberately not ported (yet)
|
|
179
|
+
|
|
180
|
+
A few things the companion Rust crate has that this port doesn't, by
|
|
181
|
+
explicit scope decision (see the relevant source docstrings):
|
|
182
|
+
|
|
183
|
+
- LLM-summarizing compaction — today's `SimpleCompressor` only trims old
|
|
184
|
+
messages, it doesn't ask the model to summarize them.
|
|
185
|
+
- Hook-triggered HITL pauses — only a tool's own execution body can raise a
|
|
186
|
+
pause today; a `ToolHook.before_call` cannot.
|
|
187
|
+
- Inline skill-script execution sharing the Bash engine.
|
|
188
|
+
- Rust's `{key}`-style prompt templating.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# ⚡ tokenloop
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/tokenloop/)
|
|
4
|
+
[](https://pypi.org/project/tokenloop/)
|
|
5
|
+
|
|
6
|
+
A protocol-driven, model-agnostic agent loop for Python. `AgentLoop` runs
|
|
7
|
+
the prompt → model → tool-call → model loop for you — streaming, retries,
|
|
8
|
+
human-in-the-loop pauses, history compaction, and session persistence
|
|
9
|
+
included — while every swappable piece (provider, tool, guardrail, approver,
|
|
10
|
+
compressor, session store) is a plain `typing.Protocol`, not a base class
|
|
11
|
+
you're forced to inherit from.
|
|
12
|
+
|
|
13
|
+
`tokenloop` is also an active, ongoing **parity port of a companion Rust
|
|
14
|
+
crate** — much of the module layout is deliberately file-for-file with its
|
|
15
|
+
Rust counterpart (`core/loop.py` ↔ `agent/agent.rs`, `core/stream.py` ↔
|
|
16
|
+
`agent/stream.rs`, `core/registry.py` ↔ `run/registry.rs`, and so on), which
|
|
17
|
+
is why you'll find "Python counterpart of ..." in a lot of the source
|
|
18
|
+
docstrings. A few Rust features are deliberately **not** ported yet — see
|
|
19
|
+
[Deliberately not ported](#deliberately-not-ported-yet) below.
|
|
20
|
+
|
|
21
|
+
## Philosophy
|
|
22
|
+
|
|
23
|
+
- **Protocol-driven, not inheritance-driven.** `LLMProvider`, `Tool`,
|
|
24
|
+
`Guardrail`, `Approver`, `Compressor`, `SessionService`, `ToolHook` — every
|
|
25
|
+
one of these is a structural `Protocol`. Anything with the right shape
|
|
26
|
+
works; you never subclass a framework base class to plug something in.
|
|
27
|
+
- **`AgentLoop` is stateless; `Session` isn't.** Build one `AgentLoop`, reuse
|
|
28
|
+
it for every conversation and every concurrent run. All per-conversation
|
|
29
|
+
state — history, app/user/tmp state, past runs — lives in a `Session`,
|
|
30
|
+
loaded and saved through a `SessionService` keyed by `session_id`. Nothing
|
|
31
|
+
conversational is ever held on the `AgentLoop` object itself.
|
|
32
|
+
- **Two different lifetimes, two different objects.** `RunContext` is the
|
|
33
|
+
*live* object threaded through hooks and tools during one `run()`/
|
|
34
|
+
`astream()` call — it's never persisted. `RunRecord` is what actually gets
|
|
35
|
+
written to `Session.runs` once a run finishes: an immutable summary, not a
|
|
36
|
+
working object.
|
|
37
|
+
- **Every event tells you who and when.** All 20+ event types share one
|
|
38
|
+
`BaseEvent` (`run_id`, `session_id`, `agent_id`, `created_at`) — full run
|
|
39
|
+
lifecycle (`RunStarted`/`RunPaused`/`RunError`/`RunCompleted`), per-turn
|
|
40
|
+
model events, and per-tool-call events, all correlatable.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **Core loop** — `AgentLoop` runs a real, working turn loop
|
|
45
|
+
(`astream()`; `run()` just drains it and returns the final message):
|
|
46
|
+
automatic retry with exponential backoff on 408/409/429/5xx, best-effort
|
|
47
|
+
history compaction, a `RunControlService` for pausing/resuming/cancelling
|
|
48
|
+
a run by `run_id`, and a working interactive CLI (`agent.as_cli()`) for
|
|
49
|
+
local dev/testing.
|
|
50
|
+
- **Providers** — `LLMProvider` is a `Protocol`; ships with working
|
|
51
|
+
`AnthropicProvider` and `OpenAIProvider` implementations, both lazily
|
|
52
|
+
imported so you only pay for the SDK you actually use.
|
|
53
|
+
- **Tools** — `FunctionTool` builds a JSON Schema straight from a plain
|
|
54
|
+
Python function's signature (stdlib `inspect`/`typing` reflection, no
|
|
55
|
+
Pydantic). Tool calls in one turn dispatch concurrently. Ships with four
|
|
56
|
+
builtin tools: `update_state`, `BashTool`, `Read`, and `FileStore`.
|
|
57
|
+
- **Human-in-the-Loop** — a tool can pause a run for `Confirmation`,
|
|
58
|
+
`InputRequired`, or `ExternalExecution`; an `Approver` can `ALLOW`/`DENY`/
|
|
59
|
+
`AUDIT` any tool call; `RunMode.INTERACTIVE` vs `NON_INTERACTIVE` controls
|
|
60
|
+
whether pausing is even allowed; full resume support built in.
|
|
61
|
+
- **Image input** — attach `ImageBase64`/`ImageUrl` sources to a user turn
|
|
62
|
+
(`image_from_file()` reads and encodes a local file); both providers
|
|
63
|
+
serialize them into their own content-block format, and context
|
|
64
|
+
compaction accounts for their token cost.
|
|
65
|
+
- **Guardrails** — `pre_hooks` validate the user's message *before* the
|
|
66
|
+
model is ever called; `post_hooks` validate the assistant's response.
|
|
67
|
+
Both also get a fire-and-forget `on_completion` for logging/eval that
|
|
68
|
+
never adds latency to the run.
|
|
69
|
+
- **Compression** — `SimpleCompressor` trims oldest messages under a token
|
|
70
|
+
budget (tool-call/result pairing preserved); `HistoryWindow` separately
|
|
71
|
+
caps the *persisted* session history by message or run count.
|
|
72
|
+
- **Skills** — disk-based, compatible with Anthropic's Agent Skills layout
|
|
73
|
+
(a `SKILL.md` per skill); every discovered skill resolves into a single
|
|
74
|
+
`invoke_skill` meta-tool rather than one tool per skill.
|
|
75
|
+
- **MCP** — a client-side bridge (`MCPToolProvider`/`MCPManager`) adapts any
|
|
76
|
+
MCP server's tools onto the same `Tool` protocol as everything else.
|
|
77
|
+
- **Sessions** — `SessionService` protocol, with an `InMemorySessionService`
|
|
78
|
+
included (single-process — bring your own for multi-replica deployments).
|
|
79
|
+
- **State** — a standalone `State` utility with delta tracking and an RFC
|
|
80
|
+
6902 JSON-Patch `diff()`, for building your own state-sync pipeline
|
|
81
|
+
(not wired into `RunContext`/the AG-UI bridge, which stay plain dicts and
|
|
82
|
+
full-snapshot-only today).
|
|
83
|
+
- **Streaming & observability** — every run emits a typed `LoopEvent`
|
|
84
|
+
stream; an `AGUIBridge` translates it into AG-UI protocol events for
|
|
85
|
+
frontend consumption, and a zero-dependency `SpanCollector` turns it into
|
|
86
|
+
spans for whatever tracing backend you want to export to.
|
|
87
|
+
|
|
88
|
+
## Installation
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install tokenloop
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`ag-ui-protocol`, `anthropic`, `mcp`, and `openai` are currently all
|
|
95
|
+
unconditional dependencies — one install gets you both providers and the
|
|
96
|
+
AG-UI/MCP protocol surface. (No optional extras exist yet.)
|
|
97
|
+
|
|
98
|
+
## Quickstart
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import asyncio
|
|
102
|
+
import os
|
|
103
|
+
|
|
104
|
+
from tokenloop import AgentLoop
|
|
105
|
+
from tokenloop.provider.anthropic import AnthropicProvider
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def main() -> None:
|
|
109
|
+
agent = AgentLoop(
|
|
110
|
+
AnthropicProvider(model=os.environ["ANTHROPIC_MODEL"]),
|
|
111
|
+
instruction="You are a concise, helpful assistant.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Interactive REPL, good for trying things out:
|
|
115
|
+
await agent.as_cli(None)
|
|
116
|
+
|
|
117
|
+
# Or drive it programmatically:
|
|
118
|
+
# message = await agent.run("session-1", "run-1", "What's 2 + 2?")
|
|
119
|
+
# print(message.content)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
asyncio.run(main())
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Adding a tool is just a plain Python function:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
def get_weather(city: str) -> str:
|
|
130
|
+
"""Get the current weather for a city."""
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
agent.add_tool(get_weather)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
See `examples/` for more — tool calling, session state, streaming events,
|
|
137
|
+
and a skills-based agent.
|
|
138
|
+
|
|
139
|
+
## Testing
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv run pytest -v # 35 passed, 0 failed, 0 skipped
|
|
143
|
+
uv run ruff check # all checks pass
|
|
144
|
+
uv run python examples/smoke_test.py # offline, no API key needed
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The test suite covers HITL resume (all three pause kinds), a HITL-skipped
|
|
148
|
+
tool not aborting the rest of its batch, `RunControlService`
|
|
149
|
+
(pause/resume/cancel), image serialization for both providers, and
|
|
150
|
+
`State`'s delta-tracking/diff behavior. `examples/smoke_test.py` exercises
|
|
151
|
+
the tool-call round trip and a blocking guardrail end-to-end against a fake
|
|
152
|
+
provider — no network or API key required.
|
|
153
|
+
|
|
154
|
+
## Documentation
|
|
155
|
+
|
|
156
|
+
There's no separate docs site yet — this README is it, plus:
|
|
157
|
+
|
|
158
|
+
- [`docs/HITL_PARITY.md`](docs/HITL_PARITY.md) — a deep dive into the
|
|
159
|
+
HITL/`RunControlService` design and its parity with the Rust crate.
|
|
160
|
+
- [Repository](https://github.com/RahulDas-dev/loop)
|
|
161
|
+
- [Releases](https://github.com/RahulDas-dev/loop/releases)
|
|
162
|
+
|
|
163
|
+
## Deliberately not ported (yet)
|
|
164
|
+
|
|
165
|
+
A few things the companion Rust crate has that this port doesn't, by
|
|
166
|
+
explicit scope decision (see the relevant source docstrings):
|
|
167
|
+
|
|
168
|
+
- LLM-summarizing compaction — today's `SimpleCompressor` only trims old
|
|
169
|
+
messages, it doesn't ask the model to summarize them.
|
|
170
|
+
- Hook-triggered HITL pauses — only a tool's own execution body can raise a
|
|
171
|
+
pause today; a `ToolHook.before_call` cannot.
|
|
172
|
+
- Inline skill-script execution sharing the Bash engine.
|
|
173
|
+
- Rust's `{key}`-style prompt templating.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tokenloop"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "Add your description here"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -70,6 +70,7 @@ ignore = [
|
|
|
70
70
|
"PLR0912",
|
|
71
71
|
"PLR0913",
|
|
72
72
|
"PLR0915",
|
|
73
|
+
"PLR0917",
|
|
73
74
|
"TRY003",
|
|
74
75
|
]
|
|
75
76
|
fixable = ["ALL"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for image input support - mirrors the portable subset of the Rust
|
|
2
|
+
crate's coverage (schemas/messages.rs, provider/anthropic_runtime.rs,
|
|
3
|
+
provider/openai_runtime.rs, agent/compact.rs). Bedrock and wire_protocol have
|
|
4
|
+
no Python counterpart (no Bedrock provider exists; Python's providers build
|
|
5
|
+
wire payloads inline rather than through a separate reusable module), so
|
|
6
|
+
those 4 Rust tests have no port target here."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from tokenloop.core.compact import _estimate_tokens
|
|
11
|
+
from tokenloop.core.types import ImageBase64, ImageUrl, MediaType, Message, image_from_file
|
|
12
|
+
from tokenloop.provider.anthropic import _to_anthropic_messages
|
|
13
|
+
from tokenloop.provider.openai import _to_openai_messages
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_image_from_file_sniffs_known_extensions(tmp_path) -> None:
|
|
17
|
+
png_path = tmp_path / "test_image.png"
|
|
18
|
+
png_path.write_bytes(bytes([0, 1, 2, 3]))
|
|
19
|
+
png = image_from_file(png_path)
|
|
20
|
+
assert png.media_type == MediaType.PNG
|
|
21
|
+
|
|
22
|
+
jpg_path = tmp_path / "test_image.jpg"
|
|
23
|
+
jpg_path.write_bytes(bytes([4, 5, 6]))
|
|
24
|
+
jpg = image_from_file(jpg_path)
|
|
25
|
+
assert jpg.media_type == MediaType.JPEG
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_image_from_file_rejects_unknown_extension(tmp_path) -> None:
|
|
29
|
+
path = tmp_path / "test_image.bmp"
|
|
30
|
+
path.write_bytes(bytes([0]))
|
|
31
|
+
try:
|
|
32
|
+
image_from_file(path)
|
|
33
|
+
except ValueError:
|
|
34
|
+
pass
|
|
35
|
+
else:
|
|
36
|
+
raise AssertionError("expected ValueError for an unrecognized extension")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_anthropic_convert_puts_images_before_trailing_text() -> None:
|
|
40
|
+
msg = Message(role="user", content="What's this?", images=[ImageBase64(media_type=MediaType.PNG, data="AAAA")])
|
|
41
|
+
_system, out = _to_anthropic_messages([msg])
|
|
42
|
+
assert len(out) == 1
|
|
43
|
+
content = out[0]["content"]
|
|
44
|
+
assert isinstance(content, list)
|
|
45
|
+
assert len(content) == 2
|
|
46
|
+
assert content[0]["type"] == "image"
|
|
47
|
+
assert content[0]["source"]["type"] == "base64"
|
|
48
|
+
assert content[0]["source"]["media_type"] == "image/png"
|
|
49
|
+
assert content[0]["source"]["data"] == "AAAA"
|
|
50
|
+
assert content[1] == {"type": "text", "text": "What's this?"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_anthropic_convert_stays_a_bare_string_without_images() -> None:
|
|
54
|
+
msg = Message(role="user", content="hello")
|
|
55
|
+
_system, out = _to_anthropic_messages([msg])
|
|
56
|
+
assert out[0]["content"] == "hello"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_openai_convert_builds_content_array_with_data_url() -> None:
|
|
60
|
+
msg = Message(role="user", content="What's this?", images=[ImageBase64(media_type=MediaType.PNG, data="AAAA")])
|
|
61
|
+
out = _to_openai_messages([msg])
|
|
62
|
+
parts = out[0]["content"]
|
|
63
|
+
assert isinstance(parts, list)
|
|
64
|
+
assert len(parts) == 2
|
|
65
|
+
assert parts[0] == {"type": "text", "text": "What's this?"}
|
|
66
|
+
assert parts[1]["type"] == "image_url"
|
|
67
|
+
assert parts[1]["image_url"]["url"] == "data:image/png;base64,AAAA"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_openai_convert_stays_plain_text_without_images() -> None:
|
|
71
|
+
msg = Message(role="user", content="hello")
|
|
72
|
+
out = _to_openai_messages([msg])
|
|
73
|
+
assert out[0]["content"] == "hello"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_openai_convert_handles_url_sourced_images() -> None:
|
|
77
|
+
msg = Message(role="user", content=None, images=[ImageUrl(url="https://example.com/cat.png")])
|
|
78
|
+
out = _to_openai_messages([msg])
|
|
79
|
+
parts = out[0]["content"]
|
|
80
|
+
assert parts == [{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_estimate_tokens_accounts_for_images() -> None:
|
|
84
|
+
plain = [Message(role="user", content="x" * 100)]
|
|
85
|
+
with_one_image = [Message(role="user", content="x" * 100, images=[ImageBase64(MediaType.PNG, "AAAA")])]
|
|
86
|
+
two_images = [ImageBase64(MediaType.PNG, "AAAA"), ImageBase64(MediaType.PNG, "BBBB")]
|
|
87
|
+
with_two_images = [Message(role="user", content="", images=two_images)]
|
|
88
|
+
|
|
89
|
+
assert _estimate_tokens(with_one_image) == _estimate_tokens(plain) + 1500
|
|
90
|
+
assert _estimate_tokens(with_two_images) == 2 * 1500
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Direct translations of the Rust crate's state/types.rs test module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tokenloop.state import State
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_roundtrip() -> None:
|
|
9
|
+
state = State()
|
|
10
|
+
state.set("name", "Alice")
|
|
11
|
+
assert state.get("name") == "Alice"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_delta_tracking() -> None:
|
|
15
|
+
state = State()
|
|
16
|
+
assert not state.has_delta()
|
|
17
|
+
state.set("x", 1)
|
|
18
|
+
assert state.has_delta()
|
|
19
|
+
d = state.commit()
|
|
20
|
+
assert d.get("x") == 1
|
|
21
|
+
assert not state.has_delta()
|
|
22
|
+
assert state.get("x") == 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_remove_records_tombstone() -> None:
|
|
26
|
+
state = State.from_map({"a": 1})
|
|
27
|
+
state.remove("a")
|
|
28
|
+
assert state.get("a") is None
|
|
29
|
+
assert state.delta().get("a") is None
|
|
30
|
+
assert "a" in state.delta()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_from_map_no_initial_delta() -> None:
|
|
34
|
+
state = State.from_map({"x": 1})
|
|
35
|
+
assert not state.has_delta()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_diff_escapes_json_pointer_keys() -> None:
|
|
39
|
+
state = State.from_map({"a/b~c": 1})
|
|
40
|
+
patch = state.diff({})
|
|
41
|
+
assert patch == [{"op": "add", "path": "/a~1b~0c", "value": 1}]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_diff_is_deterministic() -> None:
|
|
45
|
+
state = State.from_map({"z": 1, "a": 2})
|
|
46
|
+
assert state.diff({}) == [
|
|
47
|
+
{"op": "add", "path": "/a", "value": 2},
|
|
48
|
+
{"op": "add", "path": "/z", "value": 1},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_diff_reports_replace_and_remove() -> None:
|
|
53
|
+
state = State.from_map({"a": 1})
|
|
54
|
+
state.set("a", 2)
|
|
55
|
+
assert state.diff({"a": 1, "b": 9}) == [
|
|
56
|
+
{"op": "replace", "path": "/a", "value": 2},
|
|
57
|
+
{"op": "remove", "path": "/b"},
|
|
58
|
+
]
|
|
@@ -49,6 +49,10 @@ from tokenloop.core.registry import RunControlService
|
|
|
49
49
|
from tokenloop.core.types import (
|
|
50
50
|
CompletionResult,
|
|
51
51
|
GenerationParams,
|
|
52
|
+
ImageBase64,
|
|
53
|
+
ImageSource,
|
|
54
|
+
ImageUrl,
|
|
55
|
+
MediaType,
|
|
52
56
|
Message,
|
|
53
57
|
Role,
|
|
54
58
|
ToolCall,
|
|
@@ -56,6 +60,7 @@ from tokenloop.core.types import (
|
|
|
56
60
|
ToolResult,
|
|
57
61
|
ToolSpec,
|
|
58
62
|
Usage,
|
|
63
|
+
image_from_file,
|
|
59
64
|
)
|
|
60
65
|
from tokenloop.guardrails.base import Guardrail, GuardrailViolationError
|
|
61
66
|
from tokenloop.hitl.base import (
|
|
@@ -78,6 +83,7 @@ from tokenloop.session_service.base import RunRecord, Session, SessionService
|
|
|
78
83
|
from tokenloop.session_service.memory import InMemorySessionService
|
|
79
84
|
from tokenloop.skills.base import LocalSkillLoader, Skill, SkillLoader
|
|
80
85
|
from tokenloop.skills.manager import INVOKE_SKILL_TOOL_NAME, SkillManager
|
|
86
|
+
from tokenloop.state import State
|
|
81
87
|
from tokenloop.tool.base import FunctionTool, Tool
|
|
82
88
|
from tokenloop.tool.builtins.bash_tool import BASH_TOOL_NAME
|
|
83
89
|
from tokenloop.tool.builtins.read_tool import READ_TOOL_NAME
|
|
@@ -116,6 +122,9 @@ __all__ = [
|
|
|
116
122
|
"HitlResume",
|
|
117
123
|
"HitlSignal",
|
|
118
124
|
"HitlSkipped",
|
|
125
|
+
"ImageBase64",
|
|
126
|
+
"ImageSource",
|
|
127
|
+
"ImageUrl",
|
|
119
128
|
"InMemorySessionService",
|
|
120
129
|
"InputField",
|
|
121
130
|
"InputRequired",
|
|
@@ -125,6 +134,7 @@ __all__ = [
|
|
|
125
134
|
"MCPManager",
|
|
126
135
|
"MCPTool",
|
|
127
136
|
"MCPToolProvider",
|
|
137
|
+
"MediaType",
|
|
128
138
|
"Message",
|
|
129
139
|
"MessageEnd",
|
|
130
140
|
"MessageStart",
|
|
@@ -153,6 +163,7 @@ __all__ = [
|
|
|
153
163
|
"SkillInvoked",
|
|
154
164
|
"SkillLoader",
|
|
155
165
|
"SkillManager",
|
|
166
|
+
"State",
|
|
156
167
|
"TextDelta",
|
|
157
168
|
"Tool",
|
|
158
169
|
"ToolCall",
|
|
@@ -166,4 +177,5 @@ __all__ = [
|
|
|
166
177
|
"ToolResult",
|
|
167
178
|
"ToolSpec",
|
|
168
179
|
"Usage",
|
|
180
|
+
"image_from_file",
|
|
169
181
|
]
|
|
@@ -185,7 +185,7 @@ async def _run_one_stream(
|
|
|
185
185
|
pending_resume: HitlResume | None = None
|
|
186
186
|
|
|
187
187
|
try:
|
|
188
|
-
async for event in agent.astream(session_id, run_id, user_msg, opts):
|
|
188
|
+
async for event in agent.astream(session_id, run_id, user_msg, run_options=opts):
|
|
189
189
|
if isinstance(event, TextDelta):
|
|
190
190
|
if not text_open:
|
|
191
191
|
print(f"{_ts()} {_C_TEXT}Assistant:{_RST} ", end="", flush=True)
|
|
@@ -16,10 +16,17 @@ if TYPE_CHECKING:
|
|
|
16
16
|
|
|
17
17
|
_CHARS_PER_TOKEN = 4 # crude estimate, matches compression/simple.py's own heuristic
|
|
18
18
|
_MAX_COMPACTION_FAILURES = 3
|
|
19
|
+
# A crude, deliberately conservative flat estimate - real per-image cost
|
|
20
|
+
# depends on resolution and provider, but ignoring images entirely would
|
|
21
|
+
# make autocompaction fire too late once they start consuming real context
|
|
22
|
+
# budget. Matches the Rust crate's agent/compact.rs::ESTIMATED_TOKENS_PER_IMAGE.
|
|
23
|
+
_ESTIMATED_TOKENS_PER_IMAGE = 1500
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
def _estimate_tokens(messages: list[Message]) -> int:
|
|
22
|
-
|
|
27
|
+
text_tokens = sum(len(m.content or "") for m in messages) // _CHARS_PER_TOKEN
|
|
28
|
+
image_tokens = sum(len(m.images) for m in messages) * _ESTIMATED_TOKENS_PER_IMAGE
|
|
29
|
+
return text_tokens + image_tokens
|
|
23
30
|
|
|
24
31
|
|
|
25
32
|
async def maybe_compact(
|
|
@@ -34,7 +34,7 @@ if TYPE_CHECKING:
|
|
|
34
34
|
from tokenloop.core.context import RunContext, RunOptions
|
|
35
35
|
from tokenloop.core.events import LoopEvent
|
|
36
36
|
from tokenloop.core.registry import RunControlService
|
|
37
|
-
from tokenloop.core.types import Message
|
|
37
|
+
from tokenloop.core.types import ImageSource, Message
|
|
38
38
|
from tokenloop.guardrails.base import Guardrail
|
|
39
39
|
from tokenloop.hitl.base import Approver
|
|
40
40
|
from tokenloop.provider.base import LLMProvider
|
|
@@ -140,22 +140,24 @@ class AgentLoop:
|
|
|
140
140
|
session_id: str,
|
|
141
141
|
run_id: str,
|
|
142
142
|
user_msg: str,
|
|
143
|
+
images: list[ImageSource] | None = None,
|
|
143
144
|
run_options: RunOptions | None = None,
|
|
144
145
|
*,
|
|
145
146
|
user_id: str | None = None,
|
|
146
147
|
) -> Message:
|
|
147
|
-
return await core_run.run(self, session_id, run_id, user_msg, run_options, user_id=user_id)
|
|
148
|
+
return await core_run.run(self, session_id, run_id, user_msg, images, run_options, user_id=user_id)
|
|
148
149
|
|
|
149
150
|
def astream(
|
|
150
151
|
self,
|
|
151
152
|
session_id: str,
|
|
152
153
|
run_id: str,
|
|
153
154
|
user_msg: str,
|
|
155
|
+
images: list[ImageSource] | None = None,
|
|
154
156
|
run_options: RunOptions | None = None,
|
|
155
157
|
*,
|
|
156
158
|
user_id: str | None = None,
|
|
157
159
|
) -> AsyncIterator[LoopEvent]:
|
|
158
|
-
return core_stream.astream(self, session_id, run_id, user_msg, run_options, user_id=user_id)
|
|
160
|
+
return core_stream.astream(self, session_id, run_id, user_msg, images, run_options, user_id=user_id)
|
|
159
161
|
|
|
160
162
|
async def as_cli(self, session_id: str | None = None) -> None:
|
|
161
163
|
await core_cli.as_cli(self, session_id)
|
|
@@ -17,6 +17,7 @@ from tokenloop.core.types import Message
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
18
|
from tokenloop.core.context import RunOptions
|
|
19
19
|
from tokenloop.core.loop import AgentLoop
|
|
20
|
+
from tokenloop.core.types import ImageSource
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
async def run(
|
|
@@ -24,11 +25,12 @@ async def run(
|
|
|
24
25
|
session_id: str,
|
|
25
26
|
run_id: str,
|
|
26
27
|
user_msg: str,
|
|
28
|
+
images: list[ImageSource] | None = None,
|
|
27
29
|
run_options: RunOptions | None = None,
|
|
28
30
|
*,
|
|
29
31
|
user_id: str | None = None,
|
|
30
32
|
) -> Message:
|
|
31
|
-
async for event in astream(agent, session_id, run_id, user_msg, run_options, user_id=user_id):
|
|
33
|
+
async for event in astream(agent, session_id, run_id, user_msg, images, run_options, user_id=user_id):
|
|
32
34
|
if isinstance(event, RunCompleted):
|
|
33
35
|
# RunResult.tool_calls accumulates every call made across the
|
|
34
36
|
# whole run (for callers inspecting the run's history) - the
|