@tangleai/agents 0.21.1

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.
@@ -0,0 +1,205 @@
1
+ //@ts-check
2
+ /**
3
+ * The action language: what a model may say about the environment.
4
+ *
5
+ * The RLM paper's root model writes Python and an interpreter runs it.
6
+ * This package has no interpreter and will never have one — `eval` and
7
+ * `new Function` are forbidden by the house rules and a browser tab is
8
+ * the wrong place for a sandbox — so the model authors a **document**
9
+ * instead, and the document is put through the same two gates every
10
+ * generated Jaren program goes through: a schema that constrains
11
+ * decoding (the shape) and a compiler that runs before anything else
12
+ * does (the semantics).
13
+ *
14
+ * Read the two rules that shaped every line of this file:
15
+ *
16
+ * - **A step names slots; it never carries content** (D2). Every member
17
+ * below is an operation name, a binding name, a slot reference, a
18
+ * bounded instruction or a query document. There is no member a
19
+ * corpus can be poured into, and none can be added later without
20
+ * failing `test/ai/program.test.js` — the schema is walked and every
21
+ * string member must declare a `maxLength`. That is what makes "the
22
+ * program is constant-size whatever the corpus" a property of the
23
+ * grammar rather than a promise about how it will be used.
24
+ * - **`map` is the only step that calls a model.** One construct, one
25
+ * concurrency bound, one place to count spend. A grammar with
26
+ * sub-calls sprinkled through it cannot be bounded, and a runner over
27
+ * such a grammar cannot state what a program will cost before it runs
28
+ * it.
29
+ *
30
+ * Three shape decisions exist for the weak tier specifically (D8), and
31
+ * each one trades expressiveness for a decision the model does not have
32
+ * to make:
33
+ *
34
+ * - **Every step reads `from` and writes `as`.** Not `slot`/`in`/`over`
35
+ * per operation: one input member and one output member across the
36
+ * whole language, so the model picks the *operation* and never the
37
+ * spelling of its argument.
38
+ * - **Bindings are program-local names, not addresses.** The program
39
+ * says `as: "pieces"`; the runner resolves that to whatever the
40
+ * environment's derived addressing produced (HORIZON_06 owns
41
+ * addresses; a model that could write one could name a slot that
42
+ * cannot exist).
43
+ * - **`query` is left open unless a grammar is injected.** The seam is
44
+ * D3: with `@jarenjs/json`'s query grammar passed as a `ref` the
45
+ * shape is constrained too; without it the schema accepts any JSON
46
+ * value here and the compile gate is what refuses a bad one. A schema
47
+ * that hard-`$ref`'d a grammar this package may not import would make
48
+ * the whole language unusable with the seam empty.
49
+ */
50
+
51
+ /** Steps one program may have. A plan longer than this is a program
52
+ * that should have been two runs; it is also past the length a small
53
+ * model keeps coherent. */
54
+ export const MAX_STEPS = 12;
55
+
56
+ /** The whole document's character cap, enforced by the compiler. This
57
+ * is the constant in "constant-size root request": the program is one
58
+ * more thing the root carries, and it must not grow with the corpus. */
59
+ export const MAX_PROGRAM_CHARS = 4000;
60
+
61
+ /** A binding name: short, lowercase, unmistakable in an error message. */
62
+ export const NAME_PATTERN = '^[a-z][a-z0-9_]{0,31}$';
63
+
64
+ /** How long a slot reference may be — an address, never a payload. */
65
+ const SLOT_REF_MAX = 200;
66
+
67
+ /** How long a sub-call instruction may be. An instruction, not content:
68
+ * the content is the slot the sub-call is run over. */
69
+ const PROMPT_MAX = 1000;
70
+
71
+ /** How long a grep pattern may be. */
72
+ const PATTERN_MAX = 200;
73
+
74
+ /** The operations a program may name, in the order a plan uses them. */
75
+ export const PROGRAM_OPS = ['chunk', 'grep', 'select', 'stat', 'peek', 'map', 'reduce', 'answer'];
76
+
77
+ /** The one input member. See the file header for why it is not per-op. */
78
+ const FROM = {
79
+ type: 'string',
80
+ minLength: 1,
81
+ maxLength: SLOT_REF_MAX,
82
+ description: 'What this step reads: a name from an earlier step\'s "as", or a slot from the digest.',
83
+ };
84
+
85
+ /** The one output member: the name later steps use to read this one.
86
+ * `maxLength` as well as the pattern, which already bounds it: the D2
87
+ * walk in `test/ai/program.test.js` checks that every string member
88
+ * declares a cap, and a check that has to interpret a regex to decide
89
+ * whether one is bounded is a weaker check than one that reads a
90
+ * number. */
91
+ const AS = {
92
+ type: 'string',
93
+ pattern: NAME_PATTERN,
94
+ maxLength: 32,
95
+ description: 'A short name for this step\'s result, used as "from" by a later step.',
96
+ };
97
+
98
+ /**
99
+ * One step's schema.
100
+ * @param {string} op
101
+ * @param {string} description
102
+ * @param {Record<string, any>} extra - members beyond `from`/`as`
103
+ * @param {string[]} [required] - beyond `from`, `as`
104
+ */
105
+ function step(op, description, extra = {}, required = []) {
106
+ return {
107
+ title: op,
108
+ description,
109
+ type: 'object',
110
+ properties: {
111
+ op: { const: op },
112
+ from: FROM,
113
+ as: AS,
114
+ ...extra,
115
+ },
116
+ required: ['op', 'from', 'as', ...required],
117
+ additionalProperties: false,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * The program schema.
123
+ *
124
+ * `queryRef` is the `$id` of an injected query grammar
125
+ * (`@jarenjs/json/schemas/jaren-query.schema.json`, or its LLM-profile
126
+ * twin — the twin is the better choice for constrained decoding, which
127
+ * is what it was derived for). Given one, `select` and `reduce` are
128
+ * shape-constrained as well as compile-gated, and the caller must pass
129
+ * the same grammar to `createStructuredOutput` as a `ref` so the
130
+ * validator can resolve it. Given none, `query` accepts any JSON value
131
+ * and the compile gate carries the whole weight.
132
+ *
133
+ * @param {{ queryRef?: string, maxSteps?: number }} [options]
134
+ * @returns {any} a JSON Schema document
135
+ */
136
+ export function programSchema(options = {}) {
137
+ const query = options.queryRef === undefined
138
+ ? { description: 'A jaren-query document.' }
139
+ : { $ref: options.queryRef, description: 'A jaren-query document.' };
140
+ const maxSteps = options.maxSteps ?? MAX_STEPS;
141
+
142
+ return {
143
+ $id: 'https://jarenjs.github.io/schemas/ai/program.json',
144
+ title: 'Environment program',
145
+ description: 'A plan over slots in the agent\'s environment. Steps name slots and never'
146
+ + ' carry their content; the last step is always "answer".',
147
+ type: 'object',
148
+ properties: {
149
+ steps: {
150
+ type: 'array',
151
+ minItems: 1,
152
+ maxItems: maxSteps,
153
+ items: {
154
+ // anyOf, not oneOf: the branches are disjoint by their `op`
155
+ // const, and oneOf is the keyword provider implementations
156
+ // most often refuse (the same reason schemas/patch.js gives).
157
+ anyOf: [
158
+ step('chunk', 'Split a slot into addressable pieces.', {
159
+ strategy: { enum: ['size', 'line', 'separator'], description: 'How to cut. Default size.' },
160
+ size: { type: 'integer', minimum: 200, maximum: 100000, description: 'Piece size in characters.' },
161
+ }),
162
+ step('grep', 'Scan for a pattern and record which slots matched.', {
163
+ pattern: { type: 'string', minLength: 1, maxLength: PATTERN_MAX, description: 'A regular expression.' },
164
+ flags: { enum: ['i', 'm', 'im', ''], description: 'Regex flags. Default i.' },
165
+ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Maximum matches recorded.' },
166
+ }, ['pattern']),
167
+ step('select', 'Run a query over a JSON slot and store the result.', { query }, ['query']),
168
+ step('stat', 'Counts, sizes and shape of a slot or a family.'),
169
+ step('peek', 'Metadata and a head excerpt of one slot.'),
170
+ step('map', 'Ask the model once per piece. The ONLY step that calls a model.', {
171
+ prompt: {
172
+ type: 'string',
173
+ minLength: 1,
174
+ maxLength: PROMPT_MAX,
175
+ description: 'What to ask about each piece. Ask for a JSON value; the piece is'
176
+ + ' supplied automatically, so do not paste any content here.',
177
+ },
178
+ }, ['prompt']),
179
+ step('reduce', 'Combine a map\'s results with a query, into one slot.', {
180
+ query, outputSchema: { type: 'object', description: 'Declared JSON Schema for an inference-unknown result; validated before storage.' },
181
+ }, ['query']),
182
+ {
183
+ title: 'answer',
184
+ description: 'The last step: the slot the answer is read from.',
185
+ type: 'object',
186
+ properties: {
187
+ op: { const: 'answer' },
188
+ from: FROM,
189
+ chars: { type: 'integer', minimum: 1, maximum: 8000, description: 'How much of it to read.' },
190
+ },
191
+ required: ['op', 'from'],
192
+ additionalProperties: false,
193
+ },
194
+ ],
195
+ },
196
+ },
197
+ },
198
+ required: ['steps'],
199
+ additionalProperties: false,
200
+ };
201
+ }
202
+
203
+ /** The program schema with the query seam empty — what a caller with no
204
+ * grammar injected authors against. */
205
+ export const PROGRAM_SCHEMA = programSchema();
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @typedef {Object} ToolDef
3
+ * @property {string} name
4
+ * @property {string} description
5
+ * @property {any} inputSchema - JSON Schema for the arguments object
6
+ * @property {(input: any) => any} execute - may return a value or a promise
7
+ */
8
+ /**
9
+ * @param {{ validator?: any }} [options] - a shared JarenValidator, if
10
+ * the host already has one
11
+ * @returns {{
12
+ * add: (def: ToolDef) => void,
13
+ * list: () => { name: string, description: string, inputSchema: any }[],
14
+ * toFunctionTools: () => any[],
15
+ * execute: (name: string, args: any) => any,
16
+ * }}
17
+ */
18
+ export function createToolbox(options?: {
19
+ validator?: any;
20
+ }): {
21
+ add: (def: ToolDef) => void;
22
+ list: () => {
23
+ name: string;
24
+ description: string;
25
+ inputSchema: any;
26
+ }[];
27
+ toFunctionTools: () => any[];
28
+ execute: (name: string, args: any) => any;
29
+ };
30
+ /**
31
+ * Publish validated tools through the shared browser adapter. Await `ready`
32
+ * for completion and call `dispose` when the host leaves. The optional context
33
+ * remains the second argument; undefined requests automatic discovery.
34
+ * @param {ReturnType<typeof createToolbox>} toolbox
35
+ * @param {unknown} [modelContext]
36
+ * @param {(error: unknown) => void} [onError]
37
+ * @param {Omit<import('@jarenjs/contract/webmcp').WebMcpOptions, 'context'|'onError'>} [options]
38
+ */
39
+ export function registerModelContext(toolbox: ReturnType<typeof createToolbox>, modelContext?: unknown, onError?: (error: unknown) => void, options?: Omit<import("@jarenjs/contract/webmcp").WebMcpOptions, "context" | "onError">): {
40
+ ready: Promise<import("@jarenjs/contract/webmcp").WebMcpResult>;
41
+ dispose: () => Promise<import("@jarenjs/contract/webmcp").WebMcpResult>;
42
+ readonly status: "disposed" | "failed" | "pending" | "registered" | "unavailable";
43
+ };
44
+ export type ToolDef = {
45
+ name: string;
46
+ description: string;
47
+ /**
48
+ * - JSON Schema for the arguments object
49
+ */
50
+ inputSchema: any;
51
+ /**
52
+ * - may return a value or a promise
53
+ */
54
+ execute: (input: any) => any;
55
+ };
package/src/toolbox.js ADDED
@@ -0,0 +1,178 @@
1
+ //@ts-check
2
+ /**
3
+ * The toolbox: a registry of tools an AI may call, each declared with
4
+ * a JSON Schema `inputSchema` that Jaren itself compiles and enforces
5
+ * before the tool runs — the suite guarding its own tools. One
6
+ * registry serves every surface that wants to drive the host app:
7
+ *
8
+ * - an embedded agent loop (`toFunctionTools()` produces the OpenAI
9
+ * function-calling definitions, `execute()` dispatches a call);
10
+ * - a browser-hosted agent over WebMCP (`registerModelContext()`
11
+ * publishes the same tools on either browser model-context root).
12
+ *
13
+ * `execute` never throws for content-level problems: an unknown tool
14
+ * or a throwing tool comes back as `{ error }`, invalid input as
15
+ * `{ error, errors, inputSchema }` plus a `hint` where the model sent
16
+ * JSON-encoded text for a structured property — results the calling
17
+ * model can read and recover from.
18
+ */
19
+
20
+ import { JarenValidator } from '@jarenjs/validate';
21
+ import { registerWebMcp } from '@jarenjs/contract/webmcp';
22
+
23
+ import { checkOutcome } from '@jarenjs/core/check';
24
+ import { invalidInput } from '@tangleai/models/check';
25
+
26
+ /** Whether a property schema asks for structure (object or array). */
27
+ function wantsStructure(schema) {
28
+ const type = schema?.type;
29
+ return type === 'object' || type === 'array'
30
+ || (Array.isArray(type) && (type.includes('object') || type.includes('array')));
31
+ }
32
+
33
+ /**
34
+ * The top-level properties that wanted structure but arrived as
35
+ * strings — after coercion failed, these are what went wrong.
36
+ * @param {any} inputSchema
37
+ * @param {any} input
38
+ * @returns {string[]}
39
+ */
40
+ function stringStructureKeys(inputSchema, input) {
41
+ const properties = inputSchema?.properties;
42
+ if (properties == null || input === null || typeof input !== 'object') return [];
43
+ return Object.entries(properties)
44
+ .filter(([key, schema]) => wantsStructure(schema) && typeof input[key] === 'string')
45
+ .map(([key]) => key);
46
+ }
47
+
48
+ /**
49
+ * A copy of `input` with string-valued top-level properties parsed as
50
+ * JSON wherever the schema wants an object or array — or `null` when
51
+ * nothing was coercible.
52
+ * @param {any} inputSchema
53
+ * @param {any} input
54
+ * @returns {any | null}
55
+ */
56
+ function coerceStringArguments(inputSchema, input) {
57
+ const properties = inputSchema?.properties;
58
+ if (properties == null || input === null || typeof input !== 'object') return null;
59
+ let coerced = null;
60
+ for (const [key, schema] of Object.entries(properties)) {
61
+ if (!wantsStructure(schema) || typeof input[key] !== 'string') continue;
62
+ try {
63
+ const parsed = JSON.parse(input[key]);
64
+ if (parsed !== null && typeof parsed === 'object') {
65
+ if (coerced === null) coerced = { ...input };
66
+ coerced[key] = parsed;
67
+ }
68
+ }
69
+ catch {
70
+ // not JSON text: keep the original so validation reports it
71
+ }
72
+ }
73
+ return coerced;
74
+ }
75
+
76
+ /**
77
+ * @typedef {Object} ToolDef
78
+ * @property {string} name
79
+ * @property {string} description
80
+ * @property {any} inputSchema - JSON Schema for the arguments object
81
+ * @property {(input: any) => any} execute - may return a value or a promise
82
+ */
83
+
84
+ /**
85
+ * @param {{ validator?: any }} [options] - a shared JarenValidator, if
86
+ * the host already has one
87
+ * @returns {{
88
+ * add: (def: ToolDef) => void,
89
+ * list: () => { name: string, description: string, inputSchema: any }[],
90
+ * toFunctionTools: () => any[],
91
+ * execute: (name: string, args: any) => any,
92
+ * }}
93
+ */
94
+ export function createToolbox(options = {}) {
95
+ const jaren = options.validator ?? new JarenValidator({ skipErrors: false, collectErrors: true });
96
+ /** @type {Map<string, ToolDef & { check: (input: any) => any }>} */
97
+ const tools = new Map();
98
+
99
+ /** @param {ToolDef} def */
100
+ function add(def) {
101
+ tools.set(def.name, { ...def, check: jaren.compile(def.inputSchema) });
102
+ }
103
+
104
+ function list() {
105
+ return [...tools.values()].map(({ name, description, inputSchema }) =>
106
+ ({ name, description, inputSchema }));
107
+ }
108
+
109
+ function toFunctionTools() {
110
+ return [...tools.values()].map(({ name, description, inputSchema }) => ({
111
+ type: 'function',
112
+ function: { name, description, parameters: inputSchema },
113
+ }));
114
+ }
115
+
116
+ /**
117
+ * Dispatch one call. Synchronous tools answer synchronously (WebMCP
118
+ * hosts call `execute` directly); a promise-returning tool resolves
119
+ * to its value with rejections folded into `{ error }`. A rejected
120
+ * call answers `{ error, errors, inputSchema }` — the first
121
+ * `MAX_INPUT_ERRORS` validation errors plus the schema to re-read —
122
+ * with a `hint` added when a property wanting structure arrived as
123
+ * unparseable JSON text.
124
+ * @param {string} name
125
+ * @param {any} args
126
+ */
127
+ function execute(name, args) {
128
+ const tool = tools.get(name);
129
+ if (tool === undefined) return { error: `unknown tool '${name}'` };
130
+ let input = args ?? {};
131
+ let outcome = checkOutcome(tool.check(input));
132
+ if (!outcome.valid) {
133
+ // models routinely JSON-encode nested arguments; when a property
134
+ // wanted structure but arrived as parseable JSON text, validate
135
+ // the parsed value instead — valid or not, its errors point into
136
+ // the structure the model meant to send
137
+ const coerced = coerceStringArguments(tool.inputSchema, input);
138
+ if (coerced !== null) {
139
+ input = coerced;
140
+ outcome = checkOutcome(tool.check(coerced));
141
+ }
142
+ }
143
+ if (!outcome.valid) {
144
+ const stringly = stringStructureKeys(tool.inputSchema, input);
145
+ return invalidInput(name, outcome, tool.inputSchema,
146
+ stringly.length === 0 ? {} : {
147
+ hint: `${stringly.map((k) => `'${k}'`).join(', ')} arrived as a JSON-encoded string that does not parse — pass a real JSON value, not quoted JSON text`,
148
+ });
149
+ }
150
+ try {
151
+ const value = tool.execute(input);
152
+ return typeof value?.then === 'function'
153
+ ? value.then((resolved) => resolved, (/** @type {any} */ err) =>
154
+ ({ error: err?.message ?? String(err) }))
155
+ : value;
156
+ }
157
+ catch (err) {
158
+ return { error: /** @type {any} */ (err)?.message ?? String(err) };
159
+ }
160
+ }
161
+
162
+ return { add, list, toFunctionTools, execute };
163
+ }
164
+
165
+ /**
166
+ * Publish validated tools through the shared browser adapter. Await `ready`
167
+ * for completion and call `dispose` when the host leaves. The optional context
168
+ * remains the second argument; undefined requests automatic discovery.
169
+ * @param {ReturnType<typeof createToolbox>} toolbox
170
+ * @param {unknown} [modelContext]
171
+ * @param {(error: unknown) => void} [onError]
172
+ * @param {Omit<import('@jarenjs/contract/webmcp').WebMcpOptions, 'context'|'onError'>} [options]
173
+ */
174
+ export function registerModelContext(toolbox, modelContext, onError, options = {}) {
175
+ return registerWebMcp(toolbox.list().map(def => ({ ...def,
176
+ execute: (args) => toolbox.execute(def.name, args),
177
+ })), { ...options, ...(modelContext === undefined ? {} : { context: modelContext }), onError });
178
+ }