courier-encode 0.1.3__tar.gz → 0.1.4__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. {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/workspace.xml +20 -8
  2. {courier_encode-0.1.3 → courier_encode-0.1.4}/PKG-INFO +16 -1
  3. {courier_encode-0.1.3 → courier_encode-0.1.4}/README.md +15 -0
  4. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/cookbook.md +54 -0
  5. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/intercept.md +47 -0
  6. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/sessions.md +71 -0
  7. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/tools.md +14 -0
  8. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs.md +17 -3
  9. courier_encode-0.1.4/examples/tool_discovery.py +120 -0
  10. {courier_encode-0.1.3 → courier_encode-0.1.4}/pyproject.toml +1 -1
  11. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/events.py +15 -0
  12. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/relay.py +143 -61
  13. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/session.py +110 -2
  14. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/tools.py +33 -2
  15. courier_encode-0.1.4/tests/test_intercept_tool_discovery.py +243 -0
  16. courier_encode-0.1.4/tests/test_session_tools.py +148 -0
  17. {courier_encode-0.1.3 → courier_encode-0.1.4}/.gitignore +0 -0
  18. {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/.gitignore +0 -0
  19. {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/encode.iml +0 -0
  20. {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/inspectionProfiles/Project_Default.xml +0 -0
  21. {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  22. {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/misc.xml +0 -0
  23. {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/modules.xml +0 -0
  24. {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/vcs.xml +0 -0
  25. {courier_encode-0.1.3 → courier_encode-0.1.4}/LICENSE +0 -0
  26. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/async.md +0 -0
  27. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/concepts.md +0 -0
  28. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/errors.md +0 -0
  29. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/executors.md +0 -0
  30. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/messages.md +0 -0
  31. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/quickstart.md +0 -0
  32. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/relay.md +0 -0
  33. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/streaming.md +0 -0
  34. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/structured-output.md +0 -0
  35. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/terminal.md +0 -0
  36. {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/whisper.md +0 -0
  37. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/basic_chat.py +0 -0
  38. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/intercept.py +0 -0
  39. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/intercept_compact.py +0 -0
  40. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/responses_endpoint.py +0 -0
  41. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/session_resume.py +0 -0
  42. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/stateful_tool.py +0 -0
  43. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/structured_output.py +0 -0
  44. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/tools_loop.py +0 -0
  45. {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/whisper_transcribe.py +0 -0
  46. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/__init__.py +0 -0
  47. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_config.py +0 -0
  48. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_http.py +0 -0
  49. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_schema.py +0 -0
  50. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_streaming.py +0 -0
  51. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_version.py +0 -0
  52. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/client.py +0 -0
  53. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/errors.py +0 -0
  54. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/executor.py +0 -0
  55. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/messages.py +0 -0
  56. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/py.typed +0 -0
  57. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/responses.py +0 -0
  58. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/terminal.py +0 -0
  59. {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/whisper.py +0 -0
  60. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/__init__.py +0 -0
  61. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/conftest.py +0 -0
  62. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_async.py +0 -0
  63. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_errors.py +0 -0
  64. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_executor.py +0 -0
  65. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_intercept_mutation.py +0 -0
  66. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_messages.py +0 -0
  67. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_messages_obj.py +0 -0
  68. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_relay_chat.py +0 -0
  69. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_relay_responses.py +0 -0
  70. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_relay_session.py +0 -0
  71. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_response_format.py +0 -0
  72. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_session.py +0 -0
  73. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_streaming.py +0 -0
  74. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_terminal.py +0 -0
  75. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_tool_loop.py +0 -0
  76. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_tools_schema.py +0 -0
  77. {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_whisper.py +0 -0
  78. {courier_encode-0.1.3 → courier_encode-0.1.4}/uv.lock +0 -0
@@ -7,13 +7,14 @@
7
7
  <list default="true" id="9259bfb5-884c-4336-83fd-f640b45e88c5" name="Changes" comment="">
8
8
  <change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
9
9
  <change beforePath="$PROJECT_DIR$/docs.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs.md" afterDir="false" />
10
- <change beforePath="$PROJECT_DIR$/pyproject.toml" beforeDir="false" afterPath="$PROJECT_DIR$/pyproject.toml" afterDir="false" />
11
- <change beforePath="$PROJECT_DIR$/src/encode/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/__init__.py" afterDir="false" />
12
- <change beforePath="$PROJECT_DIR$/src/encode/errors.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/errors.py" afterDir="false" />
13
- <change beforePath="$PROJECT_DIR$/src/encode/messages.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/messages.py" afterDir="false" />
10
+ <change beforePath="$PROJECT_DIR$/docs/cookbook.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/cookbook.md" afterDir="false" />
11
+ <change beforePath="$PROJECT_DIR$/docs/intercept.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/intercept.md" afterDir="false" />
12
+ <change beforePath="$PROJECT_DIR$/docs/sessions.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/sessions.md" afterDir="false" />
13
+ <change beforePath="$PROJECT_DIR$/docs/tools.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/tools.md" afterDir="false" />
14
+ <change beforePath="$PROJECT_DIR$/src/encode/events.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/events.py" afterDir="false" />
14
15
  <change beforePath="$PROJECT_DIR$/src/encode/relay.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/relay.py" afterDir="false" />
15
- <change beforePath="$PROJECT_DIR$/src/encode/session.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/terminal.py" afterDir="false" />
16
- <change beforePath="$PROJECT_DIR$/tests/test_session.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_terminal.py" afterDir="false" />
16
+ <change beforePath="$PROJECT_DIR$/src/encode/session.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/session.py" afterDir="false" />
17
+ <change beforePath="$PROJECT_DIR$/src/encode/tools.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/encode/tools.py" afterDir="false" />
17
18
  </list>
18
19
  <option name="SHOW_DIALOG" value="false" />
19
20
  <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -21,7 +22,7 @@
21
22
  <option name="LAST_RESOLUTION" value="IGNORE" />
22
23
  </component>
23
24
  <component name="EmbeddingIndexingInfo">
24
- <option name="cachedIndexableFilesCount" value="52" />
25
+ <option name="cachedIndexableFilesCount" value="77" />
25
26
  <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
26
27
  </component>
27
28
  <component name="Git.Settings">
@@ -83,7 +84,18 @@
83
84
  <workItem from="1778423372046" duration="273000" />
84
85
  <workItem from="1778423984110" duration="32000" />
85
86
  <workItem from="1778512211978" duration="598000" />
86
- <workItem from="1778554267806" duration="729000" />
87
+ <workItem from="1778554267806" duration="2737000" />
88
+ <workItem from="1778562018820" duration="121000" />
89
+ <workItem from="1778562226220" duration="194000" />
90
+ <workItem from="1778562580231" duration="10000" />
91
+ <workItem from="1778562812770" duration="145000" />
92
+ <workItem from="1778563167441" duration="597000" />
93
+ <workItem from="1778563885595" duration="136000" />
94
+ <workItem from="1778564171039" duration="6000" />
95
+ <workItem from="1778565065245" duration="21000" />
96
+ <workItem from="1778566926702" duration="15000" />
97
+ <workItem from="1778567092312" duration="17000" />
98
+ <workItem from="1778617494707" duration="2069000" />
87
99
  </task>
88
100
  <servers />
89
101
  </component>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: courier-encode
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Python SDK for OpenAI-compatible inference endpoints (Courier and friends) with auto tool-call loops, structured outputs, and Whisper.
5
5
  Project-URL: Homepage, https://getcourier.ai
6
6
  Project-URL: Documentation, https://getcourier.ai/docs
@@ -359,6 +359,21 @@ def trim(event):
359
359
  encode.relay(..., tools=[noisy_tool], on_intercept=trim).response
360
360
  ```
361
361
 
362
+ **Auto tool discovery** — the model calls a `list_tools` bootstrap, an intercept registers the discovered tools on the session, the next iteration uses them.
363
+
364
+ ```python
365
+ session = encode.Session.open(tools=[list_tools])
366
+
367
+ def discover(event):
368
+ for tc in event.tool_calls:
369
+ if tc.name == "list_tools":
370
+ for spec in tc.result or []:
371
+ event.register_tool(IMPLS[spec["function"]["name"]])
372
+
373
+ encode.relay(model="m", messages=[...], session=session,
374
+ tools=session.tools, on_intercept=discover).response
375
+ ```
376
+
362
377
  **A bash sandbox tool.**
363
378
 
364
379
  ```python
@@ -123,6 +123,21 @@ def trim(event):
123
123
  encode.relay(..., tools=[noisy_tool], on_intercept=trim).response
124
124
  ```
125
125
 
126
+ **Auto tool discovery** — the model calls a `list_tools` bootstrap, an intercept registers the discovered tools on the session, the next iteration uses them.
127
+
128
+ ```python
129
+ session = encode.Session.open(tools=[list_tools])
130
+
131
+ def discover(event):
132
+ for tc in event.tool_calls:
133
+ if tc.name == "list_tools":
134
+ for spec in tc.result or []:
135
+ event.register_tool(IMPLS[spec["function"]["name"]])
136
+
137
+ encode.relay(model="m", messages=[...], session=session,
138
+ tools=session.tools, on_intercept=discover).response
139
+ ```
140
+
126
141
  **A bash sandbox tool.**
127
142
 
128
143
  ```python
@@ -310,6 +310,60 @@ for w in out.words or []:
310
310
 
311
311
  ---
312
312
 
313
+ ## Auto tool discovery — `list_tools` + intercept
314
+
315
+ Single bootstrap tool, intercept appends discovered tools, the model uses them on the next turn — no restarts.
316
+
317
+ ```python
318
+ import encode
319
+
320
+ def list_tools() -> list[dict]:
321
+ """Discover available tools."""
322
+ return [
323
+ {"type": "function", "function": {
324
+ "name": "fetch",
325
+ "description": "Fetch a URL.",
326
+ "parameters": {
327
+ "type": "object",
328
+ "properties": {"url": {"type": "string"}},
329
+ "required": ["url"],
330
+ "additionalProperties": False,
331
+ },
332
+ }},
333
+ ]
334
+
335
+ def fetch(url: str) -> dict:
336
+ """Fetch a URL."""
337
+ return {"url": url, "status": 200}
338
+
339
+ IMPLS = {"fetch": fetch}
340
+
341
+ def discover(event):
342
+ for tc in event.tool_calls:
343
+ if tc.name == "list_tools":
344
+ for spec in tc.result or []:
345
+ name = spec["function"]["name"]
346
+ if name in IMPLS:
347
+ event.register_tool(IMPLS[name])
348
+
349
+ session = encode.Session.open(tools=[list_tools])
350
+ out = encode.relay(
351
+ model="gpt-4o-mini",
352
+ messages=[{"role": "user", "content": "Discover tools, then fetch example.com."}],
353
+ session=session,
354
+ tools=session.tools,
355
+ on_intercept=discover,
356
+ max_tool_iterations=10,
357
+ ).response
358
+
359
+ print(out.content)
360
+ print("registered:", [ev.data["name"] for ev in session.events_by_type("tool.registered")])
361
+ ```
362
+
363
+ Source: [`examples/tool_discovery.py`](../examples/tool_discovery.py) — [sessions.md](./sessions.md#session-owned-tools), [intercept.md](./intercept.md#auto-tool-discovery--eventregister_toolfn)
364
+
365
+ ---
366
+
313
367
  ## Branching conversations
314
368
 
315
369
  ```python
@@ -176,6 +176,53 @@ await encode.relay_async(...).intercept(cb)
176
176
 
177
177
  Async callbacks can still call the sync mutation helpers — they operate on the in-memory `Messages` view.
178
178
 
179
+ ## Auto tool discovery — `event.register_tool(fn)`
180
+
181
+ When `session=` is attached to the run, the intercept callback can register a new tool on the session's append-only registry, and the **next** iteration of the loop will see it. This unlocks a clean auto-discovery pattern: the model calls a bootstrap `list_tools` function, the intercept reads the result, and the model proceeds to call the freshly registered tools without restarting the run.
182
+
183
+ ```python
184
+ def list_tools() -> list[dict]:
185
+ """Discover available tools."""
186
+ return [
187
+ {"type": "function", "function": {"name": "fetch", "description": "...",
188
+ "parameters": {...}}},
189
+ ]
190
+
191
+ def fetch(url: str) -> dict:
192
+ """Fetch a URL."""
193
+ ...
194
+
195
+ IMPLS = {"fetch": fetch}
196
+
197
+ def discover(event):
198
+ for tc in event.tool_calls:
199
+ if tc.name != "list_tools":
200
+ continue
201
+ for spec in tc.result or []:
202
+ name = spec["function"]["name"]
203
+ if name in IMPLS:
204
+ event.register_tool(IMPLS[name]) # binds callable for dispatch
205
+
206
+ session = encode.Session.open(tools=[list_tools])
207
+ encode.relay(
208
+ model="gpt-4o-mini",
209
+ messages=[{"role": "user", "content": "discover, then use what you find"}],
210
+ session=session,
211
+ tools=session.tools, # ← live registry
212
+ on_intercept=discover,
213
+ ).response
214
+ ```
215
+
216
+ Rules:
217
+
218
+ - `event.register_tool(tool)` requires the run to have a `session=`. Otherwise it raises.
219
+ - Idempotent: a same-name registration is a no-op. Returns `True` if newly registered, `False` if skipped.
220
+ - Each registration emits a `tool.registered` event into the session log with `by="intercept"`.
221
+ - For the new tool to appear in the model's next iteration, you must have passed `tools=session.tools` to `relay()` (so the relay loop is reading from the same list the intercept mutates).
222
+ - Works on both the chat and responses endpoints, and across streaming + non-streaming.
223
+
224
+ See [sessions.md → Session-owned tools](./sessions.md#session-owned-tools) for the registry API itself (`register_tool` / `register_tools` / `rebind_tools` / `Session.resume`).
225
+
179
226
  ## Cookbook — full agent + stop()
180
227
 
181
228
  ```python
@@ -67,6 +67,7 @@ Standard types (`EventType.*`):
67
67
  | `ASSISTANT_MESSAGE` | `assistant.message` | `{"content": str | None, "tool_calls": [...] | None}` |
68
68
  | `TOOL_CALL` | `tool.call` | `{"id": str, "name": str, "arguments": dict, "iteration": int}` |
69
69
  | `TOOL_RESULT` | `tool.result` | `{"id": str, "result": Any, "result_serialized": str, "error": str | None, "duration_ms": float}` |
70
+ | `TOOL_REGISTERED` | `tool.registered` | `{"name": str, "schema": dict, "by": str}` (emitted by `register_tool` / `Session.open(tools=...)` / `rebind_tools`) |
70
71
  | `ITERATION_END` | `iteration.end` | `{"iteration": int, "had_tool_calls": bool, "finish_reason": str | None}` |
71
72
  | `CONTEXT_MODIFY` | `context.modify` | `{"by": str, "summary": str, ...}` (emitted by Intercept mutations) |
72
73
  | `SYSTEM` | `system` | `{"content": str}` |
@@ -200,6 +201,76 @@ Recommended patterns:
200
201
  - **Single writer**: one process owns the in-memory `Session`; persist after each `relay()` call.
201
202
  - **Atomic append at the DB layer**: write events one at a time (e.g. `INSERT` with serial id from a sequence) and rehydrate before each `relay()`.
202
203
 
204
+ ## Session-owned tools
205
+
206
+ A Session can also own an **append-only tool registry** — pass `tools=session.tools` to `relay()` and you can grow the registry mid-loop (typically from an intercept callback) so a single agent run can discover and start using new tools without re-launching.
207
+
208
+ ```python
209
+ def search(query: str) -> dict:
210
+ """Search the index."""
211
+ return ...
212
+
213
+ def list_tools() -> list[dict]:
214
+ """Discover available tools."""
215
+ return [...] # tool schemas as raw dicts
216
+
217
+ session = encode.Session.open(tools=[search, list_tools])
218
+
219
+ encode.relay(
220
+ model="gpt-4o-mini",
221
+ messages=[{"role": "user", "content": "discover, then use what you find"}],
222
+ session=session,
223
+ tools=session.tools, # ← the live registry
224
+ on_intercept=lambda ev: [ev.register_tool(s) for tc in ev.tool_calls
225
+ if tc.name == "list_tools" for s in (tc.result or [])],
226
+ ).response
227
+ ```
228
+
229
+ API:
230
+
231
+ ```python
232
+ session.register_tool(fn_or_dict) # idempotent: same name → no-op, returns False
233
+ session.register_tools([fn1, fn2, ...]) # bulk, returns count newly added
234
+ session.tools # list[Any] — the live registry
235
+ ```
236
+
237
+ Each `register_tool` call emits a `tool.registered` event into the durable log; the `by` field records the origin (`"user"`, `"intercept"`, `"resume"`).
238
+
239
+ ### How auto-discovery flows through the loop
240
+
241
+ The relay loop re-reads `tools=` at the top of each iteration. When it's the same list reference as `session.tools`, additions made during an intercept callback show up on the **next** iteration's request to the model. The in-process executor's dispatch table is also rebuilt automatically — your registered callable is callable next turn.
242
+
243
+ If you pass a different list as `tools=` (i.e. not `session.tools`), `event.register_tool(...)` still appends to the session and emits the audit-log event, but the new tool won't appear in the model's next request — pass `tools=session.tools` to opt in.
244
+
245
+ ### Idempotency
246
+
247
+ Same-name registrations are silently skipped. This makes auto-discovery loops safe to re-trigger: if the model calls `list_tools` twice and returns overlapping specs, only the new names are registered. Both `register_tool(fn)` and `register_tools([...])` return how many entries were *newly* added.
248
+
249
+ ### Resuming with tools
250
+
251
+ `session.model_dump()` does **not** include the live `tools` list — Python callables aren't JSON-serializable. The `tool.registered` events are part of the log, though, so they survive the round-trip. Use `rebind_tools` (or the `Session.resume` convenience class method) to bind your callables back to the session on the other side:
252
+
253
+ ```python
254
+ # round-trip — anything that survives JSON works
255
+ raw = json.dumps(session.model_dump(), default=str)
256
+
257
+ # Method 1: one-liner via Session.resume
258
+ session = encode.Session.resume(json.loads(raw), tools=[search, list_tools])
259
+
260
+ # Method 2: split into validate + rebind
261
+ session = encode.Session.model_validate(json.loads(raw))
262
+ missing = session.rebind_tools([search, list_tools])
263
+ if missing:
264
+ print(f"missing callables for: {missing}") # names from the log without a binding
265
+
266
+ # Continue the run
267
+ encode.relay(model="m", messages=[...], session=session, tools=session.tools).response
268
+ ```
269
+
270
+ `rebind_tools` walks `tool.registered` events in order, matches each name against the callables you supplied (by `__name__` for functions, `function.name` / `name` for dicts), and registers them with `by="resume"`. It's idempotent: calling it twice on the same session is safe.
271
+
272
+ Async parity: `aregister_tool` / `aregister_tools` / `arebind_tools` on `AsyncSession`, and `AsyncSession.resume`.
273
+
203
274
  ## Custom events
204
275
 
205
276
  Emit whatever you want. Useful for application-level audit:
@@ -173,9 +173,23 @@ def with_objects(q: str) -> dict: # default=str handles datetimes etc.
173
173
  return {"created_at": datetime.now(), "rows": 3}
174
174
  ```
175
175
 
176
+ ## Tools that live on a Session
177
+
178
+ Tools can also be stored on a `Session` — pass `tools=session.tools` to `relay()` and an intercept handler can append new tools mid-loop (auto-discovery). The session keeps an append-only `tool.registered` audit log. Idempotent: same-name registrations are no-ops.
179
+
180
+ ```python
181
+ session = encode.Session.open(tools=[search])
182
+ session.register_tool(fetch) # appends + emits tool.registered
183
+
184
+ encode.relay(model="m", messages=[...], session=session, tools=session.tools).response
185
+ ```
186
+
187
+ See [sessions.md → Session-owned tools](./sessions.md#session-owned-tools) and [intercept.md → Auto tool discovery](./intercept.md#auto-tool-discovery--eventregister_toolfn).
188
+
176
189
  ## See also
177
190
 
178
191
  - [intercept.md](./intercept.md) — observe / mutate between iterations
192
+ - [sessions.md](./sessions.md) — session-owned tool registry
179
193
  - [executors.md](./executors.md) — swap dispatch (remote, MCP, sub-agent)
180
194
  - [terminal.md](./terminal.md) — the canonical stateful tool pattern
181
195
  - [errors.md](./errors.md) — `MaxToolIterationsError`, `InvalidToolCallError`
@@ -87,18 +87,32 @@ RelayHandle.execute() # run, return RelayResponse
87
87
  RelayHandle.response # property: run + memoize
88
88
  iter(RelayHandle) # stream events
89
89
 
90
+ # Intercept (mutable mid-loop)
91
+ event.append/.insert/.replace/.compact/.edit_last_tool_result # mutate messages
92
+ event.stop() # halt the loop
93
+ event.register_tool(fn_or_dict) # session-required
94
+
90
95
  AsyncRelayHandle.intercept(cb)
91
96
  await AsyncRelayHandle # awaitable directly
92
97
  await AsyncRelayHandle.execute()
93
98
  async for event in AsyncRelayHandle: ...
94
99
 
95
100
  # Sessions (durable event log)
96
- encode.Session.open(id=None, metadata=None)
101
+ encode.Session.open(id=None, metadata=None, tools=None)
102
+ encode.Session.resume(data, tools=()) # model_validate + rebind_tools
97
103
  encode.AsyncSession.open(...)
104
+ encode.AsyncSession.resume(data, tools=())
105
+ # Per-session tool registry (append-only, idempotent by name)
106
+ session.tools # list[Any], excluded from model_dump
107
+ session.register_tool(fn_or_dict) # returns True if newly added
108
+ session.register_tools([...]) # bulk, returns count newly added
109
+ session.rebind_tools([...]) # returns list[str] of unmatched names
98
110
  encode.Event # factory classmethods: user_message, assistant_message,
99
- # tool_call, tool_result, iteration_end, system, custom, ...
111
+ # tool_call, tool_result, tool_registered, iteration_end,
112
+ # system, custom, ...
100
113
  encode.EventType # USER_MESSAGE, ASSISTANT_MESSAGE, TOOL_CALL, TOOL_RESULT,
101
- # ITERATION_END, CONTEXT_MODIFY, SYSTEM, CUSTOM
114
+ # TOOL_REGISTERED, ITERATION_END, CONTEXT_MODIFY, SYSTEM,
115
+ # CUSTOM
102
116
 
103
117
  # Executors (the brain↔hands seam)
104
118
  encode.ToolExecutor # Protocol
@@ -0,0 +1,120 @@
1
+ """Auto tool discovery via Session-owned tools + intercept.
2
+
3
+ Pattern: the model has a single bootstrap tool (`list_tools`) that returns
4
+ specs for additional tools. An intercept handler reads those specs, calls
5
+ ``event.register_tool(...)`` to append them to the session's append-only
6
+ tool registry, and the *next* iteration of the relay loop sees them — the
7
+ model can immediately call them.
8
+
9
+ Run against any OpenAI-compatible endpoint with ENCODE_API_KEY +
10
+ ENCODE_BASE_URL set; pick a model that supports tool-calling. The print
11
+ statements below show the discovery flow.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import encode
17
+
18
+ # --- the bootstrap tool ---
19
+
20
+
21
+ def list_tools() -> list[dict]:
22
+ """List tools that can be registered on this session.
23
+
24
+ Returns OpenAI-style tool schemas. Pair with an intercept handler that
25
+ calls ``event.register_tool(spec)`` so the model sees them next turn.
26
+ """
27
+ return [
28
+ {
29
+ "type": "function",
30
+ "function": {
31
+ "name": "fetch",
32
+ "description": "Fetch a URL and return its body.",
33
+ "parameters": {
34
+ "type": "object",
35
+ "properties": {"url": {"type": "string"}},
36
+ "required": ["url"],
37
+ "additionalProperties": False,
38
+ },
39
+ },
40
+ },
41
+ {
42
+ "type": "function",
43
+ "function": {
44
+ "name": "summarize",
45
+ "description": "Summarize a block of text.",
46
+ "parameters": {
47
+ "type": "object",
48
+ "properties": {"text": {"type": "string"}},
49
+ "required": ["text"],
50
+ "additionalProperties": False,
51
+ },
52
+ },
53
+ },
54
+ ]
55
+
56
+
57
+ # --- the actual implementations (registered as callables once discovered) ---
58
+
59
+
60
+ def fetch(url: str) -> dict:
61
+ """Fetch a URL."""
62
+ # placeholder — wire up httpx in real code
63
+ return {"url": url, "status": 200, "body": "..."}
64
+
65
+
66
+ def summarize(text: str) -> dict:
67
+ """Summarize text."""
68
+ return {"summary": text[:80]}
69
+
70
+
71
+ IMPLS = {"fetch": fetch, "summarize": summarize}
72
+
73
+
74
+ def discover(event: encode.InterceptEvent) -> None:
75
+ """Intercept handler: register discovered tools on the session."""
76
+ for tc in event.tool_calls:
77
+ if tc.name != "list_tools":
78
+ continue
79
+ for spec in tc.result or []:
80
+ name = spec.get("function", {}).get("name") or ""
81
+ impl = IMPLS.get(name)
82
+ if impl is None:
83
+ # we don't have a Python implementation — skip
84
+ continue
85
+ registered = event.register_tool(impl)
86
+ if registered:
87
+ print(f" ↳ discovered + registered: {name}")
88
+
89
+
90
+ def main() -> None:
91
+ session = encode.Session.open(tools=[list_tools])
92
+
93
+ out = encode.relay(
94
+ model="gpt-4o-mini",
95
+ messages=[
96
+ {
97
+ "role": "user",
98
+ "content": (
99
+ "Discover the tools available to you, then fetch "
100
+ "https://example.com and summarize the body."
101
+ ),
102
+ }
103
+ ],
104
+ session=session,
105
+ tools=session.tools,
106
+ on_intercept=discover,
107
+ max_tool_iterations=10,
108
+ ).response
109
+
110
+ print()
111
+ print("final answer:", out.content)
112
+ print("iterations:", out.iterations)
113
+ print(
114
+ "registered tools (final):",
115
+ [ev.data["name"] for ev in session.events_by_type("tool.registered")],
116
+ )
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "courier-encode"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Python SDK for OpenAI-compatible inference endpoints (Courier and friends) with auto tool-call loops, structured outputs, and Whisper."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -32,6 +32,7 @@ class EventType:
32
32
  ASSISTANT_MESSAGE = "assistant.message"
33
33
  TOOL_CALL = "tool.call"
34
34
  TOOL_RESULT = "tool.result"
35
+ TOOL_REGISTERED = "tool.registered"
35
36
  ITERATION_END = "iteration.end"
36
37
  CONTEXT_MODIFY = "context.modify"
37
38
  SYSTEM = "system"
@@ -52,6 +53,7 @@ class Event(BaseModel):
52
53
  - ``tool.call`` : ``{"id": str, "name": str, "arguments": dict, "iteration": int}``
53
54
  - ``tool.result`` : ``{"id": str, "result": Any, "result_serialized": str,
54
55
  "error": str | None, "duration_ms": float}``
56
+ - ``tool.registered`` : ``{"name": str, "schema": dict, "by": str}``
55
57
  - ``iteration.end`` : ``{"iteration": int, "had_tool_calls": bool,
56
58
  "finish_reason": str | None}``
57
59
  - ``context.modify`` : ``{"by": str, "summary": str, ...}``
@@ -127,6 +129,19 @@ class Event(BaseModel):
127
129
  },
128
130
  )
129
131
 
132
+ @classmethod
133
+ def tool_registered(
134
+ cls,
135
+ *,
136
+ name: str,
137
+ schema: dict[str, Any],
138
+ by: str = "user",
139
+ ) -> Event:
140
+ return cls(
141
+ type=EventType.TOOL_REGISTERED,
142
+ data={"name": name, "schema": dict(schema), "by": by},
143
+ )
144
+
130
145
  @classmethod
131
146
  def iteration_end(
132
147
  cls,