@swarmclawai/swarmclaw 1.5.60 → 1.5.62

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.62 Highlights
403
+
404
+ 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.
405
+
406
+ - **`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.
407
+ - **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.
408
+ - **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`.
409
+ - **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"`.
410
+
411
+ ### v1.5.61 Highlights
412
+
413
+ Adds an opt-in per-agent planning mode that rides on the existing `[MAIN_LOOP_PLAN]` token machinery.
414
+
415
+ - **`Agent.planningMode: 'off' | 'strict' | null`** — new optional field on the Agent type. Defaults to `null` (off) so existing agents are unaffected. Validated by `AgentCreateSchema` / `AgentUpdateSchema` and surfaced through `createAgent` in `agent-service.ts`.
416
+ - **Strict planning prompt section.** New `buildPlanningModeSection` in `prompt-sections.ts` injects a short contract into the system prompt when `planningMode === 'strict'`: before any multi-step work, emit a single-line `[MAIN_LOOP_PLAN]{"steps":...}` block. The existing parser in `main-agent-loop.ts` reads these blocks into `MainLoopState.planSteps` / `currentPlanStep` / `completedPlanSteps` with no additional wiring. Skipped in minimal prompt mode and for heartbeat turns.
417
+ - **Test coverage.** `prompt-sections.planning-mode.test.ts` covers the null / off / strict / minimal / missing-agent paths (6 cases).
418
+
402
419
  ### v1.5.60 Highlights
403
420
 
404
421
  Adds a turn-snapshot primitive for external replay and comparison tooling, without touching the execution flow.
@@ -425,25 +442,6 @@ This release broadens the built-in evaluation harness so SwarmClaw runs can be b
425
442
  - **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).
426
443
  - **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.
427
444
 
428
- ### v1.5.57 Highlights
429
-
430
- 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.
431
-
432
- - **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).
433
- - **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.
434
- - **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.
435
- - **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.
436
- - **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.
437
- - **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.
438
- - **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.
439
- - **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).
440
- - **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.
441
-
442
- ### v1.5.56 Highlights
443
-
444
- - **Fix: TTS error responses are now proper JSON instead of a raw Buffer blob.** `POST /api/tts` and `POST /api/tts/stream` previously returned `500` with the error message wrapped in a `new NextResponse(string, ...)` that the CLI JSON-decoded into `{"type":"Buffer","data":[78,111,...]}`. Both routes now return `NextResponse.json({error}, {status: 500})`. Regression test added.
445
- - **Zod-validated PUT/PATCH endpoints — hardening sweep.** Extends the v1.5.55 work (agents, tasks, webhooks) to close the same silent-corruption bug class on the remaining vulnerable routes: `PUT /api/secrets/:id`, `POST /api/secrets`, `PATCH /api/goals/:id`, `PUT /api/providers/:id`, `PUT /api/documents/:id`, `PUT /api/external-agents/:id`, and `PUT /api/chatrooms/:id`. Each route validates against a dedicated schema (`SecretUpdateSchema`, `SecretCreateSchema`, `GoalUpdateSchema`, `ProviderUpdateSchema`, `DocumentUpdateSchema`, `ExternalAgentUpdateSchema`, `ChatroomUpdateSchema`) in `src/lib/validation/schemas.ts`, then filters parsed data to the keys actually present in the raw body so Zod defaults can't overwrite untouched stored fields. Endpoints already doing per-field `typeof` guards (knowledge, gateways, projects) were left as-is.
446
-
447
445
  Older releases: https://swarmclaw.ai/docs/release-notes
448
446
 
449
447
  - 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.60",
3
+ "version": "1.5.62",
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",
@@ -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
 
