@goodandready/dsh-agent-orchestrator 0.1.6

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/index.js ADDED
@@ -0,0 +1,950 @@
1
+ /**
2
+ * DeepSeek Harness Multi-Agent Orchestrator Plugin (Host Half)
3
+ *
4
+ * Scoped Name: @goodandready/dsh-agent-orchestrator
5
+ * Short ID: dsh-agent-orchestrator
6
+ */
7
+
8
+ import { randomUUID } from 'crypto'
9
+ // Safe peerDependency resolution with standalone fallback
10
+ function createSchemaStub(target = {}) {
11
+ return new Proxy(target, {
12
+ get(t, prop) {
13
+ if (prop === 'shape') return t.shape || {}
14
+ return (...args) => createSchemaStub({ ...t, [prop]: args[0] })
15
+ }
16
+ })
17
+ }
18
+
19
+ let z
20
+ try {
21
+ const mod = await import('@deepseek-ai/schemastery')
22
+ z = mod.default || mod
23
+ } catch {
24
+ z = {
25
+ object: (shape) => createSchemaStub({ shape }),
26
+ boolean: () => createSchemaStub({ type: 'boolean' }),
27
+ string: () => createSchemaStub({ type: 'string' }),
28
+ number: () => createSchemaStub({ type: 'number' }),
29
+ union: () => createSchemaStub({ type: 'union' }),
30
+ array: () => createSchemaStub({ type: 'array' }),
31
+ }
32
+ }
33
+
34
+ let defineTool
35
+ try {
36
+ const mod = await import('@deepseek-ai/dsh-tools')
37
+ defineTool = mod.defineTool || ((def) => def)
38
+ } catch {
39
+ defineTool = (def) => def
40
+ }
41
+ import { getDefaultRoles, getDefaultScenarios } from './pipeline/scenarios.js'
42
+ import { decomposeTask } from './pipeline/decomposer.js'
43
+ import { executeDAG } from './pipeline/dag-engine.js'
44
+ import { executeStageWorker } from './pipeline/worker-pool.js'
45
+ import { executeDirectDelegation, toRolesArray, computeDelegationDepth } from './pipeline/delegation.js'
46
+ import { SerializationGate } from './pipeline/concurrency-gate.js'
47
+ import { SessionLifecycleManager } from './pipeline/session-lifecycle.js'
48
+ import { detectOrchestratorIntent } from './pipeline/intent.js'
49
+ import { readModelSelection, fetchModelCatalog } from './pipeline/model-selection.js'
50
+ import { applyGuidance } from './pipeline/guidance.js'
51
+ import { OrchestratorStore } from './store.js'
52
+ import { KanbanBridge } from './integrations/kanban-bridge.js'
53
+ import { registerOrchestratorRoutes } from './routes.js'
54
+ import { SnapshotManager } from './pipeline/snapshots.js'
55
+ import { syncDefaultPresets } from './pipeline/preset-sync.js'
56
+
57
+ export const name = '@goodandready/dsh-agent-orchestrator'
58
+ export const inject = ['settings', 'webServer', 'llm', 'tools', 'commands', 'systemPrompt', 'subagents']
59
+
60
+ export const Config = z.object({
61
+ enabled: z.boolean().default(true).description('Enable Multi-Agent Orchestrator pipeline dispatcher'),
62
+ defaultScenario: z.string().default('auto').description('Default complexity scenario (auto, hotfix, simple, medium, complex, enterprise)'),
63
+ disableNestedDelegation: z.boolean().default(false).description('Anti-Matryoshka Guard: Disallow subagents from spawning nested subagents (Issue #19)'),
64
+ maxConcurrentSubagents: z.number().default(3).description('Maximum concurrent subagents per parent session (Issue #93)'),
65
+ subagentGracePeriodMs: z.number().default(180000).description('Grace period before archiving completed one-shot subagents (Issue #56)'),
66
+ smartModelRouting: z.boolean().default(false).description('Smart Model Routing: dynamically select model based on task complexity (Issue #48)'),
67
+ allowedProviders: z.array(z.string()).default([]).description('Whitelist of allowed LLM provider IDs (Issue #48)'),
68
+ failFastModelValidation: z.boolean().default(false).description('Fail-fast when requested model is not found in provider registry (Issue #82)'),
69
+ maxStoredSessions: z.number().default(400).description('Capacity recycling threshold for subagent sessions (Issue #67)'),
70
+ sessionRetentionMs: z.number().default(86400000).description('Retention duration in ms for sessions-archive before physical deletion (Issue #57)'),
71
+ })
72
+
73
+ export function apply(ctx, config = {}) {
74
+ const NS = 'dsh-agent-orchestrator'
75
+ const logger = ctx?.logger || {
76
+ debug: () => {},
77
+ info: () => {},
78
+ warn: () => {},
79
+ error: () => {},
80
+ }
81
+ let currentSettings = {
82
+ ...config,
83
+ disableNestedDelegation: Boolean(config.disableNestedDelegation),
84
+ smartModelRouting: Boolean(config.smartModelRouting),
85
+ allowedProviders: Array.isArray(config.allowedProviders) ? config.allowedProviders : [],
86
+ failFastModelValidation: Boolean(config.failFastModelValidation),
87
+ maxStoredSessions: config.maxStoredSessions || 400,
88
+ sessionRetentionMs: config.sessionRetentionMs || 86400000,
89
+ roles: getDefaultRoles(),
90
+ scenarios: getDefaultScenarios(),
91
+ kanbanSync: {
92
+ enabled: false,
93
+ targetColumn: 'review',
94
+ createChecklist: true,
95
+ ...(config.kanbanSync || {}),
96
+ },
97
+ }
98
+
99
+ let settingsScope = null
100
+
101
+ // Register settings scope
102
+ ctx.inject(['settings'], (sctx) => {
103
+ try {
104
+ const scope = sctx.settings.register(NS, Config, { base: config })
105
+ if (scope) {
106
+ settingsScope = scope
107
+ const saved = scope.get()
108
+ if (saved) {
109
+ currentSettings = {
110
+ ...currentSettings,
111
+ ...saved,
112
+ disableNestedDelegation: saved.disableNestedDelegation ?? currentSettings.disableNestedDelegation,
113
+ roles: saved.roles || getDefaultRoles(),
114
+ scenarios: saved.scenarios || getDefaultScenarios(),
115
+ kanbanSync: { ...currentSettings.kanbanSync, ...(saved.kanbanSync || {}) },
116
+ }
117
+ }
118
+ }
119
+ } catch (e) {
120
+ logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
121
+ }
122
+ })
123
+
124
+ const getConfig = () => currentSettings
125
+ const updateConfig = async (partial) => {
126
+ currentSettings = { ...currentSettings, ...partial }
127
+ if (settingsScope) {
128
+ try {
129
+ for (const [k, v] of Object.entries(partial)) {
130
+ if (k === 'enabled' || k === 'defaultScenario' || k === 'disableNestedDelegation') {
131
+ await settingsScope.set(k, v)
132
+ }
133
+ }
134
+ } catch (err) {
135
+ logger.debug('[dsh-agent-orchestrator] Settings update warning:', err?.message || err)
136
+ }
137
+ }
138
+ }
139
+
140
+ // Self-Syncing Presets & Roles (Issue #107)
141
+ const syncResult = syncDefaultPresets({
142
+ currentRoles: currentSettings.roles,
143
+ currentScenarios: currentSettings.scenarios,
144
+ logger,
145
+ })
146
+ currentSettings.roles = syncResult.roles
147
+ currentSettings.scenarios = syncResult.scenarios
148
+
149
+ const snapshotManager = new SnapshotManager({ logger })
150
+ const store = new OrchestratorStore({ logger })
151
+ const kanbanBridge = new KanbanBridge({
152
+ port: ctx.webServer?.port || 3080,
153
+ fetchImpl: globalThis.fetch,
154
+ })
155
+
156
+ const serializationGate = new SerializationGate(currentSettings.maxConcurrentSubagents || 3)
157
+ const sessionLifecycle = new SessionLifecycleManager({
158
+ gracePeriodMs: currentSettings.subagentGracePeriodMs,
159
+ maxCapacity: currentSettings.maxStoredSessions || 400,
160
+ retentionMs: currentSettings.sessionRetentionMs || 86400000,
161
+ subagentsService: ctx.subagents,
162
+ sessionsService: (typeof ctx.get === 'function' ? ctx.get('sessions', false) : null) || null,
163
+ })
164
+
165
+ // Track active execution controllers for clean lifecycle teardown
166
+ const activeControllers = new Set()
167
+
168
+ ctx.effect(() => () => {
169
+ // Dispose / cleanup all active child runs
170
+ for (const ctrl of activeControllers) {
171
+ try {
172
+ ctrl.abort(new Error('Plugin unmounted or restarted'))
173
+ } catch (err) {
174
+ logger.debug('[dsh-agent-orchestrator] Controller abort error:', err?.message || err)
175
+ }
176
+ }
177
+ activeControllers.clear()
178
+ sessionLifecycle.dispose()
179
+ }, 'dsh-agent-orchestrator: lifecycle resource cleanup')
180
+
181
+ /**
182
+ * Unified LLM caller wrapping ctx.llm.prepareCall().stream()
183
+ */
184
+ const callLlm = async ({ provider, model, messages, temperature = 0.3, maxTokens = 4096, reasoningEffort, onStreamDelta }) => {
185
+ if (!ctx.llm) {
186
+ throw new Error('ctx.llm is not available in cordis context')
187
+ }
188
+
189
+ // Resolve provider aliases (e.g. 'deepseek' -> 'deepseek-official')
190
+ let resolvedProvider = provider || 'deepseek-official'
191
+ try {
192
+ const providers = typeof ctx.llm.listProviders === 'function' ? ctx.llm.listProviders() : []
193
+ if (providers.length > 0) {
194
+ if (provider === 'deepseek' && providers.some((p) => p.id === 'deepseek-official')) {
195
+ resolvedProvider = 'deepseek-official'
196
+ } else if (!providers.some((p) => p.id === provider)) {
197
+ const ds = providers.find((p) => p.id === 'deepseek-official' || p.id === 'deepseek')
198
+ if (ds) resolvedProvider = ds.id
199
+ }
200
+ }
201
+ } catch (_) {
202
+ /* safe ignore */
203
+ }
204
+
205
+ const callConfig = {
206
+ provider: resolvedProvider,
207
+ model,
208
+ temperature,
209
+ maxTokens,
210
+ ...(reasoningEffort && reasoningEffort !== 'off' ? { reasoningEffort, reasoning_effort: reasoningEffort } : {}),
211
+ }
212
+
213
+ let prep
214
+ try {
215
+ prep = await ctx.llm.prepareCall(callConfig)
216
+ } catch (err) {
217
+ throw new Error(`Failed to prepareCall for ${resolvedProvider}:${model} (${err?.message || err})`)
218
+ }
219
+
220
+ if (!prep || typeof prep.stream !== 'function') {
221
+ throw new Error(`LLM provider/model ${resolvedProvider}:${model} does not support streaming`)
222
+ }
223
+
224
+ const abortCtrl = new AbortController()
225
+ activeControllers.add(abortCtrl)
226
+ const timeoutId = setTimeout(() => abortCtrl.abort(new Error('LLM call timeout (120s)')), 120000)
227
+ timeoutId.unref?.()
228
+
229
+ const normalizedMessages = (messages || []).map((m) => {
230
+ if (typeof m.content === 'string') {
231
+ return {
232
+ id: m.id || randomUUID(),
233
+ role: m.role,
234
+ content: [{ type: 'text', text: m.content }],
235
+ }
236
+ }
237
+ return m
238
+ })
239
+
240
+ try {
241
+ const stream = prep.stream({
242
+ ...prep.config,
243
+ messages: normalizedMessages,
244
+ signal: abortCtrl.signal,
245
+ })
246
+
247
+ let fullText = ''
248
+ let usageInfo = null
249
+
250
+ for await (const chunk of stream) {
251
+ if (chunk.type === 'finish' && chunk.reason?.kind === 'error') {
252
+ const failMsg =
253
+ chunk.reason.failure?.message ||
254
+ chunk.reason.failure?.detail ||
255
+ JSON.stringify(chunk.reason.failure || chunk.reason)
256
+ throw new Error(`LLM stream finished with error: ${failMsg}`)
257
+ }
258
+
259
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
260
+ fullText += chunk.text
261
+ if (typeof onStreamDelta === 'function') {
262
+ onStreamDelta(chunk.text)
263
+ }
264
+ } else if (chunk.type === 'block-end' && chunk.block?.text) {
265
+ if (!fullText) fullText = chunk.block.text
266
+ } else if (typeof chunk.delta?.text === 'string') {
267
+ fullText += chunk.delta.text
268
+ if (typeof onStreamDelta === 'function') {
269
+ onStreamDelta(chunk.delta.text)
270
+ }
271
+ } else if (typeof chunk.text === 'string' && !chunk.type) {
272
+ fullText += chunk.text
273
+ } else if (chunk.type === 'usage' && chunk.usage) {
274
+ usageInfo = chunk.usage
275
+ }
276
+ }
277
+
278
+ clearTimeout(timeoutId)
279
+ activeControllers.delete(abortCtrl)
280
+ return {
281
+ text: fullText,
282
+ content: fullText,
283
+ usage: usageInfo || {
284
+ prompt_tokens: Math.round(JSON.stringify(messages).length / 4),
285
+ completion_tokens: Math.round(fullText.length / 4),
286
+ },
287
+ }
288
+ } catch (err) {
289
+ clearTimeout(timeoutId)
290
+ activeControllers.delete(abortCtrl)
291
+ throw err
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Runner object to start, execute, and monitor pipelines
297
+ */
298
+ const runner = {
299
+ async startPipeline({ taskTitle, taskDescription, scenarioId, taskId }) {
300
+ const cfg = getConfig()
301
+ const plan = decomposeTask({
302
+ taskTitle,
303
+ taskDescription,
304
+ scenarioId: scenarioId || cfg.defaultScenario || 'auto',
305
+ customScenarios: cfg.scenarios,
306
+ customRoles: cfg.roles,
307
+ })
308
+
309
+ const pipelineData = {
310
+ ...plan,
311
+ taskId: taskId || null,
312
+ status: 'running',
313
+ startedAt: Date.now(),
314
+ completedAt: null,
315
+ durationMs: 0,
316
+ stages: plan.stages.map((s) => ({ ...s, status: 'pending' })),
317
+ stateMap: {},
318
+ artifacts: {},
319
+ }
320
+
321
+ store.recordPipeline(pipelineData)
322
+
323
+ // Execute DAG asynchronously
324
+ const executor = async (stage, context) => {
325
+ store.updateStage(plan.pipelineId, stage.id, {
326
+ status: 'running',
327
+ startedAt: Date.now(),
328
+ })
329
+
330
+ const rolesList = toRolesArray(cfg.roles)
331
+ const role = rolesList.find((r) => r.id === stage.roleId) || {
332
+ id: stage.roleId,
333
+ displayName: stage.roleName,
334
+ defaultModel: { provider: 'deepseek-official', model: 'deepseek-chat' },
335
+ systemPrompt: 'You are a specialized autonomous engineer. Produce clear, production-ready deliverables.',
336
+ }
337
+
338
+ const modelSelection = readModelSelection(ctx)
339
+
340
+ try {
341
+ const result = await executeStageWorker(stage, {
342
+ pipeline: pipelineData,
343
+ agentRole: role,
344
+ config: cfg,
345
+ callLlm,
346
+ upstreamOutputs: context.upstreamOutputs,
347
+ logger,
348
+ allowedRoutes: modelSelection.allowedRoutes,
349
+ onStreamDelta: (delta) => {
350
+ store.appendStageLog(plan.pipelineId, stage.id, delta)
351
+ },
352
+ })
353
+
354
+ store.updateStage(plan.pipelineId, stage.id, {
355
+ status: 'completed',
356
+ completedAt: Date.now(),
357
+ output: result.output,
358
+ metrics: result.metrics,
359
+ })
360
+
361
+ return result.output
362
+ } catch (stageErr) {
363
+ store.updateStage(plan.pipelineId, stage.id, {
364
+ status: 'failed',
365
+ completedAt: Date.now(),
366
+ error: stageErr?.message || String(stageErr),
367
+ })
368
+ throw stageErr
369
+ }
370
+ }
371
+
372
+ executeDAG({
373
+ stages: plan.stages,
374
+ executor,
375
+ concurrency: 3,
376
+ })
377
+ .then(async (dagResult) => {
378
+ store.updatePipeline(plan.pipelineId, {
379
+ status: 'completed',
380
+ completedAt: Date.now(),
381
+ durationMs: Date.now() - pipelineData.startedAt,
382
+ artifacts: dagResult.artifacts,
383
+ })
384
+
385
+ // Sync to Kanban if enabled
386
+ const syncConfig = cfg.kanbanSync
387
+ if (syncConfig?.enabled) {
388
+ try {
389
+ let targetTaskId = taskId
390
+ if (!targetTaskId) {
391
+ const createdTask = await kanbanBridge.createTask({
392
+ title: `[Orchestrated] ${plan.taskTitle}`,
393
+ description: `Pipeline ID: \`${plan.pipelineId}\`\nScenario: \`${plan.scenarioTitle}\`\nObjective: ${plan.taskDescription}`,
394
+ })
395
+ targetTaskId = createdTask?.id
396
+ }
397
+
398
+ if (targetTaskId) {
399
+ if (syncConfig.createChecklist) {
400
+ const checklistItems = plan.stages.map((s) => ({
401
+ title: `${s.name} (${s.roleName})`,
402
+ completed: dagResult.statusMap[s.id] === 'completed',
403
+ }))
404
+ await kanbanBridge.syncStageChecklist(targetTaskId, checklistItems)
405
+ }
406
+
407
+ if (syncConfig.targetColumn) {
408
+ await kanbanBridge.moveTask(targetTaskId, syncConfig.targetColumn)
409
+ }
410
+ }
411
+ } catch (kErr) {
412
+ logger.warn('[dsh-agent-orchestrator] Kanban sync error:', kErr?.message || kErr)
413
+ }
414
+ }
415
+
416
+ // Record Dispatch Snapshot (Issue #109)
417
+ try {
418
+ snapshotManager.createSnapshot({
419
+ id: plan.pipelineId,
420
+ title: plan.taskTitle,
421
+ scenarioId: plan.scenarioId,
422
+ status: dagResult.allSucceeded ? 'completed' : 'failed',
423
+ stages: plan.stages,
424
+ durationMs: Date.now() - pipelineData.startedAt,
425
+ })
426
+ } catch (snapErr) {
427
+ logger.debug('[dsh-agent-orchestrator] Snapshot record skipped:', snapErr?.message || snapErr)
428
+ }
429
+ })
430
+ .catch((dagErr) => {
431
+ store.updatePipeline(plan.pipelineId, {
432
+ status: 'failed',
433
+ completedAt: Date.now(),
434
+ durationMs: Date.now() - pipelineData.startedAt,
435
+ error: dagErr?.message || String(dagErr),
436
+ })
437
+ })
438
+
439
+ return pipelineData
440
+ },
441
+ }
442
+
443
+ // Register Web REST API Routes
444
+ registerOrchestratorRoutes(ctx, {
445
+ store,
446
+ runner,
447
+ getConfig,
448
+ updateConfig,
449
+ callLlm,
450
+ snapshotManager,
451
+ })
452
+
453
+ // Inject systemPrompt specialist roster (so the main chat agent knows about all 12 roles)
454
+ applyGuidance(ctx, () => getConfig().roles, 'orchestrator_delegate_specialist', { logger })
455
+
456
+ // Register model-facing tools via tools service
457
+ const registerToolsOnContext = (targetCtx) => {
458
+ if (!targetCtx.tools || typeof targetCtx.tools.register !== 'function') return
459
+ const render = (_a, v) => [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v, null, 2) }]
460
+
461
+ targetCtx.effect(() => {
462
+ const disposers = []
463
+
464
+ // 1. Full Multi-Agent Pipeline Dispatcher
465
+ try {
466
+ disposers.push(
467
+ targetCtx.tools.register(
468
+ defineTool({
469
+ name: 'orchestrator_dispatch',
470
+ description:
471
+ 'Dispatch a multi-agent orchestrated pipeline to decompose and execute a complex task across specialized roles (Architecture, Spec, UI Design, Code, QA, Docs).',
472
+ parameters: {
473
+ taskTitle: { type: 'string', required: true, description: 'Concise title of the objective' },
474
+ taskDescription: { type: 'string', required: true, description: 'Detailed functional requirements and scope' },
475
+ scenarioId: {
476
+ type: 'string',
477
+ description: 'Complexity scenario (auto, hotfix, simple, medium, complex, enterprise)',
478
+ },
479
+ },
480
+ output: { schema: { type: 'string' }, render },
481
+ async execute(args) {
482
+ const res = await runner.startPipeline(args)
483
+ return JSON.stringify(
484
+ {
485
+ status: 'started',
486
+ pipelineId: res.pipelineId,
487
+ scenario: res.scenarioTitle,
488
+ stages: res.stages.map((s) => ({ id: s.id, name: s.name, role: s.roleName })),
489
+ },
490
+ null,
491
+ 2
492
+ )
493
+ },
494
+ })
495
+ )
496
+ )
497
+ logger.debug('[dsh-agent-orchestrator] Registered tool: orchestrator_dispatch')
498
+ } catch (e) {
499
+ logger.error('[dsh-agent-orchestrator] Failed to register orchestrator_dispatch:', e)
500
+ }
501
+
502
+ // 2. Direct Single Specialist Delegation Tool (In-Chat Subagent)
503
+ try {
504
+ disposers.push(
505
+ targetCtx.tools.register(
506
+ defineTool({
507
+ name: 'orchestrator_delegate_specialist',
508
+ description:
509
+ 'Delegate a focused task directly to a specialized autonomous subagent (e.g. ui_design, architecture, spec, frontend, backend, qa_tests, code_review, security_audit, devops, docs, data_engineer, dba) without running a full multi-stage DAG pipeline. Returns the specialist deliverable directly into this turn.',
510
+ parameters: {
511
+ roleId: {
512
+ type: 'string',
513
+ required: true,
514
+ description:
515
+ 'Target role identifier: architecture, spec, ui_design, frontend, backend, qa_tests, code_review, security_audit, devops, docs, data_engineer, dba',
516
+ },
517
+ task: {
518
+ type: 'string',
519
+ required: true,
520
+ description: 'The complete, self-contained task and instructions for the specialist subagent.',
521
+ },
522
+ context: {
523
+ type: 'string',
524
+ description: 'Optional existing code, guidelines, or conversation context relevant to the task.',
525
+ },
526
+ iteration: {
527
+ type: 'integer',
528
+ description: 'Optional iteration number for rework/refinement cycles (e.g. 2, 3).',
529
+ },
530
+ feedback: {
531
+ type: 'string',
532
+ description: 'Optional review feedback from previous iteration to send to subagent.',
533
+ },
534
+ status: {
535
+ type: 'string',
536
+ description: 'Optional review status: "accepted", "rework", or "in_progress". Default: "accepted"',
537
+ },
538
+ capability: {
539
+ type: 'string',
540
+ description: 'Optional semantic capability requirement: coding, reasoning, fast, general',
541
+ },
542
+ reasoningEffort: {
543
+ type: 'string',
544
+ description: 'Optional reasoning effort level: off, low, medium, high, max',
545
+ },
546
+ maxTokens: {
547
+ type: 'integer',
548
+ description: 'Optional custom ceiling for output tokens',
549
+ },
550
+ },
551
+ output: { schema: { type: 'string' }, render },
552
+ async execute(args, execCtx) {
553
+ const cfg = {
554
+ ...getConfig(),
555
+ registryProviders: typeof ctx.llm?.listProviders === 'function' ? ctx.llm.listProviders() : [],
556
+ }
557
+ const modelSelection = readModelSelection(ctx)
558
+ const currentDepth = computeDelegationDepth(execCtx)
559
+ const isChildSession = Boolean(execCtx?.session?.parentId || execCtx?.session?.parent || currentDepth > 0)
560
+ const result = await executeDirectDelegation({
561
+ roleId: args.roleId,
562
+ task: args.task,
563
+ context: args.context || '',
564
+ roles: cfg.roles,
565
+ config: cfg,
566
+ callLlm,
567
+ allowedRoutes: modelSelection.allowedRoutes,
568
+ serializationGate,
569
+ sessionLifecycle,
570
+ currentDepth,
571
+ isChildSession,
572
+ capability: args.capability,
573
+ reasoningEffort: args.reasoningEffort,
574
+ maxTokens: args.maxTokens,
575
+ })
576
+
577
+ const hitPct = ((result.metrics?.hitRatio ?? 0) * 100).toFixed(1)
578
+ const savingsPct = result.metrics?.estimatedSavingsPct ?? 0
579
+
580
+ const isRework = Boolean(
581
+ args.feedback || (args.iteration && args.iteration > 1) || args.status === 'rework'
582
+ )
583
+ const iterationNum = args.iteration || (isRework ? 2 : 1)
584
+ const statusBadge = isRework
585
+ ? `> 🟡 **Result received from subagent [${result.roleName}] and sent for rework (iteration ${iterationNum})**`
586
+ : `> 🟢 **Subagent result accepted by orchestrator [${result.roleName}]**`
587
+
588
+ return (
589
+ `${statusBadge}\n` +
590
+ `> *Specialist: \`${result.roleId}\` | Model: \`${result.model}\` | Prompt Cache Hit: ${hitPct}% (Savings: ~${savingsPct}%)*\n\n` +
591
+ `${result.output}`
592
+ )
593
+ },
594
+ })
595
+ )
596
+ )
597
+ logger.debug('[dsh-agent-orchestrator] Registered tool: orchestrator_delegate_specialist')
598
+ } catch (e) {
599
+ logger.error('[dsh-agent-orchestrator] Failed to register orchestrator_delegate_specialist:', e)
600
+ }
601
+
602
+ // 3. Named Agent Pool Runner Tool (agent_run by name - Issue #84 & #110 & #2 & #3)
603
+ try {
604
+ disposers.push(
605
+ targetCtx.tools.register(
606
+ defineTool({
607
+ name: 'agent_run',
608
+ description:
609
+ 'Run a specialized subagent from the agent pool by name (e.g. architecture, spec, ui_design, frontend, backend, qa_tests, code_review, security_audit, devops, docs, data_engineer, dba). Supports one-shot deliverables and continuable interactive subagents with automatic tool intersection security and pinned non-interactive approvals.',
610
+ parameters: {
611
+ name: {
612
+ type: 'string',
613
+ required: true,
614
+ description:
615
+ 'Specialist agent name or role ID from {{subagent_pool}} (e.g. architecture, code, qa_tests, ui_design, docs, devops, security, spec, backend, frontend)',
616
+ },
617
+ task: {
618
+ type: 'string',
619
+ required: true,
620
+ description: 'The specific, actionable task description for the specialist subagent.',
621
+ },
622
+ mode: {
623
+ type: 'string',
624
+ description: 'Execution mode: "one-shot" (default standalone task completion) or "continuable" (interactive multi-turn subagent).',
625
+ },
626
+ cwd: {
627
+ type: 'string',
628
+ description: 'Optional working directory path relative to project root (e.g. packages/core, src/ui).',
629
+ },
630
+ context: {
631
+ type: 'string',
632
+ description: 'Optional additional context, prior deliverable, or code excerpts.',
633
+ },
634
+ },
635
+ output: {
636
+ schema: {
637
+ type: 'object',
638
+ additionalProperties: true,
639
+ properties: {
640
+ success: { type: 'boolean' },
641
+ name: { type: 'string' },
642
+ roleName: { type: 'string' },
643
+ output: { type: 'string' },
644
+ childSessionId: { type: 'string' },
645
+ },
646
+ },
647
+ render: (_a, v) => [
648
+ {
649
+ type: 'text',
650
+ text:
651
+ typeof v === 'string'
652
+ ? v
653
+ : v?.output || JSON.stringify(v, null, 2),
654
+ },
655
+ ],
656
+ },
657
+ async execute(args, execCtx) {
658
+ const parentTools = targetCtx.tools?.schemas ? targetCtx.tools.schemas() : []
659
+ const currentDepth = computeDelegationDepth(execCtx)
660
+ const isChildSession = Boolean(execCtx?.session?.parentId || execCtx?.session?.parent || currentDepth > 0)
661
+ const runCfg = {
662
+ ...getConfig(),
663
+ registryProviders: typeof ctx.llm?.listProviders === 'function' ? ctx.llm.listProviders() : [],
664
+ }
665
+ const res = await executeDirectDelegation({
666
+ roleId: args.name,
667
+ task: args.task,
668
+ mode: args.mode || 'one-shot',
669
+ cwd: args.cwd,
670
+ context: args.context || '',
671
+ roles: runCfg.roles,
672
+ config: runCfg,
673
+ callLlm,
674
+ subagents: ctx.subagents,
675
+ parentTools,
676
+ parentSessionId: execCtx?.session?.id || execCtx?.sessionId,
677
+ allowedRoutes: readModelSelection(ctx).allowedRoutes,
678
+ serializationGate,
679
+ sessionLifecycle,
680
+ currentDepth,
681
+ isChildSession,
682
+ signal: execCtx?.signal,
683
+ })
684
+ return res
685
+ },
686
+ })
687
+ )
688
+ )
689
+ logger.debug('[dsh-agent-orchestrator] Registered tool: agent_run')
690
+ } catch (e) {
691
+ logger.error('[dsh-agent-orchestrator] Failed to register agent_run:', e)
692
+ }
693
+
694
+ // 4. Dynamic Model & Subagent Catalog Tool (Issue #76)
695
+ try {
696
+ disposers.push(
697
+ targetCtx.tools.register(
698
+ defineTool({
699
+ name: 'model_subagent_catalog',
700
+ description:
701
+ 'Query dynamic live catalog of available LLM providers, models, capabilities (coding, reasoning, fast, general), max output tokens, and reasoning effort support.',
702
+ parameters: {
703
+ provider: {
704
+ type: 'string',
705
+ description: 'Optional provider filter (e.g. "deepseek-official", "deepseek", "openrouter", "ollama", "anthropic")',
706
+ },
707
+ capability: {
708
+ type: 'string',
709
+ description: 'Optional semantic capability filter ("coding", "reasoning", "fast", "general")',
710
+ },
711
+ },
712
+ output: { schema: { type: 'string' }, render },
713
+ async execute(args) {
714
+ const catalog = await fetchModelCatalog(ctx, {
715
+ provider: args.provider,
716
+ capability: args.capability,
717
+ })
718
+ return JSON.stringify(
719
+ {
720
+ total: catalog.length,
721
+ catalog,
722
+ },
723
+ null,
724
+ 2
725
+ )
726
+ },
727
+ })
728
+ )
729
+ )
730
+ logger.debug('[dsh-agent-orchestrator] Registered tool: model_subagent_catalog')
731
+ } catch (e) {
732
+ logger.error('[dsh-agent-orchestrator] Failed to register model_subagent_catalog:', e)
733
+ }
734
+
735
+ return () => {
736
+ for (const d of disposers) {
737
+ try { if (typeof d === 'function') d() } catch (_) {
738
+ /* safe ignore */
739
+ }
740
+ }
741
+ }
742
+ }, 'dsh-agent-orchestrator: registered model tools')
743
+ }
744
+
745
+ if (ctx.tools?.register) {
746
+ registerToolsOnContext(ctx)
747
+ } else {
748
+ ctx.inject(['tools'], (tctx) => registerToolsOnContext(tctx))
749
+ }
750
+
751
+ // Helper to handle pipeline dispatch triggered from chat
752
+ const handlePipelineDispatch = async ({ taskTitle, scenarioId, targetAgent, targetSession }) => {
753
+ try {
754
+ const pipeline = await runner.startPipeline({
755
+ taskTitle,
756
+ taskDescription: taskTitle,
757
+ scenarioId: scenarioId || 'auto',
758
+ })
759
+
760
+ const stagesFormatted = pipeline.stages
761
+ .map((s, i) => ` ${i + 1}. **${s.name}** — *${s.roleName}* (${s.assignedModel?.model || 'deepseek-chat'})`)
762
+ .join('\n')
763
+
764
+ const responseText =
765
+ `🚀 **Multi-Agent Orchestrator Pipeline Dispatched**\n\n` +
766
+ `- **Scenario**: \`${pipeline.scenarioTitle}\`\n` +
767
+ `- **Pipeline ID**: \`${pipeline.pipelineId}\`\n` +
768
+ `- **Objective**: ${pipeline.taskTitle}\n\n` +
769
+ `**Execution Graph (DAG):**\n${stagesFormatted}\n\n` +
770
+ `⚡ *Prompt Caching Active*: Prefix canonicalization enabled (>1024 token L1 static anchor + L2 task context). Downstream stages will stream deliverables with shared KV-cache.`
771
+
772
+ if (targetAgent && typeof targetAgent.followup === 'function') {
773
+ try {
774
+ targetAgent.followup({
775
+ id: randomUUID(),
776
+ role: 'user',
777
+ content: [{ type: 'text', text: responseText }],
778
+ source: { kind: 'user' },
779
+ })
780
+ } catch (_) {
781
+ /* safe ignore */
782
+ }
783
+ }
784
+
785
+ if (targetSession) {
786
+ if (typeof targetSession.reply === 'function') {
787
+ targetSession.reply(responseText)
788
+ } else if (typeof targetSession.append === 'function') {
789
+ targetSession.append('message', {
790
+ id: randomUUID(),
791
+ role: 'assistant',
792
+ content: [{ type: 'text', text: responseText }],
793
+ })
794
+ }
795
+ }
796
+
797
+ return { kind: 'success', text: responseText }
798
+ } catch (err) {
799
+ const errMsg = `❌ Failed to dispatch pipeline: ${err?.message || err}`
800
+ if (targetAgent && typeof targetAgent.followup === 'function') {
801
+ try {
802
+ targetAgent.followup({
803
+ id: randomUUID(),
804
+ role: 'user',
805
+ content: [{ type: 'text', text: errMsg }],
806
+ source: { kind: 'user' },
807
+ })
808
+ } catch (_) {
809
+ /* safe ignore */
810
+ }
811
+ }
812
+ return { kind: 'error', text: errMsg }
813
+ }
814
+ }
815
+
816
+ // Chat slash commands: /orchestrate and /orc via commands service
817
+ ctx.inject(['commands'], (cctx) => {
818
+ try {
819
+ if (typeof cctx.commands?.register !== 'function') return
820
+
821
+ try {
822
+ const globalLayer = cctx.commands?.layers?.global
823
+ if (globalLayer?.commands?.data instanceof Map) {
824
+ if (globalLayer.commands.data.has('orchestrate')) {
825
+ globalLayer.commands.data.delete('orchestrate')
826
+ }
827
+ if (globalLayer.commands.data.has('orc')) {
828
+ globalLayer.commands.data.delete('orc')
829
+ }
830
+ }
831
+ } catch (_) {
832
+ /* safe ignore */
833
+ }
834
+
835
+ const commandHandler = async (invocation) => {
836
+ const raw =
837
+ invocation?.rawInput ??
838
+ invocation?.line ??
839
+ invocation?.input ??
840
+ (typeof invocation === 'string' ? invocation : '')
841
+ const text = String(raw || '').trim()
842
+ const intent = detectOrchestratorIntent(`/orchestrate ${text}`)
843
+ return handlePipelineDispatch({
844
+ taskTitle: intent.taskTitle || text || 'Interactive Orchestrated Task',
845
+ scenarioId: intent.scenarioId || 'auto',
846
+ targetAgent: invocation?.agent,
847
+ })
848
+ }
849
+
850
+ const unregister1 = cctx.commands.register({
851
+ name: 'orchestrate',
852
+ description: 'Multi-Agent Orchestrator: decompose task and dispatch across specialized roles with prompt caching',
853
+ input: { hint: '[hotfix|simple|medium|complex|enterprise] <task objective>' },
854
+ handler: commandHandler,
855
+ })
856
+
857
+ const unregister2 = cctx.commands.register({
858
+ name: 'orc',
859
+ description: 'Alias for /orchestrate',
860
+ input: { hint: '[hotfix|simple|medium|complex|enterprise] <task objective>' },
861
+ handler: commandHandler,
862
+ })
863
+
864
+ // Roster Viewer Slash Command (Issue #105)
865
+ const rosterHandler = async (invocation) => {
866
+ const rolesList = toRolesArray(getConfig().roles || getDefaultRoles())
867
+ const rows = [
868
+ '| Name | Role ID | Model | Status | maxTokens | Skills/Tools |',
869
+ '| :--- | :--- | :--- | :---: | :---: | :--- |',
870
+ ]
871
+ for (const r of rolesList) {
872
+ const modelStr = r.defaultModel ? `${r.defaultModel.provider}:${r.defaultModel.model}` : 'default'
873
+ const statusStr = r.enabled !== false ? 'Active' : 'Disabled'
874
+ const maxTok = r.maxTokens || 4096
875
+ const skillsList = (r.skills || []).concat(r.tools || []).slice(0, 4).join(', ')
876
+ rows.push(`| ${r.name || r.displayName || r.id} | \`${r.id}\` | ${modelStr} | ${statusStr} | ${maxTok} | ${skillsList} |`)
877
+ }
878
+ const output = `### Multi-Agent Orchestrator: Agent Roster (${rolesList.length} specialists)\n\n${rows.join('\n')}`
879
+ if (invocation?.agent && typeof invocation.agent.followup === 'function') {
880
+ try {
881
+ invocation.agent.followup({
882
+ id: randomUUID(),
883
+ role: 'assistant',
884
+ content: [{ type: 'text', text: output }],
885
+ })
886
+ } catch (_) {
887
+ /* safe ignore */
888
+ }
889
+ }
890
+ return { kind: 'success', text: output }
891
+ }
892
+
893
+ const unregister3 = cctx.commands.register({
894
+ name: 'subagents',
895
+ description: 'View orchestrator agent roster, capabilities, and active statuses (Issue #105)',
896
+ handler: rosterHandler,
897
+ })
898
+
899
+ const unregister4 = cctx.commands.register({
900
+ name: 'roster',
901
+ description: 'Alias for /subagents roster viewer (Issue #105)',
902
+ handler: rosterHandler,
903
+ })
904
+
905
+ if (typeof cctx.effect === 'function') {
906
+ cctx.effect(() => () => {
907
+ try {
908
+ if (typeof unregister1 === 'function') unregister1()
909
+ if (typeof unregister2 === 'function') unregister2()
910
+ if (typeof unregister3 === 'function') unregister3()
911
+ if (typeof unregister4 === 'function') unregister4()
912
+ } catch (_) {
913
+ /* safe ignore */
914
+ }
915
+ }, 'dsh-agent-orchestrator: commands unregister')
916
+ }
917
+ } catch (err) {
918
+ logger.warn('[dsh-agent-orchestrator] Commands register warning:', err?.message || err)
919
+ }
920
+ })
921
+
922
+ // Auto-archive one-shot subagents on completion (Issue #56)
923
+ ctx.effect(() => {
924
+ return ctx.on('subagent/end', (session) => {
925
+ const id = session?.id || session?.sessionId
926
+ if (id) {
927
+ sessionLifecycle.markCompleted(id, { event: 'subagent/end' })
928
+ }
929
+ })
930
+ }, 'dsh-agent-orchestrator: subagent/end lifecycle listener')
931
+
932
+ // Natural Language Chat Listener (session/event)
933
+ // Detects orchestrator triggers: "/orchestrate ...", "orchestrate: ...", etc.
934
+ ctx.effect(() => {
935
+ return ctx.on('session/event', async (session, event) => {
936
+ if (!session || event?.type !== 'input/user') return
937
+
938
+ const text = String(event.text || event.content || '').trim()
939
+ const intent = detectOrchestratorIntent(text)
940
+
941
+ if (intent.isTrigger && intent.action === 'on') {
942
+ await handlePipelineDispatch({
943
+ taskTitle: intent.taskTitle || 'Interactive Orchestrated Task',
944
+ scenarioId: intent.scenarioId || 'auto',
945
+ targetSession: session,
946
+ })
947
+ }
948
+ })
949
+ }, 'dsh-agent-orchestrator: natural language chat listener')
950
+ }