@swarmclawai/swarmclaw 0.3.0 → 0.4.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.
Files changed (118) hide show
  1. package/README.md +20 -11
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +2 -0
  6. package/package.json +3 -1
  7. package/src/app/api/agents/[id]/route.ts +3 -0
  8. package/src/app/api/agents/[id]/thread/route.ts +2 -1
  9. package/src/app/api/agents/route.ts +5 -1
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/connectors/[id]/route.ts +4 -0
  13. package/src/app/api/connectors/route.ts +6 -1
  14. package/src/app/api/credentials/route.ts +3 -1
  15. package/src/app/api/daemon/route.ts +6 -1
  16. package/src/app/api/ip/route.ts +3 -1
  17. package/src/app/api/mcp-servers/route.ts +3 -1
  18. package/src/app/api/orchestrator/graph/route.ts +25 -0
  19. package/src/app/api/plugins/marketplace/route.ts +3 -1
  20. package/src/app/api/plugins/route.ts +3 -1
  21. package/src/app/api/providers/[id]/route.ts +3 -0
  22. package/src/app/api/providers/configs/route.ts +3 -1
  23. package/src/app/api/providers/route.ts +5 -1
  24. package/src/app/api/schedules/[id]/route.ts +3 -0
  25. package/src/app/api/schedules/route.ts +6 -1
  26. package/src/app/api/secrets/route.ts +3 -1
  27. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  28. package/src/app/api/sessions/route.ts +9 -2
  29. package/src/app/api/settings/route.ts +3 -1
  30. package/src/app/api/setup/doctor/route.ts +1 -0
  31. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  32. package/src/app/api/skills/route.ts +3 -1
  33. package/src/app/api/tasks/[id]/approve/route.ts +73 -0
  34. package/src/app/api/tasks/[id]/route.ts +3 -0
  35. package/src/app/api/tasks/route.ts +3 -0
  36. package/src/app/api/usage/route.ts +3 -1
  37. package/src/app/api/version/route.ts +3 -1
  38. package/src/app/api/webhooks/[id]/route.ts +2 -1
  39. package/src/app/api/webhooks/route.ts +3 -1
  40. package/src/app/icon.svg +58 -0
  41. package/src/app/page.tsx +8 -2
  42. package/src/cli/index.js +1 -9
  43. package/src/cli/index.ts +51 -1
  44. package/src/cli/spec.js +0 -8
  45. package/src/components/agents/agent-card.tsx +1 -1
  46. package/src/components/agents/agent-sheet.tsx +63 -80
  47. package/src/components/chat/chat-area.tsx +44 -30
  48. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  49. package/src/components/chat/message-bubble.tsx +110 -42
  50. package/src/components/chat/tool-call-bubble.tsx +41 -3
  51. package/src/components/chat/tool-request-banner.tsx +1 -9
  52. package/src/components/connectors/connector-list.tsx +3 -8
  53. package/src/components/connectors/connector-sheet.tsx +24 -29
  54. package/src/components/input/chat-input.tsx +72 -56
  55. package/src/components/knowledge/knowledge-list.tsx +27 -31
  56. package/src/components/layout/app-layout.tsx +92 -71
  57. package/src/components/layout/daemon-indicator.tsx +3 -5
  58. package/src/components/logs/log-list.tsx +5 -9
  59. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  60. package/src/components/memory/memory-detail.tsx +1 -1
  61. package/src/components/plugins/plugin-list.tsx +227 -27
  62. package/src/components/providers/provider-list.tsx +46 -13
  63. package/src/components/providers/provider-sheet.tsx +0 -45
  64. package/src/components/runs/run-list.tsx +6 -15
  65. package/src/components/schedules/schedule-card.tsx +54 -4
  66. package/src/components/schedules/schedule-list.tsx +6 -3
  67. package/src/components/schedules/schedule-sheet.tsx +0 -47
  68. package/src/components/secrets/secrets-list.tsx +20 -2
  69. package/src/components/sessions/new-session-sheet.tsx +8 -9
  70. package/src/components/shared/connector-platform-icon.tsx +22 -20
  71. package/src/components/shared/model-combobox.tsx +148 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +7 -39
  73. package/src/components/shared/settings/section-orchestrator.tsx +8 -9
  74. package/src/components/skills/skill-list.tsx +260 -34
  75. package/src/components/skills/skill-sheet.tsx +0 -45
  76. package/src/components/tasks/task-board.tsx +3 -6
  77. package/src/components/tasks/task-card.tsx +43 -1
  78. package/src/components/tasks/task-list.tsx +3 -5
  79. package/src/components/tasks/task-sheet.tsx +0 -44
  80. package/src/components/usage/usage-list.tsx +12 -4
  81. package/src/hooks/use-ws.ts +66 -0
  82. package/src/instrumentation.ts +2 -0
  83. package/src/lib/chat.ts +14 -2
  84. package/src/lib/providers/anthropic.ts +1 -1
  85. package/src/lib/providers/index.ts +2 -0
  86. package/src/lib/providers/ollama.ts +1 -1
  87. package/src/lib/providers/openai.ts +33 -12
  88. package/src/lib/server/chat-execution.ts +19 -4
  89. package/src/lib/server/connectors/manager.ts +9 -3
  90. package/src/lib/server/context-manager.ts +1 -1
  91. package/src/lib/server/daemon-state.ts +3 -0
  92. package/src/lib/server/data-dir.ts +1 -0
  93. package/src/lib/server/heartbeat-service.ts +67 -3
  94. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  95. package/src/lib/server/main-agent-loop.ts +61 -2
  96. package/src/lib/server/orchestrator-lg.ts +394 -13
  97. package/src/lib/server/orchestrator.ts +25 -5
  98. package/src/lib/server/queue.ts +17 -3
  99. package/src/lib/server/session-run-manager.ts +6 -1
  100. package/src/lib/server/session-tools/delegate.ts +2 -2
  101. package/src/lib/server/session-tools/index.ts +2 -0
  102. package/src/lib/server/session-tools/sandbox.ts +164 -0
  103. package/src/lib/server/storage-mcp.test.ts +25 -2
  104. package/src/lib/server/storage.ts +24 -7
  105. package/src/lib/server/stream-agent-chat.ts +77 -22
  106. package/src/lib/server/task-validation.test.ts +23 -0
  107. package/src/lib/server/task-validation.ts +5 -3
  108. package/src/lib/server/ws-hub.ts +85 -0
  109. package/src/lib/tool-definitions.ts +42 -0
  110. package/src/lib/upload.ts +7 -1
  111. package/src/lib/ws-client.ts +124 -0
  112. package/src/stores/use-chat-store.ts +33 -13
  113. package/src/types/index.ts +8 -1
  114. package/src/app/api/agents/generate/route.ts +0 -42
  115. package/src/app/api/generate/info/route.ts +0 -12
  116. package/src/app/api/generate/route.ts +0 -106
  117. package/src/app/favicon.ico +0 -0
  118. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -1,12 +1,18 @@
