nullprotocol 0.2.0__tar.gz → 0.3.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.2.0 → nullprotocol-0.3.0}/CHANGELOG.md +4 -0
  2. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/PKG-INFO +15 -1
  3. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/README.md +14 -0
  4. nullprotocol-0.3.0/examples/photos.py +26 -0
  5. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/pyproject.toml +1 -1
  6. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_agent.py +30 -4
  7. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_common.py +13 -0
  8. nullprotocol-0.3.0/tests/test_images.py +67 -0
  9. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/.github/workflows/test.yml +0 -0
  10. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/.gitignore +0 -0
  11. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/LICENSE +0 -0
  12. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/RELEASING.md +0 -0
  13. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/actions.py +0 -0
  14. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/async_basic.py +0 -0
  15. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/basic.py +0 -0
  16. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/knowledge.py +0 -0
  17. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/knowledge_management.py +0 -0
  18. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/examples/scoped_data.py +0 -0
  19. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/__init__.py +0 -0
  20. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_client.py +0 -0
  21. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_errors.py +0 -0
  22. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_knowledge.py +0 -0
  23. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_stores.py +0 -0
  24. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_transport.py +0 -0
  25. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/_types.py +0 -0
  26. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/src/nullprotocol/py.typed +0 -0
  27. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/tests/conftest.py +0 -0
  28. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/tests/test_knowledge.py +0 -0
  29. {nullprotocol-0.2.0 → nullprotocol-0.3.0}/tests/test_sdk.py +0 -0
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Add images to all four sync/async operations, with HTTPS URL validation, explicit model capability refusals and unchanged text requests.
6
+
3
7
  ## 0.2.0 — 2026-11-06
4
8
 
5
9
  - 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.2.0
3
+ Version: 0.3.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
@@ -266,3 +266,17 @@ mypy src
266
266
  ```
267
267
 
268
268
  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.
269
+
270
+ ## Photo input
271
+
272
+ ```python
273
+ result = agent.extract(
274
+ "Identify the landmark.",
275
+ {"landmark": "string"},
276
+ images=["https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80"],
277
+ )
278
+ ```
279
+
280
+ 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
+
282
+ 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).
@@ -236,3 +236,17 @@ mypy src
236
236
  ```
237
237
 
238
238
  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.
239
+
240
+ ## Photo input
241
+
242
+ ```python
243
+ result = agent.extract(
244
+ "Identify the landmark.",
245
+ {"landmark": "string"},
246
+ images=["https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80"],
247
+ )
248
+ ```
249
+
250
+ 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
+
252
+ 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).
@@ -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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nullprotocol"
7
- version = "0.2.0"
7
+ version = "0.3.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"
@@ -184,9 +184,10 @@ class Agent(AgentState):
184
184
  knowledge: Knowledge | None,
185
185
  context_keys: list[str] | None,
186
186
  idempotency_key: str | None,
187
+ images: list[str] | None = None,
187
188
  ) -> Result[Any]:
188
189
  task_id = self._task_id(idempotency_key)
189
- body = operation_payload(operation, values, self.scope, knowledge, context_keys)
190
+ body = operation_payload(operation, values, self.scope, knowledge, context_keys, images)
190
191
  return _result(
191
192
  self._transport.request(
192
193
  "POST",
@@ -203,12 +204,18 @@ class Agent(AgentState):
203
204
  data: Any,
204
205
  schema: dict[str, Any],
205
206
  *,
207
+ images: list[str] | None = None,
206
208
  knowledge: Knowledge | None = None,
207
209
  context_keys: list[str] | None = None,
208
210
  idempotency_key: str | None = None,
209
211
  ) -> Result[Any]:
210
212
  return self._run(
211
- "extract", {"data": data, "schema": schema}, knowledge, context_keys, idempotency_key
213
+ "extract",
214
+ {"data": data, "schema": schema},
215
+ knowledge,
216
+ context_keys,
217
+ idempotency_key,
218
+ images,
212
219
  )
213
220
 
214
221
  def summarize(
@@ -217,6 +224,7 @@ class Agent(AgentState):
217
224
  *,
218
225
  max_length: int = 200,
219
226
  focus: str | None = None,
227
+ images: list[str] | None = None,
220
228
  knowledge: Knowledge | None = None,
221
229
  context_keys: list[str] | None = None,
222
230
  idempotency_key: str | None = None,
@@ -227,6 +235,7 @@ class Agent(AgentState):
227
235
  knowledge,
228
236
  context_keys,
229
237
  idempotency_key,
238
+ images,
230
239
  )
231
240
 
232
241
  def validate(
@@ -235,6 +244,7 @@ class Agent(AgentState):
235
244
  subject: Any,
236
245
  *,
237
246
  reference: Any = _UNSET,
247
+ images: list[str] | None = None,
238
248
  knowledge: Knowledge | None = None,
239
249
  context_keys: list[str] | None = None,
240
250
  idempotency_key: str | None = None,
@@ -245,6 +255,7 @@ class Agent(AgentState):
245
255
  knowledge,
246
256
  context_keys,
247
257
  idempotency_key,
258
+ images,
248
259
  )
249
260
 
250
261
  def decide(
@@ -252,6 +263,7 @@ class Agent(AgentState):
252
263
  context: Any,
253
264
  actions: list[ActionChoice],
254
265
  *,
266
+ images: list[str] | None = None,
255
267
  knowledge: Knowledge | None = None,
256
268
  context_keys: list[str] | None = None,
257
269
  idempotency_key: str | None = None,
@@ -264,6 +276,7 @@ class Agent(AgentState):
264
276
  _knowledge_for_decision(original, values, knowledge),
265
277
  context_keys,
266
278
  idempotency_key,
279
+ images,
267
280
  )
268
281
  return self._remember(result, original)
269
282
 
@@ -379,9 +392,10 @@ class AsyncAgent(AgentState):
379
392
  knowledge: Knowledge | None,
380
393
  context_keys: list[str] | None,
381
394
  idempotency_key: str | None,
395
+ images: list[str] | None = None,
382
396
  ) -> Result[Any]:
