nullprotocol 0.3.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.3.0 → nullprotocol-0.4.0}/CHANGELOG.md +6 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/PKG-INFO +13 -6
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/README.md +12 -5
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/knowledge_management.py +1 -14
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/pyproject.toml +1 -1
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/__init__.py +3 -2
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_agent.py +5 -19
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_common.py +34 -12
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_errors.py +21 -1
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_knowledge.py +109 -5
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_transport.py +26 -4
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/tests/test_knowledge.py +132 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/tests/test_sdk.py +40 -10
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/.github/workflows/test.yml +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/.gitignore +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/LICENSE +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/RELEASING.md +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/actions.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/async_basic.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/basic.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/knowledge.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/photos.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/scoped_data.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_client.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_stores.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_types.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/py.typed +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/tests/conftest.py +0 -0
- {nullprotocol-0.3.0 → nullprotocol-0.4.0}/tests/test_images.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.3.0
|
|
4
10
|
|
|
5
11
|
- Add images to all four sync/async operations, with HTTPS URL validation, explicit model capability refusals and unchanged text requests.
|
|
@@ -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
|
|
|
@@ -280,3 +279,11 @@ result = agent.extract(
|
|
|
280
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.
|
|
281
280
|
|
|
282
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
|
|
|
@@ -250,3 +249,11 @@ result = agent.extract(
|
|
|
250
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.
|
|
251
250
|
|
|
252
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(
|
|
@@ -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}
|
|
@@ -273,12 +259,12 @@ class Agent(AgentState):
|
|
|
273
259
|
result = self._run(
|
|
274
260
|
"decide",
|
|
275
261
|
values,
|
|
276
|
-
|
|
262
|
+
knowledge,
|
|
277
263
|
context_keys,
|
|
278
264
|
idempotency_key,
|
|
279
265
|
images,
|
|
280
266
|
)
|
|
281
|
-
return self._remember(result, original)
|
|
267
|
+
return self._remember(result, original, values["actions"])
|
|
282
268
|
|
|
283
269
|
def execute(self, decision: Result[Any]) -> Execution:
|
|
284
270
|
made, problem = self._consume(decision)
|
|
@@ -481,12 +467,12 @@ class AsyncAgent(AgentState):
|
|
|
481
467
|
result = await self._run(
|
|
482
468
|
"decide",
|
|
483
469
|
values,
|
|
484
|
-
|
|
470
|
+
knowledge,
|
|
485
471
|
context_keys,
|
|
486
472
|
idempotency_key,
|
|
487
473
|
images,
|
|
488
474
|
)
|
|
489
|
-
return self._remember(result, original)
|
|
475
|
+
return self._remember(result, original, values["actions"])
|
|
490
476
|
|
|
491
477
|
async def execute(self, decision: Result[Any]) -> Execution:
|
|
492
478
|
made, problem = self._consume(decision)
|
|
@@ -16,7 +16,7 @@ from ._transport import identifier, json_body, request_id, scope_id, utf16_lengt
|
|
|
16
16
|
from ._types import Result
|
|
17
17
|
|
|
18
18
|
Knowledge = bool | dict[str, str]
|
|
19
|
-
ActionChoice = str | dict[str,
|
|
19
|
+
ActionChoice = str | dict[str, Any]
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def object_field(body: dict[str, Any], field: str) -> dict[str, Any]:
|
|
@@ -244,12 +244,16 @@ class AgentState:
|
|
|
244
244
|
def _choices(self, context: Any, actions: list[ActionChoice]) -> dict[str, Any]:
|
|
245
245
|
if not isinstance(actions, list) or not 1 <= len(actions) <= 32:
|
|
246
246
|
raise ValueError("actions must contain 1–32 action choices.")
|
|
247
|
-
choices
|
|
247
|
+
choices = []
|
|
248
248
|
for offered in actions:
|
|
249
249
|
if isinstance(offered, str):
|
|
250
250
|
name = offered
|
|
251
251
|
description = None
|
|
252
|
-
elif isinstance(offered, dict) and set(offered) <= {
|
|
252
|
+
elif isinstance(offered, dict) and set(offered) <= {
|
|
253
|
+
"action",
|
|
254
|
+
"description",
|
|
255
|
+
"parameters",
|
|
256
|
+
}:
|
|
253
257
|
name = offered.get("action", "")
|
|
254
258
|
description = offered.get("description")
|
|
255
259
|
else:
|
|
@@ -260,21 +264,39 @@ class AgentState:
|
|
|
260
264
|
registered = self._actions.get(name)
|
|
261
265
|
if description is None and registered is not None:
|
|
262
266
|
description = registered.description
|
|
263
|
-
choice = {"action": name}
|
|
267
|
+
choice: dict[str, Any] = {"action": name}
|
|
264
268
|
if description is not None:
|
|
265
269
|
if not isinstance(description, str) or utf16_length(description) > 1000:
|
|
266
270
|
raise ValueError("Action description must have at most 1000 UTF-16 characters.")
|
|
267
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)
|
|
268
279
|
choices.append(choice)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return {"context": prepared, "actions": choices}
|
|
275
|
-
|
|
276
|
-
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]:
|
|
277
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
|
+
)
|
|
278
300
|
snapshot = Result.from_response(
|
|
279
301
|
{"result": result.raw, "operationId": result.operation_id},
|
|
280
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")
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|