@swarmclawai/swarmclaw 0.7.6 → 0.7.8

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 (86) hide show
  1. package/README.md +19 -10
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +13 -1
  7. package/src/app/api/connectors/[id]/route.ts +20 -2
  8. package/src/app/api/connectors/route.ts +12 -8
  9. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  10. package/src/app/api/external-agents/[id]/route.ts +38 -6
  11. package/src/app/api/external-agents/route.ts +17 -1
  12. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  13. package/src/app/api/gateways/[id]/route.ts +53 -1
  14. package/src/app/api/gateways/route.ts +53 -0
  15. package/src/app/api/openclaw/deploy/route.ts +139 -0
  16. package/src/app/api/projects/[id]/route.ts +6 -2
  17. package/src/app/api/projects/route.ts +4 -3
  18. package/src/app/api/secrets/[id]/route.ts +1 -0
  19. package/src/app/api/secrets/route.ts +2 -1
  20. package/src/app/api/settings/route.ts +2 -0
  21. package/src/cli/index.js +40 -0
  22. package/src/cli/index.test.js +68 -0
  23. package/src/cli/spec.js +60 -0
  24. package/src/components/agents/agent-sheet.tsx +281 -33
  25. package/src/components/auth/setup-wizard.tsx +75 -2
  26. package/src/components/chat/chat-area.tsx +36 -19
  27. package/src/components/chat/chat-header.tsx +4 -0
  28. package/src/components/chat/delegation-banner.test.ts +14 -1
  29. package/src/components/chat/delegation-banner.tsx +1 -1
  30. package/src/components/gateways/gateway-sheet.tsx +140 -8
  31. package/src/components/layout/app-layout.tsx +40 -23
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
  33. package/src/components/projects/project-detail.tsx +217 -0
  34. package/src/components/projects/project-sheet.tsx +176 -4
  35. package/src/components/providers/provider-list.tsx +221 -17
  36. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  37. package/src/components/shared/settings/section-voice.tsx +11 -3
  38. package/src/components/tasks/approvals-panel.tsx +177 -18
  39. package/src/components/tasks/task-board.tsx +137 -23
  40. package/src/components/tasks/task-card.tsx +29 -0
  41. package/src/components/tasks/task-sheet.tsx +16 -4
  42. package/src/lib/server/agent-runtime-config.ts +142 -7
  43. package/src/lib/server/agent-thread-session.ts +9 -1
  44. package/src/lib/server/capability-router.test.ts +22 -0
  45. package/src/lib/server/capability-router.ts +54 -18
  46. package/src/lib/server/chat-execution.ts +33 -3
  47. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  48. package/src/lib/server/connectors/manager.ts +99 -74
  49. package/src/lib/server/daemon-state.ts +83 -46
  50. package/src/lib/server/elevenlabs.test.ts +59 -1
  51. package/src/lib/server/heartbeat-service.ts +5 -1
  52. package/src/lib/server/main-agent-loop.test.ts +260 -0
  53. package/src/lib/server/main-agent-loop.ts +559 -14
  54. package/src/lib/server/openclaw-deploy.test.ts +8 -0
  55. package/src/lib/server/openclaw-deploy.ts +679 -19
  56. package/src/lib/server/orchestrator-lg.ts +1 -0
  57. package/src/lib/server/orchestrator.ts +11 -0
  58. package/src/lib/server/plugins.ts +6 -1
  59. package/src/lib/server/project-context.ts +162 -0
  60. package/src/lib/server/project-utils.ts +150 -0
  61. package/src/lib/server/queue-followups.test.ts +147 -2
  62. package/src/lib/server/queue.ts +278 -8
  63. package/src/lib/server/session-run-manager.ts +31 -0
  64. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  65. package/src/lib/server/session-tools/connector.ts +26 -1
  66. package/src/lib/server/session-tools/context.ts +5 -0
  67. package/src/lib/server/session-tools/crud.ts +265 -76
  68. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  69. package/src/lib/server/session-tools/delegate.ts +38 -2
  70. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  71. package/src/lib/server/session-tools/memory.ts +14 -2
  72. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  73. package/src/lib/server/session-tools/platform.ts +60 -19
  74. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  75. package/src/lib/server/session-tools/web.ts +153 -6
  76. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  77. package/src/lib/server/stream-agent-chat.ts +104 -30
  78. package/src/lib/server/tool-aliases.ts +2 -0
  79. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  80. package/src/lib/server/tool-capability-policy.ts +29 -1
  81. package/src/lib/server/tool-planning.test.ts +44 -0
  82. package/src/lib/server/tool-planning.ts +269 -0
  83. package/src/lib/setup-defaults.ts +2 -2
  84. package/src/lib/tool-definitions.ts +2 -1
  85. package/src/lib/validation/schemas.ts +9 -0
  86. package/src/types/index.ts +104 -0
