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
108 changes: 108 additions & 0 deletions packages/js/src/capability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { Capability, Context, JSONSchema } from './types.js';
import { classifyWith } from './classify.js';
import { ReinsError } from './errors.js';

/**
* A capability handler runs one operation. It receives the run's {@link Context}
* (identity, trace id) and the validated argument map, and returns the result (or
* a promise of it). Failures thrown here become errors-as-data the model can see;
* they never abort the loop.
*/
export type Handler = (ctx: Context, args: Record<string, unknown>) => unknown | Promise<unknown>;

/** A capability's spec plus the function that runs it (the Go/Python `BoundCapability`). */
export interface BoundCapability {
spec: Capability;
handler: Handler;
/** Whether the handler returns raw rows/blobs — for response linting (§2.15). */
returnsRaw: boolean;
}

/** Options for {@link capability}. `parameters` is a JSON Schema object. */
export interface CapabilityConfig {
description: string;
/**
* A JSON Schema for the arguments. Build one with {@link params}, hand-write it,
* or (with Zod v4) pass `z.toJSONSchema(mySchema)`.
*/
parameters?: JSONSchema;
/** Force READ classification (mutually exclusive with `destructive`). */
reads?: boolean;
/** Force DESTRUCTIVE classification. */
destructive?: boolean;
/** Require approval for this write/destructive capability (reads never gate). */
confirm?: boolean;
/** Mark the capability safe to retry. */
idempotent?: boolean;
/** Tag the capability for row-level security (only the caller's rows, §2.8). */
scope?: string;
/** Declare that the handler returns untrimmed raw data (fails the linter). */
returnsRaw?: boolean;
}

/**
* Define a capability: a typed, intent-named function plus safety metadata. Access
* is classified from the name unless `reads`/`destructive` overrides it. The
* optional type parameter `Args` types the handler's argument object for the caller;
* at runtime the harness passes the validated argument map.
*/
export function capability<Args extends Record<string, unknown> = Record<string, unknown>>(
name: string,
config: CapabilityConfig,
handler: (ctx: Context, args: Args) => unknown | Promise<unknown>,
): BoundCapability {
const spec: Capability = {
name,
description: config.description,
access: classifyWith(name, config.reads ?? false, config.destructive ?? false),
confirm: config.confirm ?? false,
idempotent: config.idempotent ?? false,
};
if (config.parameters !== undefined) {
spec.input_schema = config.parameters;
}
if (config.scope !== undefined) {
spec.scope = config.scope;
}
return { spec, handler: handler as Handler, returnsRaw: config.returnsRaw ?? false };
}

const scalarTypes = new Set(['string', 'integer', 'number', 'boolean']);

/**
* A tiny JSON Schema builder so you never hand-write schemas or reach for Zod for
* simple cases. Each value is a scalar type — `string`, `integer`, `number`,
* `boolean` — optionally suffixed `[]` (array) and/or `?` (optional). Fields
* without `?` are required.
*
* @example
* params({ order_id: 'integer', reason: 'string?', tags: 'string[]' })
*/
export function params(fields: Record<string, string>): JSONSchema {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const [field, rawType] of Object.entries(fields)) {
let spec = rawType.trim();
let optional = false;
if (spec.endsWith('?')) {
optional = true;
spec = spec.slice(0, -1);
}
let isArray = false;
if (spec.endsWith('[]')) {
isArray = true;
spec = spec.slice(0, -2);
}
if (!scalarTypes.has(spec)) {
throw new ReinsError(
`unknown parameter type "${rawType}" for field "${field}"`,
'use string, integer, number, or boolean (optionally with [] or ? — e.g. "string[]", "integer?")',
);
}
properties[field] = isArray ? { type: 'array', items: { type: spec } } : { type: spec };
if (!optional) {
required.push(field);
}
}
return { type: 'object', properties, required, additionalProperties: false };
}
95 changes: 95 additions & 0 deletions packages/js/src/classify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Access } from './types.js';
import { ReinsError } from './errors.js';

