diff --git a/README.md b/README.md index 8413017..0f2d063 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,9 @@ in `kit.json`) converges, on every `ak sync`: - **Lifecycle hooks** — `~/.config/opencode/plugins/ruflo-hooks.js`: session restore/end, best-effort bash safety screening (defense-in-depth, fail-open if the local handler is unavailable), edit/task outcome recording for ruflo's learning substrate - (opencode has no settings-hooks surface; its plugin events are the hook spine). + (opencode has no settings-hooks surface; its plugin events are the hook spine), plus a bounded + repeated-tool guard. Three identical completed tool/argument/output calls in one user turn stop + a fourth identical attempt; changed calls, outputs, user turns, and sessions do not collide. - **Lazy specialists + skills** — one receipt-owned `ak-specialist` subagent replaces the eager 107-profile task catalogue. The complete converted catalogue is embedded in the gateway and reached through `ak_agent_search`, stock OpenCode `task`, and `ak_agent_load`. Installed skills diff --git a/docs/adr/0017-opencode-host.md b/docs/adr/0017-opencode-host.md index 79a724f..63c8e27 100644 --- a/docs/adr/0017-opencode-host.md +++ b/docs/adr/0017-opencode-host.md @@ -3,7 +3,7 @@ - **Status:** Accepted; compatibility references amended by [ADR-0020](0020-ga-stable-surfaces.md) - **Date:** 2026-07-28 -- **Updated:** 2026-08-15 +- **Updated:** 2026-08-17 - **Update note:** Clarified that the AQE boundary applies to inference-provider routing, not AQE's upstream OpenCode platform assets, and recorded the implemented OpenCode transcript, token, observed-cost, and provider-id analytics path. ADR-0023 adds classified SQLite source @@ -11,7 +11,8 @@ and requires pre-mutation disclosure of OpenCode's wildcard approvals, MCP registrations, lifecycle plugin, and managed host assets. The 2026-08-15 amendment keeps Ruflo and Agentic QE connected in stock OpenCode while blacklisting their eager tool catalogues from model requests - and projecting a compact, lazy Agentic Kit gateway instead. + and projecting a compact, lazy Agentic Kit gateway instead. The 2026-08-17 amendment adds a + bounded cross-assistant-message repeated-tool guard to the managed lifecycle plugin. - **Deciders:** agentic-kit maintainers > **GA amendment:** OpenCode remains opt-in, non-primary, and outside AQE inference-provider @@ -177,8 +178,16 @@ Every ak-managed byte on opencode's surfaces lives behind one module, following content-diffed (`deployPlugin`), refreshed whenever the template changes, and **no-clobber**: only content matching the exact last-written SHA-256 receipt may be refreshed or removed; marker-bearing user edits are preserved and reported. Failure - policy: hooks never break the host. Bash screening is explicitly defense-in-depth and - fails open when the local handler errors or times out. + policy: lifecycle, routing, and learning integration failures never break the host. Bash + screening is explicitly defense-in-depth and fails open when the local handler errors or times + out. One host-local safety condition fails closed: after three completed calls with the same + tool name, recursively canonicalized arguments, and exact output in one user turn, a fourth + identical call aborts that session. A changed tool/argument/output or new user message resets + the trailing streak; sessions are isolated and compaction does not erase it. This closes stock + OpenCode 1.18.18's current-message-only detector gap without copying the over-broad upstream + proposal that counts nonconsecutive matches anywhere in compacted history. Focused tests cover + reordered object keys, changed-call/output reset, user-turn reset, session isolation, and the + wired session-abort path. - **Agents converted into a lazy receipt-owned catalogue:** `convertAgents` normalizes the complete upstream profile set, deterministically resolves name collisions, and embeds the resulting metadata and bodies in the exact-receipted gateway. OpenCode scans only one managed diff --git a/src/templates/opencode-ruflo-hooks.js b/src/templates/opencode-ruflo-hooks.js index fef09ac..427a697 100644 --- a/src/templates/opencode-ruflo-hooks.js +++ b/src/templates/opencode-ruflo-hooks.js @@ -15,8 +15,9 @@ // task before/after → pre-task/post-task(route subagent work, feed learning) // chat.message → route (inject routing recommendation) // -// Failure policy: hooks NEVER break opencode. Only an explicit [BLOCKED] -// verdict from pre-bash blocks a tool call; every other error is swallowed. +// Failure policy: lifecycle and learning hooks never break opencode. An +// explicit [BLOCKED] pre-bash verdict or a proven repeated tool+args+output +// loop stops the active tool call; every other integration error is swallowed. import { spawn } from "node:child_process" import fs from "node:fs" @@ -27,6 +28,82 @@ const HOOK_TIMEOUT_MS = 4500 const ROUTE_MIN_PROMPT = 12 const ROUTE_MAX_INJECT = 1200 const TRIVIAL_PROMPT = /^(yes|y|ok|k|sure|continue|go ahead|proceed|lgtm|next)[.!]?$/i +const TOOL_LOOP_THRESHOLD = 3 +const TOOL_LOOP_SESSION_LIMIT = 256 + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]` + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}` + } + const encoded = JSON.stringify(value) + return encoded === undefined ? String(value) : encoded +} + +function trimOldest(map, limit) { + while (map.size > limit) map.delete(map.keys().next().value) +} + +// OpenCode 1.18.18's native detector only inspects parts on the current +// assistant message. Tool-driven agent turns create a new assistant message +// after every tool result, so the detector never sees a three-call streak. +// This guard keeps only the trailing completed call per session. It requires +// the tool name, canonical args, and output to repeat three times before it +// blocks the next identical call. A real user message resets the streak; +// assistant continuations and compaction do not. +function createToolLoopGuard({ threshold = TOOL_LOOP_THRESHOLD } = {}) { + const completed = new Map() + const pending = new Map() + const pendingKey = (sessionID, callID) => `${sessionID}\u0000${callID}` + + return { + before({ sessionID, callID, tool }, args) { + const signature = `${tool}\u0000${canonicalJson(args)}` + const prior = completed.get(sessionID) + if (prior && prior.signature === signature && prior.count >= threshold) { + return { + blocked: true, + count: prior.count, + fingerprint: signature, + } + } + // An intervening tool or argument set breaks a trailing streak even if + // that call later fails before the after-hook can run. + if (prior && prior.signature !== signature) completed.delete(sessionID) + pending.set(pendingKey(sessionID, callID), { sessionID, signature }) + trimOldest(pending, TOOL_LOOP_SESSION_LIMIT * 8) + return { blocked: false } + }, + + after({ sessionID, callID }, output) { + const key = pendingKey(sessionID, callID) + const call = pending.get(key) + pending.delete(key) + if (!call) return + const outputFingerprint = canonicalJson(output) + const prior = completed.get(sessionID) + const count = prior + && prior.signature === call.signature + && prior.outputFingerprint === outputFingerprint + ? prior.count + 1 + : 1 + completed.delete(sessionID) + completed.set(sessionID, { + signature: call.signature, + outputFingerprint, + count, + }) + trimOldest(completed, TOOL_LOOP_SESSION_LIMIT) + }, + + reset(sessionID) { + completed.delete(sessionID) + for (const [key, call] of pending) { + if (call.sessionID === sessionID) pending.delete(key) + } + }, + } +} // The hook-handler ships with ruflo — resolve it without machine-specific // hardcodes: explicit override → claude marketplace clone (auto-updated) → @@ -115,6 +192,7 @@ function directOpenCodeReferences(text) { } const plugin = async ({ client }) => { + const toolLoopGuard = createToolLoopGuard() await client.app.log({ body: { service: "ruflo-hooks", @@ -134,6 +212,7 @@ const plugin = async ({ client }) => { fire("session-restore") break case "session.deleted": + toolLoopGuard.reset(event?.properties?.info?.id ?? event?.properties?.sessionID) fire("session-end") break } @@ -142,6 +221,7 @@ const plugin = async ({ client }) => { "chat.message": async (input, output) => { try { + toolLoopGuard.reset(input.sessionID) const prompt = promptText(output?.parts) if (prompt.length < ROUTE_MIN_PROMPT || TRIVIAL_PROMPT.test(prompt)) return const res = await runHook("route", { prompt }) @@ -166,6 +246,15 @@ const plugin = async ({ client }) => { "tool.execute.before": async (input, output) => { try { + const loop = toolLoopGuard.before(input, output?.args) + if (loop.blocked) { + try { + await client.session.abort({ path: { id: input.sessionID } }) + } catch { /* the guard error below is the fail-closed path */ } + throw new Error( + `[ruflo] Probable doom loop stopped after ${loop.count} identical completed calls: ${input.tool}`, + ) + } if (input.tool === "bash") { const command = output?.args?.command if (typeof command !== "string" || !command) return @@ -188,6 +277,7 @@ const plugin = async ({ client }) => { "tool.execute.after": async (input, output) => { try { + toolLoopGuard.after(input, output?.output) // Skills originate in the shared Ruflo catalogue and can carry Claude // MCP spellings. Normalize them on the OpenCode-only surface even when // the optional lazy gateway is unavailable; the gateway may then @@ -213,4 +303,4 @@ const plugin = async ({ client }) => { } export default plugin -export { plugin as RufloHooks } +export { canonicalJson, createToolLoopGuard, plugin as RufloHooks } diff --git a/tests/kit/opencode-hooks.test.mjs b/tests/kit/opencode-hooks.test.mjs new file mode 100644 index 0000000..afdf134 --- /dev/null +++ b/tests/kit/opencode-hooks.test.mjs @@ -0,0 +1,116 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + canonicalJson, + createToolLoopGuard, + RufloHooks, +} from '../../src/templates/opencode-ruflo-hooks.js'; + +function complete(guard, { + sessionID = 'ses_1', + callID, + tool = 'inspect_file', + args = { path: '/tmp/a' }, + output = 'same result', +}) { + const input = { sessionID, callID, tool }; + const verdict = guard.before(input, args); + assert.equal(verdict.blocked, false); + guard.after(input, output); +} + +test('canonicalJson treats recursively reordered object keys as equivalent', () => { + assert.equal( + canonicalJson({ b: 2, a: { d: 4, c: 3 } }), + canonicalJson({ a: { c: 3, d: 4 }, b: 2 }), + ); +}); + +test('tool loop guard detects identical completed calls across assistant turns', () => { + const guard = createToolLoopGuard({ threshold: 3 }); + complete(guard, { callID: 'call_1' }); + complete(guard, { callID: 'call_2' }); + complete(guard, { callID: 'call_3' }); + + const verdict = guard.before( + { sessionID: 'ses_1', callID: 'call_4', tool: 'inspect_file' }, + { path: '/tmp/a' }, + ); + assert.equal(verdict.blocked, true); + assert.equal(verdict.count, 3); +}); + +test('tool loop guard canonicalizes argument keys before comparing calls', () => { + const guard = createToolLoopGuard({ threshold: 2 }); + complete(guard, { + callID: 'call_1', + args: { path: '/tmp/a', options: { line: 1, column: 2 } }, + }); + complete(guard, { + callID: 'call_2', + args: { options: { column: 2, line: 1 }, path: '/tmp/a' }, + }); + const verdict = guard.before( + { sessionID: 'ses_1', callID: 'call_3', tool: 'inspect_file' }, + { options: { line: 1, column: 2 }, path: '/tmp/a' }, + ); + assert.equal(verdict.blocked, true); +}); + +test('different tool arguments or output reset the trailing streak', () => { + const guard = createToolLoopGuard({ threshold: 2 }); + complete(guard, { callID: 'call_1' }); + complete(guard, { callID: 'call_2', args: { path: '/tmp/b' } }); + complete(guard, { callID: 'call_3', output: 'first result' }); + complete(guard, { callID: 'call_4', output: 'changed result' }); + + const verdict = guard.before( + { sessionID: 'ses_1', callID: 'call_5', tool: 'inspect_file' }, + { path: '/tmp/a' }, + ); + assert.equal(verdict.blocked, false); +}); + +test('new user message reset and session isolation prevent false positives', () => { + const guard = createToolLoopGuard({ threshold: 2 }); + complete(guard, { callID: 'call_1' }); + complete(guard, { callID: 'call_2' }); + guard.reset('ses_1'); + + assert.equal(guard.before( + { sessionID: 'ses_1', callID: 'call_3', tool: 'inspect_file' }, + { path: '/tmp/a' }, + ).blocked, false); + assert.equal(guard.before( + { sessionID: 'ses_2', callID: 'call_1', tool: 'inspect_file' }, + { path: '/tmp/a' }, + ).blocked, false); +}); + +test('deployed hook aborts the session before a fourth identical completed call', async () => { + const aborted = []; + const hooks = await RufloHooks({ + client: { + app: { log: async () => {} }, + session: { abort: async (request) => { aborted.push(request.path.id); } }, + }, + }); + for (let i = 1; i <= 3; i += 1) { + const input = { sessionID: 'ses_live', callID: `call_${i}`, tool: 'inspect_file' }; + await hooks['tool.execute.before'](input, { args: { path: '/tmp/a' } }); + await hooks['tool.execute.after']( + { ...input, args: { path: '/tmp/a' } }, + { title: 'inspect', output: 'same result', metadata: {} }, + ); + } + + await assert.rejects( + hooks['tool.execute.before']( + { sessionID: 'ses_live', callID: 'call_4', tool: 'inspect_file' }, + { args: { path: '/tmp/a' } }, + ), + /Probable doom loop stopped after 3 identical completed calls/, + ); + assert.deepEqual(aborted, ['ses_live']); +});