Plugins
Tools + before/after intercepts + observers + onError, bundled.
A HarnessPlugin is a bundle of capabilities applied to one run. drover
ships eight built-ins. Custom plugins are a few lines.
Anatomy
interface HarnessPlugin {
id: string;
tools?: ToolDef[];
wrapTool?: (tool) => tool;
beforeToolCall?: (name, input, ctx) => Effect<ToolDecision, ...>;
afterToolCall?: (name, input, result, ctx) => Effect<ToolResult, ...>;
beforeCompaction?: (history, ctx) => Effect<history, ...>;
onEvent?: (event, ctx) => Effect<void, never>;
onError?: (error, ctx) => Effect<ErrorRecovery, never>;
}A plugin needs only an id. Every other field is optional — pick the
ones that match what your plugin actually does.
Built-ins
Denies after N consecutive identical tool calls.
loopDetectPlugin({ window: 3 }).
Projects events into a flat trace list for observability.
stepTracerPlugin() returns { steps, plugin }.
Denies dangerous shell commands (rm -rf /, sudo, curl|sh, fork bombs).
bashBlocklistPlugin({ extraPatterns, warnOnly }).
Opens after N consecutive tool errors; cooldown then half-open.
circuitBreakerPlugin({ failureThreshold: 5, cooldownMs: 30_000 }).
Narrows writes to scoped paths beyond the sandbox’s roots.
writePolicyPlugin({ scopedWritePaths: ["/var/sites/example"] }).
Adds a record_phase tool the agent calls to mark pipeline progress.
phaseRecorderPlugin() returns { phases, plugin }.
Inline confirmation gate. Resolver decides approve/reject per call.
confirmGatePlugin({ tools, resolve, timeoutMs }).
Observes harness’s output validation lifecycle — retries + final state.
outputValidatePlugin() returns { trace, plugin }.
Wire a plugin
Either declare in the spec (permanent for the agent):
defineAgent({
/* ... */
plugins: [
loopDetectPlugin(),
bashBlocklistPlugin(),
circuitBreakerPlugin({ failureThreshold: 3 }),
],
});Or pass via RunOptions.plugins (additive — useful for observability
injected by the runner, not the agent’s policy):
const tracer = stepTracerPlugin();
runAgent(spec, input, {
plugins: [tracer.plugin],
});
console.log(tracer.steps);Author a custom plugin
import type { HarnessPlugin } from "@drover/core";
import { Effect } from "effect";
export function redactSecretsPlugin(): HarnessPlugin {
return {
id: "redact-secrets",
afterToolCall: (toolName, _input, result) =>
Effect.sync(() => ({
...result,
content: result.content.replace(/sk-[a-z0-9-]+/gi, "[REDACTED]"),
})),
};
}Apply on every tool result, no matter which tool produced it. Add to
spec.plugins or options.plugins.
Decision contract
beforeToolCall returns a ToolDecision:
| variant | what happens |
|---|---|
{ kind: "allow" } | proceed to the next plugin in the chain, then execute |
{ kind: "deny", reason } | tool returns an error result; model sees reason |
If a plugin’s beforeToolCall throws or rejects, drover fails closed
— treats it as a deny. Don’t rely on errors in observation hooks; that’s
what onEvent is for.
Ordering
beforeToolCall: first non-allow wins. Order plugins by specificity (bash-blocklistbeforeloop-detectis reasonable; both pre-empt execution but the blocklist’s reason is more useful to the model).afterToolCall: each plugin sees the prior plugin’s transformed result. Apply outermost-last for the visible final transform.wrapTool: composition order matches registration — earlier plugins wrap closer to the underlying execute.onEvent: fanout, order doesn’t matter; observers don’t compose.
Lifecycle and state
For stateful plugins (loop-detect’s call counter, circuit-breaker’s
breaker map, phase-recorder’s phases array, step-tracer’s steps,
output-validate’s trace) the right pattern is to construct per run
via options.plugins:
import { runAgent } from "@drover/facade";
import { circuitBreakerPlugin, stepTracerPlugin } from "@drover/plugins";
// Per-run construction → fresh state
for (const input of inputs) {
const tracer = stepTracerPlugin();
const result = await runAgent(spec, input, {
plugins: [circuitBreakerPlugin(), tracer.plugin],
}).result;
console.log(tracer.steps); // this run's events only
}spec.plugins is fine for stateless plugins (bash-blocklist and
write-policy — their state is config-only, no per-run accumulators).
Anything with mutable closure state should be wired per run.
For state that needs to survive pause/resume, persist it through
storage — runs.meta JSON is the easiest hook.
Auto-applied plugins
drover normally treats spec.plugins and options.plugins as the
only sources of truth — but there is one exception. When
spec.memory.enabled is true and writesPerTurn > 0, the harness
constructs memoryRateLimitPlugin({ writesPerTurn }) and appends it
to the run’s plugin list. This is the only auto-injected plugin in
drover; everything else is either explicit or doesn’t ship with state.
If you need a different rate-limit policy (or to disable it entirely)
set spec.memory.writesPerTurn = 0 and add your own plugin to
options.plugins.