/**
* Read/write classification by the name's leading verb (the easy-by-design
* heuristic). Ambiguous ⇒ write (invariant §2.4): a wrong guess only ever adds a
* needless approval; it must never let a write run as a read. The verb sets are
* kept identical to the Python and Go ports so classification is language-neutral.
*/

const readVerbs = new Set([
'get',
'list',
'find',
'search',
'fetch',
'count',
'show',
'read',
'view',
'lookup',
'describe',
]);

const writeVerbs = new Set([
'create',
'add',
'update',
'set',
'save',
'send',
'apply',
'edit',
'modify',
'insert',
'put',
'post',
'make',
'assign',
'schedule',
'approve',
'register',
]);

const destructiveVerbs = new Set([
'delete',
'remove',
'cancel',
'refund',
'charge',
'drop',
'deactivate',
'purge',
'archive',
'revoke',
'reset',
'wipe',
]);

/** Classify a capability's access from its name's leading verb (ambiguous ⇒ write, §2.4). */
export function classify(name: string): Access {
return classifyWith(name, false, false);
}

/**
* Apply explicit overrides (`reads` / `destructive`) over the heuristic. Setting
* both is a developer error and throws — as the Python decorator raises and the Go
* port panics.
*/
export function classifyWith(name: string, reads: boolean, destructive: boolean): Access {
if (reads && destructive) {
throw new ReinsError(
`capability ${name} marked both reads and destructive`,
'pick one — a capability is either a read or a write/destructive',
);
}
if (destructive) {
return 'destructive';
}
if (reads) {
return 'read';
}
const underscore = name.indexOf('_');
const verb = (underscore >= 0 ? name.slice(0, underscore) : name).toLowerCase();
if (destructiveVerbs.has(verb)) {
return 'destructive';
}
if (readVerbs.has(verb)) {
return 'read';
}
if (writeVerbs.has(verb)) {
return 'write';
}
return 'write'; // ambiguous ⇒ write (invariant §2.4)
}
3 changes: 3 additions & 0 deletions packages/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ export const VERSION = '0.1.0a1';
export * from './types.js';
export * from './errors.js';
export * from './model.js';
export * from './classify.js';
export * from './linter.js';
export * from './capability.js';
77 changes: 77 additions & 0 deletions packages/js/src/linter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Capability } from './types.js';

/**
* The Capability Linter (invariant §2.15): a build-time check that a capability is
* one a model can actually use — intent-level name, a real description, lean
* parameters, trimmed responses. Auto-generated capabilities (the ORM adapters)
* must pass it before exposure. The violation codes are language-neutral
* (`spec/contract.md` §3) and asserted by the shared conformance suite.
*/

export const VIOLATION_OPAQUE_NAME = 'opaque_name';
export const VIOLATION_MISSING_DESCRIPTION = 'missing_description';
export const VIOLATION_TOO_MANY_PARAMS = 'too_many_params';
export const VIOLATION_RAW_RESPONSE = 'raw_response';

/** Keep generated capabilities lean; more than this many params fails the linter. */
export const MAX_PARAMS = 7;
/** A description shorter than this is treated as missing. */
export const MIN_DESCRIPTION_CHARS = 10;

const SNAKE_NAME = /^[a-z][a-z0-9_]*$/;

/** The result of linting one capability: the violation codes it triggered (sorted). */
export interface LintReport {
violations: string[];
ok: boolean;
}

/** Check a capability's agent-facing surface against the §2.15 quality bar. */
export function lint(
name: string,
description: string,
paramCount: number,
returnsRaw: boolean,
): LintReport {
const violations: string[] = [];
if (isOpaque(name)) {
violations.push(VIOLATION_OPAQUE_NAME);
}
if (description.trim().length < MIN_DESCRIPTION_CHARS) {
violations.push(VIOLATION_MISSING_DESCRIPTION);
}
if (paramCount > MAX_PARAMS) {
violations.push(VIOLATION_TOO_MANY_PARAMS);
}
if (returnsRaw) {
violations.push(VIOLATION_RAW_RESPONSE);
}
violations.sort();
return { violations, ok: violations.length === 0 };
}

