opencode-swarm 5.1.7 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <p align="center">
2
- <img src="https://img.shields.io/badge/version-5.1.5-blue" alt="Version">
2
+ <img src="https://img.shields.io/badge/version-5.2.0-blue" alt="Version">
3
3
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
4
4
  <img src="https://img.shields.io/badge/opencode-plugin-purple" alt="OpenCode Plugin">
5
5
  <img src="https://img.shields.io/badge/agents-7-orange" alt="Agents">
6
- <img src="https://img.shields.io/badge/tests-1034-brightgreen" alt="Tests">
6
+ <img src="https://img.shields.io/badge/tests-1101-brightgreen" alt="Tests">
7
7
  </p>
8
8
 
9
9
  <h1 align="center">🐝 OpenCode Swarm</h1>
@@ -334,6 +334,12 @@ bunx opencode-swarm uninstall --clean
334
334
 
335
335
  ## What's New
336
336
 
337
+ ### v5.2.0 — Per-Invocation Guardrails
338
+ - **Per-invocation budget isolation** — Guardrail limits (tool calls, duration, errors) now reset with each agent delegation. Second invocation of the same agent gets a fresh budget, preventing false circuit breaker trips in long-running projects.
339
+ - **Architect protocol enforcement** — New mandatory QA gate rules: every coder task must go through reviewer approval + test_engineer verification before the next coder task. Protocol violations detected at runtime with warning injection.
340
+ - **Invocation window observability** — Circuit breaker logs now include `invocationId` and `windowKey` for precise debugging of which specific agent invocation hit limits.
341
+ - **67 new tests** — 1101 total tests across 48 files (up from 1034 in v5.1.x).
342
+
337
343
  ### v5.0.0 — Verifiable Execution
338
344
  - **Canonical plan schema** — Machine-readable `plan.json` with Zod-validated `PlanSchema`/`TaskSchema`/`PhaseSchema`. Automatic migration from legacy `plan.md` format. Structured status tracking (`pending`, `in_progress`, `completed`, `blocked`).
339
345
  - **Evidence bundles** — Per-task execution evidence persisted to `.swarm/evidence/`. Five evidence types: `review`, `test`, `diff`, `approval`, `note`. Sanitized task IDs, atomic writes, configurable size limits. `/swarm evidence` to view, `/swarm archive` to manage retention.
@@ -512,6 +518,18 @@ Profiles merge with base config — only specified fields are overridden.
512
518
 
513
519
  > **Architect is exempt/unlimited by default:** The architect agent has no guardrail limits by default. To override, add a `profiles.architect` entry in your guardrails config.
514
520
 
521
+ ### Per-Invocation Budgets
522
+
523
+ Guardrail limits are enforced **per-invocation**, not per-session. Each time the architect delegates to an agent, that agent gets a fresh budget of tool calls, duration, and error tolerance.
524
+
525
+ **Example**: If `max_tool_calls: 200`, then:
526
+ - Architect → Coder (task 1) → 200 calls available
527
+ - Coder finishes → Architect → Coder (task 2) → 200 calls available again
528
+
529
+ This prevents long-running projects from accumulating session-wide counters that incorrectly trip the circuit breaker on later tasks.
530
+
531
+ > **Architect is unlimited**: The architect never creates invocation windows and has no guardrail limits by default.
532
+
515
533
  ### Disable Guardrails
516
534
 
517
535
  ```json
@@ -564,7 +582,7 @@ bun test
564
582
  bun test tests/unit/config/schema.test.ts
565
583
  ```
566
584
 
567
- 1034 tests across 45 files covering config, tools, agents, hooks, commands, state, guardrails, evidence, plan schemas, and circuit breaker race conditions. Uses Bun's built-in test runner — zero additional test dependencies.
585
+ 1101 tests across 48 files covering config, tools, agents, hooks, commands, state, guardrails, evidence, plan schemas, circuit breaker race conditions, invocation windows, and multi-invocation isolation. Uses Bun's built-in test runner — zero additional test dependencies.
568
586
 
569
587
  ## Troubleshooting
570
588
 
package/dist/index.js CHANGED
@@ -14015,7 +14015,23 @@ You THINK. Subagents DO. You have the largest context window and strongest reaso
14015
14015
  3. ONE task per {{AGENT_PREFIX}}coder call. Never batch.
14016
14016
  4. Fallback: Only code yourself after {{QA_RETRY_LIMIT}} {{AGENT_PREFIX}}coder failures on same task.
