tokenloop 0.1.0__tar.gz → 0.1.2__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 (78) hide show
  1. {tokenloop-0.1.0 → tokenloop-0.1.2}/.gitignore +6 -0
  2. tokenloop-0.1.2/.mcp.json +8 -0
  3. tokenloop-0.1.2/PKG-INFO +50 -0
  4. tokenloop-0.1.2/README.md +35 -0
  5. tokenloop-0.1.2/docs/HITL_PARITY.md +303 -0
  6. tokenloop-0.1.2/examples/basic_chat.py +36 -0
  7. tokenloop-0.1.2/examples/session_state.py +68 -0
  8. tokenloop-0.1.2/examples/skills/explain-topic/SKILL.md +108 -0
  9. tokenloop-0.1.2/examples/skills/explain-topic/references/rust,md +274 -0
  10. tokenloop-0.1.2/examples/skills/weather/SKILL.md +139 -0
  11. tokenloop-0.1.2/examples/skills/weather/output_schema.json +61 -0
  12. tokenloop-0.1.2/examples/skills/weather/scripts/weather.sh +245 -0
  13. tokenloop-0.1.2/examples/smoke_test.py +97 -0
  14. tokenloop-0.1.2/examples/streaming.py +49 -0
  15. tokenloop-0.1.2/examples/tool.py +68 -0
  16. tokenloop-0.1.2/examples/weather_agent.py +51 -0
  17. tokenloop-0.1.2/pyproject.toml +90 -0
  18. tokenloop-0.1.2/tests/test_hitl_resume.py +103 -0
  19. tokenloop-0.1.2/tests/test_hitl_skipped.py +62 -0
  20. tokenloop-0.1.2/tests/test_run_control_service.py +72 -0
  21. tokenloop-0.1.2/tokenloop/__init__.py +169 -0
  22. tokenloop-0.1.2/tokenloop/compression/base.py +17 -0
  23. tokenloop-0.1.2/tokenloop/compression/simple.py +49 -0
  24. tokenloop-0.1.2/tokenloop/compression/window.py +65 -0
  25. tokenloop-0.1.2/tokenloop/core/__init__.py +0 -0
  26. tokenloop-0.1.2/tokenloop/core/cli.py +335 -0
  27. tokenloop-0.1.2/tokenloop/core/compact.py +83 -0
  28. tokenloop-0.1.2/tokenloop/core/context.py +94 -0
  29. tokenloop-0.1.2/tokenloop/core/dispatch.py +305 -0
  30. tokenloop-0.1.2/tokenloop/core/events.py +211 -0
  31. tokenloop-0.1.2/tokenloop/core/loop.py +161 -0
  32. tokenloop-0.1.2/tokenloop/core/prompt.py +76 -0
  33. tokenloop-0.1.2/tokenloop/core/registry.py +104 -0
  34. tokenloop-0.1.2/tokenloop/core/retry.py +75 -0
  35. tokenloop-0.1.2/tokenloop/core/run.py +45 -0
  36. tokenloop-0.1.2/tokenloop/core/session.py +27 -0
  37. tokenloop-0.1.2/tokenloop/core/shared.py +133 -0
  38. tokenloop-0.1.2/tokenloop/core/stream.py +347 -0
  39. tokenloop-0.1.2/tokenloop/core/types.py +77 -0
  40. tokenloop-0.1.2/tokenloop/guardrails/__init__.py +0 -0
  41. tokenloop-0.1.2/tokenloop/guardrails/base.py +24 -0
  42. tokenloop-0.1.2/tokenloop/hitl/__init__.py +0 -0
  43. tokenloop-0.1.2/tokenloop/hitl/base.py +122 -0
  44. tokenloop-0.1.2/tokenloop/mcp/__init__.py +0 -0
  45. tokenloop-0.1.2/tokenloop/mcp/client.py +41 -0
  46. tokenloop-0.1.2/tokenloop/mcp/manager.py +36 -0
  47. tokenloop-0.1.2/tokenloop/provider/__init__.py +0 -0
  48. tokenloop-0.1.2/tokenloop/provider/anthropic.py +182 -0
  49. tokenloop-0.1.2/tokenloop/provider/base.py +26 -0
  50. tokenloop-0.1.2/tokenloop/provider/openai.py +181 -0
  51. tokenloop-0.1.2/tokenloop/session_service/__init__.py +0 -0
  52. tokenloop-0.1.2/tokenloop/session_service/base.py +56 -0
  53. tokenloop-0.1.2/tokenloop/session_service/memory.py +24 -0
  54. tokenloop-0.1.2/tokenloop/skills/__init__.py +0 -0
  55. tokenloop-0.1.2/tokenloop/skills/base.py +36 -0
  56. tokenloop-0.1.2/tokenloop/skills/manager.py +72 -0
  57. tokenloop-0.1.2/tokenloop/stream/__init__.py +0 -0
  58. tokenloop-0.1.2/tokenloop/stream/agui.py +203 -0
  59. tokenloop-0.1.2/tokenloop/stream/sink.py +8 -0
  60. tokenloop-0.1.2/tokenloop/stream/tracing.py +83 -0
  61. tokenloop-0.1.2/tokenloop/tool/__init__.py +0 -0
  62. tokenloop-0.1.2/tokenloop/tool/base.py +57 -0
  63. tokenloop-0.1.2/tokenloop/tool/builtins/__init__.py +0 -0
  64. tokenloop-0.1.2/tokenloop/tool/builtins/bash_tool.py +84 -0
  65. tokenloop-0.1.2/tokenloop/tool/builtins/executor.py +164 -0
  66. tokenloop-0.1.2/tokenloop/tool/builtins/file_store.py +57 -0
  67. tokenloop-0.1.2/tokenloop/tool/builtins/read_tool.py +46 -0
  68. tokenloop-0.1.2/tokenloop/tool/builtins/update_state.py +28 -0
  69. tokenloop-0.1.2/tokenloop/tool/hooks.py +27 -0
  70. tokenloop-0.1.2/tokenloop/tool/registry.py +68 -0
  71. tokenloop-0.1.2/tokenloop/tool/schema.py +79 -0
  72. tokenloop-0.1.2/uv.lock +1169 -0
  73. tokenloop-0.1.0/.python-version +0 -1
  74. tokenloop-0.1.0/PKG-INFO +0 -6
  75. tokenloop-0.1.0/pyproject.toml +0 -14
  76. tokenloop-0.1.0/src/tokenloop/__init__.py +0 -2
  77. /tokenloop-0.1.0/README.md → /tokenloop-0.1.2/.pypi.md +0 -0
  78. /tokenloop-0.1.0/src/tokenloop/py.typed → /tokenloop-0.1.2/tokenloop/compression/__init__.py +0 -0
