poetry-agent 0.1.3 → 0.1.5

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.controller_docs +1 -0
  3. data/.jsdoc_floor +1 -0
  4. data/.mcp.json +8 -0
  5. data/.yard-lint.yml +25 -0
  6. data/.yard_coverage_all +1 -0
  7. data/Archspec.rb +15 -0
  8. data/CHANGELOG.md +16 -0
  9. data/app/javascript/poetry/agent/a2ui_surface_controller.js +56 -12
  10. data/app/javascript/poetry/agent/adapter.js +49 -11
  11. data/app/javascript/poetry/agent/agui_client_tool_controller.js +18 -8
  12. data/app/javascript/poetry/agent/index.js +10 -4
  13. data/app/javascript/poetry/agent/stream_actions.js +30 -9
  14. data/app/javascript/poetry/agent/webmcp_controller.js +75 -39
  15. data/app/javascript/poetry/agent/webmcp_form_controller.js +37 -24
  16. data/config/controllers_manifest.json +45 -11
  17. data/eslint.config.mjs +34 -0
  18. data/lib/poetry/agent/a2ui/catalog.rb +14 -0
  19. data/lib/poetry/agent/a2ui/catalogs/basic.rb +25 -0
  20. data/lib/poetry/agent/a2ui/catalogs/native.rb +12 -0
  21. data/lib/poetry/agent/a2ui/checks.rb +5 -1
  22. data/lib/poetry/agent/a2ui/evaluator.rb +6 -0
  23. data/lib/poetry/agent/a2ui/expression.rb +13 -0
  24. data/lib/poetry/agent/a2ui/functions.rb +26 -12
  25. data/lib/poetry/agent/a2ui/markdown.rb +6 -1
  26. data/lib/poetry/agent/a2ui/pointer.rb +5 -2
  27. data/lib/poetry/agent/a2ui/renderer.rb +18 -0
  28. data/lib/poetry/agent/a2ui/session.rb +23 -0
  29. data/lib/poetry/agent/a2ui/streams.rb +4 -0
  30. data/lib/poetry/agent/a2ui/surface.rb +22 -0
  31. data/lib/poetry/agent/agui/client.rb +6 -0
  32. data/lib/poetry/agent/agui/json_patch.rb +8 -1
  33. data/lib/poetry/agent/agui/relay.rb +4 -0
  34. data/lib/poetry/agent/agui/run_input.rb +1 -0
  35. data/lib/poetry/agent/agui/sse.rb +3 -0
  36. data/lib/poetry/agent/agui/transcript.rb +37 -0
  37. data/lib/poetry/agent/agui/turbo_stream.rb +6 -0
  38. data/lib/poetry/agent/config.rb +1 -0
  39. data/lib/poetry/agent/mcp/bundled.rb +4 -1
  40. data/lib/poetry/agent/mcp/http.rb +6 -0
  41. data/lib/poetry/agent/mcp/server.rb +132 -37
  42. data/lib/poetry/agent/version.rb +1 -1
  43. data/lib/poetry/agent/webmcp/origin_trial.rb +5 -0
  44. metadata +11 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f94b26ba2c90475193b090c1deba25d33ffad8fbec0c3b4fdee0e22be1c7a977
4
- data.tar.gz: 8d504ccbe9f96573ffe89dee1e7ed80ccefd1b5af93a9f9543aa70228ff5c059
3
+ metadata.gz: 6741c0fdec4ef51366a3c84788aa915e926783aff97b1ee65ef1a24536657c17
4
+ data.tar.gz: 3da36a6f201c54d3eb54e9c652bd28268503b7f935e022fcf56325bbdae7c827
5
5
  SHA512:
