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.
- checksums.yaml +4 -4
- data/.controller_docs +1 -0
- data/.jsdoc_floor +1 -0
- data/.mcp.json +8 -0
- data/.yard-lint.yml +25 -0
- data/.yard_coverage_all +1 -0
- data/Archspec.rb +15 -0
- data/CHANGELOG.md +16 -0
- data/app/javascript/poetry/agent/a2ui_surface_controller.js +56 -12
- data/app/javascript/poetry/agent/adapter.js +49 -11
- data/app/javascript/poetry/agent/agui_client_tool_controller.js +18 -8
- data/app/javascript/poetry/agent/index.js +10 -4
- data/app/javascript/poetry/agent/stream_actions.js +30 -9
- data/app/javascript/poetry/agent/webmcp_controller.js +75 -39
- data/app/javascript/poetry/agent/webmcp_form_controller.js +37 -24
- data/config/controllers_manifest.json +45 -11
- data/eslint.config.mjs +34 -0
- data/lib/poetry/agent/a2ui/catalog.rb +14 -0
- data/lib/poetry/agent/a2ui/catalogs/basic.rb +25 -0
- data/lib/poetry/agent/a2ui/catalogs/native.rb +12 -0
- data/lib/poetry/agent/a2ui/checks.rb +5 -1
- data/lib/poetry/agent/a2ui/evaluator.rb +6 -0
- data/lib/poetry/agent/a2ui/expression.rb +13 -0
- data/lib/poetry/agent/a2ui/functions.rb +26 -12
- data/lib/poetry/agent/a2ui/markdown.rb +6 -1
- data/lib/poetry/agent/a2ui/pointer.rb +5 -2
- data/lib/poetry/agent/a2ui/renderer.rb +18 -0
- data/lib/poetry/agent/a2ui/session.rb +23 -0
- data/lib/poetry/agent/a2ui/streams.rb +4 -0
- data/lib/poetry/agent/a2ui/surface.rb +22 -0
- data/lib/poetry/agent/agui/client.rb +6 -0
- data/lib/poetry/agent/agui/json_patch.rb +8 -1
- data/lib/poetry/agent/agui/relay.rb +4 -0
- data/lib/poetry/agent/agui/run_input.rb +1 -0
- data/lib/poetry/agent/agui/sse.rb +3 -0
- data/lib/poetry/agent/agui/transcript.rb +37 -0
- data/lib/poetry/agent/agui/turbo_stream.rb +6 -0
- data/lib/poetry/agent/config.rb +1 -0
- data/lib/poetry/agent/mcp/bundled.rb +4 -1
- data/lib/poetry/agent/mcp/http.rb +6 -0
- data/lib/poetry/agent/mcp/server.rb +132 -37
- data/lib/poetry/agent/version.rb +1 -1
- data/lib/poetry/agent/webmcp/origin_trial.rb +5 -0
- metadata +11 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6741c0fdec4ef51366a3c84788aa915e926783aff97b1ee65ef1a24536657c17
|
|
4
|
+
data.tar.gz: 3da36a6f201c54d3eb54e9c652bd28268503b7f935e022fcf56325bbdae7c827
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
data/.yard_coverage_all
ADDED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Test seam: the live registration table.
|
|
283
|
+
*/
|
|
248
284
|
export const _registrations = registrations
|