euron-code 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.
@@ -0,0 +1,167 @@
1
+ """Tool plumbing shared by every tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Dict, List, Optional
9
+
10
+ from euron_code.permissions import Request
11
+
12
+
13
+ class ToolError(Exception):
14
+ """Raised by a tool to return an error message to the model."""
15
+
16
+
17
+ @dataclass
18
+ class ToolResult:
19
+ content: str
20
+ is_error: bool = False
21
+ preview: Optional[str] = None # short text for the user; defaults to content
22
+
23
+
24
+ @dataclass
25
+ class FileRecord:
26
+ mtime: float
27
+ size: int
28
+ ranges: List[tuple] = field(default_factory=list)
29
+
30
+
31
+ class ReadTracker:
32
+ """Remembers which files the model has read (for edit safety and read dedupe)."""
33
+
34
+ def __init__(self) -> None:
35
+ self.files: Dict[str, FileRecord] = {}
36
+
37
+ @staticmethod
38
+ def _key(path: Path) -> str:
39
+ k = str(path.resolve())
40
+ return k.lower() if os.name == "nt" else k
41
+
42
+ def mark_read(self, path: Path, rng: Optional[tuple] = None) -> None:
43
+ st = path.stat()
44
+ rec = self.files.get(self._key(path))
45
+ if rec is None or rec.mtime != st.st_mtime or rec.size != st.st_size:
46
+ rec = FileRecord(st.st_mtime, st.st_size)
47
+ self.files[self._key(path)] = rec
48
+ if rng is not None and rng not in rec.ranges:
49
+ rec.ranges.append(rng)
50
+
51
+ def mark_written(self, path: Path) -> None:
52
+ st = path.stat()
53
+ self.files[self._key(path)] = FileRecord(st.st_mtime, st.st_size, [])
54
+
55
+ def was_read(self, path: Path) -> bool:
56
+ return self._key(path) in self.files
57
+
58
+ def unchanged_since_read(self, path: Path) -> bool:
59
+ rec = self.files.get(self._key(path))
60
+ if rec is None:
61
+ return False
62
+ try:
63
+ st = path.stat()
64
+ except OSError:
65
+ return False
66
+ return rec.mtime == st.st_mtime and rec.size == st.st_size
67
+
68
+ def already_has(self, path: Path, rng: tuple) -> bool:
69
+ rec = self.files.get(self._key(path))
70
+ return bool(rec) and self.unchanged_since_read(path) and rng in rec.ranges
71
+
72
+ def clear(self) -> None:
73
+ """Called after compaction: file contents are no longer in context."""
74
+ for rec in self.files.values():
75
+ rec.ranges.clear()
76
+
77
+
78
+ @dataclass
79
+ class ToolContext:
80
+ root: Path
81
+ settings: Dict[str, Any]
82
+ ui: Any
83
+ tracker: ReadTracker = field(default_factory=ReadTracker)
84
+ shell: Any = None # ShellSession
85
+ todos: List[Dict[str, Any]] = field(default_factory=list)
86
+ agent: Any = None # the running Agent (for the task tool)
87
+
88
+ @property
89
+ def max_output_chars(self) -> int:
90
+ return int(self.settings.get("toolOutputMaxChars") or 12000)
91
+
92
+ def resolve(self, p: str) -> Path:
93
+ if not isinstance(p, str) or not p.strip():
94
+ raise ToolError("A non-empty path is required.")
95
+ raw = os.path.expanduser(p.strip())
96
+ path = Path(raw)
97
+ if not path.is_absolute():
98
+ path = self.root / path
99
+ return Path(os.path.normpath(str(path)))
100
+
101
+ def rel(self, path: Path) -> str:
102
+ try:
103
+ return path.resolve().relative_to(self.root.resolve()).as_posix()
104
+ except ValueError:
105
+ return str(path)
106
+
107
+
108
+ def truncate(text: str, max_chars: int, note: str = "") -> str:
109
+ """Keep the head and tail of long output, with a note saying what was cut."""
110
+ if len(text) <= max_chars:
111
+ return text
112
+ head = int(max_chars * 0.6)
113
+ tail = max_chars - head
114
+ cut = text[head: len(text) - tail]
115
+ cut_lines = cut.count("\n")
116
+ msg = (f"\n\n[... output truncated: {len(cut)} characters / ~{cut_lines} lines omitted"
117
+ f"{'; ' + note if note else ''} ...]\n\n")
118
+ return text[:head] + msg + text[len(text) - tail:]
119
+
120
+
121
+ class Tool:
122
+ name: str = ""
123
+ description: str = ""
124
+ parameters: Dict[str, Any] = {}
125
+ read_only: bool = False
126
+
127
+ def schema(self) -> Dict[str, Any]:
128
+ return {
129
+ "type": "function",
130
+ "function": {"name": self.name, "description": self.description, "parameters": self.parameters},
131
+ }
132
+
133
+ def permission(self, args: Dict[str, Any], ctx: ToolContext) -> Request:
134
+ return Request(category=None, tool=self.name)
135
+
136
+ def describe(self, args: Dict[str, Any], ctx: ToolContext) -> str:
137
+ """One-line summary shown to the user."""
138
+ return ""
139
+
140
+ def preview(self, args: Dict[str, Any], ctx: ToolContext) -> Optional[str]:
141
+ """Extra detail shown in the permission prompt (e.g. a diff). May raise ToolError."""
142
+ return None
143
+
144
+ def run(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
145
+ raise NotImplementedError
146
+
147
+
148
+ def require(args: Dict[str, Any], key: str, typ: type = str) -> Any:
149
+ if key not in args or args[key] is None:
150
+ raise ToolError(f"Missing required parameter `{key}`.")
151
+ v = args[key]
152
+ if typ is str and not isinstance(v, str):
153
+ raise ToolError(f"Parameter `{key}` must be a string.")
154
+ return v
155
+
156
+
157
+ def opt_int(args: Dict[str, Any], key: str, default: Optional[int]) -> Optional[int]:
158
+ v = args.get(key)
159
+ if v is None or v == "":
160
+ return default
161
+ try:
162
+ return int(v)
163
+ except (TypeError, ValueError):
164
+ raise ToolError(f"Parameter `{key}` must be an integer.")
165
+
166
+
167
+ DiffFn = Callable[[str, str, str], str]
euron_code/tools/db.py ADDED
@@ -0,0 +1,368 @@
1
+ """db_query: explore SQLite files and Postgres / MySQL databases the user configured.
2
+
3
+ Read-only by default, enforced twice:
4
+ 1. a SQL guard rejects anything that is not a plain read (SELECT / WITH / EXPLAIN / SHOW / ...);
5
+ 2. the connection itself is opened read-only (SQLite `mode=ro`, Postgres read-only session,
6
+ MySQL `TRANSACTION READ ONLY`).
7
+ Writes need `db.allowWrites: true` in settings AND a per-statement permission prompt.
8
+
9
+ Connections live in settings, never in the conversation:
10
+ "db": {"connections": {"app": "postgresql://user:${PGPASSWORD}@localhost/app",
11
+ "local": "sqlite:///data/app.db"}}
12
+ `${VAR}` is expanded from the environment so passwords need not be stored in the file.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import sqlite3
20
+ from pathlib import Path
21
+ from typing import Any, List, Optional, Sequence, Tuple
22
+ from urllib.parse import unquote, urlparse
23
+
24
+ from euron_code.permissions import Request
25
+ from euron_code.tools.base import Tool, ToolContext, ToolError, ToolResult, opt_int, truncate
26
+
27
+ READ_FIRST = {"select", "with", "explain", "show", "describe", "desc", "pragma", "values", "table"}
28
+ DENY_TOKENS = {
29
+ "insert", "update", "delete", "drop", "create", "alter", "truncate", "grant", "revoke", "merge",
30
+ "upsert", "attach", "detach", "vacuum", "reindex", "copy", "call", "exec", "execute", "lock",
31
+ "into", "rename", "load_extension", "set_config", "pg_terminate_backend", "pg_cancel_backend",
32
+ "pg_reload_conf", "lo_import", "lo_export", "pg_read_file", "pg_read_binary_file", "pg_ls_dir",
33
+ "dblink", "dblink_exec", "nextval", "setval", "refresh", "analyze", "optimize", "handler",
34
+ }
35
+
36
+
37
+ def _strip_sql(sql: str) -> str:
38
+ """Remove comments, string literals and quoted identifiers so keyword checks see only code."""
39
+ out = []
40
+ i, n = 0, len(sql)
41
+ while i < n:
42
+ c = sql[i]
43
+ if sql.startswith("--", i) or c == "#":
44
+ j = sql.find("\n", i)
45
+ i = n if j == -1 else j
46
+ out.append(" ")
47
+ elif sql.startswith("/*", i):
48
+ j = sql.find("*/", i + 2)
49
+ i = n if j == -1 else j + 2
50
+ out.append(" ")
51
+ elif c in ("'", '"', "`"):
52
+ j = i + 1
53
+ while j < n:
54
+ if sql[j] == c:
55
+ if j + 1 < n and sql[j + 1] == c:
56
+ j += 2
57
+ continue
58
+ break
59
+ if sql[j] == "\\" and c == "'":
60
+ j += 2
61
+ continue
62
+ j += 1
63
+ i = j + 1
64
+ out.append(" 'x' " if c == "'" else " ident ")
65
+ elif c == "$":
66
+ m = re.match(r"\$([A-Za-z_]\w*)?\$", sql[i:])
67
+ if m:
68
+ tag = m.group(0)
69
+ j = sql.find(tag, i + len(tag))
70
+ i = n if j == -1 else j + len(tag)
71
+ out.append(" 'x' ")
72
+ else:
73
+ out.append(c)
74
+ i += 1
75
+ else:
76
+ out.append(c)
77
+ i += 1
78
+ return "".join(out)
79
+
80
+
81
+ def check_read_only(sql: str) -> Tuple[bool, str]:
82
+ """Return (ok, reason). ok=True only for statements that cannot write."""
83
+ code = _strip_sql(sql)
84
+ stmts = [s.strip() for s in code.split(";") if s.strip()]
85
+ if not stmts:
86
+ return False, "empty statement"
87
+ for s in stmts:
88
+ toks = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", s.lower())
89
+ if not toks:
90
+ return False, "no SQL keyword found"
91
+ first = toks[0]
92
+ if first not in READ_FIRST:
93
+ return False, f"`{first.upper()}` statements are not read-only"
94
+ bad = sorted(set(toks) & DENY_TOKENS)
95
+ if first == "explain":
96
+ # EXPLAIN ANALYZE runs the statement; allow only when the rest is a plain read
97
+ rest = [t for t in toks[1:] if t not in ("analyze", "verbose", "query", "plan", "format",
98
+ "json", "text", "costs", "buffers", "true", "false")]
99
+ if rest and rest[0] not in ("select", "with", "values", "table"):
100
+ return False, "EXPLAIN of a non-SELECT statement"
101
+ bad = [b for b in bad if b != "analyze"]
102
+ if bad:
103
+ return False, f"contains {', '.join(b.upper() for b in bad)}"
104
+ if first == "pragma" and "=" in s:
105
+ return False, "PRAGMA assignments change the database"
106
+ return True, ""
107
+
108
+
109
+ def expand_env(s: str) -> str:
110
+ return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", lambda m: os.environ.get(m.group(1), ""), s)
111
+
112
+
113
+ def mask_dsn(dsn: str) -> str:
114
+ return re.sub(r"(://[^:/@]+:)[^@]*@", r"\1****@", dsn)
115
+
116
+
117
+ def _fmt(v: Any) -> str:
118
+ if v is None:
119
+ return "NULL"
120
+ if isinstance(v, (bytes, bytearray, memoryview)):
121
+ return f"<{len(bytes(v))} bytes>"
122
+ s = str(v).replace("\n", "\\n")
123
+ return s if len(s) <= 200 else s[:200] + "..."
124
+
125
+
126
+ def format_table(cols: Sequence[str], rows: Sequence[Sequence[Any]], total: Optional[int], limit: int) -> str:
127
+ if not cols:
128
+ return "(statement returned no columns)"
129
+ cells = [[_fmt(v) for v in r] for r in rows]
130
+ widths = [min(60, max([len(str(c))] + [len(r[i]) for r in cells])) for i, c in enumerate(cols)]
131
+ def line(vals):
132
+ return " | ".join(str(v)[:60].ljust(widths[i]) for i, v in enumerate(vals)).rstrip()
133
+ out = [line(cols), "-+-".join("-" * w for w in widths)]
134
+ out += [line(r) for r in cells]
135
+ n = len(rows)
136
+ more = total is not None and total > n
137
+ out.append(f"({n} row{'s' if n != 1 else ''}{'; more rows exist, showing the first ' + str(limit) if more else ''})")
138
+ return "\n".join(out)
139
+
140
+
141
+ class Connection:
142
+ def __init__(self, kind: str, target: str, label: str):
143
+ self.kind = kind # sqlite | postgres | mysql
144
+ self.target = target
145
+ self.label = label
146
+
147
+
148
+ def resolve_connection(name: Optional[str], ctx: ToolContext) -> Connection:
149
+ conns = (ctx.settings.get("db") or {}).get("connections") or {}
150
+ if not name:
151
+ if len(conns) == 1:
152
+ name = next(iter(conns))
153
+ else:
154
+ raise ToolError("Give `database`: a configured connection name "
155
+ f"({', '.join(conns) or 'none configured'}) or a path to a .db/.sqlite file.")
156
+ raw = conns.get(name)
157
+ if raw is None:
158
+ p = ctx.resolve(name)
159
+ if p.suffix.lower() in (".db", ".sqlite", ".sqlite3", ".db3") or p.is_file():
160
+ if not p.is_file():
161
+ raise ToolError(f"SQLite file not found: {ctx.rel(p)}")
162
+ return Connection("sqlite", str(p), ctx.rel(p))
163
+ raise ToolError(f"Unknown database {name!r}. Configured: {', '.join(conns) or 'none'}. "
164
+ "The user can add one with `euron config set db.connections.<name> <dsn>`.")
165
+ dsn = expand_env(str(raw))
166
+ low = dsn.lower()
167
+ if low.startswith("sqlite:"):
168
+ # sqlite:///rel/path.db, sqlite:////abs/path.db, sqlite:///C:/abs/path.db, sqlite:path.db
169
+ if low.startswith("sqlite:///"):
170
+ path = dsn[len("sqlite:///"):]
171
+ elif low.startswith("sqlite://"):
172
+ path = dsn[len("sqlite://"):]
173
+ else:
174
+ path = dsn[len("sqlite:"):]
175
+ p = ctx.resolve(path)
176
+ return Connection("sqlite", str(p), name)
177
+ if low.startswith(("postgres://", "postgresql://")):
178
+ return Connection("postgres", dsn, name)
179
+ if low.startswith(("mysql://", "mariadb://")):
180
+ return Connection("mysql", dsn, name)
181
+ p = ctx.resolve(dsn)
182
+ return Connection("sqlite", str(p), name)
183
+
184
+
185
+ def _run_sqlite(conn: Connection, sql: str, params: Sequence[Any], write: bool, limit: int):
186
+ p = Path(conn.target)
187
+ if not p.is_file():
188
+ raise ToolError(f"SQLite file not found: {conn.label}")
189
+ if write:
190
+ db = sqlite3.connect(str(p), timeout=10)
191
+ else:
192
+ db = sqlite3.connect(p.resolve().as_uri() + "?mode=ro", uri=True, timeout=10)
193
+ try:
194
+ cur = db.cursor()
195
+ if write:
196
+ cur.executescript(sql) if not params and sql.count(";") > 1 else cur.execute(sql, params)
197
+ db.commit()
198
+ return [], [], None, cur.rowcount
199
+ cur.execute(sql, params)
200
+ cols = [d[0] for d in cur.description] if cur.description else []
201
+ rows = cur.fetchmany(limit + 1)
202
+ return cols, rows[:limit], (limit + 1 if len(rows) > limit else len(rows)), None
203
+ except sqlite3.Error as e:
204
+ raise ToolError(f"SQLite error: {e}")
205
+ finally:
206
+ db.close()
207
+
208
+
209
+ def _run_postgres(conn: Connection, sql: str, params: Sequence[Any], write: bool, limit: int):
210
+ try:
211
+ import psycopg # type: ignore
212
+ v3 = True
213
+ except ImportError:
214
+ try:
215
+ import psycopg2 as psycopg # type: ignore
216
+ v3 = False
217
+ except ImportError:
218
+ raise ToolError("Postgres support needs a driver: pip install \"euron-code[postgres]\"")
219
+ try:
220
+ db = psycopg.connect(conn.target, connect_timeout=10)
221
+ except Exception as e:
222
+ raise ToolError(f"Cannot connect to {conn.label}: {mask_dsn(str(e))}")
223
+ try:
224
+ if v3:
225
+ db.read_only = not write
226
+ else:
227
+ db.set_session(readonly=not write)
228
+ cur = db.cursor()
229
+ cur.execute("SET statement_timeout = 30000")
230
+ cur.execute(sql, params or None)
231
+ if write:
232
+ db.commit()
233
+ return [], [], None, cur.rowcount
234
+ cols = [d[0] for d in cur.description] if cur.description else []
235
+ rows = cur.fetchmany(limit + 1) if cur.description else []
236
+ db.rollback()
237
+ return cols, rows[:limit], (limit + 1 if len(rows) > limit else len(rows)), None
238
+ except Exception as e:
239
+ raise ToolError(f"Postgres error: {e}")
240
+ finally:
241
+ db.close()
242
+
243
+
244
+ def _run_mysql(conn: Connection, sql: str, params: Sequence[Any], write: bool, limit: int):
245
+ try:
246
+ import pymysql # type: ignore
247
+ except ImportError:
248
+ raise ToolError("MySQL support needs a driver: pip install \"euron-code[mysql]\"")
249
+ u = urlparse(conn.target)
250
+ try:
251
+ db = pymysql.connect(host=u.hostname or "localhost", port=u.port or 3306,
252
+ user=unquote(u.username or ""), password=unquote(u.password or ""),
253
+ database=(u.path or "/").lstrip("/") or None, connect_timeout=10,
254
+ read_timeout=30)
255
+ except Exception as e:
256
+ raise ToolError(f"Cannot connect to {conn.label}: {mask_dsn(str(e))}")
257
+ try:
258
+ cur = db.cursor()
259
+ if not write:
260
+ cur.execute("SET SESSION TRANSACTION READ ONLY")
261
+ cur.execute("START TRANSACTION READ ONLY")
262
+ cur.execute(sql, params or None)
263
+ if write:
264
+ db.commit()
265
+ return [], [], None, cur.rowcount
266
+ cols = [d[0] for d in cur.description] if cur.description else []
267
+ rows = cur.fetchmany(limit + 1) if cur.description else []
268
+ db.rollback()
269
+ return cols, list(rows)[:limit], (limit + 1 if len(rows) > limit else len(rows)), None
270
+ except Exception as e:
271
+ raise ToolError(f"MySQL error: {e}")
272
+ finally:
273
+ db.close()
274
+
275
+
276
+ RUNNERS = {"sqlite": _run_sqlite, "postgres": _run_postgres, "mysql": _run_mysql}
277
+
278
+ _IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$")
279
+
280
+
281
+ def helper_sql(kind: str, action: str, table: Optional[str]) -> Tuple[str, List[Any]]:
282
+ if action == "list_tables":
283
+ if kind == "sqlite":
284
+ return ("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') "
285
+ "AND name NOT LIKE 'sqlite_%' ORDER BY name"), []
286
+ if kind == "postgres":
287
+ return ("SELECT table_schema, table_name, table_type FROM information_schema.tables "
288
+ "WHERE table_schema NOT IN ('pg_catalog','information_schema') "
289
+ "ORDER BY table_schema, table_name"), []
290
+ return ("SELECT table_name, table_type FROM information_schema.tables "
291
+ "WHERE table_schema = DATABASE() ORDER BY table_name"), []
292
+ if not table or not _IDENT.match(table):
293
+ raise ToolError("`table` must be a plain table name (optionally schema.table).")
294
+ schema, _, name = table.rpartition(".")
295
+ if kind == "sqlite":
296
+ return f"SELECT cid, name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{name}')", []
297
+ ph = "%s"
298
+ if kind == "postgres":
299
+ sql = ("SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns "
300
+ f"WHERE table_name = {ph}" + (f" AND table_schema = {ph}" if schema else "") + " ORDER BY ordinal_position")
301
+ return sql, [name] + ([schema] if schema else [])
302
+ sql = ("SELECT column_name, column_type, is_nullable, column_key, column_default FROM information_schema.columns "
303
+ f"WHERE table_name = {ph} AND table_schema = " + (ph if schema else "DATABASE()") + " ORDER BY ordinal_position")
304
+ return sql, [name] + ([schema] if schema else [])
305
+
306
+
307
+ class DbQuery(Tool):
308
+ name = "db_query"
309
+ description = (
310
+ "Explore a database: a SQLite file in the project, or a Postgres/MySQL connection the user "
311
+ "configured by name. action `list_tables`, `describe` (needs `table`), or `query` (needs `sql`). "
312
+ "Read-only: only SELECT/WITH/EXPLAIN/SHOW/PRAGMA unless the user enabled writes. Results are "
313
+ "capped (default 200 rows); add LIMIT to large queries."
314
+ )
315
+ parameters = {
316
+ "type": "object",
317
+ "properties": {
318
+ "action": {"type": "string", "enum": ["query", "list_tables", "describe"]},
319
+ "database": {"type": "string", "description": "Connection name from settings, or a path to a SQLite file."},
320
+ "sql": {"type": "string"},
321
+ "table": {"type": "string"},
322
+ },
323
+ "required": ["action"],
324
+ }
325
+
326
+ def _plan(self, args, ctx):
327
+ action = args.get("action") or ("query" if args.get("sql") else "list_tables")
328
+ conn = resolve_connection(args.get("database"), ctx)
329
+ if action == "query":
330
+ sql = args.get("sql")
331
+ if not isinstance(sql, str) or not sql.strip():
332
+ raise ToolError("action `query` needs `sql`.")
333
+ ok, reason = check_read_only(sql)
334
+ write = not ok
335
+ if write and not (ctx.settings.get("db") or {}).get("allowWrites"):
336
+ raise ToolError(
337
+ f"Rejected: this database tool is read-only ({reason}). The user can allow writes with "
338
+ "`euron config set db.allowWrites true`; each write still asks for permission."
339
+ )
340
+ return action, conn, sql, [], write
341
+ if action not in ("list_tables", "describe"):
342
+ raise ToolError("action must be query, list_tables or describe.")
343
+ sql, params = helper_sql(conn.kind, action, args.get("table"))
344
+ return action, conn, sql, params, False
345
+
346
+ def permission(self, args, ctx):
347
+ _, conn, sql, _, write = self._plan(args, ctx)
348
+ paths = [Path(conn.target)] if conn.kind == "sqlite" else []
349
+ return Request("DbWrite" if write else "Db", self.name, paths=paths, command=sql)
350
+
351
+ def describe(self, args, ctx):
352
+ a = args.get("action") or "query"
353
+ what = args.get("sql") or args.get("table") or ""
354
+ return f"{a} {args.get('database') or ''} {what}".strip()[:160]
355
+
356
+ def preview(self, args, ctx):
357
+ _, conn, sql, _, write = self._plan(args, ctx)
358
+ return f"{'WRITE to' if write else 'Read'} {conn.kind} database {conn.label}:\n{sql}"
359
+
360
+ def run(self, args, ctx):
361
+ action, conn, sql, params, write = self._plan(args, ctx)
362
+ limit = max(1, min(opt_int(args, "limit", None) or int((ctx.settings.get("db") or {}).get("maxRows") or 200), 1000))
363
+ cols, rows, total, affected = RUNNERS[conn.kind](conn, sql, params, write, limit)
364
+ if write:
365
+ return ToolResult(f"Statement executed on {conn.label}. Rows affected: {affected}.")
366
+ body = format_table(cols, rows, total, limit)
367
+ return ToolResult(truncate(body, ctx.max_output_chars, "add LIMIT or select fewer columns"),
368
+ preview=f"{len(rows)} rows")