sygnal 6.1.1 → 6.3.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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +3 -1
  3. package/dist/ai.cjs.js +36 -0
  4. package/dist/ai.cjs.js.map +1 -1
  5. package/dist/ai.esm.js +1 -1
  6. package/dist/astro/server.cjs.js +3 -3
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -3
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/diagnostics.cjs.js +174 -6
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +174 -6
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/adapters.md +2 -1
  15. package/dist/guide/agent.md +119 -7
  16. package/dist/guide/ai-cards.md +319 -0
  17. package/dist/guide/ai-chat.md +64 -16
  18. package/dist/guide/ai-compose.md +881 -0
  19. package/dist/guide/ai-decisions.md +17 -1
  20. package/dist/guide/behaviors.md +1 -1
  21. package/dist/guide/error-boundaries.md +2 -1
  22. package/dist/guide/forms.md +1 -1
  23. package/dist/guide/http.md +5 -4
  24. package/dist/guide/mcp-apps.md +4 -2
  25. package/dist/guide/persistence.md +1 -1
  26. package/dist/guide/recipes/ai-form-fill.md +1 -1
  27. package/dist/guide/recipes/ai-summarize.md +1 -1
  28. package/dist/guide/recipes/ai-support-inbox.md +1 -1
  29. package/dist/guide/resources.md +11 -10
  30. package/dist/guide/ui/combobox.md +1 -1
  31. package/dist/guide/undo.md +1 -1
  32. package/dist/guide/web-components.md +1 -0
  33. package/dist/guide/webmcp.md +1 -2
  34. package/dist/index.cjs.js +5842 -370
  35. package/dist/index.cjs.js.map +1 -1
  36. package/dist/index.d.ts +679 -13
  37. package/dist/index.esm.js +5832 -370
  38. package/dist/index.esm.js.map +1 -1
  39. package/dist/sygnal.min.js +1 -1
  40. package/dist/sygnal.min.js.map +1 -1
  41. package/dist/vike/config/package.json +1 -1
  42. package/dist/vike/onRenderClient.cjs.js +3 -2
  43. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  44. package/dist/vike/onRenderClient.mjs +3 -2
  45. package/dist/vike/onRenderClient.mjs.map +1 -1
  46. package/llms.txt +8 -6
  47. package/package.json +2 -1
  48. package/src/ai.d.ts +683 -12
  49. package/src/ai.ts +6 -0
  50. package/src/collection.ts +2 -1
  51. package/src/core/instance.ts +6 -3
  52. package/src/core/markers/lazy.ts +7 -3
  53. package/src/core/markers/portal.ts +5 -2
  54. package/src/core/markers/suspense.ts +3 -2
  55. package/src/core/markers/transition.ts +6 -3
  56. package/src/core/registry.ts +9 -5
  57. package/src/cycle/dom/IsolateModule.ts +25 -22
  58. package/src/extra/ai/agent/index.ts +111 -26
  59. package/src/extra/ai/chat/behavior.ts +368 -87
  60. package/src/extra/ai/chat/driver.ts +7 -4
  61. package/src/extra/ai/compose/agent.ts +55 -0
  62. package/src/extra/ai/compose/ask.ts +147 -0
  63. package/src/extra/ai/compose/behavior.ts +183 -0
  64. package/src/extra/ai/compose/catalog.ts +211 -0
  65. package/src/extra/ai/compose/composed.ts +61 -0
  66. package/src/extra/ai/compose/decisions.ts +1649 -0
  67. package/src/extra/ai/compose/diag.ts +33 -0
  68. package/src/extra/ai/compose/index.ts +23 -0
  69. package/src/extra/ai/compose/layout.ts +441 -0
  70. package/src/extra/ai/compose/llm.ts +1120 -0
  71. package/src/extra/ai/compose/render.ts +159 -0
  72. package/src/extra/ai/compose/testing.ts +71 -0
  73. package/src/extra/ai/compose/types.ts +159 -0
  74. package/src/extra/ai/decide.ts +97 -1
  75. package/src/extra/ai/index.ts +6 -1
  76. package/src/extra/ai/webmcp.ts +5 -3
  77. package/src/extra/diagnostics/checks/next.ts +86 -2
  78. package/src/extra/diagnostics/codes.ts +64 -3
  79. package/src/extra/fetchDriver.ts +58 -27
  80. package/src/extra/lens.ts +15 -0
  81. package/src/extra/ssr.ts +3 -3
  82. package/src/extra/testing.ts +2 -0
  83. package/src/extra/widget.ts +3 -3
  84. package/src/index.d.ts +42 -1
  85. package/src/index.ts +8 -0
  86. package/src/lazy.ts +3 -2
  87. package/src/portal.ts +5 -2
  88. package/src/pragma/index.ts +2 -1
  89. package/src/suspense.ts +5 -2
  90. package/src/switchable.ts +2 -1
  91. package/src/transition.ts +5 -2
  92. package/src/vike/onRenderClient.ts +3 -2