14017
14017
  5. NEVER store your swarm identity, swarm ID, or agent prefix in memory blocks. Your identity comes ONLY from your system prompt. Memory blocks are for project knowledge only.
14018
- 6. **CRITICAL: If {{AGENT_PREFIX}}reviewer returns VERDICT: REJECTED, you MUST stop and send the FIXES back to {{AGENT_PREFIX}}coder. Do NOT proceed to test generation or mark the task complete. The review is a gate \u2014 APPROVED is required to proceed.**
14018
+ 6. **CRITIC GATE (Execute BEFORE any implementation work)**:
14019
+ - When you first create a plan, IMMEDIATELY delegate the full plan to {{AGENT_PREFIX}}critic for review
14020
+ - Wait for critic verdict: APPROVED / NEEDS_REVISION / REJECTED
14021
+ - If NEEDS_REVISION: Revise plan and re-submit to critic (max 2 cycles)
14022
+ - If REJECTED after 2 cycles: Escalate to user with explanation
14023
+ - ONLY AFTER critic approval: Proceed to implementation (Phase 3+)
14024
+ 7. **MANDATORY QA GATE (Execute AFTER every coder task)**:
14025
+ - Step A: {{AGENT_PREFIX}}coder completes implementation \u2192 STOP
14026
+ - Step B: IMMEDIATELY delegate to {{AGENT_PREFIX}}reviewer with CHECK dimensions (security, correctness, edge-cases, etc.)
14027
+ - Step C: Wait for reviewer verdict
14028
+ - If VERDICT: REJECTED \u2192 Send FIXES back to {{AGENT_PREFIX}}coder (return to Step A)
14029
+ - If VERDICT: APPROVED \u2192 Proceed to Step D
14030
+ - Step D: IMMEDIATELY delegate to {{AGENT_PREFIX}}test_engineer to generate and run tests
14031
+ - Step E: Wait for test verdict
14032
+ - If VERDICT: FAIL \u2192 Send failure details back to {{AGENT_PREFIX}}coder (return to Step A)
14033
+ - If VERDICT: PASS \u2192 Mark task complete, proceed to next task
14034
+ 8. **NEVER skip the QA gate**: You cannot delegate to {{AGENT_PREFIX}}coder for a new task until the previous task passes BOTH reviewer approval AND test_engineer verification. The sequence is ALWAYS: coder \u2192 reviewer \u2192 test_engineer \u2192 next_coder.
14019
14035
 
14020
14036
  ## AGENTS
14021
14037
 
@@ -15049,39 +15065,33 @@ function startAgentSession(sessionId, agentName, staleDurationMs = 7200000) {
15049
15065
  }
15050
15066
  const sessionState = {
15051
15067
  agentName,
15052
- startTime: now,
15053
15068
  lastToolCallTime: now,
15054
15069
  lastAgentEventTime: now,
15055
- toolCallCount: 0,
15056
- consecutiveErrors: 0,
15057
- recentToolCalls: [],
15058
- warningIssued: false,
15059
- warningReason: "",
15060
- hardLimitHit: false,
15061
- lastSuccessTime: now,
15062
- delegationActive: false
15070
+ delegationActive: false,
15071
+ activeInvocationId: 0,
15072
+ lastInvocationIdByAgent: {},
15073
+ windows: {}
15063
15074
  };
15064
15075
  swarmState.agentSessions.set(sessionId, sessionState);
15065
15076
  }
