nullprotocol 0.2.0__tar.gz → 0.4.0__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.
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/CHANGELOG.md +10 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/PKG-INFO +27 -6
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/README.md +26 -5
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/examples/knowledge_management.py +1 -14
- nullprotocol-0.4.0/examples/photos.py +26 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/pyproject.toml +1 -1
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/__init__.py +3 -2
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/_agent.py +35 -23
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/_common.py +47 -12
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/_errors.py +21 -1
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/_knowledge.py +109 -5
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/_transport.py +26 -4
- nullprotocol-0.4.0/tests/test_images.py +67 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/tests/test_knowledge.py +132 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/tests/test_sdk.py +40 -10
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/.github/workflows/test.yml +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/.gitignore +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/LICENSE +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/RELEASING.md +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/examples/actions.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/examples/async_basic.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/examples/basic.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/examples/knowledge.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/examples/scoped_data.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/_client.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/_stores.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/_types.py +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/src/nullprotocol/py.typed +0 -0
- {nullprotocol-0.2.0 → nullprotocol-0.4.0}/tests/conftest.py +0 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- Add sync/async Knowledge wait_until_ready for exact revisions, metadata polling and bounded timeouts; async task cancellation cancels reads.
|
|
6
|
+
- Send action parameter schemas as first-class choices (API0.24+) so the engine can repair them before success; reject schema-invalid successful server replies.
|
|
7
|
+
- Clarify Context key/value errors and JSON null support. Align the exported __version__ with package metadata.
|
|
8
|
+
|
|
9
|
+
## 0.3.0
|
|
10
|
+
|
|
11
|
+
- Add images to all four sync/async operations, with HTTPS URL validation, explicit model capability refusals and unchanged text requests.
|
|
12
|
+
|
|
3
13
|
## 0.2.0 — 2026-11-06
|
|
4
14
|
|
|
5
15
|
- Agent and Space Knowledge management, including source CRUD, state, preparation retry, connections and embedding usage.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nullprotocol
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Focused AI tasks with managed context, Knowledge and observable results
|
|
5
5
|
Project-URL: Homepage, https://nullprotocol.ai
|
|
6
6
|
Project-URL: Documentation, https://nullprotocol.ai/docs/python
|
|
@@ -156,9 +156,8 @@ shared = agent.space.knowledge.add(
|
|
|
156
156
|
name="Returns policy",
|
|
157
157
|
text="# Returns\nUnused goods may be returned within 30 days.",
|
|
158
158
|
)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
agent.knowledge.connect(shared["id"])
|
|
159
|
+
agent.space.knowledge.wait_until_ready(shared)
|
|
160
|
+
agent.knowledge.connect(shared["id"])
|
|
162
161
|
|
|
163
162
|
own = agent.knowledge.add(name="FAQ", text="We ship on weekdays.")
|
|
164
163
|
changed = agent.knowledge.update(
|
|
@@ -171,7 +170,7 @@ agent.knowledge.delete(own["id"], if_version=changed["version"])
|
|
|
171
170
|
|
|
172
171
|
Use these snippets inside the client context above. `add`/`update` return `id`, `revision` and `version`; preparation is asynchronous. Inspect `get(id)["state"]`: preparing, ready or failed, plus `activeRevision`, `error` and `retryScheduled`. For a live agent, stage a new document in the Space library and connect it after it is ready. An unprepared new agent-owned/connected source refuses Knowledge calls; calls without Knowledge continue. A replacement keeps the previous prepared revision in use until the new one is ready.
|
|
173
172
|
|
|
174
|
-
Both stores provide `add`, `list`, `get`, `update`, `delete` and `retry`. `agent.knowledge.list()` lists only agent-owned documents. `agent.space.knowledge.list()` lists the shared library, and `agent.knowledge.connections()` lists connected Space sources. Read/update/delete through the owning store; connecting does not copy a document. Use `connect(id)`/`disconnect(id)` on `agent.knowledge`. Scope IDs do not change Knowledge ownership or connections.
|
|
173
|
+
Both stores provide `add`, `list`, `get`, `wait_until_ready`, `update`, `delete` and `retry`. `agent.knowledge.list()` lists only agent-owned documents. `agent.space.knowledge.list()` lists the shared library, and `agent.knowledge.connections()` lists connected Space sources. Read/update/delete through the owning store; connecting does not copy a document. Use `connect(id)`/`disconnect(id)` on `agent.knowledge`. Scope IDs do not change Knowledge ownership or connections.
|
|
175
174
|
|
|
176
175
|
Updates and deletes require `if_version`; a competing edit raises `APIError` with code `version_conflict`. The SDK never silently substitutes a newer version. A rename changes the version but not the text revision. `retry(id)` explicitly restarts failed preparation and returns a `retrying` flag; it does not wait or reindex a ready document. Preparation and searches can use embedding tokens. `agent.space.knowledge.usage()` returns 30-day Space totals: `preparation`, `search`, `uncounted` and `requests`, preserving unknown sums as None and reported sums as decimal strings.
|
|
177
176
|
|
|
@@ -206,7 +205,7 @@ if decision.success:
|
|
|
206
205
|
print(execution.success, execution.outcome, execution.history_error)
|
|
207
206
|
```
|
|
208
207
|
|
|
209
|
-
Handlers receive `(parameters, ActionContext)`. `context.input` is the original task; `context.decision` is a copy of its decision snapshot.
|
|
208
|
+
Handlers receive `(parameters, ActionContext)`. `context.input` is the original task; `context.decision` is a copy of its decision snapshot. Registered parameter schemas travel with the action choices. The hosted engine checks and repairs them before a successful decision, and execute rechecks them locally. Inline choices may also provide `parameters: schema`. The original task stays unchanged and is the input for automatic Knowledge search. These schema-bearing choices require API 0.24 or later.
|
|
210
209
|
|
|
211
210
|
Before a handler runs, `execute` rereads pause/disabled settings, validates its parameters against the registered JSON Schema and checks the optional guard. A guard must return exactly `True`. Changing the returned result or values in a guard does not change the checked execution snapshot. Only an original successful decision from that agent object can be executed; a JSON/HTTP response loaded elsewhere is not executable. There is no implicit `chat` action or automatic tool loop.
|
|
212
211
|
|
|
@@ -266,3 +265,25 @@ mypy src
|
|
|
266
265
|
```
|
|
267
266
|
|
|
268
267
|
Tests use a scripted HTTP fixture, not paid providers. The workspace integration proof also runs this client against the actual API/gateway and an isolated PostgreSQL database with scripted generation/embeddings. These checks verify integration, not model accuracy.
|
|
268
|
+
|
|
269
|
+
## Photo input
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
result = agent.extract(
|
|
273
|
+
"Identify the landmark.",
|
|
274
|
+
{"landmark": "string"},
|
|
275
|
+
images=["https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80"],
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Use the loaded agent inside your client context. All four operations support up to four reachable HTTPS photo URLs of at most 2048 UTF-16 characters. Async clients accept the same keyword. Select a Vision-capable model. Empty text is allowed for photo-only input; validate still needs criteria and decide needs its actions. Inline base64, browser blob URLs and local files are not supported.
|
|
280
|
+
|
|
281
|
+
History retains URLs, not immutable copies of remote images. Knowledge uses a text query, not the photo or its URL; supply an explicit query for a photo-only search. Generation usage includes provider-reported image tokens. The gateway conservatively holds credit against the model context ceiling for photo calls, settles actual reported usage and releases the unused reserve. [Photo guide](https://nullprotocol.ai/docs/images?lang=python).
|
|
282
|
+
|
|
283
|
+
## Wait for preparation
|
|
284
|
+
|
|
285
|
+
`agent.knowledge.wait_until_ready(source, timeout=120)` accepts the revision returned by `add`, `update` or `get` and waits for that exact version using metadata only. Timeout is seconds. Both agent and Space stores support it. On an async agent, await the method; cancelling the task cancels waiting and the in-flight read. Server preparation continues.
|
|
286
|
+
|
|
287
|
+
The method waits through server-scheduled retries without starting new ones. It raises `KnowledgeError` with code `version_conflict`, `knowledge_not_found`, `knowledge_prepare_failed` or `knowledge_wait_timeout`. The returned dictionary contains metadata, not the full text.
|
|
288
|
+
|
|
289
|
+
Saved Context keys have 1–64 lowercase letters, digits, dots, underscores or hyphens, starting with a letter. JSON `None` is a stored null value; use `delete` to remove it.
|
|
@@ -126,9 +126,8 @@ shared = agent.space.knowledge.add(
|
|
|
126
126
|
name="Returns policy",
|
|
127
127
|
text="# Returns\nUnused goods may be returned within 30 days.",
|
|
128
128
|
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
agent.knowledge.connect(shared["id"])
|
|
129
|
+
agent.space.knowledge.wait_until_ready(shared)
|
|
130
|
+
agent.knowledge.connect(shared["id"])
|
|
132
131
|
|
|
133
132
|
own = agent.knowledge.add(name="FAQ", text="We ship on weekdays.")
|
|
134
133
|
changed = agent.knowledge.update(
|
|
@@ -141,7 +140,7 @@ agent.knowledge.delete(own["id"], if_version=changed["version"])
|
|
|
141
140
|
|
|
142
141
|
Use these snippets inside the client context above. `add`/`update` return `id`, `revision` and `version`; preparation is asynchronous. Inspect `get(id)["state"]`: preparing, ready or failed, plus `activeRevision`, `error` and `retryScheduled`. For a live agent, stage a new document in the Space library and connect it after it is ready. An unprepared new agent-owned/connected source refuses Knowledge calls; calls without Knowledge continue. A replacement keeps the previous prepared revision in use until the new one is ready.
|
|
143
142
|
|
|
144
|
-
Both stores provide `add`, `list`, `get`, `update`, `delete` and `retry`. `agent.knowledge.list()` lists only agent-owned documents. `agent.space.knowledge.list()` lists the shared library, and `agent.knowledge.connections()` lists connected Space sources. Read/update/delete through the owning store; connecting does not copy a document. Use `connect(id)`/`disconnect(id)` on `agent.knowledge`. Scope IDs do not change Knowledge ownership or connections.
|
|
143
|
+
Both stores provide `add`, `list`, `get`, `wait_until_ready`, `update`, `delete` and `retry`. `agent.knowledge.list()` lists only agent-owned documents. `agent.space.knowledge.list()` lists the shared library, and `agent.knowledge.connections()` lists connected Space sources. Read/update/delete through the owning store; connecting does not copy a document. Use `connect(id)`/`disconnect(id)` on `agent.knowledge`. Scope IDs do not change Knowledge ownership or connections.
|
|
145
144
|
|
|
146
145
|
Updates and deletes require `if_version`; a competing edit raises `APIError` with code `version_conflict`. The SDK never silently substitutes a newer version. A rename changes the version but not the text revision. `retry(id)` explicitly restarts failed preparation and returns a `retrying` flag; it does not wait or reindex a ready document. Preparation and searches can use embedding tokens. `agent.space.knowledge.usage()` returns 30-day Space totals: `preparation`, `search`, `uncounted` and `requests`, preserving unknown sums as None and reported sums as decimal strings.
|
|
147
146
|
|
|
@@ -176,7 +175,7 @@ if decision.success:
|
|
|
176
175
|
print(execution.success, execution.outcome, execution.history_error)
|
|
177
176
|
```
|
|
178
177
|
|
|
179
|
-
Handlers receive `(parameters, ActionContext)`. `context.input` is the original task; `context.decision` is a copy of its decision snapshot.
|
|
178
|
+
Handlers receive `(parameters, ActionContext)`. `context.input` is the original task; `context.decision` is a copy of its decision snapshot. Registered parameter schemas travel with the action choices. The hosted engine checks and repairs them before a successful decision, and execute rechecks them locally. Inline choices may also provide `parameters: schema`. The original task stays unchanged and is the input for automatic Knowledge search. These schema-bearing choices require API 0.24 or later.
|
|
180
179
|
|
|
181
180
|
Before a handler runs, `execute` rereads pause/disabled settings, validates its parameters against the registered JSON Schema and checks the optional guard. A guard must return exactly `True`. Changing the returned result or values in a guard does not change the checked execution snapshot. Only an original successful decision from that agent object can be executed; a JSON/HTTP response loaded elsewhere is not executable. There is no implicit `chat` action or automatic tool loop.
|
|
182
181
|
|
|
@@ -236,3 +235,25 @@ mypy src
|
|
|
236
235
|
```
|
|
237
236
|
|
|
238
237
|
Tests use a scripted HTTP fixture, not paid providers. The workspace integration proof also runs this client against the actual API/gateway and an isolated PostgreSQL database with scripted generation/embeddings. These checks verify integration, not model accuracy.
|
|
238
|
+
|
|
239
|
+
## Photo input
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
result = agent.extract(
|
|
243
|
+
"Identify the landmark.",
|
|
244
|
+
{"landmark": "string"},
|
|
245
|
+
images=["https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80"],
|
|
246
|
+
)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Use the loaded agent inside your client context. All four operations support up to four reachable HTTPS photo URLs of at most 2048 UTF-16 characters. Async clients accept the same keyword. Select a Vision-capable model. Empty text is allowed for photo-only input; validate still needs criteria and decide needs its actions. Inline base64, browser blob URLs and local files are not supported.
|
|
250
|
+
|
|
251
|
+
History retains URLs, not immutable copies of remote images. Knowledge uses a text query, not the photo or its URL; supply an explicit query for a photo-only search. Generation usage includes provider-reported image tokens. The gateway conservatively holds credit against the model context ceiling for photo calls, settles actual reported usage and releases the unused reserve. [Photo guide](https://nullprotocol.ai/docs/images?lang=python).
|
|
252
|
+
|
|
253
|
+
## Wait for preparation
|
|
254
|
+
|
|
255
|
+
`agent.knowledge.wait_until_ready(source, timeout=120)` accepts the revision returned by `add`, `update` or `get` and waits for that exact version using metadata only. Timeout is seconds. Both agent and Space stores support it. On an async agent, await the method; cancelling the task cancels waiting and the in-flight read. Server preparation continues.
|
|
256
|
+
|
|
257
|
+
The method waits through server-scheduled retries without starting new ones. It raises `KnowledgeError` with code `version_conflict`, `knowledge_not_found`, `knowledge_prepare_failed` or `knowledge_wait_timeout`. The returned dictionary contains metadata, not the full text.
|
|
258
|
+
|
|
259
|
+
Saved Context keys have 1–64 lowercase letters, digits, dots, underscores or hyphens, starting with a letter. JSON `None` is a stored null value; use `delete` to remove it.
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Set NULLPROTOCOL_KEY and create support first. Uses hosted tokens; cleans up its source."""
|
|
2
2
|
|
|
3
|
-
import time
|
|
4
|
-
|
|
5
3
|
from nullprotocol import NullProtocol, NullProtocolError
|
|
6
4
|
|
|
7
5
|
with NullProtocol() as client:
|
|
@@ -12,18 +10,7 @@ with NullProtocol() as client:
|
|
|
12
10
|
)
|
|
13
11
|
connected = False
|
|
14
12
|
try:
|
|
15
|
-
|
|
16
|
-
while True:
|
|
17
|
-
status = agent.space.knowledge.get(source["id"])
|
|
18
|
-
if status["version"] != source["version"]:
|
|
19
|
-
raise RuntimeError("The source was edited by another client.")
|
|
20
|
-
if status["state"] == "ready":
|
|
21
|
-
break
|
|
22
|
-
if status["state"] == "failed":
|
|
23
|
-
raise RuntimeError(f"Preparation failed: {status['error']}")
|
|
24
|
-
if time.monotonic() >= deadline:
|
|
25
|
-
raise TimeoutError("Knowledge is still preparing. Try again later.")
|
|
26
|
-
time.sleep(0.25)
|
|
13
|
+
agent.space.knowledge.wait_until_ready(source, timeout=30)
|
|
27
14
|
agent.knowledge.connect(source["id"])
|
|
28
15
|
connected = True
|
|
29
16
|
result = agent.extract(
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Run a saved Vision agent. Set NULLPROTOCOL_KEY on your backend."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from nullprotocol import NullProtocol
|
|
6
|
+
|
|
7
|
+
with NullProtocol() as client:
|
|
8
|
+
agent = client.load(os.environ.get("AGENT_ID", "photo-reader"))
|
|
9
|
+
result = agent.extract(
|
|
10
|
+
"Identify the landmark in the photo.",
|
|
11
|
+
{"landmark": "string"},
|
|
12
|
+
images=[
|
|
13
|
+
os.environ.get(
|
|
14
|
+
"PHOTO_URL",
|
|
15
|
+
"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80",
|
|
16
|
+
)
|
|
17
|
+
],
|
|
18
|
+
)
|
|
19
|
+
if not result.success:
|
|
20
|
+
print(result.error_code, result.error)
|
|
21
|
+
raise SystemExit(1)
|
|
22
|
+
print(result.data)
|
|
23
|
+
print(result.usage)
|
|
24
|
+
if result.history_error:
|
|
25
|
+
print("History was not saved:", result.history_error)
|
|
26
|
+
raise SystemExit(1)
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from ._agent import Agent, AsyncAgent
|
|
4
4
|
from ._client import AsyncNullProtocol, NullProtocol
|
|
5
|
-
from ._errors import APIError, NullProtocolError, ProtocolError, TransportError
|
|
5
|
+
from ._errors import APIError, KnowledgeError, NullProtocolError, ProtocolError, TransportError
|
|
6
6
|
from ._knowledge import AgentKnowledge, AsyncAgentKnowledge, AsyncKnowledgeLibrary, KnowledgeLibrary
|
|
7
7
|
from ._types import ActionContext, Execution, KnowledgeUsage, Result, Usage
|
|
8
8
|
|
|
9
|
-
__version__ = "0.
|
|
9
|
+
__version__ = "0.4.0"
|
|
10
10
|
__all__ = [
|
|
11
11
|
"AgentKnowledge",
|
|
12
12
|
"KnowledgeLibrary",
|
|
@@ -19,6 +19,7 @@ __all__ = [
|
|
|
19
19
|
"AsyncNullProtocol",
|
|
20
20
|
"Execution",
|
|
21
21
|
"KnowledgeUsage",
|
|
22
|
+
"KnowledgeError",
|
|
22
23
|
"NullProtocol",
|
|
23
24
|
"NullProtocolError",
|
|
24
25
|
"ProtocolError",
|
|
@@ -27,7 +27,7 @@ from ._stores import (
|
|
|
27
27
|
MemoryStore,
|
|
28
28
|
SpaceContextStore,
|
|
29
29
|
)
|
|
30
|
-
from ._transport import _UNSET, AsyncTransport, Transport
|
|
30
|
+
from ._transport import _UNSET, AsyncTransport, Transport
|
|
31
31
|
from ._types import ActionContext, Execution, Result
|
|
32
32
|
|
|
33
33
|
|
|
@@ -61,20 +61,6 @@ def _result(response: dict[str, Any], task_id: str, operation: str) -> Result[An
|
|
|
61
61
|
return result
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def _knowledge_for_decision(
|
|
65
|
-
context: Any,
|
|
66
|
-
values: dict[str, Any],
|
|
67
|
-
knowledge: Knowledge | None,
|
|
68
|
-
) -> Knowledge | None:
|
|
69
|
-
if (
|
|
70
|
-
knowledge is True
|
|
71
|
-
and isinstance(values["context"], dict)
|
|
72
|
-
and "actionParameterSchemas" in values["context"]
|
|
73
|
-
):
|
|
74
|
-
return {"query": context if isinstance(context, str) else json_body(context).decode()}
|
|
75
|
-
return knowledge
|
|
76
|
-
|
|
77
|
-
|
|
78
64
|
def _changes(values: dict[str, Any]) -> dict[str, Any]:
|
|
79
65
|
mapping = {"disabled_actions": "disabledActions", "if_revision": "ifRevision"}
|
|
80
66
|
body = {mapping.get(k, k): v for k, v in values.items() if v is not None}
|
|
@@ -184,9 +170,10 @@ class Agent(AgentState):
|
|
|
184
170
|
knowledge: Knowledge | None,
|
|
185
171
|
context_keys: list[str] | None,
|
|
186
172
|
idempotency_key: str | None,
|
|
173
|
+
images: list[str] | None = None,
|
|
187
174
|
) -> Result[Any]:
|
|
188
175
|
task_id = self._task_id(idempotency_key)
|
|
189
|
-
body = operation_payload(operation, values, self.scope, knowledge, context_keys)
|
|
176
|
+
body = operation_payload(operation, values, self.scope, knowledge, context_keys, images)
|
|
190
177
|
return _result(
|
|
191
178
|
self._transport.request(
|
|
192
179
|
"POST",
|
|
@@ -203,12 +190,18 @@ class Agent(AgentState):
|
|
|
203
190
|
data: Any,
|
|
204
191
|
schema: dict[str, Any],
|
|
205
192
|
*,
|
|
193
|
+
images: list[str] | None = None,
|
|
206
194
|
knowledge: Knowledge | None = None,
|
|
207
195
|
context_keys: list[str] | None = None,
|
|
208
196
|
idempotency_key: str | None = None,
|
|
209
197
|
) -> Result[Any]:
|
|
210
198
|
return self._run(
|
|
211
|
-
"extract",
|
|
199
|
+
"extract",
|
|
200
|
+
{"data": data, "schema": schema},
|
|
201
|
+
knowledge,
|
|
202
|
+
context_keys,
|
|
203
|
+
idempotency_key,
|
|
204
|
+
images,
|
|
212
205
|
)
|
|
213
206
|
|
|
214
207
|
def summarize(
|
|
@@ -217,6 +210,7 @@ class Agent(AgentState):
|
|
|
217
210
|
*,
|
|
218
211
|
max_length: int = 200,
|
|
219
212
|
focus: str | None = None,
|
|
213
|
+
images: list[str] | None = None,
|
|
220
214
|
knowledge: Knowledge | None = None,
|
|
221
215
|
context_keys: list[str] | None = None,
|
|
222
216
|
idempotency_key: str | None = None,
|
|
@@ -227,6 +221,7 @@ class Agent(AgentState):
|
|
|
227
221
|
knowledge,
|
|
228
222
|
context_keys,
|
|
229
223
|
idempotency_key,
|
|
224
|
+
images,
|
|
230
225
|
)
|
|
231
226
|
|
|
232
227
|
def validate(
|
|
@@ -235,6 +230,7 @@ class Agent(AgentState):
|
|
|
235
230
|
subject: Any,
|
|
236
231
|
*,
|
|
237
232
|
reference: Any = _UNSET,
|
|
233
|
+
images: list[str] | None = None,
|
|
238
234
|
knowledge: Knowledge | None = None,
|
|
239
235
|
context_keys: list[str] | None = None,
|
|
240
236
|
idempotency_key: str | None = None,
|
|
@@ -245,6 +241,7 @@ class Agent(AgentState):
|
|
|
245
241
|
knowledge,
|
|
246
242
|
context_keys,
|
|
247
243
|
idempotency_key,
|
|
244
|
+
images,
|
|
248
245
|
)
|
|
249
246
|
|
|
250
247
|
def decide(
|
|
@@ -252,6 +249,7 @@ class Agent(AgentState):
|
|
|
252
249
|
context: Any,
|
|
253
250
|
actions: list[ActionChoice],
|
|
254
251
|
*,
|
|
252
|
+
images: list[str] | None = None,
|
|
255
253
|
knowledge: Knowledge | None = None,
|
|
256
254
|
context_keys: list[str] | None = None,
|
|
257
255
|
idempotency_key: str | None = None,
|
|
@@ -261,11 +259,12 @@ class Agent(AgentState):
|
|
|
261
259
|
result = self._run(
|
|
262
260
|
"decide",
|
|
263
261
|
values,
|
|
264
|
-
|
|
262
|
+
knowledge,
|
|
265
263
|
context_keys,
|
|
266
264
|
idempotency_key,
|
|
265
|
+
images,
|
|
267
266
|
)
|
|
268
|
-
return self._remember(result, original)
|
|
267
|
+
return self._remember(result, original, values["actions"])
|
|
269
268
|
|
|
270
269
|
def execute(self, decision: Result[Any]) -> Execution:
|
|
271
270
|
made, problem = self._consume(decision)
|
|
@@ -379,9 +378,10 @@ class AsyncAgent(AgentState):
|
|
|
379
378
|
knowledge: Knowledge | None,
|
|
380
379
|
context_keys: list[str] | None,
|
|
381
380
|
idempotency_key: str | None,
|
|
381
|
+
images: list[str] | None = None,
|
|
382
382
|
) -> Result[Any]:
|
|
383
383
|
task_id = self._task_id(idempotency_key)
|
|
384
|
-
body = operation_payload(operation, values, self.scope, knowledge, context_keys)
|
|
384
|
+
body = operation_payload(operation, values, self.scope, knowledge, context_keys, images)
|
|
385
385
|
return _result(
|
|
386
386
|
await self._transport.request(
|
|
387
387
|
"POST",
|
|
@@ -398,12 +398,18 @@ class AsyncAgent(AgentState):
|
|
|
398
398
|
data: Any,
|
|
399
399
|
schema: dict[str, Any],
|
|
400
400
|
*,
|
|
401
|
+
images: list[str] | None = None,
|
|
401
402
|
knowledge: Knowledge | None = None,
|
|
402
403
|
context_keys: list[str] | None = None,
|
|
403
404
|
idempotency_key: str | None = None,
|
|
404
405
|
) -> Result[Any]:
|
|
405
406
|
return await self._run(
|
|
406
|
-
"extract",
|
|
407
|
+
"extract",
|
|
408
|
+
{"data": data, "schema": schema},
|
|
409
|
+
knowledge,
|
|
410
|
+
context_keys,
|
|
411
|
+
idempotency_key,
|
|
412
|
+
images,
|
|
407
413
|
)
|
|
408
414
|
|
|
409
415
|
async def summarize(
|
|
@@ -412,6 +418,7 @@ class AsyncAgent(AgentState):
|
|
|
412
418
|
*,
|
|
413
419
|
max_length: int = 200,
|
|
414
420
|
focus: str | None = None,
|
|
421
|
+
images: list[str] | None = None,
|
|
415
422
|
knowledge: Knowledge | None = None,
|
|
416
423
|
context_keys: list[str] | None = None,
|
|
417
424
|
idempotency_key: str | None = None,
|
|
@@ -422,6 +429,7 @@ class AsyncAgent(AgentState):
|
|
|
422
429
|
knowledge,
|
|
423
430
|
context_keys,
|
|
424
431
|
idempotency_key,
|
|
432
|
+
images,
|
|
425
433
|
)
|
|
426
434
|
|
|
427
435
|
async def validate(
|
|
@@ -430,6 +438,7 @@ class AsyncAgent(AgentState):
|
|
|
430
438
|
subject: Any,
|
|
431
439
|
*,
|
|
432
440
|
reference: Any = _UNSET,
|
|
441
|
+
images: list[str] | None = None,
|
|
433
442
|
knowledge: Knowledge | None = None,
|
|
434
443
|
context_keys: list[str] | None = None,
|
|
435
444
|
idempotency_key: str | None = None,
|
|
@@ -440,6 +449,7 @@ class AsyncAgent(AgentState):
|
|
|
440
449
|
knowledge,
|
|
441
450
|
context_keys,
|
|
442
451
|
idempotency_key,
|
|
452
|
+
images,
|
|
443
453
|
)
|
|
444
454
|
|
|
445
455
|
async def decide(
|
|
@@ -447,6 +457,7 @@ class AsyncAgent(AgentState):
|
|
|
447
457
|
context: Any,
|
|
448
458
|
actions: list[ActionChoice],
|
|
449
459
|
*,
|
|
460
|
+
images: list[str] | None = None,
|
|
450
461
|
knowledge: Knowledge | None = None,
|
|
451
462
|
context_keys: list[str] | None = None,
|
|
452
463
|
idempotency_key: str | None = None,
|
|
@@ -456,11 +467,12 @@ class AsyncAgent(AgentState):
|
|
|
456
467
|
result = await self._run(
|
|
457
468
|
"decide",
|
|
458
469
|
values,
|
|
459
|
-
|
|
470
|
+
knowledge,
|
|
460
471
|
context_keys,
|
|
461
472
|
idempotency_key,
|
|
473
|
+
images,
|
|
462
474
|
)
|
|
463
|
-
return self._remember(result, original)
|
|
475
|
+
return self._remember(result, original, values["actions"])
|
|
464
476
|
|
|
465
477
|
async def execute(self, decision: Result[Any]) -> Execution:
|
|
466
478
|
made, problem = self._consume(decision)
|
|
@@ -6,6 +6,7 @@ from copy import deepcopy
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from threading import Lock
|
|
8
8
|
from typing import Any, TypeVar
|
|
9
|
+
from urllib.parse import urlsplit
|
|
9
10
|
from uuid import UUID, uuid4
|
|
10
11
|
from weakref import WeakKeyDictionary, WeakValueDictionary
|
|
11
12
|
|
|
@@ -15,7 +16,7 @@ from ._transport import identifier, json_body, request_id, scope_id, utf16_lengt
|
|
|
15
16
|
from ._types import Result
|
|
16
17
|
|
|
17
18
|
Knowledge = bool | dict[str, str]
|
|
18
|
-
ActionChoice = str | dict[str,
|
|
19
|
+
ActionChoice = str | dict[str, Any]
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def object_field(body: dict[str, Any], field: str) -> dict[str, Any]:
|
|
@@ -60,6 +61,7 @@ def operation_payload(
|
|
|
60
61
|
scope: str | None,
|
|
61
62
|
knowledge: Knowledge | None,
|
|
62
63
|
context_keys: list[str] | None,
|
|
64
|
+
images: list[str] | None = None,
|
|
63
65
|
) -> dict[str, Any]:
|
|
64
66
|
body = {"operation": operation, **values}
|
|
65
67
|
if scope is not None:
|
|
@@ -85,6 +87,17 @@ def operation_payload(
|
|
|
85
87
|
if not isinstance(context_keys, list) or len(context_keys) > 16:
|
|
86
88
|
raise ValueError("context_keys must be a list of at most 16 keys.")
|
|
87
89
|
body["contextKeys"] = [identifier(key, "context key") for key in context_keys]
|
|
90
|
+
if images is not None:
|
|
91
|
+
if not isinstance(images, list) or len(images) > 4:
|
|
92
|
+
raise ValueError("images must be a list of at most four HTTPS photo URLs.")
|
|
93
|
+
for address in images:
|
|
94
|
+
if not isinstance(address, str) or utf16_length(address) > 2048:
|
|
95
|
+
raise ValueError("Each photo needs an HTTPS URL of at most 2048 characters.")
|
|
96
|
+
url = urlsplit(address)
|
|
97
|
+
if url.scheme != "https" or not url.hostname or url.username or url.password:
|
|
98
|
+
raise ValueError("Photo URLs must use HTTPS without embedded credentials.")
|
|
99
|
+
if images:
|
|
100
|
+
body["images"] = list(images)
|
|
88
101
|
if len(json_body(body)) > 131072:
|
|
89
102
|
raise ValueError("The HTTP operation body exceeds 131072 UTF-8 bytes.")
|
|
90
103
|
return body
|
|
@@ -231,12 +244,16 @@ class AgentState:
|
|
|
231
244
|
def _choices(self, context: Any, actions: list[ActionChoice]) -> dict[str, Any]:
|
|
232
245
|
if not isinstance(actions, list) or not 1 <= len(actions) <= 32:
|
|
233
246
|
raise ValueError("actions must contain 1–32 action choices.")
|
|
234
|
-
choices
|
|
247
|
+
choices = []
|
|
235
248
|
for offered in actions:
|
|
236
249
|
if isinstance(offered, str):
|
|
237
250
|
name = offered
|
|
238
251
|
description = None
|
|
239
|
-
elif isinstance(offered, dict) and set(offered) <= {
|
|
252
|
+
elif isinstance(offered, dict) and set(offered) <= {
|
|
253
|
+
"action",
|
|
254
|
+
"description",
|
|
255
|
+
"parameters",
|
|
256
|
+
}:
|
|
240
257
|
name = offered.get("action", "")
|
|
241
258
|
description = offered.get("description")
|
|
242
259
|
else:
|
|
@@ -247,21 +264,39 @@ class AgentState:
|
|
|
247
264
|
registered = self._actions.get(name)
|
|
248
265
|
if description is None and registered is not None:
|
|
249
266
|
description = registered.description
|
|
250
|
-
choice = {"action": name}
|
|
267
|
+
choice: dict[str, Any] = {"action": name}
|
|
251
268
|
if description is not None:
|
|
252
269
|
if not isinstance(description, str) or utf16_length(description) > 1000:
|
|
253
270
|
raise ValueError("Action description must have at most 1000 UTF-16 characters.")
|
|
254
271
|
choice["description"] = description
|
|
272
|
+
schema = registered.schema if registered is not None else None
|
|
273
|
+
if schema is None and isinstance(offered, dict):
|
|
274
|
+
if "parameters" in offered:
|
|
275
|
+
schema = offered["parameters"]
|
|
276
|
+
schema_validator(schema)
|
|
277
|
+
if schema is not None:
|
|
278
|
+
choice["parameters"] = deepcopy(schema)
|
|
255
279
|
choices.append(choice)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return {"context": prepared, "actions": choices}
|
|
262
|
-
|
|
263
|
-
def _remember(self, result: Result[Any], original: Any) -> Result[Any]:
|
|
280
|
+
return {"context": context, "actions": choices}
|
|
281
|
+
|
|
282
|
+
def _remember(
|
|
283
|
+
self, result: Result[Any], original: Any, choices: list[dict[str, Any]]
|
|
284
|
+
) -> Result[Any]:
|
|
264
285
|
if result.success:
|
|
286
|
+
from ._errors import ProtocolError
|
|
287
|
+
|
|
288
|
+
chosen = next((choice for choice in choices if choice["action"] == result.action), None)
|
|
289
|
+
if chosen is None:
|
|
290
|
+
raise ProtocolError(
|
|
291
|
+
"The server returned an action that was not offered.",
|
|
292
|
+
request_id=result.request_id,
|
|
293
|
+
)
|
|
294
|
+
schema = chosen.get("parameters")
|
|
295
|
+
if schema is not None and not schema_validator(schema).is_valid(result.parameters):
|
|
296
|
+
raise ProtocolError(
|
|
297
|
+
"The server returned parameters that do not match the action schema.",
|
|
298
|
+
request_id=result.request_id,
|
|
299
|
+
)
|
|
265
300
|
snapshot = Result.from_response(
|
|
266
301
|
{"result": result.raw, "operationId": result.operation_id},
|
|
267
302
|
result.request_id,
|
|
@@ -7,6 +7,16 @@ class NullProtocolError(Exception):
|
|
|
7
7
|
"""Base SDK error. Failed model operations are returned as Result, not raised."""
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
class KnowledgeError(NullProtocolError):
|
|
11
|
+
"""Preparation waiting failed locally; no model operation was run."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, code: str, message: str, *, source_id: str, version: str, reason: str | None = None
|
|
15
|
+
) -> None:
|
|
16
|
+
self.code, self.source_id, self.version, self.reason = code, source_id, version, reason
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
|
|
10
20
|
class APIError(NullProtocolError):
|
|
11
21
|
"""An HTTP failure. Keep request_id when retrying an uncertain run."""
|
|
12
22
|
|
|
@@ -24,7 +34,17 @@ class APIError(NullProtocolError):
|
|
|
24
34
|
self.request_id = request_id
|
|
25
35
|
self.details = details or {}
|
|
26
36
|
self.retry_after = retry_after
|
|
27
|
-
|
|
37
|
+
messages = {
|
|
38
|
+
"invalid_context_key": (
|
|
39
|
+
"A Context key must have 1–64 lowercase letters, digits, dots, underscores or "
|
|
40
|
+
"hyphens, starting with a letter; for example order_id."
|
|
41
|
+
),
|
|
42
|
+
"invalid_context_value": (
|
|
43
|
+
"Context value must be JSON-serializable. JSON null is allowed; "
|
|
44
|
+
"use delete to remove an entry."
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
super().__init__(f"NullProtocol HTTP {status_code}: {messages.get(code, code)}")
|
|
28
48
|
|
|
29
49
|
|
|
30
50
|
class TransportError(NullProtocolError):
|
|
@@ -1,11 +1,66 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
3
6
|
from typing import Any
|
|
4
7
|
|
|
5
8
|
from ._common import checked_text, list_field, object_field, version
|
|
9
|
+
from ._errors import KnowledgeError
|
|
6
10
|
from ._transport import AsyncTransport, Transport
|
|
7
11
|
|
|
8
12
|
|
|
13
|
+
def _target(source: dict[str, Any], timeout: float) -> tuple[str, str, int]:
|
|
14
|
+
if (
|
|
15
|
+
not isinstance(source, dict)
|
|
16
|
+
or type(source.get("revision")) is not int
|
|
17
|
+
or source["revision"] < 1
|
|
18
|
+
):
|
|
19
|
+
raise ValueError("Pass the Knowledge revision returned by add, update or get.")
|
|
20
|
+
if (
|
|
21
|
+
isinstance(timeout, bool)
|
|
22
|
+
or not isinstance(timeout, (int, float))
|
|
23
|
+
or not math.isfinite(timeout)
|
|
24
|
+
or timeout <= 0
|
|
25
|
+
):
|
|
26
|
+
raise ValueError("Knowledge timeout must be a positive number of seconds.")
|
|
27
|
+
source_id, expected = source.get("id"), source.get("version")
|
|
28
|
+
if not isinstance(source_id, str) or not isinstance(expected, str):
|
|
29
|
+
raise ValueError("Pass the Knowledge revision returned by add, update or get.")
|
|
30
|
+
return version(source_id), version(expected), source["revision"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _prepared(
|
|
34
|
+
sources: list[dict[str, Any]], source_id: str, expected: str, revision: int
|
|
35
|
+
) -> dict[str, Any] | None:
|
|
36
|
+
source = next((item for item in sources if item.get("id") == source_id), None)
|
|
37
|
+
if source is None:
|
|
38
|
+
raise KnowledgeError(
|
|
39
|
+
"knowledge_not_found",
|
|
40
|
+
"This Knowledge document was deleted or is not accessible.",
|
|
41
|
+
source_id=source_id,
|
|
42
|
+
version=expected,
|
|
43
|
+
)
|
|
44
|
+
if source.get("version") != expected:
|
|
45
|
+
raise KnowledgeError(
|
|
46
|
+
"version_conflict",
|
|
47
|
+
"The Knowledge document changed while waiting. Read its current revision first.",
|
|
48
|
+
source_id=source_id,
|
|
49
|
+
version=source["version"],
|
|
50
|
+
)
|
|
51
|
+
if source.get("state") == "ready" and source.get("activeRevision") == revision:
|
|
52
|
+
return source
|
|
53
|
+
if source.get("state") == "failed" and not source.get("retryScheduled"):
|
|
54
|
+
raise KnowledgeError(
|
|
55
|
+
"knowledge_prepare_failed",
|
|
56
|
+
"Knowledge preparation failed. Inspect the document and explicitly retry it.",
|
|
57
|
+
source_id=source_id,
|
|
58
|
+
version=expected,
|
|
59
|
+
reason=source.get("error"),
|
|
60
|
+
)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
9
64
|
def _fields(
|
|
10
65
|
name: str | None, text: str | None, *, update: bool = False, if_version: str | None = None
|
|
11
66
|
) -> dict[str, Any]:
|
|
@@ -33,8 +88,29 @@ class _Documents:
|
|
|
33
88
|
def __init__(self, transport: Transport, path: str, scope: str) -> None:
|
|
34
89
|
self._transport, self._path, self._scope = transport, path, scope
|
|
35
90
|
|
|
36
|
-
def _all(self) -> list[dict[str, Any]]:
|
|
37
|
-
return list_field(self._transport.request("GET", self._path), "sources")
|
|
91
|
+
def _all(self, *, deadline: float | None = None) -> list[dict[str, Any]]:
|
|
92
|
+
return list_field(self._transport.request("GET", self._path, deadline=deadline), "sources")
|
|
93
|
+
|
|
94
|
+
def wait_until_ready(self, source: dict[str, Any], *, timeout: float = 120) -> dict[str, Any]:
|
|
95
|
+
"""Wait for this exact version using metadata reads. Timeout is seconds."""
|
|
96
|
+
source_id, expected, revision = _target(source, timeout)
|
|
97
|
+
deadline = time.monotonic() + timeout
|
|
98
|
+
try:
|
|
99
|
+
while True:
|
|
100
|
+
rows = self._all(deadline=deadline)
|
|
101
|
+
if time.monotonic() >= deadline:
|
|
102
|
+
raise TimeoutError
|
|
103
|
+
ready = _prepared(rows, source_id, expected, revision)
|
|
104
|
+
if ready is not None:
|
|
105
|
+
return ready
|
|
106
|
+
time.sleep(min(0.5, max(0, deadline - time.monotonic())))
|
|
107
|
+
except TimeoutError as exc:
|
|
108
|
+
raise KnowledgeError(
|
|
109
|
+
"knowledge_wait_timeout",
|
|
110
|
+
"Knowledge preparation did not finish within the timeout.",
|
|
111
|
+
source_id=source_id,
|
|
112
|
+
version=expected,
|
|
113
|
+
) from exc
|
|
38
114
|
|
|
39
115
|
def list(self) -> list[dict[str, Any]]:
|
|
40
116
|
"""List documents owned by this scope. Connections are listed separately."""
|
|
@@ -46,7 +122,7 @@ class _Documents:
|
|
|
46
122
|
)
|
|
47
123
|
|
|
48
124
|
def add(self, *, name: str, text: str) -> dict[str, Any]:
|
|
49
|
-
"""Save text and begin preparation.
|
|
125
|
+
"""Save text and begin preparation. Use wait_until_ready to await this version."""
|
|
50
126
|
return self._transport.request("POST", self._path, body=_fields(name, text))
|
|
51
127
|
|
|
52
128
|
def update(
|
|
@@ -102,8 +178,36 @@ class _AsyncDocuments:
|
|
|
102
178
|
def __init__(self, transport: AsyncTransport, path: str, scope: str) -> None:
|
|
103
179
|
self._transport, self._path, self._scope = transport, path, scope
|
|
104
180
|
|
|
105
|
-
async def _all(self) -> list[dict[str, Any]]:
|
|
106
|
-
return list_field(
|
|
181
|
+
async def _all(self, *, deadline: float | None = None) -> list[dict[str, Any]]:
|
|
182
|
+
return list_field(
|
|
183
|
+
await self._transport.request("GET", self._path, deadline=deadline), "sources"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def wait_until_ready(
|
|
187
|
+
self, source: dict[str, Any], *, timeout: float = 120
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""Wait for this version; cancelling the task cancels the HTTP request too."""
|
|
190
|
+
source_id, expected, revision = _target(source, timeout)
|
|
191
|
+
deadline = time.monotonic() + timeout
|
|
192
|
+
try:
|
|
193
|
+
while True:
|
|
194
|
+
remaining = deadline - time.monotonic()
|
|
195
|
+
if remaining <= 0:
|
|
196
|
+
raise TimeoutError
|
|
197
|
+
rows = await asyncio.wait_for(self._all(deadline=deadline), timeout=remaining)
|
|
198
|
+
if time.monotonic() >= deadline:
|
|
199
|
+
raise TimeoutError
|
|
200
|
+
ready = _prepared(rows, source_id, expected, revision)
|
|
201
|
+
if ready is not None:
|
|
202
|
+
return ready
|
|
203
|
+
await asyncio.sleep(min(0.5, max(0, deadline - time.monotonic())))
|
|
204
|
+
except (TimeoutError, asyncio.TimeoutError) as exc:
|
|
205
|
+
raise KnowledgeError(
|
|
206
|
+
"knowledge_wait_timeout",
|
|
207
|
+
"Knowledge preparation did not finish within the timeout.",
|
|
208
|
+
source_id=source_id,
|
|
209
|
+
version=expected,
|
|
210
|
+
) from exc
|
|
107
211
|
|
|
108
212
|
async def list(self) -> list[dict[str, Any]]:
|
|
109
213
|
return [source for source in await self._all() if source.get("scope") == self._scope]
|
|
@@ -35,6 +35,11 @@ def utf16_length(value: str) -> int:
|
|
|
35
35
|
def identifier(value: str, kind: str) -> str:
|
|
36
36
|
pattern = r"[a-z0-9][a-z0-9._-]{0,63}" if kind == "agent ID" else r"[a-z][a-z0-9._-]{0,63}"
|
|
37
37
|
if not isinstance(value, str) or not re.fullmatch(pattern, value):
|
|
38
|
+
if kind == "context key":
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Invalid context key. Use 1–64 lowercase letters, digits, dots, underscores or "
|
|
41
|
+
"hyphens, starting with a letter; for example order_id."
|
|
42
|
+
)
|
|
38
43
|
raise ValueError(f"Invalid {kind}.")
|
|
39
44
|
return value
|
|
40
45
|
|
|
@@ -156,6 +161,15 @@ def _retryable(response: httpx.Response) -> bool:
|
|
|
156
161
|
return False
|
|
157
162
|
|
|
158
163
|
|
|
164
|
+
def _budget(timeout: float, deadline: float | None) -> float:
|
|
165
|
+
if deadline is None:
|
|
166
|
+
return timeout
|
|
167
|
+
remaining = deadline - time.monotonic()
|
|
168
|
+
if remaining <= 0:
|
|
169
|
+
raise TimeoutError("Knowledge preparation wait timed out.")
|
|
170
|
+
return min(timeout, remaining)
|
|
171
|
+
|
|
172
|
+
|
|
159
173
|
class Transport:
|
|
160
174
|
def __init__(self, config: _Config, client: httpx.Client | None = None) -> None:
|
|
161
175
|
self.config = config
|
|
@@ -177,6 +191,7 @@ class Transport:
|
|
|
177
191
|
body: Any = _UNSET,
|
|
178
192
|
params: dict[str, str] | None = None,
|
|
179
193
|
task_id: str | None = None,
|
|
194
|
+
deadline: float | None = None,
|
|
180
195
|
) -> dict[str, Any]:
|
|
181
196
|
if self.closed:
|
|
182
197
|
raise TransportError("This NullProtocol client is closed.", request_id=task_id)
|
|
@@ -188,6 +203,7 @@ class Transport:
|
|
|
188
203
|
headers["Idempotency-Key"] = task_id
|
|
189
204
|
retries = self.config.max_retries if method == "GET" or task_id is not None else 0
|
|
190
205
|
for attempt in range(retries + 1):
|
|
206
|
+
timeout = _budget(self.config.timeout, deadline)
|
|
191
207
|
response = None
|
|
192
208
|
try:
|
|
193
209
|
response = self.client.request(
|
|
@@ -196,23 +212,25 @@ class Transport:
|
|
|
196
212
|
headers=headers,
|
|
197
213
|
content=content,
|
|
198
214
|
params=params,
|
|
199
|
-
timeout=
|
|
215
|
+
timeout=timeout,
|
|
200
216
|
follow_redirects=False,
|
|
201
217
|
)
|
|
202
218
|
except httpx.TransportError as exc:
|
|
219
|
+
_budget(self.config.timeout, deadline)
|
|
203
220
|
if attempt == retries:
|
|
204
221
|
raise TransportError(
|
|
205
222
|
"NullProtocol could not be reached. "
|
|
206
223
|
"Preserve request_id before retrying a run.",
|
|
207
224
|
request_id=task_id,
|
|
208
225
|
) from exc
|
|
226
|
+
_budget(self.config.timeout, deadline)
|
|
209
227
|
if response is not None and (attempt == retries or not _retryable(response)):
|
|
210
228
|
return _decode(response, task_id)
|
|
211
229
|
delay = _delay(response, attempt)
|
|
212
230
|
if delay is None:
|
|
213
231
|
assert response is not None
|
|
214
232
|
return _decode(response, task_id)
|
|
215
|
-
time.sleep(delay)
|
|
233
|
+
time.sleep(_budget(delay, deadline))
|
|
216
234
|
raise AssertionError("Unreachable transport state")
|
|
217
235
|
|
|
218
236
|
|
|
@@ -237,6 +255,7 @@ class AsyncTransport:
|
|
|
237
255
|
body: Any = _UNSET,
|
|
238
256
|
params: dict[str, str] | None = None,
|
|
239
257
|
task_id: str | None = None,
|
|
258
|
+
deadline: float | None = None,
|
|
240
259
|
) -> dict[str, Any]:
|
|
241
260
|
if self.closed:
|
|
242
261
|
raise TransportError("This NullProtocol client is closed.", request_id=task_id)
|
|
@@ -248,6 +267,7 @@ class AsyncTransport:
|
|
|
248
267
|
headers["Idempotency-Key"] = task_id
|
|
249
268
|
retries = self.config.max_retries if method == "GET" or task_id is not None else 0
|
|
250
269
|
for attempt in range(retries + 1):
|
|
270
|
+
timeout = _budget(self.config.timeout, deadline)
|
|
251
271
|
response = None
|
|
252
272
|
try:
|
|
253
273
|
response = await self.client.request(
|
|
@@ -256,21 +276,23 @@ class AsyncTransport:
|
|
|
256
276
|
headers=headers,
|
|
257
277
|
content=content,
|
|
258
278
|
params=params,
|
|
259
|
-
timeout=
|
|
279
|
+
timeout=timeout,
|
|
260
280
|
follow_redirects=False,
|
|
261
281
|
)
|
|
262
282
|
except httpx.TransportError as exc:
|
|
283
|
+
_budget(self.config.timeout, deadline)
|
|
263
284
|
if attempt == retries:
|
|
264
285
|
raise TransportError(
|
|
265
286
|
"NullProtocol could not be reached. "
|
|
266
287
|
"Preserve request_id before retrying a run.",
|
|
267
288
|
request_id=task_id,
|
|
268
289
|
) from exc
|
|
290
|
+
_budget(self.config.timeout, deadline)
|
|
269
291
|
if response is not None and (attempt == retries or not _retryable(response)):
|
|
270
292
|
return _decode(response, task_id)
|
|
271
293
|
delay = _delay(response, attempt)
|
|
272
294
|
if delay is None:
|
|
273
295
|
assert response is not None
|
|
274
296
|
return _decode(response, task_id)
|
|
275
|
-
await asyncio.sleep(delay)
|
|
297
|
+
await asyncio.sleep(_budget(delay, deadline))
|
|
276
298
|
raise AssertionError("Unreachable transport state")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from nullprotocol import AsyncNullProtocol, NullProtocol
|
|
8
|
+
|
|
9
|
+
PHOTO = "https://images.example.test/mug.jpg"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_photos_survive_every_operation_and_idempotency(api):
|
|
13
|
+
with NullProtocol(
|
|
14
|
+
key="fixture", http_client=httpx.Client(transport=httpx.MockTransport(api))
|
|
15
|
+
) as client:
|
|
16
|
+
agent = client.load("support")
|
|
17
|
+
images = [PHOTO]
|
|
18
|
+
assert agent.extract("", {"days": "number"}, images=images).success
|
|
19
|
+
assert agent.summarize("Describe the mug.", images=images).success
|
|
20
|
+
assert agent.validate("There is damage.", "", images=images).success
|
|
21
|
+
assert agent.decide("What next?", ["send_message"], images=images).success
|
|
22
|
+
agent.extract("", {"days": "number"}, images=images, idempotency_key="photo-42")
|
|
23
|
+
agent.extract("", {"days": "number"}, images=images, idempotency_key="photo-42")
|
|
24
|
+
assert api.model_calls == 5
|
|
25
|
+
bodies = [json.loads(r.content) for r in api.requests if r.url.path.endswith("/run")]
|
|
26
|
+
assert all(body["images"] == [PHOTO] for body in bodies)
|
|
27
|
+
assert bodies[0]["data"] == ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize(
|
|
31
|
+
"images",
|
|
32
|
+
[
|
|
33
|
+
"photo.jpg",
|
|
34
|
+
["http://example.test/photo.jpg"],
|
|
35
|
+
["data:image/png;base64,AAAA"],
|
|
36
|
+
["https://user:password@example.test/photo.jpg"],
|
|
37
|
+
[PHOTO] * 5,
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
def test_invalid_photos_never_reach_api(api, images):
|
|
41
|
+
with NullProtocol(
|
|
42
|
+
key="fixture", http_client=httpx.Client(transport=httpx.MockTransport(api))
|
|
43
|
+
) as client:
|
|
44
|
+
agent = client.load("support")
|
|
45
|
+
before = len(api.requests)
|
|
46
|
+
with pytest.raises(ValueError):
|
|
47
|
+
agent.extract("", {"days": "number"}, images=images)
|
|
48
|
+
assert len(api.requests) == before
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_async_photos(api):
|
|
52
|
+
async def run():
|
|
53
|
+
async with AsyncNullProtocol(
|
|
54
|
+
key="fixture", http_client=httpx.AsyncClient(transport=httpx.MockTransport(api))
|
|
55
|
+
) as client:
|
|
56
|
+
agent = await client.load("support")
|
|
57
|
+
assert (await agent.extract("", {"days": "number"}, images=[PHOTO])).success
|
|
58
|
+
assert (await agent.summarize("Describe.", images=[PHOTO])).success
|
|
59
|
+
assert (await agent.validate("Damaged.", "", images=[PHOTO])).success
|
|
60
|
+
assert (await agent.decide("Next?", ["send_message"], images=[PHOTO])).success
|
|
61
|
+
|
|
62
|
+
asyncio.run(run())
|
|
63
|
+
assert all(
|
|
64
|
+
json.loads(r.content)["images"] == [PHOTO]
|
|
65
|
+
for r in api.requests
|
|
66
|
+
if r.url.path.endswith("/run")
|
|
67
|
+
)
|
|
@@ -205,3 +205,135 @@ def test_async_management_matches_sync_routes():
|
|
|
205
205
|
assert await agent.space.knowledge.retry(shared["id"]) == {"retrying": False}
|
|
206
206
|
|
|
207
207
|
asyncio.run(run())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_wait_pins_revision_reads_metadata_and_observes_scheduled_retry():
|
|
211
|
+
from nullprotocol import KnowledgeError
|
|
212
|
+
|
|
213
|
+
api = KnowledgeAPI()
|
|
214
|
+
source = api.documents["agent"].copy()
|
|
215
|
+
count = 0
|
|
216
|
+
|
|
217
|
+
def handler(request):
|
|
218
|
+
nonlocal count
|
|
219
|
+
if request.url.path.endswith("/knowledge") and request.method == "GET":
|
|
220
|
+
count += 1
|
|
221
|
+
current = {**source, "state": "failed", "retryScheduled": True}
|
|
222
|
+
if count > 1:
|
|
223
|
+
current.update(state="ready", activeRevision=1, retryScheduled=False)
|
|
224
|
+
return httpx.Response(200, json={"sources": [current]})
|
|
225
|
+
return api(request)
|
|
226
|
+
|
|
227
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as http:
|
|
228
|
+
with NullProtocol(
|
|
229
|
+
key="fixture", endpoint="https://example.test", http_client=http
|
|
230
|
+
) as client:
|
|
231
|
+
agent = client.load("support")
|
|
232
|
+
ready = agent.knowledge.wait_until_ready(source, timeout=2)
|
|
233
|
+
assert ready["activeRevision"] == 1
|
|
234
|
+
assert count == 2
|
|
235
|
+
assert all(not str(req.url).endswith(source["id"]) for req in api.requests)
|
|
236
|
+
changed = {**source, "version": str(uuid4())}
|
|
237
|
+
with pytest.raises(KnowledgeError) as conflict:
|
|
238
|
+
agent.knowledge.wait_until_ready(changed)
|
|
239
|
+
assert conflict.value.code == "version_conflict"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.mark.parametrize(
|
|
243
|
+
"state,code",
|
|
244
|
+
[
|
|
245
|
+
("failed", "knowledge_prepare_failed"),
|
|
246
|
+
("missing", "knowledge_not_found"),
|
|
247
|
+
("preparing", "knowledge_wait_timeout"),
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
def test_wait_terminal_states_and_timeout(state, code):
|
|
251
|
+
from nullprotocol import KnowledgeError
|
|
252
|
+
|
|
253
|
+
api = KnowledgeAPI()
|
|
254
|
+
source = api.documents["space"]
|
|
255
|
+
|
|
256
|
+
def handler(request):
|
|
257
|
+
if request.url.path == "/v1/knowledge":
|
|
258
|
+
rows = (
|
|
259
|
+
[] if state == "missing" else [{**source, "state": state, "retryScheduled": False}]
|
|
260
|
+
)
|
|
261
|
+
return httpx.Response(200, json={"sources": rows})
|
|
262
|
+
return api(request)
|
|
263
|
+
|
|
264
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as http:
|
|
265
|
+
with NullProtocol(
|
|
266
|
+
key="fixture", endpoint="https://example.test", http_client=http
|
|
267
|
+
) as client:
|
|
268
|
+
with pytest.raises(KnowledgeError) as error:
|
|
269
|
+
client.load("support").space.knowledge.wait_until_ready(source, timeout=0.02)
|
|
270
|
+
assert error.value.code == code
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_wait_async_cancellation_aborts_http_and_timeout_is_bounded():
|
|
274
|
+
from nullprotocol import KnowledgeError
|
|
275
|
+
|
|
276
|
+
async def run():
|
|
277
|
+
api = KnowledgeAPI()
|
|
278
|
+
entered = asyncio.Event()
|
|
279
|
+
cancelled = asyncio.Event()
|
|
280
|
+
|
|
281
|
+
async def handler(request):
|
|
282
|
+
if request.url.path.endswith("/knowledge"):
|
|
283
|
+
entered.set()
|
|
284
|
+
try:
|
|
285
|
+
await asyncio.Event().wait()
|
|
286
|
+
finally:
|
|
287
|
+
cancelled.set()
|
|
288
|
+
return api(request)
|
|
289
|
+
|
|
290
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
|
291
|
+
async with AsyncNullProtocol(
|
|
292
|
+
key="fixture", endpoint="https://example.test", http_client=http
|
|
293
|
+
) as client:
|
|
294
|
+
agent = await client.load("support")
|
|
295
|
+
task = asyncio.create_task(agent.knowledge.wait_until_ready(api.documents["agent"]))
|
|
296
|
+
await entered.wait()
|
|
297
|
+
task.cancel()
|
|
298
|
+
with pytest.raises(asyncio.CancelledError):
|
|
299
|
+
await task
|
|
300
|
+
assert cancelled.is_set()
|
|
301
|
+
cancelled.clear()
|
|
302
|
+
with pytest.raises(KnowledgeError) as timeout:
|
|
303
|
+
await agent.knowledge.wait_until_ready(api.documents["agent"], timeout=0.02)
|
|
304
|
+
assert timeout.value.code == "knowledge_wait_timeout"
|
|
305
|
+
assert cancelled.is_set()
|
|
306
|
+
|
|
307
|
+
asyncio.run(run())
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_wait_async_ready_and_busy_retry_respects_deadline():
|
|
311
|
+
from nullprotocol import KnowledgeError
|
|
312
|
+
|
|
313
|
+
async def run():
|
|
314
|
+
api = KnowledgeAPI()
|
|
315
|
+
source = api.documents["agent"]
|
|
316
|
+
source.update(state="ready", activeRevision=1)
|
|
317
|
+
busy = False
|
|
318
|
+
requests = 0
|
|
319
|
+
|
|
320
|
+
async def handler(request):
|
|
321
|
+
nonlocal requests
|
|
322
|
+
if busy and request.url.path.endswith("/knowledge"):
|
|
323
|
+
requests += 1
|
|
324
|
+
return httpx.Response(503, json={"error": "busy"})
|
|
325
|
+
return api(request)
|
|
326
|
+
|
|
327
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
|
328
|
+
async with AsyncNullProtocol(
|
|
329
|
+
key="fixture", endpoint="https://example.test", http_client=http
|
|
330
|
+
) as client:
|
|
331
|
+
agent = await client.load("support")
|
|
332
|
+
assert (await agent.knowledge.wait_until_ready(source))["activeRevision"] == 1
|
|
333
|
+
busy = True
|
|
334
|
+
with pytest.raises(KnowledgeError) as timeout:
|
|
335
|
+
await agent.knowledge.wait_until_ready(source, timeout=0.02)
|
|
336
|
+
assert timeout.value.code == "knowledge_wait_timeout"
|
|
337
|
+
assert requests == 1
|
|
338
|
+
|
|
339
|
+
asyncio.run(run())
|
|
@@ -384,8 +384,9 @@ def test_decide_execute_schema_guard_snapshot_and_recording(api):
|
|
|
384
384
|
)
|
|
385
385
|
decision = agent.decide("Good morning", ["send_message"], knowledge=True)
|
|
386
386
|
posted = json.loads(api.requests[-1].content)
|
|
387
|
-
assert posted["knowledge"]
|
|
388
|
-
assert posted["context"]
|
|
387
|
+
assert posted["knowledge"] is True
|
|
388
|
+
assert posted["context"] == "Good morning"
|
|
389
|
+
assert posted["actions"][0]["parameters"] == SCHEMA
|
|
389
390
|
decision.parameters["text"] = "tampered"
|
|
390
391
|
outcome = agent.execute(decision)
|
|
391
392
|
assert outcome.success and outcome.result == {"sent": True}
|
|
@@ -431,14 +432,13 @@ def test_execution_refusals_and_failures(api, mode, code):
|
|
|
431
432
|
agent = c.load("support")
|
|
432
433
|
if mode != "unregistered":
|
|
433
434
|
agent.register_action("send_message", handler, input_schema=SCHEMA, guard=guard)
|
|
434
|
-
if mode == "schema":
|
|
435
|
-
api.result_override = {
|
|
436
|
-
"success": True,
|
|
437
|
-
"action": "send_message",
|
|
438
|
-
"parameters": {"wrong": 1},
|
|
439
|
-
"reasoning": "x",
|
|
440
|
-
}
|
|
441
435
|
result = agent.decide("Hello", ["send_message"])
|
|
436
|
+
if mode == "schema":
|
|
437
|
+
agent.register_action(
|
|
438
|
+
"send_message",
|
|
439
|
+
handler,
|
|
440
|
+
input_schema={"type": "object", "required": ["new_required_field"]},
|
|
441
|
+
)
|
|
442
442
|
if mode == "paused":
|
|
443
443
|
api.settings["paused"] = True
|
|
444
444
|
if mode == "disabled":
|
|
@@ -610,7 +610,8 @@ def test_action_format_validation_and_local_references(api):
|
|
|
610
610
|
"parameters": {"date": "2026-02-29"},
|
|
611
611
|
"reasoning": "x",
|
|
612
612
|
}
|
|
613
|
-
|
|
613
|
+
with pytest.raises(ProtocolError, match="parameters"):
|
|
614
|
+
agent.decide("x", ["send_message"])
|
|
614
615
|
api.result_override = {
|
|
615
616
|
"success": True,
|
|
616
617
|
"action": "send_message",
|
|
@@ -692,3 +693,32 @@ def test_nonfinite_or_missing_response_metadata_is_protocol_error(api):
|
|
|
692
693
|
)
|
|
693
694
|
with pytest.raises(ProtocolError):
|
|
694
695
|
agent.extract("x", {})
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def test_context_null_and_key_errors_are_unambiguous(api):
|
|
699
|
+
with client(api) as c:
|
|
700
|
+
agent = c.load("support")
|
|
701
|
+
assert agent.context.set("order", None)["value"] is None
|
|
702
|
+
assert json.loads(api.requests[-1].content)["value"] is None
|
|
703
|
+
count = len(api.requests)
|
|
704
|
+
with pytest.raises(ValueError, match="lowercase"):
|
|
705
|
+
agent.context.set("privateProbe", {})
|
|
706
|
+
assert len(api.requests) == count
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def test_inline_parameter_contracts_stay_out_of_context_and_reject_bad_server_success(api):
|
|
710
|
+
with client(api) as c:
|
|
711
|
+
agent = c.load("support")
|
|
712
|
+
result = agent.decide("Hello", [{"action": "send_message", "parameters": SCHEMA}])
|
|
713
|
+
assert result.success
|
|
714
|
+
body = json.loads(api.requests[-1].content)
|
|
715
|
+
assert body["context"] == "Hello"
|
|
716
|
+
assert body["actions"][0]["parameters"] == SCHEMA
|
|
717
|
+
api.result_override = {
|
|
718
|
+
"success": True,
|
|
719
|
+
"action": "send_message",
|
|
720
|
+
"parameters": {"text": 42},
|
|
721
|
+
"reasoning": "test",
|
|
722
|
+
}
|
|
723
|
+
with pytest.raises(ProtocolError, match="parameters"):
|
|
724
|
+
agent.decide("Hello", [{"action": "send_message", "parameters": SCHEMA}])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|