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,24 @@
|
|
|
1
|
+
"""openadapt-agent — the local agent bridge for openadapt-flow workflows.
|
|
2
|
+
|
|
3
|
+
This package exposes compiled
|
|
4
|
+
`openadapt-flow <https://github.com/OpenAdaptAI/openadapt-flow>`_ workflow
|
|
5
|
+
bundles and PHI-safe Needs Attention items to other agents:
|
|
6
|
+
|
|
7
|
+
- an **MCP server** (``openadapt-agent serve`` / ``python -m
|
|
8
|
+
openadapt_agent.mcp``) that presents each bundle as a tool whose
|
|
9
|
+
execution shells out to the governed ``openadapt-flow run`` CLI — the
|
|
10
|
+
server never reimplements or bypasses flow's policy / identity / effect
|
|
11
|
+
gates; and
|
|
12
|
+
- an **Agent Skills emitter** (``openadapt-agent emit-skill``) that wraps
|
|
13
|
+
flow's own ``emit-skill`` and appends agent-facing MCP invocation and
|
|
14
|
+
halt and attended-action guidance.
|
|
15
|
+
|
|
16
|
+
A halted or refused run is always surfaced as a PHI-safe structured
|
|
17
|
+
halt/refusal, never as success. Protected report evidence remains local
|
|
18
|
+
unless the operator explicitly enables protected export for a trusted
|
|
19
|
+
client in the same data boundary.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "2.0.0"
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Local, capability-bound bridge to openadapt-flow's attended run contract.
|
|
2
|
+
|
|
3
|
+
This module does not implement pause, resume, verification, or audit semantics.
|
|
4
|
+
It projects Flow's PHI-safe Needs Attention DTOs and submits exact
|
|
5
|
+
``AttendedActionRequest`` values back to Flow's durable engine API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AttendedBridge",
|
|
19
|
+
"AttendedBridgeError",
|
|
20
|
+
"AttendedTool",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_ATTENTION_ID_RE = re.compile(r"^[0-9a-f]{24}$")
|
|
24
|
+
_CAPABILITY_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
25
|
+
_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9._:-]{16,200}$")
|
|
26
|
+
_LOG = logging.getLogger(__name__)
|
|
27
|
+
_SAFE_DECISION_MESSAGES = {
|
|
28
|
+
"completed": (
|
|
29
|
+
"Flow verified the attended outcome and completed the admitted "
|
|
30
|
+
"transition. Review the local run report for protected details."
|
|
31
|
+
),
|
|
32
|
+
"halted": (
|
|
33
|
+
"Flow admitted the attended transition, then halted safely at a later "
|
|
34
|
+
"condition. Review the refreshed local Needs Attention queue."
|
|
35
|
+
),
|
|
36
|
+
"refused": (
|
|
37
|
+
"Flow refused the action because the signed pause, authorization, or "
|
|
38
|
+
"current live state did not verify. Reload the item before deciding "
|
|
39
|
+
"what to do next."
|
|
40
|
+
),
|
|
41
|
+
"needs_demonstration": (
|
|
42
|
+
"The governed request for a corrective demonstration was recorded. "
|
|
43
|
+
"The durable pause remains available."
|
|
44
|
+
),
|
|
45
|
+
"escalated": (
|
|
46
|
+
"The escalation was recorded and the durable pause remains available "
|
|
47
|
+
"for qualified assistance."
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _windows_operator_identity() -> str:
|
|
53
|
+
"""Read the effective Windows token username without environment lookup."""
|
|
54
|
+
try:
|
|
55
|
+
import ctypes
|
|
56
|
+
|
|
57
|
+
buffer = ctypes.create_unicode_buffer(257)
|
|
58
|
+
size = ctypes.c_ulong(len(buffer))
|
|
59
|
+
if ctypes.windll.advapi32.GetUserNameW(buffer, ctypes.byref(size)):
|
|
60
|
+
return buffer.value
|
|
61
|
+
except (AttributeError, OSError, ValueError):
|
|
62
|
+
pass
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _local_operator_identity() -> str:
|
|
67
|
+
"""Use the effective OS account, never caller-controlled environment vars."""
|
|
68
|
+
if os.name == "nt":
|
|
69
|
+
return _windows_operator_identity()
|
|
70
|
+
try:
|
|
71
|
+
import pwd
|
|
72
|
+
|
|
73
|
+
return pwd.getpwuid(os.geteuid()).pw_name
|
|
74
|
+
except (ImportError, KeyError, OSError):
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AttendedBridgeError(Exception):
|
|
79
|
+
"""A bounded attended-operation error safe to return to a local MCP user."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class AttendedTool:
|
|
84
|
+
"""One action-specific MCP tool contract."""
|
|
85
|
+
|
|
86
|
+
action: str
|
|
87
|
+
confirmation: str
|
|
88
|
+
disposition: str
|
|
89
|
+
description: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
ATTENDED_TOOLS: dict[str, AttendedTool] = {
|
|
93
|
+
"continue_attention": AttendedTool(
|
|
94
|
+
action="continue",
|
|
95
|
+
confirmation="human_completed",
|
|
96
|
+
disposition="completed_by_operator",
|
|
97
|
+
description=(
|
|
98
|
+
"After the local operator completes the paused task in the live "
|
|
99
|
+
"application, ask OpenAdapt to verify the exact postconditions and "
|
|
100
|
+
"independent effects, checkpoint it as human-completed, and resume "
|
|
101
|
+
"without actuating that task again."
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
"skip_attention": AttendedTool(
|
|
105
|
+
action="skip",
|
|
106
|
+
confirmation="confirmed_not_applicable",
|
|
107
|
+
disposition="not_applicable",
|
|
108
|
+
description=(
|
|
109
|
+
"Apply the workflow's already-declared non-consequential skip path. "
|
|
110
|
+
"Flow rechecks the exact guard and refuses any undeclared, "
|
|
111
|
+
"consequential, stale, or ambiguous skip."
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
"teach_attention": AttendedTool(
|
|
115
|
+
action="teach",
|
|
116
|
+
confirmation="request_demonstration",
|
|
117
|
+
disposition="teach_requested",
|
|
118
|
+
description=(
|
|
119
|
+
"Record an audited request for a corrective demonstration. The "
|
|
120
|
+
"existing governed teach/revision gate still decides whether the "
|
|
121
|
+
"demonstration is accepted, banked as progress, or refused."
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
"escalate_attention": AttendedTool(
|
|
125
|
+
action="escalate",
|
|
126
|
+
confirmation="request_assistance",
|
|
127
|
+
disposition="needs_assistance",
|
|
128
|
+
description=(
|
|
129
|
+
"Record an audited escalation while preserving the exact durable "
|
|
130
|
+
"pause for a qualified operator."
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def action_input_schema(tool: AttendedTool) -> dict[str, Any]:
|
|
137
|
+
"""Exact, free-text-free schema for one attended action tool."""
|
|
138
|
+
return {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"attention_id": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"pattern": _ATTENTION_ID_RE.pattern,
|
|
144
|
+
"description": "Opaque id returned by list_needs_attention.",
|
|
145
|
+
},
|
|
146
|
+
"capability_digest": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"pattern": _CAPABILITY_DIGEST_RE.pattern,
|
|
149
|
+
"description": (
|
|
150
|
+
"Exact current capability digest returned for this attention "
|
|
151
|
+
"item. A stale or changed digest is refused."
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
"idempotency_key": {
|
|
155
|
+
"type": "string",
|
|
156
|
+
"minLength": 16,
|
|
157
|
+
"maxLength": 200,
|
|
158
|
+
"pattern": _IDEMPOTENCY_KEY_RE.pattern,
|
|
159
|
+
"description": (
|
|
160
|
+
"Caller-stable request id. Reuse the exact same value only "
|
|
161
|
+
"when retrying this exact action."
|
|
162
|
+
),
|
|
163
|
+
},
|
|
164
|
+
tool.confirmation: {
|
|
165
|
+
"type": "boolean",
|
|
166
|
+
"const": True,
|
|
167
|
+
"description": (
|
|
168
|
+
"Explicit local-operator confirmation required before this "
|
|
169
|
+
"governed decision is submitted."
|
|
170
|
+
),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
"required": [
|
|
174
|
+
"attention_id",
|
|
175
|
+
"capability_digest",
|
|
176
|
+
"idempotency_key",
|
|
177
|
+
tool.confirmation,
|
|
178
|
+
],
|
|
179
|
+
"additionalProperties": False,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class AttendedBridge:
|
|
184
|
+
"""Expose Flow's local Needs Attention and attended-action APIs safely."""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
runs_dir: Path,
|
|
189
|
+
*,
|
|
190
|
+
allow_actions: bool = False,
|
|
191
|
+
service: Optional[Any] = None,
|
|
192
|
+
operator: Optional[str] = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
self.runs_dir = Path(runs_dir)
|
|
195
|
+
self.allow_actions = allow_actions
|
|
196
|
+
self.service = service
|
|
197
|
+
self.operator = (operator if operator is not None else _local_operator_identity()).strip()
|
|
198
|
+
if allow_actions and not self.operator:
|
|
199
|
+
raise AttendedBridgeError(
|
|
200
|
+
"attended actions require a server-derived local operator identity"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def live_actions_ready(self) -> bool:
|
|
205
|
+
"""Continue/Skip need Flow's deployment-bound live executor."""
|
|
206
|
+
return self.allow_actions and self.service is not None
|
|
207
|
+
|
|
208
|
+
def enabled_action_tools(self) -> tuple[str, ...]:
|
|
209
|
+
if not self.allow_actions:
|
|
210
|
+
return ()
|
|
211
|
+
tools = ["teach_attention", "escalate_attention"]
|
|
212
|
+
if self.live_actions_ready:
|
|
213
|
+
tools[0:0] = ["continue_attention", "skip_attention"]
|
|
214
|
+
return tuple(tools)
|
|
215
|
+
|
|
216
|
+
def list(self) -> dict[str, Any]:
|
|
217
|
+
from openadapt_flow.console.attention import list_attention
|
|
218
|
+
|
|
219
|
+
items = (
|
|
220
|
+
list_attention(self.runs_dir)
|
|
221
|
+
if self.runs_dir.is_dir() and not self.runs_dir.is_symlink()
|
|
222
|
+
else []
|
|
223
|
+
)
|
|
224
|
+
return {
|
|
225
|
+
"open_count": len(items),
|
|
226
|
+
"actions_enabled": self.allow_actions,
|
|
227
|
+
"live_actions_ready": self.live_actions_ready,
|
|
228
|
+
"items": [item.model_dump(mode="json") for item in items],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def _resolve(self, attention_id: str) -> tuple[Path, Any]:
|
|
232
|
+
if not isinstance(attention_id, str) or not _ATTENTION_ID_RE.fullmatch(attention_id):
|
|
233
|
+
raise AttendedBridgeError("attention_id must be an exact opaque queue id")
|
|
234
|
+
if not self.runs_dir.is_dir() or self.runs_dir.is_symlink():
|
|
235
|
+
raise AttendedBridgeError("the configured runs directory is unavailable")
|
|
236
|
+
|
|
237
|
+
from openadapt_flow.console.attention import resolve_attention
|
|
238
|
+
|
|
239
|
+
resolved = resolve_attention(self.runs_dir, attention_id)
|
|
240
|
+
if resolved is None:
|
|
241
|
+
raise AttendedBridgeError("no current attention item has that id; reload the queue")
|
|
242
|
+
return resolved
|
|
243
|
+
|
|
244
|
+
def get(self, attention_id: str) -> dict[str, Any]:
|
|
245
|
+
_path, item = self._resolve(attention_id)
|
|
246
|
+
return item.model_dump(mode="json")
|
|
247
|
+
|
|
248
|
+
def for_run_dir(self, run_dir: Path | str | None) -> Optional[dict[str, Any]]:
|
|
249
|
+
"""Return the PHI-safe attention projection for a just-finished run."""
|
|
250
|
+
if not run_dir:
|
|
251
|
+
return None
|
|
252
|
+
if self.runs_dir.is_symlink():
|
|
253
|
+
return None
|
|
254
|
+
original = Path(run_dir)
|
|
255
|
+
if original.is_symlink():
|
|
256
|
+
return None
|
|
257
|
+
try:
|
|
258
|
+
root = self.runs_dir.resolve(strict=True)
|
|
259
|
+
path = original.resolve(strict=True)
|
|
260
|
+
path.relative_to(root)
|
|
261
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
262
|
+
return None
|
|
263
|
+
if path.is_symlink():
|
|
264
|
+
return None
|
|
265
|
+
from openadapt_flow.console.attention import attention_item
|
|
266
|
+
|
|
267
|
+
item = attention_item(self.runs_dir, path)
|
|
268
|
+
return item.model_dump(mode="json") if item is not None else None
|
|
269
|
+
|
|
270
|
+
def act(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
271
|
+
tool = ATTENDED_TOOLS.get(tool_name)
|
|
272
|
+
if tool is None:
|
|
273
|
+
raise AttendedBridgeError(f"unknown attended action tool: {tool_name}")
|
|
274
|
+
if tool_name not in self.enabled_action_tools():
|
|
275
|
+
if tool.action in {"continue", "skip"} and self.allow_actions:
|
|
276
|
+
raise AttendedBridgeError(
|
|
277
|
+
"Continue/Skip require a deployment-bound live executor "
|
|
278
|
+
"configured when this server starts"
|
|
279
|
+
)
|
|
280
|
+
raise AttendedBridgeError(
|
|
281
|
+
"attended actions are disabled: restart with --allow-attended-actions"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
expected = {
|
|
285
|
+
"attention_id",
|
|
286
|
+
"capability_digest",
|
|
287
|
+
"idempotency_key",
|
|
288
|
+
tool.confirmation,
|
|
289
|
+
}
|
|
290
|
+
unknown = set(arguments) - expected
|
|
291
|
+
missing = expected - set(arguments)
|
|
292
|
+
if unknown or missing:
|
|
293
|
+
raise AttendedBridgeError(
|
|
294
|
+
"attended action arguments do not match the exact tool schema"
|
|
295
|
+
)
|
|
296
|
+
if arguments[tool.confirmation] is not True:
|
|
297
|
+
raise AttendedBridgeError(f"{tool.confirmation} must be explicitly true")
|
|
298
|
+
capability_digest = arguments["capability_digest"]
|
|
299
|
+
idempotency_key = arguments["idempotency_key"]
|
|
300
|
+
if (
|
|
301
|
+
not isinstance(capability_digest, str)
|
|
302
|
+
or not _CAPABILITY_DIGEST_RE.fullmatch(capability_digest)
|
|
303
|
+
or not isinstance(idempotency_key, str)
|
|
304
|
+
or not _IDEMPOTENCY_KEY_RE.fullmatch(idempotency_key)
|
|
305
|
+
):
|
|
306
|
+
raise AttendedBridgeError("capability_digest or idempotency_key is malformed")
|
|
307
|
+
|
|
308
|
+
path, item = self._resolve(arguments["attention_id"])
|
|
309
|
+
capability = item.capability or {}
|
|
310
|
+
if capability.get("digest") != capability_digest:
|
|
311
|
+
raise AttendedBridgeError("the attention item changed; reload its exact capability")
|
|
312
|
+
if tool.action not in (capability.get("allowed_actions") or []):
|
|
313
|
+
raise AttendedBridgeError(f"the exact pause capability does not allow {tool.action}")
|
|
314
|
+
|
|
315
|
+
from openadapt_flow.runtime.durable import (
|
|
316
|
+
ApprovalRequired,
|
|
317
|
+
AttendedActionRefused,
|
|
318
|
+
AttendedActionRequest,
|
|
319
|
+
ResumeRefused,
|
|
320
|
+
execute_attended_action,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
request = AttendedActionRequest(
|
|
324
|
+
capability_digest=capability_digest,
|
|
325
|
+
idempotency_key=idempotency_key,
|
|
326
|
+
action=tool.action,
|
|
327
|
+
disposition=tool.disposition,
|
|
328
|
+
)
|
|
329
|
+
try:
|
|
330
|
+
if self.service is not None:
|
|
331
|
+
decision = self.service.execute(
|
|
332
|
+
path,
|
|
333
|
+
request,
|
|
334
|
+
operator=self.operator,
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
decision = execute_attended_action(
|
|
338
|
+
path,
|
|
339
|
+
request,
|
|
340
|
+
operator=self.operator,
|
|
341
|
+
)
|
|
342
|
+
except (ApprovalRequired, AttendedActionRefused, ResumeRefused) as exc:
|
|
343
|
+
_LOG.info(
|
|
344
|
+
"Flow refused attended action %s for attention item %s: %s",
|
|
345
|
+
tool.action,
|
|
346
|
+
item.id,
|
|
347
|
+
exc,
|
|
348
|
+
)
|
|
349
|
+
return {
|
|
350
|
+
"attention_id": item.id,
|
|
351
|
+
"action": tool.action,
|
|
352
|
+
"status": "refused",
|
|
353
|
+
"success": False,
|
|
354
|
+
"message": _SAFE_DECISION_MESSAGES["refused"],
|
|
355
|
+
}
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
_LOG.exception("attended action failed without a safe terminal decision")
|
|
358
|
+
raise AttendedBridgeError(
|
|
359
|
+
"attended execution did not return a safe terminal decision; "
|
|
360
|
+
"inspect the durable audit and reconcile live state before retrying"
|
|
361
|
+
) from exc
|
|
362
|
+
return {
|
|
363
|
+
"attention_id": item.id,
|
|
364
|
+
"decision_id": decision.decision_id,
|
|
365
|
+
"action": decision.action,
|
|
366
|
+
"status": decision.status,
|
|
367
|
+
"success": decision.status == "completed" and decision.report_success is True,
|
|
368
|
+
"message": _SAFE_DECISION_MESSAGES.get(
|
|
369
|
+
decision.status,
|
|
370
|
+
"Flow recorded the attended decision. Review the protected "
|
|
371
|
+
"local run evidence for details.",
|
|
372
|
+
),
|
|
373
|
+
"report_success": decision.report_success,
|
|
374
|
+
"next_transition": decision.next_transition,
|
|
375
|
+
"transition_receipt_digest": decision.transition_receipt_digest,
|
|
376
|
+
}
|