@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.
@@ -0,0 +1,625 @@
1
+ /**
2
+ * Direct Specialist Delegation Handler.
3
+ *
4
+ * Implements:
5
+ * 1. MVO 1-to-1 Specialist Delegation (`orchestrator_delegate_specialist` and `agent_run`)
6
+ * 2. KV-Cache Prompt Alignment fallback when native subagents service is absent.
7
+ * 3. Bidirectional synonym bridge & safe tool intersection (Issue #2).
8
+ * 4. Pinned non-interactive approvals (`approval: 'never'`) (Issue #110).
9
+ * 5. Anti-Redelegation Shield (Issue #103): child subagents cannot receive delegation tools.
10
+ * 6. Leaf Experts Guard (Issue #102): leaf specialized experts run with maxDepth: 1.
11
+ * 7. Anti-Matryoshka Guard (Issue #19): hard limit on nesting depth (HARD_MAX_DEPTH = 3),
12
+ * and optional disableNestedDelegation configuration toggle.
13
+ * 8. Adaptive tool sanitization diagnostics (`droppedTools`) (Issue #111).
14
+ * 9. Per-Parent Serialization Gate & Concurrency Cap (Issue #93).
15
+ * 10. Per-Call `cwd` Workspace Scoping & Sandbox containment check (Issue #87).
16
+ * 11. Multi-layered Decision Trace Ledger in `presentationMeta` (≤4KB limit) (Issue #108).
17
+ * 12. Session Lifecycle Auto-Archiving for one-shot subagents (Issue #56).
18
+ * 13. Fail-Fast Model Validation with Candidate Shortlist (Issue #82).
19
+ * 14. Smart Model Routing by task complexity tier (Issue #48).
20
+ * 15. Deterministic Max-Tokens Watchdog with continue-once guarantee (Issue #75).
21
+ */
22
+
23
+ import { randomUUID } from 'crypto'
24
+ import { resolveToolIntersection } from './intersection.js'
25
+ import { resolveSpecialistModel } from './model-selection.js'
26
+ import { resolveScopedCwd } from './concurrency-gate.js'
27
+ import { createDecisionTrace } from './decision-trace.js'
28
+ import { isMaxTokensTruncated, runWithMaxTokensWatchdog } from './token-watchdog.js'
29
+ import {
30
+ buildStaticBaseAnchor,
31
+ buildSharedTaskAnchor,
32
+ formatCumulativeArtifacts,
33
+ assembleAgentMessages,
34
+ extractCacheMetrics,
35
+ } from './cache-prefixer.js'
36
+ import { callWithTransientRetry } from './worker-pool.js'
37
+ import { getDefaultRoles } from './scenarios.js'
38
+
39
+ export const HARD_MAX_DEPTH = 3
40
+ export const NESTED_DELEGATION_GUARD_MESSAGE =
41
+ 'Nested delegation is disabled by plugin policy (disableNestedDelegation=true).'
42
+ export const DELEGATION_DEPTH_LIMIT_MESSAGE =
43
+ `Delegation depth limit reached: maximum allowed depth is ${HARD_MAX_DEPTH} (Anti-Matryoshka Guard).`
44
+
45
+ /**
46
+ * Normalizes roles collection to a flat array.
47
+ */
48
+ export function toRolesArray(roles) {
49
+ if (!roles) return []
50
+ if (Array.isArray(roles)) return roles
51
+ if (typeof roles === 'object') {
52
+ return Object.entries(roles).map(([k, v]) => ({
53
+ id: v?.id || k,
54
+ displayName: v?.displayName || v?.name || k,
55
+ ...v,
56
+ }))
57
+ }
58
+ return []
59
+ }
60
+
61
+ /**
62
+ * Canonical mapping of natural language role aliases (English and Russian)
63
+ * for chat prompt and slash command parsing (Issue #133).
64
+ */
65
+ export const ROLE_INPUT_ALIASES = {
66
+ coder: 'code',
67
+ developer: 'code',
68
+ dev: 'code',
69
+ разработчик: 'code',
70
+ программист: 'code',
71
+ код: 'code',
72
+
73
+ reviewer: 'qa_tests',
74
+ review: 'qa_tests',
75
+ ревью: 'qa_tests',
76
+ ревьюер: 'qa_tests',
77
+ tester: 'qa_tests',
78
+ qa: 'qa_tests',
79
+ тестировщик: 'qa_tests',
80
+ тесты: 'qa_tests',
81
+
82
+ architect: 'architecture',
83
+ архитектор: 'architecture',
84
+ архитектура: 'architecture',
85
+ design_system: 'architecture',
86
+
87
+ writer: 'docs',
88
+ technical_writer: 'docs',
89
+ документация: 'docs',
90
+ документатор: 'docs',
91
+ доки: 'docs',
92
+
93
+ spec: 'spec',
94
+ тз: 'spec',
95
+ спецификация: 'spec',
96
+ analyst: 'spec',
97
+
98
+ // Frontend
99
+ frontend: 'frontend',
100
+ front: 'frontend',
101
+ фронтенд: 'frontend',
102
+ фронт: 'frontend',
103
+
104
+ // Backend
105
+ backend: 'backend',
106
+ back: 'backend',
107
+ бэкенд: 'backend',
108
+ бэк: 'backend',
109
+
110
+ designer: 'ui_design',
111
+ design: 'ui_design',
112
+ дизайнер: 'ui_design',
113
+ дизайн: 'ui_design',
114
+ ux: 'ui_design',
115
+ ui: 'ui_design',
116
+
117
+ bugfix: 'bugfix',
118
+ баг: 'bugfix',
119
+ исправление: 'bugfix',
120
+ фикс: 'bugfix',
121
+
122
+ refactoring: 'refactoring',
123
+ рефакторинг: 'refactoring',
124
+ чистка: 'refactoring',
125
+
126
+ research: 'research',
127
+ исследование: 'research',
128
+ анализ: 'research',
129
+
130
+ devops: 'devops',
131
+ девопс: 'devops',
132
+ деплой: 'devops',
133
+ ci: 'devops',
134
+ }
135
+
136
+ /**
137
+ * Resolves alias (e.g. 'coder', 'разработчик', 'code', 'тестировщик') to canonical role ID.
138
+ */
139
+ export function normalizeRoleId(input, availableRoles = []) {
140
+ if (!input || typeof input !== 'string') return 'architecture'
141
+ const lower = input.trim().toLowerCase()
142
+
143
+ const rolesList = toRolesArray(
144
+ availableRoles && (Array.isArray(availableRoles) ? availableRoles.length > 0 : Object.keys(availableRoles).length > 0)
145
+ ? availableRoles
146
+ : getDefaultRoles()
147
+ )
148
+
149
+ for (const role of rolesList) {
150
+ if (role.id.toLowerCase() === lower) return role.id
151
+ if (role.name && role.name.toLowerCase() === lower) return role.id
152
+ if (role.displayName && role.displayName.toLowerCase() === lower) return role.id
153
+ }
154
+
155
+ for (const [alias, id] of Object.entries(ROLE_INPUT_ALIASES)) {
156
+ if (lower === alias || lower.includes(alias)) {
157
+ const found = rolesList.find((r) => r.id === id)
158
+ if (found) return found.id
159
+ if (id === 'code') {
160
+ const fallbackCoding = rolesList.find((r) => r.id === 'fullstack' || r.id === 'backend')
161
+ if (fallbackCoding) return fallbackCoding.id
162
+ }
163
+ }
164
+ }
165
+
166
+ return 'architecture' // Safe fallback role
167
+ }
168
+
169
+ export const resolveTargetRole = normalizeRoleId
170
+
171
+ /**
172
+ * Extracts plain text from canonical DSH subagent output block array.
173
+ */
174
+ function extractOutputText(output) {
175
+ if (!output) return ''
176
+ if (typeof output === 'string') return output
177
+ if (Array.isArray(output)) {
178
+ return output
179
+ .map((block) => {
180
+ if (typeof block === 'string') return block
181
+ if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
182
+ return block.text
183
+ }
184
+ return ''
185
+ })
186
+ .join('')
187
+ }
188
+ if (typeof output === 'object') {
189
+ return output.text || output.content || JSON.stringify(output)
190
+ }
191
+ return String(output)
192
+ }
193
+
194
+ /**
195
+ * Computes the delegation nesting depth based on parent context or session metadata.
196
+ *
197
+ * @param {object} [execCtx]
198
+ * @param {number} [explicitDepth]
199
+ * @returns {number} Current delegation depth (root = 0, child = 1)
200
+ */
201
+ export function computeDelegationDepth(execCtx, explicitDepth) {
202
+ if (typeof explicitDepth === 'number') return explicitDepth
203
+ if (typeof execCtx?.depth === 'number') return execCtx.depth
204
+ if (typeof execCtx?.session?.metadata?.delegationDepth === 'number') {
205
+ return execCtx.session.metadata.delegationDepth
206
+ }
207
+ if (execCtx?.session?.parentId || execCtx?.session?.parent) {
208
+ return 1
209
+ }
210
+ return 0
211
+ }
212
+
213
+ /**
214
+ * Executes delegation to a specialist worker with security guardrails.
215
+ *
216
+ * @param {object} params
217
+ * @param {string} params.roleId Target specialist role ID or alias
218
+ * @param {string} params.task Detailed task description for the specialist
219
+ * @param {string} [params.mode='one-shot'] Execution mode: 'one-shot' or 'continuable'
220
+ * @param {string} [params.cwd] Working directory scoping for the specialist
221
+ * @param {string} [params.context] Existing files, code, or background context
222
+ * @param {Array<object>|object} [params.roles] Available roles list or dict
223
+ * @param {object} [params.config] Plugin config
224
+ * @param {function} [params.callLlm] Direct LLM caller
225
+ * @param {object} [params.subagents] Native DSH subagents service (ctx.subagents)
226
+ * @param {Array<string|object>} [params.parentTools] Host/parent tools for security intersection
227
+ * @param {string} [params.parentSessionId] Current DSH session ID for child session linking
228
+ * @param {number} [params.currentDepth=0] Current recursion depth of caller
229
+ * @param {boolean} [params.isChildSession=false] Whether caller is already a delegated child
230
+ * @param {Array<object>} [params.allowedRoutes] Authorized models from subagent-model-selection
231
+ * @param {object} [params.serializationGate] Concurrency and serialization gate
232
+ * @param {object} [params.sessionLifecycle] Lifecycle manager for subagent sessions
233
+ * @param {function} [params.onStreamDelta] Streaming callback
234
+ * @param {AbortSignal} [params.signal] Cancellation abort signal
235
+ * @returns {Promise<object>} Result deliverable
236
+ */
237
+ export async function executeDirectDelegation({
238
+ roleId,
239
+ task,
240
+ mode = 'one-shot',
241
+ cwd,
242
+ context = '',
243
+ roles = [],
244
+ config = {},
245
+ callLlm,
246
+ subagents,
247
+ parentTools,
248
+ parentSessionId = 'root',
249
+ currentDepth = 0,
250
+ isChildSession = false,
251
+ allowedRoutes,
252
+ capability,
253
+ reasoningEffort: requestedReasoningEffort,
254
+ maxTokens: explicitMaxTokens,
255
+ serializationGate,
256
+ sessionLifecycle,
257
+ onStreamDelta,
258
+ signal,
259
+ logger = null,
260
+ }) {
261
+ const startTime = Date.now()
262
+
263
+ // 0. Anti-Matryoshka & Anti-Redelegation Depth Guardrails (Issues #19, #102, #103)
264
+ const isNested = isChildSession || currentDepth > 0
265
+
266
+ // Check 1: Nested delegation disabled by policy
267
+ if (config.disableNestedDelegation && isNested) {
268
+ throw new Error(`[DelegationGuard] ${NESTED_DELEGATION_GUARD_MESSAGE}`)
269
+ }
270
+
271
+ // Check 2: Hard ceiling on delegation depth (HARD_MAX_DEPTH = 3)
272
+ if (currentDepth >= HARD_MAX_DEPTH) {
273
+ throw new Error(`[DelegationGuard] ${DELEGATION_DEPTH_LIMIT_MESSAGE}`)
274
+ }
275
+
276
+ // 1. Workspace Scoping & Sandbox Containment (Issue #87)
277
+ const scopedCwd = cwd ? resolveScopedCwd(cwd, config.baseDir || process.cwd()) : undefined
278
+
279
+ const allRoles = toRolesArray(
280
+ roles && (Array.isArray(roles) ? roles.length > 0 : Object.keys(roles).length > 0)
281
+ ? roles
282
+ : getDefaultRoles()
283
+ )
284
+ const canonicalId = normalizeRoleId(roleId, allRoles)
285
+ const role = allRoles.find((r) => r.id === canonicalId) || allRoles[0]
286
+ const roleName = role.displayName || role.name || role.id
287
+
288
+ const executionId = `delegation-${randomUUID().slice(0, 8)}`
289
+
290
+ // 2. Concurrency & Serialization Gate Acquisition (Issue #93)
291
+ let releaseGateSlot = () => {}
292
+ if (serializationGate && typeof serializationGate.acquire === 'function') {
293
+ releaseGateSlot = await serializationGate.acquire(parentSessionId || 'root', executionId)
294
+ }
295
+
296
+ try {
297
+ // 3. Calculate Tool Intersection & Adaptive Sanitization (Issues #2, #103, #111)
298
+ const hasContext = Boolean(context && String(context).trim() !== '')
299
+ const intersection = resolveToolIntersection({
300
+ parentTools,
301
+ roleTools: role.tools,
302
+ denyList: config.deniedTools || [],
303
+ failLoud: parentTools !== undefined && !hasContext,
304
+ roleId: role.id,
305
+ })
306
+
307
+ // 4. Model resolution: Smart Model Routing (#48), Fail-Fast Candidate Shortlist (#82),
308
+ // Semantic Capability Routing (#74), maxTokens ceiling (#79), Reasoning Effort (#86)
309
+ const {
310
+ provider,
311
+ model,
312
+ warning,
313
+ candidateShortlist,
314
+ selectionReason,
315
+ maxTokens: resolvedMaxTokens,
316
+ reasoningEffort,
317
+ } = resolveSpecialistModel({
318
+ roleModel: role.defaultModel,
319
+ fallbackModel: { provider: 'deepseek', model: 'deepseek-chat' },
320
+ allowedRoutes,
321
+ roleId: role.id,
322
+ task,
323
+ capability: capability || role.capability,
324
+ reasoningEffort: requestedReasoningEffort || role.reasoningEffort,
325
+ maxTokens: explicitMaxTokens || role.maxTokens,
326
+ smartRoutingEnabled: Boolean(config.smartModelRouting),
327
+ allowedProviders: config.allowedProviders || [],
328
+ registryProviders: config.registryProviders || [],
329
+ catalog: config.catalog || [],
330
+ failFast: Boolean(config.failFastModelValidation),
331
+ })
332
+
333
+ const log = logger || config?.logger || null
334
+ if (warning && log?.warn) {
335
+ log.warn(`[dsh-agent-orchestrator] ${warning}`)
336
+ }
337
+
338
+ const temperature = role.temperature ?? 0.3
339
+ const maxTokens = resolvedMaxTokens ?? role.maxTokens ?? 4096
340
+
341
+ // Helper to produce standard result deliverable with Decision Trace Ledger (Issue #108)
342
+ const produceDeliverable = (data) => {
343
+ const durationMs = Date.now() - startTime
344
+ const decisionTrace = createDecisionTrace({
345
+ executionId,
346
+ roleId: role.id,
347
+ roleName,
348
+ requestedModel: role.defaultModel?.model,
349
+ assignedModel: `${provider}:${model}`,
350
+ selectionReason: selectionReason || (warning ? 'fallback_allowed' : 'preset_match'),
351
+ toolSummary: {
352
+ allowed: intersection.tools,
353
+ strippedSecurity: intersection.droppedTools,
354
+ },
355
+ droppedTools: intersection.droppedTools,
356
+ depth: currentDepth + 1,
357
+ durationMs,
358
+ status: data.status || 'completed',
359
+ rationale: `Delegated to [${roleName}] with ${intersection.tools.length} allowed tools`,
360
+ })
361
+
362
+ return {
363
+ ...data,
364
+ maxTokens,
365
+ reasoningEffort,
366
+ cwd: scopedCwd,
367
+ candidateShortlist: candidateShortlist?.length ? candidateShortlist : undefined,
368
+ decisionTrace,
369
+ presentationMeta: {
370
+ decisionTrace,
371
+ maxTokens,
372
+ reasoningEffort,
373
+ },
374
+ }
375
+ }
376
+
377
+ // 5. Check for Native DSH subagent service (Issues #3, #110, #56, #75)
378
+ if (subagents && typeof subagents.getProvider === 'function') {
379
+ const subagentProviderName = config.subagentProvider || 'spawn'
380
+ const transport = subagents.getProvider(subagentProviderName)
381
+
382
+ if (transport) {
383
+ const isContinuable = mode === 'continuable' && typeof subagents.startContinuable === 'function'
384
+
385
+ // Leaf Experts Guard: maxDepth is capped at 1 for leaf subagents (Issue #102)
386
+ const subagentRequest = {
387
+ label: `[${roleName}] ${task.slice(0, 48)}`,
388
+ prompt: [{ type: 'text', text: task }],
389
+ parent: parentSessionId,
390
+ persona: role.systemPrompt,
391
+ toolFilter: { allow: intersection.tools },
392
+ approval: 'never', // Pinned Non-Interactive Approvals (Issue #110)
393
+ maxDepth: 1, // Leaf expert bound: cannot spawn nested agents (Issue #102)
394
+ metadata: {
395
+ delegationDepth: currentDepth + 1,
396
+ roleId: role.id,
397
+ isLeaf: true,
398
+ },
399
+ ...(scopedCwd ? { cwd: scopedCwd } : {}),
400
+ agentOptions: {
401
+ provider,
402
+ model,
403
+ maxTokens,
404
+ temperature,
405
+ ...(reasoningEffort ? { reasoningEffort } : {}),
406
+ },
407
+ }
408
+
409
+ if (isContinuable) {
410
+ const continuableResult = await subagents.startContinuable({
411
+ provider: subagentProviderName,
412
+ label: subagentRequest.label,
413
+ request: subagentRequest,
414
+ signal,
415
+ })
416
+ const childId = continuableResult.childId || continuableResult.id
417
+ if (sessionLifecycle && childId) {
418
+ sessionLifecycle.register(childId, {
419
+ roleId: role.id,
420
+ executionId,
421
+ mode: 'continuable',
422
+ parentSessionId,
423
+ })
424
+ }
425
+
426
+ return produceDeliverable({
427
+ kind: 'continuable',
428
+ childSessionId: childId,
429
+ executionId,
430
+ roleId: role.id,
431
+ roleName,
432
+ model: `${provider}:${model}`,
433
+ toolDiff: intersection.diff,
434
+ droppedTools: intersection.droppedTools,
435
+ depth: currentDepth + 1,
436
+ status: 'running',
437
+ output: `Continuable child session started: ${childId}`,
438
+ })
439
+ }
440
+
441
+ // Foreground / one-shot subagent execution with Deterministic max-tokens Watchdog (#75)
442
+ let outputText = ''
443
+ let runResult = null
444
+ let finalStopReason = 'completed'
445
+ let capturedChildSessionId = null
446
+
447
+ const executeForegroundTurn = async (count, contPrompt) => {
448
+ const activePrompt = count === 0 ? task : contPrompt
449
+ const run = await subagents.start(subagentProviderName, {
450
+ ...subagentRequest,
451
+ prompt: [{ type: 'text', text: activePrompt }],
452
+ signal,
453
+ })
454
+
455
+ if (run?.id && !capturedChildSessionId) {
456
+ capturedChildSessionId = run.id
457
+ }
458
+
459
+ if (sessionLifecycle && run?.id) {
460
+ sessionLifecycle.register(run.id, {
461
+ roleId: role.id,
462
+ executionId,
463
+ mode: 'one-shot',
464
+ parentSessionId,
465
+ })
466
+ }
467
+
468
+ try {
469
+ runResult = await run.result
470
+ } finally {
471
+ if (run && typeof run.dispose === 'function') {
472
+ await run.dispose().catch((err) => {
473
+ if (log?.debug) {
474
+ log.debug('[dsh-agent-orchestrator] run.dispose skipped:', err?.message || err)
475
+ }
476
+ })
477
+ }
478
+ if (sessionLifecycle && run?.id) {
479
+ sessionLifecycle.markCompleted(run.id, {
480
+ roleId: role.id,
481
+ stopReason: runResult?.stopReason,
482
+ })
483
+ }
484
+ }
485
+
486
+ finalStopReason = runResult?.stopReason || 'completed'
487
+ return {
488
+ output: extractOutputText(runResult?.output),
489
+ stopReason: finalStopReason,
490
+ }
491
+ }
492
+
493
+ const watchdogResult = await runWithMaxTokensWatchdog({
494
+ executionId,
495
+ executeTurn: executeForegroundTurn,
496
+ })
497
+ outputText = watchdogResult.output
498
+
499
+ return produceDeliverable({
500
+ kind: 'foreground',
501
+ childSessionId: capturedChildSessionId || executionId,
502
+ executionId,
503
+ roleId: role.id,
504
+ roleName,
505
+ model: `${provider}:${model}`,
506
+ toolDiff: intersection.diff,
507
+ droppedTools: intersection.droppedTools,
508
+ depth: currentDepth + 1,
509
+ status: watchdogResult.stopReason,
510
+ output: outputText,
511
+ metrics: {
512
+ model: `${provider}:${model}`,
513
+ roleId: role.id,
514
+ executionId,
515
+ depth: currentDepth + 1,
516
+ stopReason: watchdogResult.stopReason,
517
+ continuations: watchdogResult.continuationCount,
518
+ },
519
+ })
520
+ }
521
+ }
522
+
523
+ // 6. Fallback: Direct LLM Turn with Prompt Cache Alignment & Watchdog (Issue #75)
524
+ if (typeof callLlm !== 'function') {
525
+ throw new Error('Neither subagents transport nor callLlm function is available for delegation')
526
+ }
527
+
528
+ const baseAnchor = buildStaticBaseAnchor({
529
+ projectType: config.projectType || 'dsh-plugin',
530
+ customRules: config.customRules || '',
531
+ })
532
+
533
+ const taskAnchor = buildSharedTaskAnchor({
534
+ id: executionId,
535
+ title: `Direct Delegation: ${roleName}`,
536
+ description: task,
537
+ repo: config.repo || 'active-workspace',
538
+ scenario: 'direct-delegation',
539
+ })
540
+
541
+ const upstreamArtifacts = context
542
+ ? formatCumulativeArtifacts([
543
+ {
544
+ stageId: 'user-context',
545
+ roleId: 'parent-agent',
546
+ output: context,
547
+ },
548
+ ])
549
+ : ''
550
+
551
+ const stage = {
552
+ id: executionId,
553
+ name: `Direct Delegation to ${roleName}`,
554
+ roleId: role.id,
555
+ prompt: task,
556
+ }
557
+
558
+ const messages = assembleAgentMessages({
559
+ baseAnchor,
560
+ taskAnchor,
561
+ cumulativeArtifacts: upstreamArtifacts,
562
+ agentRole: role,
563
+ currentStage: stage,
564
+ })
565
+
566
+ let lastUsage = {}
567
+ const executeLlmTurn = async (count, contPrompt) => {
568
+ const turnMessages = count === 0
569
+ ? messages
570
+ : [
571
+ ...messages,
572
+ { role: 'user', content: contPrompt }
573
+ ]
574
+
575
+ const res = await callWithTransientRetry(
576
+ callLlm,
577
+ {
578
+ provider,
579
+ model,
580
+ messages: turnMessages,
581
+ temperature,
582
+ maxTokens,
583
+ reasoningEffort,
584
+ onStreamDelta,
585
+ },
586
+ 2,
587
+ 1200
588
+ )
589
+ lastUsage = res.usage || {}
590
+ return {
591
+ output: res.text || res.content || '',
592
+ stopReason: res.stopReason || res.finishReason || 'completed',
593
+ }
594
+ }
595
+
596
+ const watchdogResult = await runWithMaxTokensWatchdog({
597
+ executionId,
598
+ executeTurn: executeLlmTurn,
599
+ })
600
+
601
+ const cacheMetrics = extractCacheMetrics(lastUsage)
602
+
603
+ return produceDeliverable({
604
+ kind: 'inline',
605
+ output: watchdogResult.output,
606
+ roleId: role.id,
607
+ roleName,
608
+ model: `${provider}:${model}`,
609
+ toolDiff: intersection.diff,
610
+ droppedTools: intersection.droppedTools,
611
+ depth: currentDepth + 1,
612
+ status: watchdogResult.stopReason,
613
+ metrics: {
614
+ ...cacheMetrics,
615
+ model: `${provider}:${model}`,
616
+ roleId: role.id,
617
+ executionId,
618
+ depth: currentDepth + 1,
619
+ continuations: watchdogResult.continuationCount,
620
+ },
621
+ })
622
+ } finally {
623
+ releaseGateSlot()
624
+ }
625
+ }