openadapt-agent 2.0.0__py3-none-any.whl

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.
@@ -0,0 +1,464 @@
1
+ """Transport-agnostic bridge: tool specs + dispatch over discovered bundles.
2
+
3
+ Kept free of ``mcp`` imports so the tool surface (schema generation,
4
+ gating, outcome mapping) is unit-testable without an MCP transport. The
5
+ thin MCP wiring lives in :mod:`openadapt_agent.mcp`.
6
+
7
+ Safety model (documented in ``docs/DESIGN.md``):
8
+
9
+ - PHI-safe read-only tools (workflow inspection, run summaries, and Needs
10
+ Attention projections) are always registered.
11
+ - ``run_<opaque-id>`` tools are registered ONLY when the operator started the
12
+ server with ``--allow-run``; even then, every call shells out to the
13
+ governed ``openadapt-flow run`` CLI, so flow's fail-closed admission
14
+ gates cannot be bypassed from here.
15
+ - Needs Attention projections are read-only and PHI-safe. Attended mutations
16
+ are registered only under ``--allow-attended-actions`` and are submitted to
17
+ Flow's signed capability/idempotency/audit contract.
18
+ - Remote authentication is outside this local stdio process. The local OS
19
+ user is the operator of record for attended decisions.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any, Optional
29
+
30
+ from openadapt_agent.attended import (
31
+ ATTENDED_TOOLS,
32
+ AttendedBridge,
33
+ AttendedBridgeError,
34
+ action_input_schema,
35
+ )
36
+ from openadapt_agent.bundles import (
37
+ WorkflowInfo,
38
+ discover_bundles,
39
+ tool_input_schema,
40
+ )
41
+ from openadapt_agent.runner import (
42
+ FlowRunner,
43
+ RunnerConfig,
44
+ is_safe_run_id,
45
+ public_report_summary,
46
+ )
47
+
48
+ __all__ = ["AgentBridge", "BridgeError", "ToolSpec"]
49
+
50
+ _LOG = logging.getLogger(__name__)
51
+
52
+
53
+ class BridgeError(Exception):
54
+ """A tool-level error the transport should surface to the caller."""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ToolSpec:
59
+ name: str
60
+ description: str
61
+ input_schema: dict
62
+ annotations: Optional[dict[str, Any]] = None
63
+
64
+
65
+ _READ_ONLY_ANNOTATIONS = {
66
+ "readOnlyHint": True,
67
+ "destructiveHint": False,
68
+ "idempotentHint": True,
69
+ "openWorldHint": False,
70
+ }
71
+ _RUN_ANNOTATIONS = {
72
+ "readOnlyHint": False,
73
+ "destructiveHint": True,
74
+ "idempotentHint": False,
75
+ "openWorldHint": True,
76
+ }
77
+
78
+
79
+ class AgentBridge:
80
+ """Expose a directory of compiled workflow bundles as agent tools."""
81
+
82
+ def __init__(
83
+ self,
84
+ bundles_dir: Path,
85
+ runner_config: RunnerConfig,
86
+ *,
87
+ allow_run: bool = False,
88
+ runner: Optional[FlowRunner] = None,
89
+ allow_attended_actions: bool = False,
90
+ attended_service: Optional[object] = None,
91
+ attended: Optional[AttendedBridge] = None,
92
+ allow_protected_export: bool = False,
93
+ allow_recorded_defaults: bool = False,
94
+ ):
95
+ self.bundles_dir = Path(bundles_dir)
96
+ self.allow_run = allow_run
97
+ self.allow_protected_export = allow_protected_export
98
+ self.allow_recorded_defaults = allow_recorded_defaults
99
+ self.runner_config = runner_config
100
+ self.runner = runner or FlowRunner(runner_config)
101
+ self.attended = attended or AttendedBridge(
102
+ runner_config.runs_dir,
103
+ allow_actions=allow_attended_actions,
104
+ service=attended_service,
105
+ )
106
+ self.workflows: dict[str, WorkflowInfo] = {}
107
+ for info in discover_bundles(self.bundles_dir):
108
+ base_id = info.public_id
109
+ workflow_id = base_id
110
+ suffix = 2
111
+ while workflow_id in self.workflows:
112
+ workflow_id = f"{base_id}_{suffix}"
113
+ suffix += 1
114
+ self.workflows[workflow_id] = info
115
+
116
+ # -- tool surface ------------------------------------------------------
117
+
118
+ def list_tool_specs(self) -> list[ToolSpec]:
119
+ specs = [
120
+ ToolSpec(
121
+ name="list_workflows",
122
+ description=(
123
+ "List workflows by opaque id with parameter names/types, "
124
+ "step count, availability, and whether run tools are "
125
+ "enabled. Demonstration labels, values, intents, paths, "
126
+ "and load exceptions stay local by default."
127
+ ),
128
+ input_schema={
129
+ "type": "object",
130
+ "properties": {},
131
+ "additionalProperties": False,
132
+ },
133
+ annotations=_READ_ONLY_ANNOTATIONS,
134
+ ),
135
+ ToolSpec(
136
+ name="get_workflow",
137
+ description=(
138
+ "Inspect one workflow's PHI-safe structural metadata and "
139
+ "certification result by opaque id. Recorded values, raw "
140
+ "intents, names, paths, and exception text stay local by "
141
+ "default."
142
+ ),
143
+ input_schema={
144
+ "type": "object",
145
+ "properties": {
146
+ "workflow": {
147
+ "type": "string",
148
+ "description": "Opaque id from list_workflows.",
149
+ }
150
+ },
151
+ "required": ["workflow"],
152
+ "additionalProperties": False,
153
+ },
154
+ annotations=_READ_ONLY_ANNOTATIONS,
155
+ ),
156
+ ToolSpec(
157
+ name="get_run_report",
158
+ description=(
159
+ "Fetch a PHI-safe status and count-only summary of a "
160
+ "persisted run by opaque run id. The raw report, observed "
161
+ "text, local paths, stdout, and stderr stay in the local "
162
+ "operator experience unless protected export was "
163
+ "explicitly enabled when the server started."
164
+ ),
165
+ input_schema={
166
+ "type": "object",
167
+ "properties": {
168
+ "run_id": {
169
+ "type": "string",
170
+ "description": "run_id returned by a run_* tool.",
171
+ }
172
+ },
173
+ "required": ["run_id"],
174
+ "additionalProperties": False,
175
+ },
176
+ annotations=_READ_ONLY_ANNOTATIONS,
177
+ ),
178
+ ToolSpec(
179
+ name="list_needs_attention",
180
+ description=(
181
+ "List PHI-safe local halt cards and their exact currently "
182
+ "allowed attended actions. Raw workflow names, observed "
183
+ "text, parameters, reports, and filesystem paths are not "
184
+ "returned."
185
+ ),
186
+ input_schema={
187
+ "type": "object",
188
+ "properties": {},
189
+ "additionalProperties": False,
190
+ },
191
+ annotations=_READ_ONLY_ANNOTATIONS,
192
+ ),
193
+ ToolSpec(
194
+ name="get_attention_item",
195
+ description=(
196
+ "Reload one PHI-safe halt card by its opaque id before an "
197
+ "operator decision. Use the newly returned capability "
198
+ "digest; stale capabilities are refused."
199
+ ),
200
+ input_schema={
201
+ "type": "object",
202
+ "properties": {
203
+ "attention_id": {
204
+ "type": "string",
205
+ "pattern": "^[0-9a-f]{24}$",
206
+ "description": ("Opaque id returned by list_needs_attention."),
207
+ }
208
+ },
209
+ "required": ["attention_id"],
210
+ "additionalProperties": False,
211
+ },
212
+ annotations=_READ_ONLY_ANNOTATIONS,
213
+ ),
214
+ ]
215
+ for tool_name in self.attended.enabled_action_tools():
216
+ tool = ATTENDED_TOOLS[tool_name]
217
+ specs.append(
218
+ ToolSpec(
219
+ name=tool_name,
220
+ description=tool.description,
221
+ input_schema=action_input_schema(tool),
222
+ annotations={
223
+ "readOnlyHint": False,
224
+ "destructiveHint": tool.action in {"continue", "skip"},
225
+ "idempotentHint": True,
226
+ "openWorldHint": tool.action in {"continue", "skip"},
227
+ },
228
+ )
229
+ )
230
+ if self.allow_run:
231
+ for workflow_id, info in sorted(self.workflows.items()):
232
+ if not info.ok:
233
+ continue
234
+ n_steps = len(info.step_intents)
235
+ workflow_copy = (
236
+ f"the compiled workflow {info.name!r}"
237
+ if self.allow_protected_export
238
+ else "the selected compiled workflow"
239
+ )
240
+ specs.append(
241
+ ToolSpec(
242
+ name=f"run_{workflow_id}",
243
+ description=(
244
+ f"Execute {workflow_copy} ({n_steps} steps) via the governed "
245
+ "`openadapt-flow run` CLI (fail-closed admission "
246
+ "gates). Returns a PHI-safe structured outcome: "
247
+ "status is 'success', 'halt' (run stopped with "
248
+ "protected evidence retained locally — NOT a "
249
+ "success), 'refused' "
250
+ "(admission gate refused; nothing executed), "
251
+ "'timeout', or 'error'. Never treat a non-success "
252
+ "status as success."
253
+ ),
254
+ input_schema=tool_input_schema(
255
+ info,
256
+ allow_url_override=self.runner_config.allow_url_override,
257
+ allow_recorded_defaults=self.allow_recorded_defaults,
258
+ ),
259
+ annotations=_RUN_ANNOTATIONS,
260
+ )
261
+ )
262
+ return specs
263
+
264
+ # -- dispatch ----------------------------------------------------------
265
+
266
+ def dispatch(self, name: str, arguments: Optional[dict]) -> dict:
267
+ arguments = arguments or {}
268
+ if name == "list_workflows":
269
+ return self._list_workflows()
270
+ if name == "get_workflow":
271
+ return self._get_workflow(arguments.get("workflow", ""))
272
+ if name == "get_run_report":
273
+ return self._get_run_report(arguments.get("run_id", ""))
274
+ if name == "list_needs_attention":
275
+ return self.attended.list()
276
+ if name == "get_attention_item":
277
+ try:
278
+ return self.attended.get(arguments.get("attention_id", ""))
279
+ except AttendedBridgeError as exc:
280
+ raise BridgeError(str(exc)) from exc
281
+ if name in ATTENDED_TOOLS:
282
+ try:
283
+ return self.attended.act(name, arguments)
284
+ except AttendedBridgeError as exc:
285
+ raise BridgeError(str(exc)) from exc
286
+ if name.startswith("run_"):
287
+ return self._run(name[len("run_") :], arguments)
288
+ raise BridgeError("unknown tool name")
289
+
290
+ def _list_workflows(self) -> dict:
291
+ result = {
292
+ "schema_version": 1,
293
+ "lifecycle": "beta",
294
+ "run_tools_enabled": self.allow_run,
295
+ "protected_export_enabled": self.allow_protected_export,
296
+ "synthetic_recorded_defaults_enabled": self.allow_recorded_defaults,
297
+ "workflows": [
298
+ self._workflow_projection(workflow_id, info)
299
+ for workflow_id, info in self.workflows.items()
300
+ ],
301
+ "note": (
302
+ "Run tools are disabled unless the operator started the server with --allow-run."
303
+ if not self.allow_run
304
+ else None
305
+ ),
306
+ }
307
+ if self.allow_protected_export:
308
+ result["protected"] = {"bundles_dir": str(self.bundles_dir)}
309
+ return result
310
+
311
+ def _workflow_projection(
312
+ self,
313
+ workflow_id: str,
314
+ info: WorkflowInfo,
315
+ ) -> dict:
316
+ result = {
317
+ "id": workflow_id,
318
+ "available": info.ok,
319
+ "step_count": len(info.step_intents) if info.ok else None,
320
+ "parameters": [
321
+ {
322
+ "name": name,
323
+ "type": "string",
324
+ "required": not self.allow_recorded_defaults,
325
+ }
326
+ for name in sorted(info.params)
327
+ ],
328
+ "encrypted": info.encrypted,
329
+ }
330
+ if self.allow_protected_export:
331
+ result["protected"] = {
332
+ "slug": info.slug,
333
+ "name": info.name or None,
334
+ "bundle_dir": str(info.bundle_dir),
335
+ "recorded_params": info.params,
336
+ "step_intents": info.step_intents,
337
+ "load_error": info.load_error,
338
+ }
339
+ return result
340
+
341
+ def _require_workflow(self, workflow_id: str) -> WorkflowInfo:
342
+ info = self.workflows.get(workflow_id)
343
+ if info is None:
344
+ raise BridgeError("unknown workflow id; reload list_workflows")
345
+ return info
346
+
347
+ def _get_workflow(self, workflow_id: str) -> dict:
348
+ info = self._require_workflow(workflow_id)
349
+ result = self._workflow_projection(workflow_id, info)
350
+ result.update(
351
+ {
352
+ "schema_version": info.schema_version,
353
+ "certification": self._certification_projection(info),
354
+ }
355
+ )
356
+ return result
357
+
358
+ def _certification_projection(self, info: WorkflowInfo) -> dict:
359
+ if not info.ok:
360
+ if info.load_error:
361
+ _LOG.warning(
362
+ "workflow %s could not be loaded locally: %s",
363
+ info.public_id,
364
+ info.load_error,
365
+ )
366
+ return {
367
+ "certified": None,
368
+ "message": "The local bundle could not be loaded safely.",
369
+ }
370
+ certification = self.runner.certify(info.bundle_dir)
371
+ certified = certification.get("certified")
372
+ if certified is True:
373
+ message = "The configured policy certification passed."
374
+ elif certified is False:
375
+ message = "The configured policy certification did not pass."
376
+ else:
377
+ message = "Certification was not evaluated by this server."
378
+ result = {"certified": certified, "message": message}
379
+ if self.allow_protected_export:
380
+ result["protected"] = certification
381
+ return result
382
+
383
+ def _get_run_report(self, run_id: str) -> dict:
384
+ if not run_id or not is_safe_run_id(run_id):
385
+ raise BridgeError("run_id must be a single path component")
386
+ runs_candidate = Path(self.runner_config.runs_dir)
387
+ run_candidate = runs_candidate / run_id
388
+ if runs_candidate.is_symlink() or run_candidate.is_symlink():
389
+ raise BridgeError("the configured run evidence boundary is unavailable")
390
+ run_dir = run_candidate.resolve()
391
+ runs_root = runs_candidate.resolve()
392
+ if runs_root not in run_dir.parents:
393
+ raise BridgeError("run_id resolves outside the server's runs directory")
394
+ report_path = run_dir / "report.json"
395
+ if report_path.is_symlink() or not report_path.is_file():
396
+ raise BridgeError("no local report exists for that run id")
397
+ try:
398
+ report = json.loads(report_path.read_text())
399
+ except (OSError, json.JSONDecodeError) as exc:
400
+ _LOG.exception("protected local report could not be read")
401
+ raise BridgeError("the local report could not be read safely") from exc
402
+ success = report.get("success")
403
+ status = "success" if success is True else ("halt" if success is False else "error")
404
+ messages = {
405
+ "success": "The persisted local report confirms success.",
406
+ "halt": (
407
+ "The persisted local report confirms the run did not complete. "
408
+ "Review protected evidence in the local operator experience."
409
+ ),
410
+ "error": (
411
+ "The persisted local report has no trustworthy terminal status. Review it locally."
412
+ ),
413
+ }
414
+ result = {
415
+ "schema_version": 1,
416
+ "run_id": run_id,
417
+ "status": status,
418
+ "success": status == "success",
419
+ "message": messages[status],
420
+ "summary": public_report_summary(report),
421
+ }
422
+ if self.allow_protected_export:
423
+ result["protected"] = {
424
+ "run_dir": str(run_dir),
425
+ "report": report,
426
+ }
427
+ return result
428
+
429
+ def _run(self, workflow_id: str, arguments: dict) -> dict:
430
+ info = self._require_workflow(workflow_id)
431
+ if not self.allow_run:
432
+ raise BridgeError(
433
+ "run tools are disabled: the operator did not start the server with --allow-run"
434
+ )
435
+ if not info.ok:
436
+ if info.load_error:
437
+ _LOG.warning(
438
+ "workflow %s could not be loaded locally: %s",
439
+ info.public_id,
440
+ info.load_error,
441
+ )
442
+ raise BridgeError("the selected workflow could not be loaded safely")
443
+ arguments = dict(arguments)
444
+ url_override = arguments.pop("url", None)
445
+ unknown = set(arguments) - set(info.params)
446
+ missing = set(info.params) - set(arguments)
447
+ invalid_types = any(not isinstance(value, str) for value in arguments.values()) or (
448
+ url_override is not None and not isinstance(url_override, str)
449
+ )
450
+ if unknown or (missing and not self.allow_recorded_defaults) or invalid_types:
451
+ raise BridgeError("arguments do not match the declared workflow parameter schema")
452
+ params = dict(arguments)
453
+ outcome = self.runner.run(
454
+ workflow=workflow_id,
455
+ bundle_dir=info.bundle_dir,
456
+ params=params,
457
+ url_override=url_override,
458
+ )
459
+ result = outcome.to_dict(
460
+ include_protected=self.allow_protected_export,
461
+ )
462
+ if outcome.status == "halt":
463
+ result["needs_attention"] = self.attended.for_run_dir(outcome.run_dir)
464
+ return result
@@ -0,0 +1,195 @@
1
+ """Discover compiled openadapt-flow workflow bundles and derive tool schemas.
2
+
3
+ A *bundle* is a directory written by ``openadapt-flow compile`` (or
4
+ ``induce``) containing ``workflow.json`` — or ``workflow.json.enc`` when
5
+ encrypted at rest. Loading always goes through
6
+ :meth:`openadapt_flow.ir.Workflow.load`, so structural validation,
7
+ integrity verification, and decryption (via ``OPENADAPT_BUNDLE_KEY``)
8
+ behave exactly as they do for the flow CLI; this module never parses
9
+ ``workflow.json`` itself.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ __all__ = [
21
+ "WorkflowInfo",
22
+ "discover_bundles",
23
+ "is_bundle_dir",
24
+ "load_workflow_info",
25
+ "slugify",
26
+ "tool_input_schema",
27
+ "workflow_public_id",
28
+ ]
29
+
30
+ _BUNDLE_MARKERS = ("workflow.json", "workflow.json.enc")
31
+
32
+
33
+ def is_bundle_dir(path: Path) -> bool:
34
+ """True when *path* looks like a compiled workflow bundle directory."""
35
+ return any((path / marker).is_file() for marker in _BUNDLE_MARKERS)
36
+
37
+
38
+ def slugify(name: str) -> str:
39
+ """Lowercase, underscore-separated identifier for tool names.
40
+
41
+ Mirrors flow's skill-slug convention. The slug remains local metadata
42
+ unless protected export is explicitly enabled; default MCP tool names
43
+ use :func:`workflow_public_id`.
44
+ """
45
+ slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
46
+ return slug or "workflow"
47
+
48
+
49
+ @dataclass
50
+ class WorkflowInfo:
51
+ """Metadata for one discovered bundle (or the error loading it)."""
52
+
53
+ slug: str
54
+ public_id: str
55
+ bundle_dir: Path
56
+ name: str = ""
57
+ params: dict[str, str] = field(default_factory=dict)
58
+ step_intents: list[str] = field(default_factory=list)
59
+ schema_version: int | None = None
60
+ encrypted: bool = False
61
+ load_error: str | None = None
62
+
63
+ @property
64
+ def ok(self) -> bool:
65
+ return self.load_error is None
66
+
67
+
68
+ def load_workflow_info(bundle_dir: Path, slug: str | None = None) -> WorkflowInfo:
69
+ """Load bundle metadata via ``Workflow.load`` (never hand-parsed).
70
+
71
+ A bundle that cannot be loaded (missing decryption key, tampered
72
+ manifest, malformed structure) yields a ``WorkflowInfo`` with
73
+ ``load_error`` set instead of raising, so one bad bundle does not take
74
+ down the whole server.
75
+ """
76
+ bundle_dir = Path(bundle_dir)
77
+ encrypted = (bundle_dir / "workflow.json.enc").is_file()
78
+ try:
79
+ from openadapt_flow.ir import Workflow
80
+
81
+ wf = Workflow.load(bundle_dir)
82
+ except Exception as exc: # fail-soft per bundle, loud in the listing
83
+ return WorkflowInfo(
84
+ slug=slug or slugify(bundle_dir.name),
85
+ public_id=workflow_public_id(bundle_dir),
86
+ bundle_dir=bundle_dir,
87
+ encrypted=encrypted,
88
+ load_error=f"{type(exc).__name__}: {exc}",
89
+ )
90
+ return WorkflowInfo(
91
+ slug=slug or slugify(wf.name),
92
+ public_id=workflow_public_id(bundle_dir, workflow=wf),
93
+ bundle_dir=bundle_dir,
94
+ name=wf.name,
95
+ params=dict(wf.params),
96
+ step_intents=[step.intent for step in wf.steps],
97
+ schema_version=wf.schema_version,
98
+ encrypted=encrypted,
99
+ )
100
+
101
+
102
+ def discover_bundles(root: Path) -> list[WorkflowInfo]:
103
+ """Discover bundles under *root*.
104
+
105
+ ``root`` may itself be a single bundle, or a directory whose immediate
106
+ subdirectories are bundles. Slugs are made unique by suffixing the
107
+ directory name on collision.
108
+ """
109
+ root = Path(root)
110
+ if not root.is_dir():
111
+ raise FileNotFoundError(f"bundles directory does not exist: {root}")
112
+
113
+ bundle_dirs: list[Path]
114
+ if is_bundle_dir(root):
115
+ bundle_dirs = [root]
116
+ else:
117
+ bundle_dirs = sorted(
118
+ (p for p in root.iterdir() if p.is_dir() and is_bundle_dir(p)),
119
+ key=lambda p: p.name,
120
+ )
121
+
122
+ infos: list[WorkflowInfo] = []
123
+ seen: set[str] = set()
124
+ for bundle_dir in bundle_dirs:
125
+ info = load_workflow_info(bundle_dir)
126
+ if info.slug in seen:
127
+ info.slug = slugify(f"{info.slug}_{bundle_dir.name}")
128
+ seen.add(info.slug)
129
+ infos.append(info)
130
+ return infos
131
+
132
+
133
+ def workflow_public_id(
134
+ bundle_dir: Path,
135
+ *,
136
+ workflow: Any = None,
137
+ ) -> str:
138
+ """Stable opaque MCP identifier without exporting bundle labels or paths."""
139
+ digest = None
140
+ manifest = getattr(workflow, "manifest", None)
141
+ if manifest is not None:
142
+ digest = getattr(manifest, "content_digest", None)
143
+ if isinstance(digest, str) and digest:
144
+ material = digest.encode("utf-8")
145
+ elif workflow is not None:
146
+ material = workflow.model_dump_json().encode("utf-8")
147
+ else:
148
+ hasher = hashlib.sha256()
149
+ for marker in _BUNDLE_MARKERS:
150
+ path = Path(bundle_dir) / marker
151
+ if not path.is_file() or path.is_symlink():
152
+ continue
153
+ hasher.update(marker.encode("ascii"))
154
+ try:
155
+ hasher.update(path.read_bytes())
156
+ except OSError:
157
+ continue
158
+ material = hasher.digest()
159
+ opaque = hashlib.sha256(b"openadapt-agent-workflow-id-v1\0" + material).hexdigest()[:24]
160
+ return f"workflow_{opaque}"
161
+
162
+
163
+ def tool_input_schema(
164
+ info: WorkflowInfo,
165
+ *,
166
+ allow_url_override: bool = False,
167
+ allow_recorded_defaults: bool = False,
168
+ ) -> dict:
169
+ """JSON schema for a workflow's run tool, derived from declared params.
170
+
171
+ Recorded demonstration values never enter the schema. Parameters are
172
+ required unless the operator explicitly enabled synthetic recorded-default
173
+ reuse when starting the server. ``url`` is present only under the separate
174
+ URL-override opt-in.
175
+ """
176
+ properties: dict[str, dict] = {}
177
+ for name in info.params:
178
+ properties[name] = {
179
+ "type": "string",
180
+ "description": f"Value for the declared {name!r} workflow parameter.",
181
+ }
182
+ if allow_url_override:
183
+ properties["url"] = {
184
+ "type": "string",
185
+ "description": (
186
+ "Optional target app URL override. Only honoured because the "
187
+ "operator started the server with --allow-url-override."
188
+ ),
189
+ }
190
+ return {
191
+ "type": "object",
192
+ "properties": properties,
193
+ "required": [] if allow_recorded_defaults else sorted(info.params),
194
+ "additionalProperties": False,
195
+ }