vis-agent 0.2.38.dev7570__tar.gz → 0.2.39.dev7593__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 (108) hide show
  1. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/PKG-INFO +1 -1
  2. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/gateway.json +4 -0
  3. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/toggle.json +3 -0
  4. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/pyproject.toml +1 -1
  5. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/_outside.py +111 -0
  6. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/engine/_agent.py +15 -1
  7. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/extension.py +213 -1
  8. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_agent.py +15 -0
  9. vis_agent-0.2.39.dev7593/tests/test_attachments.py +109 -0
  10. vis_agent-0.2.39.dev7593/tests/test_read_session.py +55 -0
  11. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_scoped_settings.py +17 -0
  12. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/.gitignore +0 -0
  13. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/LICENSE +0 -0
  14. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/README.md +0 -0
  15. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-arguments.json +0 -0
  16. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-cases.json +0 -0
  17. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-copy.json +0 -0
  18. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-execution.json +0 -0
  19. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-groups.json +0 -0
  20. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-read-session.json +0 -0
  21. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-reads.json +0 -0
  22. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-results.json +0 -0
  23. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity-tables.json +0 -0
  24. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/activity.json +0 -0
  25. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/diff.json +0 -0
  26. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/live-primitives.json +0 -0
  27. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/session-health.json +0 -0
  28. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/fixtures/view.json +0 -0
  29. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/activity.json +0 -0
  30. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/agents.json +0 -0
  31. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/automations.json +0 -0
  32. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/common.json +0 -0
  33. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/config.json +0 -0
  34. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/content.json +0 -0
  35. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/council.json +0 -0
  36. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/diff.json +0 -0
  37. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/improve.json +0 -0
  38. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/provider.json +0 -0
  39. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/rooms.json +0 -0
  40. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/symbol.json +0 -0
  41. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/contracts/schema/view.json +0 -0
  42. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/examples/greeter/tests/test_greeter.py +0 -0
  43. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/hatch_build.py +0 -0
  44. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/__init__.py +0 -0
  45. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/_contracts.py +0 -0
  46. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/_wire.py +0 -0
  47. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/activity.py +0 -0
  48. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/engine/__init__.py +0 -0
  49. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/engine/_agents.py +0 -0
  50. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/engine/_client.py +0 -0
  51. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/engine/_council.py +0 -0
  52. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/engine/_extensions.py +0 -0
  53. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/engine/_local.py +0 -0
  54. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/evals/__init__.py +0 -0
  55. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/evals/harbor.py +0 -0
  56. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/evals/stats.py +0 -0
  57. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/evals/trajectory.py +0 -0
  58. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/extension_package.py +0 -0
  59. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/py.typed +0 -0
  60. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/rooms.py +0 -0
  61. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/src/blockether/vis/views.py +0 -0
  62. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/conftest.py +0 -0
  63. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_activity.py +0 -0
  64. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_agent_extension_lifecycle.py +0 -0
  65. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_agent_extensions.py +0 -0
  66. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_agents.py +0 -0
  67. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_api_guides.py +0 -0
  68. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_authoring.py +0 -0
  69. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_catalog.py +0 -0
  70. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_catalog_recipe.py +0 -0
  71. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_ci_report_recipe.py +0 -0
  72. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_client.py +0 -0
  73. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_client_extensions_engine.py +0 -0
  74. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_code_quality_recipe.py +0 -0
  75. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_contracts.py +0 -0
  76. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_council.py +0 -0
  77. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_declarations.py +0 -0
  78. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_engine.py +0 -0
  79. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_engine_docstrings.py +0 -0
  80. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_evals_harbor.py +0 -0
  81. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_evals_stats.py +0 -0
  82. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_evals_trajectory.py +0 -0
  83. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_extension_package.py +0 -0
  84. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_extension_reference.py +0 -0
  85. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_gateway_performance.py +0 -0
  86. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_goals.py +0 -0
  87. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_live_primitives.py +0 -0
  88. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_live_wait.py +0 -0
  89. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_local.py +0 -0
  90. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_method_typing.py +0 -0
  91. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_monitor_recipe.py +0 -0
  92. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_namespace.py +0 -0
  93. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_outside.py +0 -0
  94. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_providers.py +0 -0
  95. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_record_arguments.py +0 -0
  96. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_registration.py +0 -0
  97. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_repair_hooks.py +0 -0
  98. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_result_sequence.py +0 -0
  99. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_rooms.py +0 -0
  100. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_sdk_bootstrap.py +0 -0
  101. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_sdk_guide.py +0 -0
  102. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_sdk_typing.py +0 -0
  103. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_structured_live.py +0 -0
  104. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_symbol_contract.py +0 -0
  105. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_testing.py +0 -0
  106. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_text_list_arguments.py +0 -0
  107. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_tools.py +0 -0
  108. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39.dev7593}/tests/test_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vis-agent