6
- metadata.gz: 9e565a75006736af0eae0d6ca7e7284c00c7450399abde58de0e9aafac1869a7f72fd77f256a3100e41525eeea2a92751ecb9b3b03a1324450453ab71402f2e3
7
- data.tar.gz: 23e554fad8c091dca3ca66f7947452f870f88586d78901470024fd9d8f84b10a15129c35c8f2cba5b4f72044b9f4eb7af3f8d35e07dec630debba4de1b704d87
6
+ metadata.gz: dde4b223ac88d79f769ff4f86158441b27f3e04077da404a0bd44abb2fcc98eef160518483c3dd3ddb2c40300420c5adf825a15a3139045465c5932775399dab
7
+ data.tar.gz: dda91195a6772508385e79306e7f60e020c1d3d37d0c99a7869d8eb387568f1b30149d1fe7ee671e021268b7bf3bd94f569ad48b045692466e0e817d0d63c0a5
data/.controller_docs ADDED
@@ -0,0 +1 @@
1
+ 0
data/.jsdoc_floor ADDED
@@ -0,0 +1 @@
1
+ 2
data/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "rubydex": {
4
+ "command": "bundle",
5
+ "args": ["exec", "rdx", "mcp"]
6
+ }
7
+ }
8
+ }
data/.yard-lint.yml ADDED
@@ -0,0 +1,25 @@
1
+ # yard-lint: the documentation tier YARD's own gates miss - a documented
2
+ # method missing a @param, an @example that does not parse, option tags,
3
+ # tag order, invalid types. Runs as rake yard:lint (in the default task).
4
+ # UndocumentedObjects stays off: yard:coverage gates public objects at its
5
+ # recorded floor, and this validator counts the @api private internals the
6
+ # gems hide on purpose (--hide-api is not honoured here). MissingReturn is
7
+ # off until the @param backlog is cleared; revisit then.
8
+ AllValidators:
9
+ YardOptions:
10
+ - --no-private
11
+ Documentation/UndocumentedObjects:
12
+ Enabled: false
13
+ Documentation/MissingReturn:
14
+ Enabled: false
15
+ Tags/InvalidTypes:
16
+ Enabled: true
17
+ # A **options splat passed through to a control is documented as @param
18
+ # options [Hash], not one @option per key; and @example blocks here are
19
+ # ERB as often as Ruby, which this validator cannot parse.
20
+ Documentation/UndocumentedOptions:
21
+ Enabled: false
22
+ Tags/OptionTags:
23
+ Enabled: false
24
+ Tags/ExampleSyntax:
25
+ Enabled: false
@@ -0,0 +1 @@
1
+ 0
data/Archspec.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The architecture the family enforces by review, as checks (rake arch:check).
4
+ # agent depends on core and ui, never on charts or extract, and never
5
+ # names the host application.
6
+ root "."
7
+ source "app/**/*.rb", "lib/**/*.rb"
8
+
9
+ component :lib, in: "lib/**/*.rb"
10
+ component :app, in: "app/**/*.rb"
11
+
12
+ lib.cannot_reference_constants "Poetry::Charts", "Poetry::Extract", "ApplicationController",
13
+ because: "agent depends on core and ui and never names the host"
14
+ app.cannot_reference_constants "Poetry::Charts", "Poetry::Extract", "ApplicationController",
15
+ because: "agent depends on core and ui and never names the host"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.5] - 2026-09-18
4
+
5
+ ### Added
6
+
7
+ - The controllers manifest carries the prose beside each controller: its purpose (a JSDoc block above the class), the meaning of every value, and a summary of every action method, harvested when the manifest is generated; `rake stimulus:docs` holds the count of gaps at a committed floor, now zero.
8
+
9
+ ### Changed
10
+
11
+ - 18 methods the reference already hid with `@api private` are Ruby-private now: each was called only by its own class or template, so the runtime enforces what the tag only stated. A host that reached one gets a NoMethodError instead of an internal that may change without notice. The tag remains on the internals the family shares between its gems and on whole internal classes.
12
+ - Every class, module and method carries a one-sentence description, private helpers included: `rake yard:coverage:all` measures the whole tree (a tag-only docstring counts as blank) and the committed floor now stands at zero.
13
+ - `describe_component` phrases any-of requirements through core's `RequiresAny`, and a test now covers the requirement and slot-facet lines.
14
+
15
+ ## [0.1.4] - 2026-09-15
16
+
17
+ Lockstep release with the family; no changes in this gem.
18
+
3
19
  ## [0.1.3] - 2026-09-13
