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.
Files changed (79) hide show
  1. {tokenloop-0.1.2 → tokenloop-0.1.3}/.gitignore +2 -1
  2. tokenloop-0.1.3/PKG-INFO +188 -0
  3. tokenloop-0.1.3/README.md +173 -0
  4. {tokenloop-0.1.2 → tokenloop-0.1.3}/pyproject.toml +2 -1
  5. tokenloop-0.1.3/tests/test_images.py +90 -0
  6. tokenloop-0.1.3/tests/test_state.py +58 -0
  7. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/__init__.py +12 -0
  8. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/cli.py +1 -1
  9. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/compact.py +8 -1
  10. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/loop.py +5 -3
  11. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/run.py +3 -1
  12. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/stream.py +5 -3
  13. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/types.py +57 -0
  14. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/provider/anthropic.py +26 -1
  15. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/provider/openai.py +23 -1
  16. tokenloop-0.1.3/tokenloop/state.py +127 -0
  17. {tokenloop-0.1.2 → tokenloop-0.1.3}/uv.lock +1 -1
  18. tokenloop-0.1.2/.pypi.md +0 -0
  19. tokenloop-0.1.2/PKG-INFO +0 -50
  20. tokenloop-0.1.2/README.md +0 -35
  21. {tokenloop-0.1.2 → tokenloop-0.1.3}/.mcp.json +0 -0
  22. {tokenloop-0.1.2 → tokenloop-0.1.3}/docs/HITL_PARITY.md +0 -0
  23. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/basic_chat.py +0 -0
  24. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/session_state.py +0 -0
  25. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/explain-topic/SKILL.md +0 -0
  26. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/explain-topic/references/rust,md +0 -0
  27. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/weather/SKILL.md +0 -0
  28. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/weather/output_schema.json +0 -0
  29. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/skills/weather/scripts/weather.sh +0 -0
  30. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/smoke_test.py +0 -0
  31. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/streaming.py +0 -0
  32. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/tool.py +0 -0
  33. {tokenloop-0.1.2 → tokenloop-0.1.3}/examples/weather_agent.py +0 -0
  34. {tokenloop-0.1.2 → tokenloop-0.1.3}/tests/test_hitl_resume.py +0 -0
  35. {tokenloop-0.1.2 → tokenloop-0.1.3}/tests/test_hitl_skipped.py +0 -0
  36. {tokenloop-0.1.2 → tokenloop-0.1.3}/tests/test_run_control_service.py +0 -0
  37. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/compression/__init__.py +0 -0
  38. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/compression/base.py +0 -0
  39. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/compression/simple.py +0 -0
  40. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/compression/window.py +0 -0
  41. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/__init__.py +0 -0
  42. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/context.py +0 -0
  43. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/dispatch.py +0 -0
  44. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/events.py +0 -0
  45. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/prompt.py +0 -0
  46. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/registry.py +0 -0
  47. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/retry.py +0 -0
  48. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/session.py +0 -0
  49. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/core/shared.py +0 -0
  50. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/guardrails/__init__.py +0 -0
  51. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/guardrails/base.py +0 -0
  52. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/hitl/__init__.py +0 -0
  53. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/hitl/base.py +0 -0
  54. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/mcp/__init__.py +0 -0
  55. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/mcp/client.py +0 -0
  56. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/mcp/manager.py +0 -0
  57. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/provider/__init__.py +0 -0
  58. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/provider/base.py +0 -0
  59. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/session_service/__init__.py +0 -0
  60. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/session_service/base.py +0 -0
  61. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/session_service/memory.py +0 -0
  62. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/skills/__init__.py +0 -0
  63. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/skills/base.py +0 -0
  64. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/skills/manager.py +0 -0
  65. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/stream/__init__.py +0 -0
  66. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/stream/agui.py +0 -0
  67. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/stream/sink.py +0 -0
  68. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/stream/tracing.py +0 -0
  69. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/__init__.py +0 -0
  70. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/base.py +0 -0
  71. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/__init__.py +0 -0
  72. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/bash_tool.py +0 -0
  73. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/executor.py +0 -0
  74. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/file_store.py +0 -0
  75. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/read_tool.py +0 -0
  76. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/builtins/update_state.py +0 -0
  77. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/hooks.py +0 -0
  78. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/registry.py +0 -0
  79. {tokenloop-0.1.2 → tokenloop-0.1.3}/tokenloop/tool/schema.py +0 -0
@@ -13,4 +13,5 @@ wheels/
13
13
  .env
14
14
  .claude/
15
15
  .ruff_cache/
16
- .pytest_cache/
16
+ .pytest_cache/
17
+ .pypi.md
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/tokenloop)](https://pypi.org/project/tokenloop/)
19
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](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
+ [![PyPI](https://img.shields.io/pypi/v/tokenloop)](https://pypi.org/project/tokenloop/)
4
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](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.2"
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
- return sum(len(m.content or "") for m in messages) // _CHARS_PER_TOKEN
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