@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,290 @@
1
+ /**
2
+ * Directed Acyclic Graph (DAG) Execution Engine for DSH Multi-Agent Pipelines.
3
+ *
4
+ * Implements:
5
+ * - Topological dependency resolution and cycle detection
6
+ * - Concurrency control with dependency-aware scheduling (parallel independent nodes)
7
+ * - Blockers propagation: dependent nodes get marked as 'blocked' if a prerequisite fails
8
+ * - Event hooks: onNodeStart, onNodeComplete, onNodeFail, onNodeBlocked
9
+ * - Pure logic: completely testable without DSH runtime or network
10
+ */
11
+
12
+ export const NODE_STATUS = {
13
+ PENDING: 'pending',
14
+ RUNNING: 'running',
15
+ COMPLETED: 'completed',
16
+ FAILED: 'failed',
17
+ BLOCKED: 'blocked',
18
+ SKIPPED: 'skipped',
19
+ }
20
+
21
+ /**
22
+ * Validates stage definitions and checks for circular dependencies.
23
+ * @param {Array<object>} stages List of stages { id, name, dependsOn: string[], roleId, ... }
24
+ * @throws {Error} if stages are invalid or contain cycles
25
+ */
26
+ export function validateGraph(stages) {
27
+ if (!Array.isArray(stages) || stages.length === 0) {
28
+ throw new Error('Pipeline must have at least one stage')
29
+ }
30
+
31
+ const ids = new Set()
32
+ for (const s of stages) {
33
+ if (!s.id || typeof s.id !== 'string') {
34
+ throw new Error(`Invalid stage ID: ${JSON.stringify(s)}`)
35
+ }
36
+ if (ids.has(s.id)) {
37
+ throw new Error(`Duplicate stage ID: ${s.id}`)
38
+ }
39
+ ids.add(s.id)
40
+ }
41
+
42
+ // Check that all dependsOn references exist
43
+ for (const s of stages) {
44
+ const deps = s.dependsOn || []
45
+ for (const dep of deps) {
46
+ if (!ids.has(dep)) {
47
+ throw new Error(`Stage "${s.id}" depends on non-existent stage "${dep}"`)
48
+ }
49
+ if (dep === s.id) {
50
+ throw new Error(`Stage "${s.id}" cannot depend on itself`)
51
+ }
52
+ }
53
+ }
54
+
55
+ // Cycle detection via DFS
56
+ const visited = new Map() // id -> 0 (unvisited), 1 (visiting), 2 (visited)
57
+ const adj = new Map()
58
+ for (const s of stages) {
59
+ visited.set(s.id, 0)
60
+ adj.set(s.id, s.dependsOn || [])
61
+ }
62
+
63
+ function dfs(nodeId, path = []) {
64
+ visited.set(nodeId, 1)
65
+ const currentPath = [...path, nodeId]
66
+ const neighbors = adj.get(nodeId) || []
67
+ for (const n of neighbors) {
68
+ const state = visited.get(n)
69
+ if (state === 1) {
70
+ const cycle = [...currentPath.slice(currentPath.indexOf(n)), n].join(' -> ')
71
+ throw new Error(`Circular dependency detected: ${cycle}`)
72
+ }
73
+ if (state === 0) {
74
+ dfs(n, currentPath)
75
+ }
76
+ }
77
+ visited.set(nodeId, 2)
78
+ }
79
+
80
+ for (const s of stages) {
81
+ if (visited.get(s.id) === 0) {
82
+ dfs(s.id)
83
+ }
84
+ }
85
+
86
+ return true
87
+ }
88
+
89
+ /**
90
+ * Returns stages whose dependencies are 100% completed.
91
+ */
92
+ export function getRunnableStages(stages, stateMap) {
93
+ return stages.filter((s) => {
94
+ const currentState = stateMap.get(s.id)?.status || NODE_STATUS.PENDING
95
+ if (currentState !== NODE_STATUS.PENDING) {
96
+ return false
97
+ }
98
+ const deps = s.dependsOn || []
99
+ return deps.every((d) => stateMap.get(d)?.status === NODE_STATUS.COMPLETED)
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Identifies stages blocked by upstream failures.
105
+ */
106
+ export function markBlockedStages(stages, stateMap) {
107
+ let changed = false
108
+ for (const s of stages) {
109
+ const state = stateMap.get(s.id)
110
+ if (state.status === NODE_STATUS.PENDING) {
111
+ const deps = s.dependsOn || []
112
+ const failedDep = deps.find((d) => {
113
+ const ds = stateMap.get(d)?.status
114
+ return ds === NODE_STATUS.FAILED || ds === NODE_STATUS.BLOCKED
115
+ })
116
+ if (failedDep) {
117
+ state.status = NODE_STATUS.BLOCKED
118
+ state.blockedBy = failedDep
119
+ state.error = `Blocked due to failure of upstream stage: ${failedDep}`
120
+ changed = true
121
+ }
122
+ }
123
+ }
124
+ return changed
125
+ }
126
+
127
+ /**
128
+ * Executes a pipeline of stages respecting DAG dependencies, concurrency, and blockers.
129
+ *
130
+ * @param {object} options
131
+ * @param {Array<object>} options.stages
132
+ * @param {function} options.executor async (stage, context) => stageResult
133
+ * @param {number} [options.concurrency=4] max parallel tasks
134
+ * @param {object} [options.initialContext={}]
135
+ * @param {function} [options.onNodeStart]
136
+ * @param {function} [options.onNodeComplete]
137
+ * @param {function} [options.onNodeFail]
138
+ * @param {function} [options.onNodeBlocked]
139
+ /**
140
+ * Executes a pipeline of stages respecting DAG dependencies, concurrency, and blockers.
141
+ * Supports both object ({ stages, executor, concurrency }) and positional (stages, executor, options) calls.
142
+ */
143
+ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {}) {
144
+ let stages
145
+ let executor
146
+ let concurrency = 4
147
+ let initialContext = {}
148
+ let onNodeStart = () => {}
149
+ let onNodeComplete = () => {}
150
+ let onNodeFail = () => {}
151
+ let onNodeBlocked = () => {}
152
+
153
+ if (Array.isArray(stagesOrOptions)) {
154
+ stages = stagesOrOptions
155
+ executor = executorArg
156
+ concurrency = optionsArg.concurrency || optionsArg.maxConcurrency || 4
157
+ initialContext = optionsArg.initialContext || {}
158
+ if (typeof optionsArg.onNodeStart === 'function') onNodeStart = optionsArg.onNodeStart
159
+ if (typeof optionsArg.onNodeComplete === 'function') onNodeComplete = optionsArg.onNodeComplete
160
+ if (typeof optionsArg.onNodeFail === 'function') onNodeFail = optionsArg.onNodeFail
161
+ if (typeof optionsArg.onNodeBlocked === 'function') onNodeBlocked = optionsArg.onNodeBlocked
162
+ } else if (stagesOrOptions && typeof stagesOrOptions === 'object') {
163
+ stages = stagesOrOptions.stages
164
+ executor = stagesOrOptions.executor
165
+ concurrency = stagesOrOptions.concurrency || stagesOrOptions.maxConcurrency || 4
166
+ initialContext = stagesOrOptions.initialContext || {}
167
+ if (typeof stagesOrOptions.onNodeStart === 'function') onNodeStart = stagesOrOptions.onNodeStart
168
+ if (typeof stagesOrOptions.onNodeComplete === 'function') onNodeComplete = stagesOrOptions.onNodeComplete
169
+ if (typeof stagesOrOptions.onNodeFail === 'function') onNodeFail = stagesOrOptions.onNodeFail
170
+ if (typeof stagesOrOptions.onNodeBlocked === 'function') onNodeBlocked = stagesOrOptions.onNodeBlocked
171
+ }
172
+
173
+ validateGraph(stages)
174
+
175
+ const startTime = Date.now()
176
+ const stateMap = new Map()
177
+ const artifacts = new Map() // stageId -> artifact output
178
+
179
+ for (const s of stages) {
180
+ stateMap.set(s.id, {
181
+ id: s.id,
182
+ name: s.name || s.id,
183
+ roleId: s.roleId,
184
+ status: NODE_STATUS.PENDING,
185
+ dependsOn: s.dependsOn || [],
186
+ startedAt: null,
187
+ completedAt: null,
188
+ durationMs: 0,
189
+ output: null,
190
+ error: null,
191
+ metrics: null,
192
+ })
193
+ }
194
+
195
+ let runningCount = 0
196
+ let isDone = false
197
+
198
+ return new Promise((resolvePromise, rejectPromise) => {
199
+ function checkCompletion() {
200
+ const allDone = stages.every((s) => {
201
+ const st = stateMap.get(s.id).status
202
+ return st === NODE_STATUS.COMPLETED || st === NODE_STATUS.FAILED || st === NODE_STATUS.BLOCKED || st === NODE_STATUS.SKIPPED
203
+ })
204
+
205
+ if (allDone && runningCount === 0 && !isDone) {
206
+ isDone = true
207
+ const hasFailures = stages.some((s) => {
208
+ const st = stateMap.get(s.id).status
209
+ return st === NODE_STATUS.FAILED || st === NODE_STATUS.BLOCKED
210
+ })
211
+ resolvePromise({
212
+ success: !hasFailures,
213
+ durationMs: Date.now() - startTime,
214
+ stateMap: Object.fromEntries(stateMap),
215
+ artifacts: Object.fromEntries(artifacts),
216
+ })
217
+ }
218
+ }
219
+
220
+ function triggerNext() {
221
+ if (isDone) return
222
+
223
+ // Propagate blockers first
224
+ if (markBlockedStages(stages, stateMap)) {
225
+ for (const s of stages) {
226
+ const st = stateMap.get(s.id)
227
+ if (st.status === NODE_STATUS.BLOCKED && !st.notifiedBlocked) {
228
+ st.notifiedBlocked = true
229
+ onNodeBlocked(st)
230
+ }
231
+ }
232
+ }
233
+
234
+ // Find runnable candidates
235
+ const runnable = getRunnableStages(stages, stateMap)
236
+
237
+ while (runnable.length > 0 && runningCount < concurrency) {
238
+ const nextStage = runnable.shift()
239
+ const nodeState = stateMap.get(nextStage.id)
240
+ nodeState.status = NODE_STATUS.RUNNING
241
+ nodeState.startedAt = Date.now()
242
+ runningCount++
243
+
244
+ onNodeStart(nodeState)
245
+
246
+ // Run task asynchronously
247
+ const upstreamOutputs = {}
248
+ for (const dep of nextStage.dependsOn || []) {
249
+ upstreamOutputs[dep] = artifacts.get(dep)
250
+ }
251
+
252
+ const runContext = {
253
+ ...initialContext,
254
+ upstreamOutputs,
255
+ allArtifacts: Object.fromEntries(artifacts),
256
+ }
257
+
258
+ Promise.resolve()
259
+ .then(() => executor(nextStage, runContext))
260
+ .then((result) => {
261
+ nodeState.status = NODE_STATUS.COMPLETED
262
+ nodeState.completedAt = Date.now()
263
+ nodeState.durationMs = nodeState.completedAt - nodeState.startedAt
264
+ nodeState.output = result?.output ?? result
265
+ nodeState.metrics = result?.metrics ?? null
266
+
267
+ artifacts.set(nextStage.id, nodeState.output)
268
+ runningCount--
269
+ onNodeComplete(nodeState)
270
+ triggerNext()
271
+ })
272
+ .catch((err) => {
273
+ nodeState.status = NODE_STATUS.FAILED
274
+ nodeState.completedAt = Date.now()
275
+ nodeState.durationMs = nodeState.completedAt - nodeState.startedAt
276
+ nodeState.error = err?.message || String(err)
277
+
278
+ runningCount--
279
+ onNodeFail(nodeState, err)
280
+ triggerNext()
281
+ })
282
+ }
283
+
284
+ checkCompletion()
285
+ }
286
+
287
+ // Initial trigger
288
+ triggerNext()
289
+ })
290
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Multi-layered Decision Trace Ledger.
3
+ *
4
+ * Implements Issue #108:
5
+ * - Records reasons for agent selection, model resolution, tool filtering, and execution timings.
6
+ * - Formatted for injection into `presentationMeta` so UI can inspect the trace without polluting LLM context.
7
+ * - Strict size limiter (`assertTraceSize <= 4096B`) with cascade truncation of secondary fields:
8
+ * logs -> checks -> calls -> summary.
9
+ */
10
+
11
+ export const MAX_TRACE_BYTES = 4096
12
+
13
+ /**
14
+ * Validates and measures the JSON-serialized byte size of a decision trace.
15
+ * @param {object} trace
16
+ * @returns {number}
17
+ */
18
+ export function getTraceByteLength(trace) {
19
+ return Buffer.byteLength(JSON.stringify(trace || {}), 'utf-8')
20
+ }
21
+
22
+ /**
23
+ * Truncates trace cascadingly if it exceeds MAX_TRACE_BYTES.
24
+ * Hierarchy of reduction:
25
+ * 1. Truncate long log messages (to max 64 chars)
26
+ * 2. Drop debug logs array entirely
27
+ * 3. Truncate droppedTools array
28
+ * 4. Truncate reasoning/rationale text
29
+ * 5. Minimal fallback summary
30
+ *
31
+ * @param {object} rawTrace
32
+ * @param {number} [maxBytes=MAX_TRACE_BYTES]
33
+ * @returns {object} Guaranteed <= maxBytes
34
+ */
35
+ export function sanitizeDecisionTrace(rawTrace, maxBytes = MAX_TRACE_BYTES) {
36
+ if (!rawTrace || typeof rawTrace !== 'object') {
37
+ return { error: 'Empty trace' }
38
+ }
39
+
40
+ let trace = JSON.parse(JSON.stringify(rawTrace))
41
+ if (getTraceByteLength(trace) <= maxBytes) {
42
+ return trace
43
+ }
44
+
45
+ // Level 1: Truncate logs items
46
+ if (Array.isArray(trace.logs)) {
47
+ trace.logs = trace.logs.map((l) =>
48
+ typeof l === 'string' && l.length > 64 ? `${l.slice(0, 61)}...` : l
49
+ )
50
+ if (getTraceByteLength(trace) <= maxBytes) return trace
51
+ }
52
+
53
+ // Level 2: Drop logs array completely
54
+ if (trace.logs) {
55
+ delete trace.logs
56
+ if (getTraceByteLength(trace) <= maxBytes) return trace
57
+ }
58
+
59
+ // Level 3: Compact droppedTools
60
+ if (Array.isArray(trace.droppedTools) && trace.droppedTools.length > 5) {
61
+ const count = trace.droppedTools.length
62
+ trace.droppedTools = trace.droppedTools.slice(0, 5)
63
+ trace.droppedToolsCount = count
64
+ if (getTraceByteLength(trace) <= maxBytes) return trace
65
+ }
66
+
67
+ // Level 4: Truncate rationale / context
68
+ if (typeof trace.rationale === 'string' && trace.rationale.length > 120) {
69
+ trace.rationale = `${trace.rationale.slice(0, 117)}...`
70
+ if (getTraceByteLength(trace) <= maxBytes) return trace
71
+ }
72
+
73
+ // Level 5: Aggressive compaction to minimal essence
74
+ return {
75
+ executionId: trace.executionId,
76
+ roleId: trace.roleId,
77
+ model: trace.model,
78
+ decision: trace.decision || 'delegated',
79
+ droppedCount: Array.isArray(rawTrace.droppedTools) ? rawTrace.droppedTools.length : 0,
80
+ truncated: true,
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Creates a structured Decision Trace object.
86
+ */
87
+ export function createDecisionTrace({
88
+ executionId,
89
+ roleId,
90
+ roleName,
91
+ requestedModel,
92
+ assignedModel,
93
+ selectionReason = 'preset_match',
94
+ toolSummary,
95
+ droppedTools = [],
96
+ depth = 1,
97
+ durationMs,
98
+ status = 'completed',
99
+ rationale,
100
+ }) {
101
+ const trace = {
102
+ executionId,
103
+ timestamp: Date.now(),
104
+ roleId,
105
+ roleName,
106
+ depth,
107
+ model: {
108
+ requested: requestedModel,
109
+ assigned: assignedModel,
110
+ reason: selectionReason,
111
+ },
112
+ tools: {
113
+ allowedCount: toolSummary?.allowed?.length ?? 0,
114
+ strippedCount: toolSummary?.strippedSecurity?.length ?? 0,
115
+ deniedCount: toolSummary?.denied?.length ?? 0,
116
+ },
117
+ droppedTools: droppedTools.slice(0, 15),
118
+ rationale,
119
+ durationMs,
120
+ status,
121
+ }
122
+
123
+ return sanitizeDecisionTrace(trace, MAX_TRACE_BYTES)
124
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Task Decomposition Engine for DSH Multi-Agent Orchestrator.
3
+ *
4
+ * Automatically or semi-automatically breaks down tasks received from
5
+ * DSH chat or Gitea Kanban into concrete subtask stages based on chosen or inferred scenario.
6
+ */
7
+
8
+ import { getDefaultScenarios, getDefaultRoles, validateScenario } from './scenarios.js'
9
+
10
+ /**
11
+ * Infers scenario complexity based on task description cues.
12
+ *
13
+ * @param {string} taskText
14
+ * @returns {'simple' | 'medium' | 'complex'}
15
+ */
16
+ /**
17
+ * Natural language complexity triggers for English and Russian input (Issue #133).
18
+ */
19
+ export const COMPLEXITY_TRIGGERS = {
20
+ complex: /новый плагин|с нуля|полная архитектур|рефакторинг всего|from scratch|new plugin|system design|major refactor|complex pipeline|5-6 этап|сложн/i,
21
+ simple: /баг|опечатк|поправь|быстро|мелк|hotfix|bugfix|typo|quick fix|minor|bump|1-2 этап|прост/i,
22
+ }
23
+
24
+ export function inferScenarioComplexity(taskText = '') {
25
+ const text = (taskText || '').toLowerCase()
26
+
27
+ if (COMPLEXITY_TRIGGERS.complex.test(text)) {
28
+ return 'complex'
29
+ }
30
+
31
+ if (COMPLEXITY_TRIGGERS.simple.test(text)) {
32
+ return 'simple'
33
+ }
34
+
35
+ return 'medium'
36
+ }
37
+
38
+ /**
39
+ * Decomposes a task into a customized execution plan using a scenario preset.
40
+ *
41
+ * @param {object} params
42
+ * @param {string} params.taskTitle
43
+ * @param {string} params.taskDescription
44
+ * @param {string} [params.scenarioId] 'simple' | 'medium' | 'complex' or 'auto'
45
+ * @param {object} [params.customScenarios]
46
+ * @param {object} [params.customRoles]
47
+ * @returns {object} Decomposed pipeline plan { scenarioId, stages, taskTitle, taskDescription }
48
+ */
49
+ export function decomposeTask({
50
+ taskTitle = '',
51
+ taskDescription = '',
52
+ scenarioId = 'auto',
53
+ customScenarios = null,
54
+ customRoles = null,
55
+ }) {
56
+ const scenarios = customScenarios || getDefaultScenarios()
57
+ const roles = customRoles || getDefaultRoles()
58
+
59
+ let selectedScenarioId = scenarioId
60
+ if (!selectedScenarioId || selectedScenarioId === 'auto') {
61
+ selectedScenarioId = inferScenarioComplexity(`${taskTitle}\n${taskDescription}`)
62
+ }
63
+
64
+ const scenario = scenarios[selectedScenarioId] || scenarios.medium
65
+ if (!scenario) {
66
+ throw new Error(`Scenario "${selectedScenarioId}" not found and fallback scenario "medium" is missing`)
67
+ }
68
+ validateScenario(scenario)
69
+
70
+ // Clone stages and inject customized task scope
71
+ const instantiatedStages = scenario.stages.map((stage) => {
72
+ const role = roles[stage.roleId] || {}
73
+ let customizedScope = stage.subtaskScope || ''
74
+
75
+ // Contextualize subtask instructions with the overall goal
76
+ customizedScope = [
77
+ `Overall Objective: "${taskTitle}"`,
78
+ `Deliverable Category: ${role.category || stage.roleId}`,
79
+ `Specific Guidance: ${customizedScope}`,
80
+ `Task Details:\n${taskDescription.trim()}`,
81
+ ].join('\n\n')
82
+
83
+ return {
84
+ ...stage,
85
+ subtaskScope: customizedScope,
86
+ roleName: role.name || stage.roleId,
87
+ assignedModel: role.defaultModel || { provider: 'deepseek', model: 'deepseek-chat' },
88
+ skills: role.skills || [],
89
+ }
90
+ })
91
+
92
+ return {
93
+ pipelineId: `pipe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
94
+ scenarioId: selectedScenarioId,
95
+ scenarioTitle: scenario.title,
96
+ taskTitle: taskTitle || 'Untitled Pipeline Task',
97
+ taskDescription: taskDescription || '',
98
+ stages: instantiatedStages,
99
+ createdAt: Date.now(),
100
+ }
101
+ }
102
+