Skip to content
Closed
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ Renders a prompt for a specific provider. Returns `{ resolved, request, warnings
| `source` | `string` | Inline prompt source (alternative to path) |
| `provider` | `string` | `'openai'`, `'anthropic'`, `'gemini'`, `'openrouter'` |
| `variables` | `Record<string, string>` | Template variables |
| `onContextOverflow` | `(info) => string` | Optional callback to transform oversized context values before rendering |
| `environment` | `string` | Environment override name |
| `tier` | `string` | Tier override name |
| `history` | `Array<{ role, content }>` | Conversation history |
Expand Down Expand Up @@ -506,7 +507,7 @@ Prompt files use YAML front matter with these fields:
| `response` | `object` | `{ format, stream }` |
| `tools` | `array` | Tool references (string names or inline definitions) |
| `mcp` | `object` | MCP server references |
| `context` | `object` | `{ inputs, history }` — declare expected variables, with optional per-input `max_size` budgets |
| `context` | `object` | `{ inputs, history }` — declare expected variables, with optional per-input `max_size`, `trim`, `allow_regex`/`deny_regex`, and legacy `regex` controls |
| `includes` | `string[]` | Paths to included prompt files |
| `environments` | `object` | Named environment overrides |
| `tiers` | `object` | Named tier overrides |
Expand Down
5 changes: 4 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ the fields required by that specific file:
| `response` | object | no | `{ format: text|json|markdown, stream: boolean }` |
| `tools` | array | no | Tool names (strings) or inline definitions with `{ name, description, input_schema }` |
| `mcp` | object | no | `{ servers: [string | { name, config }] }` |
| `context.inputs` | `Array<string | { name, max_size? }>` | no | Declared variable names used in templates, with optional size budgets |
| `context.inputs` | `Array<string | { name, max_size?, trim?, allow_regex?, deny_regex?, regex? }>` | no | Declared variable names used in templates, with optional size budgets and runtime hardening controls |
| `context.history` | object | no | `{ max_items: number }` |
| `includes` | string[] | no | Relative paths to other prompt files to include |
| `environments` | object | no | Per-environment overrides (see Overrides) |
Expand Down Expand Up @@ -100,6 +100,8 @@ Rules:
- Before finishing a new prompt file, scan the body for every `{{ variable }}` and
ensure each exact variable name appears in `context.inputs`
- Use object-form inputs with `max_size` when a variable is likely to grow large and should trigger early warnings
- Use `trim` to enforce byte budgets before interpolation when `max_size` is set
- Use `allow_regex` (or legacy `regex`) for allowlist checks and `deny_regex` for blocklist checks on risky inputs
- Escape literal braces with `\{{` and `\}}`
- In strict mode, missing variables throw an error
- In permissive mode, unresolved placeholders are left intact
Expand All @@ -115,6 +117,7 @@ context:
```

If a rendered value exceeds `max_size`, `renderPrompt()` emits a non-blocking `POK030` warning.
At render time, callers can also pass `onContextOverflow` to transform oversized values before warnings/rendering.