3
- Version: 0.2.38.dev7570
3
+ Version: 0.2.39.dev7593
4
4
  Summary: The Vis Python SDK — author extensions and control local or remote engines.
5
5
  Project-URL: Homepage, https://vis.blockether.com
6
6
  Project-URL: Documentation, https://vis.blockether.com
@@ -2732,6 +2732,10 @@
2732
2732
  "type": "string"
2733
2733
  }
2734
2734
  },
2735
+ "change_error": {
2736
+ "type": "string",
2737
+ "description": "Present after a write when the owner's change hook failed. The value stays saved."
2738
+ },
2735
2739
  "parent": {
2736
2740
  "type": "string",
2737
2741
  "description": "Id of the setting that this row belongs under."
@@ -100,6 +100,9 @@
100
100
  "minItems": 1
101
101
  },
102
102
  "visible_fn": {},
103
+ "on_change_fn": {
104
+ "description": "Local callback that receives the id, old value, new value and scope once after a write changes the effective value."
105
+ },
103
106
  "scopes": {
104
107
  "type": "array",
105
108
  "items": {
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
8
8
 
9
9
  [project]
10
10
  name = "vis-agent"
11
- version = "0.2.38.dev7570"
11
+ version = "0.2.39.dev7593"
12
12
  description = "The Vis Python SDK — author extensions and control local or remote engines."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -8,6 +8,7 @@ primes prompt values. With `VIS_OUTSIDE_NONINTERACTIVE=1`, prompts return
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import base64
11
12
  import json
12
13
  import os
13
14
  import secrets
@@ -81,6 +82,22 @@ def call_tool(tool, args, kwargs):
81
82
  )
82
83
 
83
84
 
85
+ def read_session(target):
86
+ """Refuse a session read without a bound Vis session and its database."""
87
+ raise Refused(
88
+ "vis.read_session needs a bound Vis session. Outside an extension, read the "
89
+ "session with the SDK client's get_session_transcript(session_id)."
90
+ )
91
+
92
+
93
+ def list_sessions(search):
94
+ """Refuse a session search without a bound Vis session and its database."""
95
+ raise Refused(
96
+ "vis.list_sessions needs a bound Vis session. Outside an extension, list the "
97
+ "sessions with the SDK client's list_sessions()."
98
+ )
99
+
100
+
84
101
  # -- Where the outside host keeps things --------------------------------------
85
102
 
86
103
 
@@ -212,6 +229,93 @@ def forget_secret(handle):
212
229
  return _VAULT.pop(str(handle), None) is not None
213
230
 
214
231
 
232
+ # -- Attachments ----------------------------------------------------------------
233
+ # Outside Vis, no session stores artifacts. They stay in this process memory, so
234
+ # an extension test can attach an artifact and read it back.
235
+
236
+ _ARTIFACTS = []
237
+ _ARTIFACT_BYTES = {}
238
+
239
+
240
+ def attach(options):
241
+ """Store one artifact in memory and return its descriptor as JSON."""
242
+ data = base64.b64decode(options.get("base64") or "")
243
+ if not data:
244
+ raise ValueError("vis.attach: empty payload.")
245
+ name = options.get("filename") or "artifact"
246
+ media_type = (
247
+ options.get("media_type")
248
+ or options.get("media_type_hint")
249
+ or "application/octet-stream"
250
+ )
251
+ with _LOCK:
252
+ row = {
253
+ "id": uuid.uuid4().hex,
254
+ "source": "tool",
255
+ "filename": name,
256
+ "version": 1 + sum(1 for r in _ARTIFACTS if r["filename"] == name),
257
+ "media_type": media_type,
258
+ "kind": options.get("kind")
259
+ or ("image" if media_type.startswith("image/") else "file"),
260
+ "size": len(data),
261
+ "is_pending": True,
262
+ "audience": options.get("audience") or "both",
263
+ "commentable": options.get("commentable") is True,
264
+ }
265
+ _ARTIFACTS.append(row)
266
+ _ARTIFACT_BYTES[row["id"]] = data
267
+ return json.dumps(row)
268
+
269
+
270
+ def list_attachments():
271
+ """List the in-memory artifact descriptors as JSON, oldest first."""
272
+ with _LOCK:
273
+ return json.dumps(_ARTIFACTS)
274
+
275
+
276
+ def _artifact(caller, target, version):
277
+ # The same addressing as the engine: an id, or a filename with an optional
278
+ # version, where a negative version counts back from the latest one.
279
+ with _LOCK:
280
+ if version is None:
281
+ for row in _ARTIFACTS:
282
+ if row["id"] == target:
283
+ return row
284
+ versions = sorted(
285
+ (r for r in _ARTIFACTS if r["filename"] == target),
286
+ key=lambda r: r["version"],
287
+ )
288
+ if versions:
289
+ if version is None:
290
+ return versions[-1]
291
+ if -len(versions) <= version <= -1:
292
+ return versions[version]
293
+ for row in versions:
294
+ if row["version"] == version:
295
+ return row
296
+ raise LookupError(f"{caller}: {target!r} has no version {version}.")
297
+ raise LookupError(f"{caller}: unknown id or filename {target!r}.")
298
+
299
+
300
+ def get_attachment(target, version):
301
+ """Return one in-memory artifact descriptor as JSON."""
302
+ return json.dumps(_artifact("get_attachment", target, version))
303
+
304
+
305
+ def read_attachment(target, version):
306
+ """Return the bytes of one in-memory artifact as base64 text."""
307
+ row = _artifact("read_attachment", target, version)
308
+ return base64.b64encode(_ARTIFACT_BYTES[row["id"]]).decode("ascii")
309
+
310
+
311
+ def show_attachment(target, version):
312
+ """Accept an image. Outside Vis, no model request follows to show it to."""
313
+ row = _artifact("show_attachment", target, version)
314
+ if not row["media_type"].startswith("image/"):
315
+ raise ValueError(f"show_attachment: {row['filename']!r} is not an image.")
316
+ return [row["id"], row["filename"], row["media_type"], row["size"]]
317
+
318
+
215
319
  # -- Environment --------------------------------------------------------------
216
320
 
217
321
 
@@ -1332,12 +1436,19 @@ _IMPLEMENTATIONS = {
1332
1436
  "jailed_shell": jailed_shell,
1333
1437
  "council_wake": council_wake,
1334
1438
  "call_tool": call_tool,
1439
+ "read_session": read_session,
1440
+ "list_sessions": list_sessions,
1335
1441
  "request_input": request_input,
1336
1442
  "live": live,
1337
1443
  "activity": lambda blocks: False,
1338
1444
  "reveal_secret": reveal_secret,
1339
1445
  "forget_secret": forget_secret,
1340
1446
  "declare_env": declare_env,
1447
+ "attach": attach,
1448
+ "list_attachments": list_attachments,
1449
+ "get_attachment": get_attachment,
1450
+ "read_attachment": read_attachment,
1451
+ "show_attachment": show_attachment,
1341
1452
  }
1342
1453
 
1343
1454
 
@@ -377,6 +377,10 @@ class Agent:
377
377
  extensions: Iterable of `blockether.vis.extension.Extension` declarations.
378
378
  Their Python callables stay in your application and execute on the
379
379
  calling thread while you read, wait or iterate events.
380
+ tools: Optional iterable of Vis extension tool names for the conversation.
381
+ Names such as `"py.lint_code"` or `"py.repl_*"` keep only those tools of
382
+ the extensions they name. Names such as `"-py.repl_stop"` turn tools off.
383
+ Do not mix the two forms. The gateway refuses an unknown name.
380
384
 
381
385
  Construction does not start a process or connect. Session access, context
382
386
  entry, `send` or `run` starts the conversation. Closing the default agent stops
@@ -386,7 +390,8 @@ class Agent:
386
390
 
387
391
  Raises:
388
392
  TypeError: The supplied layer does not implement
389
- `blockether.vis.engine.ExecutionLayer`, or an extension is unsupported.
393
+ `blockether.vis.engine.ExecutionLayer`, an extension is unsupported, or
394
+ `tools` is not an iterable of strings.
390
395
  ValueError: A project or extension declaration is invalid.
391
396
  FileNotFoundError: The local project does not exist.
392
397
  NotADirectoryError: The local project is not a directory.
@@ -398,10 +403,17 @@ class Agent:
398
403
  *,
399
404
  execution_layer: ExecutionLayer | None = None,
400
405
  extensions=(),
406
+ tools=None,
401
407
  ):
