morpheus-cli 0.7.3 → 0.7.5

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.
@@ -169,6 +169,7 @@ export class ConfigManager {
169
169
  working_dir: resolveString('MORPHEUS_APOC_WORKING_DIR', config.apoc.working_dir, process.cwd()),
170
170
  timeout_ms: config.apoc.timeout_ms !== undefined ? resolveNumeric('MORPHEUS_APOC_TIMEOUT_MS', config.apoc.timeout_ms, 30_000) : 30_000,
171
171
  personality: resolveString('MORPHEUS_APOC_PERSONALITY', config.apoc.personality, 'pragmatic_dev'),
172
+ execution_mode: resolveString('MORPHEUS_APOC_EXECUTION_MODE', config.apoc.execution_mode, 'async'),
172
173
  };
173
174
  }
174
175
  // Apply precedence to Neo config
@@ -209,6 +210,7 @@ export class ConfigManager {
209
210
  base_url: neoBaseUrl || undefined,
210
211
  context_window: resolveOptionalNumeric('MORPHEUS_NEO_CONTEXT_WINDOW', config.neo?.context_window, neoContextWindowFallback),
211
212
  personality: resolveString('MORPHEUS_NEO_PERSONALITY', config.neo?.personality, 'analytical_engineer'),
213
+ execution_mode: resolveString('MORPHEUS_NEO_EXECUTION_MODE', config.neo?.execution_mode, 'async'),
212
214
  };
213
215
  }
214
216
  // Apply precedence to Trinity config
@@ -233,6 +235,7 @@ export class ConfigManager {
233
235
  base_url: config.trinity?.base_url || config.llm.base_url,
234
236
  context_window: resolveOptionalNumeric('MORPHEUS_TRINITY_CONTEXT_WINDOW', config.trinity?.context_window, trinityContextWindowFallback),
235
237
  personality: resolveString('MORPHEUS_TRINITY_PERSONALITY', config.trinity?.personality, 'data_specialist'),
238
+ execution_mode: resolveString('MORPHEUS_TRINITY_EXECUTION_MODE', config.trinity?.execution_mode, 'async'),
236
239
  };
237
240
  }
238
241
  // Apply precedence to audio config
@@ -297,6 +300,7 @@ export class ConfigManager {
297
300
  logging: loggingConfig,
298
301
  memory: memoryConfig,
299
302
  chronos: chronosConfig,
303
+ verbose_mode: resolveBoolean('MORPHEUS_VERBOSE_MODE', config.verbose_mode, true),
300
304
  };
301
305
  }
