@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,94 @@
1
+ /**
2
+ * System Prompt Guidance and Specialist Roster Injection.
3
+ *
4
+ * Injects section `orchestrator:roster` into the main chat agent's system prompt:
5
+ * - Informs the agent of all active specialized roles and their capabilities
6
+ * - Filters disabled roles (r.enabled !== false) (Issue #101)
7
+ * - Provides instructions on when to call `orchestrator_delegate_specialist`
8
+ * - Enforces Strict Separation of Duties (Design/Frontend vs Backend)
9
+ */
10
+
11
+ import { toRolesArray } from './delegation.js'
12
+
13
+ export const ORCHESTRATOR_SECTION_NAME = 'orchestrator:roster'
14
+ export const ORCHESTRATOR_SECTION_ORDER = 55
15
+
16
+ /**
17
+ * Pure projection of available roles onto system prompt guidance.
18
+ *
19
+ * @param {Array<object>|object} roles Available roles list or dict
20
+ * @param {string} [toolName] Delegation tool name
21
+ * @returns {string}
22
+ */
23
+ export function renderOrchestratorRoster(roles = [], toolName = 'orchestrator_delegate_specialist') {
24
+ const allRoles = toRolesArray(roles)
25
+ // Enabled Roster Filter (Issue #101): only advertise active/enabled roles
26
+ const rolesList = allRoles.filter((r) => r.enabled !== false)
27
+ if (!rolesList || rolesList.length === 0) return ''
28
+
29
+ const lines = [
30
+ '### Multi-Agent Orchestrator: Specialized Subagent Roster',
31
+ 'You have access to a pool of specialized autonomous subagents. When the user asks you to give a task to a specialist or subagent (e.g. "дай задачу субагенту...", "попроси дизайнера...", "проверь через QA..."), NEVER do that specialized work yourself in a generic way — DELEGATE it immediately using the `' +
32
+ toolName +
33
+ '` tool.',
34
+ '',
35
+ '**Available Specialist Roles (use exact role ID in tool calls):**',
36
+ ]
37
+
38
+ for (const role of rolesList) {
39
+ const modelTag = role.defaultModel
40
+ ? ` [Model: ${role.defaultModel.provider}:${role.defaultModel.model}]`
41
+ : ''
42
+ lines.push(
43
+ `- **${role.id}** (${role.displayName})${modelTag}: ${role.description}`
44
+ )
45
+ lines.push(` → Delegate via: \`${toolName}({ roleId: "${role.id}", task: "..." })\``)
46
+ }
47
+
48
+ lines.push('')
49
+ lines.push('**Core Delegation & Governance Rules:**')
50
+ lines.push(
51
+ '1. **Single Specialist Delegation**: When the user requests a single domain task ("дай задачу субагенту дизайнеру...", "пусть архитектор опишет..."), invoke `' +
52
+ toolName +
53
+ '` with the appropriate role ID and full context. Report the specialist\'s deliverable back to the user.'
54
+ )
55
+ lines.push(
56
+ '2. **Full Multi-Agent Pipeline**: When the user requests an end-to-end coordinated project ("сделай через оркестратор...", "/orchestrate <task>"), dispatch a full orchestrated DAG pipeline via `orchestrator_dispatch`.'
57
+ )
58
+ lines.push(
59
+ '3. **Strict Separation of Duties**: Design/Frontend specialists must NEVER implement database schemas or server backend endpoints. Backend specialists must NEVER design UI layouts or CSS styles. QA specialists must independently test contracts.'
60
+ )
61
+ lines.push(
62
+ '4. **Visual Deliverable Status Callout**: Whenever presenting the output of a subagent delegation to the user in chat, you MUST ALWAYS include a clean, prominent callout banner at the very top of your response:\n' +
63
+ ' - When you have reviewed the subagent deliverable and accepted it without issues:\n' +
64
+ ' `> 🟢 **Получен результат работы от субагента [<Имя роли>] и принят агентом**`\n' +
65
+ ' - When the deliverable had issues, needed changes, or you re-invoked the subagent for revisions:\n' +
66
+ ' `> 🟡 **Результат получен от субагента [<Имя роли>] и отправлен на доработку (итерация X)**`\n' +
67
+ ' - When the user asks you to send work back for rework or revisions:\n' +
68
+ ' `> 🔄 **Задача отправлена на доработку субагенту [<Имя роли>]**`'
69
+ )
70
+
71
+ return lines.join('\n')
72
+ }
73
+
74
+ /**
75
+ * Registers the specialist roster with the systemPrompt service.
76
+ *
77
+ * @param {object} ctx Cordis context
78
+ * @param {function} getRoles Function returning current roles snapshot
79
+ * @param {string} [toolName] Delegation tool name to reference
80
+ */
81
+ export function applyGuidance(ctx, getRoles, toolName = 'orchestrator_delegate_specialist', options = {}) {
82
+ ctx.inject(['systemPrompt'], (sctx) => {
83
+ try {
84
+ sctx.systemPrompt.addSection({
85
+ name: ORCHESTRATOR_SECTION_NAME,
86
+ order: ORCHESTRATOR_SECTION_ORDER,
87
+ render: () => renderOrchestratorRoster(getRoles(), toolName),
88
+ })
89
+ } catch (e) {
90
+ const log = options.logger || ctx.logger || null
91
+ if (log?.warn) log.warn('[dsh-agent-orchestrator] Guidance section registration warning:', e?.message || e)
92
+ }
93
+ })
94
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Natural language intent detection for Multi-Agent Orchestrator.
3
+ *
4
+ * Recognizes:
5
+ * - Slash commands: /orchestrate, /orc
6
+ * - Russian triggers: "сделай через оркестратор", "запусти оркестратор", "используй режим оркестратора"
7
+ * - English triggers: "orchestrate:", "orchestrate <task>", "use orchestrate mode"
8
+ */
9
+
10
+ const KNOWN_SCENARIOS = ['hotfix', 'simple', 'medium', 'complex', 'enterprise', 'auto']
11
+
12
+ /**
13
+ * Extracts optional scenario keyword if the first token is a known scenario.
14
+ */
15
+ function extractScenarioAndTask(rawText) {
16
+ const trimmed = (rawText || '').trim()
17
+ if (!trimmed) {
18
+ return { scenarioId: 'auto', taskTitle: 'Interactive Orchestrated Task' }
19
+ }
20
+
21
+ const parts = trimmed.split(/\s+/)
22
+ const first = parts[0].toLowerCase().replace(/^[:#]/, '')
23
+ if (KNOWN_SCENARIOS.includes(first) && parts.length > 1) {
24
+ return {
25
+ scenarioId: first,
26
+ taskTitle: parts.slice(1).join(' ').trim(),
27
+ }
28
+ }
29
+
30
+ return {
31
+ scenarioId: 'auto',
32
+ taskTitle: trimmed,
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Detects whether an incoming chat message is an orchestration request.
38
+ *
39
+ * @param {string} text Raw message text
40
+ * @returns {{ isTrigger: boolean, action?: 'on' | 'off', scenarioId?: string, taskTitle?: string }}
41
+ */
42
+ export function detectOrchestratorIntent(text) {
43
+ if (typeof text !== 'string' || !text.trim()) {
44
+ return { isTrigger: false }
45
+ }
46
+
47
+ const trimmed = text.trim()
48
+
49
+ // 1. Slash command: /orchestrate [scenario] [task] or /orc [scenario] [task]
50
+ const slashMatch = trimmed.match(/^\/(?:orchestrate|orc)(?:\s+(.*))?$/i)
51
+ if (slashMatch) {
52
+ const rest = (slashMatch[1] || '').trim()
53
+ if (rest.toLowerCase() === 'off') {
54
+ return { isTrigger: true, action: 'off' }
55
+ }
56
+ const { scenarioId, taskTitle } = extractScenarioAndTask(rest)
57
+ return {
58
+ isTrigger: true,
59
+ action: 'on',
60
+ scenarioId,
61
+ taskTitle,
62
+ }
63
+ }
64
+
65
+ // 2. Russian natural language triggers
66
+ // e.g. "сделай через оркестратор: ...", "запусти оркестратор: ...", "используй режим оркестратора ..."
67
+ const ruMatch = trimmed.match(
68
+ /^(?:пожалуйста[, ]*)?(?:сделай(?:\s+это)?\s+через\s+оркестратор|запусти(?:\s+задачу\s+через)?\s+оркестратор|используй\s+режим\s+оркестратора)(?:[\s:]+(.*))?$/i
69
+ )
70
+ if (ruMatch) {
71
+ const rest = (ruMatch[1] || '').trim()
72
+ const { scenarioId, taskTitle } = extractScenarioAndTask(rest)
73
+ return {
74
+ isTrigger: true,
75
+ action: 'on',
76
+ scenarioId,
77
+ taskTitle,
78
+ }
79
+ }
80
+
81
+ // 3. English natural language triggers
82
+ // e.g. "orchestrate: ...", "use orchestrate mode: ...", "run in orchestrator mode: ..."
83
+ const enMatch = trimmed.match(
84
+ /^(?:please\s+)?(?:orchestrate|use\s+orchestrate\s+mode|run\s+(?:this\s+)?in\s+orchestrator\s+mode)(?:[\s:]+(.*))?$/i
85
+ )
86
+ if (enMatch) {
87
+ const rest = (enMatch[1] || '').trim()
88
+ const { scenarioId, taskTitle } = extractScenarioAndTask(rest)
89
+ return {
90
+ isTrigger: true,
91
+ action: 'on',
92
+ scenarioId,
93
+ taskTitle,
94
+ }
95
+ }
96
+
97
+ return { isTrigger: false }
98
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Tool Intersection & Security Narrowing Engine.
3
+ *
4
+ * Enforces the core invariants:
5
+ * 1. A subagent/specialist MUST NEVER receive more capabilities or tools than its parent.
6
+ * 2. Anti-Redelegation Shield (Issue #103): child subagents cannot receive delegation tools
7
+ * (agent_run, orchestrator_*, delegate, list_subagents).
8
+ * 3. Leaf Experts Guard (Issue #102): leaf specialized experts run with maxDepth: 1 and cannot spawn child tasks.
9
+ * 4. Adaptive Tool Filter Sanitization & droppedTools (Issue #111):
10
+ * - Automatic intersection of role tools with parent/host capabilities.
11
+ * - Detailed droppedTools diagnostics categorized into 'security', 'denied', 'unknown', or 'session-local'.
12
+ * 5. Bidirectional Tool Synonym Resolution: bridges DSH native tool names
13
+ * (read, edit, write, glob, grep, bash) with canonical agent aliases (view_file,
14
+ * replace_file_content, write_to_file, find_by_name, grep_search, run_command).
15
+ * 6. Fail-Closed Allowlist Guard (Issue #104): If the role expects a non-empty toolset
16
+ * but the allowed tools set resolves to empty, delegation is blocked with FailClosedAllowlistError.
17
+ */
18
+
19
+ export class FailClosedAllowlistError extends Error {
20
+ constructor(message, details = {}) {
21
+ super(message)
22
+ this.name = 'Error'
23
+ this.code = 'ERR_FAIL_CLOSED_ALLOWLIST'
24
+ this.isFailClosed = true
25
+ this.details = details
26
+ }
27
+ }
28
+
29
+ export const FORBIDDEN_SECURITY_TOOLS = new Set([
30
+ 'run_code',
31
+ 'code_exec',
32
+ 'system_exec',
33
+ 'terminal_exec',
34
+ ])
35
+
36
+ export const FORBIDDEN_DELEGATION_TOOLS = new Set([
37
+ 'agent_run',
38
+ 'orchestrator_delegate_specialist',
39
+ 'orchestrator_dispatch',
40
+ 'orchestrator_run',
41
+ 'delegate',
42
+ 'list_subagents',
43
+ 'subagent',
44
+ ])
45
+
46
+ export const TOOL_SYNONYMS = {
47
+ // Read
48
+ read: ['read', 'view_file', 'read_file', 'cat'],
49
+ view_file: ['read', 'view_file', 'read_file', 'cat'],
50
+ read_file: ['read', 'view_file', 'read_file', 'cat'],
51
+
52
+ // Write
53
+ write: ['write', 'write_to_file', 'create_file'],
54
+ write_to_file: ['write', 'write_to_file', 'create_file'],
55
+
56
+ // Edit
57
+ edit: ['edit', 'replace_file_content', 'edit_file', 'str_replace_editor'],
58
+ replace_file_content: ['edit', 'replace_file_content', 'edit_file', 'str_replace_editor'],
59
+ edit_file: ['edit', 'replace_file_content', 'edit_file', 'str_replace_editor'],
60
+
61
+ // Glob / Find
62
+ glob: ['glob', 'find_by_name', 'find_files', 'find'],
63
+ find_by_name: ['glob', 'find_by_name', 'find_files', 'find'],
64
+
65
+ // Grep / Search
66
+ grep: ['grep', 'grep_search', 'search_text'],
67
+ grep_search: ['grep', 'grep_search', 'search_text'],
68
+
69
+ // Command / Bash
70
+ bash: ['bash', 'run_command', 'execute_command', 'shell'],
71
+ run_command: ['bash', 'run_command', 'execute_command', 'shell'],
72
+
73
+ // Web
74
+ search_web: ['search_web', 'web_search', 'google_search'],
75
+ web_search: ['search_web', 'web_search', 'google_search'],
76
+ read_url_content: ['read_url_content', 'web_fetch', 'fetch_url', 'curl'],
77
+ web_fetch: ['read_url_content', 'web_fetch', 'fetch_url', 'curl'],
78
+ }
79
+
80
+ /**
81
+ * Normalizes tool list from strings or tool schemas.
82
+ * @param {Array<string|object>} tools
83
+ * @returns {string[]}
84
+ */
85
+ export function normalizeToolNames(tools) {
86
+ if (!tools || !Array.isArray(tools)) return []
87
+ return tools
88
+ .map((t) => (typeof t === 'string' ? t : t?.name))
89
+ .filter((n) => typeof n === 'string' && n.trim() !== '')
90
+ }
91
+
92
+ /**
93
+ * Finds if a candidate tool or any of its known synonyms exists in parentSet.
94
+ * Returns the exact tool name available in parentSet, or null.
95
+ */
96
+ function matchParentTool(toolName, parentSet) {
97
+ if (!parentSet) return toolName
98
+ if (parentSet.has(toolName)) return toolName
99
+
100
+ const synonyms = TOOL_SYNONYMS[toolName] || []
101
+ for (const syn of synonyms) {
102
+ if (parentSet.has(syn)) {
103
+ return syn
104
+ }
105
+ }
106
+ return null
107
+ }
108
+
109
+ /**
110
+ * Calculates the safe tool intersection for a specialist child session with
111
+ * comprehensive droppedTools diagnostics (Issue #111) and fail-closed allowlist guard (Issue #104).
112
+ *
113
+ * @param {object} params
114
+ * @param {Array<string|object>} [params.parentTools] Tools available to parent session (undefined = standalone/test mode)
115
+ * @param {Array<string|object>} [params.roleTools] Tools declared/requested by the role
116
+ * @param {Array<string>} [params.denyList] Additional tools explicitly denied by config
117
+ * @param {boolean} [params.failLoud=true] Throw error if intersection is empty
118
+ * @param {string} [params.roleId] Role identifier for diagnostic error reporting
119
+ * @returns {{ tools: string[], droppedTools: Array<{ tool: string, reason: string }>, diff: object }}
120
+ */
121
+ export function resolveToolIntersection({
122
+ parentTools = undefined,
123
+ roleTools = undefined,
124
+ denyList = [],
125
+ failLoud = true,
126
+ roleId = 'specialist',
127
+ } = {}) {
128
+ const normParent = parentTools !== undefined ? normalizeToolNames(parentTools) : undefined
129
+ const normRole = roleTools !== undefined ? normalizeToolNames(roleTools) : undefined
130
+ const normDeny = new Set(normalizeToolNames(denyList))
131
+
132
+ const parentSet = normParent !== undefined ? new Set(normParent) : null
133
+ const strippedSecurity = new Set()
134
+ const denied = new Set()
135
+ const allowed = new Set()
136
+ const droppedTools = []
137
+
138
+ const candidates = normRole !== undefined ? normRole : normParent !== undefined ? normParent : []
139
+
140
+ // Security shield: track dangerous tools present in parent that are never allowed in children
141
+ if (normParent !== undefined) {
142
+ for (const tool of normParent) {
143
+ if (FORBIDDEN_SECURITY_TOOLS.has(tool)) {
144
+ strippedSecurity.add(tool)
145
+ droppedTools.push({ tool, reason: 'security' })
146
+ } else if (FORBIDDEN_DELEGATION_TOOLS.has(tool)) {
147
+ strippedSecurity.add(tool)
148
+ droppedTools.push({ tool, reason: 'anti-redelegation' })
149
+ }
150
+ }
151
+ }
152
+
153
+ for (const tool of candidates) {
154
+ if (FORBIDDEN_SECURITY_TOOLS.has(tool)) {
155
+ strippedSecurity.add(tool)
156
+ if (!droppedTools.some((d) => d.tool === tool)) {
157
+ droppedTools.push({ tool, reason: 'security' })
158
+ }
159
+ continue
160
+ }
161
+ if (FORBIDDEN_DELEGATION_TOOLS.has(tool)) {
162
+ strippedSecurity.add(tool)
163
+ if (!droppedTools.some((d) => d.tool === tool)) {
164
+ droppedTools.push({ tool, reason: 'anti-redelegation' })
165
+ }
166
+ continue
167
+ }
168
+ if (normDeny.has(tool)) {
169
+ denied.add(tool)
170
+ if (!droppedTools.some((d) => d.tool === tool)) {
171
+ droppedTools.push({ tool, reason: 'denied' })
172
+ }
173
+ continue
174
+ }
175
+
176
+ // Intersection with parent if parent tools constraint is provided
177
+ if (parentSet !== null) {
178
+ const parentMatch = matchParentTool(tool, parentSet)
179
+ if (!parentMatch) {
180
+ denied.add(tool)
181
+ if (!droppedTools.some((d) => d.tool === tool)) {
182
+ droppedTools.push({ tool, reason: 'unknown-or-unsupported' })
183
+ }
184
+ continue
185
+ }
186
+ // Granted with the parent's actual tool name
187
+ allowed.add(parentMatch)
188
+ } else {
189
+ allowed.add(tool)
190
+ }
191
+ }
192
+
193
+ const allowedList = Array.from(allowed)
194
+ const strippedList = Array.from(strippedSecurity)
195
+ const deniedList = Array.from(denied)
196
+
197
+ // Fail-Closed Allowlist Guard (Issue #104)
198
+ // If role explicitly defines tools, but intersection ends up completely empty,
199
+ // we MUST fail closed rather than quietly continuing without tools.
200
+ if (normRole && normRole.length > 0 && allowedList.length === 0) {
201
+ throw new FailClosedAllowlistError(
202
+ `[ToolIntersection] SecurityViolation: Tool intersection for role "${roleId}" is empty. Fail-closed allowlist triggered. ` +
203
+ `Role requested: [${normRole.join(', ')}]. Parent available: [${normParent ? normParent.join(', ') : 'all'}]. ` +
204
+ `Stripped by policy: [${strippedList.join(', ')}]. Denied: [${deniedList.join(', ')}].`,
205
+ {
206
+ roleId,
207
+ roleRequested: normRole,
208
+ parentAvailable: normParent || [],
209
+ strippedSecurity: strippedList,
210
+ denied: deniedList,
211
+ droppedTools,
212
+ }
213
+ )
214
+ }
215
+
216
+ if (failLoud && allowedList.length === 0 && (normRole ? normRole.length > 0 : true)) {
217
+ throw new Error(
218
+ `[ToolIntersection] SecurityViolation: Tool intersection for role "${roleId}" is empty. ` +
219
+ `Role requested: [${normRole?.join(', ')}]. Parent available: [${normParent?.join(', ')}]. ` +
220
+ `Stripped by policy: [${strippedList.join(', ')}]. Denied: [${deniedList.join(', ')}].`
221
+ )
222
+ }
223
+
224
+ return {
225
+ tools: allowedList,
226
+ droppedTools,
227
+ diff: {
228
+ allowed: allowedList,
229
+ strippedSecurity: strippedList,
230
+ denied: deniedList,
231
+ roleRequested: normRole || [],
232
+ parentAvailable: normParent || [],
233
+ },
234
+ }
235
+ }