sygnal 6.0.0 → 6.1.0

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/ai.cjs.js +115 -0
  3. package/dist/ai.cjs.js.map +1 -0
  4. package/dist/ai.esm.js +2 -0
  5. package/dist/ai.esm.js.map +1 -0
  6. package/dist/astro/index.cjs.js +599 -4
  7. package/dist/astro/index.cjs.js.map +1 -1
  8. package/dist/astro/index.mjs +599 -4
  9. package/dist/astro/index.mjs.map +1 -1
  10. package/dist/astro/server.cjs.js.map +1 -1
  11. package/dist/astro/server.mjs.map +1 -1
  12. package/dist/devtools.cjs.js +413 -4
  13. package/dist/devtools.cjs.js.map +1 -1
  14. package/dist/devtools.esm.js +413 -5
  15. package/dist/devtools.esm.js.map +1 -1
  16. package/dist/diagnostics.cjs.js +170 -12
  17. package/dist/diagnostics.cjs.js.map +1 -1
  18. package/dist/diagnostics.esm.js +170 -12
  19. package/dist/diagnostics.esm.js.map +1 -1
  20. package/dist/guide/accessibility.md +54 -1
  21. package/dist/guide/agent.md +588 -0
  22. package/dist/guide/ai-chat.md +693 -0
  23. package/dist/guide/ai-decisions.md +330 -0
  24. package/dist/guide/forms-reference.md +28 -0
  25. package/dist/guide/forms.md +2 -1
  26. package/dist/guide/http.md +2 -0
  27. package/dist/guide/mcp-apps.md +272 -0
  28. package/dist/guide/recipes/ai-form-fill.md +163 -0
  29. package/dist/guide/recipes/ai-summarize.md +155 -0
  30. package/dist/guide/recipes/ai-support-inbox.md +190 -0
  31. package/dist/guide/recipes/overview.md +10 -0
  32. package/dist/guide/webmcp.md +226 -0
  33. package/dist/index.cjs.js +7841 -3370
  34. package/dist/index.cjs.js.map +1 -1
  35. package/dist/index.d.ts +1288 -6
  36. package/dist/index.esm.js +7139 -2696
  37. package/dist/index.esm.js.map +1 -1
  38. package/dist/sygnal.min.js +1 -1
  39. package/dist/sygnal.min.js.map +1 -1
  40. package/dist/vike/config/package.json +1 -1
  41. package/dist/vite/plugin.cjs.js +599 -4
  42. package/dist/vite/plugin.cjs.js.map +1 -1
  43. package/dist/vite/plugin.mjs +599 -4
  44. package/dist/vite/plugin.mjs.map +1 -1
  45. package/llms.txt +7 -1
  46. package/package.json +15 -1
  47. package/src/ai.d.ts +1186 -0
  48. package/src/ai.ts +23 -0
  49. package/src/core/hooks.ts +1 -1
  50. package/src/devtools.d.ts +20 -1
  51. package/src/devtools.ts +3 -0
  52. package/src/extra/ai/agent/index.ts +484 -0
  53. package/src/extra/ai/answers.ts +115 -0
  54. package/src/extra/ai/chat/behavior.ts +336 -0
  55. package/src/extra/ai/chat/driver.ts +271 -0
  56. package/src/extra/ai/chat/memoryTransport.ts +61 -0
  57. package/src/extra/ai/chat/output.ts +37 -0
  58. package/src/extra/ai/commandBar.ts +281 -0
  59. package/src/extra/ai/decide.ts +104 -0
  60. package/src/extra/ai/index.ts +42 -0
  61. package/src/extra/ai/link.ts +90 -0
  62. package/src/extra/ai/mcpApp.ts +314 -0
  63. package/src/extra/ai/messages.ts +51 -0
  64. package/src/extra/ai/schema/index.ts +166 -0
  65. package/src/extra/ai/schema/jsonSchema.ts +72 -0
  66. package/src/extra/ai/schema/strict.ts +152 -0
  67. package/src/extra/ai/transports/agui.ts +177 -0
  68. package/src/extra/ai/transports/anthropicMessages.ts +175 -0
  69. package/src/extra/ai/transports/chatCompletions.ts +105 -0
  70. package/src/extra/ai/transports/chromePrompt.ts +77 -0
  71. package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
  72. package/src/extra/ai/transports/fromAISDK.ts +126 -0
  73. package/src/extra/ai/transports/openResponses.ts +105 -0
  74. package/src/extra/ai/transports/shared.ts +125 -0
  75. package/src/extra/ai/transports/tools.ts +59 -0
  76. package/src/extra/ai/transports/uiMessageStream.ts +107 -0
  77. package/src/extra/ai/webmcp.ts +260 -0
  78. package/src/extra/copyAsTest.ts +2 -2
  79. package/src/extra/devMcp.ts +386 -0
  80. package/src/extra/devtoolsActions.ts +1 -1
  81. package/src/extra/diagnostics/checks/actionLog.ts +3 -3
  82. package/src/extra/diagnostics/checks/chat.ts +71 -0
  83. package/src/extra/diagnostics/checks/forms.ts +8 -0
  84. package/src/extra/diagnostics/checks/index.ts +6 -0
  85. package/src/extra/diagnostics/checks/public.d.ts +19 -2
  86. package/src/extra/diagnostics/checks/wiring.ts +5 -2
  87. package/src/extra/diagnostics/codes.ts +67 -0
  88. package/src/extra/form.ts +22 -6
  89. package/src/extra/formTool.ts +150 -0
  90. package/src/extra/testing.ts +125 -8
  91. package/src/index.d.ts +116 -3
  92. package/src/index.ts +21 -1
  93. package/src/shared.ts +9 -0
  94. package/src/vite/mcp.ts +568 -0
  95. package/src/vite/plugin.d.ts +27 -2
  96. package/src/vite/plugin.ts +43 -4
