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.
- openadapt_agent/__init__.py +24 -0
- openadapt_agent/__main__.py +6 -0
- openadapt_agent/attended.py +376 -0
- openadapt_agent/bridge.py +464 -0
- openadapt_agent/bundles.py +195 -0
- openadapt_agent/cli.py +266 -0
- openadapt_agent/flow_service.py +51 -0
- openadapt_agent/mcp.py +186 -0
- openadapt_agent/runner.py +477 -0
- openadapt_agent/skill.py +113 -0
- openadapt_agent-2.0.0.dist-info/METADATA +331 -0
- openadapt_agent-2.0.0.dist-info/RECORD +15 -0
- openadapt_agent-2.0.0.dist-info/WHEEL +4 -0
- openadapt_agent-2.0.0.dist-info/entry_points.txt +2 -0
- openadapt_agent-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|