402
408
  from ._extensions import ClientExtensions
403
409
 
404
410
  self._extensions = ClientExtensions(extensions)
411
+ if tools is not None:
412
+ if isinstance(tools, str):
413
+ raise TypeError("tools must be an iterable of tool names, not a string")
414
+ tools = list(tools)
415
+ if not all(isinstance(name, str) for name in tools):
416
+ raise TypeError("tools must be an iterable of tool names")
405
417
  if execution_layer is not None and not isinstance(
406
418
  execution_layer, ExecutionLayer
407
419
  ):
@@ -411,6 +423,8 @@ class Agent:
411
423
  LocalEngine(root=project) if execution_layer is None else execution_layer
412
424
  )
413
425
  self._session_options = self.execution_layer.session_options(project)
426
+ if tools is not None:
427
+ self._session_options["tools"] = tools
414
428
  self.project = self._session_options["root"]
415
429
  self._session = None
416
430
  self._closed = False
@@ -9,10 +9,14 @@ from __future__ import annotations
9
9
  import __future__
10
10
 
11
11
  import ast
12
+ import base64
12
13
  import builtins
13
14
  import inspect
15
+ import io
14
16
  import json
15
17
  import math
18
+ import mimetypes
19
+ import os
16
20
  import sys
17
21
  import time