@@ -0,0 +1,166 @@
1
+ /*
2
+ * PLAN-6 A-1 (0-S4): the input contract of agent tools and of structured chat output.
3
+ *
4
+ * An `input` (or a chat request's `output`) is a Standard Schema that also implements Standard
5
+ * JSON Schema (Zod 4.2+, ArkType 2.1.28+, Valibot through `toStandardJsonSchema()`), or a plain
6
+ * JSON Schema wrapped by `jsonSchema()` (./jsonSchema.ts, D262). The model always gets the
7
+ * INPUT-side JSON Schema (it produces what `validate` receives), after the portable
8
+ * normalization below, which every consumer (the agent layer, WebMCP, the chat behavior, the
9
+ * chat driver's `output`, t.tools()) shares:
10
+ * - `$schema` / `$id` dropped, `oneOf` -> `anyOf`, Zod's safe-integer bounds dropped, a closed
11
+ * tuple's `items: false` -> `maxItems` (Ollama rejects boolean subschemas);
12
+ * - a root self-reference (`"$ref": "#"`, Zod's recursion) moved into `$defs.__root` (Ollama
13
+ * rejects `#`, and wrapping changes what it means); ArkType's `$ref`-only root inlined;
14
+ * - a non-object root wrapped as `{ value }` (`required`, `additionalProperties: false`); the
15
+ * unwrap is lenient: arguments without a `value` key are taken as the value itself (0-S4: both
16
+ * local models sent a wrapped object union bare).
17
+ * A schema part with no JSON Schema form (refinements, a Date) is retried with the library's
18
+ * lossy option (`lossy`: SYG243, a dev warning; validation still enforces it); no form at all is
19
+ * `error` (SYG240). Conversions are cached by schema object (ArkType rebuilds `~standard` on
20
+ * every read, G-608). The strict layer for OpenAI / Anthropic is the transports' (Phase 2), opt-in
21
+ through `strictSchemas` (./strict.ts, D285); both walk schemas with `walk` below.
22
+ */
23
+
24
+ /** The result of converting a schema: the tool schema, or why there is none (SYG240) */
25
+ export interface Converted {
26
+ /** the normalized, input-side JSON Schema; always an object root */
27
+ schema?: any
28
+ /** the root was wrapped as `{ value }` */
29
+ wrapped?: boolean
30
+ /** a lossy conversion (SYG243): what the library couldn't express */
31
+ lossy?: string
32
+ /** no JSON Schema form (SYG240) */
33
+ error?: string
34
+ }
35
+
36
+ // each library's "give me what you can" switch (Standard JSON Schema's libraryOptions)
37
+ const LOSSY: Record<string, any> = {zod: {unrepresentable: 'any'}, valibot: {errorMode: 'ignore'}, arktype: {fallback: (c: any) => c.base}}
38
+ const SAFE = 9007199254740991
39
+ const cache = new WeakMap<object, Converted>()
40
+
41
+ const msg = (e: any) => String(e?.message || e).split('\n')[0].slice(0, 200)
42
+ export const isObj = (x: any): boolean => !!x && typeof x == 'object' && !Array.isArray(x)
43
+
44
+ /**
45
+ * The JSON Schema a model gets for `schema` (a Standard Schema with Standard JSON Schema, or
46
+ * `jsonSchema()`): `{ schema, wrapped, lossy? }`, or `{ error }` when it has no JSON Schema form.
47
+ * Cached by schema object.
48
+ */
49
+ export function toJsonSchema(input: any): Converted {
50
+ if (!input || (typeof input != 'object' && typeof input != 'function')) return {error: 'not a Standard Schema'}
51
+ let c = cache.get(input)
52
+ if (!c) cache.set(input, c = convert(input))
53
+ return c
54
+ }
55
+
56
+ function convert(input: any): Converted {
57
+ const st = input['~standard']
58
+ if (typeof st?.validate != 'function') return {error: input.type || input.properties || input.anyOf || input.enum ? 'a plain JSON Schema: wrap it with jsonSchema()' : 'not a Standard Schema'}
59
+ const conv = st.jsonSchema?.input, v = st.vendor
60
+ if (typeof conv != 'function') return {error: `the ${v} schema has no JSON Schema form` + (v == 'valibot' ? ': wrap it with toStandardJsonSchema() from @valibot/to-json-schema' : v == 'zod' ? ' (Zod Mini): use jsonSchema(z.toJSONSchema(s), { validate: s })' : '')}
61
+ let js: any, lossy: string | undefined
62
+ try { js = conv({target: 'draft-2020-12'}) } catch (e) {
63
+ try { if (!LOSSY[v]) throw 0; js = conv({target: 'draft-2020-12', libraryOptions: LOSSY[v]}) } catch (_) { return {error: 'no JSON Schema form: ' + msg(e)} }
64
+ lossy = msg(e)
65
+ }
66
+ return {...normalize(js), ...(lossy && {lossy})}
67
+ }
68
+
69
+ // G-629: every subschema, bottom-up (shared with the strict layer, ./strict.ts). The values of
70
+ // `properties` / `$defs` / ... maps are schemas, the maps themselves aren't (a property named
71
+ // `oneOf` or `items` is a name, not a keyword), and `const` / `enum` / `default` / `examples` are
72
+ // data (a default of `{ oneOf: 1 }` stays as it is)
73
+ const MAPS = /^(properties|\$defs|definitions|patternProperties|dependentSchemas)$/
74
+ const DATA = /^(const|enum|default|examples)$/
75
+ export const walk = (s: any, f: (o: any) => any): any => {
76
+ if (Array.isArray(s)) return s.map(x => walk(x, f))
77
+ if (!s || typeof s != 'object') return s
78
+ const o: any = {}
79
+ for (const k in s) {
80
+ const v = s[k]
81
+ o[k] = DATA.test(k) ? v : MAPS.test(k) && isObj(v) ? Object.fromEntries(Object.entries(v).map(([n, x]) => [n, walk(x, f)])) : walk(v, f)
82
+ }
83
+ return f(o)
84
+ }
85
+ const rewriteRefs = (s: any, to: string) => walk(s, o => {
86
+ const r = o.$ref
87
+ if (typeof r == 'string' && (r == '#' || r[1] == '/') && !/^#\/(\$defs|definitions)\//.test(r)) o.$ref = to + r.slice(1)
88
+ return o
89
+ })
90
+
91
+ /** the portable normalization (see the header): `{ schema, wrapped }` */
92
+ export function normalize(schema: any): {schema: any; wrapped: boolean} {
93
+ let {$schema, $id, ...s} = walk(schema, o => {
94
+ if ('oneOf' in o && !('anyOf' in o)) { o.anyOf = o.oneOf; delete o.oneOf }
95
+ if (o.type == 'integer' && o.minimum == -SAFE && o.maximum == SAFE) { delete o.minimum; delete o.maximum }
96
+ if (o.prefixItems && o.items === false) { delete o.items; o.maxItems ??= o.prefixItems.length }
97
+ return o
98
+ })
99
+ const m = typeof s.$ref == 'string' && /^#\/\$defs\/(.+)$/.exec(s.$ref)
100
+ if (m && s.$defs?.[m[1]]?.type == 'object' && Object.keys(s).length == 2) s = {...s.$defs[m[1]], $defs: s.$defs}
101
+ const {$defs, description, ...rest} = s
102
+ const defs: any = {...$defs}
103
+ const selfRef = /"\$ref":"#(?!\/\$defs\/|\/definitions\/)/.test(JSON.stringify(s))
104
+ if (selfRef) {
105
+ for (const k in defs) defs[k] = rewriteRefs(defs[k], '#/$defs/__root')
106
+ defs.__root = rewriteRefs(rest, '#/$defs/__root')
107
+ }
108
+ if (s.type == 'object') return {schema: selfRef ? {...defs.__root, ...(description && {description}), $defs: defs} : s, wrapped: false}
109
+ let value = selfRef ? {$ref: '#/$defs/__root'} : rest
110
+ if (description) value = {...value, description}
111
+ const out: any = {type: 'object', properties: {value}, required: ['value'], additionalProperties: false}
112
+ if (Object.keys(defs).length) out.$defs = defs
113
+ return {schema: out, wrapped: true}
114
+ }
115
+
116
+ export const resolve = (s: any, root: any) => {
117
+ const m = /^#\/\$defs\/(.+)$/.exec(s.$ref)
118
+ return m ? root.$defs?.[m[1]] : s.$ref == '#' ? root : s
119
+ }
120
+
121
+ /**
122
+ * D264: small local models send "3" for an integer and "true" for a boolean. Where the JSON
123
+ * Schema says number / integer / boolean (and not string), a string that parses as one becomes
124
+ * one. Nothing else changes; validation still decides.
125
+ */
126
+ export function repair(value: any, schema: any, root: any = schema): any {
127
+ if (schema?.$ref) schema = resolve(schema, root)
128
+ if (!schema) return value
129
+ const types = [].concat(schema.type ?? []) as string[]
130
+ if (typeof value == 'string' && !types.includes('string')) {
131
+ const t = value.trim()
132
+ if ((types.includes('integer') || types.includes('number')) && /^-?\d+(\.\d+)?$/.test(t)) {
133
+ const n = Number(t)
134
+ if (types.includes('number') || Number.isInteger(n)) return n
135
+ }
136
+ if (types.includes('boolean') && (t == 'true' || t == 'false')) return t == 'true'
137
+ }
138
+ if (Array.isArray(value)) return value.map((x, i) => repair(x, schema.prefixItems?.[i] ?? schema.items, root))
139
+ if (isObj(value)) {
140
+ const o = {...value}
141
+ for (const b of schema.anyOf || [schema]) {
142
+ const bb = b?.$ref ? resolve(b, root) : b
143
+ for (const k in bb?.properties) if (k in o) o[k] = repair(o[k], bb.properties[k], root)
144
+ }
145
+ return o
146
+ }
147
+ if (typeof value == 'string' && schema.anyOf) for (const b of schema.anyOf) { const r = repair(value, b, root); if (r !== value) return r }
148
+ return value
149
+ }
150
+
151
+ /** One validation failure: the schema's message and the path as keys */
152
+ export interface Issue {message: string; path?: PropertyKey[]}
153
+
154
+ /**
155
+ * A model's arguments for `schema` -> the validated value: the lenient `{ value }` unwrap,
156
+ * `repair` (unless `repair: false`), then the schema's own validation. Resolves with
157
+ * `{ value }` (the schema's output), `{ issues }`, or `{ error }` (no JSON Schema form)
158
+ */
159
+ export async function parseInput(schema: any, args: any, options: {repair?: boolean} = {}): Promise<{value?: any; issues?: Issue[]; error?: string}> {
160
+ const c = toJsonSchema(schema)
161
+ if (c.error) return {error: c.error}
162
+ let v = c.wrapped && isObj(args) && 'value' in args ? args.value : args
163
+ if (options.repair !== false) v = repair(v, c.wrapped ? c.schema.properties.value : c.schema, c.schema)
164
+ const r = await schema['~standard'].validate(v)
165
+ return r.issues ? {issues: r.issues.map((i: any) => ({message: i.message, ...(i.path && {path: i.path.map((p: any) => isObj(p) ? p.key : p)})}))} : {value: r.value}
166
+ }
@@ -0,0 +1,72 @@
1
+ /*
2
+ * PLAN-6 A-1 (D262, 0-S4 §4): plain JSON Schema as a tool `input` (or a chat `output`), only
3
+ * through `jsonSchema(json[, { validate }])`. It returns a Standard Schema that is also a
4
+ * Standard JSON Schema, so it takes the same path as Zod / Valibot / ArkType. Without
5
+ * `validate`, a subset validator checks the value: type (integer and type arrays too), enum,
6
+ * const, required, properties, additionalProperties (false or a schema), items, prefixItems,
7
+ * anyOf / oneOf, minLength / maxLength, pattern, minimum / maximum, minItems / maxItems and local
8
+ * `$ref`s (`#`, `#/$defs/…`, `#/definitions/…`). format, allOf, not, if/then/else, multipleOf,
9
+ * uniqueItems, exclusive* and the rest are accepted unchecked. `{ validate: s }` keeps a
10
+ * library's own validation (Zod Mini: `jsonSchema(z.toJSONSchema(s), { validate: s })`).
11
+ * A separate module: tree-shaken unless imported.
12
+ */
13
+ import type {Issue} from './index'
14
+
15
+ const T = (v: any) => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v
16
+ const is = (t: string, v: any) => t == 'integer' ? Number.isInteger(v) : t == 'number' ? typeof v == 'number' && isFinite(v) : T(v) == t
17
+ const S = JSON.stringify
18
+
19
+ /** the subset check: the issues of `v` against `s` (empty when valid) */
20
+ export function check(s: any, v: any, root: any = s, path: PropertyKey[] = [], out: Issue[] = []): Issue[] {
21
+ const bad = (message: string) => (out.push({message, path}), out)
22
+ if (s === false) return bad('no value allowed')
23
+ if (!s || s === true) return out
24
+ if (s.$ref) return check(s.$ref == '#' ? root : root.$defs?.[s.$ref.slice(8)] ?? root.definitions?.[s.$ref.slice(14)], v, root, path, out)
25
+ const types = [].concat(s.type ?? []) as string[]
26
+ if (types.length && !types.some(t => is(t, v))) return bad(`expected ${types.join(' or ')}, got ${T(v)}`)
27
+ if (s.enum && !s.enum.some((e: any) => S(e) == S(v))) return bad(`expected one of ${S(s.enum)}`)
28
+ if ('const' in s && S(s.const) != S(v)) return bad(`expected ${S(s.const)}`)
29
+ const any = s.anyOf ?? s.oneOf
30
+ if (any && !any.some((b: any) => !check(b, v, root, path).length)) return bad('matches no alternative')
31
+ if (typeof v == 'string') {
32
+ if (v.length < (s.minLength ?? 0)) bad(`at least ${s.minLength} characters`)
33
+ if (v.length > (s.maxLength ?? Infinity)) bad(`at most ${s.maxLength} characters`)
34
+ if (s.pattern && !new RegExp(s.pattern, 'u').test(v)) bad(`must match ${s.pattern}`)
35
+ }
36
+ if (typeof v == 'number') {
37
+ if (v < (s.minimum ?? -Infinity)) bad(`at least ${s.minimum}`)
38
+ if (v > (s.maximum ?? Infinity)) bad(`at most ${s.maximum}`)
39
+ }
40
+ if (Array.isArray(v)) {
41
+ if (v.length < (s.minItems ?? 0)) bad(`at least ${s.minItems} items`)
42
+ if (v.length > (s.maxItems ?? Infinity)) bad(`at most ${s.maxItems} items`)
43
+ const pre = s.prefixItems ?? []
44
+ v.forEach((x, i) => check(i < pre.length ? pre[i] : s.items, x, root, [...path, i], out))
45
+ } else if (T(v) == 'object') {
46
+ for (const k of s.required ?? []) if (!(k in v)) bad(`${k} is required`)
47
+ const p = s.properties ?? {}
48
+ for (const k in v) check(k in p ? p[k] : s.additionalProperties, v[k], root, [...path, k], out)
49
+ }
50
+ return out
51
+ }
52
+
53
+ /**
54
+ * A plain JSON Schema as a Standard Schema with Standard JSON Schema: the schema goes to the
55
+ * model as is (normalized), and the subset validator (or `options.validate`, a Standard Schema)
56
+ * checks the value
57
+ */
58
+ export function jsonSchema(schema: any, options: {validate?: any} = {}): any {
59
+ const own = options.validate?.['~standard']
60
+ const out = () => schema
61
+ return {
62
+ '~standard': {
63
+ version: 1,
64
+ vendor: 'sygnal',
65
+ validate: own ? (v: any) => own.validate(v) : (value: any) => {
66
+ const issues = check(schema, value)
67
+ return issues.length ? {issues} : {value}
68
+ },
69
+ jsonSchema: {input: out, output: out},
70
+ },
71
+ }
72
+ }
@@ -0,0 +1,152 @@
1
+ import {resolve, walk, isObj} from './index';
2
+
3
+ /*
4
+ * PLAN-6 L-2 (0-S4 layer 2, D266, D285): the opt-in strict schema layer of the OpenAI- and
5
+ * Anthropic-backed transports. Apps opt in with an import (`openResponses({ strict:
6
+ * strictSchemas })`), so the transports don't carry it otherwise (≈ 1.1 KB, D285); unstrict is
7
+ * also used by the chat driver's structured output (./chat/output.ts), where it is always safe.
8
+ *
9
+ * strictSchemas(schema, dialect) turns a portable tool / output schema (./index.ts normalize)
10
+ * into a provider's strict subset; both walk with A-1's map-aware `walk` (G-629):
11
+ * - 'openai' (developers.openai.com structured outputs, "Supported schemas", 2026-10):
12
+ * `additionalProperties: false` on every object, every property required, the optional ones
13
+ * made nullable (a type array + null in the enum, or `anyOf [..., { type: 'null' }]`);
14
+ * unsupported keywords and formats moved into the description as JSON (the model still reads
15
+ * them; validation still enforces them), a tuple's `prefixItems` turned into `items: { anyOf }`.
16
+ * - 'anthropic' (platform.claude.com structured outputs; the subset @anthropic-ai/sdk's
17
+ * transformJSONSchema keeps): `additionalProperties: false` on every object, `required` as is
18
+ * (optional keys stay optional), and only type, anyOf (oneOf), allOf, $ref/$defs, enum, const,
19
+ * description, title, properties, required, items, the supported formats and `minItems` 0/1
20
+ * kept; the rest (numeric and string bounds, pattern, other array constraints) moved into the
21
+ * description. A recursive schema or an untyped (any) value has no strict form.
22
+ * Both return `errors` when the schema has no strict form (a record: `additionalProperties` with a
23
+ * schema; a non-object root): the transport then sends that tool non-strict (SYG675, dev info),
24
+ * and `optional` / `unions` (the counts Anthropic limits per request, G-609).
25
+ *
26
+ * unstrict(value, schema) drops the nulls a strict schema forced the model to send for keys the
27
+ * original schema leaves optional and doesn't allow null for, so validation sees the original
28
+ * shape. The transports run it on every tool call they sent strict.
29
+ */
30
+
31
+ export type StrictDialect = 'openai' | 'anthropic';
32
+ export interface Strictified {schema: any; errors: string[]; optional: number; unions: number}
33
+ /** what a request has left of a provider's per-request strict limits (Anthropic, G-609) */
34
+ export interface StrictLimits {tools: number; optional: number; unions: number}
35
+
36
+ const DROP = ['allOf', 'not', 'if', 'then', 'else', 'dependentRequired', 'dependentSchemas', 'patternProperties',
37
+ 'unevaluatedProperties', 'propertyNames', 'minProperties', 'maxProperties', 'unevaluatedItems', 'contains',
38
+ 'minContains', 'maxContains', 'uniqueItems', 'default', 'examples', 'title'];
39
+ const FORMATS = ['date-time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uuid'];
40
+ // Anthropic's subset (the keywords @anthropic-ai/sdk's transformJSONSchema keeps)
41
+ const KEEP = /^(type|anyOf|allOf|\$ref|\$defs|enum|const|description|title|properties|required|additionalProperties|items)$/;
42
+ const ANT_FORMATS = /^(date-time|time|date|duration|email|hostname|uri|ipv4|ipv6|uuid)$/;
43
+
44
+ /** one schema object into Anthropic's subset, in place (oneOf -> anyOf): the keywords it moved out */
45
+ export function anthropicKeywords(o: any): any {
46
+ const moved: any = {};
47
+ if (o.oneOf && !o.anyOf) { o.anyOf = o.oneOf; delete o.oneOf; }
48
+ for (const k in o) if (k == 'format' ? !ANT_FORMATS.test(o[k]) : k == 'minItems' ? o[k] > 1 : !KEEP.test(k)) { moved[k] = o[k]; delete o[k]; }
49
+ return moved;
50
+ }
51
+
52
+ // the moved keywords as JSON at the end of the description: the model still reads them, validation still enforces them
53
+ const describe = (o: any, moved: any) => {
54
+ if (Object.keys(moved).length) {
55
+ const hint = JSON.stringify(moved);
56
+ o.description = o.description ? o.description + ' ' + hint : hint;
57
+ }
58
+ return o;
59
+ };
60
+
61
+ /**
62
+ * G-635: the light, always-on pass anthropicMessages() runs on a non-strict `output` schema
63
+ * (`output_config.format` is always constrained decoding, and a keyword outside Anthropic's subset
64
+ * is a 400): Anthropic's keywords only (the rest moved into the description), and
65
+ * `additionalProperties: false` on every object that isn't a record. No limits, no errors.
66
+ */
67
+ export const anthropicCompatible = (schema: any): any => walk(schema, o => {
68
+ const moved = anthropicKeywords(o);
69
+ if ((o.type == 'object' || o.properties) && !isObj(o.additionalProperties)) o.additionalProperties = false;
70
+ return describe(o, moved);
71
+ });
72
+
73
+ const nullable = (p: any): any => {
74
+ const t = p.type, withEnum = (o: any) => p.enum ? {...o, enum: [...p.enum, null]} : o;
75
+ if (Array.isArray(t)) return t.includes('null') ? p : withEnum({...p, type: [...t, 'null']});
76
+ if (typeof t == 'string' && !p.$ref) return withEnum({...p, type: [t, 'null']});
77
+ if (p.anyOf) return p.anyOf.some((b: any) => b.type == 'null') ? p : {...p, anyOf: [...p.anyOf, {type: 'null'}]};
78
+ const {description, ...rest} = p;
79
+ return {anyOf: [rest, {type: 'null'}], ...(description && {description})};
80
+ };
81
+
82
+ // a `$defs` entry that reaches itself through `$ref`s (or a `#` root reference)
83
+ const recursive = (s: any): boolean => {
84
+ const defs = s.$defs || {}, refs = (x: any): string[] => [...JSON.stringify(x ?? null).matchAll(/"\$ref":"#(?:\/\$defs\/([^"]*))?"/g)].map(m => m[1] ?? '#');
85
+ const to = (a: string, b: string, seen: Set<string>): boolean => !seen.has(a) && (seen.add(a), refs(defs[a]).some(n => n == b || (n in defs && to(n, b, seen))));
86
+ return refs(s).includes('#') || Object.keys(defs).some(k => to(k, k, new Set()));
87
+ };
88
+
89
+ /**
90
+ * `{ schema, errors, optional, unions }`: a provider's strict form of a portable schema, or why
91
+ * there is none. `left` (the transports'): what the request has left of the provider's
92
+ * per-request limits; a schema that fits is counted against it (`tool`: it is a tool), one that
93
+ * doesn't gets an error
94
+ */
95
+ export function strictSchemas(schema: any, dialect: StrictDialect = 'openai', left?: StrictLimits, tool?: boolean): Strictified {
96
+ const errors: string[] = [], ant = dialect == 'anthropic';
97
+ let optional = 0, unions = 0;
98
+ if (schema?.type != 'object') errors.push('the root is not an object');
99
+ if (ant && schema && recursive(schema)) errors.push('a recursive schema has no strict form');
100
+ const out = walk(schema, o => {
101
+ let moved: any = {};
102
+ if (ant) {
103
+ moved = anthropicKeywords(o);
104
+ if (!o.type && !o.anyOf && !o.allOf && !o.$ref && !o.enum && !('const' in o) && !o.properties) errors.push('a value of any type has no strict form');
105
+ } else {
106
+ for (const k of DROP) if (k in o) { moved[k] = o[k]; delete o[k]; }
107
+ if (o.format && !FORMATS.includes(o.format)) { moved.format = o.format; delete o.format; }
108
+ if (o.prefixItems) { moved.prefixItems = 'tuple'; o.items = {anyOf: o.prefixItems}; delete o.prefixItems; }
109
+ }
110
+ if (o.items === false) delete o.items;
111
+ if (o.type == 'object' || o.properties) {
112
+ if (o.additionalProperties && o.additionalProperties !== false) errors.push('a record (additionalProperties with a schema) has no strict form');
113
+ const props = o.properties ||= {}, req = new Set(o.required || []);
114
+ for (const k in props) {
115
+ const p = props[k];
116
+ if (p?.anyOf || Array.isArray(p?.type)) unions++;
117
+ if (!req.has(k)) { optional++; if (!ant) props[k] = nullable(p); }
118
+ }
119
+ if (!ant) o.required = Object.keys(props);
120
+ o.additionalProperties = false;
121
+ }
122
+ return describe(o, moved);
123
+ });
124
+ if (left && !errors.length) {
125
+ if ((tool && left.tools < 1) || left.optional < optional || left.unions < unions) errors.push('over the per-request strict limits (20 tools, 24 optional and 16 union-typed parameters)');
126
+ else { if (tool) left.tools--; left.optional -= optional; left.unions -= unions; }
127
+ }
128
+ return {schema: out, errors, optional, unions};
129
+ }
130
+
131
+ const acceptsNull = (p: any, root: any): boolean => {
132
+ p = p?.$ref ? resolve(p, root) : p;
133
+ return !!p && (p.type == 'null' || (Array.isArray(p.type) && p.type.includes('null')) || (p.enum || []).includes(null) ||
134
+ p.const === null || (p.anyOf || p.oneOf || []).some((b: any) => acceptsNull(b, root)));
135
+ };
136
+
137
+ /** strict-mode JSON -> what the original schema accepts: nulls for optional, non-nullable keys dropped */
138
+ export function unstrict(value: any, schema: any, root: any = schema): any {
139
+ if (schema?.$ref) schema = resolve(schema, root);
140
+ if (!schema || value == null || typeof value != 'object') return value;
141
+ if (Array.isArray(value)) return schema.items ? value.map(x => unstrict(x, schema.items, root)) : value;
142
+ const out = {...value};
143
+ for (const b0 of schema.anyOf || schema.oneOf || [schema]) {
144
+ const b = b0?.$ref ? resolve(b0, root) : b0, req = new Set(b?.required || []);
145
+ for (const k in b?.properties) {
146
+ if (!(k in out)) continue;
147
+ if (out[k] === null && !req.has(k) && !acceptsNull(b.properties[k], root)) delete out[k];
148
+ else out[k] = unstrict(out[k], b.properties[k], root);
149
+ }
150
+ }
151
+ return out;
152
+ }
@@ -0,0 +1,177 @@
1
+ import {post, sse, json, partsOf, toolName, toolOutput} from './shared';
2
+ import type {HttpOptions} from './shared';
3
+ import {outputJsonSchema} from '../chat/output';
4
+
5
+ /*
6
+ * PLAN-6 L-2 (wave 2): agui(url, { threadId?, state?, context?, headers?, fetch?, body?,
7
+ * dangerouslyAllowBrowser? }), a chat transport for an AG-UI agent endpoint (the Agent-User
8
+ * Interaction protocol: TanStack AI, CopilotKit runtimes, LangGraph / Mastra / Pydantic AI
9
+ * servers), checked against @ag-ui/core 1.0.2's types. POST `url` with a RunAgentInput, SSE back
10
+ * (`data: <event JSON>`).
11
+ *
12
+ * Request (RunAgentInput): `threadId` (the request's `chatId`, the option, or one per transport),
13
+ * a new `runId`, `state` (the request's `state`, else the latest state this transport saw, else
14
+ * the option, else {}), `messages` (the instructions as a system message; user / assistant / tool
15
+ * messages: an assistant message per step with its `toolCalls`, a `tool` message per result),
16
+ * `tools` (`{ name, description, parameters }`: client tools the agent may call), `context` (the
17
+ * option), `forwardedProps` (`{ model?, output? }`: the requested model and the `output` JSON
18
+ * Schema, for servers that use them); `body` merged in.
19
+ *
20
+ * Events -> ChatEvents:
21
+ * - TEXT_MESSAGE_START / CONTENT / CHUNK -> start (the first message id) and text (messages with a
22
+ * role other than assistant are skipped); REASONING_MESSAGE_CONTENT / CHUNK -> reasoning;
23
+ * - TOOL_CALL_START / ARGS / END (or CHUNK) -> a tool call, held until the step ends: a call the
24
+ * agent answers (TOOL_CALL_RESULT) is yielded `executed` + its result (the app doesn't run it), a
25
+ * call still open at STEP_FINISHED / RUN_FINISHED / the end of the stream (or when a new message
26
+ * starts) is a client tool call (the `tool` reply);
27
+ * - STATE_SNAPSHOT / STATE_DELTA (RFC 6902, applied here) -> a `data-agui-state` part (id
28
+ * 'state', so it replaces the previous one) holding the agent's whole current state;
29
+ * MESSAGES_SNAPSHOT -> a `data-agui-messages` part (id 'messages') with the agent's messages as
30
+ * sent (the app decides whether to adopt them); ACTIVITY_SNAPSHOT -> a `data-agui-activity` part
31
+ * (id: its message id);
32
+ * - RUN_FINISHED -> finish ('tool-calls' after client calls, 'other' for an interrupt outcome,
33
+ * else 'stop'; usage summed); RUN_ERROR -> a thrown Error. Everything else is ignored.
34
+ */
35
+
36
+ export interface AguiOptions extends HttpOptions {
37
+ threadId?: string;
38
+ state?: unknown;
39
+ context?: Array<{description: string; value: string}>;
40
+ }
41
+
42
+ let n = 0;
43
+ const uid = (p: string) => `${p}_${Date.now().toString(36)}_${++n}`;
44
+ const clone = (v: any) => v === undefined ? v : JSON.parse(JSON.stringify(v));
45
+
46
+ /** RFC 6902 JSON Patch (add, remove, replace, move, copy; test ignored) on a copy of `doc` */
47
+ export function applyPatch(doc: any, ops: any[]): any {
48
+ let root = clone(doc ?? {});
49
+ const keys = (p: string) => p.split('/').slice(1).map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
50
+ const get = (k: string[]) => k.reduce((o, x) => o?.[x], root);
51
+ const set = (k: string[], v: any, insert: boolean) => {
52
+ if (!k.length) { root = v; return; }
53
+ const parent = get(k.slice(0, -1)), last = k[k.length - 1];
54
+ if (Array.isArray(parent)) last == '-' ? parent.push(v) : insert ? parent.splice(+last, 0, v) : (parent[+last] = v);
55
+ else if (parent && typeof parent == 'object') parent[last] = v;
56
+ };
57
+ const del = (k: string[]) => {
58
+ const parent = get(k.slice(0, -1)), last = k[k.length - 1], v = parent?.[last];
59
+ if (Array.isArray(parent)) parent.splice(+last, 1); else if (parent) delete parent[last];
60
+ return v;
61
+ };
62
+ for (const op of ops || []) {
63
+ const to = keys(String(op.path ?? ''));
64
+ if (op.op == 'add') set(to, clone(op.value), true);
65
+ else if (op.op == 'replace') set(to, clone(op.value), false);
66
+ else if (op.op == 'remove') del(to);
67
+ else if (op.op == 'move') set(to, del(keys(op.from)), true);
68
+ else if (op.op == 'copy') set(to, clone(get(keys(op.from))), true);
69
+ }
70
+ return root;
71
+ }
72
+
73
+ const textOf = (m: any) => partsOf(m).filter(p => p.type == 'text').map(p => p.text).join('');
74
+
75
+ /** a message as AG-UI messages (an assistant message per step with its toolCalls, then a tool message per result) */
76
+ export function toAgui(m: any, i: number): any[] {
77
+ const id = m.id ?? `m${i}`;
78
+ if (m.role != 'assistant') {
79
+ const files = partsOf(m).filter(p => p.type == 'file');
80
+ const text = textOf(m);
81
+ return [{id, role: m.role, content: files.length && m.role == 'user'
82
+ ? [...(text ? [{type: 'text', text}] : []), ...files.map(p => ({type: p.mediaType?.startsWith('image/') ? 'image' : 'document', source: {type: 'url', value: p.url, mimeType: p.mediaType}}))]
83
+ : text}];
84
+ }
85
+ const out: any[] = [];
86
+ let text = '', calls: any[] = [], results: any[] = [], step = 0;
87
+ const flush = () => {
88
+ if (text || calls.length) out.push({id: step ? `${id}_${step}` : id, role: 'assistant', ...(text && {content: text}), ...(calls.length && {toolCalls: calls})});
89
+ out.push(...results);
90
+ if (text || calls.length) step++;
91
+ text = ''; calls = []; results = [];
92
+ };
93
+ for (const p of partsOf(m)) {
94
+ const name = toolName(p);
95
+ if (p.type == 'text') { if (calls.length) flush(); text += p.text; }
96
+ else if (name && p.input !== undefined) {
97
+ calls.push({id: p.toolCallId, type: 'function', function: {name, arguments: JSON.stringify(p.input ?? {})}});
98
+ const o = toolOutput(p);
99
+ if (o !== undefined) results.push({id: `${p.toolCallId}_result`, role: 'tool', toolCallId: p.toolCallId, content: o, ...(p.state == 'output-error' && {error: p.errorText ?? 'error'})});
100
+ }
101
+ }
102
+ flush();
103
+ return out;
104
+ }
105
+
106
+ export function agui(url: string, options: AguiOptions = {}) {
107
+ const thread = options.threadId ?? uid('thread');
108
+ let state: any = options.state;
109
+ const headers = async (req: any) => ({accept: 'text/event-stream', ...(typeof options.headers == 'function' ? await options.headers(req) : options.headers)});
110
+ return {
111
+ async *stream(req: any, signal: AbortSignal): AsyncGenerator<any, void, any> {
112
+ const o = req.output !== undefined ? outputJsonSchema(req.output) : undefined;
113
+ const res = await post(url, {
114
+ threadId: req.chatId ?? thread,
115
+ runId: uid('run'),
116
+ state: req.state ?? state ?? {},
117
+ messages: [...(req.instructions ? [{id: 'instructions', role: 'system', content: req.instructions}] : []), ...req.messages.flatMap(toAgui)],
118
+ tools: Object.entries(req.tools || {}).map(([name, t]: [string, any]) => ({name, description: t?.description ?? '', parameters: t?.inputSchema ?? {type: 'object', properties: {}}})),
119
+ context: options.context ?? [],
120
+ forwardedProps: {...(req.model && {model: req.model}), ...(o && {output: o})},
121
+ }, req, signal, {...options, headers}, 'agui');
122
+ // tool calls by id as they stream; `open`: ended calls not yet answered, in order
123
+ const calls: Record<string, any> = {}, roles: Record<string, string> = {};
124
+ let open: any[] = [], last: any, started = false, called = 0;
125
+ const start = function* (id: any) { if (!started && id) { started = true; yield {type: 'start', id}; } };
126
+ const release = function* () { for (const c of open) { called++; yield c; } open = []; };
127
+ const take = (id: string) => {
128
+ const i = open.findIndex(c => c.id === id);
129
+ return i < 0 ? [] : [{...open.splice(i, 1)[0], executed: true}];
130
+ };
131
+ const end = (id: string) => {
132
+ const c = calls[id];
133
+ if (!c || c.done) return;
134
+ c.done = 1;
135
+ open.push({type: 'tool-call', id, name: c.name, input: json(c.args || '{}')});
136
+ };
137
+ for await (const {data} of sse(res)) {
138
+ const e = json(data), t = e?.type;
139
+ if (typeof t != 'string') continue;
140
+ if (t == 'TEXT_MESSAGE_START') { roles[e.messageId] = e.role ?? 'assistant'; yield* release(); yield* start(e.messageId); }
141
+ else if (t == 'TEXT_MESSAGE_CONTENT' || t == 'TEXT_MESSAGE_CHUNK') {
142
+ if (t == 'TEXT_MESSAGE_CHUNK' && e.role) roles[e.messageId] = e.role;
143
+ if ((roles[e.messageId] ?? 'assistant') == 'assistant' && e.delta) { yield* release(); yield* start(e.messageId); yield {type: 'text', delta: e.delta}; }
144
+ } else if (t == 'REASONING_MESSAGE_CONTENT' || t == 'REASONING_MESSAGE_CHUNK') { if (e.delta) yield {type: 'reasoning', delta: e.delta}; }
145
+ else if (t == 'TOOL_CALL_START') { yield* start(e.parentMessageId); calls[e.toolCallId] = {name: e.toolCallName, args: ''}; last = e.toolCallId; }
146
+ else if (t == 'TOOL_CALL_ARGS') { if (calls[e.toolCallId]) calls[e.toolCallId].args += e.delta ?? ''; }
147
+ else if (t == 'TOOL_CALL_CHUNK') {
148
+ const id = e.toolCallId ?? last;
149
+ if (e.toolCallId && e.toolCallId != last) { if (last) end(last); yield* start(e.parentMessageId); }
150
+ const c = calls[id] ||= {name: e.toolCallName, args: ''};
151
+ if (e.toolCallName) c.name = e.toolCallName;
152
+ c.args += e.delta ?? '';
153
+ last = id;
154
+ } else if (t == 'TOOL_CALL_END') end(e.toolCallId);
155
+ else if (t == 'TOOL_CALL_RESULT') {
156
+ end(e.toolCallId);
157
+ yield* take(e.toolCallId);
158
+ yield {type: 'tool-result', id: e.toolCallId, output: typeof e.content == 'string' ? json(e.content) : e.content};
159
+ } else if (t == 'STATE_SNAPSHOT' || t == 'STATE_DELTA') {
160
+ state = t == 'STATE_SNAPSHOT' ? e.snapshot : applyPatch(state, e.delta);
161
+ yield {type: 'data-agui-state', id: 'state', data: state};
162
+ } else if (t == 'MESSAGES_SNAPSHOT') yield {type: 'data-agui-messages', id: 'messages', data: e.messages};
163
+ else if (t == 'ACTIVITY_SNAPSHOT') yield {type: 'data-agui-activity', id: e.messageId, data: {activityType: e.activityType, content: e.content}};
164
+ else if (t == 'STEP_FINISHED') { if (last) end(last); yield* release(); }
165
+ else if (t == 'RUN_FINISHED') {
166
+ for (const id in calls) end(id);
167
+ yield* release();
168
+ const u = (e.usage || []).reduce((a: any, x: any) => ({inputTokens: a.inputTokens + (x.inputTokens ?? 0), outputTokens: a.outputTokens + (x.outputTokens ?? 0), totalTokens: a.totalTokens + (x.totalTokens ?? (x.inputTokens ?? 0) + (x.outputTokens ?? 0))}), {inputTokens: 0, outputTokens: 0, totalTokens: 0});
169
+ yield {type: 'finish', reason: e.outcome?.type == 'interrupt' ? 'other' : called ? 'tool-calls' : 'stop', ...(e.usage?.length && {usage: u})};
170
+ return;
171
+ } else if (t == 'RUN_ERROR') throw Object.assign(new Error('agui: ' + (e.message ?? 'the run failed')), {code: e.code});
172
+ }
173
+ for (const id in calls) end(id);
174
+ yield* release();
175
+ },
176
+ };
177
+ }