/** Lint a {@link Capability}'s spec (counting its schema's properties). */
export function lintCapability(cap: Capability, returnsRaw: boolean): LintReport {
let count = 0;
const props = cap.input_schema?.['properties'];
if (typeof props === 'object' && props !== null) {
count = Object.keys(props).length;
}
return lint(cap.name, cap.description, count, returnsRaw);
}

/**
* Opaque = not clean lowercase snake_case, or built only from short abbreviation-
* like tokens with no real word (`tbl_ord_upd` is opaque; `refund_order` /
* `get_user` are fine).
*/
function isOpaque(name: string): boolean {
if (!SNAKE_NAME.test(name)) {
return true;
}
let longest = 0;
for (const token of name.split('_')) {
longest = Math.max(longest, token.length);
}
return longest < 4;
}
98 changes: 98 additions & 0 deletions packages/js/tests/capability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import {
capability,
params,
toolSpec,
lintCapability,
ReinsError,
type Context,
} from '../src/index.js';

describe('capability', () => {
it('classifies access from the name and carries safety metadata', () => {
const cap = capability(
'create_order',
{ description: 'Create a new order for a customer.', parameters: params({ sku: 'string' }) },
() => ({ ok: true }),
);
expect(cap.spec.access).toBe('write');
expect(cap.spec.name).toBe('create_order');
expect(cap.spec.input_schema).toEqual({
type: 'object',
properties: { sku: { type: 'string' } },
required: ['sku'],
additionalProperties: false,
});
expect(cap.returnsRaw).toBe(false);
});

it('honors explicit classification overrides and flags', () => {
const cap = capability(
'get_report', // read verb ...
{
description: 'Generate and email a report.',
destructive: true,
confirm: true,
scope: 'user',
},
() => undefined,
);
expect(cap.spec.access).toBe('destructive'); // ... overridden to destructive
expect(cap.spec.confirm).toBe(true);
expect(cap.spec.scope).toBe('user');
});

it('passes the run context and args to the handler', async () => {
let seen: { ctx: Context; args: Record<string, unknown> } | undefined;
const cap = capability(
'find_orders',
{ description: 'Find orders for the caller.' },
(ctx, args) => {
seen = { ctx, args };
return [];
},
);
await cap.handler({ principal: 'u1' }, { status: 'open' });
expect(seen?.ctx.principal).toBe('u1');
expect(seen?.args).toEqual({ status: 'open' });
});

it('produces a lint-clean spec and a policy-free tool surface', () => {
const cap = capability(
'refund_order',
{
description: 'Refund a customer order by its public order number.',
parameters: params({ order_id: 'integer', reason: 'string?' }),
destructive: true,
confirm: true,
},
() => undefined,
);
expect(lintCapability(cap.spec, cap.returnsRaw).ok).toBe(true);
const spec = toolSpec(cap.spec);
expect('access' in spec).toBe(false);
expect('confirm' in spec).toBe(false);
});
});

describe('params', () => {
it('marks non-optional fields required and optional fields not', () => {
const schema = params({ id: 'integer', note: 'string?' });
expect(schema['required']).toEqual(['id']);
const props = schema['properties'] as Record<string, unknown>;
expect(props['note']).toEqual({ type: 'string' });
});

it('supports arrays, alone and combined with optional', () => {
const schema = params({ tags: 'string[]', ids: 'integer[]?' });
const props = schema['properties'] as Record<string, unknown>;
expect(props['tags']).toEqual({ type: 'array', items: { type: 'string' } });
expect(props['ids']).toEqual({ type: 'array', items: { type: 'integer' } });
expect(schema['required']).toEqual(['tags']);
});

it('throws a hinted error on an unknown type', () => {
expect(() => params({ when: 'date' })).toThrow(ReinsError);
expect(() => params({ when: 'date' })).toThrow('→');
});
});
Loading
Loading