@@ -8,3 +8,9 @@ wheels/
8
8
 
9
9
  # Virtual environments
10
10
  .venv
11
+
12
+ # Local secrets / editor tooling
13
+ .env
14
+ .claude/
15
+ .ruff_cache/
16
+ .pytest_cache/
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "agno-docs": {
4
+ "type": "http",
5
+ "url": "https://docs.agno.com/mcp"
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: tokenloop
3
+ Version: 0.1.2
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
+ > A lightweight, modular, model-agnostic agent loop and token-streaming framework designed for maximum performance and zero bloat.
19
+
20
+ ## Features
21
+
22
+ * **Modular Execution Loop**
23
+ A clean, unopinionated event loop built on the Unix philosophy. Step through prompts, model inferences, and tool executions predictably without heavy state abstractions.
24
+
25
+ * **Model-Agnostic Provider Support**
26
+ Bring your own provider. Seamlessly switch between local models, cloud APIs, or custom backends with zero code refactoring.
27
+
28
+ * **Skills Support**
29
+ Add Local Skills with Validation tokenloops responsibilty
30
+
31
+ * **First-Class Tool Calling**
32
+ Effortlessly register native functions or structured Python/Rust/Go methods as executable agent tools with automatic schema generation.
33
+
34
+ * **Model Context Protocol (MCP) Integration**
35
+ Out-of-the-box client support for MCP servers, allowing your agents to dynamically discover and consume external resources and tools.
36
+
37
+ * **Human-in-the-Loop (HITL) Checkpoints**
38
+ Inject asynchronous approval hooks before executing critical or sensitive tool calls, ensuring absolute safety and human oversight.
39
+
40
+ * **Intelligent Context Compression**
41
+ Built-in utilities to prune, summarize, and compress long conversation histories to keep token windows tight and latency low.
42
+
43
+ * **AGUI & Real-Time Token Streaming**
44
+ First-class support for streaming tokens and structured UI events simultaneously, powering responsive agent-driven user interfaces.
45
+
46
+ * **Multi-Language Ergonomics**
47
+ Consistent, idiomatic APIs available natively across Python, Rust, Go, and Node.js.
48
+
49
+ * **Zero Bloat Architecture**
50
+ Extremely lightweight core footprint with zero unnecessary dependencies, giving you total control over performance.
@@ -0,0 +1,35 @@
1
+ # ⚡ tokenloop
2
+
3
+ > A lightweight, modular, model-agnostic agent loop and token-streaming framework designed for maximum performance and zero bloat.
4
+
5
+ ## Features
6
+
7
+ * **Modular Execution Loop**
8
+ A clean, unopinionated event loop built on the Unix philosophy. Step through prompts, model inferences, and tool executions predictably without heavy state abstractions.
9
+
10
+ * **Model-Agnostic Provider Support**
11
+ Bring your own provider. Seamlessly switch between local models, cloud APIs, or custom backends with zero code refactoring.
12
+
13
+ * **Skills Support**
14
+ Add Local Skills with Validation tokenloops responsibilty
15
+
16
+ * **First-Class Tool Calling**
17
+ Effortlessly register native functions or structured Python/Rust/Go methods as executable agent tools with automatic schema generation.
18
+
19
+ * **Model Context Protocol (MCP) Integration**
20
+ Out-of-the-box client support for MCP servers, allowing your agents to dynamically discover and consume external resources and tools.
21
+
22
+ * **Human-in-the-Loop (HITL) Checkpoints**
23
+ Inject asynchronous approval hooks before executing critical or sensitive tool calls, ensuring absolute safety and human oversight.
24
+
25
+ * **Intelligent Context Compression**
26
+ Built-in utilities to prune, summarize, and compress long conversation histories to keep token windows tight and latency low.
27
+
28
+ * **AGUI & Real-Time Token Streaming**
29
+ First-class support for streaming tokens and structured UI events simultaneously, powering responsive agent-driven user interfaces.
30
+
31
+ * **Multi-Language Ergonomics**
32
+ Consistent, idiomatic APIs available natively across Python, Rust, Go, and Node.js.
33
+
34
+ * **Zero Bloat Architecture**
35
+ Extremely lightweight core footprint with zero unnecessary dependencies, giving you total control over performance.
@@ -0,0 +1,303 @@
1
+ # Python HITL / RunControl parity gap analysis
2
+
3
+ This document originally recorded an **analysis only**, with nothing yet
4
+ implemented. **All 8 gaps below, plus the follow-up findings section, have
5
+ since been implemented** (see "Implementation notes" at the end of this
6
+ file). It's kept as-is otherwise, as the design record of why each change
7
+ was made and what it mirrors in the Rust crate (`rust/tokenloop`) for
8
+ **HITL and `RunControlService`** specifically. Image input support remains a
9
+ separate, out-of-scope divergence not covered here.
10
+
11
+ Scope basis: over two rounds of work, the Rust side (1) unified
12
+ Confirmation/InputRequired/ExternalExecution onto a single non-blocking pause
13
+ mechanism, then (2) added `RunMode`/`HitlSkipped` (NonInteractive mode), a
14
+ `result` field on `HitlResume`, an `"ext_pending"`/`"hitl_pending"` key split,
15
+ and restored `RunControlService` (manual pause/cancel from a UI button — not
16
+ itself a HITL-detection mechanism, but included here per explicit request).
17
+ Python was ported from an earlier snapshot, before any of round (2) existed,
18
+ and has not been touched since.
19
+
20
+ ## Already correct — no change needed
21
+
22
+ - **The core pause contract already matches Rust's target design.** Both
23
+ Python trigger paths — `HitlSignal` raised from a tool/hook
24
+ (`tool/registry.py:44-50` → `core/dispatch.py:114-123`) and
25
+ external-execution detection (`core/dispatch.py:150-169`) — write a pending
26
+ entry into `ctx.tmp_state` and return/break immediately. No channel, no
27
+ future, no blocking wait. This is exactly what the Rust side had to be
28
+ *corrected to* (its `ExternalExecution` path used to block via
29
+ `RunControlService` before this session's unification work) — Python
30
+ already had it right from the start.
31
+ - **`ToolHooks.before_call(call: ToolCall, run_context)` already receives the
32
+ real `tool_call_id`** via `call.id` (`tool/hooks.py:14-15`, `core/types.py:8-11`).
33
+ Rust's equivalent, `before_call(name: &str, args: Value, ctx: SharedContext)`
34
+ (`src/tool/hooks.rs`), does **not** get the id — a documented Rust gotcha
35
+ that forces its hooks to mint their own `uuid::Uuid::new_v4()`. Python is
36
+ strictly better here; do not regress this when porting anything below.
37
+ - **Python has a single turn-loop implementation.** `core/stream.py`'s
38
+ `_run_turns`/`astream` contains all turn-loop logic; `core/run.py::run` is
39
+ a thin wrapper that drains `astream()` (`core/run.py:1-45`). Every change
40
+ below applies in **one place**, unlike Rust's independently hand-duplicated
41
+ `src/agent/run.rs` / `src/agent/stream.rs`.
42
+
43
+ ## Gap 1 — `hitl/base.py`: shape drift vs. `src/schemas/hitl.rs`
44
+
45
+ | Type | Python today | Rust (`src/schemas/hitl.rs`) | Gap |
46
+ |---|---|---|---|
47
+ | `InputField` | doesn't exist | `struct InputField { name, description, options: Option<Vec<String>>, value: Option<Value> }` (lines 6-13) | Add the struct; `InputRequired.fields` needs it. |
48
+ | `InputRequired.fields` | `list[str]` (line ~35) | `Vec<InputField>` (line 19) | Unstructured — should hold real `InputField` entries. |
49
+ | `Confirmation.fields` | `list[str]` (line ~44) | `Vec<(String, String)>` (line 30) | Should be label/value pairs, e.g. `list[tuple[str, str]]`. |
50
+ | `Confirmation.title` | `str` (required) | `Option<String>` with `#[serde(default)]` (line 28) | Should be optional. |
51
+ | `ExternalExecution` | `tool_call_id, tool_name` | same | Matches exactly — no change. |
52
+ | `HitlPending` | `tool_call_id, tool_name, arguments` (lines 54-58) | `tool_call_id, tool_name, args, kind` (lines 47-53) | **Missing `kind` entirely.** Python's dispatch functions return kind out-of-band (`([], paused_kind, paused_correlation_id)`) instead of persisting it — pending state isn't self-describing from session storage alone the way Rust's is. |
53
+ | `HitlResume` | `tool_call_id, approved, note` (lines 60-63) | `tool_call_id, approved, note, fields, result` (lines 61-74) | **Missing `fields` and `result`.** Today `InputRequired` answers are JSON-stuffed into `note` as a workaround (`core/cli.py:137-142`, `_prompt_input_required`). |
54
+
55
+ ## Gap 2 — `core/dispatch.py`: kind on write, and `HitlSkipped` handling
56
+
57
+ Both write sites need `kind=` added once `HitlPending` gains the field:
58
+ - Hook-triggered path, `core/dispatch.py:230-258`.
59
+ - External-execution path, `core/dispatch.py:150-169`.
60
+
61
+ Rust's `dispatch.rs` (lines 34-73) has a third case Python has no equivalent
62
+ for: a hook can return `Err(AgentError::HitlSkipped(msg))` instead of pausing,
63
+ used for NonInteractive mode (see Gap 3). Rust handles this with a `skipped`
64
+ accumulator — the affected call gets a synthetic error result and the *rest
65
+ of the batch still dispatches normally* (unlike a real pause, which stops
66
+ everything). Porting this requires the per-call loop in `core/dispatch.py`
67
+ (currently around `_execute_tracked`, lines 114-123) to distinguish two
68
+ exception types instead of one:
69
+ - `HitlSignal` → stop the whole batch (today's only behavior, unchanged).
70
+ - `HitlSkipped` (new) → give just that call a synthetic error `ToolResult`,
71
+ continue dispatching every other call in the batch.
72
+
73
+ ## Gap 3 — `core/context.py`: `RunMode` / `HitlSkipped` are entirely absent
74
+
75
+ Confirmed via full-package grep: no `RunMode`, `run_mode`, or `HitlSkipped`
76
+ exists anywhere in Python today. Rust's shape (`src/run/options.rs`,
77
+ `src/run/context.rs`, `src/error.rs`):
78
+
79
+ ```rust
80
+ pub enum RunMode { Interactive, NonInteractive } // Copy, Default = Interactive, Serialize/Deserialize
81
+ // RunOptions.run_mode: RunMode
82
+ // RunContext.run_mode() -> RunMode
83
+ // AgentError::HitlSkipped(String)
84
+ ```
85
+
86
+ Needed: a `RunMode` enum (`INTERACTIVE`/`NON_INTERACTIVE`, default
87
+ `INTERACTIVE`), `RunOptions.run_mode`, `RunContext.run_mode` (copied from
88
+ options at context construction — mirrors how `setup_run_context` feeds it
89
+ in Rust, `src/agent/shared.rs`), and a `HitlSkipped` exception type
90
+ alongside the existing `HitlSignal` (`hitl/base.py:78-87`). A hook checks
91
+ `run_context.run_mode` *before* deciding to pause, exactly like the Rust
92
+ example in `docs/HITL.md`'s "NonInteractive mode" section.
93
+
94
+ ## Gap 4 — `RunControlService` equivalent: absent by design, now requested
95
+
96
+ Confirmed via grep: no registry, no `pause`/`resume`/`cancel`/`is_paused`
97
+ methods, no `RunControlService`-named anything exists in Python. What exists
98
+ instead is much thinner and explicitly documented as a deliberate
99
+ simplification (`core/context.py:22-27`, `core/cli.py:6-14`): a single
100
+ caller-owned `asyncio.Event` on `RunOptions.cancel`, checked once per turn
101
+ (`core/stream.py:260-261`). This is functionally equivalent to *half* of
102
+ Rust's `RunControlService` (the cancel flag) but has no run-id-keyed registry
103
+ and no manual pause/resume concept at all.
104
+
105
+ Rust's `src/run/registry.rs` API to mirror:
106
+
107
+ ```rust
108
+ pub struct RunControlService { runs: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>, paused_ids: Arc<Mutex<HashSet<String>>> }
109
+ fn register(&self, run_id: &str, cancel_flag: Arc<AtomicBool>)
110
+ fn unregister(&self, run_id: &str)
111
+ fn cancel(&self, run_id: &str) -> bool
112
+ fn cancel_all(&self)
113
+ fn pause(&self, run_id: &str) -> bool
114
+ fn resume(&self, run_id: &str) -> bool
115
+ fn is_paused(&self, run_id: &str) -> bool
116
+ fn active_ids(&self) -> Vec<String>
117
+ ```
118
+
119
+ A Python port (e.g. new `core/registry.py`) would hold `dict[str,
120
+ asyncio.Event]` + `set[str]` with the same method names/semantics. Unlike
121
+ Rust, no `Mutex` equivalent is strictly required if all access happens on one
122
+ event loop (Rust's lock exists because multiple OS threads touch it) — this
123
+ should be called out explicitly in the implementation rather than blindly
124
+ adding a `threading.Lock`. `AgentLoop` would gain an optional
125
+ `run_control_service` parameter (mirrors `AgentBuilder::run_control_service`,
126
+ `src/agent/builder.rs:542-545`).
127
+
128
+ Wiring needed in `core/stream.py`, the single turn-loop file:
129
+ - Auto-register/-unregister the run's cancel `asyncio.Event` with
130
+ `run_control_service` when one is wired — mirrors `setup_run_context`
131
+ (`src/agent/shared.rs:371-376`) and its `finalize_and_save` unregister call.
132
+ - A manual pause spin-wait immediately before the existing cancel check at
133
+ `core/stream.py:260-261`, mirroring `src/agent/run.rs:274-297`: while
134
+ `rc.is_paused(run_id)`, sleep 50ms in a loop up to `MAX_PAUSE_ITERS = 36_000`
135
+ (30 minutes), still honoring cancel while paused, warn+auto-resume on
136
+ timeout.
137
+
138
+ ## Gap 5 — `core/shared.py`: `inject_hitl_resume` must become kind-aware
139
+
140
+ Today (`core/shared.py:37-57`) resume-text computation is a single
141
+ unconditional formula applied to every kind:
142
+ `resume.note or "Approved."` if approved, else
143
+ `f"Rejected: {resume.note or 'No reason given.'}"`.
144
+
145
+ This is correct for `Confirmation` but **wrong for `ExternalExecution`** —
146
+ Rust never wraps an `ExternalExecution` result in approve/reject phrasing;
147
+ it just uses `result`, falling back to `note`, falling back to `"OK"` (see
148
+ `docs/HITL.md`'s resume table, sourced from `inject_hitl_resume`'s match in
149
+ `src/agent/shared.rs`). Once `HitlPending.kind` exists (Gap 1), Python should
150
+ branch:
151
+
152
+ | `HitlKind` | Fields read | Resulting tool_result text |
153
+ |---|---|---|
154
+ | `Confirmation` | `approved`, `note` | `note` (or `"Approved."`) if approved; `"Rejected: {note}"` (or `"No reason given."`) if not — **unchanged from today** |
155
+ | `InputRequired` | `fields` | the filled `InputField`s serialized as a JSON object — today done via `note`-stuffing in `core/cli.py`, should move to a real `fields` value |
156
+ | `ExternalExecution` | `result`, falls back to `note`, falls back to `"OK"` | **new** — today incorrectly gets Confirmation-style approve/reject phrasing |
157
+
158
+ **Deliberate non-port, worth recording so it isn't "rediscovered" as a missing
159
+ gap later:** Rust splits `"hitl_pending"` (hook-triggered) from
160
+ `"ext_pending"` (external-execution) as two separate `temp_state` keys,
161
+ purely because two independent write sites needed to stay distinguishable
162
+ downstream. Once Python's `HitlPending` always carries `kind` (Gap 1), a
163
+ single `"hitl_pending"` key is already self-describing — a second key would
164
+ be redundant complexity that Rust needs and Python doesn't. **Recommendation:
165
+ keep Python's single-key design.**
166
+
167
+ ## Gap 6 — `core/cli.py`: update `HitlResume(...)` construction sites
168
+
169
+ Once `fields`/`result` exist (Gap 1), `core/cli.py`'s `_prompt_input_required`
170
+ (lines 137-142) should pass filled `InputField`s via `fields=` instead of
171
+ JSON-encoding them into `note`, and any `ExternalExecution` resume site should
172
+ use `result=` instead of `note=`/`approved=`.
173
+
174
+ ## Gap 7 — Test coverage to add
175
+
176
+ Mirror Rust's coverage as a checklist for whoever implements this:
177
+ - `src/agent/shared.rs`'s ~8 tests: one per kind's resume-text logic
178
+ (Confirmation approved/rejected, InputRequired fields-as-JSON,
179
+ ExternalExecution result-wins-over-note, falls-back-to-note,
180
+ falls-back-to-"OK").
181
+ - `src/run/registry.rs`'s 7 tests: pause/resume round-trip, pause on unknown
182
+ run_id is a no-op, cancel sets the flag, cancel on unknown run_id returns
183
+ false, cancel_all sets every flag, register/unregister lifecycle.
184
+ - A `HitlSkipped`-in-NonInteractive-mode test: the skipped call gets a
185
+ synthetic error result, every other call in the same batch still dispatches.
186
+
187
+ ## Summary of files that would need changes (not made in this task)
188
+
189
+ - `hitl/base.py` — `InputField`, richer `Confirmation`/`InputRequired.fields`,
190
+ `HitlPending.kind`, `HitlResume.fields`/`.result`.
191
+ - `core/dispatch.py` — `kind=` on both `HitlPending` writes; `HitlSkipped`
192
+ handling in the per-call dispatch loop.
193
+ - `core/context.py` — `RunMode` enum, `RunOptions.run_mode`,
194
+ `RunContext.run_mode`.
195
+ - new `core/registry.py` — `RunControlService` port.
196
+ - `core/stream.py` — register/unregister with `run_control_service`; pause
197
+ spin-wait before the existing cancel check.
198
+ - `core/shared.py` — kind-aware `inject_hitl_resume`; keep the single
199
+ `"hitl_pending"` key (documented non-port of Rust's key split).
200
+ - `core/cli.py` — update `HitlResume(...)` call sites to use `fields`/`result`.
201
+
202
+ ## Update — findings from Rust's RunPaused/HitlSkipped event-lifecycle fixes
203
+
204
+ The Rust crate's `docs/syncup.md` was later updated with 5 more gaps (all
205
+ confirmed and fixed this session, in `src/agent/stream.rs` and
206
+ `src/agent/run.rs`): `RunPaused` wasn't emitted for hook-triggered HITL
207
+ during streaming (2 sites), `HitlSkipped` didn't emit a matching
208
+ `PreHookCompleted` and incorrectly ran `after_call` (2 sites), and a
209
+ `finish_reason=ToolCalls`-with-no-tool-calls anomaly went unlogged (1 site).
210
+ Each was checked directly against the current Python source below - most
211
+ don't apply the way they did in Rust, but two new structural findings
212
+ surfaced that matter more than the original gaps.
213
+
214
+ **Gaps 1+2 (`RunPaused` not emitted for hook-triggered HITL) — N/A, Python
215
+ already correct.** `core/dispatch.py`'s single `dispatch_tools` (Python has
216
+ one dispatch function/turn loop, unlike Rust's early-dispatch vs.
217
+ assembled-dispatch split in `stream.rs`) already emits `RunPaused` at both
218
+ pause sites: external-execution (`dispatch.py:159-168`) and the
219
+ tool-body-raised `HitlSignal` path (`dispatch.py:240-249`). Python never had
220
+ this gap.
221
+
222
+ **New finding — Python's pause-triggering surface is narrower than Rust's.**
223
+ Rust's `examples/hitl.rs` pauses from `ToolHooks::before_call` (the
224
+ `ApprovalHook` pattern - a hook decides to pause). Python's `HitlSignal` is
225
+ designed to be raised only from inside a tool's own execution body
226
+ (`hitl/base.py:78-87`'s docstring: "Raise this from inside a tool's own
227
+ body"). `dispatch.py`'s `_run_before_call_hooks` (lines 64-85) has **no
228
+ `try/except HitlSignal`** around its `hook.before_call(current, ctx)` call,
229
+ unlike `_execute_tracked` (lines 114-123), which does wrap tool execution in
230
+ exactly that way. If a hook raised `HitlSignal` today the way Rust's
231
+ `ApprovalHook` does, it would propagate **uncaught** out of `dispatch_tools`
232
+ instead of pausing cleanly - there is no example or test exercising this, so
233
+ it's untested territory. This is an open design question, not yet a filed
234
+ gap: either (a) document explicitly that Python hooks may only rewrite
235
+ args and never request a pause - only tool bodies can - or (b) extend
236
+ `_run_before_call_hooks` to catch `HitlSignal` (and, later, `HitlSkipped`)
237
+ the same way tool execution is wrapped, to match Rust's
238
+ hook-triggered-pause pattern.
239
+
240
+ **Gaps 3+4 (`HitlSkipped` event-lifecycle correctness) — not yet actionable
241
+ in Python (no `HitlSkipped` exists yet, see Gap 2/3 above), but two concrete
242
+ requirements to carry into that future work:**
243
+ - Whichever code path ends up allowed to raise `HitlSkipped` (per the finding
244
+ above) must still emit the matching `PreHookCompleted` before propagating -
245
+ mirroring the Rust fix that added this for its early-dispatch path
246
+ (`stream.rs`'s early-dispatch `HitlSkipped` arm was missing it).
247
+ - **New structural finding, more significant than the original gap**: Rust's
248
+ target behavior for a skipped call is "fire `on_complete` only, never
249
+ `after_call`". Python's `ToolHook` protocol (`tool/hooks.py:7-27`) has
250
+ `before_call` / `after_call` / `on_completion` - but `on_completion(self,
251
+ run_context)` fires **once per run** (a background task scheduled after the
252
+ whole run ends, for logging/eval side effects), not once per tool call,
253
+ unlike Rust's `ToolHooks::on_complete(name, output, ctx)`, which fires once
254
+ per call. **Python has no per-call, non-rewriting "always observe" hook
255
+ stage today.** Before porting `HitlSkipped`, this needs a decision: either
256
+ add a per-call `on_complete`-equivalent to `ToolHook`, or accept that a
257
+ skipped call in Python will always flow through `after_call` (Python can't
258
+ cheaply replicate "skip `after_call` for just this one call" without that
259
+ addition).
260
+
261
+ **Gap 5 (silent `finish_reason=ToolCalls` anomaly, `run.rs`) — out of scope,
262
+ and no 1:1 port target exists.** This is a general provider-response
263
+ robustness log, not a HITL/RunControlService concern, so it falls outside
264
+ this comparison's scope. It also has no direct port target: Python's turn
265
+ loop (`core/stream.py:238`, `if not message.tool_calls:`) doesn't track
266
+ `finish_reason` at that level at all - `Message` doesn't carry one. Recording
267
+ this explicitly as "out of scope, no action" so it isn't mistaken for an
268
+ overlooked gap later.
269
+
270
+ ## Implementation notes
271
+
272
+ All 8 numbered gaps above were implemented, plus the follow-up findings
273
+ section's items 1-3 (item 4/Gap 5 stayed out of scope as recorded). Two
274
+ things came out different from what the analysis above predicted:
275
+
276
+ - **`core/dispatch.py`'s `HitlSkipped` handling needed zero new code.**
277
+ Raising `HitlSkipped` from a tool body falls through to
278
+ `ToolRegistry.execute()`'s existing generic `except Exception` clause,
279
+ which already produces a per-call `ToolResult(is_error=True)` while
280
+ `asyncio.gather` keeps every other call in the batch running - exactly the
281
+ "skip this call, don't stop the batch" behavior NonInteractive mode needs.
282
+ Unlike Rust's `dispatch.rs` (which needs an explicit `skipped` accumulator
283
+ because its `before_call` hooks run sequentially, pre-dispatch, for the
284
+ whole batch), Python's concurrent per-call execution already isolates each
285
+ call's exception. `hitl/base.py::HitlSkipped` and a doc comment in
286
+ `tool/registry.py` are the only additions.
287
+ - **The "hooks can't trigger a pause" open design question (from the
288
+ follow-up findings section) was deliberately left unresolved.** `HitlSkipped`
289
+ is raised from tool bodies, same as `HitlSignal`, sidestepping the question
290
+ entirely rather than resolving it - `core/dispatch.py::_run_before_call_hooks`
291
+ still has no `try/except` around `hook.before_call(...)`. Porting Rust's
292
+ `examples/hitl.rs`-style hook-triggered Confirmation pause into Python
293
+ would still require that separate change.
294
+
295
+ Files touched: `hitl/base.py`, `core/context.py`, `core/dispatch.py`,
296
+ `tool/registry.py`, `core/registry.py` (new), `core/loop.py`,
297
+ `core/stream.py`, `core/shared.py`, `core/cli.py`, `stream/agui.py` (its
298
+ `InputRequired`/`Confirmation` JSON-schema builder needed updating for the
299
+ new `InputField`/tuple-pair shapes - not part of the original 8-item list,
300
+ found by grepping for every `.fields` access after the schema change), plus
301
+ new tests under `tests/` (`pytest` added as a dev dependency - the port had
302
+ no test suite before this). Verified with `ruff check`, `ruff format --check`,
303
+ the new `pytest` suite (20 tests), and `examples/smoke_test.py`.
@@ -0,0 +1,36 @@
1
+ """Basic chat - no tools, multi-turn conversation.
2
+
3
+ Run:
4
+ uv run python examples/basic_chat.py
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+
12
+ from tokenloop import AgentLoop
13
+ from tokenloop.provider.anthropic import AnthropicProvider
14
+
15
+ try: # best-effort .env loading, matches Rust's dotenvy::dotenv().ok()
16
+ from dotenv import load_dotenv
17
+
18
+ load_dotenv()
19
+ except ImportError:
20
+ pass
21
+
22
+
23
+ async def main() -> None:
24
+ agent = AgentLoop(
25
+ AnthropicProvider(model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5")),
26
+ instruction=(
27
+ "You are a concise, helpful assistant. Keep answers under 3 sentences unless more detail is requested."
28
+ ),
29
+ inject_datetime=True,
30
+ )
31
+
32
+ await agent.as_cli(None)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ asyncio.run(main())
@@ -0,0 +1,68 @@
1
+ """Session state - app_state, user_state, temp_state.
2
+
3
+ Demonstrates:
4
+ - Tools reading and writing state via a `ctx` parameter (see
5
+ tool/base.py::FunctionTool - naming a parameter exactly `ctx` opts a
6
+ tool into receiving the live RunContext; it's excluded from the JSON
7
+ schema the model sees).
8
+ - State surviving across turns within the same session.
9
+
10
+ Try: "What is my profile?" then "Change my name to Alice"
11
+
12
+ Run:
13
+ uv run python examples/session_state.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+
21
+ from tokenloop import AgentLoop, RunContext
22
+ from tokenloop.provider.anthropic import AnthropicProvider
23
+
24
+ try: # best-effort .env loading, matches Rust's dotenvy::dotenv().ok()
25
+ from dotenv import load_dotenv
26
+
27
+ load_dotenv()
28
+ except ImportError:
29
+ pass
30
+
31
+
32
+ async def get_profile(ctx: RunContext) -> str:
33
+ """Return the current user profile from session state."""
34
+ name = ctx.user_state.get("user_name", "Guest")
35
+ turns = ctx.user_state.get("turn_count", 0) + 1
36
+ ctx.user_state["turn_count"] = turns
37
+ return f'{{"name": "{name}", "turn_count": {turns}}}'
38
+
39
+
40
+ async def update_name(name: str, ctx: RunContext) -> str:
41
+ """Update the user's name in session state."""
42
+ ctx.user_state["user_name"] = name
43
+ return f"Name updated to '{name}'"
44
+
45
+
46
+ async def main() -> None:
47
+ agent = AgentLoop(
48
+ AnthropicProvider(model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5")),
49
+ instruction=(
50
+ "You are a personalized assistant.\n"
51
+ "The user's name is stored in session state under 'user_name'.\n"
52
+ "Call get_profile to read it and update_name to change it."
53
+ ),
54
+ )
55
+ agent.add_tool(get_profile)
56
+ agent.add_tool(update_name)
57
+
58
+ session = await agent._session_service.new(user_id="demo-user", agent_id=agent._id) # noqa: SLF001
59
+ session.user_state["user_name"] = "Guest"
60
+ session.user_state["turn_count"] = 0
61
+ await agent._session_service.save(session) # noqa: SLF001
62
+
63
+ print(f"Session: {session.session_id}")
64
+ await agent.as_cli(session.session_id)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ asyncio.run(main())
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: explain-topic
3
+ description: Explains a technical process, system, protocol, or algorithm using a vertical ASCII "activity diagram" - numbered steps, blocking/waiting calls called out explicitly, and decision forks drawn as branches with a merge back to a single flow. Use this whenever the user asks to explain, walk through, or break down how something works, especially when the thing has sequential stages, async/blocking behavior, event emissions, or a conditional branch (approved/rejected, success/failure, cache hit/miss, etc). Trigger on phrases like "explain how X works", "walk me through X", "what happens when X", "trace the flow of X", or "diagram X" - even if the user doesn't explicitly ask for a diagram, if the topic is a multi-step process this is the preferred explanation format. Do not use for purely conceptual/definitional questions with no sequence or steps (e.g. "what is a hash map").
4
+ ---
5
+
6
+ # Explain Topic (ASCII Flow Diagram)
7
+
8
+ Explain processes as a single vertical control-flow diagram in plain ASCII/text - the same style as a UML activity diagram, but compact and rendered directly in chat (no image, no artifact, no Visualizer tool).
9
+
10
+ ## When to use this
11
+
12
+ Use this format when the topic has:
13
+ - A **sequence of discrete stages** (setup -> action -> result)
14
+ - Any **blocking or waiting** step (I/O, a lock, a human approval, a network call)
15
+ - A **decision/branch point** (success vs failure, approved vs rejected, hit vs miss)
16
+ - **Events or side effects** fired along the way (logs, emitted events, callbacks)
17
+
18
+ Skip this format for static/definitional questions ("what is X", "difference between X and Y") that have no real sequence - just answer those in prose.
19
+
20
+ ## Diagram conventions
21
+
22
+ Follow this exact visual grammar so output stays consistent:
23
+
24
+ 1. **Single vertical spine.** One flow goes top to bottom. Steps stack with `|` / `v` connectors between them:
25
+ ```text
26
+ |
27
+ v
28
+ ① Step happens
29
+
30
+ ```
31
+
32
+ 2. **Numbered circled steps** using ①②③④⑤⑥⑦⑧⑨⑩ for the main stages. Each step gets:
33
+ * A short label naming the concrete action (use real function/method/event names if known - not generic placeholders)
34
+ * Optional indented detail lines starting with `- `, `->`, or `=` for sub-actions inside that step
35
+
36
+
37
+ 3. **Explicit blocking steps** get labeled with `<- BLOCKS HERE` or `-> awaits response`, so the reader immediately sees where execution stalls.
38
+ 4. **Decision forks** get drawn as an explicit branch, not just described in prose:
39
+ ```text
40
+ |
41
+ +----+----+
42
+ | |
43
+ condition A condition B
44
+ | |
45
+ v v
46
+ outcome path 1 outcome path 2
47
+
48
+ ```
49
+
50
+
51
+ Each branch should show what happens differently (e.g. one continues the flow, one short-circuits/errors out).
52
+ 5. **Merge back to one spine** after a branch, if the branches reconverge later - don't leave two dangling columns if the real process rejoins.
53
+ 6. **Use small icons sparingly** for recognizable states, only if they add clarity: `o` running/executing, `=` paused/waiting, `v` success/done, `x` failure/rejected. Don't overuse - one or two per diagram is usually enough.
54
+ 7. **Keep labels concrete, not abstract.** Prefer `registry.submit_hitl(id, Confirmed)` over "system processes confirmation". If you don't know the exact real-world name, use a plausible concrete placeholder rather than a vague noun phrase.
55
+ 8. **Diagram first, then explain.** Output the diagram, then follow it with a short prose paragraph (3-6 sentences) that: names what pattern this is (if there's a recognizable one - interceptor, producer/consumer, ask pattern, etc.), and calls out the one or two trickiest parts of the flow (usually the blocking step or the branch).
56
+
57
+ ## Worked example
58
+
59
+ For reference, here's the target shape and density (this is the level of detail to aim for - not longer, not much shorter):
60
+
61
+ ```text
62
+ LLM decides to call bash tool with cmd = "python hitl_transfer.py"
63
+ |
64
+ v
65
+ ① ToolCallStarted event emitted
66
+ - CLI prints: o bash id=tc-123
67
+ |
68
+ v
69
+ ② tool_hooks.before_call("bash", args, ctx) - BLOCKS HERE
70
+ - detects trigger condition
71
+ - creates oneshot channel, registers correlation_id
72
+ - event_tx.send(RunPaused { "Execute hitl_transfer.py?" })
73
+ - rx.await -> agent task blocks, no more events produced
74
+
75
+ CLI receives RunPaused -> prints = confirm prompt
76
+ teller responds "y" -> registry.submit_hitl(id, Confirmed)
77
+ - fires oneshot -> rx.await unblocks
78
+ |
79
+ +--------+--------+
80
+ | |
81
+ approved rejected
82
+ | |
83
+ v v
84
+ ③ hook returns Ok ToolCallError emitted
85
+ (continue) -> tool never runs, LLM sees error
86
+ |
87
+ v
88
+ ④ reg.dispatch_all() -> tool actually executes
89
+ |
90
+ v
91
+ ⑤ ToolCallCompleted event emitted
92
+ - CLI prints: v bash 200ms
93
+
94
+ ```
95
+
96
+ Then close with 3-6 sentences: name the pattern(s) at play (here: interceptor / middleware for the hook, ask-pattern for the oneshot channel, HITL interrupt gate for the overall behavior), and flag the blocking step as the part most likely to confuse someone.
97
+
98
+ ## Notes
99
+
100
+ * If the user's topic has more than ~8-10 real stages, group related sub-steps under one numbered step rather than numbering everything - the diagram should stay scannable in one screen, not become an exhaustive trace.
101
+ * If there's no natural branch/decision in the process, it's fine to omit step 4 (forks) and just run straight top-to-bottom.
102
+ * If the user asks a follow-up like "zoom into step 3", redraw just that step as its own smaller diagram using the same conventions.
103
+
104
+ ## Rust codebases
105
+
106
+ If the topic involves Rust - smart pointers (`Box`, `Rc`, `Arc`), interior mutability (`RefCell`, `Cell`, `Mutex`), lifetimes, ownership/borrowing/move semantics, `Drop`/RAII, trait objects (`dyn Trait`), or a borrow-checker error the user is debugging - read `references/rust.md` before drawing the diagram. It has ready-made templates for these exact patterns in the house style, including which parts are compile-time (lifetimes, monomorphized generics) versus runtime (RefCell panics, Mutex blocking, Rc/Arc refcounting, Drop order) - that distinction is usually the crux of the explanation, so make sure it's explicit in the diagram or the prose that follows it.
107
+
108
+ If the user pastes their own code, adapt the closest template to their actual types/variable names rather than answering with the generic template as-is.