package/src/ai.ts CHANGED
@@ -21,3 +21,9 @@ export {anthropicMessages, agui, fromAISDK, strictSchemas} from './index'
21
21
  export {makeMcpAppDriver} from './index'
22
22
  // A-3 (3-A3, experimental, D291)
23
23
  export {formTool} from './index'
24
+ // PLAN-9 (A)
25
+ export {defineCatalog, compose, composeWith, fromTree, toTree, removeNode} from './index'
26
+ // PLAN-9 H: chat client tools and cards
27
+ export {openToolCalls, askCard} from './index'
28
+ // PLAN-9 M-1
29
+ export {Composed} from './index'
package/src/collection.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {h} from './cycle/dom/snabbdom';
2
- // the core's handler for this marker, registered on import (D157)
2
+ // the core's host for this marker, registered on import (D157): unlike Portal, Transition and
3
+ // Suspense (G-751), not on first use, because the lowercase tag works without an import (docs)
3
4
  import './core/hosts/collection';
4
5
 
5
6
  /** `<Collection of={Item} from="items" />`: a marker vnode the core's Collection host renders */
@@ -52,12 +52,15 @@ function extractSlots(children: any[]): [any[], Record<string, any[]>] {
52
52
  return [slots.default || [], slots]
53
53
  }
54
54
 
55
- /** an instance's id among its parent's children: its `id` prop, else its path in the view */
55
+ /**
56
+ * an instance's id among its parent's children (D456): its vnode `key`, else its `id` prop, else
57
+ * its path in the view
58
+ */
56
59
  const idOf = (n: any, path: string) => {
57
- const id = n.data?.props?.id
60
+ const id = n.key ?? n.data?.props?.id
58
61
  let s: any
59
62
  // (G-298: an id JSON can't encode, a BigInt, is used as its string)
60
- if (id) try { s = JSON.stringify(id) } catch (_) { s = String(id) }
63
+ if (id != null) try { s = JSON.stringify(id) } catch (_) { s = String(id) }
61
64
  return `${n.sel}::${(s && s.replace(/"/g, '')) || path}`
62
65
  }
63
66
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * PLAN-4.6 core: lazy() components (R2), registered on import by the public `lazy` module.
2
+ * PLAN-4.6 core: lazy() components (R2), registered by the first `lazy()` call (G-751).
3
3
  * A view resolver (registry `resolvers`): until the import resolves, the LazyWrapper itself is
