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.
@@ -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
+ ]
@@ -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."""