tiger-agent 0.2.4 → 0.3.1

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,456 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { chatCompletion } = require('../llmClient');
6
+ const {
7
+ swarmAgentTimeoutMs,
8
+ swarmRouteOnProviderError,
9
+ swarmDefaultFlow,
10
+ swarmFirstAgentPolicy,
11
+ swarmFirstAgent
12
+ } = require('../config');
13
+ const {
14
+ AGENTS_DIR,
15
+ ensureSwarmLayout,
16
+ createTask,
17
+ listInProgressTasks,
18
+ listTasks,
19
+ findTask,
20
+ appendThread,
21
+ claimPendingTask,
22
+ releaseTask,
23
+ cancelTask,
24
+ deleteTask
25
+ } = require('./taskBus');
26
+
27
+ const AGENT_DEFS = {
28
+ tiger: { label: 'Tiger', kind: 'orchestrator' },
29
+ designer: { label: 'Designer', kind: 'worker' },
30
+ senior_eng: { label: 'Senior Eng', kind: 'worker' },
31
+ spec_writer: { label: 'Spec Writer', kind: 'worker' },
32
+ scout: { label: 'Scout', kind: 'worker' },
33
+ coder: { label: 'Coder', kind: 'worker' },
34
+ critic: { label: 'Critic', kind: 'worker' }
35
+ };
36
+ const WORKER_AGENT_NAMES = Object.keys(AGENT_DEFS).filter((n) => n !== 'tiger');
37
+
38
+ function safeJsonParse(text, fallback) {
39
+ try {
40
+ return JSON.parse(text);
41
+ } catch (err) {
42
+ return fallback;
43
+ }
44
+ }
45
+
46
+ function getAgentSoul(agentName) {
47
+ const full = path.join(AGENTS_DIR, agentName, 'soul.md');
48
+ if (!fs.existsSync(full)) return '';
49
+ try {
50
+ return fs.readFileSync(full, 'utf8');
51
+ } catch (err) {
52
+ return '';
53
+ }
54
+ }
55
+
56
+ function renderThread(task) {
57
+ return (Array.isArray(task.thread) ? task.thread : [])
58
+ .map((m) => `- [${m.at || ''}] ${m.by || 'unknown'}: ${m.msg || ''}`)
59
+ .join('\n');
60
+ }
61
+
62
+ async function llmText(system, user, stepLabel) {
63
+ // Step-level timeout: each LLM call gets its own fresh timer (hook reset)
64
+ const stepTimeoutMs = Number(process.env.SWARM_AGENT_TIMEOUT_MS || swarmAgentTimeoutMs || 720000);
65
+ const label = stepLabel || 'llmText';
66
+
67
+ const llmCall = chatCompletion([
68
+ { role: 'system', content: system },
69
+ { role: 'user', content: user }
70
+ ], {
71
+ fallbackOnAnyProviderError: swarmRouteOnProviderError
72
+ });
73
+
74
+ // Fresh timeout per LLM step — resets on every hook call
75
+ const out = await withTimeout(llmCall, stepTimeoutMs, `[step:${label}] LLM call`);
76
+ return String(out && out.content ? out.content : '').trim();
77
+ }
78
+
79
+ async function designerStep(task) {
80
+ const soul = getAgentSoul('designer');
81
+ const system = [
82
+ 'You are Designer in a multi-agent swarm.',
83
+ 'Propose or revise a solution.',
84
+ 'Be concrete. Mention architecture, flow, risks, and tradeoffs.',
85
+ 'Do not approve your own design.',
86
+ soul
87
+ ].join('\n\n');
88
+ const user = [
89
+ `Goal: ${task.goal}`,
90
+ `Flow: ${task.flow || 'design'}`,
91
+ 'Task thread so far:',
92
+ renderThread(task),
93
+ 'Return a revised design proposal for Senior Eng review.'
94
+ ].join('\n\n');
95
+ const text = await llmText(system, user, 'designer:propose');
96
+ appendThread(task, 'designer', text || 'proposed initial design');
97
+ task.next_agent = 'senior_eng';
98
+ task.status = 'pending';
99
+ return task;
100
+ }
101
+
102
+ async function seniorEngStep(task) {
103
+ const soul = getAgentSoul('senior_eng');
104
+ const system = [
105
+ 'You are Senior Engineer reviewer in a swarm.',
106
+ 'Review the latest proposed design critically.',
107
+ 'Return strict JSON only: {"approved":true|false,"feedback":"...","must_fix":["..."]}.',
108
+ 'If approving, feedback should summarize key safeguards.',
109
+ soul
110
+ ].join('\n\n');
111
+ const user = [
112
+ `Goal: ${task.goal}`,
113
+ 'Conversation thread:',
114
+ renderThread(task)
115
+ ].join('\n\n');
116
+ const raw = await llmText(system, user, 'senior_eng:review');
117
+ const parsed = safeJsonParse(raw, null);
118
+
119
+ let approved = false;
120
+ let feedback = raw || 'review completed';
121
+ let mustFix = [];
122
+
123
+ if (parsed && typeof parsed === 'object') {
124
+ approved = Boolean(parsed.approved);
125
+ feedback = String(parsed.feedback || feedback).trim();
126
+ mustFix = Array.isArray(parsed.must_fix) ? parsed.must_fix.map((x) => String(x).trim()).filter(Boolean) : [];
127
+ } else {
128
+ approved = /approved\s*✅?|approve\b/i.test(raw) && !/reject/i.test(raw);
129
+ }
130
+
131
+ const msg = approved
132
+ ? `approved ✅ ${feedback}`.trim()
133
+ : `rejected - ${feedback}${mustFix.length ? ` | must_fix: ${mustFix.join('; ')}` : ''}`.trim();
134
+
135
+ appendThread(task, 'senior_eng', msg);
136
+ task.next_agent = approved ? 'spec_writer' : 'designer';
137
+ task.status = 'pending';
138
+ return task;
139
+ }
140
+
141
+ async function specWriterStep(task) {
142
+ const soul = getAgentSoul('spec_writer');
143
+ const system = [
144
+ 'You are Spec Writer in a swarm.',
145
+ 'Write a clear formal implementation/design spec from the approved discussion.',
146
+ 'Use structured markdown with scope, architecture, flow, edge cases, and next steps.',
147
+ soul
148
+ ].join('\n\n');
149
+ const user = [
150
+ `Goal: ${task.goal}`,
151
+ 'Thread:',
152
+ renderThread(task),
153
+ 'Write the final spec now.'
154
+ ].join('\n\n');
155
+ const spec = await llmText(system, user, 'spec_writer:write');
156
+ appendThread(task, 'spec_writer', 'formal spec written');
157
+ task.result = spec || '(empty spec)';
158
+ task.next_agent = 'tiger';
159
+ task.status = 'pending';
160
+ return task;
161
+ }
162
+
163
+ async function genericWorkerStep(task, agentName, roleHint) {
164
+ const soul = getAgentSoul(agentName);
165
+ const text = await llmText(
166
+ [
167
+ `You are ${agentName} in Tiger swarm.`,
168
+ roleHint,
169
+ 'Respond concisely and practically.',
170
+ soul
171
+ ].join('\n\n'),
172
+ [`Goal: ${task.goal}`, 'Thread:', renderThread(task)].join('\n\n'),
173
+ `${agentName}:step`
174
+ );
175
+ appendThread(task, agentName, text || `${agentName} completed step`);
176
+ task.status = 'pending';
177
+ task.next_agent =
178
+ agentName === 'scout' ? 'coder' :
179
+ agentName === 'coder' ? 'critic' :
180
+ agentName === 'critic' ? 'tiger' : 'tiger';
181
+ if (agentName === 'critic') {
182
+ task.result = text || task.result || '';
183
+ }
184
+ return task;
185
+ }
186
+
187
+ async function processWorkerTask(agentName, task) {
188
+ if (agentName === 'designer') return designerStep(task);
189
+ if (agentName === 'senior_eng') return seniorEngStep(task);
190
+ if (agentName === 'spec_writer') return specWriterStep(task);
191
+ if (agentName === 'scout') return genericWorkerStep(task, 'scout', 'Research and verify from multiple angles.');
192
+ if (agentName === 'coder') return genericWorkerStep(task, 'coder', 'Propose implementation plan and code-level steps.');
193
+ if (agentName === 'critic') return genericWorkerStep(task, 'critic', 'Review for defects, risks, and regressions.');
194
+ throw new Error(`Unsupported worker: ${agentName}`);
195
+ }
196
+
197
+ function withTimeout(promise, timeoutMs, label) {
198
+ const ms = Number(timeoutMs || 0);
199
+ if (!Number.isFinite(ms) || ms <= 0) return promise;
200
+ return new Promise((resolve, reject) => {
201
+ const timer = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
202
+ Promise.resolve(promise).then(
203
+ (value) => {
204
+ clearTimeout(timer);
205
+ resolve(value);
206
+ },
207
+ (err) => {
208
+ clearTimeout(timer);
209
+ reject(err);
210
+ }
211
+ );
212
+ });
213
+ }
214
+
215
+ async function runWorkerTurn(agentName) {
216
+ ensureSwarmLayout();
217
+ const claim = claimPendingTask(agentName);
218
+ if (!claim) return { ok: true, idle: true, agent: agentName };
219
+
220
+ // ✅ FIX: Per-agent timeout — reset independently for each agent
221
+ // Each agent gets its own fresh timeout (not shared global timer)
222
+ const perAgentTimeout = Number(
223
+ process.env.SWARM_AGENT_TIMEOUT_MS || swarmAgentTimeoutMs || 720000
224
+ );
225
+
226
+ let { task, filePath } = claim;
227
+ try {
228
+ task = await withTimeout(
229
+ processWorkerTask(agentName, task),
230
+ perAgentTimeout,
231
+ `swarm agent ${agentName}`
232
+ );
233
+ const out = releaseTask(task, filePath, task.status === 'failed' ? 'failed' : 'pending');
234
+ return { ok: true, idle: false, agent: agentName, task: out.task };
235
+ } catch (err) {
236
+ appendThread(task, agentName, `error: ${err.message}`);
237
+ if (!task.metadata || typeof task.metadata !== 'object') task.metadata = {};
238
+ task.metadata.last_failed_agent = agentName;
239
+ task.metadata.last_error = String(err && err.message ? err.message : 'unknown error');
240
+ task.status = 'failed';
241
+ task.next_agent = 'tiger';
242
+ const out = releaseTask(task, filePath, 'failed');
243
+ return { ok: false, idle: false, agent: agentName, error: err.message, task: out.task };
244
+ }
245
+ }
246
+
247
+ function pickFlowFirstAgent(flow) {
248
+ return flow === 'research_build' ? 'scout' : 'designer';
249
+ }
250
+
251
+ function pickAutoFirstAgent(goal, flow) {
252
+ const text = String(goal || '').toLowerCase();
253
+ if (flow === 'research_build') return 'scout';
254
+ if (/(research|investigate|compare|look up|search|verify|news|find out)/i.test(text)) return 'scout';
255
+ if (/(bug|fix|error|exception|stack trace|refactor|implement|write code|code change|patch)/i.test(text)) return 'coder';
256
+ if (/(review|audit|critique|risk check)/i.test(text)) return 'critic';
257
+ if (/(spec|prd|requirements|design doc|document)/i.test(text)) return 'designer';
258
+ return 'designer';
259
+ }
260
+
261
+ function resolveFirstAgent(goal, flow, opts = {}) {
262
+ const policy = String(opts.firstAgentPolicy || swarmFirstAgentPolicy || 'auto').toLowerCase();
263
+ const fixed = String(opts.firstAgent || swarmFirstAgent || '').toLowerCase();
264
+
265
+ if (WORKER_AGENT_NAMES.includes(policy)) return policy;
266
+ if (policy === 'fixed' && WORKER_AGENT_NAMES.includes(fixed)) return fixed;
267
+ if (policy === 'flow') return pickFlowFirstAgent(flow);
268
+ return pickAutoFirstAgent(goal, flow);
269
+ }
270
+
271
+ function extractTigerResult(taskId) {
272
+ const found = findTask(taskId);
273
+ if (!found) return { ok: false, error: 'Task not found' };
274
+ const { filePath, task, bucket } = found;
275
+ if (task.next_agent !== 'tiger') {
276
+ return { ok: false, error: `Task not ready for tiger (next_agent=${task.next_agent})`, task };
277
+ }
278
+ task.status = task.status === 'failed' ? 'failed' : 'done';
279
+ const targetBucket = task.status === 'failed' ? 'failed' : 'done';
280
+ const nextPath = path.join(path.dirname(filePath), '..', targetBucket, `${task.task_id}.json`);
281
+ void nextPath; // path computation not used directly; move handled via releaseTask.
282
+ const released = releaseTask(task, filePath, targetBucket);
283
+ return { ok: true, task: released.task, bucketFrom: bucket, bucketTo: targetBucket };
284
+ }
285
+
286
+ async function runTaskToTiger(taskId, opts = {}) {
287
+ const rawMaxTurns = Number(opts.maxTurns);
288
+ const maxTurns = Number.isFinite(rawMaxTurns) && rawMaxTurns > 0 ? Math.floor(rawMaxTurns) : null;
289
+ const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
290
+
291
+ for (let i = 0; maxTurns == null || i < maxTurns; i += 1) {
292
+ const found = findTask(taskId);
293
+ if (!found) return { ok: false, error: 'Task disappeared' };
294
+ const { task } = found;
295
+ if (task.next_agent === 'tiger') {
296
+ return { ok: true, task, readyForTiger: true };
297
+ }
298
+ if (task.status === 'failed') {
299
+ return { ok: false, task, error: 'Task failed' };
300
+ }
301
+
302
+ const agentName = task.next_agent;
303
+ if (!AGENT_DEFS[agentName]) {
304
+ return { ok: false, task, error: `Unknown next_agent: ${agentName}` };
305
+ }
306
+
307
+ if (onProgress) onProgress({ phase: 'worker_start', agent: agentName, task });
308
+ const turn = await runWorkerTurn(agentName);
309
+ const latest = findTask(taskId);
310
+ if (onProgress && latest) {
311
+ onProgress({ phase: 'worker_done', agent: agentName, task: latest.task, turn });
312
+ }
313
+ if (!turn.ok && !turn.idle) return { ok: false, task: turn.task, error: turn.error || 'Worker failed' };
314
+ }
315
+
316
+ const found = findTask(taskId);
317
+ return { ok: false, error: `Exceeded max turns (${maxTurns})`, task: found ? found.task : null };
318
+ }
319
+
320
+ async function runTigerFlow(goal, opts = {}) {
321
+ ensureSwarmLayout();
322
+ const flow = String(opts.flow || swarmDefaultFlow || 'auto').toLowerCase();
323
+ const firstAgent = resolveFirstAgent(goal, flow, opts);
324
+ const task = createTask({
325
+ from: 'tiger',
326
+ goal,
327
+ nextAgent: firstAgent,
328
+ flow,
329
+ metadata: {
330
+ ...(opts.metadata || {}),
331
+ first_agent_policy: String(opts.firstAgentPolicy || swarmFirstAgentPolicy || 'auto').toLowerCase(),
332
+ first_agent: firstAgent
333
+ }
334
+ });
335
+
336
+ if (typeof opts.onProgress === 'function') {
337
+ opts.onProgress({ phase: 'task_created', task });
338
+ }
339
+
340
+ const progress = await runTaskToTiger(task.task_id, opts);
341
+ if (!progress.ok) return progress;
342
+
343
+ const final = extractTigerResult(task.task_id);
344
+ if (!final.ok) return final;
345
+
346
+ return { ok: true, task: final.task, result: final.task.result || '' };
347
+ }
348
+
349
+ function inferResumeAgent(task) {
350
+ const meta = task && task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
351
+ const fromMeta = String(meta.last_failed_agent || '').trim();
352
+ if (fromMeta && AGENT_DEFS[fromMeta] && fromMeta !== 'tiger') return fromMeta;
353
+
354
+ const thread = Array.isArray(task && task.thread) ? task.thread : [];
355
+ for (let i = thread.length - 1; i >= 0; i -= 1) {
356
+ const m = thread[i] || {};
357
+ const by = String(m.by || '').trim();
358
+ const msg = String(m.msg || '').trim();
359
+ if (by && by !== 'tiger' && AGENT_DEFS[by] && /^error:/i.test(msg)) return by;
360
+ }
361
+
362
+ const next = String(task && task.next_agent || '').trim();
363
+ if (next && next !== 'tiger' && AGENT_DEFS[next]) return next;
364
+ return pickFlowFirstAgent(task && task.flow);
365
+ }
366
+
367
+ async function continueTask(taskId, opts = {}) {
368
+ ensureSwarmLayout();
369
+ const found = findTask(taskId);
370
+ if (!found) return { ok: false, error: 'Task not found' };
371
+
372
+ let { task, filePath, bucket } = found;
373
+ if (bucket === 'done') {
374
+ return { ok: false, error: 'Task is already done', task };
375
+ }
376
+
377
+ if (bucket === 'failed') {
378
+ const resumeAgent = inferResumeAgent(task);
379
+ task.status = 'pending';
380
+ task.next_agent = resumeAgent;
381
+ appendThread(task, 'tiger', `resume requested: continue from ${resumeAgent}`);
382
+ const released = releaseTask(task, filePath, 'pending');
383
+ task = released.task;
384
+ filePath = released.filePath;
385
+ bucket = released.bucket;
386
+ void filePath;
387
+ void bucket;
388
+ } else if (bucket === 'in_progress') {
389
+ // Recovery path for stale stuck tasks.
390
+ task.status = 'pending';
391
+ appendThread(task, 'tiger', 'resume requested: moved stale in_progress task back to pending');
392
+ const released = releaseTask(task, filePath, 'pending');
393
+ task = released.task;
394
+ }
395
+
396
+ const progress = await runTaskToTiger(taskId, opts);
397
+ if (!progress.ok) return progress;
398
+
399
+ const final = extractTigerResult(taskId);
400
+ if (!final.ok) return final;
401
+ return { ok: true, task: final.task, result: final.task.result || '' };
402
+ }
403
+
404
+ async function askAgent(agentName, prompt) {
405
+ ensureSwarmLayout();
406
+ if (!AGENT_DEFS[agentName] || agentName === 'tiger') {
407
+ throw new Error(`Unknown or unsupported /ask agent: ${agentName}`);
408
+ }
409
+ const soul = getAgentSoul(agentName);
410
+ const system = [
411
+ `You are ${agentName} in Tiger's internal swarm.`,
412
+ 'Answer as that role only.',
413
+ 'Be concise and practical.',
414
+ soul
415
+ ].join('\n\n');
416
+ return llmText(system, String(prompt || '').trim());
417
+ }
418
+
419
+ function getAgentsStatus() {
420
+ ensureSwarmLayout();
421
+ return Object.keys(AGENT_DEFS).map((name) => {
422
+ const dir = path.join(AGENTS_DIR, name);
423
+ return {
424
+ name,
425
+ label: AGENT_DEFS[name].label,
426
+ kind: AGENT_DEFS[name].kind,
427
+ alive: fs.existsSync(dir),
428
+ path: dir
429
+ };
430
+ });
431
+ }
432
+
433
+ function getStatusSummary() {
434
+ ensureSwarmLayout();
435
+ return {
436
+ in_progress: listInProgressTasks(),
437
+ pending: listTasks('pending'),
438
+ done: listTasks('done'),
439
+ failed: listTasks('failed')
440
+ };
441
+ }
442
+
443
+ module.exports = {
444
+ AGENT_DEFS,
445
+ ensureSwarmLayout,
446
+ runWorkerTurn,
447
+ runTaskToTiger,
448
+ runTigerFlow,
449
+ continueTask,
450
+ deleteTask,
451
+ extractTigerResult,
452
+ cancelTask,
453
+ askAgent,
454
+ getAgentsStatus,
455
+ getStatusSummary
456
+ };