@@ -178,6 +178,7 @@ export function createAgent(input: {
178
178
  memoryTierPreference: (body.memoryTierPreference as Agent['memoryTierPreference']) || undefined,
179
179
  proactiveMemory: body.proactiveMemory !== false,
180
180
  autoDraftSkillSuggestions: body.autoDraftSkillSuggestions as Agent['autoDraftSkillSuggestions'],
181
+ planningMode: (body.planningMode as Agent['planningMode']) ?? null,
181
182
  projectId: typeof body.projectId === 'string' && body.projectId.trim() ? body.projectId.trim() : undefined,
182
183
  avatarSeed: typeof body.avatarSeed === 'string' ? body.avatarSeed : undefined,
183
184
  avatarUrl: typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined,
@@ -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
  }
@@ -0,0 +1,63 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { buildPlanningModeSection } from './prompt-sections'
4
+ import type { Agent } from '@/types'
5
+
6
+ function agentWith(partial: Partial<Agent>): Agent {
7
+ return {
8
+ id: 'test',
9
+ name: 'Test',
10
+ provider: 'anthropic',
11
+ model: 'claude-sonnet-4-5',
12
+ credentialId: null,
13
+ apiEndpoint: null,
14
+ soul: null,
15
+ systemPrompt: null,
16
+ description: null,
17
+ tools: [],
18
+ extensions: [],
19
+ heartbeatEnabled: false,
20
+ delegationEnabled: false,
21
+ delegationTargetMode: 'all',
22
+ delegationTargetAgentIds: [],
23
+ skillIds: [],
24
+ createdAt: 0,
25
+ updatedAt: 0,
26
+ ...partial,
27
+ } as unknown as Agent
28
+ }
29
+
30
+ test('buildPlanningModeSection returns null when planningMode is undefined', () => {
31
+ const out = buildPlanningModeSection(agentWith({}), false)
32
+ assert.equal(out, null)
33
+ })
34
+
35
+ test('buildPlanningModeSection returns null when planningMode is "off"', () => {
36
+ const out = buildPlanningModeSection(agentWith({ planningMode: 'off' }), false)
37
+ assert.equal(out, null)
38
+ })
39
+
40
+ test('buildPlanningModeSection returns null when planningMode is null', () => {
41
+ const out = buildPlanningModeSection(agentWith({ planningMode: null }), false)
42
+ assert.equal(out, null)
43
+ })
44
+
45
+ test('buildPlanningModeSection returns null in minimal prompt mode', () => {
46
+ const out = buildPlanningModeSection(agentWith({ planningMode: 'strict' }), true)
47
+ assert.equal(out, null)
48
+ })
49
+
50
+ test('buildPlanningModeSection returns null when agent is missing', () => {
51
+ assert.equal(buildPlanningModeSection(null, false), null)
52
+ assert.equal(buildPlanningModeSection(undefined, false), null)
53
+ })
54
+
55
+ test('buildPlanningModeSection emits plan block guidance when strict', () => {
56
+ const out = buildPlanningModeSection(agentWith({ planningMode: 'strict' }), false)
57
+ assert.ok(out, 'should return a non-empty block')
58
+ assert.match(out!, /## Planning Mode: Strict/)
59
+ assert.match(out!, /\[MAIN_LOOP_PLAN\]/)
60
+ assert.match(out!, /"steps":/)
61
+ assert.match(out!, /current_step/)
62
+ assert.match(out!, /completed_steps/)
63
+ })
@@ -81,6 +81,42 @@ export function buildIdentitySection(
81
81
  return parts
82
82
  }
83
83
 
84
+ // ---------------------------------------------------------------------------
85
+ // Planning Mode — opt-in, per-agent
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * When `agent.planningMode === 'strict'`, inject a plan-enforcement section
90
+ * that tells the model to emit a [MAIN_LOOP_PLAN]{...} block before tool use
91
+ * on any multi-step turn. The existing main-agent-loop parser in
92
+ * `parseMainLoopPlan()` consumes these tokens and populates planSteps /
93
+ * currentPlanStep / completedPlanSteps in MainLoopState.
94
+ *
95
+ * Returns null when planning mode is off or minimal prompt mode is active.
96
+ */
97
+ export function buildPlanningModeSection(
98
+ agent: Agent | null | undefined,
99
+ isMinimalPrompt: boolean,
100
+ ): string | null {
101
+ if (!agent || isMinimalPrompt) return null
102
+ if (agent.planningMode !== 'strict') return null
103
+ return [
104
+ '## Planning Mode: Strict',
105
+ '',
106
+ 'Before any multi-step work (two or more tool calls or file edits), emit a single machine-readable plan block on its own line:',
107
+ '',
108
+ '```',
109
+ '[MAIN_LOOP_PLAN]{"steps":["step 1","step 2","step 3"],"current_step":"step 1","completed_steps":[]}',
110
+ '```',
111
+ '',
112
+ 'Rules:',
113
+ '- Each step should be a short imperative phrase (≤80 chars).',
114
+ '- Update `current_step` as you advance, and append finished steps to `completed_steps`.',
115
+ '- Skip the block for trivial single-tool responses or pure Q&A.',
116
+ '- The block is parsed by the runtime; do not wrap it in prose, code fences, or extra punctuation.',
117
+ ].join('\n')
118
+ }
119
+
84
120
  // ---------------------------------------------------------------------------
85
121
  // Thinking Level Guidance
86
122
  // ---------------------------------------------------------------------------
@@ -17,6 +17,7 @@ import { loadRuntimeSettings, getAgentLoopRecursionLimit } from '@/lib/server/ru
17
17
  import { truncateToolResultText } from '@/lib/server/chat-execution/tool-result-guard'
18
18
  import {
19
19
  buildIdentitySection,
20
+ buildPlanningModeSection,
20
21
  buildThinkingSection,
21
22
  buildRuntimeOrientationSection,
22
23
  buildWorkspaceSection,
@@ -384,6 +385,8 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
384
385
  }
385
386
 
386
387
  // Composable prompt sections — each builder returns string | null (or string[])
388
+ const planningBlock = buildPlanningModeSection(sessionAgent, isMinimalPrompt)
389
+ if (planningBlock) promptParts.push(planningBlock)
387
390
  const thinkingBlock = buildThinkingSection(agentThinkingLevel, isMinimalPrompt)
388
391
  if (thinkingBlock) promptParts.push(thinkingBlock)
389
392
  const { rootSessionId } = resolveSessionLineageIds(session)
@@ -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([]),
@@ -122,6 +123,7 @@ export const AgentCreateSchema = z.object({
122
123
  memoryTierPreference: z.enum(['working', 'durable', 'archive', 'blended']).nullable().optional().default(null),
123
124
  proactiveMemory: z.boolean().optional().default(true),
124
125
  autoDraftSkillSuggestions: z.boolean().optional().default(true),
126
+ planningMode: z.enum(['off', 'strict']).nullable().optional().default(null),
125
127
  projectId: z.string().optional(),
126
128
  avatarSeed: z.string().optional(),
127
129
  avatarUrl: z.string().nullable().optional().default(null),
@@ -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
@@ -125,6 +131,14 @@ export interface Agent {
125
131
  proactiveMemory?: boolean
126
132
  /** Auto-refresh a reviewed skill draft from meaningful chat turns for this agent. */
127
133
  autoDraftSkillSuggestions?: boolean
134
+ /**
135
+ * Planning enforcement mode.
136
+ * - 'off' (default): no extra planning guidance
137
+ * - 'strict': instruct the model to emit a [MAIN_LOOP_PLAN] block before any
138
+ * tool call on multi-step turns. The existing main-agent-loop plan parser
139
+ * reads these blocks into MainLoopState.planSteps.
140
+ */
141
+ planningMode?: 'off' | 'strict' | null
128
142
  /** Controls whether file operations are confined to the workspace or allowed anywhere on the host. Default: 'workspace'. */
129
143
  filesystemScope?: 'workspace' | 'machine' | null
130
144
  /** Per-agent filesystem restrictions. Globs matched against resolved paths. */
@@ -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