Recursive AI

Give scripts tools from a JSON Schema

All oneecma recipes

Register host functions whose argument is checked against a JSON Schema before your C# handler runs, the way an agent host exposes its tools to model-written JavaScript. A bad call fails with a report that names the path, the rule and the value.

C#
using System.Text.Json;
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
 
const string createOrderSchema = """
{
  "type": "object",
  "required": ["customer", "items"],
  "additionalProperties": false,
  "properties": {
    "customer": { "type": "string", "minLength": 1 },
    "items": {
      "type": "array", "minItems": 1,
      "items": {
        "type": "object", "required": ["sku", "qty"],
        "properties": { "sku": { "type": "string", "pattern": "^[a-z]+$" }, "qty": { "type": "integer", "minimum": 1 } }
      }
    }
  }
}
""";
 
const string lookupSchema = """{ "type": "object", "required": ["sku"], "properties": { "sku": { "type": "string" } } }""";
 
var engine = new ScriptEngine(new ScriptEngineOptions
{
    Configure = e =>
    {
        JsRealm realm = e.MainRealm;
 
        JsObject createOrder = JsonSchemaTools.Define(realm, "createOrder", createOrderSchema, args =>
        {
            int lines = args.GetProperty("items").GetArrayLength();
            return JsonSerializer.SerializeToElement(new { id = "ord_1042", lines });
        });
        realm.DefineData(realm.Global, "createOrder", JsValue.FromObject(createOrder), JsAttributes.Method);
 
        JsObject lookup = JsonSchemaTools.DefineAsync(realm, "lookup", lookupSchema, async args =>
        {
            string sku = args.GetProperty("sku").GetString()!;          // read the argument before the first await
            await Task.Delay(10);                                        // a real service call
            return JsonSerializer.SerializeToElement(new { sku, price = 1.5 });
        });
        realm.DefineData(realm.Global, "lookup", JsValue.FromObject(lookup), JsAttributes.Method);
    },
});
 
JsValue ok = engine.EvaluateAsyncBody("""
    const { price } = await lookup({ sku: "pen" });
    const order = createOrder({ customer: "Harbor Street Books", items: [{ sku: "pen", qty: 3 }] });
    return `${order.id}: ${order.lines} line, pen at ${price}`;
    """, "agent.js");
Console.WriteLine(engine.ToDisplayString(await engine.AwaitAsync(ok)));
 
string[] attempts =
[
    """createOrder({ customer: "Harbor Street Books", items: [{ sku: "pen", qty: "3" }] });""",
    """createOrder({ customer: "Harbor Street Books", items: [{ sku: "Pen!", qty: 3 }] });""",
    """createOrder({ customer: "Harbor Street Books", item: [{ sku: "pen", qty: 3 }] });""",
];
foreach (string source in attempts)
{
    try
    {
        engine.Evaluate(source, "agent.js");
    }
    catch (JsException e)
    {
        Console.WriteLine(DiagnosticRenderer.Render(e.Report!));
    }
}
Text
ord_1042: 1 line, pen at 1.5
error[TypeError]: createOrder: argument 'args.items[0].qty' must be an integer, got string "3"
 --> agent.js:1:1
  |
1 | createOrder({ customer: "Harbor Street Books", items: [{ sku: "pen", qty: "3" }] });
  | ^^^^^^^^^^^ `args.items[0].qty` is string "3", expected an integer
  |
  = help: convert the string first: Number("3")
  = note: host function: createOrder(args) with a JSON Schema input
  = stack: createOrder [.NET] ← <top-level> (agent.js:1)
 
error[TypeError]: createOrder: argument 'args.items[0].sku' must be a string matching /^[a-z]+$/, got string "Pen!"
 --> agent.js:1:1
  |
1 | createOrder({ customer: "Harbor Street Books", items: [{ sku: "Pen!", qty: 3 }] });
  | ^^^^^^^^^^^ `args.items[0].sku` is string "Pen!", expected a string matching /^[a-z]+$/
  |
  = note: host function: createOrder(args) with a JSON Schema input
  = stack: createOrder [.NET] ← <top-level> (agent.js:1)
 
error[TypeError]: createOrder: argument 'args.items' is missing; expected an array (required property)
 --> agent.js:1:1
  |
1 | createOrder({ customer: "Harbor Street Books", item: [{ sku: "pen", qty: 3 }] });
  | ^^^^^^^^^^^ `args.items` is missing
  |
  = help: pass an array (required property) as 'args.items'
  = note: host function: createOrder(args) with a JSON Schema input
  = stack: createOrder [.NET] ← <top-level> (agent.js:1)

The schema you already give the model for a tool is the one that checks the call. The handler only ever sees arguments that passed.

The four ways to define a tool

  • JsonSchemaTools.Define(realm, name, schema, Func<JsonElement, JsonElement>): JSON in, JSON out, as above.
  • JsonSchemaTools.DefineAsync(…, Func<JsonElement, Task<JsonElement>>): the script gets a promise.
  • Typed overloads take JsonTypeInfo<TArgs> and JsonTypeInfo<TResult> from a System.Text.Json source-generated context, so they stay NativeAOT-safe. Use a camel-case naming policy to match the schema.
  • JsonSchemaTools.Validated(realm, name, schema, existingFunction, thisValue) puts a schema check in front of a function you already have, such as an exported [JsExport] method.

The script value becomes JSON from its own enumerable properties: functions, symbols and undefined properties are left out, and non-finite numbers become null.

Supported keywords

type (also a list), enum, const, properties, required, additionalProperties (false or a schema), items, minItems, maxItems, minLength, maxLength, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, pattern, allOf, anyOf, oneOf, not and local $ref. Other keywords are ignored. pattern is a .NET regular expression with a 200 ms limit, and a timeout counts as no match.

Good to know

  • In 1.0.0 the JsonElement a DefineAsync handler receives is valid only until the handler first awaits. Read what you need from it, or keep args.Clone(), before the first await, as lookup does above. Reading it afterward throws ObjectDisposedException.
  • EvaluateAsyncBody runs code as the body of an async function, so await and return work at the top level: see Run code a model wrote.
  • The helper and its messages are documented in [JsExport] bindings.