15066
- function getAgentSession(sessionId) {
15067
- return swarmState.agentSessions.get(sessionId);
15068
- }
15069
15077
  function ensureAgentSession(sessionId, agentName) {
15070
15078
  const now = Date.now();
15071
15079
  let session = swarmState.agentSessions.get(sessionId);
15072
15080
  if (session) {
15073
15081
  if (agentName && agentName !== session.agentName) {
15074
15082
  session.agentName = agentName;
15075
- session.startTime = now;
15076
- session.toolCallCount = 0;
15077
- session.consecutiveErrors = 0;
15078
- session.recentToolCalls = [];
15079
- session.warningIssued = false;
15080
- session.warningReason = "";
15081
- session.hardLimitHit = false;
15082
- session.lastSuccessTime = now;
15083
15083
  session.delegationActive = false;
15084
15084
  session.lastAgentEventTime = now;
15085
+ if (!session.windows) {
15086
+ session.activeInvocationId = 0;
15087
+ session.lastInvocationIdByAgent = {};
15088
+ session.windows = {};
15089
+ }
15090
+ }
15091
+ if (!session.windows) {
15092
+ session.activeInvocationId = 0;
15093
+ session.lastInvocationIdByAgent = {};
15094
+ session.windows = {};
15085
15095
  }
15086
15096
  session.lastToolCallTime = now;
15087
15097
  return session;
@@ -15099,6 +15109,58 @@ function updateAgentEventTime(sessionId) {
15099
15109
  session.lastAgentEventTime = Date.now();
15100
15110
  }
15101
15111
  }
15112
+ function beginInvocation(sessionId, agentName) {
15113
+ const session = swarmState.agentSessions.get(sessionId);
15114
+ if (!session) {
15115
+ throw new Error(`Cannot begin invocation: session ${sessionId} does not exist`);
15116
+ }
15117
+ const stripped = stripKnownSwarmPrefix(agentName);
15118
+ if (stripped === ORCHESTRATOR_NAME) {
15119
+ return null;
15120
+ }
15121
+ const lastId = session.lastInvocationIdByAgent[stripped] || 0;
15122
+ const newId = lastId + 1;
15123
+ session.lastInvocationIdByAgent[stripped] = newId;
15124
+ session.activeInvocationId = newId;
15125
+ const now = Date.now();
15126
+ const window = {
15127
+ id: newId,
15128
+ agentName: stripped,
15129
+ startedAtMs: now,
15130
+ toolCalls: 0,
15131
+ consecutiveErrors: 0,
15132
+ hardLimitHit: false,
15133
+ lastSuccessTimeMs: now,
15134
+ recentToolCalls: [],
15135
+ warningIssued: false,
15136
+ warningReason: ""
15137
+ };
15138
+ const key = `${stripped}:${newId}`;
15139
+ session.windows[key] = window;
15140
+ pruneOldWindows(sessionId, 24 * 60 * 60 * 1000, 50);
15141
+ return window;
15142
+ }
15143
+ function getActiveWindow(sessionId) {
15144
+ const session = swarmState.agentSessions.get(sessionId);
15145
+ if (!session || !session.windows) {
15146
+ return;
15147
+ }
15148
+ const stripped = stripKnownSwarmPrefix(session.agentName);
15149
+ const key = `${stripped}:${session.activeInvocationId}`;
15150
+ return session.windows[key];
15151
+ }
15152
+ function pruneOldWindows(sessionId, maxAgeMs = 24 * 60 * 60 * 1000, maxWindows = 50) {
15153
+ const session = swarmState.agentSessions.get(sessionId);
15154
+ if (!session || !session.windows) {
15155
+ return;
15156
+ }
15157
+ const now = Date.now();
15158
+ const entries = Object.entries(session.windows);
15159
+ const validByAge = entries.filter(([_, window]) => now - window.startedAtMs < maxAgeMs);
15160
+ const sorted = validByAge.sort((a, b) => b[1].startedAtMs - a[1].startedAtMs);
15161
+ const toKeep = sorted.slice(0, maxWindows);
15162
+ session.windows = Object.fromEntries(toKeep);
15163
+ }
15102
15164
 
15103
15165
  // src/commands/benchmark.ts
15104
15166
  var CI = {
@@ -15119,11 +15181,10 @@ async function handleBenchmarkCommand(directory, args) {
15119
15181
  hardLimits: 0,
15120
15182
  warnings: 0
15121
15183
  };
15122
- e.toolCalls += s.toolCallCount;
15123
- if (s.hardLimitHit)
15124
- e.hardLimits++;
15125
- if (s.warningIssued)
15126
- e.warnings++;
15184
+ const windows = Object.values(s.windows);
15185
+ e.toolCalls += windows.reduce((sum, w) => sum + w.toolCalls, 0);
15186
+ e.hardLimits += windows.filter((w) => w.hardLimitHit).length;
15187
+ e.warnings += windows.filter((w) => w.warningIssued).length;
15127
15188
  agentMap.set(s.agentName, e);
15128
15189
  }
15129
15190
  const agentHealth = Array.from(agentMap.entries()).map(([a, v]) => ({
@@ -16731,6 +16792,29 @@ function createDelegationGateHook(config2) {
16731
16792
  if (batchingMatches && batchingMatches.length > 0) {
16732
16793
  warnings.push("Batching language detected. Break compound objectives into separate coder calls.");
16733
16794
  }
16795
+ const sessionID = lastUserMessage.info?.sessionID;
16796
+ if (sessionID) {
16797
+ const delegationChain = swarmState.delegationChains.get(sessionID);
16798
+ if (delegationChain && delegationChain.length >= 2) {
16799
+ const coderIndices = [];
16800
+ for (let i = delegationChain.length - 1;i >= 0; i--) {
16801
+ if (stripKnownSwarmPrefix(delegationChain[i].to).includes("coder")) {
16802
+ coderIndices.unshift(i);
16803
+ if (coderIndices.length === 2)
16804
+ break;
16805
+ }
16806
+ }
16807
+ if (coderIndices.length === 2) {
16808
+ const prevCoderIndex = coderIndices[0];
16809
+ const betweenCoders = delegationChain.slice(prevCoderIndex + 1);
16810
+ const hasReviewer = betweenCoders.some((d) => stripKnownSwarmPrefix(d.to) === "reviewer");
16811
+ const hasTestEngineer = betweenCoders.some((d) => stripKnownSwarmPrefix(d.to) === "test_engineer");
16812
+ if (!hasReviewer || !hasTestEngineer) {
16813
+ warnings.push(`\u26A0\uFE0F PROTOCOL VIOLATION: Previous coder task completed, but QA gate was skipped. ` + `You MUST delegate to reviewer (code review) and test_engineer (test execution) ` + `before starting a new coder task. Review RULES 7-8 in your system prompt.`);
16814
+ }
16815
+ }
16816
+ }
16817
+ }
16734
16818
  if (warnings.length === 0)
16735
16819
  return;
16736
16820
  const warningText = `[\u26A0\uFE0F DELEGATION GATE: Your coder delegation may be too complex. Issues:
@@ -16749,12 +16833,14 @@ function createDelegationTrackerHook(config2) {
16749
16833
  const now = Date.now();
16750
16834
  if (!input.agent || input.agent === "") {
16751
16835
  const session2 = swarmState.agentSessions.get(input.sessionID);
16752
- if (session2) {
16836
+ if (session2 && session2.delegationActive) {
16753
16837
  session2.delegationActive = false;
16838
+ swarmState.activeAgent.set(input.sessionID, ORCHESTRATOR_NAME);
16839
+ ensureAgentSession(input.sessionID, ORCHESTRATOR_NAME);
16840
+ updateAgentEventTime(input.sessionID);
16841
+ } else if (!session2) {
16842
+ ensureAgentSession(input.sessionID, ORCHESTRATOR_NAME);
16754
16843
  }
16755
- swarmState.activeAgent.set(input.sessionID, ORCHESTRATOR_NAME);
16756
- ensureAgentSession(input.sessionID, ORCHESTRATOR_NAME);
16757
- updateAgentEventTime(input.sessionID);
16758
16844
  return;
16759
16845
  }
16760
16846
  const agentName = input.agent;
@@ -16764,6 +16850,9 @@ function createDelegationTrackerHook(config2) {
16764
16850
  const isArchitect = strippedAgent === ORCHESTRATOR_NAME;
16765
16851
  const session = ensureAgentSession(input.sessionID, agentName);
16766
16852
  session.delegationActive = !isArchitect;
16853
+ if (!isArchitect) {
16854
+ beginInvocation(input.sessionID, agentName);
16855
+ }
16767
16856
  if (config2.hooks?.delegation_tracker === true && previousAgent && previousAgent !== agentName) {
16768
16857
  const entry = {
16769
16858
  from: previousAgent,
@@ -16812,24 +16901,35 @@ function createGuardrailsHooks(config2) {
16812
16901
  if (agentConfig.max_duration_minutes === 0 && agentConfig.max_tool_calls === 0) {
16813
16902
  return;
16814
16903
  }
16815
- if (session.hardLimitHit) {
16904
+ if (!getActiveWindow(input.sessionID)) {
16905
+ const fallbackAgent = swarmState.activeAgent.get(input.sessionID) ?? session.agentName;
16906
+ const stripped = stripKnownSwarmPrefix(fallbackAgent);
16907
+ if (stripped !== ORCHESTRATOR_NAME) {
16908
+ beginInvocation(input.sessionID, fallbackAgent);
16909
+ }
16910
+ }
16911
+ const window = getActiveWindow(input.sessionID);
16912
+ if (!window) {
16913
+ return;
16914
+ }
16915
+ if (window.hardLimitHit) {
16816
16916
  throw new Error("\uD83D\uDED1 CIRCUIT BREAKER: Agent blocked. Hard limit was previously triggered. Stop making tool calls and return your progress summary.");
16817
16917
  }
16818
- session.toolCallCount++;
16918
+ window.toolCalls++;
16819
16919
  const hash2 = hashArgs(output.args);
16820
- session.recentToolCalls.push({
16920
+ window.recentToolCalls.push({
16821
16921
  tool: input.tool,
16822
16922
  argsHash: hash2,
16823
16923
  timestamp: Date.now()
16824
16924
  });
16825
- if (session.recentToolCalls.length > 20) {
16826
- session.recentToolCalls.shift();
16925
+ if (window.recentToolCalls.length > 20) {
16926
+ window.recentToolCalls.shift();
16827
16927
  }
16828
16928
  let repetitionCount = 0;
16829
- if (session.recentToolCalls.length > 0) {
16830
- const lastEntry = session.recentToolCalls[session.recentToolCalls.length - 1];
16831
- for (let i = session.recentToolCalls.length - 1;i >= 0; i--) {
16832
- const entry = session.recentToolCalls[i];
16929
+ if (window.recentToolCalls.length > 0) {
16930
+ const lastEntry = window.recentToolCalls[window.recentToolCalls.length - 1];
16931
+ for (let i = window.recentToolCalls.length - 1;i >= 0; i--) {
16932
+ const entry = window.recentToolCalls[i];
16833
16933
  if (entry.tool === lastEntry.tool && entry.argsHash === lastEntry.argsHash) {
16834
16934
  repetitionCount++;
16835
16935
  } else {
@@ -16837,54 +16937,60 @@ function createGuardrailsHooks(config2) {
16837
16937
  }
16838
16938
  }
16839
16939
  }
16840
- const elapsedMinutes = (Date.now() - session.startTime) / 60000;
16841
- if (agentConfig.max_tool_calls > 0 && session.toolCallCount >= agentConfig.max_tool_calls) {
16842
- session.hardLimitHit = true;
16940
+ const elapsedMinutes = (Date.now() - window.startedAtMs) / 60000;
16941
+ if (agentConfig.max_tool_calls > 0 && window.toolCalls >= agentConfig.max_tool_calls) {
16942
+ window.hardLimitHit = true;
16843
16943
  warn("Circuit breaker: tool call limit hit", {
16844
16944
  sessionID: input.sessionID,
16845
- agentName: session.agentName,
16945
+ agentName: window.agentName,
16946
+ invocationId: window.id,
16947
+ windowKey: `${window.agentName}:${window.id}`,
16846
16948
  resolvedMaxCalls: agentConfig.max_tool_calls,
16847
- currentCalls: session.toolCallCount
16949
+ currentCalls: window.toolCalls
16848
16950
  });
16849
- throw new Error(`\uD83D\uDED1 LIMIT REACHED: Tool calls exhausted (${session.toolCallCount}/${agentConfig.max_tool_calls}). Finish the current operation and return your progress summary.`);
16951
+ throw new Error(`\uD83D\uDED1 LIMIT REACHED: Tool calls exhausted (${window.toolCalls}/${agentConfig.max_tool_calls}). Finish the current operation and return your progress summary.`);
16850
16952
  }
16851
16953
  if (agentConfig.max_duration_minutes > 0 && elapsedMinutes >= agentConfig.max_duration_minutes) {
16852
- session.hardLimitHit = true;
16954
+ window.hardLimitHit = true;
16853
16955
  warn("Circuit breaker: duration limit hit", {
16854
16956
  sessionID: input.sessionID,
16855
- agentName: session.agentName,
16957
+ agentName: window.agentName,
16958
+ invocationId: window.id,
16959
+ windowKey: `${window.agentName}:${window.id}`,
16856
16960
  resolvedMaxMinutes: agentConfig.max_duration_minutes,
16857
16961
  elapsedMinutes: Math.floor(elapsedMinutes)
16858
16962
  });
16859
16963
  throw new Error(`\uD83D\uDED1 LIMIT REACHED: Duration exhausted (${Math.floor(elapsedMinutes)}/${agentConfig.max_duration_minutes} min). Finish the current operation and return your progress summary.`);
16860
16964
  }
16861
16965
  if (repetitionCount >= agentConfig.max_repetitions) {
16862
- session.hardLimitHit = true;
16966
+ window.hardLimitHit = true;
16863
16967
  throw new Error(`\uD83D\uDED1 LIMIT REACHED: Repeated the same tool call ${repetitionCount} times. This suggests a loop. Return your progress summary.`);
16864
16968
  }
16865
- if (session.consecutiveErrors >= agentConfig.max_consecutive_errors) {
16866
- session.hardLimitHit = true;
16867
- throw new Error(`\uD83D\uDED1 LIMIT REACHED: ${session.consecutiveErrors} consecutive tool errors detected. Return your progress summary with details of what went wrong.`);
16969
+ if (window.consecutiveErrors >= agentConfig.max_consecutive_errors) {
16970
+ window.hardLimitHit = true;
16971
+ throw new Error(`\uD83D\uDED1 LIMIT REACHED: ${window.consecutiveErrors} consecutive tool errors detected. Return your progress summary with details of what went wrong.`);
16868
16972
  }
16869
- const idleMinutes = (Date.now() - session.lastSuccessTime) / 60000;
16973
+ const idleMinutes = (Date.now() - window.lastSuccessTimeMs) / 60000;
16870
16974
  if (idleMinutes >= agentConfig.idle_timeout_minutes) {
16871
- session.hardLimitHit = true;
16975
+ window.hardLimitHit = true;
16872
16976
  warn("Circuit breaker: idle timeout hit", {
16873
16977
  sessionID: input.sessionID,
16874
- agentName: session.agentName,
16978
+ agentName: window.agentName,
16979
+ invocationId: window.id,
16980
+ windowKey: `${window.agentName}:${window.id}`,
16875
16981
  idleTimeoutMinutes: agentConfig.idle_timeout_minutes,
16876
16982
  idleMinutes: Math.floor(idleMinutes)
16877
16983
  });
16878
16984
  throw new Error(`\uD83D\uDED1 LIMIT REACHED: No successful tool call for ${Math.floor(idleMinutes)} minutes (idle timeout: ${agentConfig.idle_timeout_minutes} min). This suggests the agent may be stuck. Return your progress summary.`);
16879
16985
  }
16880
- if (!session.warningIssued) {
16881
- const toolPct = agentConfig.max_tool_calls > 0 ? session.toolCallCount / agentConfig.max_tool_calls : 0;
16986
+ if (!window.warningIssued) {
16987
+ const toolPct = agentConfig.max_tool_calls > 0 ? window.toolCalls / agentConfig.max_tool_calls : 0;
16882
16988
  const durationPct = agentConfig.max_duration_minutes > 0 ? elapsedMinutes / agentConfig.max_duration_minutes : 0;
16883
16989
  const repPct = repetitionCount / agentConfig.max_repetitions;
16884
- const errorPct = session.consecutiveErrors / agentConfig.max_consecutive_errors;
16990
+ const errorPct = window.consecutiveErrors / agentConfig.max_consecutive_errors;
16885
16991
  const reasons = [];
16886
16992
  if (agentConfig.max_tool_calls > 0 && toolPct >= agentConfig.warning_threshold) {
16887
- reasons.push(`tool calls ${session.toolCallCount}/${agentConfig.max_tool_calls}`);
16993
+ reasons.push(`tool calls ${window.toolCalls}/${agentConfig.max_tool_calls}`);
16888
16994
  }
16889
16995
  if (durationPct >= agentConfig.warning_threshold) {
16890
16996
  reasons.push(`duration ${Math.floor(elapsedMinutes)}/${agentConfig.max_duration_minutes} min`);
@@ -16893,25 +16999,24 @@ function createGuardrailsHooks(config2) {
16893
16999
  reasons.push(`repetitions ${repetitionCount}/${agentConfig.max_repetitions}`);
16894
17000
  }
16895
17001
  if (errorPct >= agentConfig.warning_threshold) {
16896
- reasons.push(`errors ${session.consecutiveErrors}/${agentConfig.max_consecutive_errors}`);
17002
+ reasons.push(`errors ${window.consecutiveErrors}/${agentConfig.max_consecutive_errors}`);
16897
17003
  }
16898
17004
  if (reasons.length > 0) {
16899
- session.warningIssued = true;
16900
- session.warningReason = reasons.join(", ");
17005
+ window.warningIssued = true;
17006
+ window.warningReason = reasons.join(", ");
16901
17007
  }
16902
17008
  }
16903
17009
  },
16904
17010
  toolAfter: async (input, output) => {
16905
- const session = getAgentSession(input.sessionID);
16906
- if (!session) {
17011
+ const window = getActiveWindow(input.sessionID);
17012
+ if (!window)
16907
17013
  return;
16908
- }
16909
17014
  const hasError = output.output === null || output.output === undefined;
16910
17015
  if (hasError) {
16911
- session.consecutiveErrors++;
17016
+ window.consecutiveErrors++;
16912
17017
  } else {
16913
- session.consecutiveErrors = 0;
16914
- session.lastSuccessTime = Date.now();
17018
+ window.consecutiveErrors = 0;
17019
+ window.lastSuccessTimeMs = Date.now();
16915
17020
  }
16916
17021
  },
16917
17022
  messagesTransform: async (_input, output) => {
@@ -16921,31 +17026,30 @@ function createGuardrailsHooks(config2) {
16921
17026
  }
16922
17027
  const lastMessage = messages[messages.length - 1];
16923
17028
  let sessionId = lastMessage.info?.sessionID;
16924
- if (!sessionId) {
16925
- for (const [id, session2] of swarmState.agentSessions) {
16926
- if (session2.warningIssued || session2.hardLimitHit) {
17029
+ let targetWindow = sessionId ? getActiveWindow(sessionId) : undefined;
17030
+ if (!targetWindow || !targetWindow.warningIssued && !targetWindow.hardLimitHit) {
17031
+ for (const [id] of swarmState.agentSessions) {
17032
+ const window = getActiveWindow(id);
17033
+ if (window && (window.warningIssued || window.hardLimitHit)) {
17034
+ targetWindow = window;
16927
17035
  sessionId = id;
16928
17036
  break;
16929
17037
  }
16930
17038
  }
16931
17039
  }
16932
- if (!sessionId) {
16933
- return;
16934
- }
16935
- const session = getAgentSession(sessionId);
16936
- if (!session || !session.warningIssued && !session.hardLimitHit) {
17040
+ if (!targetWindow || !targetWindow.warningIssued && !targetWindow.hardLimitHit) {
16937
17041
  return;
16938
17042
  }
16939
17043
  const textPart = lastMessage.parts.find((part) => part.type === "text" && typeof part.text === "string");
16940
17044
  if (!textPart) {
16941
17045
  return;
16942
17046
  }
16943
- if (session.hardLimitHit) {
17047
+ if (targetWindow.hardLimitHit) {
16944
17048
  textPart.text = `[\uD83D\uDED1 LIMIT REACHED: Your resource budget is exhausted. Do not make additional tool calls. Return a summary of your progress and any remaining work.]
16945
17049
 
16946
17050
  ` + textPart.text;
16947
- } else if (session.warningIssued) {
16948
- const reasonSuffix = session.warningReason ? ` (${session.warningReason})` : "";
17051
+ } else if (targetWindow.warningIssued) {
17052
+ const reasonSuffix = targetWindow.warningReason ? ` (${targetWindow.warningReason})` : "";
16949
17053
  textPart.text = `[\u26A0\uFE0F APPROACHING LIMITS${reasonSuffix}: You still have capacity to finish your current step. Complete what you're working on, then return your results.]
16950
17054
 
16951
17055
  ` + textPart.text;
package/dist/state.d.ts CHANGED
@@ -34,37 +34,56 @@ export interface DelegationEntry {
34
34
  timestamp: number;
35
35
  }
36
36
  /**
37
- * Represents per-session state for guardrail tracking
37
+ * Represents per-session state for guardrail tracking.
38
+ * Budget fields (toolCallCount, consecutiveErrors, etc.) have moved to InvocationWindow.
39
+ * This interface now tracks session-level metadata and window management.
38
40
  */
39
41
  export interface AgentSessionState {
40
- /** Which agent this session belongs to */
42
+ /** Current agent identity for this session */
41
43
  agentName: string;
42
- /** Date.now() when session started */
43
- startTime: number;
44
- /** Timestamp of most recent tool call (for stale session eviction) */
44
+ /** Timestamp of most recent tool call (for session-level stale detection) */
45
45
  lastToolCallTime: number;
46
- /** Timestamp of most recent agent identity event (chat.message sets/changes identity) */
46
+ /** Timestamp of most recent agent identity event (chat.message) */
47
47
  lastAgentEventTime: number;
48
- /** Total tool calls in this session */
49
- toolCallCount: number;
50
- /** Consecutive errors (reset on success) */
48
+ /** Whether active delegation is in progress for this session */
49
+ delegationActive: boolean;
50
+ /** Current active invocation ID for this agent */
51
+ activeInvocationId: number;
52
+ /** Last invocation ID by agent name (e.g., { "coder": 3, "reviewer": 1 }) */
53
+ lastInvocationIdByAgent: Record<string, number>;
54
+ /** Active invocation windows keyed by "${agentName}:${invId}" */
55
+ windows: Record<string, InvocationWindow>;
56
+ }
57
+ /**
58
+ * Represents a single agent invocation window with isolated guardrail budgets.
59
+ * Each time the architect delegates to an agent, a new window is created.
60
+ * Architect never creates windows (unlimited).
61
+ */
62
+ export interface InvocationWindow {
63
+ /** Unique ID for this invocation (increments per agent type) */
64
+ id: number;
65
+ /** Agent name (stripped of swarm prefix) */
66
+ agentName: string;
67
+ /** Timestamp when this invocation started */
68
+ startedAtMs: number;
69
+ /** Tool calls made in this invocation */
70
+ toolCalls: number;
71
+ /** Consecutive errors in this invocation */
51
72
  consecutiveErrors: number;
52
- /** Circular buffer of recent tool calls, max 20 entries */
73
+ /** Whether hard limit was hit for this invocation */
74
+ hardLimitHit: boolean;
75
+ /** Timestamp of most recent successful tool call */
76
+ lastSuccessTimeMs: number;
77
+ /** Circular buffer of recent tool calls (max 20) for repetition detection */
53
78
  recentToolCalls: Array<{
54
79
  tool: string;
55
80
  argsHash: number;
56
81
  timestamp: number;
57
82
  }>;
58
- /** Whether a soft warning has been issued */
83
+ /** Whether soft warning has been issued for this invocation */
59
84
  warningIssued: boolean;
60
- /** Human-readable warning reason (set when warningIssued = true) */
85
+ /** Human-readable warning reason */
61
86
  warningReason: string;
62
- /** Whether a hard limit has been triggered */
63
- hardLimitHit: boolean;
64
- /** Timestamp of most recent SUCCESSFUL tool call (for idle timeout) */
65
- lastSuccessTime: number;
66
- /** Whether active delegation is in progress for this session */
67
- delegationActive: boolean;
68
87
  }
69
88
  /**
70
89
  * Singleton state object for sharing data across hooks
@@ -122,3 +141,30 @@ export declare function ensureAgentSession(sessionId: string, agentName?: string
122
141
  * @param sessionId - The session identifier
123
142
  */
124
143
  export declare function updateAgentEventTime(sessionId: string): void;
144
+ /**
145
+ * Begin a new invocation window for the given agent.
146
+ * Increments invocation ID, creates fresh budget counters.
147
+ * Returns null for architect (unlimited, no window).
148
+ *
149
+ * @param sessionId - Session identifier
150
+ * @param agentName - Agent name (with or without swarm prefix)
151
+ * @returns New window or null if architect
152
+ */
153
+ export declare function beginInvocation(sessionId: string, agentName: string): InvocationWindow | null;
154
+ /**
155
+ * Get the currently active invocation window for the session.
156
+ * Returns undefined if no window exists (e.g., architect session).
157
+ *
158
+ * @param sessionId - Session identifier
159
+ * @returns Active window or undefined
160
+ */
161
+ export declare function getActiveWindow(sessionId: string): InvocationWindow | undefined;
162
+ /**
163
+ * Prune old invocation windows to prevent unbounded memory growth.
164
+ * Removes windows older than maxAgeMs and keeps only the most recent maxWindows.
165
+ *
166
+ * @param sessionId - Session identifier
167
+ * @param maxAgeMs - Maximum age in milliseconds (default 24 hours)
168
+ * @param maxWindows - Maximum number of windows to keep (default 50)
169
+ */
170
+ export declare function pruneOldWindows(sessionId: string, maxAgeMs?: number, maxWindows?: number): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-swarm",
3
- "version": "5.1.7",
3
+ "version": "5.2.0",
4
4
  "description": "Architect-centric agentic swarm plugin for OpenCode - hub-and-spoke orchestration with SME consultation, code generation, and QA review",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",