poetry-agent 0.0.2

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +51 -0
  5. data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
  6. data/app/javascript/poetry/agent/adapter.js +77 -0
  7. data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
  8. data/app/javascript/poetry/agent/index.js +41 -0
  9. data/app/javascript/poetry/agent/stream_actions.js +65 -0
  10. data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
  11. data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
  12. data/config/controllers_manifest.json +82 -0
  13. data/config/importmap.rb +10 -0
  14. data/exe/poetry-agent +28 -0
  15. data/lib/poetry/agent/a2ui/catalog.rb +289 -0
  16. data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
  17. data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
  18. data/lib/poetry/agent/a2ui/checks.rb +45 -0
  19. data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
  20. data/lib/poetry/agent/a2ui/expression.rb +175 -0
  21. data/lib/poetry/agent/a2ui/functions.rb +417 -0
  22. data/lib/poetry/agent/a2ui/markdown.rb +63 -0
  23. data/lib/poetry/agent/a2ui/pointer.rb +113 -0
  24. data/lib/poetry/agent/a2ui/protocol.rb +12 -0
  25. data/lib/poetry/agent/a2ui/renderer.rb +242 -0
  26. data/lib/poetry/agent/a2ui/session.rb +302 -0
  27. data/lib/poetry/agent/a2ui/streams.rb +82 -0
  28. data/lib/poetry/agent/a2ui/surface.rb +352 -0
  29. data/lib/poetry/agent/a2ui.rb +48 -0
  30. data/lib/poetry/agent/agui/client.rb +69 -0
  31. data/lib/poetry/agent/agui/json_patch.rb +137 -0
  32. data/lib/poetry/agent/agui/relay.rb +105 -0
  33. data/lib/poetry/agent/agui/run_input.rb +83 -0
  34. data/lib/poetry/agent/agui/sse.rb +97 -0
  35. data/lib/poetry/agent/agui/transcript.rb +540 -0
  36. data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
  37. data/lib/poetry/agent/agui.rb +87 -0
  38. data/lib/poetry/agent/config.rb +49 -0
  39. data/lib/poetry/agent/engine.rb +37 -0
  40. data/lib/poetry/agent/mcp/bundled.rb +54 -0
  41. data/lib/poetry/agent/mcp/http.rb +89 -0
  42. data/lib/poetry/agent/mcp/server.rb +962 -0
  43. data/lib/poetry/agent/version.rb +8 -0
  44. data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
  45. data/lib/poetry/agent/webmcp.rb +37 -0
  46. data/lib/poetry/agent.rb +66 -0
  47. data/lib/poetry-agent.rb +4 -0
  48. metadata +117 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 91f531c6479141170644c48162b3a67b82936fdc0a00ea2ae07deb620459e29c