18
22
  from collections.abc import Callable, Mapping, Sequence
@@ -138,6 +142,34 @@ class Host(Protocol):
138
142
  """Run one active session tool by the name `python_execution` calls."""
139
143
  ...
140
144
 
145
+ def read_session(self, target: str | None) -> str:
146
+ """Read one whole session as the sandbox `read_session` does. Return JSON."""
147
+ ...
148
+
149
+ def list_sessions(self, search: str | None) -> str:
150
+ """List sessions as the sandbox `list_sessions` does. Return JSON."""
151
+ ...
152
+
153
+ def attach(self, options: Mapping[str, Any]) -> str:
154
+ """Store one artifact of the running block. Return its descriptor as JSON."""
155
+ ...
156
+
157
+ def list_attachments(self) -> str:
158
+ """List the session's artifact descriptors as JSON, pending ones included."""
159
+ ...
160
+
161
+ def get_attachment(self, target: str, version: int | None) -> str:
162
+ """Return the descriptor of one artifact as JSON."""
163
+ ...
164
+
165
+ def read_attachment(self, target: str, version: int | None) -> str:
166
+ """Return the bytes of one artifact as base64 text."""
167
+ ...
168
+
169
+ def show_attachment(self, target: str, version: int | None) -> Sequence[Any]:
170
+ """Queue a stored image for the next model request: id, filename, type, size."""
171
+ ...
172
+
141
173
 
142
174
  # The engine seeds `_host` into the module dict before exec.
143
175
  _host: Host
@@ -1007,6 +1039,9 @@ class Setting(Generic[_SettingValue]):
1007
1039
  for a choice setting. Outside Vis, it returns the declared default.
1008
1040
  `parent` names another setting of the same section. Settings shows this setting
1009
1041
  under that setting. Settings can nest at any depth.