Example: this is the minimal valid shape for a prompt that references
`{{ pull_request }}` even when provider/model are inherited from defaults:
Expand Down
3 changes: 2 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const result = await kit.renderPrompt({
| `source` | `string` | Inline prompt source (alternative to `path`) |
| `provider` | `string` | `'openai'`, `'anthropic'`, `'gemini'`, `'openrouter'` (required) |
| `variables` | `Record<string, string>` | Template variables |
| `onContextOverflow` | `(info) => string` | Optional callback to transform an oversized context value before rendering |
| `environment` | `string` | Environment override name |
| `tier` | `string` | Tier override name |
| `history` | `Array<{ role, content }>` | Conversation history |
Expand Down Expand Up @@ -240,7 +241,7 @@ const request = adapter.render(resolvedAsset, {
});
```

`RuntimeRenderOptions` for direct adapter rendering supports `environment`, `tier`, `runtime`, `variables`, `history`, `toolRegistry`, and `strict`.
`RuntimeRenderOptions` for direct adapter rendering supports `environment`, `tier`, `runtime`, `variables`, `onContextOverflow`, `history`, `toolRegistry`, and `strict`.

## Standalone `renderPrompt`

Expand Down
15 changes: 15 additions & 0 deletions docs/prompt-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,28 @@ Each entry can be either a string variable name or an object with:

- `name` — the template variable name
- `max_size` — optional UTF-8 byte limit for the injected value
- `trim` — optional trim-to-budget (`true`/`end` keeps first bytes, `start` keeps trailing bytes) applied when `max_size` is set
- `allow_regex` — optional allowlist regex; input must match (throws `POK031` on mismatch)
- `deny_regex` — optional blocklist regex; input must not match (throws `POK032` on match)
- `regex` — legacy alias for `allow_regex`

The validator warns about:
- Variables used in templates but not declared in `context.inputs`
- Variables declared in `context.inputs` but never used

At render time, PromptOpsKit also emits a non-blocking `POK030` warning when a provided variable exceeds its declared `max_size`. In source and auto modes, the warning is also written to `console.warn` to make local development issues visible early.

Example hardened input definition:

```yaml
context:
inputs:
- name: user_id
trim: true
max_size: 24
allow_regex: "^user_[a-z0-9]+$"
```

## Minimal example

The simplest valid prompt:
Expand Down
10 changes: 8 additions & 2 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ context:

| Field | Type | Description |
|-------|------|-------------|
| `inputs` | `Array<string | { name, max_size? }>` | Expected variable names, optionally with a UTF-8 byte budget for render-time warnings |
| `inputs` | `Array<string | { name, max_size?, trim?, allow_regex?, deny_regex?, regex? }>` | Expected variable names, optionally with size and runtime sanitization constraints |
| `history` | `object` | History settings |
| `history.max_items` | `number` | Maximum history items |

Expand All @@ -152,7 +152,13 @@ context:
- account_summary
```

Object-form inputs add optional `max_size`, which is checked during `renderPrompt()` and can produce a `POK030` warning when the injected value exceeds the declared budget.
Object-form inputs add optional controls:

- `max_size`: checked during `renderPrompt()` and can produce `POK030` warnings.
- `trim`: trims incoming values to the `max_size` budget before interpolation (`true`/`end` keeps leading bytes, `start` keeps trailing bytes).
- `allow_regex`: allowlist validation before interpolation; non-matches throw `POK031`.
- `deny_regex`: blocklist validation before interpolation; matches throw `POK032`.
- `regex`: legacy alias of `allow_regex`.

## `includes`

Expand Down
35 changes: 35 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const result = await kit.validatePrompt('support/reply');
| `POK010` | Warning | Unknown front matter key (with "did you mean?" suggestion) |
| `POK011` | Warning | Variable used in template but not declared in `context.inputs` |
| `POK012` | Warning | Variable declared in `context.inputs` but never used |
| `POK013` | Error | Invalid context regex pattern (`allow_regex`, `deny_regex`, or legacy `regex`) |
| `POK014` | Warning | `trim` configured without `max_size` (trim-to-budget skipped) |
| `POK015` | Warning | Both `regex` and `allow_regex` set (`allow_regex` takes precedence) |
| `POK020` | Error | Include resolution failed (missing file) |
| `POK021` | Error | Circular include detected |

Expand Down Expand Up @@ -75,6 +78,38 @@ context:

If `account_summary` is rendered with a value larger than 4096 UTF-8 bytes, `renderPrompt()` returns a `POK030` warning. In source and auto modes, PromptOpsKit also writes the warning to `console.warn` so oversized context is visible during local development.

If you want to transform oversized values before warnings/rendering (for example, summarize or redact), pass `onContextOverflow` at render time:

```typescript
const result = await kit.renderPrompt({
path: 'support/reply',
provider: 'openai',
variables: { account_summary: veryLargeText },
onContextOverflow: ({ variable, value, maxSize }) =>
`${variable} truncated to fit ${maxSize} bytes: ${value.slice(0, 50)}...`,
});
```

You can also add basic input hardening directly in `context.inputs`:

```yaml
context:
inputs:
- name: user_id
trim: true
allow_regex: "^user_[a-z0-9]+$"
- name: user_message
deny_regex: "([Ii]gnore previous instructions|[Ss]ystem:)"
```

- `trim` trims values to the `max_size` byte budget before interpolation.
- `allow_regex` enforces an allowlist pattern before interpolation and throws `POK031` when a value fails validation.
- `deny_regex` enforces a blocklist pattern before interpolation and throws `POK032` when a value matches.
- `regex` remains as a legacy alias for `allow_regex`.
- During static validation, malformed `allow_regex`, `deny_regex`, or `regex` patterns are reported as `POK013`.
- During static validation, `trim` without `max_size` returns a `POK014` warning.
- During static validation, setting both `regex` and `allow_regex` returns a `POK015` warning (`allow_regex` wins).

You can override that behavior at the kit level:

```typescript
Expand Down
136 changes: 135 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type { PromptAsset, ResolvedPromptAsset, ContextInputDefinition } from '.
export interface NormalizedContextInput {
name: string;
max_size?: number;
trim?: boolean | 'start' | 'end' | 'both';
regex?: string;
allow_regex?: string;
deny_regex?: string;
}

export interface ContextSizeWarning {
Expand All @@ -11,6 +15,18 @@ export interface ContextSizeWarning {
actualSize: number;
}

export interface ContextOverflowInfo {
promptId: string;
variable: string;
value: string;
maxSize: number;
actualSize: number;
}

export interface SanitizeContextOptions {
onContextOverflow?: (info: ContextOverflowInfo) => string;
}

const textEncoder = new TextEncoder();

export function getContextInputs(
Expand All @@ -33,9 +49,127 @@ export function normalizeContextInput(input: ContextInputDefinition): Normalized
return {
name: input.name,
max_size: input.max_size,
trim: input.trim,
regex: input.regex,
allow_regex: input.allow_regex,
deny_regex: input.deny_regex,
};
}

type TrimMode = boolean | 'start' | 'end' | 'both';

function isTrimEnabled(mode: TrimMode | undefined): mode is true | 'start' | 'end' | 'both' {
return mode === true || mode === 'start' || mode === 'end' || mode === 'both';
}

function normalizeTrimMode(mode: TrimMode): 'start' | 'end' {
if (mode === 'start') {
return 'start';
}
return 'end';
}

function trimToMaxSize(
value: string,
maxSize: number,
mode: TrimMode,
): string {
const measured = measureContextValueSize(value);
if (measured <= maxSize) {
return value;
}

const characters = Array.from(value);
const normalizedMode = normalizeTrimMode(mode);

if (normalizedMode === 'start') {
let collected = '';
let size = 0;
for (let i = characters.length - 1; i >= 0; i -= 1) {
const next = characters[i];
const charSize = measureContextValueSize(next);
if (size + charSize > maxSize) {
break;
}
collected = next + collected;
size += charSize;
}
return collected;
}

let collected = '';
let size = 0;
for (const char of characters) {
const charSize = measureContextValueSize(char);
if (size + charSize > maxSize) {
break;
}
collected += char;
size += charSize;
}
return collected;
}

export function sanitizeContextVariables(
asset: Pick<PromptAsset | ResolvedPromptAsset, 'context' | 'id'>,
variables: Record<string, string> = {},
options: SanitizeContextOptions = {},
): Record<string, string> {
const { onContextOverflow } = options;
const sanitized = { ...variables };

for (const input of getContextInputs(asset)) {
const value = sanitized[input.name];
if (value === undefined) {
continue;
}

let candidate = value;

if (input.max_size !== undefined) {
const actualSize = measureContextValueSize(candidate);
if (actualSize > input.max_size && onContextOverflow) {
candidate = onContextOverflow({
promptId: asset.id,
variable: input.name,
value: candidate,
maxSize: input.max_size,
actualSize,
});
}
}

if (isTrimEnabled(input.trim) && input.max_size !== undefined) {
candidate = trimToMaxSize(candidate, input.max_size, input.trim);
}

sanitized[input.name] = candidate;

const allowRegex = input.allow_regex ?? input.regex;
if (allowRegex) {
const candidate = sanitized[input.name];
const matcher = new RegExp(allowRegex);
if (!matcher.test(candidate)) {
throw new Error(
`POK031: Context variable "${input.name}" failed allow_regex validation for prompt "${asset.id}".`,
);
}
}

if (input.deny_regex) {
const candidate = sanitized[input.name];
const matcher = new RegExp(input.deny_regex);
if (matcher.test(candidate)) {
throw new Error(
`POK032: Context variable "${input.name}" matched deny_regex for prompt "${asset.id}".`,
);
}
}
}

return sanitized;
}

export function measureContextValueSize(value: string): number {
return textEncoder.encode(value).length;
}
Expand Down Expand Up @@ -67,4 +201,4 @@ export function collectContextSizeWarnings(
}

return warnings;
}
}
12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { renderSections } from './renderer/index.js';
import { getAdapter } from './providers/index.js';
import { validateAsset, validateAssetWithIncludes } from './validation/index.js';
import { PromptCache } from './cache.js';
import { collectContextSizeWarnings } from './context.js';
import { collectContextSizeWarnings, sanitizeContextVariables } from './context.js';
import {
DEFAULT_PROMPTS_DIR,
loadPromptAsset,
Expand Down Expand Up @@ -112,6 +112,8 @@ export interface RenderPromptOptions {
runtime?: Partial<PromptAssetOverrides>;
/** Variables for interpolation */
variables?: Record<string, string>;
/** Optional callback to transform oversized context values before warnings/rendering */
onContextOverflow?: RuntimeRenderOptions['onContextOverflow'];
/** Conversation history */
history?: Array<{ role: string; content: string }>;
/** Tool registry for resolving tool references */
Expand Down Expand Up @@ -230,7 +232,11 @@ export class PromptOpsKit {
);
}

const contextSizeWarnings = collectContextSizeWarnings(resolved, options.variables).map((warning) =>
const sanitizedVariables = sanitizeContextVariables(resolved, options.variables, {
onContextOverflow: options.onContextOverflow,
});

const contextSizeWarnings = collectContextSizeWarnings(resolved, sanitizedVariables).map((warning) =>
formatContextSizeWarning(resolved, warning),
);

Expand All @@ -243,7 +249,7 @@ export class PromptOpsKit {
}

const request = adapter.render(resolved, {
variables: options.variables,
variables: sanitizedVariables,
history: options.history,
toolRegistry: options.toolRegistry,
strict: options.strict,
Expand Down
11 changes: 9 additions & 2 deletions src/providers/prompt-input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resolveInlinePromptSource, resolvePromptAsset } from '../prompt-resolution.js';
import type { ResolvedPromptAsset } from '../schema/index.js';
import { sanitizeContextVariables } from '../context.js';
import type {
ProviderAdapter,
ProviderPromptInput,
Expand Down Expand Up @@ -41,12 +42,18 @@ export function withPromptInputSupport(adapter: SyncProviderAdapter): ProviderAd

const renderPrompt: RenderPromptMethod = async (input, runtime) => {
const resolved = await resolveProviderPromptInput(input, runtime);
return adapter.render(resolved, runtime);
const variables = sanitizeContextVariables(resolved, runtime.variables, {
onContextOverflow: runtime.onContextOverflow,
});
return adapter.render(resolved, {
...runtime,
variables,
});
};

return {
...adapter,
validatePrompt,
renderPrompt,
};
}
}
Loading
Loading