@swarmclawai/swarmclaw 1.3.6 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -52
- package/next.config.ts +9 -4
- package/package.json +18 -10
- package/scripts/build-bootstrap-env.mjs +24 -0
- package/scripts/run-next-build.mjs +74 -0
- package/scripts/run-next-typegen.mjs +61 -0
- package/src/app/api/.well-known/agent-card/route.ts +46 -0
- package/src/app/api/a2a/route.ts +56 -0
- package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
- package/src/app/api/approvals/route.test.ts +29 -3
- package/src/app/api/approvals/route.ts +13 -7
- package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
- package/src/app/api/chats/[id]/chat/route.ts +24 -8
- package/src/app/api/chats/[id]/deploy/route.ts +2 -2
- package/src/app/api/chats/chat-route.test.ts +68 -0
- package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
- package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
- package/src/app/api/logs/route.test.ts +61 -0
- package/src/app/api/logs/route.ts +35 -0
- package/src/app/api/openclaw/sync/route.ts +1 -1
- package/src/app/api/swarmfeed/channels/route.ts +14 -0
- package/src/app/api/swarmfeed/posts/route.ts +60 -0
- package/src/app/api/swarmfeed/route.ts +37 -0
- package/src/app/api/tts/route.test.ts +82 -0
- package/src/app/api/tts/route.ts +13 -6
- package/src/app/api/tts/stream/route.ts +12 -5
- package/src/app/error.tsx +32 -0
- package/src/app/global-error.tsx +33 -0
- package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
- package/src/app/protocols/page.tsx +16 -7
- package/src/app/swarmfeed/page.tsx +7 -0
- package/src/cli/index.js +22 -0
- package/src/cli/spec.js +9 -0
- package/src/components/agents/agent-avatar.tsx +2 -5
- package/src/components/agents/agent-sheet.tsx +10 -0
- package/src/components/auth/access-key-gate.tsx +25 -0
- package/src/components/layout/error-boundary.tsx +12 -30
- package/src/components/layout/error-fallback.tsx +61 -0
- package/src/components/layout/sidebar-rail.tsx +52 -0
- package/src/components/protocols/builder/edge-editor.tsx +43 -0
- package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
- package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
- package/src/components/protocols/builder/edge-types/index.ts +3 -0
- package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
- package/src/components/protocols/builder/node-inspector.tsx +227 -0
- package/src/components/protocols/builder/node-palette.tsx +97 -0
- package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
- package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
- package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
- package/src/components/protocols/builder/node-types/index.ts +9 -0
- package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
- package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
- package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
- package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
- package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
- package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
- package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
- package/src/components/protocols/builder/run-overlay.tsx +29 -0
- package/src/components/protocols/builder/template-gallery.tsx +53 -0
- package/src/components/protocols/builder/validation-panel.tsx +57 -0
- package/src/components/skills/skills-workspace.tsx +1 -9
- package/src/features/protocols/builder/hooks/index.ts +2 -0
- package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
- package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
- package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
- package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
- package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
- package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
- package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
- package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
- package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
- package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
- package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
- package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
- package/src/features/swarmfeed/compose-post.tsx +139 -0
- package/src/features/swarmfeed/feed-page.tsx +136 -0
- package/src/features/swarmfeed/post-card.tsx +114 -0
- package/src/features/swarmfeed/queries.ts +28 -0
- package/src/lib/a2a/agent-card.ts +61 -0
- package/src/lib/a2a/auth.ts +54 -0
- package/src/lib/a2a/client.ts +133 -0
- package/src/lib/a2a/discovery.ts +116 -0
- package/src/lib/a2a/handlers.ts +176 -0
- package/src/lib/a2a/json-rpc-router.ts +38 -0
- package/src/lib/a2a/types.ts +95 -0
- package/src/lib/app/navigation.ts +1 -0
- package/src/lib/app/report-client-error.ts +52 -0
- package/src/lib/app/view-constants.ts +9 -1
- package/src/lib/providers/anthropic.ts +119 -107
- package/src/lib/providers/ollama.ts +34 -14
- package/src/lib/providers/openai.ts +154 -142
- package/src/lib/providers/openclaw.ts +3 -3
- package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
- package/src/lib/server/agents/main-agent-loop.ts +377 -41
- package/src/lib/server/chat-execution/chat-execution.ts +12 -7
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
- package/src/lib/server/connectors/swarmdock.ts +1 -1
- package/src/lib/server/extensions.ts +11 -0
- package/src/lib/server/messages/message-repository.ts +31 -0
- package/src/lib/server/openclaw/sync.ts +4 -4
- package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
- package/src/lib/server/protocols/protocol-normalization.ts +1 -0
- package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
- package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
- package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
- package/src/lib/server/protocols/protocol-types.ts +1 -0
- package/src/lib/server/provider-health.ts +19 -3
- package/src/lib/server/safe-parse-body.test.ts +32 -0
- package/src/lib/server/safe-parse-body.ts +20 -3
- package/src/lib/server/session-tools/delegate.ts +151 -77
- package/src/lib/server/storage-auth.ts +10 -2
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/lib/server/storage.ts +113 -4
- package/src/lib/server/working-state/service.test.ts +2 -3
- package/src/lib/server/working-state/service.ts +37 -6
- package/src/lib/swarmfeed-client.ts +157 -0
- package/src/lib/validation/schemas.ts +1 -1
- package/src/stores/slices/data-slice.ts +3 -0
- package/src/stores/use-approval-store.ts +4 -1
- package/src/types/agent.ts +31 -1
- package/src/types/index.ts +1 -0
- package/src/types/protocol.ts +19 -0
- package/src/types/session.ts +1 -1
- package/src/types/swarmfeed.ts +30 -0
- package/tsconfig.json +1 -2
|
@@ -513,7 +513,7 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
|
|
|
513
513
|
const session = getSession(sessionId)
|
|
514
514
|
if (!session) throw new Error(`Session not found: ${sessionId}`)
|
|
515
515
|
const runStartedAt = Date.now()
|
|
516
|
-
|
|
516
|
+
let runMessageStartIndex = getMessageCount(sessionId)
|
|
517
517
|
|
|
518
518
|
const appSettings = loadSettings()
|
|
519
519
|
const lifecycleRunId = runId || `${sessionId}:${runStartedAt}`
|
|
@@ -725,17 +725,10 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
|
|
|
725
725
|
}
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
|
|
733
|
-
throw new Error(`Directory not found: ${session.cwd}`)
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const apiKey = resolveApiKeyForSession(sessionForRun, provider)
|
|
737
|
-
const hideAssistantTranscript = internal && source === 'main-loop-followup'
|
|
738
|
-
|
|
728
|
+
// Persist the user message BEFORE provider/credential resolution so that if
|
|
729
|
+
// provider resolution throws (unknown provider, missing credentials, etc.),
|
|
730
|
+
// the user message is already in the DB and won't disappear from the chat
|
|
731
|
+
// when the frontend's refreshMessages overwrites the optimistic local copy.
|
|
739
732
|
const shouldPersistUserMessage = shouldPersistInboundUserMessage(internal, source)
|
|
740
733
|
if (shouldPersistUserMessage) {
|
|
741
734
|
const [linkAnalysis, semantics] = await Promise.all([
|
|
@@ -805,8 +798,22 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
|
|
|
805
798
|
}
|
|
806
799
|
}
|
|
807
800
|
}
|
|
801
|
+
// Update runMessageStartIndex to account for newly appended user message(s)
|
|
802
|
+
// so that partial persistence and finalization don't overwrite them.
|
|
803
|
+
runMessageStartIndex = getMessageCount(sessionId)
|
|
808
804
|
}
|
|
809
805
|
|
|
806
|
+
const providerType = sessionForRun.provider || 'claude-cli'
|
|
807
|
+
const provider = getProvider(providerType)
|
|
808
|
+
if (!provider) throw new Error(`Unknown provider: ${providerType}`)
|
|
809
|
+
|
|
810
|
+
if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
|
|
811
|
+
throw new Error(`Directory not found: ${session.cwd}`)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const apiKey = resolveApiKeyForSession(sessionForRun, provider)
|
|
815
|
+
const hideAssistantTranscript = internal && source === 'main-loop-followup'
|
|
816
|
+
|
|
810
817
|
const useLocalOpenClawNativeRuntime = providerType === 'openclaw' && isLocalOpenClawEndpoint(sessionForRun.apiEndpoint)
|
|
811
818
|
const enabledSessionExtensions = getEnabledCapabilityIds(sessionForRun)
|
|
812
819
|
const hasExtensions = enabledSessionExtensions.length > 0
|
|
@@ -22,7 +22,7 @@ interface SwarmDockConfig {
|
|
|
22
22
|
function parseConfig(connector: Connector): SwarmDockConfig {
|
|
23
23
|
const c = connector.config || {}
|
|
24
24
|
return {
|
|
25
|
-
apiUrl: c.apiUrl || 'https://api.
|
|
25
|
+
apiUrl: c.apiUrl || 'https://swarmdock-api.onrender.com',
|
|
26
26
|
walletAddress: c.walletAddress || '',
|
|
27
27
|
agentDescription: c.agentDescription || connector.name || '',
|
|
28
28
|
skills: c.skills || '',
|
|
@@ -874,6 +874,17 @@ class ExtensionManager {
|
|
|
874
874
|
try {
|
|
875
875
|
const parsed = JSON.parse(fs.readFileSync(EXTENSION_FAILURES, 'utf8')) as Record<string, ExtensionFailureRecord>
|
|
876
876
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
877
|
+
// Prune records older than 7 days
|
|
878
|
+
const maxAgeMs = 7 * 24 * 60 * 60 * 1000
|
|
879
|
+
const now = Date.now()
|
|
880
|
+
let pruned = false
|
|
881
|
+
for (const key of Object.keys(parsed)) {
|
|
882
|
+
if (now - (parsed[key].lastFailedAt || 0) > maxAgeMs) {
|
|
883
|
+
delete parsed[key]
|
|
884
|
+
pruned = true
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (pruned) this.writeFailureState(parsed)
|
|
877
888
|
return parsed
|
|
878
889
|
} catch {
|
|
879
890
|
return {}
|
|
@@ -286,6 +286,37 @@ export function clearMessages(sessionId: string): void {
|
|
|
286
286
|
/** Replace the entire message list (used after in-memory prune operations). */
|
|
287
287
|
export function replaceAllMessages(sessionId: string, messages: Message[]): void {
|
|
288
288
|
perf.measureSync('message-repo', 'replaceAllMessages', () => {
|
|
289
|
+
// Safety guard: reload current user messages from DB and ensure none are
|
|
290
|
+
// dropped by the replacement. This prevents races where partial persistence
|
|
291
|
+
// or finalization load a stale snapshot that's missing recently-appended
|
|
292
|
+
// user messages.
|
|
293
|
+
const currentRows = stmts().selectAll.all(sessionId) as Array<{ data: string }>
|
|
294
|
+
const currentUserMessages: Message[] = []
|
|
295
|
+
for (const row of currentRows) {
|
|
296
|
+
const m = parseMsg(row.data)
|
|
297
|
+
if (m && m.role === 'user') currentUserMessages.push(m)
|
|
298
|
+
}
|
|
299
|
+
const replacementUserTimes = new Set(
|
|
300
|
+
messages.filter(m => m.role === 'user' && typeof m.time === 'number').map(m => m.time),
|
|
301
|
+
)
|
|
302
|
+
const missingUsers = currentUserMessages.filter(
|
|
303
|
+
m => typeof m.time === 'number' && !replacementUserTimes.has(m.time),
|
|
304
|
+
)
|
|
305
|
+
if (missingUsers.length > 0) {
|
|
306
|
+
// Re-insert missing user messages at their correct position (before the
|
|
307
|
+
// first assistant message that follows them chronologically).
|
|
308
|
+
for (const user of missingUsers) {
|
|
309
|
+
let insertIdx = messages.length
|
|
310
|
+
for (let i = 0; i < messages.length; i++) {
|
|
311
|
+
if (messages[i].role === 'assistant' && typeof messages[i].time === 'number'
|
|
312
|
+
&& (messages[i].time as number) >= (user.time as number)) {
|
|
313
|
+
insertIdx = i
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
messages.splice(insertIdx, 0, user)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
289
320
|
withTransaction(() => {
|
|
290
321
|
stmts().deleteAll.run(sessionId)
|
|
291
322
|
const ins = stmts().insert
|
|
@@ -381,7 +381,7 @@ export function syncExtensionsFromOpenClaw(): { imported: number } {
|
|
|
381
381
|
const openclawExtensionDir = path.join(config.workspacePath, 'plugins')
|
|
382
382
|
if (!fs.existsSync(openclawExtensionDir)) return { imported: 0 }
|
|
383
383
|
|
|
384
|
-
const localExtensionDir = path.join(DATA_DIR, '
|
|
384
|
+
const localExtensionDir = path.join(DATA_DIR, 'extensions')
|
|
385
385
|
ensureDir(localExtensionDir)
|
|
386
386
|
|
|
387
387
|
const files = fs.readdirSync(openclawExtensionDir).filter((f) => f.endsWith('.js'))
|
|
@@ -432,7 +432,7 @@ export function setSharedDeviceToken(token: string): void {
|
|
|
432
432
|
|
|
433
433
|
// --- Unified Sync Entry Point ---
|
|
434
434
|
|
|
435
|
-
export type SyncType = 'memory' | 'workspace' | 'schedules' | 'credentials' | '
|
|
435
|
+
export type SyncType = 'memory' | 'workspace' | 'schedules' | 'credentials' | 'extensions'
|
|
436
436
|
|
|
437
437
|
export interface SyncResult {
|
|
438
438
|
type: SyncType
|
|
@@ -467,7 +467,7 @@ export async function runSync(params: {
|
|
|
467
467
|
case 'credentials':
|
|
468
468
|
results.push({ type, action: 'push', result: pushCredentialsToOpenClaw() })
|
|
469
469
|
break
|
|
470
|
-
case '
|
|
470
|
+
case 'extensions':
|
|
471
471
|
// Extensions only pull from OpenClaw
|
|
472
472
|
break
|
|
473
473
|
}
|
|
@@ -492,7 +492,7 @@ export async function runSync(params: {
|
|
|
492
492
|
case 'credentials':
|
|
493
493
|
results.push({ type, action: 'pull', result: await pullCredentialsFromOpenClaw() })
|
|
494
494
|
break
|
|
495
|
-
case '
|
|
495
|
+
case 'extensions':
|
|
496
496
|
results.push({ type, action: 'pull', result: syncExtensionsFromOpenClaw() })
|
|
497
497
|
break
|
|
498
498
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { genId } from '@/lib/id'
|
|
2
|
+
import { log } from '@/lib/server/logger'
|
|
3
|
+
import { errorMessage } from '@/lib/shared-utils'
|
|
4
|
+
import { upsertTask } from '@/lib/server/tasks/task-repository'
|
|
5
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
6
|
+
import { callA2AAgent } from '@/lib/a2a/client'
|
|
7
|
+
import { loadExternalAgents } from '@/lib/server/storage'
|
|
8
|
+
import { appendProtocolEvent, persistRun } from '@/lib/server/protocols/protocol-agent-turn'
|
|
9
|
+
import { now } from '@/lib/server/protocols/protocol-types'
|
|
10
|
+
import type { ProtocolRunDeps } from '@/lib/server/protocols/protocol-types'
|
|
11
|
+
import type { ProtocolPhaseDefinition, ProtocolRun, ProtocolRunPhaseState } from '@/types/protocol'
|
|
12
|
+
import type { BoardTask } from '@/types/task'
|
|
13
|
+
|
|
14
|
+
const TAG = 'protocol-a2a-delegate'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Process an a2a_delegate phase: call a remote A2A agent and wait for the result.
|
|
18
|
+
*
|
|
19
|
+
* Follows the same pattern as processDispatchDelegationPhase:
|
|
20
|
+
* 1. Create a BoardTask for tracking (with protocolRunId so wakeProtocolRunFromTaskCompletion fires)
|
|
21
|
+
* 2. Call the remote agent via HTTP
|
|
22
|
+
* 3. Set protocol run to 'waiting'
|
|
23
|
+
* 4. When the HTTP call completes, update the task → wake machinery resumes the run
|
|
24
|
+
*/
|
|
25
|
+
export function processA2ADelegatePhase(
|
|
26
|
+
run: ProtocolRun,
|
|
27
|
+
phase: ProtocolPhaseDefinition,
|
|
28
|
+
deps?: ProtocolRunDeps,
|
|
29
|
+
): ProtocolRun {
|
|
30
|
+
const config = phase.a2aDelegateConfig
|
|
31
|
+
if (!config?.taskName || !config?.taskMessage) {
|
|
32
|
+
appendProtocolEvent(run.id, {
|
|
33
|
+
type: 'failed',
|
|
34
|
+
phaseId: phase.id,
|
|
35
|
+
summary: `a2a_delegate phase "${phase.label}" missing a2aDelegateConfig`,
|
|
36
|
+
}, deps)
|
|
37
|
+
return persistRun({
|
|
38
|
+
...run,
|
|
39
|
+
status: 'failed',
|
|
40
|
+
lastError: `a2a_delegate phase "${phase.label}" missing a2aDelegateConfig`,
|
|
41
|
+
endedAt: run.endedAt || now(deps),
|
|
42
|
+
updatedAt: now(deps),
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve target URL
|
|
47
|
+
let targetUrl = config.targetUrl
|
|
48
|
+
if (!targetUrl && config.targetExternalAgentId) {
|
|
49
|
+
const externalAgents = loadExternalAgents()
|
|
50
|
+
const ea = externalAgents[config.targetExternalAgentId]
|
|
51
|
+
if (ea?.endpoint) {
|
|
52
|
+
targetUrl = ea.endpoint
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!targetUrl) {
|
|
57
|
+
appendProtocolEvent(run.id, {
|
|
58
|
+
type: 'failed',
|
|
59
|
+
phaseId: phase.id,
|
|
60
|
+
summary: `a2a_delegate phase "${phase.label}" — no target URL resolved`,
|
|
61
|
+
}, deps)
|
|
62
|
+
return persistRun({
|
|
63
|
+
...run,
|
|
64
|
+
status: 'failed',
|
|
65
|
+
lastError: `a2a_delegate phase "${phase.label}" — could not resolve target A2A agent URL`,
|
|
66
|
+
endedAt: run.endedAt || now(deps),
|
|
67
|
+
updatedAt: now(deps),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Create a BoardTask for tracking
|
|
72
|
+
const taskId = genId()
|
|
73
|
+
const taskData: BoardTask = {
|
|
74
|
+
id: taskId,
|
|
75
|
+
title: `A2A: ${config.taskName}`,
|
|
76
|
+
description: config.taskMessage,
|
|
77
|
+
status: 'queued',
|
|
78
|
+
agentId: run.facilitatorAgentId || run.participantAgentIds?.[0] || '',
|
|
79
|
+
protocolRunId: run.id,
|
|
80
|
+
sourceType: 'delegation',
|
|
81
|
+
externalSource: { source: 'a2a', id: taskId },
|
|
82
|
+
queuedAt: now(deps),
|
|
83
|
+
createdAt: now(deps),
|
|
84
|
+
updatedAt: now(deps),
|
|
85
|
+
}
|
|
86
|
+
upsertTask(taskId, taskData)
|
|
87
|
+
notify('tasks')
|
|
88
|
+
|
|
89
|
+
appendProtocolEvent(run.id, {
|
|
90
|
+
type: 'delegation_dispatched',
|
|
91
|
+
summary: `Dispatched A2A delegation to ${targetUrl}: ${config.taskName}`,
|
|
92
|
+
phaseId: phase.id,
|
|
93
|
+
taskId,
|
|
94
|
+
}, deps)
|
|
95
|
+
|
|
96
|
+
log.info(TAG, `Calling remote A2A agent at ${targetUrl}`, { taskName: config.taskName, taskId })
|
|
97
|
+
|
|
98
|
+
// Fire the HTTP call asynchronously — when it completes, update the task
|
|
99
|
+
// The existing wakeProtocolRunFromTaskCompletion machinery will resume the run
|
|
100
|
+
const resolvedUrl = targetUrl
|
|
101
|
+
callA2AAgent(resolvedUrl, 'executeTask', {
|
|
102
|
+
taskId,
|
|
103
|
+
taskName: config.taskName,
|
|
104
|
+
message: config.taskMessage,
|
|
105
|
+
}, {
|
|
106
|
+
timeout: config.timeoutMs ?? 300_000,
|
|
107
|
+
credentialId: config.credentialId,
|
|
108
|
+
}).then(result => {
|
|
109
|
+
const resultStr = typeof result === 'string' ? result : JSON.stringify(result)
|
|
110
|
+
upsertTask(taskId, { ...taskData, status: 'completed', result: resultStr, updatedAt: Date.now(), completedAt: Date.now() })
|
|
111
|
+
notify('tasks')
|
|
112
|
+
log.info(TAG, `A2A delegation completed for task ${taskId}`)
|
|
113
|
+
// Dynamic import to break circular dependency (protocol-step-processors → protocol-a2a-delegate → protocol-run-lifecycle → protocol-step-processors)
|
|
114
|
+
import('@/lib/server/protocols/protocol-run-lifecycle').then(m => m.wakeProtocolRunFromTaskCompletion(taskId))
|
|
115
|
+
}).catch(err => {
|
|
116
|
+
log.error(TAG, `A2A delegation failed for task ${taskId}: ${errorMessage(err)}`)
|
|
117
|
+
if (config.onFailure === 'advance_with_warning') {
|
|
118
|
+
upsertTask(taskId, { ...taskData, status: 'completed', result: `A2A delegation failed: ${errorMessage(err)}`, error: errorMessage(err), updatedAt: Date.now(), completedAt: Date.now() })
|
|
119
|
+
} else {
|
|
120
|
+
upsertTask(taskId, { ...taskData, status: 'failed', error: errorMessage(err), updatedAt: Date.now() })
|
|
121
|
+
}
|
|
122
|
+
notify('tasks')
|
|
123
|
+
import('@/lib/server/protocols/protocol-run-lifecycle').then(m => m.wakeProtocolRunFromTaskCompletion(taskId))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const createdTaskIds = [...(run.createdTaskIds || []), taskId]
|
|
127
|
+
return persistRun({
|
|
128
|
+
...run,
|
|
129
|
+
status: 'waiting',
|
|
130
|
+
waitingReason: `Waiting for A2A delegation: ${config.taskName}`,
|
|
131
|
+
createdTaskIds,
|
|
132
|
+
phaseState: { ...(run.phaseState || { phaseId: phase.id }), dispatchedTaskId: taskId } as ProtocolRunPhaseState,
|
|
133
|
+
updatedAt: now(deps),
|
|
134
|
+
})
|
|
135
|
+
}
|
|
@@ -48,7 +48,7 @@ describe('protocol-step-helpers', () => {
|
|
|
48
48
|
const kinds = [
|
|
49
49
|
'present', 'collect_independent_inputs', 'round_robin',
|
|
50
50
|
'compare', 'decide', 'summarize', 'emit_tasks', 'wait',
|
|
51
|
-
'dispatch_task', 'dispatch_delegation',
|
|
51
|
+
'dispatch_task', 'dispatch_delegation', 'a2a_delegate',
|
|
52
52
|
]
|
|
53
53
|
for (const kind of kinds) {
|
|
54
54
|
const step = { id: `step-${kind}`, kind, label: kind } as never
|
|
@@ -58,6 +58,7 @@ export function phaseFromStep(step: ProtocolStepDefinition): ProtocolPhaseDefini
|
|
|
58
58
|
completionCriteria: step.completionCriteria || null,
|
|
59
59
|
taskConfig: step.taskConfig || null,
|
|
60
60
|
delegationConfig: step.delegationConfig || null,
|
|
61
|
+
a2aDelegateConfig: step.a2aDelegateConfig || null,
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -23,6 +23,7 @@ import type * as ProtocolRunLifecycle from '@/lib/server/protocols/protocol-run-
|
|
|
23
23
|
import { processForEachStep } from '@/lib/server/protocols/protocol-foreach'
|
|
24
24
|
import { processSubflowStep } from '@/lib/server/protocols/protocol-subflow'
|
|
25
25
|
import { processSwarmStep } from '@/lib/server/protocols/protocol-swarm'
|
|
26
|
+
import { processA2ADelegatePhase } from '@/lib/server/protocols/protocol-a2a-delegate'
|
|
26
27
|
import { findRunStep } from '@/lib/server/protocols/protocol-normalization'
|
|
27
28
|
import {
|
|
28
29
|
appendProtocolEvent,
|
|
@@ -708,6 +709,7 @@ export async function stepProtocolRun(run: ProtocolRun, deps?: ProtocolRunDeps):
|
|
|
708
709
|
if (phase.kind === 'emit_tasks') return processEmitTasksPhase(started, phase, deps)
|
|
709
710
|
if (phase.kind === 'dispatch_task') return processDispatchTaskPhase(started, phase, deps)
|
|
710
711
|
if (phase.kind === 'dispatch_delegation') return processDispatchDelegationPhase(started, phase, deps)
|
|
712
|
+
if (phase.kind === 'a2a_delegate') return processA2ADelegatePhase(started, phase, deps)
|
|
711
713
|
return processWaitPhase(started, phase, deps)
|
|
712
714
|
}
|
|
713
715
|
if (step.kind === 'branch') return processBranchStep(run, step, deps)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from 'child_process'
|
|
2
2
|
import { errorMessage, hmrSingleton, jitteredBackoff } from '@/lib/shared-utils'
|
|
3
3
|
import { upsertStoredItem, loadCollection } from './storage'
|
|
4
|
+
import { log } from './logger'
|
|
5
|
+
|
|
6
|
+
const TAG = 'provider-health'
|
|
4
7
|
|
|
5
8
|
type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
|
|
6
9
|
|
|
@@ -72,7 +75,12 @@ export function markProviderFailure(providerId: string, error: string, credentia
|
|
|
72
75
|
})
|
|
73
76
|
try {
|
|
74
77
|
upsertStoredItem('provider_health', key, states.get(key)!)
|
|
75
|
-
} catch {
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log.warn(TAG, 'Failed to persist provider failure state', {
|
|
80
|
+
providerKey: key,
|
|
81
|
+
error: errorMessage(err),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
export function markProviderSuccess(providerId: string, credentialId?: string | null): void {
|
|
@@ -88,7 +96,12 @@ export function markProviderSuccess(providerId: string, credentialId?: string |
|
|
|
88
96
|
})
|
|
89
97
|
try {
|
|
90
98
|
upsertStoredItem('provider_health', key, states.get(key)!)
|
|
91
|
-
} catch {
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log.warn(TAG, 'Failed to persist provider success state', {
|
|
101
|
+
providerKey: key,
|
|
102
|
+
error: errorMessage(err),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
export function isProviderCoolingDown(providerId: string, credentialId?: string | null): boolean {
|
|
@@ -195,7 +208,10 @@ export function restoreProviderHealthState(): number {
|
|
|
195
208
|
}
|
|
196
209
|
}
|
|
197
210
|
return restored
|
|
198
|
-
} catch {
|
|
211
|
+
} catch (err) {
|
|
212
|
+
log.warn(TAG, 'Failed to restore persisted provider health state', { error: errorMessage(err) })
|
|
213
|
+
return 0
|
|
214
|
+
}
|
|
199
215
|
}
|
|
200
216
|
|
|
201
217
|
// ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { describe, it, before, after } from 'node:test'
|
|
3
|
+
import { z } from 'zod'
|
|
3
4
|
|
|
4
5
|
let safeParseBody: typeof import('@/lib/server/safe-parse-body').safeParseBody
|
|
5
6
|
before(async () => {
|
|
@@ -50,4 +51,35 @@ describe('safeParseBody', () => {
|
|
|
50
51
|
assert.equal(result.data!.name, 'test')
|
|
51
52
|
assert.equal(result.data!.count, 7)
|
|
52
53
|
})
|
|
54
|
+
|
|
55
|
+
it('validates the parsed body against a provided zod schema', async () => {
|
|
56
|
+
const result = await safeParseBody(
|
|
57
|
+
jsonRequest(JSON.stringify({ name: 'ok', count: 3 })),
|
|
58
|
+
z.object({
|
|
59
|
+
name: z.string().min(1),
|
|
60
|
+
count: z.number().int().nonnegative(),
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
assert.equal(result.error, undefined)
|
|
64
|
+
assert.deepEqual(result.data, { name: 'ok', count: 3 })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns a 400 validation error when schema parsing fails', async () => {
|
|
68
|
+
const result = await safeParseBody(
|
|
69
|
+
jsonRequest(JSON.stringify({ name: '', count: -1 })),
|
|
70
|
+
z.object({
|
|
71
|
+
name: z.string().min(1, 'name is required'),
|
|
72
|
+
count: z.number().int().nonnegative('count must be non-negative'),
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
assert.equal(result.data, undefined)
|
|
76
|
+
assert.ok(result.error)
|
|
77
|
+
assert.equal(result.error.status, 400)
|
|
78
|
+
const body = await result.error.json()
|
|
79
|
+
assert.equal(body.error, 'Validation failed')
|
|
80
|
+
assert.deepEqual(body.issues, [
|
|
81
|
+
{ path: 'name', message: 'name is required' },
|
|
82
|
+
{ path: 'count', message: 'count must be non-negative' },
|
|
83
|
+
])
|
|
84
|
+
})
|
|
53
85
|
})
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import { formatZodError } from '@/lib/validation/schemas'
|
|
2
5
|
|
|
3
6
|
type SafeResult<T> = { data: T; error?: never } | { data?: never; error: NextResponse }
|
|
4
7
|
|
|
@@ -6,11 +9,25 @@ type SafeResult<T> = { data: T; error?: never } | { data?: never; error: NextRes
|
|
|
6
9
|
* Wraps `req.json()` so malformed/empty bodies return a 400
|
|
7
10
|
* instead of throwing an unhandled error (500).
|
|
8
11
|
*/
|
|
9
|
-
export async function safeParseBody<T = Record<string, unknown>>(
|
|
12
|
+
export async function safeParseBody<T = Record<string, unknown>>(
|
|
13
|
+
req: Request,
|
|
14
|
+
schema?: z.ZodType<T>,
|
|
15
|
+
): Promise<SafeResult<T>> {
|
|
16
|
+
let raw: unknown
|
|
10
17
|
try {
|
|
11
|
-
|
|
12
|
-
return { data }
|
|
18
|
+
raw = await req.json()
|
|
13
19
|
} catch {
|
|
14
20
|
return { error: NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 }) }
|
|
15
21
|
}
|
|
22
|
+
|
|
23
|
+
if (!schema) {
|
|
24
|
+
return { data: raw as T }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parsed = schema.safeParse(raw)
|
|
28
|
+
if (!parsed.success) {
|
|
29
|
+
return { error: NextResponse.json(formatZodError(parsed.error), { status: 400 }) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { data: parsed.data }
|
|
16
33
|
}
|