Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions docs/adr/0017-opencode-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
- **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
health, preserves last-good OpenCode usage when a present store is temporarily unreadable,
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
Expand Down Expand Up @@ -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
Expand Down
96 changes: 93 additions & 3 deletions src/templates/opencode-ruflo-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) →
Expand Down Expand Up @@ -115,6 +192,7 @@ function directOpenCodeReferences(text) {
}

const plugin = async ({ client }) => {
const toolLoopGuard = createToolLoopGuard()
await client.app.log({
body: {
service: "ruflo-hooks",
Expand All @@ -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
}
Expand All @@ -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 })
Expand All @@ -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
Expand All @@ -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
Expand All @@ -213,4 +303,4 @@ const plugin = async ({ client }) => {
}

export default plugin
export { plugin as RufloHooks }
export { canonicalJson, createToolLoopGuard, plugin as RufloHooks }
116 changes: 116 additions & 0 deletions tests/kit/opencode-hooks.test.mjs
Original file line number Diff line number Diff line change
@@ -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']);
});
Loading