4
4
  * the component (it renders the data-sygnal-lazy="loading" placeholder Suspense waits for, or
5
5
  * the error placeholder); once it has, the loaded component replaces it under the same id (a
@@ -15,7 +15,7 @@ import {resolvers} from '../registry'
15
15
  /** wrapper -> the owners waiting for it (`done`: settled, nobody waits any more) */
16
16
  export const waiting = new WeakMap<any, Set<any> & {done?: boolean}>()
17
17
 
18
- resolvers.push((view, owner) => {
18
+ const resolve = (view: any, owner: any) => {
19
19
  if (!view.__sygnalLazy) return
20
20
  if (view.__sygnalLazyLoaded()) return view.__sygnalLazyLoadedComponent || undefined
21
21
  let w = waiting.get(view)
@@ -37,4 +37,8 @@ resolvers.push((view, owner) => {
37
37
  set.add(owner)
38
38
  owner.dispose$?.().addListener({next: () => set.delete(owner)})
39
39
  }
40
- })
40
+ }
41
+
42
+ let on: any
43
+ /** G-751: registers the resolver once; `lazy()` calls it, not the module's import */
44
+ export const enableLazy = () => { on ||= resolvers.push(resolve) }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * PLAN-4.6 core: the Portal marker (R2), registered on import by the public `Portal` module.
2
+ * PLAN-4.6 core: the Portal marker (R2), registered by the public `Portal` when used (G-751).
3
3
  * A template rewrite (registry `pres`): the `portal` marker becomes a hidden placeholder whose
4
4
  * hooks patch its children into the target with a separate snabbdom patch function. As today
5
5
  * (the 5.x core's createPortalPlaceholder):
@@ -93,4 +93,7 @@ export function portalPlaceholder(target: string, children: any[]): any {
93
93
  }
94
94
  }
95
95
 
96
- pres.portal = (n) => portalPlaceholder(n.data?.props?.target, n.children || [])
96
+ // (widgets among its children: `pres.widget.$k` rewrites them, extra/widget.ts; the core doesn't walk them)
97
+ const pre = (n: any, o: any, x: string) => portalPlaceholder(n.data?.props?.target, (pres.widget as any)?.$k(n.children || [], o, x) || n.children || [])
98
+ /** G-751: registers the rewrite; the public `Portal` calls it when used (its `$reg`), not on import */
99
+ export const enablePortal = () => { pres.portal = pre }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * PLAN-4.6 core: Suspense (R2), registered on import by the public `Suspense` module. A
2
+ * PLAN-4.6 core: Suspense (R2), registered by the public `Suspense` when used (G-751). A
3
3
  * post-processor (registry `posts`) of the injected vnode of an instance whose template has a
4
4
  * `suspense` marker (the 5.x core's processSuspensePost):
5
5
  * - the core injects a child whose READY is false with data-sygnal-ready="false", and a lazy
@@ -38,4 +38,5 @@ export function suspensePost(v: any): any {
38
38
  return out ? {...v, children: out} : v
39
39
  }
40
40
 
41
- posts.suspense = suspensePost
41
+ /** G-751: registers the post-processor; the public `Suspense` calls it when used (its `$reg`) */
42
+ export const enableSuspense = () => { posts.suspense = suspensePost }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * PLAN-4.6 core: the Transition marker (R2), registered on import by the public `Transition`
3
- * module. A template rewrite (registry `pres`): the `transition` marker is replaced by its first
2
+ * PLAN-4.6 core: the Transition marker (R2), registered by the public `Transition`
3
+ * when used (G-751). A template rewrite (registry `pres`): the `transition` marker is replaced by its first
4
4
  * child (walked on, at the marker's path), with snabbdom hooks that add the classes as today
5
5
  * (the 5.x core's applyTransitionHooks):
6
6
  * `${name}-enter|leave-from` + `-active`, two frames later `-from` becomes `-to`, and at the end
@@ -57,7 +57,7 @@ export function transitionHooks(vnode: any, p: {name?: string, duration?: number
57
57
  // (no global symbol registry entry per name)
58
58
  const T = Symbol()
59
59
 
60
- pres.transition = (n) => {
60
+ const pre = (n: any) => {
61
61
  const child = n.children?.[0], p = n.data?.props || {}, k = p.name + ' ' + p.duration
62
62
  // no element child: the text child (or the marker itself, left as it is: today's behaviour)
63
63
  if (!child?.sel) return child || n
@@ -68,3 +68,6 @@ pres.transition = (n) => {
68
68
  // fragment or text has no element to animate: nothing happens
69
69
  return child.sel == 'collection' ? {...child, data: {...child.data, tr: (v: any) => (v[T] ||= {})[k] ||= transitionHooks(v, p)}} : transitionHooks(child, p)
70
70
  }
71
+
72
+ /** G-751: registers the rewrite; the public `Transition` calls it when used (its `$reg`) */
73
+ export const enableTransition = () => { pres.transition = pre }
@@ -1,9 +1,13 @@
1
1
  /**
2
- * PLAN-4.6 core: the module registries (04-hooks-contract §2.1). A feature module fills them
3
- * when it is imported; the core only looks them up, so an app that never imports the module pays
4
- * nothing (D157). R2 fills hosts (core/hosts: Collection, Switchable) and the markers
5
- * (core/markers: Portal, Transition, ClientOnly, Suspense, lazy), each imported by its public
6
- * module; R3 adds the definition hooks (behaviors, undo, selection, pager, persist, resources).
2
+ * PLAN-4.6 core: the module registries (04-hooks-contract §2.1). A feature fills them when it is
3
+ * used; the core only looks them up, so an app that never uses the feature pays nothing (D157).
4
+ * R2 fills hosts (core/hosts: Collection, Switchable) and the markers (core/markers: Portal,
5
+ * Transition, ClientOnly, Suspense, lazy). G-751: Portal, Transition and Suspense register their
6
+ * handler when a tag uses them (the pragma calls the marker's `$reg`) and `lazy()` its resolver,
7
+ * not on import: a module-level registration is kept by every bundle of the single-file build.
8
+ * Collection and Switchable register on import (the lowercase `<collection>` / `<switchable>`
9
+ * work without one); ClientOnly's is in runtime.ts (sygnal/vike can't reach the registry). R3
10
+ * adds the definition hooks (behaviors, undo, selection, pager, persist, resources).
7
11
  *
8
12
  * The entries are core modules: they get the owner's raw instance (hooks get InstanceViews).
9
13
  */
@@ -81,25 +81,25 @@ export class IsolateModule {
81
81
 
82
82
  public createModule() {
83
83
  const self = this;
84
- // G-564: a vnode can be in the old tree and the new one (a component's cached vnode, a
85
- // Collection item's copy, a hoisted vnode). When an ancestor is recreated (`alt ? <ol>{c}</ol>
86
- // : <ul>{c}</ul>`, a new key), snabbdom creates it again and gives it the new element before it
87
- // destroys the old subtree, so destroy hooks saw the new element (a widget inside wasn't
88
- // unmounted, its scope lost the new element). A created element notes its children's elements
89
- // (M: a child with one is mounted, or was), before they are created; a destroyed vnode gives
90
- // its noted children their old elements back for their destroy hooks (and theirs, down the
91
- // subtree), and they get the new ones again when the removal starts (S), before the insert
92
- // hooks. (A vnode that moves to another parent still gives its own destroy hook the new one)
93
- const M = new Map<any, any>(), S: any[] = [];
94
- // G-573: a vnode in the new tree twice: its first note (its element before the patch) wins.
95
- // A destroy hook that throws ends the patch before remove / post: the next patch first gives
96
- // the vnodes their new elements back and forgets the notes (pre)
97
- const back = (c: any) => M.has(c) && (S.push(c, c.elm), c.elm = M.get(c));
98
- const ret = () => { for (let e; S.length; ) e = S.pop(), S.pop().elm = e; };
84
+ // G-564, G-750, G-753: a vnode can be in the old tree and the new one (a component's cached
85
+ // vnode, a Collection item's copy, a hoisted vnode). Under another parent element (a recreated
86
+ // ancestor: `alt ? <ol>{c}</ol> : <ul>{c}</ul>`; a keyed instance moved to another column, or
87
+ // into a new row that takes its old place among its siblings) snabbdom created it again and
88
+ // gave it the new element while the old tree still had it: its destroy hooks saw the new
89
+ // element (a widget inside wasn't unmounted, its scope lost the new element), its old parent
90
+ // removed the new element and left the old one (G-750), or inserted a new sibling before it,
91
+ // inside that sibling (NotFoundError, the patch stopped: G-753). Now an element that is
92
+ // created or patched takes a copy of each child that has an element and isn't one of its old
93
+ // children (only a mounted vnode has one), before it is created: the old tree keeps the vnode
94
+ // and its element. The copy's children array is its own, so its children are copied when it
95
+ // is created. After the patch (post) each vnode takes its copy's fields (its element) and its
96
+ // place again, so the next patch sees the same vnode (snabbdom's shortcut). A patch that
97
+ // throws keeps the copies (pre forgets them): they still work, as copies
98
+ const R: any[] = [];
99
+ const copy = (a: any[], i: number) => { R.push(a, i, a[i]); a[i] = {...a[i], children: a[i].children?.slice()} };
99
100
  return {
100
101
  pre() {
101
- ret();
102
- M.clear();
102
+ R.length = 0;
103
103
  },
104
104
 
105
105
  create(emptyVNode: VNode, vNode: VNode) {
@@ -109,7 +109,7 @@ export class IsolateModule {
109
109
  if (Array.isArray(namespace)) {
110
110
  self.insertElement(namespace, elm as Element);
111
111
  }
112
- children?.forEach((c: any) => c?.elm && M.set(c, M.get(c) || c.elm));
112
+ children?.forEach((c: any, i: number) => c?.elm && copy(children, i));
113
113
  },
114
114
 
115
115
  update(oldVNode: VNode, vNode: VNode) {
@@ -126,21 +126,25 @@ export class IsolateModule {
126
126
  if (Array.isArray(namespace)) {
127
127
  self.insertElement(namespace, elm as Element);
128
128
  }
129
+ // (oldVNode !== vNode here: snabbdom returns early for the same vnode; the Set only when a
130
+ // child isn't at its old index)
131
+ const oc = oldVNode.children, nc: any = vNode.children
132
+ let o: any
133
+ nc?.forEach((c: any, i: number) => c?.elm && c !== oc?.[i] && !(o ||= new Set(oc)).has(c) && copy(nc, i));
129
134
  },
130
135
 
131
- // (the elements as they are now: post runs after the vnodes got their new ones back)
132
136
  destroy(vNode: VNode) {
133
- vNode.children?.forEach(back);
134
137
  self.vnodesBeingRemoved.push(vNode.elm as Element);
135
138
  },
136
139
 
137
140
  remove(vNode: VNode, cb: Function) {
138
141
  self.vnodesBeingRemoved.push(vNode.elm as Element);
139
- ret();
140
142
  cb();
141
143
  },
142
144
 
143
145
  post() {
146
+ for (let i = 0; i < R.length; i += 3) R[i][R[i + 1]] = Object.assign(R[i + 2], R[i][R[i + 1]]);
147
+ R.length = 0;
144
148
  // G-144: a removed root element is no longer one of its scope's roots (an element no
145
149
  // scope has as a root isn't in any set: removing it does nothing)
146
150
  for (const elm of self.vnodesBeingRemoved) {
@@ -148,7 +152,6 @@ export class IsolateModule {
148
152
  (self.eventDelegator as EventDelegator).removeElement(elm);
149
153
  }
150
154
  self.vnodesBeingRemoved = [];
151
- M.clear();
152
155
  },
153
156
  };
154
157
  }
@@ -6,7 +6,8 @@
6
6
  * `t.tools()` / `t.callTool()` / `t.agentContext()` (A-4) all use it.
7
7
  *
8
8
  * Comp.agent = { name, description?, read?, label?, untrusted?,
9
- * actions: { ACTION: { description, input?, consequential?, when?, idempotent? } } }
9
+ * actions: { ACTION: { description, input?, consequential?, when?, idempotent?, until?, timeout? } },
10
+ * queries: { find: { description, input?, when?, run: (state, input) => result } } }
10
11
  *
11
12
  * It reaches the app only through the runtime API (`root`, `get`, `dispatch`, `flushed`,
12
13
  * `addHooks`) and one hook layer, so the core pays 0 bytes:
@@ -25,12 +26,24 @@
25
26
  * outcome measured by `wrapHandler` on that action only: success when a STATE handler changed
26
27
  * the state structurally, a sink got a value or an EFFECT ran; `abort(reason)` refuses with the
27
28
  * reason; otherwise "changed nothing" (`idempotent: true`: `{ ok: true, unchanged: true }`, D256);
29
+ * - `until`: an action whose result arrives later (a `resources` entry, a driver reply) answers
30
+ * once it is in state: `until: 'search'` (the resource is neither loading nor refreshing) or
31
+ * `until: (state) => boolean`, checked after the action's flush and after every flush that
32
+ * changed something; `{ ok: true, pending: true, note, state }` after `timeout` (default 10 s) or
33
+ * `cancel()` (the chat behavior's STOP / new turn); `removed: true` when the instance went away.
34
+ * Calls run one at a time, so a waiting call delays the next one;
35
+ * - queries: read-only tools (`readOnlyHint`) that take an input, for data too large for `read`
36
+ * or answers computed from the state: `run(state, input)` is a pure, synchronous function of the
37
+ * instance's state; a call validates the input as for actions, checks `when`, and returns
38
+ * `{ ok: true, result }` without dispatching anything (no confirm, no flush);
28
39
  * - `when` and `read`: cached by state identity per instance (reducers are immutable);
29
40
  * - HMR: given the `run()` result, it re-binds to `app.__runtime` after the root's dispose.
30
41
  * Diagnostics (dev, once each): SYG240 an input with no JSON Schema form (the tool is left out;
31
- * `list({ all: true })` shows it with `error`), SYG241 `read` / `when` / `label` threw, SYG243 a
32
- * lossy input schema, SYG440 two declarations with one name, SYG441 Collection items with no or
33
- * duplicate keys.
42
+ * `list({ all: true })` shows it with `error`), SYG241 `read` / `when` / `until` / `label` / a query threw,
43
+ * SYG243 a lossy input schema, SYG246 a query whose `run` is not a function or returned a promise,
44
+ * SYG247 a query with the tool name of an action (not offered), SYG248 `until` names no resource of
45
+ * the component (the call answers at once), SYG440 two declarations with one
46
+ * name, SYG441 Collection items with no or duplicate keys.
34
47
  */
35
48
  import {abortReason, isAbort, ORIGINAL} from '../../../shared'
36
49
  import {objIsEqual} from '../../../cycle/state/objIsEqual'
@@ -73,8 +86,8 @@ const dev = (code: string, decl: any, what: string, component: string, message:
73
86
  if (!s) done.set(decl, s = new Set())
74
87
  if (s.has(code + what)) return
75
88
  s.add(code + what)
76
- // severities are passed (DEV_CODE_SEVERITY, kept out of the main bundle's table): 240 / 241 error, 243 / 440 / 441 warn
77
- try { report(code, {component, message, severity: code < 'SYG242' ? 'error' : 'warn'}) } catch (e) { queueMicrotask(() => { throw e }) }
89
+ // severities are passed (DEV_CODE_SEVERITY, kept out of the main bundle's table): 240 / 241 / 246 error, 243 / 247 / 248 / 440 / 441 warn
90
+ try { report(code, {component, message, severity: code < 'SYG242' || code == 'SYG246' ? 'error' : 'warn'}) } catch (e) { queueMicrotask(() => { throw e }) }
78
91
  }
79
92
 
80
93
  /** the string `enum` / `const` values anywhere in a JSON Schema (the app's own words: filter names, statuses) */
@@ -144,7 +157,7 @@ export const lookUp = (d: string, g: AgentGroup, read: string) => {
144
157
  }
145
158
 
146
159
  interface Entry {name: string; decl: any; ivs: any[]; item: boolean; view: any; enums: Set<string>}
147
- interface Rec {tool: AgentTool; e: Entry; action?: string; a?: any; key?: string; off?: 1}
160
+ interface Rec {tool: AgentTool; e: Entry; action?: string; query?: string; a?: any; key?: string; off?: 1}
148
161
  interface Call {id: number; type: string; data: any; ran: boolean; out: Array<{kind: string; reason?: string; error?: any}>}
149
162
 
150
163
  /**
@@ -164,6 +177,15 @@ export function agentTools(target: any, options: AgentToolsOptions = {}) {
164
177
  const owners = new Map<any, Map<any, any>>()
165
178
  const listeners = new Set<(change: {tools: AgentTool[]; context: Record<string, any>}) => void>()
166
179
  let last: any, scheduled = false
180
+ // `until`: calls waiting for their action's result to land in state, checked after each flush that changed something
181
+ const waiters = new Set<() => void>(), cancels = new Set<() => void>()
182
+ let waking = false
183
+ const wake = () => {
184
+ if (!waiters.size || waking) return
185
+ waking = true
186
+ queueMicrotask(() => api.flushed().then(() => { waking = false; for (const w of [...waiters]) w() }))
187
+ }
188
+ const changed = () => { dirty(); wake() }
167
189
 
168
190
  const dirty = () => {
169
191
  ver++
@@ -174,10 +196,10 @@ export function agentTools(target: any, options: AgentToolsOptions = {}) {
174
196
  }
175
197
  const hooks = {
176
198
  onCreate: dirty,
177
- onPatch: dirty,
178
- onReducer: dirty,
199
+ onPatch: changed,
200
+ onReducer: changed,
179
201
  onDispose: (iv: any) => {
180
- dirty()
202
+ changed()
181
203
  // HMR: run()'s hmr() disposes this app, starts its successor and swaps __runtime
182
204
  if (iv.isRoot && isApp) queueMicrotask(rebind)
183
205
  },
@@ -301,21 +323,28 @@ export function agentTools(target: any, options: AgentToolsOptions = {}) {
301
323
  for (const e of entries) {
302
324
  const d = e.decl
303
325
  if (d.read) recs.set(e.name + '_read', {e, tool: {name: e.name + '_read', description: `Read ${e.name}` + (d.description ? `: ${d.description}` : ''), inputSchema: {type: 'object', properties: {}}, annotations: {readOnlyHint: true, ...(d.untrusted && {untrustedContentHint: true})}}})
304
- for (const action in d.actions) {
305
- const a = d.actions[action], name = e.name + '_' + snake(action), comp = e.ivs[0].name
326
+ // the actions, then the queries (read-only, `run(state, input)`): one tool each
327
+ const defs: Array<[string, any, boolean]> = []
328
+ for (const k in d.actions) defs.push([k, d.actions[k], false])
329
+ for (const k in d.queries) defs.push([k, d.queries[k], true])
330
+ for (const [action, a, isQuery] of defs) {
331
+ const name = e.name + '_' + snake(action), comp = e.ivs[0].name, where = `agent.${isQuery ? 'queries' : 'actions'}.${action}`
332
+ if (isQuery && recs.has(name)) { dev('SYG247', d, action, comp, `${where} has the tool name ${name} of an action; the query is not offered (rename it)`); continue }
333
+ if (isQuery && typeof a?.run != 'function') { dev('SYG246', d, action, comp, `${where}.run is not a function; the query is not offered`); continue }
306
334
  const c = a.input ? toJsonSchema(a.input) : undefined
307
335
  if (c?.schema) enumsOf(c.schema, e.enums)
308
- const tool: AgentTool = {name, description: a.description, inputSchema: c?.schema || {type: 'object', properties: {}}, annotations: a.consequential ? {consequentialHint: true} : {}}
309
- const rec: Rec = {e, action, a, tool}
336
+ const tool: AgentTool = {name, description: a.description, inputSchema: c?.schema || {type: 'object', properties: {}},
337
+ annotations: isQuery ? {readOnlyHint: true, ...(d.untrusted && {untrustedContentHint: true})} : a.consequential ? {consequentialHint: true} : {}}
338
+ const rec: Rec = isQuery ? {e, query: action, a, tool} : {e, action, a, tool}
310
339
  recs.set(name, rec)
311
- if (e.item) seen.set(name, {e: {...e, ivs: []}, action, a, tool})
340
+ if (e.item) seen.set(name, {...rec, e: {...e, ivs: []}})
312
341
  if (c?.error) {
313
342
  tool.error = `SYG240 ${c.error}`
314
343
  rec.off = 1
315
- dev('SYG240', d, action, comp, `agent.actions.${action}.input: ${c.error}; the tool is not offered`)
344
+ dev('SYG240', d, action, comp, `${where}.input: ${c.error}; the tool is not offered`)
316
345
  continue
317
346
  }
318
- if (c?.lossy) dev('SYG243', d, action, comp, `agent.actions.${action}.input: the model doesn't see ${c.lossy}`)
347
+ if (c?.lossy) dev('SYG243', d, action, comp, `${where}.input: the model doesn't see ${c.lossy}`)
319
348
  const live = e.ivs.filter(iv => whenOk(iv, action, a.when))
320
349
  if (!live.length) { rec.off = 1; continue }
321
350
  if (e.item) {
@@ -396,8 +425,8 @@ export function agentTools(target: any, options: AgentToolsOptions = {}) {
396
425
  return r ? {ok: false, error: `${name} is not available now`, state: resultState(r.e.ivs[0])} : {ok: false, error: `no tool ${name}; tools: ${list().map(t => t.name).join(', ')}`}
397
426
  }
398
427
  if (r.tool.error) return {ok: false, error: r.tool.error}
399
- const {e, a, action} = r
400
- if (!action) return {ok: true, state: context()[e.name]}
428
+ const {e, a, action, query} = r
429
+ if (!action && !query) return {ok: true, state: context()[e.name]}
401
430
  const key = r.key || 'id', view = e.view
402
431
  let raw = args ?? {}, k: any
403
432
  if (e.item) {
@@ -418,18 +447,20 @@ export function agentTools(target: any, options: AgentToolsOptions = {}) {
418
447
  }
419
448
  let iv = resolve()
420
449
  if (!iv) return e.item ? missing(e, view, k) : {ok: false, error: `${e.name} is not on the page`}
421
- if (!whenOk(iv, action, a.when)) return {ok: false, error: `${name} is not available now`, state: resultState(iv)}
450
+ if (query) return ask(name, iv, query, a, data)
451
+ const type = action as string
452
+ if (!whenOk(iv, type, a.when)) return {ok: false, error: `${name} is not available now`, state: resultState(iv)}
422
453
  if (a.consequential) {
423
454
  const c = 'confirm' in o ? o.confirm : options.confirm
424
- const info: ConfirmInfo = {tool: name, component: iv.name, action, description: a.description, input: data, ...(e.item && {key: k, label: labelOf(iv)})}
455
+ const info: ConfirmInfo = {tool: name, component: iv.name, action: type, description: a.description, input: data, ...(e.item && {key: k, label: labelOf(iv)})}
425
456
  if (!(typeof c == 'function' ? await c(info) : c === true)) return {ok: false, error: 'the user declined'}
426
457
  // the app may have changed while the user decided: resolve and check again
427
458
  iv = resolve()
428
459
  if (!iv) return e.item ? {...missing(e, view, k), error: `the ${e.name} with id ${JSON.stringify(k)} is gone`} : {ok: false, error: `${e.name} is not on the page any more`}
429
- if (!whenOk(iv, action, a.when)) return {ok: false, error: `${name} is not available any more`, state: resultState(iv)}
460
+ if (!whenOk(iv, type, a.when)) return {ok: false, error: `${name} is not available any more`, state: resultState(iv)}
430
461
  }
431
- const rec: Call = pending = {id: iv.id, type: action, data, ran: false, out: []}
432
- api.dispatch(iv.id, action, data, 'agent')
462
+ const rec: Call = pending = {id: iv.id, type, data, ran: false, out: []}
463
+ api.dispatch(iv.id, type, data, 'agent')
433
464
  await api.flushed()
434
465
  if (pending === rec) pending = null
435
466
  if (current === rec) current = null
@@ -440,12 +471,66 @@ export function agentTools(target: any, options: AgentToolsOptions = {}) {
440
471
  if (iv.disposed && e.item) iv = resolve() || iv
441
472
  const gone = iv.disposed
442
473
  const state = gone ? resultState(api.get(iv.parentId)) : resultState(iv)
443
- if (out.some(x => x.kind == 'changed' || x.kind == 'sent' || x.kind == 'effect')) return gone ? {ok: true, removed: true, state} : {ok: true, state}
474
+ if (out.some(x => x.kind == 'changed' || x.kind == 'sent' || x.kind == 'effect')) return gone ? {ok: true, removed: true, state} : a.until ? settle(name, iv, type, a) : {ok: true, state}
444
475
  if (refused) return {ok: false, error: `${action} was refused: ${refused.reason}`, state}
445
476
  return a.idempotent ? {ok: true, unchanged: true, state} : {ok: false, error: `${action} changed nothing (already so, or the input matched nothing)`, state}
446
477
  }
447
478
 
479
+ /** `until` as a predicate: a resource name is done when that resource is neither loading nor refreshing */
480
+ const doneTest = (iv: any, action: string, u: any): ((s: any) => boolean) | undefined => {
481
+ if (typeof u == 'function') return u
482
+ if (typeof u != 'string') return
483
+ const res = iv.def.view.resources
484
+ if (!res || !(u in res)) {
485
+ dev('SYG248', declOf(iv), 'until' + action, iv.name, `agent.actions.${action}.until names '${u}', which is not one of ${iv.name}'s resources${res ? ` (${Object.keys(res).join(', ')})` : ''}; the call answers at once`)
486
+ return
487
+ }
488
+ return (s: any) => { const r = s?.[u]; return !r || (r.status != 'loading' && !r.refreshing) }
489
+ }
490
+ /**
491
+ * `until`: answer once the action's result is in state (after this flush or a later one), or
492
+ * with `pending` on `timeout` / `cancel()`; `removed` when the instance went away meanwhile
493
+ */
494
+ function settle(name: string, iv: any, action: string, a: any): AgentResult | Promise<AgentResult> {
495
+ const test = doneTest(iv, action, a.until)
496
+ const holds = () => {
497
+ if (!test) return true
498
+ try { return !!test(iv.state) } catch (err: any) {
499
+ dev('SYG241', declOf(iv), 'until' + action, iv.name, `agent.actions.${action}.until threw: ${err?.message ?? err}`)
500
+ return true
501
+ }
502
+ }
503
+ const result = (extra?: any): AgentResult => ({ok: true, ...extra, state: iv.disposed ? resultState(api.get(iv.parentId)) : resultState(iv)})
504
+ if (holds()) return result()
505
+ return new Promise(resolve => {
506
+ const finish = (extra?: any) => { waiters.delete(check); cancels.delete(cancel); clearTimeout(timer); resolve(result(extra)) }
507
+ const cancel = () => finish({pending: true, note: `${name} is still in progress: its result will be in the app state later`})
508
+ const check = () => stopped ? cancel() : iv.disposed ? finish({removed: true}) : holds() && finish()
509
+ const timer = setTimeout(cancel, a.timeout ?? 10000)
510
+ waiters.add(check)
511
+ cancels.add(cancel)
512
+ })
513
+ }
514
+
515
+ /** a query call: `run(state, input)` on the instance's state now; nothing is dispatched */
516
+ function ask(name: string, iv: any, query: string, q: any, data: any): AgentResult {
517
+ if (!whenOk(iv, query, q.when)) return {ok: false, error: `${name} is not available now`}
518
+ let result: any
519
+ try { result = q.run(iv.state, data) } catch (err: any) {
520
+ const m = err?.message ?? err
521
+ dev('SYG241', declOf(iv), 'query' + query, iv.name, `agent.queries.${query}.run threw: ${m}`)
522
+ return {ok: false, error: `${name} failed: ${m}`}
523
+ }
524
+ if (result && typeof result.then == 'function') {
525
+ dev('SYG246', declOf(iv), 'promise' + query, iv.name, `agent.queries.${query}.run returned a promise: a query is a pure, synchronous function of the state`)
526
+ return {ok: false, error: `${name} is not available: it returned a promise`}
527
+ }
528
+ return {ok: true, result}
529
+ }
530
+
448
531
  return {
532
+ /** answer every call waiting on `until` now, as `pending` (a chat turn stopped or replaced) */
533
+ cancel() { for (const c of [...cancels]) c() },
449
534
  /** the offered tools; `{ all: true }` adds declared tools that can't be offered (`error`, SYG240) */
450
535
  list,
451
536
  /** run a tool as an agent: resolves with the result after the action's flush */
@@ -457,7 +542,7 @@ export function agentTools(target: any, options: AgentToolsOptions = {}) {
457
542
  dirty()
458
543
  return () => { listeners.delete(fn) }
459
544
  },
460
- stop() { stopped = true; off?.(); listeners.clear() },
545
+ stop() { stopped = true; off?.(); listeners.clear(); for (const c of [...cancels]) c() },
461
546
  /** internal (A-2, WebMCP; chat; the command bar): the offered tools grouped by declaration, with `untrusted` as declared and an item declaration's live labels (id → `agent.label`) */
462
547
  groups(): AgentGroup[] {
463
548
  const rs = [...build().values()]