Skip to content

Write a guardrail

This guardrail keeps an agent's answers short: it fails a turn whose answer is longer than a limit.

guardrails/answer-length/index.ts
import { defineCheck } from '@kindgi/sdk/define';
import { z } from 'zod';
export const check = defineCheck({
id: 'my-pack.checks.answer-length',
kind: 'zero-llm',
configSchema: z.object({ maxChars: z.number().int().positive().default(500) }),
evaluate: async (config, trace) => {
const length = trace.output?.length ?? 0;
if (length > config.maxChars) {
return {
passed: false,
reason: `The answer is ${length} characters; the limit is ${config.maxChars}.`,
};
}
return { passed: true };
},
});
export default {
id: 'my-pack.answer-length',
name: 'Answer is short',
kind: 'zero-llm',
check,
config: { maxChars: 60 },
action: { 'on-violation': 'halt' },
severity: 'error',
};

The file has two parts:

  • The check, from defineCheck, exported as check. Its evaluate gets the guardrail's config and the turn's trace, and returns { passed, reason? }.
  • The declaration, the default export: the guardrail's id, the check it runs, the check's config, and the action on a failure.
  • The id is <pack-id>.<name>. Name the rule as what should hold: answer-length, response-not-empty.
  • kind: 'zero-llm': the check is your code, a function of the trace. It can't call a model.
  • The reason is what the violation reports. Say what was wrong.
  • The trace has the turn's final answer (output), its tool calls (toolCalls / tool_calls, each with the tool's name and arguments), their results, its model calls, the user's input and its cost. See RunTrace in the Python reference.

An agent lists its guardrails:

// in agents/echo-agent/index.ts
guardrails: ['my-pack.response-not-empty' as GuardrailId, 'my-pack.answer-length' as GuardrailId],

The agent names the guardrail's id, not the check's.

Save. kindgi dev registers the guardrail with the rest of the pack. An agent that names a guardrail that isn't registered doesn't run:

Error [invalid-request]: Agent "my-pack.gated" references guardrails not in the registry: my-pack.no-such-guardrail

dev-echo answers with the tool's result, which is longer than 60 characters, so the turn stops:

Terminal window
pnpm exec kindgi runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}'
Error [guardrail-violation]: Turn blocked by guardrail 'my-pack.answer-length': The answer is 86 characters; the limit is 60.

The command exits with status 1. The run's status is failed, and it has no answer. Stop a turn or record a violation shows what the run records, and how to let the turn finish instead.

A check that throws fails the turn whatever its action, with the exception's message (handler-throw: Check "…" evaluate() threw: …). Return passed: false for a rule that isn't met; throw only when the check itself can't run.

guardrails/answer-length/index.test.ts
import type { RunId, TenantId } from '@kindgi/sdk/types';
import { expect, test } from 'vitest';
import { check } from './index.js';
const trace = (output: string) => ({
runId: 'run-1' as RunId,
tenantId: 'test-tenant' as TenantId,
output,
toolCalls: [],
toolResults: [],
modelCalls: [],
mode: 'runtime' as const,
});
test('passes a short answer', async () => {
expect(await check.evaluate({ maxChars: 60 }, trace('Shipped.'), {})).toEqual({ passed: true });
});
test('fails a long answer, with a reason', async () => {
const result = await check.evaluate({ maxChars: 5 }, trace('Shipped yesterday.'), {});
expect(result.passed).toBe(false);
expect(result.reason).toBe('The answer is 18 characters; the limit is 5.');
});
Terminal window
pnpm exec kindgi test
✓ guardrails/answer-length/index.test.ts (2 tests) 2ms
…
Tests 7 passed (7)