@huaqiu/dsh-tool-schematic-gen 0.1.1

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/src/tools.ts ADDED
@@ -0,0 +1,590 @@
1
+ /**
2
+ * `@huaqiu/dsh-tool-schematic-gen` — the two agent-visible tools.
3
+ *
4
+ * Faithful TypeScript port of the `hq-edge` plugin's tool bodies, adapted to
5
+ * the published DSH plugin surface:
6
+ * - the eda.cn account always comes from the `huaqiuAuth` service (no demo
7
+ * credentials — migration plan review #9);
8
+ * - generated artifacts are stored in the `huaqiuArtifacts` service
9
+ * (in-process, not a loopback);
10
+ * - tools are `defineTool` with `output.schema = { type: 'json' }` and a
11
+ * structured (lossless-JSON) result.
12
+ *
13
+ * Tools:
14
+ * generate_schematic_from_description description → KiCad schematic
15
+ * generate_system_module_graph description → module graph → KiCad zip
16
+ *
17
+ * @module @huaqiu/dsh-tool-schematic-gen
18
+ */
19
+ import { randomUUID } from 'node:crypto'
20
+ import { writeFile } from 'node:fs/promises'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import { defineTool } from '@deepseek-ai/dsh-tools'
24
+ import type { HuaqiuAuthService } from '@huaqiu/dsh-auth'
25
+ import type { CreateArtifactResult, HuaqiuArtifacts } from '@huaqiu/dsh-artifacts'
26
+ import {
27
+ agentIds,
28
+ buildHeaders,
29
+ buildRunBody,
30
+ sanitizeZipBaseName,
31
+ type EdaAccount,
32
+ type SchematicGenConfig,
33
+ } from './config.js'
34
+ import { consumeCopilotkit, exportModuleGraphZip, HTTP_TIMEOUT_MS } from './sse.js'
35
+ import type { ProgressNote, RunProgress, TodoItem } from './progress.js'
36
+ import type { TraceEvent } from './trace.js'
37
+
38
+ /** Structural alias of the DSH `JsonValue` (see part-search Phase 1 §15.2.1). */
39
+ type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
40
+
41
+ /** The normalized domain values are lossless-JSON plain objects except that
42
+ * optional fields are `undefined`, which fails DSH's lossless-JSON validation. */
43
+ function asJson<T>(value: T): Json {
44
+ return JSON.parse(JSON.stringify(value)) as Json
45
+ }
46
+
47
+ /** Console tag for log filtering. */
48
+ const LOG_TAG = '[dsh-schematic-gen]'
49
+
50
+ /** Agent-facing timeout hints. */
51
+ export const TOOL_TIMEOUT_MS = {
52
+ generate_schematic_from_description: HTTP_TIMEOUT_MS,
53
+ generate_system_module_graph: HTTP_TIMEOUT_MS,
54
+ } as const
55
+
56
+ /** Zip files up to this size are inlined as a base64 data URL on fallback;
57
+ * larger ones are written to a temp file and returned by path. */
58
+ export const MAX_INLINE_ZIP_BYTES = 1_000_000
59
+
60
+ function renderJson(_args: unknown, value: unknown) {
61
+ return [{ type: 'text' as const, text: JSON.stringify(value) }]
62
+ }
63
+
64
+ // ── Runtime environment ──────────────────────────────────────────────────────
65
+
66
+ export interface SchematicGenDeps {
67
+ fetchImpl?: typeof fetch
68
+ writeFileImpl?: (path: string, data: Buffer | string) => Promise<void>
69
+ tmpDirImpl?: () => string
70
+ uuidImpl?: () => string
71
+ }
72
+
73
+ export interface SchematicGenEnv {
74
+ config: SchematicGenConfig
75
+ /** `huaqiuAuth` node service — the eda.cn account capability. */
76
+ auth: HuaqiuAuthService['auth']
77
+ /** `huaqiuArtifacts` node service — preview-artifact store. */
78
+ artifacts: HuaqiuArtifacts
79
+ timeoutMs: number
80
+ /**
81
+ * Optional live-progress sink, keyed by the tool call id. Absent in tests
82
+ * and when the `webServer` service is unavailable — progress reporting is
83
+ * best-effort and must never be able to fail a generation.
84
+ */
85
+ progress?: RunProgress | null
86
+ deps?: SchematicGenDeps
87
+ }
88
+
89
+ /** Resolve the eda.cn account from the auth capability (no baked-in creds). */
90
+ async function resolveAccount(auth: HuaqiuAuthService['auth']): Promise<EdaAccount | null> {
91
+ if (!auth || typeof auth.getUserInfo !== 'function') return null
92
+ try {
93
+ const info = await auth.getUserInfo()
94
+ if (info && typeof info.id === 'string' && typeof info.token === 'string' && info.id.length > 0 && info.token.length > 0) {
95
+ return { userId: info.id, userToken: info.token }
96
+ }
97
+ return null
98
+ } catch (err) {
99
+ console.warn(LOG_TAG, 'could not resolve the eda.cn account', String((err as Error)?.message || err))
100
+ return null
101
+ }
102
+ }
103
+
104
+ /** Fresh run id, injectable for tests. */
105
+ function newRunId(env: SchematicGenEnv): string {
106
+ return typeof env.deps?.uuidImpl === 'function' ? env.deps.uuidImpl() : randomUUID()
107
+ }
108
+
109
+ /**
110
+ * Minimal structural view of DSH's `ToolRunContext`. We only need two fields:
111
+ * `signal` (cancellation) and `callId` — the tool-call identity that the
112
+ * `tool.call.toolview` slot hands to the browser component as `props.callId`.
113
+ * Typed structurally so tests can pass a plain object.
114
+ */
115
+ export interface ToolExecLike {
116
+ signal?: AbortSignal
117
+ callId?: string
118
+ }
119
+
120
+ export interface RunProgressHandle {
121
+ onTrace(events: TraceEvent[]): void
122
+ onState(state: Record<string, unknown>): void
123
+ onTodos(todos: TodoItem[]): void
124
+ onNote(note: ProgressNote): void
125
+ done(): void
126
+ failed(message: string): void
127
+ }
128
+
129
+ /**
130
+ * Bind one tool invocation to the progress store.
131
+ *
132
+ * Every returned callback is a no-op when there is no store or no `callId`,
133
+ * so a tool can call these unconditionally.
134
+ */
135
+ function progressFor(
136
+ env: SchematicGenEnv,
137
+ exec: ToolExecLike | undefined,
138
+ toolName: string,
139
+ kind: 'schematic' | 'system',
140
+ ): RunProgressHandle {
141
+ const store = env.progress
142
+ const callId = typeof exec?.callId === 'string' && exec.callId.length > 0 ? exec.callId : ''
143
+ const live = store && callId ? { store, callId } : null
144
+ if (live) live.store.start(live.callId, toolName, kind)
145
+ return {
146
+ onTrace: (events) => { if (live) live.store.pushTrace(live.callId, events) },
147
+ onState: (state) => { if (live) live.store.updateState(live.callId, state) },
148
+ onTodos: (todos) => { if (live) live.store.setTodos(live.callId, todos) },
149
+ onNote: (note) => { if (live) live.store.setNote(live.callId, note) },
150
+ done: () => { if (live) live.store.finish(live.callId) },
151
+ failed: (message) => { if (live) live.store.fail(live.callId, message) },
152
+ }
153
+ }
154
+
155
+ /** Store a generated artifact in the user-wide preview store (in-process). */
156
+ async function createPreviewArtifact(
157
+ env: SchematicGenEnv,
158
+ type: 'schematic' | 'zip',
159
+ filename: string,
160
+ content: string,
161
+ contentEncoding?: 'utf8' | 'base64',
162
+ ): Promise<CreateArtifactResult> {
163
+ if (!env.artifacts || typeof env.artifacts.create !== 'function') {
164
+ throw new Error('schematic-gen: huaqiuArtifacts service unavailable — cannot store preview artifact')
165
+ }
166
+ return env.artifacts.create({ type, filename, content, contentEncoding })
167
+ }
168
+
169
+ // ── Deliverable extraction ───────────────────────────────────────────────────
170
+
171
+ export interface SchematicSheet {
172
+ filename: string
173
+ content: string
174
+ }
175
+
176
+ export interface ExtractedSchematic {
177
+ outProject: string
178
+ project_achieve_url: string
179
+ kicadPro: string
180
+ schFiles: SchematicSheet[]
181
+ error: string
182
+ }
183
+
184
+ /**
185
+ * Pull the schematic deliverable out of the final agent state. `schFiles`
186
+ * carry the `.kicad_sch` content inline, so the text is returned verbatim.
187
+ */
188
+ export function extractSchematic(state: Record<string, unknown>): ExtractedSchematic {
189
+ const rawFiles = Array.isArray(state.schFiles) ? state.schFiles : []
190
+ const schFiles = rawFiles
191
+ .map((f) => {
192
+ const file = f && typeof f === 'object' ? f as Record<string, unknown> : {}
193
+ return {
194
+ filename: typeof file.filename === 'string' ? file.filename : '',
195
+ content: typeof file.content === 'string'
196
+ ? file.content
197
+ : (typeof file.content === 'object' && file.content !== null
198
+ ? JSON.stringify(file.content)
199
+ : String(file.content ?? '')),
200
+ }
201
+ })
202
+ .filter((f) => f.filename.length > 0)
203
+ return {
204
+ outProject: typeof state.outProject === 'string' ? state.outProject : '',
205
+ project_achieve_url: typeof state.project_achieve_url === 'string' ? state.project_achieve_url : '',
206
+ kicadPro: typeof state.kicadPro === 'string' ? state.kicadPro : '',
207
+ schFiles,
208
+ error: typeof state.error === 'string' ? state.error : '',
209
+ }
210
+ }
211
+
212
+ /** Pull the module graph out of the final system-design state. */
213
+ export function extractModuleGraph(state: Record<string, unknown>): Record<string, unknown> | null {
214
+ const mg = state && state.module_graph
215
+ return mg && typeof mg === 'object' ? (mg as Record<string, unknown>) : null
216
+ }
217
+
218
+ // ── Shared tail: materialize schematic artifacts ─────────────────────────────
219
+
220
+ interface MaterializedSchematic {
221
+ schFiles: Array<{ filename: string; content?: string }>
222
+ schArtifacts?: Array<{ id: string; type: string; filename: string; size: number }>
223
+ /** User-safe status detail — the client card renders this. */
224
+ note?: string
225
+ /** Agent-only explanation/directive — the client card MUST NOT render it. */
226
+ agentNote?: string
227
+ }
228
+
229
+ /**
230
+ * Store each `.kicad_sch` sheet as a preview artifact and return the
231
+ * structured result. Artifact creation is BEST-EFFORT and non-fatal: on any
232
+ * failure the raw content is preserved inline so the result card can still
233
+ * render it.
234
+ */
235
+ async function materializeSchematicArtifacts(env: SchematicGenEnv, schFiles: SchematicSheet[]): Promise<MaterializedSchematic> {
236
+ const outFiles: Array<{ filename: string; content?: string }> = schFiles.map((f) => ({ filename: f.filename }))
237
+ const artifacts: Array<{ id: string; type: string; filename: string; size: number }> = []
238
+ let anyFailed = false
239
+ let errorNote = ''
240
+
241
+ for (let i = 0; i < schFiles.length; i++) {
242
+ const file = schFiles[i]!
243
+ try {
244
+ const created = await createPreviewArtifact(env, 'schematic', file.filename, file.content)
245
+ artifacts.push({ id: created.id, type: created.type, filename: created.filename, size: created.size })
246
+ } catch (storeErr) {
247
+ anyFailed = true
248
+ outFiles[i]!.content = file.content // data-loss guard
249
+ const msg = String((storeErr as Error)?.message || storeErr)
250
+ errorNote += (errorNote ? '; ' : '') + file.filename + ': ' + msg
251
+ }
252
+ }
253
+
254
+ const result: MaterializedSchematic = { schFiles: outFiles }
255
+ if (artifacts.length > 0) result.schArtifacts = artifacts
256
+ if (anyFailed) {
257
+ // Split by audience: the degraded state is worth telling the human, but
258
+ // "the result card can render them directly / the full source is in the
259
+ // zip" is an explanation for the agent about why it need not worry.
260
+ result.note = 'Preview artifact storage partially or fully unavailable (' + errorNote + ').'
261
+ result.agentNote =
262
+ 'Sheets without an artifact id still carry their source inline, so the result card can ' +
263
+ 'render them directly. Full source is always in the project zip/export.'
264
+ }
265
+ return result
266
+ }
267
+
268
+ // ── Tool bodies ──────────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Structured `needs_auth` result returned when the eda.cn login is missing —
272
+ * the signal that makes login a human-in-the-loop step. The web client
273
+ * (dsh-auth client half) renders a login card with an embedded auth.eda.cn
274
+ * iframe for this result; the model asks the user to complete the login and
275
+ * then retries the tool. Throwing here would hide that HIT surface.
276
+ */
277
+ export function needsAuth(kind: 'schematic' | 'system'): Record<string, unknown> {
278
+ return {
279
+ status: 'needs_auth',
280
+ kind,
281
+ hint:
282
+ 'This tool requires a Huaqiu EDA (eda.cn) login. The web client is showing ' +
283
+ 'a login card with an embedded eda.cn login iframe — ask the user to complete ' +
284
+ 'the login there (or use the 华秋EDA login button in the sidebar), then call ' +
285
+ 'this tool again.',
286
+ }
287
+ }
288
+
289
+ /**
290
+ * `generate_schematic_from_description` body — stream `schemagen`, extract the
291
+ * inline `.kicad_sch` files, then store each sheet as a preview artifact.
292
+ */
293
+ export async function runGenerateSchematic(
294
+ args: Record<string, unknown>,
295
+ exec: ToolExecLike | undefined,
296
+ env: SchematicGenEnv,
297
+ ): Promise<Record<string, unknown>> {
298
+ const account = await resolveAccount(env.auth)
299
+ if (!account) return needsAuth('schematic')
300
+ const threadId = newRunId(env)
301
+ const body = buildRunBody(
302
+ agentIds.SCHEMATIC,
303
+ typeof args.description === 'string' ? args.description : '',
304
+ env.config,
305
+ account,
306
+ typeof args.user_language === 'string' ? args.user_language : undefined,
307
+ threadId,
308
+ )
309
+ const prog = progressFor(env, exec, 'generate_schematic_from_description', 'schematic')
310
+ let state: Record<string, unknown>
311
+ let text: string
312
+ try {
313
+ const res = await consumeCopilotkit(env.config.copilotkitUrl, body, buildHeaders(env.config, account, threadId), {
314
+ signal: exec?.signal,
315
+ timeoutMs: env.timeoutMs,
316
+ fetchImpl: env.deps?.fetchImpl,
317
+ onTrace: prog.onTrace,
318
+ onState: prog.onState,
319
+ onUnauthorized: () => { void env.auth.logout() },
320
+ })
321
+ state = res.state
322
+ text = res.text
323
+ } catch (err) {
324
+ prog.failed(String((err as Error)?.message || err))
325
+ throw err
326
+ }
327
+ const extracted = extractSchematic(state)
328
+ if (extracted.error) {
329
+ const message = 'schematic-gen: the schematic agent finished with an error: ' + extracted.error +
330
+ (text ? ' — ' + text.slice(0, 300) : '')
331
+ prog.failed(message)
332
+ throw new Error(message)
333
+ }
334
+ if (extracted.schFiles.length === 0) {
335
+ const message = 'schematic-gen: the schematic agent produced no .kicad_sch files.' +
336
+ (text ? ' Assistant said: ' + text.slice(0, 300) : '')
337
+ prog.failed(message)
338
+ throw new Error(message)
339
+ }
340
+ const materialized = await materializeSchematicArtifacts(env, extracted.schFiles)
341
+ const result: Record<string, unknown> = {
342
+ status: 'generated',
343
+ kind: 'schematic',
344
+ design_name: extracted.outProject || '',
345
+ schFiles: materialized.schFiles,
346
+ schArtifacts: materialized.schArtifacts,
347
+ kicadPro: extracted.kicadPro,
348
+ project_achieve_url: extracted.project_achieve_url,
349
+ }
350
+ if (materialized.note) result.note = materialized.note
351
+ if (materialized.agentNote) result.agentNote = materialized.agentNote
352
+ prog.done()
353
+ return result
354
+ }
355
+
356
+ /**
357
+ * `generate_system_module_graph` body — stream `modular_circuit`, extract the
358
+ * module graph, POST it to export-zip, return the KiCad project zip stored as
359
+ * a `zip` preview artifact.
360
+ */
361
+ export async function runGenerateSystem(
362
+ args: Record<string, unknown>,
363
+ exec: ToolExecLike | undefined,
364
+ env: SchematicGenEnv,
365
+ ): Promise<Record<string, unknown>> {
366
+ const account = await resolveAccount(env.auth)
367
+ if (!account) return needsAuth('system')
368
+ const threadId = newRunId(env)
369
+ const body = buildRunBody(
370
+ agentIds.SYSTEM,
371
+ typeof args.description === 'string' ? args.description : '',
372
+ env.config,
373
+ account,
374
+ typeof args.user_language === 'string' ? args.user_language : undefined,
375
+ threadId,
376
+ )
377
+ const prog = progressFor(env, exec, 'generate_system_module_graph', 'system')
378
+ let state: Record<string, unknown>
379
+ let text: string
380
+ try {
381
+ const res = await consumeCopilotkit(env.config.copilotkitUrl, body, buildHeaders(env.config, account, threadId), {
382
+ signal: exec?.signal,
383
+ timeoutMs: env.timeoutMs,
384
+ fetchImpl: env.deps?.fetchImpl,
385
+ onTrace: prog.onTrace,
386
+ onState: prog.onState,
387
+ onTodos: prog.onTodos,
388
+ onNote: prog.onNote,
389
+ // `modular_circuit` does NOT emit CUSTOM trace events — its stack
390
+ // arrives as the standard AG-UI tool-call lifecycle. Without this the
391
+ // system-design card reported no progress at all.
392
+ toolCallTrace: true,
393
+ onUnauthorized: () => { void env.auth.logout() },
394
+ })
395
+ state = res.state
396
+ text = res.text
397
+ } catch (err) {
398
+ prog.failed(String((err as Error)?.message || err))
399
+ throw err
400
+ }
401
+ const moduleGraph = extractModuleGraph(state)
402
+ if (!moduleGraph) {
403
+ const errState = typeof state.error === 'string' && state.error ? state.error : ''
404
+ const message = 'schematic-gen: the system design agent produced no module_graph.' +
405
+ (errState ? ' Error: ' + errState : '') +
406
+ (text ? ' Assistant said: ' + text.slice(0, 300) : '')
407
+ prog.failed(message)
408
+ throw new Error(message)
409
+ }
410
+
411
+ // The stage ladder already reports "export" (module_graph is filled) while
412
+ // this POST runs, so no extra phase marker is needed here.
413
+ const zipBuf = await exportModuleGraphZip(env.config.exportZipUrl, moduleGraph, env.config, account, {
414
+ signal: exec?.signal,
415
+ timeoutMs: env.timeoutMs,
416
+ fetchImpl: env.deps?.fetchImpl,
417
+ }).catch((err: unknown) => {
418
+ prog.failed(String((err as Error)?.message || err))
419
+ throw err
420
+ })
421
+
422
+ const designName = typeof state.design_name === 'string' && state.design_name
423
+ ? state.design_name
424
+ : 'circuit'
425
+ const connectionCount = typeof state.connection_count === 'number'
426
+ ? state.connection_count
427
+ : (Array.isArray(moduleGraph.connections) ? moduleGraph.connections.length : 0)
428
+ const moduleNames = Array.isArray(moduleGraph.modules)
429
+ ? (moduleGraph.modules as Array<Record<string, unknown>>)
430
+ .map((m) => (m && typeof m.name === 'string' ? m.name : ''))
431
+ .filter((n) => n.length > 0)
432
+ : []
433
+
434
+ const result: Record<string, unknown> = {
435
+ status: 'generated',
436
+ kind: 'system',
437
+ design_name: designName,
438
+ module_count: moduleNames.length,
439
+ connection_count: connectionCount,
440
+ module_names: moduleNames,
441
+ zip_bytes: zipBuf.length,
442
+ }
443
+ const notes: string[] = []
444
+
445
+ // Store the project zip as a `zip` preview artifact (primary). Keeping the
446
+ // zip OUT of the JSON keeps the result small — inlining base64 used to
447
+ // truncate the tool result and fail the card.
448
+ let zipArtifact: { id: string; type: string; filename: string; size: number } | null = null
449
+ try {
450
+ const safeName = sanitizeZipBaseName(designName)
451
+ const created = await createPreviewArtifact(env, 'zip', safeName + '.zip', zipBuf.toString('base64'), 'base64')
452
+ zipArtifact = { id: created.id, type: created.type, filename: created.filename, size: created.size }
453
+ } catch (storeErr) {
454
+ notes.push('Could not store the project zip as an artifact (' +
455
+ String((storeErr as Error)?.message || storeErr) + '); kept it in the result instead.')
456
+ }
457
+
458
+ if (zipArtifact) {
459
+ result.zipArtifact = zipArtifact
460
+ } else {
461
+ // Fallback (artifact store unavailable): inline the zip when small,
462
+ // otherwise write to a temp file and return the path.
463
+ if (zipBuf.length <= MAX_INLINE_ZIP_BYTES) {
464
+ result.zip = 'data:application/zip;base64,' + zipBuf.toString('base64')
465
+ } else {
466
+ const safeName = sanitizeZipBaseName(designName)
467
+ const fileName = 'hq-eda-' + safeName + '-' + newRunId(env).slice(0, 8) + '.zip'
468
+ const dir = (env.deps?.tmpDirImpl && env.deps.tmpDirImpl()) || tmpdir()
469
+ const filePath = join(dir, fileName)
470
+ try {
471
+ await (env.deps?.writeFileImpl || writeFile)(filePath, zipBuf)
472
+ result.zip_path = filePath
473
+ } catch (err) {
474
+ result.zip = 'data:application/zip;base64,' + zipBuf.toString('base64')
475
+ notes.push('Could not write the zip to a temp file (' +
476
+ String((err as Error)?.message || err) + '); returned inline instead.')
477
+ }
478
+ }
479
+ }
480
+ if (notes.length > 0) result.note = notes.join(' ')
481
+ prog.done()
482
+ return result
483
+ }
484
+
485
+ /** Agent-awareness note about the eda.cn login gate, appended to both tool
486
+ * descriptions: a `needs_auth` result surfaces a login HIT (embedded eda.cn
487
+ * iframe card) — wait for the user to log in, then retry. Never invent
488
+ * credentials or fake success. */
489
+ const AUTH_GATE_NOTE =
490
+ 'AUTH: This tool requires a Huaqiu EDA (eda.cn) account. If the result has ' +
491
+ 'status "needs_auth", the web client is showing a login card with an embedded ' +
492
+ 'eda.cn login iframe (the human-in-the-loop step). Ask the user to complete the ' +
493
+ 'login there or via the 华秋EDA login button in the sidebar (you may use ' +
494
+ 'ask_user_question to wait, offering a "retry now that I have logged in" ' +
495
+ 'option and a "cancel" option — phrase BOTH in the language the user is ' +
496
+ 'writing in), then call this tool again. Never invent credentials and never ' +
497
+ 'claim success when the result is needs_auth.'
498
+
499
+ // ── Tool definitions ─────────────────────────────────────────────────────────
500
+
501
+ function createSchematicTool(env: SchematicGenEnv) {
502
+ return defineTool({
503
+ name: 'generate_schematic_from_description',
504
+ description:
505
+ 'Generate a KiCad schematic (.kicad_sch files) from a natural-language ' +
506
+ 'description of a circuit or sub-circuit — e.g. "design a 5V LM7805 linear ' +
507
+ 'regulator power supply with input and output filter capacitors". Calls the ' +
508
+ 'online HQ-EDA schematic generation agent and returns ' +
509
+ 'schFiles (filename references), schArtifacts (preview artifact references ' +
510
+ 'with id/type/filename/size per sheet), kicadPro and project_achieve_url. ' +
511
+ 'Use this when the user asks to draw, generate or create a circuit ' +
512
+ 'schematic from a description (not from an image — for that use the ' +
513
+ 'symbol/footprint tools). ' +
514
+ 'IMPORTANT: The generated schematic renders automatically as a result card ' +
515
+ 'in the web client — an interactive canvas preview per sheet (multi-sheet ' +
516
+ 'results get a sheet tab bar) and a download button for the current sheet. ' +
517
+ 'Do NOT paste the schematic source, file URLs, or any fenced code block ' +
518
+ 'into your reply; just note in one line that the schematic was generated ' +
519
+ 'and how many sheets it has. ' + AUTH_GATE_NOTE,
520
+ parameters: {
521
+ description: {
522
+ type: 'string',
523
+ required: true,
524
+ description: 'The circuit design prompt, in natural language. Be specific about components, voltages and any required behaviour.',
525
+ },
526
+ user_language: {
527
+ type: 'string',
528
+ // The fallback is deliberately NOT spelled out here: it is deployment
529
+ // config (`HQ_EDA_DEFAULT_LANGUAGE`), so quoting a literal default in
530
+ // agent-facing copy would go stale the moment it is overridden.
531
+ description: 'Optional language hint for the agent — pass the language the user is writing in (e.g. "简体中文" or "English"). Omit to use the deployment default.',
532
+ },
533
+ },
534
+ output: { schema: { type: 'json' }, render: renderJson },
535
+ async execute(args, exec) {
536
+ return asJson(await runGenerateSchematic(args, exec, env))
537
+ },
538
+ timeoutMs: TOOL_TIMEOUT_MS.generate_schematic_from_description,
539
+ })
540
+ }
541
+
542
+ function createSystemTool(env: SchematicGenEnv) {
543
+ return defineTool({
544
+ name: 'generate_system_module_graph',
545
+ description:
546
+ 'Generate a hardware system design (module graph) from a natural-language ' +
547
+ 'description — e.g. "design a small smart alarm clock". Calls the online ' +
548
+ 'HQ-EDA system-design agent, which plans the modules, searches/selects ' +
549
+ 'parts, wires the connections, and produces a module graph; the graph is ' +
550
+ 'then exported to a KiCad project zip. Returns: a zipArtifact reference ' +
551
+ '(preview-artifact id of the full project zip — the zip is never inlined ' +
552
+ 'into the conversation) and a summary (design name, module count, ' +
553
+ 'connection count, module names). Use this when the user wants a whole ' +
554
+ 'system/module-level design, not a single schematic or symbol. ' +
555
+ 'IMPORTANT: The generated system design renders automatically as a result ' +
556
+ 'card in the web client — a canvas preview of the project root schematic ' +
557
+ '(fetched from the zip artifact) and a Download button for the full ' +
558
+ 'project zip. Do NOT paste the schematic source, file URLs, or any fenced ' +
559
+ 'code block into your reply; just note in one line that the design was ' +
560
+ 'generated, its module count, and that the project zip is downloadable ' +
561
+ 'from the card. ' + AUTH_GATE_NOTE,
562
+ parameters: {
563
+ description: {
564
+ type: 'string',
565
+ required: true,
566
+ description: 'The system design prompt, in natural language. Describe the product or function you want, e.g. "an ESP32-C3 based smart fan".',
567
+ },
568
+ user_language: {
569
+ type: 'string',
570
+ // The fallback is deliberately NOT spelled out here: it is deployment
571
+ // config (`HQ_EDA_DEFAULT_LANGUAGE`), so quoting a literal default in
572
+ // agent-facing copy would go stale the moment it is overridden.
573
+ description: 'Optional language hint for the agent — pass the language the user is writing in (e.g. "简体中文" or "English"). Omit to use the deployment default.',
574
+ },
575
+ },
576
+ output: { schema: { type: 'json' }, render: renderJson },
577
+ async execute(args, exec) {
578
+ return asJson(await runGenerateSystem(args, exec, env))
579
+ },
580
+ timeoutMs: TOOL_TIMEOUT_MS.generate_system_module_graph,
581
+ })
582
+ }
583
+
584
+ /** Build the two tool definitions against a runtime env. */
585
+ export function createSchematicGenTools(env: SchematicGenEnv) {
586
+ return [
587
+ createSchematicTool(env),
588
+ createSystemTool(env),
589
+ ]
590
+ }