dsh-data-cleaning-agent 0.6.0 → 0.6.2

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,280 @@
1
+ /**
2
+ * Agent-owned QCC command bridge.
3
+ *
4
+ * DSH Code Mode only permits dynamic MCP calls as nested executions of an
5
+ * Agent-owned tool call. The Web workbench therefore stages rows under an
6
+ * opaque command id, sends only that id through the visible conversation,
7
+ * and lets this high-level tool perform the paid calls with exec.token.
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { QccBridgeError } from './qcc.js';
11
+ import { normalizeFieldSelection } from './workflow-contract.js';
12
+
13
+ export const TOOL_QCC_COMMAND = 'data_cleaning_qcc_run';
14
+
15
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
16
+ const DEFAULT_MAX_COMMANDS = 50;
17
+
18
+ function clone(value) {
19
+ return structuredClone(value);
20
+ }
21
+
22
+ function safeError(error) {
23
+ if (error instanceof QccBridgeError) return error.toJSON();
24
+ return new QccBridgeError('QCC_COMMAND_FAILED', 'Data-cleaning QCC command failed', {
25
+ retryable: false,
26
+ }).toJSON();
27
+ }
28
+
29
+ function requiredText(value, code, message) {
30
+ const text = String(value ?? '').trim();
31
+ if (!text) throw new QccBridgeError(code, message);
32
+ return text;
33
+ }
34
+
35
+ function normalizedInput(input = {}) {
36
+ const kind = String(input.kind ?? 'enrich');
37
+ if (!['enrich', 'resolve', 'retry'].includes(kind)) {
38
+ throw new QccBridgeError('QCC_COMMAND_KIND_INVALID', 'Unsupported data-cleaning QCC command kind');
39
+ }
40
+ const taskId = requiredText(input.taskId, 'QCC_COMMAND_TASK_REQUIRED', 'A workflow taskId is required');
41
+ if (kind === 'enrich') {
42
+ const rows = Array.isArray(input.rows) ? input.rows : [];
43
+ if (rows.length === 0) throw new QccBridgeError('QCC_INVALID_ROWS', 'At least one row is required');
44
+ if (rows.length > 100) throw new QccBridgeError('QCC_BATCH_TOO_LARGE', 'QCC batch exceeds 100 rows');
45
+ return {
46
+ kind,
47
+ taskId,
48
+ rows: clone(rows),
49
+ headers: Array.isArray(input.headers) ? input.headers.map(String) : [],
50
+ nameField: String(input.nameField ?? 'name'),
51
+ fieldSelection: normalizeFieldSelection(input.fieldSelection),
52
+ includeRisk: input.includeRisk === true,
53
+ concurrency: Math.min(4, Math.max(1, Math.trunc(Number(input.concurrency ?? 2)))),
54
+ };
55
+ }
56
+ const runId = requiredText(input.runId, 'QCC_RUN_NOT_FOUND', 'A G5 runId is required');
57
+ if (kind === 'resolve') {
58
+ return {
59
+ kind,
60
+ taskId,
61
+ runId,
62
+ companyName: requiredText(input.companyName, 'QCC_REVIEW_NOT_PENDING', 'A company name is required'),
63
+ selectedCreditNo: requiredText(input.selectedCreditNo, 'QCC_CANDIDATE_INVALID', 'A selected credit number is required'),
64
+ };
65
+ }
66
+ const companyNames = [...new Set((Array.isArray(input.companyNames) ? input.companyNames : [])
67
+ .map((name) => String(name).trim()).filter(Boolean))];
68
+ if (companyNames.length === 0) throw new QccBridgeError('QCC_RETRY_EMPTY', 'At least one failed company must be selected');
69
+ return { kind, taskId, runId, companyNames };
70
+ }
71
+
72
+ export function serializeQccCommandPrompt(command) {
73
+ const visible = {
74
+ schemaVersion: 1,
75
+ commandId: command.commandId,
76
+ taskId: command.taskId,
77
+ kind: command.kind,
78
+ };
79
+ return [
80
+ '请执行数据清洗补全企查查任务。',
81
+ '',
82
+ '类型化任务意图(schemaVersion 1):',
83
+ '```json',
84
+ JSON.stringify(visible, null, 2),
85
+ '```',
86
+ '',
87
+ `请准确调用一次 ${TOOL_QCC_COMMAND} 并只传递 commandId。`,
88
+ '企业名单和字段选择已安全暂存在本机 Host,不得要求用户在对话中重复粘贴,不得直接调用任何 mcp__qcc-* 工具。',
89
+ '高层工具返回后立即结束本轮;不得重试、扩大名单或追加字段。',
90
+ ].join('\n');
91
+ }
92
+
93
+ export class QccCommandStore {
94
+ constructor({ bridge, runs, clock = () => Date.now(), ttlMs = DEFAULT_TTL_MS, maxCommands = DEFAULT_MAX_COMMANDS } = {}) {
95
+ if (!bridge || !runs) throw new TypeError('QccCommandStore requires bridge and runs');
96
+ this.bridge = bridge;
97
+ this.runs = runs;
98
+ this.clock = clock;
99
+ this.ttlMs = ttlMs;
100
+ this.maxCommands = maxCommands;
101
+ this.commands = new Map();
102
+ }
103
+
104
+ cleanup(reserveSlot = false) {
105
+ const cutoff = this.clock() - this.ttlMs;
106
+ for (const [id, command] of this.commands) {
107
+ if (command.state !== 'running' && command.touchedAtMs < cutoff) this.commands.delete(id);
108
+ }
109
+ const limit = reserveSlot ? this.maxCommands - 1 : this.maxCommands;
110
+ while (this.commands.size > limit) {
111
+ const removable = [...this.commands].find(([, command]) => command.state !== 'running');
112
+ if (!removable) throw new QccBridgeError('QCC_COMMAND_CAPACITY', 'QCC command queue is full', { retryable: true });
113
+ this.commands.delete(removable[0]);
114
+ }
115
+ }
116
+
117
+ prepare(input) {
118
+ this.cleanup(true);
119
+ const normalized = normalizedInput(input);
120
+ const commandId = `dcq-${randomUUID()}`;
121
+ const at = new Date(this.clock()).toISOString();
122
+ const record = {
123
+ commandId,
124
+ taskId: normalized.taskId,
125
+ kind: normalized.kind,
126
+ state: 'prepared',
127
+ createdAt: at,
128
+ updatedAt: at,
129
+ touchedAtMs: this.clock(),
130
+ input: normalized,
131
+ runId: null,
132
+ error: null,
133
+ promise: null,
134
+ };
135
+ this.commands.set(commandId, record);
136
+ return { ...this.publicRecord(record), prompt: serializeQccCommandPrompt(record) };
137
+ }
138
+
139
+ require(commandId) {
140
+ this.cleanup();
141
+ const record = this.commands.get(String(commandId ?? ''));
142
+ if (!record) throw new QccBridgeError('QCC_COMMAND_NOT_FOUND', 'QCC command was not found or expired');
143
+ record.touchedAtMs = this.clock();
144
+ return record;
145
+ }
146
+
147
+ publicRecord(record) {
148
+ return clone({
149
+ commandId: record.commandId,
150
+ taskId: record.taskId,
151
+ kind: record.kind,
152
+ state: record.state,
153
+ createdAt: record.createdAt,
154
+ updatedAt: record.updatedAt,
155
+ runId: record.runId,
156
+ error: record.error,
157
+ expiresInMs: this.ttlMs,
158
+ });
159
+ }
160
+
161
+ status(commandId) {
162
+ const record = this.require(commandId);
163
+ const output = this.publicRecord(record);
164
+ if (record.runId) output.run = this.runs.get(record.runId);
165
+ return output;
166
+ }
167
+
168
+ async run(commandId, execution) {
169
+ const record = this.require(commandId);
170
+ if (!execution?.agent || !execution?.token) {
171
+ throw new QccBridgeError('QCC_AGENT_EXECUTION_REQUIRED', 'QCC commands require an Agent-owned DSH tool execution');
172
+ }
173
+ if (record.promise) return record.promise;
174
+ record.state = 'running';
175
+ record.updatedAt = new Date(this.clock()).toISOString();
176
+ record.promise = this.execute(record, execution)
177
+ .then((run) => {
178
+ record.runId = run.runId;
179
+ record.state = 'completed';
180
+ record.updatedAt = new Date(this.clock()).toISOString();
181
+ record.touchedAtMs = this.clock();
182
+ return this.toolResult(record, run);
183
+ })
184
+ .catch((error) => {
185
+ record.error = safeError(error);
186
+ record.state = 'failed';
187
+ record.updatedAt = new Date(this.clock()).toISOString();
188
+ record.touchedAtMs = this.clock();
189
+ throw error;
190
+ });
191
+ return record.promise;
192
+ }
193
+
194
+ async execute(record, execution) {
195
+ const options = { execution };
196
+ if (record.kind === 'resolve') {
197
+ return this.runs.resolveCandidate(record.input.runId, record.input, this.bridge, options);
198
+ }
199
+ if (record.kind === 'retry') {
200
+ return this.runs.retryCompanies(record.input.runId, record.input.companyNames, this.bridge, options);
201
+ }
202
+ const audit = [];
203
+ const result = await this.bridge.enrichRows(record.input.rows, {
204
+ nameField: record.input.nameField,
205
+ includeRisk: record.input.includeRisk,
206
+ fieldSelection: record.input.fieldSelection,
207
+ concurrency: record.input.concurrency,
208
+ maxRows: 100,
209
+ execution,
210
+ onAudit: (event) => audit.push(event),
211
+ });
212
+ return this.runs.createRun({
213
+ headers: record.input.headers,
214
+ nameField: record.input.nameField,
215
+ includeRisk: record.input.includeRisk,
216
+ fieldSelection: record.input.fieldSelection,
217
+ concurrency: record.input.concurrency,
218
+ result,
219
+ audit,
220
+ });
221
+ }
222
+
223
+ toolResult(record, run) {
224
+ return clone({
225
+ commandId: record.commandId,
226
+ taskId: record.taskId,
227
+ runId: run.runId,
228
+ state: run.state,
229
+ summary: run.summary,
230
+ });
231
+ }
232
+ }
233
+
234
+ export function registerQccCommandTool(tools, commands) {
235
+ return tools.register({
236
+ name: TOOL_QCC_COMMAND,
237
+ description: 'Execute one already-staged data-cleaning QCC command. Call only when a visible typed intent supplies commandId. The Host owns rows, billing confirmation, idempotency and result artifacts.',
238
+ parameters: {
239
+ type: 'object',
240
+ additionalProperties: false,
241
+ properties: { commandId: { type: 'string' } },
242
+ required: ['commandId'],
243
+ },
244
+ output: {
245
+ schema: {
246
+ type: 'object',
247
+ additionalProperties: false,
248
+ properties: {
249
+ commandId: { type: 'string' },
250
+ taskId: { type: 'string' },
251
+ runId: { type: 'string' },
252
+ state: { type: 'string' },
253
+ summary: {
254
+ type: 'object',
255
+ additionalProperties: false,
256
+ properties: {
257
+ totalRows: { type: 'integer' },
258
+ uniqueCompanies: { type: 'integer' },
259
+ enriched: { type: 'integer' },
260
+ ambiguous: { type: 'integer' },
261
+ unresolved: { type: 'integer' },
262
+ failed: { type: 'integer' },
263
+ missingName: { type: 'integer' },
264
+ includeRisk: { type: 'boolean' },
265
+ },
266
+ required: ['totalRows', 'uniqueCompanies', 'enriched', 'ambiguous', 'unresolved', 'failed', 'missingName', 'includeRisk'],
267
+ },
268
+ },
269
+ required: ['commandId', 'taskId', 'runId', 'state', 'summary'],
270
+ },
271
+ render: (_args, value) => [{
272
+ type: 'text',
273
+ text: `数据清洗补全企查查任务已完成:${value.summary?.enriched ?? 0}/${value.summary?.totalRows ?? 0} 条已补全,状态 ${value.state}。`,
274
+ }],
275
+ },
276
+ async execute(args, exec) {
277
+ return commands.run(args.commandId, exec);
278
+ },
279
+ });
280
+ }
package/lib/qcc-runs.js CHANGED
@@ -143,7 +143,7 @@ export class G5RunStore {
143
143
  return { value: await entry.promise, replayed: false };
144
144
  }
145
145
 
146
- createRun({ headers, nameField, includeRisk, concurrency, result, audit = [] }) {
146
+ createRun({ headers, nameField, fieldSelection, includeRisk, concurrency, result, audit = [] }) {
147
147
  this.cleanup();
148
148
  const id = this.runIdFactory();
149
149
  const at = this.nowIso();
@@ -156,6 +156,7 @@ export class G5RunStore {
156
156
  touchedAtMs: this.clock(),
157
157
  headers: Array.isArray(headers) ? headers.map(String) : [],
158
158
  nameField: String(nameField ?? 'name'),
159
+ fieldSelection: Array.isArray(fieldSelection) ? fieldSelection.map(String) : [],
159
160
  includeRisk: Boolean(includeRisk),
160
161
  concurrency: Number(concurrency ?? 2),
161
162
  summary: clone(result.summary),
@@ -196,6 +197,7 @@ export class G5RunStore {
196
197
  createdAt: record.createdAt,
197
198
  updatedAt: record.updatedAt,
198
199
  headers: record.headers,
200
+ fieldSelection: record.fieldSelection,
199
201
  summary: record.summary,
200
202
  rows: record.rows,
201
203
  reviewQueue: record.reviewQueue,
@@ -269,6 +271,7 @@ export class G5RunStore {
269
271
  }, {
270
272
  ...options,
271
273
  includeRisk: record.includeRisk,
274
+ fieldSelection: record.fieldSelection,
272
275
  onAudit: (event) => this.appendAudit(record, event),
273
276
  });
274
277
  this.patchCompany(record, name, queued.rowIndexes, result);
@@ -303,6 +306,7 @@ export class G5RunStore {
303
306
  result = await bridge.enrichCompany(item.companyName, {
304
307
  ...options,
305
308
  includeRisk: record.includeRisk,
309
+ fieldSelection: record.fieldSelection,
306
310
  onAudit: (event) => this.appendAudit(record, event),
307
311
  });
308
312
  } catch (error) {
package/lib/qcc.js CHANGED
@@ -24,6 +24,7 @@ export const QCC_TOOL_NAMES = Object.freeze({
24
24
  oauthStatus: 'qcc_oauth_status',
25
25
  entityLookup: 'mcp__qcc-company__get_company_by_query',
26
26
  registration: 'mcp__qcc-company__get_company_registration_info',
27
+ profile: 'mcp__qcc-company__get_company_profile',
27
28
  riskScan: 'mcp__qcc-risk__get_company_risk_scan',
28
29
  });
29
30
 
@@ -145,13 +146,31 @@ export class QccBridgeError extends Error {
145
146
  }
146
147
  }
147
148
 
149
+ function messageFailureCode(message) {
150
+ const text = String(message ?? '').trim();
151
+ if (!text) return '';
152
+ const cases = [
153
+ [/only\s+[`'\"]?run_code|requires task-based execution|direct(?:ly)?[^.]{0,48}(?:not allowed|not callable|denied)/i, 'DSH_EXECUTION_DENIED'],
154
+ [/(?:^|\D)401(?:\D|$)|unauthori[sz]ed|auth(?:entication|orization)? required|invalid token|token expired/i, '401'],
155
+ [/(?:^|\D)403(?:\D|$)|forbidden|permission denied|resource not authorized/i, '403'],
156
+ [/(?:^|\D)429(?:\D|$)|rate[ -]?limit|too many requests/i, '429'],
157
+ [/quota[^.]{0,32}(?:exhausted|insufficient|limit)|insufficient quota/i, 'QUOTA_EXHAUSTED'],
158
+ [/unknown tool|tool unavailable|method not found|mcp error\s*-32601/i, 'UNKNOWN_TOOL'],
159
+ [/timed?\s*out|deadline exceeded/i, 'TIMEOUT'],
160
+ [/mcp error\s*-32602|invalid (?:argument|parameter)|validation error|bad request/i, 'INVALID_ARGUMENT'],
161
+ [/(?:^|\D)5\d\d(?:\D|$)|service unavailable|upstream unavailable|connection (?:error|failed)/i, 'UPSTREAM_UNAVAILABLE'],
162
+ ];
163
+ return cases.find(([pattern]) => pattern.test(text))?.[1] ?? 'UNCLASSIFIED_TOOL_ERROR';
164
+ }
165
+
148
166
  function upstreamFailureCode(result) {
149
- return String(
167
+ const structured = String(
150
168
  result?.error?.info?.code
151
169
  ?? result?.error?.info?.status
152
170
  ?? result?.error?.info?.httpStatus
153
171
  ?? '',
154
172
  ).trim().toUpperCase();
173
+ return structured || messageFailureCode(result?.error?.message);
155
174
  }
156
175
 
157
176
  function failureDetails(result) {
@@ -218,6 +237,13 @@ function normalizedFailure(result, toolName, state) {
218
237
  connectRequired: true,
219
238
  });
220
239
  }
240
+ if (upstreamCode === 'DSH_EXECUTION_DENIED') {
241
+ return new QccBridgeError('QCC_EXECUTION_DENIED', 'DSH denied this programmatic QCC tool execution', {
242
+ toolName,
243
+ upstreamCode,
244
+ retryable: false,
245
+ });
246
+ }
221
247
  if (/^(?:5\d\d|SERVICE_UNAVAILABLE|UPSTREAM_UNAVAILABLE|CONNECTION_ERROR)$/.test(upstreamCode)) {
222
248
  return new QccBridgeError('QCC_UPSTREAM_UNAVAILABLE', 'QCC upstream service is temporarily unavailable', {
223
249
  toolName,
@@ -291,18 +317,98 @@ export function mapRegistrationFields(value, fallback = {}) {
291
317
  if (value.无匹配项 !== undefined) {
292
318
  throw new QccBridgeError('QCC_ENTITY_NOT_FOUND', 'Locked QCC entity could not be resolved by registration tool');
293
319
  }
320
+ const text = (...keys) => {
321
+ for (const key of keys) {
322
+ const item = value[key];
323
+ if (item !== undefined && item !== null && String(item).trim()) return String(item);
324
+ }
325
+ return '';
326
+ };
327
+ const region = text('所属地区', '所属地域', '地区');
328
+ const location = splitAdministrativeRegion(region);
329
+ return {
330
+ company_name: text('企业名称') || String(fallback.companyName ?? ''),
331
+ credit_no: text('统一社会信用代码', '信用代码') || String(fallback.creditNo ?? ''),
332
+ reg_no: text('工商注册号', '注册号'),
333
+ org_no: text('组织机构代码'),
334
+ reg_status: text('登记状态', '执业状态', '证书状态'),
335
+ legal_rep: text('法定代表人', '负责人', '经营者'),
336
+ reg_capital: text('注册资本', '注册资金', '开办资金', '成员出资总额', '资金数额'),
337
+ paid_capital: text('实缴资本'),
338
+ establish_date: text('成立日期'),
339
+ company_type: text('企业类型', '公司类型'),
340
+ registration_authority: text('登记机关'),
341
+ english_name: text('英文名', '英文名称'),
342
+ registered_address: text('注册地址', '住所', '经营场所'),
343
+ province: text('省份地区', '省份', '所属省份') || location.province,
344
+ city: text('城市', '所属城市', '市') || location.city,
345
+ district: text('区县', '所属区县') || location.district,
346
+ business_scope: text('经营范围'),
347
+ industry_category: text('国标行业'),
348
+ industry_large: text('一级行业', '企查查一级行业'),
349
+ industry_middle: text('二级行业', '企查查二级行业'),
350
+ operating_period: text('营业期限', '经营期限'),
351
+ company_size: text('企业规模', '人员规模'),
352
+ biz_status: text('经营状态'),
353
+ };
354
+ }
355
+
356
+ function splitAdministrativeRegion(value) {
357
+ const region = String(value ?? '').trim().replace(/\s+/g, '');
358
+ if (!region) return { province: '', city: '', district: '' };
359
+ const municipality = region.match(/^((?:北京|上海|天津|重庆)市)(.*)$/);
360
+ if (municipality) {
361
+ const district = municipality[2].match(/^(.+?(?:区|县))/)?.[1] ?? '';
362
+ return { province: municipality[1], city: municipality[1], district };
363
+ }
364
+ const provinceMatch = region.match(/^(.+?(?:省|自治区|特别行政区))/);
365
+ const province = provinceMatch?.[1] ?? '';
366
+ const remainder = province ? region.slice(province.length) : region;
367
+ const city = remainder.match(/^(.+?(?:市|自治州|地区|盟))/)?.[1] ?? '';
368
+ const districtRemainder = city ? remainder.slice(city.length) : remainder;
369
+ const district = districtRemainder.match(/^(.+?(?:区|县|旗|市))/)?.[1] ?? '';
370
+ return { province, city, district };
371
+ }
372
+
373
+ export function mapProfileFields(value) {
374
+ if (!isRecord(value)) {
375
+ throw new QccBridgeError('QCC_CONTRACT_MISMATCH', 'QCC profile tool returned a non-object result');
376
+ }
377
+ if (value.无匹配项 !== undefined) return {};
294
378
  return {
295
- credit_no: String(value.统一社会信用代码 ?? fallback.creditNo ?? ''),
296
- legal_rep: String(value.法定代表人 ?? value.负责人 ?? value.经营者 ?? ''),
297
- reg_capital: String(
298
- value.注册资本 ?? value.注册资金 ?? value.开办资金 ?? value.成员出资总额 ?? value.资金数额 ?? '',
299
- ),
300
- establish_date: String(value.成立日期 ?? ''),
301
- reg_status: String(value.登记状态 ?? value.执业状态 ?? value.证书状态 ?? ''),
302
- biz_status: String(value.经营状态 ?? ''),
379
+ // “企查查行业”没有声明一/二级语义,不得猜测塞入层级列。
380
+ industry_large: String(value.一级行业 ?? value.企查查一级行业 ?? ''),
381
+ industry_middle: String(value.二级行业 ?? value.企查查二级行业 ?? ''),
382
+ company_profile: String(value.企业简介 ?? value.简介 ?? ''),
303
383
  };
304
384
  }
305
385
 
386
+ const LEGACY_ENRICHMENT_FIELDS = Object.freeze([
387
+ 'credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status', 'biz_status',
388
+ ]);
389
+ const PROFILE_FIELDS = new Set(['industry_large', 'industry_middle', 'company_profile']);
390
+
391
+ function requiresProfile(fieldSelection) {
392
+ return Array.isArray(fieldSelection) && fieldSelection.some((field) => PROFILE_FIELDS.has(field));
393
+ }
394
+
395
+ function mergeMappedFields(...sources) {
396
+ const output = {};
397
+ for (const source of sources) {
398
+ for (const [key, value] of Object.entries(source ?? {})) {
399
+ if (!Object.hasOwn(output, key) || (value !== '' && value !== null && value !== undefined)) output[key] = value;
400
+ }
401
+ }
402
+ return output;
403
+ }
404
+
405
+ function projectSelectedFields(fields, fieldSelection) {
406
+ const selected = Array.isArray(fieldSelection) && fieldSelection.length
407
+ ? [...new Set(fieldSelection.map(String))]
408
+ : LEGACY_ENRICHMENT_FIELDS;
409
+ return Object.fromEntries(selected.map((field) => [field, fields[field] ?? '']));
410
+ }
411
+
306
412
  export function mapRiskTags(value) {
307
413
  if (!isRecord(value)) return '';
308
414
  const rows = Array.isArray(value.风险因子扫描) ? value.风险因子扫描 : [];
@@ -374,6 +480,7 @@ export class QccHostBridge {
374
480
  oauthStatus: this.has(QCC_TOOL_NAMES.oauthStatus),
375
481
  entityLookup: this.has(QCC_TOOL_NAMES.entityLookup),
376
482
  registration: this.has(QCC_TOOL_NAMES.registration),
483
+ profile: this.has(QCC_TOOL_NAMES.profile),
377
484
  riskScan: this.has(QCC_TOOL_NAMES.riskScan),
378
485
  };
379
486
  const ready = capabilities.entityLookup && capabilities.registration;
@@ -507,11 +614,24 @@ export class QccHostBridge {
507
614
  attemptAudited = true;
508
615
  throw error;
509
616
  }
617
+ const execution = options.execution;
618
+ if (execution && (!execution.agent || !execution.token)) {
619
+ throw new QccBridgeError(
620
+ 'QCC_AGENT_EXECUTION_REQUIRED',
621
+ 'Nested QCC calls require an Agent-owned DSH tool execution',
622
+ { toolName: activeToolName },
623
+ );
624
+ }
510
625
  const result = await this.tools.execute({
511
626
  name: activeToolName,
512
627
  callId,
513
628
  signal: state.signal,
514
629
  arguments: args,
630
+ ...(execution ? {
631
+ rootCallId: execution.rootCallId,
632
+ parent: execution.token,
633
+ agent: execution.agent,
634
+ } : {}),
515
635
  });
516
636
  if (result?.isError !== true) {
517
637
  emitAudit(options, {
@@ -607,12 +727,15 @@ export class QccHostBridge {
607
727
  { searchKey: lockedKey },
608
728
  options,
609
729
  );
610
- const fields = mapRegistrationFields(registration.data, match);
730
+ let mapped = mapRegistrationFields(registration.data, match);
731
+ if (requiresProfile(options.fieldSelection)) {
732
+ const profile = await this.call(QCC_TOOL_NAMES.profile, { searchKey: lockedKey }, options);
733
+ mapped = mergeMappedFields(mapped, mapProfileFields(profile.data));
734
+ }
735
+ const fields = projectSelectedFields(mapped, options.fieldSelection);
611
736
  if (options.includeRisk) {
612
737
  const risk = await this.call(QCC_TOOL_NAMES.riskScan, { searchKey: lockedKey }, options);
613
738
  fields.risk_tags = mapRiskTags(risk.data);
614
- } else {
615
- fields.risk_tags = '';
616
739
  }
617
740
  return {
618
741
  status: 'enriched',
@@ -632,12 +755,15 @@ export class QccHostBridge {
632
755
  { searchKey: creditNo },
633
756
  options,
634
757
  );
635
- const fields = mapRegistrationFields(registration.data, { companyName, creditNo });
758
+ let mapped = mapRegistrationFields(registration.data, { companyName, creditNo });
759
+ if (requiresProfile(options.fieldSelection)) {
760
+ const profile = await this.call(QCC_TOOL_NAMES.profile, { searchKey: creditNo }, options);
761
+ mapped = mergeMappedFields(mapped, mapProfileFields(profile.data));
762
+ }
763
+ const fields = projectSelectedFields(mapped, options.fieldSelection);
636
764
  if (options.includeRisk) {
637
765
  const risk = await this.call(QCC_TOOL_NAMES.riskScan, { searchKey: creditNo }, options);
638
766
  fields.risk_tags = mapRiskTags(risk.data);
639
- } else {
640
- fields.risk_tags = '';
641
767
  }
642
768
  return { status: 'enriched', companyName, fields };
643
769
  }
@@ -652,6 +778,7 @@ export class QccHostBridge {
652
778
  }
653
779
 
654
780
  const requiredTools = [QCC_TOOL_NAMES.entityLookup, QCC_TOOL_NAMES.registration];
781
+ if (requiresProfile(options.fieldSelection)) requiredTools.push(QCC_TOOL_NAMES.profile);
655
782
  if (options.includeRisk) requiredTools.push(QCC_TOOL_NAMES.riskScan);
656
783
  try {
657
784
  await Promise.all(requiredTools.map((name) => this.waitForTool(name, {
@@ -6,9 +6,10 @@
6
6
  * - `enterprise-enrichment`:用企查查 MCP 工具按最新工商信息补全企业名单
7
7
  * (依赖 `qcc-dsh-mcp-oauth` 已连接;本 Skill 不重造 OAuth)。
8
8
  *
9
- * 方案 A(模型中介式):模型亲自调用 `mcp__qcc-company__*` / `mcp__qcc-risk__*`
9
+ * 方案 A(自由对话):模型亲自调用 `mcp__qcc-company__*` / `mcp__qcc-risk__*`
10
10
  * / `mcp__qcc-ipr__*` / `mcp__qcc-operation__*` 完成消歧 → 工商详情 → 各域维度,
11
- * 再组装结果。本插件零后端改动(Host Bridge 批量是独立于本 Skill 的方案 B)。
11
+ * 再组装结果。工作台批量路径则只调用 Agent-owned 高层工具,避免 Code Mode
12
+ * 拒绝 Web Host 的无父执行调用。
12
13
  */