1042
+ `on_change(old, new, scope)` runs once after a person changes the setting in the
1043
+ app or TUI and its effective value changes. `scope` is `global`, `project`, `group`
1044
+ or `session`. Vis keeps the saved value if the hook raises, and shows the error.
1010
1045
  """
1011
1046
 
1012
1047
  id: str
@@ -1017,6 +1052,7 @@ class Setting(Generic[_SettingValue]):
1017
1052
  scopes: Sequence[str] | None = None
1018
1053
  description: str | None = None
1019
1054
  parent: str | None = None
1055
+ on_change: Callable[[Any, Any, str], Any] | None = None
1020
1056
 
1021
1057
  def __post_init__(self):
1022
1058
  for name, value in (("scopes", self.scopes), ("choices", self.choices)):
@@ -1025,7 +1061,11 @@ class Setting(Generic[_SettingValue]):
1025
1061
  if not isinstance(value, (list, tuple)):
1026
1062
  raise TypeError(f"Setting {name} must be a list or tuple")
1027
1063
  object.__setattr__(self, name, tuple(value))
1028
- spec = _host.setting_declaration(self._spec())
1064
+ if self.on_change is not None and not callable(self.on_change):
1065
+ raise TypeError("Setting on_change must be callable")
1066
+ declaration = self._spec()
1067
+ declaration.pop("on_change", None)
1068
+ spec = _host.setting_declaration(declaration)
1029
1069
  object.__setattr__(self, "scopes", tuple(spec["scopes"]))
1030
1070
  if self.type == "boolean" and type(self.default) is not bool:
1031
1071
  raise ValueError("A boolean setting requires a boolean default")
@@ -1054,6 +1094,8 @@ class Setting(Generic[_SettingValue]):
1054
1094
  spec["description"] = self.description
1055
1095
  if self.parent is not None:
1056
1096
  spec["parent"] = self.parent
1097
+ if self.on_change is not None:
1098
+ spec["on_change"] = self.on_change
1057
1099
  return spec
1058
1100
 
1059
1101
  def value(self) -> _SettingValue:
@@ -3796,6 +3838,176 @@ def forget(handle):
3796
3838
  return bool(_host.forget_secret(str(handle)))
3797
3839
 
3798
3840
 
3841
+ # -- Attachments ----------------------------------------------------------------
3842
+ # An extension tool stores and reads artifacts like the sandbox `attach` verbs.
3843
+ # The engine binds each call to the python_execution block that runs the tool.
3844
+
3845
+ _ATTACH_AUDIENCES = ("both", "user", "model")
3846
+
3847
+ # Pillow encoders by filename suffix, as in the sandbox `attach` verb.
3848
+ _PIL_FORMATS = {
3849
+ ".png": "PNG",
3850
+ ".jpg": "JPEG",
3851
+ ".jpeg": "JPEG",
3852
+ ".gif": "GIF",
3853
+ ".bmp": "BMP",
3854
+ ".webp": "WEBP",
3855
+ ".tif": "TIFF",
3856
+ ".tiff": "TIFF",
3857
+ }
3858
+
3859
+
3860
+ def _attachment_row(row):
3861
+ # The engine sends kebab-case keys; the SDK returns snake_case keys.
3862
+ return {str(k).replace("-", "_"): v for k, v in dict(row).items()}
3863
+
3864
+
3865
+ def _attachment_source(source, filename):
3866
+ """Return `(bytes, filename)` for a path, bytes, a figure or a Pillow image."""
3867
+ if isinstance(source, (bytes, bytearray, memoryview)):
3868
+ return bytes(source), str(filename) if filename else "artifact"
3869
+ if hasattr(source, "savefig"):
3870
+ buffer = io.BytesIO()
3871
+ source.savefig(buffer, format="png")
3872
+ return buffer.getvalue(), str(filename) if filename else "figure.png"
3873
+ if hasattr(source, "save") and hasattr(source, "mode") and hasattr(source, "size"):
3874
+ name = str(filename) if filename else "image.png"
3875
+ buffer = io.BytesIO()
3876
+ source.save(buffer, format=_PIL_FORMATS.get(Path(name).suffix.lower(), "PNG"))
3877
+ return buffer.getvalue(), name
3878
+ if not isinstance(source, (str, PathLike)):
3879
+ raise TypeError(
3880
+ "vis.attach: source must be a path, bytes, an image or a figure object, got "
3881
+ + type(source).__name__
3882
+ )
3883
+ path = Path(os.path.expandvars(os.path.expanduser(os.fspath(source))))
3884
+ if path.is_dir():
3885
+ raise IsADirectoryError(f"vis.attach: {path} is a directory - attach one file")
3886
+ try:
3887
+ data = path.read_bytes()
3888
+ except FileNotFoundError:
3889
+ raise FileNotFoundError(f"vis.attach: no such file: {path}") from None
3890
+ return data, str(filename) if filename else (path.name or "artifact")
3891
+
3892
+
3893
+ def _attachment_target(target, version):
3894
+ if isinstance(target, Mapping):
3895
+ target = target.get("id") or target.get("filename") or ""
3896
+ if version is not None and (type(version) is not int):
3897
+ raise TypeError("attachment version must be an integer")
3898
+ return str(target), version
3899
+
3900
+
3901
+ def attach(
3902
+ source,
3903
+ filename=None,
3904
+ *,
3905
+ kind=None,
3906
+ media_type=None,
3907
+ label=None,
3908
+ audience="both",
3909
+ commentable=False,
3910
+ ):
3911
+ """Store an artifact as a durable attachment of the running block.
3912
+
3913
+ `source` is a path, bytes, a Matplotlib figure or a Pillow image. The engine
3914
+ stores the artifact with the `python_execution` block that called this tool,
3915
+ exactly like the sandbox `attach` verb. A second attachment under the same
3916
+ `filename` is the next version of that artifact. `audience` is `both`, `user`
3917
+ or `model`; a PDF or HTML document is for the user only. Set
3918
+ `commentable=True` to let a person comment on this version.
3919
+
3920
+ Returns the descriptor dict: id, filename, version, media_type, kind, size,
3921
+ audience and commentable. Every read function takes it as its target.
3922
+ """
3923
+ data, name = _attachment_source(source, filename)
3924
+ if audience not in _ATTACH_AUDIENCES:
3925
+ raise ValueError(
3926
+ f"vis.attach: audience must be 'both', 'user' or 'model', got {audience!r}"
3927
+ )
3928
+ if not isinstance(commentable, bool):
3929
+ raise TypeError("vis.attach: commentable must be a boolean")
3930
+ if not data:
3931
+ raise ValueError("vis.attach: empty payload.")
3932
+ options = {
3933
+ "base64": base64.b64encode(data).decode("ascii"),
3934
+ "filename": name,
3935
+ "media_type": media_type,
3936
+ "media_type_hint": mimetypes.guess_type(name)[0],
3937
+ "kind": kind,
3938
+ "audience": audience,
3939
+ "label": label,
3940
+ "commentable": commentable,
3941
+ }
3942
+ payload = _host.attach({k: v for k, v in options.items() if v is not None})
3943
+ return _attachment_row(json.loads(payload))
3944
+
3945
+
3946
+ def list_attachments(name=None):
3947
+ """List the artifact descriptors of this session, oldest first.
3948
+
3949
+ The list includes the artifacts that the running block attached. With `name`,
3950
+ it lists the versions of that one artifact.
3951
+ """
3952
+ rows = [_attachment_row(row) for row in json.loads(_host.list_attachments())]
3953
+ if name is None:
3954
+ return rows
3955
+ versions = [row for row in rows if row.get("filename") == str(name)]
3956
+ return sorted(versions, key=lambda row: int(row.get("version") or 1))
3957
+
3958
+
3959
+ def get_attachment(target, version=None):
3960
+ """Return the descriptor of one artifact, without its bytes.
3961
+
3962
+ `target` is a filename, an id or a descriptor. A filename means the latest
3963
+ version, unless you give `version`. A negative version counts back.
3964
+ """
3965
+ payload = _host.get_attachment(*_attachment_target(target, version))
3966
+ return _attachment_row(json.loads(payload))
3967
+
3968
+
3969
+ def read_attachment(target, version=None):
3970
+ """Return the bytes of one artifact. `target` works as in `get_attachment`."""
3971
+ encoded = _host.read_attachment(*_attachment_target(target, version))
3972
+ return base64.b64decode(encoded) if encoded else b""
3973
+
3974
+
3975
+ def show_attachment(target, version=None):
3976
+ """Show a stored image to the model for the next request only.
3977
+
3978
+ `target` works as in `get_attachment`. Returns the id, filename, media_type
3979
+ and size of the queued image.
3980
+ """
3981
+ shown = list(_host.show_attachment(*_attachment_target(target, version)))
3982
+ return dict(zip(("id", "filename", "media_type", "size"), shown, strict=True))
3983
+
3984
+
3985
+ def read_session(target=None):
3986
+ """Read one whole session, exactly as the sandbox `read_session` does.
3987
+
3988
+ Without `target`, it reads the session that runs the extension. `target` takes a
3989
+ bare id, an unambiguous prefix or the copied `vis_session_id#<uuid>` marker. The
3990
+ `introspection` toggle does not apply, because an extension is trusted code.
3991
+
3992
+ Returns the same string-keyed dict: session, current_turn, failures, diagnosis,
3993
+ session_forks, turn_retries, usage and transcript.
3994
+ """
3995
+ return json.loads(_host.read_session(None if target is None else str(target)))
3996
+
3997
+
3998
+ def list_sessions(search=None):
3999
+ """List sessions newest first, exactly as the sandbox `list_sessions` does.
4000
+
4001
+ With `search`, it runs the session search of the TUI and the Companion app. The
4002
+ rows come in rank order: title, request, reply, then thinking, newest first in
4003
+ each rank. Keep that order. The `introspection` toggle does not apply.
4004
+
4005
+ Returns string-keyed rows: id, channel, title, goal, turn_count, created_at and
4006
+ modified_at. A match adds rank, the is_in_* flags and optional snippets.
4007
+ """
4008
+ return json.loads(_host.list_sessions(None if search is None else str(search)))
4009
+
4010
+
3799
4011
  # -- Live views ---------------------------------------------------------------
3800
4012
  # A form PAUSES a run to collect values; a live view REPORTS on work already
3801
4013
  # running. Nothing here blocks: a push crosses to the engine, every mounted
@@ -276,6 +276,21 @@ def test_invalid_execution_layer_is_rejected_before_default_engine(local):
276
276
  local.assert_not_called()
277
277
 
278
278
 
279
+ def test_tools_reach_the_new_conversation(local, tmp_path):
280
+ with engine.Agent(tmp_path, tools=("-py.lint_code", "py.repl_*")) as agent:
281
+ assert agent.session is agent.execution_layer.create_session.return_value
282
+ agent.execution_layer.create_session.assert_called_once_with(
283
+ root=str(tmp_path.resolve()), tools=["-py.lint_code", "py.repl_*"]
284
+ )
285
+
286
+
287
+ @pytest.mark.parametrize("tools", ["py.lint_code", [1]])
288
+ def test_tools_must_be_names(local, tmp_path, tools):
289
+ with pytest.raises(TypeError, match="tool names"):
290
+ engine.Agent(tmp_path, tools=tools)
291
+ local.assert_not_called()
292
+
293
+
279
294
  class Delivery(BaseModel):
280
295
  model_config = ConfigDict(extra="forbid")
281
296
 
@@ -0,0 +1,109 @@
1
+ """`vis.attach` and the attachment readers, against the outside host."""
2
+
3
+ import base64
4
+ import json
5
+
6
+ import blockether.vis.extension as vis
7
+ import pytest
8
+ from blockether.vis import _outside
9
+
10
+ PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 16
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def _empty_store(monkeypatch):
15
+ monkeypatch.setattr(_outside, "_ARTIFACTS", [])
16
+ monkeypatch.setattr(_outside, "_ARTIFACT_BYTES", {})
17
+ monkeypatch.setattr(vis, "_host", _outside.host)
18
+
19
+
20
+ def test_attach_stores_a_path_and_returns_its_descriptor(tmp_path):
21
+ source = tmp_path / "report.csv"
22
+ source.write_text("a,b\n1,2\n", encoding="utf-8")
23
+
24
+ row = vis.attach(source, label="Totals", commentable=True)
25
+
26
+ assert row["filename"] == "report.csv"
27
+ assert row["version"] == 1
28
+ assert row["media_type"] == "text/csv"
29
+ assert row["kind"] == "file"
30
+ assert row["size"] == 8
31
+ assert row["commentable"] is True
32
+ assert vis.read_attachment(row) == b"a,b\n1,2\n"
33
+
34
+
35
+ def test_same_filename_adds_a_version_and_targets_resolve_it():
36
+ first = vis.attach(PNG, "chart.png")
37
+ second = vis.attach(PNG + b"x", "chart.png", audience="user")
38
+
39
+ assert second["version"] == 2
40
+ assert second["kind"] == "image"
41
+ assert [r["version"] for r in vis.list_attachments("chart.png")] == [1, 2]
42
+ assert vis.get_attachment("chart.png")["id"] == second["id"]
43
+ assert vis.get_attachment("chart.png", version=-2)["id"] == first["id"]
44
+ assert vis.get_attachment(first["id"])["version"] == 1
45
+ assert vis.read_attachment("chart.png", 1) == PNG
46
+ assert vis.show_attachment(second) == {
47
+ "id": second["id"],
48
+ "filename": "chart.png",
49
+ "media_type": "image/png",
50
+ "size": len(PNG) + 1,
51
+ }
52
+
53
+
54
+ def test_attach_sends_one_options_map_to_the_host(monkeypatch):
55
+ sent = []
56
+
57
+ class Host:
58
+ def attach(self, options):
59
+ sent.append(options)
60
+ return json.dumps(
61
+ {"id": "a1", "filename": options["filename"], "media-type": "x/y"}
62
+ )
63
+
64
+ monkeypatch.setattr(vis, "_host", Host())
65
+
66
+ # `.txt` maps to text/plain on every Python version; `.md` does not before 3.13.
67
+ assert vis.attach(b"data", "notes.txt", media_type="text/markdown") == {
68
+ "id": "a1",
69
+ "filename": "notes.txt",
70
+ "media_type": "x/y",
71
+ }
72
+ assert sent == [
73
+ {
74
+ "base64": base64.b64encode(b"data").decode("ascii"),
75
+ "filename": "notes.txt",
76
+ "media_type": "text/markdown",
77
+ "media_type_hint": "text/plain",
78
+ "audience": "both",
79
+ "commentable": False,
80
+ }
81
+ ]
82
+
83
+
84
+ def test_attach_refuses_wrong_sources_and_options(tmp_path):
85
+ with pytest.raises(TypeError, match="source must be a path"):
86
+ vis.attach(object())
87
+ with pytest.raises(IsADirectoryError):
88
+ vis.attach(tmp_path)
89
+ with pytest.raises(FileNotFoundError, match="no such file"):
90
+ vis.attach(tmp_path / "missing.png")
91
+ with pytest.raises(ValueError, match="audience"):
92
+ vis.attach(PNG, "a.png", audience="everyone")
93
+ with pytest.raises(TypeError, match="commentable"):
94
+ vis.attach(PNG, "a.png", commentable="yes")
95
+ with pytest.raises(ValueError, match="empty payload"):
96
+ vis.attach(b"", "a.png")
97
+
98
+
99
+ def test_readers_name_an_unknown_target_and_a_non_image():
100
+ vis.attach(b"text", "notes.txt")
101
+
102
+ with pytest.raises(LookupError, match="unknown id or filename"):
103
+ vis.get_attachment("other.txt")
104
+ with pytest.raises(LookupError, match="has no version 3"):
105
+ vis.read_attachment("notes.txt", 3)
106
+ with pytest.raises(ValueError, match="not an image"):
107
+ vis.show_attachment("notes.txt")
108
+ with pytest.raises(TypeError, match="version must be an integer"):
109
+ vis.get_attachment("notes.txt", "1")
@@ -0,0 +1,55 @@
1
+ """`vis.read_session` and `vis.list_sessions` use the canonical host session reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import blockether.vis.extension as vis
8
+ import pytest
9
+ from blockether.vis import _outside
10
+
11
+
12
+ def test_answers_the_host_read_as_one_string_keyed_dict(monkeypatch):
13
+ targets = []
14
+ data = {
15
+ "session": {"id": "6fccb4a2-bf20-448c-ac4f-9d170983c0f3"},
16
+ "transcript": {"turns": [{"position": 1, "user_request": "Fix the typo."}]},
17
+ }
18
+
19
+ def read_session(target):
20
+ targets.append(target)
21
+ return json.dumps(data)
22
+
23
+ monkeypatch.setattr(vis._host, "read_session", read_session)
24
+ assert vis.read_session() == data
25
+ assert vis.read_session("vis_session_id#6fccb4a2") == data
26
+ assert targets == [None, "vis_session_id#6fccb4a2"]
27
+
28
+
29
+ def test_answers_the_host_search_rows_in_their_rank_order(monkeypatch):
30
+ searches = []
31
+ rows = [
32
+ {"id": "b", "title": "Fix the typo", "rank": 0, "is_in_title": True},
33
+ {"id": "a", "title": "Release", "rank": 1, "is_in_request": True},
34
+ ]
35
+
36
+ def list_sessions(search):
37
+ searches.append(search)
38
+ return json.dumps(rows)
39
+
40
+ monkeypatch.setattr(vis._host, "list_sessions", list_sessions)
41
+ assert vis.list_sessions() == rows
42
+ assert vis.list_sessions(search="typo") == rows
43
+ assert searches == [None, "typo"]
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ "call, message",
48
+ [
49
+ (vis.read_session, "vis.read_session needs a bound Vis session"),
50
+ (vis.list_sessions, "vis.list_sessions needs a bound Vis session"),
51
+ ],
52
+ )
53
+ def test_refuses_outside_a_vis_session(call, message):
54
+ with pytest.raises(_outside.Refused, match=message):
55
+ call()
@@ -68,6 +68,23 @@ def test_parent_nests_a_setting():
68
68
  vis.Setting(id="test_detail", label="Test detail", default=False, parent=" ")
69
69
 
70
70
 
71
+ # Regression for #369: a setting declares a change hook that the host calls.
72
+ def test_on_change_declares_a_callable_hook():
73
+ def record(old, new, scope):
74
+ return None
75
+
76
+ plain = vis.Setting(id="test_feature", label="Test feature", default=True)
77
+ hooked = vis.Setting(
78
+ id="test_feature", label="Test feature", default=True, on_change=record
79
+ )
80
+ assert "on_change" not in plain._spec()
81
+ assert hooked._spec()["on_change"] is record
82
+ with pytest.raises(TypeError):
83
+ vis.Setting(
84
+ id="test_feature", label="Test feature", default=True, on_change="record"
85
+ )
86
+
87
+
71
88
  # Regression for #322 and #323: a 300-character description is valid, and a
72
89
  # longer one names the setting, the field, the rule and the limit.
73
90
  def test_description_limit_and_actionable_error():