@@ -26,6 +26,11 @@ export interface ResolvedAgentRoute {
26
26
  source: 'agent' | 'routing-target'
27
27
  }
28
28
 
29
+ interface GatewayRoutePreferences {
30
+ preferredGatewayTags?: string[]
31
+ preferredGatewayUseCase?: string | null
32
+ }
33
+
29
34
  interface RouteSeed {
30
35
  id: string
31
36
  label?: string
@@ -35,6 +40,8 @@ interface RouteSeed {
35
40
  fallbackCredentialIds?: string[]
36
41
  apiEndpoint?: string | null
37
42
  gatewayProfileId?: string | null
43
+ preferredGatewayTags?: string[]
44
+ preferredGatewayUseCase?: string | null
38
45
  role?: AgentRoutingTarget['role']
39
46
  priority?: number
40
47
  source: ResolvedAgentRoute['source']
@@ -47,6 +54,68 @@ function ensureStringArray(value: unknown): string[] {
47
54
  .filter(Boolean)
48
55
  }
49
56
 
57
+ function normalizeText(value: unknown): string | null {
58
+ return typeof value === 'string' && value.trim() ? value.trim() : null
59
+ }
60
+
61
+ function normalizeNullableNumber(value: unknown): number | null {
62
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
63
+ }
64
+
65
+ function normalizeGatewayDeployment(
66
+ value: unknown,
67
+ ): GatewayProfile['deployment'] {
68
+ if (!value || typeof value !== 'object') return null
69
+ const deployment = value as Record<string, unknown>
70
+ type DeploymentConfig = NonNullable<GatewayProfile['deployment']>
71
+ return {
72
+ method: normalizeText(deployment.method) as DeploymentConfig['method'],
73
+ provider: normalizeText(deployment.provider) as DeploymentConfig['provider'],
74
+ remoteTarget: normalizeText(deployment.remoteTarget) as DeploymentConfig['remoteTarget'],
75
+ useCase: normalizeText(deployment.useCase) as DeploymentConfig['useCase'],
76
+ exposure: normalizeText(deployment.exposure) as DeploymentConfig['exposure'],
77
+ managedBy: normalizeText(deployment.managedBy) as DeploymentConfig['managedBy'],
78
+ targetHost: normalizeText(deployment.targetHost),
79
+ sshHost: normalizeText(deployment.sshHost),
80
+ sshUser: normalizeText(deployment.sshUser),
81
+ sshPort: normalizeNullableNumber(deployment.sshPort),
82
+ sshKeyPath: normalizeText(deployment.sshKeyPath),
83
+ sshTargetDir: normalizeText(deployment.sshTargetDir),
84
+ image: normalizeText(deployment.image),
85
+ version: normalizeText(deployment.version),
86
+ lastDeployAt: normalizeNullableNumber(deployment.lastDeployAt),
87
+ lastDeployAction: normalizeText(deployment.lastDeployAction),
88
+ lastDeployProcessId: normalizeText(deployment.lastDeployProcessId),
89
+ lastDeploySummary: normalizeText(deployment.lastDeploySummary),
90
+ lastVerifiedAt: normalizeNullableNumber(deployment.lastVerifiedAt),
91
+ lastVerifiedOk: typeof deployment.lastVerifiedOk === 'boolean' ? deployment.lastVerifiedOk : null,
92
+ lastVerifiedMessage: normalizeText(deployment.lastVerifiedMessage),
93
+ lastBackupPath: normalizeText(deployment.lastBackupPath),
94
+ }
95
+ }
96
+
97
+ function normalizeGatewayStats(value: unknown): GatewayProfile['stats'] {
98
+ if (!value || typeof value !== 'object') return null
99
+ const stats = value as Record<string, unknown>
100
+ return {
101
+ nodeCount: normalizeNullableNumber(stats.nodeCount) ?? undefined,
102
+ connectedNodeCount: normalizeNullableNumber(stats.connectedNodeCount) ?? undefined,
103
+ pendingNodePairings: normalizeNullableNumber(stats.pendingNodePairings) ?? undefined,
104
+ pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
105
+ pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
106
+ externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
107
+ }
108
+ }
109
+
110
+ function normalizeRoutePreferences(
111
+ value?: GatewayRoutePreferences | null,
112
+ ): GatewayRoutePreferences {
113
+ return {
114
+ preferredGatewayTags: ensureStringArray(value?.preferredGatewayTags),
115
+ preferredGatewayUseCase: normalizeText(value?.preferredGatewayUseCase),
116
+ }
117
+ }
118
+
50
119
  function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
51
120
  if (!raw || typeof raw !== 'object') return null
52
121
  const gateway = raw as Partial<GatewayProfile> & Record<string, unknown>
@@ -72,6 +141,8 @@ function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
72
141
  lastModelCount: typeof gateway.lastModelCount === 'number' ? gateway.lastModelCount : null,
73
142
  discoveredHost: typeof gateway.discoveredHost === 'string' ? gateway.discoveredHost : null,
74
143
  discoveredPort: typeof gateway.discoveredPort === 'number' ? gateway.discoveredPort : null,
144
+ deployment: normalizeGatewayDeployment(gateway.deployment),
145
+ stats: normalizeGatewayStats(gateway.stats),
75
146
  isDefault: gateway.isDefault === true,
76
147
  createdAt: typeof gateway.createdAt === 'number' ? gateway.createdAt : Date.now(),
77
148
  updatedAt: typeof gateway.updatedAt === 'number' ? gateway.updatedAt : Date.now(),
@@ -107,6 +178,50 @@ function defaultGatewayProfile(gatewayProfiles: GatewayProfile[]): GatewayProfil
107
178
  return gatewayProfiles.find((profile) => profile.isDefault) || gatewayProfiles[0] || null
108
179
  }
109
180
 
181
+ function gatewayPreferenceScore(
182
+ gatewayProfile: GatewayProfile,
183
+ preferences?: GatewayRoutePreferences | null,
184
+ ): number {
185
+ const normalized = normalizeRoutePreferences(preferences)
186
+ const preferredTags = normalized.preferredGatewayTags || []
187
+ const preferredUseCase = normalized.preferredGatewayUseCase || null
188
+ const gatewayTags = new Set(ensureStringArray(gatewayProfile.tags))
189
+ const gatewayUseCase = normalizeText(gatewayProfile.deployment?.useCase)
190
+
191
+ let score = 0
192
+ if (preferredUseCase) {
193
+ if (gatewayUseCase !== preferredUseCase) return -1
194
+ score += 30
195
+ }
196
+ if (preferredTags.length > 0) {
197
+ const matchedTagCount = preferredTags.filter((tag) => gatewayTags.has(tag)).length
198
+ if (matchedTagCount === 0) return -1
199
+ score += matchedTagCount * 10
200
+ if (matchedTagCount === preferredTags.length) score += 8
201
+ }
202
+ if (gatewayProfile.status === 'healthy') score += 4
203
+ else if (gatewayProfile.status === 'degraded') score += 2
204
+ if (gatewayProfile.isDefault) score += 3
205
+ return score
206
+ }
207
+
208
+ function pickPreferredGatewayProfile(
209
+ gatewayProfiles: GatewayProfile[],
210
+ preferences?: GatewayRoutePreferences | null,
211
+ ): GatewayProfile | null {
212
+ const normalized = normalizeRoutePreferences(preferences)
213
+ if (!(normalized.preferredGatewayTags?.length || normalized.preferredGatewayUseCase)) {
214
+ return null
215
+ }
216
+ return gatewayProfiles
217
+ .map((profile) => ({ profile, score: gatewayPreferenceScore(profile, normalized) }))
218
+ .filter((entry) => entry.score >= 0)
219
+ .sort((left, right) => {
220
+ if (left.score !== right.score) return right.score - left.score
221
+ return left.profile.name.localeCompare(right.profile.name)
222
+ })[0]?.profile || null
223
+ }
224
+
110
225
  function roleWeight(strategy: AgentRoutingStrategy, role?: AgentRoutingTarget['role']): number {
111
226
  const normalized = role || 'primary'
112
227
  const matrix: Record<AgentRoutingStrategy, Record<string, number>> = {
@@ -135,14 +250,21 @@ function dedupeCredentialIds(primary: string | null | undefined, candidates: str
135
250
  function buildRouteFromSeed(
136
251
  seed: RouteSeed,
137
252
  gatewayProfiles: GatewayProfile[],
253
+ routePreferences?: GatewayRoutePreferences | null,
138
254
  agentGatewayProfileId?: string | null,
139
255
  ): ResolvedAgentRoute | null {
140
256
  const provider = (seed.provider || 'claude-cli') as ProviderType
141
- let gatewayProfileId = seed.gatewayProfileId ?? null
142
- if (!gatewayProfileId && provider === 'openclaw') {
143
- gatewayProfileId = agentGatewayProfileId ?? defaultGatewayProfile(gatewayProfiles)?.id ?? null
257
+ const mergedPreferences = normalizeRoutePreferences({
258
+ preferredGatewayTags: seed.preferredGatewayTags ?? routePreferences?.preferredGatewayTags,
259
+ preferredGatewayUseCase: seed.preferredGatewayUseCase ?? routePreferences?.preferredGatewayUseCase,
260
+ })
261
+ let gatewayProfile = findGatewayProfile(gatewayProfiles, seed.gatewayProfileId ?? null)
262
+ if (!gatewayProfile && provider === 'openclaw') {
263
+ gatewayProfile = pickPreferredGatewayProfile(gatewayProfiles, mergedPreferences)
264
+ || findGatewayProfile(gatewayProfiles, agentGatewayProfileId ?? null)
265
+ || defaultGatewayProfile(gatewayProfiles)
144
266
  }
145
- const gatewayProfile = findGatewayProfile(gatewayProfiles, gatewayProfileId)
267
+ const gatewayProfileId = gatewayProfile?.id ?? seed.gatewayProfileId ?? agentGatewayProfileId ?? null
146
268
 
147
269
  const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
148
270
  const apiEndpoint = normalizeProviderEndpoint(
@@ -189,8 +311,9 @@ function dedupeRoutes(routes: ResolvedAgentRoute[]): ResolvedAgentRoute[] {
189
311
  export function resolveAgentRouteCandidates(
190
312
  agent: Agent | null | undefined,
191
313
  preferredStrategy?: AgentRoutingStrategy | null,
314
+ routePreferences?: GatewayRoutePreferences | null,
192
315
  ): ResolvedAgentRoute[] {
193
- return resolveAgentRouteCandidatesWithProfiles(agent, getGatewayProfiles('openclaw'), preferredStrategy)
316
+ return resolveAgentRouteCandidatesWithProfiles(agent, getGatewayProfiles('openclaw'), preferredStrategy, undefined, routePreferences)
194
317
  }
195
318
 
196
319
  export function resolveAgentRouteCandidatesWithProfiles(
@@ -198,9 +321,16 @@ export function resolveAgentRouteCandidatesWithProfiles(
198
321
  gatewayProfiles: GatewayProfile[],
199
322
  preferredStrategy?: AgentRoutingStrategy | null,
200
323
  isCoolingDown: (providerId: string) => boolean = isProviderCoolingDown,
324
+ routePreferences?: GatewayRoutePreferences | null,
201
325
  ): ResolvedAgentRoute[] {
202
326
  if (!agent) return []
203
327
  const strategy = preferredStrategy || agent.routingStrategy || 'single'
328
+ const resolvedPreferences = normalizeRoutePreferences({
329
+ preferredGatewayTags: routePreferences?.preferredGatewayTags?.length
330
+ ? routePreferences.preferredGatewayTags
331
+ : agent.preferredGatewayTags,
332
+ preferredGatewayUseCase: routePreferences?.preferredGatewayUseCase || agent.preferredGatewayUseCase,
333
+ })
204
334
  const seeds: RouteSeed[] = [
205
335
  {
206
336
  id: 'base',
@@ -211,6 +341,8 @@ export function resolveAgentRouteCandidatesWithProfiles(
211
341
  fallbackCredentialIds: agent.fallbackCredentialIds || [],
212
342
  apiEndpoint: agent.apiEndpoint ?? null,
213
343
  gatewayProfileId: agent.gatewayProfileId ?? null,
344
+ preferredGatewayTags: agent.preferredGatewayTags || [],
345
+ preferredGatewayUseCase: agent.preferredGatewayUseCase ?? null,
214
346
  role: 'primary',
215
347
  priority: 0,
216
348
  source: 'agent',
@@ -224,6 +356,8 @@ export function resolveAgentRouteCandidatesWithProfiles(
224
356
  fallbackCredentialIds: target.fallbackCredentialIds || [],
225
357
  apiEndpoint: target.apiEndpoint ?? null,
226
358
  gatewayProfileId: target.gatewayProfileId ?? null,
359
+ preferredGatewayTags: target.preferredGatewayTags || [],
360
+ preferredGatewayUseCase: target.preferredGatewayUseCase ?? null,
227
361
  role: target.role,
228
362
  priority: typeof target.priority === 'number' ? target.priority : index + 1,
229
363
  source: 'routing-target' as const,
@@ -232,7 +366,7 @@ export function resolveAgentRouteCandidatesWithProfiles(
232
366
 
233
367
  return dedupeRoutes(
234
368
  seeds
235
- .map((seed) => buildRouteFromSeed(seed, gatewayProfiles, agent.gatewayProfileId ?? null))
369
+ .map((seed) => buildRouteFromSeed(seed, gatewayProfiles, resolvedPreferences, agent.gatewayProfileId ?? null))
236
370
  .filter((route): route is ResolvedAgentRoute => Boolean(route)),
237
371
  ).sort((left, right) => {
238
372
  const leftCooling = isCoolingDown(left.provider)
@@ -249,8 +383,9 @@ export function resolveAgentRouteCandidatesWithProfiles(
249
383
  export function resolvePrimaryAgentRoute(
250
384
  agent: Agent | null | undefined,
251
385
  preferredStrategy?: AgentRoutingStrategy | null,
386
+ routePreferences?: GatewayRoutePreferences | null,
252
387
  ): ResolvedAgentRoute | null {
253
- return resolveAgentRouteCandidates(agent, preferredStrategy)[0] || null
388
+ return resolveAgentRouteCandidates(agent, preferredStrategy, routePreferences)[0] || null
254
389
  }
255
390
 
256
391
  export function applyResolvedRoute<T extends {
@@ -26,6 +26,8 @@ function buildThreadSession(agent: Agent, sessionId: string, user: string, creat
26
26
  fallbackCredentialIds: agent.fallbackCredentialIds || [],
27
27
  apiEndpoint: agent.apiEndpoint || null,
28
28
  gatewayProfileId: agent.gatewayProfileId || null,
29
+ routePreferredGatewayTags: existing?.routePreferredGatewayTags || [],
30
+ routePreferredGatewayUseCase: existing?.routePreferredGatewayUseCase || null,
29
31
  claudeSessionId: existing?.claudeSessionId || null,
30
32
  codexThreadId: existing?.codexThreadId || null,
31
33
  opencodeSessionId: existing?.opencodeSessionId || null,
@@ -77,7 +79,13 @@ function buildThreadSession(agent: Agent, sessionId: string, user: string, creat
77
79
  avatar: existing?.avatar,
78
80
  canvasContent: existing?.canvasContent || null,
79
81
  }
80
- return applyResolvedRoute(baseSession, resolvePrimaryAgentRoute(agent))
82
+ return applyResolvedRoute(
83
+ baseSession,
84
+ resolvePrimaryAgentRoute(agent, undefined, {
85
+ preferredGatewayTags: baseSession.routePreferredGatewayTags || [],
86
+ preferredGatewayUseCase: baseSession.routePreferredGatewayUseCase || null,
87
+ }),
88
+ )
81
89
  }
82
90
 
83
91
  export function ensureAgentThreadSession(agentId: string, user = 'default'): Session | null {
@@ -19,3 +19,25 @@ test('routeTaskIntent keeps coding prompts prioritized over memory keywords', ()
19
19
  )
20
20
  assert.equal(decision.intent, 'coding')
21
21
  })
22
+
23
+ test('routeTaskIntent keeps hybrid research-plus-media prompts in research intent', () => {
24
+ const decision = routeTaskIntent(
25
+ 'Can you tell me more if there is any news related to the US-Iran war, and can you send me some screenshots and give me a summary and maybe send me a voice note about it?',
26
+ ['web_search', 'web_fetch', 'browser', 'manage_connectors'],
27
+ null,
28
+ )
29
+
30
+ assert.equal(decision.intent, 'research')
31
+ assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'browser', 'connector_message_tool'])
32
+ })
33
+
34
+ test('routeTaskIntent treats direct voice-note delivery as outreach', () => {
35
+ const decision = routeTaskIntent(
36
+ 'Send me a voice note over WhatsApp summarizing what changed.',
37
+ ['manage_connectors'],
38
+ null,
39
+ )
40
+
41
+ assert.equal(decision.intent, 'outreach')
42
+ assert.deepEqual(decision.preferredTools, ['connector_message_tool'])
43
+ })
@@ -1,4 +1,5 @@
1
1
  import type { AppSettings } from '@/types'
2
+ import { getToolsForCapability, matchToolCapabilitiesForMessage, TOOL_CAPABILITY } from './tool-planning'
2
3
 
3
4
  export type TaskIntent =
4
5
  | 'coding'
@@ -27,6 +28,15 @@ function containsAny(text: string, terms: string[]): boolean {
27
28
  return terms.some((term) => text.includes(term))
28
29
  }
29
30
 
31
+ function dedupe(values: string[]): string[] {
32
+ return Array.from(new Set(values.filter(Boolean)))
33
+ }
34
+
35
+ function preferredToolsForCapabilities(enabledPlugins: string[], capabilities: string[], fallback: string[] = []): string[] {
36
+ const preferred = capabilities.flatMap((capability) => getToolsForCapability(enabledPlugins, capability))
37
+ return dedupe(preferred.length > 0 ? preferred : fallback)
38
+ }
39
+
30
40
  function normalizeDelegateOrder(value: unknown): DelegateTool[] {
31
41
  const fallback: DelegateTool[] = [
32
42
  'delegate_to_claude_code',
@@ -59,6 +69,14 @@ export function routeTaskIntent(
59
69
  const text = (message || '').toLowerCase()
60
70
  const url = findFirstUrl(message || '')
61
71
  const delegateOrder = normalizeDelegateOrder(settings?.autonomyPreferredDelegates)
72
+ const matchedCapabilities = matchToolCapabilitiesForMessage(enabledPlugins, message)
73
+ const wantsVoiceNote = matchedCapabilities.has(TOOL_CAPABILITY.deliveryVoiceNote)
74
+ const wantsScreenshots = matchedCapabilities.has(TOOL_CAPABILITY.browserCapture)
75
+ const wantsMediaDelivery = matchedCapabilities.has(TOOL_CAPABILITY.deliveryMedia)
76
+ const wantsChannelDelivery = matchedCapabilities.has(TOOL_CAPABILITY.deliveryMessage)
77
+ const researchLike = matchedCapabilities.has(TOOL_CAPABILITY.researchSearch)
78
+ || matchedCapabilities.has(TOOL_CAPABILITY.researchFetch)
79
+ || !!url
62
80
 
63
81
  const coding = containsAny(text, [
64
82
  'build',
@@ -98,12 +116,20 @@ export function routeTaskIntent(
98
116
  'discord',
99
117
  'notify',
100
118
  'broadcast',
101
- ])
119
+ ]) || (!researchLike && (wantsVoiceNote || wantsMediaDelivery || wantsChannelDelivery))
102
120
  if (outreach) {
103
121
  return {
104
122
  intent: 'outreach',
105
123
  confidence: 0.8,
106
- preferredTools: ['connector_message_tool', 'manage_connectors', 'manage_sessions'],
124
+ preferredTools: preferredToolsForCapabilities(
125
+ enabledPlugins,
126
+ [
127
+ TOOL_CAPABILITY.deliveryVoiceNote,
128
+ TOOL_CAPABILITY.deliveryMedia,
129
+ TOOL_CAPABILITY.deliveryMessage,
130
+ ],
131
+ ['connector_message_tool', 'manage_connectors', 'manage_sessions'],
132
+ ),
107
133
  preferredDelegates: delegateOrder,
108
134
  primaryUrl: url,
109
135
  }
@@ -129,36 +155,46 @@ export function routeTaskIntent(
129
155
  }
130
156
 
131
157
  const browsing = !!url && (
132
- containsAny(text, ['browser', 'click', 'fill form', 'log in', 'screenshot', 'navigate'])
133
- || enabledPlugins.includes('browser')
158
+ matchedCapabilities.has(TOOL_CAPABILITY.browserNavigate)
159
+ || matchedCapabilities.has(TOOL_CAPABILITY.browserCapture)
160
+ || getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.browserNavigate).length > 0
161
+ || getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.browserCapture).length > 0
134
162
  )
135
163
  if (browsing) {
136
164
  return {
137
165
  intent: 'browsing',
138
166
  confidence: 0.7,
139
- preferredTools: ['browser', 'web_fetch'],
167
+ preferredTools: preferredToolsForCapabilities(
168
+ enabledPlugins,
169
+ [
170
+ TOOL_CAPABILITY.browserCapture,
171
+ TOOL_CAPABILITY.browserNavigate,
172
+ TOOL_CAPABILITY.researchFetch,
173
+ ],
174
+ ['browser', 'web_fetch'],
175
+ ),
140
176
  preferredDelegates: delegateOrder,
141
177
  primaryUrl: url,
142
178
  }
143
179
  }
144
180
 
145
- const research = containsAny(text, [
146
- 'research',
147
- 'look up',
148
- 'find out',
149
- 'search for',
150
- 'compare',
151
- 'latest',
152
- 'news',
153
- 'wikipedia',
154
- 'summarize this url',
155
- 'analyze website',
156
- ]) || !!url
181
+ const research = researchLike
157
182
  if (research) {
183
+ const preferred = preferredToolsForCapabilities(
184
+ enabledPlugins,
185
+ [
186
+ TOOL_CAPABILITY.researchSearch,
187
+ TOOL_CAPABILITY.researchFetch,
188
+ ...(wantsScreenshots ? [TOOL_CAPABILITY.browserCapture] : []),
189
+ ...(wantsVoiceNote ? [TOOL_CAPABILITY.deliveryVoiceNote] : []),
190
+ ...(wantsMediaDelivery || wantsChannelDelivery ? [TOOL_CAPABILITY.deliveryMedia, TOOL_CAPABILITY.deliveryMessage] : []),
191
+ ],
192
+ ['web_search', 'web_fetch', 'browser'],
193
+ )
158
194
  return {
159
195
  intent: 'research',
160
196
  confidence: 0.7,
161
- preferredTools: ['web_search', 'web_fetch', 'browser'],
197
+ preferredTools: preferred,
162
198
  preferredDelegates: delegateOrder,
163
199
  primaryUrl: url,
164
200
  }
@@ -18,7 +18,7 @@ import { getProvider } from '@/lib/providers'
18
18
  import { estimateCost, checkAgentBudgetLimits } from './cost'
19
19
  import { log } from './logger'
20
20
  import { logExecution } from './execution-log'
21
- import { streamAgentChat } from './stream-agent-chat'
21
+ import { buildToolDisciplineLines, streamAgentChat } from './stream-agent-chat'
22
22
  import { runLinkUnderstanding } from './link-understanding'
23
23
  import { buildSessionTools } from './session-tools'
24
24
  import type { StructuredToolInterface } from '@langchain/core/tools'
@@ -46,6 +46,7 @@ import { buildIdentityContinuityContext, refreshSessionIdentityState } from './i
46
46
  import { syncSessionArchiveMemory } from './session-archive-memory'
47
47
  import { evaluateSessionFreshness, resetSessionRuntime, resolveSessionResetPolicy } from './session-reset-policy'
48
48
  import { pruneStreamingAssistantArtifacts, upsertStreamingAssistantArtifact } from '@/lib/chat-streaming-state'
49
+ import { resolveActiveProjectContext } from './project-context'
49
50
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
50
51
 
51
52
  /** Slice history from the most recent context-clear marker forward */
@@ -191,6 +192,8 @@ function extractDelegateResponse(outputText: string): string | null {
191
192
  const MANAGE_PLATFORM_RESOURCE_TO_TOOL: Record<string, string> = {
192
193
  agent: 'manage_agents',
193
194
  agents: 'manage_agents',
195
+ project: 'manage_projects',
196
+ projects: 'manage_projects',
194
197
  task: 'manage_tasks',
195
198
  tasks: 'manage_tasks',
196
199
  schedule: 'manage_schedules',
@@ -626,7 +629,10 @@ function syncSessionFromAgent(sessionId: string): void {
626
629
  if (!agent) return
627
630
 
628
631
  let changed = false
629
- const route = resolvePrimaryAgentRoute(agent)
632
+ const route = resolvePrimaryAgentRoute(agent, undefined, {
633
+ preferredGatewayTags: session.routePreferredGatewayTags || [],
634
+ preferredGatewayUseCase: session.routePreferredGatewayUseCase || null,
635
+ })
630
636
  if (!session.provider && agent.provider) { session.provider = agent.provider; changed = true }
631
637
  if ((session.model === undefined || session.model === null || session.model === '') && agent.model !== undefined) {
632
638
  session.model = agent.model
@@ -668,6 +674,12 @@ function syncSessionFromAgent(sessionId: string): void {
668
674
  }
669
675
  const isShortcutChat = session.shortcutForAgentId === agent.id || agent.threadSessionId === sessionId
670
676
  if (isShortcutChat) {
677
+ const desiredPlugins = Array.isArray(agent.plugins) ? [...agent.plugins] : []
678
+ const currentPlugins = Array.isArray(session.plugins) ? [...session.plugins] : []
679
+ if (JSON.stringify(currentPlugins) !== JSON.stringify(desiredPlugins)) {
680
+ session.plugins = desiredPlugins
681
+ changed = true
682
+ }
671
683
  if (session.shortcutForAgentId !== agent.id) { session.shortcutForAgentId = agent.id; changed = true }
672
684
  if (session.name !== agent.name) { session.name = agent.name; changed = true }
673
685
  }
@@ -734,6 +746,14 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
734
746
  ]
735
747
  parts.push(thinkingHint.join('\n'))
736
748
 
749
+ const enabledPlugins = Array.isArray(session.plugins) ? session.plugins : (Array.isArray(agent.plugins) ? agent.plugins : [])
750
+ const toolDisciplineLines = buildToolDisciplineLines(enabledPlugins)
751
+ if (toolDisciplineLines.length > 0) parts.push(['## Tool Discipline', ...toolDisciplineLines].join('\n'))
752
+ const operatingGuidance = getPluginManager().collectOperatingGuidance(enabledPlugins)
753
+ if (operatingGuidance.length > 0) parts.push(['## Tool Guidance', ...operatingGuidance].join('\n'))
754
+ const capabilityLines = getPluginManager().collectCapabilityDescriptions(enabledPlugins)
755
+ if (capabilityLines.length > 0) parts.push(['## Tool Capabilities', ...capabilityLines].join('\n'))
756
+
737
757
  // 7. Heartbeat Guidance
738
758
  parts.push([
739
759
  '## Heartbeats',
@@ -849,7 +869,10 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
849
869
  ? session
850
870
  : { ...session, plugins: pluginsForRun }
851
871
  if (agentForSession) {
852
- const preferredRoute = resolvePrimaryAgentRoute(agentForSession)
872
+ const preferredRoute = resolvePrimaryAgentRoute(agentForSession, undefined, {
873
+ preferredGatewayTags: session.routePreferredGatewayTags || [],
874
+ preferredGatewayUseCase: session.routePreferredGatewayUseCase || null,
875
+ })
853
876
  if (preferredRoute) {
854
877
  sessionForRun = applyResolvedRoute({ ...sessionForRun }, preferredRoute)
855
878
  }
@@ -1255,12 +1278,18 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1255
1278
  return false
1256
1279
  }
1257
1280
  const agent = session.agentId ? loadAgents()[session.agentId] : null
1281
+ const activeProjectContext = resolveActiveProjectContext(session)
1258
1282
  const { tools, cleanup } = await buildSessionTools(session.cwd, sessionForRun.plugins || sessionForRun.tools || [], {
1259
1283
  agentId: session.agentId || null,
1260
1284
  sessionId,
1261
1285
  platformAssignScope: agent?.platformAssignScope || 'self',
1262
1286
  mcpServerIds: agent?.mcpServerIds,
1263
1287
  mcpDisabledTools: agent?.mcpDisabledTools,
1288
+ projectId: activeProjectContext.projectId,
1289
+ projectRoot: activeProjectContext.projectRoot,
1290
+ projectName: activeProjectContext.project?.name || null,
1291
+ projectDescription: activeProjectContext.project?.description || null,
1292
+ memoryScopeMode: (((session as unknown as Record<string, unknown>).memoryScopeMode as string | null | undefined) ?? agent?.memoryScopeMode ?? null),
1264
1293
  })
1265
1294
  try {
1266
1295
  const directTool = tools.find((t) => t?.name === toolName) as StructuredToolInterface | undefined
@@ -1502,6 +1531,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1502
1531
  claudeCode: normalizeResumeId(sr.claudeCode ?? cr.claudeCode),
1503
1532
  codex: normalizeResumeId(sr.codex ?? cr.codex),
1504
1533
  opencode: normalizeResumeId(sr.opencode ?? cr.opencode),
1534
+ gemini: normalizeResumeId(sr.gemini ?? cr.gemini),
1505
1535
  }
1506
1536
  if (JSON.stringify(currentResume) !== JSON.stringify(nextResume)) {
1507
1537
  current.delegateResumeIds = nextResume
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import {
4
+ advanceConnectorReconnectState,
5
+ createConnectorReconnectState,
6
+ } from './manager'
7
+
8
+ test('advanceConnectorReconnectState applies exponential backoff and exhaustion', () => {
9
+ const policy = {
10
+ initialBackoffMs: 30_000,
11
+ maxBackoffMs: 15 * 60 * 1000,
12
+ maxAttempts: 3,
13
+ }
14
+
15
+ const initial = createConnectorReconnectState({}, policy)
16
+
17
+ const first = advanceConnectorReconnectState(initial, 'boom-1', 1_000, policy)
18
+ assert.equal(first.attempts, 1)
19
+ assert.equal(first.backoffMs, 30_000)
20
+ assert.equal(first.nextRetryAt, 31_000)
21
+ assert.equal(first.exhausted, false)
22
+
23
+ const second = advanceConnectorReconnectState(first, 'boom-2', 31_000, policy)
24
+ assert.equal(second.attempts, 2)
25
+ assert.equal(second.backoffMs, 60_000)
26
+ assert.equal(second.nextRetryAt, 91_000)
27
+ assert.equal(second.exhausted, false)
28
+
29
+ const third = advanceConnectorReconnectState(second, 'boom-3', 91_000, policy)
30
+ assert.equal(third.attempts, 3)
31
+ assert.equal(third.backoffMs, 120_000)
32
+ assert.equal(third.nextRetryAt, 211_000)
33
+ assert.equal(third.exhausted, true)
34
+ })
35
+
36
+ test('createConnectorReconnectState respects custom initial backoff', () => {
37
+ const state = createConnectorReconnectState(
38
+ { error: 'seeded' },
39
+ { initialBackoffMs: 45_000 },
40
+ )
41
+
42
+ assert.equal(state.attempts, 0)
43
+ assert.equal(state.backoffMs, 45_000)
44
+ assert.equal(state.nextRetryAt, 0)
45
+ assert.equal(state.error, 'seeded')
46
+ assert.equal(state.exhausted, false)
47
+ })