agent-surface 0.1.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.
- agent_surface/AGENTS.md +10 -0
- agent_surface/__init__.py +87 -0
- agent_surface/actions.py +488 -0
- agent_surface/adapters/AGENTS.md +9 -0
- agent_surface/adapters/__init__.py +1 -0
- agent_surface/adapters/click.py +1352 -0
- agent_surface/adapters/mcp.py +362 -0
- agent_surface/app.py +49 -0
- agent_surface/budgets.py +89 -0
- agent_surface/contracts.py +149 -0
- agent_surface/discovery.py +143 -0
- agent_surface/operations.py +150 -0
- agent_surface/outcomes.py +99 -0
- agent_surface/references.py +155 -0
- agent_surface/rendering.py +247 -0
- agent_surface/skills/AGENTS.md +8 -0
- agent_surface/skills/__init__.py +20 -0
- agent_surface/skills/agent-friendly-cli-design/SKILL.md +79 -0
- agent_surface/skills/agent-friendly-cli-design/reference.md +128 -0
- agent_surface-0.1.0.dist-info/METADATA +326 -0
- agent_surface-0.1.0.dist-info/RECORD +23 -0
- agent_surface-0.1.0.dist-info/WHEEL +4 -0
- agent_surface-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_surface/AGENTS.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Core package instructions
|
|
2
|
+
|
|
3
|
+
These instructions extend the repository-root `AGENTS.md` for `src/agent_surface/`.
|
|
4
|
+
|
|
5
|
+
- Keep contracts transport-neutral and Pydantic-first.
|
|
6
|
+
- Preserve stable error codes, original argv boundaries, output budgets, and bounded
|
|
7
|
+
`next_actions` semantics.
|
|
8
|
+
- Add public exports intentionally and test them; avoid import-time adapter side effects.
|
|
9
|
+
- A renderer may change presentation, never meaning. No silent truncation or ellipsis omission.
|
|
10
|
+
- Run focused unit tests plus `uv run mypy src` for changes here.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Typed application surfaces for agents."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from agent_surface.actions import (
|
|
6
|
+
ActionCandidate,
|
|
7
|
+
ActionCatalog,
|
|
8
|
+
ActionCompiler,
|
|
9
|
+
ActionDefinitionError,
|
|
10
|
+
ActionPolicy,
|
|
11
|
+
ActionPublisher,
|
|
12
|
+
ActionSlotPlan,
|
|
13
|
+
AllowActions,
|
|
14
|
+
DenyAllActions,
|
|
15
|
+
InvalidActionCursor,
|
|
16
|
+
action,
|
|
17
|
+
)
|
|
18
|
+
from agent_surface.app import App
|
|
19
|
+
from agent_surface.budgets import BoundedCollection, OutputBudget, OutputBudgetExceeded
|
|
20
|
+
from agent_surface.contracts import (
|
|
21
|
+
Action,
|
|
22
|
+
ActionCollection,
|
|
23
|
+
CommandView,
|
|
24
|
+
ErrorEnvelope,
|
|
25
|
+
ErrorInfo,
|
|
26
|
+
ErrorOutcome,
|
|
27
|
+
ParsedCommand,
|
|
28
|
+
SuccessEnvelope,
|
|
29
|
+
SuccessOutcome,
|
|
30
|
+
)
|
|
31
|
+
from agent_surface.operations import OperationError
|
|
32
|
+
from agent_surface.outcomes import ActionProvider, NoActions, error_outcome, success_outcome
|
|
33
|
+
from agent_surface.references import (
|
|
34
|
+
DuplicateReferenceCodec,
|
|
35
|
+
InvalidReference,
|
|
36
|
+
MissingReferenceCodec,
|
|
37
|
+
ReferenceCodec,
|
|
38
|
+
ReferenceRegistry,
|
|
39
|
+
ReferenceValue,
|
|
40
|
+
encode_scalar,
|
|
41
|
+
)
|
|
42
|
+
from agent_surface.rendering import RenderOptions, render, render_envelope
|
|
43
|
+
from agent_surface.skills import bundled_skill_path
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Action",
|
|
47
|
+
"ActionCandidate",
|
|
48
|
+
"ActionCatalog",
|
|
49
|
+
"ActionCollection",
|
|
50
|
+
"ActionCompiler",
|
|
51
|
+
"ActionDefinitionError",
|
|
52
|
+
"ActionPolicy",
|
|
53
|
+
"ActionPublisher",
|
|
54
|
+
"ActionProvider",
|
|
55
|
+
"ActionSlotPlan",
|
|
56
|
+
"AllowActions",
|
|
57
|
+
"App",
|
|
58
|
+
"BoundedCollection",
|
|
59
|
+
"CommandView",
|
|
60
|
+
"DuplicateReferenceCodec",
|
|
61
|
+
"DenyAllActions",
|
|
62
|
+
"ErrorEnvelope",
|
|
63
|
+
"ErrorInfo",
|
|
64
|
+
"ErrorOutcome",
|
|
65
|
+
"InvalidReference",
|
|
66
|
+
"InvalidActionCursor",
|
|
67
|
+
"MissingReferenceCodec",
|
|
68
|
+
"OperationError",
|
|
69
|
+
"NoActions",
|
|
70
|
+
"OutputBudget",
|
|
71
|
+
"OutputBudgetExceeded",
|
|
72
|
+
"ParsedCommand",
|
|
73
|
+
"ReferenceCodec",
|
|
74
|
+
"ReferenceRegistry",
|
|
75
|
+
"ReferenceValue",
|
|
76
|
+
"RenderOptions",
|
|
77
|
+
"SuccessEnvelope",
|
|
78
|
+
"SuccessOutcome",
|
|
79
|
+
"__version__",
|
|
80
|
+
"action",
|
|
81
|
+
"bundled_skill_path",
|
|
82
|
+
"encode_scalar",
|
|
83
|
+
"error_outcome",
|
|
84
|
+
"render",
|
|
85
|
+
"render_envelope",
|
|
86
|
+
"success_outcome",
|
|
87
|
+
]
|
agent_surface/actions.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Narrow action-candidate compilation and bounded discovery."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import inspect
|
|
6
|
+
import types
|
|
7
|
+
import typing
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Protocol, get_args, get_origin, get_type_hints
|
|
11
|
+
|
|
12
|
+
from pydantic.fields import FieldInfo
|
|
13
|
+
|
|
14
|
+
from agent_surface.budgets import OutputBudget
|
|
15
|
+
from agent_surface.contracts import Action, ActionCollection
|
|
16
|
+
from agent_surface.operations import OperationRegistry, UnknownOperationError
|
|
17
|
+
from agent_surface.references import MissingReferenceCodec, ReferenceRegistry, encode_scalar
|
|
18
|
+
|
|
19
|
+
_ACTION_METADATA = "__agent_surface_action__"
|
|
20
|
+
_MISSING = object()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class _DefaultFactory:
|
|
25
|
+
field: FieldInfo
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def needs_validated_data(self) -> bool:
|
|
29
|
+
return self.field.default_factory_takes_validated_data is True
|
|
30
|
+
|
|
31
|
+
def resolve(self, validated_data: dict[str, Any]) -> Any:
|
|
32
|
+
return self.field.get_default(
|
|
33
|
+
call_default_factory=True,
|
|
34
|
+
validated_data=validated_data,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ActionDefinitionError(Exception):
|
|
39
|
+
def __init__(self, code: str, message: str, *, fix: str) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.code = code
|
|
42
|
+
self.fix = fix
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ActionSlotPlan:
|
|
47
|
+
name: str
|
|
48
|
+
annotation: Any
|
|
49
|
+
required: bool
|
|
50
|
+
default: Any = _MISSING
|
|
51
|
+
source: dict[str, Any] | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ActionCandidate:
|
|
56
|
+
operation: str
|
|
57
|
+
rel: str
|
|
58
|
+
description: str
|
|
59
|
+
slots: tuple[ActionSlotPlan, ...]
|
|
60
|
+
source: str
|
|
61
|
+
context: object | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class _ActionMetadata:
|
|
66
|
+
operation: str
|
|
67
|
+
rel: str | None
|
|
68
|
+
description: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def action(
|
|
72
|
+
*,
|
|
73
|
+
operation: str,
|
|
74
|
+
rel: str | None = None,
|
|
75
|
+
description: str = "",
|
|
76
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
77
|
+
"""Mark one method signature as an inert action candidate source."""
|
|
78
|
+
|
|
79
|
+
def decorate(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
80
|
+
setattr(
|
|
81
|
+
function,
|
|
82
|
+
_ACTION_METADATA,
|
|
83
|
+
_ActionMetadata(operation=operation, rel=rel, description=description),
|
|
84
|
+
)
|
|
85
|
+
return function
|
|
86
|
+
|
|
87
|
+
return decorate
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ActionCompiler:
|
|
91
|
+
"""Compile immutable plans from registered models and explicitly decorated methods."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, operations: OperationRegistry) -> None:
|
|
94
|
+
self._operations = operations
|
|
95
|
+
|
|
96
|
+
def compile_operations(self) -> tuple[ActionCandidate, ...]:
|
|
97
|
+
candidates = []
|
|
98
|
+
for definition in self._operations.list():
|
|
99
|
+
slots = tuple(
|
|
100
|
+
ActionSlotPlan(
|
|
101
|
+
name=name,
|
|
102
|
+
annotation=field.annotation,
|
|
103
|
+
required=field.is_required(),
|
|
104
|
+
default=(
|
|
105
|
+
_MISSING
|
|
106
|
+
if field.is_required()
|
|
107
|
+
else (
|
|
108
|
+
_DefaultFactory(field)
|
|
109
|
+
if field.default_factory is not None
|
|
110
|
+
else field.default
|
|
111
|
+
)
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
for name, field in definition.input_model.model_fields.items()
|
|
115
|
+
)
|
|
116
|
+
candidates.append(
|
|
117
|
+
ActionCandidate(
|
|
118
|
+
operation=definition.name,
|
|
119
|
+
rel=definition.name,
|
|
120
|
+
description=definition.summary,
|
|
121
|
+
slots=slots,
|
|
122
|
+
source="operation",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return tuple(candidates)
|
|
126
|
+
|
|
127
|
+
def compile_object(self, instance: object) -> tuple[ActionCandidate, ...]:
|
|
128
|
+
decorated: dict[str, tuple[Callable[..., Any], _ActionMetadata]] = {}
|
|
129
|
+
seen: set[str] = set()
|
|
130
|
+
for owner in type(instance).__mro__[:-1]:
|
|
131
|
+
for name, value in owner.__dict__.items():
|
|
132
|
+
if name in seen:
|
|
133
|
+
continue
|
|
134
|
+
seen.add(name)
|
|
135
|
+
if not inspect.isfunction(value):
|
|
136
|
+
continue
|
|
137
|
+
metadata = value.__dict__.get(_ACTION_METADATA)
|
|
138
|
+
if metadata is not None:
|
|
139
|
+
decorated[name] = (value, metadata)
|
|
140
|
+
|
|
141
|
+
return tuple(
|
|
142
|
+
self._compile_method(function, metadata, instance)
|
|
143
|
+
for function, metadata in decorated.values()
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _compile_method(
|
|
147
|
+
self,
|
|
148
|
+
function: Callable[..., Any],
|
|
149
|
+
metadata: _ActionMetadata,
|
|
150
|
+
instance: object,
|
|
151
|
+
) -> ActionCandidate:
|
|
152
|
+
try:
|
|
153
|
+
definition = self._operations.describe(metadata.operation)
|
|
154
|
+
except UnknownOperationError as error:
|
|
155
|
+
raise ActionDefinitionError(
|
|
156
|
+
"unknown_action_operation",
|
|
157
|
+
f"Action targets unknown operation: {metadata.operation}",
|
|
158
|
+
fix="Register the operation before compiling this action.",
|
|
159
|
+
) from error
|
|
160
|
+
|
|
161
|
+
signature = inspect.signature(function)
|
|
162
|
+
hints = get_type_hints(function)
|
|
163
|
+
slots = []
|
|
164
|
+
for index, parameter in enumerate(signature.parameters.values()):
|
|
165
|
+
if index == 0 and parameter.name == "self":
|
|
166
|
+
continue
|
|
167
|
+
if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
|
|
168
|
+
raise self._invalid_signature(function, "variadic parameters are not supported")
|
|
169
|
+
annotation = hints.get(parameter.name, parameter.annotation)
|
|
170
|
+
if annotation is inspect.Parameter.empty:
|
|
171
|
+
raise self._invalid_signature(function, f"{parameter.name} is unannotated")
|
|
172
|
+
required = parameter.default is inspect.Parameter.empty
|
|
173
|
+
slots.append(
|
|
174
|
+
ActionSlotPlan(
|
|
175
|
+
name=parameter.name,
|
|
176
|
+
annotation=annotation,
|
|
177
|
+
required=required,
|
|
178
|
+
default=_MISSING if required else parameter.default,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return ActionCandidate(
|
|
182
|
+
operation=metadata.operation,
|
|
183
|
+
rel=metadata.rel or metadata.operation,
|
|
184
|
+
description=metadata.description or definition.summary,
|
|
185
|
+
slots=tuple(slots),
|
|
186
|
+
source="method",
|
|
187
|
+
context=instance,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _invalid_signature(
|
|
192
|
+
function: Callable[..., Any],
|
|
193
|
+
reason: str,
|
|
194
|
+
) -> ActionDefinitionError:
|
|
195
|
+
return ActionDefinitionError(
|
|
196
|
+
"invalid_action_signature",
|
|
197
|
+
f"Invalid action signature for {function.__qualname__}: {reason}",
|
|
198
|
+
fix="Use named, annotated parameters without *args or **kwargs.",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ActionPolicy(Protocol):
|
|
203
|
+
def allows(self, candidate: ActionCandidate) -> bool: ...
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True, slots=True)
|
|
207
|
+
class DenyAllActions:
|
|
208
|
+
def allows(self, candidate: ActionCandidate) -> bool:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True, slots=True)
|
|
213
|
+
class AllowActions:
|
|
214
|
+
operations: frozenset[str]
|
|
215
|
+
|
|
216
|
+
def allows(self, candidate: ActionCandidate) -> bool:
|
|
217
|
+
return candidate.operation in self.operations
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ActionPublisher:
|
|
221
|
+
"""Bind and publish compiled candidates only through an explicit policy."""
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
references: ReferenceRegistry,
|
|
227
|
+
policy: ActionPolicy,
|
|
228
|
+
) -> None:
|
|
229
|
+
self._references = references
|
|
230
|
+
self._policy = policy
|
|
231
|
+
|
|
232
|
+
def publish(
|
|
233
|
+
self,
|
|
234
|
+
candidates: tuple[ActionCandidate, ...],
|
|
235
|
+
*,
|
|
236
|
+
values: dict[str, Any] | None = None,
|
|
237
|
+
) -> tuple[Action, ...]:
|
|
238
|
+
explicit = values or {}
|
|
239
|
+
return tuple(
|
|
240
|
+
self._publish_one(candidate, explicit)
|
|
241
|
+
for candidate in candidates
|
|
242
|
+
if self._policy.allows(candidate)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def _publish_one(
|
|
246
|
+
self,
|
|
247
|
+
candidate: ActionCandidate,
|
|
248
|
+
explicit: dict[str, Any],
|
|
249
|
+
) -> Action:
|
|
250
|
+
safe_values = self._safe_values(candidate.context)
|
|
251
|
+
argv = list(candidate.operation.split("."))
|
|
252
|
+
bound: dict[str, Any] = {}
|
|
253
|
+
validated_data: dict[str, Any] = {}
|
|
254
|
+
slots: dict[str, Any] = {}
|
|
255
|
+
unresolved = False
|
|
256
|
+
prior_slots_resolved = True
|
|
257
|
+
|
|
258
|
+
for slot in candidate.slots:
|
|
259
|
+
argv.append(f"--{slot.name.replace('_', '-')}")
|
|
260
|
+
found, value = self._bound_value(
|
|
261
|
+
slot,
|
|
262
|
+
explicit,
|
|
263
|
+
safe_values,
|
|
264
|
+
validated_data,
|
|
265
|
+
prior_slots_resolved,
|
|
266
|
+
)
|
|
267
|
+
if not found:
|
|
268
|
+
unresolved = True
|
|
269
|
+
prior_slots_resolved = False
|
|
270
|
+
argv.append(f"{{{slot.name}}}")
|
|
271
|
+
descriptor: dict[str, Any] = {
|
|
272
|
+
"type": _annotation_name(slot.annotation),
|
|
273
|
+
"required": slot.required,
|
|
274
|
+
}
|
|
275
|
+
if slot.source is not None:
|
|
276
|
+
descriptor["source"] = slot.source
|
|
277
|
+
slots[slot.name] = descriptor
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
validated_data[slot.name] = value
|
|
281
|
+
token, structured = self._encode_bound(value)
|
|
282
|
+
argv.append(token)
|
|
283
|
+
bound[slot.name] = structured
|
|
284
|
+
|
|
285
|
+
command = None if unresolved else tuple(argv)
|
|
286
|
+
command_template = tuple(argv) if unresolved else None
|
|
287
|
+
return Action(
|
|
288
|
+
rel=candidate.rel,
|
|
289
|
+
description=candidate.description,
|
|
290
|
+
command=command,
|
|
291
|
+
command_template=command_template,
|
|
292
|
+
operation=candidate.operation,
|
|
293
|
+
bound=bound,
|
|
294
|
+
slots=slots,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _safe_values(context: object | None) -> dict[str, Any]:
|
|
299
|
+
if context is None:
|
|
300
|
+
return {}
|
|
301
|
+
try:
|
|
302
|
+
return dict(vars(context))
|
|
303
|
+
except TypeError:
|
|
304
|
+
return {}
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _bound_value(
|
|
308
|
+
slot: ActionSlotPlan,
|
|
309
|
+
explicit: dict[str, Any],
|
|
310
|
+
safe_values: dict[str, Any],
|
|
311
|
+
validated_data: dict[str, Any],
|
|
312
|
+
prior_slots_resolved: bool,
|
|
313
|
+
) -> tuple[bool, Any]:
|
|
314
|
+
if slot.name in explicit and _compatible(slot.annotation, explicit[slot.name]):
|
|
315
|
+
return True, explicit[slot.name]
|
|
316
|
+
if slot.name in safe_values and _compatible(slot.annotation, safe_values[slot.name]):
|
|
317
|
+
return True, safe_values[slot.name]
|
|
318
|
+
if isinstance(slot.default, _DefaultFactory):
|
|
319
|
+
if slot.default.needs_validated_data and not prior_slots_resolved:
|
|
320
|
+
return False, None
|
|
321
|
+
return True, slot.default.resolve(validated_data)
|
|
322
|
+
if slot.default is not _MISSING:
|
|
323
|
+
return True, slot.default
|
|
324
|
+
return False, None
|
|
325
|
+
|
|
326
|
+
def _encode_bound(self, value: object) -> tuple[str, Any]:
|
|
327
|
+
try:
|
|
328
|
+
return encode_scalar(value), value
|
|
329
|
+
except MissingReferenceCodec:
|
|
330
|
+
reference = self._references.encode(value)
|
|
331
|
+
return reference.id, reference
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _compatible(annotation: Any, value: object) -> bool:
|
|
335
|
+
if annotation is Any:
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
origin = get_origin(annotation)
|
|
339
|
+
arguments = get_args(annotation)
|
|
340
|
+
if origin is typing.Annotated:
|
|
341
|
+
return bool(arguments) and _compatible(arguments[0], value)
|
|
342
|
+
if origin in (typing.Union, types.UnionType):
|
|
343
|
+
return any(_compatible(member, value) for member in arguments)
|
|
344
|
+
if origin is typing.Literal:
|
|
345
|
+
return any(type(value) is type(member) and value == member for member in arguments)
|
|
346
|
+
if origin is list:
|
|
347
|
+
return (
|
|
348
|
+
type(value) is list
|
|
349
|
+
and len(arguments) == 1
|
|
350
|
+
and all(_compatible(arguments[0], item) for item in value)
|
|
351
|
+
)
|
|
352
|
+
if origin is set:
|
|
353
|
+
return (
|
|
354
|
+
type(value) is set
|
|
355
|
+
and len(arguments) == 1
|
|
356
|
+
and all(_compatible(arguments[0], item) for item in value)
|
|
357
|
+
)
|
|
358
|
+
if origin is frozenset:
|
|
359
|
+
return (
|
|
360
|
+
type(value) is frozenset
|
|
361
|
+
and len(arguments) == 1
|
|
362
|
+
and all(_compatible(arguments[0], item) for item in value)
|
|
363
|
+
)
|
|
364
|
+
if origin is tuple:
|
|
365
|
+
if type(value) is not tuple:
|
|
366
|
+
return False
|
|
367
|
+
if len(arguments) == 2 and arguments[1] is Ellipsis:
|
|
368
|
+
return all(_compatible(arguments[0], item) for item in value)
|
|
369
|
+
return len(arguments) == len(value) and all(
|
|
370
|
+
_compatible(member, item) for member, item in zip(arguments, value, strict=True)
|
|
371
|
+
)
|
|
372
|
+
if origin is dict:
|
|
373
|
+
return (
|
|
374
|
+
type(value) is dict
|
|
375
|
+
and len(arguments) == 2
|
|
376
|
+
and all(
|
|
377
|
+
_compatible(arguments[0], key) and _compatible(arguments[1], item)
|
|
378
|
+
for key, item in value.items()
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
if isinstance(annotation, type):
|
|
382
|
+
if annotation in (bool, int, float, str):
|
|
383
|
+
return type(value) is annotation
|
|
384
|
+
return isinstance(value, annotation)
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _annotation_name(annotation: Any) -> str:
|
|
389
|
+
return getattr(annotation, "__name__", str(annotation))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class InvalidActionCursor(Exception):
|
|
393
|
+
code = "invalid_action_cursor"
|
|
394
|
+
|
|
395
|
+
def __init__(self, cursor: str) -> None:
|
|
396
|
+
super().__init__("Action cursor is invalid or out of range")
|
|
397
|
+
self.cursor = cursor
|
|
398
|
+
self.fix = "Restart discovery without a cursor."
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class ActionCatalog:
|
|
402
|
+
"""Deterministic in-memory pages of already policy-filtered actions."""
|
|
403
|
+
|
|
404
|
+
def __init__(
|
|
405
|
+
self,
|
|
406
|
+
actions: tuple[Action, ...],
|
|
407
|
+
*,
|
|
408
|
+
discovery_command: tuple[str, ...] = ("actions", "list"),
|
|
409
|
+
) -> None:
|
|
410
|
+
self._actions = tuple(sorted(actions, key=_action_sort_key))
|
|
411
|
+
self._discovery_command = discovery_command
|
|
412
|
+
|
|
413
|
+
def page(
|
|
414
|
+
self,
|
|
415
|
+
*,
|
|
416
|
+
cursor: str | None = None,
|
|
417
|
+
budget: OutputBudget | None = None,
|
|
418
|
+
) -> ActionCollection:
|
|
419
|
+
selected = budget or OutputBudget()
|
|
420
|
+
offset = 0 if cursor is None else self._decode_cursor(cursor)
|
|
421
|
+
if cursor is not None and offset >= len(self._actions):
|
|
422
|
+
raise InvalidActionCursor(cursor)
|
|
423
|
+
|
|
424
|
+
items = self._actions[offset : offset + selected.max_items]
|
|
425
|
+
next_offset = offset + len(items)
|
|
426
|
+
truncated = next_offset < len(self._actions)
|
|
427
|
+
discover = None
|
|
428
|
+
if truncated:
|
|
429
|
+
discover = Action(
|
|
430
|
+
rel="next-page",
|
|
431
|
+
description="Return the next page of available actions",
|
|
432
|
+
command=(
|
|
433
|
+
*self._discovery_command,
|
|
434
|
+
"--cursor",
|
|
435
|
+
self._encode_cursor(next_offset),
|
|
436
|
+
"--limit",
|
|
437
|
+
str(selected.max_items),
|
|
438
|
+
),
|
|
439
|
+
)
|
|
440
|
+
return ActionCollection(
|
|
441
|
+
items=items,
|
|
442
|
+
total=len(self._actions),
|
|
443
|
+
returned=len(items),
|
|
444
|
+
truncated=truncated,
|
|
445
|
+
discover=discover,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def _encode_cursor(offset: int) -> str:
|
|
450
|
+
encoded = base64.urlsafe_b64encode(f"v1:{offset}".encode()).decode()
|
|
451
|
+
return encoded.rstrip("=")
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def _decode_cursor(cursor: str) -> int:
|
|
455
|
+
try:
|
|
456
|
+
padding = "=" * (-len(cursor) % 4)
|
|
457
|
+
decoded = base64.b64decode(
|
|
458
|
+
cursor + padding,
|
|
459
|
+
altchars=b"-_",
|
|
460
|
+
validate=True,
|
|
461
|
+
).decode()
|
|
462
|
+
version, raw_offset = decoded.split(":", 1)
|
|
463
|
+
offset = int(raw_offset)
|
|
464
|
+
if version != "v1" or offset < 0:
|
|
465
|
+
raise ValueError
|
|
466
|
+
return offset
|
|
467
|
+
except (binascii.Error, UnicodeDecodeError, ValueError) as error:
|
|
468
|
+
raise InvalidActionCursor(cursor) from error
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _action_sort_key(action_value: Action) -> tuple[str, str, tuple[str, ...]]:
|
|
472
|
+
command = action_value.command or action_value.command_template or ()
|
|
473
|
+
return action_value.operation or "", action_value.rel, command
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
__all__ = [
|
|
477
|
+
"ActionCandidate",
|
|
478
|
+
"ActionCatalog",
|
|
479
|
+
"ActionCompiler",
|
|
480
|
+
"ActionDefinitionError",
|
|
481
|
+
"ActionPolicy",
|
|
482
|
+
"ActionPublisher",
|
|
483
|
+
"ActionSlotPlan",
|
|
484
|
+
"AllowActions",
|
|
485
|
+
"DenyAllActions",
|
|
486
|
+
"InvalidActionCursor",
|
|
487
|
+
"action",
|
|
488
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Adapter instructions
|
|
2
|
+
|
|
3
|
+
These instructions extend the repository-root and package `AGENTS.md` files.
|
|
4
|
+
|
|
5
|
+
- Click and MCP are sibling projections of the operation registry.
|
|
6
|
+
- Do not put business rules in adapters or invoke one transport through another.
|
|
7
|
+
- Preserve structured success and repairable error envelopes on every handled path.
|
|
8
|
+
- Keep discovery machine-readable and paginated; normal transport-native help remains available.
|
|
9
|
+
- Redact sensitive argv and parsed values, and enforce confirmation before handler invocation.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional transport projections for registered operations."""
|