@swarmclawai/swarmclaw 1.5.61 → 1.5.63

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 CHANGED
@@ -399,6 +399,23 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.5.63 Highlights
403
+
404
+ Chatroom fix from @borislavnnikolov: CLI-backed agents (codex-cli, copilot-cli, gemini-cli, and the rest of the `NON_LANGGRAPH_PROVIDER_IDS` set) now work correctly as chatroom members instead of falling through a LangGraph path they cannot run. With the execution path fixed, the worker-only membership blocks are lifted too, so any non-trashed agent can be added to a room.
405
+
406
+ - **Direct provider runtime for CLI chatroom turns.** `src/app/api/chatrooms/[id]/chat/route.ts` now branches on `NON_LANGGRAPH_PROVIDER_IDS` and calls `provider.handler.streamChat()` directly for CLI-backed agents while keeping the LangGraph `streamAgentChat` path for everything else. Streaming, tool events, and persisted messages all flow through unchanged.
407
+ - **Full member selection.** The create, update, members, session-tool, and UI layers (`src/app/api/chatrooms/route.ts`, `src/app/api/chatrooms/[id]/route.ts`, `src/app/api/chatrooms/[id]/members/route.ts`, `src/lib/server/session-tools/chatroom.ts`, `src/components/chatrooms/chatroom-sheet.tsx`) no longer reject or hide worker-only agents. Any non-trashed agent is eligible.
408
+ - **Regression test.** `src/app/api/chatrooms/[id]/chat/route.test.ts` proves a `codex-cli`-backed chatroom turn bypasses `streamAgentChat`, streams a response through the provider handler, and persists one assistant reply.
409
+
410
+ ### v1.5.62 Highlights
411
+
412
+ Hardens parallel sub-agent dispatch with a concurrency cap, a quorum join policy, and a cycle check — so a fan-out can't accidentally saturate providers, melt a mission budget, or wedge the runtime on a delegation loop.
413
+
414
+ - **`spawn_subagent` swarm/batch actions now accept `maxConcurrency`, `joinPolicy`, `quorum`, and `cancelRemaining`.** Parallel mode fans out at most 4 branches at a time by default (hard-capped at 16). Task buckets share an `executionGroupKey` so the existing per-execution serial lock enforces the cap with zero new scheduler code. `joinPolicy: 'quorum'` resolves once `quorum` branches succeed and (by default) cancels the remaining in-flight branches. `joinPolicy: 'first'` resolves on the first success. `joinPolicy: 'all'` stays the default.
415
+ - **Cycle detection in `spawnSubagent`.** Before creating a child session, the runtime walks the `parentSessionId` ancestry and rejects the spawn when the requested `agentId` already appears higher in the chain. Clear error message with an `allowCycle: true` escape hatch. Orthogonal to the existing depth cap.
416
+ - **Per-agent and per-mission overrides.** `Agent.maxParallelDelegations` and `MissionBudget.maxParallelBranches` plumb into the swarm resolver. Precedence: explicit tool arg > agent cap > mission cap > system default (4). Both are validated by `AgentUpdateSchema` and the mission budget schemas, and normalized on load via `storage-normalization.ts`.
417
+ - **Swarm snapshot exposes the effective cap.** `SwarmSnapshot.maxConcurrency` lands in the persisted snapshot payload so the UI and external tooling can surface the active concurrency level. Verified live via a 3-branch quorum run: `totalCompleted: 2`, `totalCancelled: 1`, `maxConcurrency: 2`, `joinPolicy: "quorum"`.
418
+
402
419
  ### v1.5.61 Highlights
403
420
 
404
421
  Adds an opt-in per-agent planning mode that rides on the existing `[MAIN_LOOP_PLAN]` token machinery.
@@ -423,30 +440,6 @@ Viral-loop release. Adds public share links for missions, skills, and sessions,
423
440
  - **Share-link-based skill install.** `POST /api/skills/import` already accepts an http(s) URL; pointing it at `https://<your-host>/api/s/<token>/raw` now installs a shared skill from another SwarmClaw instance without auth handshakes. Pairs naturally with existing `swarmclaw skills import` CLI.
424
441
  - **Share-link repository tests.** `share-link-repository.test.ts` covers mint / list / revoke / lookup-by-token round-trip plus expiry handling against a temporary data dir.
425
442
 
