@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/LICENSE +22 -0
- package/README.md +184 -0
- package/README.ru.md +86 -0
- package/README.zh.md +64 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1374 -0
- package/lib/http-guard.js +166 -0
- package/lib/index.js +950 -0
- package/lib/integrations/kanban-bridge.js +102 -0
- package/lib/pipeline/cache-prefixer.js +226 -0
- package/lib/pipeline/concurrency-gate.js +110 -0
- package/lib/pipeline/dag-engine.js +290 -0
- package/lib/pipeline/decision-trace.js +124 -0
- package/lib/pipeline/decomposer.js +102 -0
- package/lib/pipeline/delegation.js +625 -0
- package/lib/pipeline/guidance.js +94 -0
- package/lib/pipeline/intent.js +98 -0
- package/lib/pipeline/intersection.js +235 -0
- package/lib/pipeline/model-selection.js +742 -0
- package/lib/pipeline/preset-sync.js +84 -0
- package/lib/pipeline/scenarios.js +468 -0
- package/lib/pipeline/session-lifecycle.js +520 -0
- package/lib/pipeline/snapshots.js +240 -0
- package/lib/pipeline/token-watchdog.js +101 -0
- package/lib/pipeline/worker-pool.js +143 -0
- package/lib/routes.js +277 -0
- package/lib/store.js +176 -0
- package/package.json +94 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch Snapshots & One-Click Save as Preset Engine (Issue #109).
|
|
3
|
+
* Security Hardened against Path Traversal (Issue #135).
|
|
4
|
+
* Migrated to ctx.logger (Issue #130).
|
|
5
|
+
*
|
|
6
|
+
* Implements:
|
|
7
|
+
* 1. Atomic snapshot storage in ~/.dsh/orchestrator-snapshots/ (or configured dir).
|
|
8
|
+
* 2. FIFO retention policy with a 200-item hard ceiling.
|
|
9
|
+
* 3. Atomic writes via .tmp files and atomic rename to prevent corruption.
|
|
10
|
+
* 4. Save as Preset method for turning any completed dispatch run into a reusable preset.
|
|
11
|
+
* 5. Strict path traversal verification and snapshot ID sanitization.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs'
|
|
15
|
+
import path from 'path'
|
|
16
|
+
import os from 'os'
|
|
17
|
+
import { randomUUID } from 'crypto'
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_MAX_SNAPSHOTS = 200
|
|
20
|
+
|
|
21
|
+
export class SnapshotManager {
|
|
22
|
+
constructor({ baseDir, maxSnapshots = DEFAULT_MAX_SNAPSHOTS, logger } = {}) {
|
|
23
|
+
this.baseDir = baseDir || path.join(os.homedir(), '.dsh', 'orchestrator-snapshots')
|
|
24
|
+
this.maxSnapshots = maxSnapshots
|
|
25
|
+
this.logger = logger || null
|
|
26
|
+
this._ensureDir()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_ensureDir() {
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(this.baseDir)) {
|
|
32
|
+
fs.mkdirSync(this.baseDir, { recursive: true })
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (this.logger?.error) {
|
|
36
|
+
this.logger.error(`[SnapshotManager] Failed to create baseDir ${this.baseDir}:`, err?.message || err)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolves and verifies that a snapshotId stays strictly within baseDir.
|
|
43
|
+
* @param {string} snapshotId
|
|
44
|
+
* @returns {string} Absolute resolved file path
|
|
45
|
+
* @throws {Error} If snapshotId contains illegal characters, path separators, or escapes baseDir
|
|
46
|
+
*/
|
|
47
|
+
_pathFor(snapshotId) {
|
|
48
|
+
if (!snapshotId || typeof snapshotId !== 'string') {
|
|
49
|
+
throw new Error('Invalid snapshotId: must be a non-empty string')
|
|
50
|
+
}
|
|
51
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(snapshotId)) {
|
|
52
|
+
throw new Error(`Invalid snapshotId: "${snapshotId}" contains illegal characters or invalid length`)
|
|
53
|
+
}
|
|
54
|
+
const baseResolved = path.resolve(this.baseDir)
|
|
55
|
+
const resolved = path.resolve(this.baseDir, `${snapshotId}.json`)
|
|
56
|
+
if (!resolved.startsWith(baseResolved + path.sep)) {
|
|
57
|
+
throw new Error(`Path traversal attempt detected in snapshotId: "${snapshotId}"`)
|
|
58
|
+
}
|
|
59
|
+
return resolved
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Lists all recorded snapshots sorted newest to oldest.
|
|
64
|
+
* @returns {Array<object>}
|
|
65
|
+
*/
|
|
66
|
+
listSnapshots() {
|
|
67
|
+
this._ensureDir()
|
|
68
|
+
try {
|
|
69
|
+
const files = fs.readdirSync(this.baseDir)
|
|
70
|
+
const snapshots = []
|
|
71
|
+
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
if (!file.endsWith('.json')) continue
|
|
74
|
+
const filePath = path.join(this.baseDir, file)
|
|
75
|
+
try {
|
|
76
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
77
|
+
const data = JSON.parse(content)
|
|
78
|
+
snapshots.push(data)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Ignore corrupt files during listing
|
|
81
|
+
if (this.logger?.warn) {
|
|
82
|
+
this.logger.warn(`[SnapshotManager] Skipping invalid snapshot file ${file}:`, err?.message || err)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Sort descending by timestamp / createdAt
|
|
88
|
+
snapshots.sort((a, b) => {
|
|
89
|
+
const timeA = new Date(a.createdAt || a.timestamp || 0).getTime()
|
|
90
|
+
const timeB = new Date(b.createdAt || b.timestamp || 0).getTime()
|
|
91
|
+
return timeB - timeA
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return snapshots
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (this.logger?.error) {
|
|
97
|
+
this.logger.error('[SnapshotManager] Error listing snapshots:', err?.message || err)
|
|
98
|
+
}
|
|
99
|
+
return []
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Retrieves a snapshot by ID with path traversal protection.
|
|
105
|
+
* @param {string} snapshotId
|
|
106
|
+
* @returns {object|null}
|
|
107
|
+
*/
|
|
108
|
+
getSnapshot(snapshotId) {
|
|
109
|
+
if (!snapshotId) return null
|
|
110
|
+
let filePath
|
|
111
|
+
try {
|
|
112
|
+
filePath = this._pathFor(snapshotId)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (this.logger?.warn) {
|
|
115
|
+
this.logger.warn(`[SnapshotManager] Invalid snapshotId rejected: ${err?.message || err}`)
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!fs.existsSync(filePath)) return null
|
|
121
|
+
try {
|
|
122
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
123
|
+
return JSON.parse(content)
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (this.logger?.error) {
|
|
126
|
+
this.logger.error(`[SnapshotManager] Error reading snapshot ${snapshotId}:`, err?.message || err)
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Creates a snapshot atomically with path traversal protection.
|
|
134
|
+
* @param {object} snapshotData
|
|
135
|
+
* @returns {object} The created snapshot metadata
|
|
136
|
+
*/
|
|
137
|
+
createSnapshot(snapshotData = {}) {
|
|
138
|
+
this._ensureDir()
|
|
139
|
+
|
|
140
|
+
const snapshotId = snapshotData.id || `snap-${Date.now()}-${randomUUID().slice(0, 8)}`
|
|
141
|
+
const filePath = this._pathFor(snapshotId)
|
|
142
|
+
const nowIso = new Date().toISOString()
|
|
143
|
+
|
|
144
|
+
const record = {
|
|
145
|
+
id: snapshotId,
|
|
146
|
+
createdAt: nowIso,
|
|
147
|
+
title: snapshotData.title || `Snapshot ${snapshotId}`,
|
|
148
|
+
scenarioId: snapshotData.scenarioId || 'custom',
|
|
149
|
+
status: snapshotData.status || 'completed',
|
|
150
|
+
modelOverrides: snapshotData.modelOverrides || {},
|
|
151
|
+
stages: snapshotData.stages || [],
|
|
152
|
+
roles: snapshotData.roles || [],
|
|
153
|
+
totalCostUsd: snapshotData.totalCostUsd || 0,
|
|
154
|
+
durationMs: snapshotData.durationMs || 0,
|
|
155
|
+
metadata: snapshotData.metadata || {},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tmpPath = path.join(this.baseDir, `${snapshotId}.tmp-${Date.now()}`)
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
fs.writeFileSync(tmpPath, JSON.stringify(record, null, 2), 'utf8')
|
|
162
|
+
fs.renameSync(tmpPath, filePath)
|
|
163
|
+
} catch (err) {
|
|
164
|
+
try {
|
|
165
|
+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath)
|
|
166
|
+
} catch (_) {
|
|
167
|
+
// Safe ignore
|
|
168
|
+
}
|
|
169
|
+
throw err
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Enforce FIFO retention policy: if file count > maxSnapshots, prune oldest
|
|
173
|
+
this._enforceRetention()
|
|
174
|
+
|
|
175
|
+
return record
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Prunes oldest snapshot files when total exceeds maxSnapshots ceiling.
|
|
180
|
+
*/
|
|
181
|
+
_enforceRetention() {
|
|
182
|
+
try {
|
|
183
|
+
const files = fs.readdirSync(this.baseDir).filter((f) => f.endsWith('.json'))
|
|
184
|
+
if (files.length <= this.maxSnapshots) return
|
|
185
|
+
|
|
186
|
+
const fileStats = files.map((file) => {
|
|
187
|
+
const fullPath = path.join(this.baseDir, file)
|
|
188
|
+
const stat = fs.statSync(fullPath)
|
|
189
|
+
return { file, fullPath, mtime: stat.mtimeMs }
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Sort ascending (oldest first)
|
|
193
|
+
fileStats.sort((a, b) => a.mtime - b.mtime)
|
|
194
|
+
|
|
195
|
+
const removeCount = fileStats.length - this.maxSnapshots
|
|
196
|
+
for (let i = 0; i < removeCount; i++) {
|
|
197
|
+
try {
|
|
198
|
+
fs.unlinkSync(fileStats[i].fullPath)
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (this.logger?.warn) {
|
|
201
|
+
this.logger.warn(`[SnapshotManager] Failed to prune old snapshot ${fileStats[i].file}:`, err?.message || err)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (this.logger?.warn) {
|
|
207
|
+
this.logger.warn('[SnapshotManager] Retention enforcement error:', err?.message || err)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Converts a snapshot into a reusable scenario/preset structure.
|
|
214
|
+
* @param {string} snapshotId
|
|
215
|
+
* @param {object} [overrides] Custom name/description
|
|
216
|
+
* @returns {object} Preset configuration object
|
|
217
|
+
*/
|
|
218
|
+
saveAsPreset(snapshotId, overrides = {}) {
|
|
219
|
+
this._pathFor(snapshotId)
|
|
220
|
+
const snapshot = this.getSnapshot(snapshotId)
|
|
221
|
+
if (!snapshot) {
|
|
222
|
+
throw new Error(`Snapshot "${snapshotId}" not found`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const presetId = overrides.id || `preset-${snapshot.scenarioId || 'custom'}-${Date.now().toString(36)}`
|
|
226
|
+
const presetName = overrides.name || overrides.title || `Preset from ${snapshot.title || snapshotId}`
|
|
227
|
+
const presetDescription = overrides.description || `Generated from snapshot ${snapshotId} at ${new Date().toISOString()}`
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
id: presetId,
|
|
231
|
+
name: presetName,
|
|
232
|
+
description: presetDescription,
|
|
233
|
+
scenarioId: snapshot.scenarioId || 'custom',
|
|
234
|
+
stages: snapshot.stages || [],
|
|
235
|
+
modelOverrides: snapshot.modelOverrides || {},
|
|
236
|
+
createdAt: new Date().toISOString(),
|
|
237
|
+
sourceSnapshotId: snapshotId,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic Max-Tokens Watchdog.
|
|
3
|
+
*
|
|
4
|
+
* Implements Issue #75:
|
|
5
|
+
* 1. Turn-end interception when output is truncated due to max-tokens limit.
|
|
6
|
+
* 2. Flush Checkpoint: flushes state to prevent lost progress.
|
|
7
|
+
* 3. Continue-Once Guarantee: strictly allows at most ONE automated continuation per execution.
|
|
8
|
+
* 4. Cycle Protection: if truncation occurs again, halts with MaxTokensLoopError demanding
|
|
9
|
+
* subtask decomposition, preventing infinite loops and token drain.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class MaxTokensLoopError extends Error {
|
|
13
|
+
constructor(message, { executionId, continuationCount = 1 } = {}) {
|
|
14
|
+
super(message)
|
|
15
|
+
this.name = 'MaxTokensLoopError'
|
|
16
|
+
this.executionId = executionId
|
|
17
|
+
this.continuationCount = continuationCount
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Checks whether a given stop reason or response indicates token limit exhaustion.
|
|
23
|
+
*
|
|
24
|
+
* @param {string|object} stopReason
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function isMaxTokensTruncated(stopReason) {
|
|
28
|
+
if (!stopReason) return false
|
|
29
|
+
if (typeof stopReason === 'object') {
|
|
30
|
+
if (stopReason.kind === 'max-tokens' || stopReason.kind === 'length') return true
|
|
31
|
+
if (stopReason.stopReason === 'max_tokens' || stopReason.stopReason === 'length') return true
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const s = String(stopReason).toLowerCase()
|
|
36
|
+
return s === 'max-tokens' || s === 'max_tokens' || s === 'length' || s.includes('token_limit')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wraps a turn / worker execution with Deterministic max-tokens Watchdog.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {function} params.executeTurn Function (continuationCount) => Promise<{ output: string, stopReason: string, raw?: any }>
|
|
44
|
+
* @param {function} [params.flushCheckpoint] Callback to persist session checkpoint before continuation
|
|
45
|
+
* @param {string} [params.continuationPrompt='Continue generation precisely from where you stopped.']
|
|
46
|
+
* @param {string} [params.executionId]
|
|
47
|
+
* @returns {Promise<{ output: string, stopReason: string, continuationCount: number, wasTruncated: boolean }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function runWithMaxTokensWatchdog({
|
|
50
|
+
executeTurn,
|
|
51
|
+
flushCheckpoint,
|
|
52
|
+
continuationPrompt = 'Continue generation precisely from where you stopped.',
|
|
53
|
+
executionId = 'turn',
|
|
54
|
+
logger = null,
|
|
55
|
+
}) {
|
|
56
|
+
let continuationCount = 0
|
|
57
|
+
let combinedOutput = ''
|
|
58
|
+
|
|
59
|
+
// Step 1: Initial turn
|
|
60
|
+
const initialResult = await executeTurn(0, '')
|
|
61
|
+
combinedOutput = initialResult.output || ''
|
|
62
|
+
|
|
63
|
+
if (!isMaxTokensTruncated(initialResult.stopReason)) {
|
|
64
|
+
return {
|
|
65
|
+
output: combinedOutput,
|
|
66
|
+
stopReason: initialResult.stopReason || 'completed',
|
|
67
|
+
continuationCount: 0,
|
|
68
|
+
wasTruncated: false,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Step 2: First truncation detected -> trigger single automated continuation
|
|
73
|
+
continuationCount = 1
|
|
74
|
+
|
|
75
|
+
if (typeof flushCheckpoint === 'function') {
|
|
76
|
+
try {
|
|
77
|
+
await flushCheckpoint()
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (logger?.debug) logger.debug('[MaxTokensWatchdog] flushCheckpoint warning:', err?.message || err)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const secondResult = await executeTurn(continuationCount, continuationPrompt)
|
|
84
|
+
combinedOutput += (combinedOutput ? '\n' : '') + (secondResult.output || '')
|
|
85
|
+
|
|
86
|
+
// Step 3: Cycle Protection Check (Continue-Once Guarantee)
|
|
87
|
+
if (isMaxTokensTruncated(secondResult.stopReason)) {
|
|
88
|
+
throw new MaxTokensLoopError(
|
|
89
|
+
`[MaxTokensWatchdog] Task "${executionId}" truncated by token limit twice. ` +
|
|
90
|
+
`Automated continuation aborted to prevent infinite token consumption. Please decompose the task into smaller subtasks.`,
|
|
91
|
+
{ executionId, continuationCount }
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
output: combinedOutput,
|
|
97
|
+
stopReason: secondResult.stopReason || 'completed',
|
|
98
|
+
continuationCount,
|
|
99
|
+
wasTruncated: true,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Pool Dispatcher for DSH Multi-Agent Orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches stage executions to LLM instances with:
|
|
5
|
+
* - Canonical prefix alignment (Prompt Caching maximization)
|
|
6
|
+
* - Transient retry handling (HTTP 429, network timeouts)
|
|
7
|
+
* - Streaming token collection
|
|
8
|
+
* - Telemetry recording (Cache Hit ratio, duration, token usage)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
buildStaticBaseAnchor,
|
|
13
|
+
buildSharedTaskAnchor,
|
|
14
|
+
formatCumulativeArtifacts,
|
|
15
|
+
assembleAgentMessages,
|
|
16
|
+
extractCacheMetrics,
|
|
17
|
+
} from './cache-prefixer.js'
|
|
18
|
+
import { resolveSpecialistModel } from './model-selection.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Invokes LLM with exponential backoff on recoverable transient errors.
|
|
22
|
+
*/
|
|
23
|
+
export async function callWithTransientRetry(callLlmFn, args, maxRetries = 2, baseDelayMs = 1500) {
|
|
24
|
+
let attempt = 0
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
return await callLlmFn(args)
|
|
28
|
+
} catch (err) {
|
|
29
|
+
attempt++
|
|
30
|
+
const msg = err?.message || String(err)
|
|
31
|
+
const isTransient = /429|rate limit|quota|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
|
|
32
|
+
if (attempt <= maxRetries && isTransient) {
|
|
33
|
+
const delay = baseDelayMs * Math.pow(2, attempt - 1)
|
|
34
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
throw err
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Executes a single pipeline stage with full prompt cache alignment.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} stage Stage definition
|
|
46
|
+
* @param {object} context Execution context
|
|
47
|
+
* @param {object} context.pipeline Global pipeline metadata
|
|
48
|
+
* @param {object} context.agentRole Configured role for this stage
|
|
49
|
+
* @param {object} context.config Plugin configuration
|
|
50
|
+
* @param {function} context.callLlm Function to call DSH LLM
|
|
51
|
+
* @param {object} context.upstreamOutputs Prior stage outputs map
|
|
52
|
+
* @param {function} [context.onStreamDelta] Streaming callback
|
|
53
|
+
* @returns {Promise<{ output: string, metrics: object }>}
|
|
54
|
+
*/
|
|
55
|
+
export async function executeStageWorker(stage, context) {
|
|
56
|
+
const { pipeline, agentRole, config = {}, callLlm, upstreamOutputs = {}, onStreamDelta } = context
|
|
57
|
+
|
|
58
|
+
if (typeof callLlm !== 'function') {
|
|
59
|
+
throw new Error('callLlm function is required to execute stage worker')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 1. Build Layer 1: Static Base Anchor (> 1024 tokens)
|
|
63
|
+
const baseAnchor = buildStaticBaseAnchor({
|
|
64
|
+
projectType: config.projectType || 'dsh-plugin',
|
|
65
|
+
customRules: config.customRules || '',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// 2. Build Layer 2: Shared Task Anchor
|
|
69
|
+
const taskAnchor = buildSharedTaskAnchor({
|
|
70
|
+
id: pipeline.pipelineId,
|
|
71
|
+
title: pipeline.taskTitle,
|
|
72
|
+
description: pipeline.taskDescription,
|
|
73
|
+
repo: pipeline.repo,
|
|
74
|
+
issueUrl: pipeline.issueUrl,
|
|
75
|
+
scenario: pipeline.scenarioId,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// 3. Build Layer 3: Cumulative Upstream Artifacts (Append-Only)
|
|
79
|
+
const priorArtifactsList = []
|
|
80
|
+
for (const [depId, artContent] of Object.entries(upstreamOutputs)) {
|
|
81
|
+
priorArtifactsList.push({
|
|
82
|
+
stageId: depId,
|
|
83
|
+
roleId: stage.dependsOn?.find((d) => d === depId) || 'upstream',
|
|
84
|
+
output: artContent || '',
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
const cumulativeArtifacts = formatCumulativeArtifacts(priorArtifactsList)
|
|
88
|
+
|
|
89
|
+
// 4. Assemble canonical prompt (Layer 1 + 2 + 3 in System, Layer 4 in User)
|
|
90
|
+
const messages = assembleAgentMessages({
|
|
91
|
+
baseAnchor,
|
|
92
|
+
taskAnchor,
|
|
93
|
+
cumulativeArtifacts,
|
|
94
|
+
agentRole,
|
|
95
|
+
currentStage: stage,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const { provider, model, warning, maxTokens: resolvedMaxTokens, reasoningEffort } = resolveSpecialistModel({
|
|
99
|
+
roleModel: agentRole.defaultModel,
|
|
100
|
+
fallbackModel: { provider: 'deepseek', model: 'deepseek-chat' },
|
|
101
|
+
allowedRoutes: context.allowedRoutes,
|
|
102
|
+
capability: agentRole.capability,
|
|
103
|
+
reasoningEffort: agentRole.reasoningEffort,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const log = context.logger || context.config?.logger || null
|
|
107
|
+
if (warning && log?.warn) {
|
|
108
|
+
log.warn(`[dsh-agent-orchestrator] ${warning}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const temperature = agentRole.temperature ?? 0.3
|
|
112
|
+
const maxTokens = resolvedMaxTokens ?? agentRole.maxTokens ?? 4096
|
|
113
|
+
|
|
114
|
+
// 5. Execute LLM call with retry
|
|
115
|
+
const result = await callWithTransientRetry(
|
|
116
|
+
callLlm,
|
|
117
|
+
{
|
|
118
|
+
provider,
|
|
119
|
+
model,
|
|
120
|
+
messages,
|
|
121
|
+
temperature,
|
|
122
|
+
maxTokens,
|
|
123
|
+
reasoningEffort,
|
|
124
|
+
onStreamDelta,
|
|
125
|
+
},
|
|
126
|
+
2,
|
|
127
|
+
1200
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const outputText = result.text || result.content || ''
|
|
131
|
+
const rawUsage = result.usage || {}
|
|
132
|
+
const cacheMetrics = extractCacheMetrics(rawUsage)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
output: outputText,
|
|
136
|
+
metrics: {
|
|
137
|
+
...cacheMetrics,
|
|
138
|
+
model: `${provider}:${model}`,
|
|
139
|
+
stageId: stage.id,
|
|
140
|
+
roleId: stage.roleId,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|