@swarmclawai/swarmclaw 1.9.20 → 1.9.22

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 (34) hide show
  1. package/README.md +22 -2
  2. package/package.json +3 -3
  3. package/src/app/api/setup/check-provider/route.test.ts +44 -0
  4. package/src/app/api/setup/check-provider/route.ts +235 -63
  5. package/src/components/agents/agent-sheet.tsx +18 -5
  6. package/src/components/auth/setup-wizard/step-connect.tsx +13 -3
  7. package/src/components/auth/setup-wizard/types.ts +2 -9
  8. package/src/components/chat/activity-moment.tsx +4 -0
  9. package/src/components/chat/tool-call-bubble.tsx +6 -0
  10. package/src/components/providers/provider-diagnostics-list.tsx +58 -0
  11. package/src/components/providers/provider-sheet.tsx +11 -1
  12. package/src/features/providers/queries.ts +2 -1
  13. package/src/lib/server/capability-router.test.ts +4 -4
  14. package/src/lib/server/capability-router.ts +1 -0
  15. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +27 -0
  16. package/src/lib/server/chat-execution/chat-execution-utils.ts +21 -0
  17. package/src/lib/server/chat-execution/iteration-event-handler.ts +1 -1
  18. package/src/lib/server/chat-execution/stream-continuation.ts +6 -2
  19. package/src/lib/server/plugins-advanced.test.ts +7 -3
  20. package/src/lib/server/provider-diagnostics.test.ts +39 -0
  21. package/src/lib/server/provider-diagnostics.ts +114 -0
  22. package/src/lib/server/session-tools/web-crawl.test.ts +106 -0
  23. package/src/lib/server/session-tools/web-inputs.test.ts +5 -0
  24. package/src/lib/server/session-tools/web-utils.ts +8 -2
  25. package/src/lib/server/session-tools/web.ts +256 -29
  26. package/src/lib/server/storage.ts +2 -0
  27. package/src/lib/server/tool-aliases.ts +1 -1
  28. package/src/lib/server/tool-capability-policy-advanced.test.ts +3 -3
  29. package/src/lib/server/tool-capability-policy.ts +4 -1
  30. package/src/lib/server/tool-planning.test.ts +2 -1
  31. package/src/lib/server/tool-planning.ts +31 -0
  32. package/src/lib/server/untrusted-content.ts +2 -2
  33. package/src/types/provider.ts +21 -0
  34. package/src/types/session.ts +2 -0
@@ -10,7 +10,7 @@ import { sleep } from '@/lib/shared-utils'
10
10
  import { BottomSheet } from '@/components/shared/bottom-sheet'
11
11
  import { toast } from 'sonner'
12
12
  import { ModelCombobox } from '@/components/shared/model-combobox'
