opencode-swarm 5.1.8 → 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 +21 -3
- package/dist/index.js +176 -74
- package/dist/state.d.ts +64 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://img.shields.io/badge/version-5.
|
|
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-
|
|
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
|
-
|
|
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. **
|
|
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
|
-
|
|
15056
|
-
|
|
15057
|
-
|
|
15058
|
-
|
|
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
|
-
|
|
15123
|
-
|
|
15124
|
-
|
|
15125
|
-
|
|
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:
|
|
@@ -16766,6 +16850,9 @@ function createDelegationTrackerHook(config2) {
|
|
|
16766
16850
|
const isArchitect = strippedAgent === ORCHESTRATOR_NAME;
|
|
16767
16851
|
const session = ensureAgentSession(input.sessionID, agentName);
|
|
16768
16852
|
session.delegationActive = !isArchitect;
|
|
16853
|
+
if (!isArchitect) {
|
|
16854
|
+
beginInvocation(input.sessionID, agentName);
|
|
16855
|
+
}
|
|
16769
16856
|
if (config2.hooks?.delegation_tracker === true && previousAgent && previousAgent !== agentName) {
|
|
16770
16857
|
const entry = {
|
|
16771
16858
|
from: previousAgent,
|
|
@@ -16814,24 +16901,35 @@ function createGuardrailsHooks(config2) {
|
|
|
16814
16901
|
if (agentConfig.max_duration_minutes === 0 && agentConfig.max_tool_calls === 0) {
|
|
16815
16902
|
return;
|
|
16816
16903
|
}
|
|
16817
|
-
if (
|
|
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) {
|
|
16818
16916
|
throw new Error("\uD83D\uDED1 CIRCUIT BREAKER: Agent blocked. Hard limit was previously triggered. Stop making tool calls and return your progress summary.");
|
|
16819
16917
|
}
|
|
16820
|
-
|
|
16918
|
+
window.toolCalls++;
|
|
16821
16919
|
const hash2 = hashArgs(output.args);
|
|
16822
|
-
|
|
16920
|
+
window.recentToolCalls.push({
|
|
16823
16921
|
tool: input.tool,
|
|
16824
16922
|
argsHash: hash2,
|
|
16825
16923
|
timestamp: Date.now()
|
|
16826
16924
|
});
|
|
16827
|
-
if (
|
|
16828
|
-
|
|
16925
|
+
if (window.recentToolCalls.length > 20) {
|
|
16926
|
+
window.recentToolCalls.shift();
|
|
16829
16927
|
}
|
|
16830
16928
|
let repetitionCount = 0;
|
|
16831
|
-
if (
|
|
16832
|
-
const lastEntry =
|
|
16833
|
-
for (let i =
|
|
16834
|
-
const entry =
|
|
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];
|
|
16835
16933
|
if (entry.tool === lastEntry.tool && entry.argsHash === lastEntry.argsHash) {
|
|
16836
16934
|
repetitionCount++;
|
|
16837
16935
|
} else {
|
|
@@ -16839,54 +16937,60 @@ function createGuardrailsHooks(config2) {
|
|
|
16839
16937
|
}
|
|
16840
16938
|
}
|
|
16841
16939
|
}
|
|
16842
|
-
const elapsedMinutes = (Date.now() -
|
|
16843
|
-
if (agentConfig.max_tool_calls > 0 &&
|
|
16844
|
-
|
|
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;
|
|
16845
16943
|
warn("Circuit breaker: tool call limit hit", {
|
|
16846
16944
|
sessionID: input.sessionID,
|
|
16847
|
-
agentName:
|
|
16945
|
+
agentName: window.agentName,
|
|
16946
|
+
invocationId: window.id,
|
|
16947
|
+
windowKey: `${window.agentName}:${window.id}`,
|
|
16848
16948
|
resolvedMaxCalls: agentConfig.max_tool_calls,
|
|
16849
|
-
currentCalls:
|
|
16949
|
+
currentCalls: window.toolCalls
|
|
16850
16950
|
});
|
|
16851
|
-
throw new Error(`\uD83D\uDED1 LIMIT REACHED: Tool calls exhausted (${
|
|
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.`);
|
|
16852
16952
|
}
|
|
16853
16953
|
if (agentConfig.max_duration_minutes > 0 && elapsedMinutes >= agentConfig.max_duration_minutes) {
|
|
16854
|
-
|
|
16954
|
+
window.hardLimitHit = true;
|
|
16855
16955
|
warn("Circuit breaker: duration limit hit", {
|
|
16856
16956
|
sessionID: input.sessionID,
|
|
16857
|
-
agentName:
|
|
16957
|
+
agentName: window.agentName,
|
|
16958
|
+
invocationId: window.id,
|
|
16959
|
+
windowKey: `${window.agentName}:${window.id}`,
|
|
16858
16960
|
resolvedMaxMinutes: agentConfig.max_duration_minutes,
|
|
16859
16961
|
elapsedMinutes: Math.floor(elapsedMinutes)
|
|
16860
16962
|
});
|
|
16861
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.`);
|
|
16862
16964
|
}
|
|
16863
16965
|
if (repetitionCount >= agentConfig.max_repetitions) {
|
|
16864
|
-
|
|
16966
|
+
window.hardLimitHit = true;
|
|
16865
16967
|
throw new Error(`\uD83D\uDED1 LIMIT REACHED: Repeated the same tool call ${repetitionCount} times. This suggests a loop. Return your progress summary.`);
|
|
16866
16968
|
}
|
|
16867
|
-
if (
|
|
16868
|
-
|
|
16869
|
-
throw new Error(`\uD83D\uDED1 LIMIT REACHED: ${
|
|
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.`);
|
|
16870
16972
|
}
|
|
16871
|
-
const idleMinutes = (Date.now() -
|
|
16973
|
+
const idleMinutes = (Date.now() - window.lastSuccessTimeMs) / 60000;
|
|
16872
16974
|
if (idleMinutes >= agentConfig.idle_timeout_minutes) {
|
|
16873
|
-
|
|
16975
|
+
window.hardLimitHit = true;
|
|
16874
16976
|
warn("Circuit breaker: idle timeout hit", {
|
|
16875
16977
|
sessionID: input.sessionID,
|
|
16876
|
-
agentName:
|
|
16978
|
+
agentName: window.agentName,
|
|
16979
|
+
invocationId: window.id,
|
|
16980
|
+
windowKey: `${window.agentName}:${window.id}`,
|
|
16877
16981
|
idleTimeoutMinutes: agentConfig.idle_timeout_minutes,
|
|
16878
16982
|
idleMinutes: Math.floor(idleMinutes)
|
|
16879
16983
|
});
|
|
16880
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.`);
|
|
16881
16985
|
}
|
|
16882
|
-
if (!
|
|
16883
|
-
const toolPct = agentConfig.max_tool_calls > 0 ?
|
|
16986
|
+
if (!window.warningIssued) {
|
|
16987
|
+
const toolPct = agentConfig.max_tool_calls > 0 ? window.toolCalls / agentConfig.max_tool_calls : 0;
|
|
16884
16988
|
const durationPct = agentConfig.max_duration_minutes > 0 ? elapsedMinutes / agentConfig.max_duration_minutes : 0;
|
|
16885
16989
|
const repPct = repetitionCount / agentConfig.max_repetitions;
|
|
16886
|
-
const errorPct =
|
|
16990
|
+
const errorPct = window.consecutiveErrors / agentConfig.max_consecutive_errors;
|
|
16887
16991
|
const reasons = [];
|
|
16888
16992
|
if (agentConfig.max_tool_calls > 0 && toolPct >= agentConfig.warning_threshold) {
|
|
16889
|
-
reasons.push(`tool calls ${
|
|
16993
|
+
reasons.push(`tool calls ${window.toolCalls}/${agentConfig.max_tool_calls}`);
|
|
16890
16994
|
}
|
|
16891
16995
|
if (durationPct >= agentConfig.warning_threshold) {
|
|
16892
16996
|
reasons.push(`duration ${Math.floor(elapsedMinutes)}/${agentConfig.max_duration_minutes} min`);
|
|
@@ -16895,25 +16999,24 @@ function createGuardrailsHooks(config2) {
|
|
|
16895
16999
|
reasons.push(`repetitions ${repetitionCount}/${agentConfig.max_repetitions}`);
|
|
16896
17000
|
}
|
|
16897
17001
|
if (errorPct >= agentConfig.warning_threshold) {
|
|
16898
|
-
reasons.push(`errors ${
|
|
17002
|
+
reasons.push(`errors ${window.consecutiveErrors}/${agentConfig.max_consecutive_errors}`);
|
|
16899
17003
|
}
|
|
16900
17004
|
if (reasons.length > 0) {
|
|
16901
|
-
|
|
16902
|
-
|
|
17005
|
+
window.warningIssued = true;
|
|
17006
|
+
window.warningReason = reasons.join(", ");
|
|
16903
17007
|
}
|
|
16904
17008
|
}
|
|
16905
17009
|
},
|
|
16906
17010
|
toolAfter: async (input, output) => {
|
|
16907
|
-
const
|
|
16908
|
-
if (!
|
|
17011
|
+
const window = getActiveWindow(input.sessionID);
|
|
17012
|
+
if (!window)
|
|
16909
17013
|
return;
|
|
16910
|
-
}
|
|
16911
17014
|
const hasError = output.output === null || output.output === undefined;
|
|
16912
17015
|
if (hasError) {
|
|
16913
|
-
|
|
17016
|
+
window.consecutiveErrors++;
|
|
16914
17017
|
} else {
|
|
16915
|
-
|
|
16916
|
-
|
|
17018
|
+
window.consecutiveErrors = 0;
|
|
17019
|
+
window.lastSuccessTimeMs = Date.now();
|
|
16917
17020
|
}
|
|
16918
17021
|
},
|
|
16919
17022
|
messagesTransform: async (_input, output) => {
|
|
@@ -16923,31 +17026,30 @@ function createGuardrailsHooks(config2) {
|
|
|
16923
17026
|
}
|
|
16924
17027
|
const lastMessage = messages[messages.length - 1];
|
|
16925
17028
|
let sessionId = lastMessage.info?.sessionID;
|
|
16926
|
-
|
|
16927
|
-
|
|
16928
|
-
|
|
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;
|
|
16929
17035
|
sessionId = id;
|
|
16930
17036
|
break;
|
|
16931
17037
|
}
|
|
16932
17038
|
}
|
|
16933
17039
|
}
|
|
16934
|
-
if (!
|
|
16935
|
-
return;
|
|
16936
|
-
}
|
|
16937
|
-
const session = getAgentSession(sessionId);
|
|
16938
|
-
if (!session || !session.warningIssued && !session.hardLimitHit) {
|
|
17040
|
+
if (!targetWindow || !targetWindow.warningIssued && !targetWindow.hardLimitHit) {
|
|
16939
17041
|
return;
|
|
16940
17042
|
}
|
|
16941
17043
|
const textPart = lastMessage.parts.find((part) => part.type === "text" && typeof part.text === "string");
|
|
16942
17044
|
if (!textPart) {
|
|
16943
17045
|
return;
|
|
16944
17046
|
}
|
|
16945
|
-
if (
|
|
17047
|
+
if (targetWindow.hardLimitHit) {
|
|
16946
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.]
|
|
16947
17049
|
|
|
16948
17050
|
` + textPart.text;
|
|
16949
|
-
} else if (
|
|
16950
|
-
const reasonSuffix =
|
|
17051
|
+
} else if (targetWindow.warningIssued) {
|
|
17052
|
+
const reasonSuffix = targetWindow.warningReason ? ` (${targetWindow.warningReason})` : "";
|
|
16951
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.]
|
|
16952
17054
|
|
|
16953
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
|
-
/**
|
|
42
|
+
/** Current agent identity for this session */
|
|
41
43
|
agentName: string;
|
|
42
|
-
/**
|
|
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
|
|
46
|
+
/** Timestamp of most recent agent identity event (chat.message) */
|
|
47
47
|
lastAgentEventTime: number;
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
83
|
+
/** Whether soft warning has been issued for this invocation */
|
|
59
84
|
warningIssued: boolean;
|
|
60
|
-
/** Human-readable warning reason
|
|
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.
|
|
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",
|