13
14
  import {
14
15
  QCC_PHASE2_COMPANY_TOOLS,
@@ -53,6 +54,10 @@ export function registerEnrichSkill(skills) {
53
54
  content: [
54
55
  'You are an enterprise-list enrichment assistant. You fill company lists with Qichacha (QCC) data by calling QCC MCP tools. Never invent, pad, or fabricate any field.',
55
56
  '',
57
+ 'Typed workbench command (highest priority):',
58
+ '- If the visible user message contains a typed data-cleaning intent with `commandId` and explicitly requests `data_cleaning_qcc_run`, call that high-level tool exactly once with only `commandId`, then stop.',
59
+ '- The Host already holds the rows, billing confirmation and field selection. Do not ask the user to paste rows, do not call any `mcp__qcc-*` tool directly, do not retry, and do not expand the batch.',
60
+ '',
56
61
  'Workflow:',
57
62
  '1. Check QCC availability first: run `qcc_oauth_status`. If not connected, tell the user to run `qcc_oauth_connect` first and stop. If the token is expired, guide the user to `qcc_oauth_connect` (it reuses the grant and refreshes without a new authorization page).',
58
63
  '2. Parse the company-name list from what the user gave (pasted text / CSV / JSON / inline list). Keep only the distinct company-name column.',
package/lib/tools.js CHANGED
@@ -100,7 +100,7 @@ export function registerTools(tools) {
100
100
  const r = completeRows(Array.isArray(args.rows) ? args.rows : []);
101
101
  return {
102
102
  total: r.total,
103
- completed: r.completed,
103
+ completed: r.completedCount,
104
104
  incompleteCount: r.incompleteCount,
105
105
  name: r.fillStats.name,
106
106
  amount: r.fillStats.amount,