426
- ### v1.5.58 Highlights
427
-
428
- This release broadens the built-in evaluation harness so SwarmClaw runs can be benchmarked against named suites, adds two targeted starter kits, exposes live per-session cost data, tightens auto-skill drafting, and ships a zero-setup demo mission template.
429
-
430
- - **Benchmark-style eval suites.** New `SWEBENCH_LITE_SCENARIOS` and `GAIA_L1_SCENARIOS` in `src/lib/server/eval/scenarios-swebench.ts` and `scenarios-gaia.ts` — curated parallels (not the upstream datasets) sized for a single-agent harness run. The shared `EvalScenario` type now carries an optional `suite: 'core' | 'swe-bench-lite' | 'gaia-l1' | 'tool-use' | 'code-action'` tag. `POST /api/eval/suite` accepts `{ suite: "swe-bench-lite" }` to scope a run. New `GET /api/eval/suites` lists every suite with scenario count, max score, and categories. CLI commands: `swarmclaw eval suites`, and `swarmclaw eval suite` still takes a JSON body now including `suite`. Useful for advertising verifiable numbers against a named benchmark instead of a bespoke scoring rubric.
431
- - **Two additional starter kits.** `inbox_triage` (single Triager agent over email + memory + documents) and `data_analyst` (single Analyst agent over shell + files + web + documents) join the existing seven kits in `src/lib/setup-defaults.ts`. Both are surfaced on the intent-driven setup path alongside Personal Assistant, Research Copilot, Builder Studio, and Delegate Team.
432
- - **Live per-session usage API.** New `GET /api/usage/live?sessionId=...` returns a lightweight snapshot — records, tokens in/out, estimated cost, firstAt/lastAt, wallclockMs, turns — so frontends can surface a live cost meter without pulling the full aggregated `/api/usage` payload. Without a `sessionId` the route returns the ten most recently active sessions. Registered in the CLI as `swarmclaw usage live`.
433
- - **Auto-skill drafting is stricter and rate-limited.** `shouldAutoDraftSkillSuggestion` in `chat-turn-finalization.ts` now requires at least 3 tool events in the completed turn (was 1), and a new per-agent daily cap limits automatic drafts to 3 per day per agent to prevent suggestion-inbox spam. Both thresholds are named constants (`AUTO_DRAFT_MIN_TOOL_EVENTS`, `AUTO_DRAFT_DAILY_LIMIT`). Agents with `autoDraftSkillSuggestions = false` are unaffected (auto-drafting remains opt-in per agent).
434
- - **Hello World demo mission template.** New `hello-world-demo` entry in `BUILT_IN_MISSION_TEMPLATES` — a bounded, zero-setup mission that reads three files in the working directory and writes a one-paragraph markdown summary to `hello-world-report.md`. Budgets (USD 0.25, 20k tokens, 30 turns, 15 min) are small enough to run on a local Ollama model without cost. Intended as the first thing a new user watches an agent complete end to end.
435
-
436
- ### v1.5.57 Highlights
437
-
438
- This release closes the org-orchestration feature gap with Paperclip while keeping SwarmClaw's autonomous-assistant focus. Most additions are additive; nothing existing has changed shape.
439
-
440
- - **Workspace templates: full export/import bundle.** `src/lib/server/portability/{export,import}.ts` now round-trips agents, skills, schedules, **connectors** (with secret scrubbing), **chatrooms**, **MCP servers**, **projects**, **goals**, and an extension manifest reference. Manifest version bumped to `2`; v1 bundles still import. Connectors and MCP servers re-import with credentials stripped — the response payload now lists which records `needCredentials` so the UI can prompt. ID remapping handles cross-references (chatrooms → agents, schedules → agents, goals → projects/agents).
441
- - **Per-agent budget enforcement at enqueue.** New `src/lib/server/agents/agent-budget-hook.ts` mirrors the existing mission budget hook. When an agent has `budgetAction: 'block'` and any window (`hourlyBudget`/`dailyBudget`/`monthlyBudget`) is exhausted, autonomous enqueues now fail fast in `session-run-manager` instead of getting blocked deeper in the chat-turn pipeline. User-initiated chats still flow through (so users can talk to an agent that's hit its cap). Default `'warn'` behavior is unchanged.
442
- - **Goal hierarchy ancestry through Mission.** `Mission.goalId` is a new optional field. When a session has a `missionId` and the bound mission has a `goalId`, `main-agent-loop.ts` now walks `mission → goal → parentGoal → …` so the full Initiative/Project/Goal ancestry flows into the agent system prompt — previously only direct session-level goals were resolved.
443
- - **Billing codes / cost attribution.** `Mission`, `BoardTask`, `Session`, and `UsageRecord` accept an optional `billingCodes: string[]`. `resolveBillingCodesForSession` combines session + mission codes when usage is appended, and the new `GET /api/usage/by-code?codes=foo,bar&range=7d` endpoint rolls up cost per code (and per agent within each code). Lets users running multiple parallel projects answer "what did Project X cost?" across agents, missions, and ad-hoc chats.
444
- - **Customizable task workflow states.** New `WorkflowState` collection (`src/lib/server/tasks/workflow-state-repository.ts`) stores team-defined states like "Needs Review" or "Blocked on PM" that are orthogonal to `BoardTaskStatus` lifecycle. `BoardTask.workflowStateId` references one of seven defaults (Triage / Backlog / Todo / In Progress / Needs Review / Done / Cancelled) or any custom state. CRUD via `GET|POST|DELETE /api/task-workflow-states`. Atomic checkout via `task-checkout.ts` was already in place.
445
- - **Cross-agent delegation refusal policy.** `Chatroom.onRefusal` (`'reroute' | 'escalate' | 'human'`) and `Chatroom.escalationTargetAgentId` formalize what happens when a delegated agent declines work. `chatroom-refusal.ts` reroutes to another room member, escalates to a configured target, or surfaces a `human_loop` approval. Policy management at `POST /api/chatrooms/refusal-policy`; simulation at `PUT` for tests.
446
- - **Configuration version history.** Every `updateAgent` call now snapshots the prior agent state into `config-versions.json` (capped at 50 versions per entity). `GET /api/config-versions?entityKind=agent&entityId=...` lists history; `POST /api/config-versions/restore` rolls back. Foundation for extending to extensions, connectors, MCP servers, chatrooms, and projects.
447
- - **Multi-workspace scaffolding.** New `Workspace` registry with `GET|POST|PATCH|DELETE /api/workspaces` and `GET|POST /api/workspaces/active`. The default workspace seeds itself on first read; switching the active workspace persists to `workspace-registry.json`. **Note:** this is metadata only in v1.5.57 — actual data-dir forking per workspace is intentionally deferred (low-risk shipping).
448
- - **CLI manifest expanded.** New top-level groups: `workspaces`, `workflow-states`, `config-versions`, `cost-attribution`, `chatroom-policy`. Run `swarmclaw workspaces list`, `swarmclaw cost-attribution by-code --query codes=client-a,range=30d`, `swarmclaw config-versions list --query entityKind=agent,entityId=...`, etc. CLI route-coverage test passes.
449
-
450
443
  Older releases: https://swarmclaw.ai/docs/release-notes
451
444
 
452
445
  - GitHub releases: https://github.com/swarmclawai/swarmclaw/releases
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.61",
3
+ "version": "1.5.63",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -297,3 +297,114 @@ test('chatroom route forwards tool activity and records one reply per participat
297
297
  assert.deepEqual(output.assistantCounts, { alpha: 1, beta: 1 })
298
298
  assert.deepEqual([...new Set(output.agentOrder)].sort(), ['alpha', 'beta'])
299
299
  })
300
+
301
+ test('chatroom route uses direct provider runtime for CLI providers', () => {
302
+ const output = runWithTempDataDir<{
303
+ errors: string[]
304
+ streamedText: string
305
+ assistantTexts: string[]
306
+ }>(`
307
+ const storageMod = await import('./src/lib/server/storage')
308
+ const providersMod = await import('@/lib/providers')
309
+ const routeMod = await import('./src/app/api/chatrooms/[id]/chat/route')
310
+ const streamMod = await import('@/lib/server/chat-execution/stream-agent-chat')
311
+ const storage = storageMod.default || storageMod
312
+ const providers = providersMod.default || providersMod
313
+ const route = routeMod.default || routeMod
314
+ const stream = streamMod.default || streamMod
315
+
316
+ const originalHandler = providers.PROVIDERS['codex-cli'].handler
317
+
318
+ const now = Date.now()
319
+ storage.saveAgents({
320
+ alpha: {
321
+ id: 'alpha',
322
+ name: 'Alpha',
323
+ provider: 'codex-cli',
324
+ model: 'gpt-5.3-codex',
325
+ extensions: [],
326
+ createdAt: now,
327
+ updatedAt: now,
328
+ },
329
+ })
330
+ storage.saveChatrooms({
331
+ room_1: {
332
+ id: 'room_1',
333
+ name: 'CLI Room',
334
+ agentIds: ['alpha'],
335
+ messages: [],
336
+ createdAt: now,
337
+ updatedAt: now,
338
+ chatMode: 'sequential',
339
+ autoAddress: true,
340
+ },
341
+ })
342
+
343
+ async function readSse(response) {
344
+ const reader = response.body.getReader()
345
+ const decoder = new TextDecoder()
346
+ let buffer = ''
347
+ const events = []
348
+ while (true) {
349
+ const { done, value } = await reader.read()
350
+ if (done) break
351
+ buffer += decoder.decode(value, { stream: true })
352
+ let idx = buffer.indexOf('\\n\\n')
353
+ while (idx !== -1) {
354
+ const chunk = buffer.slice(0, idx)
355
+ buffer = buffer.slice(idx + 2)
356
+ const line = chunk
357
+ .split('\\n')
358
+ .map((entry) => entry.trim())
359
+ .find((entry) => entry.startsWith('data: '))
360
+ if (line) {
361
+ events.push(JSON.parse(line.slice(6)))
362
+ }
363
+ idx = buffer.indexOf('\\n\\n')
364
+ }
365
+ }
366
+ return events
367
+ }
368
+
369
+ stream.setStreamAgentChatForTest(async () => {
370
+ throw new Error('streamAgentChat should not be called for codex-cli chatroom turns')
371
+ })
372
+ providers.PROVIDERS['codex-cli'].handler = {
373
+ streamChat: async (opts) => {
374
+ const reply = 'Codex CLI answered from direct provider runtime.'
375
+ opts.write('data: ' + JSON.stringify({ t: 'd', text: reply }) + '\\n')
376
+ return reply
377
+ },
378
+ }
379
+
380
+ try {
381
+ const response = await route.POST(
382
+ new Request('http://local/api/chatrooms/room_1/chat', {
383
+ method: 'POST',
384
+ headers: { 'content-type': 'application/json' },
385
+ body: JSON.stringify({ senderId: 'user', text: 'Say hello to the room.' }),
386
+ }),
387
+ { params: Promise.resolve({ id: 'room_1' }) },
388
+ )
389
+
390
+ const events = await readSse(response)
391
+ const chatroom = storage.loadChatrooms().room_1
392
+ const assistantTexts = chatroom.messages
393
+ .filter((entry) => entry.role === 'assistant')
394
+ .map((entry) => entry.text)
395
+
396
+ console.log(JSON.stringify({
397
+ errors: events.filter((entry) => entry.t === 'err').map((entry) => entry.text),
398
+ streamedText: events.filter((entry) => entry.t === 'd').map((entry) => entry.text).join(''),
399
+ assistantTexts,
400
+ }))
401
+ } finally {
402
+ providers.PROVIDERS['codex-cli'].handler = originalHandler
403
+ stream.setStreamAgentChatForTest(null)
404
+ }
405
+ `, { prefix: 'swarmclaw-chatroom-route-cli-provider-' })
406
+
407
+ assert.equal(output.errors.some((text) => /streamAgentChat should not be called/i.test(text)), false)
408
+ assert.equal(output.streamedText.includes('Codex CLI answered from direct provider runtime.'), true)
409
+ assert.equal(output.assistantTexts.some((text) => text.includes('Codex CLI answered from direct provider runtime.')), true)
410
+ })
@@ -6,6 +6,7 @@ import { notFound } from '@/lib/server/collection-helpers'
6
6
  import { safeParseBody } from '@/lib/server/safe-parse-body'
