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
@@ -0,0 +1,248 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { supported, registerTool, validToolName } from "@poetry/agent/adapter"
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
+ // element -> { hash, controller: AbortController, names: string[] }
34
+ const registrations = new Map()
35
+
36
+ // element -> the registrar instance, for every connected root whether or
37
+ // not the browser exposes modelContext: in-page callers (the AG-UI
38
+ // client-tool bridge) execute a declared tool by its registered name.
39
+ const instances = new Map()
40
+
41
+ const registeredCount = () =>
42
+ [...registrations.values()].reduce((sum, entry) => sum + entry.names.length, 0)
43
+
44
+ export default class extends Controller {
45
+ static values = {
46
+ name: String,
47
+ tools: Array,
48
+ budget: { type: Number, default: 20 }
49
+ }
50
+
51
+ static events = [
52
+ "poetry:webmcp:registered",
53
+ "poetry:webmcp:executed",
54
+ "poetry:webmcp:unregistered"
55
+ ]
56
+
57
+ connect() {
58
+ instances.set(this.element, this)
59
+ this.register()
60
+ }
61
+
62
+ disconnect() {
63
+ instances.delete(this.element)
64
+ this.unregister()
65
+ }
66
+
67
+ nameValueChanged() {
68
+ if (this.#connected) this.register()
69
+ }
70
+
71
+ toolsValueChanged() {
72
+ if (this.#connected) this.register()
73
+ }
74
+
75
+ // Registers this instance's tools; idempotent for an unchanged payload.
76
+ register() {
77
+ this.#connected = true
78
+ if (!supported()) return
79
+ if (document.documentElement.hasAttribute("data-turbo-preview")) return
80
+ if (!this.nameValue || !validToolName(this.nameValue)) {
81
+ console.warn(`[poetry-agent] webmcp: invalid instance name ${JSON.stringify(this.nameValue)}`)
82
+ return
83
+ }
84
+
85
+ const hash = JSON.stringify([this.nameValue, this.toolsValue])
86
+ const existing = registrations.get(this.element)
87
+ if (existing?.hash === hash) return
88
+ if (existing) this.unregister()
89
+
90
+ const controller = new AbortController()
91
+ const entry = { hash, controller, names: [] }
92
+ registrations.set(this.element, entry)
93
+ const pending = []
94
+
95
+ for (const tool of this.toolsValue) {
96
+ const name = `poetry.${this.nameValue}.${tool.name}`
97
+ if (!validToolName(name)) {
98
+ console.warn(`[poetry-agent] webmcp: skipping invalid tool name ${name}`)
99
+ continue
100
+ }
101
+ if (registeredCount() >= this.budgetValue) {
102
+ console.warn(`[poetry-agent] webmcp: registration budget (${this.budgetValue}) reached; skipping ${name}`)
103
+ break
104
+ }
105
+
106
+ const definition = {
107
+ name,
108
+ description: tool.description,
109
+ annotations: tool.annotations,
110
+ execute: (args) => this.#execute(tool, args ?? {})
111
+ }
112
+ if (tool.title) definition.title = tool.title
113
+ if (tool.inputSchema) definition.inputSchema = tool.inputSchema
114
+
115
+ entry.names.push(name)
116
+ pending.push(registerTool(definition, { signal: controller.signal }).catch((error) => {
117
+ entry.names = entry.names.filter((registered) => registered !== name)
118
+ console.warn(`[poetry-agent] webmcp: could not register ${name}: ${error?.message ?? error}`)
119
+ }))
120
+ }
121
+
122
+ // The registered event carries what the browser ACCEPTED, so it fires
123
+ // once every registration settled (and not at all if this instance
124
+ // unregistered meanwhile).
125
+ Promise.allSettled(pending).then(() => {
126
+ if (registrations.get(this.element) !== entry) return
127
+ this.dispatch("registered", { prefix: "poetry:webmcp", detail: { name: this.nameValue, tools: [...entry.names] } })
128
+ })
129
+ }
130
+
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.
135
+ execute(name, args = {}) {
136
+ const tool = this.toolsValue.find((candidate) =>
137
+ `poetry.${this.nameValue}.${candidate.name}` === name || candidate.name === name)
138
+ if (!tool) return Promise.resolve(`Error: no tool named ${name} on ${this.nameValue}`)
139
+ return this.#execute(tool, args ?? {})
140
+ }
141
+
142
+ // Aborts every registration of this instance.
143
+ unregister() {
144
+ const entry = registrations.get(this.element)
145
+ if (!entry) return
146
+
147
+ registrations.delete(this.element)
148
+ entry.controller.abort()
149
+ this.dispatch("unregistered", { prefix: "poetry:webmcp", detail: { name: this.nameValue, tools: entry.names } })
150
+ }
151
+
152
+ async #execute(tool, args) {
153
+ const [identifier, method] = String(tool.executes).split("#")
154
+ const target = this.application.getControllerForElementAndIdentifier(this.element, identifier)
155
+ if (!target || typeof target[method] !== "function") {
156
+ return `Error: ${tool.name} cannot run - no ${identifier}#${method} on this element`
157
+ }
158
+
159
+ const problem = validate(tool, args)
160
+ if (problem) return problem
161
+
162
+ try {
163
+ const positional = Object.keys(tool.inputSchema?.properties ?? {}).map((key) => args[key])
164
+ const result = await target[method](...positional)
165
+ const value = serializable(result) ? result : `${tool.name}: done`
166
+ this.dispatch("executed", { prefix: "poetry:webmcp", detail: { tool: tool.name, args, result: value } })
167
+ return value ?? `${tool.name}: done`
168
+ } catch (error) {
169
+ return `Error: ${tool.name} failed - ${error?.message ?? error}`
170
+ }
171
+ }
172
+
173
+ #connected = false
174
+ }
175
+
176
+ // Strict validation in code, loose in schema (Chrome's rule): the schema
177
+ // is a hint the agent may miss, so every call is checked here and answered
178
+ // with a string it can act on - which parameter, what it takes.
179
+ const validate = (tool, args) => {
180
+ const schema = tool.inputSchema ?? {}
181
+ const properties = schema.properties ?? {}
182
+ const missing = (schema.required ?? []).filter((key) => args[key] === undefined)
183
+ if (missing.length > 0) return `Error: missing required parameter(s) ${missing.join(", ")}`
184
+
185
+ if (schema.additionalProperties === false) {
186
+ const unknown = Object.keys(args).filter((key) => !(key in properties))
187
+ if (unknown.length > 0) {
188
+ const takes = Object.keys(properties).join(", ") || "no parameters"
189
+ return `Error: unknown parameter(s) ${unknown.join(", ")} - ${tool.name} takes ${takes}`
190
+ }
191
+ }
192
+
193
+ for (const [key, spec] of Object.entries(properties)) {
194
+ const value = args[key]
195
+ if (value === undefined) continue
196
+ if (!matchesType(value, spec.type)) return `Error: ${key} must be ${describeType(spec.type)}`
197
+ if (spec.enum && !spec.enum.includes(value)) return `Error: ${key} must be one of ${spec.enum.join(", ")}`
198
+ }
199
+ return null
200
+ }
201
+
202
+ const matchesType = (value, type) => {
203
+ if (!type) return true
204
+ const types = Array.isArray(type) ? type : [type]
205
+ return types.some((expected) => {
206
+ switch (expected) {
207
+ case "string": return typeof value === "string"
208
+ case "number": return typeof value === "number" && Number.isFinite(value)
209
+ case "integer": return Number.isInteger(value)
210
+ case "boolean": return typeof value === "boolean"
211
+ case "array": return Array.isArray(value)
212
+ case "object": return value !== null && typeof value === "object" && !Array.isArray(value)
213
+ case "null": return value === null
214
+ default: return true
215
+ }
216
+ })
217
+ }
218
+
219
+ const describeType = (type) => (Array.isArray(type) ? type.join(" or ") : `${/^[aeiou]/.test(type) ? "an" : "a"} ${type}`)
220
+
221
+ // A tool result must survive JSON serialization (the spec stringifies it);
222
+ // DOM objects and undefined collapse to a done-marker instead.
223
+ const serializable = (value) => {
224
+ if (value === undefined || value === null) return false
225
+ if (typeof value === "object" && (value instanceof Node || value instanceof Event)) return false
226
+ try {
227
+ JSON.stringify(value)
228
+ return true
229
+ } catch {
230
+ return false
231
+ }
232
+ }
233
+
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.
237
+ export const executeRegisteredTool = (application, name, args = {}) => {
238
+ for (const [element, controller] of instances) {
239
+ const owns = controller.toolsValue.some((tool) => `poetry.${controller.nameValue}.${tool.name}` === name)
240
+ if (owns && application.getControllerForElementAndIdentifier(element, "poetry--agent--webmcp") === controller) {
241
+ return controller.execute(name, args)
242
+ }
243
+ }
244
+ return Promise.resolve(`Error: no registered tool named ${name} on this page`)
245
+ }
246
+
247
+ // Test seam: the live registration table.
248
+ export const _registrations = registrations
@@ -0,0 +1,109 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The declarative-form companion: a form declared with poetry_webmcp_form
4
+ // (toolname/tooldescription on the <form>) is registered by the BROWSER;
5
+ // this controller only answers an agent-invoked submit with the outcome.
6
+ // Chrome's SubmitEvent carries agentInvoked + respondWith(promise): we
7
+ // submit the form ourselves (fetch, same method/action, Turbo-Stream
8
+ // accepting) and respond with a short descriptive result instead of
9
+ // navigating, so the agent learns whether the submission succeeded and
10
+ // what the server said (validation errors included - it can self-correct).
11
+ //
12
+ // The person's page then catches up with the answer (a beat after the
13
+ // result is handed to the browser, so a navigation can never swallow
14
+ // it): a GET answer is the page at that URL - a Turbo visit, or a plain
15
+ // navigation without Turbo; a Turbo-Stream answer renders; a redirected
16
+ // POST (redirect-after-create) visits where the redirect went. An HTML
17
+ // re-render of a failed POST stays put - the agent already holds the
18
+ // errors, and the person keeps their filled form.
19
+ //
20
+ // Submits without agentInvoked (a person pressed Submit) pass through
21
+ // untouched: the human path stays the human path.
22
+ export default class extends Controller {
23
+ static events = ["poetry:webmcp:form-submitted"]
24
+
25
+ connect() {
26
+ this.element.addEventListener("submit", this.submit)
27
+ }
28
+
29
+ disconnect() {
30
+ this.element.removeEventListener("submit", this.submit)
31
+ }
32
+
33
+ submit = (event) => {
34
+ if (!event.agentInvoked || typeof event.respondWith !== "function") return
35
+
36
+ event.preventDefault()
37
+ event.respondWith(this.#deliver())
38
+ }
39
+
40
+ async #deliver() {
41
+ const form = this.element
42
+ const method = (form.getAttribute("method") || "get").toUpperCase()
43
+ const data = new FormData(form)
44
+ let url = form.action
45
+ const init = { method, headers: { Accept: "text/vnd.turbo-stream.html, text/html, application/json" } }
46
+
47
+ if (method === "GET") {
48
+ const target = new URL(url, document.baseURI)
49
+ for (const [key, value] of data.entries()) target.searchParams.append(key, value)
50
+ url = target.toString()
51
+ } else {
52
+ init.body = data
53
+ }
54
+
55
+ try {
56
+ const response = await fetch(url, init)
57
+ const text = await response.text()
58
+ const outcome = response.ok ? "succeeded" : "failed"
59
+ const summary = `${form.getAttribute("toolname")} ${outcome} (${response.status})${excerpt(text, response.headers.get("content-type") || "")}`
60
+ this.dispatch("form-submitted", { prefix: "poetry:webmcp", detail: { status: response.status, ok: response.ok } })
61
+ setTimeout(() => reflect(method, url, response, text), 0)
62
+ return summary
63
+ } catch (error) {
64
+ return `${form.getAttribute("toolname")} failed - ${error?.message ?? error}`
65
+ }
66
+ }
67
+ }
68
+
69
+ // The page shows what the agent read.
70
+ const reflect = (method, url, response, text) => {
71
+ const turbo = window.Turbo
72
+ const type = response.headers.get("content-type") || ""
73
+
74
+ if (type.includes("text/vnd.turbo-stream.html")) {
75
+ turbo?.renderStreamMessage?.(text)
76
+ } else if (method === "GET") {
77
+ if (turbo?.visit) turbo.visit(url, { action: "replace" })
78
+ else window.location.assign(url)
79
+ } else if (response.redirected && response.url) {
80
+ if (turbo?.visit) turbo.visit(response.url)
81
+ else window.location.assign(response.url)
82
+ }
83
+ }
84
+
85
+ // A short excerpt of the response for the agent. HTML answers are parsed:
86
+ // scripts, styles, and chrome are dropped, and a region the app marks
87
+ // `data-webmcp-result` wins over <main>, which wins over the body - so a
88
+ // search page answers with its results, not its navigation.
89
+ const excerpt = (text, contentType = "") => {
90
+ const plain = contentType.includes("html") || /^\s*</.test(text) ? htmlText(text) : text.replace(/\s+/g, " ").trim()
91
+ return plain ? `: ${plain.slice(0, 500)}` : ""
92
+ }
93
+
94
+ // Declarative tool attributes never reach an inert document: Chrome 151
95
+ // crashes the renderer when a DOMParser (or createHTMLDocument) document
96
+ // holds a <form toolname> - the answer page usually IS the page with the
97
+ // form - so they are stripped from the text before parsing. Turbo's own
98
+ // visit parse adopts the form into the live document and is unaffected.
99
+ export const stripToolAttributes = (html) =>
100
+ html.replace(/\stool(?:name|description|autosubmit|paramdescription)(?:=(?:"[^"]*"|'[^']*'|[^\s>]*))?/gi, "")
101
+
102
+ const htmlText = (html) => {
103
+ const doc = new DOMParser().parseFromString(stripToolAttributes(html), "text/html")
104
+ for (const node of doc.querySelectorAll("script, style, noscript, template, nav, header, footer, aside")) node.remove()
105
+ const region = doc.querySelector("[data-webmcp-result]") || doc.querySelector("main") || doc.body
106
+ // Element boundaries become spaces (textContent runs adjacent items together).
107
+ const spaced = new DOMParser().parseFromString((region?.innerHTML || "").replace(/<[^>]+>/g, " "), "text/html")
108
+ return (spaced.body.textContent || "").replace(/\s+/g, " ").trim()
109
+ }
@@ -0,0 +1,82 @@
1
+ {
2
+ "poetry--agent--webmcp": {
3
+ "targets": [],
4
+ "values": {
5
+ "name": {
6
+ "type": "String"
7
+ },
8
+ "tools": {
9
+ "type": "Array"
10
+ },
11
+ "budget": {
12
+ "type": "Number",
13
+ "default": 20
14
+ }
15
+ },
16
+ "classes": [],
17
+ "methods": [
18
+ "connect",
19
+ "disconnect",
20
+ "execute",
21
+ "register",
22
+ "unregister"
23
+ ],
24
+ "events": [
25
+ "poetry:webmcp:executed",
26
+ "poetry:webmcp:registered",
27
+ "poetry:webmcp:unregistered"
28
+ ]
29
+ },
30
+ "poetry--agent--webmcp-form": {
31
+ "targets": [],
32
+ "values": {},
33
+ "classes": [],
34
+ "methods": [
35
+ "connect",
36
+ "disconnect"
37
+ ],
38
+ "events": [
39
+ "poetry:webmcp:form-submitted"
40
+ ]
41
+ },
42
+ "poetry--agent--agui-client-tool": {
43
+ "targets": [],
44
+ "values": {
45
+ "call": {
46
+ "type": "Object"
47
+ },
48
+ "url": {
49
+ "type": "String"
50
+ },
51
+ "done": {
52
+ "type": "Boolean"
53
+ }
54
+ },
55
+ "classes": [],
56
+ "methods": [
57
+ "connect"
58
+ ],
59
+ "events": [
60
+ "poetry:agui:client-tool-executed"
61
+ ]
62
+ },
63
+ "poetry--agent--a2ui-surface": {
64
+ "targets": [],
65
+ "values": {
66
+ "program": {
67
+ "type": "Object"
68
+ }
69
+ },
70
+ "classes": [],
71
+ "methods": [
72
+ "apply",
73
+ "call",
74
+ "connect",
75
+ "evaluate",
76
+ "failure",
77
+ "read",
78
+ "resolve"
79
+ ],
80
+ "events": []
81
+ }
82
+ }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # poetry-agent's importmap pins (the importmap-first channel): the host
4
+ # app's importmap merges these, so `import { registerPoetryAgent } from
5
+ # "@poetry/agent"` works with zero build. Bundler hosts use the
6
+ # @poetry/agent npm package instead - one source, two channels.
7
+
8
+ pin "@poetry/agent", to: "poetry/agent/index.js"
9
+ pin_all_from File.expand_path("../app/javascript/poetry/agent", __dir__),
10
+ under: "@poetry/agent", to: "poetry/agent"
data/exe/poetry-agent ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # poetry-agent: the poetry MCP server - newline-delimited JSON-RPC 2.0
5
+ # over stdio, serving list_components / describe_component / check /
6
+ # compose / build_page / get_skill from the committed registry. Boot-free:
7
+ # the gems load once per agent session (~0.4s); the Rails app never boots,
8
+ # so every call after that is milliseconds.
9
+ #
10
+ # Usage: poetry-agent [registry-root]
11
+ # registry-root defaults to the bundled poetry-ui gem. In .mcp.json:
12
+ # {"mcpServers": {"poetry": {"command": "bundle", "args": ["exec", "poetry-agent"]}}}
13
+ #
14
+ # One gem ships this exe (poetry-agent), so the surface is the same
15
+ # wherever the binstub resolves - the old core/ui exe-parity doctrine is
16
+ # retired by construction.
17
+
18
+ require "poetry/agent"
19
+
20
+ # One assembly for the exe and the HTTP mount (Poetry::Agent::MCP::Bundled):
21
+ # the registry root defaults to the bundled poetry-ui gem; skills, live
22
+ # helper names, recipes, and icon names ride along when their gems are
23
+ # bundled; app_root (Dir.pwd) is the app the agent runs in.
24
+ begin
25
+ Poetry::Agent::MCP::Bundled.server(root: ARGV[0], app_root: Dir.pwd).serve
26
+ rescue ArgumentError => e
27
+ abort "poetry-agent: #{e.message}"
28
+ end