13
- import type { ProviderType, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types'
13
+ import type { ProviderType, ProviderDiagnosticStep, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types'
14
14
  import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
15
15
  import { MCP_INJECTION_PROVIDER_IDS, NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS, WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
16
16
  import { isOrchestratorProviderEligible } from '@/lib/orchestrator-config'
@@ -33,6 +33,7 @@ import { buildAgentSelectableProviders, resolveAgentSelectableProviderCredential
33
33
  import { AgentSocialSettings } from '@/features/swarmfeed/agent-social-settings'
34
34
  import { AgentMarketplaceSettings } from '@/features/swarmdock/agent-marketplace-settings'
35
35
  import type { ConfigVersion } from '@/types/config-version'
36
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
36
37
 
37
38
  const HB_PRESETS = [1800, 3600, 7200, 21600, 43200] as const
38
39
  const FALLBACK_ELEVENLABS_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
@@ -284,6 +285,7 @@ export function AgentSheet() {
284
285
  const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle')
285
286
  const [testMessage, setTestMessage] = useState('')
286
287
  const [testErrorCode, setTestErrorCode] = useState<string | null>(null)
288
+ const [testDiagnostics, setTestDiagnostics] = useState<ProviderDiagnosticStep[]>([])
287
289
  const [testDeviceId, setTestDeviceId] = useState<string | null>(null)
288
290
  const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
289
291
  const [configCopied, setConfigCopied] = useState(false)
@@ -426,6 +428,7 @@ export function AgentSheet() {
426
428
  .catch(() => {})
427
429
  setTestStatus('idle')
428
430
  setTestMessage('')
431
+ setTestDiagnostics([])
429
432
  setShowAdvancedSettings(false)
430
433
  if (editing) {
431
434
  setName(editing.name)
@@ -690,6 +693,7 @@ export function AgentSheet() {
690
693
  useEffect(() => {
691
694
  setTestStatus('idle')
692
695
  setTestMessage('')
696
+ setTestDiagnostics([])
693
697
  }, [provider, credentialId, apiEndpoint])
694
698
 
695
699
  // Fetch MCP tools when selected servers change
@@ -758,6 +762,7 @@ export function AgentSheet() {
758
762
  setTestStatus('idle')
759
763
  setTestMessage('')
760
764
  setTestErrorCode(null)
765
+ setTestDiagnostics([])
761
766
  setAddingKey(false)
762
767
  setNewKeyName('')
763
768
  setNewKeyValue('')
@@ -1047,8 +1052,9 @@ export function AgentSheet() {
1047
1052
  setTestStatus('testing')
1048
1053
  setTestMessage('')
1049
1054
  setTestErrorCode(null)
1055
+ setTestDiagnostics([])
1050
1056
  try {
1051
- const result = await api<{ ok: boolean; message: string; errorCode?: string; deviceId?: string }>('POST', '/setup/check-provider', {
1057
+ const result = await api<{ ok: boolean; message: string; errorCode?: string; deviceId?: string; diagnostics?: ProviderDiagnosticStep[] }>('POST', '/setup/check-provider', {
1052
1058
  provider,
1053
1059
  credentialId,
1054
1060
  endpoint: apiEndpoint,
@@ -1057,6 +1063,7 @@ export function AgentSheet() {
1057
1063
  }, {
1058
1064
  timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
1059
1065
  })
1066
+ setTestDiagnostics(result.diagnostics ?? [])
1060
1067
  if (result.deviceId) setTestDeviceId(result.deviceId)
1061
1068
  if (result.ok) {
1062
1069
  let syncedModels: string[] = []
@@ -1084,6 +1091,7 @@ export function AgentSheet() {
1084
1091
  const msg = err instanceof Error ? err.message : 'Connection test failed'
1085
1092
  setTestStatus('fail')
1086
1093
  setTestMessage(msg)
1094
+ setTestDiagnostics([])
1087
1095
  toast.error(msg)
1088
1096
  return false
1089
1097
  }
@@ -1317,6 +1325,10 @@ export function AgentSheet() {
1317
1325
  <button
1318
1326
  type="button"
1319
1327
  onClick={() => {
1328
+ setTestStatus('idle')
1329
+ setTestMessage('')
1330
+ setTestErrorCode(null)
1331
+ setTestDiagnostics([])
1320
1332
  if (!openclawEnabled) {
1321
1333
  setOpenclawEnabled(true)
1322
1334
  setProvider('openclaw')
@@ -1330,9 +1342,6 @@ export function AgentSheet() {
1330
1342
  setApiEndpoint(null)
1331
1343
  setCredentialId(null)
1332
1344
  setGatewayProfileId(null)
1333
- setTestStatus('idle')
1334
- setTestMessage('')
1335
- setTestErrorCode(null)
1336
1345
  }
1337
1346
  }}
1338
1347
  className={`relative h-6 w-11 rounded-full border-none transition-colors duration-200 ${openclawEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
@@ -1473,6 +1482,7 @@ export function AgentSheet() {
1473
1482
  <p className="text-[14px] text-emerald-400 font-600">Connected</p>
1474
1483
  </div>
1475
1484
  <p className="text-[13px] text-text-2/80 leading-[1.6]">Gateway is reachable and this device is paired. Tools and models are managed by the OpenClaw instance.</p>
1485
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
1476
1486
  </div>
1477
1487
  )}
1478
1488
  {testStatus === 'fail' && (
@@ -1548,6 +1558,7 @@ export function AgentSheet() {
1548
1558
  </p>
1549
1559
  </div>
1550
1560
  )}
1561
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
1551
1562
  </div>
1552
1563
  )}
1553
1564
  </div>
@@ -2965,11 +2976,13 @@ export function AgentSheet() {
2965
2976
  {!openclawEnabled && testStatus === 'fail' && (
2966
2977
  <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20">
2967
2978
  <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p>
2979
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
2968
2980
  </div>
2969
2981
  )}
2970
2982
  {!openclawEnabled && testStatus === 'pass' && (
2971
2983
  <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20">
2972
2984
  <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p>
2985
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
2973
2986
  </div>
2974
2987
  )}
2975
2988
 
@@ -5,7 +5,8 @@ import { api } from '@/lib/app/api-client'
5
5
  import { dedup, errorMessage } from '@/lib/shared-utils'
6
6
  import { getDefaultModelForProvider } from '@/lib/setup-defaults'
7
7
  import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
8
- import type { Credential, Credentials, GatewayProfile, ProviderId, ProviderConfig } from '@/types'
8
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
9
+ import type { Credential, Credentials, GatewayProfile, ProviderDiagnosticStep, ProviderId, ProviderConfig } from '@/types'
9
10
  import type { StepConnectProps, CheckState, ProviderCheckResponse, ConfiguredProvider } from './types'
10
11
  import {
11
12
  formatEndpointHost,
@@ -34,6 +35,7 @@ export function StepConnect({
34
35
  const [checkState, setCheckState] = useState<CheckState>(editingProvider?.verified ? 'ok' : 'idle')
35
36
  const [checkMessage, setCheckMessage] = useState('')
36
37
  const [checkErrorCode, setCheckErrorCode] = useState<string | null>(null)
38
+ const [checkDiagnostics, setCheckDiagnostics] = useState<ProviderDiagnosticStep[]>([])
37
39
  const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
38
40
  const [providerSuggestedModel, setProviderSuggestedModel] = useState(
39
41
  editingProvider?.defaultModel || (provider === 'custom' ? '' : getDefaultModelForProvider(provider)),
@@ -111,6 +113,7 @@ export function StepConnect({
111
113
  setCheckState('idle')
112
114
  setCheckMessage('')
113
115
  setCheckErrorCode(null)
116
+ setCheckDiagnostics([])
114
117
  setError('')
115
118
  }
116
119
 
@@ -118,12 +121,14 @@ export function StepConnect({
118
121
  if (requiresKey && !hasKeyOrCredential) {
119
122
  setCheckState('error')
120
123
  setCheckMessage('Please paste your API key or select a saved key first.')
124
+ setCheckDiagnostics([])
121
125
  return false
122
126
  }
123
127
 
124
128
  setCheckState('checking')
125
129
  setCheckMessage('')
126
130
  setCheckErrorCode(null)
131
+ setCheckDiagnostics([])
127
132
  setError('')
128
133
  try {
129
134
  const result = await api<ProviderCheckResponse>('POST', '/setup/check-provider', {
@@ -140,6 +145,7 @@ export function StepConnect({
140
145
  setProviderSuggestedModel(result.recommendedModel)
141
146
  }
142
147
  setCheckErrorCode(result.errorCode || null)
148
+ setCheckDiagnostics(result.diagnostics ?? [])
143
149
  setOpenclawDeviceId(result.deviceId || null)
144
150
  setCheckState(result.ok ? 'ok' : 'error')
145
151
  setCheckMessage(result.message || (result.ok ? 'Connected successfully.' : 'Connection failed.'))
@@ -148,6 +154,7 @@ export function StepConnect({
148
154
  setCheckState('error')
149
155
  setCheckMessage(errorMessage(err))
150
156
  setCheckErrorCode(null)
157
+ setCheckDiagnostics([])
151
158
  return false
152
159
  }
153
160
  }
@@ -280,7 +287,7 @@ export function StepConnect({
280
287
  <input
281
288
  type="text"
282
289
  value={endpoint}
283
- onChange={(e) => { setEndpoint(e.target.value); setCheckState('idle'); setCheckMessage('') }}
290
+ onChange={(e) => { setEndpoint(e.target.value); setCheckState('idle'); setCheckMessage(''); setCheckDiagnostics([]) }}
284
291
  placeholder={selectedProvider.defaultEndpoint || ''}
285
292
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
286
293
  text-text text-[14px] font-mono outline-none transition-all duration-200
@@ -295,6 +302,7 @@ export function StepConnect({
295
302
  setEndpoint(isCloud ? (selectedProvider.defaultEndpoint || '') : selectedProvider.cloudEndpoint!)
296
303
  setCheckState('idle')
297
304
  setCheckMessage('')
305
+ setCheckDiagnostics([])
298
306
  }}
299
307
  className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] border text-[12px] font-500 cursor-pointer transition-all duration-200 bg-transparent
300
308
  border-white/[0.08] text-text-2 hover:bg-white/[0.04]"
@@ -440,6 +448,7 @@ export function StepConnect({
440
448
  setApiKey('')
441
449
  setCheckState('idle')
442
450
  setCheckMessage('')
451
+ setCheckDiagnostics([])
443
452
  }
444
453
  }}
445
454
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
@@ -466,7 +475,7 @@ export function StepConnect({
466
475
  <input
467
476
  type="password"
468
477
  value={apiKey}
469
- onChange={(e) => { setApiKey(e.target.value); setCredentialId(null); setCheckState('idle'); setCheckMessage(''); setError('') }}
478
+ onChange={(e) => { setApiKey(e.target.value); setCredentialId(null); setCheckState('idle'); setCheckMessage(''); setCheckDiagnostics([]); setError('') }}
470
479
  placeholder={selectedProvider.keyPlaceholder || (provider === 'openclaw' ? 'Paste OpenClaw bearer token' : 'sk-...')}
471
480
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
472
481
  text-text text-[14px] font-mono outline-none transition-all duration-200
@@ -530,6 +539,7 @@ export function StepConnect({
530
539
  Device paired as <code className="text-text-2">{openclawDeviceId.slice(0, 12)}...</code>.
531
540
  </p>
532
541
  )}
542
+ <ProviderDiagnosticsList diagnostics={checkDiagnostics} />
533
543
  </div>
534
544
  )}
535
545
 
@@ -1,17 +1,10 @@
1
- import type { GatewayProfile, ProviderId } from '@/types'
1
+ import type { GatewayProfile, ProviderCheckResult, ProviderId } from '@/types'
2
2
  import type { SetupProvider } from '@/lib/setup-defaults'
3
3
 
4
4
  export type SetupStep = 'profile' | 'path' | 'providers' | 'connect' | 'agents' | 'next' | 'done'
5
5
  export type CheckState = 'idle' | 'checking' | 'ok' | 'error'
6
6
 
7
- export interface ProviderCheckResponse {
8
- ok: boolean
9
- message: string
10
- normalizedEndpoint?: string
11
- recommendedModel?: string
12
- errorCode?: string
13
- deviceId?: string
14
- }
7
+ export type ProviderCheckResponse = ProviderCheckResult
15
8
 
16
9
  export interface SetupDoctorCheck {
17
10
  id: string
@@ -19,6 +19,9 @@ const NOTABLE_TOOLS: Record<string, { label: string; color: string; icon: 'brain
19
19
  delegate_to_agent: { label: 'Delegating task', color: '#6366F1', icon: 'delegate' },
20
20
  check_delegation_status: { label: 'Checking delegation', color: '#6366F1', icon: 'delegate' },
21
21
  web_search: { label: 'Searched the web', color: '#22C55E', icon: 'search' },
22
+ web_fetch: { label: 'Read a web page', color: '#22C55E', icon: 'search' },
23
+ web_extract: { label: 'Extracted a web page', color: '#22C55E', icon: 'search' },
24
+ web_crawl: { label: 'Crawled a site', color: '#22C55E', icon: 'search' },
22
25
  connector_message_tool: { label: 'Sent a message', color: '#F97316', icon: 'message' },
23
26
  }
24
27
 
@@ -35,6 +38,7 @@ function extractSnippet(toolName: string, toolInput: string): string | null {
35
38
  if (toolName === 'check_delegation_status' && parsed.agentName) return parsed.agentName
36
39
  if (toolName.startsWith('delegate_to_') && parsed.task) return parsed.task
37
40
  if (toolName === 'web_search' && parsed.query) return parsed.query
41
+ if ((toolName === 'web_fetch' || toolName === 'web_extract' || toolName === 'web_crawl') && parsed.url) return parsed.url
38
42
  if (toolName === 'connector_message_tool' && parsed.to) return parsed.to
39
43
  } catch { /* ignore parse errors */ }
40
44
  return null
@@ -20,6 +20,8 @@ const TOOL_COLORS: Record<string, string> = {
20
20
  create_spreadsheet: '#10B981',
21
21
  web_search: '#3B82F6',
22
22
  web_fetch: '#3B82F6',
23
+ web_extract: '#3B82F6',
24
+ web_crawl: '#3B82F6',
23
25
  spawn_subagent: '#8B5CF6',
24
26
  delegate_to_agent: '#6366F1',
25
27
  check_delegation_status: '#6366F1',
@@ -77,6 +79,8 @@ export const TOOL_LABELS: Record<string, string> = {
77
79
  create_spreadsheet: 'Create Spreadsheet',
78
80
  web_search: 'Web Search',
79
81
  web_fetch: 'Web Fetch',
82
+ web_extract: 'Web Extract',
83
+ web_crawl: 'Web Crawl',
80
84
  claude_code: 'Claude Code',
81
85
  codex_cli: 'Codex CLI',
82
86
  opencode_cli: 'OpenCode CLI',
@@ -127,6 +131,8 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
127
131
  create_spreadsheet: 'Create Excel or CSV files from structured data',
128
132
  web_search: 'Search the web for information',
129
133
  web_fetch: 'Fetch and read web page content',
134
+ web_extract: 'Extract readable content from a source URL',
135
+ web_crawl: 'Crawl a bounded set of pages from one site',
130
136
  claude_code: 'Enable delegation to Claude Code CLI',
131
137
  codex_cli: 'Enable delegation to OpenAI Codex CLI',
132
138
  opencode_cli: 'Enable delegation to OpenCode CLI',
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import type { ProviderDiagnosticStep } from '@/types'
4
+
5
+ const STATUS_CLASSES: Record<ProviderDiagnosticStep['status'], string> = {
6
+ pass: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-300',
7
+ warn: 'border-amber-500/25 bg-amber-500/10 text-amber-300',
8
+ fail: 'border-red-500/25 bg-red-500/10 text-red-300',
9
+ }
10
+
11
+ const STATUS_LABELS: Record<ProviderDiagnosticStep['status'], string> = {
12
+ pass: 'Pass',
13
+ warn: 'Warn',
14
+ fail: 'Fail',
15
+ }
16
+
17
+ export function ProviderDiagnosticsList({
18
+ diagnostics,
19
+ className = '',
20
+ }: {
21
+ diagnostics?: ProviderDiagnosticStep[] | null
22
+ className?: string
23
+ }) {
24
+ if (!diagnostics?.length) return null
25
+
26
+ return (
27
+ <div className={`mt-3 border-t border-white/[0.06] pt-3 ${className}`}>
28
+ <div className="mb-2 text-[11px] font-700 uppercase tracking-[0.1em] text-text-3/70">
29
+ Diagnostics
30
+ </div>
31
+ <ol className="space-y-2">
32
+ {diagnostics.map((step) => (
33
+ <li key={step.id} className="grid gap-1 text-left sm:grid-cols-[64px_minmax(0,1fr)] sm:gap-3">
34
+ <div>
35
+ <span className={`inline-flex min-w-[54px] items-center justify-center rounded-[999px] border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] ${STATUS_CLASSES[step.status]}`}>
36
+ {STATUS_LABELS[step.status]}
37
+ </span>
38
+ </div>
39
+ <div className="min-w-0">
40
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
41
+ <span className="text-[12px] font-650 text-text-2">{step.label}</span>
42
+ {typeof step.durationMs === 'number' && (
43
+ <span className="text-[11px] text-text-3">{step.durationMs} ms</span>
44
+ )}
45
+ </div>
46
+ {step.target && (
47
+ <div className="mt-0.5 break-all font-mono text-[11px] text-text-3">{step.target}</div>
48
+ )}
49
+ {step.detail && (
50
+ <div className="mt-0.5 text-[11px] leading-relaxed text-text-3">{step.detail}</div>
51
+ )}
52
+ </div>
53
+ </li>
54
+ ))}
55
+ </ol>
56
+ </div>
57
+ )
58
+ }
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { BottomSheet } from '@/components/shared/bottom-sheet'
6
6
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
7
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
7
8
  import { toast } from 'sonner'
8
9
  import { errorMessage } from '@/lib/shared-utils'
9
10
  import {
@@ -17,6 +18,7 @@ import {
17
18
  useSaveCustomProviderMutation,
18
19
  } from '@/features/providers/queries'
19
20
  import { useCreateCredentialMutation, useCredentialsQuery } from '@/features/credentials/queries'
21
+ import type { ProviderDiagnosticStep } from '@/types'
20
22
 
21
23
  export function ProviderSheet() {
22
24
  const open = useAppStore((s) => s.providerSheetOpen)
@@ -54,6 +56,7 @@ export function ProviderSheet() {
54
56
  const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle')
55
57
  const [testMessage, setTestMessage] = useState('')
56
58
  const [testModel, setTestModel] = useState('')
59
+ const [testDiagnostics, setTestDiagnostics] = useState<ProviderDiagnosticStep[]>([])
57
60
 
58
61
  const [liveModels, setLiveModels] = useState<string[]>([])
59
62
  const [liveLoading, setLiveLoading] = useState(false)
@@ -77,6 +80,7 @@ export function ProviderSheet() {
77
80
  setLiveCached(false)
78
81
  setTestStatus('idle')
79
82
  setTestMessage('')
83
+ setTestDiagnostics([])
80
84
  if (editingCustom) {
81
85
  setName(editingCustom.name)
82
86
  setBaseUrl(editingCustom.baseUrl || '')
@@ -108,6 +112,7 @@ export function ProviderSheet() {
108
112
  useEffect(() => {
109
113
  setTestStatus('idle')
110
114
  setTestMessage('')
115
+ setTestDiagnostics([])
111
116
  }, [credentialId, baseUrl])
112
117
 
113
118
  useEffect(() => {
@@ -121,6 +126,7 @@ export function ProviderSheet() {
121
126
  if (!isBuiltin) return
122
127
  setTestStatus('testing')
123
128
  setTestMessage('')
129
+ setTestDiagnostics([])
124
130
  try {
125
131
  const result = await checkProviderConnectionMutation.mutateAsync({
126
132
  provider: editingId || 'custom',
@@ -128,6 +134,7 @@ export function ProviderSheet() {
128
134
  endpoint: baseUrl,
129
135
  model: testModel || undefined,
130
136
  })
137
+ setTestDiagnostics(result.diagnostics ?? [])
131
138
  if (result.ok) {
132
139
  setTestStatus('pass')
133
140
  setTestMessage(result.message)
@@ -141,6 +148,7 @@ export function ProviderSheet() {
141
148
  const msg = err instanceof Error ? err.message : 'Connection test failed'
142
149
  setTestStatus('fail')
143
150
  setTestMessage(msg)
151
+ setTestDiagnostics([])
144
152
  toast.error(msg)
145
153
  }
146
154
  }
@@ -530,7 +538,7 @@ export function ProviderSheet() {
530
538
  </label>
531
539
  <select
532
540
  value={testModel}
533
- onChange={(e) => { setTestModel(e.target.value); setTestStatus('idle'); setTestMessage('') }}
541
+ onChange={(e) => { setTestModel(e.target.value); setTestStatus('idle'); setTestMessage(''); setTestDiagnostics([]) }}
534
542
  className={`${inputClass} appearance-none cursor-pointer`}
535
543
  style={{ fontFamily: 'inherit' }}
536
544
  >
@@ -546,11 +554,13 @@ export function ProviderSheet() {
546
554
  {isBuiltin && testStatus === 'fail' && (
547
555
  <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20">
548
556
  <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p>
557
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
549
558
  </div>
550
559
  )}
551
560
  {isBuiltin && testStatus === 'pass' && (
552
561
  <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20">
553
562
  <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p>
563
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
554
564
  </div>
555
565
  )}
556
566
 
@@ -13,6 +13,7 @@ import {
13
13
  import { fetchProviders } from '@/lib/chat/chats'
14
14
  import type {
15
15
  ProviderConfig,
16
+ ProviderCheckResult,
16
17
  ProviderInfo,
17
18
  ProviderModelDiscoveryResult,
18
19
  } from '@/types'
@@ -129,7 +130,7 @@ export function useResetProviderModelsMutation() {
129
130
  export function useCheckProviderConnectionMutation() {
130
131
  return useMutation({
131
132
  mutationFn: ({ provider, credentialId, endpoint, model }: CheckProviderConnectionInput) =>
132
- api<{ ok: boolean; message: string }>('POST', '/setup/check-provider', {
133
+ api<ProviderCheckResult>('POST', '/setup/check-provider', {
133
134
  provider,
134
135
  credentialId,
135
136
  endpoint,
@@ -26,7 +26,7 @@ test('routeTaskIntent keeps coding prompts prioritized over memory keywords', ()
26
26
  test('routeTaskIntent keeps hybrid research-plus-media prompts in research intent', () => {
27
27
  const decision = routeTaskIntent(
28
28
  '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?',
29
- ['web_search', 'web_fetch', 'browser', 'manage_connectors'],
29
+ ['web_search', 'web_fetch', 'web_crawl', 'browser', 'manage_connectors'],
30
30
  null,
31
31
  makeClassification({
32
32
  taskIntent: 'research',
@@ -39,7 +39,7 @@ test('routeTaskIntent keeps hybrid research-plus-media prompts in research inten
39
39
  )
40
40
 
41
41
  assert.equal(decision.intent, 'research')
42
- assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'browser', 'connector_message_tool'])
42
+ assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'web_extract', 'web_crawl', 'browser', 'connector_message_tool'])
43
43
  })
44
44
 
45
45
  test('routeTaskIntent treats direct voice-note delivery as outreach', () => {
@@ -72,7 +72,7 @@ test('routeTaskIntent treats keep-watching update requests as research even with
72
72
  )
73
73
 
74
74
  assert.equal(decision.intent, 'research')
75
- assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch'])
75
+ assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'web_extract', 'web_crawl'])
76
76
  })
77
77
 
78
78
  test('routeTaskIntent uses structured classification when available', () => {
@@ -99,7 +99,7 @@ test('routeTaskIntent uses structured classification when available', () => {
99
99
  )
100
100
 
101
101
  assert.equal(decision.intent, 'browsing')
102
- assert.deepEqual(decision.preferredTools, ['browser', 'web_fetch'])
102
+ assert.deepEqual(decision.preferredTools, ['browser', 'web_fetch', 'web_extract'])
103
103
  })
104
104
 
105
105
  function makeClassification(overrides: Partial<MessageClassification>): MessageClassification {
@@ -144,6 +144,7 @@ export function routeTaskIntent(
144
144
  [
145
145
  TOOL_CAPABILITY.researchSearch,
146
146
  TOOL_CAPABILITY.researchFetch,
147
+ TOOL_CAPABILITY.researchCrawl,
147
148
  ...(wantsScreenshots ? [TOOL_CAPABILITY.browserCapture] : []),
148
149
  ...(wantsVoiceDelivery ? [TOOL_CAPABILITY.deliveryVoiceNote] : []),
149
150
  ...(wantsOutboundDelivery ? [TOOL_CAPABILITY.deliveryMedia, TOOL_CAPABILITY.deliveryMessage] : []),
@@ -407,6 +407,33 @@ describe('translateRequestedToolInvocation advanced', () => {
407
407
  assert.equal(args.action, 'search')
408
408
  assert.equal(args.query, 'test query')
409
409
  })
410
+
411
+ it('maps web_extract to web with action=extract', () => {
412
+ const { toolName, args } = translateRequestedToolInvocation(
413
+ 'web_extract',
414
+ { url: 'https://example.com/source' },
415
+ '',
416
+ ['web'],
417
+ )
418
+ assert.equal(toolName, 'web')
419
+ assert.equal(args.action, 'extract')
420
+ assert.equal(args.url, 'https://example.com/source')
421
+ })
422
+
423
+ it('maps web_crawl to web with bounded crawl arguments', () => {
424
+ const { toolName, args } = translateRequestedToolInvocation(
425
+ 'web_crawl',
426
+ { url: 'https://example.com/', maxPages: 4, maxDepth: 1, includeExternal: false },
427
+ '',
428
+ ['web'],
429
+ )
430
+ assert.equal(toolName, 'web')
431
+ assert.equal(args.action, 'crawl')
432
+ assert.equal(args.url, 'https://example.com/')
433
+ assert.equal(args.maxPages, 4)
434
+ assert.equal(args.maxDepth, 1)
435
+ assert.equal(args.includeExternal, false)
436
+ })
410
437
  })
411
438
 
412
439
  // ---------------------------------------------------------------------------
@@ -127,6 +127,27 @@ export function translateRequestedToolInvocation(
127
127
  },
128
128
  }
129
129
  }
130
+ if (requestedName === 'web_extract') {
131
+ return {
132
+ toolName: 'web',
133
+ args: {
134
+ action: 'extract',
135
+ url: rawArgs.url,
136
+ },
137
+ }
138
+ }
139
+ if (requestedName === 'web_crawl') {
140
+ return {
141
+ toolName: 'web',
142
+ args: {
143
+ action: 'crawl',
144
+ url: rawArgs.url || rawArgs.query,
145
+ maxPages: rawArgs.maxPages ?? rawArgs.maxResults,
146
+ maxDepth: rawArgs.maxDepth,
147
+ includeExternal: rawArgs.includeExternal,
148
+ },
149
+ }
150
+ }
130
151
  if (requestedName === 'delegate_to_claude_code') {
131
152
  return { toolName: 'delegate', args: { ...rawArgs, backend: 'claude' } }
132
153
  }
@@ -349,7 +349,7 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
349
349
  }
350
350
  if (
351
351
  boundedExternalExecutionTask
352
- && ['http_request', 'web', 'web_search', 'web_fetch', 'browser'].includes(toolName)
352
+ && ['http_request', 'web', 'web_search', 'web_fetch', 'web_extract', 'web_crawl', 'browser'].includes(toolName)
353
353
  && countExternalExecutionResearchSteps(state.streamedToolEvents) >= 5
354
354
  && countDistinctExternalResearchHosts(state.streamedToolEvents) >= 3
355
355
  ) {
@@ -196,7 +196,7 @@ function getRequestedArtifactStatus(params: {
196
196
 
197
197
  export function countExternalExecutionResearchSteps(toolEvents: MessageToolEvent[]): number {
198
198
  return toolEvents.filter((event) => {
199
- return ['http_request', 'web', 'web_search', 'web_fetch', 'browser'].includes(event.name)
199
+ return ['http_request', 'web', 'web_search', 'web_fetch', 'web_extract', 'web_crawl', 'browser'].includes(event.name)
200
200
  }).length
201
201
  }
202
202
 
@@ -300,6 +300,8 @@ const RECOVERABLE_TOOL_ERROR_NAMES = new Set([
300
300
  'web',
301
301
  'web_search',
302
302
  'web_fetch',
303
+ 'web_extract',
304
+ 'web_crawl',
303
305
  'http_request',
304
306
  ])
305
307
 
@@ -390,6 +392,8 @@ export function getToolFrequencyHint(toolName: string, sessionExtensions: string
390
392
  case 'http_request':
391
393
  case 'web_search':
392
394
  case 'web_fetch':
395
+ case 'web_extract':
396
+ case 'web_crawl':
393
397
  return 'Hint: You have done extensive research. Stop gathering more sources and use the information you already have to complete the task.'
394
398
 
395
399
  case 'spawn_subagent':
@@ -490,7 +494,7 @@ function buildDeliverableFollowthroughPrompt(params: {
490
494
  }
491
495
 
492
496
  if (
493
- params.toolEvents.some((event) => ['web', 'web_search', 'web_fetch', 'browser', 'http_request'].includes(event.name))
497
+ params.toolEvents.some((event) => ['web', 'web_search', 'web_fetch', 'web_extract', 'web_crawl', 'browser', 'http_request'].includes(event.name))
494
498
  && !params.toolEvents.some((event) => ['files', 'write_file', 'edit_file', 'shell', 'execute_command'].includes(event.name))
495
499
  ) {
496
500
  lines.push(
@@ -135,11 +135,13 @@ describe('expandExtensionIds', () => {
135
135
  }
136
136
  })
137
137
 
138
- it('web expands to include web_search and web_fetch', () => {
138
+ it('web expands to include granular web tools', () => {
139
139
  const result = expandExtensionIds(['web'])
140
140
  assert.ok(result.includes('web'))
141
141
  assert.ok(result.includes('web_search'))
142
142
  assert.ok(result.includes('web_fetch'))
143
+ assert.ok(result.includes('web_extract'))
144
+ assert.ok(result.includes('web_crawl'))
143
145
  })
144
146
 
145
147
  it('removes duplicates after expansion', () => {
@@ -199,12 +201,14 @@ describe('expandExtensionIds', () => {
199
201
  // getExtensionAliases
200
202
  // ---------------------------------------------------------------------------
201
203
  describe('getExtensionAliases', () => {
202
- it('web returns [web, web_search, web_fetch]', () => {
204
+ it('web returns the full web alias group', () => {
203
205
  const result = getExtensionAliases('web')
204
206
  assert.ok(result.includes('web'))
205
207
  assert.ok(result.includes('web_search'))
206
208
  assert.ok(result.includes('web_fetch'))
207
- assert.equal(result.length, 5) // web, web_search, web_fetch, http_request, http
209
+ assert.ok(result.includes('web_extract'))
210
+ assert.ok(result.includes('web_crawl'))
211
+ assert.equal(result.length, 7) // web, web_search, web_fetch, web_extract, web_crawl, http_request, http
208
212
  })
209
213
 
210
214
  it('web_search returns the same group as web', () => {