@yejiming/dsh-data-agent 0.0.5 → 0.0.9

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.
package/lib/tool.js CHANGED
@@ -1,7 +1,595 @@
1
- import { d as clientsSchema, f as enforceReadRowLimit, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, u as classifyStatement, y as assertSingleStatement } from "./defaults-Bac6QvNt.js";
2
- import { t as runClientQuery } from "./query-CmhTFklw.js";
1
+ import { a as classifyStatement, c as assertSingleStatement, i as runClientQuery, n as redactQueryResult, o as clientsSchema, r as redactSecretText, s as enforceReadRowLimit } from "./connections-DeauhaZi.js";
2
+ import { i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, r as DEFAULT_MAX_QUERY_CHARS } from "./defaults-DP4RyRh1.js";
3
3
  import z from "schemastery";
4
4
  import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ const VIEW_KINDS = [
6
+ "metric",
7
+ "line",
8
+ "bar",
9
+ "pie",
10
+ "scatter",
11
+ "table"
12
+ ];
13
+ const AXIS_TYPES = ["category", "time"];
14
+ const WIDTHS = ["full", "half"];
15
+ const METRIC_FORMATS = ["number", "percent"];
16
+ function fail(message) {
17
+ throw new Error(message);
18
+ }
19
+ function isRecord(value) {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+ /** Reject keys outside the allowed set (additionalProperties=false semantics). */
23
+ function assertOnlyKeys(record, allowed, label) {
24
+ for (const key of Object.keys(record)) if (!allowed.includes(key)) fail(label + ": 不支持的字段 \"" + key + "\"");
25
+ }
26
+ function requireNonEmptyString(value, label) {
27
+ if (typeof value !== "string" || value.trim().length === 0) fail(label + ": 必须是非空字符串");
28
+ return value;
29
+ }
30
+ function optionalString(record, key, label) {
31
+ const value = record[key];
32
+ if (value === void 0) return void 0;
33
+ if (typeof value !== "string") fail(label + "." + key + ": 必须是字符串");
34
+ return value;
35
+ }
36
+ function optionalEnum(record, key, allowed, label) {
37
+ const value = record[key];
38
+ if (value === void 0) return void 0;
39
+ if (typeof value !== "string" || !allowed.includes(value)) fail(label + "." + key + ": 必须是 " + allowed.join("/") + " 之一");
40
+ return value;
41
+ }
42
+ /** Read a required non-empty string field with a concrete error path. */
43
+ function requiredStringField(record, key, label) {
44
+ return requireNonEmptyString(record[key], label + "." + key);
45
+ }
46
+ /** Read an optional array of unique non-empty strings (the table whitelist). */
47
+ function optionalStringArray(record, key, label) {
48
+ const value = record[key];
49
+ if (value === void 0) return void 0;
50
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) fail(label + "." + key + ": 必须是非空字符串数组");
51
+ const items = value.map((item) => item);
52
+ if (new Set(items).size !== items.length) fail(label + "." + key + ": 列名不能重复");
53
+ return items;
54
+ }
55
+ function parseXAxis(value, label) {
56
+ if (!isRecord(value)) fail(label + ".x: 必须是对象 { field, type, label? }");
57
+ assertOnlyKeys(value, [
58
+ "field",
59
+ "type",
60
+ "label"
61
+ ], label + ".x");
62
+ const field = requiredStringField(value, "field", label + ".x");
63
+ const type = optionalEnum(value, "type", AXIS_TYPES, label + ".x");
64
+ if (type === void 0) fail(label + ".x.type: 必须是 category/time 之一");
65
+ const axisLabel = optionalString(value, "label", label + ".x");
66
+ const axis = {
67
+ field,
68
+ type
69
+ };
70
+ if (axisLabel !== void 0) axis.label = axisLabel;
71
+ return axis;
72
+ }
73
+ function parseView(value, label, datasetIds) {
74
+ if (!isRecord(value)) fail(label + ": 必须是对象");
75
+ const kind = value["kind"];
76
+ if (typeof kind !== "string" || !VIEW_KINDS.includes(kind)) fail(label + ": kind 必须是 metric/line/bar/pie/scatter/table 之一");
77
+ const viewKind = kind;
78
+ const id = requiredStringField(value, "id", label);
79
+ const datasetId = requiredStringField(value, "datasetId", label);
80
+ if (!datasetIds.has(datasetId)) fail(label + ": 引用了不存在的 dataset id \"" + datasetId + "\"");
81
+ if (viewKind === "metric") {
82
+ assertOnlyKeys(value, [
83
+ "id",
84
+ "kind",
85
+ "datasetId",
86
+ "field",
87
+ "label",
88
+ "format"
89
+ ], label);
90
+ const metric = {
91
+ id,
92
+ kind: "metric",
93
+ datasetId,
94
+ field: requiredStringField(value, "field", label),
95
+ label: requiredStringField(value, "label", label)
96
+ };
97
+ const format = optionalEnum(value, "format", METRIC_FORMATS, label);
98
+ if (format !== void 0) metric.format = format;
99
+ return metric;
100
+ }
101
+ const width = optionalEnum(value, "width", WIDTHS, label);
102
+ const viewLabel = optionalString(value, "label", label);
103
+ switch (viewKind) {
104
+ case "line":
105
+ case "bar": {
106
+ assertOnlyKeys(value, [
107
+ "id",
108
+ "kind",
109
+ "datasetId",
110
+ "label",
111
+ "width",
112
+ "x",
113
+ "y",
114
+ "seriesField"
115
+ ], label);
116
+ const x = parseXAxis(value["x"], label);
117
+ const y = value["y"];
118
+ if (!Array.isArray(y) || y.length < 1 || y.length > 4 || y.some((item) => typeof item !== "string" || item.trim().length === 0)) fail(label + ".y: 必须是 1-4 个非空字段名");
119
+ const seriesField = optionalString(value, "seriesField", label);
120
+ if (seriesField !== void 0 && y.length >= 2) fail(label + ": seriesField 与多个 y 字段互斥,只能二选一");
121
+ const view = {
122
+ id,
123
+ kind: viewKind,
124
+ datasetId,
125
+ x,
126
+ y: y.map((item) => item)
127
+ };
128
+ if (viewLabel !== void 0) view.label = viewLabel;
129
+ if (width !== void 0) view.width = width;
130
+ if (seriesField !== void 0) view.seriesField = seriesField;
131
+ return view;
132
+ }
133
+ case "pie": {
134
+ assertOnlyKeys(value, [
135
+ "id",
136
+ "kind",
137
+ "datasetId",
138
+ "label",
139
+ "width",
140
+ "categoryField",
141
+ "valueField"
142
+ ], label);
143
+ const view = {
144
+ id,
145
+ kind: "pie",
146
+ datasetId,
147
+ categoryField: requiredStringField(value, "categoryField", label),
148
+ valueField: requiredStringField(value, "valueField", label)
149
+ };
150
+ if (viewLabel !== void 0) view.label = viewLabel;
151
+ if (width !== void 0) view.width = width;
152
+ return view;
153
+ }
154
+ case "scatter": {
155
+ assertOnlyKeys(value, [
156
+ "id",
157
+ "kind",
158
+ "datasetId",
159
+ "label",
160
+ "width",
161
+ "xField",
162
+ "yField"
163
+ ], label);
164
+ const view = {
165
+ id,
166
+ kind: "scatter",
167
+ datasetId,
168
+ xField: requiredStringField(value, "xField", label),
169
+ yField: requiredStringField(value, "yField", label)
170
+ };
171
+ if (viewLabel !== void 0) view.label = viewLabel;
172
+ if (width !== void 0) view.width = width;
173
+ return view;
174
+ }
175
+ case "table": {
176
+ assertOnlyKeys(value, [
177
+ "id",
178
+ "kind",
179
+ "datasetId",
180
+ "label",
181
+ "width",
182
+ "columns"
183
+ ], label);
184
+ const view = {
185
+ id,
186
+ kind: "table",
187
+ datasetId
188
+ };
189
+ const columns = optionalStringArray(value, "columns", label);
190
+ if (viewLabel !== void 0) view.label = viewLabel;
191
+ if (width !== void 0) view.width = width;
192
+ if (columns !== void 0) view.columns = columns;
193
+ return view;
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * Strictly parse a model-supplied analysis request. Every structural
199
+ * violation (unknown fields, duplicate ids, dangling references, count or
200
+ * union constraints) throws with a message naming the offending view/dataset.
201
+ */
202
+ function parseAnalysisRequest(input, prefix = "render-analysis") {
203
+ if (!isRecord(input)) fail(prefix + ": 请求必须是对象");
204
+ assertOnlyKeys(input, [
205
+ "title",
206
+ "summary",
207
+ "datasets",
208
+ "views"
209
+ ], prefix);
210
+ const title = requireNonEmptyString(input["title"], prefix + ".title");
211
+ const summary = optionalString(input, "summary", prefix);
212
+ const datasets = input["datasets"];
213
+ if (!Array.isArray(datasets) || datasets.length < 1 || datasets.length > 6) fail(prefix + ": datasets 必须是 1-6 个");
214
+ const parsedDatasets = [];
215
+ const datasetIds = /* @__PURE__ */ new Set();
216
+ for (let index = 0; index < datasets.length; index += 1) {
217
+ const label = prefix + ".datasets[" + index + "]";
218
+ const item = datasets[index];
219
+ if (!isRecord(item)) fail(label + ": 必须是对象");
220
+ assertOnlyKeys(item, ["id", "sql"], label);
221
+ const id = requiredStringField(item, "id", label);
222
+ if (datasetIds.has(id)) fail(label + ": dataset id \"" + id + "\" 重复");
223
+ datasetIds.add(id);
224
+ parsedDatasets.push({
225
+ id,
226
+ sql: requiredStringField(item, "sql", label)
227
+ });
228
+ }
229
+ const views = input["views"];
230
+ if (!Array.isArray(views) || views.length < 1 || views.length > 8) fail(prefix + ": views 必须是 1-8 个");
231
+ const parsedViews = [];
232
+ const viewIds = /* @__PURE__ */ new Set();
233
+ for (let index = 0; index < views.length; index += 1) {
234
+ const label = prefix + ".views[" + index + "]";
235
+ const view = parseView(views[index], label, datasetIds);
236
+ if (viewIds.has(view.id)) fail(label + ": view id \"" + view.id + "\" 重复");
237
+ viewIds.add(view.id);
238
+ parsedViews.push(view);
239
+ }
240
+ const request = {
241
+ title,
242
+ datasets: parsedDatasets,
243
+ views: parsedViews
244
+ };
245
+ if (summary !== void 0) request.summary = summary;
246
+ return request;
247
+ }
248
+ /** Whether one string parses to a finite number. */
249
+ function isFiniteNumberText(value) {
250
+ return value.trim() !== "" && Number.isFinite(Number(value));
251
+ }
252
+ /** Whether one string parses as a time value. */
253
+ function isParseableTimeText(value) {
254
+ return value.trim() !== "" && !Number.isNaN(Date.parse(value));
255
+ }
256
+ /**
257
+ * Validate view→dataset semantics AFTER all queries succeeded and BEFORE any
258
+ * meta is built: field existence, finite numerics, pie non-negativity, time
259
+ * parseability, and table whitelist existence. The client is never asked to
260
+ * aggregate, sort, or treat null as zero — validation happens here.
261
+ */
262
+ function validateViewSemantics(views, datasets, prefix = "render-analysis") {
263
+ for (const view of views) {
264
+ const dataset = datasets.get(view.datasetId);
265
+ if (dataset === void 0) fail(prefix + ": view \"" + view.id + "\" 引用了未知 dataset \"" + view.datasetId + "\"");
266
+ const columns = new Set(dataset.columns);
267
+ const requireColumn = (field) => {
268
+ if (!columns.has(field)) fail(prefix + ": view \"" + view.id + "\" 引用了 dataset \"" + view.datasetId + "\" 中不存在的字段 \"" + field + "\"");
269
+ };
270
+ const requireNumeric = (field) => {
271
+ requireColumn(field);
272
+ for (const row of dataset.rows) {
273
+ const value = row[field] ?? null;
274
+ if (value !== null && !isFiniteNumberText(value)) fail(prefix + ": view \"" + view.id + "\" 的字段 \"" + field + "\" 含有非数值 \"" + value + "\"(不能转换为有限数)");
275
+ }
276
+ };
277
+ switch (view.kind) {
278
+ case "metric":
279
+ requireNumeric(view.field);
280
+ break;
281
+ case "line":
282
+ case "bar":
283
+ if (view.x.type === "time") {
284
+ requireColumn(view.x.field);
285
+ for (const row of dataset.rows) {
286
+ const value = row[view.x.field] ?? null;
287
+ if (value !== null && !isParseableTimeText(value)) fail(prefix + ": view \"" + view.id + "\" 的 x 字段 \"" + view.x.field + "\" 含有不可解析的时间值 \"" + value + "\"");
288
+ }
289
+ } else requireColumn(view.x.field);
290
+ if (view.seriesField !== void 0) requireColumn(view.seriesField);
291
+ for (const field of view.y) requireNumeric(field);
292
+ break;
293
+ case "pie":
294
+ requireColumn(view.categoryField);
295
+ requireNumeric(view.valueField);
296
+ for (const row of dataset.rows) {
297
+ const value = row[view.valueField] ?? null;
298
+ if (value !== null && Number(value) < 0) fail(prefix + ": view \"" + view.id + "\" 的 valueField \"" + view.valueField + "\" 含负数 \"" + value + "\"(饼图值必须非负)");
299
+ }
300
+ break;
301
+ case "scatter":
302
+ requireNumeric(view.xField);
303
+ requireNumeric(view.yField);
304
+ break;
305
+ case "table": for (const column of view.columns ?? []) requireColumn(column);
306
+ }
307
+ }
308
+ }
309
+ /** Compress object rows into column-aligned two-dimensional arrays (D2). */
310
+ function rowsToArrays(columns, rows) {
311
+ return rows.map((row) => columns.map((column) => row[column] ?? null));
312
+ }
313
+ /** JSON-encoded UTF-8 size of the normalized report (the 512 KiB bound). */
314
+ function reportJsonBytes(report) {
315
+ return new TextEncoder().encode(JSON.stringify(report)).length;
316
+ }
317
+ /** One-line model-facing summary; never re-injects rows into model context (D5). */
318
+ function formatAnalysisSummary(report) {
319
+ const emptyIds = report.datasets.filter((dataset) => dataset.rows.length === 0).map((dataset) => dataset.id);
320
+ let text = "已生成分析报告《" + report.title + "》:" + report.datasets.length + " 个数据集、" + report.views.length + " 个视图(version 1)。";
321
+ if (emptyIds.length > 0) text += "其中 " + emptyIds.length + " 个数据集无数据:" + emptyIds.join("、") + "。";
322
+ return text;
323
+ }
324
+ const BASE_VIEW_PROPERTIES = {
325
+ id: {
326
+ type: "string",
327
+ required: true,
328
+ description: "视图唯一 id(本报告内不重复)"
329
+ },
330
+ kind: {
331
+ type: "string",
332
+ required: true,
333
+ description: "视图类型"
334
+ },
335
+ datasetId: {
336
+ type: "string",
337
+ required: true,
338
+ description: "引用本次请求中的一个 dataset id"
339
+ },
340
+ label: {
341
+ type: "string",
342
+ description: "可选视图标题,用于图表可访问名称与空态"
343
+ },
344
+ width: {
345
+ type: "string",
346
+ enum: ["full", "half"],
347
+ description: "可选宽度:full 整行 / half 半行(缺省由系统决定)"
348
+ }
349
+ };
350
+ const METRIC_VIEW_SCHEMA = {
351
+ type: "object",
352
+ properties: {
353
+ id: BASE_VIEW_PROPERTIES.id,
354
+ kind: {
355
+ type: "string",
356
+ const: "metric",
357
+ required: true
358
+ },
359
+ datasetId: BASE_VIEW_PROPERTIES.datasetId,
360
+ field: {
361
+ type: "string",
362
+ required: true,
363
+ description: "数值字段名(来自 dataset 查询结果的列)"
364
+ },
365
+ label: {
366
+ type: "string",
367
+ required: true,
368
+ description: "指标名称,如「本月营收」"
369
+ },
370
+ format: {
371
+ type: "string",
372
+ enum: ["number", "percent"],
373
+ description: "可选数值格式:number(默认)或 percent(值×100 后加 %)"
374
+ }
375
+ },
376
+ additionalProperties: false
377
+ };
378
+ const LINE_BAR_VIEW_SCHEMA = (kind) => ({
379
+ type: "object",
380
+ properties: {
381
+ id: BASE_VIEW_PROPERTIES.id,
382
+ kind: {
383
+ type: "string",
384
+ const: kind,
385
+ required: true
386
+ },
387
+ datasetId: BASE_VIEW_PROPERTIES.datasetId,
388
+ label: BASE_VIEW_PROPERTIES.label,
389
+ width: BASE_VIEW_PROPERTIES.width,
390
+ x: {
391
+ type: "object",
392
+ properties: {
393
+ field: {
394
+ type: "string",
395
+ required: true,
396
+ description: "x 轴字段名"
397
+ },
398
+ type: {
399
+ type: "string",
400
+ enum: ["category", "time"],
401
+ required: true,
402
+ description: "category 分类轴 / time 时间轴(数据需可由 Date 解析,SQL 请 ORDER BY)"
403
+ },
404
+ label: {
405
+ type: "string",
406
+ description: "可选 x 轴名称"
407
+ }
408
+ },
409
+ additionalProperties: false,
410
+ required: true
411
+ },
412
+ y: {
413
+ type: "array",
414
+ required: true,
415
+ items: { type: "string" },
416
+ description: "1-4 个数值 y 字段名;声明多个 y 时不得同时声明 seriesField"
417
+ },
418
+ seriesField: {
419
+ type: "string",
420
+ description: "可选分组字段:按该字段取值拆成多个系列(与多个 y 字段互斥)"
421
+ }
422
+ },
423
+ additionalProperties: false
424
+ });
425
+ const PIE_VIEW_SCHEMA = {
426
+ type: "object",
427
+ properties: {
428
+ id: BASE_VIEW_PROPERTIES.id,
429
+ kind: {
430
+ type: "string",
431
+ const: "pie",
432
+ required: true
433
+ },
434
+ datasetId: BASE_VIEW_PROPERTIES.datasetId,
435
+ label: BASE_VIEW_PROPERTIES.label,
436
+ width: BASE_VIEW_PROPERTIES.width,
437
+ categoryField: {
438
+ type: "string",
439
+ required: true,
440
+ description: "分类字段名"
441
+ },
442
+ valueField: {
443
+ type: "string",
444
+ required: true,
445
+ description: "非负数值字段名"
446
+ }
447
+ },
448
+ additionalProperties: false
449
+ };
450
+ const SCATTER_VIEW_SCHEMA = {
451
+ type: "object",
452
+ properties: {
453
+ id: BASE_VIEW_PROPERTIES.id,
454
+ kind: {
455
+ type: "string",
456
+ const: "scatter",
457
+ required: true
458
+ },
459
+ datasetId: BASE_VIEW_PROPERTIES.datasetId,
460
+ label: BASE_VIEW_PROPERTIES.label,
461
+ width: BASE_VIEW_PROPERTIES.width,
462
+ xField: {
463
+ type: "string",
464
+ required: true,
465
+ description: "数值 x 字段名"
466
+ },
467
+ yField: {
468
+ type: "string",
469
+ required: true,
470
+ description: "数值 y 字段名"
471
+ }
472
+ },
473
+ additionalProperties: false
474
+ };
475
+ const TABLE_VIEW_SCHEMA = {
476
+ type: "object",
477
+ properties: {
478
+ id: BASE_VIEW_PROPERTIES.id,
479
+ kind: {
480
+ type: "string",
481
+ const: "table",
482
+ required: true
483
+ },
484
+ datasetId: BASE_VIEW_PROPERTIES.datasetId,
485
+ label: BASE_VIEW_PROPERTIES.label,
486
+ width: BASE_VIEW_PROPERTIES.width,
487
+ columns: {
488
+ type: "array",
489
+ items: { type: "string" },
490
+ description: "可选列白名单;省略时按 dataset 列顺序显示"
491
+ }
492
+ },
493
+ additionalProperties: false
494
+ };
495
+ /** The view union: exactly the six supported kinds, nothing else. */
496
+ const ANALYSIS_VIEWS_SCHEMA = { oneOf: [
497
+ METRIC_VIEW_SCHEMA,
498
+ LINE_BAR_VIEW_SCHEMA("line"),
499
+ LINE_BAR_VIEW_SCHEMA("bar"),
500
+ PIE_VIEW_SCHEMA,
501
+ SCATTER_VIEW_SCHEMA,
502
+ TABLE_VIEW_SCHEMA
503
+ ] };
504
+ /** Wire parameter schema of the render-analysis tool. */
505
+ const RENDER_ANALYSIS_PARAMETERS = {
506
+ title: {
507
+ type: "string",
508
+ required: true,
509
+ description: "报告标题,如「月度经营分析」"
510
+ },
511
+ summary: {
512
+ type: "string",
513
+ description: "可选一句话结论/摘要,显示在报告头部"
514
+ },
515
+ datasets: {
516
+ type: "array",
517
+ required: true,
518
+ items: {
519
+ type: "object",
520
+ properties: {
521
+ id: {
522
+ type: "string",
523
+ required: true,
524
+ description: "数据集唯一 id(供 views 引用)"
525
+ },
526
+ sql: {
527
+ type: "string",
528
+ required: true,
529
+ description: "一条只读 SQL(SELECT/SHOW/DESCRIBE/EXPLAIN;聚合、Top N、排序都写在 SQL 中)"
530
+ }
531
+ },
532
+ additionalProperties: false
533
+ },
534
+ description: "1-6 个数据集;每个按顺序恰好执行一次,同一数据集可被多个视图复用"
535
+ },
536
+ views: {
537
+ type: "array",
538
+ required: true,
539
+ items: ANALYSIS_VIEWS_SCHEMA,
540
+ description: "1-8 个视图;每个视图必须回答一个不同子问题,多个视图可共享同一 dataset"
541
+ }
542
+ };
543
+ /** Canonical output schema of the render-analysis tool. */
544
+ const ANALYSIS_REPORT_OUTPUT_SCHEMA = {
545
+ type: "object",
546
+ properties: {
547
+ version: {
548
+ type: "integer",
549
+ const: 1,
550
+ required: true
551
+ },
552
+ title: {
553
+ type: "string",
554
+ required: true
555
+ },
556
+ summary: { type: "string" },
557
+ datasets: {
558
+ type: "array",
559
+ required: true,
560
+ items: {
561
+ type: "object",
562
+ properties: {
563
+ id: {
564
+ type: "string",
565
+ required: true
566
+ },
567
+ columns: {
568
+ type: "array",
569
+ required: true,
570
+ items: { type: "string" }
571
+ },
572
+ rows: {
573
+ type: "array",
574
+ required: true,
575
+ items: {
576
+ type: "array",
577
+ items: { oneOf: [{ type: "string" }, { type: "null" }] }
578
+ }
579
+ }
580
+ },
581
+ additionalProperties: false
582
+ }
583
+ },
584
+ views: {
585
+ type: "array",
586
+ required: true,
587
+ items: ANALYSIS_VIEWS_SCHEMA
588
+ }
589
+ },
590
+ additionalProperties: false
591
+ };
592
+ //#endregion
5
593
  //#region src/structured.ts
6
594
  function normalizeNewlines(text) {
7
595
  return text.replace(/\r\n?/g, "\n");
@@ -164,6 +752,64 @@ function parseStructuredQueryOutput(type, stdout, maxRows) {
164
752
  }
165
753
  }
166
754
  //#endregion
755
+ //#region src/structured-read.ts
756
+ /** Look up the session connection, failing with the same message for every tool. */
757
+ async function requireToolConnection(ctx, exec, toolName) {
758
+ const sessionId = exec.agent?.id;
759
+ if (sessionId === void 0) throw new Error(toolName + ": 缺少会话上下文(agent loop 未注入)");
760
+ try {
761
+ return await ctx.dataAgentConnections.resolveForExecution(sessionId);
762
+ } catch (error) {
763
+ const message = error instanceof Error ? error.message : String(error);
764
+ throw new Error(toolName + ": " + message);
765
+ }
766
+ }
767
+ /** Run and redact a client result/error before it reaches tool/session output. */
768
+ async function runRedactedClientQuery(ctx, connection, sql, options, signal) {
769
+ try {
770
+ const result = await runClientQuery(ctx, connection, sql, options, signal);
771
+ return redactQueryResult(result, connection);
772
+ } catch (error) {
773
+ const message = redactSecretText(error instanceof Error ? error.message : String(error), [connection.password]);
774
+ throw new Error(message, error instanceof Error ? { cause: error } : void 0);
775
+ }
776
+ }
777
+ /** Query runner options with the deployment overrides applied. */
778
+ function runnerOptions(resolved, mode) {
779
+ return {
780
+ clients: resolved.clients,
781
+ timeoutMs: resolved.queryTimeoutMs,
782
+ maxResultChars: resolved.maxResultChars,
783
+ ...mode !== void 0 ? { mode } : {}
784
+ };
785
+ }
786
+ /**
787
+ * Execute one read-only SQL through the structured client template and parse
788
+ * it into the canonical { columns, rows } shape, with maxRows enforced at both
789
+ * the SQL level (LIMIT injection) and the parse level.
790
+ */
791
+ async function runStructuredReadQuery(ctx, connection, sql, resolved, toolName, signal) {
792
+ if (sql.trim().length === 0) throw new Error(toolName + ": sql 不能为空");
793
+ if (sql.length > resolved.maxQueryChars) throw new Error(toolName + ": sql 超过长度上限(" + resolved.maxQueryChars + " 字符)");
794
+ assertSingleStatement(sql, toolName);
795
+ if (classifyStatement(sql, connection.type) !== "read") throw new Error(toolName + " 只执行读语句(SELECT/SHOW/DESCRIBE/EXPLAIN,SQLite 还含查询型 PRAGMA);写语句请使用 sql-write");
796
+ const limitedSql = enforceReadRowLimit(sql, connection.type, resolved.maxRows);
797
+ const startedAt = Date.now();
798
+ const result = await runRedactedClientQuery(ctx, connection, limitedSql, runnerOptions(resolved, "structured"), signal);
799
+ const elapsedMs = Date.now() - startedAt;
800
+ if (result.exitCode !== 0) {
801
+ const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
802
+ throw new Error(toolName + " 执行失败(exit " + result.exitCode + "):" + detail);
803
+ }
804
+ const parsed = parseStructuredQueryOutput(connection.type, result.stdout, resolved.maxRows);
805
+ return {
806
+ columns: parsed.columns,
807
+ rows: parsed.rows,
808
+ elapsedMs,
809
+ truncated: result.truncated || parsed.rowLimitExceeded
810
+ };
811
+ }
812
+ //#endregion
167
813
  //#region src/tool.ts
168
814
  /** Cordis plugin name (diagnostics only). */
169
815
  const name = "data-agent-tool";
@@ -178,6 +824,7 @@ const Config = z.object({
178
824
  queryTimeoutMs: z.number().step(1).min(1e3).default(DEFAULT_QUERY_TIMEOUT_MS),
179
825
  maxResultChars: z.number().step(1).min(1024).default(DEFAULT_MAX_RESULT_CHARS),
180
826
  maxRows: z.number().step(1).min(1).default(100),
827
+ maxQueryChars: z.number().step(1).min(1024).default(DEFAULT_MAX_QUERY_CHARS),
181
828
  readonly: z.boolean().default(false),
182
829
  clients: clientsSchema
183
830
  });
@@ -199,31 +846,95 @@ function formatResult(value) {
199
846
  function formatStructuredResult(value) {
200
847
  return "```json\n" + JSON.stringify(value, null, 2) + "\n```";
201
848
  }
202
- /** Look up the session connection, failing with the same message for every tool. */
203
- function requireToolConnection(ctx, exec, toolName) {
204
- const sessionId = exec.agent?.id;
205
- if (sessionId === void 0) throw new Error(`${toolName}: 缺少会话上下文(agent loop 未注入)`);
206
- const connection = ctx.dataAgentConnections.getWithSecret(sessionId);
207
- if (connection === void 0) throw new Error(`请先在「数据库」标签页连接数据库,再使用 ${toolName}(未找到当前会话的连接)`);
208
- return connection;
209
- }
210
- /** Empty and multi-statement checks shared by all three tools. */
849
+ /** Empty and multi-statement checks shared by the write/raw tools. */
211
850
  function validateSingleSql(sql, toolName) {
212
- if (sql.trim().length === 0) throw new Error(`${toolName}: sql 不能为空`);
851
+ if (sql.trim().length === 0) throw new Error(toolName + ": sql 不能为空");
213
852
  assertSingleStatement(sql, toolName);
214
853
  }
215
- /** Query runner options with the deployment overrides applied. */
216
- function runnerOptions(resolved, mode) {
217
- return {
218
- clients: resolved.clients,
219
- timeoutMs: resolved.queryTimeoutMs,
220
- maxResultChars: resolved.maxResultChars,
221
- ...mode !== void 0 ? { mode } : {}
222
- };
854
+ /**
855
+ * The Web-only render-analysis tool (D1-D5): one call builds one versioned
856
+ * analysis report from 1-6 read-only datasets and 1-8 views. The full report
857
+ * is persisted as presentationMeta; the model only receives a short summary
858
+ * (output.render), never the rows themselves.
859
+ */
860
+ function defineRenderAnalysisTool(ctx, resolved) {
861
+ return defineTool({
862
+ name: "render-analysis",
863
+ description: "Web only: render one versioned analysis report (v1) from 1-6 read-only datasets and 1-8 metric, line, bar, pie, scatter, or table views. First use sql-query to inspect and verify data, then call this tool only when visualization adds value. Use one primary chart for a simple relationship or 3-6 complementary views for multi-metric, time-series, or segmented analysis. Put aggregation, Top N, and sorting in SQL, and add ORDER BY for line or time datasets. Reuse a dataset across views via datasetId; each dataset runs once. Arbitrary chart options, scripts, HTML, CSS, and URLs are not accepted. Empty datasets are valid and render as no-data states.",
864
+ parameters: RENDER_ANALYSIS_PARAMETERS,
865
+ output: {
866
+ schema: ANALYSIS_REPORT_OUTPUT_SCHEMA,
867
+ render: (_args, value) => [{
868
+ type: "text",
869
+ text: formatAnalysisSummary(value)
870
+ }],
871
+ presentationMeta: (_args, value) => value
872
+ },
873
+ presentCall: (args) => ({
874
+ card: "generic",
875
+ kind: "read",
876
+ title: "render-analysis《" + args.title + "》",
877
+ rawInput: args.title
878
+ }),
879
+ presentResult: (args, result) => ({
880
+ card: "generic",
881
+ title: "render-analysis《" + args.title + "》",
882
+ content: result.content
883
+ }),
884
+ async execute(args, exec) {
885
+ const request = parseAnalysisRequest(args);
886
+ const connection = await requireToolConnection(ctx, exec, "render-analysis");
887
+ const planned = request.datasets.map((dataset) => {
888
+ const sql = dataset.sql;
889
+ if (sql.trim().length === 0) throw new Error("render-analysis: dataset \"" + dataset.id + "\" 的 sql 不能为空");
890
+ if (sql.length > resolved.maxQueryChars) throw new Error("render-analysis: dataset \"" + dataset.id + "\" 的 sql 超过长度上限(" + resolved.maxQueryChars + " 字符)");
891
+ assertSingleStatement(sql, "render-analysis");
892
+ if (classifyStatement(sql, connection.type) !== "read") throw new Error("render-analysis: dataset \"" + dataset.id + "\" 必须是读语句(SELECT/SHOW/DESCRIBE/EXPLAIN,SQLite 还含查询型 PRAGMA)");
893
+ return {
894
+ id: dataset.id,
895
+ sql: enforceReadRowLimit(sql, connection.type, resolved.maxRows)
896
+ };
897
+ });
898
+ const results = /* @__PURE__ */ new Map();
899
+ for (const item of planned) {
900
+ let read;
901
+ try {
902
+ read = await runStructuredReadQuery(ctx, connection, item.sql, resolved, "render-analysis", exec.signal);
903
+ } catch (error) {
904
+ if (exec.signal.aborted) throw error;
905
+ const message = error instanceof Error ? error.message : String(error);
906
+ throw new Error("render-analysis: dataset \"" + item.id + "\" 执行失败:" + message);
907
+ }
908
+ if (read.truncated) throw new Error("render-analysis: dataset \"" + item.id + "\" 的查询结果被截断(超过 maxRows/maxResultChars);请缩小、聚合或拆分查询");
909
+ results.set(item.id, {
910
+ columns: read.columns,
911
+ rows: read.rows
912
+ });
913
+ }
914
+ validateViewSemantics(request.views, results);
915
+ const report = {
916
+ version: 1,
917
+ title: request.title,
918
+ ...request.summary !== void 0 ? { summary: request.summary } : {},
919
+ datasets: request.datasets.map((dataset) => {
920
+ const data = results.get(dataset.id);
921
+ return {
922
+ id: dataset.id,
923
+ columns: data.columns,
924
+ rows: rowsToArrays(data.columns, data.rows)
925
+ };
926
+ }),
927
+ views: request.views
928
+ };
929
+ const bytes = reportJsonBytes(report);
930
+ if (bytes > 524288) throw new Error("render-analysis: 报告 JSON 超过 524288 字节上限(当前 " + bytes + " 字节);请聚合、筛选或拆分报告,不得静默删减数据");
931
+ return report;
932
+ }
933
+ });
223
934
  }
224
935
  /**
225
936
  * Mount the data-agent database tools: `sql-query` (structured read-only),
226
- * `sql-write` (explicit write semantics), and `sqlcmd` (raw compatibility).
937
+ * `sql-write` (explicit write semantics), and `sql-cmd` (raw compatibility).
227
938
  * @param ctx - the preset-scoped agent context.
228
939
  * @param config - validated loader configuration.
229
940
  */
@@ -232,12 +943,13 @@ function apply(ctx, config) {
232
943
  queryTimeoutMs: config.queryTimeoutMs,
233
944
  maxResultChars: config.maxResultChars,
234
945
  maxRows: config.maxRows,
946
+ maxQueryChars: config.maxQueryChars,
235
947
  readonly: config.readonly,
236
948
  clients: config.clients
237
949
  };
238
950
  ctx.tools.register(defineTool({
239
951
  name: "sql-query",
240
- description: `在已连接数据库上执行一条只读 SQLSELECT/SHOW/DESCRIBE/EXPLAINSQLite 还含查询型 PRAGMA),返回结构化 JSON:{ columns, rows, affectedRows, elapsedMs, truncated }。SELECT 未写 LIMIT 时会自动限制为最多 ${resolved.maxRows} 行;所有结果最多返回 ${resolved.maxRows} 行。只执行单条语句;写操作请使用 sql-write,原始客户端输出请使用 sqlcmd。`,
952
+ description: `Execute exactly one read-only SQL statement (SELECT, SHOW, DESCRIBE, EXPLAIN, or a read-only SQLite PRAGMA) on the connected database. Returns structured JSON with columns, rows, affectedRows, elapsedMs, and truncated. An unbounded SELECT is limited automatically, and every result is capped at ${resolved.maxRows} rows. Use sql-write for write operations and sql-cmd when raw database-client output is required.`,
241
953
  parameters: { sql: {
242
954
  type: "string",
243
955
  required: true,
@@ -293,30 +1005,19 @@ function apply(ctx, config) {
293
1005
  content: result.content
294
1006
  }),
295
1007
  async execute(args, exec) {
296
- const connection = requireToolConnection(ctx, exec, "sql-query");
297
- validateSingleSql(args.sql, "sql-query");
298
- if (classifyStatement(args.sql, connection.type) !== "read") throw new Error("sql-query 只执行读语句(SELECT/SHOW/DESCRIBE/EXPLAIN,SQLite 还含查询型 PRAGMA);写语句请使用 sql-write");
299
- const limitedSql = enforceReadRowLimit(args.sql, connection.type, resolved.maxRows);
300
- const startedAt = Date.now();
301
- const result = await runClientQuery(ctx, connection, limitedSql, runnerOptions(resolved, "structured"), exec.signal);
302
- const elapsedMs = Date.now() - startedAt;
303
- if (result.exitCode !== 0) {
304
- const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
305
- throw new Error(`sql-query 执行失败(exit ${result.exitCode}):${detail}`);
306
- }
307
- const parsed = parseStructuredQueryOutput(connection.type, result.stdout, resolved.maxRows);
1008
+ const read = await runStructuredReadQuery(ctx, await requireToolConnection(ctx, exec, "sql-query"), args.sql, resolved, "sql-query", exec.signal);
308
1009
  return {
309
- columns: parsed.columns,
310
- rows: parsed.rows,
1010
+ columns: read.columns,
1011
+ rows: read.rows,
311
1012
  affectedRows: 0,
312
- elapsedMs,
313
- truncated: result.truncated || parsed.rowLimitExceeded
1013
+ elapsedMs: read.elapsedMs,
1014
+ truncated: read.truncated
314
1015
  };
315
1016
  }
316
1017
  }));
317
1018
  ctx.tools.register(defineTool({
318
1019
  name: "sql-write",
319
- description: "在已连接数据库上执行一条写/管理语句(INSERT/UPDATE/DELETE/DDL 等)。每次调用都是独立客户端进程并自动提交,只接受单条语句,不支持跨调用的多语句事务;如需原子性,请改用单条 SQL(如 INSERT ... SELECT)或数据库端脚本/存储过程。只读查询请使用 sql-query",
1020
+ description: "Execute exactly one write or administrative SQL statement, such as INSERT, UPDATE, DELETE, or DDL, on the connected database. Each call starts an independent database-client process and auto-commits. Multi-statement transactions cannot span calls; use one atomic statement such as INSERT ... SELECT, or a database-side script or stored procedure. Use sql-query for read-only queries.",
320
1021
  parameters: { sql: {
321
1022
  type: "string",
322
1023
  required: true,
@@ -361,16 +1062,16 @@ function apply(ctx, config) {
361
1062
  content: result.content
362
1063
  }),
363
1064
  async execute(args, exec) {
364
- const connection = requireToolConnection(ctx, exec, "sql-write");
1065
+ const connection = await requireToolConnection(ctx, exec, "sql-write");
365
1066
  validateSingleSql(args.sql, "sql-write");
366
1067
  if (classifyStatement(args.sql, connection.type) === "read") throw new Error("sql-write 只执行写/管理语句;只读查询请使用 sql-query");
367
1068
  if (connection.readonly ?? resolved.readonly) throw new Error("当前连接为只读模式,sql-write 拒绝执行写/管理语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/查询型 PRAGMA 等)");
368
- return runClientQuery(ctx, connection, args.sql, runnerOptions(resolved), exec.signal);
1069
+ return runRedactedClientQuery(ctx, connection, args.sql, runnerOptions(resolved), exec.signal);
369
1070
  }
370
1071
  }));
371
1072
  ctx.tools.register(defineTool({
372
- name: "sqlcmd",
373
- description: `在已连接数据库上执行一条 SQL 或客户端命令(如 SHOW TABLESDESCRIBE users),返回原始 exitCode/stdout/stderr 文本。新调用优先使用 sql-query(结构化只读结果)和 sql-write(明确写语义)。一次只执行一条语句;读 SELECT 会自动限制最多 ${resolved.maxRows} 行;每次调用为独立客户端进程并自动提交。`,
1073
+ name: "sql-cmd",
1074
+ description: `Execute exactly one SQL statement or database-client command, such as SHOW TABLES or DESCRIBE users, on the connected database and return raw exitCode, stdout, stderr, and truncated fields. Prefer sql-query for structured read results and sql-write for explicit write semantics. Read SELECT results are limited to ${resolved.maxRows} rows. Each call starts an independent database-client process and auto-commits.`,
374
1075
  parameters: { sql: {
375
1076
  type: "string",
376
1077
  required: true,
@@ -406,22 +1107,22 @@ function apply(ctx, config) {
406
1107
  },
407
1108
  presentCall: (args) => ({
408
1109
  card: "terminal",
409
- title: `sqlcmd ${oneLine(args.sql)}`,
1110
+ title: `sql-cmd ${oneLine(args.sql)}`,
410
1111
  description: "在数据库客户端执行一条 SQL"
411
1112
  }),
412
1113
  presentResult: (args, result) => ({
413
1114
  card: "terminal",
414
- title: `sqlcmd ${oneLine(args.sql)}`,
1115
+ title: `sql-cmd ${oneLine(args.sql)}`,
415
1116
  content: result.content
416
1117
  }),
417
1118
  async execute(args, exec) {
418
- const connection = requireToolConnection(ctx, exec, "sqlcmd");
419
- validateSingleSql(args.sql, "sqlcmd");
420
- if ((connection.readonly ?? resolved.readonly) && classifyStatement(args.sql, connection.type) === "write") throw new Error("当前连接为只读模式,sqlcmd 拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/查询型 PRAGMA 等)");
421
- const sql = classifyStatement(args.sql, connection.type) === "read" ? enforceReadRowLimit(args.sql, connection.type, resolved.maxRows) : args.sql;
422
- return runClientQuery(ctx, connection, sql, runnerOptions(resolved), exec.signal);
1119
+ const connection = await requireToolConnection(ctx, exec, "sql-cmd");
1120
+ validateSingleSql(args.sql, "sql-cmd");
1121
+ if ((connection.readonly ?? resolved.readonly) && classifyStatement(args.sql, connection.type) === "write") throw new Error("当前连接为只读模式,sql-cmd 拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/查询型 PRAGMA 等)");
1122
+ return runRedactedClientQuery(ctx, connection, classifyStatement(args.sql, connection.type) === "read" ? enforceReadRowLimit(args.sql, connection.type, resolved.maxRows) : args.sql, runnerOptions(resolved), exec.signal);
423
1123
  }
424
1124
  }));
1125
+ if (ctx.get("webServer") !== void 0) ctx.tools.register(defineRenderAnalysisTool(ctx, resolved));
425
1126
  }
426
1127
  //#endregion
427
1128
  export { Config, apply, inject, name };