1
1
  import { z } from 'zod'
2
2
  import { tool } from '@langchain/core/tools'
3
- import { createReactAgent } from '@langchain/langgraph/prebuilt'
3
+ import { StateGraph, MessagesAnnotation, START, END } from '@langchain/langgraph'
4
+ import { ToolNode } from '@langchain/langgraph/prebuilt'
5
+ import { AIMessage } from '@langchain/core/messages'
4
6
  import { loadSessions, saveSessions, loadAgents, loadCredentials, loadSettings, loadSecrets, loadTasks, saveTasks, decryptKey, loadSkills } from './storage'
7
+ import { WORKSPACE_DIR } from './data-dir'
5
8
  import { loadRuntimeSettings, getOrchestratorLoopRecursionLimit } from './runtime-settings'
6
9
  import { getMemoryDb } from './memory-db'
7
10
  import { buildChatModel } from './build-llm'
11
+ import { getCheckpointSaver } from './langgraph-checkpoint'
12
+ import { notify } from './ws-hub'
13
+ import { pushMainLoopEventToMainSessions } from './main-agent-loop'
8
14
  import crypto from 'crypto'
9
- import type { Agent, TaskComment } from '@/types'
15
+ import type { Agent, TaskComment, MessageToolEvent } from '@/types'
10
16
 
11
17
  const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
12
18
 
@@ -73,11 +79,11 @@ function getSecretsForOrchestrator(orchestratorId: string): { name: string; serv
73
79
  return result
74
80
  }
75
81
 
