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 +21 -3
- package/dist/index.js +182 -78
- 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:
|
|
@@ -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 (
|
|
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
|
-
|
|
16918
|
+
window.toolCalls++;
|
|
16819
16919
|
const hash2 = hashArgs(output.args);
|
|
16820
|
-
|
|
16920
|
+
window.recentToolCalls.push({
|
|
16821
16921
|
tool: input.tool,
|
|
16822
16922
|
argsHash: hash2,
|
|
16823
16923
|
timestamp: Date.now()
|
|
16824
16924
|
});
|
|
16825
|
-
if (
|
|
16826
|
-
|
|
16925
|
+
if (window.recentToolCalls.length > 20) {
|
|
16926
|
+
window.recentToolCalls.shift();
|
|
16827
16927
|
}
|
|
16828
16928
|
let repetitionCount = 0;
|
|
16829
|
-
if (
|
|
16830
|
-
const lastEntry =
|
|
16831
|
-
for (let i =
|
|
16832
|
-
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];
|
|
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() -
|
|
16841
|
-
if (agentConfig.max_tool_calls > 0 &&
|
|
16842
|
-
|
|
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:
|
|
16945
|
+
agentName: window.agentName,
|
|
16946
|
+
invocationId: window.id,
|
|
16947
|
+
windowKey: `${window.agentName}:${window.id}`,
|
|
16846
16948
|
resolvedMaxCalls: agentConfig.max_tool_calls,
|
|
16847
|
-
currentCalls:
|
|
16949
|
+
currentCalls: window.toolCalls
|
|
16848
16950
|
});
|
|
16849
|
-
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.`);
|
|
16850
16952
|
}
|
|
16851
16953
|
if (agentConfig.max_duration_minutes > 0 && elapsedMinutes >= agentConfig.max_duration_minutes) {
|
|
16852
|
-
|
|
16954
|
+
window.hardLimitHit = true;
|
|
16853
16955
|
warn("Circuit breaker: duration limit hit", {
|
|
16854
16956
|
sessionID: input.sessionID,
|
|
16855
|
-
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
|
-
|
|
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 (
|
|
16866
|
-
|
|
16867
|
-
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.`);
|
|
16868
16972
|
}
|
|
16869
|
-
const idleMinutes = (Date.now() -
|
|
16973
|
+
const idleMinutes = (Date.now() - window.lastSuccessTimeMs) / 60000;
|
|
16870
16974
|
if (idleMinutes >= agentConfig.idle_timeout_minutes) {
|
|
16871
|
-
|
|
16975
|
+
window.hardLimitHit = true;
|
|
16872
16976
|
warn("Circuit breaker: idle timeout hit", {
|
|
16873
16977
|
sessionID: input.sessionID,
|
|
16874
|
-
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 (!
|
|
16881
|
-
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;
|
|
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 =
|
|
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 ${
|
|
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 ${
|
|
17002
|
+
reasons.push(`errors ${window.consecutiveErrors}/${agentConfig.max_consecutive_errors}`);
|
|
16897
17003
|
}
|
|
16898
17004
|
if (reasons.length > 0) {
|
|
16899
|
-
|
|
16900
|
-
|
|
17005
|
+
window.warningIssued = true;
|
|
17006
|
+
window.warningReason = reasons.join(", ");
|
|
16901
17007
|
}
|
|
16902
17008
|
}
|
|
16903
17009
|
},
|
|
16904
17010
|
toolAfter: async (input, output) => {
|
|
16905
|
-
const
|
|
16906
|
-
if (!
|
|
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
|
-
|
|
17016
|
+
window.consecutiveErrors++;
|
|
16912
17017
|
} else {
|
|
16913
|
-
|
|
16914
|
-
|
|
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
|
-
|
|
16925
|
-
|
|
16926
|
-
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
16948
|
-
const reasonSuffix =
|
|
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
|
-
/**
|
|
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",
|