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.
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/workspace.xml +20 -8
- {courier_encode-0.1.3 → courier_encode-0.1.4}/PKG-INFO +16 -1
- {courier_encode-0.1.3 → courier_encode-0.1.4}/README.md +15 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/cookbook.md +54 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/intercept.md +47 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/sessions.md +71 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/tools.md +14 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs.md +17 -3
- courier_encode-0.1.4/examples/tool_discovery.py +120 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/pyproject.toml +1 -1
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/events.py +15 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/relay.py +143 -61
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/session.py +110 -2
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/tools.py +33 -2
- courier_encode-0.1.4/tests/test_intercept_tool_discovery.py +243 -0
- courier_encode-0.1.4/tests/test_session_tools.py +148 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.gitignore +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/.gitignore +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/encode.iml +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/inspectionProfiles/Project_Default.xml +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/misc.xml +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/modules.xml +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/.idea/vcs.xml +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/LICENSE +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/async.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/concepts.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/errors.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/executors.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/messages.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/quickstart.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/relay.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/streaming.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/structured-output.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/terminal.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/docs/whisper.md +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/basic_chat.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/intercept.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/intercept_compact.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/responses_endpoint.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/session_resume.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/stateful_tool.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/structured_output.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/tools_loop.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/examples/whisper_transcribe.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/__init__.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_config.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_http.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_schema.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_streaming.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/_version.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/client.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/errors.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/executor.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/messages.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/py.typed +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/responses.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/terminal.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/src/encode/whisper.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/__init__.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/conftest.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_async.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_errors.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_executor.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_intercept_mutation.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_messages.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_messages_obj.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_relay_chat.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_relay_responses.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_relay_session.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_response_format.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_session.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_streaming.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_terminal.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_tool_loop.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_tools_schema.py +0 -0
- {courier_encode-0.1.3 → courier_encode-0.1.4}/tests/test_whisper.py +0 -0
- {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$/
|
|
11
|
-
<change beforePath="$PROJECT_DIR$/
|
|
12
|
-
<change beforePath="$PROJECT_DIR$/
|
|
13
|
-
<change beforePath="$PROJECT_DIR$/
|
|
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/
|
|
16
|
-
<change beforePath="$PROJECT_DIR$/
|
|
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="
|
|
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="
|
|
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
|
+
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,
|
|
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,
|
|
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.
|
|
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,
|