302
306
  get() {
@@ -25,9 +25,14 @@ export const SatiConfigSchema = LLMConfigSchema.extend({
25
25
  export const ApocConfigSchema = LLMConfigSchema.extend({
26
26
  working_dir: z.string().optional(),
27
27
  timeout_ms: z.number().int().positive().optional(),
28
+ execution_mode: z.enum(['sync', 'async']).default('async'),
29
+ });
30
+ export const NeoConfigSchema = LLMConfigSchema.extend({
31
+ execution_mode: z.enum(['sync', 'async']).default('async'),
32
+ });
33
+ export const TrinityConfigSchema = LLMConfigSchema.extend({
34
+ execution_mode: z.enum(['sync', 'async']).default('async'),
28
35
  });
29
- export const NeoConfigSchema = LLMConfigSchema;
30
- export const TrinityConfigSchema = LLMConfigSchema;
31
36
  export const KeymakerConfigSchema = LLMConfigSchema.extend({
32
37
  skills_dir: z.string().optional(),
33
38
  });
@@ -62,6 +67,7 @@ export const ConfigSchema = z.object({
62
67
  }).default(DEFAULT_CONFIG.runtime?.async_tasks ?? { enabled: true }),
63
68
  }).optional(),
64
69
  chronos: ChronosConfigSchema.optional(),
70
+ verbose_mode: z.boolean().default(true),
65
71
  channels: z.object({
66
72
  telegram: z.object({
67
73
  enabled: z.boolean().default(false),
@@ -1,6 +1,78 @@
1
1
  import { Router } from 'express';
2
+ import multer from 'multer';
3
+ import extract from 'extract-zip';
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ import os from 'os';
2
7
  import { SkillRegistry, updateSkillDelegateDescription } from '../../runtime/skills/index.js';
3
8
  import { DisplayManager } from '../../runtime/display.js';
9
+ import { PATHS } from '../../config/paths.js';
10
+ import { SkillMetadataSchema } from '../../runtime/skills/schema.js';
11
+ // Multer config for ZIP uploads
12
+ const upload = multer({
13
+ storage: multer.memoryStorage(),
14
+ limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max
15
+ fileFilter: (_req, file, cb) => {
16
+ if (file.mimetype === 'application/zip' || file.mimetype === 'application/x-zip-compressed' || file.originalname.endsWith('.zip')) {
17
+ cb(null, true);
18
+ }
19
+ else {
20
+ cb(new Error('Only ZIP files are allowed'));
21
+ }
22
+ },
23
+ });
24
+ // YAML frontmatter regex
25
+ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
26
+ /**
27
+ * Simple YAML frontmatter parser
28
+ */
29
+ function parseFrontmatter(yaml) {
30
+ const result = {};
31
+ const lines = yaml.split('\n');
32
+ let currentKey = null;
33
+ let currentArray = null;
34
+ for (const line of lines) {
35
+ const trimmed = line.trim();
36
+ if (!trimmed || trimmed.startsWith('#'))
37
+ continue;
38
+ if (trimmed.startsWith('- ') && currentKey && currentArray !== null) {
39
+ currentArray.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ''));
40
+ continue;
41
+ }
42
+ const colonIndex = line.indexOf(':');
43
+ if (colonIndex > 0) {
44
+ if (currentKey && currentArray !== null && currentArray.length > 0) {
45
+ result[currentKey] = currentArray;
46
+ }
47
+ currentArray = null;
48
+ const key = line.slice(0, colonIndex).trim();
49
+ const value = line.slice(colonIndex + 1).trim();
50
+ currentKey = key;
51
+ if (value === '') {
52
+ currentArray = [];
53
+ }
54
+ else if (value === 'true') {
55
+ result[key] = true;
56
+ }
57
+ else if (value === 'false') {
58
+ result[key] = false;
59
+ }
60
+ else if (/^\d+$/.test(value)) {
61
+ result[key] = parseInt(value, 10);
62
+ }
63
+ else if (/^\d+\.\d+$/.test(value)) {
64
+ result[key] = parseFloat(value);
65
+ }
66
+ else {
67
+ result[key] = value.replace(/^["']|["']$/g, '');
68
+ }
69
+ }
70
+ }
71
+ if (currentKey && currentArray !== null && currentArray.length > 0) {
72
+ result[currentKey] = currentArray;
73
+ }
74
+ return result;
75
+ }
4
76
  /**
5
77
  * Create the skills API router
6
78
  *
@@ -8,6 +80,7 @@ import { DisplayManager } from '../../runtime/display.js';
8
80
  * - GET /api/skills - List all skills
9
81
  * - GET /api/skills/:name - Get single skill with content
10
82
  * - POST /api/skills/reload - Reload skills from filesystem
83
+ * - POST /api/skills/upload - Upload a skill ZIP
11
84
  * - POST /api/skills/:name/enable - Enable a skill
12
85
  * - POST /api/skills/:name/disable - Disable a skill
13
86
  */
@@ -65,6 +138,97 @@ export function createSkillsRouter() {
65
138
  res.status(500).json({ error: err.message });
66
139
  }
67
140
  });
141
+ // POST /api/skills/upload - Upload a skill ZIP
142
+ // Must be before /:name routes
143
+ router.post('/upload', upload.single('file'), async (req, res) => {
144
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'morpheus-skill-'));
145
+ try {
146
+ if (!req.file) {
147
+ return res.status(400).json({ error: 'No file uploaded' });
148
+ }
149
+ const zipPath = path.join(tempDir, 'skill.zip');
150
+ const extractDir = path.join(tempDir, 'extracted');
151
+ // Write buffer to temp file
152
+ await fs.writeFile(zipPath, req.file.buffer);
153
+ // Extract ZIP
154
+ await extract(zipPath, { dir: extractDir });
155
+ // Find root entries
156
+ const entries = await fs.readdir(extractDir, { withFileTypes: true });
157
+ const folders = entries.filter(e => e.isDirectory());
158
+ const files = entries.filter(e => e.isFile());
159
+ // Validate: exactly one folder at root, no loose files
160
+ if (folders.length !== 1 || files.length > 0) {
161
+ return res.status(400).json({
162
+ error: 'Invalid ZIP structure',
163
+ details: 'ZIP must contain exactly one folder at root level (no loose files)',
164
+ });
165
+ }
166
+ const skillFolderName = folders[0].name;
167
+ const skillFolderPath = path.join(extractDir, skillFolderName);
168
+ // Check for SKILL.md
169
+ const skillMdPath = path.join(skillFolderPath, 'SKILL.md');
170
+ if (!await fs.pathExists(skillMdPath)) {
171
+ return res.status(400).json({
172
+ error: 'Missing SKILL.md',
173
+ details: `Folder "${skillFolderName}" must contain a SKILL.md file`,
174
+ });
175
+ }
176
+ // Read and validate SKILL.md frontmatter
177
+ const skillMdContent = await fs.readFile(skillMdPath, 'utf-8');
178
+ const match = skillMdContent.match(FRONTMATTER_REGEX);
179
+ if (!match) {
180
+ return res.status(400).json({
181
+ error: 'Invalid SKILL.md format',
182
+ details: 'SKILL.md must have YAML frontmatter between --- delimiters',
183
+ });
184
+ }
185
+ const [, frontmatterYaml] = match;
186
+ const rawMetadata = parseFrontmatter(frontmatterYaml);
187
+ // Validate with Zod schema
188
+ const parseResult = SkillMetadataSchema.safeParse(rawMetadata);
189
+ if (!parseResult.success) {
190
+ const issues = parseResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`);
191
+ return res.status(400).json({
192
+ error: 'Invalid skill metadata',
193
+ details: issues.join('; '),
194
+ });
195
+ }
196
+ const metadata = parseResult.data;
197
+ // Check if skill already exists
198
+ const targetPath = path.join(PATHS.skills, metadata.name);
199
+ if (await fs.pathExists(targetPath)) {
200
+ return res.status(409).json({
201
+ error: 'Skill already exists',
202
+ details: `A skill named "${metadata.name}" already exists. Delete it first or choose a different name.`,
203
+ });
204
+ }
205
+ // Copy to skills directory
206
+ await fs.copy(skillFolderPath, targetPath);
207
+ // Reload skills
208
+ const registry = SkillRegistry.getInstance();
209
+ await registry.reload();
210
+ updateSkillDelegateDescription();
211
+ display.log(`Skill "${metadata.name}" uploaded successfully`, { source: 'SkillsAPI' });
212
+ res.json({
213
+ success: true,
214
+ skill: {
215
+ name: metadata.name,
216
+ description: metadata.description,
217
+ version: metadata.version,
218
+ author: metadata.author,
219
+ path: targetPath,
220
+ },
221
+ });
222
+ }
223
+ catch (err) {
224
+ display.log(`Skill upload error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
225
+ res.status(500).json({ error: err.message });
226
+ }
227
+ finally {
228
+ // Cleanup temp directory
229
+ await fs.remove(tempDir).catch(() => { });
230
+ }
231
+ });
68
232
  // GET /api/skills/:name - Get single skill with content
69
233
  router.get('/:name', (req, res) => {
70
234
  try {
@@ -341,9 +341,11 @@ Use it to inform your response and tool selection (if needed), but do not assume
341
341
  origin_user_id: taskContext?.origin_user_id,
342
342
  };
343
343
  let contextDelegationAcks = [];
344
+ let syncDelegationCount = 0;
344
345
  const response = await TaskRequestContext.run(invokeContext, async () => {
345
346
  const agentResponse = await this.provider.invoke({ messages });
346
347
  contextDelegationAcks = TaskRequestContext.getDelegationAcks();
348
+ syncDelegationCount = TaskRequestContext.getSyncDelegationCount();
347
349
  return agentResponse;
348
350
  });
349
351
  // Identify new messages generated during the interaction
@@ -364,13 +366,17 @@ Use it to inform your response and tool selection (if needed), but do not assume
364
366
  const toolDelegationAcks = this.extractDelegationAcksFromMessages(newGeneratedMessages);
365
367
  const hadDelegationToolCall = this.hasDelegationToolCall(newGeneratedMessages);
366
368
  const hadChronosToolCall = this.hasChronosToolCall(newGeneratedMessages);
369
+ // When all delegation tool calls ran synchronously, there are no task IDs to validate.
370
+ // Treat as a normal (non-delegation) response so the inline result flows through.
371
+ const allDelegationsSyncInline = hadDelegationToolCall && syncDelegationCount > 0
372
+ && contextDelegationAcks.length === 0;
367
373
  const mergedDelegationAcks = [
368
374
  ...contextDelegationAcks.map((ack) => ({ task_id: ack.task_id, agent: ack.agent })),
369
375
  ...toolDelegationAcks,
370
376
  ];
371
377
  const validDelegationAcks = this.validateDelegationAcks(mergedDelegationAcks, message);
372
378
  if (mergedDelegationAcks.length > 0) {
373
- this.display.log(`Delegation trace: context=${contextDelegationAcks.length}, tool_messages=${toolDelegationAcks.length}, valid=${validDelegationAcks.length}`, { source: "Oracle", level: "info" });
379
+ this.display.log(`Delegation trace: context=${contextDelegationAcks.length}, tool_messages=${toolDelegationAcks.length}, valid=${validDelegationAcks.length}, sync_inline=${syncDelegationCount}`, { source: "Oracle", level: "info" });
374
380
  }
375
381
  const delegatedThisTurn = validDelegationAcks.length > 0;
376
382
  let blockedSyntheticDelegationAck = false;
@@ -392,7 +398,7 @@ Use it to inform your response and tool selection (if needed), but do not assume
392
398
  // returned to the caller (Telegram / UI) immediately after this point.
393
399
  this.taskRepository.markAckSent(validDelegationAcks.map(a => a.task_id));
394
400
  }
395
- else if (mergedDelegationAcks.length > 0 || hadDelegationToolCall) {
401
+ else if (!allDelegationsSyncInline && (mergedDelegationAcks.length > 0 || hadDelegationToolCall)) {
396
402
  this.display.log(`Delegation attempted but no valid task id was confirmed (context=${contextDelegationAcks.length}, tool_messages=${toolDelegationAcks.length}, had_tool_call=${hadDelegationToolCall}).`, { source: "Oracle", level: "error" });
397
403
  // Delegation was attempted but no valid task id could be confirmed in DB.
398
404
  responseContent = this.buildDelegationFailureResponse();
@@ -6,6 +6,11 @@ import { ProviderError } from "../errors.js";
6
6
  import { createAgent, createMiddleware } from "langchain";
7
7
  import { DisplayManager } from "../display.js";
8
8
  import { getUsableApiKey } from "../trinity-crypto.js";
9
+ import { ConfigManager } from "../../config/manager.js";
10
+ import { TaskRequestContext } from "../tasks/context.js";
11
+ import { ChannelRegistry } from "../../channels/registry.js";
12
+ /** Channels that should NOT receive verbose tool notifications */
13
+ const SILENT_CHANNELS = new Set(['api', 'ui']);
9
14
  export class ProviderFactory {
10
15
  static buildMonitoringMiddleware() {
11
16
  const display = DisplayManager.getInstance();
@@ -14,6 +19,15 @@ export class ProviderFactory {
14
19
  wrapToolCall: (request, handler) => {
15
20
  display.log(`Executing tool: ${request.toolCall.name}`, { level: "warning", source: 'ConstructLoad' });
16
21
  display.log(`Arguments: ${JSON.stringify(request.toolCall.args)}`, { level: "info", source: 'ConstructLoad' });
22
+ // Verbose mode: notify originating channel about which tool is running
23
+ const verboseEnabled = ConfigManager.getInstance().get().verbose_mode !== false;
24
+ if (verboseEnabled) {
25
+ const ctx = TaskRequestContext.get();
26
+ if (ctx?.origin_channel && ctx.origin_user_id && !SILENT_CHANNELS.has(ctx.origin_channel)) {
27
+ ChannelRegistry.sendToUser(ctx.origin_channel, ctx.origin_user_id, `🔧 executing: ${request.toolCall.name}`)
28
+ .catch(() => { });
29
+ }
30
+ }
17
31
  try {
18
32
  const result = handler(request);
19
33
  display.log(`Tool completed successfully. Result: ${JSON.stringify(result)}`, { level: "info", source: 'ConstructLoad' });
@@ -27,6 +27,22 @@ export class TaskRequestContext {
27
27
  static canEnqueueDelegation() {
28
28
  return this.getDelegationAcks().length < this.MAX_DELEGATIONS_PER_TURN;
29
29
  }
30
+ /**
31
+ * Record that a delegation tool executed synchronously (inline).
32
+ * Oracle uses this to know that the tool call was NOT an async enqueue.
33
+ */
34
+ static incrementSyncDelegation() {
35
+ const current = storage.getStore();
36
+ if (!current)
37
+ return;
38
+ current.sync_delegation_count = (current.sync_delegation_count ?? 0) + 1;
39
+ }
40
+ /**
41
+ * Returns the number of delegation tools that executed synchronously this turn.
42
+ */
43
+ static getSyncDelegationCount() {
44
+ return storage.getStore()?.sync_delegation_count ?? 0;
45
+ }
30
46
  static findDuplicateDelegation(agent, task) {
31
47
  const acks = this.getDelegationAcks();
32
48
  if (acks.length === 0)
@@ -4,6 +4,16 @@ import { TaskRepository } from "../tasks/repository.js";
4
4
  import { TaskRequestContext } from "../tasks/context.js";
5
5
  import { compositeDelegationError, isLikelyCompositeDelegationTask } from "./delegation-guard.js";
6
6
  import { DisplayManager } from "../display.js";
7
+ import { ConfigManager } from "../../config/manager.js";
8
+ import { Apoc } from "../apoc.js";
9
+ import { ChannelRegistry } from "../../channels/registry.js";
10
+ /**
11
+ * Returns true when Apoc is configured to execute synchronously (inline).
12
+ */
13
+ function isApocSync() {
14
+ const config = ConfigManager.getInstance().get();
15
+ return config.apoc?.execution_mode === 'sync';
16
+ }
7
17
  /**
8
18
  * Tool that Oracle uses to delegate devtools tasks to Apoc.
9
19
  * Oracle should call this whenever the user requests operations like:
@@ -25,6 +35,29 @@ export const ApocDelegateTool = tool(async ({ task, context }) => {
25
35
  });
26
36
  return compositeDelegationError();
27
37
  }
38
+ // ── Sync mode: execute inline and return result directly ──
39
+ if (isApocSync()) {
40
+ display.log(`Apoc executing synchronously: ${task.slice(0, 80)}...`, {
41
+ source: "ApocDelegateTool",
42
+ level: "info",
43
+ });
44
+ const ctx = TaskRequestContext.get();
45
+ const sessionId = ctx?.session_id ?? "default";
46
+ // Notify originating channel that the agent is working
47
+ if (ctx?.origin_channel && ctx.origin_user_id && ctx.origin_channel !== 'api' && ctx.origin_channel !== 'ui') {
48
+ ChannelRegistry.sendToUser(ctx.origin_channel, ctx.origin_user_id, '🧑‍🔬 Apoc is executing your request...')
49
+ .catch(() => { });
50
+ }
51
+ const apoc = Apoc.getInstance();
52
+ const result = await apoc.execute(task, context, sessionId);
53
+ TaskRequestContext.incrementSyncDelegation();
54
+ display.log(`Apoc sync execution completed.`, {
55
+ source: "ApocDelegateTool",
56
+ level: "info",
57
+ });
58
+ return result;
59
+ }
60
+ // ── Async mode (default): create background task ──
28
61
  const existingAck = TaskRequestContext.findDuplicateDelegation("apoc", task);
29
62
  if (existingAck) {
30
63
  display.log(`Apoc delegation deduplicated. Reusing task ${existingAck.task_id}.`, {
@@ -72,7 +105,7 @@ export const ApocDelegateTool = tool(async ({ task, context }) => {
72
105
  }
73
106
  }, {
74
107
  name: "apoc_delegate",
75
- description: `Delegate a devtools task to Apoc, the specialized development subagent, asynchronously.
108
+ description: `Delegate a devtools task to Apoc, the specialized development subagent.
76
109
 
77
110
  This tool enqueues a background task and returns an acknowledgement with task id.
78
111
  Do not expect final execution output in the same response.
@@ -29,20 +29,24 @@ const CONFIG_TO_ENV_MAP = {
29
29
  'neo.model': ['MORPHEUS_NEO_MODEL'],
30
30
  'neo.temperature': ['MORPHEUS_NEO_TEMPERATURE'],
31
31
  'neo.api_key': ['MORPHEUS_NEO_API_KEY'],
32
+ 'neo.execution_mode': ['MORPHEUS_NEO_EXECUTION_MODE'],
32
33
  'apoc.provider': ['MORPHEUS_APOC_PROVIDER'],
33
34
  'apoc.model': ['MORPHEUS_APOC_MODEL'],
34
35
  'apoc.temperature': ['MORPHEUS_APOC_TEMPERATURE'],
35
36
  'apoc.api_key': ['MORPHEUS_APOC_API_KEY'],
36
37
  'apoc.working_dir': ['MORPHEUS_APOC_WORKING_DIR'],
37
38
  'apoc.timeout_ms': ['MORPHEUS_APOC_TIMEOUT_MS'],
39
+ 'apoc.execution_mode': ['MORPHEUS_APOC_EXECUTION_MODE'],
38
40
  'trinity.provider': ['MORPHEUS_TRINITY_PROVIDER'],
39
41
  'trinity.model': ['MORPHEUS_TRINITY_MODEL'],
40
42
  'trinity.temperature': ['MORPHEUS_TRINITY_TEMPERATURE'],
41
43
  'trinity.api_key': ['MORPHEUS_TRINITY_API_KEY'],
44
+ 'trinity.execution_mode': ['MORPHEUS_TRINITY_EXECUTION_MODE'],
42
45
  'audio.provider': ['MORPHEUS_AUDIO_PROVIDER'],
43
46
  'audio.model': ['MORPHEUS_AUDIO_MODEL'],
44
47
  'audio.apiKey': ['MORPHEUS_AUDIO_API_KEY'],
45
48
  'audio.maxDurationSeconds': ['MORPHEUS_AUDIO_MAX_DURATION'],
49
+ 'verbose_mode': ['MORPHEUS_VERBOSE_MODE'],
46
50
  };
47
51
  /**
48
52
  * Checks if a config field is overridden by an environment variable.
@@ -4,6 +4,9 @@ import { TaskRepository } from "../tasks/repository.js";
4
4
  import { TaskRequestContext } from "../tasks/context.js";
5
5
  import { compositeDelegationError, isLikelyCompositeDelegationTask } from "./delegation-guard.js";
6
6
  import { DisplayManager } from "../display.js";
7
+ import { ConfigManager } from "../../config/manager.js";
8
+ import { Neo } from "../neo.js";
9
+ import { ChannelRegistry } from "../../channels/registry.js";
7
10
  const NEO_BUILTIN_CAPABILITIES = `
8
11
  Neo built-in capabilities (always available — no MCP required):
9
12
  • Config: morpheus_config_query, morpheus_config_update — read/write Morpheus configuration (LLM, channels, UI, etc.)
@@ -39,6 +42,13 @@ function buildCatalogSection(mcpTools) {
39
42
  }
40
43
  return `\n\nRuntime MCP tools:\n${lines.join("\n")}`;
41
44
  }
45
+ /**
46
+ * Returns true when Neo is configured to execute synchronously (inline).
47
+ */
48
+ function isNeoSync() {
49
+ const config = ConfigManager.getInstance().get();
50
+ return config.neo?.execution_mode === 'sync';
51
+ }
42
52
  export function updateNeoDelegateToolDescription(tools) {
43
53
  const full = `${NEO_BASE_DESCRIPTION}${buildCatalogSection(tools)}`;
44
54
  NeoDelegateTool.description = full;
@@ -53,6 +63,34 @@ export const NeoDelegateTool = tool(async ({ task, context }) => {
53
63
  });
54
64
  return compositeDelegationError();
55
65
  }
66
+ // ── Sync mode: execute inline and return result directly ──
67
+ if (isNeoSync()) {
68
+ display.log(`Neo executing synchronously: ${task.slice(0, 80)}...`, {
69
+ source: "NeoDelegateTool",
70
+ level: "info",
71
+ });
72
+ const ctx = TaskRequestContext.get();
73
+ const sessionId = ctx?.session_id ?? "default";
74
+ // Notify originating channel that the agent is working
75
+ if (ctx?.origin_channel && ctx.origin_user_id && ctx.origin_channel !== 'api' && ctx.origin_channel !== 'ui') {
76
+ ChannelRegistry.sendToUser(ctx.origin_channel, ctx.origin_user_id, '🥷 Neo is executing your request...')
77
+ .catch(() => { });
78
+ }
79
+ const neo = Neo.getInstance();
80
+ const result = await neo.execute(task, context, sessionId, {
81
+ origin_channel: ctx?.origin_channel ?? "api",
82
+ session_id: sessionId,
83
+ origin_message_id: ctx?.origin_message_id,
84
+ origin_user_id: ctx?.origin_user_id,
85
+ });
86
+ TaskRequestContext.incrementSyncDelegation();
87
+ display.log(`Neo sync execution completed.`, {
88
+ source: "NeoDelegateTool",
89
+ level: "info",
90
+ });
91
+ return result;
92
+ }
93
+ // ── Async mode (default): create background task ──
56
94
  const existingAck = TaskRequestContext.findDuplicateDelegation("neo", task);
57
95
  if (existingAck) {
58
96
  display.log(`Neo delegation deduplicated. Reusing task ${existingAck.task_id}.`, {
@@ -4,6 +4,9 @@ import { TaskRepository } from "../tasks/repository.js";
4
4
  import { TaskRequestContext } from "../tasks/context.js";
5
5
  import { compositeDelegationError, isLikelyCompositeDelegationTask } from "./delegation-guard.js";
6
6
  import { DisplayManager } from "../display.js";
7
+ import { ConfigManager } from "../../config/manager.js";
8
+ import { Trinity } from "../trinity.js";
9
+ import { ChannelRegistry } from "../../channels/registry.js";
7
10
  const TRINITY_BASE_DESCRIPTION = `Delegate a database task to Trinity, the specialized database subagent, asynchronously.
8
11
 
9
12
  This tool enqueues a background task and returns an acknowledgement with task id.
@@ -30,6 +33,13 @@ export function updateTrinityDelegateToolDescription(databases) {
30
33
  const full = `${TRINITY_BASE_DESCRIPTION}${buildDatabaseCatalog(databases)}`;
31
34
  TrinityDelegateTool.description = full;
32
35
  }
36
+ /**
37
+ * Returns true when Trinity is configured to execute synchronously (inline).
38
+ */
39
+ function isTrinitySync() {
40
+ const config = ConfigManager.getInstance().get();
41
+ return config.trinity?.execution_mode === 'sync';
42
+ }
33
43
  export const TrinityDelegateTool = tool(async ({ task, context }) => {
34
44
  try {
35
45
  const display = DisplayManager.getInstance();
@@ -40,6 +50,29 @@ export const TrinityDelegateTool = tool(async ({ task, context }) => {
40
50
  });
41
51
  return compositeDelegationError();
42
52
  }
53
+ // ── Sync mode: execute inline and return result directly ──
54
+ if (isTrinitySync()) {
55
+ display.log(`Trinity executing synchronously: ${task.slice(0, 80)}...`, {
56
+ source: 'TrinityDelegateTool',
57
+ level: 'info',
58
+ });
59
+ const ctx = TaskRequestContext.get();
60
+ const sessionId = ctx?.session_id ?? 'default';
61
+ // Notify originating channel that the agent is working
62
+ if (ctx?.origin_channel && ctx.origin_user_id && ctx.origin_channel !== 'api' && ctx.origin_channel !== 'ui') {
63
+ ChannelRegistry.sendToUser(ctx.origin_channel, ctx.origin_user_id, '👩‍💻 Trinity is executing your request...')
64
+ .catch(() => { });
65
+ }
66
+ const trinity = Trinity.getInstance();
67
+ const result = await trinity.execute(task, context, sessionId);
68
+ TaskRequestContext.incrementSyncDelegation();
69
+ display.log(`Trinity sync execution completed.`, {
70
+ source: 'TrinityDelegateTool',
71
+ level: 'info',
72
+ });
73
+ return result;
74
+ }
75
+ // ── Async mode (default): create background task ──
43
76
  const existingAck = TaskRequestContext.findDuplicateDelegation('trinit', task);
44
77
  if (existingAck) {
45
78
  display.log(`Trinity delegation deduplicated. Reusing task ${existingAck.task_id}.`, {
@@ -51,17 +51,21 @@ export const DEFAULT_CONFIG = {
51
51
  temperature: 0.2,
52
52
  timeout_ms: 30000,
53
53
  personality: 'pragmatic_dev',
54
+ execution_mode: 'async',
54
55
  },
55
56
  neo: {
56
57
  provider: 'openai',
57
58
  model: 'gpt-4',
58
59
  temperature: 0.2,
59
60
  personality: 'analytical_engineer',
61
+ execution_mode: 'async',
60
62
  },
61
63
  trinity: {
62
64
  provider: 'openai',
63
65
  model: 'gpt-4',
64
66
  temperature: 0.2,
65
67
  personality: 'data_specialist',
66
- }
68
+ execution_mode: 'async',
69
+ },
70
+ verbose_mode: true,
67
71
  };