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