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

ts
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

loop-detect

Denies after N consecutive identical tool calls. loopDetectPlugin({ window: 3 }).

step-tracer

Projects events into a flat trace list for observability. stepTracerPlugin() returns { steps, plugin }.

bash-blocklist

Denies dangerous shell commands (rm -rf /, sudo, curl|sh, fork bombs). bashBlocklistPlugin({ extraPatterns, warnOnly }).

circuit-breaker

Opens after N consecutive tool errors; cooldown then half-open. circuitBreakerPlugin({ failureThreshold: 5, cooldownMs: 30_000 }).

write-policy

Narrows writes to scoped paths beyond the sandbox’s roots. writePolicyPlugin({ scopedWritePaths: ["/var/sites/example"] }).

phase-recorder

Adds a record_phase tool the agent calls to mark pipeline progress. phaseRecorderPlugin() returns { phases, plugin }.

confirm-gate

Inline confirmation gate. Resolver decides approve/reject per call. confirmGatePlugin({ tools, resolve, timeoutMs }).

output-validate

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):

ts
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):

ts
const tracer = stepTracerPlugin();
runAgent(spec, input, {
  plugins: [tracer.plugin],
});
console.log(tracer.steps);

Author a custom plugin

ts
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:

variantwhat 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-blocklist before loop-detect is 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:

ts
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 storageruns.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.

Type to search…

↑↓ navigate open esc close