4
+ data.tar.gz: c8cfb8421e10c1247f4e6e566288dec8fb70d4721653d5a88dac8ec07f3f14d1
5
+ SHA512:
6
+ metadata.gz: 27a86f9ea8064c7e901d9b9bd43e33e7d76f1d4fd9b0ca90b56a46798336c32574e78b02c510683f871cc9d0631eac9ec9c63d9aad1eb64d33a77d546ae9a804
7
+ data.tar.gz: 7f9c50d5b9acf878da099a80120268ef387114d0b6f85a48486362a1263434fc0aaf1195350867823703bfd65d0f6757df98089f310050ef02334809ff03cfc6
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Matt Solt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # poetry-agent
2
+
3
+ The agent-interop gem of the [poetry](https://github.com/roboruby/poetry) component library: every surface through which an agent reaches the component contract, projected from the one registry the other gems build.
4
+
5
+ Five surfaces ship:
6
+
7
+ - **The MCP server** — `bundle exec poetry-agent`, a boot-free, read-only stdio MCP server (JSON-RPC 2.0, no SDK dependency) for coding agents in Claude Code, Cursor, VS Code, Zed, and RubyMine: `compose`, `build_page`, `list_components`, `describe_component`, `check`, `list_blocks`, `describe_block`, `list_recipes`, `get_skill`, `guidance`. The same server mounts over HTTP at a same-origin path (`mount Poetry::Agent::MCP::HTTP.new => "/mcp"`) for in-page bridges.
8
+ - **The WebMCP runtime** — `registerPoetryAgent(application)` registers a rendered component's declared tools with the browser's `document.modelContext` when a call opts in (`poetry_tabs(webmcp: "sections")`), dispatching each call to the component's own Stimulus action; declarative forms (`poetry_webmcp_form`) answer agent-invoked submits through `SubmitEvent.respondWith`; an `Origin-Trial` middleware serves trial tokens.
9
+ - **The AG-UI relay** — `Poetry::Agent::AGUI`: a Rails-side client of the Agent-User Interaction protocol. `Client` runs an agent endpoint and yields its events, `Transcript` folds them into chat-shaped messages (text, reasoning, tool calls with state, activities), shared state (JSON Patch), interrupts, and the tool calls the browser must execute, and `Relay` renders every change as a versioned Turbo Stream through the host's own row partial. A rendered component's declared tools are advertised as the agent's frontend tools (`Poetry::Agent::AGUI.tool_descriptor`) and executed by the `poetry--agent--agui-client-tool` bridge through the same registrar WebMCP uses, with or without a WebMCP browser.
10
+ - **The A2UI catalog** — `Poetry::Agent::A2UI::Catalog.from_registry(root)` projects the component registry into an A2UI v1.0 catalog document (JSON Schema per component: style axes as enums, options as typed properties, slots as child references, the content block as `text` or `children`), so any A2UI agent generates against Poetry's vocabulary and a renderer validates against the same document.
11
+ - **The A2UI renderer** — `Poetry::Agent::A2UI::Session` folds the A2UI envelope (`createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`) into surfaces and answers what it cannot honor with the spec's renderer-to-agent errors; `Renderer` renders a surface through the host's view context with a catalog binding — the spec's basic catalog mapped onto Poetry's components (`Catalogs::Basic`) or Poetry's own catalog rendered straight from the registry (`Catalogs::Native`); `Streams` delivers every change as a versioned Turbo Stream. A surface renders as a form: bound inputs are named by their data-model pointer, every agent action is a submit button, and `Session#action` turns the submitted form into the spec's `action` message (two-way binding syncs on the action, as the spec asks). The catalog's functions run on the server (`Functions.basic`: `formatString` interpolation through the `Expression` grammar, the formatters, the validators behind `checks`, the combinators, `openUrl`) and a failing check rejects the action with its failures rendered under their controls; the same rules run in the browser as the user types (`poetry--agent--a2ui-surface`), disabling a button whose checks fail. Every rendered component carries a render-stable key and updates arrive as morphing replaces, so typed text, a selected tab, and an open dialog survive the agent's next message. A function a catalog marks `agentOnly` or `rendererOrAgent` answers an agent's `callRendererFunction` with a response.
12
+
13
+ ## Install
14
+
15
+ ```ruby
16
+ gem "poetry-agent"
17
+ ```
18
+
19
+ ```js
20
+ // app/javascript/controllers/index.js
21
+ import { registerPoetryControllers } from "@poetry/controllers"
22
+ import { registerPoetryAgent } from "@poetry/agent"
23
+ registerPoetryControllers(application)
24
+ registerPoetryAgent(application)
25
+ ```
26
+
27
+ Loading the gem is the integration: the engine registers its controllers manifest with poetry-core (so `webmcp:` roots validate at render), pins `@poetry/agent` in the importmap, and mounts the origin-trial middleware.
28
+
29
+ ```ruby
30
+ # config/initializers/poetry_agent.rb
31
+ Poetry::Agent.configure do |config|
32
+ config.origin_trial_tokens = ENV.fetch("WEBMCP_ORIGIN_TRIAL_TOKENS", "").split(",")
33
+ config.registration_budget = 20
34
+ end
35
+ ```
36
+
37
+ ## Safety by construction
38
+
39
+ Nothing registers until a rendered instance opts in. Tools are read-only unless declared `mutating: true`. `autosubmit` is GET-only. Registrations are budgeted per document, never repeated for an unchanged payload, never made under Turbo's cache preview. Every call is validated in code (required, unknown, and mistyped parameters, enum membership) and problems return as descriptive strings so an agent corrects its call; results carry the resulting state. An agent-invoked form answers through `respondWith`, then the page catches up with the answer (a Turbo visit for a GET, a rendered stream, the redirect target of a POST).
40
+
41
+ ## Development
42
+
43
+ ```bash
44
+ bin/setup && bundle exec rake # tests, rubocop, yard gates
45
+ npm install && npm test # the runtime's JS tests (vitest + jsdom)
46
+ npm run manifest # regenerate config/controllers_manifest.json
47
+ ```
48
+
49
+ ## License
50
+
51
+ MIT.
@@ -0,0 +1,141 @@
1
+ import { Controller } from "@hotwired/stimulus"
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
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
12
+
13
+ export default class extends Controller {
14
+ static values = { program: Object }
15
+
16
+ connect() {
17
+ this.evaluate()
18
+ }
19
+
20
+ evaluate() {
21
+ const checks = this.programValue?.checks || {}
22
+ for (const [key, entry] of Object.entries(checks)) {
23
+ const failures = (entry.rules || []).map((rule) => this.failure(rule)).filter(Boolean)
24
+ this.apply(key, entry.kind, failures)
25
+ }
26
+ }
27
+
28
+ // The message of a failing rule, or null when it passes.
29
+ failure(rule) {
30
+ const result = this.resolve(rule.condition)
31
+ if (result === null) return null // unknown here; the server decides
32
+ const valid = result && typeof result === "object" ? result.valid === true : truthy(result)
33
+ if (valid) return null
34
+ return (result && typeof result === "object" && result.message) || rule.message || "Check failed"
35
+ }
36
+
37
+ apply(key, kind, failures) {
38
+ for (const element of this.element.querySelectorAll(`[data-a2ui-key="${escapeAttribute(key)}"]`)) {
39
+ if (kind === "button") {
40
+ element.disabled = failures.length > 0
41
+ } else {
42
+ element.setAttribute("aria-invalid", failures.length > 0 ? "true" : "false")
43
+ }
44
+ }
45
+ const slot = this.element.querySelector(`[data-a2ui-error-for="${escapeAttribute(key)}"]`)
46
+ if (slot) {
47
+ slot.textContent = failures[0] || ""
48
+ slot.hidden = failures.length === 0
49
+ }
50
+ }
51
+
52
+ resolve(value) {
53
+ if (value === null || typeof value !== "object") return value
54
+ if (Array.isArray(value)) return value.map((item) => this.resolve(item))
55
+ if (typeof value.path === "string") return this.read(value.path)
56
+ if (typeof value.call === "string") return this.call(value.call, value.args || {})
57
+ return value
58
+ }
59
+
60
+ call(name, rawArgs) {
61
+ const fn = FUNCTIONS[name]
62
+ if (!fn) return null
63
+ const args = {}
64
+ for (const [key, raw] of Object.entries(rawArgs)) args[key] = this.resolve(raw)
65
+ return fn(args)
66
+ }
67
+
68
+ // The current value of a bound path: the form control first, the
69
+ // server's model when no control carries it.
70
+ read(path) {
71
+ const kind = this.programValue?.inputs?.[path]
72
+ const name = `a2ui[values][${path}]`
73
+ if (kind === "boolean") {
74
+ const box = this.element.querySelector(`input[type="checkbox"][name="${escapeAttribute(name)}"]`)
75
+ return box ? box.checked : pointer(this.programValue?.model, path)
76
+ }
77
+ if (kind === "string_list") {
78
+ const boxes = [...this.element.querySelectorAll(`input[name="${escapeAttribute(name + "[]")}"]:checked`)].map((box) => box.value).filter((v) => v !== "")
79
+ if (boxes.length) return boxes
80
+ const picked = this.element.querySelector(`input[type="radio"][name="${escapeAttribute(name)}"]:checked`)
81
+ if (picked) return [picked.value]
82
+ const select = this.element.querySelector(`select[name="${escapeAttribute(name)}"], select[name="${escapeAttribute(name + "[]")}"]`)
83
+ if (select) return [...select.selectedOptions].map((option) => option.value).filter((v) => v !== "")
84
+ return this.element.querySelector(`[name="${escapeAttribute(name + "[]")}"], [name="${escapeAttribute(name)}"]`) ? [] : pointer(this.programValue?.model, path)
85
+ }
86
+ const control = this.element.querySelector(`[name="${escapeAttribute(name)}"]:not([type="hidden"])`)
87
+ if (!control) return pointer(this.programValue?.model, path)
88
+ if (kind === "number") return control.value === "" ? null : Number(control.value)
89
+ return control.value
90
+ }
91
+ }
92
+
93
+ // An attribute-selector value (jsdom has no CSS.escape; quoting is enough).
94
+ const escapeAttribute = (value) => String(value).replace(/["\\]/g, "\\$&")
95
+
96
+ const pointer = (document, path) => {
97
+ if (!document || path === "" || path === "/") return document
98
+ return path.replace(/^\//, "").split("/").reduce((node, token) => {
99
+ if (node === null || node === undefined) return undefined
100
+ const key = token.replace(/~1/g, "/").replace(/~0/g, "~")
101
+ return Array.isArray(node) ? node[Number(key)] : node[key]
102
+ }, document)
103
+ }
104
+
105
+ const truthy = (value) => {
106
+ if (value && typeof value === "object" && !Array.isArray(value)) return value.valid === true
107
+ if (typeof value === "string") return value !== "" && !["false", "0"].includes(value.toLowerCase())
108
+ return value !== null && value !== undefined && value !== false
109
+ }
110
+
111
+ const present = (value) => {
112
+ if (value === null || value === undefined) return false
113
+ if (typeof value === "string") return value.trim() !== ""
114
+ if (Array.isArray(value)) return value.length > 0
115
+ return true
116
+ }
117
+
118
+ const within = (n, min, max) => (min === undefined || min === null || n >= min) && (max === undefined || max === null || n <= max)
119
+
120
+ const number = (value) => {
121
+ if (typeof value === "number") return Number.isFinite(value) ? value : null
122
+ if (typeof value !== "string" || value.trim() === "") return null
123
+ const parsed = Number(value)
124
+ return Number.isFinite(parsed) ? parsed : null
125
+ }
126
+
127
+ const FUNCTIONS = {
128
+ required: ({ value }) => ({ valid: present(value) }),
129
+ regex: ({ value, pattern }) => {
130
+ try { return { valid: new RegExp(pattern).test(String(value ?? "")) } } catch { return null }
131
+ },
132
+ length: ({ value, min, max }) => ({ valid: within(String(value ?? "").length, min, max) }),
133
+ numeric: ({ value, min, max }) => {
134
+ const n = number(value)
135
+ return { valid: n !== null && within(n, min, max) }
136
+ },
137
+ email: ({ value }) => ({ valid: EMAIL.test(String(value ?? "")) }),
138
+ and: ({ values }) => (Array.isArray(values) ? values : []).every(truthy),
139
+ or: ({ values }) => (Array.isArray(values) ? values : []).some(truthy),
140
+ not: ({ value }) => !truthy(value)
141
+ }
@@ -0,0 +1,77 @@
1
+ // The document.modelContext adapter - the ONE file that knows the WebMCP
2
+ // surface's shape, so API churn (the spec has changed twice this summer,
3
+ // and the shipping browser trails it) stays a one-file fix. Everything
4
+ // else in the runtime talks to this.
5
+ //
6
+ // Spec surface (rev 41d12f0): document.modelContext with
7
+ // registerTool(tool, {signal, exposedTo}), getTools({fromOrigins}) resolving
8
+ // RegisteredTool dictionaries whose inputSchema is an object,
9
+ // executeTool(tool, inputObject, {signal}) resolving the stringified
10
+ // result, and the toolchange event.
11
+ //
12
+ // Shipping-browser deltas this file absorbs (Chrome 151, measured
13
+ // 2026-09-01; spec issue #278 tracks them): getTools() reports inputSchema
14
+ // as a serialized JSON string, and executeTool() parses ONLY a JSON-string
15
+ // argument - an object rejects with UnknownError("Failed to parse input
16
+ // arguments"), not a TypeError.
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.
20
+ export const modelContext = () =>
21
+ (typeof document !== "undefined" && document.modelContext) || null
22
+
23
+ export const supported = () => modelContext() !== null
24
+
25
+ // Registers one tool; resolves when the browser accepted it, rejects on a
26
+ // duplicate name, an empty name/description, or an invalid schema.
27
+ export const registerTool = (definition, { signal } = {}) =>
28
+ modelContext().registerTool(definition, signal ? { signal } : {})
29
+
30
+ // A serialized schema marks the string-argument build; remembering it lets
31
+ // executeTool go string-first without a wasted rejection.
32
+ let stringArguments = false
33
+
34
+ // The registered tools with inputSchema normalized to an object: parsed
35
+ // when the browser serialized it, null when the text is not JSON.
36
+ export const getTools = async (options = {}) => {
37
+ const tools = await modelContext().getTools(options)
38
+ for (const tool of tools) {
39
+ if (typeof tool.inputSchema !== "string") continue
40
+ stringArguments = true
41
+ try {
42
+ tool.inputSchema = JSON.parse(tool.inputSchema)
43
+ } catch {
44
+ tool.inputSchema = null
45
+ }
46
+ }
47
+ return tools
48
+ }
49
+
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.
53
+ export const executeTool = async (tool, args = {}, options = {}) => {
54
+ const context = modelContext()
55
+ const asString = () => context.executeTool(tool, JSON.stringify(args), options)
56
+ if (stringArguments) return asString()
57
+ try {
58
+ return await context.executeTool(tool, args, options)
59
+ } catch (error) {
60
+ try {
61
+ const result = await asString()
62
+ stringArguments = true
63
+ return result
64
+ } catch {
65
+ throw error
66
+ }
67
+ }
68
+ }
69
+
70
+ // WebMCP tool-name grammar: 1-128 chars of ASCII alphanumerics, "_", "-", ".".
71
+ export const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/
72
+ export const validToolName = (name) => TOOL_NAME.test(name)
73
+
74
+ // Test seam: forget the argument shape the last browser taught us.
75
+ export const _resetArgumentShape = () => {
76
+ stringArguments = false
77
+ }
@@ -0,0 +1,53 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { executeRegisteredTool } from "@poetry/agent/webmcp_controller"
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.
12
+ export default class extends Controller {
13
+ static values = {
14
+ call: Object,
15
+ url: String,
16
+ done: Boolean
17
+ }
18
+
19
+ static events = ["poetry:agui:client-tool-executed"]
20
+
21
+ async connect() {
22
+ if (this.doneValue) return
23
+ this.doneValue = true
24
+
25
+ const { toolCallId, name, args } = this.callValue
26
+ let content
27
+ let error
28
+ try {
29
+ content = await executeRegisteredTool(this.application, name, args ?? {})
30
+ } catch (failure) {
31
+ error = failure?.message ?? String(failure)
32
+ }
33
+ if (typeof content === "string" && content.startsWith("Error:")) error = content
34
+ const text = typeof content === "string" ? content : JSON.stringify(content ?? null)
35
+
36
+ const body = { toolCallId, name, content: text }
37
+ if (error) body.error = error
38
+ this.dispatch("client-tool-executed", { prefix: "poetry:agui", detail: { ...body } })
39
+
40
+ const response = await fetch(this.urlValue, {
41
+ method: "POST",
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ Accept: "text/vnd.turbo-stream.html, text/html",
45
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content ?? ""
46
+ },
47
+ body: JSON.stringify(body)
48
+ })
49
+ const streams = await response.text()
50
+ const type = response.headers.get("content-type") || ""
51
+ if (response.ok && type.includes("text/vnd.turbo-stream.html")) globalThis.Turbo?.renderStreamMessage?.(streams)
52
+ }
53
+ }
@@ -0,0 +1,41 @@
1
+ // @poetry/agent - the WebMCP runtime. Register beside poetry's own
2
+ // controllers:
3
+ //
4
+ // import { Application } from "@hotwired/stimulus"
5
+ // import { registerPoetryControllers } from "@poetry/controllers"
6
+ // import { registerPoetryAgent } from "@poetry/agent"
7
+ // const application = Application.start()
8
+ // registerPoetryControllers(application)
9
+ // registerPoetryAgent(application)
10
+ import WebmcpController from "@poetry/agent/webmcp_controller"
11
+ import WebmcpFormController from "@poetry/agent/webmcp_form_controller"
12
+ import AguiClientToolController from "@poetry/agent/agui_client_tool_controller"
13
+ import A2uiSurfaceController from "@poetry/agent/a2ui_surface_controller"
14
+ import { installVersionedReplace, installMorphStateGuard } from "@poetry/agent/stream_actions"
15
+
16
+ export { default as WebmcpController } from "@poetry/agent/webmcp_controller"
17
+ export { default as WebmcpFormController } from "@poetry/agent/webmcp_form_controller"
18
+ export { default as AguiClientToolController } from "@poetry/agent/agui_client_tool_controller"
19
+ export { default as A2uiSurfaceController } from "@poetry/agent/a2ui_surface_controller"
20
+ export * from "@poetry/agent/adapter"
21
+ export { _registrations, executeRegisteredTool } from "@poetry/agent/webmcp_controller"
22
+ export { installVersionedReplace, installMorphStateGuard, preservesLocalState } from "@poetry/agent/stream_actions"
23
+
24
+ // identifier -> controller class (the manifest introspects this).
25
+ export const controllers = {
26
+ "poetry--agent--webmcp": WebmcpController,
27
+ "poetry--agent--webmcp-form": WebmcpFormController,
28
+ "poetry--agent--agui-client-tool": AguiClientToolController,
29
+ "poetry--agent--a2ui-surface": A2uiSurfaceController
30
+ }
31
+
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.
35
+ export const registerPoetryAgent = (application) => {
36
+ for (const [identifier, controller] of Object.entries(controllers)) {
37
+ application.register(identifier, controller)
38
+ }
39
+ installVersionedReplace()
40
+ installMorphStateGuard()
41
+ }
@@ -0,0 +1,65 @@
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.
10
+ export const installVersionedReplace = (turbo = globalThis.Turbo) => {
11
+ if (!turbo?.StreamActions || turbo.StreamActions.vreplace) return false
12
+
13
+ turbo.StreamActions.vreplace = function () {
14
+ const incoming = this.templateContent.firstElementChild
15
+ const version = Number(incoming?.dataset?.version || 0)
16
+ const targets = this.targetElements.filter((element) => version > Number(element.dataset.version || -1))
17
+ if (targets.length === 0) return
18
+
19
+ const method = typeof this.getAttribute === "function" ? this.getAttribute("method") : null
20
+ if (method === "morph" && typeof turbo.StreamActions.replace === "function") {
21
+ turbo.StreamActions.replace.call({ getAttribute: () => "morph", targetElements: targets, templateContent: this.templateContent })
22
+ } else {
23
+ targets.forEach((element) => element.replaceWith(this.templateContent.cloneNode(true)))
24
+ }
25
+ }
26
+ return true
27
+ }
28
+
29
+ // Local state a morph must not reset inside an A2UI surface: the
30
+ // selection a tab strip holds, a dialog's open state, a popup's expanded
31
+ // state, and a control the user has edited (its value or checked state
32
+ // differs from what the server last rendered). The server does not know
33
+ // this state, so its frame would carry the defaults; canceling Turbo's
34
+ // before-morph-attribute event keeps the page's own.
35
+ const LOCAL_STATE = {
36
+ "tabs-trigger": ["aria-selected", "tabindex", "data-active"],
37
+ "tabs-content": ["hidden", "data-hidden"],
38
+ "dialog-content": ["open", "data-closed"]
39
+ }
40
+ const EXPANDED = ["aria-expanded", "data-open", "data-state"]
41
+
42
+ export const preservesLocalState = (element, attributeName) => {
43
+ if (!element?.closest?.("[data-a2ui-surface]")) return false
44
+ if (attributeName === "value" || attributeName === "checked") return isDirty(element)
45
+ if (EXPANDED.includes(attributeName)) return true
46
+ return (LOCAL_STATE[element.dataset?.slot] || []).includes(attributeName)
47
+ }
48
+
49
+ const isDirty = (element) => {
50
+ if (element instanceof HTMLInputElement) {
51
+ if (element.type === "checkbox" || element.type === "radio") return element.checked !== element.defaultChecked
52
+ return element.value !== element.defaultValue
53
+ }
54
+ if (element instanceof HTMLTextAreaElement) return element.value !== element.defaultValue
55
+ return false
56
+ }
57
+
58
+ export const installMorphStateGuard = (doc = globalThis.document) => {
59
+ if (!doc || doc.__poetryA2uiMorphGuard) return false
60
+ doc.__poetryA2uiMorphGuard = true
61
+ doc.addEventListener("turbo:before-morph-attribute", (event) => {
62
+ if (preservesLocalState(event.target, event.detail?.attributeName)) event.preventDefault()
63
+ })
64
+ return true
65
+ }