nontainer 0.1.0__py3-none-any.whl

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.
nontainer/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """nontainer: a fake little computer for your agent.
2
+
3
+ Public surface:
4
+
5
+ workspace(...) -- factory; the one-liner entry point
6
+ Workspace -- files + shell + python + cache, versioned
7
+ PythonConfig -- what sandboxed code may touch
8
+ TerminalResult, PythonResult, WriteOutcome, EditOutcome
9
+ WorkspaceProvider -- the substrate protocol (bring your own)
10
+ Capabilities, CheckpointInfo
11
+ errors: WorkspaceError, NotSupportedError, SessionIdError,
12
+ CheckpointNotFoundError
13
+
14
+ Adapters (optional extras):
15
+
16
+ nontainer.adapters.agno -- WorkspaceTools (agno Toolkit)
17
+ python -m nontainer.mcp -- MCP server (stdio)
18
+ """
19
+
20
+ from .cache import Cache, CacheError
21
+ from .editing import EditOutcome
22
+ from .errors import (
23
+ CheckpointNotFoundError,
24
+ NotSupportedError,
25
+ SessionIdError,
26
+ WorkspaceError,
27
+ )
28
+ from .protocol import (
29
+ SESSION_ID_RE,
30
+ Capabilities,
31
+ CheckpointInfo,
32
+ WorkspaceProvider,
33
+ validate_session_id,
34
+ )
35
+ from .workspace import (
36
+ ModuleGrant,
37
+ Mount,
38
+ PythonConfig,
39
+ PythonResult,
40
+ TerminalResult,
41
+ Workspace,
42
+ WriteOutcome,
43
+ workspace,
44
+ )
45
+
46
+ __all__ = [
47
+ "workspace",
48
+ "Workspace",
49
+ "PythonConfig",
50
+ "Mount",
51
+ "ModuleGrant",
52
+ "TerminalResult",
53
+ "PythonResult",
54
+ "WriteOutcome",
55
+ "EditOutcome",
56
+ "WorkspaceProvider",
57
+ "Capabilities",
58
+ "CheckpointInfo",
59
+ "SESSION_ID_RE",
60
+ "validate_session_id",
61
+ "Cache",
62
+ "CacheError",
63
+ "WorkspaceError",
64
+ "NotSupportedError",
65
+ "SessionIdError",
66
+ "CheckpointNotFoundError",
67
+ ]
@@ -0,0 +1,17 @@
1
+ """Harness adapters: thin skins over one Workspace core.
2
+
3
+ - ``nontainer.adapters.agno`` — ``WorkspaceTools`` (agno Toolkit);
4
+ requires the ``[agno]`` extra.
5
+ - ``nontainer.adapters.mcp`` — FastMCP server; requires the ``[mcp]``
6
+ extra. Run via ``python -m nontainer.adapters.mcp``.
7
+
8
+ Shared behavior lives in ``nontainer.adapters.render``: observation
9
+ rendering (never inline ``namespace``; surface truncation), dynamic
10
+ tool descriptions, and the exposure-mode heuristic (``"auto"`` →
11
+ terminal-only when the python environment is plain, split tools when
12
+ it's augmented with cache/host objects).
13
+ """
14
+
15
+ from .render import resolve_tools_mode
16
+
17
+ __all__ = ["resolve_tools_mode"]
@@ -0,0 +1,438 @@
1
+ """a2ui projection: a reply (prose + artifacts) → declarative agent-UI.
2
+
3
+ a2ui (Agent-to-User Interface) is adopted as an EGRESS format at the edge,
4
+ never as the internal representation — see ``docs`` and the plan. This
5
+ adapter is split into two layers so the volatile part stays small:
6
+
7
+ - Layer 1 (this file's bulk): SEMANTICS — stable, renderer-agnostic, fully
8
+ tested. ``splice`` is the canonical definition of how a reply interleaves
9
+ with its artifacts; ``component_for`` maps one artifact to a neutral,
10
+ a2ui-shaped component fragment. Both are pure functions: plain dicts and
11
+ tuples in and out, no I/O, no new dependencies (callers pass the artifact
12
+ bytes and a ``file_url`` resolver).
13
+
14
+ - Layer 2 (``turn_to_a2ui`` + ``BASIC_CATALOG``): the version-specific
15
+ ENVELOPE, now PINNED to A2UI v0.9 (the stable production family; v1.0 is
16
+ still a release candidate). It composes layer 1's nested fragments into
17
+ the v0.9 wire sequence — one ``createSurface``, one ``updateComponents``
18
+ carrying a FLAT adjacency list, then one ``updateDataModel`` per data-
19
+ model entry. Every version-specific field name stays isolated here, so
20
+ when the consumer moves to v1.0 only this half of the file churns.
21
+
22
+ Two v0.9 quirks the envelope absorbs so layer 1 stays neutral: (1) the
23
+ component list is flat — containers reference children by id, not by
24
+ nesting — so ``turn_to_a2ui`` flattens each fragment into deterministic
25
+ ids (root ``"root"``, segment roots ``"seg0"``, ``"seg1"``, ..., nested
26
+ children ``"seg3-1"``, ``"seg3-1-value-1"``). (2) the basic-catalog
27
+ ``Text`` has no ``role`` prop, so layer 1's Text ``role`` is folded into
28
+ the component id (``"seg3-1-value-1"``) and dropped from the emitted
29
+ component. Layer 1's ``{"$ref": key}`` markers become v0.9 JSON-Pointer
30
+ bindings ``{"path": "/artifacts/{name}/{key}"}`` during flattening, the
31
+ value delivered by ``updateDataModel``. Basic-catalog ``Text`` renders
32
+ Markdown, so ``("md", text)`` segments ship verbatim as Text components.
33
+
34
+ The component vocabulary here is deliberately neutral-but-a2ui-shaped:
35
+ catalog components (``Card``/``Row``/``Column``/``Text``/``Image``) plus a
36
+ single extension type ``Chart`` for plotly specs. A fragment is
37
+ ``{"component": <tree>, "data_model": {ref_key: value}}``; components carry
38
+ ``{"$ref": key}`` where a value lives in the data model (today: only the
39
+ plotly spec, which is bulky and de-facto-standard — the consumer brings the
40
+ renderer).
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import re
47
+ from typing import Callable, Union
48
+
49
+ from .render import artifact_kind
50
+
51
+ # ("md", text) | ("artifact", name, path). Matches the two shapes studio's
52
+ # AgentMessage.svelte emits from its own splice.
53
+ Segment = Union[tuple[str, str], tuple[str, str, str]]
54
+
55
+ # The SAME regex studio's AgentMessage.svelte uses to split prose on image
56
+ # refs. The two implementations are kept deliberately in sync: this Python
57
+ # function is the canonical spec (and the a2ui converter's input); the Svelte
58
+ # copy renders it client-side. Change one, change the other.
59
+ _IMAGE_REF = re.compile(r"!\[([^\]]*)\]\((/[^)\s]+)\)")
60
+
61
+
62
+ def splice(prose: str, artifacts: list[tuple[str, str]]) -> list[Segment]:
63
+ """Canonical interleaving of a reply with its artifacts.
64
+
65
+ Prose is split at markdown image refs ``![alt](/path)``. A ref splices
66
+ an ``("artifact", name, path)`` segment at its position; runs of text
67
+ between refs become ``("md", text)`` segments (never empty ones). The
68
+ artifact LIST is authoritative for names — a ref whose path is in the
69
+ list uses the note's name, not the prose alt-text (they may differ).
70
+ A ref whose path is NOT in the list still splices (using its alt-text
71
+ as name), so workspace images an agent embeds by hand render too,
72
+ mirroring studio.
73
+
74
+ The Jupyter rule: artifacts from the list whose path was never
75
+ referenced in prose append as trailing ``("artifact", ...)`` segments
76
+ in list order. Each artifact appends at most once even if the list
77
+ names it twice; a path referenced N times in prose splices at each
78
+ reference and is not also appended.
79
+ """
80
+ # First path wins the name if the note lists a path twice.
81
+ name_by_path: dict[str, str] = {}
82
+ for name, path in artifacts:
83
+ name_by_path.setdefault(path, name)
84
+
85
+ out: list[Segment] = []
86
+ referenced: set[str] = set()
87
+ last = 0
88
+ for m in _IMAGE_REF.finditer(prose):
89
+ if m.start() > last:
90
+ out.append(("md", prose[last : m.start()]))
91
+ alt, path = m.group(1), m.group(2)
92
+ out.append(("artifact", name_by_path.get(path, alt), path))
93
+ referenced.add(path)
94
+ last = m.end()
95
+ if last < len(prose):
96
+ out.append(("md", prose[last:]))
97
+
98
+ appended: set[str] = set()
99
+ for name, path in artifacts:
100
+ if path in referenced or path in appended:
101
+ continue
102
+ out.append(("artifact", name, path))
103
+ appended.add(path)
104
+ return out
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # component_for: one artifact -> a renderer-agnostic fragment
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def component_for(
113
+ name: str,
114
+ path: str,
115
+ data: bytes | None,
116
+ file_url: Callable[[str], str],
117
+ ) -> dict:
118
+ """Project one artifact into an a2ui fragment.
119
+
120
+ Dispatches on ``artifact_kind(path)``. ``data`` is the artifact's raw
121
+ bytes, or ``None`` when the caller could not read the file — every kind
122
+ that needs the bytes then degrades to the Text+link fallback (``image``
123
+ still works, being URL-only). Malformed JSON where JSON is expected
124
+ degrades the same way; this function never raises.
125
+
126
+ Returns ``{"component": <tree>, "data_model": {...}}``.
127
+ """
128
+ kind = artifact_kind(path)
129
+
130
+ if kind == "image":
131
+ return _fragment({"componentType": "Image", "url": file_url(path)})
132
+
133
+ # The builders parse agent-writable files (near-miss adoption promotes
134
+ # DIRECT /ui writes into the note), so a malformed payload here is a
135
+ # reachable input, not a programming error. The except makes the
136
+ # never-raises contract structurally true: any builder surprise lands
137
+ # in the fallback instead of breaking an egress stream mid-turn.
138
+ if data is not None:
139
+ try:
140
+ if kind == "cards":
141
+ payload = _try_json(data)
142
+ if isinstance(payload, dict):
143
+ return _cards(payload)
144
+ elif kind == "table":
145
+ payload = _try_json(data)
146
+ if isinstance(payload, dict):
147
+ return _table(payload)
148
+ elif kind == "plotly":
149
+ spec = _try_json(data)
150
+ if isinstance(spec, dict):
151
+ return _chart(spec)
152
+ elif kind == "json":
153
+ # .json undersells itself: agents reach for
154
+ # write_json('/ui/x.json'). Content-sniff a plotly spec
155
+ # (studio's looksLikePlotly, full-parse only since we hold
156
+ # the whole file); otherwise fall to the link.
157
+ spec = _try_json(data)
158
+ if _looks_like_plotly(spec):
159
+ return _chart(spec)
160
+ except Exception:
161
+ pass
162
+
163
+ # html/text/json-non-plotly/binary, and every bytes-needing kind that
164
+ # could not parse: a Text label + a link. Never ship raw HTML across a2ui.
165
+ return _fragment(
166
+ {"componentType": "Text", "text": f"artifact: {name}", "link": file_url(path)}
167
+ )
168
+
169
+
170
+ def _fragment(component: dict, data_model: dict | None = None) -> dict:
171
+ return {"component": component, "data_model": data_model or {}}
172
+
173
+
174
+ def _text(value: object, role: str) -> dict:
175
+ return {"componentType": "Text", "text": str(value), "role": role}
176
+
177
+
178
+ def _cards(payload: dict) -> dict:
179
+ """The ``.cards.json`` payload ``{"items": [...]}`` → a Row of Cards,
180
+ dispatched per item ``type`` (mirrors studio's CardRow):
181
+
182
+ - ``stat`` → a Card of Text children roled label / value / sublabel
183
+ (sublabel only when present);
184
+ - ``callout`` → a Card carrying a passthrough ``tone`` prop (the
185
+ flattener copies unknown props through), with Text children roled
186
+ title / body (each emitted only when non-empty).
187
+
188
+ Values go inline in the Text — card text is small, so no data-model
189
+ indirection needed. Items with no recognizable type render nothing."""
190
+ items = payload.get("items")
191
+ cards = []
192
+ for item in items if isinstance(items, list) else []:
193
+ if not isinstance(item, dict):
194
+ continue
195
+ kind = item.get("type")
196
+ if kind == "stat":
197
+ children = [
198
+ _text(item.get("label", ""), "label"),
199
+ _text(item.get("value", ""), "value"),
200
+ ]
201
+ if "sublabel" in item:
202
+ children.append(_text(item["sublabel"], "sublabel"))
203
+ cards.append({"componentType": "Card", "children": children})
204
+ elif kind == "callout":
205
+ children = []
206
+ if item.get("title"):
207
+ children.append(_text(item["title"], "title"))
208
+ if item.get("body"):
209
+ children.append(_text(item["body"], "body"))
210
+ cards.append(
211
+ {
212
+ "componentType": "Card",
213
+ "tone": item.get("tone", "info"),
214
+ "children": children,
215
+ }
216
+ )
217
+ # unrecognized type: skip, never raise
218
+ return _fragment({"componentType": "Row", "children": cards})
219
+
220
+
221
+ _TABLE_ROW_CAP = 50
222
+
223
+
224
+ def _table(payload: dict) -> dict:
225
+ """The ``.table.json`` split-orient payload ``{"columns": [...], "data":
226
+ [[...]], "total": N}`` → a Column: a header Row of Text, then up to 50
227
+ data Rows. When ``total`` exceeds the rendered count, a trailing Text
228
+ notes ``showing N of M rows`` (the payload is already head-capped
229
+ upstream, so a big table degrades gracefully)."""
230
+ # both guarded the same way: these fields come from agent-writable
231
+ # files, and a truthy non-list ({"columns": 5}) must degrade, not raise
232
+ columns = payload.get("columns")
233
+ columns = columns if isinstance(columns, list) else []
234
+ rows = payload.get("data")
235
+ rows = rows if isinstance(rows, list) else []
236
+ total = payload.get("total")
237
+
238
+ children = [
239
+ {
240
+ "componentType": "Row",
241
+ "children": [_text(c, "header") for c in columns],
242
+ }
243
+ ]
244
+ rendered = rows[:_TABLE_ROW_CAP]
245
+ for row in rendered:
246
+ cells = row if isinstance(row, list) else [row]
247
+ children.append(
248
+ {
249
+ "componentType": "Row",
250
+ "children": [_text(cell, "cell") for cell in cells],
251
+ }
252
+ )
253
+ if isinstance(total, int) and total > len(rendered):
254
+ children.append(_text(f"showing {len(rendered)} of {total} rows", "caption"))
255
+ return _fragment({"componentType": "Column", "children": children})
256
+
257
+
258
+ def _chart(spec: dict) -> dict:
259
+ """Plotly spec → the one extension component. The spec is bulky and the
260
+ de-facto standard, so it rides in the data model and the component
261
+ references it — the consumer brings a plotly renderer."""
262
+ return _fragment(
263
+ {"componentType": "Chart", "spec": {"$ref": "spec"}},
264
+ {"spec": spec},
265
+ )
266
+
267
+
268
+ def _try_json(data: bytes) -> object | None:
269
+ try:
270
+ return json.loads(data)
271
+ except (ValueError, TypeError):
272
+ return None
273
+
274
+
275
+ def _looks_like_plotly(obj: object) -> bool:
276
+ """Port of studio's ``looksLikePlotly`` (frontend/src/lib/sniff.js),
277
+ full-parse branch only: a top-level dict with a ``data`` list and a
278
+ ``layout`` dict."""
279
+ return (
280
+ isinstance(obj, dict)
281
+ and isinstance(obj.get("data"), list)
282
+ and isinstance(obj.get("layout"), dict)
283
+ )
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Layer 2: the A2UI v0.9 envelope
288
+ # ---------------------------------------------------------------------------
289
+
290
+ # The v0.9 basic-catalog URL. createSurface must name a catalog; consumers
291
+ # with a Chart-capable custom catalog pass their own catalog_id instead.
292
+ BASIC_CATALOG = "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
293
+
294
+ _VERSION = "v0.9"
295
+
296
+
297
+ def turn_to_a2ui(
298
+ prose: str,
299
+ artifacts: list[tuple[str, str]],
300
+ read_bytes: Callable[[str], bytes | None],
301
+ file_url: Callable[[str], str],
302
+ *,
303
+ surface_id: str,
304
+ catalog_id: str = BASIC_CATALOG,
305
+ ) -> list[dict]:
306
+ """Compose one reply (prose + artifacts) into an A2UI v0.9 message list.
307
+
308
+ ``artifacts`` is the ``parse_artifacts_note`` output — ``(name, path)``
309
+ pairs. ``read_bytes(path) -> bytes | None`` fetches an artifact's payload
310
+ so the envelope owns no I/O policy (callers do reads and their own
311
+ caching; ``None`` means unreadable, which degrades inside
312
+ ``component_for``). ``file_url(path)`` resolves an artifact's public URL.
313
+
314
+ Pipeline: ``splice`` interleaves prose and artifacts into ordered
315
+ segments; each ``("artifact", ...)`` segment is projected by
316
+ ``component_for``; every nested fragment is FLATTENED into a v0.9
317
+ adjacency list with deterministic ids. ``("md", text)`` segments become
318
+ Markdown ``Text`` components verbatim (basic-catalog Text renders
319
+ Markdown, so no conversion).
320
+
321
+ Emits, in order, each carrying ``"version": "v0.9"``:
322
+
323
+ 1. one ``createSurface`` (``surfaceId``, ``catalogId``);
324
+ 2. one ``updateComponents`` with the full flat component list, root
325
+ ``Column`` (id ``"root"``) FIRST so a buffering consumer can start
326
+ rendering before the tail arrives;
327
+ 3. one ``updateDataModel`` per data-model entry, keyed by JSON Pointer
328
+ ``/artifacts/{name}/{key}`` — the same path the flattened components
329
+ bind to (layer 1's ``{"$ref": key}`` markers are rewritten to
330
+ ``{"path": ...}`` bindings during flattening).
331
+
332
+ Ids are stable across identical inputs and unique: ``"root"``, segment
333
+ roots ``"seg{i}"`` (i = splice index), nested children ``"{parent}-{n}"``
334
+ or, for a Text carrying a layer-1 ``role``, ``"{parent}-{role}-{k}"``
335
+ (the ``role`` prop is then dropped — v0.9 basic Text has no such prop).
336
+
337
+ Empty prose with no artifacts still yields a VALID surface: a
338
+ ``createSurface`` plus an ``updateComponents`` whose root ``Column`` has
339
+ no children (no ``updateDataModel``). Never raises on any splice /
340
+ ``component_for`` output; a ``read_bytes`` that itself raises is treated
341
+ as an unreadable artifact.
342
+ """
343
+ components: list[dict] = [{"id": "root", "component": "Column", "children": []}]
344
+ root_children: list[str] = components[0]["children"]
345
+ data_entries: list[tuple[str, object]] = []
346
+
347
+ for i, seg in enumerate(splice(prose, artifacts)):
348
+ seg_id = f"seg{i}"
349
+ root_children.append(seg_id)
350
+ if seg[0] == "md":
351
+ components.append({"id": seg_id, "component": "Text", "text": seg[1]})
352
+ continue
353
+ _, name, path = seg
354
+ try:
355
+ data = read_bytes(path)
356
+ except Exception:
357
+ # read_bytes is the caller's I/O; the envelope stays total.
358
+ data = None
359
+ frag = component_for(name, path, data, file_url)
360
+ _flatten(frag.get("component") or {}, seg_id, name, components)
361
+ for key, value in (frag.get("data_model") or {}).items():
362
+ data_entries.append((f"/artifacts/{name}/{key}", value))
363
+
364
+ messages: list[dict] = [
365
+ {
366
+ "version": _VERSION,
367
+ "createSurface": {"surfaceId": surface_id, "catalogId": catalog_id},
368
+ },
369
+ {
370
+ "version": _VERSION,
371
+ "updateComponents": {"surfaceId": surface_id, "components": components},
372
+ },
373
+ ]
374
+ for path, value in data_entries:
375
+ messages.append(
376
+ {
377
+ "version": _VERSION,
378
+ "updateDataModel": {
379
+ "surfaceId": surface_id,
380
+ "path": path,
381
+ "value": value,
382
+ },
383
+ }
384
+ )
385
+ return messages
386
+
387
+
388
+ def _flatten(node: dict, node_id: str, artifact: str, out: list[dict]) -> None:
389
+ """Append ``node`` and its subtree to ``out`` as flat v0.9 components.
390
+
391
+ Pre-order (parent before children) so the list stays buffering-friendly.
392
+ ``componentType`` becomes ``component``; ``children`` become a list of
393
+ generated child ids; ``role`` is dropped (folded into the child id by
394
+ ``_child_id``); a ``{"$ref": key}`` prop value is rewritten to a
395
+ ``/artifacts/{artifact}/{key}`` JSON-Pointer binding.
396
+ """
397
+ flat: dict = {"id": node_id, "component": node.get("componentType")}
398
+ for key, val in node.items():
399
+ if key in ("componentType", "children", "role"):
400
+ continue
401
+ flat[key] = _bind(val, artifact)
402
+
403
+ children = node.get("children")
404
+ child_pairs: list[tuple[dict, str]] = []
405
+ if isinstance(children, list):
406
+ seen: dict[str, int] = {}
407
+ ids: list[str] = []
408
+ for idx, child in enumerate(children, start=1):
409
+ cid = _child_id(node_id, idx, child, seen)
410
+ ids.append(cid)
411
+ child_pairs.append((child, cid))
412
+ flat["children"] = ids
413
+
414
+ out.append(flat)
415
+ for child, cid in child_pairs:
416
+ _flatten(child, cid, artifact, out)
417
+
418
+
419
+ def _child_id(parent_id: str, index: int, child: object, seen: dict[str, int]) -> str:
420
+ """A stable child id. A Text with a ``role`` folds the role in (v0.9 Text
421
+ drops the prop), disambiguated by per-role occurrence so a header Row's
422
+ several ``role: header`` cells stay unique; everything else uses its
423
+ 1-based sibling position. Roles are words, positions are numbers, so the
424
+ two schemes never collide even inside a mixed container (a table Column
425
+ holds numbered Rows plus a ``caption`` Text)."""
426
+ role = child.get("role") if isinstance(child, dict) else None
427
+ if role:
428
+ seen[role] = seen.get(role, 0) + 1
429
+ return f"{parent_id}-{role}-{seen[role]}"
430
+ return f"{parent_id}-{index}"
431
+
432
+
433
+ def _bind(val: object, artifact: str) -> object:
434
+ """Rewrite a layer-1 ``{"$ref": key}`` marker into a v0.9 JSON-Pointer
435
+ binding; pass everything else through untouched."""
436
+ if isinstance(val, dict) and set(val) == {"$ref"}:
437
+ return {"path": f"/artifacts/{artifact}/{val['$ref']}"}
438
+ return val