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.
Files changed (29) hide show
  1. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/CHANGELOG.md +6 -0
  2. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/PKG-INFO +13 -6
  3. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/README.md +12 -5
  4. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/knowledge_management.py +1 -14
  5. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/pyproject.toml +1 -1
  6. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/__init__.py +3 -2
  7. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_agent.py +5 -19
  8. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_common.py +34 -12
  9. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_errors.py +21 -1
  10. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_knowledge.py +109 -5
  11. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_transport.py +26 -4
  12. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/tests/test_knowledge.py +132 -0
  13. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/tests/test_sdk.py +40 -10
  14. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/.github/workflows/test.yml +0 -0
  15. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/.gitignore +0 -0
  16. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/LICENSE +0 -0
  17. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/RELEASING.md +0 -0
  18. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/actions.py +0 -0
  19. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/async_basic.py +0 -0
  20. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/basic.py +0 -0
  21. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/knowledge.py +0 -0
  22. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/photos.py +0 -0
  23. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/examples/scoped_data.py +0 -0
  24. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_client.py +0 -0
  25. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_stores.py +0 -0
  26. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/_types.py +0 -0
  27. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/src/nullprotocol/py.typed +0 -0
  28. {nullprotocol-0.3.0 → nullprotocol-0.4.0}/tests/conftest.py +0 -0
  29. {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.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
- source = agent.space.knowledge.get(shared["id"])
160
- if source["state"] == "ready":
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. The SDK supplies registered parameter schemas as structured data in the decision's `context` because the HTTP endpoint only accepts action names and descriptions. The original task and schema contract are visible in History. Automatic Knowledge queries still use the original task, not that schema wrapper.
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
- source = agent.space.knowledge.get(shared["id"])
130
- if source["state"] == "ready":
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. The SDK supplies registered parameter schemas as structured data in the decision's `context` because the HTTP endpoint only accepts action names and descriptions. The original task and schema contract are visible in History. Automatic Knowledge queries still use the original task, not that schema wrapper.
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
- deadline = time.monotonic() + 30
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(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nullprotocol"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Focused AI tasks with managed context, Knowledge and observable results"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.2.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, json_body
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
- _knowledge_for_decision(original, values, knowledge),
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
- _knowledge_for_decision(original, values, knowledge),
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, 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, schemas = [], {}
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) <= {"action", "description"}:
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
- if registered is not None and registered.schema is not None:
270
- schemas[name] = deepcopy(registered.schema)
271
- # The HTTP endpoint accepts action descriptions, not schemas. Send schema
272
- # contracts as structured task data and enforce them locally before execution.
273
- prepared = {"task": context, "actionParameterSchemas": schemas} if schemas else context
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
- super().__init__(f"NullProtocol HTTP {status_code}: {code}")
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. Inspect get(id)['state'] for readiness."""
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(await self._transport.request("GET", self._path), "sources")
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=self.config.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=self.config.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"] == {"query": "Good morning"}
388
- assert posted["context"]["actionParameterSchemas"]["send_message"] == SCHEMA
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
- assert agent.execute(agent.decide("x", ["send_message"])).error_code == "invalid_parameters"
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