7
7
  import { streamAgentChat } from '@/lib/server/chat-execution/stream-agent-chat'
8
8
  import { getProvider } from '@/lib/providers'
9
+ import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
9
10
  import {
10
11
  resolveApiKey,
11
12
  parseMentions,
@@ -231,37 +232,55 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
231
232
 
232
233
  let fullText = ''
233
234
  let agentError = ''
234
- const result = await streamAgentChat({
235
- session: syntheticSession,
236
- message: messageForAgent,
237
- imagePath,
238
- attachedFiles,
239
- apiKey,
240
- systemPrompt: fullSystemPrompt,
241
- write: (raw: string) => {
242
- const lines = raw.split('\n').filter(Boolean)
243
- for (const line of lines) {
244
- if (!line.startsWith('data: ')) continue
245
- try {
246
- const parsed = JSON.parse(line.slice(6).trim())
247
- if (parsed.t === 'd' && parsed.text) {
248
- fullText += parsed.text
249
- writeEvent({ t: 'd', text: parsed.text, agentId: agent.id, agentName: agent.name })
250
- } else if (parsed.t === 'tool_call' || parsed.t === 'tool_result') {
251
- writeEvent({ ...parsed, agentId: agent.id, agentName: agent.name })
252
- } else if (parsed.t === 'err' && parsed.text) {
253
- agentError = parsed.text
254
- writeEvent({ t: 'err', text: parsed.text, agentId: agent.id, agentName: agent.name })
255
- }
256
- } catch {
257
- // skip malformed lines
235
+ const forwardProviderEvents = (raw: string) => {
236
+ const lines = raw.split('\n').filter(Boolean)
237
+ for (const line of lines) {
238
+ if (!line.startsWith('data: ')) continue
239
+ try {
240
+ const parsed = JSON.parse(line.slice(6).trim())
241
+ if (parsed.t === 'd' && parsed.text) {
242
+ fullText += parsed.text
243
+ writeEvent({ t: 'd', text: parsed.text, agentId: agent.id, agentName: agent.name })
244
+ } else if (parsed.t === 'tool_call' || parsed.t === 'tool_result') {
245
+ writeEvent({ ...parsed, agentId: agent.id, agentName: agent.name })
246
+ } else if (parsed.t === 'err' && parsed.text) {
247
+ agentError = parsed.text
248
+ writeEvent({ t: 'err', text: parsed.text, agentId: agent.id, agentName: agent.name })
258
249
  }
250
+ } catch {
251
+ // skip malformed lines
259
252
  }
260
- },
261
- history,
262
- })
253
+ }
254
+ }
263
255
 
264
- const rawResponseText = result.finalResponse || result.fullText || fullText
256
+ let rawResponseText = ''
257
+ if (NON_LANGGRAPH_PROVIDER_IDS.has(syntheticSession.provider)) {
258
+ const provider = getProvider(syntheticSession.provider)
259
+ if (!provider) throw new Error(`Unknown provider: ${syntheticSession.provider}`)
260
+ rawResponseText = await provider.handler.streamChat({
261
+ session: syntheticSession,
262
+ message: messageForAgent,
263
+ imagePath,
264
+ apiKey,
265
+ systemPrompt: fullSystemPrompt,
266
+ write: forwardProviderEvents,
267
+ active: new Map<string, unknown>(),
268
+ loadHistory: () => history,
269
+ })
270
+ if (!rawResponseText) rawResponseText = fullText
271
+ } else {
272
+ const result = await streamAgentChat({
273
+ session: syntheticSession,
274
+ message: messageForAgent,
275
+ imagePath,
276
+ attachedFiles,
277
+ apiKey,
278
+ systemPrompt: fullSystemPrompt,
279
+ write: forwardProviderEvents,
280
+ history,
281
+ })
282
+ rawResponseText = result.finalResponse || result.fullText || fullText
283
+ }
265
284
  const responseText = stripAgentReactionTokens(stripHiddenControlTokens(rawResponseText))
266
285
 
267
286
  // Don't persist empty or error-only messages — they pollute chat history
@@ -4,7 +4,6 @@ import { notify } from '@/lib/server/ws-hub'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
  import { safeParseBody } from '@/lib/server/safe-parse-body'
6
6
  import { genId } from '@/lib/id'
7
- import { isWorkerOnlyAgent, buildWorkerOnlyAgentMessage } from '@/lib/server/agents/agent-availability'
8
7
 
9
8
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
10
9
  const { id } = await params
@@ -18,13 +17,6 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
18
17
  if (!agentId) return NextResponse.json({ error: 'agentId is required' }, { status: 400 })
19
18
 
20
19
  const agents = loadAgents()
21
- if (isWorkerOnlyAgent(agents[agentId])) {
22
- return NextResponse.json(
23
- { error: buildWorkerOnlyAgentMessage(agents[agentId], 'join chatrooms') },
24
- { status: 400 },
25
- )
26
- }
27
-
28
20
  if (!chatroom.agentIds.includes(agentId)) {
29
21
  chatroom.agentIds.push(agentId)
30
22
 
@@ -4,7 +4,6 @@ import { notify } from '@/lib/server/ws-hub'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
  import { safeParseBody } from '@/lib/server/safe-parse-body'
6
6
  import { genId } from '@/lib/id'
7
- import { isWorkerOnlyAgent } from '@/lib/server/agents/agent-availability'
8
7
  import {
9
8
  ensureChatroomRoutingGuidance,
10
9
  synthesizeRoutingGuidanceFromRules,
@@ -78,16 +77,6 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
78
77
  { status: 400 },
79
78
  )
80
79
  }
81
- const cliAgentNames = agentIds
82
- .filter((agentId) => isWorkerOnlyAgent(agents[agentId]))
83
- .map((agentId) => agents[agentId]?.name || agentId)
84
- if (cliAgentNames.length > 0) {
85
- return NextResponse.json(
86
- { error: `CLI-based agents cannot join chatrooms: ${cliAgentNames.join(', ')}. They can only be used for direct chats and delegation.` },
87
- { status: 400 },
88
- )
89
- }
90
-
91
80
  const oldIds = new Set(chatroom.agentIds)
92
81
  const newIds = new Set(agentIds)
93
82
  const added = agentIds.filter((aid: string) => !oldIds.has(aid))
@@ -6,7 +6,6 @@ import { ChatroomCreateSchema, formatZodError } from '@/lib/validation/schemas'
6
6
  import { safeParseBody } from '@/lib/server/safe-parse-body'
7
7
  import { z } from 'zod'
8
8
  import type { Chatroom, ChatroomMessage } from '@/types'
9
- import { isWorkerOnlyAgent } from '@/lib/server/agents/agent-availability'
10
9
  import {
11
10
  ensureChatroomRoutingGuidance,
12
11
  synthesizeRoutingGuidanceFromRules,
@@ -61,15 +60,6 @@ export async function POST(req: Request) {
61
60
  { status: 400 },
62
61
  )
63
62
  }
64
- const cliAgentNames = requestedAgentIds
65
- .filter((agentId) => isWorkerOnlyAgent(knownAgents[agentId]))
66
- .map((agentId) => knownAgents[agentId]?.name || agentId)
67
- if (cliAgentNames.length > 0) {
68
- return NextResponse.json(
69
- { error: `CLI-based agents cannot join chatrooms: ${cliAgentNames.join(', ')}. They can only be used for direct chats and delegation.` },
70
- { status: 400 },
71
- )
72
- }
73
63
  const agentIds: string[] = requestedAgentIds
74
64
  const chatMode = body.chatMode === 'parallel' ? 'parallel' : 'sequential'
75
65
  const autoAddress = Boolean(body.autoAddress)
@@ -23,6 +23,7 @@ const MissionUpdateSchema = z.object({
23
23
  maxToolCalls: z.number().positive().int().nullable().optional(),
24
24
  maxWallclockSec: z.number().positive().int().nullable().optional(),
25
25
  maxTurns: z.number().positive().int().nullable().optional(),
26
+ maxParallelBranches: z.number().positive().int().nullable().optional(),
26
27
  warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
27
28
  }).partial().optional(),
28
29
  reportSchedule: z.object({
@@ -14,6 +14,7 @@ const MissionBudgetSchema = z.object({
14
14
  maxToolCalls: z.number().positive().int().nullable().optional(),
15
15
  maxWallclockSec: z.number().positive().int().nullable().optional(),
16
16
  maxTurns: z.number().positive().int().nullable().optional(),
17
+ maxParallelBranches: z.number().positive().int().nullable().optional(),
17
18
  warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
18
19
  }).strict()
19
20
 
@@ -9,7 +9,6 @@ import { toast } from 'sonner'
9
9
  import { AgentAvatar } from '@/components/agents/agent-avatar'
10
10
  import type { Agent } from '@/types'
11
11
  import { CheckIcon } from '@/components/shared/check-icon'
12
- import { WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
13
12
 
14
13
  export function ChatroomSheet() {
15
14
  const open = useChatroomStore((s) => s.chatroomSheetOpen)
@@ -105,9 +104,7 @@ export function ChatroomSheet() {
105
104
  )
106
105
  }
107
106
 
108
- const agentList = Object.values(agents).filter(
109
- (a: Agent) => !a.trashedAt && !WORKER_ONLY_PROVIDER_IDS.has(a.provider)
110
- ) as Agent[]
107
+ const agentList = Object.values(agents).filter((a: Agent) => !a.trashedAt) as Agent[]
111
108
 
112
109
  return (
113
110
  <BottomSheet open={open} onClose={() => setChatroomSheetOpen(false)}>
@@ -73,6 +73,8 @@ export interface SpawnSubagentInput {
73
73
  timeoutSec?: number
74
74
  /** Optional shared execution lane key for serializing sibling runs. */
75
75
  executionGroupKey?: string
76
+ /** When true, skip the ancestor-agent cycle check (A → B → A). Default false. */
77
+ allowCycle?: boolean
76
78
  }
77
79
 
78
80
  export interface SubagentHandle {
@@ -183,6 +185,31 @@ export function getSessionDepth(
183
185
  return depth
184
186
  }
185
187
 
188
+ /**
189
+ * Collect agentIds of every session in the parent chain including the given
190
+ * session. Used to detect delegation cycles (A → B → A) before spawning.
191
+ */
192
+ export function collectAncestorAgentIds(
193
+ sessionId: string | undefined,
194
+ sessions: Record<string, unknown>,
195
+ limit = 32,
196
+ ): string[] {
197
+ if (!sessionId) return []
198
+ const ids: string[] = []
199
+ let current: string | undefined = sessionId
200
+ const visited = new Set<string>()
201
+ while (current && ids.length < limit && !visited.has(current)) {
202
+ visited.add(current)
203
+ const s = sessions[current] as Record<string, unknown> | undefined
204
+ const agentId = typeof s?.agentId === 'string' ? s.agentId.trim() : ''
205
+ if (agentId) ids.push(agentId)
206
+ const parentId = typeof s?.parentSessionId === 'string' ? s.parentSessionId : null
207
+ if (!parentId) break
208
+ current = parentId
209
+ }
210
+ return ids
211
+ }
212
+
186
213
  // ---------------------------------------------------------------------------
187
214
  // Core: Spawn a Native Subagent
188
215
  // ---------------------------------------------------------------------------
@@ -215,6 +242,16 @@ async function spawnSubagentImpl(
215
242
  log.warn('subagent', 'Spawn rejected: max depth exceeded', { agentId: input.agentId, depth, maxDepth })
216
243
  throw new Error(`Max subagent depth (${maxDepth}) reached.`)
217
244
  }
245
+ if (input.allowCycle !== true && context.sessionId) {
246
+ const ancestorAgentIds = collectAncestorAgentIds(context.sessionId, sessions)
247
+ if (ancestorAgentIds.includes(input.agentId)) {
248
+ log.warn('subagent', 'Spawn rejected: delegation cycle', { agentId: input.agentId, chain: ancestorAgentIds })
249
+ throw new Error(
250
+ `Delegation cycle: agent "${input.agentId}" is already active higher in this chain. `
251
+ + 'Pick a different sibling agent, or pass allowCycle=true to override.',
252
+ )
253
+ }
254
+ }
218
255
  const parent = context.sessionId ? sessions[context.sessionId] : null
219
256
  const parentExtensions = getEnabledCapabilityIds(parent as { tools?: string[] | null, extensions?: string[] | null } | null)
220
257
  const spawningResult = await runCapabilitySubagentSpawning(
@@ -2,8 +2,11 @@ import assert from 'node:assert/strict'
2
2
  import { afterEach, describe, it } from 'node:test'
3
3
 
4
4
  import {
5
+ _clampSwarmConcurrency,
5
6
  _clearSwarmRegistry,
6
7
  _resolveSwarmExecutionMode,
8
+ SWARM_DEFAULT_PARALLEL_CONCURRENCY,
9
+ SWARM_MAX_CONCURRENCY_HARD_LIMIT,
7
10
  getSwarm,
8
11
  getSwarmSnapshot,
9
12
  listSwarms,
@@ -13,6 +16,7 @@ import {
13
16
  type SwarmMember,
14
17
  type SwarmSnapshot,
15
18
  } from '@/lib/server/agents/subagent-swarm'
19
+ import { collectAncestorAgentIds } from '@/lib/server/agents/subagent-runtime'
16
20
 
17
21
  /**
18
22
  * Unit tests for the swarm layer. Since spawnSubagent depends on storage,
@@ -22,6 +26,17 @@ import {
22
26
  */
23
27
 
24
28
  function fakeSwarmHandle(overrides?: Partial<SwarmHandle>): SwarmHandle {
29
+ const allSettled = Promise.resolve({
30
+ swarmId: 'swarm-test-1',
31
+ parentSessionId: 'parent-sess-1',
32
+ totalSpawned: 0,
33
+ totalCompleted: 0,
34
+ totalFailed: 0,
35
+ totalCancelled: 0,
36
+ totalSpawnErrors: 0,
37
+ durationMs: 0,
38
+ results: [],
39
+ })
25
40
  const base: SwarmHandle = {
26
41
  swarmId: 'swarm-test-1',
27
42
  parentSessionId: 'parent-sess-1',
@@ -29,18 +44,10 @@ function fakeSwarmHandle(overrides?: Partial<SwarmHandle>): SwarmHandle {
29
44
  status: 'running',
30
45
  createdAt: Date.now() - 5000,
31
46
  completedAt: null,
32
- allSettled: Promise.resolve({
33
- swarmId: 'swarm-test-1',
34
- parentSessionId: 'parent-sess-1',
35
- totalSpawned: 0,
36
- totalCompleted: 0,
37
- totalFailed: 0,
38
- totalCancelled: 0,
39
- totalSpawnErrors: 0,
40
- durationMs: 0,
41
- results: [],
42
- }),
47
+ maxConcurrency: 4,
48
+ allSettled,
43
49
  firstSettled: Promise.resolve({ index: -1, result: null as any }),
50
+ quorumSettled: async () => allSettled,
44
51
  cancelAll: () => {},
45
52
  ...overrides,
46
53
  }
@@ -357,6 +364,92 @@ describe('subagent-swarm', () => {
357
364
  // Reliability fix: firstSettled with zero memberPromises (#15)
358
365
  // ---------------------------------------------------------------------------
359
366
 
367
+ // ---------------------------------------------------------------------------
368
+ // Concurrency cap (v1.5.62+)
369
+ // ---------------------------------------------------------------------------
370
+
371
+ describe('clampSwarmConcurrency', () => {
372
+ it('returns 1 when task count is 0 or 1', () => {
373
+ assert.equal(_clampSwarmConcurrency(8, 0), 1)
374
+ assert.equal(_clampSwarmConcurrency(8, 1), 1)
375
+ })
376
+
377
+ it('uses the default when no explicit cap is given', () => {
378
+ assert.equal(_clampSwarmConcurrency(undefined, 10), SWARM_DEFAULT_PARALLEL_CONCURRENCY)
379
+ })
380
+
381
+ it('honors an explicit finite positive cap', () => {
382
+ assert.equal(_clampSwarmConcurrency(2, 10), 2)
383
+ assert.equal(_clampSwarmConcurrency(7, 10), 7)
384
+ })
385
+
386
+ it('enforces the hard limit', () => {
387
+ assert.equal(_clampSwarmConcurrency(100, 50), SWARM_MAX_CONCURRENCY_HARD_LIMIT)
388
+ })
389
+
390
+ it('rounds and floors the cap to an integer >= 1', () => {
391
+ assert.equal(_clampSwarmConcurrency(3.9, 10), 3)
392
+ assert.equal(_clampSwarmConcurrency(0, 10), SWARM_DEFAULT_PARALLEL_CONCURRENCY)
393
+ assert.equal(_clampSwarmConcurrency(-4, 10), SWARM_DEFAULT_PARALLEL_CONCURRENCY)
394
+ assert.equal(_clampSwarmConcurrency(Number.NaN, 10), SWARM_DEFAULT_PARALLEL_CONCURRENCY)
395
+ })
396
+ })
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Cycle detection via ancestor agentIds (v1.5.62+)
400
+ // ---------------------------------------------------------------------------
401
+
402
+ describe('collectAncestorAgentIds', () => {
403
+ it('returns empty when the session is unknown', () => {
404
+ assert.deepEqual(collectAncestorAgentIds('missing', {}), [])
405
+ assert.deepEqual(collectAncestorAgentIds(undefined, {}), [])
406
+ })
407
+
408
+ it('walks parent chain and collects agentIds', () => {
409
+ const sessions: Record<string, unknown> = {
410
+ root: { id: 'root', agentId: 'agent-root', parentSessionId: null },
411
+ mid: { id: 'mid', agentId: 'agent-mid', parentSessionId: 'root' },
412
+ leaf: { id: 'leaf', agentId: 'agent-leaf', parentSessionId: 'mid' },
413
+ }
414
+ assert.deepEqual(
415
+ collectAncestorAgentIds('leaf', sessions),
416
+ ['agent-leaf', 'agent-mid', 'agent-root'],
417
+ )
418
+ })
419
+
420
+ it('terminates on a self-loop without infinite recursion', () => {
421
+ const sessions: Record<string, unknown> = {
422
+ a: { id: 'a', agentId: 'agent-a', parentSessionId: 'a' },
423
+ }
424
+ assert.deepEqual(collectAncestorAgentIds('a', sessions), ['agent-a'])
425
+ })
426
+
427
+ it('detects a cycle candidate (A → B → A is visible in the chain)', () => {
428
+ const sessions: Record<string, unknown> = {
429
+ root: { id: 'root', agentId: 'agent-a', parentSessionId: null },
430
+ child: { id: 'child', agentId: 'agent-b', parentSessionId: 'root' },
431
+ }
432
+ const chain = collectAncestorAgentIds('child', sessions)
433
+ // Attempting to spawn agent-a again would be caught by the cycle check:
434
+ assert.ok(chain.includes('agent-a'))
435
+ })
436
+ })
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // quorumSettled (v1.5.62+)
440
+ // ---------------------------------------------------------------------------
441
+
442
+ describe('quorumSettled shape', () => {
443
+ it('fakeSwarmHandle exposes the new quorumSettled and maxConcurrency fields', async () => {
444
+ const swarm = fakeSwarmHandle({ maxConcurrency: 3 })
445
+ assert.equal(swarm.maxConcurrency, 3)
446
+ assert.equal(typeof swarm.quorumSettled, 'function')
447
+ const agg = await swarm.quorumSettled(1)
448
+ assert.ok(agg)
449
+ assert.equal(agg.totalSpawned, 0)
450
+ })
451
+ })
452
+
360
453
  describe('firstSettled — all spawn errors (zero promises)', () => {
361
454
  it('firstSettled resolves with a valid SubagentResult, not null', async () => {
362
455
  // Build a swarm where ALL members fail to spawn
@@ -63,10 +63,19 @@ export interface SwarmHandle {
63
63
  createdAt: number
64
64
  /** When all members finished (null if still running) */
65
65
  completedAt: number | null
66
+ /** Effective concurrency cap used to dispatch this swarm. 0 = unbounded (non-serial). */
67
+ maxConcurrency: number
66
68
  /** Promise that resolves when ALL members complete */
67
69
  allSettled: Promise<SwarmAggregateResult>
68
70
  /** Promise that resolves when the FIRST member completes */
69
71
  firstSettled: Promise<{ index: number; result: SubagentResult }>
72
+ /**
73
+ * Resolve when `count` members succeed (status === 'completed').
74
+ * If the swarm cannot reach the quorum (enough members already failed),
75
+ * resolves with the current aggregate. When `cancelRemaining` is true,
76
+ * best-effort aborts in-flight members after the quorum is reached.
77
+ */
78
+ quorumSettled: (count: number, opts?: { cancelRemaining?: boolean }) => Promise<SwarmAggregateResult>
70
79
  /** Cancel all running members */
71
80
  cancelAll: () => void
72
81
  }
@@ -109,8 +118,29 @@ export interface BatchSpawnInput {
109
118
  onSwarmComplete?: (result: SwarmAggregateResult) => void
110
119
  /** Execution mode for sibling subagents. Auto defaults to serial for Ollama-backed targets. */
111
120
  executionMode?: 'auto' | 'parallel' | 'serial'
121
+ /**
122
+ * Maximum number of members allowed to run concurrently when `executionMode`
123
+ * resolves to `'parallel'`. A value of 0 or `undefined` preserves the legacy
124
+ * unbounded behavior. Ignored in serial mode (always 1).
125
+ */
126
+ maxConcurrency?: number
112
127
  }
113
128
 
129
+ /** Hard cap on `maxConcurrency` regardless of caller-supplied value. */
130
+ export const SWARM_MAX_CONCURRENCY_HARD_LIMIT = 16
131
+ /** Default concurrency when a parallel swarm does not specify an explicit cap. */
132
+ export const SWARM_DEFAULT_PARALLEL_CONCURRENCY = 4
133
+
134
+ function clampConcurrency(requested: number | undefined, taskCount: number): number {
135
+ if (taskCount <= 1) return 1
136
+ const raw = typeof requested === 'number' && Number.isFinite(requested) && requested > 0
137
+ ? Math.floor(requested)
138
+ : SWARM_DEFAULT_PARALLEL_CONCURRENCY
139
+ return Math.min(SWARM_MAX_CONCURRENCY_HARD_LIMIT, Math.max(1, raw))
140
+ }
141
+
142
+ export const _clampSwarmConcurrency = clampConcurrency
143
+
114
144
  // ---------------------------------------------------------------------------
115
145
  // Batch types (absorbed from subagent-batch)
116
146
  // ---------------------------------------------------------------------------
@@ -165,6 +195,7 @@ function persistSwarmSnapshot(swarm: SwarmHandle): void {
165
195
  parentSessionId: swarm.parentSessionId,
166
196
  status: swarm.status,
167
197
  memberCount: swarm.members.length,
198
+ maxConcurrency: swarm.maxConcurrency,
168
199
  createdAt: swarm.createdAt,
169
200
  completedAt: swarm.completedAt,
170
201
  updatedAt: Date.now(),
@@ -198,9 +229,20 @@ export async function spawnSwarm(
198
229
  const createdAt = Date.now()
199
230
  const members: SwarmMember[] = []
200
231
  const executionMode = _resolveSwarmExecutionMode(input.tasks, input.executionMode)
201
- const executionGroupKey = executionMode === 'serial'
202
- ? `swarm:${context.sessionId || 'root'}:${swarmId}`
203
- : undefined
232
+ const effectiveConcurrency = executionMode === 'serial'
233
+ ? 1
234
+ : clampConcurrency(input.maxConcurrency, input.tasks.length)
235
+ const parentKey = context.sessionId || 'root'
236
+
237
+ // Concurrency is implemented by bucketing tasks across N shared execution
238
+ // group keys — each bucket serializes through the existing session-run-manager
239
+ // per-execution lock, so bucket count === effective parallelism.
240
+ const bucketKeyFor = (index: number): string | undefined => {
241
+ if (executionMode === 'serial') return `swarm:${parentKey}:${swarmId}`
242
+ if (effectiveConcurrency >= input.tasks.length) return undefined
243
+ const bucket = index % effectiveConcurrency
244
+ return `swarm:${parentKey}:${swarmId}:b${bucket}`
245
+ }
204
246
 
205
247
  // Pre-load sessions once for all spawns (avoids N SQLite reads)
206
248
  const cachedSessions = context._sessions ?? loadSessions()
@@ -218,7 +260,7 @@ export async function spawnSwarm(
218
260
  cwd: task.cwd,
219
261
  shareBrowserProfile: task.shareBrowserProfile,
220
262
  waitForCompletion: false,
221
- executionGroupKey,
263
+ executionGroupKey: bucketKeyFor(i),
222
264
  },
223
265
  cachedContext,
224
266
  )
@@ -249,8 +291,10 @@ export async function spawnSwarm(
249
291
  status: 'running',
250
292
  createdAt,
251
293
  completedAt: null,
294
+ maxConcurrency: effectiveConcurrency,
252
295
  allSettled: null as unknown as Promise<SwarmAggregateResult>,
253
296
  firstSettled: null as unknown as Promise<{ index: number; result: SubagentResult }>,
297
+ quorumSettled: null as unknown as SwarmHandle['quorumSettled'],
254
298
  cancelAll: () => {
255
299
  for (const member of members) {
256
300
  if (member.handle && !member.result && !member.spawnError) {
@@ -330,6 +374,46 @@ export async function spawnSwarm(
330
374
  }
331
375
  })
332
376
 
377
+ // quorumSettled — resolves when `count` members succeed. If too many fail to
378
+ // ever reach the quorum, falls back to allSettled.
379
+ swarm.quorumSettled = (count, opts) => {
380
+ const requested = Math.max(1, Math.floor(Number.isFinite(count) ? count : 1))
381
+ const target = Math.min(requested, swarm.members.length)
382
+ const cancelRemaining = opts?.cancelRemaining !== false
383
+ if (target <= 0 || memberPromises.length === 0) return swarm.allSettled
384
+ return new Promise<SwarmAggregateResult>((resolve) => {
385
+ let settled = false
386
+ let successes = 0
387
+ let finalized = 0
388
+ const finalize = () => {
389
+ if (settled) return
390
+ settled = true
391
+ if (cancelRemaining) {
392
+ for (const member of members) {
393
+ if (member.handle && !member.result && !member.spawnError) {
394
+ try {
395
+ member.handle.run.abort()
396
+ cancelLineageNode(member.handle.lineageId)
397
+ } catch { /* best-effort */ }
398
+ }
399
+ }
400
+ }
401
+ swarm.allSettled.then(resolve).catch(() => resolve(buildAggregateResult(swarm)))
402
+ }
403
+ for (const p of memberPromises) {
404
+ p.then(({ result }) => {
405
+ finalized++
406
+ if (result.status === 'completed') successes++
407
+ if (successes >= target) finalize()
408
+ else if (finalized >= memberPromises.length) finalize()
409
+ }).catch(() => {
410
+ finalized++
411
+ if (finalized >= memberPromises.length) finalize()
412
+ })
413
+ }
414
+ })
415
+ }
416
+
333
417
  // Register in swarm registry
334
418
  swarmRegistry.set(swarmId, swarm)
335
419
  notifySwarmChanged()
@@ -575,6 +659,7 @@ export interface SwarmSnapshot {
575
659
  memberCount: number
576
660
  completedCount: number
577
661
  failedCount: number
662
+ maxConcurrency?: number
578
663
  members: SwarmMemberSnapshot[]
579
664
  }
580
665
 
@@ -622,6 +707,7 @@ function buildSwarmSnapshot(swarm: SwarmHandle): SwarmSnapshot {
622
707
  failedCount: members.filter((m) =>
623
708
  m.status === 'failed' || m.status === 'timed_out' || m.status === 'spawn_error',
624
709
  ).length,
710
+ maxConcurrency: swarm.maxConcurrency,
625
711
  members,
626
712
  }
627
713
  }
@@ -12,7 +12,6 @@ import { log } from '../logger'
12
12
  import { debug } from '../debug'
13
13
  import { logExecution } from '../execution-log'
14
14
  import { logActivity } from '../storage'
15
- import { WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
16
15
 
17
16
  /**
18
17
  * Core Chatroom Execution Logic
@@ -78,12 +77,6 @@ async function executeChatroomAction(args: Record<string, unknown>, context: { a
78
77
  const agents = loadAgents()
79
78
  const requestedAgentIds = agentIds || []
80
79
  const validAgentIds = requestedAgentIds.filter((aid: string) => !!agents[aid])
81
- const cliAgentNames = validAgentIds
82
- .filter((aid: string) => WORKER_ONLY_PROVIDER_IDS.has(agents[aid]?.provider))
83
- .map((aid: string) => agents[aid]?.name || aid)
84
- if (cliAgentNames.length > 0) {
85
- return `Error: CLI-based agents cannot join chatrooms: ${cliAgentNames.join(', ')}. They can only be used for direct chats and delegation.`
86
- }
87
80
 
88
81
  const chatroom: Chatroom = {
89
82
  id,
@@ -208,10 +201,6 @@ async function executeChatroomAction(args: Record<string, unknown>, context: { a
208
201
 
209
202
  if (action === 'add_agent') {
210
203
  if (!agentId) return 'Error: agentId required.'
211
- const agents = loadAgents()
212
- if (WORKER_ONLY_PROVIDER_IDS.has(agents[agentId]?.provider)) {
213
- return `Error: ${agents[agentId]?.name || agentId} is a CLI-based agent and cannot join chatrooms. CLI agents can only be used for direct chats and delegation.`
214
- }
215
204
  if (!chatroom.agentIds.includes(agentId)) {
216
205
  chatroom.agentIds.push(agentId)
217
206
  chatroom.updatedAt = Date.now()
@@ -34,7 +34,11 @@ import {
34
34
  listSwarms,
35
35
  aggregateResults,
36
36
  waitForAll,
37
+ SWARM_MAX_CONCURRENCY_HARD_LIMIT,
38
+ SWARM_DEFAULT_PARALLEL_CONCURRENCY,
37
39
  } from '@/lib/server/agents/subagent-swarm'
40
+ import { getSession } from '@/lib/server/sessions/session-repository'
41
+ import { getMission } from '@/lib/server/missions/mission-repository'
38
42
 
39
43
  const SUBAGENT_ACTIONS = [
40
44
  'start',
@@ -73,6 +77,10 @@ const subagentToolSchema = z.object({
73
77
  jobIds: z.union([z.array(z.string()), z.string()]).optional(),
74
78
  tasks: z.union([z.array(subagentTaskSchema), z.string()]).optional(),
75
79
  executionMode: z.enum(['auto', 'parallel', 'serial']).optional(),
80
+ maxConcurrency: z.union([z.number(), z.string()]).optional(),
81
+ joinPolicy: z.enum(['all', 'first', 'quorum']).optional(),
82
+ quorum: z.union([z.number(), z.string()]).optional(),
83
+ cancelRemaining: z.boolean().optional(),
76
84
  waitForCompletion: z.boolean().optional(),
77
85
  background: z.boolean().optional(),
78
86
  timeoutSec: z.union([z.number(), z.string()]).optional(),
@@ -150,10 +158,12 @@ export function coerceSubagentActionArgs(rawArgs: Record<string, unknown>): Reco
150
158
  const normalized = normalizeToolInputArgs(rawArgs)
151
159
  const coerced: Record<string, unknown> = { ...normalized }
152
160
 
153
- for (const key of ['waitForCompletion', 'background', 'shareBrowserProfile'] as const) {
161
+ for (const key of ['waitForCompletion', 'background', 'shareBrowserProfile', 'cancelRemaining'] as const) {
154
162
  coerced[key] = parseBooleanLike(coerced[key])
155
163
  }
156
164
  coerced.timeoutSec = parseNumberLike(coerced.timeoutSec)
165
+ coerced.maxConcurrency = parseNumberLike(coerced.maxConcurrency)
166
+ coerced.quorum = parseNumberLike(coerced.quorum)
157
167
 
158
168
  const parsedTasks = parseJsonLike(coerced.tasks)
159
169
  if (Array.isArray(parsedTasks)) {
@@ -239,6 +249,76 @@ function requireString(args: Record<string, unknown>, key: string): string {
239
249
  return val
240
250
  }
241
251
 
252
+ type JoinPolicy =
253
+ | { type: 'all' }
254
+ | { type: 'first' }
255
+ | { type: 'quorum'; count: number; cancelRemaining: boolean }
256
+
257
+ function parseJoinPolicy(args: Record<string, unknown>, taskCount: number): JoinPolicy {
258
+ const raw = typeof args.joinPolicy === 'string' ? args.joinPolicy.trim().toLowerCase() : ''
259
+ if (raw === 'first') return { type: 'first' }
260
+ if (raw === 'quorum') {
261
+ const parsed = typeof args.quorum === 'number' ? args.quorum : Number(args.quorum)
262
+ const count = Number.isFinite(parsed) && parsed > 0
263
+ ? Math.min(Math.floor(parsed), taskCount)
264
+ : Math.max(1, Math.ceil(taskCount / 2))
265
+ const cancelRemaining = args.cancelRemaining !== false
266
+ return { type: 'quorum', count, cancelRemaining }
267
+ }
268
+ return { type: 'all' }
269
+ }
270
+
271
+ /**
272
+ * Resolve the effective maxConcurrency for a swarm dispatch using the
273
+ * precedence: explicit arg > agent.maxParallelDelegations > mission.budget.maxParallelBranches > system default.
274
+ */
275
+ function resolveSwarmMaxConcurrency(
276
+ args: Record<string, unknown>,
277
+ ctx: ActionContext,
278
+ ): number {
279
+ const pickFinite = (value: unknown): number | null => {
280
+ const n = typeof value === 'number' ? value : Number(value)
281
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null
282
+ }
283
+ const explicit = pickFinite(args.maxConcurrency)
284
+ if (explicit !== null) return Math.min(explicit, SWARM_MAX_CONCURRENCY_HARD_LIMIT)
285
+
286
+ if (ctx.agentId) {
287
+ const agent = loadAgents()[ctx.agentId]
288
+ const agentCap = pickFinite(agent?.maxParallelDelegations)
289
+ if (agentCap !== null) return Math.min(agentCap, SWARM_MAX_CONCURRENCY_HARD_LIMIT)
290
+ }
291
+
292
+ if (ctx.sessionId) {
293
+ const session = getSession(ctx.sessionId) as { missionId?: string | null } | null
294
+ const missionId = typeof session?.missionId === 'string' && session.missionId.trim()
295
+ ? session.missionId.trim()
296
+ : null
297
+ if (missionId) {
298
+ const mission = getMission(missionId)
299
+ const missionCap = pickFinite(mission?.budget?.maxParallelBranches)
300
+ if (missionCap !== null) return Math.min(missionCap, SWARM_MAX_CONCURRENCY_HARD_LIMIT)
301
+ }
302
+ }
303
+
304
+ return SWARM_DEFAULT_PARALLEL_CONCURRENCY
305
+ }
306
+
307
+ async function awaitSwarmByPolicy(
308
+ swarm: Awaited<ReturnType<typeof spawnSwarm>>,
309
+ policy: JoinPolicy,
310
+ ): Promise<ReturnType<typeof spawnSwarm> extends Promise<infer T> ? T extends { allSettled: Promise<infer A> } ? A : never : never> {
311
+ if (policy.type === 'first') {
312
+ await swarm.firstSettled
313
+ swarm.cancelAll()
314
+ return swarm.allSettled
315
+ }
316
+ if (policy.type === 'quorum') {
317
+ return swarm.quorumSettled(policy.count, { cancelRemaining: policy.cancelRemaining })
318
+ }
319
+ return swarm.allSettled
320
+ }
321
+
242
322
  // ---------------------------------------------------------------------------
243
323
  // Promise-based wait (no polling when handle exists)
244
324
  // ---------------------------------------------------------------------------
@@ -336,9 +416,11 @@ async function handleBatch(args: Record<string, unknown>, ctx: ActionContext): P
336
416
  const executionMode = args.executionMode === 'parallel' || args.executionMode === 'serial'
337
417
  ? args.executionMode
338
418
  : 'auto'
419
+ const maxConcurrency = resolveSwarmMaxConcurrency(args, ctx)
420
+ const policy = parseJoinPolicy(args, tasks.length)
339
421
 
340
422
  // Use spawnSwarm internally — batch is a simplified interface
341
- const swarm = await spawnSwarm({ tasks, executionMode }, { sessionId: ctx.sessionId, cwd: ctx.cwd })
423
+ const swarm = await spawnSwarm({ tasks, executionMode, maxConcurrency }, { sessionId: ctx.sessionId, cwd: ctx.cwd })
342
424
  const jobIds = swarm.members
343
425
  .filter((m) => !m.spawnError && m.handle)
344
426
  .map((m) => m.handle.jobId)
@@ -349,13 +431,16 @@ async function handleBatch(args: Record<string, unknown>, ctx: ActionContext): P
349
431
  status: 'running',
350
432
  jobIds,
351
433
  taskCount: tasks.length,
434
+ maxConcurrency: swarm.maxConcurrency,
352
435
  })
353
436
  }
354
- const aggregate = await swarm.allSettled
437
+ const aggregate = await awaitSwarmByPolicy(swarm, policy)
355
438
  return JSON.stringify({
356
439
  action: 'batch',
357
440
  status: 'completed',
358
441
  jobIds,
442
+ maxConcurrency: swarm.maxConcurrency,
443
+ joinPolicy: policy.type,
359
444
  completed: aggregate.totalCompleted,
360
445
  failed: aggregate.totalFailed + aggregate.totalSpawnErrors,
361
446
  cancelled: aggregate.totalCancelled,
@@ -397,8 +482,10 @@ async function handleSwarm(args: Record<string, unknown>, ctx: ActionContext): P
397
482
  const executionMode = args.executionMode === 'parallel' || args.executionMode === 'serial'
398
483
  ? args.executionMode
399
484
  : 'auto'
485
+ const maxConcurrency = resolveSwarmMaxConcurrency(args, ctx)
486
+ const policy = parseJoinPolicy(args, tasks.length)
400
487
 
401
- const swarm = await spawnSwarm({ tasks, executionMode }, { sessionId: ctx.sessionId, cwd: ctx.cwd })
488
+ const swarm = await spawnSwarm({ tasks, executionMode, maxConcurrency }, { sessionId: ctx.sessionId, cwd: ctx.cwd })
402
489
  if (!waitForCompletion) {
403
490
  const snapshot = getSwarmSnapshot(swarm.swarmId)
404
491
  return JSON.stringify({
@@ -406,15 +493,18 @@ async function handleSwarm(args: Record<string, unknown>, ctx: ActionContext): P
406
493
  status: 'running',
407
494
  swarmId: swarm.swarmId,
408
495
  memberCount: swarm.members.length,
496
+ maxConcurrency: swarm.maxConcurrency,
409
497
  snapshot,
410
498
  })
411
499
  }
412
- const aggregate = await swarm.allSettled
500
+ const aggregate = await awaitSwarmByPolicy(swarm, policy)
413
501
  const snapshot = getSwarmSnapshot(swarm.swarmId)
414
502
  return JSON.stringify({
415
503
  action: 'swarm',
416
504
  ...aggregate,
417
505
  status: swarm.status,
506
+ maxConcurrency: swarm.maxConcurrency,
507
+ joinPolicy: policy.type,
418
508
  snapshot,
419
509
  })
420
510
  }
@@ -633,6 +723,23 @@ const SubagentExtension: Extension = {
633
723
  enum: ['auto', 'parallel', 'serial'],
634
724
  description: 'How to schedule sibling subagents. "auto" defaults to serial for Ollama-backed targets and parallel otherwise.',
635
725
  },
726
+ maxConcurrency: {
727
+ type: 'number',
728
+ description: 'Max sibling branches that may run at the same time when parallel. Defaults to agent/mission policy or 4. Hard-capped at 16.',
729
+ },
730
+ joinPolicy: {
731
+ type: 'string',
732
+ enum: ['all', 'first', 'quorum'],
733
+ description: 'How to wait. "all" (default) waits for every branch. "first" resolves when one succeeds and cancels the rest. "quorum" resolves when `quorum` branches succeed.',
734
+ },
735
+ quorum: {
736
+ type: 'number',
737
+ description: 'Required when joinPolicy="quorum" — number of successful branches needed before resolving.',
738
+ },
739
+ cancelRemaining: {
740
+ type: 'boolean',
741
+ description: 'When joinPolicy="quorum", cancel in-flight branches after quorum is reached. Default true.',
742
+ },
636
743
  waitForCompletion: { type: 'boolean' },
637
744
  background: { type: 'boolean' },
638
745
  timeoutSec: { type: 'number' },
@@ -399,6 +399,7 @@ function normalizeStoredAgentMissionRecord(value: unknown): unknown {
399
399
  budget.maxToolCalls = normalizeFiniteNumber(budget.maxToolCalls)
400
400
  budget.maxWallclockSec = normalizeFiniteNumber(budget.maxWallclockSec)
401
401
  budget.maxTurns = normalizeFiniteNumber(budget.maxTurns)
402
+ budget.maxParallelBranches = normalizeFiniteNumber(budget.maxParallelBranches)
402
403
  if (!Array.isArray(budget.warnAtFractions)) {
403
404
  budget.warnAtFractions = [0.5, 0.8, 0.95]
404
405
  } else {
@@ -620,6 +621,7 @@ function normalizeStoredRecordInner(
620
621
  if (!Array.isArray(agent.delegationTargetAgentIds)) {
621
622
  agent.delegationTargetAgentIds = legacyTargetIds
622
623
  }
624
+ agent.maxParallelDelegations = normalizeFiniteNumber(agent.maxParallelDelegations)
623
625
  delete agent.platformAssignScope
624
626
  delete agent.subAgentIds
625
627
  agent.sandboxConfig = normalizeAgentSandboxConfig(agent.sandboxConfig)
@@ -88,6 +88,7 @@ export const AgentCreateSchema = z.object({
88
88
  delegationEnabled: z.boolean().optional().default(false),
89
89
  delegationTargetMode: z.enum(['all', 'selected']).optional().default('all'),
90
90
  delegationTargetAgentIds: z.array(z.string()).optional().default([]),
91
+ maxParallelDelegations: z.number().int().positive().nullable().optional().default(null),
91
92
  tools: z.array(z.string()).optional(),
92
93
  extensions: z.array(z.string()).optional().default([]),
93
94
  skills: z.array(z.string()).optional().default([]),
@@ -67,6 +67,12 @@ export interface Agent {
67
67
  delegationEnabled?: boolean
68
68
  delegationTargetMode?: DelegationTargetMode
69
69
  delegationTargetAgentIds?: string[]
70
+ /**
71
+ * Cap on sibling subagents this agent may dispatch concurrently via
72
+ * `spawn_subagent` swarm/batch actions. Resolves after the mission-level
73
+ * cap and before the system default (4). Hard-capped at 16.
74
+ */
75
+ maxParallelDelegations?: number | null
70
76
  tools?: string[]
71
77
  // When 'scoped', the chat turn restricts enabled extensions to the
72
78
  // intersection of the universal core list and agent.tools (plus a small
@@ -28,6 +28,12 @@ export interface MissionBudget {
28
28
  maxToolCalls?: number | null
29
29
  maxWallclockSec?: number | null
30
30
  maxTurns?: number | null
31
+ /**
32
+ * Cap on concurrent sub-agent branches when this mission's agents fan out
33
+ * via `spawn_subagent` swarm/batch actions. Overrides the system default
34
+ * (4) when set. Hard-capped at 16 regardless.
35
+ */
36
+ maxParallelBranches?: number | null
31
37
  warnAtFractions?: number[]
32
38
  }
33
39