vis-agent 0.2.38.dev7568__tar.gz → 0.2.39__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.
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/PKG-INFO +1 -1
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/gateway.json +4 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/toggle.json +3 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/pyproject.toml +1 -1
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/_contracts.py +21 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/_outside.py +122 -4
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/engine/_agent.py +15 -1
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/extension.py +227 -15
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_agent.py +15 -0
- vis_agent-0.2.39/tests/test_attachments.py +109 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_live_primitives.py +25 -0
- vis_agent-0.2.39/tests/test_read_session.py +55 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_scoped_settings.py +17 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/.gitignore +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/LICENSE +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/README.md +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-arguments.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-cases.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-copy.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-execution.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-groups.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-read-session.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-reads.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-results.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity-tables.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/activity.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/diff.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/live-primitives.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/session-health.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/fixtures/view.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/activity.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/agents.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/automations.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/common.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/config.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/content.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/council.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/diff.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/improve.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/provider.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/rooms.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/symbol.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/contracts/schema/view.json +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/examples/greeter/tests/test_greeter.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/hatch_build.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/__init__.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/_wire.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/activity.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/engine/__init__.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/engine/_agents.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/engine/_client.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/engine/_council.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/engine/_extensions.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/engine/_local.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/evals/__init__.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/evals/harbor.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/evals/stats.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/evals/trajectory.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/extension_package.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/py.typed +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/rooms.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/src/blockether/vis/views.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/conftest.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_activity.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_agent_extension_lifecycle.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_agent_extensions.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_agents.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_api_guides.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_authoring.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_catalog.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_catalog_recipe.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_ci_report_recipe.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_client.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_client_extensions_engine.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_code_quality_recipe.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_contracts.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_council.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_declarations.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_engine.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_engine_docstrings.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_evals_harbor.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_evals_stats.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_evals_trajectory.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_extension_package.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_extension_reference.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_gateway_performance.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_goals.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_live_wait.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_local.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_method_typing.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_monitor_recipe.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_namespace.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_outside.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_providers.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_record_arguments.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_registration.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_repair_hooks.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_result_sequence.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_rooms.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_sdk_bootstrap.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_sdk_guide.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_sdk_typing.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_structured_live.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_symbol_contract.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_testing.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_text_list_arguments.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/tests/test_tools.py +0 -0
- {vis_agent-0.2.38.dev7568 → vis_agent-0.2.39}/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.
|
|
3
|
+
Version: 0.2.39
|
|
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.
|
|
11
|
+
version = "0.2.39"
|
|
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"
|
|
@@ -41,6 +41,27 @@ def definition(name: str, key: str) -> dict[str, Any]:
|
|
|
41
41
|
return schema(name)["$defs"][key]
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
# An appended live item replaces the item with its id. The host fills these keys
|
|
45
|
+
# when the item leaves them out; every other required key must come with it.
|
|
46
|
+
_LIVE_ITEM_DEFAULTS = {"step": {"tone": "idle"}, "link": {"target_kind": "url"}}
|
|
47
|
+
_LIVE_ITEMS = {"stats": "stat", "steps": "step", "rows": "row", "links": "link"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def live_items(key: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
51
|
+
"""Return appended live items as the host keeps them; refuse a missing key."""
|
|
52
|
+
item = _LIVE_ITEMS[key]
|
|
53
|
+
required = definition("view", item)["required"]
|
|
54
|
+
result = []
|
|
55
|
+
for one in items:
|
|
56
|
+
one = {**_LIVE_ITEM_DEFAULTS.get(item, {}), **one}
|
|
57
|
+
if missing := [name for name in required if name not in one]:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"{item} {one.get('id')}: {', '.join(missing)} is missing (required)"
|
|
60
|
+
)
|
|
61
|
+
result.append(one)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
44
65
|
@lru_cache(maxsize=64)
|
|
45
66
|
def _validator(name: str, definition: str):
|
|
46
67
|
from jsonschema import Draft202012Validator, FormatChecker
|
|
@@ -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
|
|
|
@@ -1037,9 +1141,10 @@ def _live_find(nodes, node_id):
|
|
|
1037
1141
|
return None
|
|
1038
1142
|
|
|
1039
1143
|
|
|
1040
|
-
def _live_upsert(held, incoming):
|
|
1144
|
+
def _live_upsert(held, incoming, *, is_merged=False):
|
|
1041
1145
|
# An item keeps the position it was first given: a row that updates in place
|
|
1042
|
-
# is what makes a table readable while it fills.
|
|
1146
|
+
# is what makes a table readable while it fills. The host replaces an item;
|
|
1147
|
+
# only a group declaration merges onto the one before it.
|
|
1043
1148
|
merged = list(held or [])
|
|
1044
1149
|
for item in incoming or []:
|
|
1045
1150
|
item_id = item.get("id") if isinstance(item, dict) else None
|
|
@@ -1051,7 +1156,7 @@ def _live_upsert(held, incoming):
|
|
|
1051
1156
|
if at is None:
|
|
1052
1157
|
merged.append(item)
|
|
1053
1158
|
else:
|
|
1054
|
-
merged[at] = {**merged[at], **item}
|
|
1159
|
+
merged[at] = {**merged[at], **item} if is_merged else item
|
|
1055
1160
|
return merged
|
|
1056
1161
|
|
|
1057
1162
|
|
|
@@ -1144,12 +1249,18 @@ def _live_apply(view, op):
|
|
|
1144
1249
|
)
|
|
1145
1250
|
node[key] = list(node.get(key) or []) + payload
|
|
1146
1251
|
else:
|
|
1252
|
+
try:
|
|
1253
|
+
payload = _contracts.live_items(key, payload)
|
|
1254
|
+
except ValueError as error:
|
|
1255
|
+
raise Refused(str(error)) from None
|
|
1147
1256
|
node[key] = _live_upsert(node.get(key), payload)
|
|
1148
1257
|
if "groups" in op:
|
|
1149
1258
|
# A table upserts its group DECLARATIONS in the same append that
|
|
1150
1259
|
# carries the rows hanging under them, and a second declaration
|
|
1151
1260
|
# merges onto the first: re-toning a head keeps its label.
|
|
1152
|
-
node["groups"] = _live_upsert(
|
|
1261
|
+
node["groups"] = _live_upsert(
|
|
1262
|
+
node.get("groups"), op["groups"], is_merged=True
|
|
1263
|
+
)
|
|
1153
1264
|
elif name == "clear":
|
|
1154
1265
|
node[_live_items_key(node, "clear")] = []
|
|
1155
1266
|
if node.get("type") == "log":
|
|
@@ -1332,12 +1443,19 @@ _IMPLEMENTATIONS = {
|
|
|
1332
1443
|
"jailed_shell": jailed_shell,
|
|
1333
1444
|
"council_wake": council_wake,
|
|
1334
1445
|
"call_tool": call_tool,
|
|
1446
|
+
"read_session": read_session,
|
|
1447
|
+
"list_sessions": list_sessions,
|
|
1335
1448
|
"request_input": request_input,
|
|
1336
1449
|
"live": live,
|
|
1337
1450
|
"activity": lambda blocks: False,
|
|
1338
1451
|
"reveal_secret": reveal_secret,
|
|
1339
1452
|
"forget_secret": forget_secret,
|
|
1340
1453
|
"declare_env": declare_env,
|
|
1454
|
+
"attach": attach,
|
|
1455
|
+
"list_attachments": list_attachments,
|
|
1456
|
+
"get_attachment": get_attachment,
|
|
1457
|
+
"read_attachment": read_attachment,
|
|
1458
|
+
"show_attachment": show_attachment,
|
|
1341
1459
|
}
|
|
1342
1460
|
|
|
1343
1461
|
|
|
@@ -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`,
|
|
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
|
-
|
|
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
|
|
@@ -3908,8 +4120,9 @@ class Progress(_Node):
|
|
|
3908
4120
|
|
|
3909
4121
|
|
|
3910
4122
|
class Stat(_Node):
|
|
3911
|
-
def set(self, stat_id, value_text, label
|
|
3912
|
-
# One counter of the strip, upserted by id.
|
|
4123
|
+
def set(self, stat_id, value_text, label, tone=None):
|
|
4124
|
+
# One counter of the strip, upserted by id. The View schema requires
|
|
4125
|
+
# the label in each update.
|
|
3913
4126
|
return self._op(
|
|
3914
4127
|
"append",
|
|
3915
4128
|
stats=[
|
|
@@ -3928,9 +4141,10 @@ class Stat(_Node):
|
|
|
3928
4141
|
|
|
3929
4142
|
|
|
3930
4143
|
class Steps(_Node):
|
|
3931
|
-
def set(self, step_id,
|
|
4144
|
+
def set(self, step_id, label, tone=None, detail=None, value=None):
|
|
3932
4145
|
# One step of the checklist, upserted by id: the same call marks it
|
|
3933
|
-
# running and later done.
|
|
4146
|
+
# running and later done. The View schema requires the label in each
|
|
4147
|
+
# update.
|
|
3934
4148
|
return self._op(
|
|
3935
4149
|
"append",
|
|
3936
4150
|
steps=[
|
|
@@ -4368,14 +4582,12 @@ class LiveView:
|
|
|
4368
4582
|
value=value, done=done, total=total
|
|
4369
4583
|
)
|
|
4370
4584
|
|
|
4371
|
-
def stat(self, stat_id, value_text, label
|
|
4372
|
-
return self._only("stat", "stat").set(
|
|
4373
|
-
stat_id, value_text, label=label, tone=tone
|
|
4374
|
-
)
|
|
4585
|
+
def stat(self, stat_id, value_text, label, tone=None):
|
|
4586
|
+
return self._only("stat", "stat").set(stat_id, value_text, label, tone=tone)
|
|
4375
4587
|
|
|
4376
|
-
def step(self, step_id,
|
|
4588
|
+
def step(self, step_id, label, tone=None, detail=None, value=None):
|
|
4377
4589
|
return self._only("steps", "step").set(
|
|
4378
|
-
step_id, tone=tone,
|
|
4590
|
+
step_id, label, tone=tone, detail=detail, value=value
|
|
4379
4591
|
)
|
|
4380
4592
|
|
|
4381
4593
|
def write(self, *lines: str | Sequence[str], tone: LogTone | None = None):
|
|
@@ -4597,7 +4809,7 @@ class _LiveRecorder:
|
|
|
4597
4809
|
item = _LiveRecorder._copy(item)
|
|
4598
4810
|
if item["id"] in positions:
|
|
4599
4811
|
at = positions[item["id"]]
|
|
4600
|
-
result[at] =
|
|
4812
|
+
result[at] = item
|
|
4601
4813
|
else:
|
|
4602
4814
|
positions[item["id"]] = len(result)
|
|
4603
4815
|
result.append(item)
|
|
@@ -4697,9 +4909,9 @@ class _LiveRecorder:
|
|
|
4697
4909
|
elif action == "append":
|
|
4698
4910
|
key = self._COLLECTION[node["type"]]
|
|
4699
4911
|
incoming = self._copy(op.get(key) or [])
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4912
|
+
from blockether.vis import _contracts
|
|
4913
|
+
|
|
4914
|
+
incoming = _contracts.live_items(key, incoming)
|
|
4703
4915
|
node[key] = self._upsert(node[key], incoming)
|
|
4704
4916
|
if "groups" in op:
|
|
4705
4917
|
# `live/apply-append`: a group already declared KEEPS the keys the
|
|
@@ -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")
|
|
@@ -358,3 +358,28 @@ def test_a_group_arranging_nothing_is_refused_where_it_was_written():
|
|
|
358
358
|
):
|
|
359
359
|
with pytest.raises(ValueError, match="at least one node"):
|
|
360
360
|
build()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@pytest.mark.parametrize("host_kind", ["outside", "recorder"])
|
|
364
|
+
def test_stat_and_step_updates_carry_the_label_the_schema_requires(
|
|
365
|
+
monkeypatch, host_kind
|
|
366
|
+
):
|
|
367
|
+
# The evals watch view sent a counter update without its label, and the host
|
|
368
|
+
# refused it. The recorder now refuses the same patch, as the View schema says.
|
|
369
|
+
host = (
|
|
370
|
+
_outside.host if host_kind == "outside" else vis.testing.LiveRecorder(vis._host)
|
|
371
|
+
)
|
|
372
|
+
monkeypatch.setattr(vis, "_host", host)
|
|
373
|
+
nodes = [
|
|
374
|
+
vis.stat("counts", stats=[{"id": "ok", "label": "Passed", "value_text": "0"}]),
|
|
375
|
+
vis.steps("plan", steps=[{"id": "build", "label": "Build"}]),
|
|
376
|
+
]
|
|
377
|
+
with vis.live("Counts", nodes, flush_ms=0) as view:
|
|
378
|
+
with pytest.raises((ValueError, _outside.Refused), match="label"):
|
|
379
|
+
view["counts"]._op("append", stats=[{"id": "ok", "value_text": "1"}])
|
|
380
|
+
view["counts"].set("ok", "2", "Passed")
|
|
381
|
+
view["plan"].set("build", "Build", tone="ok")
|
|
382
|
+
counts, plan = view.state()["nodes"]
|
|
383
|
+
stats, steps = counts["stats"], plan["steps"]
|
|
384
|
+
assert stats == [{"id": "ok", "label": "Passed", "value_text": "2"}]
|
|
385
|
+
assert steps[0]["label"] == "Build" and steps[0]["tone"] == "ok"
|
|
@@ -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():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|