jev-recipes 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
- import type { LanguageModel, PrepareStepFunction, StopCondition, Tool, ToolSet } from 'ai';
1
+ import type { FlexibleSchema, LanguageModel, PrepareStepFunction, StopCondition, Tool, ToolSet } from 'ai';
2
2
  import type { RecipeName } from '../../catalog/schema.js';
3
3
  import type { ModelRouteResult } from '../../recipes/model-route/index.js';
4
4
  import type { RecipeOptions } from '../../src/schema.js';
5
- import type { CompletionOptions, CompletionToolOutput, GuardOptions, RecipeToolOptions } from '../shared.js';
5
+ import type { BlockedToolOutput, CompletionOptions, CompletionToolOutput, GuardOptions, RecipeToolOptions } from '../shared.js';
6
6
  export type { BlockedToolOutput, CompletionEvent, CompletionOptions, CompletionToolOutput, GuardEvent, GuardOptions, RecipeToolOptions, } from '../shared.js';
7
7
  export type RecipeTool = Tool<Record<string, unknown>, Record<string, unknown>>;
8
8
  export declare function recipeTool(name: RecipeName, options?: RecipeToolOptions): RecipeTool;
@@ -10,7 +10,20 @@ export declare function recipeTools(names: readonly RecipeName[], options?: Reci
10
10
  export type GuardToolsOptions = GuardOptions & {
11
11
  request?: string;
12
12
  };
13
- export declare function guardTools<TOOLS extends ToolSet>(tools: TOOLS, options?: GuardToolsOptions): TOOLS;
13
+ type ToolExecution<DEFINITION extends ToolSet[string]> = NonNullable<DEFINITION['execute']>;
14
+ type ToolOutput<RESULT> = RESULT extends AsyncIterable<infer OUTPUT> ? OUTPUT : Awaited<RESULT>;
15
+ type GuardedOutput<DEFINITION extends ToolSet[string]> = ToolOutput<ReturnType<ToolExecution<DEFINITION>>> | BlockedToolOutput;
16
+ type GuardedExecution<DEFINITION extends ToolSet[string]> = {
17
+ [KEY in keyof Pick<DEFINITION, 'execute'>]: (...args: Parameters<ToolExecution<DEFINITION>>) => AsyncGenerator<GuardedOutput<DEFINITION>>;
18
+ };
19
+ type GuardedTool<DEFINITION extends ToolSet[string]> = DEFINITION extends unknown ? Omit<DEFINITION, 'execute' | 'toModelOutput' | 'outputSchema'> & GuardedExecution<DEFINITION> & {
20
+ outputSchema?: FlexibleSchema<GuardedOutput<DEFINITION>>;
21
+ toModelOutput?: NonNullable<Tool<Parameters<ToolExecution<DEFINITION>>[0], GuardedOutput<DEFINITION>>['toModelOutput']>;
22
+ } : never;
23
+ export type GuardedTools<TOOLS extends ToolSet> = {
24
+ [NAME in keyof TOOLS]: 'execute' extends keyof TOOLS[NAME] ? ToolExecution<TOOLS[NAME]> extends never ? TOOLS[NAME] : GuardedTool<TOOLS[NAME]> : TOOLS[NAME];
25
+ };
26
+ export declare function guardTools<TOOLS extends ToolSet>(tools: TOOLS, options?: GuardToolsOptions): GuardedTools<TOOLS>;
14
27
  export type ModelCandidate = {
15
28
  id: string;
16
29
  text: string;
@@ -1,6 +1,8 @@
1
- import { jsonSchema, tool } from 'ai';
1
+ import { asSchema, jsonSchema, tool } from 'ai';
2
+ import { z } from 'zod';
2
3
  import { modelRoute } from '../../recipes/model-route/index.js';
3
- import { COMPLETION_TOOL_DESCRIPTION, COMPLETION_TOOL_INPUT_SCHEMA, DEFAULT_COMPLETION_TOOL_NAME, blockedOutput, defineRecipeTool, messageText, requireText, reviewCompletion, reviewToolCall, } from '../shared.js';
4
+ import { toolCallGateResultSchema } from '../../recipes/tool-call-gate/index.js';
5
+ import { BLOCKED_TOOL_OUTPUT_KIND, COMPLETION_TOOL_DESCRIPTION, COMPLETION_TOOL_INPUT_SCHEMA, DEFAULT_COMPLETION_TOOL_NAME, blockedOutput, defineRecipeTool, messageText, requireText, reviewCompletion, reviewToolCall, } from '../shared.js';
4
6
  export function recipeTool(name, options = {}) {
5
7
  const definition = defineRecipeTool(name, options);
6
8
  return tool({
@@ -13,27 +15,75 @@ export function recipeTools(names, options = {}) {
13
15
  return Object.fromEntries(names.map((name) => [name, recipeTool(name, options)]));
14
16
  }
15
17
  export function guardTools(tools, options = {}) {
18
+ return Object.fromEntries(Object.entries(tools).map(([name, definition]) => [name, guardTool(name, definition, options)]));
19
+ }
20
+ function guardTool(toolName, definition, options) {
21
+ const execute = definition.execute;
22
+ if (typeof execute !== 'function')
23
+ return definition;
16
24
  const { request, ...guardOptions } = options;
17
- const guarded = Object.entries(tools).map(([toolName, definition]) => {
18
- if (!('execute' in definition) || typeof definition.execute !== 'function')
19
- return [toolName, definition];
20
- const execute = definition.execute;
21
- const wrapped = {
22
- ...definition,
23
- execute: async (input, executionOptions) => {
24
- const decision = await reviewToolCall({
25
- toolName,
26
- input,
27
- request: request ?? requireText(lastUserText(executionOptions.messages), 'request'),
28
- }, guardOptions, executionOptions.abortSignal);
29
- if (decision.action !== 'allow')
30
- return blockedOutput(toolName, decision);
31
- return execute(input, executionOptions);
32
- },
33
- };
34
- return [toolName, wrapped];
25
+ const toModelOutput = definition.toModelOutput;
26
+ return {
27
+ ...definition,
28
+ execute: async function* (input, executionOptions) {
29
+ guardOptions.signal?.throwIfAborted();
30
+ executionOptions.abortSignal?.throwIfAborted();
31
+ const decision = await reviewToolCall({
32
+ toolName,
33
+ input,
34
+ request: request ?? requireText(lastUserText(executionOptions.messages), 'request'),
35
+ }, guardOptions, executionOptions.abortSignal);
36
+ guardOptions.signal?.throwIfAborted();
37
+ executionOptions.abortSignal?.throwIfAborted();
38
+ if (decision.action !== 'allow') {
39
+ yield blockedOutput(toolName, decision);
40
+ return;
41
+ }
42
+ const output = execute(input, executionOptions);
43
+ if (isAsyncIterable(output))
44
+ yield* output;
45
+ else
46
+ yield await output;
47
+ },
48
+ ...(toModelOutput === undefined
49
+ ? {}
50
+ : {
51
+ toModelOutput: (result) => blockedToolOutputSchema.safeParse(result.output).success
52
+ ? { type: 'json', value: result.output }
53
+ : toModelOutput(result),
54
+ }),
55
+ ...(definition.outputSchema === undefined
56
+ ? {}
57
+ : { outputSchema: includeBlockedOutput(definition.outputSchema) }),
58
+ };
59
+ }
60
+ function isAsyncIterable(value) {
61
+ return (typeof value?.[Symbol.asyncIterator] ===
62
+ 'function');
63
+ }
64
+ const blockedToolOutputSchema = toolCallGateResultSchema
65
+ .pick({ verdict: true, status: true, detected: true, confidence: true })
66
+ .extend({
67
+ kind: z.literal(BLOCKED_TOOL_OUTPUT_KIND),
68
+ blocked: z.literal(true),
69
+ action: z.enum(['ask', 'deny']),
70
+ message: z.string(),
71
+ });
72
+ function includeBlockedOutput(outputSchema) {
73
+ const original = asSchema(outputSchema);
74
+ return jsonSchema(async () => ({
75
+ anyOf: [
76
+ { $id: 'urn:jev-recipes:tool-output', ...(await original.jsonSchema) },
77
+ z.toJSONSchema(blockedToolOutputSchema),
78
+ ],
79
+ }), {
80
+ validate: async (value) => {
81
+ const blocked = blockedToolOutputSchema.safeParse(value);
82
+ if (blocked.success)
83
+ return { success: true, value: blocked.data };
84
+ return original.validate ? original.validate(value) : { success: true, value };
85
+ },
35
86
  });
36
- return Object.fromEntries(guarded);
37
87
  }
38
88
  export function routeModelStep(options) {
39
89
  const { candidates, request, context, minConfidence, fallback, onDecision, ...recipeOptions } = options;
@@ -1,11 +1,4 @@
1
- import type { DynamicStructuredTool } from '@langchain/core/tools';
2
- export type GuardableTool = {
3
- name: string;
4
- description: string;
5
- schema: unknown;
6
- returnDirect?: boolean;
7
- invoke(input: any, config?: any): Promise<unknown>;
8
- };
1
+ import { DynamicStructuredTool } from '@langchain/core/tools';
9
2
  import type { RecipeName } from '../../catalog/schema.js';
10
3
  import { COMPLETION_TOOL_INPUT_SCHEMA } from '../shared.js';
11
4
  import type { CompletionOptions, GuardOptions, RecipeToolOptions } from '../shared.js';
@@ -18,6 +11,13 @@ export type GuardRequest = string | ((input: unknown, config: unknown) => string
18
11
  export type GuardToolsOptions = GuardOptions & {
19
12
  request: GuardRequest;
20
13
  };
14
+ export type GuardableTool = {
15
+ name: string;
16
+ description: string;
17
+ schema: unknown;
18
+ returnDirect?: boolean;
19
+ invoke(input: any, config?: any): Promise<unknown>;
20
+ };
21
21
  export declare function guardTools(tools: readonly GuardableTool[], options: GuardToolsOptions): DynamicStructuredTool[];
22
22
  export declare function completionTool(options: CompletionOptions & {
23
23
  task: string;
@@ -1,4 +1,5 @@
1
- import { tool } from '@langchain/core/tools';
1
+ import { DynamicStructuredTool, tool } from '@langchain/core/tools';
2
+ import { ToolMessage } from '@langchain/core/messages';
2
3
  import { COMPLETION_TOOL_DESCRIPTION, COMPLETION_TOOL_INPUT_SCHEMA, DEFAULT_COMPLETION_TOOL_NAME, blockedOutput, defineRecipeTool, requireText, reviewCompletion, reviewToolCall, } from '../shared.js';
3
4
  export function recipeTool(name, options = {}) {
4
5
  const definition = defineRecipeTool(name, options);
@@ -12,26 +13,53 @@ export function recipeTools(names, options = {}) {
12
13
  return names.map((name) => recipeTool(name, options));
13
14
  }
14
15
  export function guardTools(tools, options) {
15
- const { request, ...guardOptions } = options;
16
- return tools.map((original) => {
17
- const schema = original.schema;
18
- return tool(async (input, config) => {
19
- const decision = await reviewToolCall({
20
- toolName: original.name,
21
- input,
22
- request: typeof request === 'function' ? request(input, config) : request,
23
- }, guardOptions, config?.signal);
24
- if (decision.action !== 'allow')
25
- return blockedOutput(original.name, decision);
26
- const { toolCall: _toolCall, ...delegatedConfig } = config ?? {};
27
- return original.invoke(input, delegatedConfig);
28
- }, {
16
+ return tools.map((original) => new GuardedTool(original, options));
17
+ }
18
+ class GuardedTool extends DynamicStructuredTool {
19
+ original;
20
+ guardOptions;
21
+ constructor(original, guardOptions) {
22
+ super({
29
23
  name: original.name,
30
24
  description: original.description,
31
- schema,
32
- ...(original.returnDirect === undefined ? {} : { returnDirect: original.returnDirect }),
25
+ schema: original.schema,
26
+ returnDirect: original.returnDirect ?? false,
27
+ func: (input, _runManager, config) => this.invoke(input, config),
33
28
  });
34
- });
29
+ this.original = original;
30
+ this.guardOptions = guardOptions;
31
+ }
32
+ async invoke(input, config) {
33
+ const { request, ...options } = this.guardOptions;
34
+ const args = isToolCall(input) ? input.args : input;
35
+ const requestConfig = isToolCall(input) ? { ...config, toolCall: input } : config;
36
+ options.signal?.throwIfAborted();
37
+ config?.signal?.throwIfAborted();
38
+ const decision = await reviewToolCall({
39
+ toolName: this.name,
40
+ input: args,
41
+ request: typeof request === 'function' ? request(args, requestConfig) : request,
42
+ }, options, config?.signal);
43
+ options.signal?.throwIfAborted();
44
+ config?.signal?.throwIfAborted();
45
+ if (decision.action === 'allow')
46
+ return this.original.invoke(input, config);
47
+ const output = blockedOutput(this.name, decision);
48
+ const toolCallId = requestConfig?.toolCall?.id;
49
+ return toolCallId === undefined
50
+ ? output
51
+ : new ToolMessage({
52
+ name: this.name,
53
+ tool_call_id: toolCallId,
54
+ content: JSON.stringify(output),
55
+ });
56
+ }
57
+ call(input, config, tags) {
58
+ return this.invoke(input, tags === undefined ? config : { ...config, tags: config?.tags ?? tags });
59
+ }
60
+ }
61
+ function isToolCall(input) {
62
+ return (typeof input === 'object' && input !== null && 'type' in input && input.type === 'tool_call');
35
63
  }
36
64
  export function completionTool(options) {
37
65
  return tool((input, config) => reviewCompletion({ task: requireText(options.task, 'task'), report: input.report, evidence: input.evidence }, options, config?.signal), {
@@ -22,7 +22,9 @@ export type GuardEvent = {
22
22
  input: unknown;
23
23
  decision: ToolCallGateResult;
24
24
  };
25
+ export declare const BLOCKED_TOOL_OUTPUT_KIND = "jev-recipes/blocked-tool-output";
25
26
  export type BlockedToolOutput = {
27
+ kind: typeof BLOCKED_TOOL_OUTPUT_KIND;
26
28
  blocked: true;
27
29
  action: 'ask' | 'deny';
28
30
  verdict: ToolCallGateResult['verdict'];
@@ -2,6 +2,7 @@ import { describeRecipe } from '../catalog/index.js';
2
2
  import { loadRecipe } from '../catalog/runner.js';
3
3
  import { completionGate } from '../recipes/completion-gate/index.js';
4
4
  import { toolCallGate } from '../recipes/tool-call-gate/index.js';
5
+ export const BLOCKED_TOOL_OUTPUT_KIND = 'jev-recipes/blocked-tool-output';
5
6
  export const DEFAULT_COMPLETION_TOOL_NAME = 'report_completion';
6
7
  export const COMPLETION_TOOL_DESCRIPTION = 'Report that the task is complete. State what was done in report, and put the evidence that demonstrates it, such as test output, command results, or observed behavior, in evidence. A claim without evidence is not accepted.';
7
8
  export const COMPLETION_TOOL_INPUT_SCHEMA = {
@@ -54,6 +55,7 @@ export function blockedOutput(toolName, decision) {
54
55
  ? `The call to ${toolName} was refused by policy and did not run.${risks} Do not retry it. Explain what you intended and continue with permitted work.`
55
56
  : `The call to ${toolName} did not run because a person must confirm it first.${risks} Do not retry it unattended. Explain what you intended and continue with other permitted work.`;
56
57
  return {
58
+ kind: BLOCKED_TOOL_OUTPUT_KIND,
57
59
  blocked: true,
58
60
  action,
59
61
  verdict: decision.verdict,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-recipes",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "248 small Jev decisions for JavaScript and TypeScript: route, rerank, gate, grade, compare, and label text for agents, RAG, support, code review, and music. Each returns a typed result with explicit uncertainty. Plug into Vercel AI SDK or LangChain agents, import one recipe, or run the CLI with JSON from any language.",
5
5
  "type": "module",
6
6
  "license": "MIT",