databricks-explore 0.5.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.
@@ -0,0 +1,430 @@
1
+ """The canvas tools: read, add and change nodes and connectors, lay them out, render. Every write is saved and undoable."""
2
+ import base64
3
+ import copy
4
+ import json
5
+ import re
6
+ import uuid
7
+
8
+ from .. import canvases
9
+ from .. import layout
10
+
11
+ from .tooling import B, FORMAT, S, ToolError, integer, obj, strings, text, tool
12
+
13
+ EXCERPT_LINES = 14
14
+ SKETCH = ("shape", "text", "sticky", "frame", "placeholder")
15
+ NODE_TYPES = ("shape", "text", "sticky", "frame", "placeholder", "query", "note", "table")
16
+ SHAPES = ("rect", "rounded", "ellipse", "diamond", "cylinder")
17
+ SIZES = {"rect": (140, 80), "rounded": (140, 80), "ellipse": (140, 80), "diamond": (120, 100), "cylinder": (100, 120),
18
+ "text": (140, 40), "sticky": (160, 160), "frame": (400, 300), "placeholder": (280, 120),
19
+ "query": (760, 520), "note": (320, 220), "table": (320, 300)} # what the page uses for a new node of each kind
20
+ STYLE = {"shape": {"fill": "#ffffff", "stroke": "#1f2328", "font_size": 14}, "text": {"fill": "none", "stroke": "none", "font_size": 16},
21
+ "sticky": {"fill": "#fef9c3", "stroke": "#ca8a04", "font_size": 14}, "frame": {"fill": "#e5e7eb", "stroke": "#6b7280", "font_size": 14},
22
+ "placeholder": {"fill": "#ffffff", "stroke": "#2563eb", "font_size": 14}}
23
+ AUTO_TIDY = {"type": "boolean", "description": "default true: after the edit, push apart any overlapping nodes among those just touched"}
24
+ NAME = {"type": "string", "minLength": 1, "description": "canvas name (letters, digits, _ . -)"}
25
+ POINT = {"type": "object", "required": ["x", "y"], "additionalProperties": False, "properties": {"x": {"type": "number"}, "y": {"type": "number"}}}
26
+ PLACE = {"type": "object", "required": ["relation", "anchor"], "additionalProperties": False,
27
+ "description": "structural placement (preferred over at): next to or inside an existing node, with a gap",
28
+ "properties": {"relation": {"type": "string", "enum": list(layout.PLACE_RELATIONS)},
29
+ "anchor": {"type": "string", "minLength": 1, "description": "id of an existing node (or one earlier in this call)"},
30
+ "gap": {"type": "number", "minimum": 0}}}
31
+ COLUMN = {"type": "object", "required": ["name"], "additionalProperties": False,
32
+ "properties": {"name": S, "type": S, "key": {"type": "string", "enum": ["pk", "fk"]}}}
33
+ STYLE_PROPS = {"fill": {"type": "string", "description": "CSS color"}, "stroke": {"type": "string", "description": "CSS color"},
34
+ "font_size": {"type": "number", "exclusiveMinimum": 0}, "locked": B}
35
+ NODE = {"type": "object", "required": ["type"], "additionalProperties": False,
36
+ "description": "type shape (set shape), text, sticky, frame, placeholder (table that does not exist yet), query (sql), note (text) or table (table = catalog.schema.table). Place it with place (preferred) or at; with neither it goes below the existing content.",
37
+ "properties": {"type": {"type": "string", "enum": list(NODE_TYPES)}, "id": {"type": "string", "minLength": 1},
38
+ "label": S, "title": {"type": "string", "description": "query, note and table windows: unique, used as {{title}} in SQL"},
39
+ "shape": {"type": "string", "enum": list(SHAPES)}, "sql": S, "text": S, "table": S,
40
+ "name": {"type": "string", "description": "placeholder: the table name"}, "columns": {"type": "array", "items": COLUMN},
41
+ "w": {"type": "number", "exclusiveMinimum": 0, "description": "omit it and a sketch node is sized to fit its label"},
42
+ "h": {"type": "number", "exclusiveMinimum": 0},
43
+ "place": PLACE, "at": POINT, **STYLE_PROPS}}
44
+
45
+
46
+ # ---------------------------------------------------------------- reading
47
+
48
+ def _load(name: str) -> dict:
49
+ return canvases.load_canvas(name)
50
+
51
+
52
+ def _score_line(canvas: dict) -> str:
53
+ d = layout.doctor(canvas)
54
+ kinds = {}
55
+ for i in d["issues"]:
56
+ kinds[i["severity"]] = kinds.get(i["severity"], 0) + 1
57
+ detail = ", ".join(f"{n} {k}" for k, n in sorted(kinds.items())) or "no issues"
58
+ return f"doctor score {d['score']}/100 ({detail})"
59
+
60
+
61
+ def _report(canvas: dict, headline: str, revision: int) -> str:
62
+ """What an edit did: the headline, the doctor score and a short describe excerpt, so the agent sees the effect."""
63
+ return (f"{headline}. revision {revision}; {_score_line(canvas)}. undo_canvas reverts it.\n"
64
+ + layout.describe(canvas, max_lines=EXCERPT_LINES))
65
+
66
+
67
+ def _edit(name: str, mutate, auto_tidy: bool = True) -> str:
68
+ """Load, mutate (returns headline and the ids it touched), tidy overlaps among those, validate, save with a snapshot."""
69
+ with canvases.LOCK:
70
+ canvas = copy.deepcopy(_load(name))
71
+ canvas["version"] = 2 # the sketch node types and connectors need version 2; version 1 content stays valid
72
+ canvas.setdefault("edges", [])
73
+ headline, touched = mutate(canvas)
74
+ moved = 0
75
+ if auto_tidy and touched:
76
+ canvas, changes = layout.tidy(canvas, ids=touched, rules=["overlaps"])
77
+ moved = sum(1 for c in changes if c["kind"] == "node")
78
+ _, rev = canvases.write_canvas(canvas, by="agent", snapshot=True)
79
+ return _report(canvas, headline + (f"; moved {moved} to clear overlaps" if moved else ""), rev)
80
+
81
+
82
+ def _ids(canvas: dict) -> set:
83
+ return {w["id"] for w in canvas["windows"]} | {e["id"] for e in canvas.get("edges", [])}
84
+
85
+
86
+ def _next_id(prefix: str, used: set) -> str:
87
+ n = 1 + max([int(m.group(1)) for i in used if (m := re.fullmatch(prefix + r"(\d+)", i))] or [0])
88
+ return f"{prefix}{n}"
89
+
90
+
91
+ @tool("list_canvases", "List saved canvases with their node and connector counts and revision.", obj())
92
+ def list_canvases(ctx, args):
93
+ names = canvases.list_canvases()
94
+ lines = [f"{len(names)} canvas(es)"]
95
+ for n in names:
96
+ try:
97
+ c = _load(n)
98
+ info = canvases.revision_info(n)
99
+ lines.append(f"{n}: {len(c['windows'])} nodes, {len(c.get('edges', []))} connectors, revision {info['revision']}")
100
+ except (canvases.CanvasInvalid, canvases.CanvasNotFound) as e:
101
+ lines.append(f"{n}: unreadable ({e})")
102
+ return text("\n".join(lines))
103
+
104
+
105
+ @tool("get_canvas", "Read a canvas. describe (default) is a compact text summary: frames, a map, connectors, issues. json is the whole file.",
106
+ obj({"name": NAME, "format": {"type": "string", "enum": ["describe", "json"]}, "max_lines": integer(10, 300)}, ("name",)))
107
+ def get_canvas(ctx, args):
108
+ c = _load(args["name"])
109
+ if args.get("format") == "json":
110
+ return text(json.dumps(c, separators=(",", ":")))
111
+ return text(layout.describe(c, max_lines=args.get("max_lines", 60)))
112
+
113
+
114
+ # ---------------------------------------------------------------- writing
115
+
116
+ @tool("create_canvas", "Create an empty canvas (a version 2 sketch with no connection needed). Fails if the name is taken.",
117
+ obj({"name": NAME}, ("name",)), read_only=False)
118
+ def create_canvas(ctx, args):
119
+ name = args["name"]
120
+ with canvases.LOCK:
121
+ if name in canvases.list_canvases():
122
+ raise ToolError(f"canvas {name!r} already exists; use add_nodes to change it")
123
+ canvases.write_canvas({"version": 2, "name": name, "profile": None, "windows": [], "edges": []}, by="agent")
124
+ return text(f"created canvas {name} (empty). Add nodes with add_nodes or add_query_window.")
125
+
126
+
127
+ def _title(canvas: dict, base: str) -> str:
128
+ taken = {w.get("title") for w in canvas["windows"]}
129
+ if base not in taken:
130
+ return base
131
+ n = 2
132
+ while f"{base}_{n}" in taken:
133
+ n += 1
134
+ return f"{base}_{n}"
135
+
136
+
137
+ def build_node(canvas: dict, spec: dict) -> dict:
138
+ """A window dict for one add_nodes entry, with the page's defaults for its type, at a provisional position."""
139
+ kind = spec["type"]
140
+ used = _ids(canvas)
141
+ nid = spec.get("id") or _next_id("s" if kind in SKETCH else "w", used)
142
+ if nid in used:
143
+ raise ToolError(f"id {nid!r} is already used on this canvas")
144
+ shape = spec.get("shape", "rect")
145
+ w, h = SIZES[shape if kind == "shape" else kind]
146
+ at = spec.get("at") or {"x": 0, "y": 0}
147
+ node = {"id": nid, "type": kind, "position": {"x": at["x"], "y": at["y"]},
148
+ "size": {"w": spec.get("w", w), "h": spec.get("h", h)}}
149
+ if kind in STYLE:
150
+ node.update(STYLE[kind])
151
+ for key in ("fill", "stroke", "font_size", "locked"):
152
+ if key in spec:
153
+ node[key] = spec[key]
154
+ if kind == "shape":
155
+ node.update(shape=shape, label=spec.get("label", ""))
156
+ elif kind in ("text", "sticky", "frame"):
157
+ node["label"] = spec.get("label", "Group" if kind == "frame" else "")
158
+ elif kind == "placeholder":
159
+ cols = spec.get("columns") or [{"name": "id", "type": "bigint", "key": "pk"}]
160
+ node["placeholder"] = {"id": str(uuid.uuid4()), "name": spec.get("name") or spec.get("label") or "new_table", "columns": cols}
161
+ if "h" not in spec:
162
+ node["size"]["h"] = max(h, 40 + 24 * len(cols))
163
+ elif kind == "query":
164
+ if "sql" not in spec:
165
+ raise ToolError("a query node needs sql")
166
+ node.update(title=_title(canvas, spec.get("title") or "query"), sql=spec["sql"], collapsed=False, editor_split=30)
167
+ elif kind == "note":
168
+ node.update(title=_title(canvas, spec.get("title") or "note"), text=spec.get("text", ""), collapsed=False)
169
+ else: # table
170
+ if not spec.get("table"):
171
+ raise ToolError("a table node needs table (catalog.schema.table)")
172
+ base = re.sub(r"[{}]", "", spec["table"].split(".")[-1]).strip() or "table"
173
+ node.update(title=_title(canvas, spec.get("title") or base), table=spec["table"], collapsed=False)
174
+ if kind in SKETCH and "w" not in spec: # no width asked for: size the node to its label (wrapping past MAX_FIT_W)
175
+ node["size"]["w"], node["size"]["h"] = layout.fit_size(node)
176
+ return node
177
+
178
+
179
+ def _below_content(canvas: dict, gap: float = 40.0) -> tuple[float, float]:
180
+ live = [w for w in canvas["windows"]]
181
+ if not live:
182
+ return 0.0, 0.0
183
+ return (min(layout._x(w) for w in live), max(layout._y(w) + layout._h(w) for w in live) + gap)
184
+
185
+
186
+ def add_nodes_to(canvas: dict, specs: list) -> list[str]:
187
+ """Add nodes to a canvas dict (placement included) and return their ids. Raises ToolError on a bad spec."""
188
+ new_ids = []
189
+ batch = [] # consecutive nodes with the same placement are arranged together, so they stack instead of chaining
190
+ def flush():
191
+ if not batch:
192
+ return
193
+ place = batch[0][1]
194
+ out, _ = layout.arrange(canvas, [n for n, _ in batch], "place", relation=place["relation"], anchor=place["anchor"],
195
+ gap=place.get("gap", 40))
196
+ canvas["windows"][:] = out["windows"]
197
+ canvas["edges"][:] = out["edges"]
198
+ batch.clear()
199
+ for spec in specs:
200
+ if "place" in spec and "at" in spec:
201
+ raise ToolError("give place or at, not both")
202
+ node = build_node(canvas, spec)
203
+ if "at" not in spec and "place" not in spec:
204
+ flush()
205
+ node["position"]["x"], node["position"]["y"] = _below_content(canvas)
206
+ place = spec.get("place")
207
+ if batch and batch[0][1] != place:
208
+ flush() # the new node may anchor on one still waiting in the batch
209
+ if place:
210
+ anchor = next((w for w in canvas["windows"] if w["id"] == place["anchor"]), None)
211
+ if anchor is None:
212
+ raise ToolError(f"place.anchor {place['anchor']!r} is not on this canvas (nor added earlier in this call)")
213
+ node["position"]["x"], node["position"]["y"] = layout._x(anchor), layout._y(anchor)
214
+ canvas["windows"].append(node)
215
+ new_ids.append(node["id"])
216
+ if place:
217
+ batch.append((node["id"], place))
218
+ flush()
219
+ return new_ids
220
+
221
+
222
+ @tool("add_nodes", "Add shapes, text, sticky notes, frames, placeholder tables, query windows, notes or table cards to a canvas. Prefer structural placement (place: right_of/left_of/below/above/inside an anchor id, with a gap) over absolute at {x, y}. Returns the doctor score and a describe excerpt.",
223
+ obj({"name": NAME, "nodes": {"type": "array", "minItems": 1, "maxItems": 100, "items": NODE}, "auto_tidy": AUTO_TIDY}, ("name", "nodes")),
224
+ read_only=False)
225
+ def add_nodes(ctx, args):
226
+ def mutate(canvas):
227
+ ids = add_nodes_to(canvas, args["nodes"])
228
+ return f"added {', '.join(ids)}", ids
229
+ return text(_edit(args["name"], mutate, args.get("auto_tidy", True)))
230
+
231
+
232
+ @tool("add_query_window", "Add a SQL query window to a canvas (it is not run). Use {{title}} in another window's SQL to reference it. Prefer structural placement.",
233
+ obj({"name": NAME, "sql": S, "title": S, "place": PLACE, "at": POINT, "w": {"type": "number", "exclusiveMinimum": 0},
234
+ "h": {"type": "number", "exclusiveMinimum": 0}, "auto_tidy": AUTO_TIDY}, ("name", "sql")), read_only=False)
235
+ def add_query_window(ctx, args):
236
+ spec = {"type": "query", **{k: v for k, v in args.items() if k not in ("name", "auto_tidy")}}
237
+ def mutate(canvas):
238
+ ids = add_nodes_to(canvas, [spec])
239
+ return f"added query window {ids[0]}", ids
240
+ return text(_edit(args["name"], mutate, args.get("auto_tidy", True)))
241
+
242
+
243
+ UPDATE_FIELDS = {"label": S, "title": S, "sql": S, "text": S, "table": S, "shape": {"type": "string", "enum": list(SHAPES)},
244
+ "collapsed": B, "z": {"type": "number"}, "w": {"type": "number", "exclusiveMinimum": 0},
245
+ "h": {"type": "number", "exclusiveMinimum": 0}, "name": S, "columns": {"type": "array", "items": COLUMN},
246
+ "linked": {"type": ["string", "null"]}, "place": PLACE, "at": POINT, **STYLE_PROPS}
247
+
248
+
249
+ @tool("update_nodes", "Change fields of existing nodes: label, title, sql, text, colors, size, locked, collapsed, or move one with place or at. A new title rewrites {{old}} references in other windows. Locked nodes need locked: false in the same update to change.",
250
+ obj({"name": NAME, "updates": {"type": "array", "minItems": 1, "maxItems": 100, "items": {
251
+ "type": "object", "required": ["id"], "additionalProperties": False,
252
+ "properties": {"id": {"type": "string", "minLength": 1}, **UPDATE_FIELDS}}}, "auto_tidy": AUTO_TIDY}, ("name", "updates")),
253
+ read_only=False)
254
+ def update_nodes(ctx, args):
255
+ def mutate(canvas):
256
+ by_id = {w["id"]: w for w in canvas["windows"]}
257
+ touched = []
258
+ for u in args["updates"]:
259
+ n = by_id.get(u["id"])
260
+ if n is None:
261
+ raise ToolError(f"no node with id {u['id']!r}; ids: {', '.join(sorted(by_id)[:30])}")
262
+ if n.get("locked") and u.get("locked") is not False:
263
+ raise ToolError(f"node {u['id']} is locked; send locked: false to change it")
264
+ _apply(canvas, n, u)
265
+ touched.append(n["id"])
266
+ return f"updated {', '.join(touched)}", touched
267
+ return text(_edit(args["name"], mutate, args.get("auto_tidy", True)))
268
+
269
+
270
+ def _apply(canvas: dict, n: dict, u: dict) -> None:
271
+ for key in ("label", "sql", "text", "table", "shape", "collapsed", "z", "fill", "stroke", "font_size", "locked"):
272
+ if key in u:
273
+ n[key] = u[key]
274
+ if "title" in u and u["title"] != n.get("title"):
275
+ old = n.get("title")
276
+ n["title"] = u["title"]
277
+ if old: # keep {{old}} references working
278
+ pattern = re.compile(r"\{\{\s*" + re.escape(old) + r"\s*\}\}")
279
+ for w in canvas["windows"]:
280
+ if w.get("sql"):
281
+ w["sql"] = pattern.sub("{{" + u["title"] + "}}", w["sql"])
282
+ if "w" in u or "h" in u:
283
+ n["size"] = {"w": u.get("w", n["size"]["w"]), "h": u.get("h", n["size"]["h"])}
284
+ ph = n.get("placeholder")
285
+ if ph is not None:
286
+ for src, dst in (("name", "name"), ("columns", "columns"), ("linked", "linked")):
287
+ if src in u:
288
+ ph[dst] = u[src]
289
+ if "at" in u:
290
+ n["position"] = {"x": u["at"]["x"], "y": u["at"]["y"]}
291
+ if "place" in u:
292
+ if "at" in u:
293
+ raise ToolError("give place or at, not both")
294
+ p = u["place"]
295
+ out, _ = layout.arrange(canvas, [n["id"]], "place", relation=p["relation"], anchor=p["anchor"], gap=p.get("gap", 40))
296
+ canvas["windows"][:] = out["windows"]
297
+ canvas["edges"][:] = out["edges"]
298
+
299
+
300
+ @tool("remove_nodes", "Remove nodes (and the connectors attached to them) or connectors, by id.",
301
+ obj({"name": NAME, "ids": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}}, ("name", "ids")),
302
+ read_only=False, destructive=True)
303
+ def remove_nodes(ctx, args):
304
+ def mutate(canvas):
305
+ known = _ids(canvas)
306
+ missing = [i for i in args["ids"] if i not in known]
307
+ if missing:
308
+ raise ToolError(f"no node or connector with id: {', '.join(missing)}")
309
+ drop = set(args["ids"])
310
+ canvas["windows"] = [w for w in canvas["windows"] if w["id"] not in drop]
311
+ gone = {w["id"] for w in canvas["windows"]}
312
+ kept = [e for e in canvas["edges"] if e["id"] not in drop and e["source"] in gone and e["target"] in gone]
313
+ attached = sum(1 for e in canvas["edges"] if e["id"] not in drop) - len(kept)
314
+ canvas["edges"] = kept
315
+ canvas["links"] = [x for x in canvas.get("links", []) if x["from"] in gone and x["to"] in gone]
316
+ return f"removed {', '.join(args['ids'])} and {attached} attached connector(s)", []
317
+ return text(_edit(args["name"], mutate, False))
318
+
319
+
320
+ LINE = {"type": "string", "enum": ["straight", "step", "curve"]}
321
+ EDGE_PROPS = {"source": {"type": "string", "minLength": 1}, "target": {"type": "string", "minLength": 1}, "label": S, "line": LINE,
322
+ "arrow_end": B, "arrow_start": B, "dashed": B, "color": S,
323
+ "source_handle": {"type": "string", "enum": ["t", "r", "b", "l"]}, "target_handle": {"type": "string", "enum": ["t", "r", "b", "l"]}}
324
+
325
+
326
+ @tool("connect", "Draw a connector from one node to another (source, target), or several at once with edges. Sides are picked to face each other.",
327
+ obj({"name": NAME, **EDGE_PROPS, "edges": {"type": "array", "minItems": 1, "maxItems": 100, "items": {
328
+ "type": "object", "required": ["source", "target"], "additionalProperties": False, "properties": EDGE_PROPS}}}, ("name",)),
329
+ read_only=False)
330
+ def connect(ctx, args):
331
+ single = {k: v for k, v in args.items() if k in EDGE_PROPS}
332
+ if args.get("edges") and single:
333
+ raise ToolError("give source and target, or edges, not both")
334
+ specs = args.get("edges") or [single]
335
+ if not all(s.get("source") and s.get("target") for s in specs):
336
+ raise ToolError("connect needs source and target (or edges)")
337
+ def mutate(canvas):
338
+ nodes = {w["id"] for w in canvas["windows"]}
339
+ made = []
340
+ for s in specs:
341
+ for end in ("source", "target"):
342
+ if s[end] not in nodes:
343
+ raise ToolError(f"{end} {s[end]!r} is not a node on this canvas; ids: {', '.join(sorted(nodes)[:30])}")
344
+ edge = {"id": _next_id("c", _ids(canvas)), "source": s["source"], "target": s["target"],
345
+ "source_handle": s.get("source_handle", "r"), "target_handle": s.get("target_handle", "l"),
346
+ "line": s.get("line", "step"), "arrow_start": s.get("arrow_start", False), "arrow_end": s.get("arrow_end", True),
347
+ "dashed": s.get("dashed", False)}
348
+ for key in ("label", "color"):
349
+ if key in s:
350
+ edge[key] = s[key]
351
+ canvas["edges"].append(edge)
352
+ made.append(edge["id"])
353
+ ends = sorted({e for s in specs for e in (s["source"], s["target"])})
354
+ if not any("source_handle" in s or "target_handle" in s for s in specs):
355
+ out, _ = layout.tidy(canvas, ids=ends, rules=["handles"]) # re-pick sides that face away
356
+ canvas["edges"][:] = out["edges"]
357
+ pairs = ", ".join(s["source"] + "->" + s["target"] for s in specs)
358
+ return f"connected {pairs} as {', '.join(made)}", []
359
+ return text(_edit(args["name"], mutate, False))
360
+
361
+
362
+ ARRANGE_OPTS = {"gap": {"type": "number", "minimum": 0}, "align": {"type": "string", "enum": ["start", "center", "end"]},
363
+ "cols": {"type": "integer", "minimum": 1}, "direction": {"type": "string", "enum": ["LR", "TB"]},
364
+ "layer_gap": {"type": "number", "minimum": 0}, "relation": {"type": "string", "enum": list(layout.PLACE_RELATIONS)},
365
+ "anchor": S, "spacing": {"type": "number", "minimum": 0}}
366
+
367
+
368
+ @tool("arrange", "Lay out nodes from structure: grid, row, column, layered (connector graphs; direction LR or TB) or place (relation and anchor). ids defaults to every non-frame node. The subset's top-left corner stays put.",
369
+ obj({"name": NAME, "mode": {"type": "string", "enum": list(layout.ARRANGE_MODES)}, "ids": strings(), **ARRANGE_OPTS,
370
+ "grid": {"type": "number", "exclusiveMinimum": 0}}, ("name", "mode")), read_only=False)
371
+ def arrange(ctx, args):
372
+ opts = {k: v for k, v in args.items() if k in ARRANGE_OPTS}
373
+ def mutate(canvas):
374
+ out, changes = layout.arrange(canvas, args.get("ids"), args["mode"], grid=args.get("grid"), **opts)
375
+ canvas.clear()
376
+ canvas.update(out)
377
+ return f"arranged ({args['mode']}): {sum(1 for c in changes if c['kind'] == 'node')} node(s) moved, {sum(1 for c in changes if c['kind'] == 'edge')} connector(s) re-sided", []
378
+ return text(_edit(args["name"], mutate, False))
379
+
380
+
381
+ @tool("tidy", "Fix a layout with the least movement: grow nodes to fit their labels, frames, overlaps, alignment, even spacing, grid, connector sides, connector label placement. ids limits what may move; rules picks a subset.",
382
+ obj({"name": NAME, "ids": strings(), "rules": {"type": "array", "items": {"type": "string", "enum": list(layout.RULES)}},
383
+ "grid": {"type": "number", "exclusiveMinimum": 0}}, ("name",)), read_only=False)
384
+ def tidy(ctx, args):
385
+ def mutate(canvas):
386
+ before = layout.doctor(canvas, args.get("grid"))["score"]
387
+ out, changes = layout.tidy(canvas, args.get("ids"), args.get("rules"), args.get("grid"))
388
+ canvas.clear()
389
+ canvas.update(out)
390
+ return f"tidied: {len(changes)} change(s), score {before} -> {layout.doctor(out, args.get('grid'))['score']}", []
391
+ return text(_edit(args["name"], mutate, False))
392
+
393
+
394
+ @tool("doctor", "Score a canvas layout 0-100 and list what is wrong (overlaps, connectors through nodes, misalignment, ...), each with a fix when one exists. Read only.",
395
+ obj({"name": NAME, "grid": {"type": "number", "exclusiveMinimum": 0}, "limit": integer(1, 100), "format": FORMAT}, ("name",)))
396
+ def doctor(ctx, args):
397
+ d = layout.doctor(_load(args["name"]), args.get("grid"))
398
+ if args.get("format") == "json":
399
+ return text(json.dumps(d, separators=(",", ":")))
400
+ limit = args.get("limit", 25)
401
+ lines = [f"score {d['score']}/100; {len(d['issues'])} issue(s)"]
402
+ for i in d["issues"][:limit]:
403
+ fix = i["fix"]
404
+ lines.append(f"{i['severity']} {i['kind']} {','.join(i['nodes'][:4])}: {i['message']}"
405
+ + (f" [fix: tidy rules={','.join(fix['rules'])} ids={','.join(fix['ids'][:4])}]" if fix else ""))
406
+ if len(d["issues"]) > limit:
407
+ lines.append(f"... {len(d['issues']) - limit} more")
408
+ return text("\n".join(lines))
409
+
410
+
411
+ @tool("render_canvas", "A schematic PNG of the canvas (boxes by type with ids and labels, connectors with arrows) for judging layout. highlight outlines node ids, or \"issues\".",
412
+ obj({"name": NAME, "width": integer(200, 2000), "highlight": {"anyOf": [strings(), S]}}, ("name",)))
413
+ def render_canvas(ctx, args):
414
+ canvas = _load(args["name"])
415
+ hi = args.get("highlight")
416
+ if hi == "issues":
417
+ hi = sorted({n for i in layout.doctor(canvas)["issues"] if i["severity"] != "info" for n in i["nodes"]}, key=str)
418
+ elif hi is not None and not isinstance(hi, list):
419
+ raise ToolError('highlight must be a list of ids or "issues"')
420
+ png = layout.render_png(canvas, args.get("width", 1000), hi)
421
+ return [{"type": "image", "data": base64.b64encode(png).decode("ascii"), "mimeType": "image/png"},
422
+ {"type": "text", "text": f"{args['name']}: {len(canvas['windows'])} nodes, {len(canvas.get('edges', []))} connectors; {_score_line(canvas)}"}]
423
+
424
+
425
+ @tool("undo_canvas", "Undo the last agent change to a canvas (up to 20 are kept). Calling it again goes one change further back.",
426
+ obj({"name": NAME}, ("name",)), read_only=False, destructive=True)
427
+ def undo_canvas(ctx, args):
428
+ out = canvases.undo_canvas(args["name"])
429
+ return text(f"undone. revision {out['revision']}; {out['snapshots']} earlier agent change(s) can still be undone.\n"
430
+ + layout.describe(_load(args["name"]), max_lines=EXCERPT_LINES))