4
20
 
5
21
  ### Fixed
@@ -1,22 +1,33 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // The client side of an A2UI surface's checks: the server renders the
4
- // program (every checked component's rules with absolute bindings, the
5
- // bound inputs by path with their kinds, and the data model) and this
6
- // controller evaluates it as the user types - a button whose own checks
7
- // fail is disabled, a failing input is marked invalid and its error slot
8
- // carries the message. The five validators and the three combinators are
9
- // the checks vocabulary; anything else passes here and is judged by the
10
- // server, which re-runs every rule on the action.
11
3
  const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
12
4
 
5
+ /**
6
+ * The client side of an A2UI surface's checks: the server renders the
7
+ * program (every checked component's rules with absolute bindings, the
8
+ * bound inputs by path with their kinds, and the data model) and this
9
+ * controller evaluates it as the user types - a button whose own checks
10
+ * fail is disabled, a failing input is marked invalid and its error slot
11
+ * carries the message. The five validators and the three combinators are
12
+ * the checks vocabulary; anything else passes here and is judged by the
13
+ * server, which re-runs every rule on the action.
14
+ */
13
15
  export default class extends Controller {
14
- static values = { program: Object }
16
+ static values = {
17
+ // The check program the server compiled: the checks, the bound inputs by path, and the data model.
18
+ program: Object
19
+ }
15
20
 
21
+ /**
22
+ * Evaluates every check once at mount.
23
+ */
16
24
  connect() {
17
25
  this.evaluate()
18
26
  }
19
27
 
28
+ /**
29
+ * Runs every check in the program and applies its failures.
30
+ */
20
31
  evaluate() {
21
32
  const checks = this.programValue?.checks || {}
22
33
  for (const [key, entry] of Object.entries(checks)) {
@@ -25,7 +36,13 @@ export default class extends Controller {
25
36
  }
26
37
  }
27
38
 
28
- // The message of a failing rule, or null when it passes.
39
+ /**
40
+ * A rule's failure message, or null when it passes or cannot be decided
41
+ * here.
42
+ *
43
+ * @param {Object} rule the check rule with its condition and message
44
+ * @returns {string|null} the failure message, or null
45
+ */
29
46
  failure(rule) {
30
47
  const result = this.resolve(rule.condition)
31
48
  if (result === null) return null // unknown here; the server decides
@@ -34,6 +51,14 @@ export default class extends Controller {
34
51
  return (result && typeof result === "object" && result.message) || rule.message || "Check failed"
35
52
  }
36
53
 
54
+ /**
55
+ * Reflects a check's failures on the elements bound to a key: a button
56
+ * disables, an input turns invalid.
57
+ *
58
+ * @param {string} key the bound key the elements carry
59
+ * @param {string} kind the control kind: button or input
60
+ * @param {string[]} failures the failure messages, empty when the check passes
61
+ */
37
62
  apply(key, kind, failures) {
38
63
  for (const element of this.element.querySelectorAll(`[data-a2ui-key="${escapeAttribute(key)}"]`)) {
39
64
  if (kind === "button") {
@@ -49,6 +74,12 @@ export default class extends Controller {
49
74
  }
50
75
  }
51
76
 
77
+ /**
78
+ * A value with its bindings read and its calls run, recursively.
79
+ *
80
+ * @param {*} value a literal, a binding, a call, or an array of them
81
+ * @returns {*} the resolved value
82
+ */
52
83
  resolve(value) {
53
84
  if (value === null || typeof value !== "object") return value
54
85
  if (Array.isArray(value)) return value.map((item) => this.resolve(item))
@@ -57,6 +88,14 @@ export default class extends Controller {
57
88
  return value
58
89
  }
59
90
 
91
+ /**
92
+ * Runs a catalog function by name with its arguments resolved; an unknown
93
+ * name resolves to null.
94
+ *
95
+ * @param {string} name the catalog function
96
+ * @param {Object} rawArgs the arguments, bindings and calls unresolved
97
+ * @returns {*} the function's result, or null for an unknown name
98
+ */
60
99
  call(name, rawArgs) {
61
100
  const fn = FUNCTIONS[name]
62
101
  if (!fn) return null
@@ -65,8 +104,13 @@ export default class extends Controller {
65
104
  return fn(args)
66
105
  }
67
106
 
68
- // The current value of a bound path: the form control first, the
69
- // server's model when no control carries it.
107
+ /**
108
+ * The current value of a bound path from the form's inputs, falling back to
109
+ * the data model.
110
+ *
111
+ * @param {string} path the bound pointer
112
+ * @returns {*} the input's current value, or the model's
113
+ */
70
114
  read(path) {
71
115
  const kind = this.programValue?.inputs?.[path]
72
116
  const name = `a2ui[values][${path}]`
@@ -15,15 +15,31 @@
15
15
  // argument - an object rejects with UnknownError("Failed to parse input
16
16
  // arguments"), not a TypeError.
17
17
 
18
- // The live ModelContext, or null where the browser exposes none - callers
19
- // treat null as "do nothing", exactly like an edge bridge would.
18
+ /**
19
+ * The live ModelContext, or null where the browser exposes none - callers
20
+ * treat null as "do nothing", exactly like an edge bridge would.
21
+ *
22
+ * @returns {Object|null} the live ModelContext, or null
23
+ */
20
24
  export const modelContext = () =>
21
25
  (typeof document !== "undefined" && document.modelContext) || null
22
26
 
27
+ /**
28
+ * Whether this browser exposes a ModelContext.
29
+ *
30
+ * @returns {boolean} true when document.modelContext exists
31
+ */
23
32
  export const supported = () => modelContext() !== null
24
33
 
25
- // Registers one tool; resolves when the browser accepted it, rejects on a
26
- // duplicate name, an empty name/description, or an invalid schema.
34
+ /**
35
+ * Registers one tool; resolves when the browser accepted it, rejects on a
36
+ * duplicate name, an empty name/description, or an invalid schema.
37
+ *
38
+ * @param {Object} definition the tool definition (name, description, inputSchema, execute)
39
+ * @param {Object} [root0] the options
40
+ * @param {AbortSignal} [root0.signal] aborts the registration
41
+ * @returns {Promise<void>} resolves once the browser accepted the tool
42
+ */
27
43
  export const registerTool = (definition, { signal } = {}) =>
28
44
  modelContext().registerTool(definition, signal ? { signal } : {})
29
45
 
@@ -31,8 +47,13 @@ export const registerTool = (definition, { signal } = {}) =>
31
47
  // executeTool go string-first without a wasted rejection.
32
48
  let stringArguments = false
33
49
 
34
- // The registered tools with inputSchema normalized to an object: parsed
35
- // when the browser serialized it, null when the text is not JSON.
50
+ /**
51
+ * The registered tools with inputSchema normalized to an object: parsed
52
+ * when the browser serialized it, null when the text is not JSON.
53
+ *
54
+ * @param {Object} [options] the getTools options (fromOrigins)
55
+ * @returns {Promise<Array<Object>>} the registered tools
56
+ */
36
57
  export const getTools = async (options = {}) => {
37
58
  const tools = await modelContext().getTools(options)
38
59
  for (const tool of tools) {
@@ -47,9 +68,16 @@ export const getTools = async (options = {}) => {
47
68
  return tools
48
69
  }
49
70
 
50
- // Executes a tool with the spec's object arguments, falling back to the
51
- // JSON-string form the current Chrome build parses; when both shapes
52
- // reject, the first rejection surfaces.
71
+ /**
72
+ * Executes a tool with the spec's object arguments, falling back to the
73
+ * JSON-string form the current Chrome build parses; when both shapes
74
+ * reject, the first rejection surfaces.
75
+ *
76
+ * @param {Object} tool the registered tool
77
+ * @param {Object} [args] the input arguments
78
+ * @param {Object} [options] the executeTool options (signal)
79
+ * @returns {Promise<string>} the stringified result
80
+ */
53
81
  export const executeTool = async (tool, args = {}, options = {}) => {
54
82
  const context = modelContext()
55
83
  const asString = () => context.executeTool(tool, JSON.stringify(args), options)
@@ -67,11 +95,21 @@ export const executeTool = async (tool, args = {}, options = {}) => {
67
95
  }
68
96
  }
69
97
 
70
- // WebMCP tool-name grammar: 1-128 chars of ASCII alphanumerics, "_", "-", ".".
98
+ /**
99
+ * WebMCP tool-name grammar: 1-128 chars of ASCII alphanumerics, "_", "-", ".".
100
+ */
71
101
  export const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/
102
+ /**
103
+ * Whether a name fits the WebMCP tool-name grammar.
104
+ *
105
+ * @param {string} name the candidate name
106
+ * @returns {boolean} true when it matches TOOL_NAME
107
+ */
72
108
  export const validToolName = (name) => TOOL_NAME.test(name)
73
109
 
74
- // Test seam: forget the argument shape the last browser taught us.
110
+ /**
111
+ * Test seam: forget the argument shape the last browser taught us.
112
+ */
75
113
  export const _resetArgumentShape = () => {
76
114
  stringArguments = false
77
115
  }
@@ -1,23 +1,33 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { executeRegisteredTool } from "@poetry/agent/webmcp_controller"
3
3
 
4
- // The AG-UI client-tool bridge: the relay appends one of these (hidden)
5
- // per tool call the agent made to a FRONTEND tool - a component tool the
6
- // page declared - and this controller executes it through the registrar
7
- // (the same dispatch a WebMCP call takes, so it works in every browser,
8
- // modelContext or not), then POSTs the result to the continue URL. The
9
- // server folds the tool message into the transcript and answers with the
10
- // next run's streams, which Turbo renders. One element, one execution:
11
- // the done flag makes a Turbo re-render inert.
4
+ /**
5
+ * The AG-UI client-tool bridge: the relay appends one of these (hidden)
6
+ * per tool call the agent made to a FRONTEND tool - a component tool the
7
+ * page declared - and this controller executes it through the registrar
8
+ * (the same dispatch a WebMCP call takes, so it works in every browser,
9
+ * modelContext or not), then POSTs the result to the continue URL. The
10
+ * server folds the tool message into the transcript and answers with the
11
+ * next run's streams, which Turbo renders. One element, one execution:
12
+ * the done flag makes a Turbo re-render inert.
13
+ */
12
14
  export default class extends Controller {
13
15
  static values = {
16
+ // The pending tool call: its id, name and arguments.
14
17
  call: Object,
18
+ // Where the tool result is posted.
15
19
  url: String,
20
+ // Whether the call has already been answered (a restored snapshot must not
21
+ // answer twice).
16
22
  done: Boolean
17
23
  }
18
24
 
19
25
  static events = ["poetry:agui:client-tool-executed"]
20
26
 
27
+ /**
28
+ * Runs the pending client tool once, posts its result, and marks the call
29
+ * done.
30
+ */
21
31
  async connect() {
22
32
  if (this.doneValue) return
23
33
  this.doneValue = true
@@ -21,7 +21,9 @@ export * from "@poetry/agent/adapter"
21
21
  export { _registrations, executeRegisteredTool } from "@poetry/agent/webmcp_controller"
22
22
  export { installVersionedReplace, installMorphStateGuard, preservesLocalState } from "@poetry/agent/stream_actions"
23
23
 
24
- // identifier -> controller class (the manifest introspects this).
24
+ /**
25
+ * identifier -> controller class (the manifest introspects this).
26
+ */
25
27
  export const controllers = {
26
28
  "poetry--agent--webmcp": WebmcpController,
27
29
  "poetry--agent--webmcp-form": WebmcpFormController,
@@ -29,9 +31,13 @@ export const controllers = {
29
31
  "poetry--agent--a2ui-surface": A2uiSurfaceController
30
32
  }
31
33
 
32
- // Registers the runtime's controllers, installs the versioned replace
33
- // stream action the AG-UI relay and the A2UI streams emit (when Turbo is
34
- // present), and the morph guard that keeps an A2UI surface's local state.
34
+ /**
35
+ * Registers the runtime's controllers, installs the versioned replace
36
+ * stream action the AG-UI relay and the A2UI streams emit (when Turbo is
37
+ * present), and the morph guard that keeps an A2UI surface's local state.
38
+ *
39
+ * @param {Object} application the Stimulus application
40
+ */
35
41
  export const registerPoetryAgent = (application) => {
36
42
  for (const [identifier, controller] of Object.entries(controllers)) {
37
43
  application.register(identifier, controller)
@@ -1,12 +1,17 @@
1
- // The versioned replace Turbo Stream action the AG-UI relay and the A2UI
2
- // surface streams emit: a streamed frame re-renders the SAME element from
3
- // a server stream, which inherits an out-of-order delivery race, so every
4
- // payload carries data-version and this action applies only strictly-newer
5
- // frames - older or duplicate frames are dropped silently. With
6
- // method="morph" the newer frame morphs the element through Turbo's own
7
- // replace action (idiomorph), so local state survives an update. Installed
8
- // on Turbo by registerPoetryAgent when the host has Turbo and no vreplace
9
- // of its own.
1
+ /**
2
+ * The versioned replace Turbo Stream action the AG-UI relay and the A2UI
3
+ * surface streams emit: a streamed frame re-renders the SAME element from
4
+ * a server stream, which inherits an out-of-order delivery race, so every
5
+ * payload carries data-version and this action applies only strictly-newer
6
+ * frames - older or duplicate frames are dropped silently. With
7
+ * method="morph" the newer frame morphs the element through Turbo's own
8
+ * replace action (idiomorph), so local state survives an update. Installed
9
+ * on Turbo by registerPoetryAgent when the host has Turbo and no vreplace
10
+ * of its own.
11
+ *
12
+ * @param {Object} [turbo] the Turbo global; a host with no Turbo installs nothing
13
+ * @returns {boolean} true when the action was installed
14
+ */
10
15
  export const installVersionedReplace = (turbo = globalThis.Turbo) => {
11
16
  if (!turbo?.StreamActions || turbo.StreamActions.vreplace) return false
12
17
 
@@ -39,6 +44,15 @@ const LOCAL_STATE = {
39
44
  }
40
45
  const EXPANDED = ["aria-expanded", "data-open", "data-state"]
41
46
 
47
+ /**
48
+ * Whether a morph must leave an attribute alone to keep an A2UI surface's
49
+ * local state: an edited input's value or checked state, an open disclosure,
50
+ * or a slot-specific attribute the runtime owns.
51
+ *
52
+ * @param {Element} element the element being morphed
53
+ * @param {string} attributeName the attribute the morph would change
54
+ * @returns {boolean} true to keep the current attribute
55
+ */
42
56
  export const preservesLocalState = (element, attributeName) => {
43
57
  if (!element?.closest?.("[data-a2ui-surface]")) return false
44
58
  if (attributeName === "value" || attributeName === "checked") return isDirty(element)
@@ -55,6 +69,13 @@ const isDirty = (element) => {
55
69
  return false
56
70
  }
57
71
 
72
+ /**
73
+ * Installs the before-morph-attribute guard once per document, so a streamed
74
+ * re-render morphs without clobbering local state.
75
+ *
76
+ * @param {Document} [doc] the document to guard
77
+ * @returns {boolean} true when installed, false when absent or already guarded
78
+ */
58
79
  export const installMorphStateGuard = (doc = globalThis.document) => {
59
80
  if (!doc || doc.__poetryA2uiMorphGuard) return false
60
81
  doc.__poetryA2uiMorphGuard = true
@@ -1,35 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { supported, registerTool, validToolName } from "@poetry/agent/adapter"
3
3
 
4
- // The registrar: one controller on an opted-in component root
5
- // (`webmcp: "country"` on the helper call renders it beside the
6
- // component's own controllers) registers that instance's declared tools
7
- // with document.modelContext on connect and aborts them on disconnect.
8
- // Components gain zero runtime code - each tool dispatches to the
9
- // component's OWN controller action (the `executes` descriptor the Ruby
10
- // contract validated at class load), passing the tool's parameters
11
- // positionally in declared order.
12
- //
13
- // Correctness rules the spec makes load-bearing:
14
- // - Re-registration is skipped while the payload is unchanged (the spec
15
- // documents an unregister/quick-re-register race where in-flight args
16
- // for the old tool can hit the new tool's schema).
17
- // - Never register under Turbo's cache preview.
18
- // - Duplicate names are rejected by the browser; we warn and skip.
19
- // - A per-document budget caps registrations (each tool costs the agent
20
- // context; overlap confuses tool choice).
21
- // - Errors come back as descriptive result strings (granular exceptions
22
- // are still open spec issues; a string lets the agent self-correct):
23
- // a missing or unknown parameter, a value of the wrong type or outside
24
- // the enum, a missing action, a throwing action.
25
- // - A result is the action's return value when it is JSON-serializable
26
- // (the contract's actions return their resulting state, so an answer
27
- // says what happened rather than "done"); the done marker covers
28
- // actions that return nothing.
29
- // - Parameters map positionally onto the action in declared order; the
30
- // execute callback's {signal} is not forwarded (the actions are
31
- // synchronous UI operations).
32
-
33
4
  // element -> { hash, controller: AbortController, names: string[] }
34
5
  const registrations = new Map()
35
6
 
@@ -41,10 +12,45 @@ const instances = new Map()
41
12
  const registeredCount = () =>
42
13
  [...registrations.values()].reduce((sum, entry) => sum + entry.names.length, 0)
43
14
 
15
+ /**
16
+ * The registrar: one controller on an opted-in component root
17
+ * (`webmcp: "country"` on the helper call renders it beside the
18
+ * component's own controllers) registers that instance's declared tools
19
+ * with document.modelContext on connect and aborts them on disconnect.
20
+ * Components gain zero runtime code - each tool dispatches to the
21
+ * component's OWN controller action (the `executes` descriptor the Ruby
22
+ * contract validated at class load), passing the tool's parameters
23
+ * positionally in declared order.
24
+ *
25
+ * Correctness rules the spec makes load-bearing:
26
+ * - Re-registration is skipped while the payload is unchanged (the spec
27
+ * documents an unregister/quick-re-register race where in-flight args
28
+ * for the old tool can hit the new tool's schema).
29
+ * - Never register under Turbo's cache preview.
30
+ * - Duplicate names are rejected by the browser; we warn and skip.
31
+ * - A per-document budget caps registrations (each tool costs the agent
32
+ * context; overlap confuses tool choice).
33
+ * - Errors come back as descriptive result strings (granular exceptions
34
+ * are still open spec issues; a string lets the agent self-correct):
35
+ * a missing or unknown parameter, a value of the wrong type or outside
36
+ * the enum, a missing action, a throwing action.
37
+ * - A result is the action's return value when it is JSON-serializable
38
+ * (the contract's actions return their resulting state, so an answer
39
+ * says what happened rather than "done"); the done marker covers
40
+ * actions that return nothing.
41
+ * - Parameters map positionally onto the action in declared order; the
42
+ * execute callback's {signal} is not forwarded (the actions are
43
+ * synchronous UI operations).
44
+ */
44
45
  export default class extends Controller {
45
46
  static values = {
47
+ // The instance name the tools register under (poetry.<name>.<tool>).
46
48
  name: String,
49
+ // The tool definitions this instance registers, as the component declared
50
+ // them.
47
51
  tools: Array,
52
+ // The most tools one page registers; past it, further registrations are
53
+ // dropped with a console warning.
48
54
  budget: { type: Number, default: 20 }
49
55
  }
50
56
 
@@ -54,25 +60,40 @@ export default class extends Controller {
54
60
  "poetry:webmcp:unregistered"
55
61
  ]
56
62
 
63
+ /**
64
+ * Registers this instance's tools and records it for the executor.
65
+ */
57
66
  connect() {
58
67
  instances.set(this.element, this)
59
68
  this.register()
60
69
  }
61
70
 
71
+ /**
72
+ * Forgets the instance and aborts its registrations.
73
+ */
62
74
  disconnect() {
63
75
  instances.delete(this.element)
64
76
  this.unregister()
65
77
  }
66
78
 
79
+ /**
80
+ * Re-registers under the new name once connected.
81
+ */
67
82
  nameValueChanged() {
68
83
  if (this.#connected) this.register()
69
84
  }
70
85
 
86
+ /**
87
+ * Re-registers the new tool list once connected.
88
+ */
71
89
  toolsValueChanged() {
72
90
  if (this.#connected) this.register()
73
91
  }
74
92
 
75
- // Registers this instance's tools; idempotent for an unchanged payload.
93
+ /**
94
+ * Registers every declared tool with the browser under the instance name,
95
+ * within the page budget; skipped when unsupported or in a preview.
96
+ */
76
97
  register() {
77
98
  this.#connected = true
78
99
  if (!supported()) return
@@ -128,10 +149,14 @@ export default class extends Controller {
128
149
  })
129
150
  }
130
151
 
131
- // Executes one of this instance's declared tools by its full registered
132
- // name (`poetry.{instance}.{tool}`) or its bare tool name, with the same
133
- // validation and dispatch a WebMCP call takes; unknown names answer with
134
- // an error string like any other problem.
152
+ /**
153
+ * Runs one of this instance's tools by its registered or short name; an
154
+ * unknown name resolves to an error message.
155
+ *
156
+ * @param {string} name the registered or short tool name
157
+ * @param {Object} args the tool's arguments
158
+ * @returns {Promise<*>} the tool's result, or an error message for an unknown name
159
+ */
135
160
  execute(name, args = {}) {
136
161
  const tool = this.toolsValue.find((candidate) =>
137
162
  `poetry.${this.nameValue}.${candidate.name}` === name || candidate.name === name)
@@ -139,7 +164,9 @@ export default class extends Controller {
139
164
  return this.#execute(tool, args ?? {})
140
165
  }
141
166
 
142
- // Aborts every registration of this instance.
167
+ /**
168
+ * Aborts every registration of this instance and announces it.
169
+ */
143
170
  unregister() {
144
171
  const entry = registrations.get(this.element)
145
172
  if (!entry) return
@@ -231,9 +258,16 @@ const serializable = (value) => {
231
258
  }
232
259
  }
233
260
 
234
- // Executes a declared tool by its full registered name on whichever
235
- // connected root declares it - the in-page dispatch path (no
236
- // modelContext needed). Answers an error string when no root does.
261
+ /**
262
+ * Executes a declared tool by its full registered name on whichever
263
+ * connected root declares it - the in-page dispatch path (no
264
+ * modelContext needed). Answers an error string when no root does.
265
+ *
266
+ * @param {Object} application the Stimulus application
267
+ * @param {string} name the full registered tool name
268
+ * @param {Object} [args] the tool's arguments
269
+ * @returns {Promise<*>} the tool's result, or an error string when no root declares it
270
+ */
237
271
  export const executeRegisteredTool = (application, name, args = {}) => {
238
272
  for (const [element, controller] of instances) {
239
273
  const owns = controller.toolsValue.some((tool) => `poetry.${controller.nameValue}.${tool.name}` === name)
@@ -244,5 +278,7 @@ export const executeRegisteredTool = (application, name, args = {}) => {
244
278
  return Promise.resolve(`Error: no registered tool named ${name} on this page`)
245
279
  }
246
280
 
247
- // Test seam: the live registration table.
281
+ /**
282
+ * Test seam: the live registration table.
283
+ */
248
284
  export const _registrations = registrations