383
397
  task_id = self._task_id(idempotency_key)
384
- body = operation_payload(operation, values, self.scope, knowledge, context_keys)
398
+ body = operation_payload(operation, values, self.scope, knowledge, context_keys, images)
385
399
  return _result(
386
400
  await self._transport.request(
387
401
  "POST",
@@ -398,12 +412,18 @@ class AsyncAgent(AgentState):
398
412
  data: Any,
399
413
  schema: dict[str, Any],
400
414
  *,
415
+ images: list[str] | None = None,
401
416
  knowledge: Knowledge | None = None,
402
417
  context_keys: list[str] | None = None,
403
418
  idempotency_key: str | None = None,
404
419
  ) -> Result[Any]:
405
420
  return await self._run(
406
- "extract", {"data": data, "schema": schema}, knowledge, context_keys, idempotency_key
421
+ "extract",
422
+ {"data": data, "schema": schema},
423
+ knowledge,
424
+ context_keys,
425
+ idempotency_key,
426
+ images,
407
427
  )
408
428
 
409
429
  async def summarize(
@@ -412,6 +432,7 @@ class AsyncAgent(AgentState):
412
432
  *,
413
433
  max_length: int = 200,
414
434
  focus: str | None = None,
435
+ images: list[str] | None = None,
415
436
  knowledge: Knowledge | None = None,
416
437
  context_keys: list[str] | None = None,
417
438
  idempotency_key: str | None = None,
@@ -422,6 +443,7 @@ class AsyncAgent(AgentState):
422
443
  knowledge,
423
444
  context_keys,
424
445
  idempotency_key,
446
+ images,
425
447
  )
426
448
 
427
449
  async def validate(
@@ -430,6 +452,7 @@ class AsyncAgent(AgentState):
430
452
  subject: Any,
431
453
  *,
432
454
  reference: Any = _UNSET,
455
+ images: list[str] | None = None,
433
456
  knowledge: Knowledge | None = None,
434
457
  context_keys: list[str] | None = None,
435
458
  idempotency_key: str | None = None,
@@ -440,6 +463,7 @@ class AsyncAgent(AgentState):
440
463
  knowledge,
441
464
  context_keys,
442
465
  idempotency_key,
466
+ images,
443
467
  )
444
468
 
445
469
  async def decide(
@@ -447,6 +471,7 @@ class AsyncAgent(AgentState):
447
471
  context: Any,
448
472
  actions: list[ActionChoice],
449
473
  *,
474
+ images: list[str] | None = None,
450
475
  knowledge: Knowledge | None = None,
451
476
  context_keys: list[str] | None = None,
452
477
  idempotency_key: str | None = None,
@@ -459,6 +484,7 @@ class AsyncAgent(AgentState):
459
484
  _knowledge_for_decision(original, values, knowledge),
460
485
  context_keys,
461
486
  idempotency_key,
487
+ images,
462
488
  )
463
489
  return self._remember(result, original)
464
490
 
@@ -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
 
@@ -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
@@ -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
+ )
File without changes
File without changes
File without changes