@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 +17 -19
- package/package.json +1 -1
- package/src/app/api/missions/[id]/route.ts +1 -0
- package/src/app/api/missions/route.ts +1 -0
- package/src/lib/server/agents/agent-service.ts +1 -0
- package/src/lib/server/agents/subagent-runtime.ts +37 -0
- package/src/lib/server/agents/subagent-swarm.test.ts +104 -11
- package/src/lib/server/agents/subagent-swarm.ts +90 -4
- package/src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts +63 -0
- package/src/lib/server/chat-execution/prompt-sections.ts +36 -0
- package/src/lib/server/chat-execution/stream-agent-chat.ts +3 -0
- package/src/lib/server/session-tools/subagent.ts +112 -5
- package/src/lib/server/storage-normalization.ts +2 -0
- package/src/lib/validation/schemas.ts +2 -0
- package/src/types/agent.ts +14 -0
- package/src/types/mission.ts +6 -0
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.
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
202
|
-
?
|
|
203
|
-
:
|
|
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
|
|
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
|
|
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),
|
package/src/types/agent.ts
CHANGED
|
@@ -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. */
|
package/src/types/mission.ts
CHANGED
|
@@ -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
|
|