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.
- {tokenloop-0.1.0 → tokenloop-0.1.2}/.gitignore +6 -0
- tokenloop-0.1.2/.mcp.json +8 -0
- tokenloop-0.1.2/PKG-INFO +50 -0
- tokenloop-0.1.2/README.md +35 -0
- tokenloop-0.1.2/docs/HITL_PARITY.md +303 -0
- tokenloop-0.1.2/examples/basic_chat.py +36 -0
- tokenloop-0.1.2/examples/session_state.py +68 -0
- tokenloop-0.1.2/examples/skills/explain-topic/SKILL.md +108 -0
- tokenloop-0.1.2/examples/skills/explain-topic/references/rust,md +274 -0
- tokenloop-0.1.2/examples/skills/weather/SKILL.md +139 -0
- tokenloop-0.1.2/examples/skills/weather/output_schema.json +61 -0
- tokenloop-0.1.2/examples/skills/weather/scripts/weather.sh +245 -0
- tokenloop-0.1.2/examples/smoke_test.py +97 -0
- tokenloop-0.1.2/examples/streaming.py +49 -0
- tokenloop-0.1.2/examples/tool.py +68 -0
- tokenloop-0.1.2/examples/weather_agent.py +51 -0
- tokenloop-0.1.2/pyproject.toml +90 -0
- tokenloop-0.1.2/tests/test_hitl_resume.py +103 -0
- tokenloop-0.1.2/tests/test_hitl_skipped.py +62 -0
- tokenloop-0.1.2/tests/test_run_control_service.py +72 -0
- tokenloop-0.1.2/tokenloop/__init__.py +169 -0
- tokenloop-0.1.2/tokenloop/compression/base.py +17 -0
- tokenloop-0.1.2/tokenloop/compression/simple.py +49 -0
- tokenloop-0.1.2/tokenloop/compression/window.py +65 -0
- tokenloop-0.1.2/tokenloop/core/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/core/cli.py +335 -0
- tokenloop-0.1.2/tokenloop/core/compact.py +83 -0
- tokenloop-0.1.2/tokenloop/core/context.py +94 -0
- tokenloop-0.1.2/tokenloop/core/dispatch.py +305 -0
- tokenloop-0.1.2/tokenloop/core/events.py +211 -0
- tokenloop-0.1.2/tokenloop/core/loop.py +161 -0
- tokenloop-0.1.2/tokenloop/core/prompt.py +76 -0
- tokenloop-0.1.2/tokenloop/core/registry.py +104 -0
- tokenloop-0.1.2/tokenloop/core/retry.py +75 -0
- tokenloop-0.1.2/tokenloop/core/run.py +45 -0
- tokenloop-0.1.2/tokenloop/core/session.py +27 -0
- tokenloop-0.1.2/tokenloop/core/shared.py +133 -0
- tokenloop-0.1.2/tokenloop/core/stream.py +347 -0
- tokenloop-0.1.2/tokenloop/core/types.py +77 -0
- tokenloop-0.1.2/tokenloop/guardrails/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/guardrails/base.py +24 -0
- tokenloop-0.1.2/tokenloop/hitl/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/hitl/base.py +122 -0
- tokenloop-0.1.2/tokenloop/mcp/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/mcp/client.py +41 -0
- tokenloop-0.1.2/tokenloop/mcp/manager.py +36 -0
- tokenloop-0.1.2/tokenloop/provider/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/provider/anthropic.py +182 -0
- tokenloop-0.1.2/tokenloop/provider/base.py +26 -0
- tokenloop-0.1.2/tokenloop/provider/openai.py +181 -0
- tokenloop-0.1.2/tokenloop/session_service/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/session_service/base.py +56 -0
- tokenloop-0.1.2/tokenloop/session_service/memory.py +24 -0
- tokenloop-0.1.2/tokenloop/skills/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/skills/base.py +36 -0
- tokenloop-0.1.2/tokenloop/skills/manager.py +72 -0
- tokenloop-0.1.2/tokenloop/stream/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/stream/agui.py +203 -0
- tokenloop-0.1.2/tokenloop/stream/sink.py +8 -0
- tokenloop-0.1.2/tokenloop/stream/tracing.py +83 -0
- tokenloop-0.1.2/tokenloop/tool/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/tool/base.py +57 -0
- tokenloop-0.1.2/tokenloop/tool/builtins/__init__.py +0 -0
- tokenloop-0.1.2/tokenloop/tool/builtins/bash_tool.py +84 -0
- tokenloop-0.1.2/tokenloop/tool/builtins/executor.py +164 -0
- tokenloop-0.1.2/tokenloop/tool/builtins/file_store.py +57 -0
- tokenloop-0.1.2/tokenloop/tool/builtins/read_tool.py +46 -0
- tokenloop-0.1.2/tokenloop/tool/builtins/update_state.py +28 -0
- tokenloop-0.1.2/tokenloop/tool/hooks.py +27 -0
- tokenloop-0.1.2/tokenloop/tool/registry.py +68 -0
- tokenloop-0.1.2/tokenloop/tool/schema.py +79 -0
- tokenloop-0.1.2/uv.lock +1169 -0
- tokenloop-0.1.0/.python-version +0 -1
- tokenloop-0.1.0/PKG-INFO +0 -6
- tokenloop-0.1.0/pyproject.toml +0 -14
- tokenloop-0.1.0/src/tokenloop/__init__.py +0 -2
- /tokenloop-0.1.0/README.md → /tokenloop-0.1.2/.pypi.md +0 -0
- /tokenloop-0.1.0/src/tokenloop/py.typed → /tokenloop-0.1.2/tokenloop/compression/__init__.py +0 -0
tokenloop-0.1.2/PKG-INFO
ADDED
|
@@ -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.
|