@aion0/forge 0.4.15 → 0.5.0
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/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/RELEASE_NOTES.md +170 -13
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +23 -4
- package/check-forge-status.sh +9 -0
- package/cli/mw.ts +2 -2
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +12 -4
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +13 -8
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +34 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +259 -45
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2224 -0
- package/docs/LOCAL-DEPLOY.md +15 -15
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/cloudflared.ts +1 -1
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +8 -2
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +95 -6
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +204 -0
- package/lib/help-docs/CLAUDE.md +5 -2
- package/lib/init.ts +62 -12
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/telegram-standalone.ts +1 -1
- package/lib/terminal-server.ts +2 -2
- package/lib/terminal-standalone.ts +1 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1804 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +790 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +5 -2
- package/src/config/index.ts +13 -2
- package/src/core/db/database.ts +1 -0
- package/start.sh +10 -0
package/lib/pipeline.ts
CHANGED
|
@@ -12,6 +12,7 @@ import YAML from 'yaml';
|
|
|
12
12
|
import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
|
|
13
13
|
import { getProjectInfo } from './projects';
|
|
14
14
|
import { loadSettings } from './settings';
|
|
15
|
+
import { getAgent, listAgents } from './agents';
|
|
15
16
|
import type { Task } from '@/src/types';
|
|
16
17
|
import { getDataDir } from './dirs';
|
|
17
18
|
|
|
@@ -30,7 +31,8 @@ export interface WorkflowNode {
|
|
|
30
31
|
id: string;
|
|
31
32
|
project: string;
|
|
32
33
|
prompt: string;
|
|
33
|
-
mode?: 'claude' | 'shell'; // default: 'claude' (
|
|
34
|
+
mode?: 'claude' | 'shell'; // default: 'claude' (agent -p), 'shell' runs raw shell command
|
|
35
|
+
agent?: string; // agent ID (default: from settings)
|
|
34
36
|
branch?: string; // auto checkout this branch before running (supports templates)
|
|
35
37
|
dependsOn: string[];
|
|
36
38
|
outputs: { name: string; extract: 'result' | 'git_diff' | 'stdout' }[];
|
|
@@ -38,12 +40,46 @@ export interface WorkflowNode {
|
|
|
38
40
|
maxIterations: number;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
// ─── Conversation Mode Types ──────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface ConversationAgent {
|
|
46
|
+
id: string; // logical ID within this conversation (e.g., 'architect', 'implementer')
|
|
47
|
+
agent: string; // agent registry ID (e.g., 'claude', 'codex', 'aider')
|
|
48
|
+
role: string; // system prompt / role description
|
|
49
|
+
project?: string; // project context (optional, defaults to workflow input.project)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ConversationMessage {
|
|
53
|
+
round: number;
|
|
54
|
+
agentId: string; // logical ID from ConversationAgent
|
|
55
|
+
agentName: string; // display name (resolved from registry)
|
|
56
|
+
content: string;
|
|
57
|
+
timestamp: string;
|
|
58
|
+
taskId?: string; // backing task ID
|
|
59
|
+
status: 'pending' | 'running' | 'done' | 'failed';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ConversationConfig {
|
|
63
|
+
agents: ConversationAgent[];
|
|
64
|
+
maxRounds: number; // max back-and-forth rounds
|
|
65
|
+
stopCondition?: string; // e.g., "all agents say DONE", "any agent says DONE"
|
|
66
|
+
initialPrompt: string; // the seed prompt to kick off the conversation
|
|
67
|
+
contextStrategy?: 'full' | 'window' | 'summary'; // how to pass history, default: 'summary'
|
|
68
|
+
contextWindow?: number; // for 'window'/'summary': how many recent messages to include in full (default: 4)
|
|
69
|
+
maxContentLength?: number; // truncate each message to this length (default: 3000)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Workflow ─────────────────────────────────────────────
|
|
73
|
+
|
|
41
74
|
export interface Workflow {
|
|
42
75
|
name: string;
|
|
76
|
+
type?: 'dag' | 'conversation'; // default: 'dag'
|
|
43
77
|
description?: string;
|
|
44
78
|
vars: Record<string, string>;
|
|
45
79
|
input: Record<string, string>; // required input fields
|
|
46
80
|
nodes: Record<string, WorkflowNode>;
|
|
81
|
+
// Conversation mode fields (only when type === 'conversation')
|
|
82
|
+
conversation?: ConversationConfig;
|
|
47
83
|
}
|
|
48
84
|
|
|
49
85
|
export type PipelineNodeStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped';
|
|
@@ -61,6 +97,7 @@ export interface PipelineNodeState {
|
|
|
61
97
|
export interface Pipeline {
|
|
62
98
|
id: string;
|
|
63
99
|
workflowName: string;
|
|
100
|
+
type?: 'dag' | 'conversation'; // default: 'dag'
|
|
64
101
|
status: 'running' | 'done' | 'failed' | 'cancelled';
|
|
65
102
|
input: Record<string, string>;
|
|
66
103
|
vars: Record<string, string>;
|
|
@@ -68,6 +105,13 @@ export interface Pipeline {
|
|
|
68
105
|
nodeOrder: string[]; // for UI display
|
|
69
106
|
createdAt: string;
|
|
70
107
|
completedAt?: string;
|
|
108
|
+
// Conversation mode state
|
|
109
|
+
conversation?: {
|
|
110
|
+
config: ConversationConfig;
|
|
111
|
+
messages: ConversationMessage[];
|
|
112
|
+
currentRound: number;
|
|
113
|
+
currentAgentIndex: number; // index into config.agents
|
|
114
|
+
};
|
|
71
115
|
}
|
|
72
116
|
|
|
73
117
|
// ─── Workflow Loading ─────────────────────────────────────
|
|
@@ -192,6 +236,24 @@ nodes:
|
|
|
192
236
|
outputs:
|
|
193
237
|
- name: result
|
|
194
238
|
extract: stdout
|
|
239
|
+
`,
|
|
240
|
+
'multi-agent-collaboration': `
|
|
241
|
+
name: multi-agent-collaboration
|
|
242
|
+
type: conversation
|
|
243
|
+
description: "Two agents collaborate: one designs, one implements"
|
|
244
|
+
input:
|
|
245
|
+
project: "Project name"
|
|
246
|
+
task: "What to build or fix"
|
|
247
|
+
agents:
|
|
248
|
+
- id: architect
|
|
249
|
+
agent: claude
|
|
250
|
+
role: "You are a software architect. Round 1: design the solution with clear steps. Later rounds: review the implementation and say DONE if satisfied."
|
|
251
|
+
- id: implementer
|
|
252
|
+
agent: claude
|
|
253
|
+
role: "You are a developer. Implement what the architect designs. After implementing, say DONE."
|
|
254
|
+
max_rounds: 3
|
|
255
|
+
stop_condition: "both agents say DONE"
|
|
256
|
+
initial_prompt: "Task: {{input.task}}"
|
|
195
257
|
`,
|
|
196
258
|
};
|
|
197
259
|
|
|
@@ -231,6 +293,7 @@ export function getWorkflow(name: string): WorkflowWithMeta | null {
|
|
|
231
293
|
|
|
232
294
|
function parseWorkflow(raw: string): Workflow {
|
|
233
295
|
const parsed = YAML.parse(raw);
|
|
296
|
+
const workflowType = parsed.type || 'dag';
|
|
234
297
|
const nodes: Record<string, WorkflowNode> = {};
|
|
235
298
|
|
|
236
299
|
for (const [id, def] of Object.entries(parsed.nodes || {})) {
|
|
@@ -240,6 +303,7 @@ function parseWorkflow(raw: string): Workflow {
|
|
|
240
303
|
project: n.project || '',
|
|
241
304
|
prompt: n.prompt || '',
|
|
242
305
|
mode: n.mode || 'claude',
|
|
306
|
+
agent: n.agent || undefined,
|
|
243
307
|
branch: n.branch || undefined,
|
|
244
308
|
dependsOn: n.depends_on || n.dependsOn || [],
|
|
245
309
|
outputs: (n.outputs || []).map((o: any) => ({
|
|
@@ -254,12 +318,33 @@ function parseWorkflow(raw: string): Workflow {
|
|
|
254
318
|
};
|
|
255
319
|
}
|
|
256
320
|
|
|
321
|
+
// Parse conversation config
|
|
322
|
+
let conversation: ConversationConfig | undefined;
|
|
323
|
+
if (workflowType === 'conversation' && parsed.agents) {
|
|
324
|
+
conversation = {
|
|
325
|
+
agents: (parsed.agents as any[]).map((a: any) => ({
|
|
326
|
+
id: a.id,
|
|
327
|
+
agent: a.agent || 'claude',
|
|
328
|
+
role: a.role || '',
|
|
329
|
+
project: a.project || undefined,
|
|
330
|
+
})),
|
|
331
|
+
maxRounds: parsed.max_rounds || parsed.maxRounds || 10,
|
|
332
|
+
stopCondition: parsed.stop_condition || parsed.stopCondition || undefined,
|
|
333
|
+
initialPrompt: parsed.initial_prompt || parsed.initialPrompt || '',
|
|
334
|
+
contextStrategy: parsed.context_strategy || parsed.contextStrategy || 'summary',
|
|
335
|
+
contextWindow: parsed.context_window || parsed.contextWindow || 4,
|
|
336
|
+
maxContentLength: parsed.max_content_length || parsed.maxContentLength || 3000,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
257
340
|
return {
|
|
258
341
|
name: parsed.name || 'unnamed',
|
|
342
|
+
type: workflowType,
|
|
259
343
|
description: parsed.description,
|
|
260
344
|
vars: parsed.vars || {},
|
|
261
345
|
input: parsed.input || {},
|
|
262
346
|
nodes,
|
|
347
|
+
conversation,
|
|
263
348
|
};
|
|
264
349
|
}
|
|
265
350
|
|
|
@@ -381,6 +466,11 @@ export function startPipeline(workflowName: string, input: Record<string, string
|
|
|
381
466
|
const workflow = getWorkflow(workflowName);
|
|
382
467
|
if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
|
|
383
468
|
|
|
469
|
+
// Conversation mode — separate execution path
|
|
470
|
+
if (workflow.type === 'conversation' && workflow.conversation) {
|
|
471
|
+
return startConversationPipeline(workflow, input);
|
|
472
|
+
}
|
|
473
|
+
|
|
384
474
|
const id = randomUUID().slice(0, 8);
|
|
385
475
|
const nodes: Record<string, PipelineNodeState> = {};
|
|
386
476
|
const nodeOrder = topologicalSort(workflow.nodes);
|
|
@@ -415,11 +505,451 @@ export function startPipeline(workflowName: string, input: Record<string, string
|
|
|
415
505
|
return pipeline;
|
|
416
506
|
}
|
|
417
507
|
|
|
508
|
+
// ─── Conversation State Type (extracted to avoid Turbopack parse issues) ──
|
|
509
|
+
type ConversationState = {
|
|
510
|
+
config: ConversationConfig;
|
|
511
|
+
messages: ConversationMessage[];
|
|
512
|
+
currentRound: number;
|
|
513
|
+
currentAgentIndex: number;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// ─── Conversation Mode Execution ──────────────────────────
|
|
517
|
+
|
|
518
|
+
function startConversationPipeline(workflow: Workflow, input: Record<string, string>): Pipeline {
|
|
519
|
+
const conv = workflow.conversation!;
|
|
520
|
+
const id = randomUUID().slice(0, 8);
|
|
521
|
+
|
|
522
|
+
// Resolve agent display names
|
|
523
|
+
const agentNames: Record<string, string> = {};
|
|
524
|
+
const allAgents = listAgents();
|
|
525
|
+
for (const ca of conv.agents) {
|
|
526
|
+
const found = allAgents.find(a => a.id === ca.agent);
|
|
527
|
+
agentNames[ca.id] = found?.name || ca.agent;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const pipeline: Pipeline = {
|
|
531
|
+
id,
|
|
532
|
+
workflowName: workflow.name,
|
|
533
|
+
type: 'conversation',
|
|
534
|
+
status: 'running',
|
|
535
|
+
input,
|
|
536
|
+
vars: { ...workflow.vars },
|
|
537
|
+
nodes: {},
|
|
538
|
+
nodeOrder: [],
|
|
539
|
+
createdAt: new Date().toISOString(),
|
|
540
|
+
conversation: {
|
|
541
|
+
config: {
|
|
542
|
+
...conv,
|
|
543
|
+
// Store resolved initial prompt so buildConversationContext uses it
|
|
544
|
+
initialPrompt: conv.initialPrompt.replace(/\{\{input\.(\w+)\}\}/g, (_, key) => input[key] || ''),
|
|
545
|
+
},
|
|
546
|
+
messages: [],
|
|
547
|
+
currentRound: 1,
|
|
548
|
+
currentAgentIndex: 0,
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
savePipeline(pipeline);
|
|
553
|
+
|
|
554
|
+
const resolvedPrompt = pipeline.conversation!.config.initialPrompt;
|
|
555
|
+
|
|
556
|
+
// Start the first round
|
|
557
|
+
scheduleNextConversationTurn(pipeline, resolvedPrompt, agentNames);
|
|
558
|
+
|
|
559
|
+
return pipeline;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: string, agentNames?: Record<string, string>) {
|
|
563
|
+
const conv = pipeline.conversation!;
|
|
564
|
+
const config = conv.config;
|
|
565
|
+
const agentDef = config.agents[conv.currentAgentIndex];
|
|
566
|
+
|
|
567
|
+
if (!agentDef) {
|
|
568
|
+
pipeline.status = 'failed';
|
|
569
|
+
pipeline.completedAt = new Date().toISOString();
|
|
570
|
+
savePipeline(pipeline);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Resolve project
|
|
575
|
+
const projectName = agentDef.project || pipeline.input.project || '';
|
|
576
|
+
const projectInfo = getProjectInfo(projectName);
|
|
577
|
+
if (!projectInfo) {
|
|
578
|
+
pipeline.status = 'failed';
|
|
579
|
+
pipeline.completedAt = new Date().toISOString();
|
|
580
|
+
savePipeline(pipeline);
|
|
581
|
+
notifyPipelineComplete(pipeline);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Build the prompt: role context + conversation history + new message
|
|
586
|
+
const rolePrefix = agentDef.role ? `[Your role: ${agentDef.role}]\n\n` : '';
|
|
587
|
+
const fullPrompt = `${rolePrefix}${contextForAgent}`;
|
|
588
|
+
|
|
589
|
+
// Create a task for this agent's turn
|
|
590
|
+
const task = createTask({
|
|
591
|
+
projectName: projectInfo.name,
|
|
592
|
+
projectPath: projectInfo.path,
|
|
593
|
+
prompt: fullPrompt,
|
|
594
|
+
mode: 'prompt',
|
|
595
|
+
agent: agentDef.agent,
|
|
596
|
+
conversationId: '', // fresh session — no resume for conversation mode
|
|
597
|
+
});
|
|
598
|
+
pipelineTaskIds.add(task.id);
|
|
599
|
+
|
|
600
|
+
// Add pending message
|
|
601
|
+
const names = agentNames || resolveAgentNames(config.agents);
|
|
602
|
+
conv.messages.push({
|
|
603
|
+
round: conv.currentRound,
|
|
604
|
+
agentId: agentDef.id,
|
|
605
|
+
agentName: names[agentDef.id] || agentDef.agent,
|
|
606
|
+
content: '',
|
|
607
|
+
timestamp: new Date().toISOString(),
|
|
608
|
+
taskId: task.id,
|
|
609
|
+
status: 'running',
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
savePipeline(pipeline);
|
|
613
|
+
|
|
614
|
+
// Listen for this task to complete
|
|
615
|
+
setupConversationTaskListener(pipeline.id, task.id);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function resolveAgentNames(agents: ConversationAgent[]): Record<string, string> {
|
|
619
|
+
const allAgents = listAgents();
|
|
620
|
+
const names: Record<string, string> = {};
|
|
621
|
+
for (const ca of agents) {
|
|
622
|
+
const found = allAgents.find(a => a.id === ca.agent);
|
|
623
|
+
names[ca.id] = found?.name || ca.agent;
|
|
624
|
+
}
|
|
625
|
+
return names;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function setupConversationTaskListener(pipelineId: string, taskId: string) {
|
|
629
|
+
const cleanup = onTaskEvent((evtTaskId, event, data) => {
|
|
630
|
+
if (evtTaskId !== taskId) return;
|
|
631
|
+
if (event !== 'status') return;
|
|
632
|
+
if (data !== 'done' && data !== 'failed') return;
|
|
633
|
+
|
|
634
|
+
cleanup(); // one-shot listener
|
|
635
|
+
|
|
636
|
+
const pipeline = getPipeline(pipelineId);
|
|
637
|
+
if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) return;
|
|
638
|
+
|
|
639
|
+
const conv = pipeline.conversation;
|
|
640
|
+
const config = conv.config;
|
|
641
|
+
const msgIndex = conv.messages.findIndex(m => m.taskId === taskId);
|
|
642
|
+
if (msgIndex < 0) return;
|
|
643
|
+
|
|
644
|
+
const task = getTask(taskId);
|
|
645
|
+
|
|
646
|
+
if (data === 'failed' || !task) {
|
|
647
|
+
conv.messages[msgIndex].status = 'failed';
|
|
648
|
+
conv.messages[msgIndex].content = task?.error || 'Task failed';
|
|
649
|
+
pipeline.status = 'failed';
|
|
650
|
+
pipeline.completedAt = new Date().toISOString();
|
|
651
|
+
savePipeline(pipeline);
|
|
652
|
+
notifyPipelineComplete(pipeline);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Task completed — extract response
|
|
657
|
+
const response = task.resultSummary || '';
|
|
658
|
+
conv.messages[msgIndex].status = 'done';
|
|
659
|
+
conv.messages[msgIndex].content = response;
|
|
660
|
+
|
|
661
|
+
// Check stop condition
|
|
662
|
+
if (checkConversationStopCondition(conv, response)) {
|
|
663
|
+
finishConversation(pipeline, 'done');
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Move to next agent in round, or next round
|
|
668
|
+
conv.currentAgentIndex++;
|
|
669
|
+
if (conv.currentAgentIndex >= config.agents.length) {
|
|
670
|
+
// Completed a full round
|
|
671
|
+
conv.currentAgentIndex = 0;
|
|
672
|
+
conv.currentRound++;
|
|
673
|
+
|
|
674
|
+
if (conv.currentRound > config.maxRounds) {
|
|
675
|
+
finishConversation(pipeline, 'done');
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
savePipeline(pipeline);
|
|
681
|
+
|
|
682
|
+
// Build context for next agent: accumulate conversation history
|
|
683
|
+
const contextForNext = buildConversationContext(conv);
|
|
684
|
+
scheduleNextConversationTurn(pipeline, contextForNext);
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Build context string for the next agent in conversation.
|
|
690
|
+
*
|
|
691
|
+
* Three strategies:
|
|
692
|
+
* - 'full' : pass ALL history as-is (token-heavy, good for short convos)
|
|
693
|
+
* - 'window' : pass only the last N messages in full, drop older ones
|
|
694
|
+
* - 'summary' : pass older messages as one-line summaries + last N in full (default)
|
|
695
|
+
*/
|
|
696
|
+
function buildConversationContext(conv: ConversationState): string {
|
|
697
|
+
const config = conv.config;
|
|
698
|
+
const strategy = config.contextStrategy || 'summary';
|
|
699
|
+
const windowSize = config.contextWindow || 4;
|
|
700
|
+
const maxLen = config.maxContentLength || 3000;
|
|
701
|
+
|
|
702
|
+
const doneMessages = conv.messages.filter(m => m.status === 'done' && m.content);
|
|
703
|
+
|
|
704
|
+
let context = `[Conversation — Round ${conv.currentRound}]\n\n`;
|
|
705
|
+
context += `Task: ${config.initialPrompt}\n\n`;
|
|
706
|
+
|
|
707
|
+
if (doneMessages.length === 0) {
|
|
708
|
+
context += `--- Your Turn ---\nYou are the first to respond. Please address the task above. If you believe the task is complete, include "DONE" in your response.`;
|
|
709
|
+
return context;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
context += `--- Conversation History ---\n\n`;
|
|
713
|
+
|
|
714
|
+
if (strategy === 'full') {
|
|
715
|
+
// Full: all messages, truncated per maxLen
|
|
716
|
+
for (const msg of doneMessages) {
|
|
717
|
+
context += formatMessage(msg, config, maxLen);
|
|
718
|
+
}
|
|
719
|
+
} else if (strategy === 'window') {
|
|
720
|
+
// Window: only last N messages
|
|
721
|
+
const recent = doneMessages.slice(-windowSize);
|
|
722
|
+
if (doneMessages.length > windowSize) {
|
|
723
|
+
context += `[... ${doneMessages.length - windowSize} earlier messages omitted ...]\n\n`;
|
|
724
|
+
}
|
|
725
|
+
for (const msg of recent) {
|
|
726
|
+
context += formatMessage(msg, config, maxLen);
|
|
727
|
+
}
|
|
728
|
+
} else {
|
|
729
|
+
// Summary (default): older messages as one-line summaries, recent in full
|
|
730
|
+
const cutoff = doneMessages.length - windowSize;
|
|
731
|
+
if (cutoff > 0) {
|
|
732
|
+
context += `[Previous rounds summary]\n`;
|
|
733
|
+
for (let i = 0; i < cutoff; i++) {
|
|
734
|
+
const msg = doneMessages[i];
|
|
735
|
+
const summary = extractSummaryLine(msg.content);
|
|
736
|
+
context += ` R${msg.round} ${msg.agentName}: ${summary}\n`;
|
|
737
|
+
}
|
|
738
|
+
context += `\n`;
|
|
739
|
+
}
|
|
740
|
+
// Recent messages in full
|
|
741
|
+
const recent = doneMessages.slice(Math.max(0, cutoff));
|
|
742
|
+
for (const msg of recent) {
|
|
743
|
+
context += formatMessage(msg, config, maxLen);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
context += `--- Your Turn ---\nRespond based on the conversation above. If you believe the task is complete, include "DONE" in your response.`;
|
|
748
|
+
return context;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function formatMessage(msg: ConversationMessage, config: ConversationConfig, maxLen: number): string {
|
|
752
|
+
const agentDef = config.agents.find(a => a.id === msg.agentId);
|
|
753
|
+
const label = `${msg.agentName} (${agentDef?.id || '?'})`;
|
|
754
|
+
const content = msg.content.length > maxLen
|
|
755
|
+
? msg.content.slice(0, maxLen) + '\n[... truncated]'
|
|
756
|
+
: msg.content;
|
|
757
|
+
return `[${label} — Round ${msg.round}]:\n${content}\n\n`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Extract a one-line summary from agent output (first meaningful line or first 120 chars) */
|
|
761
|
+
function extractSummaryLine(content: string): string {
|
|
762
|
+
const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 10);
|
|
763
|
+
const first = lines[0] || content.slice(0, 120);
|
|
764
|
+
return first.length > 120 ? first.slice(0, 117) + '...' : first;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function checkConversationStopCondition(conv: ConversationState, latestResponse: string): boolean {
|
|
768
|
+
const condition = conv.config.stopCondition;
|
|
769
|
+
if (!condition) return false;
|
|
770
|
+
|
|
771
|
+
const lower = condition.toLowerCase();
|
|
772
|
+
|
|
773
|
+
// "any agent says DONE"
|
|
774
|
+
if (lower.includes('any') && lower.includes('done')) {
|
|
775
|
+
return latestResponse.toUpperCase().includes('DONE');
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// "all agents say DONE" / "both agents say DONE"
|
|
779
|
+
if ((lower.includes('all') || lower.includes('both')) && lower.includes('done')) {
|
|
780
|
+
// Only check messages from the CURRENT round — don't mix rounds
|
|
781
|
+
const currentRound = conv.currentRound;
|
|
782
|
+
const agentIds = conv.config.agents.map(a => a.id);
|
|
783
|
+
const roundMessages = new Map<string, string>();
|
|
784
|
+
for (const msg of conv.messages) {
|
|
785
|
+
if (msg.status === 'done' && msg.round === currentRound && msg.agentId !== 'user') {
|
|
786
|
+
roundMessages.set(msg.agentId, msg.content);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// All agents in this round must have responded AND said DONE
|
|
790
|
+
return agentIds.every(id => {
|
|
791
|
+
const content = roundMessages.get(id);
|
|
792
|
+
return content && content.toUpperCase().includes('DONE');
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Default: check if latest response contains DONE
|
|
797
|
+
return latestResponse.toUpperCase().includes('DONE');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Cleanly finish a conversation — cancel any still-running tasks, mark messages */
|
|
801
|
+
function finishConversation(pipeline: Pipeline, status: 'done' | 'failed') {
|
|
802
|
+
const conv = pipeline.conversation!;
|
|
803
|
+
for (const msg of conv.messages) {
|
|
804
|
+
if (msg.status === 'running' && msg.taskId) {
|
|
805
|
+
// Cancel the running task
|
|
806
|
+
try { const { cancelTask } = require('./task-manager'); cancelTask(msg.taskId); } catch {}
|
|
807
|
+
msg.status = status === 'done' ? 'done' : 'failed';
|
|
808
|
+
if (!msg.content) msg.content = status === 'done' ? '(conversation ended)' : '(conversation failed)';
|
|
809
|
+
}
|
|
810
|
+
if (msg.status === 'pending') {
|
|
811
|
+
msg.status = 'failed';
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
pipeline.status = status;
|
|
815
|
+
pipeline.completedAt = new Date().toISOString();
|
|
816
|
+
savePipeline(pipeline);
|
|
817
|
+
notifyPipelineComplete(pipeline);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Cancel a conversation pipeline */
|
|
821
|
+
export function cancelConversation(pipelineId: string): boolean {
|
|
822
|
+
const pipeline = getPipeline(pipelineId);
|
|
823
|
+
if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) return false;
|
|
824
|
+
|
|
825
|
+
// Cancel any running task
|
|
826
|
+
for (const msg of pipeline.conversation.messages) {
|
|
827
|
+
if (msg.status === 'running' && msg.taskId) {
|
|
828
|
+
const { cancelTask } = require('./task-manager');
|
|
829
|
+
cancelTask(msg.taskId);
|
|
830
|
+
}
|
|
831
|
+
if (msg.status === 'pending') msg.status = 'failed';
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
pipeline.status = 'cancelled';
|
|
835
|
+
pipeline.completedAt = new Date().toISOString();
|
|
836
|
+
savePipeline(pipeline);
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Inject a user message into a running conversation.
|
|
842
|
+
* Waits for current agent to finish, then sends the injected message
|
|
843
|
+
* as additional context to the specified agent on the next turn.
|
|
844
|
+
*/
|
|
845
|
+
export function injectConversationMessage(pipelineId: string, targetAgentId: string, message: string): boolean {
|
|
846
|
+
const pipeline = getPipeline(pipelineId);
|
|
847
|
+
if (!pipeline || pipeline.status !== 'running' || !pipeline.conversation) {
|
|
848
|
+
throw new Error('Pipeline not running or not a conversation');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const conv = pipeline.conversation;
|
|
852
|
+
const agentDef = conv.config.agents.find(a => a.id === targetAgentId);
|
|
853
|
+
if (!agentDef) throw new Error(`Agent not found: ${targetAgentId}`);
|
|
854
|
+
|
|
855
|
+
// Add a "user" message to the conversation
|
|
856
|
+
conv.messages.push({
|
|
857
|
+
round: conv.currentRound,
|
|
858
|
+
agentId: 'user',
|
|
859
|
+
agentName: 'Operator',
|
|
860
|
+
content: `[@${targetAgentId}] ${message}`,
|
|
861
|
+
timestamp: new Date().toISOString(),
|
|
862
|
+
status: 'done',
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
savePipeline(pipeline);
|
|
866
|
+
|
|
867
|
+
// If no agent is currently running, immediately schedule the target agent
|
|
868
|
+
const hasRunning = conv.messages.some(m => m.status === 'running');
|
|
869
|
+
if (!hasRunning) {
|
|
870
|
+
// Point to the target agent for next turn
|
|
871
|
+
const targetIdx = conv.config.agents.findIndex(a => a.id === targetAgentId);
|
|
872
|
+
if (targetIdx >= 0) {
|
|
873
|
+
conv.currentAgentIndex = targetIdx;
|
|
874
|
+
savePipeline(pipeline);
|
|
875
|
+
const context = buildConversationContext(conv);
|
|
876
|
+
scheduleNextConversationTurn(pipeline, context);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// If an agent IS running, the injected message will be included in the next context build
|
|
880
|
+
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ─── Conversation Recovery ────────────────────────────────
|
|
885
|
+
|
|
886
|
+
function recoverConversationPipeline(pipeline: Pipeline) {
|
|
887
|
+
const conv = pipeline.conversation!;
|
|
888
|
+
const runningMsg = conv.messages.find(m => m.status === 'running');
|
|
889
|
+
if (!runningMsg || !runningMsg.taskId) return;
|
|
890
|
+
|
|
891
|
+
const task = getTask(runningMsg.taskId);
|
|
892
|
+
if (!task) {
|
|
893
|
+
// Task gone — mark as done with empty content, try next turn
|
|
894
|
+
runningMsg.status = 'done';
|
|
895
|
+
runningMsg.content = '(no response — task was cleaned up)';
|
|
896
|
+
savePipeline(pipeline);
|
|
897
|
+
advanceConversation(pipeline);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (task.status === 'done') {
|
|
901
|
+
runningMsg.status = 'done';
|
|
902
|
+
runningMsg.content = task.resultSummary || '';
|
|
903
|
+
savePipeline(pipeline);
|
|
904
|
+
advanceConversation(pipeline);
|
|
905
|
+
} else if (task.status === 'failed' || task.status === 'cancelled') {
|
|
906
|
+
runningMsg.status = 'failed';
|
|
907
|
+
runningMsg.content = task.error || 'Task failed';
|
|
908
|
+
pipeline.status = 'failed';
|
|
909
|
+
pipeline.completedAt = new Date().toISOString();
|
|
910
|
+
savePipeline(pipeline);
|
|
911
|
+
} else {
|
|
912
|
+
// Still running — re-attach listener
|
|
913
|
+
setupConversationTaskListener(pipeline.id, runningMsg.taskId);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function advanceConversation(pipeline: Pipeline) {
|
|
918
|
+
const conv = pipeline.conversation!;
|
|
919
|
+
const config = conv.config;
|
|
920
|
+
const lastDoneMsg = [...conv.messages].reverse().find(m => m.status === 'done');
|
|
921
|
+
|
|
922
|
+
if (lastDoneMsg && checkConversationStopCondition(conv, lastDoneMsg.content)) {
|
|
923
|
+
finishConversation(pipeline, 'done');
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
conv.currentAgentIndex++;
|
|
928
|
+
if (conv.currentAgentIndex >= config.agents.length) {
|
|
929
|
+
conv.currentAgentIndex = 0;
|
|
930
|
+
conv.currentRound++;
|
|
931
|
+
if (conv.currentRound > config.maxRounds) {
|
|
932
|
+
finishConversation(pipeline, 'done');
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
savePipeline(pipeline);
|
|
938
|
+
const contextForNext = buildConversationContext(conv);
|
|
939
|
+
scheduleNextConversationTurn(pipeline, contextForNext);
|
|
940
|
+
}
|
|
941
|
+
|
|
418
942
|
// ─── Recovery: check for stuck pipelines ──────────────────
|
|
419
943
|
|
|
420
944
|
function recoverStuckPipelines() {
|
|
421
945
|
const pipelines = listPipelines().filter(p => p.status === 'running');
|
|
422
946
|
for (const pipeline of pipelines) {
|
|
947
|
+
// Conversation mode recovery
|
|
948
|
+
if (pipeline.type === 'conversation' && pipeline.conversation) {
|
|
949
|
+
recoverConversationPipeline(pipeline);
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
|
|
423
953
|
const workflow = getWorkflow(pipeline.workflowName);
|
|
424
954
|
if (!workflow) continue;
|
|
425
955
|
|
|
@@ -471,6 +1001,11 @@ export function cancelPipeline(id: string): boolean {
|
|
|
471
1001
|
const pipeline = getPipeline(id);
|
|
472
1002
|
if (!pipeline || pipeline.status !== 'running') return false;
|
|
473
1003
|
|
|
1004
|
+
// Conversation mode
|
|
1005
|
+
if (pipeline.type === 'conversation') {
|
|
1006
|
+
return cancelConversation(id);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
474
1009
|
pipeline.status = 'cancelled';
|
|
475
1010
|
pipeline.completedAt = new Date().toISOString();
|
|
476
1011
|
|
|
@@ -561,6 +1096,7 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
561
1096
|
projectPath: projectInfo.path,
|
|
562
1097
|
prompt,
|
|
563
1098
|
mode: taskMode as any,
|
|
1099
|
+
agent: nodeDef.agent || undefined,
|
|
564
1100
|
});
|
|
565
1101
|
pipelineTaskIds.add(task.id);
|
|
566
1102
|
if (taskMode !== 'shell') {
|