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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
- data/app/javascript/poetry/agent/adapter.js +77 -0
- data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
- data/app/javascript/poetry/agent/index.js +41 -0
- data/app/javascript/poetry/agent/stream_actions.js +65 -0
- data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
- data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
- data/config/controllers_manifest.json +82 -0
- data/config/importmap.rb +10 -0
- data/exe/poetry-agent +28 -0
- data/lib/poetry/agent/a2ui/catalog.rb +289 -0
- data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
- data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
- data/lib/poetry/agent/a2ui/checks.rb +45 -0
- data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
- data/lib/poetry/agent/a2ui/expression.rb +175 -0
- data/lib/poetry/agent/a2ui/functions.rb +417 -0
- data/lib/poetry/agent/a2ui/markdown.rb +63 -0
- data/lib/poetry/agent/a2ui/pointer.rb +113 -0
- data/lib/poetry/agent/a2ui/protocol.rb +12 -0
- data/lib/poetry/agent/a2ui/renderer.rb +242 -0
- data/lib/poetry/agent/a2ui/session.rb +302 -0
- data/lib/poetry/agent/a2ui/streams.rb +82 -0
- data/lib/poetry/agent/a2ui/surface.rb +352 -0
- data/lib/poetry/agent/a2ui.rb +48 -0
- data/lib/poetry/agent/agui/client.rb +69 -0
- data/lib/poetry/agent/agui/json_patch.rb +137 -0
- data/lib/poetry/agent/agui/relay.rb +105 -0
- data/lib/poetry/agent/agui/run_input.rb +83 -0
- data/lib/poetry/agent/agui/sse.rb +97 -0
- data/lib/poetry/agent/agui/transcript.rb +540 -0
- data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
- data/lib/poetry/agent/agui.rb +87 -0
- data/lib/poetry/agent/config.rb +49 -0
- data/lib/poetry/agent/engine.rb +37 -0
- data/lib/poetry/agent/mcp/bundled.rb +54 -0
- data/lib/poetry/agent/mcp/http.rb +89 -0
- data/lib/poetry/agent/mcp/server.rb +962 -0
- data/lib/poetry/agent/version.rb +8 -0
- data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
- data/lib/poetry/agent/webmcp.rb +37 -0
- data/lib/poetry/agent.rb +66 -0
- data/lib/poetry-agent.rb +4 -0
- 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
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
|
+
}
|