76
- function saveMessage(sessionId: string, role: 'user' | 'assistant', text: string) {
82
+ function saveMessage(sessionId: string, role: 'user' | 'assistant', text: string, toolEvents?: MessageToolEvent[]) {
77
83
  const sessions = loadSessions()
78
84
  const session = sessions[sessionId]
79
85
  if (!session) return
80
- session.messages.push({ role, text, time: Date.now() })
86
+ session.messages.push({ role, text, time: Date.now(), ...(toolEvents?.length ? { toolEvents } : {}) })
81
87
  session.lastActiveAt = Date.now()
82
88
  saveSessions(sessions)
83
89
  }
@@ -95,7 +101,7 @@ async function executeSubTaskViaCli(agent: Agent, task: string, parentSessionId:
95
101
  sessions[childId] = {
96
102
  id: childId,
97
103
  name: `[Agent] ${agent.name}: ${task.slice(0, 40)}`,
98
- cwd: parentSession?.cwd || process.cwd(),
104
+ cwd: parentSession?.cwd || WORKSPACE_DIR,
99
105
  user: 'system',
100
106
  provider: agent.provider,
101
107
  model: agent.model,
@@ -136,6 +142,7 @@ export async function executeLangGraphOrchestrator(
136
142
  orchestrator: Agent,
137
143
  task: string,
138
144
  sessionId: string,
145
+ taskId?: string,
139
146
  ): Promise<string> {
140
147
  const allAgents = loadAgents()
141
148
 
@@ -165,9 +172,12 @@ export async function executeLangGraphOrchestrator(
165
172
  return `Agent "${agentName}" not found. Available: ${agents.map((a) => a.name).join(', ')}`
166
173
  }
167
174
  console.log(`[orchestrator-lg] Delegating to ${agent.name}: ${agentTask.slice(0, 80)}`)
168
- saveMessage(sessionId, 'assistant', `[Delegating to ${agent.name}]: ${agentTask}`)
169
175
  const result = await executeSubTaskViaCli(agent, agentTask, sessionId)
170
- saveMessage(sessionId, 'user', `[Agent ${agent.name} result]: ${result.slice(0, 2000)}`)
176
+ saveMessage(sessionId, 'assistant', `Delegated to ${agent.name}: ${agentTask.slice(0, 100)}`, [{
177
+ name: 'delegate_to_agent',
178
+ input: JSON.stringify({ agentName: agent.name, agentId: agent.id, task: agentTask }),
179
+ output: result.slice(0, 2000),
180
+ }])
171
181
  return result
172
182
  },
173
183
  {
@@ -369,12 +379,102 @@ export async function executeLangGraphOrchestrator(
369
379
  taskContext,
370
380
  ].join('\n')
371
381
 
372
- const agent = createReactAgent({
373
- llm,
374
- tools: [delegateTool, storeMemoryTool, searchMemoryTool, getSecretTool, commentOnTaskTool, createTaskTool, markCompleteTool],
375
- stateModifier: systemMessage,
382
+ const checkpointSaver = getCheckpointSaver()
383
+ const isStrictMode = settings.capabilityPolicyMode === 'strict'
384
+ const allTools = [delegateTool, storeMemoryTool, searchMemoryTool, getSecretTool, commentOnTaskTool, createTaskTool, markCompleteTool]
385
+ const llmWithTools = llm.bindTools(allTools)
386
+ const toolNode = new ToolNode(allTools)
387
+
388
+ // Track fallback attempts for delegate_to_agent failures
389
+ let fallbackAttempts = 0
390
+ const MAX_FALLBACK_ATTEMPTS = 2
391
+
392
+ // Agent node: calls LLM with tools
393
+ async function agentNode(state: typeof MessagesAnnotation.State) {
394
+ const response = await llmWithTools.invoke([
395
+ { role: 'system' as const, content: systemMessage },
396
+ ...state.messages,
397
+ ])
398
+ return { messages: [response] }
399
+ }
400
+
401
+ // Router node: inspects tool results, decides next step
402
+ function routerNode(state: typeof MessagesAnnotation.State) {
403
+ const messages = state.messages
404
+ const lastMsg = messages[messages.length - 1]
405
+
406
+ // Check if the last tool message contains an error from delegate_to_agent
407
+ if (lastMsg && typeof (lastMsg as any).content === 'string') {
408
+ const content = (lastMsg as any).content as string
409
+ const isError = content.startsWith('Error:') || content.startsWith('Agent "') && content.includes('not found')
410
+
411
+ if (isError && fallbackAttempts < MAX_FALLBACK_ATTEMPTS) {
412
+ fallbackAttempts++
413
+ // Look for a delegate tool call in recent messages and try to find alternative agent
414
+ const failedToolCall = [...messages].reverse().find(
415
+ (m) => (m as any).tool_calls?.some((tc: any) => tc.name === 'delegate_to_agent')
416
+ )
417
+ if (failedToolCall) {
418
+ const tc = (failedToolCall as any).tool_calls?.find((tc: any) => tc.name === 'delegate_to_agent')
419
+ const failedAgentName = tc?.args?.agentName || 'unknown'
420
+ const fallbackHint = `The agent "${failedAgentName}" failed. Try delegating to a different agent with matching capabilities, or re-plan your approach. Fallback attempt ${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS}.`
421
+ return { messages: [new AIMessage({ content: fallbackHint })] }
422
+ }
423
+ }
424
+ }
425
+
426
+ // No fallback needed — pass through
427
+ return { messages: [] }
428
+ }
429
+
430
+ // Routing function: after agent node, check if there are tool calls
431
+ function shouldContinue(state: typeof MessagesAnnotation.State) {
432
+ const lastMsg = state.messages[state.messages.length - 1]
433
+ const toolCalls = (lastMsg as any)?.tool_calls
434
+ if (toolCalls && toolCalls.length > 0) {
435
+ return 'tools'
436
+ }
437
+ return END
438
+ }
439
+
440
+ // After router, decide whether to go back to agent or end
441
+ function afterRouter(state: typeof MessagesAnnotation.State) {
442
+ const messages = state.messages
443
+ // If router added a fallback hint, route back to agent
444
+ const lastMsg = messages[messages.length - 1]
445
+ if (lastMsg && typeof (lastMsg as any).content === 'string' && (lastMsg as any).content.includes('Fallback attempt')) {
446
+ return 'agent'
447
+ }
448
+ return 'agent'
449
+ }
450
+
451
+ // Build the StateGraph
452
+ const graph = new StateGraph(MessagesAnnotation)
453
+ .addNode('agent', agentNode)
454
+ .addNode('tools', toolNode)
455
+ .addNode('router', routerNode)
456
+ .addEdge(START, 'agent')
457
+ .addConditionalEdges('agent', shouldContinue, { tools: 'tools', [END]: END })
458
+ .addEdge('tools', 'router')
459
+ .addConditionalEdges('router', afterRouter, { agent: 'agent' })
460
+
461
+ const compiledGraph = graph.compile({
462
+ checkpointer: checkpointSaver,
463
+ ...(isStrictMode ? { interruptBefore: ['tools'] } : {}),
376
464
  })
377
465
 
466
+ // Export graph structure for introspection
467
+ ;(compiledGraph as any).__graphStructure = {
468
+ nodes: ['agent', 'tools', 'router'],
469
+ edges: [
470
+ { from: START, to: 'agent' },
471
+ { from: 'agent', to: 'tools', condition: 'has_tool_calls' },
472
+ { from: 'agent', to: END, condition: 'no_tool_calls' },
473
+ { from: 'tools', to: 'router' },
474
+ { from: 'router', to: 'agent', condition: 'fallback_or_continue' },
475
+ ],
476
+ }
477
+
378
478
  // Save initial user message
379
479
  saveMessage(sessionId, 'user', task)
380
480
 
@@ -391,9 +491,15 @@ export async function executeLangGraphOrchestrator(
391
491
  : null
392
492
 
393
493
  try {
394
- const stream = await agent.stream(
494
+ const threadId = taskId || sessionId
495
+ const streamConfig = {
496
+ recursionLimit,
497
+ signal: abortController.signal,
498
+ configurable: { thread_id: threadId },
499
+ }
500
+ const stream = await compiledGraph.stream(
395
501
  { messages: [{ role: 'user' as const, content: task }] },
396
- { recursionLimit, signal: abortController.signal },
502
+ streamConfig,
397
503
  )
398
504
 
399
505
  for await (const chunk of stream) {
@@ -414,6 +520,42 @@ export async function executeLangGraphOrchestrator(
414
520
  }
415
521
  }
416
522
  }
523
+
524
+ // Check for interrupt (paused before tool execution in strict mode)
525
+ if (isStrictMode && taskId) {
526
+ const state = await compiledGraph.getState({ configurable: { thread_id: threadId } })
527
+ const nextNodes = state?.next || []
528
+ if (nextNodes.includes('tools')) {
529
+ // Graph is paused before tool execution — extract pending tool call
530
+ const messages = state.values?.messages || []
531
+ const lastMsg = messages[messages.length - 1]
532
+ const toolCalls = lastMsg?.tool_calls || lastMsg?.additional_kwargs?.tool_calls || []
533
+ const pendingCall = toolCalls[0]
534
+ if (pendingCall) {
535
+ const tasks = loadTasks()
536
+ const t = tasks[taskId]
537
+ if (t) {
538
+ t.pendingApproval = {
539
+ toolName: pendingCall.name || pendingCall.function?.name || 'unknown',
540
+ args: pendingCall.args || (pendingCall.function?.arguments ? JSON.parse(pendingCall.function.arguments) : {}),
541
+ threadId,
542
+ }
543
+ t.updatedAt = Date.now()
544
+ saveTasks(tasks)
545
+ notify('tasks')
546
+ const approvalMsg = `[Awaiting approval] Tool: ${t.pendingApproval.toolName}\nArgs: ${JSON.stringify(t.pendingApproval.args).slice(0, 300)}\n\nApprove or reject in the task board.`
547
+ saveMessage(sessionId, 'assistant', approvalMsg)
548
+ notify(`messages:${sessionId}`)
549
+ pushMainLoopEventToMainSessions({
550
+ type: 'pending_approval',
551
+ text: `Task "${t.title}" needs approval: ${t.pendingApproval.toolName}(${JSON.stringify(t.pendingApproval.args).slice(0, 100)})`,
552
+ })
553
+ console.log(`[orchestrator-lg] Interrupt: waiting for approval of tool "${t.pendingApproval.toolName}" on task ${taskId}`)
554
+ return approvalMsg
555
+ }
556
+ }
557
+ }
558
+ }
417
559
  } catch (err: any) {
418
560
  const errMsg = timedOut
419
561
  ? 'Ongoing loop stopped after reaching the configured runtime limit.'
@@ -429,3 +571,242 @@ export async function executeLangGraphOrchestrator(
429
571
  const completeMatch = finalResult.match(/ORCHESTRATION_COMPLETE:\s*([\s\S]+)/)
430
572
  return completeMatch ? completeMatch[1].trim() : finalResult
431
573
  }
574
+
575
+ /**
576
+ * Resume a paused orchestrator run after human approval.
577
+ * Re-creates the same agent graph and streams from the saved checkpoint.
578
+ */
579
+ export async function resumeLangGraphOrchestrator(
580
+ orchestrator: Agent,
581
+ sessionId: string,
582
+ threadId: string,
583
+ ): Promise<string> {
584
+ const allAgents = loadAgents()
585
+ const agentIds = orchestrator.subAgentIds || []
586
+ const agents = agentIds.map((id) => allAgents[id]).filter(Boolean) as Agent[]
587
+
588
+ // Recreate the same tools
589
+ const delegateTool = tool(
590
+ async ({ agentName, task: agentTask }) => {
591
+ const agent = agents.find((a) => a.name.toLowerCase() === agentName.toLowerCase())
592
+ if (!agent) return `Agent "${agentName}" not found. Available: ${agents.map((a) => a.name).join(', ')}`
593
+ const result = await executeSubTaskViaCli(agent, agentTask, sessionId)
594
+ saveMessage(sessionId, 'assistant', `Delegated to ${agent.name}: ${agentTask.slice(0, 100)}`, [{
595
+ name: 'delegate_to_agent',
596
+ input: JSON.stringify({ agentName: agent.name, agentId: agent.id, task: agentTask }),
597
+ output: result.slice(0, 2000),
598
+ }])
599
+ return result
600
+ },
601
+ {
602
+ name: 'delegate_to_agent',
603
+ description: 'Delegate a task to one of the available agents.',
604
+ schema: z.object({
605
+ agentName: z.string().describe('Name of the agent to delegate to'),
606
+ task: z.string().describe('The task description for the agent'),
607
+ }),
608
+ },
609
+ )
610
+
611
+ const db = getMemoryDb()
612
+ const storeMemoryTool = tool(
613
+ async ({ category, title, content }) => {
614
+ db.add({ agentId: orchestrator.id, sessionId, category, title, content })
615
+ return 'Memory stored successfully.'
616
+ },
617
+ {
618
+ name: 'store_memory',
619
+ description: 'Store information in long-term memory.',
620
+ schema: z.object({
621
+ category: z.string(), title: z.string(), content: z.string(),
622
+ }),
623
+ },
624
+ )
625
+
626
+ const searchMemoryTool = tool(
627
+ async ({ query }) => {
628
+ const results = db.search(query, orchestrator.id)
629
+ if (!results.length) return 'No matching memories found.'
630
+ return results.map((m) => `[${m.category}] ${m.title}: ${m.content.slice(0, 300)}`).join('\n')
631
+ },
632
+ {
633
+ name: 'search_memory',
634
+ description: 'Search long-term memory.',
635
+ schema: z.object({ query: z.string() }),
636
+ },
637
+ )
638
+
639
+ const markCompleteTool = tool(
640
+ async ({ summary }) => `ORCHESTRATION_COMPLETE: ${summary}`,
641
+ {
642
+ name: 'mark_complete',
643
+ description: 'Signal orchestration is done.',
644
+ schema: z.object({ summary: z.string() }),
645
+ },
646
+ )
647
+
648
+ const availableSecrets = getSecretsForOrchestrator(orchestrator.id)
649
+ const getSecretTool = tool(
650
+ async ({ serviceName }) => {
651
+ const match = availableSecrets.find(
652
+ (s) => s.service.toLowerCase() === serviceName.toLowerCase() || s.name.toLowerCase() === serviceName.toLowerCase(),
653
+ )
654
+ if (!match) return `No secret found for "${serviceName}".`
655
+ return JSON.stringify({ name: match.name, service: match.service, value: match.value })
656
+ },
657
+ {
658
+ name: 'get_secret',
659
+ description: 'Retrieve a stored credential/secret by service name.',
660
+ schema: z.object({ serviceName: z.string() }),
661
+ },
662
+ )
663
+
664
+ const commentOnTaskTool = tool(
665
+ async ({ taskId, comment }) => {
666
+ const tasks = loadTasks()
667
+ const t = tasks[taskId]
668
+ if (!t) return `Task "${taskId}" not found.`
669
+ if (!t.comments) t.comments = []
670
+ t.comments.push({
671
+ id: crypto.randomBytes(4).toString('hex'),
672
+ author: orchestrator.name,
673
+ agentId: orchestrator.id,
674
+ text: comment,
675
+ createdAt: Date.now(),
676
+ })
677
+ t.updatedAt = Date.now()
678
+ saveTasks(tasks)
679
+ return `Comment added to task "${t.title}".`
680
+ },
681
+ {
682
+ name: 'comment_on_task',
683
+ description: 'Add a comment to a task.',
684
+ schema: z.object({ taskId: z.string(), comment: z.string() }),
685
+ },
686
+ )
687
+
688
+ const createTaskTool = tool(
689
+ async ({ title, description: desc }) => {
690
+ const tasks = loadTasks()
691
+ const id = crypto.randomBytes(4).toString('hex')
692
+ tasks[id] = {
693
+ id, title, description: desc, status: 'backlog',
694
+ agentId: orchestrator.id, sessionId: null, result: null, error: null,
695
+ comments: [], createdAt: Date.now(), updatedAt: Date.now(),
696
+ queuedAt: null, startedAt: null, completedAt: null,
697
+ }
698
+ saveTasks(tasks)
699
+ return `Task "${title}" created in backlog (id: ${id}).`
700
+ },
701
+ {
702
+ name: 'create_task',
703
+ description: 'Create a new task in the backlog.',
704
+ schema: z.object({ title: z.string(), description: z.string() }),
705
+ },
706
+ )
707
+
708
+ const engine = getOrchestrationEngineConfig(orchestrator)
709
+ const llm = buildChatModel({
710
+ provider: engine.provider, model: engine.model,
711
+ apiKey: engine.apiKey, apiEndpoint: engine.apiEndpoint,
712
+ })
713
+
714
+ const checkpointSaver = getCheckpointSaver()
715
+ const settings = loadSettings()
716
+ const isStrictMode = settings.capabilityPolicyMode === 'strict'
717
+
718
+ const allTools = [delegateTool, storeMemoryTool, searchMemoryTool, getSecretTool, commentOnTaskTool, createTaskTool, markCompleteTool]
719
+ const llmWithTools = llm.bindTools(allTools)
720
+ const toolNode = new ToolNode(allTools)
721
+
722
+ async function agentNode(state: typeof MessagesAnnotation.State) {
723
+ const response = await llmWithTools.invoke(state.messages)
724
+ return { messages: [response] }
725
+ }
726
+ function routerNode(state: typeof MessagesAnnotation.State) {
727
+ return { messages: [] }
728
+ }
729
+ function shouldContinue(state: typeof MessagesAnnotation.State) {
730
+ const lastMsg = state.messages[state.messages.length - 1]
731
+ const toolCalls = (lastMsg as any)?.tool_calls
732
+ if (toolCalls && toolCalls.length > 0) return 'tools'
733
+ return END
734
+ }
735
+
736
+ const graphAgent = new StateGraph(MessagesAnnotation)
737
+ .addNode('agent', agentNode)
738
+ .addNode('tools', toolNode)
739
+ .addNode('router', routerNode)
740
+ .addEdge(START, 'agent')
741
+ .addConditionalEdges('agent', shouldContinue, { tools: 'tools', [END]: END })
742
+ .addEdge('tools', 'router')
743
+ .addEdge('router', 'agent')
744
+ .compile({
745
+ checkpointer: checkpointSaver,
746
+ ...(isStrictMode ? { interruptBefore: ['tools'] } : {}),
747
+ })
748
+
749
+ let finalResult = ''
750
+ const runtime = loadRuntimeSettings()
751
+ const recursionLimit = getOrchestratorLoopRecursionLimit(runtime)
752
+
753
+ try {
754
+ // Resume from checkpoint with null input — the checkpoint has the full state
755
+ const stream = await graphAgent.stream(null, {
756
+ recursionLimit,
757
+ configurable: { thread_id: threadId },
758
+ })
759
+
760
+ for await (const chunk of stream) {
761
+ const agentChunk = (chunk as any).agent
762
+ if (agentChunk?.messages) {
763
+ const msgs = Array.isArray(agentChunk.messages) ? agentChunk.messages : [agentChunk.messages]
764
+ for (const msg of msgs) {
765
+ const text = typeof msg.content === 'string'
766
+ ? msg.content
767
+ : Array.isArray(msg.content)
768
+ ? msg.content.map((c: any) => c.text || '').join('')
769
+ : ''
770
+ if (text) {
771
+ finalResult = text
772
+ saveMessage(sessionId, 'assistant', text)
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ // Check for another interrupt
779
+ const state = await graphAgent.getState({ configurable: { thread_id: threadId } })
780
+ const nextNodes = state?.next || []
781
+ if (nextNodes.includes('tools')) {
782
+ const messages = state.values?.messages || []
783
+ const lastMsg = messages[messages.length - 1]
784
+ const toolCalls = lastMsg?.tool_calls || lastMsg?.additional_kwargs?.tool_calls || []
785
+ const pendingCall = toolCalls[0]
786
+ if (pendingCall) {
787
+ // Find the task by threadId
788
+ const tasks = loadTasks()
789
+ for (const t of Object.values(tasks)) {
790
+ if (t.pendingApproval?.threadId === threadId || t.id === threadId) {
791
+ t.pendingApproval = {
792
+ toolName: pendingCall.name || pendingCall.function?.name || 'unknown',
793
+ args: pendingCall.args || {},
794
+ threadId,
795
+ }
796
+ t.updatedAt = Date.now()
797
+ saveTasks(tasks)
798
+ notify('tasks')
799
+ return `Waiting for approval: ${t.pendingApproval.toolName}`
800
+ }
801
+ }
802
+ }
803
+ }
804
+ } catch (err: any) {
805
+ console.error(`[orchestrator-lg] Resume error:`, err.message || String(err))
806
+ saveMessage(sessionId, 'assistant', `[Error] ${err.message || String(err)}`)
807
+ throw err
808
+ }
809
+
810
+ const completeMatch = finalResult.match(/ORCHESTRATION_COMPLETE:\s*([\s\S]+)/)
811
+ return completeMatch ? completeMatch[1].trim() : finalResult
812
+ }
@@ -3,6 +3,7 @@ import {
3
3
  loadSessions, saveSessions, loadAgents,
4
4
  loadCredentials, decryptKey, loadSettings, loadSkills,
5
5
  } from './storage'
6
+ import { WORKSPACE_DIR } from './data-dir'
6
7
  import { loadRuntimeSettings, getLegacyOrchestratorMaxTurns } from './runtime-settings'
7
8
  import { getMemoryDb } from './memory-db'
8
9
  import { getProvider } from '../providers'
@@ -23,7 +24,7 @@ export function createOrchestratorSession(
23
24
  sessions[sessionId] = {
24
25
  id: sessionId,
25
26
  name: `[Orch] ${orchestrator.name}: ${task.slice(0, 40)}`,
26
- cwd: cwd || process.cwd(),
27
+ cwd: cwd || WORKSPACE_DIR,
27
28
  user: 'system',
28
29
  provider: orchestrator.provider,
29
30
  model: orchestrator.model,
@@ -63,13 +64,14 @@ export async function executeOrchestrator(
63
64
  orchestrator: Agent,
64
65
  task: string,
65
66
  sessionId: string,
67
+ taskId?: string,
66
68
  ): Promise<string> {
67
69
  // Use LangGraph for all non-CLI providers (including OpenAI-compatible custom providers)
68
70
  const isCliProvider = orchestrator.provider === 'claude-cli' || orchestrator.provider === 'codex-cli' || orchestrator.provider === 'opencode-cli'
69
71
  if (!isCliProvider) {
70
72
  console.log(`[orchestrator] Using LangGraph engine for ${orchestrator.name} (${orchestrator.provider})`)
71
73
  const { executeLangGraphOrchestrator } = await import('./orchestrator-lg')
72
- return executeLangGraphOrchestrator(orchestrator, task, sessionId)
74
+ return executeLangGraphOrchestrator(orchestrator, task, sessionId, taskId)
73
75
  }
74
76
 
75
77
  // claude-cli fallback (no structured tool calling)
@@ -156,7 +158,10 @@ async function executeOrchestratorLegacy(
156
158
  }
157
159
  }
158
160
 
159
- const fullText = await callProvider(orchestrator, systemPrompt, conversationHistory)
161
+ const windowedHistory = conversationHistory.length > 10
162
+ ? [conversationHistory[0], ...conversationHistory.slice(-9)]
163
+ : conversationHistory
164
+ const fullText = await callProvider(orchestrator, systemPrompt, windowedHistory)
160
165
  conversationHistory.push({ role: 'assistant', text: fullText })
161
166
 
162
167
  // Save to session
@@ -189,6 +194,21 @@ async function executeOrchestratorLegacy(
189
194
  role: 'user',
190
195
  text: `[Agent ${agent.name} result]:\n${subResult}`,
191
196
  })
197
+ // Save structured delegation message for rich card rendering
198
+ session.messages.push({
199
+ role: 'assistant' as const,
200
+ text: `Delegated to ${agent.name}: ${cmd.delegate.task.slice(0, 100)}`,
201
+ time: Date.now(),
202
+ toolEvents: [{
203
+ name: 'delegate_to_agent',
204
+ input: JSON.stringify({ agentName: agent.name, agentId: agent.id, task: cmd.delegate.task }),
205
+ output: subResult.slice(0, 2000),
206
+ }],
207
+ })
208
+ session.lastActiveAt = Date.now()
209
+ const ds = loadSessions()
210
+ ds[sessionId] = session
211
+ saveSessions(ds)
192
212
  }
193
213
 
194
214
  if (cmd.memory_store) {
@@ -248,7 +268,7 @@ async function executeSubTask(
248
268
  const childSession = {
249
269
  id: childId,
250
270
  name: `[Agent] ${agent.name}: ${task.slice(0, 40)}`,
251
- cwd: parentSession?.cwd || process.cwd(),
271
+ cwd: parentSession?.cwd || WORKSPACE_DIR,
252
272
  user: 'system',
253
273
  provider: agent.provider,
254
274
  model: agent.model,
@@ -310,7 +330,7 @@ export async function callProvider(
310
330
  model: agent.model,
311
331
  credentialId: agent.credentialId,
312
332
  apiEndpoint: agent.apiEndpoint,
313
- cwd: process.cwd(),
333
+ cwd: WORKSPACE_DIR,
314
334
  tools: agent.tools || [],
315
335
  messages: history.map((h) => ({
316
336
  role: h.role as 'user' | 'assistant',
@@ -1,11 +1,14 @@
1
1
  import crypto from 'crypto'
2
2
  import { loadTasks, saveTasks, loadQueue, saveQueue, loadAgents, loadSchedules, saveSchedules, loadSessions, saveSessions, loadSettings } from './storage'
3
+ import { notify } from './ws-hub'
4
+ import { WORKSPACE_DIR } from './data-dir'
3
5
  import { createOrchestratorSession, executeOrchestrator } from './orchestrator'
4
6
  import { formatValidationFailure, validateTaskCompletion } from './task-validation'
5
7
  import { ensureTaskCompletionReport } from './task-reports'
6
8
  import { pushMainLoopEventToMainSessions } from './main-agent-loop'
7
9
  import { executeSessionChatTurn } from './chat-execution'
8
10
  import { extractTaskResult, formatResultBody } from './task-result'
11
+ import { getCheckpointSaver } from './langgraph-checkpoint'
9
12
  import type { Agent, BoardTask, Message } from '@/types'
10
13
 
11
14
  let processing = false
@@ -119,7 +122,7 @@ async function executeTaskRun(
119
122
  ): Promise<string> {
120
123
  const prompt = task.description || task.title
121
124
  if (agent?.isOrchestrator) {
122
- return executeOrchestrator(agent, prompt, sessionId)
125
+ return executeOrchestrator(agent, prompt, sessionId, task.id)
123
126
  }
124
127
 
125
128
  const run = await executeSessionChatTurn({
@@ -389,7 +392,7 @@ export function validateCompletedTasksQueue() {
389
392
  }
390
393
  }
391
394
 
392
- if (tasksDirty) saveTasks(tasks)
395
+ if (tasksDirty) { saveTasks(tasks); notify('tasks') }
393
396
  if (sessionsDirty) saveSessions(sessions)
394
397
  if (demoted > 0) {
395
398
  console.warn(`[queue] Demoted ${demoted} invalid completed task(s) to failed after validation audit`)
@@ -510,9 +513,12 @@ export async function processNext() {
510
513
  task.startedAt = Date.now()
511
514
  task.retryScheduledAt = null
512
515
  task.deadLetteredAt = null
516
+ // Clear transient failure fields so validation/error state reflects only this attempt.
517
+ task.error = null
518
+ task.validation = null
513
519
  task.updatedAt = Date.now()
514
520
 
515
- const taskCwd = task.cwd || process.cwd()
521
+ const taskCwd = task.cwd || WORKSPACE_DIR
516
522
  let sessionId = ''
517
523
  const scheduleTask = task as ScheduleTaskMeta
518
524
  const isScheduleTask = scheduleTask.sourceType === 'schedule'
@@ -688,6 +694,8 @@ export async function processNext() {
688
694
  }
689
695
 
690
696
  saveTasks(t2)
697
+ notify('tasks')
698
+ notify('runs')
691
699
  disableSessionHeartbeat(t2[taskId].sessionId)
692
700
  }
693
701
  const doneTask = t2[taskId]
@@ -698,6 +706,10 @@ export async function processNext() {
698
706
  })
699
707
  notifyMainChatScheduleResult(doneTask)
700
708
  notifyAgentThreadTaskResult(doneTask)
709
+ // Clean up LangGraph checkpoints for completed tasks
710
+ getCheckpointSaver().deleteThread(taskId).catch((e) =>
711
+ console.warn(`[queue] Failed to clean up checkpoints for task ${taskId}:`, e)
712
+ )
701
713
  console.log(`[queue] Task "${task.title}" completed`)
702
714
  } else {
703
715
  if (doneTask?.status === 'queued') {
@@ -735,6 +747,8 @@ export async function processNext() {
735
747
  })
736
748
  }
737
749
  saveTasks(t2)
750
+ notify('tasks')
751
+ notify('runs')
738
752
  disableSessionHeartbeat(t2[taskId].sessionId)
739
753
  if (retryState === 'retry') {
740
754
  const qRetry = loadQueue()
@@ -31,6 +31,7 @@ interface QueueEntry {
31
31
  message: string
32
32
  imagePath?: string
33
33
  imageUrl?: string
34
+ attachedFiles?: string[]
34
35
  onEvents: Array<(event: SSEEvent) => void>
35
36
  signalController: AbortController
36
37
  maxRuntimeMs?: number
@@ -236,6 +237,7 @@ async function drainExecution(executionKey: string): Promise<void> {
236
237
  message: next.message,
237
238
  imagePath: next.imagePath,
238
239
  imageUrl: next.imageUrl,
240
+ attachedFiles: next.attachedFiles,
239
241
  internal: next.run.internal,
240
242
  source: next.run.source,
241
243
  runId: next.run.id,
@@ -333,6 +335,7 @@ export interface EnqueueSessionRunInput {
333
335
  message: string
334
336
  imagePath?: string
335
337
  imageUrl?: string
338
+ attachedFiles?: string[]
336
339
  internal?: boolean
337
340
  source?: string
338
341
  mode?: SessionQueueMode
@@ -384,7 +387,7 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
384
387
 
385
388
  const running = state.runningByExecution.get(executionKey)
386
389
  const q = queueForExecution(executionKey)
387
- if (mode === 'collect' && !input.imagePath && !input.imageUrl) {
390
+ if (mode === 'collect' && !input.imagePath && !input.imageUrl && !input.attachedFiles?.length) {
388
391
  const nowMs = now()
389
392
  const candidate = q.at(-1)
390
393
  const canCoalesce = !!candidate
@@ -393,6 +396,7 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
393
396
  && candidate.run.source === source
394
397
  && !candidate.imagePath
395
398
  && !candidate.imageUrl
399
+ && !candidate.attachedFiles?.length
396
400
  && (nowMs - candidate.run.queuedAt) <= COLLECT_COALESCE_WINDOW_MS
397
401
 
398
402
  if (candidate && canCoalesce) {
@@ -444,6 +448,7 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
444
448
  message: input.message,
445
449
  imagePath: input.imagePath,
446
450
  imageUrl: input.imageUrl,
451
+ attachedFiles: input.attachedFiles,
447
452
  onEvents: input.onEvent ? [input.onEvent] : [],
448
453
  signalController: new AbortController(),
449
454
  maxRuntimeMs: effectiveMaxRuntimeMs > 0 ? effectiveMaxRuntimeMs : undefined,